Compare commits
8 Commits
0c15eba67b
...
cf7eb47356
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf7eb47356 | ||
|
|
99aba9da0d | ||
|
|
76e8695364 | ||
|
|
13e35cdbba | ||
|
|
f18c6e07f6 | ||
|
|
8ad34f8c93 | ||
|
|
08c072d781 | ||
|
|
17d65f1e40 |
263
HANDOFF.md
263
HANDOFF.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
59
docs/mesures/20260827-test-edf-mppt-alterne.log
Normal file
59
docs/mesures/20260827-test-edf-mppt-alterne.log
Normal 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 ===
|
||||
40
docs/mesures/20260827-test-edf-mppt.log
Normal file
40
docs/mesures/20260827-test-edf-mppt.log
Normal 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 ===
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
84
tools/ha-history.mjs
Normal 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
Reference in New Issue
Block a user