Table des sessions de charge : etiqueter pendant, pas deviner apres

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
Tallagrand Thierry
2026-09-05 02:54:05 +02:00
parent 0cc547e71c
commit e80dbf70fb
3 changed files with 172 additions and 6 deletions

View File

@@ -120,6 +120,37 @@ CREATE TABLE evenement (
); );
CREATE INDEX evenement_ts_idx ON evenement (ts DESC); 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 -- Index de lecture
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------

View File

@@ -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** | | **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 · 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 mercredi midi la JEEP.
JEEP peut charger la nuit — donc elle vaut comme **forte presomption, jamais
comme certitude**. **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 ⚠️ **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 sur deux sessions a consigne identique de 20 A : la VW accepte 3 680 W et la

View File

@@ -328,6 +328,130 @@ async function detecterEvenements(sql, st) {
return n; 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) { async function cycle(client) {
// En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie // 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), bilan: await ecrireBilan(sql, st),
decision: await ecrireDecision(sql, st), decision: await ecrireDecision(sql, st),
prevision: await ecrirePrevision(sql, st), prevision: await ecrirePrevision(sql, st),
session: await ecrireSession(sql, client, st),
evenement: await detecterEvenements(sql, 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) { if (total > 0 || ONCE) {
console.log( console.log(
`${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` + `${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` +
`${DRY ? '[BLANC] ' : ''}mesures=${r.mesures} bilan=${r.bilan} ` + `${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, 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( console.log(
`Historisation EVCS — HA=${HA_URL} base=${DRY ? '(blanc)' : client.host + ':' + client.port} ` + `Historisation EVCS — HA=${HA_URL} base=${DRY ? '(blanc)' : client.host + ':' + client.port} ` +
`intervalle=${INTERVAL_S}s` `intervalle=${INTERVAL_S}s`