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
|
À faire après l'étape 1 — n8n a besoin que la base existe pour qu'on y crée
|
||||||
l'identifiant de connexion.
|
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
|
## Accès en lecture pour l'analyse
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -76,6 +76,65 @@ const MESURES = [
|
|||||||
'input_number.victron_evcs_night_target_soc',
|
'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 num = (v) => {
|
||||||
const n = Number.parseFloat(v);
|
const n = Number.parseFloat(v);
|
||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
@@ -211,6 +270,64 @@ async function ecrirePrevision(sql, st) {
|
|||||||
return n;
|
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) {
|
async function cycle(client) {
|
||||||
// En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie
|
// 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),
|
bilan: await ecrireBilan(sql, st),
|
||||||
decision: await ecrireDecision(sql, st),
|
decision: await ecrireDecision(sql, st),
|
||||||
prevision: await ecrirePrevision(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) {
|
if (total > 0 || ONCE) {
|
||||||
console.log(
|
console.log(
|
||||||
`${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` +
|
`${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` +
|
||||||
`${DRY ? '[BLANC] ' : ''}mesures=${r.mesures} bilan=${r.bilan} ` +
|
`${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,
|
// 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.
|
// 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 (;;) {
|
for (;;) {
|
||||||
try {
|
try {
|
||||||
await cycle(client);
|
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) {
|
} catch (e) {
|
||||||
|
if (echecDepuis === null) echecDepuis = Date.now();
|
||||||
console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`);
|
console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`);
|
||||||
}
|
}
|
||||||
await new Promise((r) => setTimeout(r, INTERVAL_S * 1000));
|
await new Promise((r) => setTimeout(r, INTERVAL_S * 1000));
|
||||||
|
|||||||
Reference in New Issue
Block a user