Le service d historisation consigne lui-meme les evenements qu il constate

Une courbe sans ses evenements ne s interprete pas. Le service voit passer les
etats toutes les minutes : autant qu il en tire les conclusions plutot que d
attendre que quelqu un y pense.

Douze etats DISCRETS suivis — contacteur EDF, session reseau de nuit, verrou
hysteresis, vehicule branche, strategie, vehicule selectionne, etat BatteryLife,
mode de l Auto Charge Manager, reseau necessaire, vigilance orage, production
bridee. Les grandeurs continues restent dans mesure et les decisions dans
decision : un evenement 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
consignee.

Le redemarrage de Home Assistant est detecte sans capteur d uptime, qui n existe
pas ici : au redemarrage HA restaure ses helpers et leur pose un last_changed
neuf. Le 04/09 a 07:28:49, 352 entites partageaient la seconde du redemarrage
dont 183 input_number. Le service prend donc le mode des last_changed des
input_number — aucune entite codee en dur, et l estimation se corrige d
elle-meme puisque entre deux redemarrages seuls les helpers reellement ecrits s
ecartent du mode. Sous dix helpers d accord, la signature est jugee trop faible
et rien n est affirme.

Un episode d indisponibilite de Home Assistant est journalise AU RETOUR avec sa
duree : au moment de la panne, la base n est peut-etre pas joignable non plus.

Au premier cycle l etat est enregistre SANS rien journaliser, sinon chaque
redemarrage du service produirait douze fausses transitions ; seul le demarrage
du service est consigne.

Verifie de bout en bout en conditions reelles et non seulement a blanc : essai a
blanc avec cycle court, bascule reelle du selecteur de vehicule puis
restauration. Amorcage sans fausse transition, cycle sans changement a zero
evenement, les deux bascules relevees, etat restaure.

Documente aussi le GRANT INSERT sur la seule table evenement pour le role d
analyse — insert seulement, ni update ni delete : on ajoute une annotation,
jamais on ne reecrit ni n efface les precedentes.

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-04 07:43:49 +02:00
parent 2846192e96
commit 931e07eca9
2 changed files with 182 additions and 2 deletions

View File

@@ -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

View File

@@ -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));