diff --git a/deploy/historisation/README.md b/deploy/historisation/README.md index 5dd2d1e..3ee1d06 100644 --- a/deploy/historisation/README.md +++ b/deploy/historisation/README.md @@ -92,6 +92,52 @@ Le bon créneau est le milieu de matinée. À faire après l'étape 1 — n8n a besoin que la base existe pour qu'on y crée l'identifiant de connexion. +## Événements : détectés par le service, annotés à la main + +La table `evenement` existe parce qu'une courbe sans ses événements ne +s'interprète pas : « le rendement a changé le 12 novembre » ne veut rien dire +sans la ligne qui dit qu'un pack a été rebranché ce jour-là. + +**Le service consigne seul ce qu'il peut constater** — il voit passer les états +toutes les minutes, autant qu'il en tire les conclusions plutôt que d'attendre +que quelqu'un y pense : + +| genre | ce qui est consigné | +|---|---| +| `systeme` | démarrage du service, redémarrage de Home Assistant, épisode d'indisponibilité avec sa durée | +| `reseau` | contacteur EDF, session réseau de nuit, reprise nuit, verrou hystérésis | +| `vehicule` | véhicule branché ou débranché | +| `intervention` | changement de stratégie ou de véhicule sélectionné | +| `ess` | état BatteryLife, mode de l'Auto Charge Manager | +| `bilan` | réseau nécessaire, vigilance orage, production bridée | + +On suit des états **discrets** dont chaque changement mérite une ligne. Les +grandeurs continues sont dans `mesure`, les décisions du contrôleur dans +`decision` : un événement qui double une autre table est du bruit. Une lecture +`unavailable` n'est pas une transition mais une absence de lecture — elle n'est +pas consignée. + +Le redémarrage de Home Assistant est detecté **sans capteur d'uptime**, qui +n'existe pas sur cette installation : au redémarrage HA restaure ses helpers et +leur pose un `last_changed` neuf. Le 04/09, 352 entités partageaient la seconde +du redémarrage, dont 183 `input_number`. Le service prend donc le **mode** des +`last_changed` des `input_number` — aucune entité codée en dur, et l'estimation +se corrige d'elle-même. + +**Les annotations qu'aucun service ne peut deviner** — « pack rebranché », +« correctif déployé » — s'écrivent à la main. Pour cela le rôle d'analyse a +besoin d'un `INSERT` sur cette seule table : + +```sh +sudo docker compose exec -T postgres psql -U evcs -d evcs -c \ +"GRANT INSERT ON evenement TO evcs_lecture; + GRANT USAGE, SELECT ON SEQUENCE evenement_id_seq TO evcs_lecture;" +``` + +`INSERT` seulement : ni `UPDATE`, ni `DELETE`. On peut ajouter une annotation, +jamais réécrire ni effacer les précédentes — y compris les siennes. C'est ce +qu'on attend d'un journal, et ça laisse les tables de mesure hors de portée. + ## Accès en lecture pour l'analyse ```sh diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index af27c5d..247507b 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -76,6 +76,65 @@ const MESURES = [ 'input_number.victron_evcs_night_target_soc', ]; +// --------------------------------------------------------------------- +// EVENEMENTS DETECTES AUTOMATIQUEMENT +// --------------------------------------------------------------------- +// « Une courbe sans ses evenements ne s'interprete pas » : six mois plus tard, +// une discontinuite ne veut rien dire sans la ligne qui dit ce qui s'est passe +// ce jour-la. Le service voit passer les etats toutes les minutes — autant +// qu'il consigne lui-meme ce qu'il peut constater, plutot que d'attendre que +// quelqu'un y pense. +// +// On suit des etats DISCRETS dont chaque changement merite une ligne. Les +// grandeurs continues n'ont rien a faire ici (elles sont dans `mesure`), et les +// decisions du controleur non plus (elles sont dans `decision`) : un evenement +// qui double une autre table est du bruit. +const TRANSITIONS_SUIVIES = [ + ['switch.edf_general_ok_switch', 'reseau', 'Contacteur EDF'], + ['input_boolean.victron_evcs_night_grid_session_committed','reseau', 'Session reseau de nuit'], + ['input_boolean.victron_evcs_night_grid_takeover_needed', 'reseau', 'Reprise reseau nuit'], + ['input_boolean.victron_grid_hysteresis_latch', 'reseau', 'Verrou hysteresis'], + ['binary_sensor.victron_evcs_vehicle_connected', 'vehicule', 'Vehicule branche'], + ['input_select.victron_evcs_vehicle', 'intervention','Vehicule selectionne'], + ['input_select.victron_evcs_strategy', 'intervention','Strategie'], + ['sensor.gx_device_ess_batterylife_state', 'ess', 'Etat BatteryLife de l ESS'], + ['input_text.victron_charge_mode', 'ess', 'Mode Auto Charge Manager'], + ['input_boolean.evcs_balance_grid_needed', 'bilan', 'Reseau necessaire'], + ['input_boolean.evcs_balance_storm_active', 'bilan', 'Vigilance orage'], + ['input_boolean.evcs_balance_pv_censored', 'bilan', 'Production bridee'], +]; + +// Heure de demarrage de Home Assistant, sans capteur d'uptime — il n'y en a pas +// sur cette installation. Au redemarrage, HA restaure ses helpers et leur pose +// un `last_changed` neuf : le 04/09, 352 entites partageaient la seconde du +// redemarrage, dont 183 input_number. Le MODE des last_changed des +// input_number est donc l'heure de demarrage. Aucune entite codee en dur, et +// l'estimation se corrige d'elle-meme : entre deux redemarrages, seuls les +// quelques helpers reellement ecrits s'ecartent du mode. +function demarrageHaMs(st) { + const paquets = new Map(); + for (const [id, e] of st) { + if (!id.startsWith('input_number.')) continue; + const t = Date.parse(e.last_changed); + if (!Number.isFinite(t)) continue; + const cle = Math.floor(t / 1000); + paquets.set(cle, (paquets.get(cle) || 0) + 1); + } + let meilleur = null, n = 0; + for (const [cle, c] of paquets) if (c > n) { n = c; meilleur = cle; } + // Moins de 10 helpers d'accord : signature trop faible pour affirmer quoi que + // ce soit. On ne devine pas. + return n >= 10 ? meilleur * 1000 : null; +} + +async function journaliser(sql, genre, resume, detail) { + await sql( + `INSERT INTO evenement (genre, source, resume, detail) VALUES ($1, $2, $3, $4)`, + [genre, 'historisation', resume, JSON.stringify(detail || {})] + ); + console.log(` evenement [${genre}] ${resume}`); +} + const num = (v) => { const n = Number.parseFloat(v); return Number.isFinite(n) ? n : null; @@ -211,6 +270,64 @@ async function ecrirePrevision(sql, st) { return n; } +// 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() }; + +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)); + } + memoire.demarrageHa = demarrageHaMs(st); + memoire.amorce = true; + await journaliser(sql, 'systeme', 'Service d historisation demarre', { + suivis: TRANSITIONS_SUIVIES.length, + }); + return 1; + } + + let n = 0; + + // Redemarrage de Home Assistant. Tolerance de 5 s : l'estimation par mode + // peut bouger d'une seconde sans qu'il se soit rien passe. + const demarrage = demarrageHaMs(st); + if ( + demarrage !== null && + memoire.demarrageHa !== null && + demarrage > memoire.demarrageHa + 5000 + ) { + await journaliser(sql, 'systeme', 'Home Assistant a redemarre', { + demarre_a: new Date(demarrage).toISOString(), + precedent: new Date(memoire.demarrageHa).toISOString(), + }); + n++; + } + if (demarrage !== null) memoire.demarrageHa = demarrage; + + // Transitions suivies. + for (const [id, genre, libelle] of TRANSITIONS_SUIVIES) { + const e = st.get(id); + if (!e) continue; + const cur = txt(e.state); + // Une lecture indisponible n'est pas une transition : c'est une absence de + // 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); + if (prec === undefined) { memoire.valeurs.set(id, cur); continue; } + if (prec === cur) continue; + await journaliser(sql, genre, `${libelle} : ${prec} -> ${cur}`, { + entite: id, avant: prec, apres: cur, + horodatage_ha: e.last_changed, + }); + memoire.valeurs.set(id, cur); + n++; + } + return n; +} + // --------------------------------------------------------------------- async function cycle(client) { // En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie @@ -230,13 +347,14 @@ async function cycle(client) { bilan: await ecrireBilan(sql, st), decision: await ecrireDecision(sql, st), prevision: await ecrirePrevision(sql, st), + evenement: await detecterEvenements(sql, st), }; - const total = r.mesures + r.bilan + r.decision + r.prevision; + const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement; 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}` + `decision=${r.decision} prevision=${r.prevision} evenement=${r.evenement}` ); } } @@ -263,10 +381,26 @@ if (ONCE) { // Une panne d'un cycle ne doit pas tuer la boucle : Home Assistant redémarre, // le réseau hoquette, la base se recharge. On journalise et on retente. +// Un episode d'indisponibilite est un evenement a part entiere : c'est +// exactement ce qu'on voudra retrouver devant un trou dans les courbes. Il est +// journalise au RETOUR, avec sa duree — au moment de la panne, la base n'est +// peut-etre pas joignable non plus. +let echecDepuis = null; + for (;;) { try { await cycle(client); + if (echecDepuis !== null) { + const duree = Math.round((Date.now() - echecDepuis) / 1000); + try { + await journaliser(client.query.bind(client), 'systeme', + `Home Assistant injoignable pendant ${duree} s`, + { depuis: new Date(echecDepuis).toISOString(), secondes: duree }); + } catch { /* la base non plus n'etait peut-etre pas la : on n'insiste pas */ } + echecDepuis = null; + } } catch (e) { + if (echecDepuis === null) echecDepuis = Date.now(); console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`); } await new Promise((r) => setTimeout(r, INTERVAL_S * 1000));