From e80dbf70fb50473c7dcdb0b7fcb044cc03ec7db2 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Sat, 5 Sep 2026 02:54:05 +0200 Subject: [PATCH] Table des sessions de charge : etiqueter pendant, pas deviner apres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'etiquette du vehicule n'existe QUE pendant la session. `input_select.victron_evcs_vehicle` est un reglage manuel, il change, et rien ne conservait sa valeur au moment ou la charge avait lieu — une session d'il y a trois semaines etait anonyme pour toujours, et toute analyse d'usage impossible. Nouvelle table `session_charge` : debut, fin, vehicule tel que le selecteur l'indiquait PENDANT la session, un drapeau disant s'il a change en cours de route, energie accumulee, puissance et courant maximum, mode source, et l'heure de debranchement — qui confirme apres coup, les deux vehicules partant a des heures differentes. Le service applique lui-meme la migration au demarrage : `schema.sql` n'est joue qu'a la creation de la base, une table ajoutee plus tard n'y arriverait jamais. `CREATE TABLE IF NOT EXISTS` rend l'operation rejouable. On ne DEVINE rien, et le commentaire du schema dit pourquoi : - electriquement, la VW accepte 3 680 W et la JEEP 3 655 W a consigne identique de 20 A — 0,1 A d'ecart, indiscernables ; - horairement, la routine s'inverse le week-end (la femme du proprietaire travaille samedi et dimanche matin), donc « midi = JEEP, nuit = VW » serait faux un week-end sur deux. Essai a blanc : la session en cours serait correctement inscrite (JEEP, 3396 W, Reseau force, profil night_hc). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ --- deploy/historisation/schema.sql | 31 ++++++++ docs/MANUEL_COMPLET.md | 13 +++- tools/historise-evcs.mjs | 134 +++++++++++++++++++++++++++++++- 3 files changed, 172 insertions(+), 6 deletions(-) diff --git a/deploy/historisation/schema.sql b/deploy/historisation/schema.sql index 1187744..c881947 100644 --- a/deploy/historisation/schema.sql +++ b/deploy/historisation/schema.sql @@ -120,6 +120,37 @@ CREATE TABLE evenement ( ); CREATE INDEX evenement_ts_idx ON evenement (ts DESC); +-- --------------------------------------------------------------------- +-- 6. SESSION_CHARGE — une ligne par charge de vehicule +-- --------------------------------------------------------------------- +-- Raison d'etre : l'etiquette du vehicule n'existe QUE pendant la session. +-- `input_select.victron_evcs_vehicle` est un reglage manuel, il change, et +-- rien ne conserve sa valeur au moment ou la charge a eu lieu. Sans cet +-- enregistrement, une session d'il y a trois semaines est anonyme pour +-- toujours — et toute analyse d'usage devient impossible. +-- +-- ⚠️ On ne DEVINE pas le vehicule. Mesure du 05/09/2026 : a consigne identique +-- de 20 A, la VW accepte 3 680 W et la JEEP 3 655 W, soit 0,1 A d'ecart. Ils +-- sont electriquement indiscernables. L'heure ne dit rien non plus : en semaine +-- la JEEP charge le midi et la VW la nuit, le week-end c'est l'inverse. +CREATE TABLE session_charge ( + debut timestamptz PRIMARY KEY, + fin timestamptz NOT NULL, + terminee boolean NOT NULL DEFAULT false, + vehicule text, -- valeur du selecteur PENDANT la session + vehicule_sur boolean NOT NULL DEFAULT true, -- le selecteur n'a pas change + energie_kwh double precision NOT NULL DEFAULT 0, + p_max_w double precision, + courant_max_a double precision, + consigne_max_a double precision, + source_mode text, + debranche_a timestamptz, -- confirme le vehicule apres coup + brut jsonb +); +COMMENT ON COLUMN session_charge.debranche_a IS + 'Heure de debranchement : les deux vehicules partent a des heures differentes, ce qui confirme l''etiquette apres coup.'; +CREATE INDEX session_charge_debut_idx ON session_charge (debut DESC); + -- --------------------------------------------------------------------- -- Index de lecture -- --------------------------------------------------------------------- diff --git a/docs/MANUEL_COMPLET.md b/docs/MANUEL_COMPLET.md index b4d3abb..21bc0e5 100644 --- a/docs/MANUEL_COMPLET.md +++ b/docs/MANUEL_COMPLET.md @@ -101,9 +101,16 @@ Deux véhicules alternent : **VW** (hybride, départ ~08:50) et **JEEP** | **VW** | son fils, parti vers 9 h, rentre vers 19 h | **le soir et la nuit** | Exemples donnes : lundi midi la JEEP · mardi midi la JEEP puis la VW la nuit · -mercredi midi la JEEP. La regularite souffre des exceptions — un samedi, la -JEEP peut charger la nuit — donc elle vaut comme **forte presomption, jamais -comme certitude**. +mercredi midi la JEEP. + +**LE WEEK-END, LA ROUTINE S'INVERSE.** Sa femme travaille le samedi matin et le +dimanche matin, retour a midi : la **JEEP** charge donc le midi ET la nuit du +samedi au dimanche. La **VW** ne bouge pas du week-end — sauf sortie plongee — +et se charge **en journee**, pendant que la JEEP est au travail. + +⚠️ **Aucune regle horaire ne permet donc d'identifier le vehicule.** Une +heuristique « midi = JEEP, nuit = VW » serait fausse un week-end sur deux. Ne +pas en ecrire une. ⚠️ **Les deux vehicules sont ELECTRIQUEMENT INDISCERNABLES.** Mesure le 05/09 sur deux sessions a consigne identique de 20 A : la VW accepte 3 680 W et la diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 247507b..66d5565 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -328,6 +328,130 @@ async function detecterEvenements(sql, st) { return n; } +// --------------------------------------------------------------------- +// Le service fait evoluer son propre schema. `schema.sql` n'est applique qu'a +// la CREATION de la base : une table ajoutee plus tard n'y arriverait jamais. +// `IF NOT EXISTS` rend l'operation rejouable a chaque demarrage. +// --------------------------------------------------------------------- +async function migrer(client) { + await client.query(` + CREATE TABLE IF NOT EXISTS session_charge ( + debut timestamptz PRIMARY KEY, + fin timestamptz NOT NULL, + terminee boolean NOT NULL DEFAULT false, + vehicule text, + vehicule_sur boolean NOT NULL DEFAULT true, + energie_kwh double precision NOT NULL DEFAULT 0, + p_max_w double precision, + courant_max_a double precision, + consigne_max_a double precision, + source_mode text, + debranche_a timestamptz, + brut jsonb + )`); + await client.query( + 'CREATE INDEX IF NOT EXISTS session_charge_debut_idx ON session_charge (debut DESC)'); + // Le role de lecture doit voir la nouvelle table. S'il n'existe pas encore, + // on ne fait pas echouer le demarrage pour autant. + await client.query(`DO $$ BEGIN + GRANT SELECT ON session_charge TO evcs_lecture; + EXCEPTION WHEN undefined_object THEN NULL; END $$`); +} + +// --------------------------------------------------------------------- +// Sessions de charge. +// +// Pourquoi une table plutot qu'un calcul a la demande : l'etiquette du +// vehicule n'existe QUE pendant la session. `input_select.victron_evcs_vehicle` +// est un reglage manuel, il change, et rien ne conserve sa valeur au moment ou +// la charge a eu lieu. Sans cet enregistrement, une session d'il y a trois +// semaines est anonyme pour toujours. +// +// ⚠️ On n'essaie PAS de deviner le vehicule. Mesure du 05/09 : a consigne +// identique de 20 A, la VW accepte 3 680 W et la JEEP 3 655 W — 0,1 A d'ecart. +// Ils sont electriquement indiscernables. Et l'heure ne dit rien non plus : en +// semaine la JEEP charge le midi et la VW la nuit, mais le week-end c'est +// l'inverse. On enregistre ce que le selecteur disait, et on note s'il a change +// en cours de route. +// --------------------------------------------------------------------- +const SEUIL_CHARGE_W = 300; // au-dela, la voiture prend vraiment du courant +const FIN_APRES_MIN = 10; // sous le seuil pendant ce temps : session close + +async function ecrireSession(sql, client, st) { + // En blanc la base n'est pas connectee : on lit du vide plutot que d'echouer. + // La logique se deroule alors comme si aucune session n'etait ouverte — ce + // qui suffit a verifier le chemin d'ouverture, pas celui de cloture. + const lire = DRY ? async () => ({ rows: [], rowCount: 0 }) : (q, v) => client.query(q, v); + const g = (id) => st.get(id); + const p = num(g('sensor.victron_evcs_ac_power')?.state) ?? 0; + const connecte = txt(g('binary_sensor.victron_evcs_vehicle_connected')?.state) === 'on'; + const vehicule = txt(g('input_select.victron_evcs_vehicle')?.state) || null; + const maintenant = new Date(); + let n = 0; + + const { rows } = await lire( + 'SELECT * FROM session_charge WHERE NOT terminee ORDER BY debut DESC LIMIT 1'); + const ouverte = rows[0] ?? null; + + if (p > SEUIL_CHARGE_W) { + if (!ouverte) { + await sql( + `INSERT INTO session_charge (debut, fin, vehicule, energie_kwh, p_max_w, + courant_max_a, consigne_max_a, source_mode, brut) + VALUES ($1,$1,$2,0,$3,$4,$5,$6,$7) ON CONFLICT (debut) DO NOTHING`, + [maintenant, vehicule, p, + num(g('sensor.victron_evcs_current')?.state), + num(g('sensor.victron_evcs_set_current')?.state), + txt(g('input_text.victron_evcs_source_mode')?.state) || null, + JSON.stringify({ profil: txt(g('input_text.victron_evcs_operating_mode')?.state) })]); + n++; + } else { + // Le pas de temps est plafonne : un arret du service ne doit pas + // extrapoler la derniere puissance connue sur plusieurs heures. + const dt = Math.min((maintenant - new Date(ouverte.fin)) / 1000, 300); + await sql( + `UPDATE session_charge SET + fin = $2, + energie_kwh = energie_kwh + $3 * $4 / 3600000.0, + p_max_w = GREATEST(coalesce(p_max_w, 0), $3), + courant_max_a = GREATEST(coalesce(courant_max_a, 0), coalesce($5, 0)), + consigne_max_a = GREATEST(coalesce(consigne_max_a, 0), coalesce($6, 0)), + vehicule = coalesce($7, vehicule), + vehicule_sur = vehicule_sur AND ($7 IS NULL OR vehicule IS NULL OR vehicule = $7) + WHERE debut = $1`, + [ouverte.debut, maintenant, p, dt, + num(g('sensor.victron_evcs_current')?.state), + num(g('sensor.victron_evcs_set_current')?.state), vehicule]); + n++; + } + } else if (ouverte) { + const silence = (maintenant - new Date(ouverte.fin)) / 60000; + if (silence >= FIN_APRES_MIN || !connecte) { + await sql('UPDATE session_charge SET terminee = true WHERE debut = $1', [ouverte.debut]); + await journaliser(sql, 'vehicule', + `Session de charge terminee : ${ouverte.vehicule ?? 'vehicule inconnu'}, ` + + `${Number(ouverte.energie_kwh).toFixed(2)} kWh`, + { debut: ouverte.debut, vehicule: ouverte.vehicule, kwh: ouverte.energie_kwh }); + n += 2; + } + } + + // L'heure de debranchement, notee sur la derniere session. Elle confirme le + // vehicule apres coup : les deux partent a des heures differentes. + if (!connecte) { + const { rowCount } = await lire( + `SELECT 1 FROM session_charge + WHERE debranche_a IS NULL AND fin > now() - interval '18 hours' LIMIT 1`); + if (rowCount) { + await sql( + `UPDATE session_charge SET debranche_a = $1 + WHERE debranche_a IS NULL AND fin > now() - interval '18 hours'`, [maintenant]); + n++; + } + } + return n; +} + // --------------------------------------------------------------------- async function cycle(client) { // En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie @@ -347,14 +471,15 @@ async function cycle(client) { bilan: await ecrireBilan(sql, st), decision: await ecrireDecision(sql, st), prevision: await ecrirePrevision(sql, st), + session: await ecrireSession(sql, client, st), evenement: await detecterEvenements(sql, st), }; - const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement; + const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session; if (total > 0 || ONCE) { console.log( `${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` + `${DRY ? '[BLANC] ' : ''}mesures=${r.mesures} bilan=${r.bilan} ` + - `decision=${r.decision} prevision=${r.prevision} evenement=${r.evenement}` + `decision=${r.decision} prevision=${r.prevision} session=${r.session} evenement=${r.evenement}` ); } } @@ -367,7 +492,10 @@ const client = new pg.Client({ password: process.env.PGPASSWORD, }); -if (!DRY) await client.connect(); +if (!DRY) { + await client.connect(); + await migrer(client); // schema a jour avant le premier cycle +} console.log( `Historisation EVCS — HA=${HA_URL} base=${DRY ? '(blanc)' : client.host + ':' + client.port} ` + `intervalle=${INTERVAL_S}s`