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

@@ -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())}`,
};
}
// ---------------------------------------------------------------------
// 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
# le rapport de nuit (§76). « Maison » = consommation AC moins la voiture.
# 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()
def nuits_sql(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 (
SELECT ((CASE WHEN (now() AT TIME ZONE 'Europe/Paris')::time >= time '18:00'
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
FROM pts p, f
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",
round((regr_intercept(maison_nuit_kwh, soiree_kwh) + regr_slope(maison_nuit_kwh, soiree_kwh) * s.kwh)::numeric, 2) AS "Ce soir (prevu)"
FROM nuits, soir s GROUP BY s.kwh"""
DROITE = """
SELECT v.x AS "Soiree", round((r.a + r.b * v.x)::numeric, 2) AS "Regression"
FROM (SELECT regr_intercept(maison_nuit_kwh, soiree_kwh) AS a, regr_slope(maison_nuit_kwh, soiree_kwh) AS b,
min(soiree_kwh) AS lo, max(soiree_kwh) AS hi FROM nuits) r,
LATERAL (VALUES (r.lo), (r.hi)) v(x)
ORDER BY 1"""
),
r AS (SELECT regr_intercept(maison_nuit_kwh, soiree_kwh) AS a, regr_slope(maison_nuit_kwh, soiree_kwh) AS b FROM nuits)
SELECT soiree_kwh::float8 AS "Soiree",
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",
round((r.a + r.b * soiree_kwh)::numeric, 2)::float8 AS "Regression",
NULL::float8 AS "Ce soir (prevu)"
FROM nuits, r
UNION ALL
SELECT round(s.kwh::numeric, 2)::float8, NULL, NULL, round((r.a + r.b * s.kwh)::numeric, 2)::float8,
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",
"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. "
"1 point de SOC = 0,30 kWh sur le parc actuel."),
"datasource": PG, "gridPos": {"x": 0, "y": 32, "w": 14, "h": 11},
"targets": [
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")],
"targets": [sql("A", NUITS_CTE.rstrip() + NUAGE, fmt="table")],
"fieldConfig": {"defaults": {"unit": "kwatth", "color": {"mode": "palette-classic"},
"custom": {"show": "points", "pointSize": {"fixed": 9}, "pointShape": "circle",
"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)"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}},
{"id": "custom.pointSize", "value": {"fixed": 18}}]}]},
"options": {"mapping": "manual",
"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)"])],
"options": {"mapping": "auto", "series": [{}],
"legend": {"showLegend": True, "displayMode": "list", "placement": "bottom"},
"tooltip": {"mode": "single", "sort": "none"}}}

View File

@@ -30,7 +30,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import pg from 'pg';
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 ONCE = process.argv.includes('--once');
@@ -1994,6 +1994,43 @@ async function publierPrevisionVrm(sql) {
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
async function publierBilanNuit(client) {
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) => {
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) => {
console.error(`bilan de nuit : ${e.message}`); return 0;
}),