Compare commits

...

8 Commits

Author SHA1 Message Date
Tallagrand Thierry
cf7eb47356 Bilan : reserve d orage, en mode ombre
Une coupure reseau n arrete pas l installation : le solaire et la batterie
continuent, seul le filet de secours disparait. La reserve a tenir est donc la
couverture prudente p80 que le bilan calcule deja — aucune constante nouvelle.
En deficit c est la fenetre traversee, en surplus la prochaine, car a 15 h la
reponse « rien a couvrir » est exacte et inutile.

Le seuil d activation est la vigilance orange, echelle officielle de
Meteo-France et non un nombre choisi ici. Le snapshot lit l alerte sans
connaitre la commune, donc le code marche des l installation de l integration
et publie « indisponible » en attendant.

Tests 38 -> 42. Le fixture du test de perimetre ombre ne contenait pas storm :
les nouvelles ecritures n etaient pas couvertes. Corrige, assertion explicite,
preuve par mutation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-27 12:05:15 +02:00
Tallagrand Thierry
99aba9da0d Preuve : la presence d EDF substitue le reseau au solaire (-73 %)
Protocole alterne OFF/ON/OFF/ON, 15 s d echantillon, avec la tension batterie
et l etat des chargeurs MPPT — des capteurs qu aucun workflow ne lisait.

PV 3858 W hors reseau, 1112 W reseau present, 4025 W hors reseau, 1043 W
reseau present, 4358 W a la fin. Consommation inchangee, DVCC constant,
batterie loin de la saturation. Bascule en 75-90 s, retour en 32 s, quatre
fois. La production moyenne est plus haute a la fin qu au debut : l hypothese
nuages du test precedent est morte.

Ce n est pas la censure normale par manque de preneur : le solaire alimentait
integralement les memes charges 90 s plus tot. Anomalie apparue depuis le
passage des deux MultiPlus II en maitre/esclave. Piste configuration ESS en
parallele avant tout firmware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-27 11:52:33 +02:00
Tallagrand Thierry
76e8695364 Mesure : test controle EDF vs MPPT, et un import reseau non demande
Le test infirme deux hypotheses et en decouvre une troisieme. La limite DVCC
reste a 130 A pendant toute la fenetre : l Auto Charge Manager n est pas en
cause. Aucun bridage du MPPT visible, mais la batterie avait 25 points de
marge — la condition du proprietaire (batterie pleine) n est pas reproduite,
donc son observation n est pas contredite, seulement pas testee.

En revanche, fermer EDF fait importer du reseau en rampe jusqu a 4113 W pour
charger la batterie, conso maison a 880 W. A la coupure le courant batterie
tombe de 104,6 a 25,7 A en 15 s. Les programmations ESS etaient deja
desactivees : le proprietaire de cet ordre reste a identifier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-27 10:58:00 +02:00
Tallagrand Thierry
13e35cdbba ESS : desactivation des charges reseau programmees du GX
Trois fenetres actives tous les jours (01:35 et 07:30 vers 45 %, 12:24 vers
55 %) chargeaient la batterie sur le reseau des qu EDF etait present, en
concurrence avec la cible du matin de l EVCS. C etait un contournement manuel
du proprietaire, utile l hiver, inutile l ete. Les cinq sont desactivees : le
controleur EVCS est seul proprietaire de la decision reseau.

Consigne le besoin hivernal correspondant : en heure solaire l hiver ajoute
~2 h de nuit a couvrir, donc la cible du matin doit monter vers 80 % et se
deduire du croisement production/consommation, pas d un palier fixe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-27 10:47:55 +02:00
Tallagrand Thierry
f18c6e07f6 Nuit : la recovery batterie est une passe unique, pas un oscillateur
La bande de 2 points du Fix A engageait le takeover a SOC 33 et le relachait
a 35 : 0,34 kWh, soit une demi-heure de conso nocturne plate. Trois cycles EDF
la nuit du 26-27/08, six la nuit du 20-21/08, 2,5 kWh importes pour rien.

Une seule passe de recovery par session de nuit. Le plancher dur reste un
terme independant, l hysteresis garde sa bande 20/30, et le vrai critere de
re-engagement (un budget d energie) appartient au bilan, pas a une constante
de plus ici.

Golden 40 -> 42, preuve par mutation. Documente aussi deux acteurs non lus :
les charges programmees de l ESS (45 % la nuit, contre notre cible 35 %) et
le regime majoritairement hors reseau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-27 10:33:21 +02:00
Tallagrand Thierry
8ad34f8c93 Bilan : une heure de nuit n'a aucune forme a minorer
Le drapeau « degrade » introduit la veille s'allumait de 20 h a 07 h toutes les
nuits. La qualite de forme d'une heure vient du taux de censure ; les heures
sans soleil portent un taux de 1 faute d'avoir quoi que ce soit a observer, pas
parce que la mesure serait douteuse. Comme les heures de deficit sont justement
les heures sans soleil, le drapeau se declenchait exactement quand le bilan
sert.

Une heure dont l'enveloppe demontree est nulle recoit desormais la qualite
'nuit'. Scenario epingle sur l'enveloppe et le taux de censure releves en
production, pas sur le fixture — dont la nuit valait 0 et qui ne reproduisait
donc pas le defaut. Preuve par mutation : avant 'minoree', apres 'nuit'.

Suite 37 -> 38. Documente la nuit du 25-26/08 : profil corrige verifie a
0,2 kWh/h pres, bilan qui predit le point bas a un point pres, et non-linearite
du SOC mesuree (huit points en dix-sept minutes pour 0,16 kWh).
2026-08-26 12:14:35 +02:00
Tallagrand Thierry
08c072d781 Chantier : la nuit du 25/08, le quatrieme oscillateur et son diagnostic
Journal de la premiere nuit observee avec le bilan en service : 12,30 kWh
importes, dont 1,19 kWh entre 06 h et 07 h sans aucun vehicule en charge, en
deux cycles EDF de ~25 min. Cause : la cible SOC du matin (35 %, valeur de
table) avec liberation a `SOC > cible` et rearmement a `SOC <= cible - 2` —
meme pathologie que les trois oscillateurs du 24/08, sur un autre seuil.

Corrige aussi le chiffre de la part matinale de production, une troisieme fois :
l'enveloppe subissait le meme decalage d'une heure. Le « 39,3 % du matin » est
abandonne dans le manuel comme dans le journal de chantier.

Ajoute la note sur `initial: off` du toggle advisor : tout redemarrage de Home
Assistant le repositionne a OFF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-25 11:12:35 +02:00
Tallagrand Thierry
17d65f1e40 Bilan : un delta horaire appartient a l'heure qui l'a consomme
`bucketHourly` garde la derniere valeur de chaque heure : le delta entre H-1
et H est l'energie depensee PENDANT H. `deltaSeries` le rangeait sous H-1.

Discret sur un compteur dense, catastrophique sur un compteur epars — et les
compteurs de cette installation le sont : `ac_in_1_to_ac_out` ne peuple que 57
heures sur 267, `evcs_total` 57 sur 264, parce qu'ils n'emettent que lorsqu'ils
avancent. Une heure vide vaut « zero energie », donc toute l'energie d'un
intervalle a trou a ete consommee pendant l'heure FINALE. Rangee sous l'heure
initiale, l'import reseau de la nuit suivante atterrissait sur la derniere
heure de la nuit precedente.

Mesure du 25/08 : consumption_p50[8] = 2,90 kWh/h pour 0,80 reellement — six
echantillons sur douze portaient un delta etale sur 17 heures. Le bilan
reclamait environ deux fois trop d'energie le matin (3,37 kWh annonces a 06 h
contre 1,35 mesures), et donc un SOC requis de 44,5 % la ou 31 % suffisait.
L'enveloppe de production etait decalee de la meme heure, ce qui a contribue a
surestimer la part matinale de la production dans le journal de chantier.

Ajoute aussi un statut `degrade` : le bilan publie ses chiffres quand aucun
croisement n'apparait sur l'horizon ou quand les heures de deficit reposent sur
une forme minoree, mais il ne les presente plus comme fiables. La nuit du 25/08,
avant la mise en service de la courbe horaire, il a publie un SOC requis de
100 % avec un statut `ok`.

Tests 36 -> 37, dont `compteur-epars-attribution-horaire` (rafale reseau de
1/2/3 kWh a 01/02/03 h : montants distincts pour qu'un decalage d'un cran soit
impossible a confondre). Mutation verifiee : l'ancienne attribution le fait
echouer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-25 11:11:24 +02:00
22 changed files with 1244 additions and 27 deletions

View File

@@ -1109,6 +1109,269 @@ p11-changement-vehicule-nuit (prev=night_recovery_done_idle + charging_time 15s
2. CHANGEMENT DE VÉHICULE / nouvelle connexion (charging_time ≤ 90 s, P11) ;
3. force_charge_now / force_grid_now (échappatoires manuelles).
## 26. Session du 27/08/2026 — L'oscillateur du takeover nuit, et deux acteurs non lus
### L'oscillateur (corrigé, golden 40 → 42, déployé 08:32)
Nuit du 26-27/08, Jeep pleine restée branchée, aucun besoin de charge : trois
sessions EDF (04:24→05:16, 05:31→06:50, 07:05→07:30), **2,5 kWh importés** un
matin annoncé à 43 kWh de solaire. La nuit du 20-21/08 en comptait six. Le
helper `night_grid_takeover_needed` précède chaque bascule du contacteur de 3 s :
la cause est entièrement dans le moteur de nuit EVCS, pas dans l'hystérésis
(`grid_soc_on`/`grid_soc_off` valent toujours 20/30, jamais approchés).
Cause, dans `WINDOWS_SOURCEV2.js` :
```
gridTakeoverTriggerPct = max(plancher 22, cible 35) = 35 → engage si SOC <= 35
nightRecoveryDone : SOC >= 35 → relâche
NIGHT_RECOVERY_REARM_BAND_PCT = 2 → réengage à 33
```
Engagement et relâchement sur **le même nombre**, la bande de 2 points du Fix A
ne faisant que retarder le réarmement. Or 2 points de SOC ≈ 0,34 kWh ≈ 31 min à
la charge nocturne (~650 W, parfaitement plate) : ce n'est pas une hystérésis,
c'est la période d'une horloge. C'est le propriétaire qui a mis le doigt dessus
en observant que le phénomène était « super stable car pas de variation de
consommation » — la platitude de la charge est ce qui rend le cycle régulier.
**Correctif** : la recovery batterie post-charge est UNE SEULE passe par session
de nuit (`nightRecoveryDone = ... || nightRecoveryDonePrev`, sans bande). C'était
déjà l'intention affichée du Fix A (« relâché DÉFINITIVEMENT »). Aucun seuil de
ré-engagement n'est ajouté : `batteryBelowHardFloor` reste un terme indépendant
de `nightGridTakeoverNeeded`, et l'hystérésis garde sa bande 20/30. Le vrai
critère de ré-engagement est un budget d'énergie restant à couvrir — il
appartient au bilan énergétique, pas à une constante de plus ici.
Golden : `night-recovery-rearm-sous-bande` (qui épinglait l'oscillateur) devient
`night-recovery-done-pas-de-reengagement` ; +`night-oscillateur-nuit-2627`
(le relevé exact de la nuit) ; +`night-recovery-done-plancher-dur` (le filet
de sécurité doit rester : SOC 21 → ré-engagement). Preuve par mutation : bande
restaurée → échec du nouveau scénario ; retirée → 42/42.
### D'où venait le 35 %, et pourquoi ce n'est pas une réserve
`batteryMorningTargetPct` vient des règles de prévision (≥ 18 kWh → 35 %,
≥ 10 → 55 %, sinon 85 %). C'est une **optimisation économique** — « demain il
fera beau, la batterie peut descendre » — et non une réserve de sécurité, ce que
le propriétaire croyait. Point de conception qui en découle : **une réserve
contre la perte du réseau ne peut pas être constituée avec le réseau** ; elle se
charge sur le solaire et se décide le soir. Voir §27 (à écrire) pour la réserve
d'orage.
### Acteur non lu n°1 — les charges programmées de l'ESS (IMPORTANT)
Le GX a **trois fenêtres de charge réseau programmées, actives tous les jours**,
que rien dans nos workflows ne lit :
| # | Départ | Durée | Cible SOC | Actif |
|---|---|---|---|---|
| 0 | 01:35 | 365 min (→ 07:40) | **45 %** | on |
| 1 | 07:30 | 298 min (→ 12:28) | **45 %** | on |
| 2 | 12:24 | 120 min (→ 14:24) | **55 %** | on |
| 3 | 14:24 | 120 min | 85 % | off |
| 4 | 23:08 | 60 min | 50 % | off |
Entités : `switch.gx_device_ess_batterylife_schedule_charge_N_enabled`,
`time..._start`, `number..._duration`, `number..._soc`.
`sensor.gx_device_ess_batterylife_state` = `self_consumption` hors réseau.
Le GX veut donc 45 % la nuit quand EDF est présent, quand notre cible dit 35 % :
**deux automates avec deux consignes différentes sur la même batterie**.
**Origine (donnée par le propriétaire) et décision du 27/08** : ces trois
fenêtres étaient un contournement manuel, posé d'abord quand le système
n'arrivait plus à tenir un seuil de 20 %, puis réutilisé l'hiver pour garantir
un minimum de charge en heures creuses et éviter de rallumer EDF en heures
pleines. Inutiles l'été. **Les cinq programmations sont désormais désactivées**
(vérifié : `..._charge_0..4_enabled` = off). Le contrôleur EVCS est le seul
propriétaire de la décision réseau.
⚠️ **Le besoin qu'elles couvraient l'hiver reste entier.** Il doit être repris
par le bilan énergétique, pas par une reprogrammation manuelle — voir la règle
saisonnière ci-dessous.
### Règle saisonnière de la cible du matin (propriétaire, 27/08)
« 20 % ou 35 % minimum c'est bien l'été mais l'hiver c'est idiot, ça va plutôt
être 80 % le matin ». Le raisonnement est en **heure solaire** : en été 9 h
locales valent 7 h TU, en hiver 10 h locales valent 9 h TU, soit **~2 h de
consommation supplémentaires à tenir sur batterie** avant le relais du solaire,
et une montée hivernale plus lente et plus basse.
Conséquence : la cible du matin ne doit être ni une constante ni un choix entre
trois paliers. Elle se déduit de l'heure réelle du croisement
production/consommation du lendemain et de l'énergie à couvrir d'ici là — ce
que le bilan sait déjà calculer. C'est le chantier qui remplace à la fois les
paliers 35/55/85 et les charges programmées de l'ESS.
### Acteur non lu n°2 — l'export d'excédent PV
`switch.gx_device_dc_coupled_pv_feed_in_excess` = off ·
`switch.gx_device_ac_coupled_pv_feed_in_excess` = off · `ess_max_feed_in_power`
= −1. Cohérent avec « sans injection », à confirmer comme voulu.
### Ce qui n'est PAS démontré : le bridage du MPPT par la présence d'EDF
Tentative de preuve sur la seule fenêtre EDF-ON diurne conservée en base
(21/08, 07:24→08:07) : PV moyenne 91 W (max 218) contre **907 W** (max 1464)
sur les 43 min suivantes, EDF coupé, à consommation identique (668 vs 637 W).
Écart spectaculaire — **mais l'enveloppe solaire passe elle-même de 0,17 kWh à
07 h à 1,03 kWh à 08 h**, soit un facteur 6 qui explique l'essentiel. La mesure
est donc confondue par le lever du soleil et **ne prouve rien**. Ne pas décider
d'une mise à jour firmware sur cette base. Test propre à faire : fermer EDF
10 min en milieu de journée, batterie non pleine, et comparer.
### Test contrôlé EDF↔MPPT du 27/08 10:47-10:56 (relevé : `docs/mesures/20260827-test-edf-mppt.log`)
Protocole : 2 min de référence, hystérésis désactivée, contacteur EDF fermé
5 min, puis restauration complète (EDF ouvert, latch remis à zéro, hystérésis
réactivée) et 2 min d'observation. Échantillon toutes les 15 s de PV, courant
batterie, conso, import réseau, **limite DVCC** et mode ACM. Conditions :
SOC 71 %, batterie absorbant +129 A, DVCC 130 A — donc MPPT avec un preneur.
**Ce que le test établit :**
1. **La limite DVCC est restée à 130 A d'un bout à l'autre** et le mode ACM à
`day_pv_boost`. Le bridage éventuel ne vient donc PAS de l'Auto Charge
Manager — hypothèse écartée.
2. **Aucun bridage du MPPT observé dans ces conditions** : PV moyenne 3260 W
pendant la fenêtre EDF ON contre 2568 W après la coupure. Le solaire
déclinait de lui-même (la référence est passée de 7708 à 4460 W en 105 s
AVANT toute action — passage nuageux). La comparaison avant/pendant est
donc inexploitable ; seule pendant/après l'est, et elle ne montre pas de
bridage.
3. **Découverte non cherchée, celle-là sans ambiguïté : EDF fermé fait importer
du réseau pour charger la batterie**, en rampe continue 0 → 983 → 2139 →
3335 → **4113 W** en 3 min 30, alors que la consommation maison n'était que
de ~880 W. Preuve directe : à la coupure, le courant batterie s'effondre de
**104,6 A à 25,7 A en 15 s** — ~80 A (≈ 4 kW) venaient donc du réseau, pas
du soleil. Les cinq programmations ESS étaient déjà désactivées depuis
~10 min : ce n'est pas elles. Le propriétaire de cet ordre reste à
identifier (`minimum SOC` de l'ESS n'est pas exposé côté HA).
**Ce que le test N'établit PAS.** Le propriétaire dispose de plusieurs mois
d'observation concordante d'un bridage du MPPT par la présence d'EDF. Ce test
ne le contredit pas : il ne reproduit pas sa condition. Avec la batterie à
71-75 % absorbant 130 A, le MPPT a toujours eu un preneur, donc rien ne pouvait
le censurer — c'est exactement l'équation de censure
`PV_mesurée = min(ressource, conso + absorption batterie)`. Le cas à tester est
**batterie pleine ou quasi pleine (SOC ≥ 90 %) avec EDF fermé** : là, le réseau
sert les charges, la batterie ne peut plus absorber, et la PV n'a plus aucun
débouché. Test à refaire dans ces conditions, 3 min suffisent.
**Conséquence immédiate, indépendante de la question du bridage** : chaque
minute d'EDF fermé coûte de l'énergie réseau qui part dans la batterie à
plusieurs kW, sans que personne ne l'ait demandé. C'est l'explication mécanique
des nuits où la batterie finit à 96 % sur réseau (incident du 09/07), et un
argument de plus pour la « session réseau unique et dimensionnée ».
**Aucune mise à jour firmware n'est justifiée par ces mesures.**
### PREUVE (27/08 11:38-11:51) : la présence d'EDF substitue le réseau au solaire
Relevé : `docs/mesures/20260827-test-edf-mppt-alterne.log`. Protocole alterné
**OFF 3 min / ON 3 min / OFF 3 min / ON 3 min**, échantillon 15 s, avec cette
fois **la tension batterie et l'état des chargeurs MPPT**
(`sensor.*_mppt_operation_mode`, `*_pv_tracker_solar_N_operation_mode`) — des
capteurs qu'aucun workflow ne lisait et qui disent explicitement si un chargeur
cherche son point de puissance maximale (`mppt_active`) ou se retient
(`voltage_current_limited`).
| Phase | PV moyen | Réseau | Conso |
|---|---:|---:|---:|
| OFF_1 | **3 858 W** | 0 | 2 223 W |
| ON_1 (établi) | **1 112 W** | 2 287 W | 2 258 W |
| OFF_2 | **4 025 W** | 0 | 2 226 W |
| ON_2 (établi) | **1 043 W** | 2 717 W | 2 590 W |
| FIN (OFF) | **4 358 W** | 0 | 2 249 W |
Transitions : bascule ~75-90 s après la fermeture du contacteur, retour en
~32 s après l'ouverture, **quatre fois de suite**. DVCC constant (110 A /
51,2 V), SOC 86→88, batterie loin de la saturation. La PV moyenne est plus
HAUTE à la fin du test qu'au début (4 358 contre 3 858) : aucune tendance
météo ne peut expliquer les creux. **L'hypothèse « nuages » du test précédent
est morte.**
Perte : ~2 900 W, soit **73 %** de la production, reprise par le réseau au watt
près, à consommation inchangée. Ce n'est PAS la censure normale par manque de
preneur (il y avait 2 250 W de charges et une batterie autorisée à 110 A, que
le solaire alimentait intégralement 90 s plus tôt) : **le réseau ne comble pas
un déficit, il prend la place.** Résidu pendant les phases ON remarquablement
constant (~1 040 W), ce qui ressemble à un plancher et non à une réduction
proportionnelle.
**Cadrage donné par le propriétaire, à respecter** : le bridage quand la
batterie se remplit — et même avant, pour limiter le courant de charge — est le
comportement NORMAL d'une installation en autoconsommation sans injection, il
le connaît, ce n'est pas le sujet. L'anomalie est la substitution ci-dessus,
apparue **depuis le passage des deux MultiPlus II en maître/esclave
(parallèle)**, avec plusieurs mois de recul.
**Piste (configuration avant firmware)** : un système parallèle ayant perdu son
comportement ESS retombe en passthrough + chargeur — le réseau alimente les
charges et charge la batterie, le solaire ne faisant plus que compléter, ce qui
est exactement le symptôme. À vérifier côté VEConfigure/console GX, non visible
depuis HA : (1) assistant ESS présent et identiquement configuré sur **les deux
unités** ; (2) point de consigne réseau (l'import se cale exactement sur les
charges) ; (3) affectation du compteur réseau après passage en parallèle.
**Conséquence chiffrée pour l'autonomie** : chaque heure de contacteur fermé
coûte ~2,3 kW de solaire gratuit remplacé par du payant, et cela **quel que
soit le niveau de la batterie** — à cumuler avec l'import de ~4 kW vers la
batterie observé au test de 10:47. Le dimensionnement des batteries prévu cet
hiver doit se faire APRÈS ce correctif, sous peine de dimensionner sur une
installation qui perd les trois quarts de sa production dès que le réseau est
présent.
### Réserve d'orage (déployé 27/08 12:04, mode ombre, tests 42/42)
Besoin : les coupures réseau d'orage sont fréquentes sur ce site, et le
propriétaire veut garder de quoi tenir jusqu'au retour du solaire.
**Point de départ, souvent mal posé** : une coupure n'arrête PAS
l'installation. Le solaire et la batterie continuent, seul le filet de secours
disparaît. La question n'est donc pas « combien stocker pour survivre » mais
« la batterie traverse-t-elle la prochaine fenêtre de déficit SANS réseau ».
C'est **exactement la couverture prudente (p80) que le bilan calcule déjà** :
aucune constante nouvelle. Le profil prudent est le bon choix ici précisément
parce qu'une coupure retire la possibilité de rattraper une sous-estimation.
Chaîne : `HA_SNAPSHOT_TEMPLATE` lit la vigilance **sans connaître la commune**
(`states.sensor | selectattr('entity_id','search','_weather_alert')`) →
`PREPARE_BALANCE` passe-plat → `BALANCE` calcule le bloc `storm` →
`BUILD_HA_CALLS` publie 5 helpers `evcs_balance_storm_*`.
Fenêtre retenue : en déficit, celle qu'on traverse (couverture prudente) ; en
surplus, la prochaine (`nextDeficit`) — car à 15 h la réponse « rien à couvrir »
est exacte et inutile, le risque étant la nuit qui vient.
Seuil d'activation : **vigilance orange**, c'est-à-dire l'échelle officielle de
Météo-France (vert/jaune/orange/rouge) et non un nombre choisi ici.
Helpers (package `evcs_energy_balance.yaml`, sauvegarde
`*.bak.20260827`, rechargés et vérifiés) : `evcs_balance_storm_level` ·
`_storm_active` · `_storm_reserve_kwh` · `_storm_reserve_soc` (borné à 100 pour
la plage de l'entrée) · `_storm_reason`.
Tests 38 → 42. Deux résultats notables figés :
- 24/08 06:43, SOC 21 % : réserve 2,42 kWh (SOC 38,1 %) → **manque 2,57 kWh**
pour tenir la matinée sans réseau ;
- 24/08 15:30, SOC 95 %, vigilance rouge : la fenêtre qui compte devient celle
de 19 h, 12,77 kWh, **SOC requis 107,1 %** — même pleine, la batterie
actuelle ne passe pas la nuit sans réseau. Valeur volontairement NON écrêtée :
c'est le signal de dimensionnement pour l'achat de batteries de cet hiver.
Garantie mode ombre étendue : le fixture synthétique `BALANCE_OK` de
`scenarios-shadow.mjs` ne contenait pas `storm`, donc les nouvelles écritures
n'étaient PAS couvertes par le test de périmètre. Corrigé, plus une assertion
explicite `wroteStorm: 5`. Preuve par mutation : une écriture détournée vers
`input_boolean.victron_evcs_night_grid_takeover_needed` fait échouer la suite
(`outOfScope` la nomme).
**RESTE À FAIRE — action utilisateur** : l'intégration **Météo-France n'est pas
installée** (HA n'a que `met` / Met.no). Elle est gratuite, sans clé d'API,
intégrée au cœur de HA. Le flux de configuration a été testé par API : il
réclame un **nom de commune** et refuse les coordonnées (`{"city":"empty"}` sur
48.7732657 / -3.5187179) — donc à ajouter par l'interface, ou à me donner la
commune. Tant qu'elle est absente, le bilan publie `indisponible`, `active`
reste faux, et la réserve est quand même chiffrée : **rien ne casse**.
### Historique HA plus court que configuré
`configuration.yaml` déclare `purge_keep_days: 90`, mais l'historique brut
s'arrête au **20/08** (~7 jours) : testé vide au 01/06, 29/07, 10/08, 17/08 ;
1 point au 20/08, 7 au 24/08. Base recréée, ou purge qui ne respecte pas la
consigne — à investiguer. Les statistiques long terme (horaires, conservées
indéfiniment) sont un stock distinct et probablement intactes.
## Diagnostic en attente de vérification (Qwen, 22/08)
Voici mon analyse et plan de correction :
Diagnostic

View File

@@ -13,7 +13,7 @@ views:
- type: conditional
conditions:
- entity: input_text.evcs_balance_status
state_not: "ok"
state: "indisponible"
card:
type: markdown
content: |
@@ -23,6 +23,22 @@ views:
Les contrôleurs fonctionnent sur leurs règles historiques.
- type: conditional
conditions:
- entity: input_text.evcs_balance_status
state: "degrade"
card:
type: markdown
content: |
## 🟠 Bilan dégradé
Les chiffres sont publiés, mais ils reposent sur une base
incomplète : soit aucun croisement production/consommation
n'apparaît sur l'horizon, soit la forme de production des heures
de déficit vient d'un repli sur l'enveloppe mesurée.
{{ states('input_text.evcs_balance_reason') }}
# --- LA comparaison qui décidera du câblage ---------------------
- type: markdown
title: Seuil calculé contre seuil fixe

View File

@@ -315,6 +315,261 @@ a servi (`courbe_horaire_api` ou `repli_enveloppe`). Elle garde par ailleurs un
rôle de repère de vraisemblance, **sans écrêtage** : étant minorée l'après-midi,
elle tronquerait à tort une prévision correcte.
## 25/08/2026 — Première nuit observée, et le défaut qu'elle a révélé
### Ce que la nuit a fait
| Heure | Événement |
|---|---|
| 01:24 | Fenêtre HC : EDF ON, charge du véhicule (P7) |
| 02:42 | Fin de charge détectée → `night_recovery_done_idle`, EDF coupé en 3 s |
| 02:44 → 02:45 | Véhicule débranché puis rebranché (93 s) |
| 02:47 → 04:25 | Seconde charge, 98 min ; puis EDF coupé |
| 06:22 → 06:49 | **EDF rallumé**, batterie 33 → 35 %, coupé |
| 07:02 → 07:27 | **EDF rallumé**, batterie 33 → 35 %, coupé |
Import réseau : **12,30 kWh**, dont 11,11 kWh entre 01 h et 04 h pour les
véhicules (l'EVCS en a pris 9,57) et **1,19 kWh entre 06 h et 07 h sans aucun
véhicule en charge**.
Les mécanismes de fin de charge ont fonctionné : détection franche à 02:42,
coupure immédiate, et P11 a correctement remis à zéro la mémoire « plein » au
rebranchement de 02:45 — le second véhicule a bien été rechargé.
### Le quatrième oscillateur
Les deux cycles de 06:22 et 07:02 sont un oscillateur de plus, de la même
famille que les trois du 24/08. La cible du matin
(`batteryMorningTargetPct` = 35 %) libère le takeover à `SOC > cible` et le
réarme à `SOC ≤ cible − 2`. Entre 33 et 35 %, avec une maison qui tire ~0,7 kWh/h
et une production encore nulle, la batterie traverse la bande en une vingtaine
de minutes : période mesurée ~35 min, deux cycles complets.
Ce n'est **pas** le seuil `grid_soc_off` de l'hystérésis (30 %) : à 33 % il
n'était pas en cause. C'est la cible du matin de l'EVCS. Et 35 % est une valeur
de table (règle « prévision forte → 35 »), pas un besoin calculé — le bilan,
une fois corrigé, demandait **31 %**.
### Le défaut de mesure que cette nuit a mis au jour
Le bilan annonçait à 06 h qu'il fallait **3,37 kWh** pour tenir jusqu'au
croisement. La consommation réelle sur cette tranche a été de 2,2 kWh, la
production de 0,85 : **1,35 kWh**. Deux fois et demie trop.
L'erreur ne venait pas du modèle mais de l'attribution horaire.
`bucketHourly` garde la dernière valeur de chaque heure ; le delta entre H−1 et
H est donc l'énergie dépensée **pendant H**. `deltaSeries` le rangeait sous
H−1.
Sur un compteur qui émet en continu, cela décale le profil d'une heure — gênant,
pas grave. Mais **les compteurs de cette installation sont épars** :
`ac_in_1_to_ac_out` ne peuple que **57 heures sur 267**, `evcs_total` 57 sur 264,
parce qu'ils n'émettent que lorsqu'ils avancent. Une heure vide veut dire
« valeur inchangée », donc zéro énergie : toute l'énergie d'un intervalle à trou
a été consommée pendant l'heure **finale**, celle qui a réveillé le compteur.
Rangée sous l'heure initiale, elle atterrissait n'importe où.
Mesuré : `consumption_p50[8] = 2,90 kWh/h` pour 0,80 réellement consommé. Six
échantillons sur douze portaient un delta étalé sur **17 heures** — l'import
réseau de la nuit suivante, déposé sur la dernière heure de la nuit précédente.
Le profil du matin était ainsi gonflé de l'import qu'il servait justement à
éviter : le bilan réclamait du réseau parce qu'il avait pris du réseau la veille.
Corrigé : le delta se range sous `hours[i]`. Profil du matin après correction —
0,7 kWh/h de 01 h à 07 h, 0,8 à 08 h — conforme aux 0,74 kWh/h mesurés ce
matin-là. Avec ce profil, le bilan aurait demandé **31 % à 06 h** contre 34 %
réels : pas de réseau. Sans les 1,19 kWh importés, la batterie serait descendue
à ~24 %, au-dessus du plancher dur de 22 % — mais de 2 points seulement.
**Troisième correction du même chiffre.** L'enveloppe de production subissait le
même décalage : la part matinale de la production était donc surestimée une
troisième fois, après le plafond `inverter_size` et après le biais max/moyenne
de l'enveloppe. Le pic de l'enveloppe passe de 11 h à 12 h. Aucune conclusion du
chantier ne change, mais le chiffre « 39,3 % du matin » doit être considéré comme
définitivement abandonné.
### Statut `degrade`
La nuit précédant la mise en service de la courbe horaire, le bilan a publié un
SOC requis de **100 %** avec un statut `ok`, faute de croisement trouvé sur
l'horizon. Il publie désormais `degrade` dans ce cas et quand les heures de
déficit reposent sur une forme minorée : les chiffres restent visibles, mais
plus présentés comme fiables. Un futur câblage ne pourra pas s'y fier par
inadvertance.
### Ce que la prévision, elle, a très bien fait
Courbe horaire Forecast.Solar contre production mesurée, ce matin :
| Heure | Prévu | Mesuré |
|---|---:|---:|
| 07 h | 0,08 kWh | 0,10 kWh |
| 08 h | 0,85 kWh | 0,75 kWh |
| 09 h | 3,07 kWh | 2,72 kWh |
Les trois plans réels et l'abonnement Professional donnent une ressource
utilisable telle quelle. **Le côté production du bilan est fiable ; c'était le
côté consommation qui mentait.**
### Suite
Le candidat évident au prochain câblage réel n'est plus l'hystérésis mais la
**cible SOC du matin** : c'est elle qui a coûté 1,19 kWh cette nuit, et le
toggle qui permettrait de la piloter existe déjà
(`input_boolean.evcs_advisor_apply_night_target`). À décider après quelques
nuits d'observation du seuil calculé contre le seuil de table.
⚠️ Ce toggle porte `initial: off` dans son package : **tout redémarrage de Home
Assistant le repositionne à OFF**, quelle que soit la valeur choisie par
l'utilisateur. C'est voulu pour un câblage réel en période d'essai, mais il faut
le savoir — et le retirer le jour où le câblage sera adopté.
## 26/08/2026 — La nuit qui valide le bilan, et le défaut du mètre
### Déroulé (véhicule débranché à 03:01, donc hors mécanique de nuit EVCS)
| Heure | Événement |
|---|---|
| 01:24:03 | Fenêtre HC → EDF ON, charge du véhicule dès 01:25:30 |
| 02:52:30 | Fin de charge détectée → `night_recovery_done_idle` |
| 02:53:03 | EDF coupé **3 secondes** plus tard |
| 03:01 | Débranchement |
| 05:10 | SOC affiché 20 % → EDF ON (branche « SOC bas » de l'hystérésis) |
| 06:18 | SOC affiché 30 % → EDF OFF |
| 09:06 | Point bas de la nuit : 23 % affichés, puis le PV prend le relais |
Réseau importé : 5,70 kWh pour le véhicule (01 h et 02 h, mesurés sur
`energy_from_ac_in_1_to_ac_out`) et 1,71 kWh pour la batterie (05:10-06:19,
intégration de `battery_power`). **Aucun yoyo** : une seule session, 69 minutes,
20 → 30 %. L'hystérésis a fait exactement ce pour quoi elle est écrite.
### Le profil de consommation corrigé colle à la mesure
Le correctif d'attribution horaire de la veille se vérifie sur une nuit
complète — profil publié contre consommation réellement mesurée :
| Heure | p50 publié | Mesuré | Heure | p50 publié | Mesuré |
|---|---:|---:|---|---:|---:|
| 21 h | 1,60 | 1,50 | 03 h | 0,70 | 0,80 |
| 22 h | 1,40 | 1,40 | 04 h | 0,70 | 0,70 |
| 23 h | 1,30 | 1,20 | 07 h | 0,70 | 0,70 |
| 00 h | 1,10 | 1,00 | 08 h | 0,80 | 0,70 |
Écart maximal 0,2 kWh/h. Le matin ne porte plus l'import réseau de la nuit
suivante.
### Le bilan a prédit le point bas de la nuit à un point près
`required_soc` publié heure par heure, contre le SOC réellement observé :
| Heure | Requis | Réel | Marge |
|---|---:|---:|---:|
| 21 h | 90,5 | 69 | −21,5 |
| 00 h | 62,7 | 45 | −17,7 |
| 03 h | 46,2 | 36 | −10,2 |
| 06 h | 32,1 | 29 | −3,1 |
| 07 h | 27,5 | 27 | −0,5 |
| 08 h | 23,3 | 24 | **+0,7** |
| 09 h | 22,0 | 23 | **+1,0** |
Le bilan annonçait le croisement à 09 h : le point bas est tombé à 09:06. Il
annonçait 22 % nécessaires à 09 h : la batterie a touché 23 %. C'est la
première validation quantitative du modèle sur une nuit entière.
Deux conséquences immédiates :
1. **Le seuil fixe a coupé une fois de trop tôt.** À 06:00 le bilan demandait
32,1 %, `grid_soc_off` a coupé à 30 %. La batterie est ensuite descendue à
23 %, soit **un point au-dessus du plancher dur**. Le seuil fixe n'était pas
grossièrement faux — il était court de 2 points, et c'est le lever du jour
qui a payé la différence. Une matinée couverte aurait traversé le plancher.
2. **La cible SOC du matin n'a joué aucun rôle cette nuit.** Le véhicule est
parti à 03:01, donc `nightSessionActive` est retombé à faux et toute la
mécanique de nuit EVCS avec lui. Ce sont `grid_soc_on`/`grid_soc_off`
(20/30, dans le workflow d'hystérésis) qui ont gouverné — et **le conseiller
IA ne peut pas les toucher**, même toggle activé.
### Le défaut du mètre : le SOC n'est pas linéaire dans la bande qui décide
Énergie réellement échangée par point de SOC, mesurée par intégration de
`sensor.victron_battery_power` :
| Fenêtre | ΔSOC | Énergie | kWh / point | Capacité implicite |
|---|---:|---:|---:|---:|
| 69 → 39 % (décharge) | 30 | 5,11 kWh | 0,170 | 17,0 kWh |
| **35 → 20 % (décharge)** | **15** | **1,07 kWh** | **0,071** | **7,1 kWh** |
| 20 → 30 % (charge) | 10 | 1,71 kWh | 0,171 | 17,1 kWh |
| 30 → 23 % (décharge) | 7 | 1,19 kWh | 0,170 | 17,0 kWh |
| 23 → 80 % (charge) | 57 | 9,06 kWh | 0,159 | 15,9 kWh |
Tout vaut 0,16-0,17 kWh par point — sauf la descente 35 → 20 %, qui n'en vaut
que 0,071. En regardant de plus près, la chute est concentrée : **de 04:17 à
04:34, le SOC affiché passe de 32 % à 24 %, huit points en dix-sept minutes**,
sous une décharge parfaitement stable de 560 W. Huit points « coûtent » 0,16 kWh
là où ils devraient en coûter 1,3.
La tension tranche : 48,81 V à 04:17, 48,75 V à 04:34, soit 60 mV pour huit
points, quand les six points précédents en avaient consommé 200. **Le pack n'a
pas perdu cette énergie ; c'est l'estimateur qui a décroché.** Comportement
classique d'un BMS Pylontech dont un module est plus faible que les autres — et
le parc en compte un mort isolé et un second qui faiblit (§0 du manuel).
Conséquence directe : la session réseau de 05:10 a été déclenchée par un « 20 % »
qui valait physiquement 26 à 28 %. Elle restait justifiée au fond — le bilan
demandait 36,9 % à 05:00 — mais elle a été **déclenchée par le mauvais
instrument, pour la bonne raison, par hasard**.
### Le drapeau « dégradé » criait au loup toutes les nuits
Introduit la veille, il a affiché `degrade` de 20:00 à 07:00. Cause : dans
`RESOURCE_ESTIMATE.js`, la qualité de forme d'une heure vient du taux de
censure, et les heures de nuit portent un taux de 1 — non pas parce que la
mesure serait douteuse, mais parce qu'il n'y a jamais eu de soleil à observer.
Or les heures de déficit *sont* les heures sans soleil : le drapeau se
déclenchait donc **toutes les nuits, c'est-à-dire exactement quand le bilan
sert**. Le commentaire du code disait déjà la bonne règle ; le code ne
l'appliquait pas.
Correctif : une heure dont l'enveloppe démontrée est nulle reçoit la qualité
`'nuit'` et non `'minoree'` — elle n'a aucune forme à minorer. Scénario
`ressource-nuit-forme-non-minoree`, épinglé sur l'enveloppe et le taux de
censure **relevés en production**, pas sur le fixture (dont la nuit valait 0 et
qui ne reproduisait donc pas le défaut — le piège du 24/08, à nouveau). Preuve
par mutation : code d'avant → `minoree`, après → `nuit`. Suite 37 → 38.
### Conclusion sur la cible SOC du matin
Trois arguments convergent, et ils déplacent la cible du chantier :
1. **La bonne grandeur n'est pas un SOC, c'est une énergie.** Le bilan calcule
déjà l'énergie à couvrir jusqu'au croisement, et il vient de le faire à un
point près sur douze heures. La cible de 35 % est une valeur de table ; la
valeur physique de cette nuit-là était 32,1 % à 06:00.
2. **Convertir une énergie en points de SOC réimporte le défaut du mètre.**
`usable_capacity_kwh = 15` appliqué linéairement suppose que tous les points
se valent ; les mesures ci-dessus montrent que ceux de la bande 24-32 %,
celle où se prennent toutes les décisions de nuit, peuvent ne rien valoir.
Tant que la conversion reste linéaire, câbler l'advisor sur la cible ne fait
que déplacer un nombre arbitraire vers un nombre calculé sur une règle
graduée fausse.
3. **La cible du matin n'est pas le seuil qui gouverne le plus souvent.** Elle
ne s'applique qu'avec un véhicule branché pendant la fenêtre HC. Les nuits
sans véhicule — celle-ci — sont gouvernées par `grid_soc_off`, que personne
n'arbitre et que le conseiller ne voit même pas.
Ordre de travail qui en découle, avant tout câblage :
- **a.** Rendre la conversion énergie ↔ SOC honnête : soit une courbe de
capacité par bande de SOC construite sur l'historique (la donnée existe, elle
vient d'être mesurée), soit un drapeau qui signale que le SOC vient de
décrocher et qu'il ne faut pas décider dessus pendant N minutes.
- **b.** Donner au bilan le seuil qui gouverne réellement : `grid_soc_off`,
côté hystérésis. C'est le chiffre qui a manqué de 2 points cette nuit, et
c'est le seul que le conseiller ne peut pas atteindre aujourd'hui.
- **c.** Ensuite seulement, la cible SOC du matin — qui deviendra un
sous-produit du bilan plutôt qu'un réglage indépendant.
## Backlog
- [ ] **Doublon `unique_id: victron_vebus_dc_power`** — déclaré deux fois, avec

View File

@@ -57,7 +57,11 @@ inclinaison 25°, 15 000 Wc, et un `inverter_size` de 8 000 W alors que
l'installation a déjà produit **10 770 W** mesurés — le plafond tronque donc
toute prévision. Résultat : la prévision attend une journée orientée
l'après-midi (15,1 % de l'énergie entre 7 h et 11 h) là où l'installation
produit le matin (39,3 % mesurés).
produit le matin. ⚠️ Le chiffre de « 39,3 % mesurés » cité dans les premières
versions de cette section est **abandonné** : il reposait sur une enveloppe qui
surpondère structurellement le matin, et sur une attribution horaire décalée
d'une heure (corrigée le 25/08, voir `docs/CHANTIER_BILAN_ENERGETIQUE.md`). Le
défaut de configuration était réel, son ampleur a été surestimée.
**Correction à appliquer** (interface HA, voir §0bis) : quatre plans.
@@ -89,6 +93,40 @@ batterie n'est publié côté HA — à corriger.
Deux véhicules alternent : **VW** (hybride, départ ~08:50) et **JEEP**
(départ ~06:30) — cf. §11.5.
### Régime d'exploitation — majoritairement HORS RÉSEAU
Précision donnée par le propriétaire le 27/08/2026, à ne pas redécouvrir :
l'installation fonctionne **la plupart du temps AC-IN ouvert**
(`switch.edf_general_ok_switch` fermé seulement lors des sessions réseau
décidées par les workflows). Conséquence assumée : **l'alarme perte-réseau du
MultiPlus (`switch.airtria_mpii_v1_grid_lost_alarm_setting`) est désactivée
volontairement** — avec cette topologie elle se déclencherait en permanence. Ne
pas proposer de la réactiver.
Pour détecter une **vraie coupure subie** (orage), le signal correct n'est donc
pas l'alarme mais la contradiction commande/mesure : contacteur EDF commandé
fermé alors que `sensor.victron_ac_in1_available` reste à 0 ou que
`sensor.airtria_mpii_v1_input_voltage_l1` reste nulle. Entièrement local.
Les coupures d'orage sont **fréquentes** sur ce site, et emportent souvent la
connexion Internet avec elles — d'où l'exigence d'outils consultables hors
ligne (la console VRM est hébergée dans le cloud Victron).
### Charges programmées de l'ESS (propriétaire concurrent — 27/08/2026)
Le GX porte ses propres fenêtres de charge réseau, **trois actives tous les
jours**, qu'aucun workflow ne lit : 01:35 → 45 %, 07:30 → 45 %, 12:24 → 55 %
(plus deux désactivées). Elles entraient en concurrence directe avec la cible du
matin calculée par l'EVCS (35 % en été). Entités
`switch.gx_device_ess_batterylife_schedule_charge_N_enabled` et associées.
**Désactivées le 27/08/2026** (les cinq), sur décision du propriétaire : c'était
un contournement manuel de sa part — d'abord quand le système ne tenait plus un
seuil de 20 %, puis l'hiver pour garantir un minimum de charge en heures creuses
et éviter de rallumer EDF en heures pleines. Le contrôleur EVCS est désormais
seul propriétaire de la décision réseau. ⚠️ Le besoin hivernal reste entier et
doit être repris par le bilan énergétique (cf. règle saisonnière : l'hiver la
cible du matin monte vers 80 %, car 10 h locales = 9 h TU contre 9 h locales =
7 h TU l'été, soit ~2 h de nuit en plus à couvrir).
### Réseau
Victron **ESS sans injection** : la production est bridée en permanence à ce que
le système peut absorber (consommation + charge batterie). Voir

View File

@@ -0,0 +1,59 @@
TEST 2 — alterne, avec tension batterie et etat des MPPT — 27/08/2026 11:38:27
MPPT ordre: mppt1, RS450 tracker1..4, V1 (MPPT=cherche le max, LIM_=bride tension/courant)
11:38:27 | OFF_1 | PV 3844 | U 50.53 | I 29.1 | conso 2197 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:38:42 | OFF_1 | PV 3835 | U 50.53 | I 29.3 | conso 2184 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:38:57 | OFF_1 | PV 3855 | U 50.54 | I 29.5 | conso 2212 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:39:12 | OFF_1 | PV 3835 | U 50.55 | I 29.5 | conso 2183 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf off | | MPPT LIM_ LIM_ LIM_ MPPT LIM_ MPPT LIM_
11:39:27 | OFF_1 | PV 3835 | U 50.55 | I 29.2 | conso 2177 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:39:42 | OFF_1 | PV 3850 | U 50.55 | I 29.5 | conso 2178 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:39:57 | OFF_1 | PV 3846 | U 50.56 | I 29.4 | conso 2176 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:40:12 | OFF_1 | PV 3901 | U 50.56 | I 28.6 | conso 2221 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:40:27 | OFF_1 | PV 3896 | U 50.56 | I 29.4 | conso 2169 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:40:42 | OFF_1 | PV 3829 | U 50.56 | I 29.5 | conso 2187 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:40:57 | OFF_1 | PV 3830 | U 50.56 | I 29.0 | conso 2539 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:41:12 | OFF_1 | PV 3947 | U 50.57 | I 32.3 | conso 2258 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
>>> EDF ON
11:41:31 | ON_1 | PV 3885 | U 50.56 | I 28.9 | conso 2255 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:41:46 | ON_1 | PV 3936 | U 50.56 | I 29.1 | conso 2282 | grid 0 | DVCC 110A/51.2V | SOC 86 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:42:01 | ON_1 | PV 3915 | U 50.57 | I 29.7 | conso 2253 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:42:16 | ON_1 | PV 3906 | U 50.58 | I 28.8 | conso 2258 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:42:31 | ON_1 | PV 3952 | U 50.58 | I 29.1 | conso 2267 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:42:46 | ON_1 | PV 1939 | U 50.71 | I 57.9 | conso 2261 | grid 2249 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:43:01 | ON_1 | PV 1388 | U 50.64 | I 28.4 | conso 2246 | grid 2263 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ off
11:43:16 | ON_1 | PV 1168 | U 50.59 | I 23.9 | conso 2271 | grid 2299 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ off
11:43:31 | ON_1 | PV 1193 | U 50.56 | I 23.6 | conso 2272 | grid 2299 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ off
11:43:46 | ON_1 | PV 1086 | U 50.54 | I 21.4 | conso 2254 | grid 2276 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ LIM_
11:44:01 | ON_1 | PV 1070 | U 50.52 | I 20.9 | conso 2239 | grid 2284 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ off
11:44:16 | ON_1 | PV 1045 | U 50.51 | I 20.0 | conso 2254 | grid 2276 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ LIM_
>>> EDF OFF
11:44:34 | OFF_2 | PV 1106 | U 50.49 | I 20.4 | conso 2188 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ LIM_
11:44:49 | OFF_2 | PV 4224 | U 50.40 | I 11.6 | conso 2212 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:45:04 | OFF_2 | PV 4347 | U 50.54 | I 39.0 | conso 2198 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:45:19 | OFF_2 | PV 4340 | U 50.59 | I 39.1 | conso 2187 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:45:34 | OFF_2 | PV 4177 | U 50.63 | I 37.9 | conso 2241 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:45:49 | OFF_2 | PV 3894 | U 50.62 | I 32.2 | conso 2205 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:46:04 | OFF_2 | PV 3842 | U 50.61 | I 29.3 | conso 2203 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:46:19 | OFF_2 | PV 3842 | U 50.61 | I 29.1 | conso 2230 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:46:35 | OFF_2 | PV 3883 | U 50.61 | I 29.1 | conso 2243 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:46:50 | OFF_2 | PV 3894 | U 50.61 | I 30.3 | conso 2211 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:47:05 | OFF_2 | PV 3888 | U 50.62 | I 29.9 | conso 2218 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:47:20 | OFF_2 | PV 4141 | U 50.60 | I 25.4 | conso 2519 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf off | | MPPT LIM_ LIM_ MPPT MPPT LIM_ MPPT LIM_
>>> EDF ON
11:47:38 | ON_2 | PV 4186 | U 50.60 | I 28.0 | conso 2526 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:47:53 | ON_2 | PV 4182 | U 50.60 | I 27.5 | conso 2517 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:48:08 | ON_2 | PV 4192 | U 50.61 | I 27.6 | conso 2542 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:48:23 | ON_2 | PV 4193 | U 50.61 | I 27.8 | conso 2529 | grid 0 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:48:38 | ON_2 | PV 4183 | U 50.61 | I 27.8 | conso 2578 | grid -57 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:48:53 | ON_2 | PV 2019 | U 50.77 | I 55.2 | conso 2810 | grid 2793 | DVCC 110A/51.2V | SOC 87 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:49:08 | ON_2 | PV 1328 | U 50.70 | I 28.3 | conso 2795 | grid 2822 | DVCC 110A/51.2V | SOC 88 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ off
11:49:23 | ON_2 | PV 1175 | U 50.65 | I 23.4 | conso 2830 | grid 2866 | DVCC 110A/51.2V | SOC 88 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ off
11:49:38 | ON_2 | PV 1088 | U 50.61 | I 21.3 | conso 2839 | grid 2872 | DVCC 110A/51.2V | SOC 88 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ LIM_
11:49:53 | ON_2 | PV 1036 | U 50.58 | I 19.9 | conso 2786 | grid 2831 | DVCC 110A/51.2V | SOC 88 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ off
11:50:08 | ON_2 | PV 1021 | U 50.57 | I 20.4 | conso 2398 | grid 2471 | DVCC 110A/51.2V | SOC 88 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ LIM_
11:50:23 | ON_2 | PV 1026 | U 50.56 | I 20.6 | conso 2323 | grid 2449 | DVCC 110A/51.2V | SOC 88 | edf on | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ LIM_
>>> restauration
11:50:40 | FIN | PV 1616 | U 50.55 | I 20.7 | conso 2189 | grid 0 | DVCC 110A/51.2V | SOC 88 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ LIM_ LIM_
11:50:55 | FIN | PV 4265 | U 50.47 | I 11.9 | conso 2251 | grid 0 | DVCC 110A/51.2V | SOC 88 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:51:10 | FIN | PV 4412 | U 50.60 | I 37.4 | conso 2245 | grid 0 | DVCC 110A/51.2V | SOC 88 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
11:51:26 | FIN | PV 4396 | U 50.65 | I 38.1 | conso 2251 | grid 0 | DVCC 110A/51.2V | SOC 88 | edf off | | MPPT LIM_ LIM_ LIM_ LIM_ LIM_ MPPT LIM_
=== FIN ===

View File

@@ -0,0 +1,40 @@
TEST BRIDAGE MPPT PAR PRESENCE EDF — 27/08/2026 10:47:09
10:47:09 | AVANT | PV 7708 | batt 129.3A | conso 992 | grid 0 | DVCC 130 | SOC 71 | edf off | acin 0 | acm day_pv_boost
10:47:24 | AVANT | PV 7857 | batt 129.3A | conso 984 | grid 0 | DVCC 130 | SOC 71 | edf off | acin 0 | acm day_pv_boost
10:47:39 | AVANT | PV 7493 | batt 127.4A | conso 998 | grid 0 | DVCC 130 | SOC 72 | edf off | acin 0 | acm day_pv_boost
10:47:54 | AVANT | PV 6643 | batt 118.7A | conso 999 | grid 0 | DVCC 130 | SOC 72 | edf off | acin 0 | acm day_pv_boost
10:48:09 | AVANT | PV 5634 | batt 88.6A | conso 1009 | grid 0 | DVCC 130 | SOC 72 | edf off | acin 0 | acm day_pv_boost
10:48:24 | AVANT | PV 5586 | batt 79.5A | conso 930 | grid 0 | DVCC 130 | SOC 72 | edf off | acin 0 | acm day_pv_boost
10:48:39 | AVANT | PV 5056 | batt 81.5A | conso 921 | grid 0 | DVCC 130 | SOC 72 | edf off | acin 0 | acm day_pv_boost
10:48:54 | AVANT | PV 4460 | batt 77.7A | conso 984 | grid 0 | DVCC 130 | SOC 72 | edf off | acin 0 | acm day_pv_boost
>>> hysteresis OFF puis EDF ON
10:49:12 | EDF_ON | PV 2670 | batt 51.5A | conso 975 | grid 0 | DVCC 130 | SOC 72 | edf off | acin 0 | acm day_pv_boost
10:49:27 | EDF_ON | PV 4037 | batt 36.0A | conso 971 | grid 0 | DVCC 130 | SOC 72 | edf on | acin 1 | acm day_pv_boost
10:49:42 | EDF_ON | PV 3838 | batt 56.4A | conso 1014 | grid 0 | DVCC 130 | SOC 72 | edf on | acin 1 | acm day_pv_boost
10:49:57 | EDF_ON | PV 4387 | batt 62.3A | conso 1016 | grid 0 | DVCC 130 | SOC 72 | edf on | acin 1 | acm day_pv_boost
10:50:12 | EDF_ON | PV 3843 | batt 57.6A | conso 983 | grid 0 | DVCC 130 | SOC 73 | edf on | acin 1 | acm day_pv_boost
10:50:27 | EDF_ON | PV 3112 | batt 59.4A | conso 988 | grid 983 | DVCC 130 | SOC 73 | edf on | acin 1 | acm day_pv_boost
10:50:42 | EDF_ON | PV 2674 | batt 62.9A | conso 1017 | grid 1237 | DVCC 130 | SOC 73 | edf on | acin 1 | acm day_pv_boost
10:50:57 | EDF_ON | PV 2772 | batt 52.5A | conso 988 | grid 1415 | DVCC 130 | SOC 73 | edf on | acin 1 | acm day_pv_boost
10:51:12 | EDF_ON | PV 3191 | batt 76.1A | conso 979 | grid 1700 | DVCC 130 | SOC 73 | edf on | acin 1 | acm day_pv_boost
10:51:27 | EDF_ON | PV 2968 | batt 74.7A | conso 990 | grid 1880 | DVCC 130 | SOC 73 | edf on | acin 1 | acm day_pv_boost
10:51:42 | EDF_ON | PV 3253 | batt 78.0A | conso 1004 | grid 2139 | DVCC 130 | SOC 73 | edf on | acin 1 | acm day_pv_boost
10:51:57 | EDF_ON | PV 4048 | batt 98.0A | conso 1002 | grid 2384 | DVCC 130 | SOC 73 | edf on | acin 1 | acm day_pv_boost
10:52:12 | EDF_ON | PV 3559 | batt 105.2A | conso 975 | grid 2634 | DVCC 130 | SOC 73 | edf on | acin 1 | acm day_pv_boost
10:52:27 | EDF_ON | PV 3457 | batt 100.7A | conso 954 | grid 2946 | DVCC 130 | SOC 74 | edf on | acin 1 | acm day_pv_boost
10:52:42 | EDF_ON | PV 3359 | batt 102.3A | conso 953 | grid 3060 | DVCC 130 | SOC 74 | edf on | acin 1 | acm day_pv_boost
10:52:57 | EDF_ON | PV 2983 | batt 100.8A | conso 951 | grid 3335 | DVCC 130 | SOC 74 | edf on | acin 1 | acm day_pv_boost
10:53:12 | EDF_ON | PV 2892 | batt 104.0A | conso 960 | grid 3557 | DVCC 130 | SOC 74 | edf on | acin 1 | acm day_pv_boost
10:53:27 | EDF_ON | PV 2688 | batt 101.7A | conso 890 | grid 3709 | DVCC 130 | SOC 74 | edf on | acin 1 | acm day_pv_boost
10:53:42 | EDF_ON | PV 2572 | batt 104.1A | conso 879 | grid 3926 | DVCC 130 | SOC 74 | edf on | acin 1 | acm day_pv_boost
10:53:57 | EDF_ON | PV 2530 | batt 106.0A | conso 877 | grid 4113 | DVCC 130 | SOC 74 | edf on | acin 1 | acm day_pv_boost
>>> restauration : EDF OFF, latch OFF, hysteresis ON
10:54:15 | APRES | PV 2302 | batt 104.6A | conso 937 | grid 0 | DVCC 130 | SOC 74 | edf off | acin 0 | acm day_pv_boost
10:54:30 | APRES | PV 2500 | batt 25.7A | conso 934 | grid 0 | DVCC 130 | SOC 75 | edf off | acin 0 | acm day_pv_boost
10:54:45 | APRES | PV 2411 | batt 26.3A | conso 929 | grid 0 | DVCC 130 | SOC 75 | edf off | acin 0 | acm day_pv_boost
10:55:00 | APRES | PV 2551 | batt 29.7A | conso 944 | grid 0 | DVCC 130 | SOC 75 | edf off | acin 0 | acm day_pv_boost
10:55:15 | APRES | PV 2388 | batt 30.7A | conso 940 | grid 0 | DVCC 130 | SOC 75 | edf off | acin 0 | acm day_pv_boost
10:55:30 | APRES | PV 2545 | batt 29.3A | conso 1028 | grid 0 | DVCC 130 | SOC 75 | edf off | acin 0 | acm day_pv_boost
10:55:45 | APRES | PV 2846 | batt 35.2A | conso 955 | grid 0 | DVCC 130 | SOC 75 | edf off | acin 0 | acm day_pv_boost
10:56:00 | APRES | PV 3002 | batt 24.9A | conso 932 | grid 0 | DVCC 130 | SOC 75 | edf off | acin 0 | acm day_pv_boost
=== FIN ===

View File

@@ -193,6 +193,60 @@ function nextDeficitWindow(slots) {
const nextDeficit = nextDeficitWindow(prudent.slots);
// ---------------------------------------------------------------------
// RÉSERVE D'ORAGE (27/08/2026)
//
// Les coupures réseau sont fréquentes ici lors des orages. Point de départ
// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et
// la batterie continuent de fonctionner, seul le filet de secours disparaît.
// La question n'est donc pas « combien stocker pour survivre » mais « la
// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ».
//
// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut :
// rien à inventer, aucune constante de plus. Le profil prudent est le bon
// choix ici parce qu'une coupure retire précisément la possibilité de
// rattraper une sous-estimation.
//
// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est
// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de
// l'émetteur, pas un nombre choisi ici.
// ---------------------------------------------------------------------
const STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge'];
const STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange');
const stormRaw = typeof input.storm_alert_level === 'string'
? input.storm_alert_level.trim().toLowerCase()
: '';
const stormIndex = STORM_SCALE.indexOf(stormRaw);
const stormKnown = stormIndex >= 0;
const stormActive = stormIndex >= STORM_ACTIVE_FROM;
// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs
// (« Orages », « Vent violent »…). Purement informatif.
const stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object')
? input.storm_alert_detail
: {};
const stormPhenomena = Object.keys(stormDetail)
.filter(k => typeof stormDetail[k] === 'string'
&& STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM)
.sort();
// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ;
// en déficit, c'est celle qu'on est en train de traverser.
const inSurplusNow = prudent.found && prudent.crossingIndex === 0;
const stormWindow = (inSurplusNow && nextDeficit.found)
? {
kwh: nextDeficit.energyKwh,
socPct: nextDeficit.requiredSocAtStartPct,
startHour: nextDeficit.startHour,
}
: {
kwh: prudent.energyToCoverKwh,
socPct: prudent.requiredSocPct,
startHour: null,
};
const stormMarginPct = socPct - stormWindow.socPct;
const slots = typical.slots;
const crossingFound = typical.found;
const crossingIndex = typical.crossingIndex;
@@ -278,6 +332,24 @@ return [{
surplusAvailableKwh: round2(surplusAfterCrossingKwh),
},
// Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui
// compte. Publiée en permanence — elle documente le risque même par temps
// calme ; `active` dit seulement si la vigilance la rend d'actualité.
storm: {
level: stormKnown ? stormRaw : 'indisponible',
known: stormKnown,
active: stormActive,
phenomena: stormPhenomena,
windowStartHour: stormWindow.startHour,
reserveKwh: round2(stormWindow.kwh),
reserveSocPct: round1(stormWindow.socPct),
socMarginPct: round1(stormMarginPct),
covered: stormMarginPct >= 0,
shortfallKwh: stormMarginPct < 0
? round2((Math.abs(stormMarginPct) / 100) * usableCapacityKwh)
: 0,
},
confidence,
slots,

View File

@@ -65,7 +65,21 @@ if (!balance.usable) {
const c = balance.coverage;
setText('input_text.evcs_balance_status', 'ok');
// `ok` ne veut pas dire « le calcul a abouti » mais « on peut s'appuyer
// dessus ». Deux cas dégradent la réponse sans l'empêcher :
// · aucun croisement production/consommation sur l'horizon — le SOC requis
// n'est alors plus une exigence datée mais la somme d'un horizon entier ;
// · des heures de déficit reposant sur une forme de production minorée
// (repli sur l'enveloppe faute de courbe horaire fraîche).
// Dans les deux cas le bilan publie quand même ses chiffres — ils restent
// informatifs — mais il le DIT, pour qu'aucun câblage futur ne s'y fie en
// silence. Mesuré le 25/08 : sans cette distinction, la nuit précédant la mise
// en service de la courbe horaire, le bilan a publié un SOC requis de 100 %
// avec un statut `ok`.
const degraded =
!balance.crossing.found ||
!(balance.confidence && balance.confidence.deficitTrustworthy);
setText('input_text.evcs_balance_status', degraded ? 'degrade' : 'ok');
setNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);
setNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);
setNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);
@@ -86,6 +100,27 @@ if (nd.found) {
setText('input_text.evcs_balance_next_deficit_window',
nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\'horizon');
}
// --- Réserve d'orage -----------------------------------------------------
// Publiée même hors vigilance : le chiffre dit en permanence si l'installation
// passerait la prochaine nuit sans réseau. Le helper SOC est borné à 100 pour
// rester dans la plage de l'entrée ; le dépassement éventuel reste lisible
// dans la raison et dans le manque en kWh.
const st = balance.storm;
if (st) {
setText('input_text.evcs_balance_storm_level', st.level);
setBool('input_boolean.evcs_balance_storm_active', !!st.active);
setNumber('input_number.evcs_balance_storm_reserve_kwh', st.reserveKwh);
setNumber('input_number.evcs_balance_storm_reserve_soc',
Number.isFinite(st.reserveSocPct) ? Math.min(100, Math.max(0, st.reserveSocPct)) : null);
setText('input_text.evcs_balance_storm_reason',
(st.known ? `Vigilance ${st.level}` : 'Vigilance indisponible')
+ (st.phenomena.length ? ` (${st.phenomena.join(', ')})` : '')
+ ` — sans réseau il faut ${st.reserveKwh} kWh`
+ (st.windowStartHour == null ? ' pour la fenêtre en cours' : ` dès ${st.windowStartHour} h`)
+ `, soit SOC ${st.reserveSocPct}% ; actuel ${c.currentSocPct}% → `
+ (st.covered ? 'couvert.' : `manque ${st.shortfallKwh} kWh.`));
}
setText('input_text.evcs_balance_crossing_hour',
balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\'horizon');
@@ -93,10 +128,13 @@ const ndText = nd.found
? ` | prochaine traversée ${nd.energyKwh} kWh dès ${nd.startHour} h → SOC requis ${nd.requiredSocAtStartPct}%${nd.feasibleWithCurrentStorage ? '' : ` (manque ${Math.round(nd.storageShortfallKwh * 100) / 100} kWh de stockage)`}`
: '';
const degradedNote = !balance.crossing.found
? ' ⚠ aucun croisement sur l\'horizon : chiffre indicatif, pas une exigence datée.'
: (degraded ? ' ⚠ forme de production minorée sur les heures de déficit.' : '');
const reason = balance.crossing.found
? `Croisement à ${balance.crossing.hour} h, ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}% (actuel ${c.currentSocPct}%). ${c.gridNeeded ? `Réseau : ${c.gridEnergyNeededKwh} kWh.` : 'Réseau non nécessaire.'}`
: `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;
setText('input_text.evcs_balance_reason', reason + ndText);
setText('input_text.evcs_balance_reason', reason + degradedNote + ndText);
// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil
// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.

View File

@@ -89,17 +89,37 @@ function bucketHourly(series) {
// Deltas entre heures consécutives. Un delta négatif signale une remise à zéro
// du compteur (redémarrage HA, réinitialisation d'intégration) : on jette
// l'échantillon plutôt que de fabriquer une valeur.
//
// ⚠️ À QUELLE HEURE APPARTIENT UN DELTA ? (corrigé le 25/08/2026)
// `bucketHourly` garde la DERNIÈRE valeur de chaque heure. Le delta entre
// l'heure H−1 et l'heure H est donc l'énergie consommée PENDANT H : il se
// range sous `hours[i]`, jamais sous `hours[i-1]`.
//
// L'erreur d'un cran avait deux effets, l'un discret et l'autre grave :
// · tout le profil horaire était décalé d'une heure vers l'avant ;
// · surtout, les compteurs de cette installation sont ÉPARS — ils n'émettent
// que lorsqu'ils avancent. `ac_in_1_to_ac_out` ne peuple que 57 heures sur
// 267, `evcs_total` 57 sur 264. Une heure vide signifie « valeur inchangée,
// donc zéro énergie » : toute l'énergie d'un intervalle à trou a été
// consommée pendant l'heure FINALE, celle qui a réveillé le compteur.
// En la rangeant sous l'heure initiale, l'import réseau de la nuit suivante
// atterrissait sur la dernière heure de la nuit précédente. Mesuré le
// 25/08 : `consumption_p50[8] = 2,90 kWh/h` pour une consommation réelle de
// 0,9 — six échantillons sur douze portaient un delta étalé sur 17 heures.
// Le bilan réclamait alors deux fois trop d'énergie le matin.
function deltaSeries(buckets) {
const hours = [...buckets.keys()].sort((a, b) => a - b);
const deltas = new Map();
let resets = 0;
let spannedGaps = 0;
for (let i = 1; i < hours.length; i++) {
const d = buckets.get(hours[i]).value - buckets.get(hours[i - 1]).value;
if (d < 0) { resets++; continue; }
if (d > CFG.MAX_PLAUSIBLE_HOURLY_KWH) { resets++; continue; }
deltas.set(hours[i - 1], d);
if (hours[i] - hours[i - 1] > 3600000 && d > 0) spannedGaps++;
deltas.set(hours[i], d);
}
return { deltas, resets };
return { deltas, resets, spannedGaps };
}
// ---------------------------------------------------------------------
@@ -261,6 +281,14 @@ return [{
socHoursUsed: socHourly.size,
hoursWithEvcs: evcsHours,
countersReset: fromInverter.resets + fromGrid.resets + fromEvcs.resets,
// Nombre de deltas couvrant plus d'une heure : c'est le signe que le
// compteur est resté silencieux (donc à l'arrêt). L'énergie est rangée
// sous l'heure qui l'a réveillé. Un chiffre élevé n'est pas une anomalie
// sur cette installation — le réseau et la borne sont éteints la plupart
// du temps — mais il faut le voir pour ne pas relire un profil sans le
// savoir.
spannedGaps:
fromInverter.spannedGaps + fromGrid.spannedGaps + fromEvcs.spannedGaps,
unreliableHours: profile.filter(h => !h.reliable).map(h => h.hour),
},
cfg: CFG,

View File

@@ -47,5 +47,12 @@
"forecast_tomorrow_kwh": states("sensor.energy_production_tomorrow"),
"forecast_next_hour_w": states("sensor.power_production_next_hour"),
"sun_elevation": state_attr("sun.sun", "elevation")
"sun_elevation": state_attr("sun.sun", "elevation"),
"storm_alert_level": (states.sensor
| selectattr('entity_id', 'search', '_weather_alert')
| map(attribute='state') | list | first) | default('indisponible', true),
"storm_alert_detail": (states.sensor
| selectattr('entity_id', 'search', '_weather_alert')
| map(attribute='attributes') | list | first) | default({}, true)
} | tojson }}

View File

@@ -46,6 +46,13 @@ return [{
// de code, cf. backlog du chantier).
soc_floor_percent: 22,
// Vigilance Météo-France, lue sans connaître la commune (l'entité
// s'appelle sensor.<commune>_weather_alert). Absente tant que
// l'intégration n'est pas installée : le bilan publie alors
// 'indisponible' et ne change rien.
storm_alert_level: snapshot.storm_alert_level,
storm_alert_detail: snapshot.storm_alert_detail,
// Repris pour l'écriture des helpers, sans influencer le calcul.
resourceUsable: resource.usable,
shapeConfidence: resource.source ? resource.source.shapeConfidence : null,

View File

@@ -137,16 +137,27 @@ const dayKey = (ms) =>
const currentHourStart = Math.floor(nowMs / 3600000) * 3600000;
const today = dayKey(nowMs);
// Une heure ou l'enveloppe demontree est nulle est une heure de nuit : il n'y
// a jamais eu de soleil a y observer. Son taux de censure vaut 1 pour cette
// raison-la, pas parce que la mesure serait douteuse, et il n'existe aucune
// forme a minorer. La classer « minoree » faisait crier au loup TOUTES les
// nuits (les heures de deficit sont justement les heures sans soleil), donc
// exactement quand le bilan sert. Constate sur la nuit du 25-26/08 : statut
// « degrade » de 20 h a 07 h alors que le bilan a predit le point bas a un
// point pres.
const slots = [];
for (let i = 0; i < CFG.HORIZON_HOURS; i++) {
const start = currentHourStart + i * 3600000;
const hour = hourOfDay(start);
const nightHour = !!envelope && !(envelope[hour] > 0);
slots.push({
startMs: start,
hour,
day: dayKey(start),
weight: envelope ? envelope[hour] : 0,
quality: censorshipRate[hour] <= CFG.CENSORSHIP_QUALITY_LIMIT ? 'fiable' : 'minoree',
quality: nightHour
? 'nuit'
: censorshipRate[hour] <= CFG.CENSORSHIP_QUALITY_LIMIT ? 'fiable' : 'minoree',
});
}

View File

@@ -394,17 +394,24 @@ const vehicleChargeCompleted =
// ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de
// l'hystérésis (full_or_paused / not_requesting_power / charge_complete /
// no_demand) — même contrainte que 'night_session_done_idle'.
const NIGHT_RECOVERY_REARM_BAND_PCT = 2;
// 27/08/2026 : la bande de 2 points fabriquait un oscillateur. Relevé de la
// nuit du 26-27/08 (cible 35) : engagement à SOC 33, relâchement à 35, trois
// cycles EDF de 52, 79 et 25 min, 2,5 kWh importés pour rien un matin de grand
// soleil ; la nuit du 20-21/08 en comptait six. Deux points de SOC valent
// ~0,34 kWh, soit une demi-heure de conso nocturne : ce n'est pas une
// hystérésis, c'est la période d'une horloge — d'autant plus régulière que la
// charge de nuit est parfaitement plate.
// La recovery batterie post-charge est donc UNE SEULE passe par session de
// nuit, ce qui était déjà l'intention affichée du Fix A ("relâché
// DÉFINITIVEMENT"). Aucun seuil de ré-engagement ici : le plancher dur reste
// un terme indépendant de nightGridTakeoverNeeded (voir plus bas), et
// l'hystérésis réseau garde sa propre bande 20/30. Le vrai critère de
// ré-engagement est un budget d'énergie restant à couvrir, pas un delta de
// SOC : il appartient au bilan énergétique, pas à une constante de plus ici.
const nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle';
const nightRecoveryDone =
vehicleChargeCompleted &&
(
parsed.soc >= batteryMorningTargetPct ||
(
nightRecoveryDonePrev &&
parsed.soc > batteryMorningTargetPct - NIGHT_RECOVERY_REARM_BAND_PCT
)
);
(parsed.soc >= batteryMorningTargetPct || nightRecoveryDonePrev);
// =====================================================
// P9 (09/08/2026) : CHARGE RÉSEAU AVANT DÉPART, HORS FENÊTRE HC.

View File

@@ -56,6 +56,11 @@ const SUMMARIZE = {
csvFitsHelper: o.csv.p80.length <= 255 && o.csv.p50.length <= 255,
p50AtHour14: o.profile[14].p50,
p80AtHour14: o.profile[14].p80,
// Heures 1 à 3 et 8 : sert au scénario d'attribution horaire, où un
// compteur épars ne se réveille que la nuit.
p50Hours1a3: o.profile.slice(1, 4).map(h => h.p50).join(','),
p50AtHour8: o.profile[8].p50,
spannedGaps: o.quality.spannedGaps,
}),
RESOURCE_ESTIMATE: o => ({
usable: o.usable,
@@ -63,6 +68,11 @@ const SUMMARIZE = {
todayExpected: o.todayExpectedRemainingKwh,
tomorrowExpected: o.tomorrowExpectedKwh,
shapeConfidenceBelow: o.source.shapeConfidence != null && o.source.shapeConfidence < 0.5,
// Qualite de forme d'une heure de nuit (03 h) et d'une heure de plein
// apres-midi (14 h) : sert au scenario de nuit, ou une heure sans soleil
// ne doit pas etre declaree « minoree ».
qualityAtHour3: (o.horizon.find(h => h.hour === 3) || {}).shapeQuality,
qualityAtHour14: (o.horizon.find(h => h.hour === 14) || {}).shapeQuality,
}),
BALANCE: o => o.usable ? ({
usable: true,
@@ -78,6 +88,14 @@ const SUMMARIZE = {
nextDeficitEnergyKwh: o.nextDeficit.found ? o.nextDeficit.energyKwh : null,
nextDeficitRequiredSocPct: o.nextDeficit.found ? o.nextDeficit.requiredSocAtStartPct : null,
storageShortfallKwh: o.nextDeficit.found ? Math.round(o.nextDeficit.storageShortfallKwh * 100) / 100 : null,
// Réserve d'orage : niveau lu, activation, et le chiffre qui compte —
// ce qu'il faut en batterie pour traverser sans réseau.
stormLevel: o.storm.level,
stormActive: o.storm.active,
stormReserveKwh: o.storm.reserveKwh,
stormReserveSocPct: o.storm.reserveSocPct,
stormCovered: o.storm.covered,
stormShortfallKwh: o.storm.shortfallKwh,
}) : ({ usable: false, missing: o.missing.join(',') }),
};
@@ -91,8 +109,12 @@ SUMMARIZE.BUILD_HA_CALLS = (calls) => {
outOfScope: entities.filter(e => !/^(input_text|input_number|input_boolean)\.evcs_balance_/.test(e)).join(','),
wroteRequiredSoc: entities.includes('input_number.evcs_balance_required_soc'),
wroteGridNeeded: entities.includes('input_boolean.evcs_balance_grid_needed'),
// Les 5 écritures de la réserve d'orage sont-elles bien émises ET dans le
// périmètre ombre ? (outOfScope les attraperait si elles en sortaient).
wroteStorm: entities.filter(e => /evcs_balance_storm_/.test(e)).length,
hasLogbook: calls.some(c => c.json.domain === 'logbook'),
wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'),
status: (calls.find(c => c.json.data.entity_id === 'input_text.evcs_balance_status') || { json: { data: {} } }).json.data.value,
};
};
SUMMARIZE.BUILD_HA_CALLS_PROFILES = SUMMARIZE.BUILD_HA_CALLS;

View File

@@ -145,4 +145,113 @@ export const balanceScenarios = [
missing: 'usable_capacity_kwh',
},
},
// ================= RÉSERVE D'ORAGE (27/08/2026) =================
// Une coupure réseau n'arrête PAS l'installation : le solaire et la batterie
// continuent, seul le filet de secours disparaît. La réserve à tenir est donc
// la couverture PRUDENTE (p80) de la fenêtre de déficit qui compte — pas une
// nouvelle constante.
{
node: 'BALANCE',
id: 'orage-vigilance-absente',
label: 'Intégration Météo-France non installée : niveau indisponible, aucune activation, mais la réserve reste chiffrée',
snapshot: {
nowMs: Date.parse('2026-08-24T06:43:00+02:00'),
consumption_p50: P50,
consumption_p80: P80,
horizon: horizonFrom('2026-08-24T06:43:00+02:00', 18, PROD_24_08),
soc_percent: 21,
usable_capacity_kwh: 15,
soc_floor_percent: 22,
storm_alert_level: 'indisponible',
},
expect: {
usable: true,
stormLevel: 'indisponible',
stormActive: false,
// La réserve se calcule quand même : 2,42 kWh pour la matinée du 24/08
// sur le profil prudent, soit SOC 38,1 %.
stormReserveKwh: 2.42,
stormReserveSocPct: 38.1,
},
},
{
node: 'BALANCE',
id: 'orage-vigilance-jaune-pas-active',
label: 'Vigilance jaune : en dessous du seuil de Météo-France, la réserve est publiée mais pas déclarée d\'actualité',
snapshot: {
nowMs: Date.parse('2026-08-24T06:43:00+02:00'),
consumption_p50: P50,
consumption_p80: P80,
horizon: horizonFrom('2026-08-24T06:43:00+02:00', 18, PROD_24_08),
soc_percent: 21,
usable_capacity_kwh: 15,
soc_floor_percent: 22,
storm_alert_level: 'Jaune',
},
expect: {
usable: true,
stormLevel: 'jaune',
stormActive: false,
stormReserveKwh: 2.42,
},
},
{
node: 'BALANCE',
id: 'orage-vigilance-orange-deficit-decouvert',
label: 'Vigilance orange à 06:43 avec SOC 21 % : la matinée ne passe pas sans réseau, le manque est chiffré',
snapshot: {
nowMs: Date.parse('2026-08-24T06:43:00+02:00'),
consumption_p50: P50,
consumption_p80: P80,
horizon: horizonFrom('2026-08-24T06:43:00+02:00', 18, PROD_24_08),
soc_percent: 21,
usable_capacity_kwh: 15,
soc_floor_percent: 22,
storm_alert_level: 'Orange',
storm_alert_detail: { 'Orages': 'Orange', 'Vent violent': 'Jaune' },
},
expect: {
usable: true,
stormLevel: 'orange',
stormActive: true,
stormReserveKwh: 2.42,
stormReserveSocPct: 38.1,
stormCovered: false,
// SOC 21 contre 38,1 requis : il manque 2,57 kWh pour tenir la matinée
// sans réseau.
stormShortfallKwh: 2.57,
},
},
{
node: 'BALANCE',
id: 'orage-vigilance-rouge-surplus-nuit-a-venir',
label: 'Vigilance rouge à 15:30, batterie à 95 % : la fenêtre qui compte est la nuit qui vient, pas celle en cours',
snapshot: {
nowMs: Date.parse('2026-08-24T15:30:00+02:00'),
consumption_p50: P50,
consumption_p80: P80,
horizon: horizonFrom('2026-08-24T15:30:00+02:00', 24, {
...PROD_24_08,
15: 3.00, 16: 2.80, 17: 2.60, 18: 2.40, 19: 1.20, 20: 0.20,
7: 1.50, 8: 3.00, 9: 5.00,
}),
soc_percent: 95,
usable_capacity_kwh: 15,
soc_floor_percent: 22,
storm_alert_level: 'Rouge',
},
expect: {
usable: true,
stormLevel: 'rouge',
stormActive: true,
// Bascule sur la fenêtre de 19 h : 12,77 kWh, soit un SOC requis de
// 107,1 % — même pleine, la batterie actuelle ne passe pas la nuit sans
// réseau. C'est le signal de dimensionnement, il ne doit pas être écrêté.
stormReserveKwh: 12.77,
stormReserveSocPct: 107.1,
stormCovered: false,
stormShortfallKwh: 1.82,
},
},
];

View File

@@ -83,6 +83,55 @@ export const profileScenarios = [
unreliableHours: '0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23',
},
},
{
node: 'DAILY_PROFILES',
id: 'compteur-epars-attribution-horaire',
label:
'Compteur épars (réseau allumé seulement la nuit) : chaque kWh se range sous l\'heure qui l\'a consommé',
// Reproduit le défaut mesuré le 25/08/2026 : `ac_in_1_to_ac_out` n'émet que
// pendant les heures creuses. Entre deux rafales, le compteur reste muet
// pendant 21 heures. L'énergie d'un intervalle à trou appartient à l'heure
// qui a réveillé le compteur, pas à celle où il s'était endormi.
snapshot: (() => {
const inverter = [];
const grid = [];
let cumInverter = 0;
let cumGrid = 0;
const start = Date.parse('2026-08-01T00:00:00+02:00');
// Rafale réseau : 1 kWh à 01 h, 2 kWh à 02 h, 3 kWh à 03 h. Montants
// distincts pour qu'un décalage d'un cran soit impossible à confondre.
const burst = { 1: 1, 2: 2, 3: 3 };
for (let h = 0; h < 12 * 24; h++) {
const ts = new Date(start + h * 3600000);
const hourParis = Number.parseInt(
new Intl.DateTimeFormat('fr-FR', {
timeZone: 'Europe/Paris', hour: '2-digit', hour12: false,
}).format(ts),
10
);
cumInverter += 0.8;
inverter.push({ ts: ts.toISOString(), state: String(cumInverter.toFixed(2)) });
const kwh = burst[hourParis];
if (kwh) {
cumGrid += kwh;
// Le compteur n'émet QUE pendant les heures où il avance.
grid.push({ ts: ts.toISOString(), state: String(cumGrid.toFixed(2)) });
}
}
return {
series: { inverter_to_ac_out: inverter, ac_in_to_ac_out: grid, evcs_total: [] },
nowMs: NOW,
};
})(),
expect: {
// 0,8 de fond + la rafale de l'heure : 1,8 / 2,8 / 3,8.
p50Hours1a3: '1.8,2.8,3.8',
// 08 h ne consomme que le fond : c'est l'heure qui portait 2,90 kWh/h
// en production avant correction.
p50AtHour8: 0.8,
complete: true,
},
},
];
// --- RESOURCE_ESTIMATE -------------------------------------------------
@@ -110,6 +159,32 @@ export const resourceScenarios = [
shapeConfidenceBelow: true,
},
},
{
node: 'RESOURCE_ESTIMATE',
id: 'ressource-nuit-forme-non-minoree',
label: 'Projection lancee en pleine nuit : une heure sans soleil n\'a aucune forme a minorer, elle ne doit pas degrader la confiance',
snapshot: {
// 22:30 : l'horizon couvre les heures 0 a 6, dont le taux de censure
// vaut 1 faute de soleil a observer, pas faute de mesure fiable.
nowMs: Date.parse('2026-08-25T22:30:00+02:00'),
forecast_today_remaining_kwh: '0',
forecast_tomorrow_kwh: '32',
// Enveloppe et taux de censure RELEVES EN PRODUCTION le 26/08/2026, pas
// ceux du fixture (capture plus ancienne ou la nuit valait 0). En
// production la nuit vaut 1 : c'est cette valeur-la qui declenchait le
// faux « degrade », et c'est donc elle qu'il faut tester.
envelope_kwh: [0,0,0,0,0,0,0,0.17,1.03,3.86,6.55,7.17,9.33,8.38,
7.78,6.37,3.77,4.78,2.55,2.72,0.91,0.16,0,0],
censorship_rate: [1,1,1,1,1,1,1,0,0,0,0,0,0.25,0.36,
0.44,0.63,0.71,0.88,0.80,0.75,0.08,0,1,1],
},
expect: {
usable: true,
qualityAtHour3: 'nuit',
// L'apres-midi, lui, reste bien signale comme minore.
qualityAtHour14: 'minoree',
},
},
{
node: 'RESOURCE_ESTIMATE',
id: 'ressource-matin-forme-fiable',

View File

@@ -29,6 +29,23 @@ const BALANCE_OK = {
prudent: { requiredSocPct: 38.1 },
},
vehicle: { surplusAvailableKwh: 20.17 },
confidence: { deficitTrustworthy: true },
// La réserve d'orage fait partie de la sortie normale de BALANCE : elle doit
// figurer ici, sinon la garantie de périmètre ne couvre pas ses écritures.
storm: {
level: 'orange', known: true, active: true, phenomena: ['Orages'],
windowStartHour: null, reserveKwh: 2.42, reserveSocPct: 38.1,
socMarginPct: -17.1, covered: false, shortfallKwh: 2.57,
},
};
// Le même bilan, mais sans croisement trouvé sur l'horizon : le SOC requis
// n'est plus une exigence datée, seulement la somme d'un horizon entier. Le
// statut doit le dire.
const BALANCE_SANS_CROISEMENT = {
...BALANCE_OK,
crossing: { found: false, hour: null },
coverage: { ...BALANCE_OK.coverage, requiredSocPct: 100, energyToCoverKwh: 25.97 },
};
export const shadowScenarios = [
@@ -44,7 +61,26 @@ export const shadowScenarios = [
outOfScope: '',
wroteRequiredSoc: true,
wroteGridNeeded: true,
// Les 5 écritures de la réserve d'orage sont émises et restent dans le
// périmètre ombre.
wroteStorm: 5,
hasLogbook: true,
status: 'ok',
},
},
{
node: 'BUILD_HA_CALLS',
id: 'ombre-sans-croisement-statut-degrade',
label:
'Aucun croisement sur l\'horizon : les chiffres sont publiés mais le statut passe à `degrade`',
snapshot: {
...BALANCE_SANS_CROISEMENT,
__nodes: { 'HA Snapshot Balance': SNAPSHOT, CENSORSHIP: CENSORSHIP_LIBRE },
},
expect: {
outOfScope: '',
wroteRequiredSoc: true,
status: 'degrade',
},
},
{

View File

@@ -582,8 +582,8 @@ export const scenarios = [
},
},
{
id: 'night-recovery-rearm-sous-bande',
label: 'Nuit HC 05:30, recovery done mais SOC 32 ≤ cible−2 → ré-engagement takeover (recharge batterie)',
id: 'night-recovery-done-pas-de-reengagement',
label: 'Nuit HC 05:30, recovery done et SOC 32 sous la cible → AUCUN ré-engagement (la recovery est une passe unique, 27/08)',
time: '2026-08-08T05:30:00+02:00',
overrides: {
...VEHICLE_NIGHT, soc_percent: '32', allow_grid_hc_night: 'on',
@@ -595,15 +595,65 @@ export const scenarios = [
expect: {
profile: 'night_hc',
sourceMode: 'blocked',
state: 'night_session_done_idle',
// Collant désormais : la recovery ayant eu lieu, l'état reste
// 'night_recovery_done_idle' même sous la cible — c'est lui qui autorise
// la coupure EDF immédiate côté hystérésis (E2) dès SOC >= grid_soc_off.
state: 'night_recovery_done_idle',
decision: 'hold',
desiredCharging: false,
currentA: 6,
takeoverNeeded: true,
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: 'turn_off',
},
},
{
// L'oscillateur mesuré : nuit du 26-27/08, Jeep pleine restée branchée,
// cible 35. L'ancienne bande de 2 points ré-engageait le takeover pile à
// SOC 33 -> EDF ON 04:24/05:31/07:05, OFF 05:16/06:50/07:30, 2,5 kWh
// importés une nuit où le lendemain était annoncé à 43 kWh.
id: 'night-oscillateur-nuit-2627',
label: 'Nuit HC 05:31, recovery done et SOC 33 (cible 35 − 2) → le takeover NE se ré-engage pas : plus d\'oscillateur',
time: '2026-08-27T05:31:00+02:00',
overrides: {
...VEHICLE_NIGHT, soc_percent: '33', allow_grid_hc_night: 'on',
grid_state: 'off', victron_acin1_available: '0',
battery_current_a: '-11', night_takeover_prev: 'off',
evcs_charging_switch: 'off',
prev_surplus_state: 'night_recovery_done_idle',
},
expect: {
profile: 'night_hc',
sourceMode: 'blocked',
state: 'night_recovery_done_idle',
takeoverNeeded: false,
edfOnAction: false,
takeoverHelperService: 'turn_off',
},
},
{
// Le filet de sécurité doit rester intact : sous le plancher dur, le
// takeover se ré-engage même si la recovery a déjà eu lieu, parce que
// batteryBelowHardFloor est un terme INDÉPENDANT de nightGridTakeoverNeeded.
id: 'night-recovery-done-plancher-dur',
label: 'Nuit HC 05:31, recovery done mais SOC 21 sous le plancher dur 22 → ré-engagement du takeover (sécurité)',
time: '2026-08-27T05:31:00+02:00',
overrides: {
...VEHICLE_NIGHT, soc_percent: '21', allow_grid_hc_night: 'on',
grid_state: 'off', victron_acin1_available: '0',
battery_current_a: '-11', night_takeover_prev: 'off',
evcs_charging_switch: 'off',
prev_surplus_state: 'night_recovery_done_idle',
},
expect: {
profile: 'night_hc',
takeoverNeeded: true,
takeoverHelperService: 'turn_on',
},
},
{
id: 'night-vw-statut-charged',
label: 'Nuit HC 03:00, statut EVCS brut 3 (charged), sans état VDF préalable → fin de charge immédiate (Fix D)',

84
tools/ha-history.mjs Normal file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env node
// Usage: node tools/ha-history.mjs <entity_id>[,<entity_id>...] [debut] [fin]
// node tools/ha-history.mjs sensor.victron_battery_soc 2026-08-25T00:00 2026-08-25T10:00
// node tools/ha-history.mjs input_boolean.evcs_advisor_apply_night_target -48h
//
// Lit l'historique d'états via l'API REST de Home Assistant (jeton dans .env,
// même mécanique que tools/ha-service.mjs).
//
// ⚠️ PIÈGE VÉRIFIÉ : /api/history/period ne renvoie QU'UNE SEULE JOURNÉE si
// end_time est omis, sans le signaler. Ce script impose donc toujours les deux
// bornes.
//
// Les MCP du projet ne donnent que l'état courant : pour toute analyse
// a posteriori (qui a changé quoi, et quand), il faut l'historique.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadToken() {
if (process.env.HA_TOKEN) return process.env.HA_TOKEN;
const envPath = path.join(ROOT, '.env');
if (!fs.existsSync(envPath)) {
console.error('Jeton introuvable : ni $HA_TOKEN, ni fichier .env.');
process.exit(1);
}
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/);
if (m) return m[1].replace(/^["']|["']$/g, '');
}
console.error('HA_TOKEN absent du fichier .env');
process.exit(1);
}
const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123';
const entities = (process.argv[2] || '').split(',').filter(Boolean);
if (!entities.length) {
console.error('Usage: node tools/ha-history.mjs <entity_id>[,...] [debut|-Nh] [fin]');
process.exit(1);
}
function parseBound(arg, fallback) {
if (!arg) return fallback;
const rel = arg.match(/^-(\d+)h$/);
if (rel) return new Date(Date.now() - Number(rel[1]) * 3600e3);
return new Date(arg);
}
const end = parseBound(process.argv[4], new Date());
const start = parseBound(process.argv[3], new Date(end.getTime() - 24 * 3600e3));
const url =
`${HA_URL}/api/history/period/${start.toISOString()}` +
`?end_time=${encodeURIComponent(end.toISOString())}` +
`&filter_entity_id=${encodeURIComponent(entities.join(','))}` +
`&minimal_response&significant_changes_only`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${loadToken()}` } });
if (!res.ok) {
console.error(`HTTP ${res.status} — ${await res.text()}`);
process.exit(1);
}
const series = await res.json();
const fmt = (iso) =>
new Date(iso).toLocaleString('fr-FR', {
timeZone: 'Europe/Paris',
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
});
console.log(`Fenêtre : ${fmt(start.toISOString())} → ${fmt(end.toISOString())} (heure de Paris)\n`);
for (const rows of series) {
if (!rows.length) continue;
console.log(`=== ${rows[0].entity_id} (${rows.length} points) ===`);
let prev = null;
for (const r of rows) {
if (r.state === prev) continue;
console.log(` ${fmt(r.last_changed || r.last_updated)} ${r.state}`);
prev = r.state;
}
console.log('');
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long