From 36c2fb713abccc0aecab21a557103075190ca33e Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Wed, 7 Oct 2026 07:25:41 +0200 Subject: [PATCH] =?UTF-8?q?Chauffe-eau=20:=20Grafana=20=C2=AB=20Le=20chauf?= =?UTF-8?q?fe-eau=20=C2=BB,=20historisation=20(compteur=20rattrape=20depui?= =?UTF-8?q?s=20le=2014/08),=20jour=20faible=20=3D=20aussi=20surplus=20sola?= =?UTF-8?q?ire=20reel=20et=20securite=20douche=20de=2020=20h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../grafana/dashboards/chauffe-eau.json | 468 ++++++++++++++++++ src/water-heater/ARBITRATION.js | 23 +- .../HA_SNAPSHOT_TEMPLATE.json.tpl | 1 + src/water-heater/NORMALIZE.js | 2 + tests/allers-retours/chauffe-eau.mjs | 5 +- tests/water-heater/run.mjs | 10 +- tools/chauffe-eau.mjs | 14 + tools/gen-grafana-dashboards.py | 109 +++- tools/historise-evcs.mjs | 28 +- workflows/water-heater-ecs-v1.json | 6 +- 10 files changed, 654 insertions(+), 12 deletions(-) create mode 100644 deploy/historisation/grafana/dashboards/chauffe-eau.json diff --git a/deploy/historisation/grafana/dashboards/chauffe-eau.json b/deploy/historisation/grafana/dashboards/chauffe-eau.json new file mode 100644 index 0000000..3187473 --- /dev/null +++ b/deploy/historisation/grafana/dashboards/chauffe-eau.json @@ -0,0 +1,468 @@ +{ + "templating": { + "list": [] + }, + "uid": "evcs-chauffe-eau", + "title": "Le chauffe-eau", + "description": "Quand le chauffe-eau chauffe, et sur quoi : solaire, batterie ou EDF. Les jours faibles il doit chauffer la nuit sur EDF (decision du bilan apres 07:24), jamais sur la batterie.", + "tags": [ + "evcs" + ], + "timezone": "Europe/Paris", + "editable": true, + "schemaVersion": 39, + "version": 1, + "refresh": "1m", + "time": { + "from": "now-7d", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "30s", + "1m", + "5m", + "15m", + "1h" + ] + }, + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "name": "Annotations & Alerts", + "iconColor": "rgba(0, 211, 255, 1)", + "type": "dashboard" + }, + { + "name": "Evenements", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "enable": false, + "iconColor": "semi-dark-orange", + "hide": false, + "target": { + "format": "table", + "rawQuery": true, + "editorMode": "code", + "rawSql": "SELECT ts AS time, resume AS text, genre || ',' || coalesce(source,'?') AS tags FROM evenement WHERE $__timeFilter(ts) AND genre <> 'bilan' ORDER BY 1" + } + } + ] + }, + "panels": [ + { + "type": "stat", + "title": "Maintenant", + "description": "Energie des 24 dernieres heures (securite eau chaude : sous 1,5 kWh il chauffe meme un jour faible) et puissance mesuree par le workflow ECS sur ses 5 dernieres minutes (morte jusqu'au 07/10).", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "format": "table", + "rawQuery": true, + "editorMode": "code", + "rawSql": "SELECT now() AS time,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.evcs_chauffe_eau_24h_kwh' ORDER BY ts DESC LIMIT 1) AS \"Sur 24 h\",\n (SELECT valeur FROM mesure WHERE cle = 'input_number.victron_water_heater_observed_power_w' ORDER BY ts DESC LIMIT 1) AS \"Puissance\"" + } + ], + "gridPos": { + "x": 0, + "y": 0, + "w": 8, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Sur 24 h" + }, + "properties": [ + { + "id": "unit", + "value": "kwatth" + }, + { + "id": "decimals", + "value": 2 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Puissance" + }, + "properties": [ + { + "id": "unit", + "value": "watt" + } + ] + } + ] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "orientation": "horizontal", + "textMode": "auto", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "percentChangeColorMode": "standard", + "noValue": "—", + "wideLayout": true + } + }, + { + "type": "table", + "title": "Decisions et etats", + "description": "Decision quotidienne du bilan (jour faible : eau chaude en HC de nuit), mise sous tension / coupure, changements de regime (pompe a chaleur, resistance). Journalise depuis le 07/10.", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "format": "table", + "rawQuery": true, + "editorMode": "code", + "rawSql": "SELECT to_char(ts AT TIME ZONE 'Europe/Paris', 'DD/MM HH24:MI') AS \"Quand\", resume AS \"Quoi\"\nFROM evenement WHERE genre = 'chauffe_eau' AND $__timeFilter(ts) ORDER BY ts DESC" + } + ], + "gridPos": { + "x": 8, + "y": 0, + "w": 16, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + }, + "filterable": true + } + }, + "overrides": [] + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "footer": { + "show": false + } + } + }, + { + "type": "timeseries", + "title": "Energie par heure, selon d'ou elle venait", + "description": "Une barre par heure. Contexte de l'heure : EDF si le reseau fournit plus de 300 W en moyenne, solaire si la production couvre la maison, batterie sinon (attribution par heure). Le rouge est ce qu'on veut faire disparaitre : de l'eau chauffee sur la batterie.", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "format": "time_series", + "rawQuery": true, + "editorMode": "code", + "rawSql": "WITH h AS (SELECT generate_series(date_trunc('hour', $__timeFrom()::timestamptz),\n date_trunc('hour', least(now(), $__timeTo()::timestamptz)), interval '1 hour') AS t),\nv AS (SELECT t, (SELECT valeur FROM mesure WHERE cle = 'sensor.tt_chauffe_eau_pleumeur_energie_totale' AND ts <= t\n ORDER BY ts DESC LIMIT 1) AS e FROM h),\nk AS (SELECT t, greatest(0, lead(e) OVER (ORDER BY t) - e) AS kwh FROM v),\nc AS (SELECT k.t, k.kwh,\n coalesce((SELECT avg(valeur) FROM mesure WHERE cle = 'sensor.victron_grid_power_l1' AND ts >= k.t AND ts < k.t + interval '1 hour'), (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_grid_power_l1' AND ts < k.t ORDER BY ts DESC LIMIT 1), 0) AS reseau,\n coalesce((SELECT avg(valeur) FROM mesure WHERE cle = 'sensor.victron_pv_power_system' AND ts >= k.t AND ts < k.t + interval '1 hour'), (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_pv_power_system' AND ts < k.t ORDER BY ts DESC LIMIT 1), 0) AS pv,\n coalesce((SELECT avg(valeur) FROM mesure WHERE cle = 'sensor.victron_consumption_power_l1' AND ts >= k.t AND ts < k.t + interval '1 hour'), (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_consumption_power_l1' AND ts < k.t ORDER BY ts DESC LIMIT 1), 0) AS maison\n FROM k WHERE k.kwh IS NOT NULL),\nx AS (SELECT t, kwh, CASE WHEN reseau > 300 THEN 'EDF' WHEN pv >= maison THEN 'solaire'\n ELSE 'batterie' END AS ctx FROM c)\nSELECT t AS time,\n CASE WHEN ctx = 'solaire' THEN kwh END AS \"Solaire\",\n CASE WHEN ctx = 'batterie' THEN kwh END AS \"Batterie\",\n CASE WHEN ctx = 'EDF' THEN kwh END AS \"EDF\"\nFROM x ORDER BY t" + } + ], + "gridPos": { + "x": 0, + "y": 8, + "w": 24, + "h": 9 + }, + "fieldConfig": { + "defaults": { + "unit": "kwatth", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "bars", + "fillOpacity": 85, + "lineWidth": 0, + "stacking": { + "mode": "normal", + "group": "A" + }, + "showPoints": "never", + "spanNulls": false + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Solaire" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "yellow" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Batterie" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "EDF" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + } + ] + } + ] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "type": "barchart", + "title": "Par jour", + "description": "kWh du chauffe-eau par jour et par contexte. Repere : le 06/10, 5,4 kWh dont une partie sur la batterie a midi ; un jour faible reussi n'a que du bleu (EDF en heures creuses).", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "format": "table", + "rawQuery": true, + "editorMode": "code", + "rawSql": "WITH h AS (SELECT generate_series(date_trunc('hour', $__timeFrom()::timestamptz),\n date_trunc('hour', least(now(), $__timeTo()::timestamptz)), interval '1 hour') AS t),\nv AS (SELECT t, (SELECT valeur FROM mesure WHERE cle = 'sensor.tt_chauffe_eau_pleumeur_energie_totale' AND ts <= t\n ORDER BY ts DESC LIMIT 1) AS e FROM h),\nk AS (SELECT t, greatest(0, lead(e) OVER (ORDER BY t) - e) AS kwh FROM v),\nc AS (SELECT k.t, k.kwh,\n coalesce((SELECT avg(valeur) FROM mesure WHERE cle = 'sensor.victron_grid_power_l1' AND ts >= k.t AND ts < k.t + interval '1 hour'), (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_grid_power_l1' AND ts < k.t ORDER BY ts DESC LIMIT 1), 0) AS reseau,\n coalesce((SELECT avg(valeur) FROM mesure WHERE cle = 'sensor.victron_pv_power_system' AND ts >= k.t AND ts < k.t + interval '1 hour'), (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_pv_power_system' AND ts < k.t ORDER BY ts DESC LIMIT 1), 0) AS pv,\n coalesce((SELECT avg(valeur) FROM mesure WHERE cle = 'sensor.victron_consumption_power_l1' AND ts >= k.t AND ts < k.t + interval '1 hour'), (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_consumption_power_l1' AND ts < k.t ORDER BY ts DESC LIMIT 1), 0) AS maison\n FROM k WHERE k.kwh IS NOT NULL),\nx AS (SELECT t, kwh, CASE WHEN reseau > 300 THEN 'EDF' WHEN pv >= maison THEN 'solaire'\n ELSE 'batterie' END AS ctx FROM c)\nSELECT to_char(t AT TIME ZONE 'Europe/Paris', 'DD/MM') AS \"Jour\",\n round(sum(kwh) FILTER (WHERE ctx = 'solaire')::numeric, 2) AS \"Solaire\",\n round(sum(kwh) FILTER (WHERE ctx = 'batterie')::numeric, 2) AS \"Batterie\",\n round(sum(kwh) FILTER (WHERE ctx = 'EDF')::numeric, 2) AS \"EDF\"\nFROM x GROUP BY 1, date_trunc('day', t AT TIME ZONE 'Europe/Paris')\nORDER BY date_trunc('day', t AT TIME ZONE 'Europe/Paris')" + } + ], + "gridPos": { + "x": 0, + "y": 17, + "w": 24, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "kwatth", + "color": { + "mode": "palette-classic" + }, + "custom": { + "lineWidth": 1, + "fillOpacity": 80, + "gradientMode": "none", + "axisBorderShow": false + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Solaire" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "yellow" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Batterie" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "EDF" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + } + ] + } + ] + }, + "options": { + "orientation": "auto", + "xTickLabelRotation": -45, + "showValue": "never", + "stacking": "none", + "groupWidth": 0.7, + "barWidth": 0.9, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + } + }, + { + "type": "timeseries", + "title": "Puissance mesuree et regime", + "description": "Puissance moyenne sur 5 minutes calculee par le workflow ECS depuis le compteur. Pompe a chaleur : puissance basse, ~5 h pour 55 °C ; resistance (~1,2 kW) : de 55 a 65 °C. Disponible depuis le 07/10 (avant, la mesure ne lisait jamais ses horodatages).", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "format": "time_series", + "rawQuery": true, + "editorMode": "code", + "rawSql": "SELECT ts AS time, valeur AS \"Puissance\" FROM mesure\nWHERE cle = 'input_number.victron_water_heater_observed_power_w' AND $__timeFilter(ts) ORDER BY ts" + } + ], + "gridPos": { + "x": 0, + "y": 25, + "w": 24, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "watt", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "lineInterpolation": "stepAfter", + "fillOpacity": 20 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + } + ] +} diff --git a/src/water-heater/ARBITRATION.js b/src/water-heater/ARBITRATION.js index 35b86de..99244c4 100644 --- a/src/water-heater/ARBITRATION.js +++ b/src/water-heater/ARBITRATION.js @@ -269,8 +269,15 @@ if (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn // 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; +// Douches a 06:00 et 20:00 tous les jours (proprietaire, 07/10) : couper toute +// la journee laisserait l'eau froide a 20 h. Un jour faible, il chauffe donc +// AUSSI sur un vrai surplus solaire (jamais sur la batterie), et a partir de +// 16 h s'il n'a presque pas chauffe depuis 07:24 (securite de la douche du soir). +const ECS_SOIR_DEBUT_H = 16; +const ECS_SOIR_FIN_H = 20; const { - ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, offJourFaiblePrev = false, + ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, ecsJourKwh = NaN, + offJourFaiblePrev = false, pvPowerW = 0, autoPowerW = 1200, } = normalized; let offJourFaible = false; let jourFaibleState = 'none'; @@ -278,11 +285,21 @@ 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; + // Surplus reel : sous tension, la production couvre la maison (chauffe-eau + // compris) ; hors tension, il reste de quoi l'alimenter. Reseau ouvert. + const solaireCouvre = !gridIsOn && (waterHeaterSwitchOn + ? pvPowerW >= houseAcPowerW + : pvPowerW - houseAcPowerW >= autoPowerW); + const heure = Number.isFinite(now.hour) ? now.hour : -1; + const securiteSoir = heure >= ECS_SOIR_DEBUT_H && heure < ECS_SOIR_FIN_H + && !(Number.isFinite(ecsJourKwh) && ecsJourKwh >= ECS_EAU_FROIDE_KWH); + const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide || solaireCouvre || securiteSoir; offJourFaible = true; jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible' : heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf' : eauFroide ? 'jour_faible_eau_froide' + : securiteSoir ? 'jour_faible_securite_soir' + : solaireCouvre ? 'jour_faible_surplus_solaire' : 'jour_faible_coupe'; if (veutChauffer !== waterHeaterSwitchOn) { powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off'; @@ -299,6 +316,8 @@ if (advisorEnabled && ecsNuit) { 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' + : jourFaibleState === 'jour_faible_securite_soir' ? 'Jour faible : chauffe pour la douche de 20 h (peu chauffe depuis 07:24)' + : jourFaibleState === 'jour_faible_surplus_solaire' ? 'Jour faible : surplus solaire reel, il chauffe sans toucher la batterie' : 'Jour faible : chauffe-eau coupe, il chauffera en heures creuses sur EDF' ); } else if (advisorEnabled && offJourFaiblePrev && !waterHeaterSwitchOn) { diff --git a/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl index ebb0537..e4b64df 100644 --- a/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/water-heater/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -31,6 +31,7 @@ "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"), + "ecs_jour_kwh": states("sensor.evcs_chauffe_eau_jour_kwh"), "off_jour_faible_prev": states("input_boolean.victron_water_heater_off_jour_faible"), "advisor_enabled": states("input_boolean.victron_water_heater_advisor_enabled"), diff --git a/src/water-heater/NORMALIZE.js b/src/water-heater/NORMALIZE.js index c1418ae..67ada02 100644 --- a/src/water-heater/NORMALIZE.js +++ b/src/water-heater/NORMALIZE.js @@ -195,6 +195,7 @@ const lastSignature = cleanStateText(input.last_signature); const ecsNuit = low(input.ecs_nuit) === 'on'; const hcSessionCommitted = low(input.hc_session_committed) === 'on'; const ecs24hKwh = num(input.ecs_24h_kwh, NaN); +const ecsJourKwh = num(input.ecs_jour_kwh, NaN); const offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on'; const forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar'; @@ -243,6 +244,7 @@ return [{ ecsNuit, hcSessionCommitted, ecs24hKwh, + ecsJourKwh, offJourFaiblePrev, forecastSource } diff --git a/tests/allers-retours/chauffe-eau.mjs b/tests/allers-retours/chauffe-eau.mjs index f2375ab..2aef49e 100644 --- a/tests/allers-retours/chauffe-eau.mjs +++ b/tests/allers-retours/chauffe-eau.mjs @@ -1,6 +1,6 @@ #!/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'; +import { energieSur24h, energieDepuis } 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) })); @@ -9,5 +9,8 @@ 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); +const p = (iso, state) => ({ last_changed: iso, state: String(state) }); +const serie = [p('2026-10-06T20:00:00Z', 18.0), p('2026-10-07T05:00:00Z', 18.2), p('2026-10-07T06:00:00Z', 18.6), p('2026-10-07T08:00:00Z', 19.1)]; +v('depuis-07h24-reference-avant-la-borne', energieDepuis(serie, Date.parse('2026-10-07T05:24:00Z')), 0.9); console.log(ko ? `\n${ko} ECHEC(S)` : `\nCHAUFFE-EAU 24 H OK — ${ok}/${ok}`); process.exit(ko ? 1 : 0); diff --git a/tests/water-heater/run.mjs b/tests/water-heater/run.mjs index 7926c62..6e75902 100644 --- a/tests/water-heater/run.mjs +++ b/tests/water-heater/run.mjs @@ -26,11 +26,11 @@ function executer(snapshot) { // 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', + house_ac_power_w: '2000', 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', + ecs_nuit: 'on', hc_session_committed: 'off', ecs_24h_kwh: '5.5', ecs_jour_kwh: '3', off_jour_faible_prev: 'off', evcs_priority_active: 'off', forced_off_by_advisor: 'off', }; const cas = [ @@ -43,6 +43,12 @@ const cas = [ 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-faible-surplus-solaire-chauffe', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', pv_power_w: '3500', pv_power_10m_avg_w: '3400', house_ac_power_w: '900' }, + attendu: { action: 'turn_on', desired: true, etat: 'jour_faible_surplus_solaire', owner: true } }, + { id: 'jour-faible-surplus-insuffisant-reste-coupe', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', pv_power_w: '1500', house_ac_power_w: '900' }, + attendu: { action: 'none', etat: 'jour_faible_coupe', owner: true } }, + { id: 'jour-faible-sous-tension-sur-batterie-coupe', snap: { water_heater_switch: 'on', pv_power_w: '1500', house_ac_power_w: '2100' }, + attendu: { action: 'turn_off', desired: false, etat: 'jour_faible_coupe', 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' }, diff --git a/tools/chauffe-eau.mjs b/tools/chauffe-eau.mjs index 2ed0cd8..984bfdc 100644 --- a/tools/chauffe-eau.mjs +++ b/tools/chauffe-eau.mjs @@ -5,6 +5,20 @@ // indisponible ne cree ni energie negative ni saut. export const COMPTEUR_CHAUFFE_EAU = 'sensor.tt_chauffe_eau_pleumeur_energie_totale'; +// Energie depuis `depuisMs` (meme regle : somme des hausses ; le point juste +// avant la borne sert de reference). +export function energieDepuis(serie, depuisMs) { + const pts = (serie || []).map((e) => [Date.parse(e.last_changed), Number.parseFloat(e.state)]) + .filter(([t, v]) => Number.isFinite(t) && Number.isFinite(v)); + if (!pts.length) return null; + let ref = null; const apres = []; + for (const p of pts) { if (p[0] <= depuisMs) ref = p; else apres.push(p); } + const vals = (ref ? [ref, ...apres] : apres).map((p) => p[1]); + 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; +} + 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 diff --git a/tools/gen-grafana-dashboards.py b/tools/gen-grafana-dashboards.py index e933104..6d4f16d 100644 --- a/tools/gen-grafana-dashboards.py +++ b/tools/gen-grafana-dashboards.py @@ -1971,6 +1971,112 @@ journee = dashboard( ], frm="now/d", jusqu="now/d") +# ===================================================================== +# LE CHAUFFE-EAU (07/10/2026) — demande du proprietaire +# ===================================================================== +# Le 06/10 il a pris 5,4 kWh de 10 h a 15 h, l'energie qui manquait a la +# batterie. Depuis le 07/10 il chauffe en HC de nuit les jours faibles +# (decision du bilan, workflow ECS). Ce tableau dit QUAND il chauffe et SUR +# QUOI : l'energie vient du compteur cumule du ballon (historique HA rattrape +# depuis le 14/08), le contexte de chaque heure des puissances moyennes de +# l'heure — EDF si le reseau fournit plus de 300 W, solaire si la production +# couvre la maison, batterie sinon. C'est une ATTRIBUTION par heure, pas un +# comptage par electron : une heure moitie soleil moitie batterie est classee +# d'un seul cote. +ECS_C = "sensor.tt_chauffe_eau_pleumeur_energie_totale" +def ecs_heures(select, filtre=""): + """kWh du chauffe-eau par heure locale, avec le contexte de l'heure.""" + def moy(cle): + return (f"coalesce((SELECT avg(valeur) FROM mesure WHERE cle = '{cle}' " + f"AND ts >= k.t AND ts < k.t + interval '1 hour'), " + f"(SELECT valeur FROM mesure WHERE cle = '{cle}' AND ts < k.t " + f"ORDER BY ts DESC LIMIT 1), 0)") + return ( + "WITH h AS (SELECT generate_series(date_trunc('hour', $__timeFrom()::timestamptz),\n" + " date_trunc('hour', least(now(), $__timeTo()::timestamptz)), interval '1 hour') AS t),\n" + f"v AS (SELECT t, (SELECT valeur FROM mesure WHERE cle = '{ECS_C}' AND ts <= t\n" + " ORDER BY ts DESC LIMIT 1) AS e FROM h),\n" + "k AS (SELECT t, greatest(0, lead(e) OVER (ORDER BY t) - e) AS kwh FROM v),\n" + "c AS (SELECT k.t, k.kwh,\n" + f" {moy('sensor.victron_grid_power_l1')} AS reseau,\n" + f" {moy('sensor.victron_pv_power_system')} AS pv,\n" + f" {moy('sensor.victron_consumption_power_l1')} AS maison\n" + " FROM k WHERE k.kwh IS NOT NULL),\n" + "x AS (SELECT t, kwh, CASE WHEN reseau > 300 THEN 'EDF' WHEN pv >= maison THEN 'solaire'\n" + " ELSE 'batterie' END AS ctx FROM c)\n" + select) + +ECS_PAR_SOURCE = sur # (alias de lisibilite) +COULEURS_ECS = [sur("Solaire", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "yellow"}}]), + sur("Batterie", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}]), + sur("EDF", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}])] + +chauffe_eau = dashboard("evcs-chauffe-eau", "Le chauffe-eau", ( + "Quand le chauffe-eau chauffe, et sur quoi : solaire, batterie ou EDF. " + "Les jours faibles il doit chauffer la nuit sur EDF (decision du bilan " + "apres 07:24), jamais sur la batterie."), [ + + stat("Maintenant", [sql("A", + "SELECT now() AS time,\n" + " (SELECT valeur FROM mesure WHERE cle = 'sensor.evcs_chauffe_eau_24h_kwh' ORDER BY ts DESC LIMIT 1) AS \"Sur 24 h\",\n" + " (SELECT valeur FROM mesure WHERE cle = 'input_number.victron_water_heater_observed_power_w' ORDER BY ts DESC LIMIT 1) AS \"Puissance\"", + fmt="table")], + PG, 0, 0, 8, 4, + desc="Energie des 24 dernieres heures (securite eau chaude : sous 1,5 kWh " + "il chauffe meme un jour faible) et puissance mesuree par le workflow " + "ECS sur ses 5 dernieres minutes (morte jusqu'au 07/10).", + overrides=[sur("Sur 24 h", [{"id": "unit", "value": "kwatth"}, {"id": "decimals", "value": 2}]), + sur("Puissance", [{"id": "unit", "value": "watt"}])]), + + table("Decisions et etats", [sql("A", + "SELECT to_char(ts AT TIME ZONE 'Europe/Paris', 'DD/MM HH24:MI') AS \"Quand\", resume AS \"Quoi\"\n" + "FROM evenement WHERE genre = 'chauffe_eau' AND $__timeFilter(ts) ORDER BY ts DESC", fmt="table")], + PG, 8, 0, 16, 8, + desc="Decision quotidienne du bilan (jour faible : eau chaude en HC de " + "nuit), mise sous tension / coupure, changements de regime (pompe a " + "chaleur, resistance). Journalise depuis le 07/10."), + + ts("Energie par heure, selon d'ou elle venait", [sql("A", ecs_heures( + "SELECT t AS time,\n" + " CASE WHEN ctx = 'solaire' THEN kwh END AS \"Solaire\",\n" + " CASE WHEN ctx = 'batterie' THEN kwh END AS \"Batterie\",\n" + " CASE WHEN ctx = 'EDF' THEN kwh END AS \"EDF\"\n" + "FROM x ORDER BY t"))], + PG, 0, 8, 24, 9, unit="kwatth", + desc="Une barre par heure. Contexte de l'heure : EDF si le reseau fournit " + "plus de 300 W en moyenne, solaire si la production couvre la maison, " + "batterie sinon (attribution par heure). Le rouge est ce qu'on veut " + "faire disparaitre : de l'eau chauffee sur la batterie.", + extra={"defaults": {"custom": {"drawStyle": "bars", "fillOpacity": 85, "lineWidth": 0, + "stacking": {"mode": "normal", "group": "A"}, + "showPoints": "never", "spanNulls": False}}, + "overrides": COULEURS_ECS}), + + barchart("Par jour", [sql("A", ecs_heures( + "SELECT to_char(t AT TIME ZONE 'Europe/Paris', 'DD/MM') AS \"Jour\",\n" + " round(sum(kwh) FILTER (WHERE ctx = 'solaire')::numeric, 2) AS \"Solaire\",\n" + " round(sum(kwh) FILTER (WHERE ctx = 'batterie')::numeric, 2) AS \"Batterie\",\n" + " round(sum(kwh) FILTER (WHERE ctx = 'EDF')::numeric, 2) AS \"EDF\"\n" + "FROM x GROUP BY 1, date_trunc('day', t AT TIME ZONE 'Europe/Paris')\n" + "ORDER BY date_trunc('day', t AT TIME ZONE 'Europe/Paris')"), fmt="table")], + PG, 0, 17, 24, 8, unit="kwatth", + desc="kWh du chauffe-eau par jour et par contexte. Repere : le 06/10, 5,4 kWh " + "dont une partie sur la batterie a midi ; un jour faible reussi n'a que " + "du bleu (EDF en heures creuses).", + overrides=COULEURS_ECS), + + ts("Puissance mesuree et regime", [sql("A", + "SELECT ts AS time, valeur AS \"Puissance\" FROM mesure\n" + "WHERE cle = 'input_number.victron_water_heater_observed_power_w' AND $__timeFilter(ts) ORDER BY ts")], + PG, 0, 25, 24, 8, unit="watt", + desc="Puissance moyenne sur 5 minutes calculee par le workflow ECS depuis le " + "compteur. Pompe a chaleur : puissance basse, ~5 h pour 55 °C ; " + "resistance (~1,2 kW) : de 55 a 65 °C. Disponible depuis le 07/10 " + "(avant, la mesure ne lisait jamais ses horodatages).", + extra={"defaults": {"custom": {"drawStyle": "line", "lineInterpolation": "stepAfter", + "fillOpacity": 20}}}), +], frm="now-7d") + + base = pathlib.Path('deploy/historisation/grafana/dashboards') for nom, d in [('journee.json', journee), ('nuit.json', nuit), ('solaire.json', solaire), @@ -1978,6 +2084,7 @@ for nom, d in [('journee.json', journee), ('telephone.json', telephone), ('systeme.json', systeme), ('vehicules.json', vehicules), - ('batteries.json', batteries)]: + ('batteries.json', batteries), + ('chauffe-eau.json', chauffe_eau)]: (base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n') print(f"{nom:16s} {len(d['panels'])} panneaux") diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 2065fb9..e07e3c9 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -33,7 +33,7 @@ import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from ' import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs'; import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs'; import { transitionsDansSerie } from './allers-retours.mjs'; -import { energieSur24h, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs'; +import { energieSur24h, energieDepuis, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ONCE = process.argv.includes('--once'); @@ -99,6 +99,11 @@ const MESURES = [ 'sensor.jeep_renegade_phev_odometer', 'sensor.jeep_renegade_phev_hvbattery_charge', 'sensor.jeep_renegade_phev_extrapolated_battery', + // Chauffe-eau (07/10/2026) : compteur cumule, puissance observee par le + // workflow ECS (morte jusqu'au 07/10, cf. HANDOFF), energie sur 24 h. + 'sensor.tt_chauffe_eau_pleumeur_energie_totale', + 'input_number.victron_water_heater_observed_power_w', + 'sensor.evcs_chauffe_eau_24h_kwh', ]; // --------------------------------------------------------------------- @@ -129,6 +134,10 @@ const TRANSITIONS_SUIVIES = [ ['input_select.victron_evcs_vehicle', 'intervention','Vehicule selectionne'], ['input_select.victron_evcs_strategy', 'intervention','Strategie'], ['input_select.victron_evcs_night_charge_mode', 'intervention','Charge de la voiture la nuit'], + ['switch.tt_chauffe_eau_pleumeur_switch', 'chauffe_eau', 'Chauffe-eau sous tension'], + ['input_boolean.evcs_balance_ecs_nuit', 'chauffe_eau', 'Chauffe-eau en HC de nuit (jour faible)'], + ['input_text.evcs_balance_ecs_nuit_jour', 'chauffe_eau', 'Decision du jour pour le chauffe-eau'], + ['input_text.victron_water_heater_observed_regime', 'chauffe_eau', 'Regime du chauffe-eau'], ['input_text.victron_evcs_night_budget', 'vehicule', 'Budget voiture de la nuit fige (kWh|index borne)'], ['sensor.gx_device_ess_batterylife_state', 'ess', 'Etat BatteryLife de l ESS'], ['input_text.victron_charge_mode', 'ess', 'Mode Auto Charge Manager'], @@ -2076,12 +2085,25 @@ async function publierChauffeEau24h() { { 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]); + const serie = (await r.json())[0]; + const kwh = energieSur24h(serie); 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; + // Depuis la fin de la derniere fenetre HC de nuit (07:24 heure de Paris) : + // securite de la douche du soir (20 h), regle « jour faible ». + const p = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(fin); + const [jour, hm] = p.split(' '); + const decalage = new Date(fin.toLocaleString('en-US', { timeZone: 'Europe/Paris' })).getTime() - new Date(fin.toLocaleString('en-US', { timeZone: 'UTC' })).getTime(); + let borne = Date.parse(`${jour}T07:24:00Z`) - decalage; + if (hm < '07:24') borne -= 24 * 3600e3; + const kwhJour = energieDepuis(serie, borne); + await publierCapteur('sensor.evcs_chauffe_eau_jour_kwh', kwhJour === null ? 'indisponible' : kwhJour, { + friendly_name: 'EVCS — chauffe-eau depuis 07:24', unit_of_measurement: 'kWh', icon: 'mdi:water-boiler', + depuis: new Date(borne).toISOString(), calcule_a: fin.toISOString(), + }); + return 2; } async function publierImportJour(st) { diff --git a/workflows/water-heater-ecs-v1.json b/workflows/water-heater-ecs-v1.json index 6a7140d..360dfc8 100644 --- a/workflows/water-heater-ecs-v1.json +++ b/workflows/water-heater-ecs-v1.json @@ -9,7 +9,7 @@ "nodes": [ { "parameters": { - "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input ?? {};\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisNowParts() {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n minNow: hour * 60 + Number(parts.minute),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanStateText(v) {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n batteryUsableCapacityKwh: 17.5,\n sampleWindowMinMin: 2,\n sampleWindowMinMax: 15,\n energyByDemandKwh: {\n low: { green: 0.9, auto: 1.4, boost: 2.0 },\n normal: { green: 1.4, auto: 2.2, boost: 3.1 },\n high: { green: 2.2, auto: 3.4, boost: 4.6 },\n three_showers: { green: 3.0, auto: 4.4, boost: 5.8 }\n }\n};\n\nconst raw = $input.first()?.json ?? {};\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = Date.now();\n\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst batteryCurrentA = num(input.battery_current_a, 0);\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\nconst pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW));\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\nconst acInAvailable = num(input.victron_acin1_available, 0) === 1;\nconst gridAvailable = gridIsOn || acInAvailable;\nconst todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));\nconst tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0));\nconst advisorEnabled = low(input.advisor_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\n\nconst hotWaterDemandRaw = low(input.hot_water_demand || 'normal');\nconst hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw)\n ? hotWaterDemandRaw\n : 'normal';\n\nconst greenPowerW = Math.max(0, num(input.green_power_w, 620));\nconst autoPowerW = Math.max(0, num(input.auto_power_w, 1200));\nconst boostPowerW = Math.max(0, num(input.boost_power_w, 2000));\nconst strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18));\nconst mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10));\nconst strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500));\nconst mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500));\nconst boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100);\nconst boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100);\nconst endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100);\nconst endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);\nconst baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0));\nconst flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000));\nconst lastSignature = cleanStateText(input.last_signature);\n// Jour faible (07/10/2026) : decision du bilan, session reseau HC engagee,\n// energie du chauffe-eau sur 24 h (service d'historisation), propriete de la coupure.\nconst ecsNuit = low(input.ecs_nuit) === 'on';\nconst hcSessionCommitted = low(input.hc_session_committed) === 'on';\nconst ecs24hKwh = num(input.ecs_24h_kwh, NaN);\nconst offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on';\nconst forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar';\n\nreturn [{\n json: {\n meta: {\n workflow: 'water_heater_advisor',\n architecture: 'ecs_6nodes_simple_early_boost',\n node: 'ecs_normalize_inputs',\n timestamp_paris: now.stamp\n },\n cfg,\n now,\n nowMs,\n input,\n normalized: {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature,\n ecsNuit,\n hcSessionCommitted,\n ecs24hKwh,\n offJourFaiblePrev,\n forecastSource\n }\n }\n}];" + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input ?? {};\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisNowParts() {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n minNow: hour * 60 + Number(parts.minute),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanStateText(v) {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n batteryUsableCapacityKwh: 17.5,\n sampleWindowMinMin: 2,\n sampleWindowMinMax: 15,\n energyByDemandKwh: {\n low: { green: 0.9, auto: 1.4, boost: 2.0 },\n normal: { green: 1.4, auto: 2.2, boost: 3.1 },\n high: { green: 2.2, auto: 3.4, boost: 4.6 },\n three_showers: { green: 3.0, auto: 4.4, boost: 5.8 }\n }\n};\n\nconst raw = $input.first()?.json ?? {};\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = Date.now();\n\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst batteryCurrentA = num(input.battery_current_a, 0);\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\nconst pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW));\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\nconst acInAvailable = num(input.victron_acin1_available, 0) === 1;\nconst gridAvailable = gridIsOn || acInAvailable;\nconst todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));\nconst tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0));\nconst advisorEnabled = low(input.advisor_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\n\nconst hotWaterDemandRaw = low(input.hot_water_demand || 'normal');\nconst hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw)\n ? hotWaterDemandRaw\n : 'normal';\n\nconst greenPowerW = Math.max(0, num(input.green_power_w, 620));\nconst autoPowerW = Math.max(0, num(input.auto_power_w, 1200));\nconst boostPowerW = Math.max(0, num(input.boost_power_w, 2000));\nconst strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18));\nconst mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10));\nconst strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500));\nconst mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500));\nconst boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100);\nconst boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100);\nconst endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100);\nconst endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);\nconst baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0));\nconst flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000));\nconst lastSignature = cleanStateText(input.last_signature);\n// Jour faible (07/10/2026) : decision du bilan, session reseau HC engagee,\n// energie du chauffe-eau sur 24 h (service d'historisation), propriete de la coupure.\nconst ecsNuit = low(input.ecs_nuit) === 'on';\nconst hcSessionCommitted = low(input.hc_session_committed) === 'on';\nconst ecs24hKwh = num(input.ecs_24h_kwh, NaN);\nconst ecsJourKwh = num(input.ecs_jour_kwh, NaN);\nconst offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on';\nconst forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar';\n\nreturn [{\n json: {\n meta: {\n workflow: 'water_heater_advisor',\n architecture: 'ecs_6nodes_simple_early_boost',\n node: 'ecs_normalize_inputs',\n timestamp_paris: now.stamp\n },\n cfg,\n now,\n nowMs,\n input,\n normalized: {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature,\n ecsNuit,\n hcSessionCommitted,\n ecs24hKwh,\n ecsJourKwh,\n offJourFaiblePrev,\n forecastSource\n }\n }\n}];" }, "id": "1226082d-222c-4837-99d3-35094bd1c4f1", "name": "ECS Normalize Inputs", @@ -61,7 +61,7 @@ }, { "parameters": { - "jsCode": "function regimeRank(regime) {\n switch (regime) {\n case 'off': return 0;\n case 'hp_low': return 1;\n case 'resistive_mid': return 2;\n case 'boost_high': return 3;\n default: return -1;\n }\n}\n\nfunction allowedRankForMode(mode) {\n switch (mode) {\n case 'off': return 0;\n case 'green': return 1;\n case 'auto': return 2;\n case 'boost': return 3;\n default: return 0;\n }\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nconst root = $input.first()?.json ?? {};\nconst now = root.now ?? {};\nconst nowMs = root.nowMs ?? null;\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst baseDecision = root.baseDecision ?? {};\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState\n} = baseDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst {\n advisorEnabled,\n soc,\n batteryCurrentA,\n pvPowerAvg10mW,\n houseAcPowerW,\n todayRemainingKwh,\n tomorrowKwh,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n hotWaterDemand,\n gridIsOn,\n acInAvailable,\n gridAvailable\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityReason,\n waterHeaterSwitchOn,\n observedPowerW,\n observedRegime,\n mismatchSinceAt,\n mismatchSinceMsPrev,\n forcedOffByAdvisorPrev,\n forcedOffReasonPrev,\n forcedOffAtPrev,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable\n} = observation;\n\nconst {\n targetSoc,\n energyNeededToReachTargetKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n greenAllowed,\n autoAllowed,\n boostAllowed,\n batteryNotDrainingHard\n} = energyModel;\n\nif (advisorEnabled && evcsPriorityActive) {\n if (recommendedMode === 'boost' || recommendedMode === 'auto') {\n if (greenAllowed && batteryNotDrainingHard) {\n recommendedMode = 'green';\n adviceColor = 'orange';\n state = 'evcs_priority_green_cap';\n reason.push(`Priorité EVCS active : chauffe-eau plafonné à GREEN (${energyPriorityReason || 'raison non précisée'})`);\n } else {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_off_cap';\n reason.push(`Priorité EVCS active : chauffe-eau déconseillé (${energyPriorityReason || 'raison non précisée'})`);\n }\n } else if (recommendedMode === 'green' && (!greenAllowed || !batteryNotDrainingHard)) {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_green_blocked';\n reason.push(`Priorité EVCS active : GREEN devient lui-même trop coûteux (${energyPriorityReason || 'raison non précisée'})`);\n }\n}\n\nconst selectedLoadKwh =\n recommendedMode === 'green' ? greenEnergyKwh :\n recommendedMode === 'auto' ? autoEnergyKwh :\n recommendedMode === 'boost' ? boostEnergyKwh :\n 0;\n\nconst selectedProjectedSoc =\n recommendedMode === 'green' ? projectedGreenSoc :\n recommendedMode === 'auto' ? projectedAutoSoc :\n recommendedMode === 'boost' ? projectedBoostSoc :\n soc;\n\nconst selectedBudgetMarginKwh =\n recommendedMode === 'green' ? greenBudgetMarginKwh :\n recommendedMode === 'auto' ? autoBudgetMarginKwh :\n recommendedMode === 'boost' ? boostBudgetMarginKwh :\n todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - energyNeededToReachTargetKwh;\n\nconst legacyAdviceOk = recommendedMode !== 'off';\n\nconst observedRank = regimeRank(observedRegime);\nconst allowedRank = allowedRankForMode(recommendedMode);\n\nconst mismatchActive =\n waterHeaterSwitchOn &&\n observedRank >= 0 &&\n allowedRank >= 0 &&\n observedRank > allowedRank;\n\nlet mismatchSeverity = 'none';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n mismatchSeverity = 'critical';\n } else if (observedRank - allowedRank >= 2) {\n mismatchSeverity = 'high';\n } else {\n mismatchSeverity = 'medium';\n }\n}\n\nconst mismatchSinceMsEffective = mismatchActive ? (mismatchSinceMsPrev ?? nowMs) : null;\nconst mismatchSinceAtEffective = mismatchActive ? (mismatchSinceMsPrev ? mismatchSinceAt : now.stamp) : '';\nconst mismatchDurationMin = mismatchActive && mismatchSinceMsEffective !== null ? Math.max(0, (nowMs - mismatchSinceMsEffective) / 60000) : 0;\n\nlet pendingUserAction = '';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n pendingUserAction = evcsPriorityActive\n ? 'Priorité EVCS : coupe temporairement le chauffe-eau ou laisse-le hors tension'\n : 'Le chauffe-eau consomme alors qu\\'il est déconseillé : coupe-le ou attends une meilleure fenêtre';\n } else if (recommendedMode === 'green') {\n pendingUserAction = observedRegime === 'boost_high'\n ? 'Passe le chauffe-eau en GREEN si possible, il semble en régime BOOST/résistance forte'\n : 'Passe le chauffe-eau en GREEN si possible, il semble plus gourmand que prévu';\n } else if (recommendedMode === 'auto') {\n pendingUserAction = 'Le chauffe-eau semble trop gourmand pour AUTO : repasse-le en AUTO modéré ou GREEN si possible';\n }\n}\n\nconst firstMismatchRun = mismatchActive && (mismatchSinceMsPrev === null || mismatchDurationMin < 1);\n\nconst realConflictNow =\n evcsPriorityActive &&\n (\n batteryCurrentA < -1 ||\n pvPowerAvg10mW < restoreSurplusW ||\n houseAcPowerW > acLoadsMaxSafeW ||\n recommendedMode === 'off' ||\n observedRegime === 'boost_high' ||\n (recommendedMode === 'green' && observedRegime === 'resistive_mid')\n );\n\nconst forceOffNow =\n advisorEnabled &&\n waterHeaterSwitchOn &&\n !forcedOffByAdvisorPrev &&\n mismatchActive &&\n mismatchDurationMin >= modeMismatchDelayMin &&\n realConflictNow;\n\nconst restoreEnergyOk =\n pvPowerAvg10mW >= restoreSurplusW &&\n batteryCurrentA >= -1 &&\n houseAcPowerW <= acLoadsMaxSafeW;\n\nconst restoreNow =\n advisorEnabled &&\n forcedOffByAdvisorPrev &&\n !waterHeaterSwitchOn &&\n (\n (!evcsPriorityActive && recommendedMode !== 'off') ||\n restoreEnergyOk\n );\n\nlet forcedOffByAdvisor = forcedOffByAdvisorPrev;\nlet forcedOffReason = forcedOffReasonPrev;\nlet forcedOffAt = forcedOffAtPrev;\nlet protectionState = 'none';\nlet powerSwitchAction = 'none';\nlet powerSwitchDesiredOn = waterHeaterSwitchOn;\n\nif (forceOffNow) {\n powerSwitchAction = 'turn_off';\n powerSwitchDesiredOn = false;\n forcedOffByAdvisor = true;\n forcedOffReason = `Coupure temporaire ECS: priorité EVCS + régime ${observedRegime} trop gourmand`;\n forcedOffAt = now.stamp;\n protectionState = 'forced_off_now_evcs_priority';\n state = 'forced_off_now_evcs_priority';\n adviceColor = 'red';\n reason.push(forcedOffReason);\n}\nelse if (restoreNow) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = 'restored_after_evcs_priority';\n state = 'restored_after_evcs_priority';\n reason.push('Remise sous tension ECS : conflit EVCS terminé ou surplus redevenu suffisant');\n}\nelse if (forcedOffByAdvisorPrev && !waterHeaterSwitchOn) {\n protectionState = 'forced_off_wait_restore';\n state = 'forced_off_wait_restore';\n reason.push(`ECS temporairement coupé par l'advisor (${forcedOffReasonPrev || 'raison non précisée'})`);\n}\nelse if (mismatchActive && evcsPriorityActive) {\n protectionState = 'watch_mismatch_evcs_priority';\n state = 'watch_mismatch_evcs_priority';\n reason.push(`Écart de régime ECS sous priorité EVCS depuis ${round1(mismatchDurationMin)} min`);\n}\n\nif (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn) {\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n}\n\n// =====================================================\n// JOUR FAIBLE : EAU CHAUDE EN HC DE NUIT (07/10/2026, option b du proprietaire)\n// =====================================================\n// Le bilan decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) : si\n// le solaire prevu ne remplit pas la batterie pour la nuit, EDF sera ferme de\n// toute facon -> le chauffe-eau est COUPE le jour et chauffe pendant une\n// session reseau HC engagee (contacteur ferme), au tarif creux, jamais sur la\n// batterie. Securite eau chaude : moins de ECS_EAU_FROIDE_KWH en 24 h -> il\n// chauffe quand meme. Energie 24 h illisible -> aucune restriction (le\n// comportement d'avant revient, jamais un ballon froid par panne de capteur).\n// Jour normal : le chauffe-eau coupe par cette regle est remis sous tension.\nconst ECS_EAU_FROIDE_KWH = 1.5;\nconst {\n ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, offJourFaiblePrev = false,\n} = normalized;\nlet offJourFaible = false;\nlet jourFaibleState = 'none';\nif (advisorEnabled && ecsNuit) {\n const energieConnue = Number.isFinite(ecs24hKwh);\n const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH;\n const heuresCreusesEdf = hcSessionCommitted && gridIsOn;\n const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide;\n offJourFaible = true;\n jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible'\n : heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf'\n : eauFroide ? 'jour_faible_eau_froide'\n : 'jour_faible_coupe';\n if (veutChauffer !== waterHeaterSwitchOn) {\n powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off';\n powerSwitchDesiredOn = veutChauffer;\n } else {\n powerSwitchAction = 'none';\n powerSwitchDesiredOn = waterHeaterSwitchOn;\n }\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push(\n jourFaibleState === 'jour_faible_chauffe_hc_edf' ? 'Jour faible : eau chaude en heures creuses sur EDF (session reseau engagee)'\n : jourFaibleState === 'jour_faible_eau_froide' ? `Jour faible mais seulement ${round1(ecs24hKwh)} kWh en 24 h : chauffe pour garantir l eau chaude`\n : jourFaibleState === 'jour_faible_energie_illisible' ? 'Jour faible, energie 24 h illisible : aucune restriction'\n : 'Jour faible : chauffe-eau coupe, il chauffera en heures creuses sur EDF'\n );\n} else if (advisorEnabled && offJourFaiblePrev && !waterHeaterSwitchOn) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n jourFaibleState = 'jour_normal_remise_sous_tension';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push('Jour normal : chauffe-eau remis sous tension (il chauffe au solaire)');\n}\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_final_arbitration_and_protection'\n },\n finalDecision: {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn,\n offJourFaible,\n jourFaibleState\n }\n }\n}];" + "jsCode": "function regimeRank(regime) {\n switch (regime) {\n case 'off': return 0;\n case 'hp_low': return 1;\n case 'resistive_mid': return 2;\n case 'boost_high': return 3;\n default: return -1;\n }\n}\n\nfunction allowedRankForMode(mode) {\n switch (mode) {\n case 'off': return 0;\n case 'green': return 1;\n case 'auto': return 2;\n case 'boost': return 3;\n default: return 0;\n }\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nconst root = $input.first()?.json ?? {};\nconst now = root.now ?? {};\nconst nowMs = root.nowMs ?? null;\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst baseDecision = root.baseDecision ?? {};\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState\n} = baseDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst {\n advisorEnabled,\n soc,\n batteryCurrentA,\n pvPowerAvg10mW,\n houseAcPowerW,\n todayRemainingKwh,\n tomorrowKwh,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n hotWaterDemand,\n gridIsOn,\n acInAvailable,\n gridAvailable\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityReason,\n waterHeaterSwitchOn,\n observedPowerW,\n observedRegime,\n mismatchSinceAt,\n mismatchSinceMsPrev,\n forcedOffByAdvisorPrev,\n forcedOffReasonPrev,\n forcedOffAtPrev,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable\n} = observation;\n\nconst {\n targetSoc,\n energyNeededToReachTargetKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n greenAllowed,\n autoAllowed,\n boostAllowed,\n batteryNotDrainingHard\n} = energyModel;\n\nif (advisorEnabled && evcsPriorityActive) {\n if (recommendedMode === 'boost' || recommendedMode === 'auto') {\n if (greenAllowed && batteryNotDrainingHard) {\n recommendedMode = 'green';\n adviceColor = 'orange';\n state = 'evcs_priority_green_cap';\n reason.push(`Priorité EVCS active : chauffe-eau plafonné à GREEN (${energyPriorityReason || 'raison non précisée'})`);\n } else {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_off_cap';\n reason.push(`Priorité EVCS active : chauffe-eau déconseillé (${energyPriorityReason || 'raison non précisée'})`);\n }\n } else if (recommendedMode === 'green' && (!greenAllowed || !batteryNotDrainingHard)) {\n recommendedMode = 'off';\n adviceColor = 'red';\n state = 'evcs_priority_green_blocked';\n reason.push(`Priorité EVCS active : GREEN devient lui-même trop coûteux (${energyPriorityReason || 'raison non précisée'})`);\n }\n}\n\nconst selectedLoadKwh =\n recommendedMode === 'green' ? greenEnergyKwh :\n recommendedMode === 'auto' ? autoEnergyKwh :\n recommendedMode === 'boost' ? boostEnergyKwh :\n 0;\n\nconst selectedProjectedSoc =\n recommendedMode === 'green' ? projectedGreenSoc :\n recommendedMode === 'auto' ? projectedAutoSoc :\n recommendedMode === 'boost' ? projectedBoostSoc :\n soc;\n\nconst selectedBudgetMarginKwh =\n recommendedMode === 'green' ? greenBudgetMarginKwh :\n recommendedMode === 'auto' ? autoBudgetMarginKwh :\n recommendedMode === 'boost' ? boostBudgetMarginKwh :\n todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - energyNeededToReachTargetKwh;\n\nconst legacyAdviceOk = recommendedMode !== 'off';\n\nconst observedRank = regimeRank(observedRegime);\nconst allowedRank = allowedRankForMode(recommendedMode);\n\nconst mismatchActive =\n waterHeaterSwitchOn &&\n observedRank >= 0 &&\n allowedRank >= 0 &&\n observedRank > allowedRank;\n\nlet mismatchSeverity = 'none';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n mismatchSeverity = 'critical';\n } else if (observedRank - allowedRank >= 2) {\n mismatchSeverity = 'high';\n } else {\n mismatchSeverity = 'medium';\n }\n}\n\nconst mismatchSinceMsEffective = mismatchActive ? (mismatchSinceMsPrev ?? nowMs) : null;\nconst mismatchSinceAtEffective = mismatchActive ? (mismatchSinceMsPrev ? mismatchSinceAt : now.stamp) : '';\nconst mismatchDurationMin = mismatchActive && mismatchSinceMsEffective !== null ? Math.max(0, (nowMs - mismatchSinceMsEffective) / 60000) : 0;\n\nlet pendingUserAction = '';\nif (mismatchActive) {\n if (recommendedMode === 'off') {\n pendingUserAction = evcsPriorityActive\n ? 'Priorité EVCS : coupe temporairement le chauffe-eau ou laisse-le hors tension'\n : 'Le chauffe-eau consomme alors qu\\'il est déconseillé : coupe-le ou attends une meilleure fenêtre';\n } else if (recommendedMode === 'green') {\n pendingUserAction = observedRegime === 'boost_high'\n ? 'Passe le chauffe-eau en GREEN si possible, il semble en régime BOOST/résistance forte'\n : 'Passe le chauffe-eau en GREEN si possible, il semble plus gourmand que prévu';\n } else if (recommendedMode === 'auto') {\n pendingUserAction = 'Le chauffe-eau semble trop gourmand pour AUTO : repasse-le en AUTO modéré ou GREEN si possible';\n }\n}\n\nconst firstMismatchRun = mismatchActive && (mismatchSinceMsPrev === null || mismatchDurationMin < 1);\n\nconst realConflictNow =\n evcsPriorityActive &&\n (\n batteryCurrentA < -1 ||\n pvPowerAvg10mW < restoreSurplusW ||\n houseAcPowerW > acLoadsMaxSafeW ||\n recommendedMode === 'off' ||\n observedRegime === 'boost_high' ||\n (recommendedMode === 'green' && observedRegime === 'resistive_mid')\n );\n\nconst forceOffNow =\n advisorEnabled &&\n waterHeaterSwitchOn &&\n !forcedOffByAdvisorPrev &&\n mismatchActive &&\n mismatchDurationMin >= modeMismatchDelayMin &&\n realConflictNow;\n\nconst restoreEnergyOk =\n pvPowerAvg10mW >= restoreSurplusW &&\n batteryCurrentA >= -1 &&\n houseAcPowerW <= acLoadsMaxSafeW;\n\nconst restoreNow =\n advisorEnabled &&\n forcedOffByAdvisorPrev &&\n !waterHeaterSwitchOn &&\n (\n (!evcsPriorityActive && recommendedMode !== 'off') ||\n restoreEnergyOk\n );\n\nlet forcedOffByAdvisor = forcedOffByAdvisorPrev;\nlet forcedOffReason = forcedOffReasonPrev;\nlet forcedOffAt = forcedOffAtPrev;\nlet protectionState = 'none';\nlet powerSwitchAction = 'none';\nlet powerSwitchDesiredOn = waterHeaterSwitchOn;\n\nif (forceOffNow) {\n powerSwitchAction = 'turn_off';\n powerSwitchDesiredOn = false;\n forcedOffByAdvisor = true;\n forcedOffReason = `Coupure temporaire ECS: priorité EVCS + régime ${observedRegime} trop gourmand`;\n forcedOffAt = now.stamp;\n protectionState = 'forced_off_now_evcs_priority';\n state = 'forced_off_now_evcs_priority';\n adviceColor = 'red';\n reason.push(forcedOffReason);\n}\nelse if (restoreNow) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = 'restored_after_evcs_priority';\n state = 'restored_after_evcs_priority';\n reason.push('Remise sous tension ECS : conflit EVCS terminé ou surplus redevenu suffisant');\n}\nelse if (forcedOffByAdvisorPrev && !waterHeaterSwitchOn) {\n protectionState = 'forced_off_wait_restore';\n state = 'forced_off_wait_restore';\n reason.push(`ECS temporairement coupé par l'advisor (${forcedOffReasonPrev || 'raison non précisée'})`);\n}\nelse if (mismatchActive && evcsPriorityActive) {\n protectionState = 'watch_mismatch_evcs_priority';\n state = 'watch_mismatch_evcs_priority';\n reason.push(`Écart de régime ECS sous priorité EVCS depuis ${round1(mismatchDurationMin)} min`);\n}\n\nif (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn) {\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n}\n\n// =====================================================\n// JOUR FAIBLE : EAU CHAUDE EN HC DE NUIT (07/10/2026, option b du proprietaire)\n// =====================================================\n// Le bilan decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) : si\n// le solaire prevu ne remplit pas la batterie pour la nuit, EDF sera ferme de\n// toute facon -> le chauffe-eau est COUPE le jour et chauffe pendant une\n// session reseau HC engagee (contacteur ferme), au tarif creux, jamais sur la\n// batterie. Securite eau chaude : moins de ECS_EAU_FROIDE_KWH en 24 h -> il\n// chauffe quand meme. Energie 24 h illisible -> aucune restriction (le\n// comportement d'avant revient, jamais un ballon froid par panne de capteur).\n// Jour normal : le chauffe-eau coupe par cette regle est remis sous tension.\nconst ECS_EAU_FROIDE_KWH = 1.5;\n// Douches a 06:00 et 20:00 tous les jours (proprietaire, 07/10) : couper toute\n// la journee laisserait l'eau froide a 20 h. Un jour faible, il chauffe donc\n// AUSSI sur un vrai surplus solaire (jamais sur la batterie), et a partir de\n// 16 h s'il n'a presque pas chauffe depuis 07:24 (securite de la douche du soir).\nconst ECS_SOIR_DEBUT_H = 16;\nconst ECS_SOIR_FIN_H = 20;\nconst {\n ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, ecsJourKwh = NaN,\n offJourFaiblePrev = false, pvPowerW = 0, autoPowerW = 1200,\n} = normalized;\nlet offJourFaible = false;\nlet jourFaibleState = 'none';\nif (advisorEnabled && ecsNuit) {\n const energieConnue = Number.isFinite(ecs24hKwh);\n const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH;\n const heuresCreusesEdf = hcSessionCommitted && gridIsOn;\n // Surplus reel : sous tension, la production couvre la maison (chauffe-eau\n // compris) ; hors tension, il reste de quoi l'alimenter. Reseau ouvert.\n const solaireCouvre = !gridIsOn && (waterHeaterSwitchOn\n ? pvPowerW >= houseAcPowerW\n : pvPowerW - houseAcPowerW >= autoPowerW);\n const heure = Number.isFinite(now.hour) ? now.hour : -1;\n const securiteSoir = heure >= ECS_SOIR_DEBUT_H && heure < ECS_SOIR_FIN_H\n && !(Number.isFinite(ecsJourKwh) && ecsJourKwh >= ECS_EAU_FROIDE_KWH);\n const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide || solaireCouvre || securiteSoir;\n offJourFaible = true;\n jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible'\n : heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf'\n : eauFroide ? 'jour_faible_eau_froide'\n : securiteSoir ? 'jour_faible_securite_soir'\n : solaireCouvre ? 'jour_faible_surplus_solaire'\n : 'jour_faible_coupe';\n if (veutChauffer !== waterHeaterSwitchOn) {\n powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off';\n powerSwitchDesiredOn = veutChauffer;\n } else {\n powerSwitchAction = 'none';\n powerSwitchDesiredOn = waterHeaterSwitchOn;\n }\n forcedOffByAdvisor = false;\n forcedOffReason = '';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push(\n jourFaibleState === 'jour_faible_chauffe_hc_edf' ? 'Jour faible : eau chaude en heures creuses sur EDF (session reseau engagee)'\n : jourFaibleState === 'jour_faible_eau_froide' ? `Jour faible mais seulement ${round1(ecs24hKwh)} kWh en 24 h : chauffe pour garantir l eau chaude`\n : jourFaibleState === 'jour_faible_energie_illisible' ? 'Jour faible, energie 24 h illisible : aucune restriction'\n : jourFaibleState === 'jour_faible_securite_soir' ? 'Jour faible : chauffe pour la douche de 20 h (peu chauffe depuis 07:24)'\n : jourFaibleState === 'jour_faible_surplus_solaire' ? 'Jour faible : surplus solaire reel, il chauffe sans toucher la batterie'\n : 'Jour faible : chauffe-eau coupe, il chauffera en heures creuses sur EDF'\n );\n} else if (advisorEnabled && offJourFaiblePrev && !waterHeaterSwitchOn) {\n powerSwitchAction = 'turn_on';\n powerSwitchDesiredOn = true;\n jourFaibleState = 'jour_normal_remise_sous_tension';\n protectionState = jourFaibleState;\n state = jourFaibleState;\n reason.push('Jour normal : chauffe-eau remis sous tension (il chauffe au solaire)');\n}\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_final_arbitration_and_protection'\n },\n finalDecision: {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn,\n offJourFaible,\n jourFaibleState\n }\n }\n}];" }, "id": "994b3242-616d-46e7-83ed-d236a117d15c", "name": "ECS Final Arbitration & Protection", @@ -167,7 +167,7 @@ "parameters": [ { "name": "template", - "value": "{#- 07/10/2026 : prevision lue comme le bilan (input_select.evcs_balance_forecast_source).\n VRM : reste du jour = heures restantes de la courbe horaire (heure en cours au\n prorata), demain = somme de la courbe de demain. Courbe absente ou\n malformee -> repli sur Forecast.Solar. -#}\n{%- set src = states('input_select.evcs_balance_forecast_source') -%}\n{%- set vt = states('input_text.evcs_balance_vrm_today').split(',') -%}\n{%- set vd = states('input_text.evcs_balance_vrm_tomorrow').split(',') -%}\n{%- set p = namespace(reste=0, demain=0, ok=(src == 'vrm' and vt | length == 24 and vd | length == 24)) -%}\n{%- if p.ok -%}\n {%- for i in range(now().hour, 24) -%}\n {%- set v = vt[i] | float(0) -%}\n {%- set p.reste = p.reste + (v * (1 - now().minute / 60) if i == now().hour else v) -%}\n {%- endfor -%}\n {%- for v in vd -%}{%- set p.demain = p.demain + (v | float(0)) -%}{%- endfor -%}\n{%- endif -%}\n{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"today_remaining_kwh\": (p.reste | round(2)) if p.ok else states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": (p.demain | round(2)) if p.ok else states(\"sensor.energy_production_tomorrow\"),\n \"forecast_source\": \"vrm\" if p.ok else \"forecast_solar\",\n\n \"ecs_nuit\": states(\"input_boolean.evcs_balance_ecs_nuit\"),\n \"ecs_nuit_jour\": states(\"input_text.evcs_balance_ecs_nuit_jour\"),\n \"hc_session_committed\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"ecs_24h_kwh\": states(\"sensor.evcs_chauffe_eau_24h_kwh\"),\n \"off_jour_faible_prev\": states(\"input_boolean.victron_water_heater_off_jour_faible\"),\n\n \"advisor_enabled\": states(\"input_boolean.victron_water_heater_advisor_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_water_heater_notifications\"),\n\n \"hot_water_demand\": states(\"input_select.victron_water_heater_hot_water_demand\"),\n \"morning_boost_priority_enabled\": states(\"input_boolean.victron_water_heater_morning_boost_priority_enabled\"),\n \"morning_boost_latest_time\": states(\"input_datetime.victron_water_heater_morning_boost_latest_time\"),\n \"late_red_time\": states(\"input_datetime.victron_water_heater_late_red_time\"),\n\n \"green_power_w\": states(\"input_number.victron_water_heater_green_power_w\"),\n \"auto_power_w\": states(\"input_number.victron_water_heater_auto_power_w\"),\n \"boost_power_w\": states(\"input_number.victron_water_heater_boost_power_w\"),\n\n \"strong_forecast_kwh\": states(\"input_number.victron_water_heater_strong_forecast_kwh\"),\n \"medium_forecast_kwh\": states(\"input_number.victron_water_heater_medium_forecast_kwh\"),\n \"strong_pv_now_w\": states(\"input_number.victron_water_heater_strong_pv_now_w\"),\n \"medium_pv_now_w\": states(\"input_number.victron_water_heater_medium_pv_now_w\"),\n\n \"morning_boost_min_soc_strong_day\": states(\"input_number.victron_water_heater_morning_boost_min_soc_strong_day\"),\n \"boost_min_soc_medium_day\": states(\"input_number.victron_water_heater_boost_min_soc_medium_day\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_water_heater_end_of_day_soc_guard\"),\n\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"last_signature\": states(\"input_text.victron_water_heater_advice_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"energy_priority_mode\": states(\"input_select.victron_energy_priority_mode\"),\n \"energy_priority_reason\": states(\"input_text.victron_energy_priority_reason\"),\n \"evcs_priority_until\": states(\"input_datetime.victron_evcs_priority_until\"),\n\n \"water_heater_switch\": states(\"switch.tt_chauffe_eau_pleumeur_switch\"),\n \"water_heater_energy_total_kwh\": states(\"sensor.tt_chauffe_eau_pleumeur_energie_totale\"),\n\n \"energy_total_prev_kwh\": states(\"input_number.victron_water_heater_energy_total_prev_kwh\"),\n \"energy_prev_at\": states(\"input_datetime.victron_water_heater_energy_prev_at\"),\n\n \"observed_regime_helper\": states(\"input_text.victron_water_heater_observed_regime\"),\n \"observed_power_helper_w\": states(\"input_number.victron_water_heater_observed_power_w\"),\n\n \"mode_mismatch_since\": states(\"input_datetime.victron_water_heater_mode_mismatch_since\"),\n\n \"forced_off_by_advisor\": states(\"input_boolean.victron_water_heater_forced_off_by_advisor\"),\n \"forced_off_reason\": states(\"input_text.victron_water_heater_forced_off_reason\"),\n \"forced_off_at\": states(\"input_datetime.victron_water_heater_forced_off_at\"),\n\n \"pending_user_action_prev\": states(\"input_text.victron_water_heater_pending_user_action\"),\n\n \"restore_surplus_w\": states(\"input_number.victron_water_heater_restore_surplus_w\"),\n \"mode_mismatch_delay_min\": states(\"input_number.victron_water_heater_mode_mismatch_delay_min\")\n} | tojson }}\n" + "value": "{#- 07/10/2026 : prevision lue comme le bilan (input_select.evcs_balance_forecast_source).\n VRM : reste du jour = heures restantes de la courbe horaire (heure en cours au\n prorata), demain = somme de la courbe de demain. Courbe absente ou\n malformee -> repli sur Forecast.Solar. -#}\n{%- set src = states('input_select.evcs_balance_forecast_source') -%}\n{%- set vt = states('input_text.evcs_balance_vrm_today').split(',') -%}\n{%- set vd = states('input_text.evcs_balance_vrm_tomorrow').split(',') -%}\n{%- set p = namespace(reste=0, demain=0, ok=(src == 'vrm' and vt | length == 24 and vd | length == 24)) -%}\n{%- if p.ok -%}\n {%- for i in range(now().hour, 24) -%}\n {%- set v = vt[i] | float(0) -%}\n {%- set p.reste = p.reste + (v * (1 - now().minute / 60) if i == now().hour else v) -%}\n {%- endfor -%}\n {%- for v in vd -%}{%- set p.demain = p.demain + (v | float(0)) -%}{%- endfor -%}\n{%- endif -%}\n{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"today_remaining_kwh\": (p.reste | round(2)) if p.ok else states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": (p.demain | round(2)) if p.ok else states(\"sensor.energy_production_tomorrow\"),\n \"forecast_source\": \"vrm\" if p.ok else \"forecast_solar\",\n\n \"ecs_nuit\": states(\"input_boolean.evcs_balance_ecs_nuit\"),\n \"ecs_nuit_jour\": states(\"input_text.evcs_balance_ecs_nuit_jour\"),\n \"hc_session_committed\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"ecs_24h_kwh\": states(\"sensor.evcs_chauffe_eau_24h_kwh\"),\n \"ecs_jour_kwh\": states(\"sensor.evcs_chauffe_eau_jour_kwh\"),\n \"off_jour_faible_prev\": states(\"input_boolean.victron_water_heater_off_jour_faible\"),\n\n \"advisor_enabled\": states(\"input_boolean.victron_water_heater_advisor_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_water_heater_notifications\"),\n\n \"hot_water_demand\": states(\"input_select.victron_water_heater_hot_water_demand\"),\n \"morning_boost_priority_enabled\": states(\"input_boolean.victron_water_heater_morning_boost_priority_enabled\"),\n \"morning_boost_latest_time\": states(\"input_datetime.victron_water_heater_morning_boost_latest_time\"),\n \"late_red_time\": states(\"input_datetime.victron_water_heater_late_red_time\"),\n\n \"green_power_w\": states(\"input_number.victron_water_heater_green_power_w\"),\n \"auto_power_w\": states(\"input_number.victron_water_heater_auto_power_w\"),\n \"boost_power_w\": states(\"input_number.victron_water_heater_boost_power_w\"),\n\n \"strong_forecast_kwh\": states(\"input_number.victron_water_heater_strong_forecast_kwh\"),\n \"medium_forecast_kwh\": states(\"input_number.victron_water_heater_medium_forecast_kwh\"),\n \"strong_pv_now_w\": states(\"input_number.victron_water_heater_strong_pv_now_w\"),\n \"medium_pv_now_w\": states(\"input_number.victron_water_heater_medium_pv_now_w\"),\n\n \"morning_boost_min_soc_strong_day\": states(\"input_number.victron_water_heater_morning_boost_min_soc_strong_day\"),\n \"boost_min_soc_medium_day\": states(\"input_number.victron_water_heater_boost_min_soc_medium_day\"),\n \"end_of_day_soc_guard\": states(\"input_number.victron_water_heater_end_of_day_soc_guard\"),\n\n \"end_of_day_soc_target\": states(\"input_number.victron_end_of_day_soc_target\"),\n \"base_load_remaining_kwh\": states(\"input_number.victron_base_load_remaining_kwh\"),\n \"flexible_safety_margin_kwh\": states(\"input_number.victron_flexible_safety_margin_kwh\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"last_signature\": states(\"input_text.victron_water_heater_advice_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"energy_priority_mode\": states(\"input_select.victron_energy_priority_mode\"),\n \"energy_priority_reason\": states(\"input_text.victron_energy_priority_reason\"),\n \"evcs_priority_until\": states(\"input_datetime.victron_evcs_priority_until\"),\n\n \"water_heater_switch\": states(\"switch.tt_chauffe_eau_pleumeur_switch\"),\n \"water_heater_energy_total_kwh\": states(\"sensor.tt_chauffe_eau_pleumeur_energie_totale\"),\n\n \"energy_total_prev_kwh\": states(\"input_number.victron_water_heater_energy_total_prev_kwh\"),\n \"energy_prev_at\": states(\"input_datetime.victron_water_heater_energy_prev_at\"),\n\n \"observed_regime_helper\": states(\"input_text.victron_water_heater_observed_regime\"),\n \"observed_power_helper_w\": states(\"input_number.victron_water_heater_observed_power_w\"),\n\n \"mode_mismatch_since\": states(\"input_datetime.victron_water_heater_mode_mismatch_since\"),\n\n \"forced_off_by_advisor\": states(\"input_boolean.victron_water_heater_forced_off_by_advisor\"),\n \"forced_off_reason\": states(\"input_text.victron_water_heater_forced_off_reason\"),\n \"forced_off_at\": states(\"input_datetime.victron_water_heater_forced_off_at\"),\n\n \"pending_user_action_prev\": states(\"input_text.victron_water_heater_pending_user_action\"),\n\n \"restore_surplus_w\": states(\"input_number.victron_water_heater_restore_surplus_w\"),\n \"mode_mismatch_delay_min\": states(\"input_number.victron_water_heater_mode_mismatch_delay_min\")\n} | tojson }}\n" } ] },