From 28851c83e4238c7d5d7ab2f1d1308e5c4f2afa35 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Tue, 6 Oct 2026 23:28:56 +0200 Subject: [PATCH] Historisation : allers-retours brefs de la borne et du contacteur retrouves dans l'historique HA (echange VW -> JEEP de 20 s) --- HANDOFF.md | 43 ++++++++++++++++++++++++- tests/allers-retours/run.mjs | 44 +++++++++++++++++++++++++ tools/allers-retours.mjs | 24 ++++++++++++++ tools/historise-evcs.mjs | 62 ++++++++++++++++++++++++++++++++---- 4 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 tests/allers-retours/run.mjs create mode 100644 tools/allers-retours.mjs diff --git a/HANDOFF.md b/HANDOFF.md index e394cea..9f2b583 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1847,6 +1847,47 @@ orange-cloture (verifie par SPARK : envoye le 05/10 a 17:42). de 90 % de SOC, EDF a midi des que le surplus prevu ne couvre pas le besoin ; plus de `force_grid_now` a la main. ⚠️ La session ne vaut que dans la fenetre HC de midi (12:24-14:24) : une voiture branchee apres 14:24 n'est pas couverte. +- ⚠️ **Defaut decouvert apres coup, a traiter** : `force_grid_now` n'a donne que **2,0 kWh** (13:18 -> 14:22). + Deux arrets « batterie trop sollicitee » (13:46 -41,9 A, 14:22 -47,3 A) sur des pointes de la maison a + 7,1-7,7 kW (voiture 3,5 kW + cuisine ~4 kW) alors que le reseau ne fournissait que ~3,9 kW ; la garde + batterie du mode reseau juge sur UN echantillon, et le blocage anti-cliquetis de 20 min a coute ~40 min + de charge. Deux pistes soumises au proprietaire (tolerance de duree en mode reseau ; brider a 6 A au + lieu de couper), decision en attente. +- 14:24:00 echeance de `force_grid_now` en reel (trace « retour auto_solar_plus (sortie de la fenetre + d'heures creuses), SOC 57 % »), EDF ouvert 14:25:03. + +### 06/10 soir — Production 14,5 kWh, et un echange VW -> JEEP de 20 s que personne n'a vu +- **Production reelle 14,5 kWh** a 18:06 (journee finie) pour VRM 22,1 (matin), ECMWF ~25-28, + Meteo-France ~25-29, ICON ~15-18 : ICON le plus juste, VRM -34 %. +- VW branchee 16:16:11 (question concierge 16:40 apres deux cycles d'attente Uconnect) ; reponses + WhatsApp 18:08 « VW », 18:09 « 0% » -> l'agent a fusionne (premier appel annule par OpenClaw), + « ✅ Noté : VW … 🔋 Niveau noté : 0 % » a 18:10:30. J'avais ecrit a 18:10:21 le selecteur VW, + l'etiquette (source proprietaire) et le mode de nuit **complete** (proprietaire : EDF de toute facon + cette nuit pour la batterie, SOC 62 pour 71,4 requis) ; le concierge a reecrit l'etiquette 9 s apres. +- **19:52:36 off / 19:52:56 on : echange VW -> JEEP en 20 s, JAMAIS journalise.** Le service lit l'etat + une fois par cycle (~75 s) : etat identique avant et apres -> aucune transition dans `evenement`, + donc ni branchement neuf, ni question du concierge (SPARK : son SQL_TRANS ne voyait qu'un branchement + depuis 16:16), ni regle « fin de trajet » JEEP (Uconnect 19:52:17, 28 %, 112 831 km). A 22:34 « J'ai + branche la JEEP » a donc relabellise le branchement de 16:16 en JEEP. Aucune session depuis 16:16 : + aucun kWh mal attribue. +- **Correctif (accord du proprietaire, deploye 23:26)** : `detecterEvenements` garde le `last_changed` + de chaque entite ; sur `ALLERS_RETOURS_SUIVIS` (borne branchee, contacteur EDF), si l'etat est + identique mais que `last_changed` a avance, il relit l'historique HA de l'intervalle et journalise + chaque transition A SON HORODATAGE HA (`journaliser(..., ts)`, colonne ts explicite, detail + `aller_retour: true`). Fonction pure `transitionsDansSerie` dans `tools/allers-retours.mjs`, tests + `node tests/allers-retours/run.mjs` 4/4 (dont le cas reel), mutation « indisponible compte » qui mord ; + kilometrage 21/21. Sauvegarde `historise-evcs.mjs.bak-20261006` (scratchpad) ; retour = `git revert` + + `docker restart evcs-historisation`. +- **Rattrapage en base** : les deux transitions de 19:52 inserees a leur heure HA ; etiquette du + branchement 16:16 remise a **VW** ; helper concierge pointe le branchement **19:52:56 = JEEP**. + Au cycle suivant (23:28) la regle « fin de trajet » a joue seule : « Branchement identifie : JEEP + (fin de trajet juste avant le branchement, Uconnect), niveau 28 % ». Niveau HA saisi a 28 % par moi + a 23:24 (reference du compteur 4069,9 = compteur courant, rien de livre depuis 19:52). +- **Nuit 06->07** : SOC 37 % a 23:14 (soiree lourde, 62 -> 37 % depuis 18:06), requis 48,8 / 61,8 ; + EDF a 01:24 (~7,3 kWh maison + batterie) + JEEP 28 % -> plein (~7,2 kWh), mode complete. A verifier + le 07/10 : session JEEP comptee sur le branchement de 19:52:56, capacite estimee JEEP (28 % -> plein), + SOC a 9 h. Mode de nuit dans HA : EVCS Master onglet Vehicules, carte « Vehicule branche et cette + nuit » (« Charge cette nuit ») ; Bilan energetique onglet Production, carte « Voiture cette nuit ». --- -*Document mis a jour le : 06/10/2026 (~13:35) — voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* +*Document mis a jour le : 06/10/2026 (~23:35) — allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* diff --git a/tests/allers-retours/run.mjs b/tests/allers-retours/run.mjs new file mode 100644 index 0000000..15a81f2 --- /dev/null +++ b/tests/allers-retours/run.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// Allers-retours brefs retrouves dans l'historique HA (06/10/2026). +// Usage : node tests/allers-retours/run.mjs +import { transitionsDansSerie } from '../../tools/allers-retours.mjs'; + +let ok = 0, ko = 0; +function verifie(nom, obtenu, attendu) { + const a = JSON.stringify(obtenu), b = JSON.stringify(attendu); + if (a === b) { ok++; console.log(`✅ ${nom}`); } + else { ko++; console.log(`❌ ${nom}\n attendu ${b}\n obtenu ${a}`); } +} +const s = (state, last_changed) => ({ state, last_changed }); + +// Cas reel du 06/10 : echange VW -> JEEP en 20 s, entre deux cycles. +// L'historique HA commence par l'etat a l'instant de debut (16:16:10). +const reel = [ + s('on', '2026-10-06T14:16:10.124Z'), + s('off', '2026-10-06T17:52:36.000Z'), + s('on', '2026-10-06T17:52:56.474Z'), +]; +verifie('echange-20s-du-06-10', + transitionsDansSerie(reel, 'on', '2026-10-06T14:16:10.124Z', '2026-10-06T17:52:56.474Z'), + [{ avant: 'on', apres: 'off', a: '2026-10-06T17:52:36.000Z' }, + { avant: 'off', apres: 'on', a: '2026-10-06T17:52:56.474Z' }]); + +// Un hoquet d'indisponibilite n'est pas un branchement. +verifie('indisponible-ignore', + transitionsDansSerie([s('on', '2026-10-06T14:16:10Z'), s('unavailable', '2026-10-06T15:00:00Z'), s('on', '2026-10-06T15:00:05Z')], + 'on', '2026-10-06T14:16:10Z', '2026-10-06T15:00:05Z'), + []); + +// L'entree de depart (= dernier changement deja vu) n'est pas rejouee. +verifie('point-de-depart-exclu', + transitionsDansSerie([s('on', '2026-10-06T14:16:10Z')], 'on', '2026-10-06T14:16:10Z', '2026-10-06T14:16:10Z'), + []); + +// Deux allers-retours dans le meme intervalle : les quatre transitions, dans l'ordre. +verifie('deux-allers-retours', + transitionsDansSerie([s('on', '2026-10-06T10:00:00Z'), s('off', '2026-10-06T10:00:10Z'), s('on', '2026-10-06T10:00:20Z'), + s('off', '2026-10-06T10:00:40Z'), s('on', '2026-10-06T10:00:50Z')], 'on', '2026-10-06T10:00:00Z', '2026-10-06T10:00:50Z').length, + 4); + +console.log(ko ? `\n${ko} ECHEC(S)` : `\nALLERS-RETOURS OK — ${ok}/${ok}`); +process.exit(ko ? 1 : 0); diff --git a/tools/allers-retours.mjs b/tools/allers-retours.mjs new file mode 100644 index 0000000..0ef4516 --- /dev/null +++ b/tools/allers-retours.mjs @@ -0,0 +1,24 @@ +// Transitions d'etat retrouvees dans l'historique HA (06/10/2026). +// Voir ALLERS_RETOURS_SUIVIS dans historise-evcs.mjs : un aller-retour plus +// bref qu'un cycle du service ne se voit que par le `last_changed` de HA. +const txt = (s) => (s ?? '').toString().trim(); +export const ETATS_ABSENTS = new Set(['', 'unavailable', 'unknown']); + +// Transitions reelles entre `depuisIso` (exclu) et `jusquaIso` (inclus), en +// partant de l'etat `depart`. Les lectures indisponibles ne comptent pas. +// Pure (testee) : `serie` = reponse de /api/history/period pour une entite. +export function transitionsDansSerie(serie, depart, depuisIso, jusquaIso) { + const depuis = Date.parse(depuisIso), jusqua = Date.parse(jusquaIso); + const res = []; + let courant = depart; + for (const e of serie || []) { + const t = Date.parse(e.last_changed); + if (!(t > depuis && t <= jusqua)) continue; + const etat = txt(e.state); + if (ETATS_ABSENTS.has(etat) || etat === courant) continue; + res.push({ avant: courant, apres: etat, a: e.last_changed }); + courant = etat; + } + return res; +} + diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 360fe14..09a9152 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -32,6 +32,7 @@ import pg from 'pg'; import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from './kilometrage.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, nuitMaisonPrevue } from './diagnostics-agent.mjs'; +import { transitionsDansSerie } from './allers-retours.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ONCE = process.argv.includes('--once'); @@ -158,10 +159,13 @@ function demarrageHaMs(st) { return n >= 10 ? meilleur * 1000 : null; } -async function journaliser(sql, genre, resume, detail) { +async function journaliser(sql, genre, resume, detail, ts = null) { + // `ts` : horodatage HA d'une transition retrouvee apres coup (aller-retour + // bref, cf. detecterEvenements) ; sinon l'heure d'ecriture (defaut de la table). await sql( - `INSERT INTO evenement (genre, source, resume, detail) VALUES ($1, $2, $3, $4)`, - [genre, 'historisation', resume, JSON.stringify(detail || {})] + `INSERT INTO evenement (ts, genre, source, resume, detail) + VALUES (COALESCE($5::timestamptz, now()), $1, $2, $3, $4)`, + [genre, 'historisation', resume, JSON.stringify(detail || {}), ts] ); console.log(` evenement [${genre}] ${resume}`); } @@ -430,13 +434,37 @@ async function ecrirePrevision(sql, st) { // Memoire entre cycles. Au PREMIER passage on enregistre l'etat sans rien // journaliser : sinon chaque redemarrage du service produirait douze fausses // transitions. Seul le demarrage du service lui-meme est consigne. -const memoire = { amorce: false, demarrageHa: null, valeurs: new Map() }; +const memoire = { amorce: false, demarrageHa: null, valeurs: new Map(), changes: new Map() }; + +// ALLERS-RETOURS BREFS (06/10/2026). Le service lit l'etat une fois par cycle +// (~75 s) : un debranchement-rebranchement de 20 s (echange VW -> JEEP a +// 19:52:36 / 19:52:56 le 06/10) tombe entre deux lectures, l'etat est le meme +// avant et apres, et AUCUN branchement n'etait vu — ni par l'etiquetage, ni par +// le concierge qui lit cette table. Le `last_changed` de HA, lui, avance : sur +// ces entites, s'il a bouge alors que l'etat est identique, on relit +// l'historique HA de l'intervalle et on journalise chaque transition a SON +// horodatage. C'est la signature d'un echange de vehicule. +const ALLERS_RETOURS_SUIVIS = new Set([ + 'binary_sensor.victron_evcs_vehicle_connected', + 'switch.edf_general_ok_switch', +]); +async function transitionsCachees(id, depart, depuisIso, jusquaIso) { + const r = await fetch( + `${HA_URL}/api/history/period/${encodeURIComponent(depuisIso)}?end_time=` + + `${encodeURIComponent(new Date(Date.parse(jusquaIso) + 1000).toISOString())}` + + `&filter_entity_id=${id}&minimal_response&no_attributes`, + { headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(15000) }, + ); + if (!r.ok) throw new Error(`historique HA ${r.status}`); + const series = await r.json(); + return transitionsDansSerie(series[0], depart, depuisIso, jusquaIso); +} async function detecterEvenements(sql, st) { if (!memoire.amorce) { for (const [id] of TRANSITIONS_SUIVIES.map((t) => [t[0]])) { const e = st.get(id); - if (e) memoire.valeurs.set(id, txt(e.state)); + if (e) { memoire.valeurs.set(id, txt(e.state)); memoire.changes.set(id, e.last_changed); } } memoire.demarrageHa = demarrageHaMs(st); memoire.amorce = true; @@ -473,8 +501,30 @@ async function detecterEvenements(sql, st) { // lecture. La consigner ferait deux lignes de bruit a chaque hoquet MQTT. if (cur === '' || cur === 'unavailable' || cur === 'unknown') continue; const prec = memoire.valeurs.get(id); + const changePrec = memoire.changes.get(id); + memoire.changes.set(id, e.last_changed); if (prec === undefined) { memoire.valeurs.set(id, cur); continue; } - if (prec === cur) continue; + if (prec === cur) { + if (!ALLERS_RETOURS_SUIVIS.has(id) || !changePrec + || !(Date.parse(e.last_changed) > Date.parse(changePrec))) continue; + let cachees = []; + try { + cachees = await transitionsCachees(id, prec, changePrec, e.last_changed); + } catch (err) { + console.log(` aller-retour ${id} : historique illisible (${err.message})`); + } + for (const tr of cachees) { + const quiC = await auteurDeLaTransition(id, tr.a); + const resumeC = `${libelle} : ${tr.avant} -> ${tr.apres} (aller-retour bref, retrouve dans l historique HA)` + + (quiC ? (quiC.type === 'derivation' ? ` — derive de ${quiC.nom}` : ` — par ${quiC.nom}`) : ''); + await journaliser(sql, genre, resumeC, { + entite: id, avant: tr.avant, apres: tr.apres, horodatage_ha: tr.a, aller_retour: true, + ...(quiC ? { auteur: quiC.auteur, auteur_nom: quiC.nom, auteur_type: quiC.type, declencheur: quiC.declencheur } : {}), + }, tr.a); + n++; + } + continue; + } const qui = await auteurDeLaTransition(id, e.last_changed); // L auteur est colle au RESUME, pas seulement au detail : c est le resume // que Grafana affiche en annotation sur tous les tableaux et dans les