Tracker solaire DIY

Le défi relevé : permettre à un panneau photovoltaïque de suivre le soleil. Voici comment réaliser ce projet, avec pilotage et intégration dans Home Assistant via ESPHome.
Tracker solaire DIY

Sommaire

Introduction

Pourquoi un tracker solaire plutôt qu'avoir une installation fixe et simple ?
Parce que c'est moins marrant…

Mais aussi parce que le nombre de panneaux solaires d'une installation est réduit, donc l'empreinte carbone. Le nombre de micro-onduleurs l'est d'autant. La surface utile est également plus faible.
En cas d'évènement climatique, les panneaux peuvent être inclinés convenablement pour limiter les dégâts, au pire, le nombre de panneaux à remplacer est moins important qu'une installation classique.
Contrairement à une installation classique en toiture, les panneaux solaires chauffent moins grâce à une meilleure ventilation naturelle, qui s'ensuit par une meilleure performance (pour rappel, le rendement des panneaux diminue avec l'augmentation de la température), et une réduction quasi totale du risque d'incendie.

💡
Avant de vous lancer dans l'aventure, pensez à consulter le PLU applicable à votre localisation, puis à réaliser les démarches nécessaires.

Mes objectifs pour ce projet :

  • Pouvoir agir sur le tracker depuis Home Assistant.
  • Accueillir deux panneaux solaires de 400W.
  • Avoir une emprise en sol inférieure à 1m² et pouvoir passer sous les panneaux quelque soit leur position.
  • Ne pas dépasser 300€ (hors panneaux et onduleur).

Le premier point m'a orienté vers ESPHome.
Les deux suivants ont contraint la structure mécanique du tracker.
Enfin, le dernier m'a permis de faire le choix des composants et actionneurs.

Choix de l'architecture

Plusieurs architectures sont possibles pour réaliser un tracker solaire.

Mécanisme à un axe

Les versions 1 axe, moins cher et plus simple, permettent une bonne optimisation de la production solaire, cependant l'optimum n'est pas atteint.

Deux solutions principales existent :

  • Inclinaison fixe, puis rotation autour de cet axe (illustration gauche).
  • Inclinaison fixe, puis suivi sur l'axe azimut (illustration droite).

Mécanisme à 2 axes

Les versions 2 axes, plus complexe, mais ayant la meilleure optimisation solaire.

Deux solutions existent :

  • Suivi en azimut et élévation (la combinaison des deux illustrations ci-dessus).
    Cette solution est plus chère à mettre en œuvre, car elle utilise deux systèmes mécaniques différents, donc les actionneurs, l'électronique et les logiciels de contrôle d'asservissement. En revanche le suivi du soleil est plus simple car les éphémérides sont directement transposables, autrement dit le soleil et le tracker vivent dans le même repère.
  • Suivi en roulis et tangage (solution exposée ci-après).
    Cette solution est plus économe, car elle duplique les systèmes de rotation. En revanche le suivi du soleil est plus complexe car le soleil et le tracker ne vivent dans le même repère. Il faut transposer le soleil (azimut et élévation), dans le même repère que le tracker (roulis et tangage). Cela demande un peu plus de calculs.

Matériel nécessaire

Voici la liste du matériel utilisé :

La structure

Sans trop entrer dans les détails, voici macroscopiquement la structure que j'ai réalisée.

Il faut qu'elle soit adéquate au nombre de panneaux solaires et à l'environnement dans lequel elle sera implantée. C'est-à-dire, prendre en compte les ombres portées, le vent, les chutes de branches…

Je suis parti sur une structure entièrement en bois.
Pourquoi ?
Car ce n'est pas cher, facile à travailler, facile de s'approvisionner, tolérant aux erreurs de mesure, transmet peu les vibrations.
Et si on se trompe de quelques millimètres sur une vis, on peut la remettre à côté sans trop de problème.

Première étape, les fondations et le mât principal.

Le bloc de béton fait environ, 50cm de largeur, 60cm de longueur, et 70cm de profondeur.
Au préalable, le bastaing a été traité, et des gros clous ont été enfoncés partiellement sur les 60cm scellé dans le béton. Le béton est plus haut que le niveau du sol et bombé du centre vers les bords, pour évacuer l'eau de ruissellement.

A l'aide d'une boussole et d'un niveau à bulle, la face bleue est orientée vers le Nord, et la rose vers l'Ouest. Puis le poteau mis d'aplomb.

💡
Les vérins sont montés à "l'envers". Bien que vendu IP65, le joint à lèvre de l'axe n'est pas étanche.

Le plus délicat a été de trouver la bonne position pour les vérins :

  • En roulis, je souhaitais une amplitude symétrique d'est vers l'ouest.
  • En tangage, je voulais une amplitude dissymétrique, car le soleil étant en dominante vers le Sud et bas en hiver, je cherchais à pouvoir faire du -50° à +30° en tangage.

Pour dégrossir les positions, j'ai utilisé le calcul de segment circulaire, puis j'ai peaufiné suite à quelques essais. À noter que ces calculs ne fonctionnent que si les positions extrêmes du vérin, sont colinéaires.

J'ai pris Teta comme mon amplitude recherchée. C la corde, en tant que débattement de mon vérin. Ce qui m'a donné R, la position de l'extrémité du vérin sur la partie qu'il met en mouvement.

Ensuite pour le positionnement de l'autre extrémité du vérin, sur la partie fixe (dans le repère du vérin, donc le mât pour le tangage, et le bras de tangage pour le roulis), j'ai rentré les vérins jusqu'en butée minimale, puis j'ai positionné en conséquence.

Les contraintes de cette solution

Il est nécessaire de calibrer le tracker lors de sa mise en service.

Le système repose sur l'hypothèse que son axe de tangage est colinéaire à l'axe Nord-Sud, et le roulis à l'axe Est-Ouest.

Il faut s'assurer de cette cohérence, sinon la position suivie ne sera pas la bonne.
Cette limitation pourrait être levée par la suite, en ajoutant un magnétomètre au système, afin qu'il puisse s'autocalibrer.
Il est aussi possible d'ajouter deux GPS, distants d'au moins 1 mètre, (plus ils sont écartés, plus ce sera précis), pour réaliser la même fonction que le magnétomètre, c'est-à-dire connaître son orientation en azimut. En effet, en ayant deux positions GPS sur le tracker, et comme nous savons comment elles seraient disposées, il est possible d'estimer sa direction.

Si le GPS#1 donne les coordonnées du point rouge, et le GPS#2 du point jaune, on peut en déduire la droite bleue et donc la flèche verte.

D'une pierre deux coups, les GPS permettraient de lever la seconde contrainte de devoir renseigner manuellement dans le code, la position actuelle du tracker solaire. Nécessaire pour calculer les éphémérides.

Ces deux contraintes sont les deux seules majeures pour le bon fonctionnement du système.
Reste ensuite la calibration de l'IMU, qui permet d'avoir des résultats plus précis, mais ce n'est pas obligatoire.

IMU, centrale inertielle

Le choix s'est porté vers la MPU6050 car elle est disponible facilement et à bas coût. De plus elle est nativement supportée par ESPHome.

Combinant un accéléromètre et un gyroscope 3 axes. elle permet de mesurer les accélérations linéaires et les vitesses angulaires selon les axes X, Y et Z. En analysant ces données, notamment les accélérations dues à la gravité, on peut déterminer l’orientation d’un objet dans l’espace, par exemple l’inclinaison de panneaux solaires par rapport au sol.

Je n'utilise pas les valeurs des gyroscopes, car la dynamique d'un tracker solaire ne les nécessite pas.
J'ai réalisé un prototype de code sous ESPHome pour utiliser les gyroscopes, en intégrant les vitesses angulaires, calibrant la dérive statique et leur donnant une référence initiale. Cependant, les résultats sont décevants, mais c'est parce que ESPHome n'est pas fait pour ce genre de calculs.

Câblage

4 fils sont nécessaires à la mise en œuvre.

  1. VCC, ici je l'alimente en 5Vdc
  2. GND
  3. SCL, l'horloge de la liaison série, qui sera relié à une GPIO de l'ESP
  4. SDA, les données numériques de la liaison série, qui sera relié à une GPIO de l'ESP

Mise en forme

Pour une question de praticité, j'ai mis l'IMU dans un Lego cubique, imprégné dans de l'Epoxy.
À refaire, j'aurais mis du mastic à la place.

💡
Tous les matériaux ont des coefficients de dilatation différents. En cyclage thermique (passage de chaud à froid et vice-versa), l'époxy, les composants et le PCB vont se dilater/rétracter différemment.
Les 3 étant solides, des forces de cisaillement vont être générées, pouvant mener au décollement des composants. Cette rupture de continuité électrique provoquera un non-fonctionnement total ou temporaire dont la panne peut être difficile à cibler.
Le mastic quant à lui, élastique, supprime ces contraintes de cisaillement.

Ainsi, il sera plus simple de réaliser la calibration, puis son assemblage sur les panneaux solaires, et en même temps, ça lui procure une protection pour une utilisation extérieure.

Récupérer les valeurs sous ESPHome

Pour récupérer les données de la MPU6050 sous ESPHome, il faut au préalable déclarer la liaison I2C.

Ensuite, créer un senseur mpu6050.

i2c:
  sda: $GPIO_SDA
  scl: $GPIO_SCL
  frequency: 400kHz

sensor:
- platform: mpu6050
    address: 0x68
    accel_x:
      id: accel_x
      name: "IMU Accel X"

Ici, pour récupérer les données sur l'accéléromètre X

Calibration de l'IMU

Parce que les 3 accéléromètres ne sont pas parfaits, qu'ils n'ont pas été installés sur la carte parfaitement sur un trièdre direct, et encore moins une fois dans le Lego Epoxysé ... il y a besoin de corriger ces écarts.

Pour notre projet, nous ne recherchons pas une précision chirurgicale, l'ordre de grandeur du degré est suffisant. C'est pourquoi j'ai réalisé une calibration grossière de l'IMU.
D'autres méthodes plus complètes, complexes et précises existent.

Le tracker solaire a une dynamique principalement autour de la position à plat. C'est pourquoi on va s'attacher à supprimer les erreurs à cette position afin d'avoir 0.0 sur x et y.

Pour faire cette calibration, il faut au préalable une surface plane de niveau. Par exemple une planche.

Placer l'IMU sous la planche, comme elle sera intégrée plus tard sur le tracker.
La fixer comme vous pouvez, mais il ne faut pas la toucher pour éviter d'ajouter du bruit sur les mesures.

Relier l'IMU à l'ESP, et mettre sous tension afin de lire les mesures.
Relever les valeurs fournies par l'accéléromètre sur les 3 axes, les multiplier par -1, puis renseigner ces valeurs dans les substitutions.

offset_accX: '-0.35'
offset_accY: '0.10'
offset_accZ: '-0.70'

Puis tester.
Ci-dessous un bout de code pour faire des essais sur table.

esphome:
  name: calib-imu
  friendly_name: calib-imu
  project:
    name: "scorpix.calib-IMU"
    version: "1.0"

substitutions:
  GPIO_SDA: '17'
  GPIO_SCL: '21'
  NB_MOY: '50'
  boucle_asservissement: '250ms'
  PI: '3.14159265359'
  offset_accX: '-0.743'
  offset_accY: '0.044'
  offset_accZ: '0.004'
    
i2c:
  sda: $GPIO_SDA
  scl: $GPIO_SCL
  frequency: 400kHz


#  --------- Globals pour calibrer ---------
globals:
  - id: offset_accX
    type: float
    restore_value: yes
    initial_value: '0.0'
  - id: offset_accY
    type: float
    restore_value: yes
    initial_value: '0.0'
  - id: offset_accZ
    type: float
    restore_value: yes
    initial_value: '0.0'
  - id: nb_moy
    type: int
    restore_value: no
    initial_value: '0'


sensor:
  - platform: mpu6050
    address: 0x68
    accel_x:
      id: accel_x
      name: "IMU Accel X"
      filters:
        - offset: $offset_accX
        - exponential_moving_average:
            alpha: 0.20
            send_every: 2
      on_raw_value:
        if:
          condition:
            switch.is_on: calibration_acc
          then:
            - lambda: |-
                if (id(nb_moy) < $NB_MOY) {
                  id(offset_accX) += x;
                  id(nb_moy) += 1 ;
                } else {
                  id(offset_accX) /= id(nb_moy);
                  id(offset_accY) /= id(nb_moy);
                  id(offset_accZ) /= id(nb_moy);
                  id(calibration_acc).publish_state(false);
                }
    accel_y:
      id: accel_y
      name: "IMU Accel Y"
      filters:
        - offset: $offset_accY
        - exponential_moving_average:
            alpha: 0.20
            send_every: 2
      on_raw_value:
        if:
          condition:
            switch.is_on: calibration_acc
          then:
            - lambda: (id(offset_accY) += x);
    accel_z:
      id: accel_z
      name: "IMU Accel z"
      filters:
        - multiply: -1.0
        - offset: $offset_accZ
        - exponential_moving_average:
            alpha: 0.20
            send_every: 2
      on_raw_value:
        if:
          condition:
            switch.is_on: calibration_acc
          then:
            - lambda: (id(offset_accZ) += x);
    update_interval: $boucle_asservissement

  - platform: template
    id: PV_roulis
    name: PV roulis
    unit_of_measurement: °
    update_interval: $boucle_asservissement
    lambda: return atan2(id(accel_y).state, id(accel_z).state)*180/$PI;
            
  - platform: template
    id: PV_tangage
    name: PV tangage
    unit_of_measurement: °
    update_interval: $boucle_asservissement
    lambda: return atan2(id(accel_x).state, id(accel_z).state)*180/$PI;

  - platform: template
    name: norme_offset
    id: norme_offset
    accuracy_decimals: 3
    unit_of_measurement: m²/s
  - platform: template
    name: offset_accX
    id: txt_offset_accX
    accuracy_decimals: 3
    unit_of_measurement: m²/s
  - platform: template
    name: offset_accY
    id: txt_offset_accY
    accuracy_decimals: 3
    unit_of_measurement: m²/s
  - platform: template
    name: offset_accZ
    id: txt_offset_accZ
    accuracy_decimals: 3
    unit_of_measurement: m²/s

switch:
  - platform: template
    id: calibration_acc
    optimistic: True
    name: calibration acc
    icon: "mdi:cog-sync-outline"
    turn_on_action:
      - lambda: |-
          id(offset_accX) = 0.0;
          id(offset_accY) = 0.0;
          id(offset_accZ) = 0.0;
          id(nb_moy) = 0.0;
    on_turn_off:
      - lambda: |-
          id(txt_offset_accX).publish_state(id(offset_accX));
          id(txt_offset_accY).publish_state(id(offset_accY));
          id(txt_offset_accZ).publish_state(-9.81-id(offset_accZ));
          id(norme_offset).publish_state(sqrt((-9.81-id(offset_accZ))*(-9.81-id(offset_accZ))+id(offset_accY)*id(offset_accY)+id(offset_accX)*id(offset_accX)));

En passant le switch à ON, une moyenne sur NB_MOY est calculée, pour chaque axe. Le switch passe à OFF automatiquement à la fin de la calibration. Ici 12,5 secondes (50 * 250ms).
Les résultats sont publiés dans les senseurs offset_accX, offset_accY et offset_accZ.

Reporter ces valeurs comme indiqué ci-dessus, puis compiler à nouveau et vérifier que désormais les valeurs IMU Accel X et IMU Accel Y sont proches de 0 (zéro), et IMU Accel Z de 9,81.

Il faut se mettre des limites de mauvais positionnement initial. De façon totalement arbitraire, disons que si norme_offset est supérieure à 1 m²/s, il faut reprendre la position de l'IMU.

Filtrage des données

Parce que les accéléromètres ne se contentent pas de nous retranscrire la gravité projetée sur les 3 axes (x,y,z), tous les mouvements sont régis par le Principe Fondamental de la Dynamique, et donc les accélérations qui vont avec, que l'on norme généralement en "g".

💡
Vous savez, la pomme de Newton, ce qui nous sert à garder les pieds sur Terre, c'est une accélération de 9,81m²/s, bien que ça puisse varier très légèrement selon les zones géographique.

L'idée ici est d'éliminer les bruits de mesures.

Il y a deux sources principales :

  1. le bruit statique, les variations des données alors que tout est fixe, qui est une caractéristique propre à chaque IMU
  2. le bruit dynamique généré par tous les éléments extérieurs à l'IMU

La science des filtres est complexe, et ici, je me limiterai aux filtres disponibles nativement dans ESPHome dans la classe Sensor/Filters.

La pesanteur est une accélération constante, qu'on peut assimiler à 0Hz (zéro).
On souhaite se débarrasser de tout le reste. Donc, nous avons besoin d'un filtre "passe-bas". C'est-à-dire qui supprimer tous les "mouvements" hautes fréquences.

- exponential_moving_average:
            alpha: 0.20
            send_every: 2

Filtre passe bas

Ensuite, pour supprimer encore plus les valeurs non désirées, j'ai ajouté un écrêtage. C'est un peu brut, mais ça fonctionne bien pour notre application.

Si les accéléromètres fournissent une valeur supérieure, elles ne sont pas prises en compte, et la valeur min_value ou max_value est envoyée à la place.

- clamp:
            min_value: -9.81
            max_value: 9.81

Position de l'IMU sur le tracker

Le code actuel est réalisé pour être compatible avec le montage illustré sur la photo ci-dessous.

  • Y doit pointer vers l'Ouest.
  • X doit pointer vers le Sud.

J'ai remplacé le Lego par une impression 3D, qui m'a permis de coincer l'IMU dans le cadre du panneau solaire. Et d'ajouter un petit espace, surligné en rose, pour limiter le transfert thermique du panneau vers l'IMU. Avec une face complètement ouverte pour le refroidissement.

La température atteint tout de même 60°C dans cette configuration.


Transposer les coordonnées du Soleil dans le repère de l'IMU

Pour asservir une variable sur une consigne, il est nécessaire qu'elles soient de même nature et comparable, donc dans le même repère.

Dans l'image ci-dessous, qui va le plus vite ? ça dépend du repère dans lequel on se place.

Qui va le plus vite ?

Du point de vue du lièvre, la tortue est très rapide, elle cumule (si elle va dans la même direction que le train) la vitesse du train et sa vitesse de marche sur le train.
Pourtant, nous savons tous que le lièvre court plus vite ... à la condition qu'ils soient dans le même repère. Ici, que le lièvre soit sur le toit du train à côté de la tortue.

Il faut donc transformer les valeurs fournies par la classe SUN, qui donne son azimut et élévation, pour les transposer en roulis et tangage. Pour faire ça, il faut passer par une étape intermédiaire en x, y et z.

Commencer par créer SUN et récupérer les valeurs d'azimut et élévation.
Il est nécessaire d'entrer la position géoréférencée (longitude et latitude) du futur tracker solaire. Et d'ajouter la classe time.

time:
  - platform: homeassistant

sun:
  latitude: $HOME_LAT
  longitude: $HOME_LON

sensor:
 - platform: sun
    name: Sun Elevation
    id: sunElevation
    type: elevation
    internal: True
    filters: 
      - lambda: |-
          if(isnan(x)){return {};}
          else{return x;}
      - clamp:
          min_value: 05
          max_value: 89
          ignore_out_of_range: False
      
  - platform: sun
    name: Sun Azimuth
    id: sunAzimuth
    type: azimuth
    internal: True
    filters: 
      - lambda: |-
          if(isnan(x)){return {};}
          else{return x;}
      - offset: $offset_azimuth
      - clamp:
          min_value: 60
          max_value: 310
          ignore_out_of_range: False

Récupérer les valeurs du soleil

Une fois l'élévation et l'azimut du soleil récupérés, on passe dans le même repère que l'IMU, en projetant (0, azimut, élévation) dans (0,x,y,z).

- platform: template
    id: sunX
    name: sunX
    internal: True
    lambda: return  cos(id(sunElevation).state*$PI/180)*cos(id(sunAzimuth).state*$PI/180);

  - platform: template
    id: sunY
    name: sunY
    internal: True
    lambda: return  -cos(id(sunElevation).state*$PI/180)*sin(id(sunAzimuth).state*$PI/180);
    
  - platform: template
    id: sunZ
    name: sunZ
    internal: True
    lambda: return  sin(id(sunElevation).state*$PI/180);

Première étape

Puis, afin de pouvoir comparer des valeurs comparables et plus facilement compréhensibles, on calcule le roulis et tangage équivalent du soleil dans le repère de l'IMU.

- platform: template
    id: sunRoulis
    name: sunRoulis
    unit_of_measurement: °
    lambda: |-
      if (id(storm_mode).state) return {$repos_roulis}; // Angle azimuth repos
      return -(-acos(id(sunY).state/sqrt( pow( id(sunY).state , 2) + pow( id(sunZ).state , 2)))*180/$PI+90);
    filters: 
      - clamp:
          min_value: $angle_min_roulis
          max_value: $angle_max_roulis
          ignore_out_of_range: False

  - platform: template
    id: sunTangage
    name: sunTangage
    unit_of_measurement: °
    lambda: |-
      if (id(storm_mode).state) return {$repos_tangage}; // Angle elevation repos
      return asin(id(sunX).state)/sqrt( pow( id(sunX).state , 2) + pow( id(sunZ).state , 2))*180/$PI;
    filters: 
      - clamp:
          min_value: $angle_min_tangage
          max_value: $angle_max_tangage
          ignore_out_of_range: False

Seconde étape

💡
La position de repos des panneaux solaires est réalisée en générant un faux soleil, ou plutôt en écrasant les données de la position réelle du soleil.

Pont en H

Le pont en H va nous servir à piloter la vitesse des vérins, dans les deux directions.

Si tu fermes S1 et S4, le moteur, donc le vérin, ira dans une direction.
Si tu fermes S3 et S2, il ira dans l'autre direction.

Si tu ouvres et fermes S1 et S4, en même temps et rapidement, tu peux faire varier la vitesse. Plus ou moins de temps, et tu as du PWM.

C'est la partie dont s'occupe les DRV8871, ou tout autre pont en H en fonction des besoins.

À gauche, la partie puissance.

Tu branches un vérin sur les bornes MOTOR. Le sens n'a pas d'importance.
Tu branches l'alimentation des vérins sur les bornes POWER. Cette fois-ci, il faut respecter le + et -.

À droite, à droite la commande.

Tu branches le GND sur le GND en provenance de mon ESP.
Tu branches VM à une borne 3.3V de ton ESP.
Puis IN1 et IN2 vont aux GPIO déclarées dans le code pour le pilotage des vérins.

Configuration des variables de substitutions

HOME_LAT: '41.2222222°' Renseigner la latitude du tracker solaire.

HOME_LON: '4.33333333°' Renseigner la longitude du tracker solaire.

repos_tangage: '-30.0'  Angle repos du mode storm pour la nuit et lors de fort vent ou intempérie. Pensez au ruissellement et à la direction des vents dominants.

repos_roulis: '-20.0'  Angle repos du mode storm pour la nuit et lors de fort vent ou intempérie. Pensez au ruissellement et à la direction des vents dominants.

precision_asservissement: '2.0'  L'asservissement se met en route quand l'écart entre le soleil et le tracker est plus important que cette valeur, s'arrête une fois cette précision atteinte.

max_speed_offset: '12.0'  Le PWM (vitesse des vérins) est à 100% quand l'écart entre le soleil et le tracker est supérieur à cette valeur.
La vitesse minimale est en dur dans le code, à 60%. Ensuite, une interpolation est réalisée entre ces deux valeurs.

offset_tangage: '0.0'  Si vous constatez un décalage constant en tangage, ça vous permet d'effectuer une correction.

offset_roulis: '0.0'  Si vous constatez un décalage constant en roulis, ça vous permet d'effectuer une correction.

offset_azimuth: '-5.0'  Si vous constatez un décalage entre l'axe de tangage et l'axe Nord-Sud, ça vous permet d'effectuer une correction. Décalage entre l'axe vert X (illustré plus haut sur l'IMU) et l'axe Nord-Sud.

angle_min_tangage: '-50.0'  Butée logicielle de la valeur minimale en tangage.

angle_max_tangage: '30.0'  Butée logicielle de la valeur maximale en tangage.

angle_min_roulis: '-52.0'  Butée logicielle de la valeur minimale en roulis.

angle_max_roulis: '52.0'  Butée logicielle de la valeur maximale en roulis.

seuil_manuel_min_tangage: '-53.0'  Passage en mode manuel si un angle de tangage est calculé en dessous de cette valeur. Arrêt de l'asservissement.

seuil_manuel_max_tangage: '33.0'  Passage en mode manuel si un angle de tangage est calculé au-dessus de cette valeur. Arrêt de l'asservissement.

seuil_manuel_min_roulis: '-55.0'  Passage en mode manuel si un angle de roulis est calculé en dessous de cette valeur. Arrêt de l'asservissement.

seuil_manuel_max_roulis: '55.0'  Passage en mode manuel si un angle de roulis est calculé au-dessus de cette valeur. Arrêt de l'asservissement.

boucle_asservissement: '100ms'  Période à laquelle les calculs sont réalisés, soit 10Hz.
Pendant la période de mise au point, je vous recommande d'augmenter cette période à au moins 1s, sinon les valeurs crachent trop rapidement. Attention, car le paramétrage des filtres passe bas sont corrélés à ces 100ms. Une fois la mise au point terminée, si vous décidez de ne pas revenir à 100ms, il faudra retoucher les paramètres des filtres, sans quoi des retards importants seront visibles.
Toutes les valeurs qui sont mises à jour à cette période ne sont pas remontées dans HA afin de ne pas submerger la bande passante et la base de données.

Retour d'expérience après 1 an d'utilisation

Le tracker solaire remplit entièrement sa fonction.

Ci-dessous l'illustration de l'asservissement. On remarque bien que c'est un proportionnel pur. Un petit terme Intégral pourrait aider à parfaitement coller à la consigne, mais je ne souhaite pas que mes vérins soient pilotés en continu.
Ils subissent déjà environ 800 mises en œuvre par jour.

Lors des plus belles journées, avec 2 panneaux solaires (405 + 415Wc), je dépasse régulièrement les 6kWh d'énergie produite. Avec du masquage le matin et le soir, par les habitations voisines et les arbres. Le record étant 7,5kWh par une belle journée de printemps, avec les températures encore douces, la température de surface des panneaux reste raisonnable et permet de conserver une efficacité maximale.

Le vérin de 500mm en tangage aurait pu être identique à celui du roulis en 300mm. Cela permettrait une petite économie.

Avoir réalisé l'axe du pivot du tangage au centre du bastaing entraine un jeu beaucoup trop important. Ce manque de rigidité entraine des mouvements parasites en roulis lorsqu'il y a du vent, et provoque une usure prématurée.
J'avais réalisé ce choix technique car il me permettait de réaliser l'axe du roulis aux extrémités du demi bastaing formant l'axe du tangage.

Axe roulis en vert, tangage en bleu

Entre temps, j'ai ajouté des roulements coniques aux extrémités des axes. Les rotations sont ainsi plus fluides.

Ça ne fonctionne pas comme prévu ?

Des soucis en butées ?

Il est recommandé d'avoir la pyramide/cône utile d'asservissement (défini par angle_min_roulis, angle_max_roulis, angle_min_tangage et angle_max_tangage), plus petit que l'amplitude utile mécanique.

Asservissement à l'envers ?

Intervertir la polarité du vérin concerné, ou IN1 et IN2.

Le code

esphome:
  name: c6-suntracker
  friendly_name: C6_sunTracker
  project:
    name: "scorpix.tracker_solaire"
    version: "3.2"


substitutions:
  HOME_LAT: 'yy.xxxxx°'
  HOME_LON: 'zz.xxxxx°' 
  
  repos_tangage: '-20.0'
  repos_roulis: '-20.0'
  precision_asservissement: '2.0'
  max_speed_offset: '12.0'
  offset_tangage: '0.0'
  offset_roulis: '0.0'
  offset_azimuth: '-5.0'
  angle_min_tangage: '-50.0'
  angle_max_tangage: '30.0'
  angle_min_roulis: '-52.0'
  angle_max_roulis: '52.0'
  seuil_manuel_min_tangage: '-53.0'
  seuil_manuel_max_tangage: '33.0'
  seuil_manuel_min_roulis: '-55.0'
  seuil_manuel_max_roulis: '55.0'
  boucle_asservissement: '100ms'
  PI: '3.14159265359'
  offset_accX: '-0.35'
  offset_accY: '0.1'
  offset_accZ: '-0.7'

  # GPIO
  GPIO_FREQ_PWM: '30000Hz'
  GPIO_SDA: '22'
  GPIO_SCL: '23'
  GPIO_PWM_TANGAGE_FORWARD: '18'
  GPIO_PWM_TANGAGE_REVERSE: '20'
  GPIO_PWM_ROULIS_FORWARD: '19'
  GPIO_PWM_ROULIS_REVERSE: '17'

esp32:
  variant: ESP32C6
  framework:
    type: esp-idf
    advanced:
      compiler_optimization: PERF

# Enable logging
logger:
  level: DEBUG #WARN #DEBUG
    
i2c:
  sda: $GPIO_SDA
  scl: $GPIO_SCL
  frequency: 400kHz #default 50kHz
  scan: False #Sinon erreur de compilation

output:
  - platform: ledc
    id: tangage_forward_pin
    pin: $GPIO_PWM_TANGAGE_FORWARD
    frequency: $GPIO_FREQ_PWM
  - platform: ledc
    id: tangage_reverse_pin
    pin: $GPIO_PWM_TANGAGE_REVERSE
    frequency: $GPIO_FREQ_PWM
  - platform: ledc
    id: roulis_forward_pin
    pin: $GPIO_PWM_ROULIS_FORWARD
    frequency: $GPIO_FREQ_PWM
  - platform: ledc
    id: roulis_reverse_pin
    pin: $GPIO_PWM_ROULIS_REVERSE
    frequency: $GPIO_FREQ_PWM

fan:
  - platform: hbridge
    id: tangage_verin
    icon: mdi:sun-compass
    name: "Verin tangage"
    pin_b: tangage_forward_pin
    pin_a: tangage_reverse_pin
    internal: False
    
  - platform: hbridge
    id: roulis_verin
    icon: mdi:sun-compass
    name: "Verin roulis"
    pin_b: roulis_forward_pin
    pin_a: roulis_reverse_pin
    internal: False
    
script:
  - id: RollPilot
    mode: single
    parameters:
      erreur: float
    then:
      - lambda: |-
          if (abs(erreur)>$precision_asservissement) {
            int speed = 0;
            if(abs(erreur)>$max_speed_offset) {
              speed = 100;
            }
            else {
              speed = 60 + (40*abs(erreur)/$max_speed_offset);
            }
            if (erreur>0) {
              auto call = id(roulis_verin).turn_on();
              call.set_speed(speed);
              call.set_direction(FanDirection::REVERSE);
              call.perform();
            } else {
              auto call = id(roulis_verin).turn_on();
              call.set_speed(speed);
              call.set_direction(FanDirection::FORWARD);
              call.perform();
            }
          }
          else {
            auto call = id(roulis_verin).turn_off();
            call.perform();
          }

  - id: PitchPilot
    mode: single
    parameters:
      erreur: float
    then:
      - lambda: |-
          if (abs(erreur)>$precision_asservissement) {
            int speed = 0;
            if(abs(erreur)>$max_speed_offset) {
              speed = 100;
            }
            else {
              speed = 60 + (40*abs(erreur)/$max_speed_offset);
            }
            if (erreur>0) {
              auto call = id(tangage_verin).turn_on();
              call.set_speed(speed);
              call.set_direction(FanDirection::REVERSE);
              call.perform();
            } else {
              auto call = id(tangage_verin).turn_on();
              call.set_speed(speed);
              call.set_direction(FanDirection::FORWARD);
              call.perform();
            }
          }
          else {
            auto call = id(tangage_verin).turn_off();
            call.perform();
          }


sensor:
  - platform: mpu6050
    address: 0x68
    accel_x:
      id: accel_x
      name: "IMU Accel X"
      internal: True
      filters:
        - offset: $offset_accX
        - clamp:
            min_value: -9.81
            max_value: 9.81
        - exponential_moving_average:
            alpha: 0.20
            send_every: 2
    accel_y:
      id: accel_y
      name: "IMU Accel Y"
      internal: True
      filters:
        - offset: $offset_accY
        - clamp:
            min_value: -9.81
            max_value: 9.81
        - exponential_moving_average:
            alpha: 0.20
            send_every: 2
    accel_z:
      id: accel_z
      name: "IMU Accel z"
      internal: True
      filters:
        - multiply: -1.0
        - offset: $offset_accZ
        - timeout: 1s
        - clamp:
            min_value: 0.01 #IMU à l'envers, tombé
            max_value: 9.81
            ignore_out_of_range: False
        - exponential_moving_average:
            alpha: 0.20
            send_every: 2
    temperature:
      name: "MPU6050 Temperature"
      id: MPU_temp
      internal: True
    update_interval: $boucle_asservissement

  - platform: template
    id: PV_roulis
    name: PV roulis
    internal: True
    unit_of_measurement: °
    update_interval: $boucle_asservissement
    lambda: return atan2(id(accel_y).state, id(accel_z).state)*180/$PI;
    filters:
      - offset : $offset_roulis
    on_value_range: #si calcul d'un angle supérieur au réalisable mécanique, alors problème, arrêt de l'asservissement
      - above: $seuil_manuel_max_roulis
        then:
          - switch.turn_on: manual_mode
      - below: $seuil_manuel_min_roulis
        then:
          - switch.turn_on: manual_mode
            
  - platform: template
    id: PV_tangage
    name: PV tangage
    internal: True
    unit_of_measurement: °
    update_interval: $boucle_asservissement
    lambda: return atan2(id(accel_x).state, id(accel_z).state)*180/$PI;
    filters:
      - offset : $offset_tangage
    on_value_range: #si calcul d'un angle supérieur au réalisable mécanique, alors problème, arrêt de l'asservissement
      - above: $seuil_manuel_max_tangage
        then:
          - switch.turn_on: manual_mode
      - below: $seuil_manuel_min_tangage
        then:
          - switch.turn_on: manual_mode

  - platform: template
    name: PV roulis
    unit_of_measurement: °
    accuracy_decimals: 1
    update_interval: 10.20s #Mettre plus de temps une fois en service
    lambda: return id(PV_roulis).state;
    

  - platform: template
    name: PV tangage
    unit_of_measurement: °
    accuracy_decimals: 1
    update_interval: 10s #Mettre plus de temps une fois en service
    lambda: return id(PV_tangage).state;

  - platform: template
    name: MPU température
    device_class: temperature
    unit_of_measurement: °C
    update_interval: 60s
    lambda: return id(MPU_temp).state;

  - platform: template
    name: Erreur Roulis
    internal: True
    update_interval: $boucle_asservissement
    lambda: return id(PV_roulis).state - id(sunRoulis).state;
    on_value: 
      - if:
          condition:
            - switch.is_off: manual_mode #vérification du mode manuel, si ON alors pas de pilotage
          then:
            - lambda: id(RollPilot)->execute(x);

  - platform: template
    name: Erreur Tangage
    internal: True
    update_interval: $boucle_asservissement
    lambda: return id(PV_tangage).state - id(sunTangage).state;
    on_value: 
      - if:
          condition:
            - switch.is_off: manual_mode #vérification du mode manuel, si ON alors pas de pilotage
          then:
            - lambda: id(PitchPilot)->execute(x);

  - platform: sun
    name: Sun Elevation
    id: sunElevation
    type: elevation
    internal: True
    filters: 
      - lambda: |-
          if(isnan(x)){return {};}
          else{return x;}
      - clamp:
          min_value: 05
          max_value: 89
          ignore_out_of_range: False
      
  - platform: sun
    name: Sun Azimuth
    id: sunAzimuth
    type: azimuth
    internal: True
    filters: 
      - lambda: |-
          if(isnan(x)){return {};}
          else{return x;}
      - offset: $offset_azimuth
      - clamp:
          min_value: 60
          max_value: 310
          ignore_out_of_range: False

  - platform: template
    id: sunX
    name: sunX
    internal: True
    lambda: return  cos(id(sunElevation).state*$PI/180)*cos(id(sunAzimuth).state*$PI/180);
    update_interval: 10s

  - platform: template
    id: sunY
    name: sunY
    internal: True
    lambda: return  -cos(id(sunElevation).state*$PI/180)*sin(id(sunAzimuth).state*$PI/180);
    update_interval: 10s

  - platform: template
    id: sunZ
    name: sunZ
    internal: True
    lambda: return  sin(id(sunElevation).state*$PI/180);
    update_interval: 10s

  - platform: template
    id: sunRoulis
    name: sunRoulis
    unit_of_measurement: °
    internal: False
    update_interval: 10.1s
    lambda: |-
      if (id(storm_mode).state) return {$repos_roulis}; // Angle azimuth repos
      return -(-acos(id(sunY).state/sqrt( pow( id(sunY).state , 2) + pow( id(sunZ).state , 2)))*180/$PI+90);
    filters: 
      - clamp:
          min_value: $angle_min_roulis
          max_value: $angle_max_roulis
          ignore_out_of_range: False

  - platform: template
    id: sunTangage
    name: sunTangage
    unit_of_measurement: °
    internal: False
    update_interval: 10.15s
    lambda: |-
      if (id(storm_mode).state) return {$repos_tangage}; // Angle elevation repos
      return asin(id(sunX).state)/sqrt( pow( id(sunX).state , 2) + pow( id(sunZ).state , 2))*180/$PI;
    filters: 
      - clamp:
          min_value: $angle_min_tangage
          max_value: $angle_max_tangage
          ignore_out_of_range: False

switch:
  - platform: template
    id: manual_mode
    optimistic: True
    name: "Mode manuel"
    icon: "mdi:hand-back-right-outline"
    on_turn_on:
    - fan.turn_off: tangage_verin
    - fan.turn_off: roulis_verin

  - platform: template
    id: storm_mode
    name: "Mode nuit/intemperie"
    icon: "mdi:weather-lightning-rainy"
    optimistic: True
    on_turn_on:
    - lambda: return id(sunTangage).publish_state($repos_tangage); #Angle elevation repos
    - lambda: return id(sunRoulis).publish_state($repos_roulis); #Angle azimuth repos

time:
  - platform: homeassistant

sun:
  latitude: $HOME_LAT
  longitude: $HOME_LON
  
  on_sunrise:
    - then:
      - switch.turn_off: storm_mode
        # Asservir vers position du levée du soleil

  on_sunset:
    - then:
      - switch.turn_on: storm_mode # Mettre en position de repos/vent à presque horizontal, avec légère pente pour écoulement pluie
          
        

Je suis persuadé que nombre d'entre vous optimiseront le code.
N'hésitez pas à m'en faire part 😉