Chauffe-eau en HC de nuit les jours faibles : decision quotidienne du bilan, workflow ECS sous controle du depot (VRM, prevision du jour), energie 24 h

This commit is contained in:
Tallagrand Thierry
2026-10-07 07:06:03 +02:00
parent 0e0c0a558d
commit c2824085c9
26 changed files with 3060 additions and 42 deletions

View File

@@ -1908,5 +1908,51 @@ orange-cloture (verifie par SPARK : envoye le 05/10 a 17:42).
(G inconnue 3 h -> trace, H inconnue 1 h -> question, I JEEP 3 h -> info). Sauvegarde
`concierge-live.js.bak-2h-20261007-011338`.
### 07/10 — Le chauffe-eau prenait l'energie de la batterie : eau chaude en HC de nuit les jours faibles
**Constat (rapport du matin)** : le 06/10 (14,5 kWh produits, VRM 22,1), batterie chargee 4,6 kWh /
dechargee 16,3 ; SOC 57 -> 62 % seulement de 9 h a 16 h. Le chauffe-eau
(`sensor.tt_chauffe_eau_pleumeur_energie_totale`) a pris **5,4 kWh de 10:00 a 15:25** (~1,2 kW), 37 %
de la production, en partie sur la batterie (12 h-13 h : PV < maison). Sans lui, ~80 % a 16 h.
Lecture des deux workflows ECS (sous-agent, lecture seule) : le conseiller ne COUPE le switch que sous
priorite EVCS ; « off » n'est qu'un avis -> le ballon suit son mode AUTO. Il conseillait meme
`boost_early_strong_forecast` (10:05, 11:00, 14:40) : prevision Forecast.Solar, « forte » si le reste du
jour OU DEMAIN >= 18 kWh, ou PV >= 2,5 kW. Deux workflows actifs ecrivaient les memes helpers et le meme
switch : V2.3 (`A11aOb50cGtpJmvn`, en erreur a chaque execution) et ECS 6 Nodes (`yX2j8m04e3pxaoQq`).
**Decisions du proprietaire (07/10)** : option b (eau chaude en HC de nuit les jours faibles), conseiller
sur VRM comme le bilan, un seul workflow, securite eau chaude 1,5 kWh / 24 h.
- **V2.3 desactive** (07/10 ~06:55, `deactivate_workflow`, code intact, reactivable).
- **ECS 6 Nodes sous controle du depot** : `tools/n8n-pull.mjs` (nouveau, GET seul),
`workflows/water-heater-ecs-v1.json`, `src/water-heater/*` (8 fichiers, `lib-node-map.mjs` id
`water-heater`), tests `node tests/water-heater/run.mjs` 9/9 (chaine complete Normalize -> BUILD en vm),
branches dans `n8n-push.mjs`. Sauvegarde `water-heater-ecs-v1.json.bak-20261007` (scratchpad) + backup
GET automatique du push.
- **Bilan (`BALANCE.js`, deploye 07:00)** : `ecsNight` — UNE decision par jour, au premier calcul apres la
fin de la fenetre HC de nuit (la fenetre suivante finit dans > 12 h), figee jusqu'au lendemain (retirer
le chauffe-eau ameliore la projection : reevaluee a chaque cycle, la regle oscillerait). Jour faible =
trajectoire SIGNEE du profil habituel jusqu'au debut de la traversee du soir < SOC requis a ce debut.
Publie `input_boolean.evcs_balance_ecs_nuit` et `input_text.evcs_balance_ecs_nuit_jour`
(« AAAA-MM-JJ|on|raison »), lus en retour par le gabarit (`ecs_nuit_jour`). Tests bilan 83/83 (+4 :
sombre -> on 50,3 < 65,3 ; normal -> off ; deja decide -> rien ; 00:30 avant la fin de la nuit -> rien),
mutations qui mordent (sens du test : 2 ; decision non figee : 1).
- **Workflow ECS (deploye 07:04)** : gabarit = prevision de `input_select.evcs_balance_forecast_source`
(VRM : heures restantes, heure en cours au prorata ; demain = somme ; repli Forecast.Solar si courbe
malformee) ; prevision « forte/moyenne » sur le reste du JOUR seulement ; ARBITRATION : jour faible ->
switch coupe, allume seulement si `victron_evcs_night_grid_session_committed` ET contacteur ferme
(session reseau HC, nuit ou midi) ou si < 1,5 kWh en 24 h ; energie 24 h illisible -> aucune
restriction ; jour normal -> remise sous tension seulement si la coupure etait la sienne
(`input_boolean.victron_water_heater_off_jour_faible`) ; une coupure manuelle est respectee.
Mutations : session non exigee -> 1 ; securite eau froide retiree -> 1.
- **Service d'historisation** : `sensor.evcs_chauffe_eau_24h_kwh` (somme des hausses du compteur sur 24 h,
`tools/chauffe-eau.mjs`, tests `tests/allers-retours/chauffe-eau.mjs` 5/5), toutes les 5 min ; 5,59 kWh
a 07:03. Paquet HA `packages/evcs_chauffe_eau_nuit.yaml` (3 helpers, check_config valide, recharges).
- Gabarit rendu par HA en reel a 07:0x : reste du jour VRM 16,37 kWh, demain 33,39, source vrm. Premiere
execution apres le PUT : « Forecast: medium » (avant : strong).
- ⚠️ A verifier : premiere decision du bilan apres 07:24 et l'action sur le switch ; premiere nuit ou le
chauffe-eau chauffe en session HC ; le profil de consommation (appris sur l'historique) mettra
quelques jours a deplacer ~5 kWh du jour vers la nuit les jours faibles.
- Question du proprietaire (charger la batterie en HC de midi pour atteindre la nuit) : deja en place
depuis le 28/09 (« tenir jusqu'a la prochaine HC ») ; le 06/10 : 43,4 % requis a 14:24, SOC 57 -> pas
d'EDF, batterie arrivee a 31 % a 01:24 (> 22) : decision juste.
---
*Document mis a jour le : 07/10/2026 (~01:20) — 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 : 07/10/2026 (~07:10) — 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

@@ -353,6 +353,15 @@ semaine, 3,5 le week-end) ; le reste (~1 kWh le samedi, ~2,7 le dimanche) est
la vraie demande (repas a la maison). Jugement : pas de profil week-end pour la
nuit (1 a 2 points de SOC, sous l'ecart p50/p80) ; a reconsiderer pour midi.
**Chauffe-eau les jours faibles (07/10/2026, decision du proprietaire)** : quand
le bilan prevoit (une fois par jour, apres 07:24) que le solaire ne remplira pas
la batterie pour la nuit, le chauffe-eau est COUPE le jour et chauffe pendant la
session reseau en heures creuses (EDF ferme), jamais sur la batterie ; il chauffe
quand meme s'il a pris moins de 1,5 kWh en 24 h. Le 06/10 il avait pris 5,4 kWh
de 10 h a 15 h, l'energie qui manquait a la batterie. Un seul workflow commande
le chauffe-eau (« Water Heater Advisor ECS 6 Nodes », V2.3 desactive le 07/10),
avec la prevision VRM comme le bilan.
Les repas ne sont pas les memes d'un jour a l'autre, et l'heure du diner se
decale entre 20 h et 23 h. Consequences directes pour le bilan energetique :

View File

@@ -0,0 +1,25 @@
# =====================================================
# EVCS — CHAUFFE-EAU EN HC DE NUIT LES JOURS FAIBLES (07/10/2026, option b du
# proprietaire). Decision du bilan energetique, UNE FOIS PAR JOUR apres la fin
# de la fenetre HC de nuit, figee jusqu'au lendemain (sinon retirer le
# chauffe-eau de la journee changerait la projection : oscillateur).
# Le workflow « Water Heater Advisor ECS 6 Nodes » l'applique sur
# switch.tt_chauffe_eau_pleumeur_switch. Ecrits par le bilan : ne pas editer.
# =====================================================
input_boolean:
evcs_balance_ecs_nuit:
name: "EVCS - Chauffe-eau en HC de nuit (jour faible)"
icon: mdi:water-boiler-alert
# Ecrit par le workflow ECS : la coupure du chauffe-eau appartient a la regle
# « jour faible ». Sert a le remettre sous tension un jour normal, sans
# toucher a une coupure faite a la main.
victron_water_heater_off_jour_faible:
name: "Chauffe-eau - coupe par la regle jour faible"
icon: mdi:water-boiler-off
input_text:
# « AAAA-MM-JJ|on|raison » : date de la decision, decision, explication.
evcs_balance_ecs_nuit_jour:
name: "EVCS - Chauffe-eau : decision du jour"
max: 255
icon: mdi:calendar-check

View File

@@ -613,6 +613,55 @@ function nextDeficitWindow(slots) {
const nextDeficit = nextDeficitWindow(prudent.slots);
// ---------------------------------------------------------------------
// CHAUFFE-EAU EN HC DE NUIT LES JOURS FAIBLES (07/10/2026, option b du
// proprietaire). Le 06/10 (14,5 kWh produits), le chauffe-eau a pris 5,4 kWh
// de 10 h a 15 h, soit l'energie qui manquait a la batterie pour la nuit.
// Regle : si le solaire prevu (profil habituel) ne suffit pas a amener la
// batterie au SOC requis au debut de la traversee du soir, EDF sera ferme la
// nuit de toute facon -> l'eau se chauffe en HC de nuit sur EDF, pas avec le
// solaire du jour.
// ⚠️ DECISION UNE FOIS PAR JOUR, figee jusqu'au lendemain : retirer le
// chauffe-eau de la journee AMELIORE la projection — reevaluee a chaque cycle,
// la regle detruirait sa propre cause (oscillateur, cf. §24/§36). On decide au
// premier calcul apres la fin de la fenetre HC de nuit du jour (la fenetre
// suivante se termine alors dans plus de 12 h) ; la decision vaut pour la
// journee et la nuit qui suit, jusqu'a la decision du lendemain.
// ---------------------------------------------------------------------
const ecsNuitPrev = typeof input.ecs_nuit_jour === 'string' ? input.ecs_nuit_jour : '';
const ecsNuitPrevDate = ecsNuitPrev.split('|')[0] || '';
const nowMsEcs = num(input.nowMs, Date.now());
const todayParis = new Date(nowMsEcs).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });
const ecsNightHcEndMs = num(input.night_hc_end_ms, NaN);
const ecsDecisionDue = ecsNuitPrevDate !== todayParis
&& Number.isFinite(ecsNightHcEndMs) && ecsNightHcEndMs - nowMsEcs > 12 * 3600000;
let ecsNight = { due: ecsDecisionDue, decided: false, date: todayParis };
if (ecsDecisionDue) {
const nd50 = nextDeficitWindow(slotsP50);
if (nd50.found) {
// Trajectoire SIGNEE jusqu'au debut de la traversee (le deficit du matin
// compte), bornee au plancher et a 100 %.
let socProj = socPct;
for (let i = 0; i < nd50.hoursFromNow; i++) {
const net = slotsP50[i].netKwh;
// socAfterEnergy ignore une energie negative : la decharge passe par
// l'energie stockee (meme courbe).
socProj = net >= 0 ? Math.min(100, socAfterEnergy(socProj, net))
: Math.max(socFloorPct, socForEnergy(Math.max(0, energyBetweenSoc(0, socProj) + net), 0));
}
const on = socProj < nd50.requiredSocAtStartPct;
ecsNight = {
due: true, decided: true, date: todayParis, on,
projectedSocPct: round1(socProj),
requiredSocPct: round1(nd50.requiredSocAtStartPct),
startHour: nd50.startHour,
reason: on
? `jour faible : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude en HC de nuit`
: `jour suffisant : batterie prevue a ${round1(socProj)} % a ${nd50.startHour} h pour ${round1(nd50.requiredSocAtStartPct)} % requis -> eau chaude au solaire`,
};
}
}
// ---------------------------------------------------------------------
// RÉSERVE D'ORAGE (27/08/2026)
//
@@ -749,6 +798,9 @@ return [{
surplusAvailableKwh: round2(surplusAfterCrossingKwh),
},
// Chauffe-eau en HC de nuit les jours faibles (07/10).
ecsNight,
// Nuit prevue par la soiree (28/09) : facteur applique au profil.
nightModel: {
active: nightModelActive,

View File

@@ -105,6 +105,12 @@ setNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surpl
setBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);
// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas.
// Chauffe-eau (07/10) : n'ecrit QUE le jour de la decision (une fois par jour).
const en = balance.ecsNight || {};
if (en.decided) {
setBool('input_boolean.evcs_balance_ecs_nuit', !!en.on);
setText('input_text.evcs_balance_ecs_nuit_jour', `${en.date}|${en.on ? 'on' : 'off'}|${en.reason}`);
}
const nd = balance.nextDeficit || { found: false };
if (nd.found) {
setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);

View File

@@ -81,6 +81,7 @@
"next_night_hc_start_ms": (as_timestamp(ns.nuit) * 1000) if ns.nuit != '' else none,
"night_hc_start_ms": (as_timestamp(ns.nd) * 1000) if ns.nd != '' else none,
"night_hc_end_ms": (as_timestamp(ns.nf) * 1000) if ns.nf != '' else none,
"ecs_nuit_jour": states("input_text.evcs_balance_ecs_nuit_jour"),
"nowMs": as_timestamp(now()) * 1000,
"balance_enabled": states("input_boolean.evcs_balance_enabled"),

View File

@@ -81,6 +81,8 @@ return [{
// session de nuit s'engage.
night_hc_start_ms: snapshot.night_hc_start_ms,
night_hc_end_ms: snapshot.night_hc_end_ms,
// Decision du jour pour le chauffe-eau (07/10) : « AAAA-MM-JJ|on|raison ».
ecs_nuit_jour: snapshot.ecs_nuit_jour,
// Repris pour l'écriture des helpers, sans influencer le calcul.
resourceUsable: resource.usable,

View File

@@ -0,0 +1,353 @@
function regimeRank(regime) {
switch (regime) {
case 'off': return 0;
case 'hp_low': return 1;
case 'resistive_mid': return 2;
case 'boost_high': return 3;
default: return -1;
}
}
function allowedRankForMode(mode) {
switch (mode) {
case 'off': return 0;
case 'green': return 1;
case 'auto': return 2;
case 'boost': return 3;
default: return 0;
}
}
function round1(v) {
return Math.round(v * 10) / 10;
}
const root = $input.first()?.json ?? {};
const now = root.now ?? {};
const nowMs = root.nowMs ?? null;
const normalized = root.normalized ?? {};
const observation = root.observation ?? {};
const energyModel = root.energyModel ?? {};
const baseDecision = root.baseDecision ?? {};
let {
recommendedMode,
adviceColor,
state,
reason,
baseRecommendedMode,
baseAdviceColor,
baseState
} = baseDecision;
reason = Array.isArray(reason) ? [...reason] : [];
const {
advisorEnabled,
soc,
batteryCurrentA,
pvPowerAvg10mW,
houseAcPowerW,
todayRemainingKwh,
tomorrowKwh,
baseLoadRemainingKwh,
flexibleSafetyMarginKwh,
acLoadsMaxSafeW,
hotWaterDemand,
gridIsOn,
acInAvailable,
gridAvailable
} = normalized;
const {
evcsPriorityActive,
energyPriorityReason,
waterHeaterSwitchOn,
observedPowerW,
observedRegime,
mismatchSinceAt,
mismatchSinceMsPrev,
forcedOffByAdvisorPrev,
forcedOffReasonPrev,
forcedOffAtPrev,
restoreSurplusW,
modeMismatchDelayMin,
sampleDurationMin,
energyDeltaKwh,
sampleIsUsable
} = observation;
const {
targetSoc,
energyNeededToReachTargetKwh,
greenEnergyKwh,
autoEnergyKwh,
boostEnergyKwh,
projectedGreenSoc,
projectedAutoSoc,
projectedBoostSoc,
greenBudgetMarginKwh,
autoBudgetMarginKwh,
boostBudgetMarginKwh,
forecastStrength,
netPvHeadroomW,
pvCoversHouseNow,
pvStartedWell,
greenAllowed,
autoAllowed,
boostAllowed,
batteryNotDrainingHard
} = energyModel;
if (advisorEnabled && evcsPriorityActive) {
if (recommendedMode === 'boost' || recommendedMode === 'auto') {
if (greenAllowed && batteryNotDrainingHard) {
recommendedMode = 'green';
adviceColor = 'orange';
state = 'evcs_priority_green_cap';
reason.push(`Priorité EVCS active : chauffe-eau plafonné à GREEN (${energyPriorityReason || 'raison non précisée'})`);
} else {
recommendedMode = 'off';
adviceColor = 'red';
state = 'evcs_priority_off_cap';
reason.push(`Priorité EVCS active : chauffe-eau déconseillé (${energyPriorityReason || 'raison non précisée'})`);
}
} else if (recommendedMode === 'green' && (!greenAllowed || !batteryNotDrainingHard)) {
recommendedMode = 'off';
adviceColor = 'red';
state = 'evcs_priority_green_blocked';
reason.push(`Priorité EVCS active : GREEN devient lui-même trop coûteux (${energyPriorityReason || 'raison non précisée'})`);
}
}
const selectedLoadKwh =
recommendedMode === 'green' ? greenEnergyKwh :
recommendedMode === 'auto' ? autoEnergyKwh :
recommendedMode === 'boost' ? boostEnergyKwh :
0;
const selectedProjectedSoc =
recommendedMode === 'green' ? projectedGreenSoc :
recommendedMode === 'auto' ? projectedAutoSoc :
recommendedMode === 'boost' ? projectedBoostSoc :
soc;
const selectedBudgetMarginKwh =
recommendedMode === 'green' ? greenBudgetMarginKwh :
recommendedMode === 'auto' ? autoBudgetMarginKwh :
recommendedMode === 'boost' ? boostBudgetMarginKwh :
todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - energyNeededToReachTargetKwh;
const legacyAdviceOk = recommendedMode !== 'off';
const observedRank = regimeRank(observedRegime);
const allowedRank = allowedRankForMode(recommendedMode);
const mismatchActive =
waterHeaterSwitchOn &&
observedRank >= 0 &&
allowedRank >= 0 &&
observedRank > allowedRank;
let mismatchSeverity = 'none';
if (mismatchActive) {
if (recommendedMode === 'off') {
mismatchSeverity = 'critical';
} else if (observedRank - allowedRank >= 2) {
mismatchSeverity = 'high';
} else {
mismatchSeverity = 'medium';
}
}
const mismatchSinceMsEffective = mismatchActive ? (mismatchSinceMsPrev ?? nowMs) : null;
const mismatchSinceAtEffective = mismatchActive ? (mismatchSinceMsPrev ? mismatchSinceAt : now.stamp) : '';
const mismatchDurationMin = mismatchActive && mismatchSinceMsEffective !== null ? Math.max(0, (nowMs - mismatchSinceMsEffective) / 60000) : 0;
let pendingUserAction = '';
if (mismatchActive) {
if (recommendedMode === 'off') {
pendingUserAction = evcsPriorityActive
? 'Priorité EVCS : coupe temporairement le chauffe-eau ou laisse-le hors tension'
: 'Le chauffe-eau consomme alors qu\'il est déconseillé : coupe-le ou attends une meilleure fenêtre';
} else if (recommendedMode === 'green') {
pendingUserAction = observedRegime === 'boost_high'
? 'Passe le chauffe-eau en GREEN si possible, il semble en régime BOOST/résistance forte'
: 'Passe le chauffe-eau en GREEN si possible, il semble plus gourmand que prévu';
} else if (recommendedMode === 'auto') {
pendingUserAction = 'Le chauffe-eau semble trop gourmand pour AUTO : repasse-le en AUTO modéré ou GREEN si possible';
}
}
const firstMismatchRun = mismatchActive && (mismatchSinceMsPrev === null || mismatchDurationMin < 1);
const realConflictNow =
evcsPriorityActive &&
(
batteryCurrentA < -1 ||
pvPowerAvg10mW < restoreSurplusW ||
houseAcPowerW > acLoadsMaxSafeW ||
recommendedMode === 'off' ||
observedRegime === 'boost_high' ||
(recommendedMode === 'green' && observedRegime === 'resistive_mid')
);
const forceOffNow =
advisorEnabled &&
waterHeaterSwitchOn &&
!forcedOffByAdvisorPrev &&
mismatchActive &&
mismatchDurationMin >= modeMismatchDelayMin &&
realConflictNow;
const restoreEnergyOk =
pvPowerAvg10mW >= restoreSurplusW &&
batteryCurrentA >= -1 &&
houseAcPowerW <= acLoadsMaxSafeW;
const restoreNow =
advisorEnabled &&
forcedOffByAdvisorPrev &&
!waterHeaterSwitchOn &&
(
(!evcsPriorityActive && recommendedMode !== 'off') ||
restoreEnergyOk
);
let forcedOffByAdvisor = forcedOffByAdvisorPrev;
let forcedOffReason = forcedOffReasonPrev;
let forcedOffAt = forcedOffAtPrev;
let protectionState = 'none';
let powerSwitchAction = 'none';
let powerSwitchDesiredOn = waterHeaterSwitchOn;
if (forceOffNow) {
powerSwitchAction = 'turn_off';
powerSwitchDesiredOn = false;
forcedOffByAdvisor = true;
forcedOffReason = `Coupure temporaire ECS: priorité EVCS + régime ${observedRegime} trop gourmand`;
forcedOffAt = now.stamp;
protectionState = 'forced_off_now_evcs_priority';
state = 'forced_off_now_evcs_priority';
adviceColor = 'red';
reason.push(forcedOffReason);
}
else if (restoreNow) {
powerSwitchAction = 'turn_on';
powerSwitchDesiredOn = true;
forcedOffByAdvisor = false;
forcedOffReason = '';
protectionState = 'restored_after_evcs_priority';
state = 'restored_after_evcs_priority';
reason.push('Remise sous tension ECS : conflit EVCS terminé ou surplus redevenu suffisant');
}
else if (forcedOffByAdvisorPrev && !waterHeaterSwitchOn) {
protectionState = 'forced_off_wait_restore';
state = 'forced_off_wait_restore';
reason.push(`ECS temporairement coupé par l'advisor (${forcedOffReasonPrev || 'raison non précisée'})`);
}
else if (mismatchActive && evcsPriorityActive) {
protectionState = 'watch_mismatch_evcs_priority';
state = 'watch_mismatch_evcs_priority';
reason.push(`Écart de régime ECS sous priorité EVCS depuis ${round1(mismatchDurationMin)} min`);
}
if (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn) {
forcedOffByAdvisor = false;
forcedOffReason = '';
}
// =====================================================
// JOUR FAIBLE : EAU CHAUDE EN HC DE NUIT (07/10/2026, option b du proprietaire)
// =====================================================
// Le bilan decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) : si
// le solaire prevu ne remplit pas la batterie pour la nuit, EDF sera ferme de
// toute facon -> le chauffe-eau est COUPE le jour et chauffe pendant une
// session reseau HC engagee (contacteur ferme), au tarif creux, jamais sur la
// batterie. Securite eau chaude : moins de ECS_EAU_FROIDE_KWH en 24 h -> il
// chauffe quand meme. Energie 24 h illisible -> aucune restriction (le
// comportement d'avant revient, jamais un ballon froid par panne de capteur).
// Jour normal : le chauffe-eau coupe par cette regle est remis sous tension.
const ECS_EAU_FROIDE_KWH = 1.5;
const {
ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, offJourFaiblePrev = false,
} = normalized;
let offJourFaible = false;
let jourFaibleState = 'none';
if (advisorEnabled && ecsNuit) {
const energieConnue = Number.isFinite(ecs24hKwh);
const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH;
const heuresCreusesEdf = hcSessionCommitted && gridIsOn;
const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide;
offJourFaible = true;
jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible'
: heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf'
: eauFroide ? 'jour_faible_eau_froide'
: 'jour_faible_coupe';
if (veutChauffer !== waterHeaterSwitchOn) {
powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off';
powerSwitchDesiredOn = veutChauffer;
} else {
powerSwitchAction = 'none';
powerSwitchDesiredOn = waterHeaterSwitchOn;
}
forcedOffByAdvisor = false;
forcedOffReason = '';
protectionState = jourFaibleState;
state = jourFaibleState;
reason.push(
jourFaibleState === 'jour_faible_chauffe_hc_edf' ? 'Jour faible : eau chaude en heures creuses sur EDF (session reseau engagee)'
: jourFaibleState === 'jour_faible_eau_froide' ? `Jour faible mais seulement ${round1(ecs24hKwh)} kWh en 24 h : chauffe pour garantir l eau chaude`
: jourFaibleState === 'jour_faible_energie_illisible' ? 'Jour faible, energie 24 h illisible : aucune restriction'
: 'Jour faible : chauffe-eau coupe, il chauffera en heures creuses sur EDF'
);
} else if (advisorEnabled && offJourFaiblePrev && !waterHeaterSwitchOn) {
powerSwitchAction = 'turn_on';
powerSwitchDesiredOn = true;
jourFaibleState = 'jour_normal_remise_sous_tension';
protectionState = jourFaibleState;
state = jourFaibleState;
reason.push('Jour normal : chauffe-eau remis sous tension (il chauffe au solaire)');
}
return [{
json: {
...root,
meta: {
...(root.meta ?? {}),
node: 'ecs_final_arbitration_and_protection'
},
finalDecision: {
recommendedMode,
adviceColor,
state,
reason,
baseRecommendedMode,
baseAdviceColor,
baseState,
selectedLoadKwh,
selectedProjectedSoc,
selectedBudgetMarginKwh,
legacyAdviceOk,
mismatchActive,
mismatchSeverity,
mismatchSinceAtEffective,
mismatchSinceMsEffective,
mismatchDurationMin,
pendingUserAction,
firstMismatchRun,
realConflictNow,
forceOffNow,
restoreEnergyOk,
restoreNow,
forcedOffByAdvisor,
forcedOffReason,
forcedOffAt,
protectionState,
powerSwitchAction,
powerSwitchDesiredOn,
offJourFaible,
jourFaibleState
}
}
}];

View File

@@ -0,0 +1,128 @@
const root = $input.first()?.json ?? {};
const normalized = root.normalized ?? {};
const energyModel = root.energyModel ?? {};
const {
advisorEnabled,
hotWaterDemand,
soc,
pvPowerW,
pvPowerAvg10mW,
houseAcPowerW,
acLoadsMaxSafeW
} = normalized;
const {
targetSoc,
forecastStrength,
netPvHeadroomW,
pvCoversHouseNow,
acSafe,
greenAllowed,
earlyBoostStrongReady,
earlyBoostMediumReady,
autoReady,
greenReady,
projectedGreenSoc,
projectedAutoSoc,
projectedBoostSoc,
greenBudgetMarginKwh,
autoBudgetMarginKwh,
boostBudgetMarginKwh
} = energyModel;
let recommendedMode = 'off';
let adviceColor = 'red';
let state = 'off_waiting_pv';
let reason = [];
if (!advisorEnabled) {
recommendedMode = 'off';
adviceColor = 'red';
state = 'advisor_disabled';
reason.push('Advisor chauffe-eau désactivé');
}
else if (!acSafe) {
recommendedMode = 'off';
adviceColor = 'red';
state = 'off_ac_loads_too_high';
reason.push(`Charges maison trop élevées pour ECS (${Math.round(houseAcPowerW)} W > seuil ${Math.round(acLoadsMaxSafeW)} W)`);
}
else if (earlyBoostStrongReady) {
recommendedMode = 'boost';
adviceColor = 'green';
state = 'boost_early_strong_forecast';
reason.push('Boost tôt autorisé : forecast fort + SOC suffisant + PV net maison favorable');
}
else if (earlyBoostMediumReady) {
recommendedMode = 'boost';
adviceColor = 'orange';
state = 'boost_early_medium_forecast';
reason.push('Boost tôt autorisé : forecast moyen + SOC élevé + surplus net maison suffisant');
}
else if (autoReady) {
recommendedMode = 'auto';
adviceColor = 'green';
state = 'auto_from_pv_net';
reason.push('Mode auto recommandé : le PV couvre déjà la maison');
}
else if (greenReady) {
recommendedMode = 'green';
adviceColor = 'green';
state = 'green_waiting_pv_ramp';
reason.push('Mode green recommandé : démarrage PV détecté');
}
else {
recommendedMode = 'off';
adviceColor = 'red';
if (!pvCoversHouseNow) {
state = 'wait_for_pv_to_cover_house';
reason.push('Attente : le PV ne couvre pas encore la maison');
} else if (!greenAllowed) {
state = 'off_budget_guard';
reason.push('Attente : garde-fou budget / SOC fin de journée');
} else {
state = 'wait_for_pv_or_soc';
reason.push('Attente d\'un meilleur niveau PV / forecast / SOC');
}
}
reason.push(`Demande ECS: ${hotWaterDemand}`);
reason.push(`Forecast: ${forecastStrength}`);
reason.push(`SOC: ${Math.round(soc)}%`);
reason.push(`PV: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`);
reason.push(`Maison: ${Math.round(houseAcPowerW)} W`);
reason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`);
if (recommendedMode === 'boost') {
reason.push(`Boost budget: marge ${boostBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedBoostSoc.toFixed(1)}%`);
} else if (recommendedMode === 'auto') {
reason.push(`Auto budget: marge ${autoBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedAutoSoc.toFixed(1)}%`);
} else if (recommendedMode === 'green') {
reason.push(`Green budget: marge ${greenBudgetMarginKwh.toFixed(2)} kWh | SOC fin ${projectedGreenSoc.toFixed(1)}%`);
} else {
reason.push(`Target fin de jour: ${targetSoc.toFixed(1)}%`);
}
const baseRecommendedMode = recommendedMode;
const baseAdviceColor = adviceColor;
const baseState = state;
return [{
json: {
...root,
meta: {
...(root.meta ?? {}),
node: 'ecs_base_advisor_decision'
},
baseDecision: {
recommendedMode,
adviceColor,
state,
reason,
baseRecommendedMode,
baseAdviceColor,
baseState
}
}
}];

View File

@@ -0,0 +1,148 @@
const root = $input.first()?.json ?? {};
const calls = root.ha_service_calls ?? {};
const decision = root.decision ?? {};
function isObject(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
function isValidParisDateTime(v) {
return typeof v === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v.trim());
}
function normalizeText(v) {
return (v ?? '').toString();
}
function shouldExecute(call) {
if (!isObject(call)) return false;
return call.execute !== false;
}
function getBaseUrl() {
return ($env.HA_BASE_URL || 'http://10.10.10.17:8123').replace(/\/$/, '');
}
function getDebugEntityId(data) {
if (!isObject(data)) return '';
if (typeof data.entity_id === 'string' && data.entity_id.trim()) return data.entity_id.trim();
return '';
}
function sanitizeCall(key, call) {
if (!isObject(call)) return null;
if (!shouldExecute(call)) return null;
const domain = normalizeText(call.domain).trim();
const service = normalizeText(call.service).trim();
const data = isObject(call.data) ? { ...call.data } : {};
if (!domain || !service) return null;
// Garde-fous ciblés
if (domain === 'input_datetime' && service === 'set_datetime') {
if (!isValidParisDateTime(data.datetime)) return null;
}
if (domain === 'input_text' && service === 'set_value') {
data.value = normalizeText(data.value);
}
if (domain === 'input_select' && service === 'select_option') {
if (!normalizeText(data.option).trim()) return null;
}
if (domain === 'input_number' && service === 'set_value') {
const n = Number(data.value);
if (!Number.isFinite(n)) return null;
data.value = n;
}
if ((domain === 'switch' || domain === 'input_boolean') && !data.entity_id) {
return null;
}
if (domain === 'notify') {
data.title = normalizeText(data.title);
data.message = normalizeText(data.message);
}
const baseUrl = getBaseUrl();
const debugEntityId = getDebugEntityId(data);
const debugUrl = `${baseUrl}/api/services/${domain}/${service}`;
return {
json: {
action: key,
domain,
service,
data,
// mini debug demandé
debug_action: key,
debug_entity_id: debugEntityId || '(none)',
debug_url: debugUrl,
meta: {
source: 'water_heater_advisor',
timestamp_paris: root.timestamp_paris ?? null,
recommended_mode: decision.recommended_mode ?? null,
advice_color: decision.advice_color ?? null,
state: decision.state ?? null
}
}
};
}
// Ordre conseillé
const orderedKeys = [
'helper_recommended_mode',
'helper_advice_color',
'helper_state',
'helper_reason',
'helper_signature',
'helper_projected_soc',
'helper_budget_margin',
'helper_load_kwh',
'helper_legacy_advice_ok',
'helper_green_allowed',
'helper_auto_allowed',
'helper_boost_allowed',
'helper_observed_power',
'helper_observed_regime',
'helper_pending_user_action',
'helper_mismatch_since',
'helper_energy_prev_kwh',
'helper_energy_prev_at',
'helper_forced_off_by_advisor',
'helper_forced_off_reason',
'helper_forced_off_at',
'water_heater_power_switch',
'logbook',
'mobile_notification'
];
const actions = [];
const alreadySeen = new Set();
for (const key of orderedKeys) {
alreadySeen.add(key);
const built = sanitizeCall(key, calls[key]);
if (built) actions.push(built);
}
// Sécurité si de nouveaux appels sont ajoutés plus tard
for (const [key, call] of Object.entries(calls)) {
if (alreadySeen.has(key)) continue;
const built = sanitizeCall(key, call);
if (built) actions.push(built);
}
return actions;

View File

@@ -0,0 +1,207 @@
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
const root = $input.first()?.json ?? {};
const cfg = root.cfg ?? {};
const normalized = root.normalized ?? {};
const {
soc,
batteryCurrentA,
pvPowerW,
pvPowerAvg10mW,
houseAcPowerW,
todayRemainingKwh,
tomorrowKwh,
advisorEnabled,
hotWaterDemand,
greenPowerW,
autoPowerW,
boostPowerW,
strongForecastKwh,
mediumForecastKwh,
strongPvNowW,
mediumPvNowW,
boostMinSocStrongForecastPct,
boostMinSocMediumForecastPct,
endOfDaySocGuard,
endOfDaySocTarget,
baseLoadRemainingKwh,
flexibleSafetyMarginKwh,
acLoadsMaxSafeW
} = normalized;
const targetSoc = Math.max(endOfDaySocTarget, endOfDaySocGuard);
const batteryCapacityKwh = cfg.batteryUsableCapacityKwh ?? 17.5;
const energyTable =
(cfg.energyByDemandKwh && cfg.energyByDemandKwh[hotWaterDemand]) ||
(cfg.energyByDemandKwh && cfg.energyByDemandKwh.normal) || {
green: 1.4,
auto: 2.2,
boost: 3.1
};
const greenEnergyKwh = energyTable.green;
const autoEnergyKwh = energyTable.auto;
const boostEnergyKwh = energyTable.boost;
const energyNeededToReachTargetKwh =
Math.max(0, ((targetSoc - soc) / 100) * batteryCapacityKwh);
function projectedEndSocAfterMode(loadKwh) {
const netKwh =
todayRemainingKwh -
baseLoadRemainingKwh -
flexibleSafetyMarginKwh -
loadKwh;
const deltaSoc = (netKwh / batteryCapacityKwh) * 100;
return clamp(soc + deltaSoc, 0, 100);
}
function budgetMarginKwh(loadKwh) {
return (
todayRemainingKwh -
baseLoadRemainingKwh -
flexibleSafetyMarginKwh -
loadKwh -
energyNeededToReachTargetKwh
);
}
const projectedGreenSoc = projectedEndSocAfterMode(greenEnergyKwh);
const projectedAutoSoc = projectedEndSocAfterMode(autoEnergyKwh);
const projectedBoostSoc = projectedEndSocAfterMode(boostEnergyKwh);
const greenBudgetMarginKwh = budgetMarginKwh(greenEnergyKwh);
const autoBudgetMarginKwh = budgetMarginKwh(autoEnergyKwh);
const boostBudgetMarginKwh = budgetMarginKwh(boostEnergyKwh);
// 07/10/2026 : la force de la journee se juge sur le reste du JOUR. Avant, le
// lendemain suffisait (« || tomorrowKwh ») : le 06/10, journee de 14,5 kWh, le
// boost etait conseille parce que mercredi etait annonce fort.
const strongForecast =
todayRemainingKwh >= strongForecastKwh;
const mediumForecast =
!strongForecast &&
todayRemainingKwh >= mediumForecastKwh;
const forecastStrength =
strongForecast ? 'strong' :
mediumForecast ? 'medium' :
'weak';
const pv10mW = Number.isFinite(pvPowerAvg10mW) ? pvPowerAvg10mW : pvPowerW;
const netPvHeadroomW = Math.round(pv10mW - houseAcPowerW);
const pvCoversHouseNow = pvPowerW > houseAcPowerW;
const strongPvNow = pv10mW >= strongPvNowW || pvPowerW >= strongPvNowW;
const mediumPvNow = pv10mW >= mediumPvNowW || pvPowerW >= mediumPvNowW;
const pvStartedWell =
mediumPvNow &&
pvCoversHouseNow &&
netPvHeadroomW >= Math.max(300, Math.round(greenPowerW * 0.5));
const acSafe = acLoadsMaxSafeW <= 0 ? true : houseAcPowerW <= acLoadsMaxSafeW;
const batteryNotDrainingHard = batteryCurrentA >= -2;
const greenAllowed =
advisorEnabled &&
acSafe &&
(
projectedGreenSoc >= targetSoc - 6 ||
greenBudgetMarginKwh >= -1.0 ||
soc >= targetSoc
);
const autoAllowed =
advisorEnabled &&
acSafe &&
(
autoBudgetMarginKwh >= 0 ||
projectedAutoSoc >= targetSoc ||
(strongForecast && projectedAutoSoc >= targetSoc - 2)
);
const boostAllowedByBudget =
advisorEnabled &&
acSafe &&
(
projectedBoostSoc >= targetSoc ||
boostBudgetMarginKwh >= 0 ||
(strongForecast && projectedBoostSoc >= targetSoc - 5) ||
(strongForecast && boostBudgetMarginKwh >= -1.0)
);
const earlyBoostStrongReady =
boostAllowedByBudget &&
soc >= boostMinSocStrongForecastPct &&
pvStartedWell &&
(strongForecast || strongPvNow);
const earlyBoostMediumReady =
boostAllowedByBudget &&
mediumForecast &&
soc >= boostMinSocMediumForecastPct &&
pvStartedWell &&
netPvHeadroomW >= greenPowerW;
const autoReady =
autoAllowed &&
mediumPvNow &&
pvCoversHouseNow &&
netPvHeadroomW >= 0;
const greenReady =
greenAllowed &&
(
pv10mW >= greenPowerW ||
(pvPowerW >= greenPowerW && pvCoversHouseNow)
);
const boostAllowed = earlyBoostStrongReady || earlyBoostMediumReady;
return [{
json: {
...root,
meta: {
...(root.meta ?? {}),
node: 'ecs_energy_model_and_early_boost_signals'
},
energyModel: {
targetSoc,
batteryCapacityKwh,
greenEnergyKwh,
autoEnergyKwh,
boostEnergyKwh,
energyNeededToReachTargetKwh,
projectedGreenSoc,
projectedAutoSoc,
projectedBoostSoc,
greenBudgetMarginKwh,
autoBudgetMarginKwh,
boostBudgetMarginKwh,
forecastStrength,
strongForecast,
mediumForecast,
strongPvNow,
mediumPvNow,
netPvHeadroomW,
pvCoversHouseNow,
pvStartedWell,
acSafe,
batteryNotDrainingHard,
greenAllowed,
autoAllowed,
boostAllowedByBudget,
boostAllowed,
earlyBoostStrongReady,
earlyBoostMediumReady,
autoReady,
greenReady
}
}
}];

View File

@@ -0,0 +1,91 @@
{#- 07/10/2026 : prevision lue comme le bilan (input_select.evcs_balance_forecast_source).
VRM : reste du jour = heures restantes de la courbe horaire (heure en cours au
prorata), demain = somme de la courbe de demain. Courbe absente ou
malformee -> repli sur Forecast.Solar. -#}
{%- set src = states('input_select.evcs_balance_forecast_source') -%}
{%- set vt = states('input_text.evcs_balance_vrm_today').split(',') -%}
{%- set vd = states('input_text.evcs_balance_vrm_tomorrow').split(',') -%}
{%- set p = namespace(reste=0, demain=0, ok=(src == 'vrm' and vt | length == 24 and vd | length == 24)) -%}
{%- if p.ok -%}
{%- for i in range(now().hour, 24) -%}
{%- set v = vt[i] | float(0) -%}
{%- set p.reste = p.reste + (v * (1 - now().minute / 60) if i == now().hour else v) -%}
{%- endfor -%}
{%- for v in vd -%}{%- set p.demain = p.demain + (v | float(0)) -%}{%- endfor -%}
{%- endif -%}
{{ {
"soc_percent": states("sensor.victron_battery_soc"),
"battery_current_a": states("sensor.victron_battery_current"),
"pv_power_w": states("sensor.victron_pv_power_system"),
"pv_power_10m_avg_w": states("sensor.victron_pv_power_10m_avg"),
"house_ac_power_w": states("sensor.victron_consumption_power_l1"),
"grid_power_w": states("sensor.victron_grid_power_l1"),
"grid_state": states("switch.edf_general_ok_switch"),
"victron_acin1_available": states("sensor.victron_ac_in1_available"),
"today_remaining_kwh": (p.reste | round(2)) if p.ok else states("sensor.energy_production_today_remaining"),
"tomorrow_kwh": (p.demain | round(2)) if p.ok else states("sensor.energy_production_tomorrow"),
"forecast_source": "vrm" if p.ok else "forecast_solar",
"ecs_nuit": states("input_boolean.evcs_balance_ecs_nuit"),
"ecs_nuit_jour": states("input_text.evcs_balance_ecs_nuit_jour"),
"hc_session_committed": states("input_boolean.victron_evcs_night_grid_session_committed"),
"ecs_24h_kwh": states("sensor.evcs_chauffe_eau_24h_kwh"),
"off_jour_faible_prev": states("input_boolean.victron_water_heater_off_jour_faible"),
"advisor_enabled": states("input_boolean.victron_water_heater_advisor_enabled"),
"notifications_enabled": states("input_boolean.victron_water_heater_notifications"),
"hot_water_demand": states("input_select.victron_water_heater_hot_water_demand"),
"morning_boost_priority_enabled": states("input_boolean.victron_water_heater_morning_boost_priority_enabled"),
"morning_boost_latest_time": states("input_datetime.victron_water_heater_morning_boost_latest_time"),
"late_red_time": states("input_datetime.victron_water_heater_late_red_time"),
"green_power_w": states("input_number.victron_water_heater_green_power_w"),
"auto_power_w": states("input_number.victron_water_heater_auto_power_w"),
"boost_power_w": states("input_number.victron_water_heater_boost_power_w"),
"strong_forecast_kwh": states("input_number.victron_water_heater_strong_forecast_kwh"),
"medium_forecast_kwh": states("input_number.victron_water_heater_medium_forecast_kwh"),
"strong_pv_now_w": states("input_number.victron_water_heater_strong_pv_now_w"),
"medium_pv_now_w": states("input_number.victron_water_heater_medium_pv_now_w"),
"morning_boost_min_soc_strong_day": states("input_number.victron_water_heater_morning_boost_min_soc_strong_day"),
"boost_min_soc_medium_day": states("input_number.victron_water_heater_boost_min_soc_medium_day"),
"end_of_day_soc_guard": states("input_number.victron_water_heater_end_of_day_soc_guard"),
"end_of_day_soc_target": states("input_number.victron_end_of_day_soc_target"),
"base_load_remaining_kwh": states("input_number.victron_base_load_remaining_kwh"),
"flexible_safety_margin_kwh": states("input_number.victron_flexible_safety_margin_kwh"),
"ac_loads_max_safe_w": states("input_number.victron_ac_loads_max_safe_w"),
"day_start_time": states("input_datetime.victron_flexible_day_start_time"),
"day_stop_time": states("input_datetime.victron_flexible_day_stop_time"),
"last_signature": states("input_text.victron_water_heater_advice_last_signature"),
"evcs_priority_active": states("input_boolean.victron_evcs_priority_active"),
"energy_priority_mode": states("input_select.victron_energy_priority_mode"),
"energy_priority_reason": states("input_text.victron_energy_priority_reason"),
"evcs_priority_until": states("input_datetime.victron_evcs_priority_until"),
"water_heater_switch": states("switch.tt_chauffe_eau_pleumeur_switch"),
"water_heater_energy_total_kwh": states("sensor.tt_chauffe_eau_pleumeur_energie_totale"),
"energy_total_prev_kwh": states("input_number.victron_water_heater_energy_total_prev_kwh"),
"energy_prev_at": states("input_datetime.victron_water_heater_energy_prev_at"),
"observed_regime_helper": states("input_text.victron_water_heater_observed_regime"),
"observed_power_helper_w": states("input_number.victron_water_heater_observed_power_w"),
"mode_mismatch_since": states("input_datetime.victron_water_heater_mode_mismatch_since"),
"forced_off_by_advisor": states("input_boolean.victron_water_heater_forced_off_by_advisor"),
"forced_off_reason": states("input_text.victron_water_heater_forced_off_reason"),
"forced_off_at": states("input_datetime.victron_water_heater_forced_off_at"),
"pending_user_action_prev": states("input_text.victron_water_heater_pending_user_action"),
"restore_surplus_w": states("input_number.victron_water_heater_restore_surplus_w"),
"mode_mismatch_delay_min": states("input_number.victron_water_heater_mode_mismatch_delay_min")
} | tojson }}

View File

@@ -0,0 +1,250 @@
function num(v, fallback = NaN) {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function low(v) {
return (v ?? '').toString().trim().toLowerCase();
}
function parseMaybeJson(v) {
if (typeof v !== 'string') return v;
try {
return JSON.parse(v);
} catch {
return v;
}
}
function extractSnapshot(input) {
if (typeof input === 'string') return parseMaybeJson(input);
if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);
if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);
if (input?.data && typeof input.data === 'object') return input.data;
if (input?.body && typeof input.body === 'object') return input.body;
return input ?? {};
}
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
function to2(n) {
return String(n).padStart(2, '0');
}
function getParisNowParts() {
const fmt = new Intl.DateTimeFormat('fr-FR', {
timeZone: 'Europe/Paris',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(
fmt.formatToParts(new Date())
.filter(p => p.type !== 'literal')
.map(p => [p.type, p.value])
);
const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);
const date = `${parts.year}-${parts.month}-${parts.day}`;
const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
hour,
minute: Number(parts.minute),
second: Number(parts.second),
date,
stamp,
minNow: hour * 60 + Number(parts.minute),
};
}
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(
dtf.formatToParts(new Date(utcTs))
.filter(p => p.type !== 'literal')
.map(p => [p.type, p.value])
);
const asIfUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
Number(parts.minute),
Number(parts.second)
);
const d = new Date(utcTs);
const actualUtc = Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
d.getUTCHours(),
d.getUTCMinutes(),
d.getUTCSeconds()
);
return asIfUtc - actualUtc;
}
function parseParisStampToMs(s) {
const text = (s ?? '').toString().trim();
const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
const h = Number(m[4]);
const mi = Number(m[5]);
const se = Number(m[6]);
if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
let ts = guessUtc - off1;
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
if (off2 !== off1) ts = guessUtc - off2;
return ts;
}
function cleanStateText(v) {
const s = (v ?? '').toString().trim();
const x = s.toLowerCase();
return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;
}
const cfg = {
mobileNotifyDomain: 'notify',
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
batteryUsableCapacityKwh: 17.5,
sampleWindowMinMin: 2,
sampleWindowMinMax: 15,
energyByDemandKwh: {
low: { green: 0.9, auto: 1.4, boost: 2.0 },
normal: { green: 1.4, auto: 2.2, boost: 3.1 },
high: { green: 2.2, auto: 3.4, boost: 4.6 },
three_showers: { green: 3.0, auto: 4.4, boost: 5.8 }
}
};
const raw = $input.first()?.json ?? {};
const input = extractSnapshot(raw);
const now = getParisNowParts();
const nowMs = Date.now();
const soc = clamp(num(input.soc_percent, 0), 0, 100);
const batteryCurrentA = num(input.battery_current_a, 0);
const pvPowerW = Math.max(0, num(input.pv_power_w, 0));
const pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW));
const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));
const gridPowerW = num(input.grid_power_w, 0);
const gridIsOn = low(input.grid_state || 'off') === 'on';
const acInAvailable = num(input.victron_acin1_available, 0) === 1;
const gridAvailable = gridIsOn || acInAvailable;
const todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));
const tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0));
const advisorEnabled = low(input.advisor_enabled || 'off') === 'on';
const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';
const hotWaterDemandRaw = low(input.hot_water_demand || 'normal');
const hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw)
? hotWaterDemandRaw
: 'normal';
const greenPowerW = Math.max(0, num(input.green_power_w, 620));
const autoPowerW = Math.max(0, num(input.auto_power_w, 1200));
const boostPowerW = Math.max(0, num(input.boost_power_w, 2000));
const strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18));
const mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10));
const strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500));
const mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500));
const boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100);
const boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100);
const endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100);
const endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);
const baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0));
const flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0));
const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000));
const lastSignature = cleanStateText(input.last_signature);
// Jour faible (07/10/2026) : decision du bilan, session reseau HC engagee,
// energie du chauffe-eau sur 24 h (service d'historisation), propriete de la coupure.
const ecsNuit = low(input.ecs_nuit) === 'on';
const hcSessionCommitted = low(input.hc_session_committed) === 'on';
const ecs24hKwh = num(input.ecs_24h_kwh, NaN);
const offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on';
const forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar';
return [{
json: {
meta: {
workflow: 'water_heater_advisor',
architecture: 'ecs_6nodes_simple_early_boost',
node: 'ecs_normalize_inputs',
timestamp_paris: now.stamp
},
cfg,
now,
nowMs,
input,
normalized: {
soc,
batteryCurrentA,
pvPowerW,
pvPowerAvg10mW,
houseAcPowerW,
gridPowerW,
gridIsOn,
acInAvailable,
gridAvailable,
todayRemainingKwh,
tomorrowKwh,
advisorEnabled,
notificationsEnabled,
hotWaterDemand,
greenPowerW,
autoPowerW,
boostPowerW,
strongForecastKwh,
mediumForecastKwh,
strongPvNowW,
mediumPvNowW,
boostMinSocStrongForecastPct,
boostMinSocMediumForecastPct,
endOfDaySocGuard,
endOfDaySocTarget,
baseLoadRemainingKwh,
flexibleSafetyMarginKwh,
acLoadsMaxSafeW,
lastSignature,
ecsNuit,
hcSessionCommitted,
ecs24hKwh,
offJourFaiblePrev,
forecastSource
}
}
}];

View File

@@ -0,0 +1,207 @@
function num(v, fallback = NaN) {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function low(v) {
return (v ?? '').toString().trim().toLowerCase();
}
function cleanStateText(v) {
const s = (v ?? '').toString().trim();
const x = s.toLowerCase();
return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;
}
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(
dtf.formatToParts(new Date(utcTs))
.filter(p => p.type !== 'literal')
.map(p => [p.type, p.value])
);
const asIfUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
Number(parts.minute),
Number(parts.second)
);
const d = new Date(utcTs);
const actualUtc = Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
d.getUTCHours(),
d.getUTCMinutes(),
d.getUTCSeconds()
);
return asIfUtc - actualUtc;
}
function parseParisStampToMs(s) {
const text = (s ?? '').toString().trim();
const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
const h = Number(m[4]);
const mi = Number(m[5]);
const se = Number(m[6]);
if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
let ts = guessUtc - off1;
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
if (off2 !== off1) ts = guessUtc - off2;
return ts;
}
function cleanParisStamp(v) {
const s = cleanStateText(v);
return parseParisStampToMs(s) !== null ? s : '';
}
function minutesSince(ts, nowMs) {
if (ts === null || nowMs === null) return null;
return Math.max(0, (nowMs - ts) / 60000);
}
const root = $input.first()?.json ?? {};
const cfg = root.cfg ?? {};
const now = root.now ?? {};
const nowMs = root.nowMs ?? null;
const input = root.input ?? {};
const normalized = root.normalized ?? {};
const { greenPowerW, autoPowerW, boostPowerW } = normalized;
const evcsPriorityActiveRaw = low(input.evcs_priority_active || 'off') === 'on';
const energyPriorityMode = cleanStateText(input.energy_priority_mode || 'normal').toLowerCase() || 'normal';
const energyPriorityReason = cleanStateText(input.energy_priority_reason);
const evcsPriorityUntilAt = cleanParisStamp(input.evcs_priority_until);
const evcsPriorityUntilMs = parseParisStampToMs(evcsPriorityUntilAt);
const evcsPriorityActive =
evcsPriorityActiveRaw &&
energyPriorityMode === 'evcs_priority' &&
(
evcsPriorityUntilMs === null ||
(nowMs !== null && evcsPriorityUntilMs > nowMs + 1000)
);
const waterHeaterSwitchOn = low(input.water_heater_switch || 'off') === 'on';
const waterHeaterEnergyTotalKwh = Math.max(0, num(input.water_heater_energy_total_kwh, 0));
const energyPrevKwh = num(input.energy_total_prev_kwh, NaN);
const energyPrevAt = cleanParisStamp(input.energy_prev_at);
const energyPrevMs = parseParisStampToMs(energyPrevAt);
const observedPowerPrevW = Math.max(0, num(input.observed_power_helper_w, 0));
const observedRegimePrev = cleanStateText(input.observed_regime_helper).toLowerCase();
const mismatchSinceAt = cleanParisStamp(input.mode_mismatch_since);
const mismatchSinceMsPrev = parseParisStampToMs(mismatchSinceAt);
const forcedOffByAdvisorPrev = low(input.forced_off_by_advisor || 'off') === 'on';
const forcedOffReasonPrev = cleanStateText(input.forced_off_reason);
const forcedOffAtPrev = cleanParisStamp(input.forced_off_at);
const forcedOffAtPrevMs = parseParisStampToMs(forcedOffAtPrev);
const pendingUserActionPrev = cleanStateText(input.pending_user_action_prev);
const restoreSurplusW = Math.max(0, num(input.restore_surplus_w, 2500));
const modeMismatchDelayMin = Math.max(1, num(input.mode_mismatch_delay_min, 15));
const sampleDurationMin = minutesSince(energyPrevMs, nowMs);
const energyDeltaKwh =
Number.isFinite(energyPrevKwh)
? Math.max(0, waterHeaterEnergyTotalKwh - energyPrevKwh)
: NaN;
const sampleIsUsable =
Number.isFinite(sampleDurationMin) &&
sampleDurationMin >= (cfg.sampleWindowMinMin ?? 2) &&
sampleDurationMin <= (cfg.sampleWindowMinMax ?? 15) &&
Number.isFinite(energyDeltaKwh);
let observedPowerW = 0;
if (sampleIsUsable) {
observedPowerW = Math.max(0, (energyDeltaKwh * 60000) / sampleDurationMin);
} else if (waterHeaterSwitchOn && observedPowerPrevW > 0) {
observedPowerW = observedPowerPrevW;
}
const offThresholdW = Math.max(80, greenPowerW * 0.25);
const hpUpperW = (greenPowerW + autoPowerW) / 2;
const resistiveUpperW = (autoPowerW + boostPowerW) / 2;
let observedRegime = 'unknown';
if (!waterHeaterSwitchOn) {
observedRegime = 'off';
} else if (!sampleIsUsable && observedRegimePrev) {
observedRegime = observedRegimePrev;
} else if (observedPowerW < offThresholdW) {
observedRegime = 'off';
} else if (observedPowerW < hpUpperW) {
observedRegime = 'hp_low';
} else if (observedPowerW < resistiveUpperW) {
observedRegime = 'resistive_mid';
} else {
observedRegime = 'boost_high';
}
return [{
json: {
...root,
meta: {
...(root.meta ?? {}),
node: 'ecs_evcs_and_water_heater_observation'
},
observation: {
evcsPriorityActiveRaw,
energyPriorityMode,
energyPriorityReason,
evcsPriorityUntilAt,
evcsPriorityUntilMs,
evcsPriorityActive,
waterHeaterSwitchOn,
waterHeaterEnergyTotalKwh,
energyPrevKwh,
energyPrevAt,
energyPrevMs,
observedPowerPrevW,
observedRegimePrev,
mismatchSinceAt,
mismatchSinceMsPrev,
forcedOffByAdvisorPrev,
forcedOffReasonPrev,
forcedOffAtPrev,
forcedOffAtPrevMs,
pendingUserActionPrev,
restoreSurplusW,
modeMismatchDelayMin,
sampleDurationMin,
energyDeltaKwh,
sampleIsUsable,
observedPowerW,
observedRegime,
offThresholdW,
hpUpperW,
resistiveUpperW
}
}
}];

View File

@@ -0,0 +1,612 @@
function round1(v) {
return Math.round(v * 10) / 10;
}
function round2(v) {
return Math.round(v * 100) / 100;
}
function safeText(v, max = 255) {
return (v ?? '').toString().slice(0, max);
}
function boolService(flag) {
return flag ? 'turn_on' : 'turn_off';
}
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(
dtf.formatToParts(new Date(utcTs))
.filter(p => p.type !== 'literal')
.map(p => [p.type, p.value])
);
const asIfUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
Number(parts.minute),
Number(parts.second)
);
const d = new Date(utcTs);
const actualUtc = Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
d.getUTCHours(),
d.getUTCMinutes(),
d.getUTCSeconds()
);
return asIfUtc - actualUtc;
}
function parseParisStampToMs(s) {
const text = (s ?? '').toString().trim();
const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
const h = Number(m[4]);
const mi = Number(m[5]);
const se = Number(m[6]);
if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
let ts = guessUtc - off1;
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
if (off2 !== off1) ts = guessUtc - off2;
return ts;
}
const root = $input.first()?.json ?? {};
const cfg = root.cfg ?? {};
const now = root.now ?? {};
const input = root.input ?? {};
const normalized = root.normalized ?? {};
const observation = root.observation ?? {};
const energyModel = root.energyModel ?? {};
const finalDecision = root.finalDecision ?? {};
const {
soc,
batteryCurrentA,
pvPowerW,
pvPowerAvg10mW,
houseAcPowerW,
gridPowerW,
gridIsOn,
acInAvailable,
gridAvailable,
todayRemainingKwh,
tomorrowKwh,
advisorEnabled,
notificationsEnabled,
hotWaterDemand,
greenPowerW,
autoPowerW,
boostPowerW,
strongForecastKwh,
mediumForecastKwh,
strongPvNowW,
mediumPvNowW,
boostMinSocStrongForecastPct,
boostMinSocMediumForecastPct,
endOfDaySocGuard,
endOfDaySocTarget,
baseLoadRemainingKwh,
flexibleSafetyMarginKwh,
acLoadsMaxSafeW,
lastSignature
} = normalized;
const {
evcsPriorityActive,
energyPriorityMode,
energyPriorityReason,
evcsPriorityUntilAt,
waterHeaterSwitchOn,
waterHeaterEnergyTotalKwh,
restoreSurplusW,
modeMismatchDelayMin,
sampleDurationMin,
energyDeltaKwh,
sampleIsUsable,
observedPowerW,
observedRegime
} = observation;
const {
targetSoc,
batteryCapacityKwh,
greenEnergyKwh,
autoEnergyKwh,
boostEnergyKwh,
energyNeededToReachTargetKwh,
projectedGreenSoc,
projectedAutoSoc,
projectedBoostSoc,
greenBudgetMarginKwh,
autoBudgetMarginKwh,
boostBudgetMarginKwh,
forecastStrength,
strongForecast,
mediumForecast,
strongPvNow,
mediumPvNow,
netPvHeadroomW,
pvCoversHouseNow,
pvStartedWell,
acSafe,
batteryNotDrainingHard,
greenAllowed,
autoAllowed,
boostAllowedByBudget,
boostAllowed,
earlyBoostStrongReady,
earlyBoostMediumReady,
autoReady,
greenReady
} = energyModel;
let {
recommendedMode,
adviceColor,
state,
reason,
baseRecommendedMode,
baseAdviceColor,
baseState,
selectedLoadKwh,
selectedProjectedSoc,
selectedBudgetMarginKwh,
legacyAdviceOk,
mismatchActive,
mismatchSeverity,
mismatchSinceAtEffective,
mismatchSinceMsEffective,
mismatchDurationMin,
pendingUserAction,
firstMismatchRun,
realConflictNow,
forceOffNow,
restoreEnergyOk,
restoreNow,
forcedOffByAdvisor,
forcedOffReason,
forcedOffAt,
protectionState,
powerSwitchAction,
powerSwitchDesiredOn,
offJourFaible = false
} = finalDecision;
reason = Array.isArray(reason) ? [...reason] : [];
const forcedOffAtSafe = forcedOffAt && parseParisStampToMs(forcedOffAt) !== null ? forcedOffAt : '';
const forcedOffReasonSafe = forcedOffByAdvisor ? safeText(forcedOffReason, 255) : '';
const pendingUserActionSafe = safeText(pendingUserAction, 255);
if (reason.length === 0) {
if (recommendedMode === 'boost') reason.push('Boost recommandé');
else if (recommendedMode === 'auto') reason.push('Mode auto recommandé');
else if (recommendedMode === 'green') reason.push('Mode green recommandé');
else reason.push('Chauffe-eau déconseillé pour le moment');
}
reason.push(`Demande ECS: ${hotWaterDemand}`);
reason.push(`Conseil final: ${recommendedMode}`);
reason.push(`Conseil initial: ${baseRecommendedMode}`);
reason.push(`Couleur conseil: ${adviceColor}`);
reason.push(`Forecast du jour: ${round1(todayRemainingKwh)} kWh`);
reason.push(`Production demain: ${round1(tomorrowKwh)} kWh`);
reason.push(`Classe forecast: ${forecastStrength}`);
reason.push(`PV actuel: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`);
reason.push(`Charges AC maison: ${Math.round(houseAcPowerW)} W`);
reason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`);
reason.push(`PV couvre maison: ${pvCoversHouseNow ? 'oui' : 'non'} | PV bien démarré: ${pvStartedWell ? 'oui' : 'non'}`);
reason.push(`Batterie: SOC ${round1(soc)}% | courant ${round1(batteryCurrentA)} A`);
reason.push(`Réseau: cmd=${gridIsOn ? 'ON' : 'OFF'} | acin=${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'}`);
reason.push(`Target fin de jour: ${round1(targetSoc)}%`);
reason.push(`Base load restante: ${round1(baseLoadRemainingKwh)} kWh`);
reason.push(`Marge sécurité: ${round1(flexibleSafetyMarginKwh)} kWh`);
reason.push(`Green ${round1(greenEnergyKwh)}kWh → SOC fin ${round1(projectedGreenSoc)}% | marge ${round1(greenBudgetMarginKwh)}kWh`);
reason.push(`Auto ${round1(autoEnergyKwh)}kWh → SOC fin ${round1(projectedAutoSoc)}% | marge ${round1(autoBudgetMarginKwh)}kWh`);
reason.push(`Boost ${round1(boostEnergyKwh)}kWh → SOC fin ${round1(projectedBoostSoc)}% | marge ${round1(boostBudgetMarginKwh)}kWh`);
reason.push(`Régime observé: ${observedRegime} | puissance ≈ ${Math.round(observedPowerW)} W | delta ${sampleIsUsable ? round2(energyDeltaKwh) : 'n/a'} kWh/${sampleIsUsable ? round1(sampleDurationMin) : 'n/a'} min`);
reason.push(`Prio EVCS: ${evcsPriorityActive ? 'ON' : 'OFF'}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`);
reason.push(`Mismatch: ${mismatchActive ? 'oui' : 'non'} | sévérité=${mismatchSeverity} | durée=${round1(mismatchDurationMin)} min`);
reason.push(`Protection ECS: ${protectionState}`);
if (pendingUserActionSafe) reason.push(`Action attendue: ${pendingUserActionSafe}`);
const shortReason = reason.join(' | ').slice(0, 255);
const signature = [
now.date,
`d=${hotWaterDemand}`,
`m=${recommendedMode}`,
`base=${baseRecommendedMode}`,
`c=${adviceColor}`,
`fs=${strongForecast ? '1' : '0'}`,
`fm=${mediumForecast ? '1' : '0'}`,
`pb=${pvStartedWell ? '1' : '0'}`,
`soc=${round1(soc)}`,
`pv=${Math.round(pvPowerW)}`,
`pv10=${Math.round(pvPowerAvg10mW)}`,
`npv=${Math.round(netPvHeadroomW)}`,
`obs=${observedRegime}`,
`prio=${evcsPriorityActive ? '1' : '0'}`,
`cut=${forcedOffByAdvisor ? '1' : '0'}`
].join('|');
const signatureChanged = signature !== lastSignature;
let notifyWanted = false;
let notifyTitle = 'Water Heater Advisor';
let notifyMessage = '';
if (notificationsEnabled) {
if (forceOffNow) {
notifyWanted = true;
notifyTitle = 'ECS coupé temporairement';
notifyMessage = forcedOffReasonSafe || 'Le chauffe-eau a été coupé temporairement';
} else if (restoreNow) {
notifyWanted = true;
notifyTitle = 'ECS remis sous tension';
notifyMessage = 'Le chauffe-eau est réalimenté car la priorité EVCS est terminée ou le surplus est redevenu suffisant.';
} else if (firstMismatchRun && pendingUserActionSafe) {
notifyWanted = true;
notifyTitle = 'Action utilisateur ECS conseillée';
notifyMessage = pendingUserActionSafe;
} else if (signatureChanged) {
notifyWanted = true;
notifyTitle = `Water Heater Advisor: ${recommendedMode.toUpperCase()} / ${adviceColor.toUpperCase()}`;
notifyMessage = `Demande=${hotWaterDemand} | régime=${observedRegime} | SOC fin estimé=${round1(selectedProjectedSoc)}% | marge=${round1(selectedBudgetMarginKwh)} kWh`;
}
}
const BUDGET_MARGIN_MIN = -50;
const BUDGET_MARGIN_MAX = 50;
const result = {
timestamp_paris: now.stamp,
inputs: {
soc_percent: soc,
battery_current_a: batteryCurrentA,
pv_power_w: pvPowerW,
pv_power_10m_avg_w: pvPowerAvg10mW,
house_ac_power_w: houseAcPowerW,
grid_power_w: gridPowerW,
grid_state: gridIsOn ? 'on' : 'off',
victron_acin1_available: acInAvailable ? 1 : 0,
today_remaining_kwh: round2(todayRemainingKwh),
tomorrow_kwh: round2(tomorrowKwh),
advisor_enabled: advisorEnabled,
notifications_enabled: notificationsEnabled,
hot_water_demand: hotWaterDemand,
green_power_w: greenPowerW,
auto_power_w: autoPowerW,
boost_power_w: boostPowerW,
strong_forecast_kwh: strongForecastKwh,
medium_forecast_kwh: mediumForecastKwh,
strong_pv_now_w: strongPvNowW,
medium_pv_now_w: mediumPvNowW,
boost_min_soc_strong_forecast_pct: boostMinSocStrongForecastPct,
boost_min_soc_medium_forecast_pct: boostMinSocMediumForecastPct,
end_of_day_soc_guard: endOfDaySocGuard,
end_of_day_soc_target: endOfDaySocTarget,
base_load_remaining_kwh: baseLoadRemainingKwh,
flexible_safety_margin_kwh: flexibleSafetyMarginKwh,
ac_loads_max_safe_w: acLoadsMaxSafeW,
water_heater_switch: waterHeaterSwitchOn,
water_heater_energy_total_kwh: round2(waterHeaterEnergyTotalKwh),
last_signature: lastSignature || null,
evcs_priority_active: evcsPriorityActive,
energy_priority_mode: energyPriorityMode,
energy_priority_reason: energyPriorityReason || null,
evcs_priority_until: evcsPriorityUntilAt || null
},
computed: {
forecast_strength: forecastStrength,
strong_forecast: strongForecast,
medium_forecast: mediumForecast,
strong_pv_now: strongPvNow,
medium_pv_now: mediumPvNow,
net_pv_headroom_w: Math.round(netPvHeadroomW),
pv_covers_house_now: pvCoversHouseNow,
pv_started_well: pvStartedWell,
battery_usable_capacity_kwh_internal: batteryCapacityKwh,
energy_needed_to_reach_target_kwh: round2(energyNeededToReachTargetKwh),
green_energy_kwh: round2(greenEnergyKwh),
auto_energy_kwh: round2(autoEnergyKwh),
boost_energy_kwh: round2(boostEnergyKwh),
projected_green_soc: round1(projectedGreenSoc),
projected_auto_soc: round1(projectedAutoSoc),
projected_boost_soc: round1(projectedBoostSoc),
green_budget_margin_kwh: round2(greenBudgetMarginKwh),
auto_budget_margin_kwh: round2(autoBudgetMarginKwh),
boost_budget_margin_kwh: round2(boostBudgetMarginKwh),
ac_safe: acSafe,
battery_not_draining_hard: batteryNotDrainingHard,
green_allowed: greenAllowed,
auto_allowed: autoAllowed,
boost_allowed_by_budget: boostAllowedByBudget,
boost_allowed: boostAllowed,
early_boost_strong_ready: earlyBoostStrongReady,
early_boost_medium_ready: earlyBoostMediumReady,
auto_ready: autoReady,
green_ready: greenReady,
base_recommended_mode: baseRecommendedMode,
base_advice_color: baseAdviceColor,
base_state: baseState,
observed_power_w: Math.round(observedPowerW),
observed_regime: observedRegime,
sample_duration_min: sampleIsUsable ? round1(sampleDurationMin) : null,
energy_delta_kwh: sampleIsUsable ? round2(energyDeltaKwh) : null,
mismatch_active: mismatchActive,
mismatch_severity: mismatchSeverity,
mismatch_duration_min: round1(mismatchDurationMin),
evcs_priority_active: evcsPriorityActive,
protection_state: protectionState,
real_conflict_now: realConflictNow,
restore_energy_ok: restoreEnergyOk,
restore_surplus_w: restoreSurplusW,
mode_mismatch_delay_min: modeMismatchDelayMin
},
decision: {
recommended_mode: recommendedMode,
advice_color: adviceColor,
state,
legacy_advice_ok: legacyAdviceOk,
selected_load_kwh: round2(selectedLoadKwh),
projected_end_of_day_soc: round1(selectedProjectedSoc),
budget_margin_kwh: round2(selectedBudgetMarginKwh),
observed_power_w: Math.round(observedPowerW),
observed_regime: observedRegime,
mismatch_active: mismatchActive,
mismatch_severity: mismatchSeverity,
mismatch_duration_min: round1(mismatchDurationMin),
pending_user_action: pendingUserActionSafe,
power_switch_action: powerSwitchAction,
power_switch_desired_on: powerSwitchDesiredOn,
force_off_now: forceOffNow,
restore_now: restoreNow,
forced_off_by_advisor: forcedOffByAdvisor,
forced_off_reason: forcedOffReasonSafe,
forced_off_at: forcedOffByAdvisor ? forcedOffAtSafe || null : null,
signature,
signature_changed: signatureChanged,
should_notify: notifyWanted,
reason,
short_reason: shortReason
},
ha_service_calls: {
helper_recommended_mode: {
domain: 'input_select',
service: 'select_option',
data: {
entity_id: 'input_select.victron_water_heater_recommended_mode',
option: recommendedMode
}
},
helper_advice_color: {
domain: 'input_select',
service: 'select_option',
data: {
entity_id: 'input_select.victron_water_heater_advice_color',
option: adviceColor
}
},
helper_state: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_water_heater_advice_state',
value: state
}
},
helper_reason: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_water_heater_advice_reason',
value: shortReason || 'Aucune raison'
}
},
helper_signature: {
execute: signatureChanged,
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_water_heater_advice_last_signature',
value: signature
}
},
helper_projected_soc: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_water_heater_projected_end_of_day_soc',
value: round1(selectedProjectedSoc)
}
},
helper_budget_margin: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_water_heater_budget_margin_kwh',
value: Math.max(BUDGET_MARGIN_MIN, Math.min(BUDGET_MARGIN_MAX, round2(selectedBudgetMarginKwh)))
}
},
helper_load_kwh: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_load_water_heater_kwh',
value: round2(selectedLoadKwh)
}
},
helper_legacy_advice_ok: {
domain: 'input_boolean',
service: boolService(legacyAdviceOk),
data: {
entity_id: 'input_boolean.victron_advice_water_heater_ok'
}
},
helper_green_allowed: {
domain: 'input_boolean',
service: boolService(greenAllowed),
data: {
entity_id: 'input_boolean.victron_water_heater_green_allowed'
}
},
helper_auto_allowed: {
domain: 'input_boolean',
service: boolService(autoAllowed),
data: {
entity_id: 'input_boolean.victron_water_heater_auto_allowed'
}
},
helper_boost_allowed: {
domain: 'input_boolean',
service: boolService(boostAllowed),
data: {
entity_id: 'input_boolean.victron_water_heater_boost_allowed'
}
},
helper_observed_power: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_water_heater_observed_power_w',
value: Math.round(observedPowerW)
}
},
helper_observed_regime: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_water_heater_observed_regime',
value: observedRegime
}
},
helper_pending_user_action: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_water_heater_pending_user_action',
value: pendingUserActionSafe
}
},
helper_mismatch_since: {
execute: mismatchActive && !!mismatchSinceAtEffective,
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_water_heater_mode_mismatch_since',
datetime: mismatchSinceAtEffective
}
},
helper_energy_prev_kwh: {
domain: 'input_number',
service: 'set_value',
data: {
entity_id: 'input_number.victron_water_heater_energy_total_prev_kwh',
value: round2(waterHeaterEnergyTotalKwh)
}
},
helper_energy_prev_at: {
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_water_heater_energy_prev_at',
datetime: now.stamp
}
},
helper_forced_off_by_advisor: {
domain: 'input_boolean',
service: boolService(forcedOffByAdvisor),
data: {
entity_id: 'input_boolean.victron_water_heater_forced_off_by_advisor'
}
},
helper_forced_off_reason: {
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_water_heater_forced_off_reason',
value: forcedOffReasonSafe
}
},
helper_forced_off_at: {
execute: forceOffNow && !!forcedOffAtSafe,
domain: 'input_datetime',
service: 'set_datetime',
data: {
entity_id: 'input_datetime.victron_water_heater_forced_off_at',
datetime: forcedOffAtSafe
}
},
// Jour faible (07/10/2026) : la coupure appartient a la regle « eau chaude en HC de nuit ».
helper_off_jour_faible: {
domain: 'input_boolean',
service: boolService(offJourFaible),
data: {
entity_id: 'input_boolean.victron_water_heater_off_jour_faible'
}
},
water_heater_power_switch: {
execute: powerSwitchAction !== 'none',
domain: 'switch',
service: powerSwitchDesiredOn ? 'turn_on' : 'turn_off',
data: {
entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'
}
},
logbook: {
execute: signatureChanged || forceOffNow || restoreNow,
domain: 'logbook',
service: 'log',
data: {
name: 'Water Heater Advisor',
message: `${recommendedMode}/${adviceColor} | regime=${observedRegime} | prioEVCS=${evcsPriorityActive ? 'on' : 'off'} | cut=${forcedOffByAdvisor ? 'on' : 'off'} | ${shortReason}`,
entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'
}
},
mobile_notification: {
execute: notifyWanted,
domain: cfg.mobileNotifyDomain,
service: cfg.mobileNotifyService,
data: {
title: notifyTitle,
message: notifyMessage,
data: {
push: {
'interruption-level': 'time-sensitive',
sound: { name: 'default' }
},
ttl: 0,
priority: 'high'
}
}
}
}
};
return [{ json: result }];

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env node
// Energie du chauffe-eau sur 24 h (07/10/2026). Usage : node tests/allers-retours/chauffe-eau.mjs
import { energieSur24h } from '../../tools/chauffe-eau.mjs';
let ok = 0, ko = 0;
const v = (n, a, b) => { if (a === b) { ok++; console.log(`✅ ${n}`); } else { ko++; console.log(`❌ ${n} : attendu ${b}, obtenu ${a}`); } };
const s = (...x) => x.map((state) => ({ state: String(state) }));
v('journee-du-06-10', energieSur24h(s(12.739, 13.04, 14.23, 15.42, 16.57, 17.74, 18.15, 18.233)), 5.49);
v('compteur-immobile-0-kwh', energieSur24h(s(18.254)), 0);
v('indisponible-ignore', energieSur24h(s(10, 'unavailable', 11)), 1);
v('remise-a-zero-sans-negatif', energieSur24h(s(10, 11, 0, 0.5)), 1.5);
v('illisible-null', energieSur24h(s('unavailable')), null);
console.log(ko ? `\n${ko} ECHEC(S)` : `\nCHAUFFE-EAU 24 H OK — ${ok}/${ok}`);
process.exit(ko ? 1 : 0);

View File

@@ -122,6 +122,12 @@ const SUMMARIZE = {
vehicleSolarKwh: o.vehicleMidday ? o.vehicleMidday.solarKwh : null,
vehicleFloorAtDeparturePct: o.vehicleMidday ? o.vehicleMidday.floorAtDeparturePct : null,
vehicleGridNeeded: o.vehicleMidday ? o.vehicleMidday.gridNeeded : false,
// Chauffe-eau en HC de nuit les jours faibles (07/10).
ecsDue: o.ecsNight ? o.ecsNight.due : null,
ecsDecided: o.ecsNight ? o.ecsNight.decided : null,
ecsOn: o.ecsNight ? (o.ecsNight.on ?? null) : null,
ecsProjectedSocPct: o.ecsNight ? (o.ecsNight.projectedSocPct ?? null) : null,
ecsRequiredSocPct: o.ecsNight ? (o.ecsNight.requiredSocPct ?? null) : null,
}) : ({ usable: false, missing: o.missing.join(',') }),
};

View File

@@ -522,6 +522,39 @@ export const balanceScenarios = [
snapshot: jeep(snap2709('2026-09-27T15:00:00+02:00', 30, null, SOMBRE), 12),
expect: { usable: true, vehicleActive: false, vehicleGridNeeded: false },
},
// ======== CHAUFFE-EAU EN HC DE NUIT LES JOURS FAIBLES (07/10/2026) ========
{
node: 'BALANCE',
id: 'bilan-ecs-jour-sombre-eau-la-nuit',
label: 'Matin 07:30, journee sombre (18 kWh), SOC 40 : la batterie n arrive pas au requis du soir -> chauffe-eau en HC de nuit',
snapshot: Object.assign(snap2709('2026-09-27T07:30:00+02:00', 40, null, SOMBRE),
{ night_hc_end_ms: Date.parse('2026-09-28T07:24:00+02:00'), ecs_nuit_jour: '2026-09-26|off|x' }),
expect: { usable: true, ecsDue: true, ecsDecided: true, ecsOn: true },
},
{
node: 'BALANCE',
id: 'bilan-ecs-jour-ensoleille-eau-au-solaire',
label: 'Matin 07:30, journee normale (25,1 kWh), SOC 70 : la batterie atteint le requis du soir -> chauffe-eau au solaire',
snapshot: Object.assign(snap2709('2026-09-27T07:30:00+02:00', 70, null),
{ night_hc_end_ms: Date.parse('2026-09-28T07:24:00+02:00'), ecs_nuit_jour: '2026-09-26|on|x' }),
expect: { usable: true, ecsDue: true, ecsDecided: true, ecsOn: false },
},
{
node: 'BALANCE',
id: 'bilan-ecs-decision-figee-dans-la-journee',
label: 'Deja decide aujourd hui : pas de nouvelle decision (sinon retirer le chauffe-eau changerait la projection -> oscillateur)',
snapshot: Object.assign(snap2709('2026-09-27T13:00:00+02:00', 40, null, SOMBRE),
{ night_hc_end_ms: Date.parse('2026-09-28T07:24:00+02:00'), ecs_nuit_jour: '2026-09-27|on|x' }),
expect: { usable: true, ecsDue: false, ecsDecided: false },
},
{
node: 'BALANCE',
id: 'bilan-ecs-pas-de-decision-avant-la-fin-de-la-nuit',
label: '00:30, nouvelle date mais fenetre HC de nuit pas encore passee : la decision de la veille vaut encore',
snapshot: Object.assign(snap2709('2026-09-27T00:30:00+02:00', 40, null, SOMBRE),
{ night_hc_end_ms: Date.parse('2026-09-27T07:24:00+02:00'), ecs_nuit_jour: '2026-09-26|on|x' }),
expect: { usable: true, ecsDue: false, ecsDecided: false },
},
// ======== LES TROIS VEHICULES (28/09/2026) ========
{
node: 'BALANCE',

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env node
// Workflow « Water Heater Advisor ECS 6 Nodes » (07/10/2026) : la chaine
// Normalize -> ... -> BUILD executee sur des snapshots, comme en production.
// Usage : node tests/water-heater/run.mjs
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const CHAINE = ['NORMALIZE', 'OBSERVATION', 'ENERGY_MODEL', 'BASE_DECISION', 'ARBITRATION', 'OUTPUT_BUILDER', 'BUILD_ACTIONS'];
const code = Object.fromEntries(CHAINE.map((n) => [n, fs.readFileSync(path.join(ROOT, `src/water-heater/${n}.js`), 'utf8')]));
function executer(snapshot) {
let item = snapshot, sorties = {};
for (const n of CHAINE) {
const sb = { $input: { first: () => ({ json: item }), all: () => [{ json: item }] }, $env: {}, console };
vm.createContext(sb);
const out = vm.runInContext(`(function(){ ${code[n]} })()`, sb, { timeout: 5000 });
sorties[n] = out;
item = out[0].json;
}
return sorties;
}
// Snapshot de base : matin d'un jour faible, contacteur ouvert, chauffe-eau sous tension.
const BASE = {
soc_percent: '45', battery_current_a: '-5', pv_power_w: '900', pv_power_10m_avg_w: '850',
house_ac_power_w: '700', grid_power_w: '0', grid_state: 'off', victron_acin1_available: '0',
today_remaining_kwh: '15', tomorrow_kwh: '25', forecast_source: 'vrm',
advisor_enabled: 'on', notifications_enabled: 'off', hot_water_demand: 'normal',
water_heater_switch: 'on', water_heater_energy_total_kwh: '18.25',
ecs_nuit: 'on', hc_session_committed: 'off', ecs_24h_kwh: '5.5', off_jour_faible_prev: 'off',
evcs_priority_active: 'off', forced_off_by_advisor: 'off',
};
const cas = [
{ id: 'jour-faible-coupe-le-jour', snap: {}, attendu: { action: 'turn_off', desired: false, etat: 'jour_faible_coupe', owner: true } },
{ id: 'jour-faible-chauffe-en-hc-edf', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', grid_state: 'on', hc_session_committed: 'on' },
attendu: { action: 'turn_on', desired: true, etat: 'jour_faible_chauffe_hc_edf', owner: true } },
{ id: 'jour-faible-edf-ferme-hors-session-reste-coupe', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', grid_state: 'on', hc_session_committed: 'off' },
attendu: { action: 'none', etat: 'jour_faible_coupe', owner: true } },
{ id: 'jour-faible-eau-froide-chauffe', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', ecs_24h_kwh: '0.4' },
attendu: { action: 'turn_on', desired: true, etat: 'jour_faible_eau_froide', owner: true } },
{ id: 'jour-faible-energie-illisible-aucune-restriction', snap: { ecs_24h_kwh: 'indisponible' },
attendu: { action: 'none', etat: 'jour_faible_energie_illisible', owner: true } },
{ id: 'jour-normal-remise-sous-tension', snap: { ecs_nuit: 'off', water_heater_switch: 'off', off_jour_faible_prev: 'on' },
attendu: { action: 'turn_on', desired: true, etat: 'jour_normal_remise_sous_tension', owner: false } },
{ id: 'jour-normal-coupure-manuelle-respectee', snap: { ecs_nuit: 'off', water_heater_switch: 'off', off_jour_faible_prev: 'off' },
attendu: { action: 'none', owner: false } },
{ id: 'conseiller-desactive-rien', snap: { advisor_enabled: 'off' }, attendu: { action: 'none', owner: false } },
];
let ok = 0, ko = 0;
for (const c of cas) {
const s = executer({ ...BASE, ...c.snap });
const fd = s.ARBITRATION[0].json.finalDecision;
const appels = s.BUILD_ACTIONS.map((x) => x.json);
const sw = appels.find((a) => a.domain === 'switch' && a.data?.entity_id === 'switch.tt_chauffe_eau_pleumeur_switch');
const own = appels.find((a) => JSON.stringify(a).includes('victron_water_heater_off_jour_faible'));
const erreurs = [];
if (fd.powerSwitchAction !== c.attendu.action) erreurs.push(`action ${fd.powerSwitchAction} (attendu ${c.attendu.action})`);
if (c.attendu.desired !== undefined && fd.powerSwitchDesiredOn !== c.attendu.desired) erreurs.push(`desired ${fd.powerSwitchDesiredOn}`);
if (c.attendu.etat && fd.jourFaibleState !== c.attendu.etat) erreurs.push(`etat ${fd.jourFaibleState} (attendu ${c.attendu.etat})`);
if (fd.offJourFaible !== c.attendu.owner) erreurs.push(`propriete ${fd.offJourFaible}`);
if (c.attendu.action !== 'none' && !sw) erreurs.push('aucun appel switch emis par BUILD');
if (c.attendu.action === 'none' && sw) erreurs.push('appel switch emis alors qu aucune action');
if (!own) erreurs.push('helper de propriete absent des appels');
if (erreurs.length) { ko++; console.log(`❌ ${c.id} | ${erreurs.join(' ; ')}`); } else { ok++; console.log(`✅ ${c.id}`); }
}
// Prevision forte = le reste du jour seulement (06/10 : demain fort ne rend plus forte une journee faible).
{
const s = executer({ ...BASE, ecs_nuit: 'off', today_remaining_kwh: '8', tomorrow_kwh: '30' });
const f = s.ENERGY_MODEL[0].json.energyModel.forecastStrength;
if (f === 'weak') { ok++; console.log('✅ prevision-forte-sur-le-jour-seulement'); }
else { ko++; console.log(`❌ prevision-forte-sur-le-jour-seulement | ${f}`); }
}
console.log(ko ? `\n${ko} ECHEC(S) — NE PAS POUSSER EN PRODUCTION` : `\nWATER HEATER OK — ${ok}/${ok}`);
process.exit(ko ? 1 : 0);

14
tools/chauffe-eau.mjs Normal file
View File

@@ -0,0 +1,14 @@
// Energie du chauffe-eau sur 24 h (07/10/2026), pour la regle « jour faible :
// eau chaude en HC de nuit » du workflow Water Heater Advisor ECS 6 Nodes :
// moins de 1,5 kWh en 24 h -> il chauffe quand meme (securite eau chaude).
// Somme des HAUSSES du compteur cumule : une remise a zero ou une lecture
// indisponible ne cree ni energie negative ni saut.
export const COMPTEUR_CHAUFFE_EAU = 'sensor.tt_chauffe_eau_pleumeur_energie_totale';
export function energieSur24h(serie) {
const vals = (serie || []).map((e) => Number.parseFloat(e.state)).filter(Number.isFinite);
if (!vals.length) return null; // compteur illisible sur 24 h : on ne dit rien
let kwh = 0;
for (let i = 1; i < vals.length; i++) if (vals[i] > vals[i - 1]) kwh += vals[i] - vals[i - 1];
return Math.round(kwh * 100) / 100;
}

View File

@@ -33,6 +33,7 @@ import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from '
import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs';
import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs';
import { transitionsDansSerie } from './allers-retours.mjs';
import { energieSur24h, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ONCE = process.argv.includes('--once');
@@ -2064,6 +2065,25 @@ async function valeursAMinuit() {
return valeurs;
}
// Chauffe-eau sur 24 h (07/10/2026) : securite eau chaude de la regle « jour
// faible » du workflow ECS. Illisible -> « indisponible » : le workflow ne
// restreint alors rien (jamais un ballon froid par panne de capteur).
async function publierChauffeEau24h() {
const fin = new Date(), debut = new Date(fin.getTime() - 24 * 3600e3);
const r = await fetch(
`${HA_URL}/api/history/period/${debut.toISOString()}?end_time=${encodeURIComponent(fin.toISOString())}` +
`&filter_entity_id=${COMPTEUR_CHAUFFE_EAU}&minimal_response&no_attributes`,
{ headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(15000) },
);
if (!r.ok) throw new Error(`historique HA ${r.status}`);
const kwh = energieSur24h((await r.json())[0]);
await publierCapteur('sensor.evcs_chauffe_eau_24h_kwh', kwh === null ? 'indisponible' : kwh, {
friendly_name: 'EVCS — chauffe-eau sur 24 h', unit_of_measurement: 'kWh', icon: 'mdi:water-boiler',
compteur: COMPTEUR_CHAUFFE_EAU, calcule_a: fin.toISOString(),
});
return 1;
}
async function publierImportJour(st) {
const minuit = await valeursAMinuit();
const courant = {};
@@ -2171,6 +2191,7 @@ async function publierNuitMaison(client) {
return 1;
}
let bilanNuitPublie = false; // publie des le premier cycle, puis toutes les 5 min
let chauffeEauPublie = false; // chauffe-eau sur 24 h (07/10) : premier cycle puis toutes les 5 min
async function publierBilanNuit(client) {
if (DRY || !client) return 0;
const d = await donneesNuit(client);
@@ -2282,6 +2303,9 @@ async function cycle(client) {
nuitMaison: (DRY || (nuitMaisonPubliee && new Date().getMinutes() % 5 !== 2)) ? 0 : await publierNuitMaison(client).then((n) => { nuitMaisonPubliee = true; return n; }).catch((e) => {
console.error(`nuit de la maison : ${e.message}`); return 0;
}),
chauffeEau24h: (DRY || (chauffeEauPublie && new Date().getMinutes() % 5 !== 4)) ? 0 : await publierChauffeEau24h().then((n) => { chauffeEauPublie = true; return n; }).catch((e) => {
console.error(`chauffe-eau 24 h : ${e.message}`); return 0;
}),
bilanNuit: (DRY || (bilanNuitPublie && new Date().getMinutes() % 5 !== 1)) ? 0 : await publierBilanNuit(client).then((n) => { bilanNuitPublie = true; return n; }).catch((e) => {
console.error(`bilan de nuit : ${e.message}`); return 0;
}),

View File

@@ -61,6 +61,22 @@ export const WORKFLOWS = [
{ file: 'HA_SNAPSHOT_FORECAST.json.tpl', node: 'HA Snapshot Forecast', kind: 'template' },
],
},
{
// Chauffe-eau (07/10/2026) : seul workflow ECS actif (V2.3 desactive le 07/10).
id: 'water-heater',
workflowFile: 'workflows/water-heater-ecs-v1.json',
srcDir: 'src/water-heater',
nodes: [
{ file: 'NORMALIZE.js', node: 'ECS Normalize Inputs', kind: 'jsCode' },
{ file: 'OBSERVATION.js', node: 'ECS EVCS & Water Heater Observation', kind: 'jsCode' },
{ file: 'ENERGY_MODEL.js', node: 'ECS Energy Model & Early Boost Signals', kind: 'jsCode' },
{ file: 'BASE_DECISION.js', node: 'ECS Base Advisor Decision', kind: 'jsCode' },
{ file: 'ARBITRATION.js', node: 'ECS Final Arbitration & Protection', kind: 'jsCode' },
{ file: 'OUTPUT_BUILDER.js', node: 'ECS Output Builder', kind: 'jsCode' },
{ file: 'BUILD_ACTIONS.js', node: 'BUILD ECS HA Actions1', kind: 'jsCode' },
{ file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Water Heater Advisor V2', kind: 'template' },
],
},
];
// Rétro-compatibilité (outils historiques mono-workflow)

View File

@@ -1,47 +1,23 @@
#!/usr/bin/env node
// Usage: node tools/n8n-pull.mjs [workflowId] [outputFile]
// GET un workflow n8n et l'écrit en JSON (pretty) dans un fichier local.
// Clé API lue depuis ~/.config/opencode/opencode.json (mcp.n8n-mcp.environment).
// Usage: node tools/n8n-pull.mjs <workflowId> <fichierSortie>
// GET d'un workflow n8n vers un fichier local (mise sous controle du depot).
// Meme configuration d'API que n8n-push.mjs. N'ecrit rien sur le serveur.
import fs from 'fs';
import os from 'os';
import path from 'path';
const WORKFLOW_ID = process.argv[2] || 'ATswvGfG4KJUls0K';
const OUT = process.argv[3] || `workflows/pull-${WORKFLOW_ID}.json`;
const [ID, OUT] = process.argv.slice(2);
if (!ID || !OUT) { console.error('Usage: node tools/n8n-pull.mjs <workflowId> <fichierSortie>'); process.exit(1); }
function loadApiConfig() {
if (process.env.N8N_API_KEY) {
return {
url: process.env.N8N_URL || 'https://n8n.airtria.pro',
key: process.env.N8N_API_KEY
};
}
const cfgPath = path.join(os.homedir(), '.config/opencode/opencode.json');
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
if (process.env.N8N_API_KEY) return { url: process.env.N8N_URL || 'https://n8n.airtria.pro', key: process.env.N8N_API_KEY };
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.config/opencode/opencode.json'), 'utf8'));
const env = cfg?.mcp?.['n8n-mcp']?.environment || {};
if (!env.N8N_API_KEY) {
console.error('Clé API n8n introuvable (env N8N_API_KEY ou opencode.json)');
process.exit(1);
}
return {
url: (env.N8N_API_URL || 'https://n8n.airtria.pro/api/v1').replace(/\/api\/v1\/?$/, ''),
key: env.N8N_API_KEY
};
if (!env.N8N_API_KEY) { console.error('Clé API n8n introuvable'); process.exit(1); }
return { url: (env.N8N_API_URL || 'https://n8n.airtria.pro/api/v1').replace(/\/api\/v1\/?$/, ''), key: env.N8N_API_KEY };
}
const { url, key } = loadApiConfig();
const res = await fetch(`${url}/api/v1/workflows/${WORKFLOW_ID}`, {
headers: { 'X-N8N-API-KEY': key }
});
if (!res.ok) {
console.error(`GET échoué: ${res.status} ${res.statusText}`);
console.error(await res.text());
process.exit(1);
}
const wf = await res.json();
fs.mkdirSync(path.dirname(OUT), { recursive: true });
const r = await fetch(`${url}/api/v1/workflows/${ID}`, { headers: { 'X-N8N-API-KEY': key } });
if (!r.ok) { console.error(`GET ${r.status}`); process.exit(1); }
const wf = await r.json();
fs.writeFileSync(OUT, JSON.stringify(wf, null, 2) + '\n');
console.log(`OK: "${wf.name}" (${wf.nodes?.length} nodes, active=${wf.active}) -> ${OUT}`);
console.log(`updatedAt: ${wf.updatedAt}`);
console.log(`${OUT} ← "${wf.name}" (${wf.nodes.length} nodes, active=${wf.active})`);

View File

@@ -110,6 +110,7 @@ const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.js
const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json';
const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json';
const isAcmWorkflow = path.basename(INPUT) === 'victron-auto-charge-manager-v1.2.json';
const isWaterHeaterWorkflow = path.basename(INPUT) === 'water-heater-ecs-v1.json';
const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json'
|| path.basename(INPUT) === 'evcs-energy-profiles-daily-v1.json'
|| path.basename(INPUT) === 'evcs-solar-forecast-v1.json';
@@ -120,6 +121,7 @@ const testSuite =
isHysteresisWorkflow && fs.existsSync('tests/golden-hysteresis/run.mjs') ? 'tests/golden-hysteresis/run.mjs' :
isAcmWorkflow && fs.existsSync('tests/golden-acm/run.mjs') ? 'tests/golden-acm/run.mjs' :
isBalanceWorkflow && fs.existsSync('tests/energy-balance/run.mjs') ? 'tests/energy-balance/run.mjs' :
isWaterHeaterWorkflow && fs.existsSync('tests/water-heater/run.mjs') ? 'tests/water-heater/run.mjs' :
null;
if (testSuite) {
if (skipGolden) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long