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:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user