Nuit : la voiture profite d'EDF ferme pour la batterie, la nuit prepare le soir ; conseiller IA relecteur de la journee (revue calculee + pistes)

This commit is contained in:
Tallagrand Thierry
2026-10-08 05:31:44 +02:00
parent 043fb85383
commit 78f434778b
21 changed files with 742 additions and 552 deletions

View File

@@ -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 - ⚠️ 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). 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*

View File

@@ -1,67 +1,41 @@
// ===================================================== // =====================================================
// EVCS ADVISOR SHADOW V1 — BUILD_HA_CALLS // EVCS ADVISOR — BUILD_HA_CALLS (relecteur, 08/10/2026)
// Écrit les helpers advisor_* + divergence + compteurs + logbook. // GARANTIE : ce node n'ecrit QUE des entites advisor (input_text.evcs_advisor_*,
// GARANTIE MODE OMBRE : ce node ne produit QUE des écritures vers // sensor.evcs_advisor_revue) et le logbook. AUCUN switch, AUCUNE strategie,
// des entités advisor_* et le logbook. AUCUN switch, AUCUNE stratégie // AUCUNE commande. Ne pas ajouter d'autres entites ici.
// réelle, AUCUNE commande borne. Ne pas ajouter d'autres entités ici. // Les items `state_entity` sont envoyes a /api/states (capteur avec attributs),
// les autres a /api/services/<domain>/<service>.
// ===================================================== // =====================================================
const { status, detail, advice, modelUsed, context } = $input.first().json; const { status, detail, pistes, rejetees, modelUsed, context } = $input.first().json;
function stampParis() { function stampParis() {
const p = new Intl.DateTimeFormat('fr-FR', { const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',
timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date());
hour: '2-digit', minute: '2-digit', hour12: false, const g = (t) => p.find((x) => x.type === t)?.value || '';
}).formatToParts(new Date());
const g = t => p.find(x => x.type === t)?.value || '';
return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`; return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;
} }
const calls = []; const calls = [];
function push(domain, service, data) { const push = (domain, service, data) => calls.push({ json: { 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) });
}
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 });
}
// Statut + horodatage + modèle : écrits à CHAQUE run
setText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : '')); setText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : ''));
setText('input_text.evcs_advisor_last_run', stampParis()); setText('input_text.evcs_advisor_last_run', stampParis());
setText('input_text.evcs_advisor_model', modelUsed); setText('input_text.evcs_advisor_model', modelUsed);
if (status === 'ok' && advice) { if (status === 'ok' && Array.isArray(pistes)) {
// Observation — la seule sortie desormais. Le conseiller ne planifie plus : calls.push({ json: { state_entity: 'sensor.evcs_advisor_revue', data: {
// la planification est faite par le bilan, qui est calcule et mesure, et la state: context.jour,
// campagne du 12/09 a montre que lui demander les deux degradait les deux attributes: { friendly_name: 'Revue du conseiller IA', pistes, rejetees, modele: modelUsed,
// (ecart de cible passe de 0 a 35 points sur certains cas des l ajout de la avertissement: 'Avis a verifier, pas des faits. Les constats chiffres sont dans sensor.evcs_revue_jour.' },
// 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.
setText('input_text.evcs_advisor_reason', setText('input_text.evcs_advisor_reason',
`[${Math.round(advice.confidence * 100)}%] ${advice.piste || 'rien a creuser'}`); `Revue du ${context.jour} : ${pistes.length} piste(s)${rejetees ? `, ${rejetees} ecartee(s)` : ''}`);
// 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);
}
push('logbook', 'log', { push('logbook', 'log', {
name: 'EVCS Advisor (observateur)', name: 'EVCS Advisor (relecteur)',
message: `[${advice.gravite}] ${advice.constat}` message: pistes.map((p) => `[${p.code}] ${p.piste}`).join(' || ') || 'aucune piste retenue',
+ (advice.ceQuiCloche ? ` || ${advice.ceQuiCloche}` : '') entity_id: 'input_text.evcs_advisor_reason',
+ (advice.piste ? ` || piste : ${advice.piste}` : ''),
entity_id: 'input_text.evcs_advisor_constat',
}); });
} }

View File

@@ -1,317 +1,80 @@
// ===================================================== // =====================================================
// EVCS ADVISOR SHADOW V1 — BUILD_PROMPT // EVCS ADVISOR — BUILD_PROMPT (RELECTEUR DE LA JOURNEE, 08/10/2026)
// Construit le prompt LLM à partir du snapshot HA compact. //
// MODE OMBRE : ce workflow n'écrit que des helpers advisor_*. // 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 raw = $input.first().json;
const input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw); 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 = { const ctx = {
advisorEnabled: onOff(input.advisor_enabled), advisorEnabled: onOff(input.advisor_enabled),
agreeCount: num(input.advisor_agree_count, 0), jour: jourParis,
divergeCount: num(input.advisor_diverge_count, 0), revueDuJour,
dejaRepondu,
soc: num(input.soc_percent, NaN), constats: aExaminer.map((c) => ({ code: c.code, texte: String(c.texte), contexte: String(c.contexte || '') })),
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,
};
})(),
}; };
// 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 = [ const systemPrompt = [
"Tu es le PLANIFICATEUR ENERGIE d'une maison en autoconsommation, en Bretagne.", "Tu es le RELECTEUR du systeme energie d'une maison en Bretagne : 16 kWc de solaire,",
'Tu es en MODE OBSERVATION : tes reponses sont enregistrees et comparees au reel,', 'batterie de ~23 kWh utiles (0,30 kWh par point de SOC), Victron ESS SANS injection',
'elles ne pilotent rien.', '(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) :', 'On te donne des CONSTATS MESURES sur les dernieres 24 h. Ce sont des FAITS : ne les',
`- Batterie : ${ctx.modulesEnService} equivalents US2000C, soit ${capaciteUtileKwh.toFixed(1)} kWh`, 'conteste pas et ne les recalcule pas. Pour chacun, propose UNE piste : la cause la',
` reellement livres a la maison entre 100 % et le plancher de securite de 22 %.`, 'plus probable et ce qu il faut verifier ou corriger. Tu ne pilotes rien.',
` 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.',
'', '',
'LA MAISON, telle qu elle consomme vraiment :', 'REGLES ABSOLUES :',
'- Quatre personnes. Les gros appels sont BREFS et cales sur les repas :', '- N ecris AUCUN chiffre qui ne figure pas dans le constat ou son contexte.',
' bouilloire vers 05:45, grille-pain vers 08:00, cuisson du soir entre 20 h et 23 h.', '- Si tu ne vois pas de cause, dis « cause non identifiable avec ces donnees ».',
"- L heure du diner varie de TROIS HEURES d un jour a l autre : un profil horaire", '- Une piste fait au plus 280 caracteres, en francais, sans jargon inutile.',
' moyen ne represente ni l un ni l autre.', '- Le contexte est la raison publiee par le controleur au debut de l episode.',
'', '',
'CE QUI REMPLACE LES REGLES DE SEUIL :', 'Reponds UNIQUEMENT en JSON : {"pistes":[{"code":"<code du constat>","piste":"<texte>"}]}',
'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.',
].join('\n'); ].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 = [ const userPrompt = [
`Nous sommes ${nowParis} (heure de Paris).`, `Revue du ${jourParis}. ${aExaminer.length} constat(s) a examiner :`,
'', '',
'ETAT MESURE :', ...aExaminer.map((c, i) => `${i + 1}. code=${c.code}\n constat : ${c.texte}\n contexte : ${String(c.contexte || '(aucun)').slice(0, 400)}`),
`- 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`,
'', '',
'BILAN ENERGETIQUE :', 'JSON uniquement.',
...bilanLignes,
'',
'DERNIERS EVENEMENTS DU SYSTEME (le plus recent en premier) :',
...(ctx.evenements.length ? ctx.evenements : ['- aucun evenement transmis']),
'',
'Quelle est ta recommandation ? JSON uniquement.',
].join('\n'); ].join('\n');
return [{ return [{
json: { json: {
enabled: ctx.advisorEnabled, enabled: ctx.advisorEnabled && revueDuJour && !dejaRepondu && aExaminer.length > 0 && heureParis < 10,
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }, { role: 'user', content: userPrompt },
], ],
context: ctx, context: ctx,
} },
}]; }];

View File

@@ -2,6 +2,9 @@
"advisor_enabled": states("input_boolean.evcs_advisor_enabled"), "advisor_enabled": states("input_boolean.evcs_advisor_enabled"),
"advisor_agree_count": states("input_number.evcs_advisor_agree_count"), "advisor_agree_count": states("input_number.evcs_advisor_agree_count"),
"advisor_diverge_count": states("input_number.evcs_advisor_diverge_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"), "soc_percent": states("sensor.victron_battery_soc"),
"battery_current_a": states("sensor.victron_battery_current"), "battery_current_a": states("sensor.victron_battery_current"),

View File

@@ -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). // 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 resp = $input.first().json;
const context = $('BUILD_PROMPT').first().json.context; const context = $('BUILD_PROMPT').first().json.context;
function fail(status, detail) { function fail(status, detail) {
return [{ return [{ json: { status, detail: (detail || '').toString().slice(0, 200), pistes: null, rejetees: 0,
json: { modelUsed: (resp?.model || 'aucun').toString().slice(0, 60), context } }];
status,
detail: (detail || '').toString().slice(0, 200),
advice: null,
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; const content = resp?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) { if (typeof content !== 'string' || !content.trim()) {
return fail('llm_offline', resp?.error?.message || resp?.error || 'aucune réponse LLM'); return fail('llm_offline', resp?.error?.message || resp?.error || 'aucune réponse LLM');
} }
const text = content.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/```(?:json)?/gi, '').trim();
// 2. Nettoyage : balises de raisonnement (<think>...</think>) et fences markdown
let text = content
.replace(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/```(?:json)?/gi, '')
.trim();
// 3. Extraction du premier objet JSON équilibré
const start = text.indexOf('{'); const start = text.indexOf('{');
if (start === -1) return fail('invalid', 'aucun JSON dans la réponse'); if (start === -1) return fail('invalid', 'aucun JSON dans la réponse');
let depth = 0, end = -1; 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; } } else if (text[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
} }
if (end === -1) return fail('invalid', 'JSON non terminé'); if (end === -1) return fail('invalid', 'JSON non terminé');
let obj; let obj;
try { try { obj = JSON.parse(text.slice(start, end + 1)); } catch (e) { return fail('invalid', `JSON invalide: ${e.message}`); }
obj = JSON.parse(text.slice(start, end + 1)); if (!Array.isArray(obj.pistes)) return fail('invalid', 'champ pistes absent');
} catch (e) {
return fail('invalid', `JSON invalide: ${e.message}`); // 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) return [{ json: { status: 'ok', detail: '', pistes, rejetees,
// ── OBSERVATION (12/09/2026) ─────────────────────────────────────────── modelUsed: (resp.model || 'inconnu').toString().slice(0, 60), context } }];
// 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,
}
}];

View File

@@ -458,7 +458,59 @@ const hcFloorPrudentReachPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
const hcMiddayWindow = hcReachActive && hcNightReachActive && nextHcStartMs === nextNightHcStartMs; const hcMiddayWindow = hcReachActive && hcNightReachActive && nextHcStartMs === nextNightHcStartMs;
const hcMaxCharge = hcMiddayWindow && ecsNuitAujourdhui; 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 hcFloorPrudentPct = hcMaxCharge ? 100 : hcFloorPrudentReachPct;
const typical = coverage(slotsP50, hcFloorTypicalPct); const typical = coverage(slotsP50, hcFloorTypicalPct);
const prudent = coverage(slotsP80, hcFloorPrudentPct); const prudent = coverage(slotsP80, hcFloorPrudentPct);
@@ -917,6 +969,8 @@ return [{
// Jour faible a midi : cible 100 % (charge maximale) ; floorReachPct garde // Jour faible a midi : cible 100 % (charge maximale) ; floorReachPct garde
// le plancher « tenir jusqu'a 01:24 » calcule, pour comparaison. // le plancher « tenir jusqu'a 01:24 » calcule, pour comparaison.
maxCharge: hcMaxCharge, maxCharge: hcMaxCharge,
// Nuit d'un jour annonce faible : SOC de fin de nuit pour arriver au soir.
nightEvening,
floorReachPct: round1(Math.min(100, hcFloorReachPct)), floorReachPct: round1(Math.min(100, hcFloorReachPct)),
floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)), floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)),
nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null, nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null,

View File

@@ -160,7 +160,10 @@ const reason = balance.crossing.found
// Fenetre HC (28/09) : dire quand c'est « tenir jusqu'a la prochaine HC » qui // 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. // fixe l'exigence, sinon la raison affiche un croisement qui ne l'explique pas.
const hc = balance.hcReach || {}; 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}%).` ? ` 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 : 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.` ? ` 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.`

View File

@@ -1150,6 +1150,7 @@ return [{
nightChargeMode: nightPrev.nightChargeMode || 'sans_edf', nightChargeMode: nightPrev.nightChargeMode || 'sans_edf',
nightCarWaitsNoGrid: !!nightPrev.nightCarWaitsNoGrid, nightCarWaitsNoGrid: !!nightPrev.nightCarWaitsNoGrid,
nightBatteryGridOnly: !!nightPrev.nightBatteryGridOnly, nightBatteryGridOnly: !!nightPrev.nightBatteryGridOnly,
nightCarRidesGridSession: !!nightPrev.nightCarRidesGridSession,
maxBatteryDischargePoints, maxBatteryDischargePoints,
gridTakeoverTriggerPct, gridTakeoverTriggerPct,

View File

@@ -304,9 +304,12 @@ const reason = [...decision.reason];
if (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) { if (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) {
const f = (x) => String(Math.round(x * 100) / 100).replace('.', ','); const f = (x) => String(Math.round(x * 100) / 100).replace('.', ',');
reason.push(`Budget batterie nuit ${f(night.nightBudgetDeliveredKwh ?? 0)}/${f(night.nightBudgetKwh)} kWh` + 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.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil'
: night.nightBudgetReached ? ' — atteint, EDF finit la charge' : '')); : 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) { } else if (night.nightCarWaitsNoGrid) {
reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil'); reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil');
} }

View File

@@ -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 = const nightPreferredSourceMode =
!nightSessionActive !nightSessionActive
? 'blocked' ? 'blocked'
@@ -878,7 +889,7 @@ const nightPreferredSourceMode =
// laisse la JEEP prendre 4,7 kWh au reseau jusqu'au plein. La protection // 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 // du materiel reste entiere : EDF se ferme quand meme (takeover), seule la
// voiture attend le soleil. // voiture attend le soleil.
: nightCarWaitsNoGrid : nightCarWaitsNoGrid && !nightCarRidesGridSession
? 'blocked' ? 'blocked'
: batteryAssistAllowed : batteryAssistAllowed
? 'battery_assist_night' ? 'battery_assist_night'
@@ -895,10 +906,14 @@ const nightPreferredSourceMode =
// reseau. Sans ce drapeau, la fermeture d'EDF et la consigne de recharge, qui // 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 // suivent la source de la VOITURE, seraient perdues avec elle (golden
// budget-sans-edf-plancher-dur-edf-protege, depart-matin-…-sous-la-cible). // 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 = const nightBatteryGridOnly =
nightSessionActive && nightSessionActive &&
nightCarWaitsNoGrid && nightCarWaitsNoGrid &&
nightGridTakeoverNeeded; nightGridTakeoverNeeded &&
!nightCarRidesGridSession;
const batteryRecoveryAllowedNow = const batteryRecoveryAllowedNow =
nightSessionActive && nightSessionActive &&
@@ -1291,6 +1306,7 @@ return [{
nightChargeMode: params.nightChargeMode, nightChargeMode: params.nightChargeMode,
nightCarWaitsNoGrid, nightCarWaitsNoGrid,
nightBatteryGridOnly, nightBatteryGridOnly,
nightCarRidesGridSession,
nightGridSessionCommitted, nightGridSessionCommitted,
middayVehicleGridNeeded, middayVehicleGridNeeded,
nightGridSessionUncovered, nightGridSessionUncovered,

View File

@@ -1,153 +1,94 @@
#!/usr/bin/env node #!/usr/bin/env node
// Tests de l'EVCS Advisor Shadow V1 : exécute le VRAI code de BUILD_PROMPT et // Tests de l'EVCS Advisor en RELECTEUR de la journee (08/10/2026) : execute le
// VALIDATE_PARSE (sandbox vm) contre des réponses LLM simulées. // VRAI code de BUILD_PROMPT, VALIDATE_PARSE et BUILD_HA_CALLS (sandbox vm)
// Vérifie aussi la GARANTIE MODE OMBRE : BUILD_HA_CALLS ne touche que advisor_*/logbook. // contre des reponses LLM simulees. Verifie aussi la GARANTIE : BUILD_HA_CALLS
// Intégré au push (tools/n8n-push.mjs) pour le workflow advisor. // 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 fs from 'fs';
import vm from 'vm'; import vm from 'vm';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const ROOT = path.resolve(__dirname, '../..'); const src = (f) => fs.readFileSync(path.join(ROOT, 'src/advisor', f), 'utf8');
const src = f => fs.readFileSync(path.join(ROOT, 'src/advisor', f), 'utf8');
const BUILD_PROMPT = src('BUILD_PROMPT.js'); const BUILD_PROMPT = src('BUILD_PROMPT.js');
const VALIDATE_PARSE = src('VALIDATE_PARSE.js'); const VALIDATE_PARSE = src('VALIDATE_PARSE.js');
const BUILD_HA_CALLS = src('BUILD_HA_CALLS.js'); const BUILD_HA_CALLS = src('BUILD_HA_CALLS.js');
function run(code, inputJson, nodeOutputs = {}) { // Horloge figee : 08/10/2026 07:35 a Paris.
const ctx = { const FIXE = Date.parse('2026-10-08T07:35:00+02:00');
$input: { first: () => ({ json: inputJson }) }, class DateFixe extends Date { constructor(...a) { super(...(a.length ? a : [FIXE])); } static now() { return FIXE; } }
$: name => ({ first: () => ({ json: nodeOutputs[name] }) }),
console, Date, Intl, Math, Number, Object, String, JSON, Array, Boolean, 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); vm.createContext(ctx);
return vm.runInContext(`(function(){\n${code}\n})()`, ctx); return vm.runInContext(`(function(){\n${code}\n})()`, ctx);
} }
// Snapshot simulé (soir d'été, VW branchée, SOC 80) // Constats reels du 07-08/10 (sortie de tools/revue-jour.mjs sur la base).
const snapshot = { const CONSTATS = [
data: JSON.stringify({ { 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' },
advisor_enabled: 'on', advisor_agree_count: '10', advisor_diverge_count: '2', { 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' },
advisor_prev_strategy: 'auto_solar_plus', { 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)' },
soc_percent: '80', battery_current_a: '-5', pv_power_w: '500', pv_power_10m_avg_w: '600', { code: 'charge_max_midi', gravite: 'info', texte: 'info', contexte: '' },
house_ac_power_w: '900', grid_state: 'off', acin1_available: '0', ];
evcs_vehicle_connected: 'on', evcs_charging: 'off', evcs_current_a: '0', const snap = (o = {}) => ({ data: JSON.stringify({ advisor_enabled: 'on', revue_jour_date: '2026-10-08',
evcs_surplus_state: 'idle_wait_conditions', evcs_source_mode: 'Solaire', revue_jour_constats: CONSTATS, advisor_revue_date: '2026-10-07', ...o }) });
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',
}),
};
let failures = 0; let failures = 0;
function check(label, cond, detail = '') { const check = (label, cond, detail = '') => { if (cond) console.log(`✅ ${label}`); else { failures++; console.log(`❌ ${label} ${detail}`); } };
if (cond) console.log(`✅ ${label}`);
else { failures++; console.log(`❌ ${label} ${detail}`); }
}
// ---------- BUILD_PROMPT ---------- // ---------- BUILD_PROMPT ----------
const promptOut = run(BUILD_PROMPT, snapshot)[0].json; const p = run(BUILD_PROMPT, snap())[0].json;
check('BUILD_PROMPT: enabled=true', promptOut.enabled === true); check('PROMPT: actif quand la revue du jour a des points et pas encore de reponse', p.enabled === true);
check('BUILD_PROMPT: 2 messages system+user', promptOut.messages?.length === 2 && promptOut.messages[0].role === 'system'); 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('BUILD_PROMPT: contexte transporté', promptOut.context?.soc === 80 && promptOut.context?.userStrategy === 'auto_solar_plus'); check('PROMPT: chaque constat et son contexte sont dans le message', CONSTATS.slice(0, 3).every((c) => p.messages[1].content.includes(c.texte)));
// La borne de 6000 datait du modele local d aout (contexte etroit). Le modele check('PROMPT: < 6000 caracteres', p.messages[0].content.length + p.messages[1].content.length < 6000);
// charge aujourd hui annonce 98 304 tokens ; la vraie contrainte est le cout check('PROMPT: kill-switch OFF -> inactif', run(BUILD_PROMPT, snap({ advisor_enabled: 'off' }))[0].json.enabled === false);
// de latence, pas le contexte. On garde une borne pour eviter la derive du check('PROMPT: deja repondu aujourd hui -> inactif', run(BUILD_PROMPT, snap({ advisor_revue_date: '2026-10-08' }))[0].json.enabled === false);
// briefing, elargie a la mission d observation et a l historique des evenements. check('PROMPT: revue d hier -> inactif', run(BUILD_PROMPT, snap({ revue_jour_date: '2026-10-07' }))[0].json.enabled === false);
check('BUILD_PROMPT: prompt < 9000 chars (garde-fou contre la derive)', (promptOut.messages[0].content.length + promptOut.messages[1].content.length) < 9000); 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; // ---------- VALIDATE_PARSE ----------
check('BUILD_PROMPT: kill-switch OFF → enabled=false', killSwitchOut.enabled === false); 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 bon = { pistes: [
const ctxNode = { BUILD_PROMPT: promptOut }; { 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.' },
const validAdvice = '{"constat":"Contacteur reseau stable, batterie en charge.","gravite":"aucune","ce_qui_cloche":"","piste":"","confidence":0.85}'; { 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: <think> et fences tolerees', v('<think>...</think>\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 = [ // ---------- BUILD_HA_CALLS : garantie de perimetre ----------
{ const calls = run(BUILD_HA_CALLS, o1, ctxNode).map((i) => i.json);
label: 'réponse valide → ok', const permis = (c) => c.domain === 'logbook' || c.state_entity === 'sensor.evcs_advisor_revue'
resp: { model: 'qwen3.6-35b', choices: [{ message: { content: validAdvice } }] }, || /^input_text\.evcs_advisor_/.test(c.data?.entity_id || '');
expect: o => o.status === 'ok' && o.advice.gravite === 'aucune' && o.advice.constat.includes('stable'), 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);
label: 'balises <think> + fences markdown → ok', const hl = run(BUILD_HA_CALLS, run(VALIDATE_PARSE, { error: 'x' }, ctxNode)[0].json, ctxNode).map((i) => i.json);
resp: { model: 'qwen3-vl', choices: [{ message: { content: `<think>je réfléchis...\nblabla</think>\n\`\`\`json\n${validAdvice}\n\`\`\`` } }] }, 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')));
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,
},
];
for (const c of cases) { // ---------- Execute node : routage states / services ----------
const out = run(VALIDATE_PARSE, c.resp, ctxNode)[0].json; const wf = JSON.parse(fs.readFileSync(path.join(ROOT, 'workflows/evcs-advisor-shadow-v1.json'), 'utf8'));
check(`VALIDATE: ${c.label}`, c.expect(out), `(obtenu: ${JSON.stringify(out).slice(0, 120)})`); 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/'"));
// ---------- 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')));
console.log(failures ? `\n${failures} ÉCHEC(S) — NE PAS POUSSER` : '\nADVISOR OK — tous les tests passent'); console.log(failures ? `\n${failures} ÉCHEC(S) — NE PAS POUSSER` : '\nADVISOR OK — tous les tests passent');
process.exit(failures ? 1 : 0); process.exit(failures ? 1 : 0);

View File

@@ -107,6 +107,7 @@ const SUMMARIZE = {
hcFloorPct: o.hcReach ? o.hcReach.floorPct : null, hcFloorPct: o.hcReach ? o.hcReach.floorPct : null,
hcFloorReachPct: o.hcReach ? o.hcReach.floorReachPct : null, hcFloorReachPct: o.hcReach ? o.hcReach.floorReachPct : null,
hcMaxCharge: o.hcReach ? o.hcReach.maxCharge : 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, hcNightFloorPrudentPct: o.hcReach ? o.hcReach.nightFloorPrudentPct : null,
// Nuit prevue par la soiree (28/09). // Nuit prevue par la soiree (28/09).
nightModelActive: o.nightModel ? o.nightModel.active : false, nightModelActive: o.nightModel ? o.nightModel.active : false,

View File

@@ -417,8 +417,12 @@ export const balanceScenarios = [
id: 'bilan-hc-nuit-2709-prevision-forecast-solar', 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 %', 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), 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, 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 // 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', 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', 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), 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 }, 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 »', 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), snapshot: Object.assign(snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709),
{ ecs_nuit_jour: '2026-09-27|on|x' }), { 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', node: 'BALANCE',

View File

@@ -3232,18 +3232,18 @@ export const scenarios = [
time: '2026-09-30T04:00:00+02:00', 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', 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' }, 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 // 08/10 (proprietaire) : EDF se ferme pour la BATTERIE en heures creuses, la
// voiture reste bloquee « sans EDF » (avant : grid_forced, voiture sur EDF). // voiture en profite meme « sans EDF » (option A du 04/10 abandonnee).
expect: { sourceMode: 'blocked', desiredCharging: false, edfOnAction: true, takeoverNeeded: true }, expect: { sourceMode: 'grid_forced', edfOnAction: true, takeoverNeeded: true },
}, },
{ {
id: 'budget-sans-edf-reprise-reseau-voiture-bloquee', 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', time: '2026-10-04T02:57:00+02:00',
overrides: { ...BUDGET_NUIT, balance_last_run: '04/10 02:55', soc_percent: '39', overrides: { ...BUDGET_NUIT, balance_last_run: '04/10 02:55', soc_percent: '39',
balance_required_soc: '39.5', balance_required_soc_prudent: '40.4', 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' }, 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', 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', balance_required_soc: '28.6', balance_required_soc_prudent: '29.7',
evcs_vehicle: 'JEEP', departure_time_jeep: '06:30:00', departure_day_ok: 'on', 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' }, 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). // « sans EDF » : l'urgence ferme EDF pour la batterie, la voiture en profite (08/10).
expect: { sourceMode: 'blocked', desiredCharging: false, edfOnAction: true }, expect: { sourceMode: 'grid_forced', edfOnAction: true },
}, },
{ {
id: 'depart-matin-pas-un-jour-de-depart', id: 'depart-matin-pas-un-jour-de-depart',

82
tests/revue-jour/run.mjs Normal file
View File

@@ -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);

View File

@@ -31,6 +31,7 @@ import { fileURLToPath } from 'node:url';
import pg from 'pg'; import pg from 'pg';
import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from './kilometrage.mjs'; import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from './kilometrage.mjs';
import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.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 { 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 { transitionsDansSerie } from './allers-retours.mjs';
import { energieSur24h, energieDepuis, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs'; import { energieSur24h, energieDepuis, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs';
@@ -1122,6 +1123,83 @@ async function envoyerRapportDuMatin(sql, client) {
return 1; 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 // 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 // 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) => { rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => {
console.error('rapport de nuit :', e.message); return 0; 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) => { cellules: DRY ? 0 : await ingererCellules(client).catch((e) => {
console.error(`ingestion cellules en echec : ${e.message}`); console.error(`ingestion cellules en echec : ${e.message}`);
return 0; return 0;

View File

@@ -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');

218
tools/revue-jour.mjs Normal file
View File

@@ -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'),
};
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long