diff --git a/HANDOFF.md b/HANDOFF.md index c39df0f..891ef57 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2121,5 +2121,60 @@ pris a midi remplace un kWh d'heures pleines du soir (ou au pire un kWh HC de nu - ⚠️ A verifier au prochain jour faible : courant ACM (~100-140 A), consigne au plafond, SOC a 14:24, et que la PV n'est pas evincee (part DVCC des MPPT : priorite solaire de Victron, non verifiee en reel). +### 08/10 05:09 — Nuit : batterie tenue a 30 %, VW bloquee « sans EDF » alors qu'EDF etait ferme +- **Constat** (05:09) : EDF ferme depuis 18:04 le 07/10 en HEURES PLEINES (automatisation HA « Victron - Grid ON + si SoC < 22% », SOC 21 ; 8,3 kWh HP jusqu'a 01:24), session de nuit engagee a 01:24 (jour faible du 07/10), + batterie tenue a 30 % (cible du matin 30,7 % = « tenir jusqu'au croisement de 9 h », ACM a 2 A). La VW + (branchee 12:32, 0 %, **6,30 kWh pris en HC de midi le 07/10**, session close 14:34) bloquee de 01:24 a 05:14 : + mode remis a `sans_edf` a 12:00, budget 0, option A du 04/10 (voiture bloquee meme EDF ferme pour la batterie). +- **05:13 mode de nuit -> `complete`** (proprietaire, ecrit par moi via l'API HA) : VW en charge des 05:17 (3,6 kW). +- **Correctif 1 — controleur (deploye 05:17, golden 144/144)** : `nightCarRidesGridSession` (WINDOWS_SOURCEV2) = + session de nuit && voiture en attente « sans EDF » && `nightGridTakeoverNeeded` -> la voiture n'est plus + `blocked` : elle profite d'EDF deja ferme pour la batterie en HC. **Revient sur l'option A du 04/10 (accord du + proprietaire).** « Sans EDF » garde son sens : la voiture ne fait jamais fermer EDF a elle seule + (`budget-bilan-perime-sans-edf-rien` inchange). Raison publiee « Sans EDF, mais EDF est ferme pour la batterie + en heures creuses : la voiture en profite ». Golden : 3 attendus passes de `blocked` a `grid_forced` + (`budget-sans-edf-plancher-dur-edf-protege`, `budget-sans-edf-reprise-reseau-voiture-bloquee`, + `depart-matin-jour-de-depart-batterie-sous-la-cible`), mutation qui mord (3). +- **Correctif 2 — bilan (deploye 05:16, tests 91/91)** : pendant la fenetre HC de NUIT, plancher = plus petit SOC + de fin de nuit qui, au profil habituel (production ecretee a 100 %), amene la batterie au SOC requis au debut + de la traversee du soir (`hcReach.nightEvening`) ; si hors d'atteinte, celui qui en approche le plus sans + ecreter le solaire. Journee suffisante -> inchange. Rejeux 27/09 : 25,3 -> 30,3 % (prevision 25,1 kWh) et + 41,4 -> 54 % (journee sombre), verifies a la main (deficit du soir en points, courbe plate). Mutation « regle + coupee » mord (3) ; ⚠️ le plafond anti-gaspillage (`min(requis, socAtEvening(100))`) n'est couvert par aucun + scenario (mutation qui ne mord pas). +- **Ce matin la regle de nuit ne s'est PAS declenchee, a raison** : VRM annonce ~35 kWh le 08/10, batterie + prevue a 94,7 % a 18 h pour 70,5 % requis (rejeu de l'entree reelle de 05:20). ⚠️ Mon annonce de 05:09 (« 99,3 % + requis = journee faible ») etait fausse : 99,3 % est l'exigence PRUDENTE (p80) au debut de la traversee, pas + une prevision de journee faible. + +### 08/10 ~05:30 — Le conseiller IA devient RELECTEUR de la journee (option 3 du proprietaire) +Le conseiller (observation toutes les 15 min, 1 500 « rien » / 871 signalements) n'avait vu aucun des rates du +07/10. Doctrine §79 : les FAITS sont calcules par du code, le modele ne fait que les relire. +- **`tools/revue-jour.mjs`** (pur, `node tests/revue-jour/run.mjs` 13/13, 4 mutations qui mordent) : sur les 24 h + qui finissent a la fin des HC de nuit, trois constats « a examiner » : **EDF en heures pleines** (juge sur la + PUISSANCE reseau > 100 W hors HC, plages < 5 min ignorees, ecarts < 5 min fusionnes, kWh au compteur EDF, auteur + de la fermeture) ; **voiture branchee bloquee pendant une session HC** (table `decision` : session engagee, + source « Bloque », etat ni absent ni plein, > 10 min) ; **batterie sous son objectif en fin de HC** (nuit : + `cible_matin_pct` ; midi : `evcs_balance_required_soc` ; > 2 points). Plus une info : midi en charge maximale + (requis 100) -> gain de la fenetre. ⚠️ Premier essai sur le journal du contacteur seul : faux « EDF ferme de + 07:24 a 12:24 » (l'ouverture de 07:24 le 07/10 manque dans `evenement`, 0,2 kWh reels) — d'ou la puissance. + Rejoue sur la base reelle (07->08/10) : exactement les trois rates (midi 35 % pour 43,6 % ; EDF HP 18:06-01:24 + 8,3 kWh ; VW bloquee 01:24-05:14). +- **Service d'historisation** : 07:30 `sensor.evcs_revue_jour` (etat = date, attributs `a_examiner`, `constats` + [code, gravite, texte, contexte = raison du controleur], `texte`), evenement `revue` ; **07:45 courriel « EVCS - + revue de la journee »** (constats + pistes du conseiller presentees comme AVIS ; sans avis a 08:15), evenement + `revue_courriel`. Redemarre 05:30. Sauvegarde `historise-evcs.mjs.bak-20261008` (scratchpad). +- **Conseiller** (`src/advisor/*`, `tools/patch-20261008-advisor-relecteur.mjs`, tests 23/23) : ne tourne que si la + revue du jour existe, a au moins un point, pas encore de reponse, avant 10 h ; prompt = constats + contexte ; + sortie `{"pistes":[{code, piste}]}` ; **anti-invention** : piste ecartee si elle contient un nombre absent du + constat et de son contexte (mutation qui mord) ; publie `sensor.evcs_advisor_revue` (via `/api/states`, routage + `state_entity` du noeud Execute) et `input_text.evcs_advisor_reason` « Revue du … : n piste(s) ». Les anciens + helpers d'observation (`evcs_advisor_constat`, `_gravite`, `_ce_qui_cloche`, compteurs) ne sont plus mis a jour. + ⚠️ Repli cloud (OpenRouter) du workflow inchange : si le modele local echoue, les constats partent chez + OpenRouter. Sauvegarde `advisor.bak-20261008.json` (scratchpad). +- **A verifier ce matin** : 07:30 `sensor.evcs_revue_jour`, ~07:30-07:45 reponse du conseiller (pistes retenues / + ecartees), 07:45 courriel. Retour arriere : `git revert` + push du conseiller + redemarrage du service. + --- -*Document mis a jour le : 08/10/2026 (~00:15) — jour faible : charge maximale en HC de midi (bilan 100 %, ACM plafond + solaire) ; 07/10/2026 (~22:45) — reprise de 16 h verifiee, EDF en HP a 18:03, bilan : reprise du soir et prevision recalee sur la matinee ; bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* +*Document mis a jour le : 08/10/2026 (~05:35) — conseiller relecteur de la journee (revue calculee + pistes) ; nuit : voiture qui profite d'EDF ferme pour la batterie, nuit qui prepare le soir les jours annonces faibles ; 08/10/2026 (~00:15) — jour faible : charge maximale en HC de midi (bilan 100 %, ACM plafond + solaire) ; 07/10/2026 (~22:45) — reprise de 16 h verifiee, EDF en HP a 18:03, bilan : reprise du soir et prevision recalee sur la matinee ; bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* diff --git a/src/advisor/BUILD_HA_CALLS.js b/src/advisor/BUILD_HA_CALLS.js index f769895..b28b57f 100644 --- a/src/advisor/BUILD_HA_CALLS.js +++ b/src/advisor/BUILD_HA_CALLS.js @@ -1,67 +1,41 @@ // ===================================================== -// EVCS ADVISOR SHADOW V1 — BUILD_HA_CALLS -// Écrit les helpers advisor_* + divergence + compteurs + logbook. -// GARANTIE MODE OMBRE : ce node ne produit QUE des écritures vers -// des entités advisor_* et le logbook. AUCUN switch, AUCUNE stratégie -// réelle, AUCUNE commande borne. Ne pas ajouter d'autres entités ici. +// EVCS ADVISOR — BUILD_HA_CALLS (relecteur, 08/10/2026) +// GARANTIE : ce node n'ecrit QUE des entites advisor (input_text.evcs_advisor_*, +// sensor.evcs_advisor_revue) et le logbook. AUCUN switch, AUCUNE strategie, +// AUCUNE commande. Ne pas ajouter d'autres entites ici. +// Les items `state_entity` sont envoyes a /api/states (capteur avec attributs), +// les autres a /api/services//. // ===================================================== -const { status, detail, advice, modelUsed, context } = $input.first().json; +const { status, detail, pistes, rejetees, modelUsed, context } = $input.first().json; function stampParis() { - const p = new Intl.DateTimeFormat('fr-FR', { - timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit', - hour: '2-digit', minute: '2-digit', hour12: false, - }).formatToParts(new Date()); - const g = t => p.find(x => x.type === t)?.value || ''; + const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date()); + const g = (t) => p.find((x) => x.type === t)?.value || ''; return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`; } const calls = []; -function push(domain, service, data) { - calls.push({ json: { domain, service, data } }); -} -function setText(entity, value) { - push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) }); -} -function setNumber(entity, value) { - push('input_number', 'set_value', { entity_id: entity, value }); -} +const push = (domain, service, data) => calls.push({ json: { domain, service, data } }); +const setText = (entity, value) => push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) }); -// Statut + horodatage + modèle : écrits à CHAQUE run setText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : '')); setText('input_text.evcs_advisor_last_run', stampParis()); setText('input_text.evcs_advisor_model', modelUsed); -if (status === 'ok' && advice) { - // Observation — la seule sortie desormais. Le conseiller ne planifie plus : - // la planification est faite par le bilan, qui est calcule et mesure, et la - // campagne du 12/09 a montre que lui demander les deux degradait les deux - // (ecart de cible passe de 0 a 35 points sur certains cas des l ajout de la - // mission d observation). - setText('input_text.evcs_advisor_constat', advice.constat); - setText('input_text.evcs_advisor_gravite', advice.gravite); - setText('input_text.evcs_advisor_ce_qui_cloche', advice.ceQuiCloche || 'rien de signale'); - // Quand il n'y a rien a signaler il n'y a pas de piste, et recopier le - // constat faisait afficher la meme phrase deux fois sur la carte — et - // partir en double dans la colonne `piste` de la table d'observation. +if (status === 'ok' && Array.isArray(pistes)) { + calls.push({ json: { state_entity: 'sensor.evcs_advisor_revue', data: { + state: context.jour, + attributes: { friendly_name: 'Revue du conseiller IA', pistes, rejetees, modele: modelUsed, + avertissement: 'Avis a verifier, pas des faits. Les constats chiffres sont dans sensor.evcs_revue_jour.' }, + } } }); setText('input_text.evcs_advisor_reason', - `[${Math.round(advice.confidence * 100)}%] ${advice.piste || 'rien a creuser'}`); - - // Un observateur qui signale en permanence ne sert a rien : on compte le - // VOLUME de signalements pour pouvoir le constater. - if (advice.gravite === 'aucune') { - setNumber('input_number.evcs_advisor_agree_count', context.agreeCount + 1); - } else { - setNumber('input_number.evcs_advisor_diverge_count', context.divergeCount + 1); - } - + `Revue du ${context.jour} : ${pistes.length} piste(s)${rejetees ? `, ${rejetees} ecartee(s)` : ''}`); push('logbook', 'log', { - name: 'EVCS Advisor (observateur)', - message: `[${advice.gravite}] ${advice.constat}` - + (advice.ceQuiCloche ? ` || ${advice.ceQuiCloche}` : '') - + (advice.piste ? ` || piste : ${advice.piste}` : ''), - entity_id: 'input_text.evcs_advisor_constat', + name: 'EVCS Advisor (relecteur)', + message: pistes.map((p) => `[${p.code}] ${p.piste}`).join(' || ') || 'aucune piste retenue', + entity_id: 'input_text.evcs_advisor_reason', }); } diff --git a/src/advisor/BUILD_PROMPT.js b/src/advisor/BUILD_PROMPT.js index b3c1658..a19b5a2 100644 --- a/src/advisor/BUILD_PROMPT.js +++ b/src/advisor/BUILD_PROMPT.js @@ -1,317 +1,80 @@ // ===================================================== -// EVCS ADVISOR SHADOW V1 — BUILD_PROMPT -// Construit le prompt LLM à partir du snapshot HA compact. -// MODE OMBRE : ce workflow n'écrit que des helpers advisor_*. +// EVCS ADVISOR — BUILD_PROMPT (RELECTEUR DE LA JOURNEE, 08/10/2026) +// +// Le conseiller n'observe plus l'instantane toutes les 15 min : 1 500 « rien a +// signaler » contre 871 signalements sans suite, et aucun des rates du 07/10 +// (HC de midi perdue, EDF en heures pleines a 18:03, VW bloquee de 01:24 a +// 05:14 avec EDF ferme) n'avait ete vu. Il RELIT desormais, une fois par jour, +// les constats CALCULES par le service d'historisation (tools/revue-jour.mjs, +// sensor.evcs_revue_jour) et propose pour chacun une piste a examiner. +// Doctrine §79 : les chiffres viennent du code, le modele ne fait que relire. +// Il ne pilote rien. +// +// Il tourne quand : le conseiller est actif, la revue du jour est publiee, il +// y a au moins un point a examiner, et il n'a pas deja repondu aujourd'hui. // ===================================================== -function num(v, d) { - const n = Number.parseFloat(v); - return Number.isFinite(n) ? n : d; -} -function onOff(v) { - return (v ?? 'off').toString().trim().toLowerCase() === 'on'; -} - const raw = $input.first().json; const input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw); +const onOff = (v) => (v ?? 'off').toString().trim().toLowerCase() === 'on'; + +const jourParis = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }); +let constats = input.revue_jour_constats; +if (typeof constats === 'string') { try { constats = JSON.parse(constats); } catch { constats = []; } } +if (!Array.isArray(constats)) constats = []; +const aExaminer = constats.filter((c) => c && c.gravite === 'a_examiner' && c.code && c.texte); + +const revueDuJour = (input.revue_jour_date || '').toString().trim() === jourParis; +const dejaRepondu = (input.advisor_revue_date || '').toString().trim() === jourParis; +// Pas de relance au-dela de 10 h : un modele qui echoue ne doit pas etre +// rappele toute la journee (le courriel part a 08:15 sans lui). +const heureParis = Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false }).format(new Date())); const ctx = { advisorEnabled: onOff(input.advisor_enabled), - agreeCount: num(input.advisor_agree_count, 0), - divergeCount: num(input.advisor_diverge_count, 0), - - soc: num(input.soc_percent, NaN), - batteryCurrentA: num(input.battery_current_a, NaN), - pvW: num(input.pv_power_w, 0), - pvAvg10mW: num(input.pv_power_10m_avg_w, 0), - houseW: num(input.house_ac_power_w, 0), - gridOn: onOff(input.grid_state), - acinAvailable: num(input.acin1_available, 0) === 1, - - vehicleConnected: onOff(input.evcs_vehicle_connected), - evcsCharging: onOff(input.evcs_charging), - evcsCurrentA: num(input.evcs_current_a, 0), - surplusState: (input.evcs_surplus_state || '').toString(), - sourceMode: (input.evcs_source_mode || '').toString(), - - userStrategy: (input.user_strategy || 'auto_solar_plus').toString().trim().toLowerCase(), - allowGridHcNight: onOff(input.allow_grid_hc_night), - allowGridHcMidday: onOff(input.allow_grid_hc_midday), - nightTargetActual: num(input.night_target_soc_actual, 35), - currentMaxAutoA: num(input.current_max_auto_a, 22), - plafondEdfOnA: num(input.plafond_edf_on_a, 20), - - departureTime: (input.departure_time || '').toString(), - departurePriority: onOff(input.departure_priority_enabled), - requiredEnergyKwh: num(input.required_energy_kwh, 0), - - todayRemainingKwh: num(input.today_remaining_kwh, 0), - tomorrowKwh: num(input.tomorrow_kwh, 0), - - // ── BILAN ENERGETIQUE ──────────────────────────────────────────────── - // Ces grandeurs REMPLACENT les regles de seuil. Elles n'etaient pas - // transmises jusqu'au 12/09 : le conseiller raisonnait sur des paliers - // que le bilan avait deja demontres faux, et il ne pouvait pas mieux faire. - balanceStatus: (input.balance_status || 'indisponible').toString().trim(), - balanceLastRun: (input.balance_last_run || '').toString().trim(), - balanceRequiredSoc: num(input.balance_required_soc, NaN), - balanceRequiredSocPrudent: num(input.balance_required_soc_prudent, NaN), - balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN), - balanceCrossingHour: (input.balance_crossing_hour || '').toString().trim(), - balanceGridNeeded: onOff(input.balance_grid_needed), - balanceGridEnergyNeededKwh: num(input.balance_grid_energy_needed_kwh, NaN), - balancePvCensored: onOff(input.balance_pv_censored), - balanceCensorReason: (input.balance_censor_reason || '').toString().trim(), - balanceStorageShortfallKwh: num(input.balance_storage_shortfall_kwh, NaN), - balanceNextDeficitKwh: num(input.balance_next_deficit_kwh, NaN), - balanceNextDeficitWindow: (input.balance_next_deficit_window || '').toString().trim(), - stormLevel: (input.balance_storm_level || 'indisponible').toString().trim(), - stormReserveSoc: num(input.balance_storm_reserve_soc, NaN), - - // ── PARC ET MATERIEL, MESURES ──────────────────────────────────────── - modulesEnService: num(input.modules_en_service, 4), - bmsMaxDischargeA: num(input.bms_max_discharge_current_a, NaN), - bmsMaxChargeA: num(input.bms_max_charge_current_a, NaN), - gridSetpointW: num(input.grid_setpoint_w, NaN), - vehicleSelected: (input.vehicle_selected || 'inconnu').toString().trim(), - // Historique recent des transitions, fourni par le snapshot. C est la SUITE - // des evenements qui revele les contradictions entre automates ; un - // instantane ne peut pas les montrer. - evenements: String(input.evenements_recents || '') - .split('|').map(x => x.trim()).filter(Boolean).slice(0, 12) - .map(x => `- ${x}`), - - mppt: (() => { - const modes = String(input.mppt_operation_modes || '') - .split(',').map(x => x.trim().toLowerCase()) - .filter(x => x && x !== 'unavailable' && x !== 'unknown'); - return { - total: modes.length, - aFond: modes.filter(x => x === 'mppt_active').length, - brides: modes.filter(x => x.includes('limited')).length, - }; - })(), + jour: jourParis, + revueDuJour, + dejaRepondu, + constats: aExaminer.map((c) => ({ code: c.code, texte: String(c.texte), contexte: String(c.contexte || '') })), }; -// Capacite REELLEMENT livree a la maison, deduite de la courbe mesuree le -// 03/09 sur 576 paliers et du parc declare. Un US5000 compte pour DEUX -// equivalents US2000C. Aucune constante : si le parc change, ceci suit. -// GRANDEURS DERIVEES — on les CALCULE pour lui (12/09/2026, iteration 2). -// Campagne de rejeu : sur le releve du 03/09 19:44 (PV 758 W, maison 1202 W) -// il a recommande de charger la voiture a 28 A. Les deux nombres etaient dans -// son prompt ; il n a pas fait la soustraction. Et sur celui du 05/09 20:40 -// (batterie a -97,4 A pour une limite declaree de 100 A) il a repondu « SOC -// 79 % est suffisant » sans mentionner ni le courant ni la limite. -// Un modele raisonne bien sur UN nombre, mal sur deux qu il doit combiner. -// Meme principe que le bilan : on lui donne la grandeur, pas les ingredients. -const maisonHorsVoitureW = Math.max(0, ctx.houseW - (ctx.evcsCharging ? ctx.evcsCurrentA * 230 : 0)); -const excedentSolaireW = Math.round(ctx.pvW - maisonHorsVoitureW); -const margeRechargeW = ctx.gridOn && Number.isFinite(ctx.gridSetpointW) - ? Math.round(ctx.gridSetpointW - maisonHorsVoitureW) - : null; -// Le bilan dit lui-meme si la batterie doit monter. A defaut de bilan -// exploitable on ne tranche pas, et la marge reste un simple constat. -const batterieDoitMonter = Number.isFinite(ctx.balanceRequiredSoc) && Number.isFinite(ctx.soc) - ? ctx.soc < ctx.balanceRequiredSoc - : null; -const margeDechargeA = Number.isFinite(ctx.bmsMaxDischargeA) && Number.isFinite(ctx.batteryCurrentA) - ? Math.round(ctx.bmsMaxDischargeA - Math.abs(Math.min(0, ctx.batteryCurrentA))) - : null; -const partDuPlafond = Number.isFinite(ctx.bmsMaxDischargeA) && ctx.bmsMaxDischargeA > 0 - ? Math.round(100 * Math.abs(Math.min(0, ctx.batteryCurrentA)) / ctx.bmsMaxDischargeA) - : null; - -const kwhParPointHaut = 0.169 * ctx.modulesEnService / 7; -const kwhParPointBas = 0.097 * ctx.modulesEnService / 7; -const capaciteUtileKwh = (67 * kwhParPointHaut + 11 * kwhParPointBas) * 0.955; - -// Heure Paris -const parisFmt = new Intl.DateTimeFormat('fr-FR', { - timeZone: 'Europe/Paris', - weekday: 'long', day: '2-digit', month: '2-digit', - hour: '2-digit', minute: '2-digit', hour12: false, -}); -const nowParis = parisFmt.format(new Date()); - const systemPrompt = [ - "Tu es le PLANIFICATEUR ENERGIE d'une maison en autoconsommation, en Bretagne.", - 'Tu es en MODE OBSERVATION : tes reponses sont enregistrees et comparees au reel,', - 'elles ne pilotent rien.', + "Tu es le RELECTEUR du systeme energie d'une maison en Bretagne : 16 kWc de solaire,", + 'batterie de ~23 kWh utiles (0,30 kWh par point de SOC), Victron ESS SANS injection', + '(le solaire est bride quand personne ne le prend), abonnement EDF 12 kVA, contacteur', + 'EDF ouvert la plupart du temps, heures creuses 01:24-07:24 et 12:24-14:24, borne de', + 'recharge pour trois vehicules. Des automates decident : le controleur de la borne,', + "le bilan energetique (SOC requis), l'ACM (courant de charge), l'hysteresis EDF, des", + 'automatisations Home Assistant.', '', - 'INSTALLATION, CHIFFRES MESURES (pas des ordres de grandeur) :', - `- Batterie : ${ctx.modulesEnService} equivalents US2000C, soit ${capaciteUtileKwh.toFixed(1)} kWh`, - ` reellement livres a la maison entre 100 % et le plancher de securite de 22 %.`, - ` Un point de SOC vaut ${Math.round(kwhParPointHaut * 1000)} Wh au-dessus de 33 %,`, - ` ${Math.round(kwhParPointBas * 1000)} Wh en dessous.`, - '- Champ PV 16,1 kWc sur QUATRE orientations (sud-est 40, sud 35, ouest 45, un suiveur).', - '- Victron ESS, AUCUNE injection possible : la production est bridee des que rien', - ' ne peut l absorber. Une PV mesuree basse ne veut donc PAS dire ressource faible.', - '- Borne EVCS 6-28 A. Reseau EDF commutable, heures creuses 01:24-07:24 et 12:24-14:24.', - '- Chaque fermeture du contacteur coute 50 a 150 Wh de transitoire : une session', - ' longue et dimensionnee vaut mieux que des creneaux repetes.', + 'On te donne des CONSTATS MESURES sur les dernieres 24 h. Ce sont des FAITS : ne les', + 'conteste pas et ne les recalcule pas. Pour chacun, propose UNE piste : la cause la', + 'plus probable et ce qu il faut verifier ou corriger. Tu ne pilotes rien.', '', - 'LA MAISON, telle qu elle consomme vraiment :', - '- Quatre personnes. Les gros appels sont BREFS et cales sur les repas :', - ' bouilloire vers 05:45, grille-pain vers 08:00, cuisson du soir entre 20 h et 23 h.', - "- L heure du diner varie de TROIS HEURES d un jour a l autre : un profil horaire", - ' moyen ne represente ni l un ni l autre.', + 'REGLES ABSOLUES :', + '- N ecris AUCUN chiffre qui ne figure pas dans le constat ou son contexte.', + '- Si tu ne vois pas de cause, dis « cause non identifiable avec ces donnees ».', + '- Une piste fait au plus 280 caracteres, en francais, sans jargon inutile.', + '- Le contexte est la raison publiee par le controleur au debut de l episode.', '', - 'CE QUI REMPLACE LES REGLES DE SEUIL :', - 'un bilan energetique tourne toutes les 5 minutes et te donne, pour l instant present,', - 'l heure de croisement ou la production repassera au-dessus de la consommation,', - 'l energie que la batterie doit couvrir d ici la, et le SOC qui serait necessaire.', - 'RAISONNE SUR CES CHIFFRES. Il n existe plus de palier du type', - '"prevision forte => cible basse" : cette regle a ete mesuree fausse sur cette maison.', - 'Si le bilan est indisponible ou perime, dis-le dans ta raison et reste prudent.', - '', - 'CE QUE TU DOIS SAVOIR ET QUE PERSONNE NE TE DIRA AUTREMENT :', - '- SANS VEHICULE BRANCHE IL Y A QUAND MEME QUELQUE CHOSE A DECIDER : c est la MAISON', - ' qui doit traverser la nuit. Une batterie au plancher pendant les heures creuses,', - ' alors que le bilan reclame davantage, est une occasion manquee qui se paiera au', - ' plein tarif le matin.', - '- Les chargeurs solaires declarent eux-memes s ils se BRIDENT. Si certains se', - ' brident, il reste de la ressource REFUSEE : lui donner un preneur (voiture,', - ' batterie) ne coute rien. S ils sont tous a fond, la production mesuree EST la', - ' ressource.', - '- L hiver la nuit a couvrir est plus longue de deux heures en temps solaire : la', - ' cible du matin y monte naturellement bien plus haut qu en ete.', - '', - 'DEUX REGLES QUI PRIMENT SUR TOUT LE RESTE :', - '1. EXCEDENT SOLAIRE. Charger un vehicule ne se decide pas sur la production', - ' mais sur ce qui RESTE une fois la maison servie. Cet excedent t est donne', - ' plus bas. S il est NEGATIF, charger la voiture revient a vider la batterie', - ' solaire : ne le fais pas, quelle que soit la prevision. S il est nul ou a', - ' peine positif ALORS QUE DES CHARGEURS SE BRIDENT, la ressource existe mais', - ' est refusee : la charger est gratuit.', - '2. LIMITE DU PARC. La marge de decharge restante t est donnee plus bas. Si la', - ' batterie depasse 80 % de ce que le parc declare accepter, c est un sujet de', - ' PROTECTION DU MATERIEL, pas de confort : il faut appeler le reseau meme si', - ' le SOC parait confortable et meme hors heures creuses. Le proprietaire a un', - ' abonnement 12 kVA et prefere payer quelques kWh plutot que fragiliser le parc.', - '', - // <<< CARTE GENEREE — ne pas editer a la main, voir tools/carte-decisions.mjs - 'QUI ECRIT SUR QUOI (carte generee depuis les sources) :', - '- Contacteur reseau EDF : proprietaire = Battery / Grid Hysteresis Fix V1, AUTRES ECRIVAINS : EVCS Surplus Controller V2.6, automations, scripts', - '- Courant de la borne : proprietaire = EVCS Surplus Controller V2.6, AUTRES ECRIVAINS : scripts', - '- Verrou hysteresis : proprietaire = Battery / Grid Hysteresis Fix V1, AUTRES ECRIVAINS : scripts', - 'SEUILS DE SOC FIGES dans le code : 20, 22, 25, 35, 40, 60, 80, 85, 90, 95, 100 % — chacun peut contredire le bilan.', - 'Quand un actionneur bascule de facon incoherente, le coupable le plus probable', - 'est un ecrivain AUTRE QUE SON PROPRIETAIRE. Nomme-le.', - // CARTE GENEREE >>> - '', - 'TU NE PILOTES RIEN ET TU NE CHOISIS AUCUNE STRATEGIE.', - 'La planification energetique est faite par le bilan, qui est calcule et mesure.', - 'Ton seul travail est de REGARDER LE SYSTEME SE COMPORTER et de dire ce qui cloche.', - '', - 'Reponds UNIQUEMENT avec un objet JSON valide, sans texte autour, au format EXACT :', - '{"constat":"ce que tu observes, factuel, max 200 caracteres",', - '"gravite":"aucune|faible|moyenne|haute",', - '"ce_qui_cloche":"la contradiction ou l incoherence, ou \'\' si rien",', - '"piste":"ce qu il faudrait verifier ou corriger, ou \'\' si rien",', - '"confidence":<0.0-1.0>}', - '', - 'ECHELLE DE GRAVITE, ancree sur des cas reels de cette installation :', - '- aucune : tout est coherent. C EST LE CAS LE PLUS FREQUENT ET DE LOIN.', - ' N invente pas un probleme pour justifier une reponse.', - '- faible : un ecart sans consequence mesurable — une valeur un peu hors cible,', - ' un etat transitoire qui se resorbe seul.', - '- moyenne : quelque chose coute de l energie ou de l argent sans danger materiel.', - ' Exemple reel : la consigne reseau laissee haute en plein soleil, qui', - ' evince la production — 5,6 kW de solaire remplaces par du reseau.', - '- haute : un actionneur bascule de facon repetee, OU le materiel est sollicite', - ' pres de ses limites, OU un automate defait ce qu un autre vient de', - ' faire. Deux exemples reels : le contacteur reseau qui bascule six', - ' fois en dix minutes le 12/09 — chaque bascule coute 50 a 150 Wh et', - ' use le relais, et aucun automate ne le voyait ; et le 05/09 a 20:40,', - ' le parc a -97,4 A pour une limite declaree de 100 A, soit 3 A de', - ' marge, pendant que le reseau restait disponible et inutilise.', - 'UNE OSCILLATION EST TOUJOURS DE GRAVITE HAUTE, meme si chaque bascule prise', - 'isolement parait anodine : c est la repetition qui fait le degat, et c est', - 'precisement ce qu aucune regle locale ne peut voir.', + 'Reponds UNIQUEMENT en JSON : {"pistes":[{"code":"","piste":""}]}', ].join('\n'); -// Les etats du controleur sont des identifiants internes. Le conseiller n'a -// aucune raison de savoir que `day_vehicle_full_idle` veut dire « le vehicule -// est plein, il n'absorbera plus rien » — et sans le savoir il signale comme -// une anomalie un systeme qui a raison. -const ETATS = { - vehicle_not_connected: 'aucun vehicule branche', - idle_wait_conditions: 'a l arret, conditions de demarrage non reunies', - idle_wait_source: 'a l arret, en attente d une source', - day_vehicle_full_idle: 'vehicule PLEIN, il n absorbera plus rien (memoire du jour)', - night_recovery_done_idle: 'charge de nuit terminee ET batterie a la cible', - night_session_done_idle: 'charge de nuit terminee, batterie encore sous la cible', - departure_done_idle: 'charge avant depart terminee', - vehicle_full_or_paused: 'le vehicule ne demande plus de courant', - start_min_current: 'demarrage au courant minimal', - start_grid_hc_min_current: 'demarrage sur le reseau en heures creuses', - start_manual_local_min_current: 'demarrage manuel au courant minimal', - charging_hold_grid: 'en charge sur le reseau, consigne tenue', - charging_hold_manual_local: 'en charge, consigne manuelle', - charging_hold_cloud_tolerance: 'en charge, tolerance nuage en cours', - charging_step_up_battery: 'en charge, courant en hausse sur le solaire', - charging_step_up_grid_hc: 'en charge, courant en hausse sur le reseau', - charging_step_down_battery: 'en charge, courant en baisse', - charging_step_down_system_cap: 'en charge, courant ramene au plafond du systeme', - battery_current_hard_stop: 'ARRET : la batterie se dechargeait trop fort', - stop_conditions_lost: 'ARRET : conditions de maintien perdues', - stop_source_lost: 'ARRET : la source a disparu', - hold_min_run: 'maintenu le temps de la duree minimale de marche', -}; -const etatLisible = (e) => ETATS[e] ? `${ETATS[e]} (${e})` : e; - -function nombre(v, unite, defaut) { - return Number.isFinite(v) ? `${v}${unite}` : (defaut || 'inconnu'); -} - -const bilanLignes = ctx.balanceStatus === 'ok' - ? [ - `- heure de croisement production/consommation : ${ctx.balanceCrossingHour || 'inconnue'}`, - `- energie que la batterie doit couvrir d ici la : ${nombre(ctx.balanceEnergyToCoverKwh, ' kWh')}`, - `- SOC qui serait necessaire : ${nombre(ctx.balanceRequiredSoc, ' %')} (prudent : ${nombre(ctx.balanceRequiredSocPrudent, ' %')})`, - `- reseau juge necessaire : ${ctx.balanceGridNeeded ? 'OUI' : 'non'}${Number.isFinite(ctx.balanceGridEnergyNeededKwh) ? ` (${ctx.balanceGridEnergyNeededKwh} kWh)` : ''}`, - `- production actuellement bridee : ${ctx.balancePvCensored ? `OUI (${ctx.balanceCensorReason})` : 'non'}`, - `- manque de stockage identifie : ${nombre(ctx.balanceStorageShortfallKwh, ' kWh')}`, - `- prochaine traversee deficitaire : ${nombre(ctx.balanceNextDeficitKwh, ' kWh')} ${ctx.balanceNextDeficitWindow || ''}`, - `- vigilance orage : ${ctx.stormLevel}${Number.isFinite(ctx.stormReserveSoc) ? `, reserve conseillee ${ctx.stormReserveSoc} %` : ''}`, - `- calcule a ${ctx.balanceLastRun || 'heure inconnue'}`, - ] - : [`- INDISPONIBLE (statut ${ctx.balanceStatus}) : raisonne sans lui, et dis-le.`]; - const userPrompt = [ - `Nous sommes ${nowParis} (heure de Paris).`, + `Revue du ${jourParis}. ${aExaminer.length} constat(s) a examiner :`, '', - 'ETAT MESURE :', - `- Batterie : SOC ${ctx.soc} %, courant ${ctx.batteryCurrentA} A (positif = charge)`, - `- PV : ${Math.round(ctx.pvW)} W instantane, ${Math.round(ctx.pvAvg10mW)} W sur 10 min`, - `- Chargeurs solaires : ${ctx.mppt.aFond}/${ctx.mppt.total} a pleine puissance, ${ctx.mppt.brides} qui se BRIDENT`, - `- Maison : ${Math.round(ctx.houseW)} W, dont voiture ${ctx.evcsCharging ? Math.round(ctx.evcsCurrentA * 230) : 0} W`, - `- EXCEDENT SOLAIRE (production moins maison hors voiture) : ${excedentSolaireW > 0 ? `+${excedentSolaireW} W, il RESTE du solaire` : excedentSolaireW < 0 ? `${excedentSolaireW} W` : '0 W, exactement a l equilibre'}${ctx.mppt.brides > 0 ? ` — ATTENTION, ce chiffre est CENSURE : ${ctx.mppt.brides} chargeurs se retiennent, la ressource reelle est PLUS HAUTE${excedentSolaireW < 0 ? ' et ce chiffre negatif ne prouve PAS qu il manque du soleil' : ''}` : excedentSolaireW < 0 ? ', DEFICIT reel : la batterie porte deja la maison' : ''}`, - `- MARGE DE DECHARGE du parc : ${margeDechargeA === null ? 'inconnue' : `${margeDechargeA} A restants`}${partDuPlafond === null ? '' : ` — la batterie est a ${partDuPlafond} % de son plafond`}`, - `- Reseau EDF : ${ctx.gridOn ? 'PRESENT, il peut alimenter la maison' : 'ABSENT, la maison vit sur batterie et solaire'} (contacteur ${ctx.gridOn ? 'ferme' : 'ouvert'}, AC-IN ${ctx.acinAvailable ? 'present' : 'absent'})`, - `- Consigne de puissance reseau : ${nombre(ctx.gridSetpointW, ' W')}${margeRechargeW === null ? '' : batterieDoitMonter === false && margeRechargeW > 0 ? ` — la batterie n a PAS besoin de monter (SOC ${ctx.soc} %, bilan ${ctx.balanceRequiredSoc} %), donc ces ${margeRechargeW} W de demande reseau n ont aucune destination : ils ne peuvent que remplacer du solaire` : margeRechargeW > 0 ? ` — il reste ${margeRechargeW} W pour RECHARGER la batterie, qui en a besoin` : ' — une fois la maison servie il ne reste RIEN pour recharger la batterie'}`, - `- Limites declarees par le parc : charge ${nombre(ctx.bmsMaxChargeA, ' A')}, decharge ${nombre(ctx.bmsMaxDischargeA, ' A')}`, - `- Vehicule : ${ctx.vehicleConnected ? `BRANCHE (${ctx.vehicleSelected})` : 'AUCUN branche'}${ctx.evcsCharging ? `, en charge a ${ctx.evcsCurrentA} A` : ''}`, - `- Controleur : ${etatLisible(ctx.surplusState)} | source ${ctx.sourceMode} | strategie reelle ${ctx.userStrategy}`, - `- Cible SOC matin actuellement appliquee : ${ctx.nightTargetActual} %`, - `- Prevision : ${ctx.todayRemainingKwh} kWh restant aujourd hui, ${ctx.tomorrowKwh} kWh demain`, + ...aExaminer.map((c, i) => `${i + 1}. code=${c.code}\n constat : ${c.texte}\n contexte : ${String(c.contexte || '(aucun)').slice(0, 400)}`), '', - 'BILAN ENERGETIQUE :', - ...bilanLignes, - '', - 'DERNIERS EVENEMENTS DU SYSTEME (le plus recent en premier) :', - ...(ctx.evenements.length ? ctx.evenements : ['- aucun evenement transmis']), - '', - 'Quelle est ta recommandation ? JSON uniquement.', + 'JSON uniquement.', ].join('\n'); return [{ json: { - enabled: ctx.advisorEnabled, + enabled: ctx.advisorEnabled && revueDuJour && !dejaRepondu && aExaminer.length > 0 && heureParis < 10, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], context: ctx, - } + }, }]; diff --git a/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl index 9f00477..6251963 100644 --- a/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -2,6 +2,9 @@ "advisor_enabled": states("input_boolean.evcs_advisor_enabled"), "advisor_agree_count": states("input_number.evcs_advisor_agree_count"), "advisor_diverge_count": states("input_number.evcs_advisor_diverge_count"), + "revue_jour_date": states("sensor.evcs_revue_jour"), + "revue_jour_constats": state_attr("sensor.evcs_revue_jour", "constats") or [], + "advisor_revue_date": states("sensor.evcs_advisor_revue"), "soc_percent": states("sensor.victron_battery_soc"), "battery_current_a": states("sensor.victron_battery_current"), diff --git a/src/advisor/VALIDATE_PARSE.js b/src/advisor/VALIDATE_PARSE.js index 153dc93..a7bdde3 100644 --- a/src/advisor/VALIDATE_PARSE.js +++ b/src/advisor/VALIDATE_PARSE.js @@ -1,38 +1,25 @@ // ===================================================== -// EVCS ADVISOR SHADOW V1 — VALIDATE_PARSE +// EVCS ADVISOR — VALIDATE_PARSE (relecteur, 08/10/2026) // Valide STRICTEMENT la sortie LLM (locale OU cloud, format OpenAI). -// Sortie invalide = REJET TOTAL (status invalid), jamais de clamp silencieux. +// - JSON {"pistes":[{code, piste}]} ; sinon REJET TOTAL. +// - une piste dont le code n'est pas celui d'un constat est ecartee ; +// - ANTI-INVENTION : une piste qui contient un nombre absent du constat et de +// son contexte est ecartee (le modele ne produit aucun chiffre, §79). // ===================================================== const resp = $input.first().json; const context = $('BUILD_PROMPT').first().json.context; - function fail(status, detail) { - return [{ - json: { - status, - detail: (detail || '').toString().slice(0, 200), - advice: null, - modelUsed: (resp?.model || 'aucun').toString().slice(0, 60), - context, - } - }]; + return [{ json: { status, detail: (detail || '').toString().slice(0, 200), pistes: null, rejetees: 0, + modelUsed: (resp?.model || 'aucun').toString().slice(0, 60), context } }]; } -// 1. Réponse LLM présente ? (les deux backends ont échoué => item d'erreur n8n) const content = resp?.choices?.[0]?.message?.content; if (typeof content !== 'string' || !content.trim()) { return fail('llm_offline', resp?.error?.message || resp?.error || 'aucune réponse LLM'); } - -// 2. Nettoyage : balises de raisonnement (...) et fences markdown -let text = content - .replace(/[\s\S]*?<\/think>/gi, '') - .replace(/```(?:json)?/gi, '') - .trim(); - -// 3. Extraction du premier objet JSON équilibré +const text = content.replace(/[\s\S]*?<\/think>/gi, '').replace(/```(?:json)?/gi, '').trim(); const start = text.indexOf('{'); if (start === -1) return fail('invalid', 'aucun JSON dans la réponse'); let depth = 0, end = -1; @@ -41,47 +28,26 @@ for (let i = start; i < text.length; i++) { else if (text[i] === '}') { depth--; if (depth === 0) { end = i; break; } } } if (end === -1) return fail('invalid', 'JSON non terminé'); - let obj; -try { - obj = JSON.parse(text.slice(start, end + 1)); -} catch (e) { - return fail('invalid', `JSON invalide: ${e.message}`); +try { obj = JSON.parse(text.slice(start, end + 1)); } catch (e) { return fail('invalid', `JSON invalide: ${e.message}`); } +if (!Array.isArray(obj.pistes)) return fail('invalid', 'champ pistes absent'); + +// Nombres d'un texte, normalises (virgule decimale -> point, sans zeros inutiles). +const nombres = (s) => (String(s).match(/\d+(?:[.,]\d+)?/g) || []).map((x) => String(Number(x.replace(',', '.')))); + +const parCode = new Map(context.constats.map((c) => [c.code, c])); +const pistes = []; +let rejetees = 0; +for (const p of obj.pistes) { + const code = (p?.code ?? '').toString().trim(); + const piste = (p?.piste ?? '').toString().trim(); + const c = parCode.get(code); + if (!c || !piste) { rejetees++; continue; } + const permis = new Set(nombres(`${c.texte} ${c.contexte}`)); + if (nombres(piste).some((n) => !permis.has(n))) { rejetees++; continue; } + if (pistes.some((x) => x.code === code)) { rejetees++; continue; } + pistes.push({ code, piste: piste.slice(0, 280) }); } -// 4. Validation stricte du schéma (enums + bornes) -// ── OBSERVATION (12/09/2026) ─────────────────────────────────────────── -// La mission principale du conseiller n est plus de choisir une strategie -// parmi cinq, mais de REPERER CE QUI CLOCHE. Les pannes de ce systeme sont -// des contradictions entre automates — chacun localement correct — et elles -// ne se voient que dans la suite des evenements. Le 12/09 le contacteur -// reseau a bascule toutes les 4 minutes pendant dix minutes : aucun automate -// ne l a vu, un humain oui. -const GRAVITES = ['aucune', 'faible', 'moyenne', 'haute']; -const constat = (obj.constat ?? '').toString().trim(); -if (!constat) return fail('invalid', 'constat vide'); -const gravite = (obj.gravite ?? '').toString().trim().toLowerCase(); -if (!GRAVITES.includes(gravite)) return fail('invalid', `gravite hors enum: ${obj.gravite}`); -const ceQuiCloche = (obj.ce_qui_cloche ?? '').toString().trim(); -const piste = (obj.piste ?? '').toString().trim(); - -const confidence = Number(obj.confidence); -if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) { - return fail('invalid', `confidence hors bornes: ${obj.confidence}`); -} - -return [{ - json: { - status: 'ok', - detail: '', - advice: { - constat: constat.slice(0, 200), - gravite, - ceQuiCloche: ceQuiCloche.slice(0, 200), - piste: piste.slice(0, 200), - confidence: Math.round(confidence * 100) / 100, - }, - modelUsed: (resp.model || 'inconnu').toString().slice(0, 60), - context, - } -}]; +return [{ json: { status: 'ok', detail: '', pistes, rejetees, + modelUsed: (resp.model || 'inconnu').toString().slice(0, 60), context } }]; diff --git a/src/energy-balance/BALANCE.js b/src/energy-balance/BALANCE.js index f51763c..e413b88 100644 --- a/src/energy-balance/BALANCE.js +++ b/src/energy-balance/BALANCE.js @@ -458,7 +458,59 @@ const hcFloorPrudentReachPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs) // --------------------------------------------------------------------- const hcMiddayWindow = hcReachActive && hcNightReachActive && nextHcStartMs === nextNightHcStartMs; const hcMaxCharge = hcMiddayWindow && ecsNuitAujourdhui; -const hcFloorTypicalPct = hcMaxCharge ? 100 : hcFloorReachPct; +// --------------------------------------------------------------------- +// LA NUIT PREPARE AUSSI LE SOIR QUAND LA JOURNEE S'ANNONCE FAIBLE (08/10/2026) +// +// Nuit du 07-08/10 : session engagee, mais la cible « tenir jusqu'a midi » +// valait 30,7 % alors que le meme bilan annoncait 99,3 % requis pour la +// traversee de 18 h. Les heures creuses de la nuit coutent le meme prix que +// celles de midi, et midi est borne par la maison (cuisine, voiture, BOOST). +// Pendant la fenetre de NUIT : plus petit SOC de fin de fenetre qui, avec le +// profil habituel (production ecretee a 100 %), amene la batterie au SOC requis +// au debut de la traversee du soir. Si c'est hors d'atteinte, celui qui en +// approche le plus SANS gaspiller (au-dela, le solaire du jour serait ecrete). +// Journee suffisante -> rien ne change (le plus petit SOC est le plancher +// « tenir jusqu'a midi »). +// --------------------------------------------------------------------- +const hcNightWindow = hcReachActive && !hcMiddayWindow; +let nightEvening = { active: false }; +if (hcNightWindow) { + const ndEve = nextDeficitWindow(slotsP50); + if (ndEve.found && Number.isFinite(num(ndEve.startMs, NaN)) && ndEve.startMs > hcWindowEndMs) { + const socAtEvening = (s0) => { + let soc = s0; + for (const sl of slotsP50) { + const debut = num(sl.startMs, NaN); + if (!Number.isFinite(debut)) continue; + const a = Math.max(debut, hcWindowEndMs); + const b = Math.min(debut + 3600000, ndEve.startMs); + if (b <= a) continue; + const net = sl.netKwh * (b - a) / 3600000; + soc = net >= 0 ? Math.min(100, socAfterEnergy(soc, net)) + : Math.max(socFloorPct, socForEnergy(Math.max(0, energyBetweenSoc(0, soc) + net), 0)); + } + return soc; + }; + const requis = ndEve.requiredSocAtStartPct; + const base = Math.max(socFloorPct, hcFloorReachPct); + const objectif = Math.min(requis, socAtEvening(100)) - 0.05; + let floor = base; + if (socAtEvening(base) < objectif) { + let lo = base, hi = 100; + for (let k = 0; k < 30; k++) { const m = (lo + hi) / 2; if (socAtEvening(m) >= objectif) hi = m; else lo = m; } + floor = hi; + } + nightEvening = { + active: floor > base + 0.05, + floorPct: round1(floor), + eveningRequiredPct: round1(requis), + eveningProjectedPct: round1(socAtEvening(base)), + eveningStartHour: ndEve.startHour, + }; + } +} +const hcFloorTypicalPct = hcMaxCharge ? 100 + : (nightEvening.active ? Math.max(hcFloorReachPct, nightEvening.floorPct) : hcFloorReachPct); const hcFloorPrudentPct = hcMaxCharge ? 100 : hcFloorPrudentReachPct; const typical = coverage(slotsP50, hcFloorTypicalPct); const prudent = coverage(slotsP80, hcFloorPrudentPct); @@ -917,6 +969,8 @@ return [{ // Jour faible a midi : cible 100 % (charge maximale) ; floorReachPct garde // le plancher « tenir jusqu'a 01:24 » calcule, pour comparaison. maxCharge: hcMaxCharge, + // Nuit d'un jour annonce faible : SOC de fin de nuit pour arriver au soir. + nightEvening, floorReachPct: round1(Math.min(100, hcFloorReachPct)), floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)), nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null, diff --git a/src/energy-balance/BUILD_HA_CALLS.js b/src/energy-balance/BUILD_HA_CALLS.js index f893493..f32b59b 100644 --- a/src/energy-balance/BUILD_HA_CALLS.js +++ b/src/energy-balance/BUILD_HA_CALLS.js @@ -160,7 +160,10 @@ const reason = balance.crossing.found // Fenetre HC (28/09) : dire quand c'est « tenir jusqu'a la prochaine HC » qui // fixe l'exigence, sinon la raison affiche un croisement qui ne l'explique pas. const hc = balance.hcReach || {}; -const hcText = hc.active && hc.maxCharge +const ne = hc.nightEvening || {}; +const hcText = hc.active && ne.active + ? ` Journée faible annoncée : la nuit charge à ${ne.floorPct}% (batterie prévue à ${ne.eveningProjectedPct}% à ${ne.eveningStartHour} h pour ${ne.eveningRequiredPct}% requis ; tenir jusqu'à midi demandait ${hc.floorReachPct}%).` + : hc.active && hc.maxCharge ? ` Jour faible : charge maximale en HC de midi (tenir jusqu'à ${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })} demandait ${hc.floorReachPct}%).` : hc.active && hc.raisesRequirement ? ` Tenir jusqu'à la prochaine HC (${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })}) → ${hc.floorPct}% en fin de fenêtre.` diff --git a/src/nodes/BATTERY_CLOUD_LOCKS.js b/src/nodes/BATTERY_CLOUD_LOCKS.js index 7273aad..fedaa0e 100644 --- a/src/nodes/BATTERY_CLOUD_LOCKS.js +++ b/src/nodes/BATTERY_CLOUD_LOCKS.js @@ -1150,6 +1150,7 @@ return [{ nightChargeMode: nightPrev.nightChargeMode || 'sans_edf', nightCarWaitsNoGrid: !!nightPrev.nightCarWaitsNoGrid, nightBatteryGridOnly: !!nightPrev.nightBatteryGridOnly, + nightCarRidesGridSession: !!nightPrev.nightCarRidesGridSession, maxBatteryDischargePoints, gridTakeoverTriggerPct, diff --git a/src/nodes/HA_SERVICE_CALLS_OUTPUT.js b/src/nodes/HA_SERVICE_CALLS_OUTPUT.js index 17449db..06ab0fe 100644 --- a/src/nodes/HA_SERVICE_CALLS_OUTPUT.js +++ b/src/nodes/HA_SERVICE_CALLS_OUTPUT.js @@ -304,9 +304,12 @@ const reason = [...decision.reason]; if (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) { const f = (x) => String(Math.round(x * 100) / 100).replace('.', ','); reason.push(`Budget batterie nuit ${f(night.nightBudgetDeliveredKwh ?? 0)}/${f(night.nightBudgetKwh)} kWh` + - (night.nightBatteryGridOnly ? ' — sans EDF : EDF ferme pour la batterie seule, la voiture attend le soleil' + (night.nightCarRidesGridSession ? ' — sans EDF, mais EDF est ferme pour la batterie en heures creuses : la voiture en profite' + : night.nightBatteryGridOnly ? ' — sans EDF : EDF ferme pour la batterie seule, la voiture attend le soleil' : night.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil' : night.nightBudgetReached ? ' — atteint, EDF finit la charge' : '')); +} else if (night.nightCarRidesGridSession) { + reason.push('Sans EDF, mais EDF est ferme pour la batterie en heures creuses : la voiture en profite'); } else if (night.nightCarWaitsNoGrid) { reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil'); } diff --git a/src/nodes/WINDOWS_SOURCEV2.js b/src/nodes/WINDOWS_SOURCEV2.js index 821a5bf..7eb1825 100644 --- a/src/nodes/WINDOWS_SOURCEV2.js +++ b/src/nodes/WINDOWS_SOURCEV2.js @@ -866,6 +866,17 @@ const nightGridTakeoverNeeded = ) ); +// « Sans EDF » MAIS EDF DEJA FERME POUR LA BATTERIE EN HC (08/10/2026, +// proprietaire) : la voiture en profite. Nuit du 07-08/10 : session engagee des +// 01:24 (jour faible), toute la maison sur le reseau en heures creuses, et la +// VW branchee restait bloquee par « sans EDF ». Revient sur l'option A du 04/10. +// « Sans EDF » garde son sens quand EDF n'a aucune autre raison d'etre ferme : +// la voiture ne fait jamais fermer le contacteur a elle seule. +const nightCarRidesGridSession = + nightSessionActive && + nightCarWaitsNoGrid && + nightGridTakeoverNeeded; + const nightPreferredSourceMode = !nightSessionActive ? 'blocked' @@ -878,7 +889,7 @@ const nightPreferredSourceMode = // laisse la JEEP prendre 4,7 kWh au reseau jusqu'au plein. La protection // du materiel reste entiere : EDF se ferme quand meme (takeover), seule la // voiture attend le soleil. - : nightCarWaitsNoGrid + : nightCarWaitsNoGrid && !nightCarRidesGridSession ? 'blocked' : batteryAssistAllowed ? 'battery_assist_night' @@ -895,10 +906,14 @@ const nightPreferredSourceMode = // reseau. Sans ce drapeau, la fermeture d'EDF et la consigne de recharge, qui // suivent la source de la VOITURE, seraient perdues avec elle (golden // budget-sans-edf-plancher-dur-edf-protege, depart-matin-…-sous-la-cible). +// Depuis le 08/10 la voiture profite de la session : ce drapeau ne vaut plus +// que si elle ne peut pas en profiter (garde de securite conservee, vide en +// pratique). const nightBatteryGridOnly = nightSessionActive && nightCarWaitsNoGrid && - nightGridTakeoverNeeded; + nightGridTakeoverNeeded && + !nightCarRidesGridSession; const batteryRecoveryAllowedNow = nightSessionActive && @@ -1291,6 +1306,7 @@ return [{ nightChargeMode: params.nightChargeMode, nightCarWaitsNoGrid, nightBatteryGridOnly, + nightCarRidesGridSession, nightGridSessionCommitted, middayVehicleGridNeeded, nightGridSessionUncovered, diff --git a/tests/advisor/run.mjs b/tests/advisor/run.mjs index 2a2257e..8d24c71 100644 --- a/tests/advisor/run.mjs +++ b/tests/advisor/run.mjs @@ -1,153 +1,94 @@ #!/usr/bin/env node -// Tests de l'EVCS Advisor Shadow V1 : exécute le VRAI code de BUILD_PROMPT et -// VALIDATE_PARSE (sandbox vm) contre des réponses LLM simulées. -// Vérifie aussi la GARANTIE MODE OMBRE : BUILD_HA_CALLS ne touche que advisor_*/logbook. -// Intégré au push (tools/n8n-push.mjs) pour le workflow advisor. - +// Tests de l'EVCS Advisor en RELECTEUR de la journee (08/10/2026) : execute le +// VRAI code de BUILD_PROMPT, VALIDATE_PARSE et BUILD_HA_CALLS (sandbox vm) +// contre des reponses LLM simulees. Verifie aussi la GARANTIE : BUILD_HA_CALLS +// ne touche que les entites advisor et le logbook. +// Integre au push (tools/n8n-push.mjs) pour le workflow advisor. import fs from 'fs'; import vm from 'vm'; import path from 'path'; import { fileURLToPath } from 'url'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ROOT = path.resolve(__dirname, '../..'); -const src = f => fs.readFileSync(path.join(ROOT, 'src/advisor', f), 'utf8'); - +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const src = (f) => fs.readFileSync(path.join(ROOT, 'src/advisor', f), 'utf8'); const BUILD_PROMPT = src('BUILD_PROMPT.js'); const VALIDATE_PARSE = src('VALIDATE_PARSE.js'); const BUILD_HA_CALLS = src('BUILD_HA_CALLS.js'); -function run(code, inputJson, nodeOutputs = {}) { - const ctx = { - $input: { first: () => ({ json: inputJson }) }, - $: name => ({ first: () => ({ json: nodeOutputs[name] }) }), - console, Date, Intl, Math, Number, Object, String, JSON, Array, Boolean, - }; +// Horloge figee : 08/10/2026 07:35 a Paris. +const FIXE = Date.parse('2026-10-08T07:35:00+02:00'); +class DateFixe extends Date { constructor(...a) { super(...(a.length ? a : [FIXE])); } static now() { return FIXE; } } + +function run(code, inputJson, nodeOutputs = {}, D = DateFixe) { + const ctx = { $input: { first: () => ({ json: inputJson }) }, $: (name) => ({ first: () => ({ json: nodeOutputs[name] }) }), + console, Date: D, Intl, Math, Number, Object, String, JSON, Array, Boolean, Set, Map }; vm.createContext(ctx); return vm.runInContext(`(function(){\n${code}\n})()`, ctx); } -// Snapshot simulé (soir d'été, VW branchée, SOC 80) -const snapshot = { - data: JSON.stringify({ - advisor_enabled: 'on', advisor_agree_count: '10', advisor_diverge_count: '2', - advisor_prev_strategy: 'auto_solar_plus', - soc_percent: '80', battery_current_a: '-5', pv_power_w: '500', pv_power_10m_avg_w: '600', - house_ac_power_w: '900', grid_state: 'off', acin1_available: '0', - evcs_vehicle_connected: 'on', evcs_charging: 'off', evcs_current_a: '0', - evcs_surplus_state: 'idle_wait_conditions', evcs_source_mode: 'Solaire', - user_strategy: 'auto_solar_plus', allow_grid_hc_night: 'on', allow_grid_hc_midday: 'on', - night_target_soc_actual: '35', current_max_auto_a: '22', plafond_edf_on_a: '20', - departure_time: '08:50:00', departure_priority_enabled: 'on', required_energy_kwh: '10.5', - today_remaining_kwh: '1.2', tomorrow_kwh: '45', - }), -}; +// Constats reels du 07-08/10 (sortie de tools/revue-jour.mjs sur la base). +const CONSTATS = [ + { code: 'batterie_sous_objectif_midi', gravite: 'a_examiner', texte: 'Fin des heures creuses de midi (14:24) : batterie a 35 % pour 43,6 % vises, il manquait 2,6 kWh.', contexte: 'Maintien du courant' }, + { code: 'edf_heures_pleines', gravite: 'a_examiner', texte: 'EDF ferme en heures pleines de 18:06 a 01:24 (7 h 18), 8,3 kWh achetes, batterie a 21 % a la fermeture ; ferme par Victron - Grid ON si SoC < 22%.', contexte: 'Conditions de démarrage non réunies' }, + { code: 'voiture_bloquee_session_hc', gravite: 'a_examiner', texte: "Voiture branchee mais bloquee de 01:24 a 05:14 (3 h 50) alors qu'EDF etait ferme en heures creuses (etat night_no_grid_hc_lock).", contexte: 'Nuit sans autorisation HC réseau | Nuit EVCS: target=38.8% (BILAN)' }, + { code: 'charge_max_midi', gravite: 'info', texte: 'info', contexte: '' }, +]; +const snap = (o = {}) => ({ data: JSON.stringify({ advisor_enabled: 'on', revue_jour_date: '2026-10-08', + revue_jour_constats: CONSTATS, advisor_revue_date: '2026-10-07', ...o }) }); let failures = 0; -function check(label, cond, detail = '') { - if (cond) console.log(`✅ ${label}`); - else { failures++; console.log(`❌ ${label} ${detail}`); } -} +const check = (label, cond, detail = '') => { if (cond) console.log(`✅ ${label}`); else { failures++; console.log(`❌ ${label} ${detail}`); } }; // ---------- BUILD_PROMPT ---------- -const promptOut = run(BUILD_PROMPT, snapshot)[0].json; -check('BUILD_PROMPT: enabled=true', promptOut.enabled === true); -check('BUILD_PROMPT: 2 messages system+user', promptOut.messages?.length === 2 && promptOut.messages[0].role === 'system'); -check('BUILD_PROMPT: contexte transporté', promptOut.context?.soc === 80 && promptOut.context?.userStrategy === 'auto_solar_plus'); -// La borne de 6000 datait du modele local d aout (contexte etroit). Le modele -// charge aujourd hui annonce 98 304 tokens ; la vraie contrainte est le cout -// de latence, pas le contexte. On garde une borne pour eviter la derive du -// briefing, elargie a la mission d observation et a l historique des evenements. -check('BUILD_PROMPT: prompt < 9000 chars (garde-fou contre la derive)', (promptOut.messages[0].content.length + promptOut.messages[1].content.length) < 9000); +const p = run(BUILD_PROMPT, snap())[0].json; +check('PROMPT: actif quand la revue du jour a des points et pas encore de reponse', p.enabled === true); +check('PROMPT: seuls les constats a examiner sont transmis (pas les infos)', p.context.constats.length === 3 && !p.context.constats.some((c) => c.code === 'charge_max_midi')); +check('PROMPT: chaque constat et son contexte sont dans le message', CONSTATS.slice(0, 3).every((c) => p.messages[1].content.includes(c.texte))); +check('PROMPT: < 6000 caracteres', p.messages[0].content.length + p.messages[1].content.length < 6000); +check('PROMPT: kill-switch OFF -> inactif', run(BUILD_PROMPT, snap({ advisor_enabled: 'off' }))[0].json.enabled === false); +check('PROMPT: deja repondu aujourd hui -> inactif', run(BUILD_PROMPT, snap({ advisor_revue_date: '2026-10-08' }))[0].json.enabled === false); +check('PROMPT: revue d hier -> inactif', run(BUILD_PROMPT, snap({ revue_jour_date: '2026-10-07' }))[0].json.enabled === false); +check('PROMPT: rien a examiner -> inactif (pas d appel au modele)', run(BUILD_PROMPT, snap({ revue_jour_constats: [CONSTATS[3]] }))[0].json.enabled === false); +const D11 = class extends Date { constructor(...a) { super(...(a.length ? a : [Date.parse('2026-10-08T11:00:00+02:00')])); } }; +check('PROMPT: pas de relance apres 10 h', run(BUILD_PROMPT, snap(), {}, D11)[0].json.enabled === false); -const killSwitchOut = run(BUILD_PROMPT, { data: JSON.stringify({ ...JSON.parse(snapshot.data), advisor_enabled: 'off' }) })[0].json; -check('BUILD_PROMPT: kill-switch OFF → enabled=false', killSwitchOut.enabled === false); +// ---------- VALIDATE_PARSE ---------- +const ctxNode = { BUILD_PROMPT: p }; +const rep = (obj) => ({ model: 'qwen3-coder', choices: [{ message: { content: typeof obj === 'string' ? obj : JSON.stringify(obj) } }] }); +const v = (obj) => run(VALIDATE_PARSE, rep(obj), ctxNode)[0].json; -// ---------- VALIDATE_PARSE : 7 réponses LLM simulées ---------- -const ctxNode = { BUILD_PROMPT: promptOut }; -const validAdvice = '{"constat":"Contacteur reseau stable, batterie en charge.","gravite":"aucune","ce_qui_cloche":"","piste":"","confidence":0.85}'; +const bon = { pistes: [ + { code: 'voiture_bloquee_session_hc', piste: 'Le mode « sans EDF » bloquait la voiture alors que la session HC etait engagee pour la batterie : verifier la regle qui garde la voiture bloquee.' }, + { code: 'edf_heures_pleines', piste: "L'automatisation « Grid ON si SoC < 22% » a ferme EDF a 21 % : la recharge de midi n'a pas suffi." }, +] }; +const o1 = v(bon); +check('VALIDATE: reponse valide -> 2 pistes', o1.status === 'ok' && o1.pistes.length === 2 && o1.rejetees === 0, JSON.stringify(o1)); +check('VALIDATE: et fences tolerees', v('...\n```json\n' + JSON.stringify(bon) + '\n```').pistes.length === 2); +const o2 = v({ pistes: [{ code: 'edf_heures_pleines', piste: 'Il aurait fallu 60 % a 14:24 pour eviter les 8,3 kWh.' }] }); +check('VALIDATE: ANTI-INVENTION — chiffre absent du constat (60) -> piste ecartee', o2.pistes.length === 0 && o2.rejetees === 1, JSON.stringify(o2)); +const o3 = v({ pistes: [{ code: 'edf_heures_pleines', piste: 'Fermeture a 21 %, 8,3 kWh : revoir la recharge de midi.' }] }); +check('VALIDATE: chiffres du constat (21, 8,3) acceptes', o3.pistes.length === 1, JSON.stringify(o3)); +check('VALIDATE: code inconnu -> ecarte', v({ pistes: [{ code: 'invente', piste: 'x' }] }).rejetees === 1); +check('VALIDATE: un constat info n est pas relu', v({ pistes: [{ code: 'charge_max_midi', piste: 'x' }] }).pistes.length === 0); +check('VALIDATE: doublon de code -> un seul garde', v({ pistes: [bon.pistes[0], bon.pistes[0]] }).pistes.length === 1); +check('VALIDATE: texte sans JSON -> invalid', v('Je pense que tout va bien.').status === 'invalid'); +check('VALIDATE: pistes absent -> invalid', v({ constat: 'x' }).status === 'invalid'); +check('VALIDATE: LLM hors ligne -> llm_offline', run(VALIDATE_PARSE, { error: 'ECONNREFUSED' }, ctxNode)[0].json.status === 'llm_offline'); -const cases = [ - { - label: 'réponse valide → ok', - resp: { model: 'qwen3.6-35b', choices: [{ message: { content: validAdvice } }] }, - expect: o => o.status === 'ok' && o.advice.gravite === 'aucune' && o.advice.constat.includes('stable'), - }, - { - label: 'balises + fences markdown → ok', - resp: { model: 'qwen3-vl', choices: [{ message: { content: `je réfléchis...\nblabla\n\`\`\`json\n${validAdvice}\n\`\`\`` } }] }, - expect: o => o.status === 'ok' && o.advice.confidence === 0.85, - }, - { - label: 'confidence hors bornes → invalid', - resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('"confidence":0.85', '"confidence":7') } }] }, - expect: o => o.status === 'invalid' && o.advice === null, - }, - { - label: 'texte sans JSON → invalid', - resp: { model: 'x', choices: [{ message: { content: 'Je recommande le mode solaire.' } }] }, - expect: o => o.status === 'invalid', - }, - { - label: 'les deux LLM en échec → llm_offline', - resp: { error: 'connect ECONNREFUSED 10.10.10.8:8000' }, - expect: o => o.status === 'llm_offline' && o.advice === null, - }, -]; +// ---------- BUILD_HA_CALLS : garantie de perimetre ---------- +const calls = run(BUILD_HA_CALLS, o1, ctxNode).map((i) => i.json); +const permis = (c) => c.domain === 'logbook' || c.state_entity === 'sensor.evcs_advisor_revue' + || /^input_text\.evcs_advisor_/.test(c.data?.entity_id || ''); +check('PERIMETRE: aucune ecriture hors entites advisor / logbook', calls.every(permis), JSON.stringify(calls.filter((c) => !permis(c)))); +const capteur = calls.find((c) => c.state_entity === 'sensor.evcs_advisor_revue'); +check('PERIMETRE: capteur de la revue publie, date du jour, pistes en attribut', !!capteur && capteur.data.state === '2026-10-08' && capteur.data.attributes.pistes.length === 2); +const hl = run(BUILD_HA_CALLS, run(VALIDATE_PARSE, { error: 'x' }, ctxNode)[0].json, ctxNode).map((i) => i.json); +check('PERIMETRE: hors ligne -> statut ecrit, aucun capteur (relance au cycle suivant)', !hl.some((c) => c.state_entity) && hl.some((c) => String(c.data?.value || '').startsWith('llm_offline'))); -for (const c of cases) { - const out = run(VALIDATE_PARSE, c.resp, ctxNode)[0].json; - check(`VALIDATE: ${c.label}`, c.expect(out), `(obtenu: ${JSON.stringify(out).slice(0, 120)})`); -} - -// ---------- BUILD_HA_CALLS : garantie mode ombre ---------- -const okValidated = run(VALIDATE_PARSE, cases[0].resp, ctxNode)[0].json; -const calls = run(BUILD_HA_CALLS, okValidated, ctxNode).map(i => i.json); -const ALLOWED = /^(input_text|input_number)\.evcs_advisor_/; -const violations = calls.filter(c => !(c.domain === 'logbook' || ALLOWED.test(c.data.entity_id || ''))); -check('OMBRE: aucune écriture hors advisor_*/logbook', violations.length === 0, JSON.stringify(violations)); -check('OMBRE: écrit constat + reason', calls.some(c => (c.data.entity_id || '').includes('advisor_constat')) && calls.some(c => (c.data.entity_id || '').includes('advisor_reason'))); - -// ── LE COMPTEUR MESURE LE VOLUME DE SIGNALEMENTS (12/09/2026) ───────── -// Un observateur qui signale en permanence ne sert a rien. On ne peut pas -// mesurer automatiquement s il a RAISON ; on peut mesurer s il crie au loup. -function compteur(json) { - const v = run(VALIDATE_PARSE, { model: 'x', choices: [{ message: { content: json } }] }, ctxNode)[0].json; - const out = run(BUILD_HA_CALLS, v, ctxNode).map(i => i.json); - const id = k => out.some(c => (c.data.entity_id || '').includes(k)); - return { calme: id('agree_count'), signale: id('diverge_count') }; -} -const rienASignaler = compteur(validAdvice); -check('COMPTEUR: gravite aucune → compte comme calme', rienASignaler.calme && !rienASignaler.signale); -const signalement = compteur(validAdvice.replace('"gravite":"aucune"', '"gravite":"haute"')); -check('COMPTEUR: gravite haute → compte comme signalement', signalement.signale && !signalement.calme); - -// ── OBSERVATION : le constat et la gravite sont obligatoires ─────────── -function valider(json) { - return run(VALIDATE_PARSE, { model: 'x', choices: [{ message: { content: json } }] }, ctxNode)[0].json; -} -const osc = valider(validAdvice - .replace('"constat":"Contacteur reseau stable, batterie en charge."', '"constat":"Le contacteur reseau a bascule 4 fois en 10 minutes."') - .replace('"gravite":"aucune"', '"gravite":"haute"') - .replace('"ce_qui_cloche":""', '"ce_qui_cloche":"deux ecrivains avec des seuils differents sur le meme actionneur"')); -check('OBSERVATION: constat + gravite haute acceptes', - osc.status === 'ok' && osc.advice.gravite === 'haute' && osc.advice.constat.includes('bascule')); - -check('OBSERVATION: constat vide → REJET', - valider(validAdvice.replace('"constat":"Contacteur reseau stable, batterie en charge."', '"constat":""')).status === 'invalid'); -check('OBSERVATION: gravite hors enum → REJET', - valider(validAdvice.replace('"gravite":"aucune"', '"gravite":"catastrophique"')).status === 'invalid'); - -const oscCalls = run(BUILD_HA_CALLS, osc, ctxNode).map(i => i.json); -check('OBSERVATION: constat, gravite et diagnostic publies', - ['constat', 'gravite', 'ce_qui_cloche'].every(k => oscCalls.some(c => (c.data.entity_id || '').includes(`advisor_${k}`)))); -check('OMBRE: l observation ne sort pas du perimetre advisor_*', - oscCalls.every(c => c.domain === 'logbook' || /^(input_text|input_number)\.evcs_advisor_/.test(c.data.entity_id || ''))); - -const casHorsLigne = cases.find(c => c.label.includes('llm_offline')); -const offlineValidated = run(VALIDATE_PARSE, casHorsLigne.resp, ctxNode)[0].json; -const offlineCalls = run(BUILD_HA_CALLS, offlineValidated, ctxNode).map(i => i.json); -check('OMBRE: llm_offline → statut écrit, pas de recommandation', offlineCalls.every(c => !(c.data.entity_id || '').includes('advisor_constat')) && offlineCalls.some(c => (c.data.value || '').toString().startsWith('llm_offline'))); +// ---------- Execute node : routage states / services ---------- +const wf = JSON.parse(fs.readFileSync(path.join(ROOT, 'workflows/evcs-advisor-shadow-v1.json'), 'utf8')); +const url = wf.nodes.find((n) => n.name === 'Execute HA Service Advisor').parameters.url; +check('WORKFLOW: un item state_entity part vers /api/states', url.includes("$json.state_entity ? 'http://10.10.10.17:8123/api/states/'")); console.log(failures ? `\n${failures} ÉCHEC(S) — NE PAS POUSSER` : '\nADVISOR OK — tous les tests passent'); process.exit(failures ? 1 : 0); diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs index cb742ff..c98f9d5 100644 --- a/tests/energy-balance/run.mjs +++ b/tests/energy-balance/run.mjs @@ -107,6 +107,7 @@ const SUMMARIZE = { hcFloorPct: o.hcReach ? o.hcReach.floorPct : null, hcFloorReachPct: o.hcReach ? o.hcReach.floorReachPct : null, hcMaxCharge: o.hcReach ? o.hcReach.maxCharge : null, + nightEveningActive: o.hcReach && o.hcReach.nightEvening ? o.hcReach.nightEvening.active : null, hcNightFloorPrudentPct: o.hcReach ? o.hcReach.nightFloorPrudentPct : null, // Nuit prevue par la soiree (28/09). nightModelActive: o.nightModel ? o.nightModel.active : false, diff --git a/tests/energy-balance/scenarios-balance.mjs b/tests/energy-balance/scenarios-balance.mjs index 2fd6173..1c044d1 100644 --- a/tests/energy-balance/scenarios-balance.mjs +++ b/tests/energy-balance/scenarios-balance.mjs @@ -417,8 +417,12 @@ export const balanceScenarios = [ id: 'bilan-hc-nuit-2709-prevision-forecast-solar', label: 'Nuit 26-27/09, prevision 25,1 kWh : P12 plancher 48,4 % (nuit suivante), reseau vise midi 25,3 %', snapshot: snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709), + // 08/10 : la nuit prepare aussi le soir. Profil habituel : batterie prevue a + // 59,6 % a 19 h pour 64,7 % requis -> +5,1 points (courbe plate, pas + // d'ecretage) : 25,3 + 5,0 = 30,3 % en fin de nuit. Le 27/09 reel (22 kWh) + // a vide la batterie a 01:00 : le sens est le bon. expect: { usable: true, requiredSocPct: 40.2, prudentRequiredSocPct: 48.4, gridNeeded: false, - hcReachActive: true, hcFloorPct: 25.3, hcNightFloorPrudentPct: 48.4 }, + hcReachActive: true, nightEveningActive: true, hcFloorPct: 30.3, hcNightFloorPrudentPct: 48.4 }, }, { // Meme nuit, journee annoncee sombre (18 kWh, prevision VRM du 27) : le @@ -428,7 +432,7 @@ export const balanceScenarios = [ id: 'bilan-hc-nuit-2709-journee-sombre-bloque-p12', label: 'Nuit 26-27/09, journee sombre (18 kWh) : P12 plancher 72,1 % > SOC 72 → voiture sur le reseau HC', snapshot: snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709, SOMBRE), - expect: { usable: true, requiredSocPct: 41.4, prudentRequiredSocPct: 72.1, gridNeeded: false, + expect: { usable: true, requiredSocPct: 54, prudentRequiredSocPct: 72.1, gridNeeded: false, hcReachActive: true, hcNightFloorPrudentPct: 72.1 }, }, { @@ -598,7 +602,16 @@ export const balanceScenarios = [ label: 'Fenetre HC de NUIT, decision jour faible du jour : la nuit garde « tenir jusqu a midi »', snapshot: Object.assign(snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709), { ecs_nuit_jour: '2026-09-27|on|x' }), - expect: { usable: true, hcReachActive: true, hcMaxCharge: false, hcFloorPct: 25.3 }, + // Pas de charge maximale la nuit ; la nuit prepare le soir (30,3 %, voir le + // scenario du 27/09 ci-dessus). + expect: { usable: true, hcReachActive: true, hcMaxCharge: false, nightEveningActive: true, hcFloorPct: 30.3 }, + }, + { + node: 'BALANCE', + id: 'bilan-hc-nuit-journee-ensoleillee-rien-ne-change', + label: 'Nuit, journee annoncee forte (prevision x2) : le soir est atteint au solaire, la nuit garde « tenir jusqu a midi »', + snapshot: snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709, 2), + expect: { usable: true, hcReachActive: true, nightEveningActive: false }, }, { node: 'BALANCE', diff --git a/tests/golden/scenarios.mjs b/tests/golden/scenarios.mjs index c053498..bad584c 100644 --- a/tests/golden/scenarios.mjs +++ b/tests/golden/scenarios.mjs @@ -3232,18 +3232,18 @@ export const scenarios = [ time: '2026-09-30T04:00:00+02:00', overrides: { ...BUDGET_NUIT, balance_last_run: '30/09 03:55', soc_percent: '20', night_charge_mode: 'sans_edf', night_budget_prev: '3.7|4000', evcs_total_energy_kwh: '4003.75' }, - // 04/10 (option A du proprietaire) : EDF se ferme pour la BATTERIE, la - // voiture reste bloquee « sans EDF » (avant : grid_forced, voiture sur EDF). - expect: { sourceMode: 'blocked', desiredCharging: false, edfOnAction: true, takeoverNeeded: true }, + // 08/10 (proprietaire) : EDF se ferme pour la BATTERIE en heures creuses, la + // voiture en profite meme « sans EDF » (option A du 04/10 abandonnee). + expect: { sourceMode: 'grid_forced', edfOnAction: true, takeoverNeeded: true }, }, { id: 'budget-sans-edf-reprise-reseau-voiture-bloquee', - label: 'Nuit du 03-04/10 rejouee : budget 1,41 atteint, SOC 39 sous la cible 39,5 a 02:57 : EDF ferme pour la batterie, la JEEP ne prend PAS le reseau', + label: 'Nuit du 03-04/10 rejouee : budget 1,41 atteint, SOC 39 sous la cible 39,5 a 02:57 : EDF ferme pour la batterie, la JEEP en profite (08/10)', time: '2026-10-04T02:57:00+02:00', overrides: { ...BUDGET_NUIT, balance_last_run: '04/10 02:55', soc_percent: '39', balance_required_soc: '39.5', balance_required_soc_prudent: '40.4', night_charge_mode: 'sans_edf', night_budget_prev: '1.41|4038.8', evcs_total_energy_kwh: '4040.2' }, - expect: { sourceMode: 'blocked', desiredCharging: false, edfOnAction: true, takeoverNeeded: true }, + expect: { sourceMode: 'grid_forced', edfOnAction: true, takeoverNeeded: true }, }, { id: 'budget-bilan-perime-sans-edf-rien', @@ -3291,8 +3291,8 @@ export const scenarios = [ balance_required_soc: '28.6', balance_required_soc_prudent: '29.7', evcs_vehicle: 'JEEP', departure_time_jeep: '06:30:00', departure_day_ok: 'on', night_charge_mode: 'sans_edf', night_budget_prev: '5.93|4010', evcs_total_energy_kwh: '4015' }, - // « sans EDF » : l'urgence ferme EDF pour la batterie, la voiture reste bloquee (04/10). - expect: { sourceMode: 'blocked', desiredCharging: false, edfOnAction: true }, + // « sans EDF » : l'urgence ferme EDF pour la batterie, la voiture en profite (08/10). + expect: { sourceMode: 'grid_forced', edfOnAction: true }, }, { id: 'depart-matin-pas-un-jour-de-depart', diff --git a/tests/revue-jour/run.mjs b/tests/revue-jour/run.mjs new file mode 100644 index 0000000..ac7b1ea --- /dev/null +++ b/tests/revue-jour/run.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// Tests de la revue de la journee (tools/revue-jour.mjs, 08/10/2026). +// Donnees synthetiques calquees sur la journee du 07 au 08/10. +import { constatsRevue } from '../../tools/revue-jour.mjs'; + +const T = (s) => Date.parse(`2026-10-${s}+02:00`); +const F = [{ nom: 'nuit', debut: '01:24', fin: '07:24' }, { nom: 'midi', debut: '12:24', fin: '14:24' }]; +const base = () => ({ + debut: T('07T07:25:00'), fin: T('08T07:25:00'), fenetres: F, + contacteur: [{ ts: T('07T07:25:00'), v: 'off' }], + edfKwh: [{ ts: T('07T07:00:00'), v: 100 }], + reseauW: [{ ts: T('07T07:25:00'), v: 0 }], + decisions: [{ ts: T('07T07:25:00'), profil: 'outside_window', source_mode: 'Solaire', etat: 'idle', session_engagee: false, cible_matin_pct: 30, raison: 'r' }], + soc: [{ ts: T('07T07:25:00'), v: 40 }], + requis: [{ ts: T('07T07:25:00'), v: 30 }], +}); + +let echecs = 0; +const ok = (id, cond, info = '') => { if (cond) console.log(`✅ ${id}`); else { echecs++; console.log(`❌ ${id} ${info}`); } }; +const codes = (r) => r.constats.map((c) => c.code).join(','); + +{ const r = constatsRevue(base()); + ok('rien-a-signaler', r.constats.length === 0 && /Rien a signaler/.test(r.texte), r.texte); } + +{ // 07/10 18:06 -> 01:24, 8,3 kWh, ferme par l'automatisation SoC < 22 % + const d = base(); + d.contacteur.push({ ts: T('07T18:04:27'), v: 'on', auteur: 'Victron - Grid ON si SoC < 22%' }); + d.reseauW.push({ ts: T('07T18:06:00'), v: 1800 }, { ts: T('08T07:24:30'), v: 0 }); + d.edfKwh.push({ ts: T('07T18:05:00'), v: 100 }, { ts: T('08T01:24:00'), v: 108.3 }, { ts: T('08T07:24:00'), v: 113 }); + d.soc.push({ ts: T('07T18:00:00'), v: 21 }); + const r = constatsRevue(d); const c = r.constats.find((x) => x.code === 'edf_heures_pleines'); + ok('hp-releve-0710', !!c && c.chiffres.minutes === 438 && c.chiffres.kwh === 8.3 && /18:06 a 01:24/.test(c.texte) + && /Grid ON si SoC < 22%/.test(c.texte) && r.constats.filter((x) => x.code === 'edf_heures_pleines').length === 1, JSON.stringify(r.constats)); } + +{ const d = base(); // reseau 4 min en heures pleines : ignore + d.reseauW.push({ ts: T('07T19:00:00'), v: 900 }, { ts: T('07T19:04:00'), v: 0 }); + ok('hp-breve-ignoree', constatsRevue(d).constats.length === 0); } + +{ const d = base(); // reseau en heures creuses seulement : rien + d.reseauW.push({ ts: T('08T01:24:00'), v: 2000 }, { ts: T('08T07:24:00'), v: 0 }); + ok('hc-pas-un-constat', constatsRevue(d).constats.length === 0, codes(constatsRevue(d))); } + +{ const d = base(); // ecart de 3 min dans une fermeture : une seule plage + d.reseauW.push({ ts: T('07T19:00:00'), v: 900 }, { ts: T('07T19:30:00'), v: 0 }, { ts: T('07T19:33:00'), v: 900 }, { ts: T('07T20:00:00'), v: 0 }); + const r = constatsRevue(d); ok('hp-ecart-bref-fusionne', r.constats.length === 1 && r.constats[0].chiffres.minutes === 60, JSON.stringify(r.constats.map((x) => x.chiffres))); } + +const sessionNuit = (d, etat, source = 'Bloqué') => { + d.contacteur.push({ ts: T('08T01:23:50'), v: 'on' }); + d.decisions.push({ ts: T('08T01:24:00'), profil: 'night_hc', source_mode: source, etat, session_engagee: true, cible_matin_pct: 30.7, raison: 'Nuit sans autorisation HC réseau' }, + { ts: T('08T05:14:00'), profil: 'night_hc', source_mode: 'Réseau forcé', etat: 'charging_hold_grid', session_engagee: true, cible_matin_pct: 30.7, raison: 'x' }); + d.soc.push({ ts: T('08T07:00:00'), v: 30 }); +}; +{ const d = base(); sessionNuit(d, 'night_no_grid_hc_lock'); + const r = constatsRevue(d); const c = r.constats.find((x) => x.code === 'voiture_bloquee_session_hc'); + ok('voiture-bloquee-0810', !!c && c.chiffres.minutes === 230 && /01:24 a 05:14/.test(c.texte), JSON.stringify(r.constats)); } +{ const d = base(); sessionNuit(d, 'night_recovery_done_idle'); + ok('voiture-pleine-pas-un-constat', !codes(constatsRevue(d)).includes('voiture_bloquee')); } +{ const d = base(); sessionNuit(d, 'vehicle_not_connected'); + ok('pas-de-voiture-pas-un-constat', !codes(constatsRevue(d)).includes('voiture_bloquee')); } + +{ const d = base(); sessionNuit(d, 'night_no_grid_hc_lock'); + d.decisions[d.decisions.length - 1].cible_matin_pct = 40; // objectif 40, SOC 30 a 07:24 + const c = constatsRevue(d).constats.find((x) => x.code === 'batterie_sous_objectif_nuit'); + ok('nuit-sous-objectif', !!c && c.chiffres.manqueKwh === 3 && /il manquait 3 kWh/.test(c.texte), JSON.stringify(c)); } +{ const d = base(); sessionNuit(d, 'night_no_grid_hc_lock'); // objectif 30,7, SOC 30 : moins de 2 points + ok('nuit-objectif-tenu', !codes(constatsRevue(d)).includes('batterie_sous_objectif')); } + +const sessionMidi = (d, requis) => { + d.decisions.push({ ts: T('07T12:24:00'), profil: 'midday_hc_or_solar', source_mode: 'Réseau HC autorisé', etat: 'x', session_engagee: true, cible_matin_pct: 30, raison: 'm' }, + { ts: T('07T14:25:00'), profil: 'outside_window', source_mode: 'Solaire', etat: 'idle', session_engagee: false, cible_matin_pct: 30, raison: 'r' }); + d.soc.push({ ts: T('07T12:20:00'), v: 31 }, { ts: T('07T14:20:00'), v: 35 }); + d.requis.push({ ts: T('07T12:25:00'), v: requis }); +}; +{ const d = base(); sessionMidi(d, 43.6); + const c = constatsRevue(d).constats.find((x) => x.code === 'batterie_sous_objectif_midi'); + ok('midi-sous-objectif-0710', !!c && /35 % pour 43,6 % vises, il manquait 2,6 kWh/.test(c.texte), JSON.stringify(c)); } +{ const d = base(); sessionMidi(d, 100); + const r = constatsRevue(d); const c = r.constats.find((x) => x.code === 'charge_max_midi'); + ok('midi-charge-max-info', !!c && c.gravite === 'info' && /31 % -> 35 % \(\+1,2 kWh\)/.test(c.texte) && !codes(r).includes('sous_objectif'), JSON.stringify(r.constats)); } + +console.log(echecs ? `\n${echecs} ECHEC(S)` : '\nREVUE OK'); +process.exit(echecs ? 1 : 0); diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 7931f88..9052a3e 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -31,6 +31,7 @@ import { fileURLToPath } from 'node:url'; import pg from 'pg'; import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from './kilometrage.mjs'; import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs'; +import { donneesRevue, constatsRevue } from './revue-jour.mjs'; import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs'; import { transitionsDansSerie } from './allers-retours.mjs'; import { energieSur24h, energieDepuis, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs'; @@ -1122,6 +1123,83 @@ async function envoyerRapportDuMatin(sql, client) { return 1; } +// --------------------------------------------------------------------- +// REVUE DE LA JOURNEE (08/10/2026, tools/revue-jour.mjs) : a 07:30, les +// constats (calcules, testes) sont publies dans sensor.evcs_revue_jour ; le +// conseiller IA les relit vers 07:35 et publie ses pistes dans +// sensor.evcs_advisor_revue ; a 07:45 le courriel « revue de la journee » part +// avec les constats et, s'il a repondu (sinon a 08:15 sans lui), ses pistes — +// presentees comme un AVIS, jamais comme un fait. +const REVUE_PUBLIEE = 7 * 60 + 30; +const REVUE_COURRIEL = 7 * 60 + 45; +const REVUE_COURRIEL_SANS_AVIS = 8 * 60 + 15; +function partsParis() { + const p = Object.fromEntries(new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }).formatToParts(new Date()).filter((x) => x.type !== 'literal').map((x) => [x.type, Number(x.value)])); + return { min: p.hour * 60 + p.minute, jour: `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}` }; +} +async function dejaFait(client, genre, jour) { + const { rowCount } = await client.query(`SELECT 1 FROM evenement WHERE genre = $1 + AND ts >= $2::date AND ts < $2::date + interval '1 day' LIMIT 1`, [genre, jour]); + return rowCount > 0; +} +async function revueDeLaJournee(sql, client, st) { + if (!client) return 0; + const { min, jour } = partsParis(); + if (min < REVUE_PUBLIEE || min > 10 * 60) return 0; + const hc = (id) => (st.get(id)?.state || '').slice(0, 5); + const fenetres = [ + { nom: 'nuit', debut: hc('input_datetime.victron_evcs_hc_night_start'), fin: hc('input_datetime.victron_evcs_hc_night_stop') }, + { nom: 'midi', debut: hc('input_datetime.victron_evcs_hc_midday_start'), fin: hc('input_datetime.victron_evcs_hc_midday_stop') }, + ]; + let n = 0; + if (!(await dejaFait(client, 'revue', jour))) { + // Fin de la fenetre = fin des HC de nuit d'aujourd'hui + 1 min. + const fin = Date.parse(`${jour}T${fenetres[0].fin || '07:24'}:00${decalageParis(jour)}`) + 60000; + const r = constatsRevue(await donneesRevue(client, fenetres, fin)); + const aExaminer = r.constats.filter((c) => c.gravite === 'a_examiner').length; + await publierCapteur('sensor.evcs_revue_jour', jour, { + friendly_name: 'Revue de la journee', + a_examiner: aExaminer, + constats: r.constats.map((c) => ({ code: c.code, gravite: c.gravite, texte: c.texte, contexte: c.contexte })), + texte: r.texte, + }); + await journaliser(sql, 'revue', `Revue de la journee : ${aExaminer} point(s) a examiner`, { jour, texte: r.texte }); + n++; + } + if (min >= REVUE_COURRIEL && !(await dejaFait(client, 'revue_courriel', jour))) { + const revue = st.get('sensor.evcs_revue_jour'); + const avis = st.get('sensor.evcs_advisor_revue'); + const avisDuJour = avis && avis.state === jour ? avis.attributes : null; + if (revue && revue.state === jour && (avisDuJour || min >= REVUE_COURRIEL_SANS_AVIS)) { + const a = revue.attributes || {}; + const pistes = (avisDuJour?.pistes || []).map((p) => `- ${p.piste}`); + const texte = [ + `Revue de la journee (24 h jusqu'a la fin des heures creuses de ce matin)`, + '', + 'CONSTATS (mesures) :', + a.texte || '(aucun)', + '', + a.a_examiner > 0 + ? (pistes.length ? ['PISTES DU CONSEILLER IA (avis a verifier, pas des faits) :', ...pistes].join('\n') + : `Pistes du conseiller IA : ${avisDuJour ? 'aucune' : 'pas de reponse ce matin'}.`) + : '', + ].join('\n').trim(); + await envoyer(texte, `EVCS - revue de la journee${a.a_examiner > 0 ? ` (${a.a_examiner} point(s))` : ''}`); + await journaliser(sql, 'revue_courriel', 'Revue de la journee envoyee par courriel', { jour, avis: !!avisDuJour }); + n++; + } + } + return n; +} +function decalageParis(jour) { + const d = new Date(`${jour}T12:00:00Z`); + const h = Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false }).format(d)); + return `+${String(h - 12).padStart(2, '0')}:00`; +} + // --------------------------------------------------------------------- // Verse dans la base les relevés de cellule deposes par le poste qui porte le // cable serie. Celui-ci n'ecrit qu'un CSV dans le depot partage : il n'a donc @@ -2355,6 +2433,9 @@ async function cycle(client) { rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => { console.error('rapport de nuit :', e.message); return 0; }), + revue: DRY ? 0 : await revueDeLaJournee(sql, client, st).catch((e) => { + console.error('revue de la journee :', e.message); return 0; + }), cellules: DRY ? 0 : await ingererCellules(client).catch((e) => { console.error(`ingestion cellules en echec : ${e.message}`); return 0; diff --git a/tools/patch-20261008-advisor-relecteur.mjs b/tools/patch-20261008-advisor-relecteur.mjs new file mode 100644 index 0000000..7ebf566 --- /dev/null +++ b/tools/patch-20261008-advisor-relecteur.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node +// 08/10/2026 (proprietaire) : le conseiller devient RELECTEUR de la journee. Son +// noeud d'ecriture doit pouvoir publier un capteur avec attributs +// (sensor.evcs_advisor_revue) : un item `state_entity` part vers /api/states, +// les autres vers /api/services comme avant. Rejouable (idempotent). +import fs from 'fs'; +const F = 'workflows/evcs-advisor-shadow-v1.json'; +const wf = JSON.parse(fs.readFileSync(F, 'utf8')); +const n = wf.nodes.find((x) => x.name === 'Execute HA Service Advisor'); +const ancien = "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}"; +const nouveau = "={{ $json.state_entity ? 'http://10.10.10.17:8123/api/states/' + $json.state_entity : 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}"; +if (n.parameters.url === nouveau) { console.log('deja applique'); process.exit(0); } +if (n.parameters.url !== ancien) throw new Error('url inattendue : ' + n.parameters.url); +n.parameters.url = nouveau; +fs.writeFileSync(F, JSON.stringify(wf, null, 2) + '\n'); +console.log('patch applique'); diff --git a/tools/revue-jour.mjs b/tools/revue-jour.mjs new file mode 100644 index 0000000..976dce1 --- /dev/null +++ b/tools/revue-jour.mjs @@ -0,0 +1,218 @@ +// ===================================================================== +// REVUE DE LA JOURNEE (08/10/2026, decision du proprietaire) +// +// Chaque matin, ce que le systeme a DECIDE est confronte a ce qui s'est PASSE, +// sur les 24 h qui finissent avec la fenetre HC de nuit. Nuit du 07-08/10 : la +// HC de midi avait ete perdue, EDF s'etait ferme en heures pleines a 18:03, et +// la VW etait restee bloquee de 01:24 a 05:14 alors qu'EDF etait ferme pour la +// batterie — rien de cela n'avait ete signale par aucun automate. +// +// ⚠️ DOCTRINE (§79) : les CONSTATS sont calcules ici, par du code teste. Le +// conseiller IA ne fait que les RELIRE (pistes a examiner) ; il ne produit +// aucun chiffre et ne decide rien. Un constat ne depend jamais de lui. +// +// `constatsRevue` est pure (tests : tests/revue-jour/run.mjs) ; +// `donneesRevue` fait les lectures en base. +// ===================================================================== + +const MIN = 60000; +const KWH_PAR_POINT = 0.30; // courbe batterie mesuree le 20/09 (HANDOFF §74) +const SEUIL_HP_MIN = 5; // fermeture EDF en heures pleines retenue au-dela +const SEUIL_RESEAU_W = 100; // bruit de mesure du reseau (comme sensor.evcs_pv_diagnostic) +const SEUIL_VOITURE_MIN = 10; // voiture bloquee pendant une session HC +const SEUIL_OBJECTIF_PTS = 2; // batterie sous son objectif en fin de HC + +const ETATS_VOITURE_ABSENTE_OU_PLEINE = /not_connected|done|full|complete|charged/i; + +function minutesParis(ms) { + const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false }) + .formatToParts(new Date(ms)); + return Number(p.find((x) => x.type === 'hour').value) % 24 * 60 + Number(p.find((x) => x.type === 'minute').value); +} +export function hhmm(ms) { + return new Date(ms).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }); +} +const hm = (s) => { const [h, m] = String(s || '').split(':').map(Number); return Number.isFinite(h) && Number.isFinite(m) ? h * 60 + m : null; }; +const f1 = (x) => String(Math.round(x * 10) / 10).replace('.', ','); +const duree = (min) => (min >= 60 ? `${Math.floor(min / 60)} h ${String(Math.round(min % 60)).padStart(2, '0')}` : `${Math.round(min)} min`); + +// fenetres : [{ nom: 'nuit'|'midi', debut: 'HH:MM', fin: 'HH:MM' }] +export function enHc(ms, fenetres) { + const m = minutesParis(ms); + return fenetres.some((f) => { + const a = hm(f.debut), b = hm(f.fin); + if (a === null || b === null) return false; + return a <= b ? m >= a && m < b : m >= a || m < b; + }); +} + +// Valeur d'une serie en escalier (derniere valeur connue a l'instant t). +function aInstant(serie, t) { + let v = null; + for (const p of serie) { if (p.ts <= t) v = p.v; else break; } + return v; +} + +// Plages contigues ou `pred(t)` est vrai, echantillonnees a la minute. +function plages(debut, fin, pred) { + const out = []; + let cur = null; + for (let t = debut; t < fin; t += MIN) { + if (pred(t)) { if (!cur) cur = { debut: t, fin: t + MIN }; else cur.fin = t + MIN; } + else if (cur) { out.push(cur); cur = null; } + } + if (cur) out.push(cur); + return out; +} + +/** + * @param {object} d + * debut, fin : ms + * fenetres : fenetres HC (voir enHc) + * contacteur : [{ ts, v: 'on'|'off', auteur }] trie, avec l'etat avant `debut` en tete + * edfKwh : [{ ts, v }] compteur EDF cumule (kWh) + * reseauW : [{ ts, v }] sensor.victron_grid_power_l1 (W) + * decisions : [{ ts, profil, source_mode, etat, session_engagee, soc_pct, cible_matin_pct, raison }] + * soc : [{ ts, v }] + * requis : [{ ts, v }] input_number.evcs_balance_required_soc + */ +export function constatsRevue(d) { + const constats = []; + const contact = d.contacteur.map((c) => ({ ts: c.ts, v: c.v })); + + // 1. EDF ferme en HEURES PLEINES. Juge sur la PUISSANCE RESEAU mesuree, pas + // sur le journal du contacteur : le 07/10 l'ouverture de 07:24 n'y figurait + // pas, et le journal seul annoncait 5 h d'EDF pour 0,2 kWh reellement achete. + // Les plages separees de moins de 5 min sont fusionnees (un ecart bref de la + // mesure ne coupe pas une fermeture). + const brutes = plages(d.debut, d.fin, (t) => (aInstant(d.reseauW, t) ?? 0) > SEUIL_RESEAU_W && !enHc(t, d.fenetres)); + const fusion = []; + for (const p of brutes) { + const der = fusion[fusion.length - 1]; + if (der && p.debut - der.fin <= SEUIL_HP_MIN * MIN) der.fin = p.fin; else fusion.push({ ...p }); + } + for (const p of fusion) { + const min = (p.fin - p.debut) / MIN; + if (min < SEUIL_HP_MIN) continue; + const k0 = aInstant(d.edfKwh, p.debut), k1 = aInstant(d.edfKwh, p.fin); + const kwh = k0 !== null && k1 !== null && k1 >= k0 ? k1 - k0 : null; + const ouverture = [...d.contacteur].reverse().find((c) => c.v === 'on' && c.ts <= p.debut + 2 * MIN && c.ts >= p.debut - 30 * MIN); + const socDebut = aInstant(d.soc, p.debut); + constats.push({ + code: 'edf_heures_pleines', + gravite: 'a_examiner', + debut: p.debut, fin: p.fin, + texte: `EDF ferme en heures pleines de ${hhmm(p.debut)} a ${hhmm(p.fin)} (${duree(min)})` + + (kwh !== null ? `, ${f1(kwh)} kWh achetes` : '') + + (socDebut !== null ? `, batterie a ${Math.round(socDebut)} % a la fermeture` : '') + + (ouverture?.auteur ? ` ; ferme par ${ouverture.auteur}` : '') + '.', + chiffres: { minutes: Math.round(min), kwh: kwh === null ? null : Math.round(kwh * 100) / 100, soc: socDebut }, + contexte: (aInstant(d.decisions.map((x) => ({ ts: x.ts, v: x.raison })), p.debut) || '').slice(0, 400), + }); + } + + // 2. VOITURE BRANCHEE BLOQUEE pendant une session reseau HC (EDF ferme) + const dec = (t) => { let r = null; for (const x of d.decisions) { if (x.ts <= t) r = x; else break; } return r; }; + for (const p of plages(d.debut, d.fin, (t) => { + const x = dec(t); + return !!x && enHc(t, d.fenetres) && aInstant(contact, t) === 'on' && x.session_engagee === true + && /bloqu/i.test(x.source_mode || '') && !ETATS_VOITURE_ABSENTE_OU_PLEINE.test(x.etat || ''); + })) { + const min = (p.fin - p.debut) / MIN; + if (min < SEUIL_VOITURE_MIN) continue; + const x = dec(p.debut); + constats.push({ + code: 'voiture_bloquee_session_hc', + gravite: 'a_examiner', + debut: p.debut, fin: p.fin, + texte: `Voiture branchee mais bloquee de ${hhmm(p.debut)} a ${hhmm(p.fin)} (${duree(min)}) alors qu'EDF etait ferme en heures creuses (etat ${x.etat}).`, + chiffres: { minutes: Math.round(min) }, + contexte: (x.raison || '').slice(0, 400), + }); + } + + // 3. BATTERIE SOUS SON OBJECTIF a la fin d'une fenetre HC ou une session etait engagee + for (const f of d.fenetres) { + const b = hm(f.fin); + if (b === null) continue; + for (let t = d.debut; t <= d.fin; t += MIN) { + if (minutesParis(t) !== b) continue; + const x = dec(t - MIN); + if (!x || x.session_engagee !== true) continue; + const objectif = f.nom === 'nuit' ? Number(x.cible_matin_pct) : aInstant(d.requis, t - 5 * MIN); + const soc = aInstant(d.soc, t); + if (!Number.isFinite(objectif) || !Number.isFinite(soc)) continue; + // Jour faible a midi (08/10) : le bilan vise 100 % = « charge au maximum + // possible ». Le manque a 100 % ne dit rien ; on rend compte du gain. + if (f.nom === 'midi' && objectif >= 99.5) { + const a = hm(f.debut); + let t0 = null; + for (let u = t - 4 * 60 * MIN; u < t; u += MIN) if (minutesParis(u) === a) { t0 = u; break; } + const soc0 = t0 === null ? null : aInstant(d.soc, t0); + constats.push({ + code: 'charge_max_midi', + gravite: 'info', + debut: t, fin: t, + texte: `Heures creuses de midi en charge maximale (jour faible) : batterie ${soc0 === null ? '?' : Math.round(soc0)} % -> ${Math.round(soc)} %` + + (soc0 === null ? '.' : ` (+${f1((soc - soc0) * KWH_PAR_POINT)} kWh).`), + chiffres: { socDebut: soc0, socFin: soc }, + contexte: (x.raison || '').slice(0, 400), + }); + continue; + } + const manque = objectif - soc; + if (manque < SEUIL_OBJECTIF_PTS) continue; + constats.push({ + code: `batterie_sous_objectif_${f.nom}`, + gravite: 'a_examiner', + debut: t, fin: t, + texte: `Fin des heures creuses de ${f.nom} (${hhmm(t)}) : batterie a ${Math.round(soc)} % pour ${f1(objectif)} % vises, il manquait ${f1(manque * KWH_PAR_POINT)} kWh.`, + chiffres: { soc, objectif, manqueKwh: Math.round(manque * KWH_PAR_POINT * 10) / 10 }, + contexte: (x.raison || '').slice(0, 400), + }); + } + } + + constats.sort((a, b) => a.debut - b.debut); + const texte = constats.length + ? constats.map((c) => `- ${c.texte}`).join('\n') + : 'Rien a signaler : pas d\'EDF en heures pleines, pas de voiture bloquee pendant une session, batterie a son objectif en fin d\'heures creuses.'; + return { constats, texte }; +} + +export async function donneesRevue(client, fenetres, finMs) { + // 24 h qui se terminent a la fin de la fenetre HC de nuit (07:24 + 1 min). + const fin = finMs; + const debut = fin - 24 * 60 * MIN; + const q = async (sql, v) => (await client.query(sql, v)).rows; + const d0 = new Date(debut), d1 = new Date(fin); + const ms = (r) => new Date(r.ts).getTime(); + + const avant = await q(`SELECT ts, detail->>'apres' v, detail->>'auteur_nom' auteur_nom, detail->>'auteur' auteur FROM evenement + WHERE genre='reseau' AND resume LIKE 'Contacteur EDF%' AND ts < $1 ORDER BY ts DESC LIMIT 1`, [d0]); + const bascules = await q(`SELECT ts, detail->>'apres' v, detail->>'auteur_nom' auteur_nom, detail->>'auteur' auteur, resume FROM evenement + WHERE genre='reseau' AND resume LIKE 'Contacteur EDF%' AND ts >= $1 AND ts < $2 ORDER BY ts`, [d0, d1]); + const auteurDe = (r) => { + const m = /— par (.+)$/.exec(r.resume || ''); + return r.auteur_nom || (m ? m[1] : null) || r.auteur || null; + }; + const contacteur = [ + ...avant.map((r) => ({ ts: debut, v: r.v, auteur: auteurDe(r) })), + ...bascules.map((r) => ({ ts: ms(r), v: r.v, auteur: auteurDe(r) })), + ]; + const serie = async (cle) => (await q(`SELECT ts, valeur v FROM ( + (SELECT ts, valeur FROM mesure WHERE cle=$1 AND ts < $2 ORDER BY ts DESC LIMIT 1) + UNION ALL (SELECT ts, valeur FROM mesure WHERE cle=$1 AND ts >= $2 AND ts < $3)) s ORDER BY ts`, [cle, d0, d1])) + .map((r) => ({ ts: ms(r), v: Number(r.v) })); + const decisions = (await q(`SELECT * FROM ( + (SELECT ts, profil, source_mode, etat, session_engagee, soc_pct, cible_matin_pct, raison FROM decision WHERE ts < $1 ORDER BY ts DESC LIMIT 1) + UNION ALL (SELECT ts, profil, source_mode, etat, session_engagee, soc_pct, cible_matin_pct, raison FROM decision WHERE ts >= $1 AND ts < $2)) s ORDER BY ts`, [d0, d1])) + .map((r) => ({ ...r, ts: ms(r) })); + return { + debut, fin, fenetres, contacteur, decisions, + edfKwh: await serie('sensor.edf_general_ok_energie_totale'), + reseauW: await serie('sensor.victron_grid_power_l1'), + soc: await serie('sensor.victron_battery_soc'), + requis: await serie('input_number.evcs_balance_required_soc'), + }; +} diff --git a/workflows/evcs-advisor-shadow-v1.json b/workflows/evcs-advisor-shadow-v1.json index 35b3af4..b56948c 100644 --- a/workflows/evcs-advisor-shadow-v1.json +++ b/workflows/evcs-advisor-shadow-v1.json @@ -51,7 +51,7 @@ "parameters": [ { "name": "template", - "value": "{{ {\n \"advisor_enabled\": states(\"input_boolean.evcs_advisor_enabled\"),\n \"advisor_agree_count\": states(\"input_number.evcs_advisor_agree_count\"),\n \"advisor_diverge_count\": states(\"input_number.evcs_advisor_diverge_count\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_surplus_state\": states(\"input_text.victron_evcs_surplus_state\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n\n \"balance_status\": states(\"input_text.evcs_balance_status\"),\n \"balance_last_run\": states(\"input_text.evcs_balance_last_run\"),\n \"balance_required_soc\": states(\"input_number.evcs_balance_required_soc\"),\n \"balance_required_soc_prudent\": states(\"input_number.evcs_balance_required_soc_prudent\"),\n \"balance_energy_to_cover_kwh\": states(\"input_number.evcs_balance_energy_to_cover_kwh\"),\n \"balance_crossing_hour\": states(\"input_text.evcs_balance_crossing_hour\"),\n \"balance_grid_needed\": states(\"input_boolean.evcs_balance_grid_needed\"),\n \"balance_grid_energy_needed_kwh\": states(\"input_number.evcs_balance_grid_energy_needed_kwh\"),\n \"balance_pv_censored\": states(\"input_boolean.evcs_balance_pv_censored\"),\n \"balance_censor_reason\": states(\"input_text.evcs_balance_censor_reason\"),\n \"balance_storage_shortfall_kwh\": states(\"input_number.evcs_balance_storage_shortfall_kwh\"),\n \"balance_next_deficit_kwh\": states(\"input_number.evcs_balance_next_deficit_kwh\"),\n \"balance_next_deficit_window\": states(\"input_text.evcs_balance_next_deficit_window\"),\n \"balance_storm_level\": states(\"input_text.evcs_balance_storm_level\"),\n \"balance_storm_reserve_soc\": states(\"input_number.evcs_balance_storm_reserve_soc\"),\n\n \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\n \"bms_max_discharge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_discharge_current\"),\n \"bms_max_charge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_charge_current\"),\n \"grid_setpoint_w\": states(\"number.gx_device_ac_power_setpoint\"),\n \"mppt_operation_modes\": states.sensor\n | selectattr('entity_id','search','mppt')\n | selectattr('entity_id','search','operation_mode')\n | map(attribute='state') | join(','),\n \"vehicle_selected\": states(\"input_select.victron_evcs_vehicle\"),\n \"evenements_recents\": (\n [ \"EDF \" ~ states(\"switch.edf_general_ok_switch\") ~ \" depuis \" ~ relative_time(states.switch.edf_general_ok_switch.last_changed),\n \"verrou hysteresis \" ~ states(\"input_boolean.victron_grid_hysteresis_latch\") ~ \" depuis \" ~ relative_time(states.input_boolean.victron_grid_hysteresis_latch.last_changed),\n \"takeover nuit \" ~ states(\"input_boolean.victron_evcs_night_grid_takeover_needed\") ~ \" depuis \" ~ relative_time(states.input_boolean.victron_evcs_night_grid_takeover_needed.last_changed),\n \"etat EVCS \" ~ states(\"input_text.victron_evcs_surplus_state\") ~ \" depuis \" ~ relative_time(states.input_text.victron_evcs_surplus_state.last_changed),\n \"mode ACM \" ~ states(\"input_text.victron_charge_mode\") ~ \" depuis \" ~ relative_time(states.input_text.victron_charge_mode.last_changed) ]\n | join(\"|\")\n ),\n\n \"user_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"allow_grid_hc_midday\": states(\"input_boolean.victron_evcs_allow_grid_hc_midday\"),\n \"night_target_soc_actual\": states(\"input_number.victron_evcs_night_target_soc\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"plafond_edf_on_a\": states(\"input_number.evcs_plafond_courant_edf_on\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\")\n} | tojson }}\n" + "value": "{{ {\n \"advisor_enabled\": states(\"input_boolean.evcs_advisor_enabled\"),\n \"advisor_agree_count\": states(\"input_number.evcs_advisor_agree_count\"),\n \"advisor_diverge_count\": states(\"input_number.evcs_advisor_diverge_count\"),\n \"revue_jour_date\": states(\"sensor.evcs_revue_jour\"),\n \"revue_jour_constats\": state_attr(\"sensor.evcs_revue_jour\", \"constats\") or [],\n \"advisor_revue_date\": states(\"sensor.evcs_advisor_revue\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_surplus_state\": states(\"input_text.victron_evcs_surplus_state\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n\n \"balance_status\": states(\"input_text.evcs_balance_status\"),\n \"balance_last_run\": states(\"input_text.evcs_balance_last_run\"),\n \"balance_required_soc\": states(\"input_number.evcs_balance_required_soc\"),\n \"balance_required_soc_prudent\": states(\"input_number.evcs_balance_required_soc_prudent\"),\n \"balance_energy_to_cover_kwh\": states(\"input_number.evcs_balance_energy_to_cover_kwh\"),\n \"balance_crossing_hour\": states(\"input_text.evcs_balance_crossing_hour\"),\n \"balance_grid_needed\": states(\"input_boolean.evcs_balance_grid_needed\"),\n \"balance_grid_energy_needed_kwh\": states(\"input_number.evcs_balance_grid_energy_needed_kwh\"),\n \"balance_pv_censored\": states(\"input_boolean.evcs_balance_pv_censored\"),\n \"balance_censor_reason\": states(\"input_text.evcs_balance_censor_reason\"),\n \"balance_storage_shortfall_kwh\": states(\"input_number.evcs_balance_storage_shortfall_kwh\"),\n \"balance_next_deficit_kwh\": states(\"input_number.evcs_balance_next_deficit_kwh\"),\n \"balance_next_deficit_window\": states(\"input_text.evcs_balance_next_deficit_window\"),\n \"balance_storm_level\": states(\"input_text.evcs_balance_storm_level\"),\n \"balance_storm_reserve_soc\": states(\"input_number.evcs_balance_storm_reserve_soc\"),\n\n \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\n \"bms_max_discharge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_discharge_current\"),\n \"bms_max_charge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_charge_current\"),\n \"grid_setpoint_w\": states(\"number.gx_device_ac_power_setpoint\"),\n \"mppt_operation_modes\": states.sensor\n | selectattr('entity_id','search','mppt')\n | selectattr('entity_id','search','operation_mode')\n | map(attribute='state') | join(','),\n \"vehicle_selected\": states(\"input_select.victron_evcs_vehicle\"),\n \"evenements_recents\": (\n [ \"EDF \" ~ states(\"switch.edf_general_ok_switch\") ~ \" depuis \" ~ relative_time(states.switch.edf_general_ok_switch.last_changed),\n \"verrou hysteresis \" ~ states(\"input_boolean.victron_grid_hysteresis_latch\") ~ \" depuis \" ~ relative_time(states.input_boolean.victron_grid_hysteresis_latch.last_changed),\n \"takeover nuit \" ~ states(\"input_boolean.victron_evcs_night_grid_takeover_needed\") ~ \" depuis \" ~ relative_time(states.input_boolean.victron_evcs_night_grid_takeover_needed.last_changed),\n \"etat EVCS \" ~ states(\"input_text.victron_evcs_surplus_state\") ~ \" depuis \" ~ relative_time(states.input_text.victron_evcs_surplus_state.last_changed),\n \"mode ACM \" ~ states(\"input_text.victron_charge_mode\") ~ \" depuis \" ~ relative_time(states.input_text.victron_charge_mode.last_changed) ]\n | join(\"|\")\n ),\n\n \"user_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"allow_grid_hc_midday\": states(\"input_boolean.victron_evcs_allow_grid_hc_midday\"),\n \"night_target_soc_actual\": states(\"input_number.victron_evcs_night_target_soc\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"plafond_edf_on_a\": states(\"input_number.evcs_plafond_courant_edf_on\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\")\n} | tojson }}\n" } ] }, @@ -75,7 +75,7 @@ 0 ], "parameters": { - "jsCode": "// =====================================================\n// EVCS ADVISOR SHADOW V1 — BUILD_PROMPT\n// Construit le prompt LLM à partir du snapshot HA compact.\n// MODE OMBRE : ce workflow n'écrit que des helpers advisor_*.\n// =====================================================\n\nfunction num(v, d) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : d;\n}\nfunction onOff(v) {\n return (v ?? 'off').toString().trim().toLowerCase() === 'on';\n}\n\nconst raw = $input.first().json;\nconst input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw);\n\nconst ctx = {\n advisorEnabled: onOff(input.advisor_enabled),\n agreeCount: num(input.advisor_agree_count, 0),\n divergeCount: num(input.advisor_diverge_count, 0),\n\n soc: num(input.soc_percent, NaN),\n batteryCurrentA: num(input.battery_current_a, NaN),\n pvW: num(input.pv_power_w, 0),\n pvAvg10mW: num(input.pv_power_10m_avg_w, 0),\n houseW: num(input.house_ac_power_w, 0),\n gridOn: onOff(input.grid_state),\n acinAvailable: num(input.acin1_available, 0) === 1,\n\n vehicleConnected: onOff(input.evcs_vehicle_connected),\n evcsCharging: onOff(input.evcs_charging),\n evcsCurrentA: num(input.evcs_current_a, 0),\n surplusState: (input.evcs_surplus_state || '').toString(),\n sourceMode: (input.evcs_source_mode || '').toString(),\n\n userStrategy: (input.user_strategy || 'auto_solar_plus').toString().trim().toLowerCase(),\n allowGridHcNight: onOff(input.allow_grid_hc_night),\n allowGridHcMidday: onOff(input.allow_grid_hc_midday),\n nightTargetActual: num(input.night_target_soc_actual, 35),\n currentMaxAutoA: num(input.current_max_auto_a, 22),\n plafondEdfOnA: num(input.plafond_edf_on_a, 20),\n\n departureTime: (input.departure_time || '').toString(),\n departurePriority: onOff(input.departure_priority_enabled),\n requiredEnergyKwh: num(input.required_energy_kwh, 0),\n\n todayRemainingKwh: num(input.today_remaining_kwh, 0),\n tomorrowKwh: num(input.tomorrow_kwh, 0),\n\n // ── BILAN ENERGETIQUE ────────────────────────────────────────────────\n // Ces grandeurs REMPLACENT les regles de seuil. Elles n'etaient pas\n // transmises jusqu'au 12/09 : le conseiller raisonnait sur des paliers\n // que le bilan avait deja demontres faux, et il ne pouvait pas mieux faire.\n balanceStatus: (input.balance_status || 'indisponible').toString().trim(),\n balanceLastRun: (input.balance_last_run || '').toString().trim(),\n balanceRequiredSoc: num(input.balance_required_soc, NaN),\n balanceRequiredSocPrudent: num(input.balance_required_soc_prudent, NaN),\n balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN),\n balanceCrossingHour: (input.balance_crossing_hour || '').toString().trim(),\n balanceGridNeeded: onOff(input.balance_grid_needed),\n balanceGridEnergyNeededKwh: num(input.balance_grid_energy_needed_kwh, NaN),\n balancePvCensored: onOff(input.balance_pv_censored),\n balanceCensorReason: (input.balance_censor_reason || '').toString().trim(),\n balanceStorageShortfallKwh: num(input.balance_storage_shortfall_kwh, NaN),\n balanceNextDeficitKwh: num(input.balance_next_deficit_kwh, NaN),\n balanceNextDeficitWindow: (input.balance_next_deficit_window || '').toString().trim(),\n stormLevel: (input.balance_storm_level || 'indisponible').toString().trim(),\n stormReserveSoc: num(input.balance_storm_reserve_soc, NaN),\n\n // ── PARC ET MATERIEL, MESURES ────────────────────────────────────────\n modulesEnService: num(input.modules_en_service, 4),\n bmsMaxDischargeA: num(input.bms_max_discharge_current_a, NaN),\n bmsMaxChargeA: num(input.bms_max_charge_current_a, NaN),\n gridSetpointW: num(input.grid_setpoint_w, NaN),\n vehicleSelected: (input.vehicle_selected || 'inconnu').toString().trim(),\n // Historique recent des transitions, fourni par le snapshot. C est la SUITE\n // des evenements qui revele les contradictions entre automates ; un\n // instantane ne peut pas les montrer.\n evenements: String(input.evenements_recents || '')\n .split('|').map(x => x.trim()).filter(Boolean).slice(0, 12)\n .map(x => `- ${x}`),\n\n mppt: (() => {\n const modes = String(input.mppt_operation_modes || '')\n .split(',').map(x => x.trim().toLowerCase())\n .filter(x => x && x !== 'unavailable' && x !== 'unknown');\n return {\n total: modes.length,\n aFond: modes.filter(x => x === 'mppt_active').length,\n brides: modes.filter(x => x.includes('limited')).length,\n };\n })(),\n};\n\n// Capacite REELLEMENT livree a la maison, deduite de la courbe mesuree le\n// 03/09 sur 576 paliers et du parc declare. Un US5000 compte pour DEUX\n// equivalents US2000C. Aucune constante : si le parc change, ceci suit.\n// GRANDEURS DERIVEES — on les CALCULE pour lui (12/09/2026, iteration 2).\n// Campagne de rejeu : sur le releve du 03/09 19:44 (PV 758 W, maison 1202 W)\n// il a recommande de charger la voiture a 28 A. Les deux nombres etaient dans\n// son prompt ; il n a pas fait la soustraction. Et sur celui du 05/09 20:40\n// (batterie a -97,4 A pour une limite declaree de 100 A) il a repondu « SOC\n// 79 % est suffisant » sans mentionner ni le courant ni la limite.\n// Un modele raisonne bien sur UN nombre, mal sur deux qu il doit combiner.\n// Meme principe que le bilan : on lui donne la grandeur, pas les ingredients.\nconst maisonHorsVoitureW = Math.max(0, ctx.houseW - (ctx.evcsCharging ? ctx.evcsCurrentA * 230 : 0));\nconst excedentSolaireW = Math.round(ctx.pvW - maisonHorsVoitureW);\nconst margeRechargeW = ctx.gridOn && Number.isFinite(ctx.gridSetpointW)\n ? Math.round(ctx.gridSetpointW - maisonHorsVoitureW)\n : null;\n// Le bilan dit lui-meme si la batterie doit monter. A defaut de bilan\n// exploitable on ne tranche pas, et la marge reste un simple constat.\nconst batterieDoitMonter = Number.isFinite(ctx.balanceRequiredSoc) && Number.isFinite(ctx.soc)\n ? ctx.soc < ctx.balanceRequiredSoc\n : null;\nconst margeDechargeA = Number.isFinite(ctx.bmsMaxDischargeA) && Number.isFinite(ctx.batteryCurrentA)\n ? Math.round(ctx.bmsMaxDischargeA - Math.abs(Math.min(0, ctx.batteryCurrentA)))\n : null;\nconst partDuPlafond = Number.isFinite(ctx.bmsMaxDischargeA) && ctx.bmsMaxDischargeA > 0\n ? Math.round(100 * Math.abs(Math.min(0, ctx.batteryCurrentA)) / ctx.bmsMaxDischargeA)\n : null;\n\nconst kwhParPointHaut = 0.169 * ctx.modulesEnService / 7;\nconst kwhParPointBas = 0.097 * ctx.modulesEnService / 7;\nconst capaciteUtileKwh = (67 * kwhParPointHaut + 11 * kwhParPointBas) * 0.955;\n\n// Heure Paris\nconst parisFmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n weekday: 'long', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n});\nconst nowParis = parisFmt.format(new Date());\n\nconst systemPrompt = [\n \"Tu es le PLANIFICATEUR ENERGIE d'une maison en autoconsommation, en Bretagne.\",\n 'Tu es en MODE OBSERVATION : tes reponses sont enregistrees et comparees au reel,',\n 'elles ne pilotent rien.',\n '',\n 'INSTALLATION, CHIFFRES MESURES (pas des ordres de grandeur) :',\n `- Batterie : ${ctx.modulesEnService} equivalents US2000C, soit ${capaciteUtileKwh.toFixed(1)} kWh`,\n ` reellement livres a la maison entre 100 % et le plancher de securite de 22 %.`,\n ` Un point de SOC vaut ${Math.round(kwhParPointHaut * 1000)} Wh au-dessus de 33 %,`,\n ` ${Math.round(kwhParPointBas * 1000)} Wh en dessous.`,\n '- Champ PV 16,1 kWc sur QUATRE orientations (sud-est 40, sud 35, ouest 45, un suiveur).',\n '- Victron ESS, AUCUNE injection possible : la production est bridee des que rien',\n ' ne peut l absorber. Une PV mesuree basse ne veut donc PAS dire ressource faible.',\n '- Borne EVCS 6-28 A. Reseau EDF commutable, heures creuses 01:24-07:24 et 12:24-14:24.',\n '- Chaque fermeture du contacteur coute 50 a 150 Wh de transitoire : une session',\n ' longue et dimensionnee vaut mieux que des creneaux repetes.',\n '',\n 'LA MAISON, telle qu elle consomme vraiment :',\n '- Quatre personnes. Les gros appels sont BREFS et cales sur les repas :',\n ' bouilloire vers 05:45, grille-pain vers 08:00, cuisson du soir entre 20 h et 23 h.',\n \"- L heure du diner varie de TROIS HEURES d un jour a l autre : un profil horaire\",\n ' moyen ne represente ni l un ni l autre.',\n '',\n 'CE QUI REMPLACE LES REGLES DE SEUIL :',\n 'un bilan energetique tourne toutes les 5 minutes et te donne, pour l instant present,',\n 'l heure de croisement ou la production repassera au-dessus de la consommation,',\n 'l energie que la batterie doit couvrir d ici la, et le SOC qui serait necessaire.',\n 'RAISONNE SUR CES CHIFFRES. Il n existe plus de palier du type',\n '\"prevision forte => cible basse\" : cette regle a ete mesuree fausse sur cette maison.',\n 'Si le bilan est indisponible ou perime, dis-le dans ta raison et reste prudent.',\n '',\n 'CE QUE TU DOIS SAVOIR ET QUE PERSONNE NE TE DIRA AUTREMENT :',\n '- SANS VEHICULE BRANCHE IL Y A QUAND MEME QUELQUE CHOSE A DECIDER : c est la MAISON',\n ' qui doit traverser la nuit. Une batterie au plancher pendant les heures creuses,',\n ' alors que le bilan reclame davantage, est une occasion manquee qui se paiera au',\n ' plein tarif le matin.',\n '- Les chargeurs solaires declarent eux-memes s ils se BRIDENT. Si certains se',\n ' brident, il reste de la ressource REFUSEE : lui donner un preneur (voiture,',\n ' batterie) ne coute rien. S ils sont tous a fond, la production mesuree EST la',\n ' ressource.',\n '- L hiver la nuit a couvrir est plus longue de deux heures en temps solaire : la',\n ' cible du matin y monte naturellement bien plus haut qu en ete.',\n '',\n 'DEUX REGLES QUI PRIMENT SUR TOUT LE RESTE :',\n '1. EXCEDENT SOLAIRE. Charger un vehicule ne se decide pas sur la production',\n ' mais sur ce qui RESTE une fois la maison servie. Cet excedent t est donne',\n ' plus bas. S il est NEGATIF, charger la voiture revient a vider la batterie',\n ' solaire : ne le fais pas, quelle que soit la prevision. S il est nul ou a',\n ' peine positif ALORS QUE DES CHARGEURS SE BRIDENT, la ressource existe mais',\n ' est refusee : la charger est gratuit.',\n '2. LIMITE DU PARC. La marge de decharge restante t est donnee plus bas. Si la',\n ' batterie depasse 80 % de ce que le parc declare accepter, c est un sujet de',\n ' PROTECTION DU MATERIEL, pas de confort : il faut appeler le reseau meme si',\n ' le SOC parait confortable et meme hors heures creuses. Le proprietaire a un',\n ' abonnement 12 kVA et prefere payer quelques kWh plutot que fragiliser le parc.',\n '',\n // <<< CARTE GENEREE — ne pas editer a la main, voir tools/carte-decisions.mjs\n 'QUI ECRIT SUR QUOI (carte generee depuis les sources) :',\n '- Contacteur reseau EDF : proprietaire = Battery / Grid Hysteresis Fix V1, AUTRES ECRIVAINS : EVCS Surplus Controller V2.6, automations, scripts',\n '- Courant de la borne : proprietaire = EVCS Surplus Controller V2.6, AUTRES ECRIVAINS : scripts',\n '- Verrou hysteresis : proprietaire = Battery / Grid Hysteresis Fix V1, AUTRES ECRIVAINS : scripts',\n 'SEUILS DE SOC FIGES dans le code : 20, 22, 25, 35, 40, 60, 80, 85, 90, 95, 100 % — chacun peut contredire le bilan.',\n 'Quand un actionneur bascule de facon incoherente, le coupable le plus probable',\n 'est un ecrivain AUTRE QUE SON PROPRIETAIRE. Nomme-le.',\n // CARTE GENEREE >>>\n '',\n 'TU NE PILOTES RIEN ET TU NE CHOISIS AUCUNE STRATEGIE.',\n 'La planification energetique est faite par le bilan, qui est calcule et mesure.',\n 'Ton seul travail est de REGARDER LE SYSTEME SE COMPORTER et de dire ce qui cloche.',\n '',\n 'Reponds UNIQUEMENT avec un objet JSON valide, sans texte autour, au format EXACT :',\n '{\"constat\":\"ce que tu observes, factuel, max 200 caracteres\",',\n '\"gravite\":\"aucune|faible|moyenne|haute\",',\n '\"ce_qui_cloche\":\"la contradiction ou l incoherence, ou \\'\\' si rien\",',\n '\"piste\":\"ce qu il faudrait verifier ou corriger, ou \\'\\' si rien\",',\n '\"confidence\":<0.0-1.0>}',\n '',\n 'ECHELLE DE GRAVITE, ancree sur des cas reels de cette installation :',\n '- aucune : tout est coherent. C EST LE CAS LE PLUS FREQUENT ET DE LOIN.',\n ' N invente pas un probleme pour justifier une reponse.',\n '- faible : un ecart sans consequence mesurable — une valeur un peu hors cible,',\n ' un etat transitoire qui se resorbe seul.',\n '- moyenne : quelque chose coute de l energie ou de l argent sans danger materiel.',\n ' Exemple reel : la consigne reseau laissee haute en plein soleil, qui',\n ' evince la production — 5,6 kW de solaire remplaces par du reseau.',\n '- haute : un actionneur bascule de facon repetee, OU le materiel est sollicite',\n ' pres de ses limites, OU un automate defait ce qu un autre vient de',\n ' faire. Deux exemples reels : le contacteur reseau qui bascule six',\n ' fois en dix minutes le 12/09 — chaque bascule coute 50 a 150 Wh et',\n ' use le relais, et aucun automate ne le voyait ; et le 05/09 a 20:40,',\n ' le parc a -97,4 A pour une limite declaree de 100 A, soit 3 A de',\n ' marge, pendant que le reseau restait disponible et inutilise.',\n 'UNE OSCILLATION EST TOUJOURS DE GRAVITE HAUTE, meme si chaque bascule prise',\n 'isolement parait anodine : c est la repetition qui fait le degat, et c est',\n 'precisement ce qu aucune regle locale ne peut voir.',\n].join('\\n');\n\n// Les etats du controleur sont des identifiants internes. Le conseiller n'a\n// aucune raison de savoir que `day_vehicle_full_idle` veut dire « le vehicule\n// est plein, il n'absorbera plus rien » — et sans le savoir il signale comme\n// une anomalie un systeme qui a raison.\nconst ETATS = {\n vehicle_not_connected: 'aucun vehicule branche',\n idle_wait_conditions: 'a l arret, conditions de demarrage non reunies',\n idle_wait_source: 'a l arret, en attente d une source',\n day_vehicle_full_idle: 'vehicule PLEIN, il n absorbera plus rien (memoire du jour)',\n night_recovery_done_idle: 'charge de nuit terminee ET batterie a la cible',\n night_session_done_idle: 'charge de nuit terminee, batterie encore sous la cible',\n departure_done_idle: 'charge avant depart terminee',\n vehicle_full_or_paused: 'le vehicule ne demande plus de courant',\n start_min_current: 'demarrage au courant minimal',\n start_grid_hc_min_current: 'demarrage sur le reseau en heures creuses',\n start_manual_local_min_current: 'demarrage manuel au courant minimal',\n charging_hold_grid: 'en charge sur le reseau, consigne tenue',\n charging_hold_manual_local: 'en charge, consigne manuelle',\n charging_hold_cloud_tolerance: 'en charge, tolerance nuage en cours',\n charging_step_up_battery: 'en charge, courant en hausse sur le solaire',\n charging_step_up_grid_hc: 'en charge, courant en hausse sur le reseau',\n charging_step_down_battery: 'en charge, courant en baisse',\n charging_step_down_system_cap: 'en charge, courant ramene au plafond du systeme',\n battery_current_hard_stop: 'ARRET : la batterie se dechargeait trop fort',\n stop_conditions_lost: 'ARRET : conditions de maintien perdues',\n stop_source_lost: 'ARRET : la source a disparu',\n hold_min_run: 'maintenu le temps de la duree minimale de marche',\n};\nconst etatLisible = (e) => ETATS[e] ? `${ETATS[e]} (${e})` : e;\n\nfunction nombre(v, unite, defaut) {\n return Number.isFinite(v) ? `${v}${unite}` : (defaut || 'inconnu');\n}\n\nconst bilanLignes = ctx.balanceStatus === 'ok'\n ? [\n `- heure de croisement production/consommation : ${ctx.balanceCrossingHour || 'inconnue'}`,\n `- energie que la batterie doit couvrir d ici la : ${nombre(ctx.balanceEnergyToCoverKwh, ' kWh')}`,\n `- SOC qui serait necessaire : ${nombre(ctx.balanceRequiredSoc, ' %')} (prudent : ${nombre(ctx.balanceRequiredSocPrudent, ' %')})`,\n `- reseau juge necessaire : ${ctx.balanceGridNeeded ? 'OUI' : 'non'}${Number.isFinite(ctx.balanceGridEnergyNeededKwh) ? ` (${ctx.balanceGridEnergyNeededKwh} kWh)` : ''}`,\n `- production actuellement bridee : ${ctx.balancePvCensored ? `OUI (${ctx.balanceCensorReason})` : 'non'}`,\n `- manque de stockage identifie : ${nombre(ctx.balanceStorageShortfallKwh, ' kWh')}`,\n `- prochaine traversee deficitaire : ${nombre(ctx.balanceNextDeficitKwh, ' kWh')} ${ctx.balanceNextDeficitWindow || ''}`,\n `- vigilance orage : ${ctx.stormLevel}${Number.isFinite(ctx.stormReserveSoc) ? `, reserve conseillee ${ctx.stormReserveSoc} %` : ''}`,\n `- calcule a ${ctx.balanceLastRun || 'heure inconnue'}`,\n ]\n : [`- INDISPONIBLE (statut ${ctx.balanceStatus}) : raisonne sans lui, et dis-le.`];\n\nconst userPrompt = [\n `Nous sommes ${nowParis} (heure de Paris).`,\n '',\n 'ETAT MESURE :',\n `- Batterie : SOC ${ctx.soc} %, courant ${ctx.batteryCurrentA} A (positif = charge)`,\n `- PV : ${Math.round(ctx.pvW)} W instantane, ${Math.round(ctx.pvAvg10mW)} W sur 10 min`,\n `- Chargeurs solaires : ${ctx.mppt.aFond}/${ctx.mppt.total} a pleine puissance, ${ctx.mppt.brides} qui se BRIDENT`,\n `- Maison : ${Math.round(ctx.houseW)} W, dont voiture ${ctx.evcsCharging ? Math.round(ctx.evcsCurrentA * 230) : 0} W`,\n `- EXCEDENT SOLAIRE (production moins maison hors voiture) : ${excedentSolaireW > 0 ? `+${excedentSolaireW} W, il RESTE du solaire` : excedentSolaireW < 0 ? `${excedentSolaireW} W` : '0 W, exactement a l equilibre'}${ctx.mppt.brides > 0 ? ` — ATTENTION, ce chiffre est CENSURE : ${ctx.mppt.brides} chargeurs se retiennent, la ressource reelle est PLUS HAUTE${excedentSolaireW < 0 ? ' et ce chiffre negatif ne prouve PAS qu il manque du soleil' : ''}` : excedentSolaireW < 0 ? ', DEFICIT reel : la batterie porte deja la maison' : ''}`,\n `- MARGE DE DECHARGE du parc : ${margeDechargeA === null ? 'inconnue' : `${margeDechargeA} A restants`}${partDuPlafond === null ? '' : ` — la batterie est a ${partDuPlafond} % de son plafond`}`,\n `- Reseau EDF : ${ctx.gridOn ? 'PRESENT, il peut alimenter la maison' : 'ABSENT, la maison vit sur batterie et solaire'} (contacteur ${ctx.gridOn ? 'ferme' : 'ouvert'}, AC-IN ${ctx.acinAvailable ? 'present' : 'absent'})`,\n `- Consigne de puissance reseau : ${nombre(ctx.gridSetpointW, ' W')}${margeRechargeW === null ? '' : batterieDoitMonter === false && margeRechargeW > 0 ? ` — la batterie n a PAS besoin de monter (SOC ${ctx.soc} %, bilan ${ctx.balanceRequiredSoc} %), donc ces ${margeRechargeW} W de demande reseau n ont aucune destination : ils ne peuvent que remplacer du solaire` : margeRechargeW > 0 ? ` — il reste ${margeRechargeW} W pour RECHARGER la batterie, qui en a besoin` : ' — une fois la maison servie il ne reste RIEN pour recharger la batterie'}`,\n `- Limites declarees par le parc : charge ${nombre(ctx.bmsMaxChargeA, ' A')}, decharge ${nombre(ctx.bmsMaxDischargeA, ' A')}`,\n `- Vehicule : ${ctx.vehicleConnected ? `BRANCHE (${ctx.vehicleSelected})` : 'AUCUN branche'}${ctx.evcsCharging ? `, en charge a ${ctx.evcsCurrentA} A` : ''}`,\n `- Controleur : ${etatLisible(ctx.surplusState)} | source ${ctx.sourceMode} | strategie reelle ${ctx.userStrategy}`,\n `- Cible SOC matin actuellement appliquee : ${ctx.nightTargetActual} %`,\n `- Prevision : ${ctx.todayRemainingKwh} kWh restant aujourd hui, ${ctx.tomorrowKwh} kWh demain`,\n '',\n 'BILAN ENERGETIQUE :',\n ...bilanLignes,\n '',\n 'DERNIERS EVENEMENTS DU SYSTEME (le plus recent en premier) :',\n ...(ctx.evenements.length ? ctx.evenements : ['- aucun evenement transmis']),\n '',\n 'Quelle est ta recommandation ? JSON uniquement.',\n].join('\\n');\n\nreturn [{\n json: {\n enabled: ctx.advisorEnabled,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n context: ctx,\n }\n}];\n" + "jsCode": "// =====================================================\n// EVCS ADVISOR — BUILD_PROMPT (RELECTEUR DE LA JOURNEE, 08/10/2026)\n//\n// Le conseiller n'observe plus l'instantane toutes les 15 min : 1 500 « rien a\n// signaler » contre 871 signalements sans suite, et aucun des rates du 07/10\n// (HC de midi perdue, EDF en heures pleines a 18:03, VW bloquee de 01:24 a\n// 05:14 avec EDF ferme) n'avait ete vu. Il RELIT desormais, une fois par jour,\n// les constats CALCULES par le service d'historisation (tools/revue-jour.mjs,\n// sensor.evcs_revue_jour) et propose pour chacun une piste a examiner.\n// Doctrine §79 : les chiffres viennent du code, le modele ne fait que relire.\n// Il ne pilote rien.\n//\n// Il tourne quand : le conseiller est actif, la revue du jour est publiee, il\n// y a au moins un point a examiner, et il n'a pas deja repondu aujourd'hui.\n// =====================================================\n\nconst raw = $input.first().json;\nconst input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw);\nconst onOff = (v) => (v ?? 'off').toString().trim().toLowerCase() === 'on';\n\nconst jourParis = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });\nlet constats = input.revue_jour_constats;\nif (typeof constats === 'string') { try { constats = JSON.parse(constats); } catch { constats = []; } }\nif (!Array.isArray(constats)) constats = [];\nconst aExaminer = constats.filter((c) => c && c.gravite === 'a_examiner' && c.code && c.texte);\n\nconst revueDuJour = (input.revue_jour_date || '').toString().trim() === jourParis;\nconst dejaRepondu = (input.advisor_revue_date || '').toString().trim() === jourParis;\n// Pas de relance au-dela de 10 h : un modele qui echoue ne doit pas etre\n// rappele toute la journee (le courriel part a 08:15 sans lui).\nconst heureParis = Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false }).format(new Date()));\n\nconst ctx = {\n advisorEnabled: onOff(input.advisor_enabled),\n jour: jourParis,\n revueDuJour,\n dejaRepondu,\n constats: aExaminer.map((c) => ({ code: c.code, texte: String(c.texte), contexte: String(c.contexte || '') })),\n};\n\nconst systemPrompt = [\n \"Tu es le RELECTEUR du systeme energie d'une maison en Bretagne : 16 kWc de solaire,\",\n 'batterie de ~23 kWh utiles (0,30 kWh par point de SOC), Victron ESS SANS injection',\n '(le solaire est bride quand personne ne le prend), abonnement EDF 12 kVA, contacteur',\n 'EDF ouvert la plupart du temps, heures creuses 01:24-07:24 et 12:24-14:24, borne de',\n 'recharge pour trois vehicules. Des automates decident : le controleur de la borne,',\n \"le bilan energetique (SOC requis), l'ACM (courant de charge), l'hysteresis EDF, des\",\n 'automatisations Home Assistant.',\n '',\n 'On te donne des CONSTATS MESURES sur les dernieres 24 h. Ce sont des FAITS : ne les',\n 'conteste pas et ne les recalcule pas. Pour chacun, propose UNE piste : la cause la',\n 'plus probable et ce qu il faut verifier ou corriger. Tu ne pilotes rien.',\n '',\n 'REGLES ABSOLUES :',\n '- N ecris AUCUN chiffre qui ne figure pas dans le constat ou son contexte.',\n '- Si tu ne vois pas de cause, dis « cause non identifiable avec ces donnees ».',\n '- Une piste fait au plus 280 caracteres, en francais, sans jargon inutile.',\n '- Le contexte est la raison publiee par le controleur au debut de l episode.',\n '',\n 'Reponds UNIQUEMENT en JSON : {\"pistes\":[{\"code\":\"\",\"piste\":\"\"}]}',\n].join('\\n');\n\nconst userPrompt = [\n `Revue du ${jourParis}. ${aExaminer.length} constat(s) a examiner :`,\n '',\n ...aExaminer.map((c, i) => `${i + 1}. code=${c.code}\\n constat : ${c.texte}\\n contexte : ${String(c.contexte || '(aucun)').slice(0, 400)}`),\n '',\n 'JSON uniquement.',\n].join('\\n');\n\nreturn [{\n json: {\n enabled: ctx.advisorEnabled && revueDuJour && !dejaRepondu && aExaminer.length > 0 && heureParis < 10,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n context: ctx,\n },\n}];\n" } }, { @@ -235,7 +235,7 @@ -30 ], "parameters": { - "jsCode": "// =====================================================\n// EVCS ADVISOR SHADOW V1 — VALIDATE_PARSE\n// Valide STRICTEMENT la sortie LLM (locale OU cloud, format OpenAI).\n// Sortie invalide = REJET TOTAL (status invalid), jamais de clamp silencieux.\n// =====================================================\n\nconst resp = $input.first().json;\nconst context = $('BUILD_PROMPT').first().json.context;\n\n\nfunction fail(status, detail) {\n return [{\n json: {\n status,\n detail: (detail || '').toString().slice(0, 200),\n advice: null,\n modelUsed: (resp?.model || 'aucun').toString().slice(0, 60),\n context,\n }\n }];\n}\n\n// 1. Réponse LLM présente ? (les deux backends ont échoué => item d'erreur n8n)\nconst content = resp?.choices?.[0]?.message?.content;\nif (typeof content !== 'string' || !content.trim()) {\n return fail('llm_offline', resp?.error?.message || resp?.error || 'aucune réponse LLM');\n}\n\n// 2. Nettoyage : balises de raisonnement (...) et fences markdown\nlet text = content\n .replace(/[\\s\\S]*?<\\/think>/gi, '')\n .replace(/```(?:json)?/gi, '')\n .trim();\n\n// 3. Extraction du premier objet JSON équilibré\nconst start = text.indexOf('{');\nif (start === -1) return fail('invalid', 'aucun JSON dans la réponse');\nlet depth = 0, end = -1;\nfor (let i = start; i < text.length; i++) {\n if (text[i] === '{') depth++;\n else if (text[i] === '}') { depth--; if (depth === 0) { end = i; break; } }\n}\nif (end === -1) return fail('invalid', 'JSON non terminé');\n\nlet obj;\ntry {\n obj = JSON.parse(text.slice(start, end + 1));\n} catch (e) {\n return fail('invalid', `JSON invalide: ${e.message}`);\n}\n\n// 4. Validation stricte du schéma (enums + bornes)\n// ── OBSERVATION (12/09/2026) ───────────────────────────────────────────\n// La mission principale du conseiller n est plus de choisir une strategie\n// parmi cinq, mais de REPERER CE QUI CLOCHE. Les pannes de ce systeme sont\n// des contradictions entre automates — chacun localement correct — et elles\n// ne se voient que dans la suite des evenements. Le 12/09 le contacteur\n// reseau a bascule toutes les 4 minutes pendant dix minutes : aucun automate\n// ne l a vu, un humain oui.\nconst GRAVITES = ['aucune', 'faible', 'moyenne', 'haute'];\nconst constat = (obj.constat ?? '').toString().trim();\nif (!constat) return fail('invalid', 'constat vide');\nconst gravite = (obj.gravite ?? '').toString().trim().toLowerCase();\nif (!GRAVITES.includes(gravite)) return fail('invalid', `gravite hors enum: ${obj.gravite}`);\nconst ceQuiCloche = (obj.ce_qui_cloche ?? '').toString().trim();\nconst piste = (obj.piste ?? '').toString().trim();\n\nconst confidence = Number(obj.confidence);\nif (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {\n return fail('invalid', `confidence hors bornes: ${obj.confidence}`);\n}\n\nreturn [{\n json: {\n status: 'ok',\n detail: '',\n advice: {\n constat: constat.slice(0, 200),\n gravite,\n ceQuiCloche: ceQuiCloche.slice(0, 200),\n piste: piste.slice(0, 200),\n confidence: Math.round(confidence * 100) / 100,\n },\n modelUsed: (resp.model || 'inconnu').toString().slice(0, 60),\n context,\n }\n}];\n" + "jsCode": "// =====================================================\n// EVCS ADVISOR — VALIDATE_PARSE (relecteur, 08/10/2026)\n// Valide STRICTEMENT la sortie LLM (locale OU cloud, format OpenAI).\n// - JSON {\"pistes\":[{code, piste}]} ; sinon REJET TOTAL.\n// - une piste dont le code n'est pas celui d'un constat est ecartee ;\n// - ANTI-INVENTION : une piste qui contient un nombre absent du constat et de\n// son contexte est ecartee (le modele ne produit aucun chiffre, §79).\n// =====================================================\n\nconst resp = $input.first().json;\nconst context = $('BUILD_PROMPT').first().json.context;\n\nfunction fail(status, detail) {\n return [{ json: { status, detail: (detail || '').toString().slice(0, 200), pistes: null, rejetees: 0,\n modelUsed: (resp?.model || 'aucun').toString().slice(0, 60), context } }];\n}\n\nconst content = resp?.choices?.[0]?.message?.content;\nif (typeof content !== 'string' || !content.trim()) {\n return fail('llm_offline', resp?.error?.message || resp?.error || 'aucune réponse LLM');\n}\nconst text = content.replace(/[\\s\\S]*?<\\/think>/gi, '').replace(/```(?:json)?/gi, '').trim();\nconst start = text.indexOf('{');\nif (start === -1) return fail('invalid', 'aucun JSON dans la réponse');\nlet depth = 0, end = -1;\nfor (let i = start; i < text.length; i++) {\n if (text[i] === '{') depth++;\n else if (text[i] === '}') { depth--; if (depth === 0) { end = i; break; } }\n}\nif (end === -1) return fail('invalid', 'JSON non terminé');\nlet obj;\ntry { obj = JSON.parse(text.slice(start, end + 1)); } catch (e) { return fail('invalid', `JSON invalide: ${e.message}`); }\nif (!Array.isArray(obj.pistes)) return fail('invalid', 'champ pistes absent');\n\n// Nombres d'un texte, normalises (virgule decimale -> point, sans zeros inutiles).\nconst nombres = (s) => (String(s).match(/\\d+(?:[.,]\\d+)?/g) || []).map((x) => String(Number(x.replace(',', '.'))));\n\nconst parCode = new Map(context.constats.map((c) => [c.code, c]));\nconst pistes = [];\nlet rejetees = 0;\nfor (const p of obj.pistes) {\n const code = (p?.code ?? '').toString().trim();\n const piste = (p?.piste ?? '').toString().trim();\n const c = parCode.get(code);\n if (!c || !piste) { rejetees++; continue; }\n const permis = new Set(nombres(`${c.texte} ${c.contexte}`));\n if (nombres(piste).some((n) => !permis.has(n))) { rejetees++; continue; }\n if (pistes.some((x) => x.code === code)) { rejetees++; continue; }\n pistes.push({ code, piste: piste.slice(0, 280) });\n}\n\nreturn [{ json: { status: 'ok', detail: '', pistes, rejetees,\n modelUsed: (resp.model || 'inconnu').toString().slice(0, 60), context } }];\n" } }, { @@ -247,7 +247,7 @@ -30 ], "parameters": { - "jsCode": "// =====================================================\n// EVCS ADVISOR SHADOW V1 — BUILD_HA_CALLS\n// Écrit les helpers advisor_* + divergence + compteurs + logbook.\n// GARANTIE MODE OMBRE : ce node ne produit QUE des écritures vers\n// des entités advisor_* et le logbook. AUCUN switch, AUCUNE stratégie\n// réelle, AUCUNE commande borne. Ne pas ajouter d'autres entités ici.\n// =====================================================\n\nconst { status, detail, advice, modelUsed, context } = $input.first().json;\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n push('input_number', 'set_value', { entity_id: entity, value });\n}\n\n// Statut + horodatage + modèle : écrits à CHAQUE run\nsetText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : ''));\nsetText('input_text.evcs_advisor_last_run', stampParis());\nsetText('input_text.evcs_advisor_model', modelUsed);\n\nif (status === 'ok' && advice) {\n // Observation — la seule sortie desormais. Le conseiller ne planifie plus :\n // la planification est faite par le bilan, qui est calcule et mesure, et la\n // campagne du 12/09 a montre que lui demander les deux degradait les deux\n // (ecart de cible passe de 0 a 35 points sur certains cas des l ajout de la\n // mission d observation).\n setText('input_text.evcs_advisor_constat', advice.constat);\n setText('input_text.evcs_advisor_gravite', advice.gravite);\n setText('input_text.evcs_advisor_ce_qui_cloche', advice.ceQuiCloche || 'rien de signale');\n // Quand il n'y a rien a signaler il n'y a pas de piste, et recopier le\n // constat faisait afficher la meme phrase deux fois sur la carte — et\n // partir en double dans la colonne `piste` de la table d'observation.\n setText('input_text.evcs_advisor_reason',\n `[${Math.round(advice.confidence * 100)}%] ${advice.piste || 'rien a creuser'}`);\n\n // Un observateur qui signale en permanence ne sert a rien : on compte le\n // VOLUME de signalements pour pouvoir le constater.\n if (advice.gravite === 'aucune') {\n setNumber('input_number.evcs_advisor_agree_count', context.agreeCount + 1);\n } else {\n setNumber('input_number.evcs_advisor_diverge_count', context.divergeCount + 1);\n }\n\n push('logbook', 'log', {\n name: 'EVCS Advisor (observateur)',\n message: `[${advice.gravite}] ${advice.constat}`\n + (advice.ceQuiCloche ? ` || ${advice.ceQuiCloche}` : '')\n + (advice.piste ? ` || piste : ${advice.piste}` : ''),\n entity_id: 'input_text.evcs_advisor_constat',\n });\n}\n\nreturn calls;\n" + "jsCode": "// =====================================================\n// EVCS ADVISOR — BUILD_HA_CALLS (relecteur, 08/10/2026)\n// GARANTIE : ce node n'ecrit QUE des entites advisor (input_text.evcs_advisor_*,\n// sensor.evcs_advisor_revue) et le logbook. AUCUN switch, AUCUNE strategie,\n// AUCUNE commande. Ne pas ajouter d'autres entites ici.\n// Les items `state_entity` sont envoyes a /api/states (capteur avec attributs),\n// les autres a /api/services//.\n// =====================================================\n\nconst { status, detail, pistes, rejetees, modelUsed, context } = $input.first().json;\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date());\n const g = (t) => p.find((x) => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\nconst push = (domain, service, data) => calls.push({ json: { domain, service, data } });\nconst setText = (entity, value) => push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n\nsetText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : ''));\nsetText('input_text.evcs_advisor_last_run', stampParis());\nsetText('input_text.evcs_advisor_model', modelUsed);\n\nif (status === 'ok' && Array.isArray(pistes)) {\n calls.push({ json: { state_entity: 'sensor.evcs_advisor_revue', data: {\n state: context.jour,\n attributes: { friendly_name: 'Revue du conseiller IA', pistes, rejetees, modele: modelUsed,\n avertissement: 'Avis a verifier, pas des faits. Les constats chiffres sont dans sensor.evcs_revue_jour.' },\n } } });\n setText('input_text.evcs_advisor_reason',\n `Revue du ${context.jour} : ${pistes.length} piste(s)${rejetees ? `, ${rejetees} ecartee(s)` : ''}`);\n push('logbook', 'log', {\n name: 'EVCS Advisor (relecteur)',\n message: pistes.map((p) => `[${p.code}] ${p.piste}`).join(' || ') || 'aucune piste retenue',\n entity_id: 'input_text.evcs_advisor_reason',\n });\n}\n\nreturn calls;\n" } }, { @@ -260,7 +260,7 @@ ], "parameters": { "method": "POST", - "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}", + "url": "={{ $json.state_entity ? 'http://10.10.10.17:8123/api/states/' + $json.state_entity : 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}", "authentication": "genericCredentialType", "genericAuthType": "httpBearerAuth", "sendHeaders": true, diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index 6b4e311..dcc414e 100644 --- a/workflows/evcs-energy-balance-v1.json +++ b/workflows/evcs-energy-balance-v1.json @@ -111,7 +111,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 40,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// =====================================================\n// CHARGE HC MANUELLE — UN PLANCHER, PAS UNE CIBLE (18/09/2026)\n// =====================================================\n// Le proprietaire veut pouvoir demander une recharge batterie en heures creuses\n// quand le bilan ne l'a pas jugee necessaire — journee d'hiver courte, ciel\n// couvert. La decision qu'il a tranchee : CONTRAINDRE le bilan plutot que de\n// l'ignorer, pour garder un seul arbitre. Ce projet a deja paye cher les\n// automates concurrents (charges programmees de l'ESS §26, consigne a 4200 W\n// §27, plancher SOC du GX §27).\n//\n// ⚠️ LE FORCAGE DOIT ETRE ICI, EN AMONT, ET NULLE PART AILLEURS. Mesure du\n// 18/09 sur banc : ecrire directement dans `input_number.evcs_balance_required_soc`\n// engage bien la session — contacteur ferme, takeover ON — mais laisse la\n// consigne reseau a 700 W, donc la batterie recoit une centaine de watts. C'est\n// le defaut du 01/09 et du 04/09 a l'identique, et c'est le seul resultat des\n// trois qui soit INDIAGNOSTICABLE depuis le tableau de bord : il a l'air de\n// marcher. La raison : `required_soc` ENGAGE la session, `grid_energy_needed_kwh`\n// la FINANCE, et les deux sont publiees separement. Pose ici, dans `coverage()`,\n// le plancher fait bouger les deux ENSEMBLE par construction — c'est une\n// consequence de la forme du code et non une discipline a tenir.\n//\n// ⚠️ ET CE PLANCHER EST DEJA FILTRE PAR L'HEURE QUAND IL ARRIVE ICI. Le bilan\n// ignore totalement les fenetres d'heures creuses (aucune occurrence de\n// `hc_night` / `hc_midday` dans ce sous-systeme) et doit le rester : il repond a\n// « de combien la batterie a-t-elle besoin », pas a « quand est-ce bon marche ».\n// C'est Home Assistant qui detient le calendrier tarifaire et qui ne publie une\n// valeur non nulle que pendant une fenetre creuse. Sans ce filtrage en amont, un\n// plancher a 85 % fuirait dans la journee et mordrait sur\n// `BATTERY_CLOUD_LOCKS.js` — `eveningSocStartFloorPct = max(eveningSocStartMin,\n// balanceRequiredSocPct)` — au point de bloquer la charge du vehicule le soir.\nconst manualHcFloorPct = (() => {\n const v = num(input.manual_hc_floor_percent, 0);\n return Number.isFinite(v) && v > socFloorPct && v <= 100 ? v : 0;\n})();\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\n// ---------------------------------------------------------------------\n// LA NUIT PREVUE PAR LA SOIREE (28/09/2026, demande du proprietaire)\n//\n// Le profil p50 est une mediane HORAIRE : il ignore la soiree qui vient de se\n// passer. Or sur 24 nuits, la maison consomme d'autant plus de 21:30 a 09:00\n// que la soiree 18:00-21:30 a ete chargee (four) : nuit = 6,98 + 0,74 x soiree,\n// r2 = 0,40. Le service d'historisation publie le FACTEUR nuit prevue / nuit\n// moyenne (sensor.evcs_nuit_maison_prevue_kwh) ; on multiplie le profil par ce\n// facteur sur la seule fenetre 21:30 -> 09:00, au prorata des heures a cheval.\n// Le 28/09 : soiree 6,27 kWh -> facteur 1,09.\n//\n// Un FACTEUR et non des kWh : le profil vient des compteurs d'energie, la\n// regression des puissances integrees ; le rapport annule l'ecart de methode.\n// Dans les deux sens pour le profil TYPIQUE (c'est un estimateur, une soiree\n// legere allege la nuit) ; le PRUDENT ne descend jamais : max(p80, p50 recale).\n// Filet : facteur borne a [0,75 ; 1,45], les extremes des 24 nuits observees\n// (0,77 et 1,41 fois la moyenne) — hors de la, c'est une mesure fausse, pas une\n// nuit. Facteur absent, perime (> 30 min) ou hors fenetre : profil inchange.\nconst NIGHT_FACTOR_MIN = 0.75;\nconst NIGHT_FACTOR_MAX = 1.45;\nconst nowMsNight = num(input.nowMs, Date.now());\nconst nightStartMs = num(input.night_house_start_ms, NaN);\nconst nightEndMs = num(input.night_house_end_ms, NaN);\nconst nightFactorRaw = num(input.night_house_factor, NaN);\nconst nightComputedMs = num(input.night_house_computed_ms, NaN);\nconst nightModelActive =\n Number.isFinite(nightFactorRaw) && nightFactorRaw > 0 &&\n Number.isFinite(nightStartMs) && Number.isFinite(nightEndMs) && nightEndMs > nightStartMs &&\n nowMsNight >= nightStartMs && nowMsNight < nightEndMs &&\n Number.isFinite(nightComputedMs) && nowMsNight - nightComputedMs <= 30 * 60000;\nconst nightFactor = nightModelActive\n ? Math.min(NIGHT_FACTOR_MAX, Math.max(NIGHT_FACTOR_MIN, nightFactorRaw))\n : 1;\nfunction nightShare(startMs) {\n const debut = num(startMs, NaN);\n if (!nightModelActive || !Number.isFinite(debut)) return 0;\n const a = Math.max(debut, nightStartMs), b = Math.min(debut + 3600000, nightEndMs);\n return b > a ? (b - a) / 3600000 : 0;\n}\nfunction nightScaled(profile, slot) {\n const h = num(slot.hour, 0);\n return (profile[h] ?? 0) * (1 + (nightFactor - 1) * nightShare(slot.startMs));\n}\n\n// =====================================================================\n// JOUR FAIBLE : CE QUE LA JOURNEE DIT DEJA (07/10/2026, proprietaire)\n// =====================================================================\n// Le 07/10 la HC de midi visait 44 % (« arriver a 01:24 au-dessus de 22 % »),\n// et EDF a quand meme ete ferme en HEURES PLEINES a 18:03 (~8 kWh HP). Deux\n// erreurs du calcul, corrigees ici :\n// 1. La reprise du chauffe-eau de 16 h a 20 h (douche de 20 h, 2,1 kWh mesures\n// le 07/10) n'etait pas comptee : les jours faibles (decision du bilan),\n// `ecs_reprise_soir_kwh` est ajoute a la consommation de 16 h a 20 h du jour.\n// 2. La prevision de l'apres-midi n'etait pas recalee sur la matinee : VRM\n// annoncait ~9 kWh de 14 h a 19 h, il en est venu ~5, et la matinee le\n// disait deja (3,1 kWh produits a 12:36 pour ~5 prevus). Quand le reel du\n// jour est EN RETARD sur la prevision des heures ecoulees (au moins 2 kWh\n// prevus, production complete), les heures restantes du jour sont\n// multipliees par le meme rapport, borne a [0,4 ; 1]. Jamais a la hausse :\n// une matinee meilleure que prevu ne promet rien pour l'apres-midi.\nconst ECS_SOIR_H0 = 16;\nconst ECS_SOIR_H1 = 20;\nconst nowMsSlots = num(input.nowMs, Date.now());\nconst jourParis = (ms) => new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });\nconst aujourdhuiParis = jourParis(nowMsSlots);\nfunction slotIsToday(slot) {\n return Number.isFinite(num(slot.startMs, NaN)) && jourParis(num(slot.startMs, 0)) === aujourdhuiParis;\n}\nconst ecsNuitAujourdhui = typeof input.ecs_nuit_jour === 'string'\n && input.ecs_nuit_jour.startsWith(aujourdhuiParis + '|on');\nconst ecsRepriseSoirKwh = ecsNuitAujourdhui ? Math.max(0, num(input.ecs_reprise_soir_kwh, 0)) : 0;\nlet prodRecal = { factor: 1, active: false };\n{\n const prevu = typeof input.forecast_today_csv === 'string' ? input.forecast_today_csv.split(',').map(Number) : [];\n const reel = num(input.production_jour_kwh, NaN);\n if (prevu.length === 24 && prevu.every(Number.isFinite) && Number.isFinite(reel)\n && input.production_jour_statut === 'complet') {\n const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false })\n .format(new Date(nowMsSlots)).split(':').map(Number);\n let ecoule = 0;\n for (let h = 0; h < 24; h++) ecoule += h < p[0] ? prevu[h] : (h === p[0] ? prevu[h] * p[1] / 60 : 0);\n if (ecoule >= 2) {\n const r = reel / ecoule;\n prodRecal = { factor: round2(Math.max(0.4, Math.min(1, r))), active: r < 1, ratio: round2(r),\n realKwh: round2(reel), expectedSoFarKwh: round2(ecoule) };\n }\n }\n}\nconst prodRecalFactor = prodRecal.factor;\n\nfunction buildSlots(consumption, prudent = false) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const aujourdhui = slotIsToday(slot);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0)) * (aujourdhui ? prodRecalFactor : 1);\n const consumptionKwh = (prudent\n ? Math.max(consumption[hour] ?? 0, nightScaled(consumptionP50, slot))\n : nightScaled(consumption, slot))\n + (aujourdhui && hour >= ECS_SOIR_H0 && hour < ECS_SOIR_H1 ? ecsRepriseSoirKwh / (ECS_SOIR_H1 - ECS_SOIR_H0) : 0);\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\n// =====================================================================\n// COURBE D'ENERGIE DE LA BATTERIE — MESUREE, PAS SUPPOSEE (03/09/2026)\n// =====================================================================\n// La conversion energie <-> points de SOC etait une CONSTANTE\n// (`usable_capacity_kwh`, 15 kWh) la ou la realite est une COURBE.\n//\n// Mesure : 576 paliers de SOC sur 8 jours (`tools/mesure-courbe-batterie.mjs`).\n// Pour chaque palier ou le SOC reste a une valeur puis DESCEND d'un point, on\n// integre la puissance batterie — en ecartant les paliers ou la batterie a\n// charge ou ou le reseau a apporte quoi que ce soit.\n//\n// tranche paliers kWh/point\n// 21-31 88 0,097 <- effondrement pres du genou\n// 35-44 78 0,171\n// 45-54 76 0,169\n// 55-64 85 0,163\n// 65-74 80 0,171\n// 75-84 81 0,170\n// 85-94 62 0,169\n//\n// Plate a 0,169 de 33 a 93, genou net a 32-33, puis 0,097 : sous le genou, un\n// point de SOC vaut 57 % de ce qu'il vaut au-dessus. Coherent avec §0 (un\n// module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la\n// jauge deja signale au §28 — ceci lui donne sa courbe.\n//\n// CE QUE LA CONSTANTE FAISAIT : 15 kWh se situe ENTRE les deux pentes, donc\n// l'erreur CHANGE DE SIGNE selon l'energie a couvrir —\n// 1 kWh a couvrir : SOC requis 28,7 % annonce contre 32,3 % reel (-3,6)\n// 4,3 kWh : 50,7 contre 52,1 (-1,4)\n// 8 kWh : 75,3 contre 74,0 (+1,3)\n// 11,7 kWh : 99,7 contre 95,7 (+4,1)\n// Trop BAS en fin de nuit (la direction dangereuse), trop HAUT en debut.\n//\n// Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par\n// le bilan et 13,65 par INPUT_PARSE. Les deux constantes existantes etaient\n// fausses, dans des sens opposes.\n//\n// ⚠️ A RE-MESURER apres toute evolution du parc (extension prevue cet hiver) :\n// `node tools/mesure-courbe-batterie.mjs 8`, puis mettre a jour ces trois\n// nombres. Ce ne sont pas des reglages, ce sont des resultats de mesure.\n//\n// ⚠️ CES PENTES ONT ETE MESUREES SUR SEPT MODULES. Retirer ou ajouter un module\n// divise ou multiplie la capacite sans changer la FORME de la courbe : on les\n// exprime donc par module, et on les multiplie par le nombre reellement en\n// service. Ce nombre se lit dans un helper — `input_number\n// .evcs_balance_modules_en_service` — et non dans le code : le parc a change\n// deux fois en une semaine et changera encore cet hiver. Helper absent ou\n// illisible => 7, la valeur sur laquelle la mesure a ete faite, donc aucun\n// changement de comportement tant que personne ne l'ecrit.\nconst MODULES_MESURE = 7;\nconst modulesEnService = (() => {\n const n = num(input.modules_en_service, MODULES_MESURE);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n : MODULES_MESURE;\n})();\nconst echelleParc = modulesEnService / MODULES_MESURE;\n\n// 20/09/2026 — RE-MESURE sur le parc ASSAINI (13 equivalents US2000 : 4 US5000\n// + 5 US2000C, cellules a 3-6 mV). `tools/mesure-courbe-batterie.mjs 8` :\n// 0,30 kWh/point PLAT de 25 a 97 %, PLUS DE GENOU. Le genou a 33 % etait bien\n// la signature de la cellule 12 morte du pack ancien (§50) — il a disparu avec\n// elle, exactement comme le predisait la note de prudence du 03/09. La reserve\n// reelle a quasiment double (12,4 -> ~23 kWh utiles de 100 a 22 %).\n// Base normalisee a 7 modules (MODULES_MESURE) : 0,30 x 7/13 = 0,1615 par point.\n// x echelleParc(13/7) = 0,30 au parc actuel. Pente unique => plus de genou.\n// A REMESURER apres toute evolution du parc (2e cable console en commande pour\n// surveiller aussi les cellules du pack 1 US5000).\nconst BATTERY_CURVE = {\n kneePct: 0,\n kwhPerPointAbove: 0.1615 * echelleParc,\n kwhPerPointBelow: 0.1615 * echelleParc,\n};\n\n// Energie que la batterie peut delivrer en descendant de socHi a socLo.\nfunction energyBetweenSoc(socLo, socHi) {\n if (!Number.isFinite(socLo) || !Number.isFinite(socHi) || socHi <= socLo) return 0;\n const knee = BATTERY_CURVE.kneePct;\n const lowSpan = Math.max(0, Math.min(socHi, knee) - Math.min(socLo, knee));\n const highSpan = Math.max(0, Math.max(socHi, knee) - Math.max(socLo, knee));\n return lowSpan * BATTERY_CURVE.kwhPerPointBelow\n + highSpan * BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC necessaire pour delivrer energyKwh en descendant jusqu'a floorPct.\n// NON ecrete : au-dela de 100 la valeur reste le signal de sous-dimensionnement\n// (cf. §26). L'extrapolation au-dessus de 100 utilise la pente haute.\nfunction socForEnergy(energyKwh, floorPct) {\n if (!Number.isFinite(energyKwh) || energyKwh <= 0) return floorPct;\n const knee = Math.max(floorPct, BATTERY_CURVE.kneePct);\n const belowKwh = (knee - floorPct) * BATTERY_CURVE.kwhPerPointBelow;\n if (energyKwh <= belowKwh) {\n return floorPct + energyKwh / BATTERY_CURVE.kwhPerPointBelow;\n }\n return knee + (energyKwh - belowKwh) / BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC atteint en ajoutant addKwh a partir de socPct.\nfunction socAfterEnergy(socPct, addKwh) {\n if (!Number.isFinite(addKwh) || addKwh <= 0) return socPct;\n return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0);\n}\n\n// =====================================================\n// TENIR JUSQU'A LA PROCHAINE FENETRE HC (28/09/2026)\n// =====================================================\n// Strategie du proprietaire, apres la nuit du 26-27/09 : P12 avait charge la\n// JEEP sur la batterie (72 -> 44 %) parce qu'il restait assez pour atteindre le\n// solaire du matin — alors que ce meme bilan annoncait deja « prochaine\n// traversee 17,35 kWh des 18 h -> SOC requis 79,8 % ». La journee (22 kWh reels)\n// n'a pas pu reconstituer la reserve. Sa regle : une voiture branchee se charge\n// en heures creuses sur le reseau, et la batterie doit TENIR JUSQU'A LA\n// PROCHAINE FENETRE HC ; si elle ne le peut pas, la fenetre HC de midi la\n// complete.\n//\n// Traduction : PENDANT une fenetre HC, l'exigence vaut au moins le SOC qu'il\n// faut avoir A LA FIN de cette fenetre pour atteindre le DEBUT de la suivante\n// sans passer sous le plancher, production prevue comprise (tranches horaires\n// ci-dessus, heure par heure, profil de conso du profil calcule).\n//\n// ⚠️ LE CALENDRIER RESTE DANS HOME ASSISTANT (§73) : le bilan ne recoit que deux\n// instants, la fin de la fenetre en cours (absente hors fenetre) et le debut de\n// la suivante. Hors fenetre HC, rien ne change.\n//\n// ⚠️ INSERE DANS coverage(), COMME LE PLANCHER MANUEL : la cible (required_soc,\n// qui ENGAGE la session et releve le plancher de P12) et le manque en kWh (qui\n// FINANCE la consigne) bougent ensemble par construction — jamais de session\n// muette (§73). Sens unique : il ne peut que RELEVER l'exigence.\nconst hcWindowEndMs = num(input.hc_window_end_ms, NaN);\nconst nextHcStartMs = num(input.next_hc_start_ms, NaN);\nconst nextNightHcStartMs = num(input.next_night_hc_start_ms, NaN);\nconst hcReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextHcStartMs)\n && nextHcStartMs > hcWindowEndMs;\n// DEUX HORIZONS, DEUX USAGES (decision du proprietaire, 28/09) :\n// - recharger la batterie au reseau : tenir jusqu'a la PROCHAINE fenetre HC\n// (la nuit : midi ; a midi : 01:24). Midi complete si besoin, et la matinee\n// garde de la place pour le solaire. -> required_soc (engage ET finance).\n// - laisser la voiture puiser dans la batterie la nuit (P12) : seulement si la\n// batterie tient ensuite jusqu'a la NUIT SUIVANTE (01:24), sans compter sur\n// midi. Sinon la voiture se charge sur le reseau en HC. -> required_soc_prudent,\n// qui n'est lu que comme plancher de P12 (et par le conseiller, pour info).\nconst hcNightReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextNightHcStartMs)\n && nextNightHcStartMs > hcWindowEndMs;\n\n// Energie a avoir en reserve a la fin de la fenetre : parcours A REBOURS des\n// tranches comprises entre les deux instants (au prorata de leur recouvrement).\n// Un surplus ne rembourse qu'un deficit POSTERIEUR — d'ou le sens du parcours.\nfunction hcReachFloorPct(slots, untilMs = nextHcStartMs, fromMs = hcWindowEndMs) {\n if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) return 0;\n let needKwh = 0;\n for (let i = slots.length - 1; i >= 0; i--) {\n const s = slots[i];\n const debut = num(s.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, fromMs);\n const b = Math.min(debut + 3600000, untilMs);\n if (b <= a) continue;\n needKwh = Math.max(0, needKwh - s.netKwh * (b - a) / 3600000);\n }\n return needKwh > 0 ? socForEnergy(needKwh, socFloorPct) : 0;\n}\n\nfunction coverage(slots, hcFloorPct = 0) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n // Le plancher manuel ne peut que RELEVER l'exigence, jamais l'abaisser — meme\n // sens unique que les cablages de §38, §39 et §40 : le bilan ne pousse jamais\n // dans la direction « couvre moins bien ».\n const requiredSocRawPct = Math.max(\n socForEnergy(energyToCoverKwh, socFloorPct),\n manualHcFloorPct,\n hcFloorPct\n );\n const socNeededPct = requiredSocRawPct - socFloorPct;\n const requiredSocPct = Math.min(100, requiredSocRawPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0,\n };\n}\n\nconst slotsP50 = buildSlots(consumptionP50);\nconst slotsP80 = buildSlots(consumptionP80, true);\nconst hcFloorReachPct = hcReachFloorPct(slotsP50, nextHcStartMs);\nconst hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;\nconst hcFloorPrudentReachPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);\n// ---------------------------------------------------------------------\n// JOUR FAIBLE : LA HC DE MIDI CHARGE AU MAXIMUM (08/10/2026, proprietaire)\n//\n// Le 07/10, « tenir jusqu'a 01:24 » visait 44 % (profil habituel) : la soiree\n// a ete plus lourde, EDF s'est ferme en heures pleines a 18:03. Un jour faible\n// (decision du bilan apres 07:24, `ecs_nuit_jour` = on), l'apres-midi ne\n// remplira pas la batterie : un kWh pris a midi remplace un kWh d'heures pleines\n// du soir, ou au pire un kWh d'heures creuses de la nuit (meme prix, ~10 % de\n// pertes). Viser juste ne protege de rien.\n//\n// La cible devient 100 % pendant la fenetre de MIDI (la prochaine HC est celle\n// de la nuit) : ce qui est REELLEMENT charge est fixe par la physique, pas par\n// un nombre — l'ACM regle le courant sur ce que l'abonnement laisse libre une\n// fois la maison (et la voiture) servie, plus le solaire, plafonne par les\n// 140 A des deux MultiPlus. 65, 70 ou 80 % selon la cuisine et le soleil.\n// La nuit, la regle « tenir jusqu'a midi » est inchangee.\n// ---------------------------------------------------------------------\nconst hcMiddayWindow = hcReachActive && hcNightReachActive && nextHcStartMs === nextNightHcStartMs;\nconst hcMaxCharge = hcMiddayWindow && ecsNuitAujourdhui;\nconst hcFloorTypicalPct = hcMaxCharge ? 100 : hcFloorReachPct;\nconst hcFloorPrudentPct = hcMaxCharge ? 100 : hcFloorPrudentReachPct;\nconst typical = coverage(slotsP50, hcFloorTypicalPct);\nconst prudent = coverage(slotsP80, hcFloorPrudentPct);\n\n// ---------------------------------------------------------------------\n// CE QUE LA SESSION DE NUIT ACHETERA AU RESEAU (29/09/2026)\n//\n// Incident du 28/09 : la prevision annoncait « 0,14 kWh a prendre sur EDF » et\n// le reseau en a fourni 4,6. Les deux etaient justes, mais le premier est le\n// MANQUE de la batterie : une fois engagee, la session tient la batterie et la\n// maison passe TOUTE la fenetre HC de nuit sur le reseau (§40). Le chiffre qui\n// repond a « combien vais-je acheter » est donc la maison non couverte par le\n// solaire sur la fenetre (profil habituel, recale sur la soiree), depuis\n// maintenant si la fenetre est ouverte. Le manque batterie s'y ajoute, a part.\n// ---------------------------------------------------------------------\nconst nightHcStartMs = num(input.night_hc_start_ms, NaN);\nconst nightHcEndMs = num(input.night_hc_end_ms, NaN);\nconst nightHcActive = Number.isFinite(nightHcStartMs) && Number.isFinite(nightHcEndMs)\n && nightHcEndMs > nightHcStartMs;\nlet nightHcHouseKwh = null;\nif (nightHcActive) {\n const depuis = Math.max(nightHcStartMs, num(input.nowMs, Date.now()));\n nightHcHouseKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, depuis), b = Math.min(debut + 3600000, nightHcEndMs);\n if (b > a) nightHcHouseKwh += Math.max(0, -sl.netKwh) * (b - a) / 3600000;\n }\n}\n\n// ---------------------------------------------------------------------\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire)\n//\n// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la\n// charge a cette valeur ou autoriser une charge complete avec EDF. » Choix du\n// 30/09 : profil PRUDENT (p80) et plancher du bilan (22 %) ; par defaut « sans\n// EDF » ; en charge complete, le budget batterie d'abord, EDF pour le reste.\n//\n// Budget = la plus petite reserve au-dessus du plancher que la batterie\n// connaitra entre l'ouverture de la fenetre HC de nuit (ou maintenant si elle\n// est ouverte) et le debut de la fenetre de nuit SUIVANTE, sur le profil\n// prudent. Retirer X kWh a l'ouverture abaisse toute la trajectoire de X :\n// le budget est donc le creux de la trajectoire. L'horizon jusqu'a la nuit\n// suivante applique « la batterie tient jusqu'a la prochaine HC » ; la\n// batterie pleine ecrete la recharge (on ne stocke pas au-dela de 100 %).\n// Le 30/09 01:25 : SOC 57, prudent 44,7 -> ~3,7 kWh.\n// ---------------------------------------------------------------------\nlet nightVehicleBudgetKwh = null;\nif (nightHcActive && Number.isFinite(socPct)) {\n const maintenant = num(input.nowMs, Date.now());\n const depuis = Math.max(nightHcStartMs, maintenant);\n const jusqua = nightHcStartMs + 24 * 3600000;\n const plein = energyBetweenSoc(socFloorPct, 100);\n let reserve = socPct >= socFloorPct\n ? energyBetweenSoc(socFloorPct, socPct) : -energyBetweenSoc(socPct, socFloorPct);\n let creux = maintenant >= depuis ? reserve : Infinity;\n for (const sl of slotsP80) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, maintenant), b = Math.min(debut + 3600000, jusqua);\n if (b <= a) continue;\n // La tranche qui contient l'ouverture est coupee en deux : le creux ne se\n // lit qu'a partir de l'ouverture.\n const coupes = depuis > a && depuis < b ? [a, depuis, b] : [a, b];\n for (let i = 0; i + 1 < coupes.length; i++) {\n reserve = Math.min(plein, reserve + sl.netKwh * (coupes[i + 1] - coupes[i]) / 3600000);\n if (coupes[i + 1] >= depuis) creux = Math.min(creux, reserve);\n }\n }\n // BORNE PAR LE PLANCHER PRUDENT (04/10/2026). Nuit du 03-04/10 : SOC 47,\n // prudent 43,6 -> ~1,0 kWh de marge, mais le creux a donne 1,41 kWh. La\n // voiture a pris toute la marge, la batterie a touche la cible du matin a\n // 02:57 et la reprise reseau a ferme EDF. Le budget ne peut donc jamais\n // depasser l'energie au-dessus du SOC requis PRUDENT, celui-la meme que la\n // voiture n'a pas le droit de franchir en assistance batterie (P12) : les\n // deux criteres sont desormais les memes par construction.\n const margePrudente = Number.isFinite(prudent.requiredSocPct)\n ? (socPct > prudent.requiredSocPct ? energyBetweenSoc(prudent.requiredSocPct, socPct) : 0)\n : 0;\n nightVehicleBudgetKwh = Number.isFinite(creux) ? Math.max(0, Math.min(creux, margePrudente)) : null;\n}\n\n// ---------------------------------------------------------------------\n// VEHICULE BRANCHE EN HC AVEC UN DEPART AVANT LA PROCHAINE HC (28/09/2026)\n//\n// Regle du proprietaire : « JEEP branchee engage le reseau en HC midi si\n// l'analyse de production le justifie ». La JEEP repart a 15:30 certains jours\n// (calendrier dans HA) : ce qu'elle n'a pas pris avant, elle ne le prendra pas\n// en HC la nuit. Ailleurs, elle reste branchee et la HC de 01:24 la finit au\n// meme tarif — fermer le contacteur a midi n'y gagnerait rien et etoufferait le\n// solaire (installation sans injection).\n//\n// Ce que le solaire peut donner a la voiture avant son depart : le bilan net\n// prevu (production - maison) d'ici le depart, profil p50 comme required_soc.\n// Une batterie SOUS son plancher « tenir jusqu'a la prochaine HC » (evalue AU\n// DEPART) est d'abord remontee par ce surplus.\n// ⚠️ 06/10/2026 (proprietaire : « bascule automatique sur force_grid_now sauf si\n// le solaire donne la charge ce midi ») : la batterie au-dessus de ce plancher\n// n'est PLUS comptee. Le controleur ne donne pas la batterie a la voiture en\n// journee (demarrage sur excedent, §36) sauf par l'assistance batterie, a partir\n// de VEHICLE_BATTERY_ASSIST_SOC_PCT : seule la part au-dessus est comptee. Le\n// 06/10 a 13:12, 4,08 kWh de batterie comptes donnaient « solaire suffisant »\n// (5,86 pour 4,4) alors que le controleur attendait un excedent qui n'est pas\n// venu : la JEEP serait partie a 15:30 sans charge.\n// Le reseau est justifie quand le besoin de la voiture, borne par ce que son\n// chargeur peut absorber avant le depart, depasse ce solaire.\n//\n// Besoin = (100 - niveau saisi) % x capacite MESUREE au compteur de la borne,\n// moins ce que la borne a deja delivre depuis la saisie. Aucun capteur ne donne\n// le niveau de la voiture : sans saisie valable, la regle se tait.\n//\n// Vaut pour les TROIS vehicules (JEEP, VW, moto ZERO) : capacite, puissance de\n// charge et calendrier viennent de HA pour le vehicule selectionne.\n// La puissance du chargeur EMBARQUE (plateau mesure : JEEP 3,66 kW, VW\n// 3,68 kW) borne ce que le vehicule peut prendre avant son depart ; inconnue\n// (0), on ne borne pas — l'erreur va alors vers « reseau justifie », c'est-a-\n// dire vers le vehicule charge, jamais vers la batterie videe.\n// Seuil de 0,5 kWh : en dessous une session ne se justifie pas — une fermeture\n// du contacteur coute 50 a 150 Wh de transitoire (§57), on exige un gain d'au\n// moins trois fois ce cout.\nconst VEHICLE_GRID_MIN_SHORTFALL_KWH = 0.5;\n// = batteryAssistSolarStartSocMin de BATTERY_CLOUD_LOCKS.js (verifie par un\n// test qui lit le controleur : les deux valeurs ne peuvent pas diverger).\nconst VEHICLE_BATTERY_ASSIST_SOC_PCT = 90;\nconst vehicleChargerKw = num(input.vehicle_charger_kw, NaN);\nconst nowMsBalance = num(input.nowMs, Date.now());\nconst vehicleConnected = input.vehicle_connected === true || input.vehicle_connected === 'true';\nconst vehicleDepartureMs = num(input.vehicle_departure_ms, NaN);\nconst vehicleLevelPct = num(input.vehicle_level_pct, NaN);\nconst vehicleLevelValid = input.vehicle_level_valid === true || input.vehicle_level_valid === 'true';\nconst vehicleCapacityKwh = num(input.vehicle_capacity_kwh, NaN);\nconst vehicleDeliveredKwh = Math.max(0, num(input.vehicle_delivered_since_level_kwh, 0));\nconst vehicleNeedKwh =\n vehicleLevelValid && Number.isFinite(vehicleLevelPct) && vehicleLevelPct >= 0 && vehicleLevelPct <= 100\n && Number.isFinite(vehicleCapacityKwh) && vehicleCapacityKwh > 0\n ? Math.max(0, (100 - vehicleLevelPct) / 100 * vehicleCapacityKwh - vehicleDeliveredKwh)\n : NaN;\nconst vehicleRuleActive =\n hcReachActive && vehicleConnected && Number.isFinite(vehicleNeedKwh)\n && Number.isFinite(vehicleDepartureMs) && vehicleDepartureMs > nowMsBalance\n && vehicleDepartureMs < nextHcStartMs;\nlet vehicleSolarKwh = null;\nlet vehicleReachableKwh = null;\nlet vehicleShortfallKwh = null;\nlet vehicleFloorAtDeparturePct = null;\nif (vehicleRuleActive) {\n vehicleFloorAtDeparturePct = hcReachFloorPct(slotsP50, nextHcStartMs, vehicleDepartureMs);\n let netKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, nowMsBalance);\n const b = Math.min(debut + 3600000, vehicleDepartureMs);\n if (b > a) netKwh += sl.netKwh * (b - a) / 3600000;\n }\n // Difference SIGNEE : une batterie SOUS son plancher doit d'abord etre\n // remontee par ce meme surplus avant que la voiture n'en voie un kWh.\n const plancherDepart = Math.max(socFloorPct, vehicleFloorAtDeparturePct);\n const manqueBatterieKwh = socPct >= plancherDepart ? 0 : energyBetweenSoc(socPct, plancherDepart);\n const seuilAssist = Math.max(plancherDepart, VEHICLE_BATTERY_ASSIST_SOC_PCT);\n const batterieAssistKwh = socPct > seuilAssist ? energyBetweenSoc(seuilAssist, socPct) : 0;\n vehicleSolarKwh = Math.max(0, netKwh - manqueBatterieKwh) + batterieAssistKwh;\n vehicleReachableKwh = Number.isFinite(vehicleChargerKw) && vehicleChargerKw > 0\n ? Math.min(vehicleNeedKwh, vehicleChargerKw * (vehicleDepartureMs - nowMsBalance) / 3600000)\n : vehicleNeedKwh;\n vehicleShortfallKwh = Math.max(0, vehicleReachableKwh - vehicleSolarKwh);\n}\nconst vehicleGridNeeded = vehicleRuleActive && vehicleShortfallKwh > VEHICLE_GRID_MIN_SHORTFALL_KWH;\n\n// ---------------------------------------------------------------------\n// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)\n//\n// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire\n// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est\n// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à\n// cette heure-là, car le déficit qui approche est celui de la nuit.\n//\n// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en\n// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint\n// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et\n// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.\n//\n// Profil PRUDENT : contrairement à la décision réseau immédiate, cette\n// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit\n// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.\n// ---------------------------------------------------------------------\nfunction nextDeficitWindow(slots) {\n const firstSurplus = slots.findIndex(s => s.netKwh > 0);\n if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };\n\n const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);\n if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };\n\n let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);\n if (end < 0) end = slots.length;\n\n const energyKwh = slots.slice(start, end)\n .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n\n // Surplus encore récupérable d'ici là : de quoi remplir, ou pas.\n const surplusBeforeKwh = slots.slice(0, start)\n .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);\n\n return {\n found: true,\n startHour: slots[start].hour,\n startMs: slots[start].startMs,\n hoursFromNow: start,\n endHour: end < slots.length ? slots[end].hour : null,\n complete: end < slots.length,\n energyKwh,\n // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie\n // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est\n // exactement le signal de sous-dimensionnement du stockage — l'écrêter le\n // masquerait.\n requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),\n surplusBeforeKwh,\n };\n}\n\nconst nextDeficit = nextDeficitWindow(prudent.slots);\n\n// ---------------------------------------------------------------------\n// CHAUFFE-EAU EN HC DE NUIT LES JOURS FAIBLES (07/10/2026, option b du\n// proprietaire). Le 06/10 (14,5 kWh produits), le chauffe-eau a pris 5,4 kWh\n// de 10 h a 15 h, soit l'energie qui manquait a la batterie pour la nuit.\n// Regle : si le solaire prevu (profil habituel) ne suffit pas a amener la\n// batterie au SOC requis au debut de la traversee du soir, EDF sera ferme la\n// nuit de toute facon -> l'eau se chauffe en HC de nuit sur EDF, pas avec le\n// solaire du jour.\n// ⚠️ DECISION UNE FOIS PAR JOUR, figee jusqu'au lendemain : retirer le\n// chauffe-eau de la journee AMELIORE la projection — reevaluee a chaque cycle,\n// la regle detruirait sa propre cause (oscillateur, cf. §24/§36). On decide au\n// premier calcul apres la fin de la fenetre HC de nuit du jour (la fenetre\n// suivante se termine alors dans plus de 12 h) ; la decision vaut pour la\n// journee et la nuit qui suit, jusqu'a la decision du lendemain.\n// ---------------------------------------------------------------------\nconst ecsNuitPrev = typeof input.ecs_nuit_jour === 'string' ? input.ecs_nuit_jour : '';\nconst ecsNuitPrevDate = ecsNuitPrev.split('|')[0] || '';\nconst nowMsEcs = num(input.nowMs, Date.now());\nconst todayParis = new Date(nowMsEcs).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });\nconst ecsNightHcEndMs = num(input.night_hc_end_ms, NaN);\nconst ecsDecisionDue = ecsNuitPrevDate !== todayParis\n && Number.isFinite(ecsNightHcEndMs) && ecsNightHcEndMs - nowMsEcs > 12 * 3600000;\nlet ecsNight = { due: ecsDecisionDue, decided: false, date: todayParis };\nif (ecsDecisionDue) {\n const nd50 = nextDeficitWindow(slotsP50);\n if (nd50.found) {\n // Trajectoire SIGNEE jusqu'au debut de la traversee (le deficit du matin\n // compte), bornee au plancher et a 100 %.\n let socProj = socPct;\n for (let i = 0; i < nd50.hoursFromNow; i++) {\n const net = slotsP50[i].netKwh;\n // socAfterEnergy ignore une energie negative : la decharge passe par\n // l'energie stockee (meme courbe).\n socProj = net >= 0 ? Math.min(100, socAfterEnergy(socProj, net))\n : Math.max(socFloorPct, socForEnergy(Math.max(0, energyBetweenSoc(0, socProj) + net), 0));\n }\n const on = socProj < nd50.requiredSocAtStartPct;\n ecsNight = {\n due: true, decided: true, date: todayParis, on,\n projectedSocPct: round1(socProj),\n requiredSocPct: round1(nd50.requiredSocAtStartPct),\n startHour: nd50.startHour,\n reason: on\n ? `jour faible : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude en HC de nuit`\n : `jour suffisant : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude au solaire`,\n };\n }\n}\n\n// ---------------------------------------------------------------------\n// RÉSERVE D'ORAGE (27/08/2026)\n//\n// Les coupures réseau sont fréquentes ici lors des orages. Point de départ\n// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et\n// la batterie continuent de fonctionner, seul le filet de secours disparaît.\n// La question n'est donc pas « combien stocker pour survivre » mais « la\n// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ».\n//\n// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut :\n// rien à inventer, aucune constante de plus. Le profil prudent est le bon\n// choix ici parce qu'une coupure retire précisément la possibilité de\n// rattraper une sous-estimation.\n//\n// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est\n// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de\n// l'émetteur, pas un nombre choisi ici.\n// ---------------------------------------------------------------------\nconst STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge'];\nconst STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange');\n\nconst stormRaw = typeof input.storm_alert_level === 'string'\n ? input.storm_alert_level.trim().toLowerCase()\n : '';\nconst stormIndex = STORM_SCALE.indexOf(stormRaw);\nconst stormKnown = stormIndex >= 0;\nconst stormActive = stormIndex >= STORM_ACTIVE_FROM;\n\n// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs\n// (« Orages », « Vent violent »…). Purement informatif.\nconst stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object')\n ? input.storm_alert_detail\n : {};\nconst stormPhenomena = Object.keys(stormDetail)\n .filter(k => typeof stormDetail[k] === 'string'\n && STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM)\n .sort();\n\n// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ;\n// en déficit, c'est celle qu'on est en train de traverser.\nconst inSurplusNow = prudent.found && prudent.crossingIndex === 0;\nconst stormWindow = (inSurplusNow && nextDeficit.found)\n ? {\n kwh: nextDeficit.energyKwh,\n socPct: nextDeficit.requiredSocAtStartPct,\n startHour: nextDeficit.startHour,\n }\n : {\n kwh: prudent.energyToCoverKwh,\n socPct: prudent.requiredSocPct,\n startHour: null,\n };\nconst stormMarginPct = socPct - stormWindow.socPct;\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.\n nextDeficit: nextDeficit.found ? {\n found: true,\n startHour: nextDeficit.startHour,\n hoursFromNow: nextDeficit.hoursFromNow,\n endHour: nextDeficit.endHour,\n complete: nextDeficit.complete,\n energyKwh: round2(nextDeficit.energyKwh),\n requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),\n surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),\n // Franchissable avec le stockage actuel, batterie pleine ?\n feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,\n // Si non : capacité utile manquante. C'est le chiffre du dimensionnement.\n storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n // Chauffe-eau en HC de nuit les jours faibles (07/10).\n ecsNight,\n // Reprise du soir et prevision du jour recalee sur la matinee (07/10).\n ecsReprise: { active: ecsRepriseSoirKwh > 0, kwh: round2(ecsRepriseSoirKwh) },\n prodRecal,\n\n // Nuit prevue par la soiree (28/09) : facteur applique au profil.\n nightModel: {\n active: nightModelActive,\n factor: Math.round(nightFactor * 1000) / 1000,\n rawFactor: Number.isFinite(nightFactorRaw) ? nightFactorRaw : null,\n forecastKwh: num(input.night_house_forecast_kwh, NaN) || null,\n },\n\n // Maison sur le reseau pendant la fenetre HC de nuit, si la session s'engage (29/09).\n nightHcImport: {\n active: nightHcActive,\n startMs: nightHcActive ? nightHcStartMs : null,\n endMs: nightHcActive ? nightHcEndMs : null,\n houseKwh: nightHcHouseKwh === null ? null : round2(nightHcHouseKwh),\n },\n\n // Ce que la voiture peut prendre sur la batterie cette nuit sans EDF (30/09).\n nightVehicleBudget: {\n active: nightVehicleBudgetKwh !== null,\n kwh: nightVehicleBudgetKwh === null ? null : round2(nightVehicleBudgetKwh),\n },\n\n // Voiture branchee en HC, depart avant la prochaine HC (28/09).\n vehicleMidday: {\n active: vehicleRuleActive,\n needKwh: Number.isFinite(vehicleNeedKwh) ? round2(vehicleNeedKwh) : null,\n departureMs: Number.isFinite(vehicleDepartureMs) ? vehicleDepartureMs : null,\n solarKwh: vehicleSolarKwh === null ? null : round2(vehicleSolarKwh),\n reachableKwh: vehicleReachableKwh === null ? null : round2(vehicleReachableKwh),\n shortfallKwh: vehicleShortfallKwh === null ? null : round2(vehicleShortfallKwh),\n floorAtDeparturePct: vehicleFloorAtDeparturePct === null ? null : round1(vehicleFloorAtDeparturePct),\n gridNeeded: vehicleGridNeeded,\n },\n\n // Tenir jusqu'a la prochaine fenetre HC (28/09) : SOC a avoir en fin de\n // fenetre HC en cours. 0 hors fenetre ou si la production suffit.\n hcReach: {\n active: hcReachActive,\n windowEndMs: hcReachActive ? hcWindowEndMs : null,\n nextHcStartMs: hcReachActive ? nextHcStartMs : null,\n floorPct: round1(Math.min(100, hcFloorTypicalPct)),\n // Jour faible a midi : cible 100 % (charge maximale) ; floorReachPct garde\n // le plancher « tenir jusqu'a 01:24 » calcule, pour comparaison.\n maxCharge: hcMaxCharge,\n floorReachPct: round1(Math.min(100, hcFloorReachPct)),\n floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)),\n nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null,\n nightFloorPrudentPct: round1(Math.min(100, hcFloorNightPrudentPct)),\n raisesRequirement: hcReachActive && hcFloorTypicalPct >= typical.requiredSocPct - 0.05\n && hcFloorTypicalPct > socForEnergy(typical.energyToCoverKwh, socFloorPct),\n },\n\n // Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui\n // compte. Publiée en permanence — elle documente le risque même par temps\n // calme ; `active` dit seulement si la vigilance la rend d'actualité.\n storm: {\n level: stormKnown ? stormRaw : 'indisponible',\n known: stormKnown,\n active: stormActive,\n phenomena: stormPhenomena,\n windowStartHour: stormWindow.startHour,\n reserveKwh: round2(stormWindow.kwh),\n reserveSocPct: round1(stormWindow.socPct),\n socMarginPct: round1(stormMarginPct),\n covered: stormMarginPct >= 0,\n shortfallKwh: stormMarginPct < 0\n ? round2(energyBetweenSoc(socPct, stormWindow.socPct))\n : 0,\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" + "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 40,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// =====================================================\n// CHARGE HC MANUELLE — UN PLANCHER, PAS UNE CIBLE (18/09/2026)\n// =====================================================\n// Le proprietaire veut pouvoir demander une recharge batterie en heures creuses\n// quand le bilan ne l'a pas jugee necessaire — journee d'hiver courte, ciel\n// couvert. La decision qu'il a tranchee : CONTRAINDRE le bilan plutot que de\n// l'ignorer, pour garder un seul arbitre. Ce projet a deja paye cher les\n// automates concurrents (charges programmees de l'ESS §26, consigne a 4200 W\n// §27, plancher SOC du GX §27).\n//\n// ⚠️ LE FORCAGE DOIT ETRE ICI, EN AMONT, ET NULLE PART AILLEURS. Mesure du\n// 18/09 sur banc : ecrire directement dans `input_number.evcs_balance_required_soc`\n// engage bien la session — contacteur ferme, takeover ON — mais laisse la\n// consigne reseau a 700 W, donc la batterie recoit une centaine de watts. C'est\n// le defaut du 01/09 et du 04/09 a l'identique, et c'est le seul resultat des\n// trois qui soit INDIAGNOSTICABLE depuis le tableau de bord : il a l'air de\n// marcher. La raison : `required_soc` ENGAGE la session, `grid_energy_needed_kwh`\n// la FINANCE, et les deux sont publiees separement. Pose ici, dans `coverage()`,\n// le plancher fait bouger les deux ENSEMBLE par construction — c'est une\n// consequence de la forme du code et non une discipline a tenir.\n//\n// ⚠️ ET CE PLANCHER EST DEJA FILTRE PAR L'HEURE QUAND IL ARRIVE ICI. Le bilan\n// ignore totalement les fenetres d'heures creuses (aucune occurrence de\n// `hc_night` / `hc_midday` dans ce sous-systeme) et doit le rester : il repond a\n// « de combien la batterie a-t-elle besoin », pas a « quand est-ce bon marche ».\n// C'est Home Assistant qui detient le calendrier tarifaire et qui ne publie une\n// valeur non nulle que pendant une fenetre creuse. Sans ce filtrage en amont, un\n// plancher a 85 % fuirait dans la journee et mordrait sur\n// `BATTERY_CLOUD_LOCKS.js` — `eveningSocStartFloorPct = max(eveningSocStartMin,\n// balanceRequiredSocPct)` — au point de bloquer la charge du vehicule le soir.\nconst manualHcFloorPct = (() => {\n const v = num(input.manual_hc_floor_percent, 0);\n return Number.isFinite(v) && v > socFloorPct && v <= 100 ? v : 0;\n})();\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\n// ---------------------------------------------------------------------\n// LA NUIT PREVUE PAR LA SOIREE (28/09/2026, demande du proprietaire)\n//\n// Le profil p50 est une mediane HORAIRE : il ignore la soiree qui vient de se\n// passer. Or sur 24 nuits, la maison consomme d'autant plus de 21:30 a 09:00\n// que la soiree 18:00-21:30 a ete chargee (four) : nuit = 6,98 + 0,74 x soiree,\n// r2 = 0,40. Le service d'historisation publie le FACTEUR nuit prevue / nuit\n// moyenne (sensor.evcs_nuit_maison_prevue_kwh) ; on multiplie le profil par ce\n// facteur sur la seule fenetre 21:30 -> 09:00, au prorata des heures a cheval.\n// Le 28/09 : soiree 6,27 kWh -> facteur 1,09.\n//\n// Un FACTEUR et non des kWh : le profil vient des compteurs d'energie, la\n// regression des puissances integrees ; le rapport annule l'ecart de methode.\n// Dans les deux sens pour le profil TYPIQUE (c'est un estimateur, une soiree\n// legere allege la nuit) ; le PRUDENT ne descend jamais : max(p80, p50 recale).\n// Filet : facteur borne a [0,75 ; 1,45], les extremes des 24 nuits observees\n// (0,77 et 1,41 fois la moyenne) — hors de la, c'est une mesure fausse, pas une\n// nuit. Facteur absent, perime (> 30 min) ou hors fenetre : profil inchange.\nconst NIGHT_FACTOR_MIN = 0.75;\nconst NIGHT_FACTOR_MAX = 1.45;\nconst nowMsNight = num(input.nowMs, Date.now());\nconst nightStartMs = num(input.night_house_start_ms, NaN);\nconst nightEndMs = num(input.night_house_end_ms, NaN);\nconst nightFactorRaw = num(input.night_house_factor, NaN);\nconst nightComputedMs = num(input.night_house_computed_ms, NaN);\nconst nightModelActive =\n Number.isFinite(nightFactorRaw) && nightFactorRaw > 0 &&\n Number.isFinite(nightStartMs) && Number.isFinite(nightEndMs) && nightEndMs > nightStartMs &&\n nowMsNight >= nightStartMs && nowMsNight < nightEndMs &&\n Number.isFinite(nightComputedMs) && nowMsNight - nightComputedMs <= 30 * 60000;\nconst nightFactor = nightModelActive\n ? Math.min(NIGHT_FACTOR_MAX, Math.max(NIGHT_FACTOR_MIN, nightFactorRaw))\n : 1;\nfunction nightShare(startMs) {\n const debut = num(startMs, NaN);\n if (!nightModelActive || !Number.isFinite(debut)) return 0;\n const a = Math.max(debut, nightStartMs), b = Math.min(debut + 3600000, nightEndMs);\n return b > a ? (b - a) / 3600000 : 0;\n}\nfunction nightScaled(profile, slot) {\n const h = num(slot.hour, 0);\n return (profile[h] ?? 0) * (1 + (nightFactor - 1) * nightShare(slot.startMs));\n}\n\n// =====================================================================\n// JOUR FAIBLE : CE QUE LA JOURNEE DIT DEJA (07/10/2026, proprietaire)\n// =====================================================================\n// Le 07/10 la HC de midi visait 44 % (« arriver a 01:24 au-dessus de 22 % »),\n// et EDF a quand meme ete ferme en HEURES PLEINES a 18:03 (~8 kWh HP). Deux\n// erreurs du calcul, corrigees ici :\n// 1. La reprise du chauffe-eau de 16 h a 20 h (douche de 20 h, 2,1 kWh mesures\n// le 07/10) n'etait pas comptee : les jours faibles (decision du bilan),\n// `ecs_reprise_soir_kwh` est ajoute a la consommation de 16 h a 20 h du jour.\n// 2. La prevision de l'apres-midi n'etait pas recalee sur la matinee : VRM\n// annoncait ~9 kWh de 14 h a 19 h, il en est venu ~5, et la matinee le\n// disait deja (3,1 kWh produits a 12:36 pour ~5 prevus). Quand le reel du\n// jour est EN RETARD sur la prevision des heures ecoulees (au moins 2 kWh\n// prevus, production complete), les heures restantes du jour sont\n// multipliees par le meme rapport, borne a [0,4 ; 1]. Jamais a la hausse :\n// une matinee meilleure que prevu ne promet rien pour l'apres-midi.\nconst ECS_SOIR_H0 = 16;\nconst ECS_SOIR_H1 = 20;\nconst nowMsSlots = num(input.nowMs, Date.now());\nconst jourParis = (ms) => new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });\nconst aujourdhuiParis = jourParis(nowMsSlots);\nfunction slotIsToday(slot) {\n return Number.isFinite(num(slot.startMs, NaN)) && jourParis(num(slot.startMs, 0)) === aujourdhuiParis;\n}\nconst ecsNuitAujourdhui = typeof input.ecs_nuit_jour === 'string'\n && input.ecs_nuit_jour.startsWith(aujourdhuiParis + '|on');\nconst ecsRepriseSoirKwh = ecsNuitAujourdhui ? Math.max(0, num(input.ecs_reprise_soir_kwh, 0)) : 0;\nlet prodRecal = { factor: 1, active: false };\n{\n const prevu = typeof input.forecast_today_csv === 'string' ? input.forecast_today_csv.split(',').map(Number) : [];\n const reel = num(input.production_jour_kwh, NaN);\n if (prevu.length === 24 && prevu.every(Number.isFinite) && Number.isFinite(reel)\n && input.production_jour_statut === 'complet') {\n const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false })\n .format(new Date(nowMsSlots)).split(':').map(Number);\n let ecoule = 0;\n for (let h = 0; h < 24; h++) ecoule += h < p[0] ? prevu[h] : (h === p[0] ? prevu[h] * p[1] / 60 : 0);\n if (ecoule >= 2) {\n const r = reel / ecoule;\n prodRecal = { factor: round2(Math.max(0.4, Math.min(1, r))), active: r < 1, ratio: round2(r),\n realKwh: round2(reel), expectedSoFarKwh: round2(ecoule) };\n }\n }\n}\nconst prodRecalFactor = prodRecal.factor;\n\nfunction buildSlots(consumption, prudent = false) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const aujourdhui = slotIsToday(slot);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0)) * (aujourdhui ? prodRecalFactor : 1);\n const consumptionKwh = (prudent\n ? Math.max(consumption[hour] ?? 0, nightScaled(consumptionP50, slot))\n : nightScaled(consumption, slot))\n + (aujourdhui && hour >= ECS_SOIR_H0 && hour < ECS_SOIR_H1 ? ecsRepriseSoirKwh / (ECS_SOIR_H1 - ECS_SOIR_H0) : 0);\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\n// =====================================================================\n// COURBE D'ENERGIE DE LA BATTERIE — MESUREE, PAS SUPPOSEE (03/09/2026)\n// =====================================================================\n// La conversion energie <-> points de SOC etait une CONSTANTE\n// (`usable_capacity_kwh`, 15 kWh) la ou la realite est une COURBE.\n//\n// Mesure : 576 paliers de SOC sur 8 jours (`tools/mesure-courbe-batterie.mjs`).\n// Pour chaque palier ou le SOC reste a une valeur puis DESCEND d'un point, on\n// integre la puissance batterie — en ecartant les paliers ou la batterie a\n// charge ou ou le reseau a apporte quoi que ce soit.\n//\n// tranche paliers kWh/point\n// 21-31 88 0,097 <- effondrement pres du genou\n// 35-44 78 0,171\n// 45-54 76 0,169\n// 55-64 85 0,163\n// 65-74 80 0,171\n// 75-84 81 0,170\n// 85-94 62 0,169\n//\n// Plate a 0,169 de 33 a 93, genou net a 32-33, puis 0,097 : sous le genou, un\n// point de SOC vaut 57 % de ce qu'il vaut au-dessus. Coherent avec §0 (un\n// module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la\n// jauge deja signale au §28 — ceci lui donne sa courbe.\n//\n// CE QUE LA CONSTANTE FAISAIT : 15 kWh se situe ENTRE les deux pentes, donc\n// l'erreur CHANGE DE SIGNE selon l'energie a couvrir —\n// 1 kWh a couvrir : SOC requis 28,7 % annonce contre 32,3 % reel (-3,6)\n// 4,3 kWh : 50,7 contre 52,1 (-1,4)\n// 8 kWh : 75,3 contre 74,0 (+1,3)\n// 11,7 kWh : 99,7 contre 95,7 (+4,1)\n// Trop BAS en fin de nuit (la direction dangereuse), trop HAUT en debut.\n//\n// Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par\n// le bilan et 13,65 par INPUT_PARSE. Les deux constantes existantes etaient\n// fausses, dans des sens opposes.\n//\n// ⚠️ A RE-MESURER apres toute evolution du parc (extension prevue cet hiver) :\n// `node tools/mesure-courbe-batterie.mjs 8`, puis mettre a jour ces trois\n// nombres. Ce ne sont pas des reglages, ce sont des resultats de mesure.\n//\n// ⚠️ CES PENTES ONT ETE MESUREES SUR SEPT MODULES. Retirer ou ajouter un module\n// divise ou multiplie la capacite sans changer la FORME de la courbe : on les\n// exprime donc par module, et on les multiplie par le nombre reellement en\n// service. Ce nombre se lit dans un helper — `input_number\n// .evcs_balance_modules_en_service` — et non dans le code : le parc a change\n// deux fois en une semaine et changera encore cet hiver. Helper absent ou\n// illisible => 7, la valeur sur laquelle la mesure a ete faite, donc aucun\n// changement de comportement tant que personne ne l'ecrit.\nconst MODULES_MESURE = 7;\nconst modulesEnService = (() => {\n const n = num(input.modules_en_service, MODULES_MESURE);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n : MODULES_MESURE;\n})();\nconst echelleParc = modulesEnService / MODULES_MESURE;\n\n// 20/09/2026 — RE-MESURE sur le parc ASSAINI (13 equivalents US2000 : 4 US5000\n// + 5 US2000C, cellules a 3-6 mV). `tools/mesure-courbe-batterie.mjs 8` :\n// 0,30 kWh/point PLAT de 25 a 97 %, PLUS DE GENOU. Le genou a 33 % etait bien\n// la signature de la cellule 12 morte du pack ancien (§50) — il a disparu avec\n// elle, exactement comme le predisait la note de prudence du 03/09. La reserve\n// reelle a quasiment double (12,4 -> ~23 kWh utiles de 100 a 22 %).\n// Base normalisee a 7 modules (MODULES_MESURE) : 0,30 x 7/13 = 0,1615 par point.\n// x echelleParc(13/7) = 0,30 au parc actuel. Pente unique => plus de genou.\n// A REMESURER apres toute evolution du parc (2e cable console en commande pour\n// surveiller aussi les cellules du pack 1 US5000).\nconst BATTERY_CURVE = {\n kneePct: 0,\n kwhPerPointAbove: 0.1615 * echelleParc,\n kwhPerPointBelow: 0.1615 * echelleParc,\n};\n\n// Energie que la batterie peut delivrer en descendant de socHi a socLo.\nfunction energyBetweenSoc(socLo, socHi) {\n if (!Number.isFinite(socLo) || !Number.isFinite(socHi) || socHi <= socLo) return 0;\n const knee = BATTERY_CURVE.kneePct;\n const lowSpan = Math.max(0, Math.min(socHi, knee) - Math.min(socLo, knee));\n const highSpan = Math.max(0, Math.max(socHi, knee) - Math.max(socLo, knee));\n return lowSpan * BATTERY_CURVE.kwhPerPointBelow\n + highSpan * BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC necessaire pour delivrer energyKwh en descendant jusqu'a floorPct.\n// NON ecrete : au-dela de 100 la valeur reste le signal de sous-dimensionnement\n// (cf. §26). L'extrapolation au-dessus de 100 utilise la pente haute.\nfunction socForEnergy(energyKwh, floorPct) {\n if (!Number.isFinite(energyKwh) || energyKwh <= 0) return floorPct;\n const knee = Math.max(floorPct, BATTERY_CURVE.kneePct);\n const belowKwh = (knee - floorPct) * BATTERY_CURVE.kwhPerPointBelow;\n if (energyKwh <= belowKwh) {\n return floorPct + energyKwh / BATTERY_CURVE.kwhPerPointBelow;\n }\n return knee + (energyKwh - belowKwh) / BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC atteint en ajoutant addKwh a partir de socPct.\nfunction socAfterEnergy(socPct, addKwh) {\n if (!Number.isFinite(addKwh) || addKwh <= 0) return socPct;\n return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0);\n}\n\n// =====================================================\n// TENIR JUSQU'A LA PROCHAINE FENETRE HC (28/09/2026)\n// =====================================================\n// Strategie du proprietaire, apres la nuit du 26-27/09 : P12 avait charge la\n// JEEP sur la batterie (72 -> 44 %) parce qu'il restait assez pour atteindre le\n// solaire du matin — alors que ce meme bilan annoncait deja « prochaine\n// traversee 17,35 kWh des 18 h -> SOC requis 79,8 % ». La journee (22 kWh reels)\n// n'a pas pu reconstituer la reserve. Sa regle : une voiture branchee se charge\n// en heures creuses sur le reseau, et la batterie doit TENIR JUSQU'A LA\n// PROCHAINE FENETRE HC ; si elle ne le peut pas, la fenetre HC de midi la\n// complete.\n//\n// Traduction : PENDANT une fenetre HC, l'exigence vaut au moins le SOC qu'il\n// faut avoir A LA FIN de cette fenetre pour atteindre le DEBUT de la suivante\n// sans passer sous le plancher, production prevue comprise (tranches horaires\n// ci-dessus, heure par heure, profil de conso du profil calcule).\n//\n// ⚠️ LE CALENDRIER RESTE DANS HOME ASSISTANT (§73) : le bilan ne recoit que deux\n// instants, la fin de la fenetre en cours (absente hors fenetre) et le debut de\n// la suivante. Hors fenetre HC, rien ne change.\n//\n// ⚠️ INSERE DANS coverage(), COMME LE PLANCHER MANUEL : la cible (required_soc,\n// qui ENGAGE la session et releve le plancher de P12) et le manque en kWh (qui\n// FINANCE la consigne) bougent ensemble par construction — jamais de session\n// muette (§73). Sens unique : il ne peut que RELEVER l'exigence.\nconst hcWindowEndMs = num(input.hc_window_end_ms, NaN);\nconst nextHcStartMs = num(input.next_hc_start_ms, NaN);\nconst nextNightHcStartMs = num(input.next_night_hc_start_ms, NaN);\nconst hcReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextHcStartMs)\n && nextHcStartMs > hcWindowEndMs;\n// DEUX HORIZONS, DEUX USAGES (decision du proprietaire, 28/09) :\n// - recharger la batterie au reseau : tenir jusqu'a la PROCHAINE fenetre HC\n// (la nuit : midi ; a midi : 01:24). Midi complete si besoin, et la matinee\n// garde de la place pour le solaire. -> required_soc (engage ET finance).\n// - laisser la voiture puiser dans la batterie la nuit (P12) : seulement si la\n// batterie tient ensuite jusqu'a la NUIT SUIVANTE (01:24), sans compter sur\n// midi. Sinon la voiture se charge sur le reseau en HC. -> required_soc_prudent,\n// qui n'est lu que comme plancher de P12 (et par le conseiller, pour info).\nconst hcNightReachActive = Number.isFinite(hcWindowEndMs) && Number.isFinite(nextNightHcStartMs)\n && nextNightHcStartMs > hcWindowEndMs;\n\n// Energie a avoir en reserve a la fin de la fenetre : parcours A REBOURS des\n// tranches comprises entre les deux instants (au prorata de leur recouvrement).\n// Un surplus ne rembourse qu'un deficit POSTERIEUR — d'ou le sens du parcours.\nfunction hcReachFloorPct(slots, untilMs = nextHcStartMs, fromMs = hcWindowEndMs) {\n if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) return 0;\n let needKwh = 0;\n for (let i = slots.length - 1; i >= 0; i--) {\n const s = slots[i];\n const debut = num(s.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, fromMs);\n const b = Math.min(debut + 3600000, untilMs);\n if (b <= a) continue;\n needKwh = Math.max(0, needKwh - s.netKwh * (b - a) / 3600000);\n }\n return needKwh > 0 ? socForEnergy(needKwh, socFloorPct) : 0;\n}\n\nfunction coverage(slots, hcFloorPct = 0) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n // Le plancher manuel ne peut que RELEVER l'exigence, jamais l'abaisser — meme\n // sens unique que les cablages de §38, §39 et §40 : le bilan ne pousse jamais\n // dans la direction « couvre moins bien ».\n const requiredSocRawPct = Math.max(\n socForEnergy(energyToCoverKwh, socFloorPct),\n manualHcFloorPct,\n hcFloorPct\n );\n const socNeededPct = requiredSocRawPct - socFloorPct;\n const requiredSocPct = Math.min(100, requiredSocRawPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0,\n };\n}\n\nconst slotsP50 = buildSlots(consumptionP50);\nconst slotsP80 = buildSlots(consumptionP80, true);\nconst hcFloorReachPct = hcReachFloorPct(slotsP50, nextHcStartMs);\nconst hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;\nconst hcFloorPrudentReachPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);\n// ---------------------------------------------------------------------\n// JOUR FAIBLE : LA HC DE MIDI CHARGE AU MAXIMUM (08/10/2026, proprietaire)\n//\n// Le 07/10, « tenir jusqu'a 01:24 » visait 44 % (profil habituel) : la soiree\n// a ete plus lourde, EDF s'est ferme en heures pleines a 18:03. Un jour faible\n// (decision du bilan apres 07:24, `ecs_nuit_jour` = on), l'apres-midi ne\n// remplira pas la batterie : un kWh pris a midi remplace un kWh d'heures pleines\n// du soir, ou au pire un kWh d'heures creuses de la nuit (meme prix, ~10 % de\n// pertes). Viser juste ne protege de rien.\n//\n// La cible devient 100 % pendant la fenetre de MIDI (la prochaine HC est celle\n// de la nuit) : ce qui est REELLEMENT charge est fixe par la physique, pas par\n// un nombre — l'ACM regle le courant sur ce que l'abonnement laisse libre une\n// fois la maison (et la voiture) servie, plus le solaire, plafonne par les\n// 140 A des deux MultiPlus. 65, 70 ou 80 % selon la cuisine et le soleil.\n// La nuit, la regle « tenir jusqu'a midi » est inchangee.\n// ---------------------------------------------------------------------\nconst hcMiddayWindow = hcReachActive && hcNightReachActive && nextHcStartMs === nextNightHcStartMs;\nconst hcMaxCharge = hcMiddayWindow && ecsNuitAujourdhui;\n// ---------------------------------------------------------------------\n// LA NUIT PREPARE AUSSI LE SOIR QUAND LA JOURNEE S'ANNONCE FAIBLE (08/10/2026)\n//\n// Nuit du 07-08/10 : session engagee, mais la cible « tenir jusqu'a midi »\n// valait 30,7 % alors que le meme bilan annoncait 99,3 % requis pour la\n// traversee de 18 h. Les heures creuses de la nuit coutent le meme prix que\n// celles de midi, et midi est borne par la maison (cuisine, voiture, BOOST).\n// Pendant la fenetre de NUIT : plus petit SOC de fin de fenetre qui, avec le\n// profil habituel (production ecretee a 100 %), amene la batterie au SOC requis\n// au debut de la traversee du soir. Si c'est hors d'atteinte, celui qui en\n// approche le plus SANS gaspiller (au-dela, le solaire du jour serait ecrete).\n// Journee suffisante -> rien ne change (le plus petit SOC est le plancher\n// « tenir jusqu'a midi »).\n// ---------------------------------------------------------------------\nconst hcNightWindow = hcReachActive && !hcMiddayWindow;\nlet nightEvening = { active: false };\nif (hcNightWindow) {\n const ndEve = nextDeficitWindow(slotsP50);\n if (ndEve.found && Number.isFinite(num(ndEve.startMs, NaN)) && ndEve.startMs > hcWindowEndMs) {\n const socAtEvening = (s0) => {\n let soc = s0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, hcWindowEndMs);\n const b = Math.min(debut + 3600000, ndEve.startMs);\n if (b <= a) continue;\n const net = sl.netKwh * (b - a) / 3600000;\n soc = net >= 0 ? Math.min(100, socAfterEnergy(soc, net))\n : Math.max(socFloorPct, socForEnergy(Math.max(0, energyBetweenSoc(0, soc) + net), 0));\n }\n return soc;\n };\n const requis = ndEve.requiredSocAtStartPct;\n const base = Math.max(socFloorPct, hcFloorReachPct);\n const objectif = Math.min(requis, socAtEvening(100)) - 0.05;\n let floor = base;\n if (socAtEvening(base) < objectif) {\n let lo = base, hi = 100;\n for (let k = 0; k < 30; k++) { const m = (lo + hi) / 2; if (socAtEvening(m) >= objectif) hi = m; else lo = m; }\n floor = hi;\n }\n nightEvening = {\n active: floor > base + 0.05,\n floorPct: round1(floor),\n eveningRequiredPct: round1(requis),\n eveningProjectedPct: round1(socAtEvening(base)),\n eveningStartHour: ndEve.startHour,\n };\n }\n}\nconst hcFloorTypicalPct = hcMaxCharge ? 100\n : (nightEvening.active ? Math.max(hcFloorReachPct, nightEvening.floorPct) : hcFloorReachPct);\nconst hcFloorPrudentPct = hcMaxCharge ? 100 : hcFloorPrudentReachPct;\nconst typical = coverage(slotsP50, hcFloorTypicalPct);\nconst prudent = coverage(slotsP80, hcFloorPrudentPct);\n\n// ---------------------------------------------------------------------\n// CE QUE LA SESSION DE NUIT ACHETERA AU RESEAU (29/09/2026)\n//\n// Incident du 28/09 : la prevision annoncait « 0,14 kWh a prendre sur EDF » et\n// le reseau en a fourni 4,6. Les deux etaient justes, mais le premier est le\n// MANQUE de la batterie : une fois engagee, la session tient la batterie et la\n// maison passe TOUTE la fenetre HC de nuit sur le reseau (§40). Le chiffre qui\n// repond a « combien vais-je acheter » est donc la maison non couverte par le\n// solaire sur la fenetre (profil habituel, recale sur la soiree), depuis\n// maintenant si la fenetre est ouverte. Le manque batterie s'y ajoute, a part.\n// ---------------------------------------------------------------------\nconst nightHcStartMs = num(input.night_hc_start_ms, NaN);\nconst nightHcEndMs = num(input.night_hc_end_ms, NaN);\nconst nightHcActive = Number.isFinite(nightHcStartMs) && Number.isFinite(nightHcEndMs)\n && nightHcEndMs > nightHcStartMs;\nlet nightHcHouseKwh = null;\nif (nightHcActive) {\n const depuis = Math.max(nightHcStartMs, num(input.nowMs, Date.now()));\n nightHcHouseKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, depuis), b = Math.min(debut + 3600000, nightHcEndMs);\n if (b > a) nightHcHouseKwh += Math.max(0, -sl.netKwh) * (b - a) / 3600000;\n }\n}\n\n// ---------------------------------------------------------------------\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire)\n//\n// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la\n// charge a cette valeur ou autoriser une charge complete avec EDF. » Choix du\n// 30/09 : profil PRUDENT (p80) et plancher du bilan (22 %) ; par defaut « sans\n// EDF » ; en charge complete, le budget batterie d'abord, EDF pour le reste.\n//\n// Budget = la plus petite reserve au-dessus du plancher que la batterie\n// connaitra entre l'ouverture de la fenetre HC de nuit (ou maintenant si elle\n// est ouverte) et le debut de la fenetre de nuit SUIVANTE, sur le profil\n// prudent. Retirer X kWh a l'ouverture abaisse toute la trajectoire de X :\n// le budget est donc le creux de la trajectoire. L'horizon jusqu'a la nuit\n// suivante applique « la batterie tient jusqu'a la prochaine HC » ; la\n// batterie pleine ecrete la recharge (on ne stocke pas au-dela de 100 %).\n// Le 30/09 01:25 : SOC 57, prudent 44,7 -> ~3,7 kWh.\n// ---------------------------------------------------------------------\nlet nightVehicleBudgetKwh = null;\nif (nightHcActive && Number.isFinite(socPct)) {\n const maintenant = num(input.nowMs, Date.now());\n const depuis = Math.max(nightHcStartMs, maintenant);\n const jusqua = nightHcStartMs + 24 * 3600000;\n const plein = energyBetweenSoc(socFloorPct, 100);\n let reserve = socPct >= socFloorPct\n ? energyBetweenSoc(socFloorPct, socPct) : -energyBetweenSoc(socPct, socFloorPct);\n let creux = maintenant >= depuis ? reserve : Infinity;\n for (const sl of slotsP80) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, maintenant), b = Math.min(debut + 3600000, jusqua);\n if (b <= a) continue;\n // La tranche qui contient l'ouverture est coupee en deux : le creux ne se\n // lit qu'a partir de l'ouverture.\n const coupes = depuis > a && depuis < b ? [a, depuis, b] : [a, b];\n for (let i = 0; i + 1 < coupes.length; i++) {\n reserve = Math.min(plein, reserve + sl.netKwh * (coupes[i + 1] - coupes[i]) / 3600000);\n if (coupes[i + 1] >= depuis) creux = Math.min(creux, reserve);\n }\n }\n // BORNE PAR LE PLANCHER PRUDENT (04/10/2026). Nuit du 03-04/10 : SOC 47,\n // prudent 43,6 -> ~1,0 kWh de marge, mais le creux a donne 1,41 kWh. La\n // voiture a pris toute la marge, la batterie a touche la cible du matin a\n // 02:57 et la reprise reseau a ferme EDF. Le budget ne peut donc jamais\n // depasser l'energie au-dessus du SOC requis PRUDENT, celui-la meme que la\n // voiture n'a pas le droit de franchir en assistance batterie (P12) : les\n // deux criteres sont desormais les memes par construction.\n const margePrudente = Number.isFinite(prudent.requiredSocPct)\n ? (socPct > prudent.requiredSocPct ? energyBetweenSoc(prudent.requiredSocPct, socPct) : 0)\n : 0;\n nightVehicleBudgetKwh = Number.isFinite(creux) ? Math.max(0, Math.min(creux, margePrudente)) : null;\n}\n\n// ---------------------------------------------------------------------\n// VEHICULE BRANCHE EN HC AVEC UN DEPART AVANT LA PROCHAINE HC (28/09/2026)\n//\n// Regle du proprietaire : « JEEP branchee engage le reseau en HC midi si\n// l'analyse de production le justifie ». La JEEP repart a 15:30 certains jours\n// (calendrier dans HA) : ce qu'elle n'a pas pris avant, elle ne le prendra pas\n// en HC la nuit. Ailleurs, elle reste branchee et la HC de 01:24 la finit au\n// meme tarif — fermer le contacteur a midi n'y gagnerait rien et etoufferait le\n// solaire (installation sans injection).\n//\n// Ce que le solaire peut donner a la voiture avant son depart : le bilan net\n// prevu (production - maison) d'ici le depart, profil p50 comme required_soc.\n// Une batterie SOUS son plancher « tenir jusqu'a la prochaine HC » (evalue AU\n// DEPART) est d'abord remontee par ce surplus.\n// ⚠️ 06/10/2026 (proprietaire : « bascule automatique sur force_grid_now sauf si\n// le solaire donne la charge ce midi ») : la batterie au-dessus de ce plancher\n// n'est PLUS comptee. Le controleur ne donne pas la batterie a la voiture en\n// journee (demarrage sur excedent, §36) sauf par l'assistance batterie, a partir\n// de VEHICLE_BATTERY_ASSIST_SOC_PCT : seule la part au-dessus est comptee. Le\n// 06/10 a 13:12, 4,08 kWh de batterie comptes donnaient « solaire suffisant »\n// (5,86 pour 4,4) alors que le controleur attendait un excedent qui n'est pas\n// venu : la JEEP serait partie a 15:30 sans charge.\n// Le reseau est justifie quand le besoin de la voiture, borne par ce que son\n// chargeur peut absorber avant le depart, depasse ce solaire.\n//\n// Besoin = (100 - niveau saisi) % x capacite MESUREE au compteur de la borne,\n// moins ce que la borne a deja delivre depuis la saisie. Aucun capteur ne donne\n// le niveau de la voiture : sans saisie valable, la regle se tait.\n//\n// Vaut pour les TROIS vehicules (JEEP, VW, moto ZERO) : capacite, puissance de\n// charge et calendrier viennent de HA pour le vehicule selectionne.\n// La puissance du chargeur EMBARQUE (plateau mesure : JEEP 3,66 kW, VW\n// 3,68 kW) borne ce que le vehicule peut prendre avant son depart ; inconnue\n// (0), on ne borne pas — l'erreur va alors vers « reseau justifie », c'est-a-\n// dire vers le vehicule charge, jamais vers la batterie videe.\n// Seuil de 0,5 kWh : en dessous une session ne se justifie pas — une fermeture\n// du contacteur coute 50 a 150 Wh de transitoire (§57), on exige un gain d'au\n// moins trois fois ce cout.\nconst VEHICLE_GRID_MIN_SHORTFALL_KWH = 0.5;\n// = batteryAssistSolarStartSocMin de BATTERY_CLOUD_LOCKS.js (verifie par un\n// test qui lit le controleur : les deux valeurs ne peuvent pas diverger).\nconst VEHICLE_BATTERY_ASSIST_SOC_PCT = 90;\nconst vehicleChargerKw = num(input.vehicle_charger_kw, NaN);\nconst nowMsBalance = num(input.nowMs, Date.now());\nconst vehicleConnected = input.vehicle_connected === true || input.vehicle_connected === 'true';\nconst vehicleDepartureMs = num(input.vehicle_departure_ms, NaN);\nconst vehicleLevelPct = num(input.vehicle_level_pct, NaN);\nconst vehicleLevelValid = input.vehicle_level_valid === true || input.vehicle_level_valid === 'true';\nconst vehicleCapacityKwh = num(input.vehicle_capacity_kwh, NaN);\nconst vehicleDeliveredKwh = Math.max(0, num(input.vehicle_delivered_since_level_kwh, 0));\nconst vehicleNeedKwh =\n vehicleLevelValid && Number.isFinite(vehicleLevelPct) && vehicleLevelPct >= 0 && vehicleLevelPct <= 100\n && Number.isFinite(vehicleCapacityKwh) && vehicleCapacityKwh > 0\n ? Math.max(0, (100 - vehicleLevelPct) / 100 * vehicleCapacityKwh - vehicleDeliveredKwh)\n : NaN;\nconst vehicleRuleActive =\n hcReachActive && vehicleConnected && Number.isFinite(vehicleNeedKwh)\n && Number.isFinite(vehicleDepartureMs) && vehicleDepartureMs > nowMsBalance\n && vehicleDepartureMs < nextHcStartMs;\nlet vehicleSolarKwh = null;\nlet vehicleReachableKwh = null;\nlet vehicleShortfallKwh = null;\nlet vehicleFloorAtDeparturePct = null;\nif (vehicleRuleActive) {\n vehicleFloorAtDeparturePct = hcReachFloorPct(slotsP50, nextHcStartMs, vehicleDepartureMs);\n let netKwh = 0;\n for (const sl of slotsP50) {\n const debut = num(sl.startMs, NaN);\n if (!Number.isFinite(debut)) continue;\n const a = Math.max(debut, nowMsBalance);\n const b = Math.min(debut + 3600000, vehicleDepartureMs);\n if (b > a) netKwh += sl.netKwh * (b - a) / 3600000;\n }\n // Difference SIGNEE : une batterie SOUS son plancher doit d'abord etre\n // remontee par ce meme surplus avant que la voiture n'en voie un kWh.\n const plancherDepart = Math.max(socFloorPct, vehicleFloorAtDeparturePct);\n const manqueBatterieKwh = socPct >= plancherDepart ? 0 : energyBetweenSoc(socPct, plancherDepart);\n const seuilAssist = Math.max(plancherDepart, VEHICLE_BATTERY_ASSIST_SOC_PCT);\n const batterieAssistKwh = socPct > seuilAssist ? energyBetweenSoc(seuilAssist, socPct) : 0;\n vehicleSolarKwh = Math.max(0, netKwh - manqueBatterieKwh) + batterieAssistKwh;\n vehicleReachableKwh = Number.isFinite(vehicleChargerKw) && vehicleChargerKw > 0\n ? Math.min(vehicleNeedKwh, vehicleChargerKw * (vehicleDepartureMs - nowMsBalance) / 3600000)\n : vehicleNeedKwh;\n vehicleShortfallKwh = Math.max(0, vehicleReachableKwh - vehicleSolarKwh);\n}\nconst vehicleGridNeeded = vehicleRuleActive && vehicleShortfallKwh > VEHICLE_GRID_MIN_SHORTFALL_KWH;\n\n// ---------------------------------------------------------------------\n// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)\n//\n// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire\n// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est\n// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à\n// cette heure-là, car le déficit qui approche est celui de la nuit.\n//\n// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en\n// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint\n// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et\n// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.\n//\n// Profil PRUDENT : contrairement à la décision réseau immédiate, cette\n// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit\n// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.\n// ---------------------------------------------------------------------\nfunction nextDeficitWindow(slots) {\n const firstSurplus = slots.findIndex(s => s.netKwh > 0);\n if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };\n\n const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);\n if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };\n\n let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);\n if (end < 0) end = slots.length;\n\n const energyKwh = slots.slice(start, end)\n .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n\n // Surplus encore récupérable d'ici là : de quoi remplir, ou pas.\n const surplusBeforeKwh = slots.slice(0, start)\n .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);\n\n return {\n found: true,\n startHour: slots[start].hour,\n startMs: slots[start].startMs,\n hoursFromNow: start,\n endHour: end < slots.length ? slots[end].hour : null,\n complete: end < slots.length,\n energyKwh,\n // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie\n // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est\n // exactement le signal de sous-dimensionnement du stockage — l'écrêter le\n // masquerait.\n requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),\n surplusBeforeKwh,\n };\n}\n\nconst nextDeficit = nextDeficitWindow(prudent.slots);\n\n// ---------------------------------------------------------------------\n// CHAUFFE-EAU EN HC DE NUIT LES JOURS FAIBLES (07/10/2026, option b du\n// proprietaire). Le 06/10 (14,5 kWh produits), le chauffe-eau a pris 5,4 kWh\n// de 10 h a 15 h, soit l'energie qui manquait a la batterie pour la nuit.\n// Regle : si le solaire prevu (profil habituel) ne suffit pas a amener la\n// batterie au SOC requis au debut de la traversee du soir, EDF sera ferme la\n// nuit de toute facon -> l'eau se chauffe en HC de nuit sur EDF, pas avec le\n// solaire du jour.\n// ⚠️ DECISION UNE FOIS PAR JOUR, figee jusqu'au lendemain : retirer le\n// chauffe-eau de la journee AMELIORE la projection — reevaluee a chaque cycle,\n// la regle detruirait sa propre cause (oscillateur, cf. §24/§36). On decide au\n// premier calcul apres la fin de la fenetre HC de nuit du jour (la fenetre\n// suivante se termine alors dans plus de 12 h) ; la decision vaut pour la\n// journee et la nuit qui suit, jusqu'a la decision du lendemain.\n// ---------------------------------------------------------------------\nconst ecsNuitPrev = typeof input.ecs_nuit_jour === 'string' ? input.ecs_nuit_jour : '';\nconst ecsNuitPrevDate = ecsNuitPrev.split('|')[0] || '';\nconst nowMsEcs = num(input.nowMs, Date.now());\nconst todayParis = new Date(nowMsEcs).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });\nconst ecsNightHcEndMs = num(input.night_hc_end_ms, NaN);\nconst ecsDecisionDue = ecsNuitPrevDate !== todayParis\n && Number.isFinite(ecsNightHcEndMs) && ecsNightHcEndMs - nowMsEcs > 12 * 3600000;\nlet ecsNight = { due: ecsDecisionDue, decided: false, date: todayParis };\nif (ecsDecisionDue) {\n const nd50 = nextDeficitWindow(slotsP50);\n if (nd50.found) {\n // Trajectoire SIGNEE jusqu'au debut de la traversee (le deficit du matin\n // compte), bornee au plancher et a 100 %.\n let socProj = socPct;\n for (let i = 0; i < nd50.hoursFromNow; i++) {\n const net = slotsP50[i].netKwh;\n // socAfterEnergy ignore une energie negative : la decharge passe par\n // l'energie stockee (meme courbe).\n socProj = net >= 0 ? Math.min(100, socAfterEnergy(socProj, net))\n : Math.max(socFloorPct, socForEnergy(Math.max(0, energyBetweenSoc(0, socProj) + net), 0));\n }\n const on = socProj < nd50.requiredSocAtStartPct;\n ecsNight = {\n due: true, decided: true, date: todayParis, on,\n projectedSocPct: round1(socProj),\n requiredSocPct: round1(nd50.requiredSocAtStartPct),\n startHour: nd50.startHour,\n reason: on\n ? `jour faible : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude en HC de nuit`\n : `jour suffisant : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude au solaire`,\n };\n }\n}\n\n// ---------------------------------------------------------------------\n// RÉSERVE D'ORAGE (27/08/2026)\n//\n// Les coupures réseau sont fréquentes ici lors des orages. Point de départ\n// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et\n// la batterie continuent de fonctionner, seul le filet de secours disparaît.\n// La question n'est donc pas « combien stocker pour survivre » mais « la\n// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ».\n//\n// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut :\n// rien à inventer, aucune constante de plus. Le profil prudent est le bon\n// choix ici parce qu'une coupure retire précisément la possibilité de\n// rattraper une sous-estimation.\n//\n// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est\n// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de\n// l'émetteur, pas un nombre choisi ici.\n// ---------------------------------------------------------------------\nconst STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge'];\nconst STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange');\n\nconst stormRaw = typeof input.storm_alert_level === 'string'\n ? input.storm_alert_level.trim().toLowerCase()\n : '';\nconst stormIndex = STORM_SCALE.indexOf(stormRaw);\nconst stormKnown = stormIndex >= 0;\nconst stormActive = stormIndex >= STORM_ACTIVE_FROM;\n\n// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs\n// (« Orages », « Vent violent »…). Purement informatif.\nconst stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object')\n ? input.storm_alert_detail\n : {};\nconst stormPhenomena = Object.keys(stormDetail)\n .filter(k => typeof stormDetail[k] === 'string'\n && STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM)\n .sort();\n\n// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ;\n// en déficit, c'est celle qu'on est en train de traverser.\nconst inSurplusNow = prudent.found && prudent.crossingIndex === 0;\nconst stormWindow = (inSurplusNow && nextDeficit.found)\n ? {\n kwh: nextDeficit.energyKwh,\n socPct: nextDeficit.requiredSocAtStartPct,\n startHour: nextDeficit.startHour,\n }\n : {\n kwh: prudent.energyToCoverKwh,\n socPct: prudent.requiredSocPct,\n startHour: null,\n };\nconst stormMarginPct = socPct - stormWindow.socPct;\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.\n nextDeficit: nextDeficit.found ? {\n found: true,\n startHour: nextDeficit.startHour,\n hoursFromNow: nextDeficit.hoursFromNow,\n endHour: nextDeficit.endHour,\n complete: nextDeficit.complete,\n energyKwh: round2(nextDeficit.energyKwh),\n requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),\n surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),\n // Franchissable avec le stockage actuel, batterie pleine ?\n feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,\n // Si non : capacité utile manquante. C'est le chiffre du dimensionnement.\n storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n // Chauffe-eau en HC de nuit les jours faibles (07/10).\n ecsNight,\n // Reprise du soir et prevision du jour recalee sur la matinee (07/10).\n ecsReprise: { active: ecsRepriseSoirKwh > 0, kwh: round2(ecsRepriseSoirKwh) },\n prodRecal,\n\n // Nuit prevue par la soiree (28/09) : facteur applique au profil.\n nightModel: {\n active: nightModelActive,\n factor: Math.round(nightFactor * 1000) / 1000,\n rawFactor: Number.isFinite(nightFactorRaw) ? nightFactorRaw : null,\n forecastKwh: num(input.night_house_forecast_kwh, NaN) || null,\n },\n\n // Maison sur le reseau pendant la fenetre HC de nuit, si la session s'engage (29/09).\n nightHcImport: {\n active: nightHcActive,\n startMs: nightHcActive ? nightHcStartMs : null,\n endMs: nightHcActive ? nightHcEndMs : null,\n houseKwh: nightHcHouseKwh === null ? null : round2(nightHcHouseKwh),\n },\n\n // Ce que la voiture peut prendre sur la batterie cette nuit sans EDF (30/09).\n nightVehicleBudget: {\n active: nightVehicleBudgetKwh !== null,\n kwh: nightVehicleBudgetKwh === null ? null : round2(nightVehicleBudgetKwh),\n },\n\n // Voiture branchee en HC, depart avant la prochaine HC (28/09).\n vehicleMidday: {\n active: vehicleRuleActive,\n needKwh: Number.isFinite(vehicleNeedKwh) ? round2(vehicleNeedKwh) : null,\n departureMs: Number.isFinite(vehicleDepartureMs) ? vehicleDepartureMs : null,\n solarKwh: vehicleSolarKwh === null ? null : round2(vehicleSolarKwh),\n reachableKwh: vehicleReachableKwh === null ? null : round2(vehicleReachableKwh),\n shortfallKwh: vehicleShortfallKwh === null ? null : round2(vehicleShortfallKwh),\n floorAtDeparturePct: vehicleFloorAtDeparturePct === null ? null : round1(vehicleFloorAtDeparturePct),\n gridNeeded: vehicleGridNeeded,\n },\n\n // Tenir jusqu'a la prochaine fenetre HC (28/09) : SOC a avoir en fin de\n // fenetre HC en cours. 0 hors fenetre ou si la production suffit.\n hcReach: {\n active: hcReachActive,\n windowEndMs: hcReachActive ? hcWindowEndMs : null,\n nextHcStartMs: hcReachActive ? nextHcStartMs : null,\n floorPct: round1(Math.min(100, hcFloorTypicalPct)),\n // Jour faible a midi : cible 100 % (charge maximale) ; floorReachPct garde\n // le plancher « tenir jusqu'a 01:24 » calcule, pour comparaison.\n maxCharge: hcMaxCharge,\n // Nuit d'un jour annonce faible : SOC de fin de nuit pour arriver au soir.\n nightEvening,\n floorReachPct: round1(Math.min(100, hcFloorReachPct)),\n floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)),\n nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null,\n nightFloorPrudentPct: round1(Math.min(100, hcFloorNightPrudentPct)),\n raisesRequirement: hcReachActive && hcFloorTypicalPct >= typical.requiredSocPct - 0.05\n && hcFloorTypicalPct > socForEnergy(typical.energyToCoverKwh, socFloorPct),\n },\n\n // Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui\n // compte. Publiée en permanence — elle documente le risque même par temps\n // calme ; `active` dit seulement si la vigilance la rend d'actualité.\n storm: {\n level: stormKnown ? stormRaw : 'indisponible',\n known: stormKnown,\n active: stormActive,\n phenomena: stormPhenomena,\n windowStartHour: stormWindow.startHour,\n reserveKwh: round2(stormWindow.kwh),\n reserveSocPct: round1(stormWindow.socPct),\n socMarginPct: round1(stormMarginPct),\n covered: stormMarginPct >= 0,\n shortfallKwh: stormMarginPct < 0\n ? round2(energyBetweenSoc(socPct, stormWindow.socPct))\n : 0,\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" } }, { @@ -123,7 +123,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS\n// =====================================================================\n// GARANTIE MODE OMBRE : ce nœud ne produit QUE des écritures vers des entités\n// dont le nom commence par `evcs_balance_`, plus une trace logbook. Aucun\n// switch, aucune consigne de borne, aucune stratégie, aucun helper de takeover.\n// Le bilan observe et publie ; il ne pilote rien tant que le câblage réel n'a\n// pas été décidé explicitement.\n//\n// Cette garantie est VÉRIFIÉE par tests/energy-balance/ : la suite échoue si\n// une écriture sort de ce périmètre. Ne pas ajouter d'autres entités ici sans\n// changer le test en connaissance de cause.\n// =====================================================================\n\nconst balance = $input.first().json;\nconst censorship = $('CENSORSHIP').first().json;\nconst rawSnapshot = $('HA Snapshot Balance').first().json;\nconst snapshot = (rawSnapshot && typeof rawSnapshot.data === 'string')\n ? (() => { try { return JSON.parse(rawSnapshot.data); } catch { return rawSnapshot; } })()\n : ((rawSnapshot && rawSnapshot.data) || rawSnapshot);\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\n\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n if (!Number.isFinite(value)) return;\n push('input_number', 'set_value', { entity_id: entity, value });\n}\nfunction setBool(entity, on) {\n push('input_boolean', on ? 'turn_on' : 'turn_off', { entity_id: entity });\n}\n\n// Kill-switch : à OFF, on ne touche à rien du tout, pas même au statut.\nconst enabled = (snapshot.balance_enabled ?? 'on').toString().toLowerCase() === 'on';\nif (!enabled) return [];\n\n// --- Ce que le bilan constate de la production, dans tous les cas ---------\nsetBool('input_boolean.evcs_balance_pv_censored', !!censorship.censorship.pvCensored);\nsetText('input_text.evcs_balance_censor_reason',\n censorship.censorship.pvCensored\n ? censorship.censorship.reasons.join(', ')\n : 'production non bridée');\nsetText('input_text.evcs_balance_last_run', stampParis());\n\n// --- Le bilan lui-même ---------------------------------------------------\nif (!balance.usable) {\n setText('input_text.evcs_balance_status', 'indisponible');\n // Jamais de verdict voiture perime quand le bilan ne sait plus rien.\n setBool('input_boolean.evcs_balance_vehicle_grid_needed', false);\n setText('input_text.evcs_balance_reason',\n `Entrées manquantes : ${(balance.missing || []).join(', ')}. Les contrôleurs gardent leurs règles.`);\n return calls;\n}\n\nconst c = balance.coverage;\n\n// `ok` ne veut pas dire « le calcul a abouti » mais « on peut s'appuyer\n// dessus ». Deux cas dégradent la réponse sans l'empêcher :\n// · aucun croisement production/consommation sur l'horizon — le SOC requis\n// n'est alors plus une exigence datée mais la somme d'un horizon entier ;\n// · des heures de déficit reposant sur une forme de production minorée\n// (repli sur l'enveloppe faute de courbe horaire fraîche).\n// Dans les deux cas le bilan publie quand même ses chiffres — ils restent\n// informatifs — mais il le DIT, pour qu'aucun câblage futur ne s'y fie en\n// silence. Mesuré le 25/08 : sans cette distinction, la nuit précédant la mise\n// en service de la courbe horaire, le bilan a publié un SOC requis de 100 %\n// avec un statut `ok`.\nconst degraded =\n !balance.crossing.found ||\n !(balance.confidence && balance.confidence.deficitTrustworthy);\nsetText('input_text.evcs_balance_status', degraded ? 'degrade' : 'ok');\nsetNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);\nsetNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);\n// Regle HC (28/09) publiee pour etre verifiable : 0 = hors fenetre HC.\nsetNumber('input_number.evcs_balance_hc_floor_pct', balance.hcReach ? balance.hcReach.floorPct : 0);\nsetNumber('input_number.evcs_balance_hc_floor_prudent_pct', balance.hcReach ? balance.hcReach.floorPrudentPct : 0);\n// Voiture branchee en HC, depart avant la prochaine HC (28/09). OFF hors regle.\nconst vm = balance.vehicleMidday || {};\nsetBool('input_boolean.evcs_balance_vehicle_grid_needed', !!vm.gridNeeded);\nsetNumber('input_number.evcs_balance_vehicle_need_kwh', vm.active ? vm.reachableKwh : 0);\nsetNumber('input_number.evcs_balance_vehicle_solar_kwh', vm.active ? vm.solarKwh : 0);\n// Maison sur le reseau pendant la fenetre HC de nuit si la session s'engage (29/09).\nconst nh = balance.nightHcImport || {};\nsetNumber('input_number.evcs_balance_night_hc_house_kwh', nh.active && nh.houseKwh !== null ? nh.houseKwh : 0);\n// Budget voiture de la nuit sans EDF (30/09) : 0 si inconnu — le controleur\n// ne donne alors rien de la batterie.\nconst nb = balance.nightVehicleBudget || {};\nsetNumber('input_number.evcs_balance_night_vehicle_budget_kwh', nb.active && nb.kwh !== null ? nb.kwh : 0);\nsetNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);\nsetNumber('input_number.evcs_balance_grid_energy_needed_kwh', c.gridEnergyNeededKwh);\nsetNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surplusAvailableKwh);\nsetBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);\n\n// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas.\n// Chauffe-eau (07/10) : n'ecrit QUE le jour de la decision (une fois par jour).\nconst en = balance.ecsNight || {};\nif (en.decided) {\n setBool('input_boolean.evcs_balance_ecs_nuit', !!en.on);\n setText('input_text.evcs_balance_ecs_nuit_jour', `${en.date}|${en.on ? 'on' : 'off'}|${en.reason}`);\n}\nconst nd = balance.nextDeficit || { found: false };\nif (nd.found) {\n setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);\n setNumber('input_number.evcs_balance_next_deficit_required_soc', nd.requiredSocAtStartPct);\n setNumber('input_number.evcs_balance_storage_shortfall_kwh', nd.storageShortfallKwh);\n setText('input_text.evcs_balance_next_deficit_window',\n `${String(nd.startHour).padStart(2, '0')} h → ${nd.endHour == null ? '?' : String(nd.endHour).padStart(2, '0') + ' h'}`\n + (nd.feasibleWithCurrentStorage ? '' : ' (stockage insuffisant)'));\n} else {\n setText('input_text.evcs_balance_next_deficit_window',\n nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\\'horizon');\n}\n// --- Réserve d'orage -----------------------------------------------------\n// Publiée même hors vigilance : le chiffre dit en permanence si l'installation\n// passerait la prochaine nuit sans réseau. Le helper SOC est borné à 100 pour\n// rester dans la plage de l'entrée ; le dépassement éventuel reste lisible\n// dans la raison et dans le manque en kWh.\nconst st = balance.storm;\nif (st) {\n setText('input_text.evcs_balance_storm_level', st.level);\n setBool('input_boolean.evcs_balance_storm_active', !!st.active);\n setNumber('input_number.evcs_balance_storm_reserve_kwh', st.reserveKwh);\n setNumber('input_number.evcs_balance_storm_reserve_soc',\n Number.isFinite(st.reserveSocPct) ? Math.min(100, Math.max(0, st.reserveSocPct)) : null);\n setText('input_text.evcs_balance_storm_reason',\n (st.known ? `Vigilance ${st.level}` : 'Vigilance indisponible')\n + (st.phenomena.length ? ` (${st.phenomena.join(', ')})` : '')\n + ` — sans réseau il faut ${st.reserveKwh} kWh`\n + (st.windowStartHour == null ? ' pour la fenêtre en cours' : ` dès ${st.windowStartHour} h`)\n + `, soit SOC ${st.reserveSocPct}% ; actuel ${c.currentSocPct}% → `\n + (st.covered ? 'couvert.' : `manque ${st.shortfallKwh} kWh.`));\n}\n\nsetText('input_text.evcs_balance_crossing_hour',\n balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\\'horizon');\n\nconst ndText = nd.found\n ? ` | 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)`}`\n : '';\n\nconst degradedNote = !balance.crossing.found\n ? ' ⚠ aucun croisement sur l\\'horizon : chiffre indicatif, pas une exigence datée.'\n : (degraded ? ' ⚠ forme de production minorée sur les heures de déficit.' : '');\nconst reason = balance.crossing.found\n ? `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.'}`\n : `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;\n// Fenetre HC (28/09) : dire quand c'est « tenir jusqu'a la prochaine HC » qui\n// fixe l'exigence, sinon la raison affiche un croisement qui ne l'explique pas.\nconst hc = balance.hcReach || {};\nconst hcText = hc.active && hc.maxCharge\n ? ` Jour faible : charge maximale en HC de midi (tenir jusqu'à ${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })} demandait ${hc.floorReachPct}%).`\n : hc.active && hc.raisesRequirement\n ? ` Tenir jusqu'à la prochaine HC (${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })}) → ${hc.floorPct}% en fin de fenêtre.`\n : '';\nconst hhmm = ms => new Date(ms).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' });\nconst vehText = vm.active\n ? ` Voiture (départ ${hhmm(vm.departureMs)}) : ${vm.reachableKwh} kWh à donner, solaire ${vm.solarKwh} kWh → ${vm.gridNeeded ? 'réseau justifié' : 'solaire suffisant'}.`\n : '';\nconst nm = balance.nightModel || {};\nconst nightText = nm.active && Math.abs(nm.factor - 1) >= 0.005\n ? ` Nuit recalée sur la soirée : conso ×${String(nm.factor).replace('.', ',')}${nm.forecastKwh ? ` (${String(nm.forecastKwh).replace('.', ',')} kWh prévus 21:30-09:00)` : ''}.`\n : '';\nsetText('input_text.evcs_balance_reason', reason + nightText + hcText + vehText + degradedNote + ndText);\n\n// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil\n// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.\npush('logbook', 'log', {\n name: 'EVCS Bilan énergétique (ombre)',\n message: `${reason} | seuil fixe actuel grid_soc_off = ${snapshot.grid_soc_off ?? 'n/a'}% | production ${censorship.censorship.pvCensored ? 'BRIDÉE (' + censorship.censorship.reasons.join(', ') + ')' : 'libre'}`,\n entity_id: 'input_text.evcs_balance_status',\n});\n\nreturn calls;\n" + "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS\n// =====================================================================\n// GARANTIE MODE OMBRE : ce nœud ne produit QUE des écritures vers des entités\n// dont le nom commence par `evcs_balance_`, plus une trace logbook. Aucun\n// switch, aucune consigne de borne, aucune stratégie, aucun helper de takeover.\n// Le bilan observe et publie ; il ne pilote rien tant que le câblage réel n'a\n// pas été décidé explicitement.\n//\n// Cette garantie est VÉRIFIÉE par tests/energy-balance/ : la suite échoue si\n// une écriture sort de ce périmètre. Ne pas ajouter d'autres entités ici sans\n// changer le test en connaissance de cause.\n// =====================================================================\n\nconst balance = $input.first().json;\nconst censorship = $('CENSORSHIP').first().json;\nconst rawSnapshot = $('HA Snapshot Balance').first().json;\nconst snapshot = (rawSnapshot && typeof rawSnapshot.data === 'string')\n ? (() => { try { return JSON.parse(rawSnapshot.data); } catch { return rawSnapshot; } })()\n : ((rawSnapshot && rawSnapshot.data) || rawSnapshot);\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\n\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n if (!Number.isFinite(value)) return;\n push('input_number', 'set_value', { entity_id: entity, value });\n}\nfunction setBool(entity, on) {\n push('input_boolean', on ? 'turn_on' : 'turn_off', { entity_id: entity });\n}\n\n// Kill-switch : à OFF, on ne touche à rien du tout, pas même au statut.\nconst enabled = (snapshot.balance_enabled ?? 'on').toString().toLowerCase() === 'on';\nif (!enabled) return [];\n\n// --- Ce que le bilan constate de la production, dans tous les cas ---------\nsetBool('input_boolean.evcs_balance_pv_censored', !!censorship.censorship.pvCensored);\nsetText('input_text.evcs_balance_censor_reason',\n censorship.censorship.pvCensored\n ? censorship.censorship.reasons.join(', ')\n : 'production non bridée');\nsetText('input_text.evcs_balance_last_run', stampParis());\n\n// --- Le bilan lui-même ---------------------------------------------------\nif (!balance.usable) {\n setText('input_text.evcs_balance_status', 'indisponible');\n // Jamais de verdict voiture perime quand le bilan ne sait plus rien.\n setBool('input_boolean.evcs_balance_vehicle_grid_needed', false);\n setText('input_text.evcs_balance_reason',\n `Entrées manquantes : ${(balance.missing || []).join(', ')}. Les contrôleurs gardent leurs règles.`);\n return calls;\n}\n\nconst c = balance.coverage;\n\n// `ok` ne veut pas dire « le calcul a abouti » mais « on peut s'appuyer\n// dessus ». Deux cas dégradent la réponse sans l'empêcher :\n// · aucun croisement production/consommation sur l'horizon — le SOC requis\n// n'est alors plus une exigence datée mais la somme d'un horizon entier ;\n// · des heures de déficit reposant sur une forme de production minorée\n// (repli sur l'enveloppe faute de courbe horaire fraîche).\n// Dans les deux cas le bilan publie quand même ses chiffres — ils restent\n// informatifs — mais il le DIT, pour qu'aucun câblage futur ne s'y fie en\n// silence. Mesuré le 25/08 : sans cette distinction, la nuit précédant la mise\n// en service de la courbe horaire, le bilan a publié un SOC requis de 100 %\n// avec un statut `ok`.\nconst degraded =\n !balance.crossing.found ||\n !(balance.confidence && balance.confidence.deficitTrustworthy);\nsetText('input_text.evcs_balance_status', degraded ? 'degrade' : 'ok');\nsetNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);\nsetNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);\n// Regle HC (28/09) publiee pour etre verifiable : 0 = hors fenetre HC.\nsetNumber('input_number.evcs_balance_hc_floor_pct', balance.hcReach ? balance.hcReach.floorPct : 0);\nsetNumber('input_number.evcs_balance_hc_floor_prudent_pct', balance.hcReach ? balance.hcReach.floorPrudentPct : 0);\n// Voiture branchee en HC, depart avant la prochaine HC (28/09). OFF hors regle.\nconst vm = balance.vehicleMidday || {};\nsetBool('input_boolean.evcs_balance_vehicle_grid_needed', !!vm.gridNeeded);\nsetNumber('input_number.evcs_balance_vehicle_need_kwh', vm.active ? vm.reachableKwh : 0);\nsetNumber('input_number.evcs_balance_vehicle_solar_kwh', vm.active ? vm.solarKwh : 0);\n// Maison sur le reseau pendant la fenetre HC de nuit si la session s'engage (29/09).\nconst nh = balance.nightHcImport || {};\nsetNumber('input_number.evcs_balance_night_hc_house_kwh', nh.active && nh.houseKwh !== null ? nh.houseKwh : 0);\n// Budget voiture de la nuit sans EDF (30/09) : 0 si inconnu — le controleur\n// ne donne alors rien de la batterie.\nconst nb = balance.nightVehicleBudget || {};\nsetNumber('input_number.evcs_balance_night_vehicle_budget_kwh', nb.active && nb.kwh !== null ? nb.kwh : 0);\nsetNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);\nsetNumber('input_number.evcs_balance_grid_energy_needed_kwh', c.gridEnergyNeededKwh);\nsetNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surplusAvailableKwh);\nsetBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);\n\n// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas.\n// Chauffe-eau (07/10) : n'ecrit QUE le jour de la decision (une fois par jour).\nconst en = balance.ecsNight || {};\nif (en.decided) {\n setBool('input_boolean.evcs_balance_ecs_nuit', !!en.on);\n setText('input_text.evcs_balance_ecs_nuit_jour', `${en.date}|${en.on ? 'on' : 'off'}|${en.reason}`);\n}\nconst nd = balance.nextDeficit || { found: false };\nif (nd.found) {\n setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);\n setNumber('input_number.evcs_balance_next_deficit_required_soc', nd.requiredSocAtStartPct);\n setNumber('input_number.evcs_balance_storage_shortfall_kwh', nd.storageShortfallKwh);\n setText('input_text.evcs_balance_next_deficit_window',\n `${String(nd.startHour).padStart(2, '0')} h → ${nd.endHour == null ? '?' : String(nd.endHour).padStart(2, '0') + ' h'}`\n + (nd.feasibleWithCurrentStorage ? '' : ' (stockage insuffisant)'));\n} else {\n setText('input_text.evcs_balance_next_deficit_window',\n nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\\'horizon');\n}\n// --- Réserve d'orage -----------------------------------------------------\n// Publiée même hors vigilance : le chiffre dit en permanence si l'installation\n// passerait la prochaine nuit sans réseau. Le helper SOC est borné à 100 pour\n// rester dans la plage de l'entrée ; le dépassement éventuel reste lisible\n// dans la raison et dans le manque en kWh.\nconst st = balance.storm;\nif (st) {\n setText('input_text.evcs_balance_storm_level', st.level);\n setBool('input_boolean.evcs_balance_storm_active', !!st.active);\n setNumber('input_number.evcs_balance_storm_reserve_kwh', st.reserveKwh);\n setNumber('input_number.evcs_balance_storm_reserve_soc',\n Number.isFinite(st.reserveSocPct) ? Math.min(100, Math.max(0, st.reserveSocPct)) : null);\n setText('input_text.evcs_balance_storm_reason',\n (st.known ? `Vigilance ${st.level}` : 'Vigilance indisponible')\n + (st.phenomena.length ? ` (${st.phenomena.join(', ')})` : '')\n + ` — sans réseau il faut ${st.reserveKwh} kWh`\n + (st.windowStartHour == null ? ' pour la fenêtre en cours' : ` dès ${st.windowStartHour} h`)\n + `, soit SOC ${st.reserveSocPct}% ; actuel ${c.currentSocPct}% → `\n + (st.covered ? 'couvert.' : `manque ${st.shortfallKwh} kWh.`));\n}\n\nsetText('input_text.evcs_balance_crossing_hour',\n balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\\'horizon');\n\nconst ndText = nd.found\n ? ` | 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)`}`\n : '';\n\nconst degradedNote = !balance.crossing.found\n ? ' ⚠ aucun croisement sur l\\'horizon : chiffre indicatif, pas une exigence datée.'\n : (degraded ? ' ⚠ forme de production minorée sur les heures de déficit.' : '');\nconst reason = balance.crossing.found\n ? `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.'}`\n : `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;\n// Fenetre HC (28/09) : dire quand c'est « tenir jusqu'a la prochaine HC » qui\n// fixe l'exigence, sinon la raison affiche un croisement qui ne l'explique pas.\nconst hc = balance.hcReach || {};\nconst ne = hc.nightEvening || {};\nconst hcText = hc.active && ne.active\n ? ` Journée faible annoncée : la nuit charge à ${ne.floorPct}% (batterie prévue à ${ne.eveningProjectedPct}% à ${ne.eveningStartHour} h pour ${ne.eveningRequiredPct}% requis ; tenir jusqu'à midi demandait ${hc.floorReachPct}%).`\n : hc.active && hc.maxCharge\n ? ` Jour faible : charge maximale en HC de midi (tenir jusqu'à ${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })} demandait ${hc.floorReachPct}%).`\n : hc.active && hc.raisesRequirement\n ? ` Tenir jusqu'à la prochaine HC (${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })}) → ${hc.floorPct}% en fin de fenêtre.`\n : '';\nconst hhmm = ms => new Date(ms).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' });\nconst vehText = vm.active\n ? ` Voiture (départ ${hhmm(vm.departureMs)}) : ${vm.reachableKwh} kWh à donner, solaire ${vm.solarKwh} kWh → ${vm.gridNeeded ? 'réseau justifié' : 'solaire suffisant'}.`\n : '';\nconst nm = balance.nightModel || {};\nconst nightText = nm.active && Math.abs(nm.factor - 1) >= 0.005\n ? ` Nuit recalée sur la soirée : conso ×${String(nm.factor).replace('.', ',')}${nm.forecastKwh ? ` (${String(nm.forecastKwh).replace('.', ',')} kWh prévus 21:30-09:00)` : ''}.`\n : '';\nsetText('input_text.evcs_balance_reason', reason + nightText + hcText + vehText + degradedNote + ndText);\n\n// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil\n// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.\npush('logbook', 'log', {\n name: 'EVCS Bilan énergétique (ombre)',\n message: `${reason} | seuil fixe actuel grid_soc_off = ${snapshot.grid_soc_off ?? 'n/a'}% | production ${censorship.censorship.pvCensored ? 'BRIDÉE (' + censorship.censorship.reasons.join(', ') + ')' : 'libre'}`,\n entity_id: 'input_text.evcs_balance_status',\n});\n\nreturn calls;\n" } }, { diff --git a/workflows/evcs-surplus-controller-v2.6.json b/workflows/evcs-surplus-controller-v2.6.json index a2d59d6..f410e2c 100644 --- a/workflows/evcs-surplus-controller-v2.6.json +++ b/workflows/evcs-surplus-controller-v2.6.json @@ -81,7 +81,7 @@ }, { "parameters": { - "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n\n return {\n date,\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction hhmmssFromMinutes(minOfDay) {\n const mm = ((minOfDay % 1440) + 1440) % 1440;\n const hh = Math.floor(mm / 60);\n const mi = mm % 60;\n return `${to2(hh)}:${to2(mi)}:00`;\n}\n\nfunction nextDailyOccurrenceMs(startMin, refMs) {\n if (!Number.isFinite(startMin)) return null;\n\n const today = getParisParts(refMs).date;\n const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;\n const todayMs = parseParisStampToMs(todayStamp);\n\n if (todayMs !== null && todayMs > refMs + 1000) return todayMs;\n\n const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;\n const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;\n return parseParisStampToMs(tomorrowStamp);\n}\n\nconst prev = $input.first().json;\n\nconst {\n nowMs,\n cfg,\n parsed,\n params,\n history,\n batteryTune,\n strategy,\n derived,\n context,\n lifecycle,\n} = prev;\n\n// =====================================================\n// SESSION / SOURCE\n// =====================================================\nconst sessionObservedActive =\n parsed.evcsChargingSwitchOn ||\n parsed.controlLatch ||\n parsed.chargePermissionNow;\n\nconst isGridSource =\n context.sourceMode === 'grid_hc_allowed' ||\n context.sourceMode === 'grid_forced';\n\nconst isLocalSolarSource =\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential';\n\n// =====================================================\n// SOC BAND / BATTERY DYNAMIC POLICY\n// =====================================================\nconst socBand =\n parsed.soc > 90 ? 'gt90' :\n parsed.soc > 80 ? '80_90' :\n parsed.soc > 60 ? '60_80' :\n parsed.soc > 40 ? '40_60' :\n parsed.soc > 25 ? '25_40' :\n 'lt25';\n\nconst band = batteryTune.bands[socBand];\n\nconst dischargeModeFactor =\n strategy.forceChargeRequested ? 1.2 :\n strategy.forceGridRequested ? 0.7 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst chargeReserveModeFactor =\n strategy.forceChargeRequested ? 0.8 :\n strategy.forceGridRequested ? 1.1 :\n strategy.userStrategy === 'auto_solar' ? 1.1 :\n 1.0;\n\nconst toleranceModeFactor =\n strategy.forceChargeRequested ? 1.25 :\n strategy.forceGridRequested ? 0.8 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;\nconst batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;\nconst batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);\nconst batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;\nconst batteryHardToleranceSeconds = band.hardToleranceSec;\nconst batteryRecoveryMinutes = band.recoveryMin;\n\n// =====================================================\n// CONTEXT HELPERS\n// =====================================================\nconst houseSpikeThresholdW = 1500;\nconst houseSpikeDetected =\n (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;\n\nconst solarPotentialContext =\n context.sourceMode === 'solar_potential' ||\n (\n parsed.soc >= 80 &&\n derived.forecastBufferKwh > 0 &&\n parsed.pvPowerW < context.effectivePvStartMinW &&\n derived.houseNonEvcsW < 1200\n );\n\n// =====================================================\n// OUTSIDE-WINDOW LOCAL BYPASS\n// IMPORTANT:\n// en auto_solar_plus ou force_charge_now, un contexte local solaire\n// ne doit plus rester bloqué juste parce qu'on est hors fenêtre.\n// =====================================================\nconst outsideWindowLocalBypass =\n context.profile === 'outside_window' &&\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\n// =====================================================\n// REASON-BASED LOCKS\n// =====================================================\nconst nextWindowCandidates = [\n nextDailyOccurrenceMs(params.morningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),\n nextDailyOccurrenceMs(params.eveningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),\n].filter(v => Number.isFinite(v));\n\nconst nextAllowedWindowStartMs =\n nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;\n\nconst blockActive =\n history.blockedUntilMs !== null &&\n history.blockedUntilMs > nowMs + 1000;\n\nconst blockStillRelevant =\n history.lastBlockReason === 'outside_window'\n ? (context.profile === 'outside_window' && !outsideWindowLocalBypass)\n : history.lastBlockReason === 'probe_failed'\n ? context.sourceMode === 'hybrid_probe'\n : history.lastBlockReason === 'battery_protection_stop'\n ? (\n parsed.batteryCurrentA < -params.batteryNeutralBandA ||\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageSoftLow\n )\n : history.lastBlockReason === 'grid_battery_guard'\n ? isGridSource\n // Garde batterie declenche RESEAU ABSENT (18/09) : sa cause est le\n // trou entre la demande de reseau et la fermeture du contacteur.\n // Le reseau arrive => la cause a disparu => le verrou se leve. Si la\n // batterie est encore sollicitee au retry, le garde re-mord et pose\n // cette fois 'grid_battery_guard' (reseau present) — verrou plein.\n // Convergence en une tentative, meme patron que Fix H.\n : history.lastBlockReason === 'grid_battery_guard_no_grid'\n ? (isGridSource && !parsed.gridAvailable)\n : history.lastBlockReason === 'vebus_overload_persistent'\n ? derived.anyVebusOverload\n : false;\n\nconst startBlockedByReason = blockActive && blockStillRelevant;\n\n// =====================================================\n// CLOUD DEBT\n// =====================================================\nconst batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;\nlet cloudDebtNextKwh = params.cloudDebtPrevKwh;\n\nif (sessionObservedActive) {\n if (batteryNetPowerW < 0 && !isGridSource) {\n cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;\n } else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n }\n} else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n}\n\ncloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);\n\nconst cloudDebtMeaningful =\n cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;\n\nconst cloudEventDischargingNow =\n sessionObservedActive &&\n !isGridSource &&\n parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&\n cloudDebtMeaningful;\n\nconst continuingCloudEvent =\n cloudEventDischargingNow &&\n history.cloudDebtStartedMsPrev !== null &&\n params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;\n\nconst enteringCloudDebt =\n cloudEventDischargingNow && !continuingCloudEvent;\n\nconst cloudDebtActive = cloudEventDischargingNow;\n\nconst cloudDebtStartedMs =\n cloudEventDischargingNow\n ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)\n : null;\n\nconst cloudDebtDurationMinutes =\n cloudDebtActive && cloudDebtStartedMs !== null\n ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)\n : 0;\n\nconst effectiveCloudToleranceMinutes =\n params.cloudToleranceMinutes +\n (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +\n (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);\n\nconst cloudToleranceOk =\n cloudDebtNextKwh <= params.cloudDebtMaxKwh &&\n (\n !cloudDebtActive ||\n cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes\n );\n\n// =====================================================\n// LEGACY / FORECAST MODES\n// =====================================================\nconst batteryFullHarvestMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.batteryFullSoc &&\n parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);\n\nconst forecastCommitMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.forecastCommitSocFloor &&\n parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&\n derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;\n\nconst batteryPriorityMode =\n !batteryFullHarvestMode && !forecastCommitMode;\n\n// =====================================================\n// EVCS NIGHT ENGINE — R1 (07/08/2026)\n// Le moteur nuit est calculé UNE SEULE FOIS dans WINDOWS_SOURCEV2\n// (payload nightEvcsPreview). Ce node CONSOMME, il ne recalcule plus.\n// TOUTE évolution de la logique nuit se fait dans WINDOWS_SOURCEV2.\n// Fallback sûr si preview absent : session inactive / 'blocked'.\n// =====================================================\nconst nightPrev = prev.nightEvcsPreview || {};\n\nconst nightSessionActive = !!nightPrev.nightSessionActive;\nconst nightSessionNeedsCapture = !!nightPrev.nightSessionNeedsCapture;\nconst nightSessionNeedsClear = !!nightPrev.nightSessionNeedsClear;\nconst nightSessionStartSocPct =\n Number.isFinite(nightPrev.nightSessionStartSocPct) ? nightPrev.nightSessionStartSocPct : null;\nconst nightSessionStartAt = nightPrev.nightSessionStartAt || '';\nconst nightSessionStartMs =\n Number.isFinite(nightPrev.nightSessionStartMs) ? nightPrev.nightSessionStartMs : null;\n\nconst batteryHardFloorPct =\n Number.isFinite(nightPrev.batteryHardFloorPct)\n ? nightPrev.batteryHardFloorPct\n : params.evcsNightBatteryHardFloorPct;\nconst batteryMorningTargetPct =\n Number.isFinite(nightPrev.batteryMorningTargetPct)\n ? nightPrev.batteryMorningTargetPct\n : params.evcsNightTargetWeakForecastPct;\nconst balanceMorningTargetApplied = !!nightPrev.balanceMorningTargetApplied;\nconst nightGridSessionCommitted = !!nightPrev.nightGridSessionCommitted;\nconst maxBatteryDischargePoints =\n Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0;\nconst gridTakeoverTriggerPct =\n Number.isFinite(nightPrev.gridTakeoverTriggerPct)\n ? nightPrev.gridTakeoverTriggerPct\n : Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor = !!nightPrev.batteryBelowHardFloor;\nconst batteryBelowMorningTarget = !!nightPrev.batteryBelowMorningTarget;\nconst batteryAtOrBelowGridTakeoverTrigger = !!nightPrev.batteryAtOrBelowGridTakeoverTrigger;\n\nconst batteryKwhMissingToTarget =\n Number.isFinite(nightPrev.batteryKwhMissingToTarget) ? nightPrev.batteryKwhMissingToTarget : 0;\nconst batteryRecoveryMinutesNeeded =\n Number.isFinite(nightPrev.batteryRecoveryMinutesNeeded) ? nightPrev.batteryRecoveryMinutesNeeded : 0;\nconst batteryRecoveryUrgent = !!nightPrev.batteryRecoveryUrgent;\nconst batteryRecoveryAllowedNow = !!nightPrev.batteryRecoveryAllowedNow;\nconst batteryTargetReached = !!nightPrev.batteryTargetReached;\n\nconst batteryAssistAllowed = !!nightPrev.batteryAssistAllowed;\nconst nightGridTakeoverNeeded = !!nightPrev.nightGridTakeoverNeeded;\nconst nightTakeoverLatched = !!nightPrev.nightTakeoverLatched;\nconst vehicleChargeCompleted = !!nightPrev.vehicleChargeCompleted;\nconst nightRecoveryDone = !!nightPrev.nightRecoveryDone;\nconst departureUrgentActive = !!nightPrev.departureUrgentActive;\nconst departureChargeDone = !!nightPrev.departureChargeDone;\nconst dayVehicleFullIdle = !!nightPrev.dayVehicleFullIdle;\nconst nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked';\n\n// =====================================================\n// PROFILE-BASED THRESHOLDS\n// =====================================================\n// =====================================================\n// PLANCHER DU SOIR : UN NIVEAU FIXE, REMPLACE PAR L'ENERGIE A COUVRIR (03/09/2026)\n// =====================================================\n// `evening_soc_start_min` = 85 % est une tentative de dire « garde de quoi\n// passer la nuit ». C'est un NIVEAU, pris a un instant, et il ne sait rien de\n// la nuit qui vient : ni sa duree, ni la consommation a couvrir, ni l'heure a\n// laquelle le solaire prendra le relais.\n//\n// Le bilan energetique calcule exactement cela et le publie —\n// `evcs_balance_required_soc`, deduit de l'energie a couvrir jusqu'au\n// croisement production/consommation. Personne ne le lisait : `grep\n// evcs_balance` sur les deux workflows de decision renvoyait 0.\n//\n// Le 03/09 a 19:44, les deux nombres coexistaient dans le meme systeme :\n// plancher du soir 85 % (SOC 94, donc charge autorisee) contre SOC requis\n// 99,7 % pour tenir jusqu'au croisement de 9 h. La charge du soir a fait\n// passer le manque de la nuit de 0,86 a 2,21 kWh.\n//\n// SENS UNIQUE, ET C'EST DELIBERE : le bilan ne peut que RENFORCER le plancher,\n// jamais l'abaisser (Math.max). L'assouplir — le bilan dira souvent moins de\n// 85 % par forte prevision d'ete — autoriserait des charges qui n'ont pas lieu\n// aujourd'hui : c'est une decision distincte, avec ses propres risques, qui\n// doit etre validee pour elle-meme et non glissee dans ce correctif.\n// Corollaire : le max rend la garde robuste sans borne ni ecretage — une\n// valeur nulle, negative ou aberrante ne peut pas relacher le plancher.\n//\n// PORTEE : plancher de DEMARRAGE. Le plancher de maintien reste\n// min(profileSocHoldFloor, ...) donc inchange a 80 % : relever le plancher de\n// demarrage ne coupe aucune charge en cours.\n//\n// L'abondance P6 continue de primer : PV moyen >= 4 kW ET batterie qui monte a\n// >= +20 A est une PREUVE MESUREE que la ressource depasse ce que le systeme\n// absorbe — le cas ou la voiture est effectivement gratuite.\n//\n// REPLI : lecture absente, statut non 'ok', ou bilan plus vieux que six de ses\n// cycles => regle historique inchangee. Le kill-switch existe deja et n'a pas\n// besoin d'etre double : `input_boolean.evcs_balance_enabled` a OFF arrete les\n// ecritures du bilan, `balance_last_run` se fige, et le plancher retombe seul\n// sur 85 % en une demi-heure.\nconst BALANCE_MAX_AGE_MIN = 30;\nconst balanceAgeMin =\n params.balanceLastRunMs !== null && params.balanceLastRunMs !== undefined\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\nconst balanceFresh =\n balanceAgeMin !== null &&\n balanceAgeMin >= -5 &&\n balanceAgeMin <= BALANCE_MAX_AGE_MIN;\nconst balanceEveningFloorUsable =\n balanceFresh &&\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct);\n\nconst eveningSocStartFloorPct = balanceEveningFloorUsable\n ? Math.max(params.eveningSocStartMin, params.balanceRequiredSocPct)\n : params.eveningSocStartMin;\nconst eveningFloorFromBalance =\n balanceEveningFloorUsable && eveningSocStartFloorPct > params.eveningSocStartMin;\n\nconst profileSocStartFloor =\n context.profile === 'morning_solar' ? params.morningSocMin :\n context.profile === 'midday_hc_or_solar' ? params.middaySocMin :\n context.profile === 'evening_high_soc' ? eveningSocStartFloorPct :\n params.socStartMin;\n\n// P6 ABONDANCE : PV moyen élevé + batterie en charge franche => le plancher SOC\n// de démarrage perd son sens (la batterie continue de monter même avec l'EVCS à 6 A).\n// Jours nuageux ou batterie peu chargée par le PV : rien ne change.\nconst PV_ABUNDANCE_AVG_MIN_W = 4000;\nconst ABUNDANCE_BATTERY_CHARGE_MIN_A = 20;\nconst ABUNDANCE_SOC_FLOOR_PCT = 40;\n\n// 24/08/2026 — L'abondance se PROUVE au démarrage (batterie qui monte franchement,\n// >= +20 A, avant qu'on touche à quoi que ce soit) mais elle se MAINTIENT tant que\n// le PV reste fort ET que la batterie n'est pas devenue fournisseuse. Sans cette\n// distinction, la charge du véhicule — qui détourne précisément le surplus — fait\n// tomber le courant batterie sous +20 A et détruit sa propre condition\n// d'autorisation : système bistable, oscillation à la période de min_run.\nconst solarAbundanceStart =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A;\nconst solarAbundanceHold =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= 0;\nconst solarAbundance = solarAbundanceStart; // conservé pour la traçabilité/debug\n\nconst abundanceSocFloor = (abundant) =>\n abundant ? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT) : profileSocStartFloor;\n\nconst effectiveSocStartFloor = abundanceSocFloor(solarAbundanceStart);\n\nconst profileSocHoldFloor =\n context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :\n context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :\n context.profile === 'evening_high_soc' ? params.eveningSocStopMin :\n params.socStopMin;\n\n// INVARIANT (24/08/2026) : on ne doit JAMAIS avoir le droit de DÉMARRER une charge\n// qu'on n'a pas le droit de MAINTENIR. Le plancher de maintien doit donc rester\n// <= au plancher de démarrage effectif.\n// Incident du 24/08 midi : bascule de profil vers midday_hc_or_solar à 12:24 avec\n// SOC 58 % => plancher de démarrage 40 % (abondance P6) mais plancher de maintien\n// max(29, 75-15) = 60 % => démarrage autorisé, maintien refusé, cycle limite de\n// 3 min (min_run) sur toute la bande [40 %, 60 %], et ~2,6 kW de PV écrêtés à\n// chaque creux faute de preneur (ESS sans injection).\nconst effectiveSocHoldFloor = Math.min(\n profileSocHoldFloor,\n abundanceSocFloor(solarAbundanceHold)\n);\n\nconst profileSpecificDischargeFloorA =\n context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :\n context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :\n context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :\n params.batteryDischargeSoftA;\n\nconst profileBatteryDischargeFloorA =\n Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);\n\n// =====================================================\n// DEPASSEMENT SOUPLE DU PLANCHER DE DECHARGE (01/09/2026)\n// =====================================================\n// La table des bandes SOC (INPUT_PARSE) declare depuis toujours DEUX regimes :\n// un courant SOUPLE tolere pendant des MINUTES (softToleranceMin : 12 min a\n// SOC > 90, 1 min sous 25) et un courant DUR tolere quelques SECONDES. Seuls\n// les seuils de courant etaient lus ; la dimension temporelle etait calculee,\n// exportee dans le debug, et branchee sur AUCUNE decision. Le controleur\n// comparait donc un echantillon instantane, toutes les 30 s, a un plancher.\n//\n// Incident du 01/09 (14:26 et 14:56, SOC 94, batterie en float) : le courant\n// batterie oscillait entre +1,8 et -2,6 A d'un echantillon a l'autre, soit le\n// bruit de regulation d'une batterie qui ne fait rien. Le test devenait un\n// tirage a pile ou face et la charge du vehicule s'arretait dessus. Chaque\n// arret coutait ~0,45 kWh de production refusee : batterie pleine, le vehicule\n// etait le SEUL preneur, donc la PV s'effondrait de 6,6 a 2,5 kW en 30 s, et\n// la reprise mettait 21 min a remonter de 6 A a 20 A. 1 a 6 arrets par jour\n// sur la semaine du 26 au 31/08.\n//\n// Le correctif est PUREMENT TEMPOREL : aucun seuil n'est deplace, aucune\n// constante n'est ajoutee. On applique la table telle qu'elle est ecrite :\n// un depassement souple doit DURER pour compter. Comme le bruit change de\n// signe en quelques secondes, le compteur se remet a zero de lui-meme ; une\n// vraie decharge, elle, reste negative a chaque echantillon et finit par\n// depasser la tolerance de sa bande.\n//\n// CE QUI N'EST PAS TOUCHE : le plancher de DEMARRAGE reste instantane (ne pas\n// lancer une charge sur une batterie qui se decharge deja, c'est la frontiere\n// \"delestage et non charge prioritaire\"), et le garde DUR reste instantane —\n// hardToleranceSec n'est volontairement PAS branche, cela ne pourrait que\n// ralentir une securite, et c'est le risque qui prime.\nconst batterySoftBreachNow =\n sessionObservedActive &&\n parsed.batteryCurrentA < profileBatteryDischargeFloorA;\n\nconst batterySoftBreachStartedMs = batterySoftBreachNow\n ? (history.batterySoftBreachMsPrev !== null\n ? history.batterySoftBreachMsPrev\n : nowMs)\n : null;\n\nconst batterySoftBreachMinutes =\n batterySoftBreachStartedMs !== null\n ? Math.max(0, (nowMs - batterySoftBreachStartedMs) / 60000)\n : 0;\n\n// Vrai tant que le depassement n'a pas dure plus que la tolerance de sa bande.\n// Une tolerance a 0 minute redonne exactement le comportement instantane.\nconst batterySoftDischargeHoldOk =\n !batterySoftBreachNow ||\n batterySoftBreachMinutes <= batterySoftToleranceMinutes;\n\n// =====================================================\n// GRID RELAX IN force_charge_now\n// =====================================================\n// Assouplissement uniquement pour la garde réseau,\n// sans toucher aux sécurités dures globales.\nconst gridForceChargeRelaxSoftA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 28 :\n parsed.soc >= 80 ? 16 :\n parsed.soc >= 60 ? 8 :\n parsed.soc >= 40 ? 4 :\n 0\n )\n : 0;\n\nconst gridForceChargeRelaxHardA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 39 :\n parsed.soc >= 80 ? 29 :\n parsed.soc >= 60 ? 18 :\n parsed.soc >= 40 ? 8 :\n 0\n )\n : 0;\n\nconst gridBatterySoftDynamicABase =\n Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);\n\nconst gridBatteryHardDynamicABase =\n Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);\n\nlet gridBatterySoftDynamicA =\n gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;\n\nlet gridBatteryHardDynamicA =\n gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;\n\n// Garantit que le hard reste plus permissif que le soft d'au moins 3 A\nif (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {\n gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;\n}\n\n// =====================================================\n// BASE SAFETY\n// =====================================================\nconst commonSafetyStartOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusOverload &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.vebusAcOutSoftReached &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\nconst commonSafetyHoldOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageHardLow &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\n// =====================================================\n// EXCEDENT SOLAIRE — LA GRANDEUR QUI DECIDE D'UN DEMARRAGE (03/09/2026)\n// =====================================================\n// Incident du 03/09 19:44 : PV 758 W, maison 1202 W, batterie deja a -12 A,\n// nuit qui tombe. Le controleur a lance 1,6 kW de charge parce que\n// pv_start_min_w (700 W) est une PUISSANCE PV ABSOLUE : \"758 >= 700, donc il y\n// a du soleil\". L'excedent, lui, valait -444 W. En 33 minutes la voiture a pris\n// 0,897 kWh et la batterie en a rendu 1,479 kWh, la veille d'une nuit que le\n// bilan energetique annoncait deja deficitaire (SOC requis 99,7 %).\n//\n// La grandeur juste n'est pas la production, c'est ce qui RESTE une fois la\n// maison servie. Sans injection et reseau absent, elle vaut la puissance\n// batterie au signe pres :\n//\n// excedent = PV - maison(hors EVCS) ~= puissance batterie\n//\n// C'est aussi ce qui separe les deux situations que la mesure PV seule confond\n// (PV_mesuree = min(ressource, conso + absorption batterie)) :\n//\n// - CENSURE : batterie pleine, MPPT bride faute de puits. La production se\n// cale sur la maison, l'excedent vaut ~0, la batterie ne bouge pas. Il y a\n// de la ressource REFUSEE : la provoquer avec la voiture est legitime, et\n// c'est toute la raison d'etre de P3 / de l'assistance batterie.\n// - DEFICIT reel : soir, nuages. L'excedent est NEGATIF, la batterie porte\n// deja la maison. Charger la voiture, c'est la vider.\n//\n// Zero n'est donc pas un seuil de plus : c'est la frontiere physique entre les\n// deux, et elle etait invisible tant qu'on regardait la production seule.\n// Aucune constante n'est introduite ; le chemin \"soleil reel\" reutilise\n// pv_start_min_w tel quel, applique a la bonne grandeur.\n//\n// PORTEE : DEMARRAGE uniquement. Les conditions de MAINTIEN sont inchangees —\n// une charge etablie a deja son plancher de decharge et sa tolerance temporelle\n// (01/09), et couper une charge en cours sur une mesure d'excedent bruitee\n// recreerait l'oscillateur qu'on vient d'eteindre.\nconst solarSurplusW = parsed.pvPowerW - derived.houseNonEvcsW;\n\n// LA PROVOCATION DOIT ETRE JUSTIFIEE PAR LES CHARGEURS EUX-MEMES (03/09/2026)\n// =====================================================================\n// Un excedent proche de zero est AMBIGU : c'est la signature de la censure\n// (production bridee, calee sur la maison) ET celle de l'equilibre de fin de\n// journee (production reelle qui rejoint la consommation). Le releve du 03/09\n// contient les deux a 51 minutes d'ecart, avec le meme excedent :\n//\n// 18:50 PV 769 W, maison 755 W, excedent +14 W -> 7 trackers LIMITES\n// 19:41 PV 817 W, maison 816 W, excedent +1 W -> 7 trackers MPPT_ACTIVE\n//\n// Le premier cache de la ressource refusee, le second n'a plus rien a donner.\n// Aucun seuil de puissance ne les separe. Les chargeurs, eux, le disent :\n// 'mppt_active' = je suis a mon point de puissance maximale, je donne tout ;\n// 'voltage_current_limited' / 'off' = je me retiens faute de preneur.\n//\n// Signal DIRECT (le materiel lui-meme, pas un proxy), INSTANTANE (30 s, la\n// cadence de la boucle) et, sur l'incident du 03/09, il a bascule a 19:20 —\n// 24 minutes avant le drapeau `pv_censored` du bilan energetique, qui tourne\n// toutes les 5 min et dont l'un des motifs est justement le proxy SOC >= 90\n// qu'on cherchait a remplacer.\n//\n// ⚠️ DEMARRAGE UNIQUEMENT, et c'est structurel, pas une precaution :\n// gager le sourceMode ou le MAINTIEN sur ce signal ferait que la charge, en\n// donnant un preneur au MPPT, detruirait sa propre condition d'autorisation —\n// exactement le bistable de l'abondance P6 corrige le 24/08. La garde n'est\n// lue que quand la voiture est a l'arret, instant ou l'etat des trackers\n// decrit la situation SANS elle. Verifie le 01/09 a 14:22-14:34 : les trackers\n// restaient limites PENDANT une charge de 3,4 kW sous 6,9 kW de PV — a midi la\n// ressource depasse ce que la voiture peut prendre, le signal ne s'auto-annule\n// donc pas non plus.\n//\n// Aucune lecture exploitable (integration muette) => on ne sait pas => on\n// retombe sur le comportement anterieur. On ne devine jamais un etat.\nconst mpptReadable = parsed.mpptReadableCount > 0;\nconst mpptAllAtMaxPower = mpptReadable && parsed.mpptHoldingBackCount === 0;\nconst solarProvocationJustified = !mpptAllAtMaxPower;\n\n// Chemin PROVOCATION (batterie haute, ou sourceMode solar_potential) : on\n// n'exige pas d'excedent mesurable — il est censure par construction — mais on\n// exige que la batterie ne porte pas deja la maison.\nconst solarSurplusProvocationOk = solarSurplusW >= 0;\n\n// Chemin SOLEIL REEL : l'excedent doit atteindre le seuil de demarrage.\nconst solarSurplusStartOk =\n context.sourceMode === 'solar_potential'\n ? (solarSurplusProvocationOk && solarProvocationJustified)\n : solarSurplusW >= context.effectivePvStartMinW;\n\n// =====================================================\n// BATTERY-ASSISTED SOLAR START/HOLD\n// IMPORTANT:\n// Installation sans injection:\n// le PV visible peut rester bas tant que l'EVCS ne démarre pas.\n// On autorise donc un démarrage/maintien local si la batterie est haute\n// et que la dette nuage reste faible.\n// =====================================================\nconst batteryAssistSolarPolicyEligible =\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\nconst batteryAssistSolarStartSocMin = 90;\nconst batteryAssistSolarHoldSocMin = 85;\n\nconst batteryAssistSolarDebtStartMaxKwh = Math.max(\n cfg.cloudDebtRecoveryThresholdKwh,\n Math.min(params.cloudDebtMaxKwh, 0.05)\n);\n\nconst batteryAssistSolarStartOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyStartOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) &&\n parsed.soc > batteryAssistSolarHoldSocMin &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n solarSurplusProvocationOk &&\n solarProvocationJustified &&\n cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&\n !cloudDebtActive;\n\nconst batteryAssistSolarHoldOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyHoldOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&\n batterySoftDischargeHoldOk &&\n cloudToleranceOk;\n\n// =====================================================\n// START / HOLD CONDITIONS\n// =====================================================\n// FENETRE D'ETABLISSEMENT (31/08/2026) — cf. WINDOWS_SOURCEV2 pour la mesure.\n// Pendant les ~150 s qui suivent l'arrivee du reseau, PV et courant batterie ne\n// mesurent rien d'exploitable. On NE DEMARRE PAS sur ces valeurs, et on ne\n// laisse pas une charge en cours s'arreter a cause d'elles. Les gardes de\n// securite (commonSafety*, planchers SOC, surcharge, pas de tampon) restent\n// evaluees normalement : c'est le risque qui prime, jamais le confort.\nconst gridSettling = derived.gridSettling === true;\n\nconst rawSolarStartOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n !gridSettling &&\n commonSafetyStartOk &&\n parsed.soc >= effectiveSocStartFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n solarSurplusStartOk;\n\nconst solarStartOk =\n rawSolarStartOk || batteryAssistSolarStartOk;\n\nconst rawSolarHoldOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyHoldOk &&\n parsed.soc >= effectiveSocHoldFloor &&\n (\n gridSettling ||\n batterySoftDischargeHoldOk\n ) &&\n (\n gridSettling ||\n parsed.pvPowerW >= params.pvHoldMinW ||\n cloudToleranceOk\n );\n\nconst solarHoldOk =\n rawSolarHoldOk || batteryAssistSolarHoldOk;\n\n// FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now\n// P9 (09/08/2026) : la fenêtre départ imminent autorise aussi le réseau.\nconst gridAllowedBySoc = \n strategy.forceGridRequested ||\n context.profile === 'night_hc' ||\n departureUrgentActive ||\n parsed.soc <= 35;\n\nconst gridStartOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyStartOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA;\n\nconst gridHoldOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyHoldOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatteryHardDynamicA;\n\n// P12 (15/09/2026) — le mode `battery_assist_night` etait MORT depuis toujours.\n// Il exige `batteryCurrentA >= plancher de decharge du profil` (0 A), condition\n// impossible la nuit puisque la maison tire sur la batterie (§11, ~ -14 A). Il\n// n'a donc jamais demarre une seule fois.\n//\n// Quand P12 est actif, on remplace ce test de COURANT par un test d'ENERGIE :\n// rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement PV. Le\n// plancher vient du bilan (`required_soc_prudent`, profil p80), il est calcule\n// dans WINDOWS_SOURCEV2 et transmis ici — ce node CONSOMME, il ne recalcule pas.\n//\n// ⚠️ LES GARDES DE COURANT NE SONT PAS SUPPRIMEES POUR AUTANT :\n// - `commonSafetyStartOk` / `commonSafetyHoldOk` restent evalues normalement ;\n// - le MAINTIEN garde `batteryDischargeHardDynamicA`, l'arret dur, qui n'est\n// jamais contourne ;\n// - la garde SOUPLE et sa tolerance temporelle (§35) continuent de s'appliquer\n// par `batterySoftDischargeHoldOk` en aval, et c'est elle qui arretera la\n// charge quand la bande de SOC deviendra trop basse pour le courant tire.\n// Ce qu'on retire, c'est uniquement le plancher de PROFIL, qui interdisait de\n// demarrer quoi que ce soit des que la batterie n'etait pas a l'equilibre.\nconst nightBatterySurplusAvailable = !!nightPrev.nightBatterySurplusAvailable;\nconst nightBatterySurplusFloorPct = Number.isFinite(nightPrev.nightBatterySurplusFloorPct)\n ? nightPrev.nightBatterySurplusFloorPct\n : Infinity;\n\nconst batteryAssistNightStartOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyStartOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n (\n nightBatterySurplusAvailable\n ? parsed.soc > nightBatterySurplusFloorPct\n : (\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA\n )\n );\n\nconst batteryAssistNightHoldOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyHoldOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.batteryCurrentA >= batteryDischargeHardDynamicA &&\n (\n nightBatterySurplusAvailable\n ? parsed.soc > nightBatterySurplusFloorPct\n : parsed.soc > gridTakeoverTriggerPct\n );\n\n// =====================================================\n// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE\n// =====================================================\nconst probeModeActive = context.sourceMode === 'hybrid_probe';\n\nconst probeStartOk =\n probeModeActive &&\n commonSafetyStartOk &&\n (context.morningProbeEligible || context.middayProbeEligible || context.afternoonProbeEligible);\n\nconst probeHoldGuardOk =\n probeModeActive &&\n commonSafetyHoldOk &&\n batterySoftDischargeHoldOk;\n\nconst probeWindowActive =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart < params.probeMinutes;\n\nconst probeElapsedReached =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart >= params.probeMinutes;\n\nconst probeSuccessNow =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n (\n parsed.pvPowerW >= params.pvHoldMinW &&\n parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)\n );\n\n// Une sonde ne peut pas etre declaree ratee sur une mesure prise pendant\n// l'etablissement : c'est le reseau qui masque la production, pas la sonde qui\n// echoue. On differe le verdict, on ne l'invente pas.\nconst probeFailedNow =\n probeModeActive &&\n probeElapsedReached &&\n !gridSettling &&\n !probeSuccessNow;\n\n// =====================================================\n// STEP-UP LOGIC\n// =====================================================\nconst stepUpByBatteryReserve =\n batteryPriorityMode &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA;\n\nconst stepUpByForecastMode =\n !batteryPriorityMode &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW\n ) &&\n derived.forecastBufferKwh >= -1 &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&\n cloudToleranceOk;\n\n// Pas de montee en courant sur des mesures d'etablissement. La descente, elle,\n// n'est pas bridee : c'est la direction sure.\nconst solarShouldStepUp =\n !gridSettling &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n (\n stepUpByBatteryReserve ||\n stepUpByForecastMode ||\n parsed.pvPowerW >= (context.effectivePvStartMinW + 400)\n );\n\nconst gridShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA &&\n !derived.houseLoadHighVsHeadroom;\n\nconst batteryAssistNightShouldStepUp =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA &&\n batteryAssistAllowed;\n\n// =====================================================\n// STEP-DOWN LOGIC\n// =====================================================\nconst solarShouldStepDownBattery =\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst solarShouldStepDownCloudDebt =\n !cloudToleranceOk &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst gridShouldStepDownBattery =\n parsed.batteryCurrentA < gridBatterySoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst batteryAssistNightShouldStepDown =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA &&\n (\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA ||\n parsed.soc <= gridTakeoverTriggerPct ||\n batteryRecoveryUrgent\n );\n\nconst shouldStepDownVebusPower =\n derived.vebusAcOutSoftReached &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownVebusVoltage =\n derived.vebusDcVoltageSoftLow &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownOverload =\n derived.anyVebusOverload &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStopOverloadAtMin =\n derived.anyVebusOverload &&\n derived.currentReferenceA <= params.currentMinA &&\n sessionObservedActive;\n\n// =====================================================\n// HARD STOPS / PROTECTIONS\n// =====================================================\nconst batteryCurrentHardExceeded =\n parsed.batteryCurrentA <= batteryDischargeHardDynamicA;\n\n// overload volontairement exclu du hard stop immédiat\nconst mustStopHard =\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageHardLow ||\n derived.vebusAcOutHardReached ||\n batteryCurrentHardExceeded;\n\n// 07/10/2026 (proprietaire : « brider a 6 A au lieu de couper »). Le 06/10\n// sous force_grid_now, deux pointes de la maison (7,1 et 7,7 kW, voiture\n// comprise) ont fait passer la batterie a -41,9 / -47,3 A : la garde coupait la\n// voiture puis posait 20 min de verrou — 2,0 kWh donnes au lieu de ~4 en\n// 66 min. La voiture est la charge compressible : au-dessus du courant mini, on\n// la RAMENE au mini tout de suite (sans attendre le cooldown) ; l'arret et son\n// verrou ne restent que si la batterie est encore trop sollicitee AU MINI.\nconst gridBatteryHardBreach =\n sessionObservedActive &&\n isGridSource &&\n parsed.batteryCurrentA < gridBatteryHardDynamicA;\n\nconst gridBatteryCutToMin =\n gridBatteryHardBreach &&\n derived.currentReferenceA > params.currentMinA;\n\nconst gridBatteryProtectionStop =\n gridBatteryHardBreach &&\n !gridBatteryCutToMin;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n batteryPolicy: {\n socBand,\n batteryDischargeSoftDynamicA,\n batteryDischargeHardDynamicA,\n batteryChargeReserveDynamicA,\n batterySoftToleranceMinutes,\n batteryHardToleranceSeconds,\n batteryRecoveryMinutes,\n\n // Depassement souple en cours (01/09/2026). startedMs = null quand il\n // n'y a pas de depassement : c'est ce que HA_SERVICE_CALLS_OUTPUT lit\n // pour ecrire ou effacer le helper de persistance.\n batterySoftBreachNow,\n batterySoftBreachStartedMs,\n batterySoftBreachMinutes,\n batterySoftDischargeHoldOk,\n\n gridBatterySoftDynamicA,\n gridBatteryHardDynamicA,\n gridForceChargeRelaxSoftA,\n gridForceChargeRelaxHardA,\n\n batteryAssistSolarStartSocMin,\n batteryAssistSolarHoldSocMin,\n batteryAssistSolarDebtStartMaxKwh,\n\n houseSpikeDetected,\n solarPotentialContext,\n outsideWindowLocalBypass,\n },\n\n locks: {\n nextAllowedWindowStartMs,\n blockActive,\n blockStillRelevant,\n startBlockedByReason,\n },\n\n cloud: {\n sessionObservedActive,\n batteryNetPowerW,\n cloudDebtNextKwh,\n cloudDebtMeaningful,\n cloudEventDischargingNow,\n continuingCloudEvent,\n enteringCloudDebt,\n cloudDebtActive,\n cloudDebtStartedMs,\n cloudDebtDurationMinutes,\n effectiveCloudToleranceMinutes,\n cloudToleranceOk,\n },\n\n modes: {\n batteryFullHarvestMode,\n forecastCommitMode,\n batteryPriorityMode,\n },\n\n nightEvcs: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n balanceMorningTargetApplied,\n nightGridSessionCommitted,\n // P12 — passe-plat du verrou anti-oscillateur vers la sortie.\n nightBatterySurplusExhausted: !!nightPrev.nightBatterySurplusExhausted,\n nightBatterySurplusAvailable: !!nightPrev.nightBatterySurplusAvailable,\n // Budget voiture de la nuit (30/09) — passe-plat vers la sortie.\n nightBudgetKwh: nightPrev.nightBudgetKwh ?? null,\n nightBudgetDeliveredKwh: nightPrev.nightBudgetDeliveredKwh ?? null,\n nightBudgetReached: !!nightPrev.nightBudgetReached,\n nightBudgetToWrite: nightPrev.nightBudgetToWrite ?? null,\n nightChargeMode: nightPrev.nightChargeMode || 'sans_edf',\n nightCarWaitsNoGrid: !!nightPrev.nightCarWaitsNoGrid,\n nightBatteryGridOnly: !!nightPrev.nightBatteryGridOnly,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n vehicleChargeCompleted,\n nightRecoveryDone,\n departureUrgentActive,\n departureChargeDone,\n dayVehicleFullIdle,\n nightPreferredSourceMode,\n },\n\n safeguards: {\n isGridSource,\n commonSafetyStartOk,\n commonSafetyHoldOk,\n profileSocStartFloor,\n effectiveSocStartFloor,\n solarAbundance,\n solarAbundanceHold,\n profileSocHoldFloor,\n effectiveSocHoldFloor,\n profileBatteryDischargeFloorA,\n\n // Excedent solaire (03/09/2026) : la grandeur qui autorise un demarrage.\n // Plancher du soir issu du bilan energetique (03/09/2026).\n eveningSocStartFloorPct,\n eveningFloorFromBalance,\n balanceRequiredSocPct: params.balanceRequiredSocPct,\n balanceEnergyToCoverKwh: params.balanceEnergyToCoverKwh,\n balanceAgeMin: balanceAgeMin === null ? null : Math.round(balanceAgeMin * 10) / 10,\n balanceEveningFloorUsable,\n\n solarSurplusW,\n solarSurplusProvocationOk,\n solarSurplusStartOk,\n\n // Etat declare par les chargeurs solaires (03/09/2026).\n mpptReadableCount: parsed.mpptReadableCount,\n mpptHoldingBackCount: parsed.mpptHoldingBackCount,\n mpptAllAtMaxPower,\n solarProvocationJustified,\n batteryCurrentHardExceeded,\n mustStopHard,\n gridBatteryProtectionStop,\n gridBatteryCutToMin,\n },\n\n conditions: {\n rawSolarStartOk,\n rawSolarHoldOk,\n batteryAssistSolarPolicyEligible,\n batteryAssistSolarStartOk,\n batteryAssistSolarHoldOk,\n\n solarStartOk,\n solarHoldOk,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n gridStartOk,\n gridHoldOk,\n\n batteryAssistNightStartOk,\n nightBatterySurplusAvailable,\n nightBatterySurplusFloorPct,\n batteryAssistNightHoldOk,\n\n probeStartOk,\n probeHoldGuardOk,\n probeWindowActive,\n probeElapsedReached,\n probeSuccessNow,\n probeFailedNow,\n\n solarShouldStepUp,\n gridShouldStepUp,\n batteryAssistNightShouldStepUp,\n\n solarShouldStepDownBattery,\n solarShouldStepDownCloudDebt,\n gridShouldStepDownBattery,\n batteryAssistNightShouldStepDown,\n\n shouldStepDownVebusPower,\n shouldStepDownVebusVoltage,\n shouldStepDownOverload,\n shouldStopOverloadAtMin,\n }\n }\n}];\n" + "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n\n return {\n date,\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction hhmmssFromMinutes(minOfDay) {\n const mm = ((minOfDay % 1440) + 1440) % 1440;\n const hh = Math.floor(mm / 60);\n const mi = mm % 60;\n return `${to2(hh)}:${to2(mi)}:00`;\n}\n\nfunction nextDailyOccurrenceMs(startMin, refMs) {\n if (!Number.isFinite(startMin)) return null;\n\n const today = getParisParts(refMs).date;\n const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;\n const todayMs = parseParisStampToMs(todayStamp);\n\n if (todayMs !== null && todayMs > refMs + 1000) return todayMs;\n\n const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;\n const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;\n return parseParisStampToMs(tomorrowStamp);\n}\n\nconst prev = $input.first().json;\n\nconst {\n nowMs,\n cfg,\n parsed,\n params,\n history,\n batteryTune,\n strategy,\n derived,\n context,\n lifecycle,\n} = prev;\n\n// =====================================================\n// SESSION / SOURCE\n// =====================================================\nconst sessionObservedActive =\n parsed.evcsChargingSwitchOn ||\n parsed.controlLatch ||\n parsed.chargePermissionNow;\n\nconst isGridSource =\n context.sourceMode === 'grid_hc_allowed' ||\n context.sourceMode === 'grid_forced';\n\nconst isLocalSolarSource =\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential';\n\n// =====================================================\n// SOC BAND / BATTERY DYNAMIC POLICY\n// =====================================================\nconst socBand =\n parsed.soc > 90 ? 'gt90' :\n parsed.soc > 80 ? '80_90' :\n parsed.soc > 60 ? '60_80' :\n parsed.soc > 40 ? '40_60' :\n parsed.soc > 25 ? '25_40' :\n 'lt25';\n\nconst band = batteryTune.bands[socBand];\n\nconst dischargeModeFactor =\n strategy.forceChargeRequested ? 1.2 :\n strategy.forceGridRequested ? 0.7 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst chargeReserveModeFactor =\n strategy.forceChargeRequested ? 0.8 :\n strategy.forceGridRequested ? 1.1 :\n strategy.userStrategy === 'auto_solar' ? 1.1 :\n 1.0;\n\nconst toleranceModeFactor =\n strategy.forceChargeRequested ? 1.25 :\n strategy.forceGridRequested ? 0.8 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;\nconst batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;\nconst batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);\nconst batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;\nconst batteryHardToleranceSeconds = band.hardToleranceSec;\nconst batteryRecoveryMinutes = band.recoveryMin;\n\n// =====================================================\n// CONTEXT HELPERS\n// =====================================================\nconst houseSpikeThresholdW = 1500;\nconst houseSpikeDetected =\n (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;\n\nconst solarPotentialContext =\n context.sourceMode === 'solar_potential' ||\n (\n parsed.soc >= 80 &&\n derived.forecastBufferKwh > 0 &&\n parsed.pvPowerW < context.effectivePvStartMinW &&\n derived.houseNonEvcsW < 1200\n );\n\n// =====================================================\n// OUTSIDE-WINDOW LOCAL BYPASS\n// IMPORTANT:\n// en auto_solar_plus ou force_charge_now, un contexte local solaire\n// ne doit plus rester bloqué juste parce qu'on est hors fenêtre.\n// =====================================================\nconst outsideWindowLocalBypass =\n context.profile === 'outside_window' &&\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\n// =====================================================\n// REASON-BASED LOCKS\n// =====================================================\nconst nextWindowCandidates = [\n nextDailyOccurrenceMs(params.morningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),\n nextDailyOccurrenceMs(params.eveningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),\n].filter(v => Number.isFinite(v));\n\nconst nextAllowedWindowStartMs =\n nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;\n\nconst blockActive =\n history.blockedUntilMs !== null &&\n history.blockedUntilMs > nowMs + 1000;\n\nconst blockStillRelevant =\n history.lastBlockReason === 'outside_window'\n ? (context.profile === 'outside_window' && !outsideWindowLocalBypass)\n : history.lastBlockReason === 'probe_failed'\n ? context.sourceMode === 'hybrid_probe'\n : history.lastBlockReason === 'battery_protection_stop'\n ? (\n parsed.batteryCurrentA < -params.batteryNeutralBandA ||\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageSoftLow\n )\n : history.lastBlockReason === 'grid_battery_guard'\n ? isGridSource\n // Garde batterie declenche RESEAU ABSENT (18/09) : sa cause est le\n // trou entre la demande de reseau et la fermeture du contacteur.\n // Le reseau arrive => la cause a disparu => le verrou se leve. Si la\n // batterie est encore sollicitee au retry, le garde re-mord et pose\n // cette fois 'grid_battery_guard' (reseau present) — verrou plein.\n // Convergence en une tentative, meme patron que Fix H.\n : history.lastBlockReason === 'grid_battery_guard_no_grid'\n ? (isGridSource && !parsed.gridAvailable)\n : history.lastBlockReason === 'vebus_overload_persistent'\n ? derived.anyVebusOverload\n : false;\n\nconst startBlockedByReason = blockActive && blockStillRelevant;\n\n// =====================================================\n// CLOUD DEBT\n// =====================================================\nconst batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;\nlet cloudDebtNextKwh = params.cloudDebtPrevKwh;\n\nif (sessionObservedActive) {\n if (batteryNetPowerW < 0 && !isGridSource) {\n cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;\n } else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n }\n} else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n}\n\ncloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);\n\nconst cloudDebtMeaningful =\n cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;\n\nconst cloudEventDischargingNow =\n sessionObservedActive &&\n !isGridSource &&\n parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&\n cloudDebtMeaningful;\n\nconst continuingCloudEvent =\n cloudEventDischargingNow &&\n history.cloudDebtStartedMsPrev !== null &&\n params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;\n\nconst enteringCloudDebt =\n cloudEventDischargingNow && !continuingCloudEvent;\n\nconst cloudDebtActive = cloudEventDischargingNow;\n\nconst cloudDebtStartedMs =\n cloudEventDischargingNow\n ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)\n : null;\n\nconst cloudDebtDurationMinutes =\n cloudDebtActive && cloudDebtStartedMs !== null\n ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)\n : 0;\n\nconst effectiveCloudToleranceMinutes =\n params.cloudToleranceMinutes +\n (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +\n (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);\n\nconst cloudToleranceOk =\n cloudDebtNextKwh <= params.cloudDebtMaxKwh &&\n (\n !cloudDebtActive ||\n cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes\n );\n\n// =====================================================\n// LEGACY / FORECAST MODES\n// =====================================================\nconst batteryFullHarvestMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.batteryFullSoc &&\n parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);\n\nconst forecastCommitMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.forecastCommitSocFloor &&\n parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&\n derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;\n\nconst batteryPriorityMode =\n !batteryFullHarvestMode && !forecastCommitMode;\n\n// =====================================================\n// EVCS NIGHT ENGINE — R1 (07/08/2026)\n// Le moteur nuit est calculé UNE SEULE FOIS dans WINDOWS_SOURCEV2\n// (payload nightEvcsPreview). Ce node CONSOMME, il ne recalcule plus.\n// TOUTE évolution de la logique nuit se fait dans WINDOWS_SOURCEV2.\n// Fallback sûr si preview absent : session inactive / 'blocked'.\n// =====================================================\nconst nightPrev = prev.nightEvcsPreview || {};\n\nconst nightSessionActive = !!nightPrev.nightSessionActive;\nconst nightSessionNeedsCapture = !!nightPrev.nightSessionNeedsCapture;\nconst nightSessionNeedsClear = !!nightPrev.nightSessionNeedsClear;\nconst nightSessionStartSocPct =\n Number.isFinite(nightPrev.nightSessionStartSocPct) ? nightPrev.nightSessionStartSocPct : null;\nconst nightSessionStartAt = nightPrev.nightSessionStartAt || '';\nconst nightSessionStartMs =\n Number.isFinite(nightPrev.nightSessionStartMs) ? nightPrev.nightSessionStartMs : null;\n\nconst batteryHardFloorPct =\n Number.isFinite(nightPrev.batteryHardFloorPct)\n ? nightPrev.batteryHardFloorPct\n : params.evcsNightBatteryHardFloorPct;\nconst batteryMorningTargetPct =\n Number.isFinite(nightPrev.batteryMorningTargetPct)\n ? nightPrev.batteryMorningTargetPct\n : params.evcsNightTargetWeakForecastPct;\nconst balanceMorningTargetApplied = !!nightPrev.balanceMorningTargetApplied;\nconst nightGridSessionCommitted = !!nightPrev.nightGridSessionCommitted;\nconst maxBatteryDischargePoints =\n Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0;\nconst gridTakeoverTriggerPct =\n Number.isFinite(nightPrev.gridTakeoverTriggerPct)\n ? nightPrev.gridTakeoverTriggerPct\n : Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor = !!nightPrev.batteryBelowHardFloor;\nconst batteryBelowMorningTarget = !!nightPrev.batteryBelowMorningTarget;\nconst batteryAtOrBelowGridTakeoverTrigger = !!nightPrev.batteryAtOrBelowGridTakeoverTrigger;\n\nconst batteryKwhMissingToTarget =\n Number.isFinite(nightPrev.batteryKwhMissingToTarget) ? nightPrev.batteryKwhMissingToTarget : 0;\nconst batteryRecoveryMinutesNeeded =\n Number.isFinite(nightPrev.batteryRecoveryMinutesNeeded) ? nightPrev.batteryRecoveryMinutesNeeded : 0;\nconst batteryRecoveryUrgent = !!nightPrev.batteryRecoveryUrgent;\nconst batteryRecoveryAllowedNow = !!nightPrev.batteryRecoveryAllowedNow;\nconst batteryTargetReached = !!nightPrev.batteryTargetReached;\n\nconst batteryAssistAllowed = !!nightPrev.batteryAssistAllowed;\nconst nightGridTakeoverNeeded = !!nightPrev.nightGridTakeoverNeeded;\nconst nightTakeoverLatched = !!nightPrev.nightTakeoverLatched;\nconst vehicleChargeCompleted = !!nightPrev.vehicleChargeCompleted;\nconst nightRecoveryDone = !!nightPrev.nightRecoveryDone;\nconst departureUrgentActive = !!nightPrev.departureUrgentActive;\nconst departureChargeDone = !!nightPrev.departureChargeDone;\nconst dayVehicleFullIdle = !!nightPrev.dayVehicleFullIdle;\nconst nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked';\n\n// =====================================================\n// PROFILE-BASED THRESHOLDS\n// =====================================================\n// =====================================================\n// PLANCHER DU SOIR : UN NIVEAU FIXE, REMPLACE PAR L'ENERGIE A COUVRIR (03/09/2026)\n// =====================================================\n// `evening_soc_start_min` = 85 % est une tentative de dire « garde de quoi\n// passer la nuit ». C'est un NIVEAU, pris a un instant, et il ne sait rien de\n// la nuit qui vient : ni sa duree, ni la consommation a couvrir, ni l'heure a\n// laquelle le solaire prendra le relais.\n//\n// Le bilan energetique calcule exactement cela et le publie —\n// `evcs_balance_required_soc`, deduit de l'energie a couvrir jusqu'au\n// croisement production/consommation. Personne ne le lisait : `grep\n// evcs_balance` sur les deux workflows de decision renvoyait 0.\n//\n// Le 03/09 a 19:44, les deux nombres coexistaient dans le meme systeme :\n// plancher du soir 85 % (SOC 94, donc charge autorisee) contre SOC requis\n// 99,7 % pour tenir jusqu'au croisement de 9 h. La charge du soir a fait\n// passer le manque de la nuit de 0,86 a 2,21 kWh.\n//\n// SENS UNIQUE, ET C'EST DELIBERE : le bilan ne peut que RENFORCER le plancher,\n// jamais l'abaisser (Math.max). L'assouplir — le bilan dira souvent moins de\n// 85 % par forte prevision d'ete — autoriserait des charges qui n'ont pas lieu\n// aujourd'hui : c'est une decision distincte, avec ses propres risques, qui\n// doit etre validee pour elle-meme et non glissee dans ce correctif.\n// Corollaire : le max rend la garde robuste sans borne ni ecretage — une\n// valeur nulle, negative ou aberrante ne peut pas relacher le plancher.\n//\n// PORTEE : plancher de DEMARRAGE. Le plancher de maintien reste\n// min(profileSocHoldFloor, ...) donc inchange a 80 % : relever le plancher de\n// demarrage ne coupe aucune charge en cours.\n//\n// L'abondance P6 continue de primer : PV moyen >= 4 kW ET batterie qui monte a\n// >= +20 A est une PREUVE MESUREE que la ressource depasse ce que le systeme\n// absorbe — le cas ou la voiture est effectivement gratuite.\n//\n// REPLI : lecture absente, statut non 'ok', ou bilan plus vieux que six de ses\n// cycles => regle historique inchangee. Le kill-switch existe deja et n'a pas\n// besoin d'etre double : `input_boolean.evcs_balance_enabled` a OFF arrete les\n// ecritures du bilan, `balance_last_run` se fige, et le plancher retombe seul\n// sur 85 % en une demi-heure.\nconst BALANCE_MAX_AGE_MIN = 30;\nconst balanceAgeMin =\n params.balanceLastRunMs !== null && params.balanceLastRunMs !== undefined\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\nconst balanceFresh =\n balanceAgeMin !== null &&\n balanceAgeMin >= -5 &&\n balanceAgeMin <= BALANCE_MAX_AGE_MIN;\nconst balanceEveningFloorUsable =\n balanceFresh &&\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct);\n\nconst eveningSocStartFloorPct = balanceEveningFloorUsable\n ? Math.max(params.eveningSocStartMin, params.balanceRequiredSocPct)\n : params.eveningSocStartMin;\nconst eveningFloorFromBalance =\n balanceEveningFloorUsable && eveningSocStartFloorPct > params.eveningSocStartMin;\n\nconst profileSocStartFloor =\n context.profile === 'morning_solar' ? params.morningSocMin :\n context.profile === 'midday_hc_or_solar' ? params.middaySocMin :\n context.profile === 'evening_high_soc' ? eveningSocStartFloorPct :\n params.socStartMin;\n\n// P6 ABONDANCE : PV moyen élevé + batterie en charge franche => le plancher SOC\n// de démarrage perd son sens (la batterie continue de monter même avec l'EVCS à 6 A).\n// Jours nuageux ou batterie peu chargée par le PV : rien ne change.\nconst PV_ABUNDANCE_AVG_MIN_W = 4000;\nconst ABUNDANCE_BATTERY_CHARGE_MIN_A = 20;\nconst ABUNDANCE_SOC_FLOOR_PCT = 40;\n\n// 24/08/2026 — L'abondance se PROUVE au démarrage (batterie qui monte franchement,\n// >= +20 A, avant qu'on touche à quoi que ce soit) mais elle se MAINTIENT tant que\n// le PV reste fort ET que la batterie n'est pas devenue fournisseuse. Sans cette\n// distinction, la charge du véhicule — qui détourne précisément le surplus — fait\n// tomber le courant batterie sous +20 A et détruit sa propre condition\n// d'autorisation : système bistable, oscillation à la période de min_run.\nconst solarAbundanceStart =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A;\nconst solarAbundanceHold =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= 0;\nconst solarAbundance = solarAbundanceStart; // conservé pour la traçabilité/debug\n\nconst abundanceSocFloor = (abundant) =>\n abundant ? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT) : profileSocStartFloor;\n\nconst effectiveSocStartFloor = abundanceSocFloor(solarAbundanceStart);\n\nconst profileSocHoldFloor =\n context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :\n context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :\n context.profile === 'evening_high_soc' ? params.eveningSocStopMin :\n params.socStopMin;\n\n// INVARIANT (24/08/2026) : on ne doit JAMAIS avoir le droit de DÉMARRER une charge\n// qu'on n'a pas le droit de MAINTENIR. Le plancher de maintien doit donc rester\n// <= au plancher de démarrage effectif.\n// Incident du 24/08 midi : bascule de profil vers midday_hc_or_solar à 12:24 avec\n// SOC 58 % => plancher de démarrage 40 % (abondance P6) mais plancher de maintien\n// max(29, 75-15) = 60 % => démarrage autorisé, maintien refusé, cycle limite de\n// 3 min (min_run) sur toute la bande [40 %, 60 %], et ~2,6 kW de PV écrêtés à\n// chaque creux faute de preneur (ESS sans injection).\nconst effectiveSocHoldFloor = Math.min(\n profileSocHoldFloor,\n abundanceSocFloor(solarAbundanceHold)\n);\n\nconst profileSpecificDischargeFloorA =\n context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :\n context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :\n context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :\n params.batteryDischargeSoftA;\n\nconst profileBatteryDischargeFloorA =\n Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);\n\n// =====================================================\n// DEPASSEMENT SOUPLE DU PLANCHER DE DECHARGE (01/09/2026)\n// =====================================================\n// La table des bandes SOC (INPUT_PARSE) declare depuis toujours DEUX regimes :\n// un courant SOUPLE tolere pendant des MINUTES (softToleranceMin : 12 min a\n// SOC > 90, 1 min sous 25) et un courant DUR tolere quelques SECONDES. Seuls\n// les seuils de courant etaient lus ; la dimension temporelle etait calculee,\n// exportee dans le debug, et branchee sur AUCUNE decision. Le controleur\n// comparait donc un echantillon instantane, toutes les 30 s, a un plancher.\n//\n// Incident du 01/09 (14:26 et 14:56, SOC 94, batterie en float) : le courant\n// batterie oscillait entre +1,8 et -2,6 A d'un echantillon a l'autre, soit le\n// bruit de regulation d'une batterie qui ne fait rien. Le test devenait un\n// tirage a pile ou face et la charge du vehicule s'arretait dessus. Chaque\n// arret coutait ~0,45 kWh de production refusee : batterie pleine, le vehicule\n// etait le SEUL preneur, donc la PV s'effondrait de 6,6 a 2,5 kW en 30 s, et\n// la reprise mettait 21 min a remonter de 6 A a 20 A. 1 a 6 arrets par jour\n// sur la semaine du 26 au 31/08.\n//\n// Le correctif est PUREMENT TEMPOREL : aucun seuil n'est deplace, aucune\n// constante n'est ajoutee. On applique la table telle qu'elle est ecrite :\n// un depassement souple doit DURER pour compter. Comme le bruit change de\n// signe en quelques secondes, le compteur se remet a zero de lui-meme ; une\n// vraie decharge, elle, reste negative a chaque echantillon et finit par\n// depasser la tolerance de sa bande.\n//\n// CE QUI N'EST PAS TOUCHE : le plancher de DEMARRAGE reste instantane (ne pas\n// lancer une charge sur une batterie qui se decharge deja, c'est la frontiere\n// \"delestage et non charge prioritaire\"), et le garde DUR reste instantane —\n// hardToleranceSec n'est volontairement PAS branche, cela ne pourrait que\n// ralentir une securite, et c'est le risque qui prime.\nconst batterySoftBreachNow =\n sessionObservedActive &&\n parsed.batteryCurrentA < profileBatteryDischargeFloorA;\n\nconst batterySoftBreachStartedMs = batterySoftBreachNow\n ? (history.batterySoftBreachMsPrev !== null\n ? history.batterySoftBreachMsPrev\n : nowMs)\n : null;\n\nconst batterySoftBreachMinutes =\n batterySoftBreachStartedMs !== null\n ? Math.max(0, (nowMs - batterySoftBreachStartedMs) / 60000)\n : 0;\n\n// Vrai tant que le depassement n'a pas dure plus que la tolerance de sa bande.\n// Une tolerance a 0 minute redonne exactement le comportement instantane.\nconst batterySoftDischargeHoldOk =\n !batterySoftBreachNow ||\n batterySoftBreachMinutes <= batterySoftToleranceMinutes;\n\n// =====================================================\n// GRID RELAX IN force_charge_now\n// =====================================================\n// Assouplissement uniquement pour la garde réseau,\n// sans toucher aux sécurités dures globales.\nconst gridForceChargeRelaxSoftA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 28 :\n parsed.soc >= 80 ? 16 :\n parsed.soc >= 60 ? 8 :\n parsed.soc >= 40 ? 4 :\n 0\n )\n : 0;\n\nconst gridForceChargeRelaxHardA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 39 :\n parsed.soc >= 80 ? 29 :\n parsed.soc >= 60 ? 18 :\n parsed.soc >= 40 ? 8 :\n 0\n )\n : 0;\n\nconst gridBatterySoftDynamicABase =\n Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);\n\nconst gridBatteryHardDynamicABase =\n Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);\n\nlet gridBatterySoftDynamicA =\n gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;\n\nlet gridBatteryHardDynamicA =\n gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;\n\n// Garantit que le hard reste plus permissif que le soft d'au moins 3 A\nif (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {\n gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;\n}\n\n// =====================================================\n// BASE SAFETY\n// =====================================================\nconst commonSafetyStartOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusOverload &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.vebusAcOutSoftReached &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\nconst commonSafetyHoldOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageHardLow &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\n// =====================================================\n// EXCEDENT SOLAIRE — LA GRANDEUR QUI DECIDE D'UN DEMARRAGE (03/09/2026)\n// =====================================================\n// Incident du 03/09 19:44 : PV 758 W, maison 1202 W, batterie deja a -12 A,\n// nuit qui tombe. Le controleur a lance 1,6 kW de charge parce que\n// pv_start_min_w (700 W) est une PUISSANCE PV ABSOLUE : \"758 >= 700, donc il y\n// a du soleil\". L'excedent, lui, valait -444 W. En 33 minutes la voiture a pris\n// 0,897 kWh et la batterie en a rendu 1,479 kWh, la veille d'une nuit que le\n// bilan energetique annoncait deja deficitaire (SOC requis 99,7 %).\n//\n// La grandeur juste n'est pas la production, c'est ce qui RESTE une fois la\n// maison servie. Sans injection et reseau absent, elle vaut la puissance\n// batterie au signe pres :\n//\n// excedent = PV - maison(hors EVCS) ~= puissance batterie\n//\n// C'est aussi ce qui separe les deux situations que la mesure PV seule confond\n// (PV_mesuree = min(ressource, conso + absorption batterie)) :\n//\n// - CENSURE : batterie pleine, MPPT bride faute de puits. La production se\n// cale sur la maison, l'excedent vaut ~0, la batterie ne bouge pas. Il y a\n// de la ressource REFUSEE : la provoquer avec la voiture est legitime, et\n// c'est toute la raison d'etre de P3 / de l'assistance batterie.\n// - DEFICIT reel : soir, nuages. L'excedent est NEGATIF, la batterie porte\n// deja la maison. Charger la voiture, c'est la vider.\n//\n// Zero n'est donc pas un seuil de plus : c'est la frontiere physique entre les\n// deux, et elle etait invisible tant qu'on regardait la production seule.\n// Aucune constante n'est introduite ; le chemin \"soleil reel\" reutilise\n// pv_start_min_w tel quel, applique a la bonne grandeur.\n//\n// PORTEE : DEMARRAGE uniquement. Les conditions de MAINTIEN sont inchangees —\n// une charge etablie a deja son plancher de decharge et sa tolerance temporelle\n// (01/09), et couper une charge en cours sur une mesure d'excedent bruitee\n// recreerait l'oscillateur qu'on vient d'eteindre.\nconst solarSurplusW = parsed.pvPowerW - derived.houseNonEvcsW;\n\n// LA PROVOCATION DOIT ETRE JUSTIFIEE PAR LES CHARGEURS EUX-MEMES (03/09/2026)\n// =====================================================================\n// Un excedent proche de zero est AMBIGU : c'est la signature de la censure\n// (production bridee, calee sur la maison) ET celle de l'equilibre de fin de\n// journee (production reelle qui rejoint la consommation). Le releve du 03/09\n// contient les deux a 51 minutes d'ecart, avec le meme excedent :\n//\n// 18:50 PV 769 W, maison 755 W, excedent +14 W -> 7 trackers LIMITES\n// 19:41 PV 817 W, maison 816 W, excedent +1 W -> 7 trackers MPPT_ACTIVE\n//\n// Le premier cache de la ressource refusee, le second n'a plus rien a donner.\n// Aucun seuil de puissance ne les separe. Les chargeurs, eux, le disent :\n// 'mppt_active' = je suis a mon point de puissance maximale, je donne tout ;\n// 'voltage_current_limited' / 'off' = je me retiens faute de preneur.\n//\n// Signal DIRECT (le materiel lui-meme, pas un proxy), INSTANTANE (30 s, la\n// cadence de la boucle) et, sur l'incident du 03/09, il a bascule a 19:20 —\n// 24 minutes avant le drapeau `pv_censored` du bilan energetique, qui tourne\n// toutes les 5 min et dont l'un des motifs est justement le proxy SOC >= 90\n// qu'on cherchait a remplacer.\n//\n// ⚠️ DEMARRAGE UNIQUEMENT, et c'est structurel, pas une precaution :\n// gager le sourceMode ou le MAINTIEN sur ce signal ferait que la charge, en\n// donnant un preneur au MPPT, detruirait sa propre condition d'autorisation —\n// exactement le bistable de l'abondance P6 corrige le 24/08. La garde n'est\n// lue que quand la voiture est a l'arret, instant ou l'etat des trackers\n// decrit la situation SANS elle. Verifie le 01/09 a 14:22-14:34 : les trackers\n// restaient limites PENDANT une charge de 3,4 kW sous 6,9 kW de PV — a midi la\n// ressource depasse ce que la voiture peut prendre, le signal ne s'auto-annule\n// donc pas non plus.\n//\n// Aucune lecture exploitable (integration muette) => on ne sait pas => on\n// retombe sur le comportement anterieur. On ne devine jamais un etat.\nconst mpptReadable = parsed.mpptReadableCount > 0;\nconst mpptAllAtMaxPower = mpptReadable && parsed.mpptHoldingBackCount === 0;\nconst solarProvocationJustified = !mpptAllAtMaxPower;\n\n// Chemin PROVOCATION (batterie haute, ou sourceMode solar_potential) : on\n// n'exige pas d'excedent mesurable — il est censure par construction — mais on\n// exige que la batterie ne porte pas deja la maison.\nconst solarSurplusProvocationOk = solarSurplusW >= 0;\n\n// Chemin SOLEIL REEL : l'excedent doit atteindre le seuil de demarrage.\nconst solarSurplusStartOk =\n context.sourceMode === 'solar_potential'\n ? (solarSurplusProvocationOk && solarProvocationJustified)\n : solarSurplusW >= context.effectivePvStartMinW;\n\n// =====================================================\n// BATTERY-ASSISTED SOLAR START/HOLD\n// IMPORTANT:\n// Installation sans injection:\n// le PV visible peut rester bas tant que l'EVCS ne démarre pas.\n// On autorise donc un démarrage/maintien local si la batterie est haute\n// et que la dette nuage reste faible.\n// =====================================================\nconst batteryAssistSolarPolicyEligible =\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\nconst batteryAssistSolarStartSocMin = 90;\nconst batteryAssistSolarHoldSocMin = 85;\n\nconst batteryAssistSolarDebtStartMaxKwh = Math.max(\n cfg.cloudDebtRecoveryThresholdKwh,\n Math.min(params.cloudDebtMaxKwh, 0.05)\n);\n\nconst batteryAssistSolarStartOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyStartOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) &&\n parsed.soc > batteryAssistSolarHoldSocMin &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n solarSurplusProvocationOk &&\n solarProvocationJustified &&\n cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&\n !cloudDebtActive;\n\nconst batteryAssistSolarHoldOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyHoldOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&\n batterySoftDischargeHoldOk &&\n cloudToleranceOk;\n\n// =====================================================\n// START / HOLD CONDITIONS\n// =====================================================\n// FENETRE D'ETABLISSEMENT (31/08/2026) — cf. WINDOWS_SOURCEV2 pour la mesure.\n// Pendant les ~150 s qui suivent l'arrivee du reseau, PV et courant batterie ne\n// mesurent rien d'exploitable. On NE DEMARRE PAS sur ces valeurs, et on ne\n// laisse pas une charge en cours s'arreter a cause d'elles. Les gardes de\n// securite (commonSafety*, planchers SOC, surcharge, pas de tampon) restent\n// evaluees normalement : c'est le risque qui prime, jamais le confort.\nconst gridSettling = derived.gridSettling === true;\n\nconst rawSolarStartOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n !gridSettling &&\n commonSafetyStartOk &&\n parsed.soc >= effectiveSocStartFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n solarSurplusStartOk;\n\nconst solarStartOk =\n rawSolarStartOk || batteryAssistSolarStartOk;\n\nconst rawSolarHoldOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyHoldOk &&\n parsed.soc >= effectiveSocHoldFloor &&\n (\n gridSettling ||\n batterySoftDischargeHoldOk\n ) &&\n (\n gridSettling ||\n parsed.pvPowerW >= params.pvHoldMinW ||\n cloudToleranceOk\n );\n\nconst solarHoldOk =\n rawSolarHoldOk || batteryAssistSolarHoldOk;\n\n// FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now\n// P9 (09/08/2026) : la fenêtre départ imminent autorise aussi le réseau.\nconst gridAllowedBySoc = \n strategy.forceGridRequested ||\n context.profile === 'night_hc' ||\n departureUrgentActive ||\n parsed.soc <= 35;\n\nconst gridStartOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyStartOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA;\n\nconst gridHoldOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyHoldOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatteryHardDynamicA;\n\n// P12 (15/09/2026) — le mode `battery_assist_night` etait MORT depuis toujours.\n// Il exige `batteryCurrentA >= plancher de decharge du profil` (0 A), condition\n// impossible la nuit puisque la maison tire sur la batterie (§11, ~ -14 A). Il\n// n'a donc jamais demarre une seule fois.\n//\n// Quand P12 est actif, on remplace ce test de COURANT par un test d'ENERGIE :\n// rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement PV. Le\n// plancher vient du bilan (`required_soc_prudent`, profil p80), il est calcule\n// dans WINDOWS_SOURCEV2 et transmis ici — ce node CONSOMME, il ne recalcule pas.\n//\n// ⚠️ LES GARDES DE COURANT NE SONT PAS SUPPRIMEES POUR AUTANT :\n// - `commonSafetyStartOk` / `commonSafetyHoldOk` restent evalues normalement ;\n// - le MAINTIEN garde `batteryDischargeHardDynamicA`, l'arret dur, qui n'est\n// jamais contourne ;\n// - la garde SOUPLE et sa tolerance temporelle (§35) continuent de s'appliquer\n// par `batterySoftDischargeHoldOk` en aval, et c'est elle qui arretera la\n// charge quand la bande de SOC deviendra trop basse pour le courant tire.\n// Ce qu'on retire, c'est uniquement le plancher de PROFIL, qui interdisait de\n// demarrer quoi que ce soit des que la batterie n'etait pas a l'equilibre.\nconst nightBatterySurplusAvailable = !!nightPrev.nightBatterySurplusAvailable;\nconst nightBatterySurplusFloorPct = Number.isFinite(nightPrev.nightBatterySurplusFloorPct)\n ? nightPrev.nightBatterySurplusFloorPct\n : Infinity;\n\nconst batteryAssistNightStartOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyStartOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n (\n nightBatterySurplusAvailable\n ? parsed.soc > nightBatterySurplusFloorPct\n : (\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA\n )\n );\n\nconst batteryAssistNightHoldOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyHoldOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.batteryCurrentA >= batteryDischargeHardDynamicA &&\n (\n nightBatterySurplusAvailable\n ? parsed.soc > nightBatterySurplusFloorPct\n : parsed.soc > gridTakeoverTriggerPct\n );\n\n// =====================================================\n// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE\n// =====================================================\nconst probeModeActive = context.sourceMode === 'hybrid_probe';\n\nconst probeStartOk =\n probeModeActive &&\n commonSafetyStartOk &&\n (context.morningProbeEligible || context.middayProbeEligible || context.afternoonProbeEligible);\n\nconst probeHoldGuardOk =\n probeModeActive &&\n commonSafetyHoldOk &&\n batterySoftDischargeHoldOk;\n\nconst probeWindowActive =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart < params.probeMinutes;\n\nconst probeElapsedReached =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart >= params.probeMinutes;\n\nconst probeSuccessNow =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n (\n parsed.pvPowerW >= params.pvHoldMinW &&\n parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)\n );\n\n// Une sonde ne peut pas etre declaree ratee sur une mesure prise pendant\n// l'etablissement : c'est le reseau qui masque la production, pas la sonde qui\n// echoue. On differe le verdict, on ne l'invente pas.\nconst probeFailedNow =\n probeModeActive &&\n probeElapsedReached &&\n !gridSettling &&\n !probeSuccessNow;\n\n// =====================================================\n// STEP-UP LOGIC\n// =====================================================\nconst stepUpByBatteryReserve =\n batteryPriorityMode &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA;\n\nconst stepUpByForecastMode =\n !batteryPriorityMode &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW\n ) &&\n derived.forecastBufferKwh >= -1 &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&\n cloudToleranceOk;\n\n// Pas de montee en courant sur des mesures d'etablissement. La descente, elle,\n// n'est pas bridee : c'est la direction sure.\nconst solarShouldStepUp =\n !gridSettling &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n (\n stepUpByBatteryReserve ||\n stepUpByForecastMode ||\n parsed.pvPowerW >= (context.effectivePvStartMinW + 400)\n );\n\nconst gridShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA &&\n !derived.houseLoadHighVsHeadroom;\n\nconst batteryAssistNightShouldStepUp =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA &&\n batteryAssistAllowed;\n\n// =====================================================\n// STEP-DOWN LOGIC\n// =====================================================\nconst solarShouldStepDownBattery =\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst solarShouldStepDownCloudDebt =\n !cloudToleranceOk &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst gridShouldStepDownBattery =\n parsed.batteryCurrentA < gridBatterySoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst batteryAssistNightShouldStepDown =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA &&\n (\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA ||\n parsed.soc <= gridTakeoverTriggerPct ||\n batteryRecoveryUrgent\n );\n\nconst shouldStepDownVebusPower =\n derived.vebusAcOutSoftReached &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownVebusVoltage =\n derived.vebusDcVoltageSoftLow &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownOverload =\n derived.anyVebusOverload &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStopOverloadAtMin =\n derived.anyVebusOverload &&\n derived.currentReferenceA <= params.currentMinA &&\n sessionObservedActive;\n\n// =====================================================\n// HARD STOPS / PROTECTIONS\n// =====================================================\nconst batteryCurrentHardExceeded =\n parsed.batteryCurrentA <= batteryDischargeHardDynamicA;\n\n// overload volontairement exclu du hard stop immédiat\nconst mustStopHard =\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageHardLow ||\n derived.vebusAcOutHardReached ||\n batteryCurrentHardExceeded;\n\n// 07/10/2026 (proprietaire : « brider a 6 A au lieu de couper »). Le 06/10\n// sous force_grid_now, deux pointes de la maison (7,1 et 7,7 kW, voiture\n// comprise) ont fait passer la batterie a -41,9 / -47,3 A : la garde coupait la\n// voiture puis posait 20 min de verrou — 2,0 kWh donnes au lieu de ~4 en\n// 66 min. La voiture est la charge compressible : au-dessus du courant mini, on\n// la RAMENE au mini tout de suite (sans attendre le cooldown) ; l'arret et son\n// verrou ne restent que si la batterie est encore trop sollicitee AU MINI.\nconst gridBatteryHardBreach =\n sessionObservedActive &&\n isGridSource &&\n parsed.batteryCurrentA < gridBatteryHardDynamicA;\n\nconst gridBatteryCutToMin =\n gridBatteryHardBreach &&\n derived.currentReferenceA > params.currentMinA;\n\nconst gridBatteryProtectionStop =\n gridBatteryHardBreach &&\n !gridBatteryCutToMin;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n batteryPolicy: {\n socBand,\n batteryDischargeSoftDynamicA,\n batteryDischargeHardDynamicA,\n batteryChargeReserveDynamicA,\n batterySoftToleranceMinutes,\n batteryHardToleranceSeconds,\n batteryRecoveryMinutes,\n\n // Depassement souple en cours (01/09/2026). startedMs = null quand il\n // n'y a pas de depassement : c'est ce que HA_SERVICE_CALLS_OUTPUT lit\n // pour ecrire ou effacer le helper de persistance.\n batterySoftBreachNow,\n batterySoftBreachStartedMs,\n batterySoftBreachMinutes,\n batterySoftDischargeHoldOk,\n\n gridBatterySoftDynamicA,\n gridBatteryHardDynamicA,\n gridForceChargeRelaxSoftA,\n gridForceChargeRelaxHardA,\n\n batteryAssistSolarStartSocMin,\n batteryAssistSolarHoldSocMin,\n batteryAssistSolarDebtStartMaxKwh,\n\n houseSpikeDetected,\n solarPotentialContext,\n outsideWindowLocalBypass,\n },\n\n locks: {\n nextAllowedWindowStartMs,\n blockActive,\n blockStillRelevant,\n startBlockedByReason,\n },\n\n cloud: {\n sessionObservedActive,\n batteryNetPowerW,\n cloudDebtNextKwh,\n cloudDebtMeaningful,\n cloudEventDischargingNow,\n continuingCloudEvent,\n enteringCloudDebt,\n cloudDebtActive,\n cloudDebtStartedMs,\n cloudDebtDurationMinutes,\n effectiveCloudToleranceMinutes,\n cloudToleranceOk,\n },\n\n modes: {\n batteryFullHarvestMode,\n forecastCommitMode,\n batteryPriorityMode,\n },\n\n nightEvcs: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n balanceMorningTargetApplied,\n nightGridSessionCommitted,\n // P12 — passe-plat du verrou anti-oscillateur vers la sortie.\n nightBatterySurplusExhausted: !!nightPrev.nightBatterySurplusExhausted,\n nightBatterySurplusAvailable: !!nightPrev.nightBatterySurplusAvailable,\n // Budget voiture de la nuit (30/09) — passe-plat vers la sortie.\n nightBudgetKwh: nightPrev.nightBudgetKwh ?? null,\n nightBudgetDeliveredKwh: nightPrev.nightBudgetDeliveredKwh ?? null,\n nightBudgetReached: !!nightPrev.nightBudgetReached,\n nightBudgetToWrite: nightPrev.nightBudgetToWrite ?? null,\n nightChargeMode: nightPrev.nightChargeMode || 'sans_edf',\n nightCarWaitsNoGrid: !!nightPrev.nightCarWaitsNoGrid,\n nightBatteryGridOnly: !!nightPrev.nightBatteryGridOnly,\n nightCarRidesGridSession: !!nightPrev.nightCarRidesGridSession,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n vehicleChargeCompleted,\n nightRecoveryDone,\n departureUrgentActive,\n departureChargeDone,\n dayVehicleFullIdle,\n nightPreferredSourceMode,\n },\n\n safeguards: {\n isGridSource,\n commonSafetyStartOk,\n commonSafetyHoldOk,\n profileSocStartFloor,\n effectiveSocStartFloor,\n solarAbundance,\n solarAbundanceHold,\n profileSocHoldFloor,\n effectiveSocHoldFloor,\n profileBatteryDischargeFloorA,\n\n // Excedent solaire (03/09/2026) : la grandeur qui autorise un demarrage.\n // Plancher du soir issu du bilan energetique (03/09/2026).\n eveningSocStartFloorPct,\n eveningFloorFromBalance,\n balanceRequiredSocPct: params.balanceRequiredSocPct,\n balanceEnergyToCoverKwh: params.balanceEnergyToCoverKwh,\n balanceAgeMin: balanceAgeMin === null ? null : Math.round(balanceAgeMin * 10) / 10,\n balanceEveningFloorUsable,\n\n solarSurplusW,\n solarSurplusProvocationOk,\n solarSurplusStartOk,\n\n // Etat declare par les chargeurs solaires (03/09/2026).\n mpptReadableCount: parsed.mpptReadableCount,\n mpptHoldingBackCount: parsed.mpptHoldingBackCount,\n mpptAllAtMaxPower,\n solarProvocationJustified,\n batteryCurrentHardExceeded,\n mustStopHard,\n gridBatteryProtectionStop,\n gridBatteryCutToMin,\n },\n\n conditions: {\n rawSolarStartOk,\n rawSolarHoldOk,\n batteryAssistSolarPolicyEligible,\n batteryAssistSolarStartOk,\n batteryAssistSolarHoldOk,\n\n solarStartOk,\n solarHoldOk,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n gridStartOk,\n gridHoldOk,\n\n batteryAssistNightStartOk,\n nightBatterySurplusAvailable,\n nightBatterySurplusFloorPct,\n batteryAssistNightHoldOk,\n\n probeStartOk,\n probeHoldGuardOk,\n probeWindowActive,\n probeElapsedReached,\n probeSuccessNow,\n probeFailedNow,\n\n solarShouldStepUp,\n gridShouldStepUp,\n batteryAssistNightShouldStepUp,\n\n solarShouldStepDownBattery,\n solarShouldStepDownCloudDebt,\n gridShouldStepDownBattery,\n batteryAssistNightShouldStepDown,\n\n shouldStepDownVebusPower,\n shouldStepDownVebusVoltage,\n shouldStepDownOverload,\n shouldStopOverloadAtMin,\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -107,7 +107,7 @@ }, { "parameters": { - "jsCode": "// Bornes de la consigne de puissance reseau. Le maximum est celui declare par\n// l'entite elle-meme (number.airtria_mpii_v1_ac_power_setpoint_l1, max 10000 W),\n// pas un chiffre choisi ici. La bande morte evite de reecrire la meme valeur\n// toutes les 30 s sans empecher la correction d'une derive externe.\nconst GRID_SETPOINT_MAX_W = 10000;\nconst GRID_SETPOINT_DEADBAND_W = 100;\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatParisStampFromMs(ms) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n input,\n now,\n nowMs,\n cfg,\n parsed,\n params,\n history,\n strategy,\n windows,\n lifecycle,\n context,\n derived,\n batteryTune,\n batteryPolicy,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n decision,\n nightEvcs,\n vehicleDemandFilter,\n} = prev;\n\n// =====================================================\n// SAFE ACCESS NIGHT EVCS\n// =====================================================\nconst night = nightEvcs ?? {\n nightSessionActive: false,\n nightSessionNeedsCapture: false,\n nightSessionNeedsClear: false,\n nightSessionStartSocPct: null,\n nightSessionStartAt: '',\n batteryHardFloorPct: null,\n batteryMorningTargetPct: null,\n maxBatteryDischargePoints: null,\n gridTakeoverTriggerPct: null,\n batteryRecoveryUrgent: false,\n nightGridTakeoverNeeded: false,\n nightPreferredSourceMode: 'blocked',\n};\n\n// =====================================================\n// SAFE ACCESS VEHICLE DEMAND FILTER\n// =====================================================\nconst vdf = vehicleDemandFilter ?? {\n dtSec: null,\n commandCurrentA: null,\n realCurrentA: null,\n expectedPowerW: null,\n realPowerW: null,\n currentDeliveryRatioRaw: null,\n powerDeliveryRatioRaw: null,\n deliveryRatioRaw: null,\n deliveryRatioEma: null,\n deliveryNoiseEma: null,\n adaptiveQuietBand: null,\n noDemandEvidenceRaw: null,\n sessionElapsedSec: null,\n sessionArmFactor: null,\n commandFactor: null,\n sourceDeliverableForNoDemand: false,\n evcsSessionArmedForNoDemand: false,\n noDemandScoreRaw: null,\n noDemandScoreEma: null,\n noDemandScoreOn: null,\n noDemandScoreOff: null,\n evcsVehicleNoDemandSemantic: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandSinceMinutes: null,\n nextHistory: {\n evcsDeliveryRatioEma: null,\n evcsDeliveryNoiseEma: null,\n evcsNoDemandScoreEma: null,\n evcsNoDemandSemanticActive: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandFilterEvalMs: null\n }\n};\n\n// =====================================================\n// FINAL ACTIONS\n// =====================================================\nlet desiredCurrentA = decision.desiredCurrentA;\n\ndesiredCurrentA = decision.desiredCharging\n ? clamp(\n desiredCurrentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n )\n : params.currentMinA;\n\nconst chargingAction =\n decision.desiredCharging === parsed.evcsChargingSwitchOn\n ? 'none'\n : decision.desiredCharging\n ? 'turn_on'\n : 'turn_off';\n\nconst permissionAction =\n decision.desiredPermission === parsed.chargePermissionNow\n ? 'none'\n : decision.desiredPermission\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n decision.desiredLatch === parsed.controlLatch\n ? 'none'\n : decision.desiredLatch\n ? 'turn_on'\n : 'turn_off';\n\nconst autoStartAction =\n parsed.controllerEnabled &&\n parsed.evcsVehicleConnected &&\n parsed.evcsAutoStartSwitchOn\n ? 'turn_off'\n : 'none';\n\nconst currentAction =\n decision.desiredCharging &&\n (\n !Number.isFinite(derived.currentReferenceA) ||\n Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5\n )\n ? 'set'\n : 'none';\n\nconst setLastStartAt = chargingAction === 'turn_on';\nconst setLastStopAt = chargingAction === 'turn_off';\nconst setLastCurrentChangeAt = currentAction === 'set';\n\n// =====================================================\n// CLEAN BLOCKING LOGIC\n// =====================================================\nconst blockedUntilToWrite =\n decision.nextBlockReason === 'none'\n ? now.stamp\n : decision.nextBlockedUntilStamp;\n\nconst needsBlockReasonUpdate =\n decision.nextBlockReason !== history.lastBlockReason;\n\n// =====================================================\n// GLOBAL EVCS PRIORITY\n// AUTO + OVERRIDE MANUEL\n// =====================================================\nconst forceWindowMinutes = Math.max(\n 30,\n batteryTune?.global?.forceModeAutoRevertMinutes ?? 120\n);\n\nconst activeChargeIntent =\n decision.desiredCharging ||\n ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision);\n\nconst evcsVehicleNotRequestingPowerState =\n decision.state === 'vehicle_full_or_paused' ||\n decision.currentDecision === 'stop_vehicle_full_or_paused';\n\n// -------------------------\n// PRIORITÉ AUTO\n// -------------------------\nlet autoEvcsPriorityActive = false;\nlet autoEnergyPriorityMode = 'normal';\nlet autoEnergyPriorityReason = 'Aucune priorité EVCS';\nlet autoEvcsPriorityUntil = now.stamp;\n\nif (\n parsed.evcsVehicleConnected &&\n !strategy.isStopMode &&\n !evcsVehicleNotRequestingPowerState\n) {\n if (strategy.forceChargeRequested || strategy.forceGridRequested) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n strategy.forceGridRequested\n ? 'EVCS prioritaire : force_grid_now'\n : 'EVCS prioritaire : force_charge_now';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (night.nightSessionActive) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n night.batteryRecoveryUrgent\n ? 'EVCS prioritaire : session nuit + urgence départ'\n : night.nightGridTakeoverNeeded\n ? 'EVCS prioritaire : session nuit + takeover réseau'\n : 'EVCS prioritaire : session nuit active';\n\n autoEvcsPriorityUntil =\n Number.isFinite(lifecycle.minutesUntilDeparture)\n ? formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n )\n : formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`;\n autoEvcsPriorityUntil = formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n );\n }\n\n else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000);\n }\n}\n\n// -------------------------\n// OVERRIDE MANUEL\n// -------------------------\nconst manualPriorityOverrideActive =\n parsed.evcsPriorityManualOverride &&\n parsed.evcsPriorityManualMode === 'evcs_priority' &&\n history.evcsPriorityManualUntilMs !== null &&\n history.evcsPriorityManualUntilMs > nowMs + 1000;\n\nlet evcsPriorityActive = autoEvcsPriorityActive;\nlet energyPriorityMode = autoEnergyPriorityMode;\nlet energyPriorityReason = autoEnergyPriorityReason;\nlet evcsPriorityUntil = autoEvcsPriorityUntil;\nlet evcsPrioritySource = 'auto';\n\nif (manualPriorityOverrideActive && !evcsVehicleNotRequestingPowerState) {\n evcsPriorityActive = true;\n energyPriorityMode = 'evcs_priority';\n energyPriorityReason =\n parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel';\n evcsPriorityUntil =\n history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil;\n evcsPrioritySource = 'manual';\n}\n\n// =====================================================\n// REASONS / LABELS / SIGNATURE\n// =====================================================\nconst reason = [...decision.reason];\n\n// Budget voiture de la nuit (30/09) : dire pourquoi la voiture s'arrete.\nif (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) {\n const f = (x) => String(Math.round(x * 100) / 100).replace('.', ',');\n reason.push(`Budget batterie nuit ${f(night.nightBudgetDeliveredKwh ?? 0)}/${f(night.nightBudgetKwh)} kWh` +\n (night.nightBatteryGridOnly ? ' — sans EDF : EDF ferme pour la batterie seule, la voiture attend le soleil'\n : night.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil'\n : night.nightBudgetReached ? ' — atteint, EDF finit la charge' : ''));\n} else if (night.nightCarWaitsNoGrid) {\n reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil');\n}\nreason.push(`Stratégie=${strategy.userStrategy}`);\nreason.push(`Profil=${context.profile}`);\nreason.push(`Source=${context.sourceMode}`);\nreason.push(\n `Mode=${\n modes.batteryFullHarvestMode\n ? 'battery_full_harvest_mode'\n : modes.forecastCommitMode\n ? 'forecast_commit_mode'\n : 'battery_priority_mode'\n }`\n);\nreason.push(`SOC=${round1(parsed.soc)}%`);\nreason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);\nreason.push(`PV=${Math.round(parsed.pvPowerW)}W`);\nreason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);\n// =====================================================\n// CONSIGNE DE PUISSANCE RESEAU (01/09/2026)\n// =====================================================\n// Fermer le contacteur ne suffit PAS. Au-dessus du SOC minimal, l'ESS en\n// autoconsommation ne preleve que `ac_power_setpoint` et laisse la batterie\n// porter tout le reste : nuit du 01/09, contacteur ferme, consigne 50 W,\n// batterie a -803 W pour 761 W de maison, voiture arretee par le garde\n// \"batterie trop sollicitee\". La consigne est donc le levier qui rend une\n// session reseau REELLE, et il doit appartenir au controleur, pas rester un\n// reglage manuel permanent (celui-ci provoquait la substitution du solaire\n// par le reseau en journee, cf. HANDOFF 27/28).\n//\n// La valeur n'est pas une constante : c'est le besoin mesure du moment, la\n// maison telle qu'elle consomme plus la voiture au courant que le controleur\n// s'autorise. Le plafond joue le role de marge : quand la voiture n'est pas a\n// son plafond, la consigne est deja genereuse, et l'exces ne peut pas partir\n// dans la batterie (DVCC tenue a 2 A par l'Auto Charge Manager pendant un\n// takeover). Hors session, retour a la valeur de repos.\n// ⚠️ LA SESSION ENGAGEE COMPTE AUSSI (04/09/2026).\n// Nuit du 03-04/09 : la session reseau unique a tenu le contacteur toute la\n// nuit — une seule bascule, comme voulu. Mais a 02:50, la voiture finie, le\n// sourceMode est passe a 'blocked' et la consigne est retombee de 5078 W a\n// 50 W. Contacteur ferme, reseau disponible, heures creuses, et l'ESS ne\n// demandait que 50 W pendant que la maison tirait 770 W SUR LA BATTERIE.\n// Trois heures et demie, SOC 44 % -> 17 %, 2,60 kWh pris a la batterie quand\n// le reseau etait la et bon marche.\n//\n// C'est le defaut du 01/09 revenu ailleurs : fermer le contacteur ne suffit\n// pas, la consigne doit demander la puissance. J'avais conditionne la consigne\n// au sourceMode, puis cree la veille une NOUVELLE raison de garder le\n// contacteur ferme — l'engagement de session — sans la brancher ici. La\n// session tenait le contacteur, pas l'energie.\n//\n// Regle : tant que le contacteur est ferme A NOTRE DEMANDE, la consigne doit\n// couvrir la maison. Peu importe laquelle des deux raisons le tient.\nconst gridSessionActive =\n parsed.acInAvailable &&\n (\n context.sourceMode === 'grid_forced' ||\n context.sourceMode === 'grid_hc_allowed' ||\n night.nightGridSessionCommitted === true ||\n // Batterie seule sur le reseau, voiture bloquee « sans EDF » (04/10).\n night.nightBatteryGridOnly === true\n );\n\n// =====================================================\n// DIMENSIONNEMENT SUR LE DEFICIT DU BILAN (18/09/2026)\n// =====================================================\n// Ce que la consigne remplace : `maison + plafond de la borne`, ou le plafond\n// de la borne n'a AUCUNE relation physique avec ce dont la batterie a besoin.\n// Qu'il tombe juste est un accident — releve du 17/09 a 13:51, voiture partie\n// depuis cinq minutes, consigne a 7032 W pour une maison a 933 W.\n//\n// La consigne additionne desormais des besoins NOMMES :\n// maison toujours ;\n// voiture seulement si un vehicule est branche ;\n// batterie seulement dans une fenetre d'heures creuses.\n//\n// ⚠️ MAX ET NON ADDITION, et ce n'est pas de la prudence. La consigne est une\n// AUTORISATION, pas une prediction de consommation — le commentaire de la bande\n// asymetrique le dit plus bas : « la consigne dit ce que le reseau a le DROIT\n// de servir, DVCC dit ce que la batterie a le droit de prendre ». Additionner\n// deux autorisations independantes n'est pas plus honnete, seulement plus\n// permissif. Mesure a l'appui : avec l'addition,\n// `consigne-session-engagee-couvre-la-maison` passe de 5595 a 6341 W, soit\n// 746 W d'autorisation en plus sur une nuit engagee qui fonctionne deja — une\n// regression sur le chemin DOMINANT pour corriger un chemin emprunte zero fois\n// en treize jours.\n//\n// ⚠️ ET LE PLAFOND DU TERME BATTERIE EST DVCC, PAS LE BMS. Le BMS declare\n// 250 A, soit 12,2 kW a 49 V : au-dessus de GRID_SETPOINT_MAX_W, donc il ne\n// bornerait rien. Mesure : en fin de fenetre (14 min restantes, manque\n// 5,672 kWh) la division rendait 17 kW, ecretes a 10 000 — PIRE que les 5437\n// d'aujourd'hui. Avec DVCC le terme vaut 1538 W et la consigne 2380 W.\n// Composer les deux leviers, ce n'est pas les confondre : le reseau n'est\n// autorise a servir la batterie qu'a hauteur de ce que la batterie est\n// autorisee a prendre.\n//\n// Corollaire : aucun plancher d'horizon n'est necessaire. Un horizon qui tend\n// vers zero donne `min(enorme, DVCC) = DVCC`. Il ne reste qu'une garde d'une\n// minute, qui est de l'arithmetique defensive et non une constante calibree.\n//\n// INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre\n// sous la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 — « tant\n// que le contacteur est ferme A NOTRE DEMANDE, la consigne doit couvrir la\n// maison » — est donc garantie par construction. A DVCC nul, le plafond vaut\n// exactement la maison.\nconst SETPOINT_BALANCE_MAX_AGE_MIN = 30;\nconst setpointBalanceAgeMin = Number.isFinite(params.balanceLastRunMs)\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\n\n// Le manque, pas l'energie a couvrir : voir le commentaire d'INPUT_PARSE.\nconst balanceUsableForSetpoint =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceGridEnergyNeededKwh) &&\n setpointBalanceAgeMin !== null &&\n setpointBalanceAgeMin >= -5 &&\n setpointBalanceAgeMin <= SETPOINT_BALANCE_MAX_AGE_MIN;\n\nconst dvccChargeLimitW =\n Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA >= 0\n ? params.dvccChargeLimitA * derived.batteryVoltageRef\n : null;\n\n// Toute lecture manquante retombe sur la formule d'avant : degradation vers\n// l'existant, jamais pire.\nconst setpointFromBalance = balanceUsableForSetpoint && dvccChargeLimitW !== null;\n\nconst evcsSetpointShareW = parsed.evcsVehicleConnected\n ? derived.physicalCapA * derived.acVoltageRef\n : 0;\n\n// Recharger la batterie sur le reseau n'a de sens economique QUE dans une\n// fenetre creuse. Hors fenetre le terme vaut zero — ce n'est pas un repli\n// faute d'horizon, c'est le comportement voulu, et le probleme de la division\n// par un temps qui tend vers zero disparait par construction.\nconst hcMinutesLeft = Number.isFinite(windows.minutesRemainingInHcWindow)\n ? windows.minutesRemainingInHcWindow\n : null;\n\nconst batterySetpointShareW =\n setpointFromBalance && hcMinutesLeft !== null && params.balanceGridEnergyNeededKwh > 0\n ? Math.min(\n (params.balanceGridEnergyNeededKwh * 1000) / Math.max(hcMinutesLeft / 60, 1 / 60),\n dvccChargeLimitW\n )\n : 0;\n\n// =====================================================\n// LE REPLI NE DOIT PAS RESSUSCITER LE DEFAUT (18/09/2026)\n// =====================================================\n// Quand la garde du bilan tombe -- statut degrade, mesure perimee, champ\n// illisible -- la consigne revenait a `maison + plafond de borne`, y compris\n// sans voiture. C'est-a-dire au defaut d'origine, et precisement au moment ou\n// le systeme est deja degrade. Mesure sur le chemin concerne (midi, fenetre HC\n// autorisee, aucun vehicule, bilan indisponible) : 5725 W autorises pour une\n// maison a 900 W. Avec la part voiture : 1130 W.\n//\n// La branche de repli emploie donc la meme part voiture que la branche\n// nominale. L'invariant du 01/09 et du 04/09 reste tenu PAR LA FORME : le\n// plafond vaut `maison + un terme positif ou nul`, il ne descend donc jamais\n// sous la maison, quelle que soit la branche empruntee.\n//\n// ⚠️ NE PAS REINTRODUIRE UN DISJOINT `nightGridSessionCommitted ||\n// forceGridRequested` SUR LA PART VOITURE. Il a ete ecrit, mesure, puis\n// retire : il ECRASE le terme batterie du bilan, puisque `max(4825 emprunte,\n// 1538 mesure)` retient l'emprunt. Cout mesure, trois attendus figes d'un coup :\n// nuit-sans-vehicule-session-engage 2380 -> 5437\n// consigne-bilan-fin-de-fenetre-bornee-par-dvcc 2380 -> 5437\n// consigne-bilan-plancher-nuage-exclut-la-batterie 4219 -> 6301\n// Ce disjoint avait un sens tant que la consigne n'avait AUCUN terme batterie.\n// Depuis qu'elle en a un, dimensionne sur le manque reel, il n'est plus une\n// protection mais un court-circuit.\n//\n// Et si l'idee revient malgre tout : pendant une panne de bilan,\n// `nightGridSessionCommitted` ne peut engager aucune session NOUVELLE, il ne\n// survit que par `committedPrev`. Dans le repli, ce sont donc\n// `forceGridRequested` et le terme vehicule qui portent reellement.\nconst gridSetpointCeilingW = clamp(\n Math.round(\n setpointFromBalance\n ? derived.houseNonEvcsW + Math.max(evcsSetpointShareW, batterySetpointShareW)\n : derived.houseNonEvcsW + evcsSetpointShareW\n ),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointCurrentW = Number.isFinite(parsed.gridSetpointW)\n ? parsed.gridSetpointW\n : null;\n\n// =====================================================\n// CREDIT SOLAIRE SUR LA CONSIGNE (11/09/2026)\n// =====================================================\n// Defaut mesure ce jour-la : BOOST 28 A en plein soleil, consigne calculee a\n// 8242 W (maison 1802 + 28 A x 230). L'ESS n'a qu'un moyen de prelever 8,2 kW\n// quand le soleil en donne 4 : brider les MPPT. Releve 17:02 -- PV 121 W,\n// reseau 7697 W, batterie 0 A, SOC 96 %. La production etait refusee, et par\n// NOTRE consigne. Le commentaire ci-dessus (\"une consigne trop HAUTE ne coute\n// rien\") a ete ecrit la nuit du 01/09, ou la PV valait zero : il n'y avait\n// alors rien a evincer. En plein jour il est faux.\n//\n// ⚠️ ON NE PEUT PAS SOUSTRAIRE LA PV MESUREE. Elle vaut 121 W *a cause* de\n// cette consigne : mesure censuree, la soustraire ne bouge rien et le systeme\n// reste sur son mauvais equilibre. Preuve le meme jour a 17:22 -- consigne\n// descendue A LA MAIN a 2280 W : PV 4758 W en 50 secondes, batterie +9 A. La\n// ressource etait la, seule la consigne l'evincait.\n//\n// On DESCEND donc, et on laisse la physique repondre. Deux signaux, tous deux\n// deja lus et tous deux INCENSURABLES :\n// - les chargeurs declarent eux-memes s'ils se BRIDENT (mpptLimitedCount).\n// `off` ne compte pas : la nuit ils sont tous `off` et il n'y a rien a\n// liberer -- c'est ce qui garantit que le comportement de nuit (§31, §57)\n// est strictement inchange ;\n// - le courant batterie dit si on est descendu trop bas, via la politique\n// par bande de SOC deja calibree (batterySoftBreachNow, §35).\n// La valeur courante vit dans le GX : l'etat est deja persiste, aucun helper\n// nouveau n'est necessaire.\n//\n// PLANCHER : la consigne ne descend JAMAIS sous la maison hors voiture. C'est\n// la garantie du 01/09 et du 04/09 -- le reseau, quand le contacteur est ferme\n// a notre demande, couvre la maison. On ne libere que la part VOITURE, qui est\n// exactement celle qui evince le solaire. Le solaire pourrait souvent couvrir\n// la maison en plus, mais l'affirmer demanderait ses propres scenarios.\n// ⚠️ SECOND PLANCHER : LE NUAGE (ajoute 11/09 18:05, apres incident).\n// Premiere version de la descente : consigne tombee a 1281 W alors que le\n// solaire portait tout (PV 6058 W, reseau 1163 W, voiture a 28 A). Un nuage a\n// fait chuter la PV de 4,6 kW en une minute ; le reseau etant plafonne a\n// 1281 W, la difference est tombee entierement sur la batterie -- 87 A pour\n// une limite dure de 70 A -> `battery_current_hard_stop`, charge coupee.\n//\n// Sans injection, la production peut s'effondrer de plusieurs kW en quelques\n// secondes. Une boucle a 30 s ne peut pas rattraper cela : il faut que le\n// reseau soit DEJA autorise a prendre le relais. La consigne ne descend donc\n// jamais au point ou la perte totale du solaire depasserait ce que la batterie\n// sait encaisser.\n//\n// Le budget vient de la politique batterie deja calibree par bande de SOC\n// (§35), pas d'une constante nouvelle, et on garde 20 % de marge sous la\n// limite DURE -- c'est elle qui coupe la charge, et une coupure est plus\n// couteuse que quelques centaines de watts de solaire non captes.\n//\n// Contrepartie assumee, et elle est reelle : voiture a 28 A, la descente est\n// nettement moins profonde qu'a 10 A. C'est proportionne -- c'est le courant\n// de la voiture qui cree l'exposition. Et c'est la regle du proprietaire :\n// protéger le materiel prime sur l'economie de kilowattheures.\n// `derived.batteryVoltageRef` est deja la tension retenue en amont (mesure\n// vebus, repli 51,2 V) : meme grandeur que celle qui sert a convertir le\n// courant batterie en puissance dans BATTERY_CLOUD_LOCKS.\nconst batterySurgeBudgetW = Math.max(\n 0,\n Math.abs(batteryPolicy.batteryDischargeHardDynamicA) * derived.batteryVoltageRef * 0.8\n);\n// ⚠️ LE PLANCHER NUAGE SUIT LA VOITURE, PAS LA BATTERIE. Il existe pour que la\n// consigne ne descende pas au point ou la perte totale du solaire depasserait\n// ce que la batterie sait encaisser — et le commentaire ci-dessus dit pourquoi :\n// « c'est le courant de la voiture qui cree l'exposition ». Une batterie qu'on\n// recharge depuis le reseau n'est PAS une exposition : si le solaire s'effondre,\n// la recharge ralentit et personne ne se fait couper. L'y inclure remonterait le\n// plancher sans aucune contrepartie de securite, et contre l'objectif.\n// Une seule branche depuis le 18/09/2026 : le plancher nuage se construit sur\n// la maison et la voiture dans les deux cas. La distinction n'avait de sens que\n// tant que le repli gardait le plafond de borne emprunte.\nconst gridSetpointLoadW = derived.houseNonEvcsW + evcsSetpointShareW;\nconst gridSetpointCloudFloorW = Math.round(\n Math.max(0, gridSetpointLoadW - batterySurgeBudgetW)\n);\n\nconst gridSetpointHouseFloorW = clamp(\n Math.max(Math.round(derived.houseNonEvcsW), gridSetpointCloudFloorW),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst solarResourceRefused =\n parsed.mpptReadableCount > 0 && parsed.mpptLimitedCount > 0;\n\n// Pendant la fenetre d'etablissement (§29) les mesures PV et batterie ne\n// decrivent aucun regime permanent : on ne descend pas sur elles.\nconst gridSetpointDescendOk =\n gridSessionActive &&\n solarResourceRefused &&\n !batteryPolicy.batterySoftBreachNow &&\n !derived.gridSettling &&\n gridSetpointCurrentW !== null;\n\n// ⚠️ LE PLAFOND EST UN PLAFOND, PAS UNE CIBLE (corrige 11/09 17:52).\n// Premiere version de ce correctif, en production 12 minutes : la consigne\n// descendait bien a 2241 W et la PV remontait a 7074 W -- puis, tous les\n// chargeurs repassant `mppt_active`, la condition de descente devenait fausse,\n// le besoin revenait au plafond et la consigne etait REECRITE a 7100 W. La PV\n// s'effondrait a 1785 W, les chargeurs se bridaient, on redescendait.\n// Oscillateur de periode ~5 min. Releve : 17:40:30 -> 2241 / 17:47:19 -> 7100\n// / 17:47:28 PV 1785 / 17:48:30 -> 7368.\n//\n// Une fois la consigne descendue, on la TIENT tant que le solaire produit. Ce\n// n'est pas un etat a memoriser : la valeur courante est dans le GX, et le\n// plancher maison la fait remonter d'elle-meme si la maison grossit.\n//\n// La nuit, les chargeurs sont `off` (ils ne produisent pas) : on retombe sur le\n// comportement historique -- la consigne monte au plafond pour que le reseau\n// porte maison et voiture (§31, §57). C'est cette distinction qui empeche le\n// « on tient » de casser les nuits HC.\nconst solarProducing =\n parsed.mpptReadableCount > 0 && parsed.mpptAtMaxPowerCount > 0;\n\nlet gridSetpointNeedW;\nlet gridSetpointDescending = false;\n\nif (!gridSessionActive) {\n gridSetpointNeedW = params.gridSetpointIdleW;\n} else if (solarProducing && !batteryPolicy.batterySoftBreachNow && gridSetpointCurrentW !== null\n && !(batterySetpointShareW > 0)) {\n // On tient la valeur acquise, bornee par la maison et par le plafond.\n // ⚠️ SAUF si le bilan finance la batterie (07/10/2026) : HC de midi d'un jour\n // faible, SOC 31 pour 44 % requis, solaire a ~1,5 kW donc chargeurs « au\n // max » -> la consigne restait tenue a 5 198 W (la maison) et la batterie ne\n // prenait que 3 a 6 A, alors que l'Auto Charge Manager l'autorisait a 99 A.\n // Le maintien protege le solaire d'une consigne trop haute ; quand la\n // batterie a un MANQUE chiffre a reprendre en heures creuses, le solaire et\n // le reseau la rechargent ENSEMBLE : on va au plafond (maison + part batterie).\n gridSetpointNeedW = clamp(\n Math.min(gridSetpointCurrentW, gridSetpointCeilingW),\n gridSetpointHouseFloorW,\n gridSetpointCeilingW\n );\n} else {\n gridSetpointNeedW = gridSetpointCeilingW;\n}\n\nif (gridSetpointDescendOk) {\n // Un chargeur bride PROUVE qu'il reste de la ressource : on peut descendre\n // franchement. Une descente trop forte est rattrapee au cycle suivant (30 s\n // ou la batterie porte l'ecart, soit quelques dizaines de Wh), alors qu'une\n // descente trop timide laisse des kW de solaire refuses pendant dix minutes.\n const minStepW = Math.max(\n GRID_SETPOINT_DEADBAND_W,\n Math.round(params.currentStepUpA * derived.acVoltageRef)\n );\n const baseW = Math.min(gridSetpointCurrentW, gridSetpointCeilingW);\n const gapW = baseW - gridSetpointHouseFloorW;\n if (gapW > 0) {\n const stepW = Math.max(minStepW, Math.round(gapW / 2));\n gridSetpointNeedW = Math.max(gridSetpointHouseFloorW, baseW - stepW);\n gridSetpointDescending = true;\n }\n}\n\n// La bande est ASYMETRIQUE, et c'est le point. Une consigne trop BASSE fait\n// porter la difference par la batterie : c'est le defaut de la nuit du 01/09,\n// il se corrige immediatement. Une consigne trop HAUTE ne coute rien : l'ESS\n// ne preleve que ce qu'il peut placer, et le courant de charge de la batterie\n// est tenu par ailleurs (DVCC, Auto Charge Manager). Deux leviers, deux roles\n// -- la consigne dit ce que le reseau a le DROIT de servir, DVCC dit ce que la\n// batterie a le droit de prendre. Ne pas confondre les deux.\n//\n// On ecrit donc avec une marge d'un pas de courant EVCS (grandeur du systeme,\n// pas une constante inventee) pour que le bruit de la maison ne rende pas la\n// consigne insuffisante au cycle suivant, et on ne redescend que si elle est\n// devenue franchement inutile. Sans cela la boucle 30 s reecrivait a chaque\n// cycle en poursuivant le bruit.\nconst gridSetpointSlackW = gridSessionActive && !gridSetpointDescending\n ? Math.max(GRID_SETPOINT_DEADBAND_W, Math.round(params.currentStepUpA * derived.acVoltageRef))\n : 0;\n\nconst gridSetpointTargetW = clamp(\n gridSetpointNeedW + gridSetpointSlackW,\n 0,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointNeedsWrite =\n gridSetpointCurrentW === null ||\n gridSetpointCurrentW < gridSetpointNeedW ||\n gridSetpointCurrentW > gridSetpointNeedW + 2 * gridSetpointSlackW + GRID_SETPOINT_DEADBAND_W;\n\nreason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);\n// Trace de la fenetre d'etablissement : sans elle, une lecture du logbook\n// pendant le transitoire donne l'impression que le systeme decide n'importe\n// quoi. Il decide, au contraire, de ne PAS decider sur ces mesures.\nif (derived.gridSettling) {\n reason.push(`ETABLISSEMENT reseau ${Math.round(parsed.gridTransitionAgeSec)}s/${params.gridSettleSeconds}s (mesures PV/batterie non fiables)`);\n}\nif (gridSessionActive) {\n reason.push(\n gridSetpointDescending\n ? `Consigne reseau=${gridSetpointTargetW}W (CREDIT SOLAIRE, ${parsed.mpptLimitedCount}/${parsed.mpptReadableCount} brides)`\n : `Consigne reseau=${gridSetpointTargetW}W`\n );\n}\n// Visible dans le logbook uniquement pendant un depassement souple : c'est le\n// seul moment ou l'information sert, et la raison est tronquee a 255 caracteres.\nif (batteryPolicy.batterySoftBreachNow) {\n reason.push(\n `Batt souple ${round1(batteryPolicy.batterySoftBreachMinutes)}/${round1(batteryPolicy.batterySoftToleranceMinutes)}min`\n );\n}\n// Idem : visible seulement quand le bilan energetique DURCIT le plancher du\n// soir. Sans cette trace, un blocage de charge un soir de SOC 94 % est\n// incomprehensible a la lecture du logbook.\nif (safeguards.eveningFloorFromBalance) {\n reason.push(\n `Plancher soir=${round1(safeguards.eveningSocStartFloorPct)}% (BILAN, ${round1(safeguards.balanceEnergyToCoverKwh)}kWh a couvrir)`\n );\n}\nreason.push(`Cap=${derived.physicalCapA}A`);\nreason.push(`Ref=${round1(derived.currentReferenceA)}A`);\nreason.push(`Target=${round1(desiredCurrentA)}A`);\nreason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`);\nreason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`);\nreason.push(`EVCS mode=${parsed.evcsModeRaw}`);\nreason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`);\nreason.push(\n `VE.Bus DC=${\n Number.isFinite(parsed.vebusDcVoltageV)\n ? round1(parsed.vebusDcVoltageV)\n : 'n/a'\n }V`\n);\nreason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`);\nreason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`);\nreason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`);\nreason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`);\nreason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\nif (night.nightSessionActive) {\n reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`);\n reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`);\n\n if (Number.isFinite(night.nightSessionStartSocPct)) {\n reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`);\n reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`);\n }\n\n reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`);\n reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`);\n reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`);\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst sourceModeLabel =\n context.sourceMode === 'blocked' ? 'Bloqué' :\n context.sourceMode === 'solar_only' ? 'Solaire' :\n context.sourceMode === 'solar_potential' ? 'Potentiel solaire' :\n context.sourceMode === 'hybrid_probe' ? 'Probe hybride' :\n context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :\n context.sourceMode === 'grid_forced' ? 'Réseau forcé' :\n context.sourceMode === 'battery_assist_night' ? 'Assistance batterie nuit' :\n context.sourceMode;\n\nconst signature = [\n decision.state,\n `profile=${context.profile}`,\n `source=${context.sourceMode}`,\n `chg=${decision.desiredCharging ? '1' : '0'}`,\n `perm=${decision.desiredPermission ? '1' : '0'}`,\n `l=${decision.desiredLatch ? '1' : '0'}`,\n `cur=${round1(desiredCurrentA)}`,\n `block=${decision.nextBlockReason}`,\n `stop=${decision.mustStop ? '1' : '0'}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `night=${night.nightSessionActive ? '1' : '0'}`,\n `ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`,\n `nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`,\n `vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`,\n `vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== history.lastSignature;\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = shortReason;\n\nif (parsed.notificationsEnabled && signatureChanged) {\n if (decision.currentDecision === 'start') {\n notifyWanted = true;\n notifyTitle = '⚡ EVCS démarrage';\n } else if (decision.currentDecision === 'step_up') {\n notifyWanted = true;\n notifyTitle = '⬆️ EVCS hausse courant';\n } else if (\n decision.currentDecision === 'step_down' ||\n decision.currentDecision === 'step_down_fast'\n ) {\n notifyWanted = true;\n notifyTitle = '⬇️ EVCS baisse courant';\n } else if (\n decision.mustStop ||\n decision.currentDecision === 'stop' ||\n decision.currentDecision === 'stop_hard' ||\n decision.currentDecision === 'stop_power_cap'\n ) {\n notifyWanted = true;\n notifyTitle = '⛔ EVCS arrêt';\n } else if (decision.state === 'mode_auto_conflict') {\n notifyWanted = true;\n notifyTitle = '⚠️ EVCS mode Auto';\n } else if (decision.state === 'vehicle_full_or_paused') {\n notifyWanted = true;\n notifyTitle = '🚗 EVCS véhicule plein ou en pause';\n }\n}\n\n// =====================================================\n// TIMESTAMPS TO WRITE\n// =====================================================\nconst cloudDebtStartedAtToWrite =\n cloud.cloudDebtStartedMs\n ? formatParisStampFromMs(cloud.cloudDebtStartedMs)\n : now.stamp;\n\n// DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : chaine vide quand il n'y a pas\n// de depassement en cours. Le helper n'est reecrit que sur changement reel\n// (debut ou fin), pas a chaque cycle : la valeur du debut doit RESTER figee\n// pendant toute la duree du depassement, sinon le compteur ne monte jamais.\nconst batterySoftBreachAtToWrite =\n batteryPolicy.batterySoftBreachStartedMs !== null &&\n batteryPolicy.batterySoftBreachStartedMs !== undefined\n ? formatParisStampFromMs(batteryPolicy.batterySoftBreachStartedMs)\n : '';\n\nconst setBatterySoftBreachAt =\n batterySoftBreachAtToWrite !== (history.batterySoftBreachAt || '');\n\nconst probeStartedAtToWrite = now.stamp;\nconst lastStartAtToWrite = now.stamp;\nconst lastStopAtToWrite = now.stamp;\nconst lastCurrentChangeAtToWrite = now.stamp;\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\nconst nightSessionStartSocToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? String(round1(parsed.soc))\n : (\n Number.isFinite(history.nightSessionStartSocPct)\n ? String(round1(history.nightSessionStartSocPct))\n : ''\n );\n\nconst nightSessionStartAtToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? now.stamp\n : (history.nightSessionStartAt || '');\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n timestamp_paris: now.stamp,\n inputs: input,\n\n computed: {\n operating_mode: context.operatingMode,\n profile: context.profile,\n source_mode: context.sourceMode,\n source_mode_label: sourceModeLabel,\n user_strategy: strategy.userStrategy,\n\n in_night_hc_window: windows.inNightHcWindow,\n in_morning_window: windows.inMorningWindow,\n in_midday_window: windows.inMiddayWindow,\n in_evening_window: windows.inEveningWindow,\n\n // D'ou vient la consigne reseau. Sans cette trace, un repli est\n // SILENCIEUX : la consigne reprend la formule d'avant sans que rien ne le\n // dise, et c'est precisement l'information qui manquera le jour ou elle se\n // comportera mal. `bilan` = dimensionnee sur le manque, `heritee` = repli.\n setpoint_source: setpointFromBalance ? 'bilan' : 'heritee',\n setpoint_battery_share_w: Math.round(batterySetpointShareW),\n setpoint_evcs_share_w: Math.round(evcsSetpointShareW),\n dvcc_limit_w: dvccChargeLimitW === null ? null : Math.round(dvccChargeLimitW),\n hc_minutes_left: hcMinutesLeft,\n\n departure_priority_active: context.departurePriorityActive,\n minutes_until_departure: lifecycle.minutesUntilDeparture,\n\n battery_priority_mode: modes.batteryPriorityMode,\n battery_full_harvest_mode: modes.batteryFullHarvestMode,\n forecast_commit_mode: modes.forecastCommitMode,\n\n physical_cap_current_a: derived.physicalCapA,\n can_run_at_min_current: derived.canRunAtMinCurrent,\n\n forecast_buffer_kwh: round2(derived.forecastBufferKwh),\n projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge),\n\n cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh),\n cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes),\n cloud_debt_active: cloud.cloudDebtActive,\n cloud_tolerance_ok: cloud.cloudToleranceOk,\n\n start_blocked_by_reason: locks.startBlockedByReason,\n\n probe_window_active: conditions.probeWindowActive,\n probe_success_now: conditions.probeSuccessNow,\n probe_failed_now: conditions.probeFailedNow,\n\n any_vebus_overload: derived.anyVebusOverload,\n any_vebus_low_battery: derived.anyVebusLowBattery,\n vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached,\n vebus_ac_out_hard_reached: derived.vebusAcOutHardReached,\n vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow,\n vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow,\n\n soc_band: batteryPolicy.socBand,\n batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA),\n batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA),\n batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA),\n batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes),\n batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds),\n batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes),\n grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA),\n grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA),\n house_spike_detected: batteryPolicy.houseSpikeDetected,\n solar_potential_context: batteryPolicy.solarPotentialContext,\n\n batt_soft_breach_now: !!batteryPolicy.batterySoftBreachNow,\n batt_soft_breach_minutes: round1(batteryPolicy.batterySoftBreachMinutes || 0),\n batt_soft_discharge_hold_ok: batteryPolicy.batterySoftDischargeHoldOk !== false,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource,\n evcs_priority_manual_override_active: manualPriorityOverrideActive,\n\n night_session_active: night.nightSessionActive,\n night_session_needs_capture: !!night.nightSessionNeedsCapture,\n night_session_needs_clear: !!night.nightSessionNeedsClear,\n night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct)\n ? round1(night.nightSessionStartSocPct)\n : null,\n night_session_start_at: night.nightSessionStartAt || null,\n\n battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct)\n ? round1(night.batteryHardFloorPct)\n : null,\n battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct)\n ? round1(night.batteryMorningTargetPct)\n : null,\n max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints)\n ? round1(night.maxBatteryDischargePoints)\n : null,\n grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct)\n ? round1(night.gridTakeoverTriggerPct)\n : null,\n\n battery_recovery_urgent: !!night.batteryRecoveryUrgent,\n night_grid_takeover_needed: !!night.nightGridTakeoverNeeded,\n night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked',\n\n evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null,\n evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null,\n evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null,\n evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic,\n evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null,\n evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand,\n evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand,\n evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null,\n evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null\n },\n\n decision: {\n state: decision.state,\n current_decision: decision.currentDecision,\n desired_charging: decision.desiredCharging,\n desired_permission: decision.desiredPermission,\n desired_latch: decision.desiredLatch,\n desired_current_a: round1(desiredCurrentA),\n\n charging_action: chargingAction,\n permission_action: permissionAction,\n latch_action: latchAction,\n current_action: currentAction,\n auto_start_action: autoStartAction,\n\n set_last_start_at: setLastStartAt,\n set_last_stop_at: setLastStopAt,\n set_last_current_change_at: setLastCurrentChangeAt,\n set_cloud_debt_started_at: decision.setCloudDebtStartedAt,\n set_probe_started_at: decision.setProbeStartedAt,\n\n set_night_session_start: !!decision.setNightSessionStart,\n clear_night_session_start: !!decision.clearNightSessionStart,\n\n set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled,\n block_reason: decision.nextBlockReason,\n\n set_blocked_until: true,\n blocked_until: blockedUntilToWrite,\n\n dynamic_ok: decision.dynamicOk,\n must_stop: decision.mustStop,\n\n short_reason: shortReason,\n reason,\n signature,\n signature_changed: signatureChanged,\n\n should_notify: notifyWanted,\n notify_title: notifyTitle,\n notify_message: notifyMessage,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource\n },\n\n ha_service_calls: {\n helper_state: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_state',\n value: decision.state\n }\n },\n\n helper_current_decision: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_current_decision',\n value: decision.currentDecision\n }\n },\n\n helper_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_reason',\n value: shortReason\n }\n },\n\n helper_operating_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_operating_mode',\n value: context.operatingMode\n }\n },\n\n helper_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_source_mode',\n value: sourceModeLabel\n }\n },\n\n helper_target_current: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_target_current_auto',\n value: round1(desiredCurrentA)\n }\n },\n\n helper_budget_remaining: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_budget_remaining_kwh',\n value: round2(derived.forecastBufferKwh)\n }\n },\n\n helper_projected_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',\n value: round1(derived.projectedEndSocAfterCharge)\n }\n },\n\n helper_cloud_debt: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_cloud_debt_kwh',\n value: round2(cloud.cloudDebtNextKwh)\n }\n },\n\n helper_dynamic_ok: {\n execute: true,\n domain: 'input_boolean',\n service: decision.dynamicOk ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_dynamic_ok'\n }\n },\n\n helper_must_stop: {\n execute: true,\n domain: 'input_boolean',\n service: decision.mustStop ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_must_stop'\n }\n },\n\n // Bouton reset borne (19/09/2026) : momentane. Il a deja fait son effet\n // dans WINDOWS (sessionFresh forcee ce cycle) ; on le rebascule off pour\n // qu'il ne force pas un retest en boucle. N'ecrit que s'il etait ON.\n helper_reset_borne_ack: {\n execute: !!params.resetBorneRequested,\n domain: 'input_boolean',\n service: 'turn_off',\n data: {\n entity_id: 'input_boolean.evcs_reset_borne'\n }\n },\n\n helper_cloud_debt_started_at: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',\n datetime: cloudDebtStartedAtToWrite\n }\n },\n\n helper_battery_soft_breach_at: {\n execute: setBatterySoftBreachAt,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_battery_soft_breach_at',\n value: batterySoftBreachAtToWrite\n }\n },\n\n helper_last_current_change_at: {\n execute: setLastCurrentChangeAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_current_change_at',\n datetime: lastCurrentChangeAtToWrite\n }\n },\n\n helper_last_start_at: {\n execute: setLastStartAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_start_at',\n datetime: lastStartAtToWrite\n }\n },\n\n helper_last_stop_at: {\n execute: setLastStopAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_stop_at',\n datetime: lastStopAtToWrite\n }\n },\n\n helper_signature: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_control_last_signature',\n value: signature\n }\n },\n\n helper_block_reason: {\n execute: needsBlockReasonUpdate || !parsed.controllerEnabled,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_last_block_reason',\n value: decision.nextBlockReason\n }\n },\n\n helper_blocked_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_blocked_until',\n datetime: blockedUntilToWrite\n }\n },\n\n helper_probe_started_at: {\n execute: decision.setProbeStartedAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_probe_started_at',\n datetime: probeStartedAtToWrite\n }\n },\n\n helper_evcs_priority_active: {\n execute: true,\n domain: 'input_boolean',\n service: evcsPriorityActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_priority_active'\n }\n },\n\n helper_energy_priority_mode: {\n execute: true,\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_energy_priority_mode',\n option: energyPriorityMode\n }\n },\n\n helper_energy_priority_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_energy_priority_reason',\n value: energyPriorityReason\n }\n },\n\n helper_evcs_priority_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_priority_until',\n datetime: evcsPriorityUntil\n }\n },\n\n helper_night_target_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_target_soc',\n value: round1(\n Number.isFinite(night.batteryMorningTargetPct)\n ? night.batteryMorningTargetPct\n : 0\n )\n }\n },\n\n helper_night_grid_trigger_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_grid_trigger_soc',\n value: round1(\n Number.isFinite(night.gridTakeoverTriggerPct)\n ? night.gridTakeoverTriggerPct\n : 0\n )\n }\n },\n\n helper_night_preferred_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_preferred_source_mode',\n value: night.nightPreferredSourceMode || 'blocked'\n }\n },\n\n // SESSION RESEAU UNIQUE (03/09/2026) : ce bit est ce qui rend la session\n // UNIQUE. Sans persistance, l'engagement serait reevalue a chaque cycle\n // et le systeme retomberait dans une bande — donc dans un oscillateur.\n // Ecrit uniquement sur changement reel.\n helper_night_grid_session_committed: {\n execute: night.nightGridSessionCommitted !== history.nightGridSessionCommittedPrev,\n domain: 'input_boolean',\n service: night.nightGridSessionCommitted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_session_committed'\n }\n },\n\n // P12 — LE VERROU ANTI-OSCILLATEUR.\n //\n // Le plancher de surplus (`required_soc_prudent`) DESCEND pendant la nuit,\n // et le SOC aussi. Mesure du 15/09 : voiture en charge, le SOC descend de\n // 7,1 pt/h et le plancher de 3,7 ; voiture ARRETEE, le SOC ne descend plus\n // que de 2,6 pt/h quand le plancher continue a 3,7. Le surplus\n // REAPPARAITRAIT donc tout seul et la charge repartirait — cinquieme\n // oscillateur du projet, par construction.\n //\n // Une seule passe de surplus par session de nuit, comme la recovery\n // batterie depuis le 27/08. Ecrit uniquement sur changement reel.\n helper_night_battery_done: {\n execute: !!night.nightBatterySurplusExhausted !== !!params.nightBatteryDonePrev,\n domain: 'input_boolean',\n service: night.nightBatterySurplusExhausted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_done'\n }\n },\n\n helper_night_grid_takeover_needed: {\n execute: true,\n domain: 'input_boolean',\n // P9 : la fenêtre \"départ imminent\" (jour) tient aussi le helper\n // takeover → override hystérésis + maintien batterie ACM pendant\n // la charge réseau pré-départ.\n service: (night.nightGridTakeoverNeeded || night.departureUrgentActive)\n ? 'turn_on'\n : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed'\n }\n },\n\n helper_night_battery_recovery_urgent: {\n execute: true,\n domain: 'input_boolean',\n service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent'\n }\n },\n\n helper_night_session_active: {\n execute: true,\n domain: 'input_boolean',\n service: night.nightSessionActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_session_active'\n }\n },\n\n helper_night_session_start_soc: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_soc',\n value: nightSessionStartSocToWrite\n }\n },\n\n // Budget voiture de la nuit (30/09) : fige a l'ouverture, efface en fin.\n helper_night_budget: {\n execute: night.nightBudgetToWrite !== null && night.nightBudgetToWrite !== undefined,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_budget',\n value: night.nightBudgetToWrite ?? ''\n }\n },\n\n helper_night_session_start_at: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_at',\n value: nightSessionStartAtToWrite\n }\n },\n\n // =================================================\n // VEHICLE DEMAND FILTER HISTORY PERSISTENCE\n // =================================================\n helper_evcs_delivery_ratio_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_ratio_ema',\n value: round3(vdf.nextHistory.evcsDeliveryRatioEma)\n }\n },\n\n helper_evcs_delivery_noise_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_noise_ema',\n value: round3(vdf.nextHistory.evcsDeliveryNoiseEma)\n }\n },\n\n helper_evcs_no_demand_score_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_no_demand_score_ema',\n value: round3(vdf.nextHistory.evcsNoDemandScoreEma)\n }\n },\n\n helper_evcs_no_demand_semantic_active: {\n execute: true,\n domain: 'input_boolean',\n service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active'\n }\n },\n\n helper_evcs_no_demand_since_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_since_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs)\n }\n },\n\n helper_evcs_no_demand_filter_eval_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs)\n }\n },\n\n // =================================================\n // PHYSICAL EDF GRID TAKEOVER\n // =================================================\n edf_general_ok_on: {\n execute:\n (['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) ||\n night.nightBatteryGridOnly === true) &&\n !parsed.gridAvailable,\n domain: 'switch',\n service: 'turn_on',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n grid_power_setpoint: {\n execute: gridSetpointNeedsWrite,\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.gx_device_ac_power_setpoint',\n value: gridSetpointTargetW\n }\n },\n\n evcs_auto_start_off: {\n execute: autoStartAction === 'turn_off',\n domain: 'switch',\n service: 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_auto_start'\n }\n },\n\n evcs_charging: {\n execute: chargingAction !== 'none',\n domain: 'switch',\n service: decision.desiredCharging ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_charging'\n }\n },\n\n helper_permission: {\n execute: permissionAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredPermission ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_charge_permission_now'\n }\n },\n\n helper_latch: {\n execute: latchAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredLatch ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_control_latch'\n }\n },\n\n evcs_set_current: {\n execute: currentAction === 'set',\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.victron_evcs_set_current_command',\n value: round1(desiredCurrentA)\n }\n },\n\n logbook: {\n execute: signatureChanged,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron EVCS Surplus',\n message: shortReason\n }\n },\n\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage || shortReason\n }\n }\n }\n }\n}];\n" + "jsCode": "// Bornes de la consigne de puissance reseau. Le maximum est celui declare par\n// l'entite elle-meme (number.airtria_mpii_v1_ac_power_setpoint_l1, max 10000 W),\n// pas un chiffre choisi ici. La bande morte evite de reecrire la meme valeur\n// toutes les 30 s sans empecher la correction d'une derive externe.\nconst GRID_SETPOINT_MAX_W = 10000;\nconst GRID_SETPOINT_DEADBAND_W = 100;\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction formatParisStampFromMs(ms) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n input,\n now,\n nowMs,\n cfg,\n parsed,\n params,\n history,\n strategy,\n windows,\n lifecycle,\n context,\n derived,\n batteryTune,\n batteryPolicy,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n decision,\n nightEvcs,\n vehicleDemandFilter,\n} = prev;\n\n// =====================================================\n// SAFE ACCESS NIGHT EVCS\n// =====================================================\nconst night = nightEvcs ?? {\n nightSessionActive: false,\n nightSessionNeedsCapture: false,\n nightSessionNeedsClear: false,\n nightSessionStartSocPct: null,\n nightSessionStartAt: '',\n batteryHardFloorPct: null,\n batteryMorningTargetPct: null,\n maxBatteryDischargePoints: null,\n gridTakeoverTriggerPct: null,\n batteryRecoveryUrgent: false,\n nightGridTakeoverNeeded: false,\n nightPreferredSourceMode: 'blocked',\n};\n\n// =====================================================\n// SAFE ACCESS VEHICLE DEMAND FILTER\n// =====================================================\nconst vdf = vehicleDemandFilter ?? {\n dtSec: null,\n commandCurrentA: null,\n realCurrentA: null,\n expectedPowerW: null,\n realPowerW: null,\n currentDeliveryRatioRaw: null,\n powerDeliveryRatioRaw: null,\n deliveryRatioRaw: null,\n deliveryRatioEma: null,\n deliveryNoiseEma: null,\n adaptiveQuietBand: null,\n noDemandEvidenceRaw: null,\n sessionElapsedSec: null,\n sessionArmFactor: null,\n commandFactor: null,\n sourceDeliverableForNoDemand: false,\n evcsSessionArmedForNoDemand: false,\n noDemandScoreRaw: null,\n noDemandScoreEma: null,\n noDemandScoreOn: null,\n noDemandScoreOff: null,\n evcsVehicleNoDemandSemantic: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandSinceMinutes: null,\n nextHistory: {\n evcsDeliveryRatioEma: null,\n evcsDeliveryNoiseEma: null,\n evcsNoDemandScoreEma: null,\n evcsNoDemandSemanticActive: false,\n evcsNoDemandSinceMs: null,\n evcsNoDemandFilterEvalMs: null\n }\n};\n\n// =====================================================\n// FINAL ACTIONS\n// =====================================================\nlet desiredCurrentA = decision.desiredCurrentA;\n\ndesiredCurrentA = decision.desiredCharging\n ? clamp(\n desiredCurrentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n )\n : params.currentMinA;\n\nconst chargingAction =\n decision.desiredCharging === parsed.evcsChargingSwitchOn\n ? 'none'\n : decision.desiredCharging\n ? 'turn_on'\n : 'turn_off';\n\nconst permissionAction =\n decision.desiredPermission === parsed.chargePermissionNow\n ? 'none'\n : decision.desiredPermission\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n decision.desiredLatch === parsed.controlLatch\n ? 'none'\n : decision.desiredLatch\n ? 'turn_on'\n : 'turn_off';\n\nconst autoStartAction =\n parsed.controllerEnabled &&\n parsed.evcsVehicleConnected &&\n parsed.evcsAutoStartSwitchOn\n ? 'turn_off'\n : 'none';\n\nconst currentAction =\n decision.desiredCharging &&\n (\n !Number.isFinite(derived.currentReferenceA) ||\n Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5\n )\n ? 'set'\n : 'none';\n\nconst setLastStartAt = chargingAction === 'turn_on';\nconst setLastStopAt = chargingAction === 'turn_off';\nconst setLastCurrentChangeAt = currentAction === 'set';\n\n// =====================================================\n// CLEAN BLOCKING LOGIC\n// =====================================================\nconst blockedUntilToWrite =\n decision.nextBlockReason === 'none'\n ? now.stamp\n : decision.nextBlockedUntilStamp;\n\nconst needsBlockReasonUpdate =\n decision.nextBlockReason !== history.lastBlockReason;\n\n// =====================================================\n// GLOBAL EVCS PRIORITY\n// AUTO + OVERRIDE MANUEL\n// =====================================================\nconst forceWindowMinutes = Math.max(\n 30,\n batteryTune?.global?.forceModeAutoRevertMinutes ?? 120\n);\n\nconst activeChargeIntent =\n decision.desiredCharging ||\n ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision);\n\nconst evcsVehicleNotRequestingPowerState =\n decision.state === 'vehicle_full_or_paused' ||\n decision.currentDecision === 'stop_vehicle_full_or_paused';\n\n// -------------------------\n// PRIORITÉ AUTO\n// -------------------------\nlet autoEvcsPriorityActive = false;\nlet autoEnergyPriorityMode = 'normal';\nlet autoEnergyPriorityReason = 'Aucune priorité EVCS';\nlet autoEvcsPriorityUntil = now.stamp;\n\nif (\n parsed.evcsVehicleConnected &&\n !strategy.isStopMode &&\n !evcsVehicleNotRequestingPowerState\n) {\n if (strategy.forceChargeRequested || strategy.forceGridRequested) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n strategy.forceGridRequested\n ? 'EVCS prioritaire : force_grid_now'\n : 'EVCS prioritaire : force_charge_now';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (night.nightSessionActive) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason =\n night.batteryRecoveryUrgent\n ? 'EVCS prioritaire : session nuit + urgence départ'\n : night.nightGridTakeoverNeeded\n ? 'EVCS prioritaire : session nuit + takeover réseau'\n : 'EVCS prioritaire : session nuit active';\n\n autoEvcsPriorityUntil =\n Number.isFinite(lifecycle.minutesUntilDeparture)\n ? formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n )\n : formatParisStampFromMs(nowMs + forceWindowMinutes * 60000);\n }\n\n else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`;\n autoEvcsPriorityUntil = formatParisStampFromMs(\n nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000\n );\n }\n\n else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') {\n autoEvcsPriorityActive = true;\n autoEnergyPriorityMode = 'evcs_priority';\n autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi';\n autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000);\n }\n}\n\n// -------------------------\n// OVERRIDE MANUEL\n// -------------------------\nconst manualPriorityOverrideActive =\n parsed.evcsPriorityManualOverride &&\n parsed.evcsPriorityManualMode === 'evcs_priority' &&\n history.evcsPriorityManualUntilMs !== null &&\n history.evcsPriorityManualUntilMs > nowMs + 1000;\n\nlet evcsPriorityActive = autoEvcsPriorityActive;\nlet energyPriorityMode = autoEnergyPriorityMode;\nlet energyPriorityReason = autoEnergyPriorityReason;\nlet evcsPriorityUntil = autoEvcsPriorityUntil;\nlet evcsPrioritySource = 'auto';\n\nif (manualPriorityOverrideActive && !evcsVehicleNotRequestingPowerState) {\n evcsPriorityActive = true;\n energyPriorityMode = 'evcs_priority';\n energyPriorityReason =\n parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel';\n evcsPriorityUntil =\n history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil;\n evcsPrioritySource = 'manual';\n}\n\n// =====================================================\n// REASONS / LABELS / SIGNATURE\n// =====================================================\nconst reason = [...decision.reason];\n\n// Budget voiture de la nuit (30/09) : dire pourquoi la voiture s'arrete.\nif (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) {\n const f = (x) => String(Math.round(x * 100) / 100).replace('.', ',');\n reason.push(`Budget batterie nuit ${f(night.nightBudgetDeliveredKwh ?? 0)}/${f(night.nightBudgetKwh)} kWh` +\n (night.nightCarRidesGridSession ? ' — sans EDF, mais EDF est ferme pour la batterie en heures creuses : la voiture en profite'\n : night.nightBatteryGridOnly ? ' — sans EDF : EDF ferme pour la batterie seule, la voiture attend le soleil'\n : night.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil'\n : night.nightBudgetReached ? ' — atteint, EDF finit la charge' : ''));\n} else if (night.nightCarRidesGridSession) {\n reason.push('Sans EDF, mais EDF est ferme pour la batterie en heures creuses : la voiture en profite');\n} else if (night.nightCarWaitsNoGrid) {\n reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil');\n}\nreason.push(`Stratégie=${strategy.userStrategy}`);\nreason.push(`Profil=${context.profile}`);\nreason.push(`Source=${context.sourceMode}`);\nreason.push(\n `Mode=${\n modes.batteryFullHarvestMode\n ? 'battery_full_harvest_mode'\n : modes.forecastCommitMode\n ? 'forecast_commit_mode'\n : 'battery_priority_mode'\n }`\n);\nreason.push(`SOC=${round1(parsed.soc)}%`);\nreason.push(`Batt=${round1(parsed.batteryCurrentA)}A`);\nreason.push(`PV=${Math.round(parsed.pvPowerW)}W`);\nreason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`);\n// =====================================================\n// CONSIGNE DE PUISSANCE RESEAU (01/09/2026)\n// =====================================================\n// Fermer le contacteur ne suffit PAS. Au-dessus du SOC minimal, l'ESS en\n// autoconsommation ne preleve que `ac_power_setpoint` et laisse la batterie\n// porter tout le reste : nuit du 01/09, contacteur ferme, consigne 50 W,\n// batterie a -803 W pour 761 W de maison, voiture arretee par le garde\n// \"batterie trop sollicitee\". La consigne est donc le levier qui rend une\n// session reseau REELLE, et il doit appartenir au controleur, pas rester un\n// reglage manuel permanent (celui-ci provoquait la substitution du solaire\n// par le reseau en journee, cf. HANDOFF 27/28).\n//\n// La valeur n'est pas une constante : c'est le besoin mesure du moment, la\n// maison telle qu'elle consomme plus la voiture au courant que le controleur\n// s'autorise. Le plafond joue le role de marge : quand la voiture n'est pas a\n// son plafond, la consigne est deja genereuse, et l'exces ne peut pas partir\n// dans la batterie (DVCC tenue a 2 A par l'Auto Charge Manager pendant un\n// takeover). Hors session, retour a la valeur de repos.\n// ⚠️ LA SESSION ENGAGEE COMPTE AUSSI (04/09/2026).\n// Nuit du 03-04/09 : la session reseau unique a tenu le contacteur toute la\n// nuit — une seule bascule, comme voulu. Mais a 02:50, la voiture finie, le\n// sourceMode est passe a 'blocked' et la consigne est retombee de 5078 W a\n// 50 W. Contacteur ferme, reseau disponible, heures creuses, et l'ESS ne\n// demandait que 50 W pendant que la maison tirait 770 W SUR LA BATTERIE.\n// Trois heures et demie, SOC 44 % -> 17 %, 2,60 kWh pris a la batterie quand\n// le reseau etait la et bon marche.\n//\n// C'est le defaut du 01/09 revenu ailleurs : fermer le contacteur ne suffit\n// pas, la consigne doit demander la puissance. J'avais conditionne la consigne\n// au sourceMode, puis cree la veille une NOUVELLE raison de garder le\n// contacteur ferme — l'engagement de session — sans la brancher ici. La\n// session tenait le contacteur, pas l'energie.\n//\n// Regle : tant que le contacteur est ferme A NOTRE DEMANDE, la consigne doit\n// couvrir la maison. Peu importe laquelle des deux raisons le tient.\nconst gridSessionActive =\n parsed.acInAvailable &&\n (\n context.sourceMode === 'grid_forced' ||\n context.sourceMode === 'grid_hc_allowed' ||\n night.nightGridSessionCommitted === true ||\n // Batterie seule sur le reseau, voiture bloquee « sans EDF » (04/10).\n night.nightBatteryGridOnly === true\n );\n\n// =====================================================\n// DIMENSIONNEMENT SUR LE DEFICIT DU BILAN (18/09/2026)\n// =====================================================\n// Ce que la consigne remplace : `maison + plafond de la borne`, ou le plafond\n// de la borne n'a AUCUNE relation physique avec ce dont la batterie a besoin.\n// Qu'il tombe juste est un accident — releve du 17/09 a 13:51, voiture partie\n// depuis cinq minutes, consigne a 7032 W pour une maison a 933 W.\n//\n// La consigne additionne desormais des besoins NOMMES :\n// maison toujours ;\n// voiture seulement si un vehicule est branche ;\n// batterie seulement dans une fenetre d'heures creuses.\n//\n// ⚠️ MAX ET NON ADDITION, et ce n'est pas de la prudence. La consigne est une\n// AUTORISATION, pas une prediction de consommation — le commentaire de la bande\n// asymetrique le dit plus bas : « la consigne dit ce que le reseau a le DROIT\n// de servir, DVCC dit ce que la batterie a le droit de prendre ». Additionner\n// deux autorisations independantes n'est pas plus honnete, seulement plus\n// permissif. Mesure a l'appui : avec l'addition,\n// `consigne-session-engagee-couvre-la-maison` passe de 5595 a 6341 W, soit\n// 746 W d'autorisation en plus sur une nuit engagee qui fonctionne deja — une\n// regression sur le chemin DOMINANT pour corriger un chemin emprunte zero fois\n// en treize jours.\n//\n// ⚠️ ET LE PLAFOND DU TERME BATTERIE EST DVCC, PAS LE BMS. Le BMS declare\n// 250 A, soit 12,2 kW a 49 V : au-dessus de GRID_SETPOINT_MAX_W, donc il ne\n// bornerait rien. Mesure : en fin de fenetre (14 min restantes, manque\n// 5,672 kWh) la division rendait 17 kW, ecretes a 10 000 — PIRE que les 5437\n// d'aujourd'hui. Avec DVCC le terme vaut 1538 W et la consigne 2380 W.\n// Composer les deux leviers, ce n'est pas les confondre : le reseau n'est\n// autorise a servir la batterie qu'a hauteur de ce que la batterie est\n// autorisee a prendre.\n//\n// Corollaire : aucun plancher d'horizon n'est necessaire. Un horizon qui tend\n// vers zero donne `min(enorme, DVCC) = DVCC`. Il ne reste qu'une garde d'une\n// minute, qui est de l'arithmetique defensive et non une constante calibree.\n//\n// INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre\n// sous la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 — « tant\n// que le contacteur est ferme A NOTRE DEMANDE, la consigne doit couvrir la\n// maison » — est donc garantie par construction. A DVCC nul, le plafond vaut\n// exactement la maison.\nconst SETPOINT_BALANCE_MAX_AGE_MIN = 30;\nconst setpointBalanceAgeMin = Number.isFinite(params.balanceLastRunMs)\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\n\n// Le manque, pas l'energie a couvrir : voir le commentaire d'INPUT_PARSE.\nconst balanceUsableForSetpoint =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceGridEnergyNeededKwh) &&\n setpointBalanceAgeMin !== null &&\n setpointBalanceAgeMin >= -5 &&\n setpointBalanceAgeMin <= SETPOINT_BALANCE_MAX_AGE_MIN;\n\nconst dvccChargeLimitW =\n Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA >= 0\n ? params.dvccChargeLimitA * derived.batteryVoltageRef\n : null;\n\n// Toute lecture manquante retombe sur la formule d'avant : degradation vers\n// l'existant, jamais pire.\nconst setpointFromBalance = balanceUsableForSetpoint && dvccChargeLimitW !== null;\n\nconst evcsSetpointShareW = parsed.evcsVehicleConnected\n ? derived.physicalCapA * derived.acVoltageRef\n : 0;\n\n// Recharger la batterie sur le reseau n'a de sens economique QUE dans une\n// fenetre creuse. Hors fenetre le terme vaut zero — ce n'est pas un repli\n// faute d'horizon, c'est le comportement voulu, et le probleme de la division\n// par un temps qui tend vers zero disparait par construction.\nconst hcMinutesLeft = Number.isFinite(windows.minutesRemainingInHcWindow)\n ? windows.minutesRemainingInHcWindow\n : null;\n\nconst batterySetpointShareW =\n setpointFromBalance && hcMinutesLeft !== null && params.balanceGridEnergyNeededKwh > 0\n ? Math.min(\n (params.balanceGridEnergyNeededKwh * 1000) / Math.max(hcMinutesLeft / 60, 1 / 60),\n dvccChargeLimitW\n )\n : 0;\n\n// =====================================================\n// LE REPLI NE DOIT PAS RESSUSCITER LE DEFAUT (18/09/2026)\n// =====================================================\n// Quand la garde du bilan tombe -- statut degrade, mesure perimee, champ\n// illisible -- la consigne revenait a `maison + plafond de borne`, y compris\n// sans voiture. C'est-a-dire au defaut d'origine, et precisement au moment ou\n// le systeme est deja degrade. Mesure sur le chemin concerne (midi, fenetre HC\n// autorisee, aucun vehicule, bilan indisponible) : 5725 W autorises pour une\n// maison a 900 W. Avec la part voiture : 1130 W.\n//\n// La branche de repli emploie donc la meme part voiture que la branche\n// nominale. L'invariant du 01/09 et du 04/09 reste tenu PAR LA FORME : le\n// plafond vaut `maison + un terme positif ou nul`, il ne descend donc jamais\n// sous la maison, quelle que soit la branche empruntee.\n//\n// ⚠️ NE PAS REINTRODUIRE UN DISJOINT `nightGridSessionCommitted ||\n// forceGridRequested` SUR LA PART VOITURE. Il a ete ecrit, mesure, puis\n// retire : il ECRASE le terme batterie du bilan, puisque `max(4825 emprunte,\n// 1538 mesure)` retient l'emprunt. Cout mesure, trois attendus figes d'un coup :\n// nuit-sans-vehicule-session-engage 2380 -> 5437\n// consigne-bilan-fin-de-fenetre-bornee-par-dvcc 2380 -> 5437\n// consigne-bilan-plancher-nuage-exclut-la-batterie 4219 -> 6301\n// Ce disjoint avait un sens tant que la consigne n'avait AUCUN terme batterie.\n// Depuis qu'elle en a un, dimensionne sur le manque reel, il n'est plus une\n// protection mais un court-circuit.\n//\n// Et si l'idee revient malgre tout : pendant une panne de bilan,\n// `nightGridSessionCommitted` ne peut engager aucune session NOUVELLE, il ne\n// survit que par `committedPrev`. Dans le repli, ce sont donc\n// `forceGridRequested` et le terme vehicule qui portent reellement.\nconst gridSetpointCeilingW = clamp(\n Math.round(\n setpointFromBalance\n ? derived.houseNonEvcsW + Math.max(evcsSetpointShareW, batterySetpointShareW)\n : derived.houseNonEvcsW + evcsSetpointShareW\n ),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointCurrentW = Number.isFinite(parsed.gridSetpointW)\n ? parsed.gridSetpointW\n : null;\n\n// =====================================================\n// CREDIT SOLAIRE SUR LA CONSIGNE (11/09/2026)\n// =====================================================\n// Defaut mesure ce jour-la : BOOST 28 A en plein soleil, consigne calculee a\n// 8242 W (maison 1802 + 28 A x 230). L'ESS n'a qu'un moyen de prelever 8,2 kW\n// quand le soleil en donne 4 : brider les MPPT. Releve 17:02 -- PV 121 W,\n// reseau 7697 W, batterie 0 A, SOC 96 %. La production etait refusee, et par\n// NOTRE consigne. Le commentaire ci-dessus (\"une consigne trop HAUTE ne coute\n// rien\") a ete ecrit la nuit du 01/09, ou la PV valait zero : il n'y avait\n// alors rien a evincer. En plein jour il est faux.\n//\n// ⚠️ ON NE PEUT PAS SOUSTRAIRE LA PV MESUREE. Elle vaut 121 W *a cause* de\n// cette consigne : mesure censuree, la soustraire ne bouge rien et le systeme\n// reste sur son mauvais equilibre. Preuve le meme jour a 17:22 -- consigne\n// descendue A LA MAIN a 2280 W : PV 4758 W en 50 secondes, batterie +9 A. La\n// ressource etait la, seule la consigne l'evincait.\n//\n// On DESCEND donc, et on laisse la physique repondre. Deux signaux, tous deux\n// deja lus et tous deux INCENSURABLES :\n// - les chargeurs declarent eux-memes s'ils se BRIDENT (mpptLimitedCount).\n// `off` ne compte pas : la nuit ils sont tous `off` et il n'y a rien a\n// liberer -- c'est ce qui garantit que le comportement de nuit (§31, §57)\n// est strictement inchange ;\n// - le courant batterie dit si on est descendu trop bas, via la politique\n// par bande de SOC deja calibree (batterySoftBreachNow, §35).\n// La valeur courante vit dans le GX : l'etat est deja persiste, aucun helper\n// nouveau n'est necessaire.\n//\n// PLANCHER : la consigne ne descend JAMAIS sous la maison hors voiture. C'est\n// la garantie du 01/09 et du 04/09 -- le reseau, quand le contacteur est ferme\n// a notre demande, couvre la maison. On ne libere que la part VOITURE, qui est\n// exactement celle qui evince le solaire. Le solaire pourrait souvent couvrir\n// la maison en plus, mais l'affirmer demanderait ses propres scenarios.\n// ⚠️ SECOND PLANCHER : LE NUAGE (ajoute 11/09 18:05, apres incident).\n// Premiere version de la descente : consigne tombee a 1281 W alors que le\n// solaire portait tout (PV 6058 W, reseau 1163 W, voiture a 28 A). Un nuage a\n// fait chuter la PV de 4,6 kW en une minute ; le reseau etant plafonne a\n// 1281 W, la difference est tombee entierement sur la batterie -- 87 A pour\n// une limite dure de 70 A -> `battery_current_hard_stop`, charge coupee.\n//\n// Sans injection, la production peut s'effondrer de plusieurs kW en quelques\n// secondes. Une boucle a 30 s ne peut pas rattraper cela : il faut que le\n// reseau soit DEJA autorise a prendre le relais. La consigne ne descend donc\n// jamais au point ou la perte totale du solaire depasserait ce que la batterie\n// sait encaisser.\n//\n// Le budget vient de la politique batterie deja calibree par bande de SOC\n// (§35), pas d'une constante nouvelle, et on garde 20 % de marge sous la\n// limite DURE -- c'est elle qui coupe la charge, et une coupure est plus\n// couteuse que quelques centaines de watts de solaire non captes.\n//\n// Contrepartie assumee, et elle est reelle : voiture a 28 A, la descente est\n// nettement moins profonde qu'a 10 A. C'est proportionne -- c'est le courant\n// de la voiture qui cree l'exposition. Et c'est la regle du proprietaire :\n// protéger le materiel prime sur l'economie de kilowattheures.\n// `derived.batteryVoltageRef` est deja la tension retenue en amont (mesure\n// vebus, repli 51,2 V) : meme grandeur que celle qui sert a convertir le\n// courant batterie en puissance dans BATTERY_CLOUD_LOCKS.\nconst batterySurgeBudgetW = Math.max(\n 0,\n Math.abs(batteryPolicy.batteryDischargeHardDynamicA) * derived.batteryVoltageRef * 0.8\n);\n// ⚠️ LE PLANCHER NUAGE SUIT LA VOITURE, PAS LA BATTERIE. Il existe pour que la\n// consigne ne descende pas au point ou la perte totale du solaire depasserait\n// ce que la batterie sait encaisser — et le commentaire ci-dessus dit pourquoi :\n// « c'est le courant de la voiture qui cree l'exposition ». Une batterie qu'on\n// recharge depuis le reseau n'est PAS une exposition : si le solaire s'effondre,\n// la recharge ralentit et personne ne se fait couper. L'y inclure remonterait le\n// plancher sans aucune contrepartie de securite, et contre l'objectif.\n// Une seule branche depuis le 18/09/2026 : le plancher nuage se construit sur\n// la maison et la voiture dans les deux cas. La distinction n'avait de sens que\n// tant que le repli gardait le plafond de borne emprunte.\nconst gridSetpointLoadW = derived.houseNonEvcsW + evcsSetpointShareW;\nconst gridSetpointCloudFloorW = Math.round(\n Math.max(0, gridSetpointLoadW - batterySurgeBudgetW)\n);\n\nconst gridSetpointHouseFloorW = clamp(\n Math.max(Math.round(derived.houseNonEvcsW), gridSetpointCloudFloorW),\n params.gridSetpointIdleW,\n GRID_SETPOINT_MAX_W\n);\n\nconst solarResourceRefused =\n parsed.mpptReadableCount > 0 && parsed.mpptLimitedCount > 0;\n\n// Pendant la fenetre d'etablissement (§29) les mesures PV et batterie ne\n// decrivent aucun regime permanent : on ne descend pas sur elles.\nconst gridSetpointDescendOk =\n gridSessionActive &&\n solarResourceRefused &&\n !batteryPolicy.batterySoftBreachNow &&\n !derived.gridSettling &&\n gridSetpointCurrentW !== null;\n\n// ⚠️ LE PLAFOND EST UN PLAFOND, PAS UNE CIBLE (corrige 11/09 17:52).\n// Premiere version de ce correctif, en production 12 minutes : la consigne\n// descendait bien a 2241 W et la PV remontait a 7074 W -- puis, tous les\n// chargeurs repassant `mppt_active`, la condition de descente devenait fausse,\n// le besoin revenait au plafond et la consigne etait REECRITE a 7100 W. La PV\n// s'effondrait a 1785 W, les chargeurs se bridaient, on redescendait.\n// Oscillateur de periode ~5 min. Releve : 17:40:30 -> 2241 / 17:47:19 -> 7100\n// / 17:47:28 PV 1785 / 17:48:30 -> 7368.\n//\n// Une fois la consigne descendue, on la TIENT tant que le solaire produit. Ce\n// n'est pas un etat a memoriser : la valeur courante est dans le GX, et le\n// plancher maison la fait remonter d'elle-meme si la maison grossit.\n//\n// La nuit, les chargeurs sont `off` (ils ne produisent pas) : on retombe sur le\n// comportement historique -- la consigne monte au plafond pour que le reseau\n// porte maison et voiture (§31, §57). C'est cette distinction qui empeche le\n// « on tient » de casser les nuits HC.\nconst solarProducing =\n parsed.mpptReadableCount > 0 && parsed.mpptAtMaxPowerCount > 0;\n\nlet gridSetpointNeedW;\nlet gridSetpointDescending = false;\n\nif (!gridSessionActive) {\n gridSetpointNeedW = params.gridSetpointIdleW;\n} else if (solarProducing && !batteryPolicy.batterySoftBreachNow && gridSetpointCurrentW !== null\n && !(batterySetpointShareW > 0)) {\n // On tient la valeur acquise, bornee par la maison et par le plafond.\n // ⚠️ SAUF si le bilan finance la batterie (07/10/2026) : HC de midi d'un jour\n // faible, SOC 31 pour 44 % requis, solaire a ~1,5 kW donc chargeurs « au\n // max » -> la consigne restait tenue a 5 198 W (la maison) et la batterie ne\n // prenait que 3 a 6 A, alors que l'Auto Charge Manager l'autorisait a 99 A.\n // Le maintien protege le solaire d'une consigne trop haute ; quand la\n // batterie a un MANQUE chiffre a reprendre en heures creuses, le solaire et\n // le reseau la rechargent ENSEMBLE : on va au plafond (maison + part batterie).\n gridSetpointNeedW = clamp(\n Math.min(gridSetpointCurrentW, gridSetpointCeilingW),\n gridSetpointHouseFloorW,\n gridSetpointCeilingW\n );\n} else {\n gridSetpointNeedW = gridSetpointCeilingW;\n}\n\nif (gridSetpointDescendOk) {\n // Un chargeur bride PROUVE qu'il reste de la ressource : on peut descendre\n // franchement. Une descente trop forte est rattrapee au cycle suivant (30 s\n // ou la batterie porte l'ecart, soit quelques dizaines de Wh), alors qu'une\n // descente trop timide laisse des kW de solaire refuses pendant dix minutes.\n const minStepW = Math.max(\n GRID_SETPOINT_DEADBAND_W,\n Math.round(params.currentStepUpA * derived.acVoltageRef)\n );\n const baseW = Math.min(gridSetpointCurrentW, gridSetpointCeilingW);\n const gapW = baseW - gridSetpointHouseFloorW;\n if (gapW > 0) {\n const stepW = Math.max(minStepW, Math.round(gapW / 2));\n gridSetpointNeedW = Math.max(gridSetpointHouseFloorW, baseW - stepW);\n gridSetpointDescending = true;\n }\n}\n\n// La bande est ASYMETRIQUE, et c'est le point. Une consigne trop BASSE fait\n// porter la difference par la batterie : c'est le defaut de la nuit du 01/09,\n// il se corrige immediatement. Une consigne trop HAUTE ne coute rien : l'ESS\n// ne preleve que ce qu'il peut placer, et le courant de charge de la batterie\n// est tenu par ailleurs (DVCC, Auto Charge Manager). Deux leviers, deux roles\n// -- la consigne dit ce que le reseau a le DROIT de servir, DVCC dit ce que la\n// batterie a le droit de prendre. Ne pas confondre les deux.\n//\n// On ecrit donc avec une marge d'un pas de courant EVCS (grandeur du systeme,\n// pas une constante inventee) pour que le bruit de la maison ne rende pas la\n// consigne insuffisante au cycle suivant, et on ne redescend que si elle est\n// devenue franchement inutile. Sans cela la boucle 30 s reecrivait a chaque\n// cycle en poursuivant le bruit.\nconst gridSetpointSlackW = gridSessionActive && !gridSetpointDescending\n ? Math.max(GRID_SETPOINT_DEADBAND_W, Math.round(params.currentStepUpA * derived.acVoltageRef))\n : 0;\n\nconst gridSetpointTargetW = clamp(\n gridSetpointNeedW + gridSetpointSlackW,\n 0,\n GRID_SETPOINT_MAX_W\n);\n\nconst gridSetpointNeedsWrite =\n gridSetpointCurrentW === null ||\n gridSetpointCurrentW < gridSetpointNeedW ||\n gridSetpointCurrentW > gridSetpointNeedW + 2 * gridSetpointSlackW + GRID_SETPOINT_DEADBAND_W;\n\nreason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`);\n// Trace de la fenetre d'etablissement : sans elle, une lecture du logbook\n// pendant le transitoire donne l'impression que le systeme decide n'importe\n// quoi. Il decide, au contraire, de ne PAS decider sur ces mesures.\nif (derived.gridSettling) {\n reason.push(`ETABLISSEMENT reseau ${Math.round(parsed.gridTransitionAgeSec)}s/${params.gridSettleSeconds}s (mesures PV/batterie non fiables)`);\n}\nif (gridSessionActive) {\n reason.push(\n gridSetpointDescending\n ? `Consigne reseau=${gridSetpointTargetW}W (CREDIT SOLAIRE, ${parsed.mpptLimitedCount}/${parsed.mpptReadableCount} brides)`\n : `Consigne reseau=${gridSetpointTargetW}W`\n );\n}\n// Visible dans le logbook uniquement pendant un depassement souple : c'est le\n// seul moment ou l'information sert, et la raison est tronquee a 255 caracteres.\nif (batteryPolicy.batterySoftBreachNow) {\n reason.push(\n `Batt souple ${round1(batteryPolicy.batterySoftBreachMinutes)}/${round1(batteryPolicy.batterySoftToleranceMinutes)}min`\n );\n}\n// Idem : visible seulement quand le bilan energetique DURCIT le plancher du\n// soir. Sans cette trace, un blocage de charge un soir de SOC 94 % est\n// incomprehensible a la lecture du logbook.\nif (safeguards.eveningFloorFromBalance) {\n reason.push(\n `Plancher soir=${round1(safeguards.eveningSocStartFloorPct)}% (BILAN, ${round1(safeguards.balanceEnergyToCoverKwh)}kWh a couvrir)`\n );\n}\nreason.push(`Cap=${derived.physicalCapA}A`);\nreason.push(`Ref=${round1(derived.currentReferenceA)}A`);\nreason.push(`Target=${round1(desiredCurrentA)}A`);\nreason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`);\nreason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`);\nreason.push(`EVCS mode=${parsed.evcsModeRaw}`);\nreason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`);\nreason.push(\n `VE.Bus DC=${\n Number.isFinite(parsed.vebusDcVoltageV)\n ? round1(parsed.vebusDcVoltageV)\n : 'n/a'\n }V`\n);\nreason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`);\nreason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`);\nreason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`);\nreason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`);\nreason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\nif (night.nightSessionActive) {\n reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`);\n reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`);\n\n if (Number.isFinite(night.nightSessionStartSocPct)) {\n reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`);\n reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`);\n }\n\n reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`);\n reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`);\n reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`);\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst sourceModeLabel =\n context.sourceMode === 'blocked' ? 'Bloqué' :\n context.sourceMode === 'solar_only' ? 'Solaire' :\n context.sourceMode === 'solar_potential' ? 'Potentiel solaire' :\n context.sourceMode === 'hybrid_probe' ? 'Probe hybride' :\n context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :\n context.sourceMode === 'grid_forced' ? 'Réseau forcé' :\n context.sourceMode === 'battery_assist_night' ? 'Assistance batterie nuit' :\n context.sourceMode;\n\nconst signature = [\n decision.state,\n `profile=${context.profile}`,\n `source=${context.sourceMode}`,\n `chg=${decision.desiredCharging ? '1' : '0'}`,\n `perm=${decision.desiredPermission ? '1' : '0'}`,\n `l=${decision.desiredLatch ? '1' : '0'}`,\n `cur=${round1(desiredCurrentA)}`,\n `block=${decision.nextBlockReason}`,\n `stop=${decision.mustStop ? '1' : '0'}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `night=${night.nightSessionActive ? '1' : '0'}`,\n `ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`,\n `nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`,\n `vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`,\n `vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== history.lastSignature;\n\n// =====================================================\n// NOTIFICATIONS\n// =====================================================\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = shortReason;\n\nif (parsed.notificationsEnabled && signatureChanged) {\n if (decision.currentDecision === 'start') {\n notifyWanted = true;\n notifyTitle = '⚡ EVCS démarrage';\n } else if (decision.currentDecision === 'step_up') {\n notifyWanted = true;\n notifyTitle = '⬆️ EVCS hausse courant';\n } else if (\n decision.currentDecision === 'step_down' ||\n decision.currentDecision === 'step_down_fast'\n ) {\n notifyWanted = true;\n notifyTitle = '⬇️ EVCS baisse courant';\n } else if (\n decision.mustStop ||\n decision.currentDecision === 'stop' ||\n decision.currentDecision === 'stop_hard' ||\n decision.currentDecision === 'stop_power_cap'\n ) {\n notifyWanted = true;\n notifyTitle = '⛔ EVCS arrêt';\n } else if (decision.state === 'mode_auto_conflict') {\n notifyWanted = true;\n notifyTitle = '⚠️ EVCS mode Auto';\n } else if (decision.state === 'vehicle_full_or_paused') {\n notifyWanted = true;\n notifyTitle = '🚗 EVCS véhicule plein ou en pause';\n }\n}\n\n// =====================================================\n// TIMESTAMPS TO WRITE\n// =====================================================\nconst cloudDebtStartedAtToWrite =\n cloud.cloudDebtStartedMs\n ? formatParisStampFromMs(cloud.cloudDebtStartedMs)\n : now.stamp;\n\n// DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : chaine vide quand il n'y a pas\n// de depassement en cours. Le helper n'est reecrit que sur changement reel\n// (debut ou fin), pas a chaque cycle : la valeur du debut doit RESTER figee\n// pendant toute la duree du depassement, sinon le compteur ne monte jamais.\nconst batterySoftBreachAtToWrite =\n batteryPolicy.batterySoftBreachStartedMs !== null &&\n batteryPolicy.batterySoftBreachStartedMs !== undefined\n ? formatParisStampFromMs(batteryPolicy.batterySoftBreachStartedMs)\n : '';\n\nconst setBatterySoftBreachAt =\n batterySoftBreachAtToWrite !== (history.batterySoftBreachAt || '');\n\nconst probeStartedAtToWrite = now.stamp;\nconst lastStartAtToWrite = now.stamp;\nconst lastStopAtToWrite = now.stamp;\nconst lastCurrentChangeAtToWrite = now.stamp;\n\n// =====================================================\n// NIGHT SESSION MEMORY\n// =====================================================\nconst nightSessionStartSocToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? String(round1(parsed.soc))\n : (\n Number.isFinite(history.nightSessionStartSocPct)\n ? String(round1(history.nightSessionStartSocPct))\n : ''\n );\n\nconst nightSessionStartAtToWrite =\n decision.clearNightSessionStart\n ? ''\n : decision.setNightSessionStart\n ? now.stamp\n : (history.nightSessionStartAt || '');\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n timestamp_paris: now.stamp,\n inputs: input,\n\n computed: {\n operating_mode: context.operatingMode,\n profile: context.profile,\n source_mode: context.sourceMode,\n source_mode_label: sourceModeLabel,\n user_strategy: strategy.userStrategy,\n\n in_night_hc_window: windows.inNightHcWindow,\n in_morning_window: windows.inMorningWindow,\n in_midday_window: windows.inMiddayWindow,\n in_evening_window: windows.inEveningWindow,\n\n // D'ou vient la consigne reseau. Sans cette trace, un repli est\n // SILENCIEUX : la consigne reprend la formule d'avant sans que rien ne le\n // dise, et c'est precisement l'information qui manquera le jour ou elle se\n // comportera mal. `bilan` = dimensionnee sur le manque, `heritee` = repli.\n setpoint_source: setpointFromBalance ? 'bilan' : 'heritee',\n setpoint_battery_share_w: Math.round(batterySetpointShareW),\n setpoint_evcs_share_w: Math.round(evcsSetpointShareW),\n dvcc_limit_w: dvccChargeLimitW === null ? null : Math.round(dvccChargeLimitW),\n hc_minutes_left: hcMinutesLeft,\n\n departure_priority_active: context.departurePriorityActive,\n minutes_until_departure: lifecycle.minutesUntilDeparture,\n\n battery_priority_mode: modes.batteryPriorityMode,\n battery_full_harvest_mode: modes.batteryFullHarvestMode,\n forecast_commit_mode: modes.forecastCommitMode,\n\n physical_cap_current_a: derived.physicalCapA,\n can_run_at_min_current: derived.canRunAtMinCurrent,\n\n forecast_buffer_kwh: round2(derived.forecastBufferKwh),\n projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge),\n\n cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh),\n cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes),\n cloud_debt_active: cloud.cloudDebtActive,\n cloud_tolerance_ok: cloud.cloudToleranceOk,\n\n start_blocked_by_reason: locks.startBlockedByReason,\n\n probe_window_active: conditions.probeWindowActive,\n probe_success_now: conditions.probeSuccessNow,\n probe_failed_now: conditions.probeFailedNow,\n\n any_vebus_overload: derived.anyVebusOverload,\n any_vebus_low_battery: derived.anyVebusLowBattery,\n vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached,\n vebus_ac_out_hard_reached: derived.vebusAcOutHardReached,\n vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow,\n vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow,\n\n soc_band: batteryPolicy.socBand,\n batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA),\n batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA),\n batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA),\n batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes),\n batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds),\n batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes),\n grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA),\n grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA),\n house_spike_detected: batteryPolicy.houseSpikeDetected,\n solar_potential_context: batteryPolicy.solarPotentialContext,\n\n batt_soft_breach_now: !!batteryPolicy.batterySoftBreachNow,\n batt_soft_breach_minutes: round1(batteryPolicy.batterySoftBreachMinutes || 0),\n batt_soft_discharge_hold_ok: batteryPolicy.batterySoftDischargeHoldOk !== false,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource,\n evcs_priority_manual_override_active: manualPriorityOverrideActive,\n\n night_session_active: night.nightSessionActive,\n night_session_needs_capture: !!night.nightSessionNeedsCapture,\n night_session_needs_clear: !!night.nightSessionNeedsClear,\n night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct)\n ? round1(night.nightSessionStartSocPct)\n : null,\n night_session_start_at: night.nightSessionStartAt || null,\n\n battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct)\n ? round1(night.batteryHardFloorPct)\n : null,\n battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct)\n ? round1(night.batteryMorningTargetPct)\n : null,\n max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints)\n ? round1(night.maxBatteryDischargePoints)\n : null,\n grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct)\n ? round1(night.gridTakeoverTriggerPct)\n : null,\n\n battery_recovery_urgent: !!night.batteryRecoveryUrgent,\n night_grid_takeover_needed: !!night.nightGridTakeoverNeeded,\n night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked',\n\n evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null,\n evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null,\n evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null,\n evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic,\n evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null,\n evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand,\n evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand,\n evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null,\n evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null\n },\n\n decision: {\n state: decision.state,\n current_decision: decision.currentDecision,\n desired_charging: decision.desiredCharging,\n desired_permission: decision.desiredPermission,\n desired_latch: decision.desiredLatch,\n desired_current_a: round1(desiredCurrentA),\n\n charging_action: chargingAction,\n permission_action: permissionAction,\n latch_action: latchAction,\n current_action: currentAction,\n auto_start_action: autoStartAction,\n\n set_last_start_at: setLastStartAt,\n set_last_stop_at: setLastStopAt,\n set_last_current_change_at: setLastCurrentChangeAt,\n set_cloud_debt_started_at: decision.setCloudDebtStartedAt,\n set_probe_started_at: decision.setProbeStartedAt,\n\n set_night_session_start: !!decision.setNightSessionStart,\n clear_night_session_start: !!decision.clearNightSessionStart,\n\n set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled,\n block_reason: decision.nextBlockReason,\n\n set_blocked_until: true,\n blocked_until: blockedUntilToWrite,\n\n dynamic_ok: decision.dynamicOk,\n must_stop: decision.mustStop,\n\n short_reason: shortReason,\n reason,\n signature,\n signature_changed: signatureChanged,\n\n should_notify: notifyWanted,\n notify_title: notifyTitle,\n notify_message: notifyMessage,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason,\n evcs_priority_until: evcsPriorityUntil,\n evcs_priority_source: evcsPrioritySource\n },\n\n ha_service_calls: {\n helper_state: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_state',\n value: decision.state\n }\n },\n\n helper_current_decision: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_current_decision',\n value: decision.currentDecision\n }\n },\n\n helper_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_surplus_reason',\n value: shortReason\n }\n },\n\n helper_operating_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_operating_mode',\n value: context.operatingMode\n }\n },\n\n helper_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_source_mode',\n value: sourceModeLabel\n }\n },\n\n helper_target_current: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_target_current_auto',\n value: round1(desiredCurrentA)\n }\n },\n\n helper_budget_remaining: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_budget_remaining_kwh',\n value: round2(derived.forecastBufferKwh)\n }\n },\n\n helper_projected_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',\n value: round1(derived.projectedEndSocAfterCharge)\n }\n },\n\n helper_cloud_debt: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_cloud_debt_kwh',\n value: round2(cloud.cloudDebtNextKwh)\n }\n },\n\n helper_dynamic_ok: {\n execute: true,\n domain: 'input_boolean',\n service: decision.dynamicOk ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_dynamic_ok'\n }\n },\n\n helper_must_stop: {\n execute: true,\n domain: 'input_boolean',\n service: decision.mustStop ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_must_stop'\n }\n },\n\n // Bouton reset borne (19/09/2026) : momentane. Il a deja fait son effet\n // dans WINDOWS (sessionFresh forcee ce cycle) ; on le rebascule off pour\n // qu'il ne force pas un retest en boucle. N'ecrit que s'il etait ON.\n helper_reset_borne_ack: {\n execute: !!params.resetBorneRequested,\n domain: 'input_boolean',\n service: 'turn_off',\n data: {\n entity_id: 'input_boolean.evcs_reset_borne'\n }\n },\n\n helper_cloud_debt_started_at: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',\n datetime: cloudDebtStartedAtToWrite\n }\n },\n\n helper_battery_soft_breach_at: {\n execute: setBatterySoftBreachAt,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_battery_soft_breach_at',\n value: batterySoftBreachAtToWrite\n }\n },\n\n helper_last_current_change_at: {\n execute: setLastCurrentChangeAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_current_change_at',\n datetime: lastCurrentChangeAtToWrite\n }\n },\n\n helper_last_start_at: {\n execute: setLastStartAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_start_at',\n datetime: lastStartAtToWrite\n }\n },\n\n helper_last_stop_at: {\n execute: setLastStopAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_last_stop_at',\n datetime: lastStopAtToWrite\n }\n },\n\n helper_signature: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_control_last_signature',\n value: signature\n }\n },\n\n helper_block_reason: {\n execute: needsBlockReasonUpdate || !parsed.controllerEnabled,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_last_block_reason',\n value: decision.nextBlockReason\n }\n },\n\n helper_blocked_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_blocked_until',\n datetime: blockedUntilToWrite\n }\n },\n\n helper_probe_started_at: {\n execute: decision.setProbeStartedAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_probe_started_at',\n datetime: probeStartedAtToWrite\n }\n },\n\n helper_evcs_priority_active: {\n execute: true,\n domain: 'input_boolean',\n service: evcsPriorityActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_priority_active'\n }\n },\n\n helper_energy_priority_mode: {\n execute: true,\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_energy_priority_mode',\n option: energyPriorityMode\n }\n },\n\n helper_energy_priority_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_energy_priority_reason',\n value: energyPriorityReason\n }\n },\n\n helper_evcs_priority_until: {\n execute: true,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_priority_until',\n datetime: evcsPriorityUntil\n }\n },\n\n helper_night_target_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_target_soc',\n value: round1(\n Number.isFinite(night.batteryMorningTargetPct)\n ? night.batteryMorningTargetPct\n : 0\n )\n }\n },\n\n helper_night_grid_trigger_soc: {\n execute: true,\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_night_grid_trigger_soc',\n value: round1(\n Number.isFinite(night.gridTakeoverTriggerPct)\n ? night.gridTakeoverTriggerPct\n : 0\n )\n }\n },\n\n helper_night_preferred_source_mode: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_preferred_source_mode',\n value: night.nightPreferredSourceMode || 'blocked'\n }\n },\n\n // SESSION RESEAU UNIQUE (03/09/2026) : ce bit est ce qui rend la session\n // UNIQUE. Sans persistance, l'engagement serait reevalue a chaque cycle\n // et le systeme retomberait dans une bande — donc dans un oscillateur.\n // Ecrit uniquement sur changement reel.\n helper_night_grid_session_committed: {\n execute: night.nightGridSessionCommitted !== history.nightGridSessionCommittedPrev,\n domain: 'input_boolean',\n service: night.nightGridSessionCommitted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_session_committed'\n }\n },\n\n // P12 — LE VERROU ANTI-OSCILLATEUR.\n //\n // Le plancher de surplus (`required_soc_prudent`) DESCEND pendant la nuit,\n // et le SOC aussi. Mesure du 15/09 : voiture en charge, le SOC descend de\n // 7,1 pt/h et le plancher de 3,7 ; voiture ARRETEE, le SOC ne descend plus\n // que de 2,6 pt/h quand le plancher continue a 3,7. Le surplus\n // REAPPARAITRAIT donc tout seul et la charge repartirait — cinquieme\n // oscillateur du projet, par construction.\n //\n // Une seule passe de surplus par session de nuit, comme la recovery\n // batterie depuis le 27/08. Ecrit uniquement sur changement reel.\n helper_night_battery_done: {\n execute: !!night.nightBatterySurplusExhausted !== !!params.nightBatteryDonePrev,\n domain: 'input_boolean',\n service: night.nightBatterySurplusExhausted ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_done'\n }\n },\n\n helper_night_grid_takeover_needed: {\n execute: true,\n domain: 'input_boolean',\n // P9 : la fenêtre \"départ imminent\" (jour) tient aussi le helper\n // takeover → override hystérésis + maintien batterie ACM pendant\n // la charge réseau pré-départ.\n service: (night.nightGridTakeoverNeeded || night.departureUrgentActive)\n ? 'turn_on'\n : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed'\n }\n },\n\n helper_night_battery_recovery_urgent: {\n execute: true,\n domain: 'input_boolean',\n service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent'\n }\n },\n\n helper_night_session_active: {\n execute: true,\n domain: 'input_boolean',\n service: night.nightSessionActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_night_session_active'\n }\n },\n\n helper_night_session_start_soc: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_soc',\n value: nightSessionStartSocToWrite\n }\n },\n\n // Budget voiture de la nuit (30/09) : fige a l'ouverture, efface en fin.\n helper_night_budget: {\n execute: night.nightBudgetToWrite !== null && night.nightBudgetToWrite !== undefined,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_budget',\n value: night.nightBudgetToWrite ?? ''\n }\n },\n\n helper_night_session_start_at: {\n execute: decision.setNightSessionStart || decision.clearNightSessionStart,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_evcs_night_session_start_at',\n value: nightSessionStartAtToWrite\n }\n },\n\n // =================================================\n // VEHICLE DEMAND FILTER HISTORY PERSISTENCE\n // =================================================\n helper_evcs_delivery_ratio_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_ratio_ema',\n value: round3(vdf.nextHistory.evcsDeliveryRatioEma)\n }\n },\n\n helper_evcs_delivery_noise_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_delivery_noise_ema',\n value: round3(vdf.nextHistory.evcsDeliveryNoiseEma)\n }\n },\n\n helper_evcs_no_demand_score_ema: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma),\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_evcs_no_demand_score_ema',\n value: round3(vdf.nextHistory.evcsNoDemandScoreEma)\n }\n },\n\n helper_evcs_no_demand_semantic_active: {\n execute: true,\n domain: 'input_boolean',\n service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active'\n }\n },\n\n helper_evcs_no_demand_since_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_since_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs)\n }\n },\n\n helper_evcs_no_demand_filter_eval_at: {\n execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs),\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at',\n datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs)\n }\n },\n\n // =================================================\n // PHYSICAL EDF GRID TAKEOVER\n // =================================================\n edf_general_ok_on: {\n execute:\n (['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) ||\n night.nightBatteryGridOnly === true) &&\n !parsed.gridAvailable,\n domain: 'switch',\n service: 'turn_on',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n grid_power_setpoint: {\n execute: gridSetpointNeedsWrite,\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.gx_device_ac_power_setpoint',\n value: gridSetpointTargetW\n }\n },\n\n evcs_auto_start_off: {\n execute: autoStartAction === 'turn_off',\n domain: 'switch',\n service: 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_auto_start'\n }\n },\n\n evcs_charging: {\n execute: chargingAction !== 'none',\n domain: 'switch',\n service: decision.desiredCharging ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.victron_evcs_charging'\n }\n },\n\n helper_permission: {\n execute: permissionAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredPermission ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_charge_permission_now'\n }\n },\n\n helper_latch: {\n execute: latchAction !== 'none',\n domain: 'input_boolean',\n service: decision.desiredLatch ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_evcs_control_latch'\n }\n },\n\n evcs_set_current: {\n execute: currentAction === 'set',\n domain: 'number',\n service: 'set_value',\n data: {\n entity_id: 'number.victron_evcs_set_current_command',\n value: round1(desiredCurrentA)\n }\n },\n\n logbook: {\n execute: signatureChanged,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron EVCS Surplus',\n message: shortReason\n }\n },\n\n mobile_notification: {\n execute: notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage || shortReason\n }\n }\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -165,7 +165,7 @@ }, { "parameters": { - "jsCode": "function minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction secondsSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 1000);\n}\n\nfunction isWithinWindow(nowMin, startMin, stopMin) {\n if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;\n if (startMin === stopMin) return false;\n if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;\n return nowMin >= startMin || nowMin < stopMin;\n}\n\n// Temps restant dans une fenetre, en minutes, ou null si on n'y est pas.\n//\n// ⚠️ IL VIT ICI, A COTE DE `isWithinWindow`, ET PAS DANS LE NOEUD QUI L'UTILISE.\n// Ce qui serait duplique ailleurs n'est pas l'analyse des heures — INPUT_PARSE\n// les a deja converties en minutes — c'est la LOGIQUE D'APPARTENANCE, et en\n// particulier le traitement du passage de minuit. Une seconde interpretation\n// divergerait le jour ou une borne bouge, et elles ont deja bouge une fois\n// (01:30 -> 01:24 le 09/07/2026).\nfunction minutesRemainingInWindow(nowMin, startMin, stopMin) {\n if (!isWithinWindow(nowMin, startMin, stopMin)) return null;\n if (startMin < stopMin) return stopMin - nowMin;\n // Fenetre a cheval sur minuit : avant minuit il reste la fin de la nuit EN\n // PLUS du reste de la journee. Atteignable par les valeurs de repli\n // d'INPUT_PARSE (23:00 -> 06:00) si les helpers d'horaire sont illisibles.\n return nowMin >= startMin ? stopMin + 1440 - nowMin : stopMin - nowMin;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst prev = $input.first().json;\nconst {\n input,\n nowMs,\n now,\n cfg,\n parsed,\n params,\n history,\n batteryTune\n} = prev;\n\n// =====================================================\n// STRATEGY\n// =====================================================\nconst userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();\n\nconst isStopMode = userStrategy === 'stop';\nconst forceGridRequested = userStrategy === 'force_grid_now';\nconst forceChargeRequested = userStrategy === 'force_charge_now';\nconst isForceMode = forceGridRequested || forceChargeRequested;\n\n// =====================================================\n// LIFECYCLE / TIMING\n// =====================================================\nconst minutesSinceStart = minutesSince(history.lastStartMs, nowMs);\nconst minutesSinceStop = minutesSince(history.lastStopMs, nowMs);\nconst secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);\nconst minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);\n\nconst minRunElapsed =\n minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;\n\nconst minStopElapsed =\n minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;\n\nconst currentCooldownBaseSec = parsed.evcsChargingSwitchOn\n ? batteryTune.global.stepDownCooldownSeconds\n : batteryTune.global.stepUpCooldownSeconds;\n\nconst currentCooldownElapsed =\n secondsSinceCurrentChange === null\n ? true\n : secondsSinceCurrentChange >= Math.max(\n params.currentChangeCooldownSeconds,\n currentCooldownBaseSec\n );\n\n// =====================================================\n// DEPARTURE PRIORITY\n// =====================================================\nconst minutesUntilDeparture =\n params.departureTimeMin === null\n ? null\n : (() => {\n const rawDiff = params.departureTimeMin - now.minNow;\n return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);\n })();\n\nconst departurePriorityActive =\n parsed.departurePriorityEnabled &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&\n parsed.evcsVehicleConnected;\n\n// =====================================================\n// MANUAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge,\n// MAIS PAS demande implicite de réseau.\n// =====================================================\nconst manualChargeBypassWindows = forceChargeRequested;\nconst manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;\n\n// Compatibilité ascendante : on garde le champ dans context,\n// mais on le neutralise volontairement.\nconst forceChargeDepartureGridOverride = false;\n\n// =====================================================\n// SYSTEM DERIVED VALUES\n// =====================================================\nconst acVoltageRef =\n Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180\n ? parsed.vebusAcOutVoltageV\n : cfg.nominalAcVoltageV;\n\nconst batteryVoltageRef =\n Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0\n ? parsed.vebusDcVoltageV\n : 51.2;\n\nconst houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);\nconst vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);\n\nconst totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);\nconst totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;\n\n// =====================================================\n// FENETRE D'ETABLISSEMENT DU RESEAU (31/08/2026)\n// =====================================================\n// Mesure du 31/08 : a la fermeture du contacteur, le reseau reprend d'un coup\n// TOUTE la charge de la maison et le solaire bascule vers la batterie. Batterie\n// preneuse, la production ne bouge pas ; batterie pleine, elle s'effondre (2663\n// -> 559 W) faute de destination. Dans les deux cas l'import decroit ensuite\n// jusqu'a la consigne en ~150 s (1985 -> 25 W, puis 2600 -> 52 W).\n//\n// Pendant cette phase, PV, import et courant batterie ne decrivent aucun regime\n// permanent : la boucle de 30 s y prend cinq decisions, et la moyenne PV 10 min\n// en garde la trace une dizaine de minutes de plus. Le systeme lit \"plus de\n// soleil\", en conclut qu'il a besoin du reseau, et confirme ainsi sa propre\n// premisse -- une boucle de rattrapage, comme les trois du 24/08.\n//\n// ASYMETRIE VOULUE : la fenetre ne s'ouvre qu'a l'ARRIVEE du reseau. A sa\n// disparition, les onduleurs reprennent instantanement toute la charge : les\n// gardes doivent agir plus vite, jamais moins vite.\nconst gridSettling =\n parsed.gridAvailable &&\n parsed.gridTransitionAgeSec < params.gridSettleSeconds;\n\nconst endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);\n\nconst energyNeededForEndDayGuardKwh = Math.max(\n 0,\n ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n);\n\nconst forecastBufferKwh =\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh -\n energyNeededForEndDayGuardKwh;\n\nconst projectedEndSocAfterCharge = clamp(\n parsed.soc + (\n (\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh\n ) / cfg.batteryUsableCapacityKwh\n ) * 100,\n 0,\n 100\n);\n\nconst currentReferenceA = clamp(\n Number.isFinite(parsed.evcsCommandCurrentA)\n ? parsed.evcsCommandCurrentA\n : Number.isFinite(parsed.evcsSetCurrentA)\n ? parsed.evcsSetCurrentA\n : params.targetCurrentAutoA,\n 0,\n 32\n);\n\nconst anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;\nconst anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;\n\nconst vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;\nconst vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;\n\nconst vebusDcVoltageSoftLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;\n\nconst vebusDcVoltageHardLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;\n\nconst houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;\n\n// =====================================================\n// WINDOWS / PROFILE\n// =====================================================\nconst inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);\nconst inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);\nconst inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);\nconst inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);\n\nconst profile =\n inNightHcWindow ? 'night_hc' :\n inMorningWindow ? 'morning_solar' :\n inMiddayWindow ? 'midday_hc_or_solar' :\n inEveningWindow ? 'evening_high_soc' :\n 'outside_window';\n\n// =====================================================\n// EVCS NIGHT PREVIEW ENGINE\n// =====================================================\nconst nightSessionActive =\n profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionStartMs =\n nightSessionActive\n ? (history.nightSessionStartMs ?? null)\n : null;\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !nightSessionStartAt ||\n nightSessionStartMs === null\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\n// P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain.\nconst nightForecastHour = now.hour;\nconst nightForecastKwh =\n (nightForecastHour >= 20 || nightForecastHour < 8)\n ? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0)\n : parsed.todayRemainingKwh;\n\nconst batteryMorningTargetRulePct =\n nightForecastKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : nightForecastKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\n// =====================================================\n// LE CONSEILLER NE FIXE PLUS LA CIBLE (retire le 13/09/2026)\n// =====================================================\n// Le premier cablage reel (09/08/2026, HANDOFF §20) faisait suivre a la cible\n// du matin `input_number.evcs_advisor_night_target_soc`, derriere un toggle,\n// un statut `ok`, une fraicheur de 120 min et un bornage.\n// Le 12/09 le conseiller est devenu OBSERVATEUR : il n'emet plus de cible, et\n// ce helper est fige depuis. Les quatre gardes continuaient pourtant de passer\n// toutes les quatre — la garde de fraicheur date le RUN, pas la VALEUR, et le\n// conseiller tourne toujours. Activer le toggle aurait donc applique un chiffre\n// perime sans qu'aucune garde ne s'y oppose. Un cablage qui ne peut plus que se\n// tromper se retire ; le scenario `advisor-cible-ignoree` le verifie.\nconst batteryMorningTargetBasePct = batteryMorningTargetRulePct;\n\n// =====================================================\n// CIBLE DU MATIN : UNE ENERGIE A COUVRIR, PAS UN PALIER (03/09/2026)\n// =====================================================\n// La regle historique classe la journee du lendemain en trois paliers\n// (>= 18 kWh -> 35 %, >= 10 -> 55 %, sinon 85 %) a partir de son TOTAL. Elle\n// est donc aveugle a l'heure d'arrivee de l'energie : le 03/09, demain\n// annoncait 33,9 kWh — « fort », donc 35 % — mais avec un matin creux\n// (0,03 kWh a 7 h, 0,77 a 8 h, 1,81 a 9 h) et un croisement a 9 h.\n//\n// C'est le meme defaut que le plancher du soir (cf. le bloc BILAN de\n// BATTERY_CLOUD_LOCKS), a l'autre bout de la nuit : un palier fige contre une\n// energie mesurable. Le bilan publie la seconde, instant par instant.\n//\n// RELEVE DE LA NUIT 02-03/09, qui a motive ce cablage :\n//\n// heure requis SOC reel cible fixe\n// 00:30 62,4 53 35\n// 03:00 50,4 45 35 <- EDF relache ici, 5 points sous la ligne\n// 04:30 41,7 37 35\n// 06:00 37,1 22 35 <- hysteresis, puis reseau PLEIN TARIF\n// de 07:24 a 10:33 (1,49 kWh)\n//\n// La batterie est restee sous la ligne toute la nuit et le bilan le savait des\n// 00:30. Le `required_soc` decroit proprement avec la nuit qui se consomme\n// (6,1 -> 0 kWh a couvrir) : c'est bien une cible instantanee, pas une\n// constante deguisee.\n//\n// SENS UNIQUE, comme pour le plancher du soir : le bilan ne peut que RELEVER\n// la cible, jamais l'abaisser. La direction corrigee est celle de l'echec\n// observe (cible trop basse -> batterie a plat avant le croisement -> reseau\n// au plein tarif le matin). L'abaisser — la regle dit 85 % l'hiver quand le\n// bilan dira parfois moins — ouvrirait un risque neuf que rien n'a encore\n// valide. Le principe unificateur : le bilan ne pousse JAMAIS dans le sens\n// « couvre moins bien la nuit ».\n//\n// PLAFOND 90 % : au-dela, la batterie cesse d'etre un puits et la production\n// du matin se fait evincer (cf. mesures du 31/08). Charger au reseau au-dela\n// de 90 echangerait un kWh bon marche cette nuit contre plusieurs kWh censures\n// demain. C'est deja la borne du chemin advisor : aucune constante nouvelle.\n//\n// PAS D'OSCILLATEUR : `required_soc` decroit de facon monotone pendant la nuit\n// (l'energie restant a couvrir ne fait que diminuer), et la passe unique de\n// recovery (`nightRecoveryDonePrev`, 27/08) reste en place.\n//\n// REPLI identique au plancher du soir : fraicheur, statut, valeur finie —\n// sinon regle historique. Kill-switch existant : `evcs_balance_enabled` OFF\n// fige `balance_last_run` et la cible retombe seule sur les paliers.\nconst BALANCE_TARGET_MAX_AGE_MIN = 30;\nconst balanceTargetAgeMin = minutesSince(params.balanceLastRunMs, nowMs);\nconst balanceMorningTargetApplied =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct) &&\n balanceTargetAgeMin !== null &&\n balanceTargetAgeMin <= BALANCE_TARGET_MAX_AGE_MIN;\n\n// ⚠️ 01/10/2026 — DECISION DU PROPRIETAIRE : LE BILAN FIXE LA CIBLE, DANS LES\n// DEUX SENS. « La valeur de 35 % c'est idiot si le besoin est prevu 26,6 %, et\n// c'est aussi idiot si la prevision est de 60 % pour une journee nuageuse. »\n// Le sens unique ci-dessus (03/09) gardait le palier comme plancher : le\n// 01/10 a 06:00, cible 35 (palier « forte prevision ») pour un besoin du bilan\n// de 28,6 %. Le palier n'est plus qu'un REPLI quand le bilan est absent,\n// perime ou degrade. Bornes : le plancher dur en bas, 90 % en haut.\nconst batteryMorningTargetPct = balanceMorningTargetApplied\n ? Math.min(90, Math.max(batteryHardFloorPct, params.balanceRequiredSocPct))\n : batteryMorningTargetBasePct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\n// 01/10/2026 : l'urgence avant depart exige qu'il MANQUE de l'energie. Sans\n// cette garde, « 0 min de recharge + 30 min de marge >= 30 min » la declenchait\n// a 06:00 chaque jour de depart, batterie au-dessus de sa cible (01/10 : SOC 37,\n// cible 35, EDF ferme 06:00-06:38 pour rien ; idem 21, 22, 25 et 27/09).\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n batteryKwhMissingToTarget > 0 &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= minutesUntilDeparture\n );\n\n// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne.\n// Une fois le réseau HC engagé pendant une session nuit, on y reste\n// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine.\n// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée).\nconst NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90;\nconst nightTakeoverPrev =\n (input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\n// P8 (07/08/2026) : \"charge terminée\" COLLANT via l'état surplus du cycle\n// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot).\n// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume\n// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au\n// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit.\nconst prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase();\n\n// P11 (12/08/2026) : RESET du collant \"véhicule plein\" au CHANGEMENT DE\n// VÉHICULE. Incident 12/08 : JEEP chargée → night_recovery_done_idle collant ;\n// débranchement JEEP + branchement VW → l'état collant a survécu et bloquait\n// la VW (« charge terminée » = celle de la JEEP, pas de la VW).\n// Signal fiable : sensor.victron_evcs_charging_time repart de 0 à chaque\n// nouvelle connexion physique. Sous ~90 s → session neuve → on neutralise le\n// collant pour laisser le système RETESTER le véhicule fraîchement branché.\nconst P11_FRESH_SESSION_MAX_SEC = 90;\n// P11-bis (19/09/2026) : le charging_time de la borne repart de 0 sur un\n// debranchement FRANC, mais reste FIGE sur un swap trop rapide (14 s le 19/09 :\n// VW debranchee, JEEP branchee, borne gelee a l'etat VW). P11 restait alors\n// aveugle et le collant \"vehicule plein\" de la VW bloquait la JEEP. Le\n// last_changed du capteur de connexion, lui, a bien bouge sur ce swap : c'est\n// un SECOND signal de fraicheur, independant de la borne. Meme fenetre (90 s =\n// 3 cycles de 30 s, assez pour capter un (re)branchement entre deux cycles).\n// Cout d'un faux positif (glitch MQTT sub-seconde) : au pire un retest, que le\n// statut 3 / VDF re-latche aussitot si le vehicule est vraiment plein.\nconst sessionFresh =\n parsed.evcsVehicleConnected &&\n (\n params.resetBorneRequested || // echappatoire manuelle (bouton reset borne)\n (Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec <= P11_FRESH_SESSION_MAX_SEC) ||\n (Number.isFinite(parsed.evcsVehicleConnectedAgeSec) &&\n parsed.evcsVehicleConnectedAgeSec <= P11_FRESH_SESSION_MAX_SEC)\n );\n\n// Fix D (08/08/2026) : le statut natif EVCS \"charged\" (3) vaut aussi fin de\n// charge, sans attendre le VehicleDemandFilter (désarmé si la session a été\n// stoppée par une garde, ex battery_current_hard_stop → détection tardive\n// observée la nuit du 07-08/08).\n// Fix E (09/08/2026) : garde puissance ≤ 300 W pour ne jamais latcher \"fini\"\n// sur un glitch de statut pendant une vraie charge. NB : le statut retombe\n// (3→1/6) dès l'ouverture du relais → la persistance est assurée par l'état\n// collant écrit par DECISION pendant la session (voir Fix E DECISION).\n// P11 : sur une session neuve, on ignore aussi un éventuel statut 3 résiduel\n// (le temps que la borne renégocie avec le nouveau véhicule).\n// Fix H (07/09/2026) : un statut \"charged\" n'est pas cru sur parole.\n// La borne l'a annonce le 07/09 a 14:28:18 apres 2 min 42 s de charge et\n// 0,10 kWh livre, ce qui a latche `day_vehicle_full_idle` pour deux heures\n// alors que le vehicule pouvait charger (il a repris a 16:30 et tenu).\n//\n// Aucun signal ELECTRIQUE instantane ne separe une vraie fin de charge d'une\n// interruption : mesure a la seconde des deux coupures, elles sont\n// superposables (VW 06/09, vraie fin : 1534 -> 550 -> 82 -> 11 -> 0 W ;\n// JEEP 07/09, faux positif : 1626 -> 741 -> 100 -> 13 -> 0 W). Seule la\n// DUREE de la session les distingue, et le systeme porte deja la grandeur qui\n// dit ce qu'est une charge etablie : min_run_minutes (29 min en production).\n//\n// Deux formes de \"charged\" sont credibles, et une seule ne l'est pas :\n// energie de session nulle -> le vehicule etait plein en arrivant (P10)\n// session >= min_run_minutes -> charge menee a son terme\n// entre les deux -> charge INTERROMPUE : on ne latche pas\n// Convergence : une session interrompue redemarre a energie nulle, donc un\n// second \"charged\" immediat retombe dans le premier cas et latche.\nconst chargedSessionEnergyKwh = Math.max(0, parsed.evcsSessionEnergyKwh || 0);\nconst chargedSessionEstablished =\n Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec >= params.minRunMinutes * 60;\nconst chargedCredible =\n chargedSessionEnergyKwh <= 0 || chargedSessionEstablished;\n\nconst evcsStatusCharged =\n !sessionFresh &&\n Number.parseFloat(parsed.evcsStatusRaw) === 3 &&\n Math.max(0, parsed.evcsAcPowerW || 0) <= 300 &&\n chargedCredible;\n\nconst vehicleChargeCompleted =\n nightSessionActive &&\n !sessionFresh &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'day_vehicle_full_idle' ||\n evcsStatusCharged\n );\n\n// Fix A (08/08/2026) : recovery batterie post-charge = UNE SEULE passe.\n// Sans flag collant, SOC==cible est un point dégénéré (engage soc<=cible,\n// release soc>cible, l'ACM recharge pile à la cible) → rebond EDF ON/OFF\n// période ~10 min observé la nuit du 07-08/08. Une fois la cible atteinte,\n// l'état 'night_recovery_done_idle' (auto-entretenu via prev_surplus_state)\n// coupe le takeover. Ré-engagement uniquement sous cible − 2 pts (bande),\n// hard floor et fenêtre/débranchement restent prioritaires.\n// ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de\n// l'hystérésis (full_or_paused / not_requesting_power / charge_complete /\n// no_demand) — même contrainte que 'night_session_done_idle'.\n// 27/08/2026 : la bande de 2 points fabriquait un oscillateur. Relevé de la\n// nuit du 26-27/08 (cible 35) : engagement à SOC 33, relâchement à 35, trois\n// cycles EDF de 52, 79 et 25 min, 2,5 kWh importés pour rien un matin de grand\n// soleil ; la nuit du 20-21/08 en comptait six. Deux points de SOC valent\n// ~0,34 kWh, soit une demi-heure de conso nocturne : ce n'est pas une\n// hystérésis, c'est la période d'une horloge — d'autant plus régulière que la\n// charge de nuit est parfaitement plate.\n// La recovery batterie post-charge est donc UNE SEULE passe par session de\n// nuit, ce qui était déjà l'intention affichée du Fix A (\"relâché\n// DÉFINITIVEMENT\"). Aucun seuil de ré-engagement ici : le plancher dur reste\n// un terme indépendant de nightGridTakeoverNeeded (voir plus bas), et\n// l'hystérésis réseau garde sa propre bande 20/30. Le vrai critère de\n// ré-engagement est un budget d'énergie restant à couvrir, pas un delta de\n// SOC : il appartient au bilan énergétique, pas à une constante de plus ici.\nconst nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle';\nconst nightRecoveryDone =\n vehicleChargeCompleted &&\n (parsed.soc >= batteryMorningTargetPct || nightRecoveryDonePrev);\n\n// =====================================================\n// P9 (09/08/2026) : CHARGE RÉSEAU AVANT DÉPART, HORS FENÊTRE HC.\n// Cas VW : départ 08:50 > fin HC 07:24 → fenêtre morte où le véhicule\n// sous-chargé attendait un solaire insuffisant (observé le 09/08 : advisor\n// force_grid_now, divergence n°10). Si le toggle est ON et que le départ du\n// véhicule SÉLECTIONNÉ est dans ≤ 120 min : réseau autorisé (grid_hc_allowed)\n// jusqu'à fin de charge. L'arrêt réutilise TOUTE la mécanique anti-rebond :\n// VDF → vehicle_full_or_paused → état collant 'departure_done_idle'\n// (réécrit chaque cycle, patterns hystérésis sûrs, coupure immédiate E2).\n// =====================================================\nconst P9_DEPARTURE_LEAD_MIN = 120;\n\nconst departureWindowActive =\n params.allowGridDepartureUrgent &&\n parsed.evcsVehicleConnected &&\n !inNightHcWindow &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= P9_DEPARTURE_LEAD_MIN;\n\nconst departureDonePrev =\n prevSurplusState === 'departure_done_idle' ||\n (!inNightHcWindow && prevSurplusState === 'vehicle_full_or_paused');\n\nconst departureChargeDone =\n departureWindowActive &&\n !sessionFresh &&\n (departureDonePrev || evcsStatusCharged);\n\nconst departureUrgentActive =\n departureWindowActive &&\n !departureChargeDone;\n\n// =====================================================\n// P10 (09/08/2026) : MÉMOIRE \"VÉHICULE PLEIN\" EN JOURNÉE.\n// Observé le 09/08 09:49 : VW pleine branchée depuis 07:00, le surplus\n// solaire retentait une charge (statut \"Charged\", 0 W) toutes les ~15 min\n// toute la journée (usure relais, bruit). Les états collants P8/P9 étaient\n// scopés nuit/fenêtre départ. Ici : une fois le véhicule vu plein (statut 3\n// ou VDF), état collant 'day_vehicle_full_idle' jusqu'au DÉBRANCHEMENT.\n// - hérite des états \"done\" de la nuit (07:24 → pas de re-tentative) ;\n// - la nuit hérite en retour (vehicleChargeCompleted inclut day_*) ;\n// - échappatoires : force_charge_now / force_grid_now / débrancher-rebrancher.\n// =====================================================\nconst dayVehicleFullPrev =\n prevSurplusState === 'day_vehicle_full_idle' ||\n (\n !inNightHcWindow &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'departure_done_idle'\n )\n );\n\nconst dayVehicleFullIdle =\n parsed.evcsVehicleConnected &&\n !sessionFresh &&\n !inNightHcWindow &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n (dayVehicleFullPrev || evcsStatusCharged);\n\n// P8 : correction du fix 04/08 (logique inversée : \"|| vehicleChargeCompleted\"\n// GARDAIT le latch après la charge au lieu de le libérer).\nconst nightTakeoverLatched =\n nightSessionActive &&\n nightTakeoverPrev &&\n parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT &&\n !vehicleChargeCompleted;\n\n// =====================================================\n// P12 — CHARGE NUIT SUR LA BATTERIE (15/09/2026)\n// =====================================================\n// Idee du proprietaire : plutot qu'acheter du reseau la nuit pour charger la\n// voiture, lui donner le solaire DEJA STOCKE, doucement, et laisser ainsi de la\n// place dans la batterie pour la production du lendemain. Payer du reseau la\n// nuit pour ecreter le solaire le lendemain, c'est perdre deux fois.\n//\n// Le mode `battery_assist_night` existe depuis toujours pour exactement ca, et\n// il est MORT depuis toujours : il exige `batteryCurrentA >= plancher de\n// decharge` (0 A), impossible la nuit puisque la maison tire sur la batterie\n// (§11 du HANDOFF, ~ -14 A). On ne cree donc pas un chemin, on rend viable\n// celui qui existe — en remplacant un test de COURANT impossible par un test\n// d'ENERGIE : rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement.\n//\n// Le plancher n'est PAS une constante nouvelle : c'est `required_soc_prudent`,\n// que le bilan publie deja, en profil p80. La marge est mesuree, pas inventee.\nconst nightBatterySurplusFloorPct = Math.max(\n batteryHardFloorPct,\n Number.isFinite(params.balanceRequiredSocPrudentPct)\n ? params.balanceRequiredSocPrudentPct\n : Infinity,\n);\n\n// ⚠️ LE VERROU, ET IL N'EST PAS DECORATIF. Le plancher DESCEND pendant la nuit\n// (il reste moins de nuit a couvrir) et le SOC aussi. Mesure du 15/09 :\n//\n// voiture en charge : SOC -7,1 pt/h, plancher -3,7 pt/h -> le surplus s'epuise\n// voiture arretee : SOC -2,6 pt/h, plancher -3,7 pt/h -> le surplus REVIENT\n//\n// Sans verrou, la charge repartirait des qu'elle s'arrete : cinquieme\n// oscillateur du projet, par construction. UNE SEULE PASSE de surplus par\n// session de nuit, comme la recovery batterie depuis le 27/08.\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire).\n// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la\n// charge a cette valeur ou autoriser une charge complete avec EDF. »\n// Le bilan publie le budget ; il est FIGE au premier cycle de la session\n// (avec l'index du compteur de la borne) dans input_text\n// victron_evcs_night_budget = « budget|index », efface en fin de session.\n// L'arret se juge sur le COMPTEUR, qui ne fait que monter : condition\n// monotone, aucun oscillateur possible (meme logique que §40). Le plancher de\n// SOC prudent reste un second arret, jamais retire.\nconst nightBudgetPrev = (() => {\n const m = String(params.nightBudgetPrevRaw || '').match(/^\\s*([\\d.]+)\\|([\\d.]+)\\s*$/);\n return m ? { budgetKwh: Number(m[1]), refKwh: Number(m[2]) } : null;\n})();\nconst nightBudgetCaptureOk =\n nightSessionActive && !nightBudgetPrev &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceNightVehicleBudgetKwh) &&\n Number.isFinite(params.evcsTotalEnergyKwh);\nconst nightBudget = !nightSessionActive\n ? null\n : nightBudgetPrev || (nightBudgetCaptureOk\n ? { budgetKwh: Math.max(0, params.balanceNightVehicleBudgetKwh), refKwh: params.evcsTotalEnergyKwh }\n : null);\nconst nightBudgetToWrite = nightSessionActive\n ? (nightBudgetCaptureOk ? `${Math.round(nightBudget.budgetKwh * 100) / 100}|${nightBudget.refKwh}` : null)\n : (nightBudgetPrev ? '' : null);\nconst nightBudgetDeliveredKwh = nightBudget && Number.isFinite(params.evcsTotalEnergyKwh)\n ? Math.max(0, params.evcsTotalEnergyKwh - nightBudget.refKwh)\n : null;\nconst nightBudgetReached =\n !!nightBudget && nightBudgetDeliveredKwh !== null &&\n nightBudgetDeliveredKwh >= nightBudget.budgetKwh - 0.05;\nconst nightChargeComplete = params.nightChargeMode === 'complete';\n\n// Le verrou ne vaut QUE pendant la session de nuit (01/10/2026). Ecrit sous la\n// forme `prev || …`, il restait vrai pour toujours une fois pose ; invisible\n// tant que son ecriture n'atteignait pas HA (jamais relayee du 15/09 au 01/10).\n// Fin de session (debranchement ou fin de fenetre HC) = verrou leve.\nconst nightBatterySurplusExhausted =\n nightSessionActive && (\n params.nightBatteryDonePrev ||\n parsed.soc <= nightBatterySurplusFloorPct ||\n nightBudgetReached\n );\n\nconst nightBatterySurplusAvailable =\n nightSessionActive &&\n params.nightBatteryChargeEnabled &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceRequiredSocPrudentPct) &&\n !vehicleChargeCompleted &&\n // Le plancher est teste UNE SEULE FOIS, dans `nightBatterySurplusExhausted`.\n // Le retester ici serait redondant — et une redondance ne se teste pas : la\n // mutation du 15/09 l'a montre en ne cassant aucun scenario.\n !nightBatterySurplusExhausted;\n\n// La batterie d'abord, le reseau quand le surplus est epuise. C'est la soupape\n// decrite par le proprietaire — « quitte a fermer EDF entre 6h et 7h24 pour\n// completer » — et elle devient automatique : l'epuisement du surplus rend\n// `nightGridHcPreferred` vrai, donc P7 prend le relais.\n// « Sans EDF » (defaut) : la voiture ne prend JAMAIS le reseau la nuit ; une\n// fois le budget batterie atteint (ou sans budget exploitable), elle attend le\n// soleil. « Charge complete » : EDF en HC finit la charge, comme avant.\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst nightCarWaitsNoGrid =\n nightSessionActive &&\n !nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\n !nightCarWaitsNoGrid &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !nightTakeoverLatched &&\n !batteryRecoveryUrgent;\n\n// =====================================================\n// SESSION RESEAU UNIQUE DE LA NUIT (03/09/2026)\n// =====================================================\n// §26 avait pose l'objectif — « une session reseau unique et dimensionnee » —\n// et designe le bon critere : « un budget d'energie restant a couvrir, il\n// appartient au bilan energetique ». Le bilan est cable depuis ce soir.\n//\n// CE QUI A ETE MESURE AVANT D'ECRIRE CECI, et qui a dicte la conception :\n// la conversion kWh -> points de SOC du bilan est une constante unique\n// (15 kWh). La jauge reelle vaut ~17 kWh au-dessus de 42 % (0,164 a\n// 0,171 kWh/point sur trois fenetres independantes) mais s'effondre en\n// dessous : 12,1 kWh entre 45 et 22 %, 8,2 kWh entre 33 et 20 %. Un point de\n// SOC vaut donc MOITIE MOINS dans la bande ou la nuit se termine, et\n// `required_soc` y est systematiquement trop BAS — c'est pourquoi la batterie\n// est restee sous la ligne toute la nuit du 02-03/09, en descendant 1,75 fois\n// plus vite qu'elle.\n//\n// CONSEQUENCE DE CONCEPTION : le relachement ne doit PAS etre un seuil de SOC.\n// Il porterait sur une grandeur fausse, et tout seuil que le SOC repasse\n// ensuite rouvre l'oscillateur eteint le 27/08.\n//\n// Donc : ENGAGEMENT sur le bilan (batterie sous la cible), RELACHEMENT a la\n// FIN DE LA FENETRE. L'oscillation devient structurellement impossible : la\n// condition de sortie est monotone, une fenetre ne se ferme qu'une fois. Le\n// bit d'engagement est porte par\n// input_boolean.victron_evcs_night_grid_session_committed — sans lui le\n// systeme reevaluerait a chaque cycle et retomberait dans une bande.\n//\n// CE QUE CA FAIT CONCRETEMENT : l'ACM recharge jusqu'a la cible puis tient la\n// batterie a 2 A (evcs_takeover_hold) ; la maison finit la nuit sur le reseau\n// en HEURES CREUSES au lieu de vider la reserve, et la batterie arrive a\n// l'aube pres de sa cible, donc avec de la place pour le solaire du matin —\n// au lieu d'etre a 20 % avec le reseau encore ouvert a 10:33 (03/09).\n//\n// PORTEE : uniquement quand le bilan est exploitable, et uniquement pendant\n// une session nuit (fenetre HC + vehicule branche). Sinon, comportement\n// historique inchange. Le bit se nettoie tout seul a la fin de la session.\nconst nightGridSessionCommittedPrev =\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\nconst nightGridSessionUncovered =\n balanceMorningTargetApplied &&\n (batteryBelowMorningTarget || params.balanceEcsNuit);\n\n// ⚠️ SANS VEHICULE AUSSI (12/09/2026).\n// Nuit du 11-12/09, voiture rendue la veille : le bilan a reclame entre 92 %\n// et 59 % de SOC a CHAQUE calcul de la nuit, `reseau_necessaire` vrai partout,\n// et rien ne s'est passe. SOC bloque a 20 % de 23:43 a l'aube, consigne reseau\n// figee a 700 W depuis 18:36 — soit exactement la maison. L'hysteresis avait\n// bien ferme le contacteur (SOC <= 20), mais fermer le contacteur ne suffit\n// pas : il faut DEMANDER la puissance (lecon du 01/09). Personne ne la\n// demandait, parce que la session exigeait un vehicule branche.\n//\n// Or le besoin n'a rien a voir avec la voiture : c'est la maison qui doit\n// traverser la nuit, et le bilan sait exactement ce qu'il lui faut. La\n// fenetre HC suffit. Le relachement reste la FIN DE FENETRE, donc monotone,\n// donc toujours immune a l'oscillation (§40).\n//\n// Sans cette correction, le cout se paye le matin : a 07:24 le SOC est encore\n// sous le seuil OFF de l'hysteresis (30), donc le contacteur reste ferme et\n// la maison passe au PLEIN TARIF jusqu'au croisement PV — le 1,49 kWh deja\n// consigne le 03/09.\n// ⚠️ LA FENETRE DE MIDI EST AUSSI UNE FENETRE D'HEURES CREUSES (18/09/2026).\n// L'engagement de session etait gate sur `night_hc` seul, parce que §40 traitait\n// la nuit. Consequence mesuree : la charge HC manuelle demandee par le\n// proprietaire ne pouvait RIEN faire a midi — le plancher relevait bien la cible\n// du bilan, mais aucune session ne s'engageait, donc le contacteur ne se fermait\n// pas et la consigne restait au repos. Le reglage aurait releve le plancher du\n// soir (donc bloque la voiture) sans jamais charger la batterie.\n//\n// ⚠️ ET LE CRITERE N'EST PAS LE MEME AUX DEUX FENETRES. La nuit, on compare le\n// SOC a `batteryMorningTargetPct`, qui contient le palier de prevision\n// (35/55/85). A midi ce palier n'a aucun sens : il vaut 35 par beau temps, donc\n// un SOC de 30 % en plein soleil engagerait une session reseau — c'est-a-dire la\n// substitution du solaire par le reseau qu'on a mis des semaines a eteindre.\n//\n// A midi le critere honnete est le SOC requis du bilan lui-meme, qui\n// s'auto-regule : le croisement PV etant passe, `energyToCoverKwh` vaut zero et\n// `required_soc` retombe au plancher dur. Releve du 17/09 a 13:51, plein soleil :\n// `required_soc` = 22,0 pour `energy_to_cover` = 0,0. Il ne remonte qu'un jour\n// sombre — ou quand le proprietaire pose un plancher manuel, ce qui est\n// exactement le cas d'usage.\n//\n// ET LA VOITURE (28/09/2026, strategie du proprietaire) : « JEEP branchee\n// engage le reseau en HC midi si l'analyse de production le justifie ». Le\n// bilan fait l'analyse (besoin saisi contre solaire disponible avant le depart,\n// batterie gardee pour tenir jusqu'a 01:24) et publie un verdict ; on le relaie,\n// sous les memes gardes de fraicheur et de statut que la cible.\nconst middayVehicleGridNeeded =\n balanceMorningTargetApplied &&\n params.balanceVehicleGridNeeded &&\n parsed.evcsVehicleConnected;\n\n// ET LE CHAUFFE-EAU (07/10/2026, proprietaire) : un jour faible, l'eau chaude\n// se fait en heures creuses sur EDF, a PLEINE consommation toute la fenetre\n// (douches de 06:00 et 20:00 a 65 °C ; le 06/10 le ballon avait pris 5,4 kWh\n// de solaire de 10 h a 15 h, l'energie qui manquait a la batterie). Le bilan\n// decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) ; le workflow\n// ECS n'alimente le ballon que si la session est engagee ET le contacteur\n// ferme. Memes gardes de fraicheur et de statut que la cible.\nconst hcEcsGridNeeded =\n balanceMorningTargetApplied && params.balanceEcsNuit;\n\nconst middayGridSessionUncovered =\n balanceMorningTargetApplied &&\n (\n (Number.isFinite(params.balanceRequiredSocPct) &&\n parsed.soc < params.balanceRequiredSocPct) ||\n middayVehicleGridNeeded ||\n hcEcsGridNeeded\n );\n\nconst gridSessionInHcWindow =\n profile === 'night_hc' || profile === 'midday_hc_or_solar';\n\nconst gridSessionUncovered =\n profile === 'night_hc' ? nightGridSessionUncovered : middayGridSessionUncovered;\n\nconst nightGridSessionCommitted =\n gridSessionInHcWindow &&\n (gridSessionUncovered || nightGridSessionCommittedPrev);\n\nconst nightGridTakeoverNeeded =\n nightGridSessionCommitted ||\n (\n nightSessionActive &&\n (\n nightGridSessionCommitted ||\n nightGridHcPreferred ||\n batteryBelowHardFloor ||\n (\n // Fix A : après fin de charge, le seul motif réseau restant est la\n // recovery batterie, et elle ne se ré-engage pas une fois done.\n vehicleChargeCompleted\n ? !nightRecoveryDone\n : (\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent ||\n nightTakeoverLatched\n )\n )\n )\n );\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : vehicleChargeCompleted\n ? 'blocked'\n // « Sans EDF » : la voiture ne prend JAMAIS le reseau la nuit, MEME quand\n // EDF se ferme pour la batterie (plancher dur, cible du matin, session du\n // bilan, verrou). Decision du proprietaire le 04/10 (option A) : la nuit du\n // 03-04/10, EDF ferme a 02:57 pour la batterie, l'ancienne exception a\n // laisse la JEEP prendre 4,7 kWh au reseau jusqu'au plein. La protection\n // du materiel reste entiere : EDF se ferme quand meme (takeover), seule la\n // voiture attend le soleil.\n : nightCarWaitsNoGrid\n ? 'blocked'\n : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\n\n// « Sans EDF » ET EDF necessaire pour la BATTERIE (04/10/2026, option A du\n// proprietaire) : la voiture reste bloquee, mais la batterie a sa session\n// reseau. Sans ce drapeau, la fermeture d'EDF et la consigne de recharge, qui\n// suivent la source de la VOITURE, seraient perdues avec elle (golden\n// budget-sans-edf-plancher-dur-edf-protege, depart-matin-…-sous-la-cible).\nconst nightBatteryGridOnly =\n nightSessionActive &&\n nightCarWaitsNoGrid &&\n nightGridTakeoverNeeded;\n\nconst batteryRecoveryAllowedNow =\n nightSessionActive &&\n nightGridTakeoverNeeded &&\n (\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n );\n\nconst batteryTargetReached =\n nightSessionActive &&\n parsed.soc >= batteryMorningTargetPct;\n\n// =====================================================\n// PROBES / SOLAR POTENTIAL\n// =====================================================\n// IMPORTANT:\n// On ne met plus effectivePvStartMinW à 0 en force_charge_now,\n// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire.\nconst effectivePvStartMinW = params.pvStartMinW;\n\nconst morningProbeEligible =\n profile === 'morning_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW &&\n parsed.pvTrendW >= params.morningProbePvTrendMinW &&\n parsed.soc >= params.morningSocMin &&\n parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA;\n\nconst middayProbeEligible =\n profile === 'midday_hc_or_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW &&\n parsed.soc >= params.middaySocMin &&\n parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA;\n\nconst afternoonProbeEligible =\n profile === 'outside_window' &&\n params.probeEnabled &&\n parsed.soc >= params.afternoonProbeSocMin &&\n parsed.batteryCurrentA >= params.afternoonBatteryMaxDischargeA &&\n parsed.pvPowerAvg10mW >= params.afternoonProbePvAvgMinW;\n\nconst cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;\nconst batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);\n\n// P3 PROVOCATION PV (installation sans injection) :\n// Batteries quasi pleines => le MPPT bride la production à la conso maison.\n// Les seuils PV observés, le forecast buffer et la cloud debt ne veulent\n// alors plus rien dire (la dette ne peut d'ailleurs plus se rembourser,\n// batterie pleine = courant nul). On autorise solar_potential sur la seule\n// santé batterie : SOC très haut + batterie non déchargée.\n// Les gardes aval (BATTERY_CLOUD_LOCKS / DECISION) restent actives.\nconst PV_PROVOCATION_SOC_MIN = 90;\nconst pvProvocationEligible =\n parsed.soc >= PV_PROVOCATION_SOC_MIN &&\n batteryOkForPotential;\n\nconst isHighBatteryForPotential =\n pvProvocationEligible ||\n (\n parsed.soc >= 85 &&\n forecastBufferKwh > 0 &&\n cloudDebtOkForPotential &&\n batteryOkForPotential\n );\n\nconst potentialAllowed = userStrategy !== 'auto_solar';\n\n// IMPORTANT:\n// On impose un vrai PV > 0 pour le test sur pvPowerW.\n// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.\nconst strongSolarSignal =\n (\n parsed.pvPowerW > 0 &&\n parsed.pvPowerW >= effectivePvStartMinW\n ) ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW ||\n (departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);\n\n// =====================================================\n// SOURCE MODE\n// SÉMANTIQUE CORRIGÉE:\n// - force_charge_now bypass les fenêtres\n// - mais ne devient jamais grid_forced à lui seul\n// - outside_window = pas de réseau programmé\n// - la charge locale peut rester autorisée\n// =====================================================\nlet sourceMode = 'blocked';\n\nif (isStopMode) {\n sourceMode = 'blocked';\n}\n\nelse if (forceGridRequested) {\n // Demande explicite de réseau.\n sourceMode = 'grid_forced';\n}\n\nelse if (forceChargeRequested) {\n // IMPORTANT:\n // force_charge_now = intention de charger maintenant,\n // sans exiger le réseau.\n\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (nightSessionActive && batteryAssistAllowed) {\n sourceMode = 'battery_assist_night';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // Fallback manuel local :\n // on n'impose pas le grid, on laisse l'étage aval décider\n // selon les garde-fous physiques / batterie.\n sourceMode = 'solar_potential';\n }\n}\n\nelse if (profile === 'night_hc') {\n if (nightSessionActive) {\n sourceMode = nightPreferredSourceMode;\n } else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {\n // Session de midi engagee et contacteur ferme : la voiture charge sur le\n // reseau avec la batterie (« batterie et voiture ensemble », 28/09), meme au\n // soleil. La consigne garde son credit solaire (HA_SERVICE_CALLS_OUTPUT) :\n // elle ne preleve au reseau que ce que les chargeurs ne donnent pas.\n if (profile === 'midday_hc_or_solar' && nightGridSessionCommitted && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'evening_high_soc') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'outside_window') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (afternoonProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // IMPORTANT:\n // hors fenêtre = pas de réseau programmé.\n // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.\n sourceMode = 'blocked';\n }\n}\n\n// P9 : le réseau avant départ prend le pas sur les modes spéculatifs du jour\n// (blocked / solar_potential / hybrid_probe), mais JAMAIS sur le solaire réel\n// (solar_only), ni sur stop / force_* (déjà traités en tête de cascade).\nif (\n departureUrgentActive &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n profile !== 'night_hc' &&\n sourceMode !== 'solar_only'\n) {\n sourceMode = 'grid_hc_allowed';\n}\n\n// P9 : véhicule plein pendant la fenêtre départ → aucune source.\nif (departureChargeDone && profile !== 'night_hc' && !isStopMode && !forceGridRequested && !forceChargeRequested) {\n sourceMode = 'blocked';\n}\n\n// P10 : véhicule plein en journée (mémoire collante) → aucune source,\n// y compris solaire (charger un véhicule plein ne sert à rien).\nif (dayVehicleFullIdle && profile !== 'night_hc') {\n sourceMode = 'blocked';\n}\n\n// =====================================================\n// PLAFOND DE COURANT EVCS — calculé APRÈS sourceMode (31/08/2026)\n// =====================================================\n// P5 : plafond majoré (input_number.evcs_plafond_courant_edf_on, jusqu'à 28 A\n// en BOOST) quand le réseau alimente réellement AC-IN ; sinon plafond câblé\n// prudent, celui que les deux MultiPlus peuvent porter SEULS.\n//\n// SÉQUENCE D'OUVERTURE (31/08/2026) — pourquoi ce bloc a été déplacé ici.\n// Le plafond majoré n'est légitime que tant que le réseau est là. Or c'est\n// l'hystérésis (cycle 1 min) qui ouvre le contacteur, et le contrôleur EVCS ne\n// l'apprenait qu'après coup : pendant ce temps les deux onduleurs portaient\n// seuls la voiture au courant majoré plus la maison — le mécanisme du W-105.\n//\n// Correctif sans poignée de main entre les deux automates : le plafond majoré\n// exige désormais que l'EVCS DEMANDE lui-même le réseau. Sa demande tombe au\n// cycle où la fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir\n// qu'ensuite (override disparu ET min_on écoulé, cycle 1 min). L'EVCS baisse\n// donc AVANT, mécaniquement, sans rien coordonner.\n// Le calcul doit venir après sourceMode ; rien entre les deux ne l'utilisait.\nconst evcsGridCeilingAllowed =\n parsed.acInAvailable &&\n (\n sourceMode === 'grid_forced' ||\n sourceMode === 'grid_hc_allowed' ||\n forceGridRequested\n );\n\nconst evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);\nconst safeEvcsByDedicatedCapW = evcsGridCeilingAllowed\n ? Math.max(0, params.gridCurrentMaxA * acVoltageRef)\n : evcsDedicatedCapBaseW;\nconst safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);\nconst safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));\n\nconst systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));\nconst physicalCapA = clamp(\n systemCapCurrentA,\n 0,\n evcsGridCeilingAllowed\n ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)\n : params.currentMaxAutoA\n);\nconst canRunAtMinCurrent = physicalCapA >= params.currentMinA;\n\nconst operatingMode = profile;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n strategy: {\n userStrategy,\n isStopMode,\n isForceMode,\n forceGridRequested,\n forceChargeRequested,\n },\n\n lifecycle: {\n minutesSinceStart,\n minutesSinceStop,\n secondsSinceCurrentChange,\n minutesSinceProbeStart,\n minRunElapsed,\n minStopElapsed,\n currentCooldownElapsed,\n minutesUntilDeparture,\n },\n\n windows: {\n inNightHcWindow,\n inMorningWindow,\n inMiddayWindow,\n inEveningWindow,\n // Temps restant dans la fenetre d'HEURES CREUSES courante (nuit ou midi),\n // null hors de toute fenetre creuse. Consomme par le dimensionnement de\n // la consigne reseau : recharger la batterie sur le reseau n'a de sens\n // economique que la-dedans, donc `null` y vaut « pas de part batterie »\n // sans test supplementaire.\n minutesRemainingInHcWindow:\n minutesRemainingInWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin)\n ?? minutesRemainingInWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin),\n },\n\n derived: {\n acVoltageRef,\n batteryVoltageRef,\n houseNonEvcsW,\n vebusNonEvcsW,\n totalVebusSoftCeilingW,\n totalVebusHardCeilingW,\n safeEvcsByDedicatedCapW,\n safeEvcsByVebusW,\n safeEvcsCombinedW,\n systemCapCurrentA,\n physicalCapA,\n canRunAtMinCurrent,\n evcsGridCeilingAllowed,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n endDayGuardSoc,\n energyNeededForEndDayGuardKwh,\n forecastBufferKwh,\n projectedEndSocAfterCharge,\n currentReferenceA,\n anyVebusOverload,\n anyVebusLowBattery,\n vebusAcOutSoftReached,\n vebusAcOutHardReached,\n vebusDcVoltageSoftLow,\n vebusDcVoltageHardLow,\n houseLoadHighVsHeadroom,\n },\n\n context: {\n departurePriorityActive,\n forceChargeDepartureGridOverride,\n manualChargeBypassWindows,\n manualChargeLocalRequested,\n morningProbeEligible,\n middayProbeEligible,\n afternoonProbeEligible,\n pvProvocationEligible,\n isHighBatteryForPotential,\n strongSolarSignal,\n effectivePvStartMinW,\n profile,\n operatingMode,\n sourceMode,\n },\n\n // R1 : source de vérité UNIQUE du moteur nuit.\n // BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien.\n nightEvcsPreview: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n batteryMorningTargetBasePct,\n balanceMorningTargetApplied,\n nightBatterySurplusFloorPct,\n nightBatterySurplusAvailable,\n nightBatterySurplusExhausted,\n nightBudgetKwh: nightBudget ? nightBudget.budgetKwh : null,\n nightBudgetDeliveredKwh,\n nightBudgetReached,\n nightBudgetToWrite,\n nightChargeMode: params.nightChargeMode,\n nightCarWaitsNoGrid,\n nightBatteryGridOnly,\n nightGridSessionCommitted,\n middayVehicleGridNeeded,\n nightGridSessionUncovered,\n balanceTargetAgeMin:\n balanceTargetAgeMin === null ? null : Math.round(balanceTargetAgeMin * 10) / 10,\n batteryMorningTargetRulePct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n nightGridHcPreferred,\n vehicleChargeCompleted,\n nightRecoveryDone,\n nightPreferredSourceMode,\n\n // P9\n departureUrgentActive,\n departureChargeDone,\n\n // P10\n dayVehicleFullIdle,\n\n // P11\n sessionFresh,\n chargedSessionEnergyKwh,\n chargedSessionEstablished,\n chargedCredible,\n }\n }\n}];\n" + "jsCode": "function minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction secondsSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 1000);\n}\n\nfunction isWithinWindow(nowMin, startMin, stopMin) {\n if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;\n if (startMin === stopMin) return false;\n if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;\n return nowMin >= startMin || nowMin < stopMin;\n}\n\n// Temps restant dans une fenetre, en minutes, ou null si on n'y est pas.\n//\n// ⚠️ IL VIT ICI, A COTE DE `isWithinWindow`, ET PAS DANS LE NOEUD QUI L'UTILISE.\n// Ce qui serait duplique ailleurs n'est pas l'analyse des heures — INPUT_PARSE\n// les a deja converties en minutes — c'est la LOGIQUE D'APPARTENANCE, et en\n// particulier le traitement du passage de minuit. Une seconde interpretation\n// divergerait le jour ou une borne bouge, et elles ont deja bouge une fois\n// (01:30 -> 01:24 le 09/07/2026).\nfunction minutesRemainingInWindow(nowMin, startMin, stopMin) {\n if (!isWithinWindow(nowMin, startMin, stopMin)) return null;\n if (startMin < stopMin) return stopMin - nowMin;\n // Fenetre a cheval sur minuit : avant minuit il reste la fin de la nuit EN\n // PLUS du reste de la journee. Atteignable par les valeurs de repli\n // d'INPUT_PARSE (23:00 -> 06:00) si les helpers d'horaire sont illisibles.\n return nowMin >= startMin ? stopMin + 1440 - nowMin : stopMin - nowMin;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nconst prev = $input.first().json;\nconst {\n input,\n nowMs,\n now,\n cfg,\n parsed,\n params,\n history,\n batteryTune\n} = prev;\n\n// =====================================================\n// STRATEGY\n// =====================================================\nconst userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();\n\nconst isStopMode = userStrategy === 'stop';\nconst forceGridRequested = userStrategy === 'force_grid_now';\nconst forceChargeRequested = userStrategy === 'force_charge_now';\nconst isForceMode = forceGridRequested || forceChargeRequested;\n\n// =====================================================\n// LIFECYCLE / TIMING\n// =====================================================\nconst minutesSinceStart = minutesSince(history.lastStartMs, nowMs);\nconst minutesSinceStop = minutesSince(history.lastStopMs, nowMs);\nconst secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);\nconst minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);\n\nconst minRunElapsed =\n minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;\n\nconst minStopElapsed =\n minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;\n\nconst currentCooldownBaseSec = parsed.evcsChargingSwitchOn\n ? batteryTune.global.stepDownCooldownSeconds\n : batteryTune.global.stepUpCooldownSeconds;\n\nconst currentCooldownElapsed =\n secondsSinceCurrentChange === null\n ? true\n : secondsSinceCurrentChange >= Math.max(\n params.currentChangeCooldownSeconds,\n currentCooldownBaseSec\n );\n\n// =====================================================\n// DEPARTURE PRIORITY\n// =====================================================\nconst minutesUntilDeparture =\n params.departureTimeMin === null\n ? null\n : (() => {\n const rawDiff = params.departureTimeMin - now.minNow;\n return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);\n })();\n\nconst departurePriorityActive =\n parsed.departurePriorityEnabled &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&\n parsed.evcsVehicleConnected;\n\n// =====================================================\n// MANUAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge,\n// MAIS PAS demande implicite de réseau.\n// =====================================================\nconst manualChargeBypassWindows = forceChargeRequested;\nconst manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;\n\n// Compatibilité ascendante : on garde le champ dans context,\n// mais on le neutralise volontairement.\nconst forceChargeDepartureGridOverride = false;\n\n// =====================================================\n// SYSTEM DERIVED VALUES\n// =====================================================\nconst acVoltageRef =\n Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180\n ? parsed.vebusAcOutVoltageV\n : cfg.nominalAcVoltageV;\n\nconst batteryVoltageRef =\n Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0\n ? parsed.vebusDcVoltageV\n : 51.2;\n\nconst houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);\nconst vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);\n\nconst totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);\nconst totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;\n\n// =====================================================\n// FENETRE D'ETABLISSEMENT DU RESEAU (31/08/2026)\n// =====================================================\n// Mesure du 31/08 : a la fermeture du contacteur, le reseau reprend d'un coup\n// TOUTE la charge de la maison et le solaire bascule vers la batterie. Batterie\n// preneuse, la production ne bouge pas ; batterie pleine, elle s'effondre (2663\n// -> 559 W) faute de destination. Dans les deux cas l'import decroit ensuite\n// jusqu'a la consigne en ~150 s (1985 -> 25 W, puis 2600 -> 52 W).\n//\n// Pendant cette phase, PV, import et courant batterie ne decrivent aucun regime\n// permanent : la boucle de 30 s y prend cinq decisions, et la moyenne PV 10 min\n// en garde la trace une dizaine de minutes de plus. Le systeme lit \"plus de\n// soleil\", en conclut qu'il a besoin du reseau, et confirme ainsi sa propre\n// premisse -- une boucle de rattrapage, comme les trois du 24/08.\n//\n// ASYMETRIE VOULUE : la fenetre ne s'ouvre qu'a l'ARRIVEE du reseau. A sa\n// disparition, les onduleurs reprennent instantanement toute la charge : les\n// gardes doivent agir plus vite, jamais moins vite.\nconst gridSettling =\n parsed.gridAvailable &&\n parsed.gridTransitionAgeSec < params.gridSettleSeconds;\n\nconst endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);\n\nconst energyNeededForEndDayGuardKwh = Math.max(\n 0,\n ((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n);\n\nconst forecastBufferKwh =\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh -\n energyNeededForEndDayGuardKwh;\n\nconst projectedEndSocAfterCharge = clamp(\n parsed.soc + (\n (\n parsed.todayRemainingKwh -\n params.baseLoadRemainingKwh -\n params.flexibleSafetyMarginKwh -\n params.requiredEnergyKwh\n ) / cfg.batteryUsableCapacityKwh\n ) * 100,\n 0,\n 100\n);\n\nconst currentReferenceA = clamp(\n Number.isFinite(parsed.evcsCommandCurrentA)\n ? parsed.evcsCommandCurrentA\n : Number.isFinite(parsed.evcsSetCurrentA)\n ? parsed.evcsSetCurrentA\n : params.targetCurrentAutoA,\n 0,\n 32\n);\n\nconst anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;\nconst anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;\n\nconst vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;\nconst vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;\n\nconst vebusDcVoltageSoftLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;\n\nconst vebusDcVoltageHardLow =\n Number.isFinite(parsed.vebusDcVoltageV) &&\n parsed.vebusDcVoltageV > 0 &&\n parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;\n\nconst houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;\n\n// =====================================================\n// WINDOWS / PROFILE\n// =====================================================\nconst inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);\nconst inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);\nconst inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);\nconst inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);\n\nconst profile =\n inNightHcWindow ? 'night_hc' :\n inMorningWindow ? 'morning_solar' :\n inMiddayWindow ? 'midday_hc_or_solar' :\n inEveningWindow ? 'evening_high_soc' :\n 'outside_window';\n\n// =====================================================\n// EVCS NIGHT PREVIEW ENGINE\n// =====================================================\nconst nightSessionActive =\n profile === 'night_hc' &&\n parsed.evcsVehicleConnected;\n\nconst nightSessionStartSocPct =\n nightSessionActive\n ? (\n Number.isFinite(history.nightSessionStartSocPct)\n ? history.nightSessionStartSocPct\n : parsed.soc\n )\n : null;\n\nconst nightSessionStartAt =\n nightSessionActive\n ? (history.nightSessionStartAt || '')\n : '';\n\nconst nightSessionStartMs =\n nightSessionActive\n ? (history.nightSessionStartMs ?? null)\n : null;\n\nconst nightSessionNeedsCapture =\n nightSessionActive &&\n (\n !Number.isFinite(history.nightSessionStartSocPct) ||\n !nightSessionStartAt ||\n nightSessionStartMs === null\n );\n\nconst nightSessionNeedsClear =\n !nightSessionActive &&\n (\n Number.isFinite(history.nightSessionStartSocPct) ||\n !!history.nightSessionStartAt\n );\n\nconst batteryHardFloorPct =\n params.evcsNightBatteryHardFloorPct;\n\n// P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain.\nconst nightForecastHour = now.hour;\nconst nightForecastKwh =\n (nightForecastHour >= 20 || nightForecastHour < 8)\n ? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0)\n : parsed.todayRemainingKwh;\n\nconst batteryMorningTargetRulePct =\n nightForecastKwh >= params.evcsNightStrongForecastKwh\n ? params.evcsNightTargetStrongForecastPct\n : nightForecastKwh >= params.evcsNightMediumForecastKwh\n ? params.evcsNightTargetMediumForecastPct\n : params.evcsNightTargetWeakForecastPct;\n\n// =====================================================\n// LE CONSEILLER NE FIXE PLUS LA CIBLE (retire le 13/09/2026)\n// =====================================================\n// Le premier cablage reel (09/08/2026, HANDOFF §20) faisait suivre a la cible\n// du matin `input_number.evcs_advisor_night_target_soc`, derriere un toggle,\n// un statut `ok`, une fraicheur de 120 min et un bornage.\n// Le 12/09 le conseiller est devenu OBSERVATEUR : il n'emet plus de cible, et\n// ce helper est fige depuis. Les quatre gardes continuaient pourtant de passer\n// toutes les quatre — la garde de fraicheur date le RUN, pas la VALEUR, et le\n// conseiller tourne toujours. Activer le toggle aurait donc applique un chiffre\n// perime sans qu'aucune garde ne s'y oppose. Un cablage qui ne peut plus que se\n// tromper se retire ; le scenario `advisor-cible-ignoree` le verifie.\nconst batteryMorningTargetBasePct = batteryMorningTargetRulePct;\n\n// =====================================================\n// CIBLE DU MATIN : UNE ENERGIE A COUVRIR, PAS UN PALIER (03/09/2026)\n// =====================================================\n// La regle historique classe la journee du lendemain en trois paliers\n// (>= 18 kWh -> 35 %, >= 10 -> 55 %, sinon 85 %) a partir de son TOTAL. Elle\n// est donc aveugle a l'heure d'arrivee de l'energie : le 03/09, demain\n// annoncait 33,9 kWh — « fort », donc 35 % — mais avec un matin creux\n// (0,03 kWh a 7 h, 0,77 a 8 h, 1,81 a 9 h) et un croisement a 9 h.\n//\n// C'est le meme defaut que le plancher du soir (cf. le bloc BILAN de\n// BATTERY_CLOUD_LOCKS), a l'autre bout de la nuit : un palier fige contre une\n// energie mesurable. Le bilan publie la seconde, instant par instant.\n//\n// RELEVE DE LA NUIT 02-03/09, qui a motive ce cablage :\n//\n// heure requis SOC reel cible fixe\n// 00:30 62,4 53 35\n// 03:00 50,4 45 35 <- EDF relache ici, 5 points sous la ligne\n// 04:30 41,7 37 35\n// 06:00 37,1 22 35 <- hysteresis, puis reseau PLEIN TARIF\n// de 07:24 a 10:33 (1,49 kWh)\n//\n// La batterie est restee sous la ligne toute la nuit et le bilan le savait des\n// 00:30. Le `required_soc` decroit proprement avec la nuit qui se consomme\n// (6,1 -> 0 kWh a couvrir) : c'est bien une cible instantanee, pas une\n// constante deguisee.\n//\n// SENS UNIQUE, comme pour le plancher du soir : le bilan ne peut que RELEVER\n// la cible, jamais l'abaisser. La direction corrigee est celle de l'echec\n// observe (cible trop basse -> batterie a plat avant le croisement -> reseau\n// au plein tarif le matin). L'abaisser — la regle dit 85 % l'hiver quand le\n// bilan dira parfois moins — ouvrirait un risque neuf que rien n'a encore\n// valide. Le principe unificateur : le bilan ne pousse JAMAIS dans le sens\n// « couvre moins bien la nuit ».\n//\n// PLAFOND 90 % : au-dela, la batterie cesse d'etre un puits et la production\n// du matin se fait evincer (cf. mesures du 31/08). Charger au reseau au-dela\n// de 90 echangerait un kWh bon marche cette nuit contre plusieurs kWh censures\n// demain. C'est deja la borne du chemin advisor : aucune constante nouvelle.\n//\n// PAS D'OSCILLATEUR : `required_soc` decroit de facon monotone pendant la nuit\n// (l'energie restant a couvrir ne fait que diminuer), et la passe unique de\n// recovery (`nightRecoveryDonePrev`, 27/08) reste en place.\n//\n// REPLI identique au plancher du soir : fraicheur, statut, valeur finie —\n// sinon regle historique. Kill-switch existant : `evcs_balance_enabled` OFF\n// fige `balance_last_run` et la cible retombe seule sur les paliers.\nconst BALANCE_TARGET_MAX_AGE_MIN = 30;\nconst balanceTargetAgeMin = minutesSince(params.balanceLastRunMs, nowMs);\nconst balanceMorningTargetApplied =\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct) &&\n balanceTargetAgeMin !== null &&\n balanceTargetAgeMin <= BALANCE_TARGET_MAX_AGE_MIN;\n\n// ⚠️ 01/10/2026 — DECISION DU PROPRIETAIRE : LE BILAN FIXE LA CIBLE, DANS LES\n// DEUX SENS. « La valeur de 35 % c'est idiot si le besoin est prevu 26,6 %, et\n// c'est aussi idiot si la prevision est de 60 % pour une journee nuageuse. »\n// Le sens unique ci-dessus (03/09) gardait le palier comme plancher : le\n// 01/10 a 06:00, cible 35 (palier « forte prevision ») pour un besoin du bilan\n// de 28,6 %. Le palier n'est plus qu'un REPLI quand le bilan est absent,\n// perime ou degrade. Bornes : le plancher dur en bas, 90 % en haut.\nconst batteryMorningTargetPct = balanceMorningTargetApplied\n ? Math.min(90, Math.max(batteryHardFloorPct, params.balanceRequiredSocPct))\n : batteryMorningTargetBasePct;\n\nconst maxBatteryDischargePoints =\n nightSessionActive && Number.isFinite(nightSessionStartSocPct)\n ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)\n : 0;\n\nconst gridTakeoverTriggerPct =\n Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor =\n parsed.soc <= batteryHardFloorPct;\n\nconst batteryBelowMorningTarget =\n parsed.soc < batteryMorningTargetPct;\n\nconst batteryAtOrBelowGridTakeoverTrigger =\n parsed.soc <= gridTakeoverTriggerPct;\n\nconst batteryKwhMissingToTarget =\n nightSessionActive\n ? Math.max(\n 0,\n ((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh\n )\n : 0;\n\nconst batteryRecoveryMinutesNeeded =\n nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0\n ? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60\n : 0;\n\n// 01/10/2026 : l'urgence avant depart exige qu'il MANQUE de l'energie. Sans\n// cette garde, « 0 min de recharge + 30 min de marge >= 30 min » la declenchait\n// a 06:00 chaque jour de depart, batterie au-dessus de sa cible (01/10 : SOC 37,\n// cible 35, EDF ferme 06:00-06:38 pour rien ; idem 21, 22, 25 et 27/09).\nconst batteryRecoveryUrgent =\n nightSessionActive &&\n batteryKwhMissingToTarget > 0 &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n (\n batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin\n >= minutesUntilDeparture\n );\n\n// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne.\n// Une fois le réseau HC engagé pendant une session nuit, on y reste\n// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine.\n// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée).\nconst NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90;\nconst nightTakeoverPrev =\n (input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\n// P8 (07/08/2026) : \"charge terminée\" COLLANT via l'état surplus du cycle\n// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot).\n// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume\n// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au\n// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit.\nconst prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase();\n\n// P11 (12/08/2026) : RESET du collant \"véhicule plein\" au CHANGEMENT DE\n// VÉHICULE. Incident 12/08 : JEEP chargée → night_recovery_done_idle collant ;\n// débranchement JEEP + branchement VW → l'état collant a survécu et bloquait\n// la VW (« charge terminée » = celle de la JEEP, pas de la VW).\n// Signal fiable : sensor.victron_evcs_charging_time repart de 0 à chaque\n// nouvelle connexion physique. Sous ~90 s → session neuve → on neutralise le\n// collant pour laisser le système RETESTER le véhicule fraîchement branché.\nconst P11_FRESH_SESSION_MAX_SEC = 90;\n// P11-bis (19/09/2026) : le charging_time de la borne repart de 0 sur un\n// debranchement FRANC, mais reste FIGE sur un swap trop rapide (14 s le 19/09 :\n// VW debranchee, JEEP branchee, borne gelee a l'etat VW). P11 restait alors\n// aveugle et le collant \"vehicule plein\" de la VW bloquait la JEEP. Le\n// last_changed du capteur de connexion, lui, a bien bouge sur ce swap : c'est\n// un SECOND signal de fraicheur, independant de la borne. Meme fenetre (90 s =\n// 3 cycles de 30 s, assez pour capter un (re)branchement entre deux cycles).\n// Cout d'un faux positif (glitch MQTT sub-seconde) : au pire un retest, que le\n// statut 3 / VDF re-latche aussitot si le vehicule est vraiment plein.\nconst sessionFresh =\n parsed.evcsVehicleConnected &&\n (\n params.resetBorneRequested || // echappatoire manuelle (bouton reset borne)\n (Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec <= P11_FRESH_SESSION_MAX_SEC) ||\n (Number.isFinite(parsed.evcsVehicleConnectedAgeSec) &&\n parsed.evcsVehicleConnectedAgeSec <= P11_FRESH_SESSION_MAX_SEC)\n );\n\n// Fix D (08/08/2026) : le statut natif EVCS \"charged\" (3) vaut aussi fin de\n// charge, sans attendre le VehicleDemandFilter (désarmé si la session a été\n// stoppée par une garde, ex battery_current_hard_stop → détection tardive\n// observée la nuit du 07-08/08).\n// Fix E (09/08/2026) : garde puissance ≤ 300 W pour ne jamais latcher \"fini\"\n// sur un glitch de statut pendant une vraie charge. NB : le statut retombe\n// (3→1/6) dès l'ouverture du relais → la persistance est assurée par l'état\n// collant écrit par DECISION pendant la session (voir Fix E DECISION).\n// P11 : sur une session neuve, on ignore aussi un éventuel statut 3 résiduel\n// (le temps que la borne renégocie avec le nouveau véhicule).\n// Fix H (07/09/2026) : un statut \"charged\" n'est pas cru sur parole.\n// La borne l'a annonce le 07/09 a 14:28:18 apres 2 min 42 s de charge et\n// 0,10 kWh livre, ce qui a latche `day_vehicle_full_idle` pour deux heures\n// alors que le vehicule pouvait charger (il a repris a 16:30 et tenu).\n//\n// Aucun signal ELECTRIQUE instantane ne separe une vraie fin de charge d'une\n// interruption : mesure a la seconde des deux coupures, elles sont\n// superposables (VW 06/09, vraie fin : 1534 -> 550 -> 82 -> 11 -> 0 W ;\n// JEEP 07/09, faux positif : 1626 -> 741 -> 100 -> 13 -> 0 W). Seule la\n// DUREE de la session les distingue, et le systeme porte deja la grandeur qui\n// dit ce qu'est une charge etablie : min_run_minutes (29 min en production).\n//\n// Deux formes de \"charged\" sont credibles, et une seule ne l'est pas :\n// energie de session nulle -> le vehicule etait plein en arrivant (P10)\n// session >= min_run_minutes -> charge menee a son terme\n// entre les deux -> charge INTERROMPUE : on ne latche pas\n// Convergence : une session interrompue redemarre a energie nulle, donc un\n// second \"charged\" immediat retombe dans le premier cas et latche.\nconst chargedSessionEnergyKwh = Math.max(0, parsed.evcsSessionEnergyKwh || 0);\nconst chargedSessionEstablished =\n Number.isFinite(parsed.evcsChargingTimeSec) &&\n parsed.evcsChargingTimeSec >= params.minRunMinutes * 60;\nconst chargedCredible =\n chargedSessionEnergyKwh <= 0 || chargedSessionEstablished;\n\nconst evcsStatusCharged =\n !sessionFresh &&\n Number.parseFloat(parsed.evcsStatusRaw) === 3 &&\n Math.max(0, parsed.evcsAcPowerW || 0) <= 300 &&\n chargedCredible;\n\nconst vehicleChargeCompleted =\n nightSessionActive &&\n !sessionFresh &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'day_vehicle_full_idle' ||\n evcsStatusCharged\n );\n\n// Fix A (08/08/2026) : recovery batterie post-charge = UNE SEULE passe.\n// Sans flag collant, SOC==cible est un point dégénéré (engage soc<=cible,\n// release soc>cible, l'ACM recharge pile à la cible) → rebond EDF ON/OFF\n// période ~10 min observé la nuit du 07-08/08. Une fois la cible atteinte,\n// l'état 'night_recovery_done_idle' (auto-entretenu via prev_surplus_state)\n// coupe le takeover. Ré-engagement uniquement sous cible − 2 pts (bande),\n// hard floor et fenêtre/débranchement restent prioritaires.\n// ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de\n// l'hystérésis (full_or_paused / not_requesting_power / charge_complete /\n// no_demand) — même contrainte que 'night_session_done_idle'.\n// 27/08/2026 : la bande de 2 points fabriquait un oscillateur. Relevé de la\n// nuit du 26-27/08 (cible 35) : engagement à SOC 33, relâchement à 35, trois\n// cycles EDF de 52, 79 et 25 min, 2,5 kWh importés pour rien un matin de grand\n// soleil ; la nuit du 20-21/08 en comptait six. Deux points de SOC valent\n// ~0,34 kWh, soit une demi-heure de conso nocturne : ce n'est pas une\n// hystérésis, c'est la période d'une horloge — d'autant plus régulière que la\n// charge de nuit est parfaitement plate.\n// La recovery batterie post-charge est donc UNE SEULE passe par session de\n// nuit, ce qui était déjà l'intention affichée du Fix A (\"relâché\n// DÉFINITIVEMENT\"). Aucun seuil de ré-engagement ici : le plancher dur reste\n// un terme indépendant de nightGridTakeoverNeeded (voir plus bas), et\n// l'hystérésis réseau garde sa propre bande 20/30. Le vrai critère de\n// ré-engagement est un budget d'énergie restant à couvrir, pas un delta de\n// SOC : il appartient au bilan énergétique, pas à une constante de plus ici.\nconst nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle';\nconst nightRecoveryDone =\n vehicleChargeCompleted &&\n (parsed.soc >= batteryMorningTargetPct || nightRecoveryDonePrev);\n\n// =====================================================\n// P9 (09/08/2026) : CHARGE RÉSEAU AVANT DÉPART, HORS FENÊTRE HC.\n// Cas VW : départ 08:50 > fin HC 07:24 → fenêtre morte où le véhicule\n// sous-chargé attendait un solaire insuffisant (observé le 09/08 : advisor\n// force_grid_now, divergence n°10). Si le toggle est ON et que le départ du\n// véhicule SÉLECTIONNÉ est dans ≤ 120 min : réseau autorisé (grid_hc_allowed)\n// jusqu'à fin de charge. L'arrêt réutilise TOUTE la mécanique anti-rebond :\n// VDF → vehicle_full_or_paused → état collant 'departure_done_idle'\n// (réécrit chaque cycle, patterns hystérésis sûrs, coupure immédiate E2).\n// =====================================================\nconst P9_DEPARTURE_LEAD_MIN = 120;\n\nconst departureWindowActive =\n params.allowGridDepartureUrgent &&\n parsed.evcsVehicleConnected &&\n !inNightHcWindow &&\n Number.isFinite(minutesUntilDeparture) &&\n minutesUntilDeparture >= 0 &&\n minutesUntilDeparture <= P9_DEPARTURE_LEAD_MIN;\n\nconst departureDonePrev =\n prevSurplusState === 'departure_done_idle' ||\n (!inNightHcWindow && prevSurplusState === 'vehicle_full_or_paused');\n\nconst departureChargeDone =\n departureWindowActive &&\n !sessionFresh &&\n (departureDonePrev || evcsStatusCharged);\n\nconst departureUrgentActive =\n departureWindowActive &&\n !departureChargeDone;\n\n// =====================================================\n// P10 (09/08/2026) : MÉMOIRE \"VÉHICULE PLEIN\" EN JOURNÉE.\n// Observé le 09/08 09:49 : VW pleine branchée depuis 07:00, le surplus\n// solaire retentait une charge (statut \"Charged\", 0 W) toutes les ~15 min\n// toute la journée (usure relais, bruit). Les états collants P8/P9 étaient\n// scopés nuit/fenêtre départ. Ici : une fois le véhicule vu plein (statut 3\n// ou VDF), état collant 'day_vehicle_full_idle' jusqu'au DÉBRANCHEMENT.\n// - hérite des états \"done\" de la nuit (07:24 → pas de re-tentative) ;\n// - la nuit hérite en retour (vehicleChargeCompleted inclut day_*) ;\n// - échappatoires : force_charge_now / force_grid_now / débrancher-rebrancher.\n// =====================================================\nconst dayVehicleFullPrev =\n prevSurplusState === 'day_vehicle_full_idle' ||\n (\n !inNightHcWindow &&\n (\n prevSurplusState === 'vehicle_full_or_paused' ||\n prevSurplusState === 'night_recovery_done_idle' ||\n prevSurplusState === 'night_session_done_idle' ||\n prevSurplusState === 'departure_done_idle'\n )\n );\n\nconst dayVehicleFullIdle =\n parsed.evcsVehicleConnected &&\n !sessionFresh &&\n !inNightHcWindow &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n (dayVehicleFullPrev || evcsStatusCharged);\n\n// P8 : correction du fix 04/08 (logique inversée : \"|| vehicleChargeCompleted\"\n// GARDAIT le latch après la charge au lieu de le libérer).\nconst nightTakeoverLatched =\n nightSessionActive &&\n nightTakeoverPrev &&\n parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT &&\n !vehicleChargeCompleted;\n\n// =====================================================\n// P12 — CHARGE NUIT SUR LA BATTERIE (15/09/2026)\n// =====================================================\n// Idee du proprietaire : plutot qu'acheter du reseau la nuit pour charger la\n// voiture, lui donner le solaire DEJA STOCKE, doucement, et laisser ainsi de la\n// place dans la batterie pour la production du lendemain. Payer du reseau la\n// nuit pour ecreter le solaire le lendemain, c'est perdre deux fois.\n//\n// Le mode `battery_assist_night` existe depuis toujours pour exactement ca, et\n// il est MORT depuis toujours : il exige `batteryCurrentA >= plancher de\n// decharge` (0 A), impossible la nuit puisque la maison tire sur la batterie\n// (§11 du HANDOFF, ~ -14 A). On ne cree donc pas un chemin, on rend viable\n// celui qui existe — en remplacant un test de COURANT impossible par un test\n// d'ENERGIE : rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement.\n//\n// Le plancher n'est PAS une constante nouvelle : c'est `required_soc_prudent`,\n// que le bilan publie deja, en profil p80. La marge est mesuree, pas inventee.\nconst nightBatterySurplusFloorPct = Math.max(\n batteryHardFloorPct,\n Number.isFinite(params.balanceRequiredSocPrudentPct)\n ? params.balanceRequiredSocPrudentPct\n : Infinity,\n);\n\n// ⚠️ LE VERROU, ET IL N'EST PAS DECORATIF. Le plancher DESCEND pendant la nuit\n// (il reste moins de nuit a couvrir) et le SOC aussi. Mesure du 15/09 :\n//\n// voiture en charge : SOC -7,1 pt/h, plancher -3,7 pt/h -> le surplus s'epuise\n// voiture arretee : SOC -2,6 pt/h, plancher -3,7 pt/h -> le surplus REVIENT\n//\n// Sans verrou, la charge repartirait des qu'elle s'arrete : cinquieme\n// oscillateur du projet, par construction. UNE SEULE PASSE de surplus par\n// session de nuit, comme la recovery batterie depuis le 27/08.\n// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire).\n// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la\n// charge a cette valeur ou autoriser une charge complete avec EDF. »\n// Le bilan publie le budget ; il est FIGE au premier cycle de la session\n// (avec l'index du compteur de la borne) dans input_text\n// victron_evcs_night_budget = « budget|index », efface en fin de session.\n// L'arret se juge sur le COMPTEUR, qui ne fait que monter : condition\n// monotone, aucun oscillateur possible (meme logique que §40). Le plancher de\n// SOC prudent reste un second arret, jamais retire.\nconst nightBudgetPrev = (() => {\n const m = String(params.nightBudgetPrevRaw || '').match(/^\\s*([\\d.]+)\\|([\\d.]+)\\s*$/);\n return m ? { budgetKwh: Number(m[1]), refKwh: Number(m[2]) } : null;\n})();\nconst nightBudgetCaptureOk =\n nightSessionActive && !nightBudgetPrev &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceNightVehicleBudgetKwh) &&\n Number.isFinite(params.evcsTotalEnergyKwh);\nconst nightBudget = !nightSessionActive\n ? null\n : nightBudgetPrev || (nightBudgetCaptureOk\n ? { budgetKwh: Math.max(0, params.balanceNightVehicleBudgetKwh), refKwh: params.evcsTotalEnergyKwh }\n : null);\nconst nightBudgetToWrite = nightSessionActive\n ? (nightBudgetCaptureOk ? `${Math.round(nightBudget.budgetKwh * 100) / 100}|${nightBudget.refKwh}` : null)\n : (nightBudgetPrev ? '' : null);\nconst nightBudgetDeliveredKwh = nightBudget && Number.isFinite(params.evcsTotalEnergyKwh)\n ? Math.max(0, params.evcsTotalEnergyKwh - nightBudget.refKwh)\n : null;\nconst nightBudgetReached =\n !!nightBudget && nightBudgetDeliveredKwh !== null &&\n nightBudgetDeliveredKwh >= nightBudget.budgetKwh - 0.05;\nconst nightChargeComplete = params.nightChargeMode === 'complete';\n\n// Le verrou ne vaut QUE pendant la session de nuit (01/10/2026). Ecrit sous la\n// forme `prev || …`, il restait vrai pour toujours une fois pose ; invisible\n// tant que son ecriture n'atteignait pas HA (jamais relayee du 15/09 au 01/10).\n// Fin de session (debranchement ou fin de fenetre HC) = verrou leve.\nconst nightBatterySurplusExhausted =\n nightSessionActive && (\n params.nightBatteryDonePrev ||\n parsed.soc <= nightBatterySurplusFloorPct ||\n nightBudgetReached\n );\n\nconst nightBatterySurplusAvailable =\n nightSessionActive &&\n params.nightBatteryChargeEnabled &&\n balanceMorningTargetApplied &&\n Number.isFinite(params.balanceRequiredSocPrudentPct) &&\n !vehicleChargeCompleted &&\n // Le plancher est teste UNE SEULE FOIS, dans `nightBatterySurplusExhausted`.\n // Le retester ici serait redondant — et une redondance ne se teste pas : la\n // mutation du 15/09 l'a montre en ne cassant aucun scenario.\n !nightBatterySurplusExhausted;\n\n// La batterie d'abord, le reseau quand le surplus est epuise. C'est la soupape\n// decrite par le proprietaire — « quitte a fermer EDF entre 6h et 7h24 pour\n// completer » — et elle devient automatique : l'epuisement du surplus rend\n// `nightGridHcPreferred` vrai, donc P7 prend le relais.\n// « Sans EDF » (defaut) : la voiture ne prend JAMAIS le reseau la nuit ; une\n// fois le budget batterie atteint (ou sans budget exploitable), elle attend le\n// soleil. « Charge complete » : EDF en HC finit la charge, comme avant.\nconst nightGridHcPreferred =\n nightSessionActive &&\n params.allowGridHcNight &&\n nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst nightCarWaitsNoGrid =\n nightSessionActive &&\n !nightChargeComplete &&\n !vehicleChargeCompleted &&\n !nightBatterySurplusAvailable;\n\nconst batteryAssistAllowed =\n nightSessionActive &&\n !nightGridHcPreferred &&\n !nightCarWaitsNoGrid &&\n parsed.soc > batteryHardFloorPct &&\n parsed.soc > gridTakeoverTriggerPct &&\n !nightTakeoverLatched &&\n !batteryRecoveryUrgent;\n\n// =====================================================\n// SESSION RESEAU UNIQUE DE LA NUIT (03/09/2026)\n// =====================================================\n// §26 avait pose l'objectif — « une session reseau unique et dimensionnee » —\n// et designe le bon critere : « un budget d'energie restant a couvrir, il\n// appartient au bilan energetique ». Le bilan est cable depuis ce soir.\n//\n// CE QUI A ETE MESURE AVANT D'ECRIRE CECI, et qui a dicte la conception :\n// la conversion kWh -> points de SOC du bilan est une constante unique\n// (15 kWh). La jauge reelle vaut ~17 kWh au-dessus de 42 % (0,164 a\n// 0,171 kWh/point sur trois fenetres independantes) mais s'effondre en\n// dessous : 12,1 kWh entre 45 et 22 %, 8,2 kWh entre 33 et 20 %. Un point de\n// SOC vaut donc MOITIE MOINS dans la bande ou la nuit se termine, et\n// `required_soc` y est systematiquement trop BAS — c'est pourquoi la batterie\n// est restee sous la ligne toute la nuit du 02-03/09, en descendant 1,75 fois\n// plus vite qu'elle.\n//\n// CONSEQUENCE DE CONCEPTION : le relachement ne doit PAS etre un seuil de SOC.\n// Il porterait sur une grandeur fausse, et tout seuil que le SOC repasse\n// ensuite rouvre l'oscillateur eteint le 27/08.\n//\n// Donc : ENGAGEMENT sur le bilan (batterie sous la cible), RELACHEMENT a la\n// FIN DE LA FENETRE. L'oscillation devient structurellement impossible : la\n// condition de sortie est monotone, une fenetre ne se ferme qu'une fois. Le\n// bit d'engagement est porte par\n// input_boolean.victron_evcs_night_grid_session_committed — sans lui le\n// systeme reevaluerait a chaque cycle et retomberait dans une bande.\n//\n// CE QUE CA FAIT CONCRETEMENT : l'ACM recharge jusqu'a la cible puis tient la\n// batterie a 2 A (evcs_takeover_hold) ; la maison finit la nuit sur le reseau\n// en HEURES CREUSES au lieu de vider la reserve, et la batterie arrive a\n// l'aube pres de sa cible, donc avec de la place pour le solaire du matin —\n// au lieu d'etre a 20 % avec le reseau encore ouvert a 10:33 (03/09).\n//\n// PORTEE : uniquement quand le bilan est exploitable, et uniquement pendant\n// une session nuit (fenetre HC + vehicule branche). Sinon, comportement\n// historique inchange. Le bit se nettoie tout seul a la fin de la session.\nconst nightGridSessionCommittedPrev =\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on';\n\nconst nightGridSessionUncovered =\n balanceMorningTargetApplied &&\n (batteryBelowMorningTarget || params.balanceEcsNuit);\n\n// ⚠️ SANS VEHICULE AUSSI (12/09/2026).\n// Nuit du 11-12/09, voiture rendue la veille : le bilan a reclame entre 92 %\n// et 59 % de SOC a CHAQUE calcul de la nuit, `reseau_necessaire` vrai partout,\n// et rien ne s'est passe. SOC bloque a 20 % de 23:43 a l'aube, consigne reseau\n// figee a 700 W depuis 18:36 — soit exactement la maison. L'hysteresis avait\n// bien ferme le contacteur (SOC <= 20), mais fermer le contacteur ne suffit\n// pas : il faut DEMANDER la puissance (lecon du 01/09). Personne ne la\n// demandait, parce que la session exigeait un vehicule branche.\n//\n// Or le besoin n'a rien a voir avec la voiture : c'est la maison qui doit\n// traverser la nuit, et le bilan sait exactement ce qu'il lui faut. La\n// fenetre HC suffit. Le relachement reste la FIN DE FENETRE, donc monotone,\n// donc toujours immune a l'oscillation (§40).\n//\n// Sans cette correction, le cout se paye le matin : a 07:24 le SOC est encore\n// sous le seuil OFF de l'hysteresis (30), donc le contacteur reste ferme et\n// la maison passe au PLEIN TARIF jusqu'au croisement PV — le 1,49 kWh deja\n// consigne le 03/09.\n// ⚠️ LA FENETRE DE MIDI EST AUSSI UNE FENETRE D'HEURES CREUSES (18/09/2026).\n// L'engagement de session etait gate sur `night_hc` seul, parce que §40 traitait\n// la nuit. Consequence mesuree : la charge HC manuelle demandee par le\n// proprietaire ne pouvait RIEN faire a midi — le plancher relevait bien la cible\n// du bilan, mais aucune session ne s'engageait, donc le contacteur ne se fermait\n// pas et la consigne restait au repos. Le reglage aurait releve le plancher du\n// soir (donc bloque la voiture) sans jamais charger la batterie.\n//\n// ⚠️ ET LE CRITERE N'EST PAS LE MEME AUX DEUX FENETRES. La nuit, on compare le\n// SOC a `batteryMorningTargetPct`, qui contient le palier de prevision\n// (35/55/85). A midi ce palier n'a aucun sens : il vaut 35 par beau temps, donc\n// un SOC de 30 % en plein soleil engagerait une session reseau — c'est-a-dire la\n// substitution du solaire par le reseau qu'on a mis des semaines a eteindre.\n//\n// A midi le critere honnete est le SOC requis du bilan lui-meme, qui\n// s'auto-regule : le croisement PV etant passe, `energyToCoverKwh` vaut zero et\n// `required_soc` retombe au plancher dur. Releve du 17/09 a 13:51, plein soleil :\n// `required_soc` = 22,0 pour `energy_to_cover` = 0,0. Il ne remonte qu'un jour\n// sombre — ou quand le proprietaire pose un plancher manuel, ce qui est\n// exactement le cas d'usage.\n//\n// ET LA VOITURE (28/09/2026, strategie du proprietaire) : « JEEP branchee\n// engage le reseau en HC midi si l'analyse de production le justifie ». Le\n// bilan fait l'analyse (besoin saisi contre solaire disponible avant le depart,\n// batterie gardee pour tenir jusqu'a 01:24) et publie un verdict ; on le relaie,\n// sous les memes gardes de fraicheur et de statut que la cible.\nconst middayVehicleGridNeeded =\n balanceMorningTargetApplied &&\n params.balanceVehicleGridNeeded &&\n parsed.evcsVehicleConnected;\n\n// ET LE CHAUFFE-EAU (07/10/2026, proprietaire) : un jour faible, l'eau chaude\n// se fait en heures creuses sur EDF, a PLEINE consommation toute la fenetre\n// (douches de 06:00 et 20:00 a 65 °C ; le 06/10 le ballon avait pris 5,4 kWh\n// de solaire de 10 h a 15 h, l'energie qui manquait a la batterie). Le bilan\n// decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) ; le workflow\n// ECS n'alimente le ballon que si la session est engagee ET le contacteur\n// ferme. Memes gardes de fraicheur et de statut que la cible.\nconst hcEcsGridNeeded =\n balanceMorningTargetApplied && params.balanceEcsNuit;\n\nconst middayGridSessionUncovered =\n balanceMorningTargetApplied &&\n (\n (Number.isFinite(params.balanceRequiredSocPct) &&\n parsed.soc < params.balanceRequiredSocPct) ||\n middayVehicleGridNeeded ||\n hcEcsGridNeeded\n );\n\nconst gridSessionInHcWindow =\n profile === 'night_hc' || profile === 'midday_hc_or_solar';\n\nconst gridSessionUncovered =\n profile === 'night_hc' ? nightGridSessionUncovered : middayGridSessionUncovered;\n\nconst nightGridSessionCommitted =\n gridSessionInHcWindow &&\n (gridSessionUncovered || nightGridSessionCommittedPrev);\n\nconst nightGridTakeoverNeeded =\n nightGridSessionCommitted ||\n (\n nightSessionActive &&\n (\n nightGridSessionCommitted ||\n nightGridHcPreferred ||\n batteryBelowHardFloor ||\n (\n // Fix A : après fin de charge, le seul motif réseau restant est la\n // recovery batterie, et elle ne se ré-engage pas une fois done.\n vehicleChargeCompleted\n ? !nightRecoveryDone\n : (\n batteryAtOrBelowGridTakeoverTrigger ||\n batteryRecoveryUrgent ||\n nightTakeoverLatched\n )\n )\n )\n );\n\n// « Sans EDF » MAIS EDF DEJA FERME POUR LA BATTERIE EN HC (08/10/2026,\n// proprietaire) : la voiture en profite. Nuit du 07-08/10 : session engagee des\n// 01:24 (jour faible), toute la maison sur le reseau en heures creuses, et la\n// VW branchee restait bloquee par « sans EDF ». Revient sur l'option A du 04/10.\n// « Sans EDF » garde son sens quand EDF n'a aucune autre raison d'etre ferme :\n// la voiture ne fait jamais fermer le contacteur a elle seule.\nconst nightCarRidesGridSession =\n nightSessionActive &&\n nightCarWaitsNoGrid &&\n nightGridTakeoverNeeded;\n\nconst nightPreferredSourceMode =\n !nightSessionActive\n ? 'blocked'\n : vehicleChargeCompleted\n ? 'blocked'\n // « Sans EDF » : la voiture ne prend JAMAIS le reseau la nuit, MEME quand\n // EDF se ferme pour la batterie (plancher dur, cible du matin, session du\n // bilan, verrou). Decision du proprietaire le 04/10 (option A) : la nuit du\n // 03-04/10, EDF ferme a 02:57 pour la batterie, l'ancienne exception a\n // laisse la JEEP prendre 4,7 kWh au reseau jusqu'au plein. La protection\n // du materiel reste entiere : EDF se ferme quand meme (takeover), seule la\n // voiture attend le soleil.\n : nightCarWaitsNoGrid && !nightCarRidesGridSession\n ? 'blocked'\n : batteryAssistAllowed\n ? 'battery_assist_night'\n : (\n batteryBelowHardFloor ||\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n )\n ? 'grid_forced'\n : 'grid_hc_allowed';\n\n// « Sans EDF » ET EDF necessaire pour la BATTERIE (04/10/2026, option A du\n// proprietaire) : la voiture reste bloquee, mais la batterie a sa session\n// reseau. Sans ce drapeau, la fermeture d'EDF et la consigne de recharge, qui\n// suivent la source de la VOITURE, seraient perdues avec elle (golden\n// budget-sans-edf-plancher-dur-edf-protege, depart-matin-…-sous-la-cible).\n// Depuis le 08/10 la voiture profite de la session : ce drapeau ne vaut plus\n// que si elle ne peut pas en profiter (garde de securite conservee, vide en\n// pratique).\nconst nightBatteryGridOnly =\n nightSessionActive &&\n nightCarWaitsNoGrid &&\n nightGridTakeoverNeeded &&\n !nightCarRidesGridSession;\n\nconst batteryRecoveryAllowedNow =\n nightSessionActive &&\n nightGridTakeoverNeeded &&\n (\n batteryBelowMorningTarget ||\n batteryRecoveryUrgent\n );\n\nconst batteryTargetReached =\n nightSessionActive &&\n parsed.soc >= batteryMorningTargetPct;\n\n// =====================================================\n// PROBES / SOLAR POTENTIAL\n// =====================================================\n// IMPORTANT:\n// On ne met plus effectivePvStartMinW à 0 en force_charge_now,\n// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire.\nconst effectivePvStartMinW = params.pvStartMinW;\n\nconst morningProbeEligible =\n profile === 'morning_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW &&\n parsed.pvTrendW >= params.morningProbePvTrendMinW &&\n parsed.soc >= params.morningSocMin &&\n parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA;\n\nconst middayProbeEligible =\n profile === 'midday_hc_or_solar' &&\n params.probeEnabled &&\n parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW &&\n parsed.soc >= params.middaySocMin &&\n parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA;\n\nconst afternoonProbeEligible =\n profile === 'outside_window' &&\n params.probeEnabled &&\n parsed.soc >= params.afternoonProbeSocMin &&\n parsed.batteryCurrentA >= params.afternoonBatteryMaxDischargeA &&\n parsed.pvPowerAvg10mW >= params.afternoonProbePvAvgMinW;\n\nconst cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;\nconst batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);\n\n// P3 PROVOCATION PV (installation sans injection) :\n// Batteries quasi pleines => le MPPT bride la production à la conso maison.\n// Les seuils PV observés, le forecast buffer et la cloud debt ne veulent\n// alors plus rien dire (la dette ne peut d'ailleurs plus se rembourser,\n// batterie pleine = courant nul). On autorise solar_potential sur la seule\n// santé batterie : SOC très haut + batterie non déchargée.\n// Les gardes aval (BATTERY_CLOUD_LOCKS / DECISION) restent actives.\nconst PV_PROVOCATION_SOC_MIN = 90;\nconst pvProvocationEligible =\n parsed.soc >= PV_PROVOCATION_SOC_MIN &&\n batteryOkForPotential;\n\nconst isHighBatteryForPotential =\n pvProvocationEligible ||\n (\n parsed.soc >= 85 &&\n forecastBufferKwh > 0 &&\n cloudDebtOkForPotential &&\n batteryOkForPotential\n );\n\nconst potentialAllowed = userStrategy !== 'auto_solar';\n\n// IMPORTANT:\n// On impose un vrai PV > 0 pour le test sur pvPowerW.\n// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.\nconst strongSolarSignal =\n (\n parsed.pvPowerW > 0 &&\n parsed.pvPowerW >= effectivePvStartMinW\n ) ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW ||\n (departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);\n\n// =====================================================\n// SOURCE MODE\n// SÉMANTIQUE CORRIGÉE:\n// - force_charge_now bypass les fenêtres\n// - mais ne devient jamais grid_forced à lui seul\n// - outside_window = pas de réseau programmé\n// - la charge locale peut rester autorisée\n// =====================================================\nlet sourceMode = 'blocked';\n\nif (isStopMode) {\n sourceMode = 'blocked';\n}\n\nelse if (forceGridRequested) {\n // Demande explicite de réseau.\n sourceMode = 'grid_forced';\n}\n\nelse if (forceChargeRequested) {\n // IMPORTANT:\n // force_charge_now = intention de charger maintenant,\n // sans exiger le réseau.\n\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (nightSessionActive && batteryAssistAllowed) {\n sourceMode = 'battery_assist_night';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // Fallback manuel local :\n // on n'impose pas le grid, on laisse l'étage aval décider\n // selon les garde-fous physiques / batterie.\n sourceMode = 'solar_potential';\n }\n}\n\nelse if (profile === 'night_hc') {\n if (nightSessionActive) {\n sourceMode = nightPreferredSourceMode;\n } else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {\n // Session de midi engagee et contacteur ferme : la voiture charge sur le\n // reseau avec la batterie (« batterie et voiture ensemble », 28/09), meme au\n // soleil. La consigne garde son credit solaire (HA_SERVICE_CALLS_OUTPUT) :\n // elle ne preleve au reseau que ce que les chargeurs ne donnent pas.\n if (profile === 'midday_hc_or_solar' && nightGridSessionCommitted && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'morning_solar' && morningProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {\n sourceMode = 'grid_hc_allowed';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'evening_high_soc') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else {\n sourceMode = 'blocked';\n }\n}\n\nelse if (profile === 'outside_window') {\n if (strongSolarSignal) {\n sourceMode = 'solar_only';\n }\n\n else if (isHighBatteryForPotential && potentialAllowed) {\n sourceMode = 'solar_potential';\n }\n\n else if (afternoonProbeEligible) {\n sourceMode = 'hybrid_probe';\n }\n\n else {\n // IMPORTANT:\n // hors fenêtre = pas de réseau programmé.\n // Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.\n sourceMode = 'blocked';\n }\n}\n\n// P9 : le réseau avant départ prend le pas sur les modes spéculatifs du jour\n// (blocked / solar_potential / hybrid_probe), mais JAMAIS sur le solaire réel\n// (solar_only), ni sur stop / force_* (déjà traités en tête de cascade).\nif (\n departureUrgentActive &&\n !isStopMode &&\n !forceGridRequested &&\n !forceChargeRequested &&\n profile !== 'night_hc' &&\n sourceMode !== 'solar_only'\n) {\n sourceMode = 'grid_hc_allowed';\n}\n\n// P9 : véhicule plein pendant la fenêtre départ → aucune source.\nif (departureChargeDone && profile !== 'night_hc' && !isStopMode && !forceGridRequested && !forceChargeRequested) {\n sourceMode = 'blocked';\n}\n\n// P10 : véhicule plein en journée (mémoire collante) → aucune source,\n// y compris solaire (charger un véhicule plein ne sert à rien).\nif (dayVehicleFullIdle && profile !== 'night_hc') {\n sourceMode = 'blocked';\n}\n\n// =====================================================\n// PLAFOND DE COURANT EVCS — calculé APRÈS sourceMode (31/08/2026)\n// =====================================================\n// P5 : plafond majoré (input_number.evcs_plafond_courant_edf_on, jusqu'à 28 A\n// en BOOST) quand le réseau alimente réellement AC-IN ; sinon plafond câblé\n// prudent, celui que les deux MultiPlus peuvent porter SEULS.\n//\n// SÉQUENCE D'OUVERTURE (31/08/2026) — pourquoi ce bloc a été déplacé ici.\n// Le plafond majoré n'est légitime que tant que le réseau est là. Or c'est\n// l'hystérésis (cycle 1 min) qui ouvre le contacteur, et le contrôleur EVCS ne\n// l'apprenait qu'après coup : pendant ce temps les deux onduleurs portaient\n// seuls la voiture au courant majoré plus la maison — le mécanisme du W-105.\n//\n// Correctif sans poignée de main entre les deux automates : le plafond majoré\n// exige désormais que l'EVCS DEMANDE lui-même le réseau. Sa demande tombe au\n// cycle où la fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir\n// qu'ensuite (override disparu ET min_on écoulé, cycle 1 min). L'EVCS baisse\n// donc AVANT, mécaniquement, sans rien coordonner.\n// Le calcul doit venir après sourceMode ; rien entre les deux ne l'utilisait.\nconst evcsGridCeilingAllowed =\n parsed.acInAvailable &&\n (\n sourceMode === 'grid_forced' ||\n sourceMode === 'grid_hc_allowed' ||\n forceGridRequested\n );\n\nconst evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);\nconst safeEvcsByDedicatedCapW = evcsGridCeilingAllowed\n ? Math.max(0, params.gridCurrentMaxA * acVoltageRef)\n : evcsDedicatedCapBaseW;\nconst safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);\nconst safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));\n\nconst systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));\nconst physicalCapA = clamp(\n systemCapCurrentA,\n 0,\n evcsGridCeilingAllowed\n ? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)\n : params.currentMaxAutoA\n);\nconst canRunAtMinCurrent = physicalCapA >= params.currentMinA;\n\nconst operatingMode = profile;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n strategy: {\n userStrategy,\n isStopMode,\n isForceMode,\n forceGridRequested,\n forceChargeRequested,\n },\n\n lifecycle: {\n minutesSinceStart,\n minutesSinceStop,\n secondsSinceCurrentChange,\n minutesSinceProbeStart,\n minRunElapsed,\n minStopElapsed,\n currentCooldownElapsed,\n minutesUntilDeparture,\n },\n\n windows: {\n inNightHcWindow,\n inMorningWindow,\n inMiddayWindow,\n inEveningWindow,\n // Temps restant dans la fenetre d'HEURES CREUSES courante (nuit ou midi),\n // null hors de toute fenetre creuse. Consomme par le dimensionnement de\n // la consigne reseau : recharger la batterie sur le reseau n'a de sens\n // economique que la-dedans, donc `null` y vaut « pas de part batterie »\n // sans test supplementaire.\n minutesRemainingInHcWindow:\n minutesRemainingInWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin)\n ?? minutesRemainingInWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin),\n },\n\n derived: {\n acVoltageRef,\n batteryVoltageRef,\n houseNonEvcsW,\n vebusNonEvcsW,\n totalVebusSoftCeilingW,\n totalVebusHardCeilingW,\n safeEvcsByDedicatedCapW,\n safeEvcsByVebusW,\n safeEvcsCombinedW,\n systemCapCurrentA,\n physicalCapA,\n canRunAtMinCurrent,\n evcsGridCeilingAllowed,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n endDayGuardSoc,\n energyNeededForEndDayGuardKwh,\n forecastBufferKwh,\n projectedEndSocAfterCharge,\n currentReferenceA,\n anyVebusOverload,\n anyVebusLowBattery,\n vebusAcOutSoftReached,\n vebusAcOutHardReached,\n vebusDcVoltageSoftLow,\n vebusDcVoltageHardLow,\n houseLoadHighVsHeadroom,\n },\n\n context: {\n departurePriorityActive,\n forceChargeDepartureGridOverride,\n manualChargeBypassWindows,\n manualChargeLocalRequested,\n morningProbeEligible,\n middayProbeEligible,\n afternoonProbeEligible,\n pvProvocationEligible,\n isHighBatteryForPotential,\n strongSolarSignal,\n effectivePvStartMinW,\n profile,\n operatingMode,\n sourceMode,\n },\n\n // R1 : source de vérité UNIQUE du moteur nuit.\n // BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien.\n nightEvcsPreview: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n batteryMorningTargetBasePct,\n balanceMorningTargetApplied,\n nightBatterySurplusFloorPct,\n nightBatterySurplusAvailable,\n nightBatterySurplusExhausted,\n nightBudgetKwh: nightBudget ? nightBudget.budgetKwh : null,\n nightBudgetDeliveredKwh,\n nightBudgetReached,\n nightBudgetToWrite,\n nightChargeMode: params.nightChargeMode,\n nightCarWaitsNoGrid,\n nightBatteryGridOnly,\n nightCarRidesGridSession,\n nightGridSessionCommitted,\n middayVehicleGridNeeded,\n nightGridSessionUncovered,\n balanceTargetAgeMin:\n balanceTargetAgeMin === null ? null : Math.round(balanceTargetAgeMin * 10) / 10,\n batteryMorningTargetRulePct,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n nightGridHcPreferred,\n vehicleChargeCompleted,\n nightRecoveryDone,\n nightPreferredSourceMode,\n\n // P9\n departureUrgentActive,\n departureChargeDone,\n\n // P10\n dayVehicleFullIdle,\n\n // P11\n sessionFresh,\n chargedSessionEnergyKwh,\n chargedSessionEstablished,\n chargedCredible,\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2,