diff --git a/HANDOFF.md b/HANDOFF.md index 8318101..67e46bb 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4596,6 +4596,81 @@ session de `tools/historise-evcs.mjs` semble rejouer la session de la nuit au lieu d'en ouvrir une nouvelle. A corriger, sans quoi la table `session_charge` double-compte. +### Double comptage des sessions de charge — corrige +`Session de charge terminee : JEEP, 4.20 kWh` a ete emis a 02:52 (vraie charge +HC) puis a 13:42 (onze minutes de charge diurne), avec la MEME valeur au +centieme. + +Cause : `energie_station_kwh` etait echantillonne a chaque cycle depuis +`sensor.evcs_hq2239dnt7p_last_session_energy` et conserve par un **`GREATEST`** +— un operateur monotone, qui ne peut donc que SURESTIMER et qui verrouille la +premiere valeur vue. Au premier echantillon de la session de midi, le capteur +portait encore le total de la nuit ; le maximum l'a fige pour toujours. + +Confrontation de chaque ligne a l'integration de `victron_evcs_ac_power` relue +dans InfluxDB sur la periode de charge REELLE (periodes > 300 W separees de plus +de dix minutes) : + +| periode reelle | energie reelle | valeur en base | ecart | +|---|---:|---:|---:| +| 05 01:26-03:55 | 8,27 kWh | 9,400 | 14 % | +| 05 10:00-10:54 | 2,32 kWh | 2,200 | 5 % | +| 05 11:11-12:38 | 3,37 kWh | 5,400 | **60 %** | +| 06 01:25-02:44 | 4,24 kWh | 4,200 | 1 % | +| 06 13:32-13:42 | 0,34 kWh | 4,200 | **1135 %** | + +⚠️ **La semantique de `last_session_energy` n'etait pas etablie** — le capteur +n'etait pas historise, donc aucune serie ne permettait de trancher entre +« derniere session terminee » et « session en cours avec reinitialisation +tardive ». Deux indices concordants recueillis apres coup : au moment de la +reparation il lisait 0,3 kWh et `..._last_session_time` 11 min, soit exactement +la session de midi. Il decrit donc la derniere session TERMINEE, et le lire +PENDANT une session donne la precedente. + +Correctif (`tools/historise-evcs.mjs`) : +1. **Le compteur CUMULATIF de la borne** (`..._total_energy`, ~3 780 kWh) + devient la mesure de reference : releve a l'ouverture + (`compteur_debut_kwh`), sa difference a la cloture donne l'energie sans + ambiguite de semantique NI erreur d'integration. C'est desormais le chiffre + annonce. +2. `last_session_energy` et `last_session_time` sont lus **une seule fois, a la + cloture**, et enregistres comme des OBSERVATIONS + (`energie_station_kwh`, `duree_borne_min`). Le journal ne les affiche que + s'ils s'ecartent de plus de 10 % — ce qui rend visible le cas qui avait + motive la colonne (le 05/09, 8 kWh reels pour 0,75 integres, session commencee + avant le demarrage du service) au lieu de les substituer en silence. +3. Plus aucun `GREATEST` sur une grandeur d'energie. +4. **Les treize capteurs `sensor.evcs_*` de la borne entrent dans + l'historisation InfluxDB** (`configuration.yaml`, sauvegarde dans + `SAVE_CONFIG/`) : c'est leur absence qui a empeche d'etablir la semantique. + ⚠️ Prend effet au prochain redemarrage de Home Assistant (les filtres + `include`/`exclude` d'InfluxDB restent en YAML et n'ont pas de rechargement). + +### Meme fonction, second defaut : l'heure de debranchement posee en bloc +`debranche_a` etait ecrit par un UPDATE **sans limite** sur toutes les sessions +non marquees des dix-huit dernieres heures. Le 05/09 a 15:02:16, les trois +sessions ouvertes ont recu la meme heure — dont **la session de nuit de la JEEP, +qui a herite du debranchement de la VW**. Or cette colonne existe precisement +pour departager les deux vehicules apres coup : elle affirmait l'inverse de son +role. + +Correctif : la fenetre est bornee par +`sensor.victron_evcs_charging_time`, qui repart de zero a chaque connexion +(signal du reset P11) et se fige a la valeur du debranchement — il donne donc la +duree exacte du branchement qui s'acheve. Duree illisible ou nulle => on ne +marque que la session la plus recente, jamais celles d'avant. + +### Reparation des lignes existantes (migration idempotente) +Appliquee au prochain demarrage du service. On ne corrige QUE ce qui a ete +verifie : la ligne du 06 13:31 passe a 0,3 kWh (valeur du capteur au moment de +la reparation, corroboree par la duree de 11 min), celle du 05 11:11 est +EFFACEE (60 % d'ecart, aucun substitut fiable), les trois lignes coherentes ne +sont pas touchees, et le `debranche_a` errone de la session JEEP du 05 02:56 +repasse a NULL — on efface plutot que d'inventer. +⚠️ `debut` porte les millisecondes : les reparations ciblent une fenetre d'une +seconde et non une egalite, et la condition sur la valeur attendue les rend +idempotentes. Verifie : chaque fenetre designe exactement une ligne. + ### Reste ouvert - Detecteur de gel dans le service d'historisation : une grandeur qui ne bouge pas alors qu'une autre du meme bilan bouge = alerte. Non fait. diff --git a/docs/mesures/batteries/202609052040-suivi.csv b/docs/mesures/batteries/202609052040-suivi.csv index 976ef07..ecc8b02 100644 --- a/docs/mesures/batteries/202609052040-suivi.csv +++ b/docs/mesures/batteries/202609052040-suivi.csv @@ -404,3 +404,11 @@ heure_reelle,etat,courant_mA,c1_mV,c2_mV,c3_mV,c4_mV,c5_mV,c6_mV,c7_mV,c8_mV,c9_ 2026-09-06T11:41:05.944Z,Y230202C30371425,,3350,3350,3350,3350,3350,3349,3350,3349,3349,3350,3350,3350,3350,3350,3350,1 2026-09-06T11:41:06.039Z,Y230202C30371431,,3349,3348,3348,3348,3348,3349,3347,3348,3347,3347,3349,3348,3348,3348,3347,2 2026-09-06T11:41:06.226Z,Y230202C30371427,,3342,3342,3343,3344,3343,3342,3343,3343,3343,3343,3342,3343,3343,3344,3344,2 +2026-09-06T11:51:06.330Z,Y230202C30371426,,3342,3343,3343,3344,3344,3342,3343,3343,3342,3343,3342,3343,3343,3343,3344,2 +2026-09-06T11:51:06.426Z,Y230202C30371425,,3406,3405,3405,3407,3405,3403,3406,3404,3406,3404,3406,3407,3408,3405,3406,5 +2026-09-06T11:51:06.552Z,Y230202C30371431,,3405,3404,3404,3402,3409,3403,3401,3402,3402,3402,3407,3402,3403,3402,3403,8 +2026-09-06T11:51:06.678Z,Y230202C30371427,,3404,3403,3404,3404,3406,3405,3404,3404,3404,3404,3404,3404,3406,3407,3405,4 +2026-09-06T12:01:06.782Z,Y230202C30371426,,3403,3402,3404,3406,3406,3406,3403,3404,3399,3404,3404,3405,3404,3403,3405,7 +2026-09-06T12:01:06.911Z,Y230202C30371425,,3412,3411,3409,3414,3409,3408,3411,3409,3411,3409,3411,3412,3410,3411,3408,6 +2026-09-06T12:01:07.007Z,Y230202C30371431,,3414,3413,3413,3411,3424,3411,3408,3410,3410,3404,3415,3409,3412,3410,3412,20 +2026-09-06T12:01:07.135Z,Y230202C30371427,,3413,3409,3411,3411,3406,3413,3410,3408,3412,3411,3411,3411,3415,3416,3407,10 diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 4e07c5a..ccddc47 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -374,9 +374,70 @@ async function migrer(client) { // 03:16 — 0,75 kWh au lieu de 8. La borne, elle, compte depuis le debut. await client.query( 'ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS energie_station_kwh double precision'); + // Le COMPTEUR CUMULATIF de la borne (`..._total_energy`, ~3 780 kWh au + // 06/09). Sa difference entre l'ouverture et la cloture donne l'energie de + // la session sans aucune ambiguite de semantique et sans erreur + // d'integration — contrairement a `..._last_session_energy`, dont on ne + // savait meme pas s'il decrivait la session en cours ou la precedente. + // Il ne couvre que ce que le service a vu ; `duree_borne_min` + // (`..._last_session_time`) dit si la borne, elle, en a vu davantage. + for (const col of ['compteur_debut_kwh', 'energie_compteur_kwh', 'duree_borne_min']) { + await client.query( + `ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS ${col} double precision`); + } await client.query( 'CREATE INDEX IF NOT EXISTS session_charge_debut_idx ON session_charge (debut DESC)'); + // ------------------------------------------------------------------- + // REPARATION UNIQUE (06/09/2026) — sessions faussees par le GREATEST + // ------------------------------------------------------------------- + // Chaque ligne est confrontee a l'integration de `victron_evcs_ac_power` + // relue dans InfluxDB sur la periode de charge REELLE (detection des + // periodes > 300 W separees par plus de dix minutes) : + // + // periode reelle energie reelle valeur en base ecart + // 05 01:26-03:55 8,27 kWh 9,400 14 % -> gardee + // 05 10:00-10:54 2,32 kWh 2,200 5 % -> gardee + // 05 11:11-12:38 3,37 kWh 5,400 60 % -> effacee + // 06 01:25-02:44 4,24 kWh 4,200 1 % -> gardee + // 06 13:32-13:42 0,34 kWh 4,200 1135 % -> corrigee + // + // La derniere est celle qui a produit le double comptage dans le journal : + // le total de la nuit, capture au premier echantillon et verrouille par le + // GREATEST. Le capteur de la borne lisait 0,3 kWh au moment de la reparation, + // et `last_session_time` 11 min — la duree exacte de cette session. + // On ne corrige QUE ce qu'on a verifie ; les trois lignes coherentes ne sont + // pas touchees, et celle a 60 % d'ecart est effacee plutot que devinee. + // + // ⚠️ `debut` porte les millisecondes : on cible une FENETRE D'UNE SECONDE, + // pas une egalite. Deux sessions ne peuvent pas s'ouvrir dans la meme + // seconde, et la condition sur la valeur attendue rend l'operation + // idempotente — rejouee, elle ne trouve plus rien. + const reparations = [ + ['2026-09-06 13:31:18+02', 0.3, 4.2], + ['2026-09-05 11:11:22+02', null, 5.4], + ]; + for (const [debut, valeur, attendu] of reparations) { + await client.query( + `UPDATE session_charge SET energie_station_kwh = $2 + WHERE debut >= $1::timestamptz AND debut < $1::timestamptz + interval '1 second' + AND energie_station_kwh = $3::float8`, + [debut, valeur, attendu]); + } + // Meme jour, meme fonction : l'heure de debranchement etait posee en bloc sur + // toutes les sessions non marquees des dix-huit dernieres heures. Le 05/09 a + // 15:02:16, la session de nuit de la JEEP a donc herite du debranchement de + // la VW. On ne sait pas quand la JEEP a ete debranchee : on efface plutot que + // d'inventer. + // Les deux sessions VW de la journee (10:00 et 11:11) appartiennent bien au + // meme branchement que ce debranchement de 15:02 : elles sont conservees. + await client.query( + `UPDATE session_charge SET debranche_a = NULL + WHERE debut >= '2026-09-05 02:56:13+02'::timestamptz + AND debut < '2026-09-05 02:56:14+02'::timestamptz + AND debranche_a >= '2026-09-05 15:02:16+02'::timestamptz + AND debranche_a < '2026-09-05 15:02:17+02'::timestamptz`); + // Tensions cellule par cellule des modules Pylontech. La source est la // console serie du BMS, pas Home Assistant : aucune de ces valeurs n'est // publiee cote Victron, qui n'expose que l'agregat du parc. @@ -424,6 +485,20 @@ async function migrer(client) { 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 +// "1h 49m 53s" / "8m 27s" / "0s" -> secondes. Meme lecture que INPUT_PARSE. +// `sensor.victron_evcs_charging_time` repart de zero a CHAQUE nouvelle +// connexion physique (c'est le signal du reset P11) et se fige a la valeur du +// debranchement : il donne donc la duree exacte du branchement qui s'acheve. +function dureeBranchementSec(s) { + const t = (s ?? '').toString().toLowerCase(); + if (!t) return null; + let sec = 0, vu = false; + const h = t.match(/(\d+)\s*h/); if (h) { sec += Number(h[1]) * 3600; vu = true; } + const m = t.match(/(\d+)\s*m/); if (m) { sec += Number(m[1]) * 60; vu = true; } + const q = t.match(/(\d+)\s*s/); if (q) { sec += Number(q[1]); vu = true; } + return vu ? sec : null; +} + 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 @@ -445,14 +520,14 @@ async function ecrireSession(sql, client, st) { await sql( `INSERT INTO session_charge (debut, fin, vehicule, energie_kwh, p_max_w, courant_max_a, consigne_max_a, source_mode, brut, - energie_station_kwh) + compteur_debut_kwh) VALUES ($1,$1,$2,0,$3,$4,$5,$6,$7,$8) 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) }), - num(g('sensor.evcs_hq2239dnt7p_last_session_energy')?.state)]); + num(g('sensor.evcs_hq2239dnt7p_total_energy')?.state)]); n++; } else { // Le pas de temps est plafonne : un arret du service ne doit pas @@ -475,39 +550,100 @@ async function ecrireSession(sql, client, st) { 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)), - energie_station_kwh = GREATEST(coalesce(energie_station_kwh, 0), coalesce($9::float8, 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, - txt(g('input_text.victron_evcs_source_mode')?.state).startsWith('Réseau') ? 1 : 0, - num(g('sensor.evcs_hq2239dnt7p_last_session_energy')?.state)]); + txt(g('input_text.victron_evcs_source_mode')?.state).startsWith('Réseau') ? 1 : 0]); 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]); + // Le compteur de la borne se lit UNE FOIS, A LA CLOTURE. + // + // Il etait auparavant echantillonne a chaque cycle et conserve par un + // GREATEST : un operateur monotone, qui ne peut donc que SUREVALUER, et + // qui verrouillait la premiere valeur vue. Le 06/09 la session de midi + // (0,34 kWh reels, 0,324 integres) s'est refermee sur 4,20 kWh — le + // total de la nuit precedente, capture au premier echantillon et jamais + // relache. D'ou le meme chiffre annonce deux fois dans le journal. + // + // ⚠️ La semantique exacte de ce capteur n'est PAS etablie : il n'etait + // pas historise, donc on n'a aucune serie pour trancher entre « energie + // de la derniere session terminee » et « compteur de la session en cours + // qui se reinitialise avec du retard ». Confrontation aux integrations + // reelles des cinq sessions connues : ecarts de 1, 5, 14, 60 et 1135 %. + // Il est donc enregistre comme une OBSERVATION, jamais substitue en + // silence a notre integration. Le capteur vient d'etre ajoute a + // l'historisation InfluxDB pour pouvoir enfin l'etablir. + const station = num(g('sensor.evcs_hq2239dnt7p_last_session_energy')?.state); + const dureeBorne = num(g('sensor.evcs_hq2239dnt7p_last_session_time')?.state); + const compteur = num(g('sensor.evcs_hq2239dnt7p_total_energy')?.state); + const parCompteur = compteur !== null && ouverte.compteur_debut_kwh !== null + && compteur >= ouverte.compteur_debut_kwh + ? compteur - Number(ouverte.compteur_debut_kwh) : null; + await sql( + `UPDATE session_charge SET terminee = true, energie_station_kwh = $2, + energie_compteur_kwh = $3, duree_borne_min = $4 + WHERE debut = $1`, + [ouverte.debut, station, parCompteur, dureeBorne]); + + // Le chiffre annonce est le NOTRE, celui qu'on sait defendre. Celui de la + // borne n'apparait que s'il s'en ecarte notablement — soit qu'elle ait vu + // un debut que le service a manque (le cas qui a motive cette colonne : + // 8 kWh reels pour 0,75 integres le 05/09), soit qu'elle raconte autre + // chose, et dans les deux cas ca se voit. + // Le chiffre annonce est, par ordre de preference : la difference du + // compteur cumulatif de la borne (exacte, sans integration), sinon notre + // integration de la puissance. Celui de `last_session_energy` n'apparait + // que s'il s'en ecarte notablement — soit que la borne ait vu un debut + // que le service a manque (le cas qui a motive cette colonne : 8 kWh + // reels pour 0,75 integres le 05/09), soit qu'elle raconte autre chose, + // et dans les deux cas ca se voit au lieu de se substituer en silence. + const retenu = parCompteur !== null ? parCompteur : Number(ouverte.energie_kwh); + const ecart = station !== null && retenu > 0 + ? Math.abs(station - retenu) / retenu : 0; await journaliser(sql, 'vehicule', - `Session de charge terminee : ${ouverte.vehicule ?? 'vehicule inconnu'}, ` + - `${Number(ouverte.energie_station_kwh ?? ouverte.energie_kwh).toFixed(2)} kWh`, - { debut: ouverte.debut, vehicule: ouverte.vehicule, kwh: ouverte.energie_kwh }); + `Session de charge terminee : ${ouverte.vehicule ?? 'vehicule inconnu'}, ` + + `${retenu.toFixed(2)} kWh` + + (ecart > 0.10 ? ` (borne : ${station.toFixed(2)} kWh sur ${dureeBorne ?? '?'} min)` : ''), + { debut: ouverte.debut, vehicule: ouverte.vehicule, kwh: retenu, + kwh_integre: Number(ouverte.energie_kwh), kwh_compteur: parCompteur, + kwh_borne: station, duree_borne_min: dureeBorne }); n += 2; } } - // L'heure de debranchement, notee sur la derniere session. Elle confirme le - // vehicule apres coup : les deux partent a des heures differentes. + // L'heure de debranchement. Elle confirme le vehicule apres coup : les deux + // partent a des heures differentes. + // + // ⚠️ Ne marquer QUE les sessions du branchement qui vient de s'achever. La + // version d'origine marquait en bloc toutes les sessions non marquees des + // dix-huit dernieres heures : le 05/09 a 15:02, la session de nuit de la + // JEEP a herite de l'heure de debranchement de la VW — soit exactement + // l'inverse de ce que cette colonne est censee etablir. La duree du + // branchement borne la fenetre (5 min de marge pour le pas de mesure) ; + // duree illisible ou nulle => on ne marque que la session la plus recente, + // jamais celles d'avant. 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]); + const dureeSec = dureeBranchementSec(txt(g('sensor.victron_evcs_charging_time')?.state)); + const depuis = dureeSec !== null && dureeSec > 0 + ? new Date(maintenant.getTime() - dureeSec * 1000 - 5 * 60000) + : null; + const { rows: aMarquer } = await lire( + depuis + ? `SELECT debut FROM session_charge + WHERE debranche_a IS NULL AND fin > now() - interval '18 hours' AND debut >= $1` + : `SELECT debut FROM session_charge + WHERE debranche_a IS NULL AND fin > now() - interval '18 hours' + ORDER BY debut DESC LIMIT 1`, + depuis ? [depuis] : []); + for (const r of aMarquer) { + await sql('UPDATE session_charge SET debranche_a = $2 WHERE debut = $1', + [r.debut, maintenant]); n++; } }