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:
48
HANDOFF.md
48
HANDOFF.md
@@ -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
|
(G inconnue 3 h -> trace, H inconnue 1 h -> question, I JEEP 3 h -> info). Sauvegarde
|
||||||
`concierge-live.js.bak-2h-20261007-011338`.
|
`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*
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
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 :
|
decale entre 20 h et 23 h. Consequences directes pour le bilan energetique :
|
||||||
|
|
||||||
|
|||||||
25
packages/evcs_chauffe_eau_nuit.yaml
Executable file
25
packages/evcs_chauffe_eau_nuit.yaml
Executable 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
|
||||||
@@ -613,6 +613,55 @@ function nextDeficitWindow(slots) {
|
|||||||
|
|
||||||
const nextDeficit = nextDeficitWindow(prudent.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)
|
// RÉSERVE D'ORAGE (27/08/2026)
|
||||||
//
|
//
|
||||||
@@ -749,6 +798,9 @@ return [{
|
|||||||
surplusAvailableKwh: round2(surplusAfterCrossingKwh),
|
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.
|
// Nuit prevue par la soiree (28/09) : facteur applique au profil.
|
||||||
nightModel: {
|
nightModel: {
|
||||||
active: nightModelActive,
|
active: nightModelActive,
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ setNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surpl
|
|||||||
setBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);
|
setBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);
|
||||||
|
|
||||||
// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas.
|
// 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 };
|
const nd = balance.nextDeficit || { found: false };
|
||||||
if (nd.found) {
|
if (nd.found) {
|
||||||
setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);
|
setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
"next_night_hc_start_ms": (as_timestamp(ns.nuit) * 1000) if ns.nuit != '' else none,
|
"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_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,
|
"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,
|
"nowMs": as_timestamp(now()) * 1000,
|
||||||
|
|
||||||
"balance_enabled": states("input_boolean.evcs_balance_enabled"),
|
"balance_enabled": states("input_boolean.evcs_balance_enabled"),
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ return [{
|
|||||||
// session de nuit s'engage.
|
// session de nuit s'engage.
|
||||||
night_hc_start_ms: snapshot.night_hc_start_ms,
|
night_hc_start_ms: snapshot.night_hc_start_ms,
|
||||||
night_hc_end_ms: snapshot.night_hc_end_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.
|
// Repris pour l'écriture des helpers, sans influencer le calcul.
|
||||||
resourceUsable: resource.usable,
|
resourceUsable: resource.usable,
|
||||||
|
|||||||
353
src/water-heater/ARBITRATION.js
Normal file
353
src/water-heater/ARBITRATION.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
128
src/water-heater/BASE_DECISION.js
Normal file
128
src/water-heater/BASE_DECISION.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
148
src/water-heater/BUILD_ACTIONS.js
Normal file
148
src/water-heater/BUILD_ACTIONS.js
Normal 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;
|
||||||
207
src/water-heater/ENERGY_MODEL.js
Normal file
207
src/water-heater/ENERGY_MODEL.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
91
src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl
Normal file
91
src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl
Normal 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 }}
|
||||||
250
src/water-heater/NORMALIZE.js
Normal file
250
src/water-heater/NORMALIZE.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
207
src/water-heater/OBSERVATION.js
Normal file
207
src/water-heater/OBSERVATION.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
612
src/water-heater/OUTPUT_BUILDER.js
Normal file
612
src/water-heater/OUTPUT_BUILDER.js
Normal 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 }];
|
||||||
13
tests/allers-retours/chauffe-eau.mjs
Normal file
13
tests/allers-retours/chauffe-eau.mjs
Normal 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);
|
||||||
@@ -122,6 +122,12 @@ const SUMMARIZE = {
|
|||||||
vehicleSolarKwh: o.vehicleMidday ? o.vehicleMidday.solarKwh : null,
|
vehicleSolarKwh: o.vehicleMidday ? o.vehicleMidday.solarKwh : null,
|
||||||
vehicleFloorAtDeparturePct: o.vehicleMidday ? o.vehicleMidday.floorAtDeparturePct : null,
|
vehicleFloorAtDeparturePct: o.vehicleMidday ? o.vehicleMidday.floorAtDeparturePct : null,
|
||||||
vehicleGridNeeded: o.vehicleMidday ? o.vehicleMidday.gridNeeded : false,
|
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(',') }),
|
}) : ({ usable: false, missing: o.missing.join(',') }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -522,6 +522,39 @@ export const balanceScenarios = [
|
|||||||
snapshot: jeep(snap2709('2026-09-27T15:00:00+02:00', 30, null, SOMBRE), 12),
|
snapshot: jeep(snap2709('2026-09-27T15:00:00+02:00', 30, null, SOMBRE), 12),
|
||||||
expect: { usable: true, vehicleActive: false, vehicleGridNeeded: false },
|
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) ========
|
// ======== LES TROIS VEHICULES (28/09/2026) ========
|
||||||
{
|
{
|
||||||
node: 'BALANCE',
|
node: 'BALANCE',
|
||||||
|
|||||||
78
tests/water-heater/run.mjs
Normal file
78
tests/water-heater/run.mjs
Normal 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
14
tools/chauffe-eau.mjs
Normal 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;
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from '
|
|||||||
import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs';
|
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 { 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, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs';
|
||||||
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const ONCE = process.argv.includes('--once');
|
const ONCE = process.argv.includes('--once');
|
||||||
@@ -2064,6 +2065,25 @@ async function valeursAMinuit() {
|
|||||||
return valeurs;
|
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) {
|
async function publierImportJour(st) {
|
||||||
const minuit = await valeursAMinuit();
|
const minuit = await valeursAMinuit();
|
||||||
const courant = {};
|
const courant = {};
|
||||||
@@ -2171,6 +2191,7 @@ async function publierNuitMaison(client) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
let bilanNuitPublie = false; // publie des le premier cycle, puis toutes les 5 min
|
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) {
|
async function publierBilanNuit(client) {
|
||||||
if (DRY || !client) return 0;
|
if (DRY || !client) return 0;
|
||||||
const d = await donneesNuit(client);
|
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) => {
|
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;
|
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) => {
|
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;
|
console.error(`bilan de nuit : ${e.message}`); return 0;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -61,6 +61,22 @@ export const WORKFLOWS = [
|
|||||||
{ file: 'HA_SNAPSHOT_FORECAST.json.tpl', node: 'HA Snapshot Forecast', kind: 'template' },
|
{ 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)
|
// Rétro-compatibilité (outils historiques mono-workflow)
|
||||||
|
|||||||
@@ -1,47 +1,23 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Usage: node tools/n8n-pull.mjs [workflowId] [outputFile]
|
// Usage: node tools/n8n-pull.mjs <workflowId> <fichierSortie>
|
||||||
// GET un workflow n8n et l'écrit en JSON (pretty) dans un fichier local.
|
// GET d'un workflow n8n vers un fichier local (mise sous controle du depot).
|
||||||
// Clé API lue depuis ~/.config/opencode/opencode.json (mcp.n8n-mcp.environment).
|
// Meme configuration d'API que n8n-push.mjs. N'ecrit rien sur le serveur.
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const WORKFLOW_ID = process.argv[2] || 'ATswvGfG4KJUls0K';
|
const [ID, OUT] = process.argv.slice(2);
|
||||||
const OUT = process.argv[3] || `workflows/pull-${WORKFLOW_ID}.json`;
|
if (!ID || !OUT) { console.error('Usage: node tools/n8n-pull.mjs <workflowId> <fichierSortie>'); process.exit(1); }
|
||||||
|
|
||||||
function loadApiConfig() {
|
function loadApiConfig() {
|
||||||
if (process.env.N8N_API_KEY) {
|
if (process.env.N8N_API_KEY) return { url: process.env.N8N_URL || 'https://n8n.airtria.pro', key: process.env.N8N_API_KEY };
|
||||||
return {
|
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.config/opencode/opencode.json'), 'utf8'));
|
||||||
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'));
|
|
||||||
const env = cfg?.mcp?.['n8n-mcp']?.environment || {};
|
const env = cfg?.mcp?.['n8n-mcp']?.environment || {};
|
||||||
if (!env.N8N_API_KEY) {
|
if (!env.N8N_API_KEY) { console.error('Clé API n8n introuvable'); process.exit(1); }
|
||||||
console.error('Clé API n8n introuvable (env N8N_API_KEY ou opencode.json)');
|
return { url: (env.N8N_API_URL || 'https://n8n.airtria.pro/api/v1').replace(/\/api\/v1\/?$/, ''), key: env.N8N_API_KEY };
|
||||||
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 { url, key } = loadApiConfig();
|
||||||
|
const r = await fetch(`${url}/api/v1/workflows/${ID}`, { headers: { 'X-N8N-API-KEY': key } });
|
||||||
const res = await fetch(`${url}/api/v1/workflows/${WORKFLOW_ID}`, {
|
if (!r.ok) { console.error(`GET ${r.status}`); process.exit(1); }
|
||||||
headers: { 'X-N8N-API-KEY': key }
|
const wf = await r.json();
|
||||||
});
|
|
||||||
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 });
|
|
||||||
fs.writeFileSync(OUT, JSON.stringify(wf, null, 2) + '\n');
|
fs.writeFileSync(OUT, JSON.stringify(wf, null, 2) + '\n');
|
||||||
console.log(`OK: "${wf.name}" (${wf.nodes?.length} nodes, active=${wf.active}) -> ${OUT}`);
|
console.log(`${OUT} ← "${wf.name}" (${wf.nodes.length} nodes, active=${wf.active})`);
|
||||||
console.log(`updatedAt: ${wf.updatedAt}`);
|
|
||||||
|
|||||||
@@ -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 isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json';
|
||||||
const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-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 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'
|
const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json'
|
||||||
|| path.basename(INPUT) === 'evcs-energy-profiles-daily-v1.json'
|
|| path.basename(INPUT) === 'evcs-energy-profiles-daily-v1.json'
|
||||||
|| path.basename(INPUT) === 'evcs-solar-forecast-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' :
|
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' :
|
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' :
|
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;
|
null;
|
||||||
if (testSuite) {
|
if (testSuite) {
|
||||||
if (skipGolden) {
|
if (skipGolden) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
719
workflows/water-heater-ecs-v1.json
Normal file
719
workflows/water-heater-ecs-v1.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user