Bilan : la nuit prevue suit la soiree reelle (regression sur 24 nuits), nuage Grafana corrige

Le service publie le facteur nuit prevue / nuit moyenne (sensor.evcs_nuit_maison_prevue_kwh),
le bilan l'applique a son profil de 21:30 a 09:00. Diagnostics 81/81, bilan 68/68.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
This commit is contained in:
Tallagrand Thierry
2026-09-28 23:36:08 +02:00
parent fc5b219abf
commit e18158bfd1
14 changed files with 306 additions and 153 deletions

View File

@@ -1066,8 +1066,20 @@ Grafana « La nuit » : 3 panneaux (nuage soiree/nuit avec regression et CE SOIR
tableau des nuits, profil horaire 20 nuits contre la nuit en cours). SQL dans tableau des nuits, profil horaire 20 nuits contre la nuit en cours). SQL dans
`tools/grafana-nuits.sql` et `tools/grafana-profil-horaire.sql`, verifiees contre le `tools/grafana-nuits.sql` et `tools/grafana-profil-horaire.sql`, verifiees contre le
calcul par script (identiques). ⚠️ Rendu du nuage (xychart Grafana 11.3) non vu de mes calcul par script (identiques). ⚠️ Rendu du nuage (xychart Grafana 11.3) non vu de mes
yeux : le proprietaire confirme. Piste ouverte : faire utiliser la regression par le yeux : le proprietaire a vu « Err » -> refait en UNE requete, mapping automatique.
bilan (la nuit prevue suit la soiree reelle). **Le bilan utilise la regression (28/09 23:34, demande du proprietaire).** Le service
d'historisation publie `sensor.evcs_nuit_maison_prevue_kwh` (21:30 -> 09:00, toutes les
5 min ; `hors_fenetre` le jour, `indisponible` si < 15 nuits ou r2 < 0,2) avec le
FACTEUR nuit prevue / nuit moyenne des memes donnees (le profil vient des compteurs,
la regression des puissances : le rapport annule l'ecart de methode). Le bilan
multiplie son profil p50 par ce facteur sur 21:30-09:00 (prorata), le p80 ne descend
jamais (max(p80, p50 recale)), facteur borne [0,75 ; 1,45] = extremes des 24 nuits,
perime > 30 min -> ignore. Phrase « Nuit recalee sur la soiree : conso x1,092 » dans
la raison. Tests : diagnostics 81/81 (valeurs verifiees par un calcul Python
independant), bilan 68/68 (verifie a la main : +0,71 kWh, +2,4 points), 2 mutations
qui mordent. Premier calcul reel 23:35 : SOC 47, requis 50,5 (~48 sans facteur) ->
1,05 kWh EDF a 01:24. ⚠️ Ma premiere annonce d'un facteur 1,12 etait fausse (moyenne
calculee de tete) : 1,09.
--- ---
*Document mis a jour le : 28/09/2026 (~02:15) — VRM remplace Forecast.Solar (comparaison le 05/10), tenir jusqu'a la prochaine HC, prevision de nuit pour l'agent* *Document mis a jour le : 28/09/2026 (~02:15) — VRM remplace Forecast.Solar (comparaison le 05/10), tenir jusqu'a la prochaine HC, prevision de nuit pour l'agent*

View File

@@ -667,40 +667,7 @@
"format": "table", "format": "table",
"rawQuery": true, "rawQuery": true,
"editorMode": "code", "editorMode": "code",
"rawSql": "WITH pts AS (\n SELECT cle, ts, valeur,\n least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin\n FROM mesure\n WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power',\n 'sensor.victron_grid_power_l1', 'sensor.victron_pv_power_system')\n AND ts > now() - interval '40 days'\n),\njours AS (\n SELECT d::date AS j,\n (d::date + time '18:00') AT TIME ZONE 'Europe/Paris' AS s0,\n (d::date + time '21:30') AT TIME ZONE 'Europe/Paris' AS n0,\n (d::date + 1 + time '01:24') AT TIME ZONE 'Europe/Paris' AS hc,\n (d::date + 1 + time '09:00') AT TIME ZONE 'Europe/Paris' AS n1\n FROM generate_series(current_date - 35, current_date, interval '1 day') d\n),\ne AS (\n SELECT j.j,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.s0 AND p.ts < j.n0) AS c_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.s0 AND p.ts < j.n0) AS v_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.hc) AS c_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.hc) AS v_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS c_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.n1) AS v_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_grid_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS r_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_pv_power_system' AND p.fin > j.n0 AND p.ts < j.n1) AS pv_nuit\n FROM jours j JOIN pts p ON p.fin > j.s0 AND p.ts < j.n1\n GROUP BY j.j\n),\nnuits AS (\n SELECT j.j, j.n0, j.n1,\n round(((coalesce(c_soir,0) - coalesce(v_soir,0)) / 3.6e6)::numeric, 2) AS soiree_kwh,\n round(((coalesce(c_hc,0) - coalesce(v_hc,0)) / 3.6e6)::numeric, 2) AS maison_hc_kwh,\n round(((coalesce(c_nuit,0) - coalesce(v_nuit,0)) / 3.6e6)::numeric, 2) AS maison_nuit_kwh,\n round((coalesce(v_nuit,0) / 3.6e6)::numeric, 2) AS voiture_kwh,\n round((coalesce(r_nuit,0) / 3.6e6)::numeric, 2) AS reseau_kwh,\n round((coalesce(pv_nuit,0) / 3.6e6)::numeric, 2) AS pv_avant_9h_kwh,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n0 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_2130,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.hc AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_0124,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n1 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_9h\n FROM jours j JOIN e ON e.j = j.j\n WHERE j.n1 <= now() AND j.j >= date '2026-09-04'\n)\nSELECT soiree_kwh AS \"Soiree\", maison_nuit_kwh AS \"Nuit sans voiture\" FROM nuits WHERE voiture_kwh < 0.5 ORDER BY 1" "rawSql": "WITH pts AS (\n SELECT cle, ts, valeur,\n least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin\n FROM mesure\n WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power',\n 'sensor.victron_grid_power_l1', 'sensor.victron_pv_power_system')\n AND ts > now() - interval '40 days'\n),\njours AS (\n SELECT d::date AS j,\n (d::date + time '18:00') AT TIME ZONE 'Europe/Paris' AS s0,\n (d::date + time '21:30') AT TIME ZONE 'Europe/Paris' AS n0,\n (d::date + 1 + time '01:24') AT TIME ZONE 'Europe/Paris' AS hc,\n (d::date + 1 + time '09:00') AT TIME ZONE 'Europe/Paris' AS n1\n FROM generate_series(current_date - 35, current_date, interval '1 day') d\n),\ne AS (\n SELECT j.j,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.s0 AND p.ts < j.n0) AS c_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.s0 AND p.ts < j.n0) AS v_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.hc) AS c_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.hc) AS v_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS c_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.n1) AS v_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_grid_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS r_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_pv_power_system' AND p.fin > j.n0 AND p.ts < j.n1) AS pv_nuit\n FROM jours j JOIN pts p ON p.fin > j.s0 AND p.ts < j.n1\n GROUP BY j.j\n),\nnuits AS (\n SELECT j.j, j.n0, j.n1,\n round(((coalesce(c_soir,0) - coalesce(v_soir,0)) / 3.6e6)::numeric, 2) AS soiree_kwh,\n round(((coalesce(c_hc,0) - coalesce(v_hc,0)) / 3.6e6)::numeric, 2) AS maison_hc_kwh,\n round(((coalesce(c_nuit,0) - coalesce(v_nuit,0)) / 3.6e6)::numeric, 2) AS maison_nuit_kwh,\n round((coalesce(v_nuit,0) / 3.6e6)::numeric, 2) AS voiture_kwh,\n round((coalesce(r_nuit,0) / 3.6e6)::numeric, 2) AS reseau_kwh,\n round((coalesce(pv_nuit,0) / 3.6e6)::numeric, 2) AS pv_avant_9h_kwh,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n0 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_2130,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.hc AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_0124,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n1 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_9h\n FROM jours j JOIN e ON e.j = j.j\n WHERE j.n1 <= now() AND j.j >= date '2026-09-04'\n),\nf AS (\n SELECT ((CASE WHEN (now() AT TIME ZONE 'Europe/Paris')::time >= time '18:00'\n THEN (now() AT TIME ZONE 'Europe/Paris')::date\n ELSE (now() AT TIME ZONE 'Europe/Paris')::date - 1 END) + time '18:00')\n AT TIME ZONE 'Europe/Paris' AS s0\n),\nsoir AS (\n SELECT sum(p.valeur * extract(epoch FROM least(p.fin, f.s0 + interval '3 hours 30 minutes', now()) - greatest(p.ts, f.s0))\n * CASE WHEN p.cle = 'sensor.victron_evcs_ac_power' THEN -1\n WHEN p.cle = 'sensor.victron_consumption_power_l1' THEN 1 ELSE 0 END) / 3.6e6 AS kwh\n FROM pts p, f\n WHERE p.fin > f.s0 AND p.ts < least(f.s0 + interval '3 hours 30 minutes', now())\n),\nr AS (SELECT regr_intercept(maison_nuit_kwh, soiree_kwh) AS a, regr_slope(maison_nuit_kwh, soiree_kwh) AS b FROM nuits)\nSELECT soiree_kwh::float8 AS \"Soiree\",\n CASE WHEN voiture_kwh < 0.5 THEN maison_nuit_kwh END::float8 AS \"Nuit sans voiture\",\n CASE WHEN voiture_kwh >= 0.5 THEN maison_nuit_kwh END::float8 AS \"Nuit avec voiture\",\n round((r.a + r.b * soiree_kwh)::numeric, 2)::float8 AS \"Regression\",\n NULL::float8 AS \"Ce soir (prevu)\"\n FROM nuits, r\nUNION ALL\nSELECT round(s.kwh::numeric, 2)::float8, NULL, NULL, round((r.a + r.b * s.kwh)::numeric, 2)::float8,\n round((r.a + r.b * s.kwh)::numeric, 2)::float8\n FROM soir s, r\nORDER BY 1"
},
{
"refId": "B",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "WITH pts AS (\n SELECT cle, ts, valeur,\n least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin\n FROM mesure\n WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power',\n 'sensor.victron_grid_power_l1', 'sensor.victron_pv_power_system')\n AND ts > now() - interval '40 days'\n),\njours AS (\n SELECT d::date AS j,\n (d::date + time '18:00') AT TIME ZONE 'Europe/Paris' AS s0,\n (d::date + time '21:30') AT TIME ZONE 'Europe/Paris' AS n0,\n (d::date + 1 + time '01:24') AT TIME ZONE 'Europe/Paris' AS hc,\n (d::date + 1 + time '09:00') AT TIME ZONE 'Europe/Paris' AS n1\n FROM generate_series(current_date - 35, current_date, interval '1 day') d\n),\ne AS (\n SELECT j.j,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.s0 AND p.ts < j.n0) AS c_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.s0 AND p.ts < j.n0) AS v_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.hc) AS c_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.hc) AS v_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS c_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.n1) AS v_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_grid_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS r_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_pv_power_system' AND p.fin > j.n0 AND p.ts < j.n1) AS pv_nuit\n FROM jours j JOIN pts p ON p.fin > j.s0 AND p.ts < j.n1\n GROUP BY j.j\n),\nnuits AS (\n SELECT j.j, j.n0, j.n1,\n round(((coalesce(c_soir,0) - coalesce(v_soir,0)) / 3.6e6)::numeric, 2) AS soiree_kwh,\n round(((coalesce(c_hc,0) - coalesce(v_hc,0)) / 3.6e6)::numeric, 2) AS maison_hc_kwh,\n round(((coalesce(c_nuit,0) - coalesce(v_nuit,0)) / 3.6e6)::numeric, 2) AS maison_nuit_kwh,\n round((coalesce(v_nuit,0) / 3.6e6)::numeric, 2) AS voiture_kwh,\n round((coalesce(r_nuit,0) / 3.6e6)::numeric, 2) AS reseau_kwh,\n round((coalesce(pv_nuit,0) / 3.6e6)::numeric, 2) AS pv_avant_9h_kwh,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n0 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_2130,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.hc AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_0124,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n1 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_9h\n FROM jours j JOIN e ON e.j = j.j\n WHERE j.n1 <= now() AND j.j >= date '2026-09-04'\n)\nSELECT soiree_kwh AS \"Soiree\", maison_nuit_kwh AS \"Nuit avec voiture\" FROM nuits WHERE voiture_kwh >= 0.5 ORDER BY 1"
},
{
"refId": "C",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "WITH pts AS (\n SELECT cle, ts, valeur,\n least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin\n FROM mesure\n WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power',\n 'sensor.victron_grid_power_l1', 'sensor.victron_pv_power_system')\n AND ts > now() - interval '40 days'\n),\njours AS (\n SELECT d::date AS j,\n (d::date + time '18:00') AT TIME ZONE 'Europe/Paris' AS s0,\n (d::date + time '21:30') AT TIME ZONE 'Europe/Paris' AS n0,\n (d::date + 1 + time '01:24') AT TIME ZONE 'Europe/Paris' AS hc,\n (d::date + 1 + time '09:00') AT TIME ZONE 'Europe/Paris' AS n1\n FROM generate_series(current_date - 35, current_date, interval '1 day') d\n),\ne AS (\n SELECT j.j,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.s0 AND p.ts < j.n0) AS c_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.s0 AND p.ts < j.n0) AS v_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.hc) AS c_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.hc) AS v_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS c_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.n1) AS v_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_grid_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS r_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_pv_power_system' AND p.fin > j.n0 AND p.ts < j.n1) AS pv_nuit\n FROM jours j JOIN pts p ON p.fin > j.s0 AND p.ts < j.n1\n GROUP BY j.j\n),\nnuits AS (\n SELECT j.j, j.n0, j.n1,\n round(((coalesce(c_soir,0) - coalesce(v_soir,0)) / 3.6e6)::numeric, 2) AS soiree_kwh,\n round(((coalesce(c_hc,0) - coalesce(v_hc,0)) / 3.6e6)::numeric, 2) AS maison_hc_kwh,\n round(((coalesce(c_nuit,0) - coalesce(v_nuit,0)) / 3.6e6)::numeric, 2) AS maison_nuit_kwh,\n round((coalesce(v_nuit,0) / 3.6e6)::numeric, 2) AS voiture_kwh,\n round((coalesce(r_nuit,0) / 3.6e6)::numeric, 2) AS reseau_kwh,\n round((coalesce(pv_nuit,0) / 3.6e6)::numeric, 2) AS pv_avant_9h_kwh,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n0 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_2130,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.hc AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_0124,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n1 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_9h\n FROM jours j JOIN e ON e.j = j.j\n WHERE j.n1 <= now() AND j.j >= date '2026-09-04'\n)\n\nSELECT v.x AS \"Soiree\", round((r.a + r.b * v.x)::numeric, 2) AS \"Regression\"\n FROM (SELECT regr_intercept(maison_nuit_kwh, soiree_kwh) AS a, regr_slope(maison_nuit_kwh, soiree_kwh) AS b,\n min(soiree_kwh) AS lo, max(soiree_kwh) AS hi FROM nuits) r,\n LATERAL (VALUES (r.lo), (r.hi)) v(x)\n ORDER BY 1"
},
{
"refId": "D",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "WITH pts AS (\n SELECT cle, ts, valeur,\n least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin\n FROM mesure\n WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power',\n 'sensor.victron_grid_power_l1', 'sensor.victron_pv_power_system')\n AND ts > now() - interval '40 days'\n),\njours AS (\n SELECT d::date AS j,\n (d::date + time '18:00') AT TIME ZONE 'Europe/Paris' AS s0,\n (d::date + time '21:30') AT TIME ZONE 'Europe/Paris' AS n0,\n (d::date + 1 + time '01:24') AT TIME ZONE 'Europe/Paris' AS hc,\n (d::date + 1 + time '09:00') AT TIME ZONE 'Europe/Paris' AS n1\n FROM generate_series(current_date - 35, current_date, interval '1 day') d\n),\ne AS (\n SELECT j.j,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.s0 AND p.ts < j.n0) AS c_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n0) - greatest(p.ts, j.s0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.s0 AND p.ts < j.n0) AS v_soir,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.hc) AS c_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.hc) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.hc) AS v_hc,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_consumption_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS c_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_evcs_ac_power' AND p.fin > j.n0 AND p.ts < j.n1) AS v_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_grid_power_l1' AND p.fin > j.n0 AND p.ts < j.n1) AS r_nuit,\n sum(p.valeur * extract(epoch FROM least(p.fin, j.n1) - greatest(p.ts, j.n0))) FILTER (WHERE p.cle = 'sensor.victron_pv_power_system' AND p.fin > j.n0 AND p.ts < j.n1) AS pv_nuit\n FROM jours j JOIN pts p ON p.fin > j.s0 AND p.ts < j.n1\n GROUP BY j.j\n),\nnuits AS (\n SELECT j.j, j.n0, j.n1,\n round(((coalesce(c_soir,0) - coalesce(v_soir,0)) / 3.6e6)::numeric, 2) AS soiree_kwh,\n round(((coalesce(c_hc,0) - coalesce(v_hc,0)) / 3.6e6)::numeric, 2) AS maison_hc_kwh,\n round(((coalesce(c_nuit,0) - coalesce(v_nuit,0)) / 3.6e6)::numeric, 2) AS maison_nuit_kwh,\n round((coalesce(v_nuit,0) / 3.6e6)::numeric, 2) AS voiture_kwh,\n round((coalesce(r_nuit,0) / 3.6e6)::numeric, 2) AS reseau_kwh,\n round((coalesce(pv_nuit,0) / 3.6e6)::numeric, 2) AS pv_avant_9h_kwh,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n0 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_2130,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.hc AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_0124,\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts <= j.n1 AND valeur > 0 ORDER BY ts DESC LIMIT 1) AS soc_9h\n FROM jours j JOIN e ON e.j = j.j\n WHERE j.n1 <= now() AND j.j >= date '2026-09-04'\n),\nf AS (\n SELECT ((CASE WHEN (now() AT TIME ZONE 'Europe/Paris')::time >= time '18:00'\n THEN (now() AT TIME ZONE 'Europe/Paris')::date\n ELSE (now() AT TIME ZONE 'Europe/Paris')::date - 1 END) + time '18:00')\n AT TIME ZONE 'Europe/Paris' AS s0\n),\nsoir AS (\n SELECT sum(p.valeur * extract(epoch FROM least(p.fin, f.s0 + interval '3 hours 30 minutes', now()) - greatest(p.ts, f.s0))\n * CASE WHEN p.cle = 'sensor.victron_evcs_ac_power' THEN -1\n WHEN p.cle = 'sensor.victron_consumption_power_l1' THEN 1 ELSE 0 END) / 3.6e6 AS kwh\n FROM pts p, f\n WHERE p.fin > f.s0 AND p.ts < least(f.s0 + interval '3 hours 30 minutes', now())\n)\nSELECT round(s.kwh::numeric, 2) AS \"Soiree\",\n round((regr_intercept(maison_nuit_kwh, soiree_kwh) + regr_slope(maison_nuit_kwh, soiree_kwh) * s.kwh)::numeric, 2) AS \"Ce soir (prevu)\"\n FROM nuits, soir s GROUP BY s.kwh"
} }
], ],
"fieldConfig": { "fieldConfig": {
@@ -793,88 +760,9 @@
] ]
}, },
"options": { "options": {
"mapping": "manual", "mapping": "auto",
"series": [ "series": [
{ {}
"frame": {
"matcher": {
"id": "byIndex",
"options": 0
}
},
"x": {
"matcher": {
"id": "byName",
"options": "Soiree"
}
},
"y": {
"matcher": {
"id": "byName",
"options": "Nuit sans voiture"
}
}
},
{
"frame": {
"matcher": {
"id": "byIndex",
"options": 1
}
},
"x": {
"matcher": {
"id": "byName",
"options": "Soiree"
}
},
"y": {
"matcher": {
"id": "byName",
"options": "Nuit avec voiture"
}
}
},
{
"frame": {
"matcher": {
"id": "byIndex",
"options": 2
}
},
"x": {
"matcher": {
"id": "byName",
"options": "Soiree"
}
},
"y": {
"matcher": {
"id": "byName",
"options": "Regression"
}
}
},
{
"frame": {
"matcher": {
"id": "byIndex",
"options": 3
}
},
"x": {
"matcher": {
"id": "byName",
"options": "Soiree"
}
},
"y": {
"matcher": {
"id": "byName",
"options": "Ce soir (prevu)"
}
}
}
], ],
"legend": { "legend": {
"showLegend": true, "showLegend": true,

View File

@@ -140,11 +140,57 @@ if (missing.length) {
// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement // - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement
// du stockage, choix qui engagent plusieurs heures. // du stockage, choix qui engagent plusieurs heures.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
function buildSlots(consumption) { // ---------------------------------------------------------------------
// LA NUIT PREVUE PAR LA SOIREE (28/09/2026, demande du proprietaire)
//
// Le profil p50 est une mediane HORAIRE : il ignore la soiree qui vient de se
// passer. Or sur 24 nuits, la maison consomme d'autant plus de 21:30 a 09:00
// que la soiree 18:00-21:30 a ete chargee (four) : nuit = 6,98 + 0,74 x soiree,
// r2 = 0,40. Le service d'historisation publie le FACTEUR nuit prevue / nuit
// moyenne (sensor.evcs_nuit_maison_prevue_kwh) ; on multiplie le profil par ce
// facteur sur la seule fenetre 21:30 -> 09:00, au prorata des heures a cheval.
// Le 28/09 : soiree 6,27 kWh -> facteur 1,09.
//
// Un FACTEUR et non des kWh : le profil vient des compteurs d'energie, la
// regression des puissances integrees ; le rapport annule l'ecart de methode.
// Dans les deux sens pour le profil TYPIQUE (c'est un estimateur, une soiree
// legere allege la nuit) ; le PRUDENT ne descend jamais : max(p80, p50 recale).
// Filet : facteur borne a [0,75 ; 1,45], les extremes des 24 nuits observees
// (0,77 et 1,41 fois la moyenne) — hors de la, c'est une mesure fausse, pas une
// nuit. Facteur absent, perime (> 30 min) ou hors fenetre : profil inchange.
const NIGHT_FACTOR_MIN = 0.75;
const NIGHT_FACTOR_MAX = 1.45;
const nowMsNight = num(input.nowMs, Date.now());
const nightStartMs = num(input.night_house_start_ms, NaN);
const nightEndMs = num(input.night_house_end_ms, NaN);
const nightFactorRaw = num(input.night_house_factor, NaN);
const nightComputedMs = num(input.night_house_computed_ms, NaN);
const nightModelActive =
Number.isFinite(nightFactorRaw) && nightFactorRaw > 0 &&
Number.isFinite(nightStartMs) && Number.isFinite(nightEndMs) && nightEndMs > nightStartMs &&
nowMsNight >= nightStartMs && nowMsNight < nightEndMs &&
Number.isFinite(nightComputedMs) && nowMsNight - nightComputedMs <= 30 * 60000;
const nightFactor = nightModelActive
? Math.min(NIGHT_FACTOR_MAX, Math.max(NIGHT_FACTOR_MIN, nightFactorRaw))
: 1;
function nightShare(startMs) {
const debut = num(startMs, NaN);
if (!nightModelActive || !Number.isFinite(debut)) return 0;
const a = Math.max(debut, nightStartMs), b = Math.min(debut + 3600000, nightEndMs);
return b > a ? (b - a) / 3600000 : 0;
}
function nightScaled(profile, slot) {
const h = num(slot.hour, 0);
return (profile[h] ?? 0) * (1 + (nightFactor - 1) * nightShare(slot.startMs));
}
function buildSlots(consumption, prudent = false) {
return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => { return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {
const hour = num(slot.hour, 0); const hour = num(slot.hour, 0);
const productionKwh = Math.max(0, num(slot.expectedKwh, 0)); const productionKwh = Math.max(0, num(slot.expectedKwh, 0));
const consumptionKwh = consumption[hour] ?? 0; const consumptionKwh = prudent
? Math.max(consumption[hour] ?? 0, nightScaled(consumptionP50, slot))
: nightScaled(consumption, slot);
return { return {
startMs: slot.startMs, startMs: slot.startMs,
hour, hour,
@@ -341,7 +387,7 @@ function coverage(slots, hcFloorPct = 0) {
} }
const slotsP50 = buildSlots(consumptionP50); const slotsP50 = buildSlots(consumptionP50);
const slotsP80 = buildSlots(consumptionP80); const slotsP80 = buildSlots(consumptionP80, true);
const hcFloorTypicalPct = hcReachFloorPct(slotsP50, nextHcStartMs); const hcFloorTypicalPct = hcReachFloorPct(slotsP50, nextHcStartMs);
const hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0; const hcFloorNightPrudentPct = hcNightReachActive ? hcReachFloorPct(slotsP80, nextNightHcStartMs) : 0;
const hcFloorPrudentPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct); const hcFloorPrudentPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs), hcFloorNightPrudentPct);
@@ -613,6 +659,14 @@ return [{
surplusAvailableKwh: round2(surplusAfterCrossingKwh), surplusAvailableKwh: round2(surplusAfterCrossingKwh),
}, },
// Nuit prevue par la soiree (28/09) : facteur applique au profil.
nightModel: {
active: nightModelActive,
factor: Math.round(nightFactor * 1000) / 1000,
rawFactor: Number.isFinite(nightFactorRaw) ? nightFactorRaw : null,
forecastKwh: num(input.night_house_forecast_kwh, NaN) || null,
},
// Voiture branchee en HC, depart avant la prochaine HC (28/09). // Voiture branchee en HC, depart avant la prochaine HC (28/09).
vehicleMidday: { vehicleMidday: {
active: vehicleRuleActive, active: vehicleRuleActive,

View File

@@ -154,7 +154,11 @@ const hhmm = ms => new Date(ms).toLocaleTimeString('fr-FR', { timeZone: 'Europe/
const vehText = vm.active const vehText = vm.active
? ` Voiture (départ ${hhmm(vm.departureMs)}) : ${vm.reachableKwh} kWh à donner, solaire ${vm.solarKwh} kWh → ${vm.gridNeeded ? 'réseau justifié' : 'solaire suffisant'}.` ? ` Voiture (départ ${hhmm(vm.departureMs)}) : ${vm.reachableKwh} kWh à donner, solaire ${vm.solarKwh} kWh → ${vm.gridNeeded ? 'réseau justifié' : 'solaire suffisant'}.`
: ''; : '';
setText('input_text.evcs_balance_reason', reason + hcText + vehText + degradedNote + ndText); const nm = balance.nightModel || {};
const nightText = nm.active && Math.abs(nm.factor - 1) >= 0.005
? ` Nuit recalée sur la soirée : conso ×${String(nm.factor).replace('.', ',')}${nm.forecastKwh ? ` (${String(nm.forecastKwh).replace('.', ',')} kWh prévus 21:30-09:00)` : ''}.`
: '';
setText('input_text.evcs_balance_reason', reason + nightText + hcText + vehText + degradedNote + ndText);
// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil // Trace longue durée, pour comparer jour après jour le seuil calculé au seuil
// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage. // fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.

View File

@@ -59,6 +59,11 @@
{%- endif -%} {%- endif -%}
{{ { {{ {
"ts": now().isoformat(), "ts": now().isoformat(),
"night_house_factor": state_attr("sensor.evcs_nuit_maison_prevue_kwh", "facteur"),
"night_house_start_ms": state_attr("sensor.evcs_nuit_maison_prevue_kwh", "debut_ms"),
"night_house_end_ms": state_attr("sensor.evcs_nuit_maison_prevue_kwh", "fin_ms"),
"night_house_computed_ms": (as_timestamp(state_attr("sensor.evcs_nuit_maison_prevue_kwh", "calcule_a"), 0) * 1000),
"night_house_forecast_kwh": states("sensor.evcs_nuit_maison_prevue_kwh"),
"vehicle_connected": is_state("binary_sensor.victron_evcs_vehicle_connected", "on"), "vehicle_connected": is_state("binary_sensor.victron_evcs_vehicle_connected", "on"),
"vehicle_departure_ms": (as_timestamp(vh.dep) * 1000) if vh.dep != '' else none, "vehicle_departure_ms": (as_timestamp(vh.dep) * 1000) if vh.dep != '' else none,
"vehicle_level_pct": states("input_number.victron_evcs_vehicle_level_pct"), "vehicle_level_pct": states("input_number.victron_evcs_vehicle_level_pct"),

View File

@@ -60,6 +60,12 @@ return [{
// Plancher de charge HC manuelle (18/09) : oublie ici jusqu'au 28/09, le // Plancher de charge HC manuelle (18/09) : oublie ici jusqu'au 28/09, le
// bouton n'avait donc jamais atteint le bilan. // bouton n'avait donc jamais atteint le bilan.
manual_hc_floor_percent: snapshot.manual_hc_floor_percent, manual_hc_floor_percent: snapshot.manual_hc_floor_percent,
// Nuit de la maison prevue par la soiree (28/09) : facteur sur le profil.
night_house_factor: snapshot.night_house_factor,
night_house_start_ms: snapshot.night_house_start_ms,
night_house_end_ms: snapshot.night_house_end_ms,
night_house_computed_ms: snapshot.night_house_computed_ms,
night_house_forecast_kwh: snapshot.night_house_forecast_kwh,
// Vehicule branche a midi (28/09, JEEP/VW/ZERO) : depart du jour et besoin saisi. // Vehicule branche a midi (28/09, JEEP/VW/ZERO) : depart du jour et besoin saisi.
vehicle_connected: snapshot.vehicle_connected, vehicle_connected: snapshot.vehicle_connected,
vehicle_departure_ms: snapshot.vehicle_departure_ms, vehicle_departure_ms: snapshot.vehicle_departure_ms,

View File

@@ -2,7 +2,7 @@
// Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7. // Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7.
// node tests/diagnostics-agent/run.mjs // node tests/diagnostics-agent/run.mjs
import fs from 'node:fs'; import fs from 'node:fs';
import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, COMPTEURS_IMPORT, COMPTEUR_EDF, MPPT_MODES, ORIGINE_PAR_SOURCE, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes } from '../../tools/diagnostics-agent.mjs'; import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, COMPTEURS_IMPORT, COMPTEUR_EDF, MPPT_MODES, ORIGINE_PAR_SOURCE, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from '../../tools/diagnostics-agent.mjs';
const LIM = 'voltage_current_limited', ACT = 'mppt_active'; const LIM = 'voltage_current_limited', ACT = 'mppt_active';
function st({ pv, conso, reseau, soc = 80, courant = 0, edf = 'off', modes }) { function st({ pv, conso, reseau, soc = 80, courant = 0, edf = 'off', modes }) {
@@ -287,6 +287,40 @@ for (const [nom, r, test] of imp) {
globalThis.__nConso = (globalThis.__nConso || 0) + pj.length; globalThis.__nConso = (globalThis.__nConso || 0) + pj.length;
} }
// --- Nuit de la maison prevue par la soiree : les 24 nuits REELLES (04->27/09) ---
{
const N = [[4.11,10.29],[6.52,9.81],[4.65,9.9],[3.76,8.66],[3.53,8.55],[3.61,9.48],[3.73,8.7],[7.04,11.88],
[2.96,10.36],[5.73,13.63],[5.39,8.93],[3.12,8.14],[4.35,12.44],[5.08,12.64],[3.74,10],[4.24,10.75],
[4.96,12.18],[5.47,11.43],[6.68,13.85],[9.48,14.95],[7.48,9.56],[4.41,9.72],[3.67,10.04],[4.81,8.87]]
.map(([soiree, nuit]) => ({ soiree, nuit }));
const soir = new Date('2026-09-28T21:00:00Z'); // 23:00 a Paris
const r = nuitMaisonPrevue(N, 6.27, soir);
const cas = [
// Verifie par un calcul independant (Python) : a 6,979, b 0,7362, r2 0,388,
// moyenne 10,615, prevue 11,595, facteur 1,0924.
['soir du 28/09 : soiree 6,27 -> nuit prevue 11,6 kWh (a 6,98, b 0,736, r2 0,39)', r,
(x) => x.etat === 11.6 && x.attributs.a === 6.98 && x.attributs.b === 0.736 && x.attributs.r2 === 0.39],
['facteur = prevue / moyenne des memes nuits (10,62) -> 1,092', r, (x) => x.attributs.facteur === 1.092 && x.attributs.nuit_moyenne_kwh === 10.62],
['fenetre 21:30 -> 09:00 de la bonne nuit', r,
(x) => x.attributs.debut_ms === Date.parse('2026-09-28T19:30:00Z') && x.attributs.fin_ms === Date.parse('2026-09-29T07:00:00Z')],
['03:00 : c est la nuit commencee la veille', nuitMaisonPrevue(N, 6.27, new Date('2026-09-29T01:00:00Z')),
(x) => x.etat === 11.6 && x.attributs.debut_ms === Date.parse('2026-09-28T19:30:00Z')],
['14:00 -> hors_fenetre (pas de soiree complete)', nuitMaisonPrevue(N, 6.27, new Date('2026-09-28T12:00:00Z')),
(x) => x.etat === 'hors_fenetre' && x.attributs.facteur === null],
['10 nuits seulement -> indisponible', nuitMaisonPrevue(N.slice(0, 10), 6.27, soir),
(x) => x.etat === 'indisponible' && x.attributs.facteur === null],
['nuage sans relation (r2 < 0,2) -> indisponible', nuitMaisonPrevue(N.map((p, i) => ({ soiree: p.soiree, nuit: 10 + (i % 2) })), 6.27, soir),
(x) => x.etat === 'indisponible'],
['soiree illisible -> indisponible', nuitMaisonPrevue(N, NaN, soir), (x) => x.etat === 'indisponible'],
];
for (const [nom, x, test] of cas) {
const ok = test(x); if (!ok) { ko++; console.log(JSON.stringify(x)); }
console.log(`${ok ? 'OK ' : 'KO '} ${nom}`);
}
globalThis.__nConso = (globalThis.__nConso || 0) + cas.length;
}
// --- Prevision de la nuit : releve reel du 28/09 01:25 ---------------------- // --- Prevision de la nuit : releve reel du 28/09 01:25 ----------------------
{ {
const base = () => new Map(Object.entries({ const base = () => new Map(Object.entries({

View File

@@ -106,6 +106,9 @@ const SUMMARIZE = {
hcReachActive: o.hcReach ? o.hcReach.active : false, hcReachActive: o.hcReach ? o.hcReach.active : false,
hcFloorPct: o.hcReach ? o.hcReach.floorPct : null, hcFloorPct: o.hcReach ? o.hcReach.floorPct : null,
hcNightFloorPrudentPct: o.hcReach ? o.hcReach.nightFloorPrudentPct : null, hcNightFloorPrudentPct: o.hcReach ? o.hcReach.nightFloorPrudentPct : null,
// Nuit prevue par la soiree (28/09).
nightModelActive: o.nightModel ? o.nightModel.active : false,
nightFactor: o.nightModel ? o.nightModel.factor : null,
// Voiture branchee en HC, depart avant la prochaine HC (28/09). // Voiture branchee en HC, depart avant la prochaine HC (28/09).
vehicleActive: o.vehicleMidday ? o.vehicleMidday.active : false, vehicleActive: o.vehicleMidday ? o.vehicleMidday.active : false,
vehicleNeedKwh: o.vehicleMidday ? o.vehicleMidday.needKwh : null, vehicleNeedKwh: o.vehicleMidday ? o.vehicleMidday.needKwh : null,

View File

@@ -512,4 +512,49 @@ export const balanceScenarios = [
snapshot: jeep(snap2709('2026-09-27T12:30:00+02:00', 30, MIDI_2709, SOMBRE), 0, { vehicle_capacity_kwh: 15, vehicle_charger_kw: 0 }), snapshot: jeep(snap2709('2026-09-27T12:30:00+02:00', 30, MIDI_2709, SOMBRE), 0, { vehicle_capacity_kwh: 15, vehicle_charger_kw: 0 }),
expect: { usable: true, vehicleActive: true, vehicleReachableKwh: 15, vehicleGridNeeded: true }, expect: { usable: true, vehicleActive: true, vehicleReachableKwh: 15, vehicleGridNeeded: true },
}, },
// ======== LA NUIT PREVUE PAR LA SOIREE (28/09/2026) ========
// Soir du 28/09 : soiree 6,27 kWh -> facteur 1,092 sur le profil de 21:30 a
// 09:00. Meme snapshot avec et sans facteur : seule la consommation de nuit
// bouge, donc le SOC requis monte.
{
node: 'BALANCE',
id: 'bilan-nuit-soiree-reference-sans-facteur',
label: 'Reference : 23:00, SOC 52, sans facteur de nuit',
snapshot: snap2709('2026-09-27T23:00:00+02:00', 52, null),
expect: { usable: true, nightModelActive: false, nightFactor: 1, energyToCoverKwh: 7.77, requiredSocPct: 47.9 },
},
{
node: 'BALANCE',
id: 'bilan-nuit-soiree-chargee-releve-le-requis',
label: 'Soiree chargee (facteur 1,092) : la nuit est plus lourde, le SOC requis monte',
snapshot: Object.assign(snap2709('2026-09-27T23:00:00+02:00', 52, null), {
night_house_factor: 1.092, night_house_forecast_kwh: '11.59',
night_house_start_ms: Date.parse('2026-09-27T21:30:00+02:00'), night_house_end_ms: Date.parse('2026-09-28T09:00:00+02:00'),
night_house_computed_ms: Date.parse('2026-09-27T22:57:00+02:00') }),
// Verifie a la main : profil 23:00->09:00 = 8,0 kWh, x 0,092 = +0,74 (0,71
// apres l'heure ou le soleil revient) ; +2,4 points = 0,71 / 0,30. Le
// prudent (p80) est deja au-dessus du p50 recale : il ne bouge pas.
expect: { usable: true, nightModelActive: true, nightFactor: 1.092, energyToCoverKwh: 8.48,
requiredSocPct: 50.3, prudentRequiredSocPct: 56.7 },
},
{
node: 'BALANCE',
id: 'bilan-nuit-soiree-facteur-perime',
label: 'Facteur calcule il y a 2 h : on ne l applique pas',
snapshot: Object.assign(snap2709('2026-09-27T23:00:00+02:00', 52, null), {
night_house_factor: 1.092,
night_house_start_ms: Date.parse('2026-09-27T21:30:00+02:00'), night_house_end_ms: Date.parse('2026-09-28T09:00:00+02:00'),
night_house_computed_ms: Date.parse('2026-09-27T21:00:00+02:00') }),
expect: { usable: true, nightModelActive: false, nightFactor: 1 },
},
{
node: 'BALANCE',
id: 'bilan-nuit-soiree-facteur-aberrant-borne',
label: 'Facteur 3 (mesure fausse) : borne a 1,45, l extreme observe',
snapshot: Object.assign(snap2709('2026-09-27T23:00:00+02:00', 52, null), {
night_house_factor: 3,
night_house_start_ms: Date.parse('2026-09-27T21:30:00+02:00'), night_house_end_ms: Date.parse('2026-09-28T09:00:00+02:00'),
night_house_computed_ms: Date.parse('2026-09-27T22:57:00+02:00') }),
expect: { usable: true, nightModelActive: true, nightFactor: 1.45, requiredSocPct: 59.8, prudentRequiredSocPct: 62.2 },
},
]; ];

View File

@@ -677,3 +677,70 @@ export function vrmVersCourbes(paires, maintenant = new Date()) {
updated: `${p2(d.getDate())}/${p2(d.getMonth() + 1)} ${p2(d.getHours())}:${p2(d.getMinutes())}`, updated: `${p2(d.getDate())}/${p2(d.getMonth() + 1)} ${p2(d.getHours())}:${p2(d.getMinutes())}`,
}; };
} }
// ---------------------------------------------------------------------
// La nuit de la maison, prevue par la soiree (28/09/2026)
// ---------------------------------------------------------------------
// Demande du proprietaire : « si tu superposes 20 nuits tu dois trouver un
// nuage de points ». Sur 24 nuits (04/09 -> 27/09), l'energie consommee par la
// maison de 21:30 a 09:00 (voiture exclue) suit celle de la soiree 18:00-21:30 :
// nuit = 6,98 + 0,74 x soiree, r2 = 0,40. Une soiree chargee (four) annonce une
// nuit chargee ; le profil horaire MOYEN du bilan ne le voit pas.
//
// Ce qui est publie n'est PAS un kWh a substituer au bilan : c'est un FACTEUR,
// nuit prevue / nuit moyenne des MEMES donnees. Le profil du bilan vient des
// compteurs d'energie, la regression des puissances integrees : le rapport
// annule l'ecart de methode, que la difference ne ferait qu'ajouter.
//
// Garde-fous : au moins 15 nuits et r2 >= 0,2, sinon le modele ne vaut rien et
// on le dit (`indisponible`). Hors 21:30 -> 09:00, pas de soiree complete a
// lire : `hors_fenetre`.
export const NUIT_MIN_NUITS = 15;
export const NUIT_MIN_R2 = 0.2;
function heureLocaleMs(jour, hhmm, reference) {
// Meme calcul de decalage que minuitLocal, pour une heure de Paris donnee.
const decalage = new Date(reference.toLocaleString('en-US', { timeZone: 'Europe/Paris' })) -
new Date(reference.toLocaleString('en-US', { timeZone: 'UTC' }));
return Date.parse(`${jour}T${hhmm}:00Z`) - decalage;
}
export function nuitMaisonPrevue(nuits, soireeKwh, maintenant = new Date()) {
const jourDe = (d) => new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris' }).format(d);
const hm = Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false })
.format(maintenant).replace(':', ''));
const aujourdhui = jourDe(maintenant);
const hier = jourDe(new Date(maintenant.getTime() - 86400000));
const demain = jourDe(new Date(maintenant.getTime() + 86400000));
const fenetre = hm >= 2130 ? { soir: aujourdhui, matin: demain } : hm < 900 ? { soir: hier, matin: aujourdhui } : null;
const pts = (nuits || []).map((x) => [Number(x.soiree), Number(x.nuit)]).filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b));
const n = pts.length;
let modele = null;
if (n >= 2) {
const mx = pts.reduce((s, p) => s + p[0], 0) / n, my = pts.reduce((s, p) => s + p[1], 0) / n;
let sxy = 0, sxx = 0, syy = 0;
for (const [x, y] of pts) { sxy += (x - mx) * (y - my); sxx += (x - mx) ** 2; syy += (y - my) ** 2; }
if (sxx > 0 && syy > 0) {
const b = sxy / sxx;
modele = { a: my - b * mx, b, r2: (sxy * sxy) / (sxx * syy), moyenne: my };
}
}
const r2 = (v) => Math.round(v * 100) / 100;
const attributs = {
nuits: n, soiree_kwh: Number.isFinite(soireeKwh) ? r2(soireeKwh) : null,
a: modele ? r2(modele.a) : null, b: modele ? Math.round(modele.b * 1000) / 1000 : null,
r2: modele ? r2(modele.r2) : null, nuit_moyenne_kwh: modele ? r2(modele.moyenne) : null,
debut_ms: fenetre ? heureLocaleMs(fenetre.soir, '21:30', maintenant) : null,
fin_ms: fenetre ? heureLocaleMs(fenetre.matin, '09:00', maintenant) : null,
};
if (!fenetre) return { etat: 'hors_fenetre', attributs: { ...attributs, facteur: null, statut: 'hors_fenetre' } };
if (!modele || n < NUIT_MIN_NUITS || modele.r2 < NUIT_MIN_R2 || !Number.isFinite(soireeKwh)) {
return { etat: 'indisponible', attributs: { ...attributs, facteur: null, statut: 'indisponible',
explication: !Number.isFinite(soireeKwh) ? 'soiree illisible'
: n < NUIT_MIN_NUITS ? `seulement ${n} nuits (${NUIT_MIN_NUITS} requises)`
: `modele trop faible (r2 ${modele ? r2(modele.r2) : '?'} < ${NUIT_MIN_R2})` } };
}
const prevue = modele.a + modele.b * soireeKwh;
return { etat: r2(prevue), attributs: { ...attributs, facteur: Math.round((prevue / modele.moyenne) * 1000) / 1000,
statut: 'ok', explication: `soiree ${r2(soireeKwh)} kWh -> nuit prevue ${r2(prevue)} kWh (moyenne ${r2(modele.moyenne)}, ${n} nuits, r2 ${r2(modele.r2)})` } };
}

View File

@@ -217,13 +217,17 @@ PREVISION_A_VENIR = """
# changement), plafond 1 h pour qu'un trou n'extrapole rien — meme methode que # changement), plafond 1 h pour qu'un trou n'extrapole rien — meme methode que
# le rapport de nuit (§76). « Maison » = consommation AC moins la voiture. # le rapport de nuit (§76). « Maison » = consommation AC moins la voiture.
# Chiffres verifies contre un calcul independant (script) le 28/09 : identiques. # Chiffres verifies contre un calcul independant (script) le 28/09 : identiques.
NUITS_CTE = pathlib.Path(__file__).with_name('grafana-nuits.sql').read_text() NUITS_CTE = pathlib.Path(__file__).with_name('nuits-maison.sql').read_text()
PROFIL_SQL = pathlib.Path(__file__).with_name('grafana-profil-horaire.sql').read_text() PROFIL_SQL = pathlib.Path(__file__).with_name('grafana-profil-horaire.sql').read_text()
def nuits_sql(select): def nuits_sql(select):
return NUITS_CTE.rstrip() + "\n" + select return NUITS_CTE.rstrip() + "\n" + select
CE_SOIR = """, # UNE seule trame, colonnes X puis Y (mapping automatique : premiere colonne
# numerique = X). La premiere version (4 requetes, mapping manuel par trame)
# affichait « Err » dans Grafana 11.3. La droite est calculee pour CHAQUE nuit :
# elle passe par toutes les abscisses, donc aucun trou ne la coupe.
NUAGE = """,
f AS ( f AS (
SELECT ((CASE WHEN (now() AT TIME ZONE 'Europe/Paris')::time >= time '18:00' SELECT ((CASE WHEN (now() AT TIME ZONE 'Europe/Paris')::time >= time '18:00'
THEN (now() AT TIME ZONE 'Europe/Paris')::date THEN (now() AT TIME ZONE 'Europe/Paris')::date
@@ -236,17 +240,19 @@ soir AS (
WHEN p.cle = 'sensor.victron_consumption_power_l1' THEN 1 ELSE 0 END) / 3.6e6 AS kwh WHEN p.cle = 'sensor.victron_consumption_power_l1' THEN 1 ELSE 0 END) / 3.6e6 AS kwh
FROM pts p, f FROM pts p, f
WHERE p.fin > f.s0 AND p.ts < least(f.s0 + interval '3 hours 30 minutes', now()) WHERE p.fin > f.s0 AND p.ts < least(f.s0 + interval '3 hours 30 minutes', now())
) ),
SELECT round(s.kwh::numeric, 2) AS "Soiree", r AS (SELECT regr_intercept(maison_nuit_kwh, soiree_kwh) AS a, regr_slope(maison_nuit_kwh, soiree_kwh) AS b FROM nuits)
round((regr_intercept(maison_nuit_kwh, soiree_kwh) + regr_slope(maison_nuit_kwh, soiree_kwh) * s.kwh)::numeric, 2) AS "Ce soir (prevu)" SELECT soiree_kwh::float8 AS "Soiree",
FROM nuits, soir s GROUP BY s.kwh""" CASE WHEN voiture_kwh < 0.5 THEN maison_nuit_kwh END::float8 AS "Nuit sans voiture",
CASE WHEN voiture_kwh >= 0.5 THEN maison_nuit_kwh END::float8 AS "Nuit avec voiture",
DROITE = """ round((r.a + r.b * soiree_kwh)::numeric, 2)::float8 AS "Regression",
SELECT v.x AS "Soiree", round((r.a + r.b * v.x)::numeric, 2) AS "Regression" NULL::float8 AS "Ce soir (prevu)"
FROM (SELECT regr_intercept(maison_nuit_kwh, soiree_kwh) AS a, regr_slope(maison_nuit_kwh, soiree_kwh) AS b, FROM nuits, r
min(soiree_kwh) AS lo, max(soiree_kwh) AS hi FROM nuits) r, UNION ALL
LATERAL (VALUES (r.lo), (r.hi)) v(x) SELECT round(s.kwh::numeric, 2)::float8, NULL, NULL, round((r.a + r.b * s.kwh)::numeric, 2)::float8,
ORDER BY 1""" round((r.a + r.b * s.kwh)::numeric, 2)::float8
FROM soir s, r
ORDER BY 1"""
NUIT_XY = {"type": "xychart", "title": "Soiree contre nuit : le nuage des nuits", NUIT_XY = {"type": "xychart", "title": "Soiree contre nuit : le nuage des nuits",
"description": ("Un point = une nuit. En abscisse ce que la maison a consomme de 18:00 a " "description": ("Un point = une nuit. En abscisse ce que la maison a consomme de 18:00 a "
@@ -255,13 +261,7 @@ NUIT_XY = {"type": "xychart", "title": "Soiree contre nuit : le nuage des nuits"
"est CE SOIR, place sur la droite : une prevision, pas une mesure. " "est CE SOIR, place sur la droite : une prevision, pas une mesure. "
"1 point de SOC = 0,30 kWh sur le parc actuel."), "1 point de SOC = 0,30 kWh sur le parc actuel."),
"datasource": PG, "gridPos": {"x": 0, "y": 32, "w": 14, "h": 11}, "datasource": PG, "gridPos": {"x": 0, "y": 32, "w": 14, "h": 11},
"targets": [ "targets": [sql("A", NUITS_CTE.rstrip() + NUAGE, fmt="table")],
sql("A", nuits_sql('SELECT soiree_kwh AS "Soiree", maison_nuit_kwh AS "Nuit sans voiture" '
'FROM nuits WHERE voiture_kwh < 0.5 ORDER BY 1'), fmt="table"),
sql("B", nuits_sql('SELECT soiree_kwh AS "Soiree", maison_nuit_kwh AS "Nuit avec voiture" '
'FROM nuits WHERE voiture_kwh >= 0.5 ORDER BY 1'), fmt="table"),
sql("C", nuits_sql(DROITE), fmt="table"),
sql("D", NUITS_CTE.rstrip() + CE_SOIR, fmt="table")],
"fieldConfig": {"defaults": {"unit": "kwatth", "color": {"mode": "palette-classic"}, "fieldConfig": {"defaults": {"unit": "kwatth", "color": {"mode": "palette-classic"},
"custom": {"show": "points", "pointSize": {"fixed": 9}, "pointShape": "circle", "custom": {"show": "points", "pointSize": {"fixed": 9}, "pointShape": "circle",
"lineWidth": 2, "fillOpacity": 60}}, "lineWidth": 2, "fillOpacity": 60}},
@@ -276,12 +276,7 @@ NUIT_XY = {"type": "xychart", "title": "Soiree contre nuit : le nuage des nuits"
{"matcher": {"id": "byName", "options": "Ce soir (prevu)"}, {"matcher": {"id": "byName", "options": "Ce soir (prevu)"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}},
{"id": "custom.pointSize", "value": {"fixed": 18}}]}]}, {"id": "custom.pointSize", "value": {"fixed": 18}}]}]},
"options": {"mapping": "manual", "options": {"mapping": "auto", "series": [{}],
"series": [{"frame": {"matcher": {"id": "byIndex", "options": i}},
"x": {"matcher": {"id": "byName", "options": "Soiree"}},
"y": {"matcher": {"id": "byName", "options": n}}}
for i, n in enumerate(["Nuit sans voiture", "Nuit avec voiture",
"Regression", "Ce soir (prevu)"])],
"legend": {"showLegend": True, "displayMode": "list", "placement": "bottom"}, "legend": {"showLegend": True, "displayMode": "list", "placement": "bottom"},
"tooltip": {"mode": "single", "sort": "none"}}} "tooltip": {"mode": "single", "sort": "none"}}}

View File

@@ -30,7 +30,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import pg from 'pg'; import pg from 'pg';
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 } 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';
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');
@@ -1994,6 +1994,43 @@ async function publierPrevisionVrm(sql) {
return n; return n;
} }
// La nuit de la maison prevue par la soiree (28/09/2026). Les nuits closes
// viennent de la MEME requete que le tableau Grafana « La nuit »
// (tools/nuits-maison.sql) : un seul calcul, deux lecteurs. Le bilan lit le
// FACTEUR (nuit prevue / nuit moyenne) pour recaler son profil de 21:30 a 09:00.
const NUITS_SQL = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'nuits-maison.sql'), 'utf8');
const SOIREE_SQL = `
WITH pts AS (
SELECT cle, ts, valeur,
least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin
FROM mesure WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power')
AND ts > to_timestamp($1 / 1000.0) - interval '2 hours' AND ts < to_timestamp($2 / 1000.0)
)
SELECT sum(valeur * extract(epoch FROM least(fin, to_timestamp($2 / 1000.0)) - greatest(ts, to_timestamp($1 / 1000.0)))
* CASE WHEN cle = 'sensor.victron_evcs_ac_power' THEN -1 ELSE 1 END) / 3.6e6 AS kwh,
count(*) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS points
FROM pts WHERE fin > to_timestamp($1 / 1000.0)`;
let nuitMaisonPubliee = false;
async function publierNuitMaison(client) {
if (DRY || !client) return 0;
const maintenant = new Date();
const cadre = nuitMaisonPrevue([], NaN, maintenant);
let res = cadre;
if (cadre.etat !== 'hors_fenetre') {
const nuits = (await client.query(NUITS_SQL + '\nSELECT soiree_kwh AS soiree, maison_nuit_kwh AS nuit FROM nuits')).rows;
const debut = cadre.attributs.debut_ms;
const soir = (await client.query(SOIREE_SQL, [debut - 3.5 * 3600000, debut])).rows[0];
// Une soiree sans mesure (service arrete) ne vaut pas zero kWh.
const soiree = soir && Number(soir.points) > 0 ? Number(soir.kwh) : NaN;
res = nuitMaisonPrevue(nuits, soiree, maintenant);
}
await publierCapteur('sensor.evcs_nuit_maison_prevue_kwh', res.etat, {
friendly_name: 'EVCS — nuit de la maison prevue par la soiree', icon: 'mdi:chart-scatter-plot',
unit_of_measurement: typeof res.etat === 'number' ? 'kWh' : undefined, ...res.attributs,
calcule_a: maintenant.toISOString(),
});
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
async function publierBilanNuit(client) { async function publierBilanNuit(client) {
if (DRY || !client) return 0; if (DRY || !client) return 0;
@@ -2095,6 +2132,9 @@ async function cycle(client) {
vrm: (DRY || (vrmPublie && new Date().getMinutes() % 15 !== 7)) ? 0 : await publierPrevisionVrm(sql).then((n) => { vrmPublie = true; return n; }).catch((e) => { vrm: (DRY || (vrmPublie && new Date().getMinutes() % 15 !== 7)) ? 0 : await publierPrevisionVrm(sql).then((n) => { vrmPublie = true; return n; }).catch((e) => {
console.error(`prevision VRM : ${e.message}`); return 0; console.error(`prevision VRM : ${e.message}`); return 0;
}), }),
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;
}),
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;
}), }),

File diff suppressed because one or more lines are too long