diff --git a/HANDOFF.md b/HANDOFF.md index 91636b8..9263644 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2418,5 +2418,16 @@ batterie a 32 A et JEEP (branchee 13:01, 52 %) qui ne demarre pas. SOC 47 % a 12 - Verifie : fermeture a distance par l'ancien identifiant -> tension d'entree du MultiPlus 230 V en 4 s ; reouverture par l'HYSTERESIS a 20:10:04 (passage de la minute, sans session) -> le systeme pilote bien le nouveau disjoncteur. - Sauvegardes : `.storage/core.entity_registry.bak-20261013-edf100a`, `core.device_registry…`, `energy…`. Retour arriere = renommer en sens inverse (meme API). +### 13/10 soir — La revue disait « Rien a signaler » sur la nuit de la panne EDF ; piles qui battaient +- Revue du 13/10 (12/10 07:24 -> 13/10 07:24) : « Rien a signaler » alors qu'EDF etait absent 4 h 35 et que la VW avait ete payee par la batterie. Elle ne connaissait que 4 cas, et « batterie a son objectif » n'etait vrai que grace aux gestes du proprietaire. SPARK confirme : rien relaye de la revue ce jour-la, seul le courriel de 08:15. +- `tools/revue-jour.mjs` (accord du proprietaire, tests revue 26/26, 3 mutations qui mordent) : + - `edf_absent_session` : session HC engagee (journal `evenement`, la table `decision` ne logue que les changements d'etat) ou verrou d'hysteresis, ET reseau <= 100 W ET batterie qui donne (< -150 W), >= 10 min ; + - `voiture_payee_par_batterie` : borne > 500 W, batterie qui donne, source non reseau ; kWh = min(borne, decharge) minute par minute ; >= 0,5 kWh (aussi dans la revue glissante) ; + - `intervention` (info) : contacteur manoeuvre hors systeme qui TIENT 5 min, charge HC manuelle armee (nouvellement journalisee), mode de nuit change hors 12:00 ; + - kWh EDF en HP = puissance reseau Victron minute par minute (le compteur du disjoncteur a ete remplace : la glissante du 13/10 20:11 annoncait « 0 kWh » pour 17 min a ~600 W) ; + - texte vide : « Aucun des cas surveilles n'est apparu : . Ce n'est pas la preuve que tout s'est bien passe ». + - Rejeu reel : revue du 13/10 -> VW payee 16:20-17:00 (1,2 kWh), EDF absent 01:25-06:00 (36 -> 26 %, 3,2 kWh), intervention 05:58 ; revue du 14/10 (projet) -> EDF HP 19:50-20:08, 0,2 kWh. ⚠️ Correction : la batterie est passee de 36 % (et non 31) a 26 % pendant la panne. +- `tools/piles.mjs` (tests 14/14, 2 mutations) : hysteresis (alerte a <= 20 %, retombe a >= 40 % ; etat low tient jusqu'a high) ; **acquittement** `input_text.evcs_piles_acquittees` = « cle:AAAA-MM-JJ,… » (cle = entite sans « sensor. », pas de rappel jusqu'a la fin de ce jour), ecrit par le concierge sur reponse du proprietaire. `sensor.evcs_piles` = nombre d'alertes NON acquittees ; attributs `a_rappeler`, `acquittees`, `cle` par alerte. Paquet `evcs_piles.yaml` (sauvegarde `.bak-20261013-acquit`). + --- *Document mis a jour le : 13/10/2026 (~06:30) — nuit du 12-13/10 : disjoncteur EDF qui n'executait pas la fermeture (alerte edf_ne_ferme_pas, envoyee a toute heure), charge HC manuelle reparee et utilisee a 40 % ; 13/10 : certificats HTTPS du .50 renouveles (via le .17) + alerte d'expiration ; 12/10 : VW payee par la batterie (temps mini + tolerance nuage bornes par le bilan), niveaux estimes par l'autonomie ; 10/10/2026 (~16:00) — conseiller relecteur de la journee (revue calculee + pistes) ; nuit : voiture qui profite d'EDF ferme pour la batterie, nuit qui prepare le soir les jours annonces faibles ; 08/10/2026 (~00:15) — jour faible : charge maximale en HC de midi (bilan 100 %, ACM plafond + solaire) ; 07/10/2026 (~22:45) — reprise de 16 h verifiee, EDF en HP a 18:03, bilan : reprise du soir et prevision recalee sur la matinee ; bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; 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/packages/evcs_piles.yaml b/packages/evcs_piles.yaml index 7e80530..7dc7668 100755 --- a/packages/evcs_piles.yaml +++ b/packages/evcs_piles.yaml @@ -6,3 +6,10 @@ input_text: name: Piles - capteurs a ignorer max: 255 icon: mdi:battery-off-outline + # Acquittement (13/10/2026) : « cle:AAAA-MM-JJ,cle:AAAA-MM-JJ », cle = entite + # sans « sensor. ». Le proprietaire a vu l'alerte et changera les piles avant + # la date : pas de rappel d'ici la. Ecrit par le concierge (SPARK). + evcs_piles_acquittees: + name: Piles - alertes vues (pas de rappel avant la date) + max: 255 + icon: mdi:battery-clock-outline diff --git a/tests/piles/run.mjs b/tests/piles/run.mjs index 0fe3e19..ac5d4f9 100644 --- a/tests/piles/run.mjs +++ b/tests/piles/run.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { pilesAAlerter } from '../../tools/piles.mjs'; +import { lireAcquittements, pilesAAlerter } from '../../tools/piles.mjs'; const T = Date.parse('2026-10-10T00:30:00+02:00'); const e = (entity_id, state, attrs = {}, last = '2026-10-09T22:00:00+02:00') => ({ entity_id, state, attributes: attrs, last_changed: last }); // Extrait reel des etats HA du 10/10 00:30. @@ -25,4 +25,20 @@ ok('muet-plus-de-24h', r.alertes.find((a) => a.entite === 'sensor.tt_chambre_bat ok('muet-recent-pas-encore', !ids.includes('sensor.chambre_danielle_etat_batterie')); ok('ignorees', !pilesAAlerter(etats, T, ['sensor.kt_chambre_batterie']).alertes.some((a) => a.entite === 'sensor.kt_chambre_batterie')); ok('texte', /SYLVIE CHAMBRE Batterie : pile a 12 %/.test(r.texte) && /TT CHAMBRE Batterie ne publie plus depuis 5 j/.test(r.texte), r.texte); +// 13/10 : hysteresis. GRENIER 13 -> 19 % -> 6 % : une seule alerte, qui tient tant que < 40 %. +const grenier = (n) => [{ entity_id: 'sensor.salle_de_bain_bruz_batterie', state: String(n), last_changed: new Date(T).toISOString(), + attributes: { device_class: 'battery', unit_of_measurement: '%', friendly_name: 'GRENIER Batterie' } }]; +ok('hysteresis-tient-a-25', pilesAAlerter(grenier(25), T, [], { precedentes: ['sensor.salle_de_bain_bruz_batterie'] }).alertes.length === 1); +ok('hysteresis-pas-de-nouvelle-a-25', pilesAAlerter(grenier(25), T, [], { precedentes: [] }).alertes.length === 0); +ok('hysteresis-retombe-a-40', pilesAAlerter(grenier(40), T, [], { precedentes: ['sensor.salle_de_bain_bruz_batterie'] }).alertes.length === 0); +// Acquittement : « vu, je change dans 3 jours » -> plus de rappel jusqu'a la date. +const acq = lireAcquittements('salle_de_bain_bruz_batterie:2026-10-16, mauvais format, x:2026-13-99'); +const ra = pilesAAlerter(grenier(6), Date.parse('2026-10-14T09:00:00+02:00'), [], { acquittements: acq }); +ok('acquittee-pas-de-rappel', ra.alertes.length === 1 && ra.a_rappeler.length === 0 && ra.acquittees[0].jusqu_au === '2026-10-16' && /jusqu'au 16\/10/.test(ra.texte), JSON.stringify(ra)); +const rb = pilesAAlerter(grenier(6), Date.parse('2026-10-17T08:00:00+02:00'), [], { acquittements: acq }); +ok('acquittement-expire-rappel', rb.a_rappeler.length === 1 && rb.a_rappeler[0].cle === 'salle_de_bain_bruz_batterie'); +ok('acquittement-format-tolerant', Object.keys(acq).length === 1); +const gerard = (v) => [{ entity_id: 'sensor.chambre_gerard_etat_batterie', state: v, last_changed: new Date(T).toISOString(), attributes: { device_class: 'enum', friendly_name: 'Chambre Gerard Etat batterie' } }]; +ok('enum-low-middle-tient', pilesAAlerter(gerard('middle'), T, [], { precedentes: ['sensor.chambre_gerard_etat_batterie'] }).alertes.length === 1 + && pilesAAlerter(gerard('middle'), T, []).alertes.length === 0 && pilesAAlerter(gerard('high'), T, [], { precedentes: ['sensor.chambre_gerard_etat_batterie'] }).alertes.length === 0); console.log(f ? `\n${f} ECHEC(S)` : '\nPILES OK'); process.exit(f ? 1 : 0); diff --git a/tests/revue-jour/run.mjs b/tests/revue-jour/run.mjs index d390c5c..9081436 100644 --- a/tests/revue-jour/run.mjs +++ b/tests/revue-jour/run.mjs @@ -20,7 +20,7 @@ const ok = (id, cond, info = '') => { if (cond) console.log(`✅ ${id}`); else { const codes = (r) => r.constats.map((c) => c.code).join(','); { const r = constatsRevue(base()); - ok('rien-a-signaler', r.constats.length === 0 && /Rien a signaler/.test(r.texte), r.texte); } + ok('rien-a-signaler', r.constats.length === 0 && /Aucun des cas surveilles/.test(r.texte) && /seuls ces cas sont verifies/.test(r.texte), r.texte); } { // 07/10 18:06 -> 01:24, 8,3 kWh, ferme par l'automatisation SoC < 22 % const d = base(); @@ -29,7 +29,8 @@ const codes = (r) => r.constats.map((c) => c.code).join(','); d.edfKwh.push({ ts: T('07T18:05:00'), v: 100 }, { ts: T('08T01:24:00'), v: 108.3 }, { ts: T('08T07:24:00'), v: 113 }); d.soc.push({ ts: T('07T18:00:00'), v: 21 }); const r = constatsRevue(d); const c = r.constats.find((x) => x.code === 'edf_heures_pleines'); - ok('hp-releve-0710', !!c && c.chiffres.minutes === 438 && c.chiffres.kwh === 8.3 && /18:06 a 01:24/.test(c.texte) + // 13/10 : kWh = puissance reseau mesuree (1 800 W x 438 min = 13,14 kWh), plus le compteur du disjoncteur. + ok('hp-releve-0710', !!c && c.chiffres.minutes === 438 && c.chiffres.kwh === 13.14 && /18:06 a 01:24/.test(c.texte) && /Grid ON si SoC < 22%/.test(c.texte) && r.constats.filter((x) => x.code === 'edf_heures_pleines').length === 1, JSON.stringify(r.constats)); } { const d = base(); // reseau 4 min en heures pleines : ignore @@ -105,5 +106,35 @@ const sessionMidi = (d, requis) => { const c = constatsRevue(d).constats.find((x) => x.code === 'charge_max_bridee_en_cours'); ok('charge-max-bridee-en-cours', c && /depuis 12:24/.test(c.texte) && c.gravite === 'a_examiner', JSON.stringify(constatsRevue(d).constats.map((x) => x.code))); } +// 13/10 : EDF demande mais absent. Releve du 12-13/10 : session engagee a 01:24, +// reseau 0 W, batterie a -900 W, contacteur rearme au levier a 05:58. +const nuitEdfAbsent = () => { const d = base(); + d.sessionNuit = [{ ts: T('07T07:25:00'), v: 'off' }, { ts: T('08T01:24:00'), v: 'on' }, { ts: T('08T07:24:00'), v: 'off' }]; + d.batterieW = [{ ts: T('07T07:25:00'), v: 0 }, { ts: T('08T01:25:00'), v: -900 }, { ts: T('08T05:58:00'), v: 1500 }]; + d.soc.push({ ts: T('08T01:25:00'), v: 36 }, { ts: T('08T05:58:00'), v: 26 }); + return d; }; +{ const r = constatsRevue(nuitEdfAbsent()); const c = r.constats.find((x) => x.code === 'edf_absent_session'); + ok('edf-absent-1213', c && c.gravite === 'a_examiner' && /ABSENT de 01:25 a 05:58/.test(c.texte) && /36 % -> 26 %/.test(c.texte) && c.chiffres.kwh === 4.1 && /session HC engagee oui/.test(c.etat) && !/Aucun des cas/.test(r.texte), JSON.stringify(r.constats)); } +{ const d = nuitEdfAbsent(); d.reseauW.push({ ts: T('08T01:25:00'), v: 2300 }, { ts: T('08T07:24:00'), v: 0 }); + ok('edf-present-rien', !constatsRevue(d).constats.some((x) => x.code === 'edf_absent_session')); } +{ const d = nuitEdfAbsent(); d.sessionNuit = [{ ts: T('07T07:25:00'), v: 'off' }]; + ok('pas-demande-rien', !constatsRevue(d).constats.some((x) => x.code === 'edf_absent_session')); } +{ const d = nuitEdfAbsent(); d.sessionNuit = [{ ts: T('07T07:25:00'), v: 'off' }]; + d.verrouHysteresis = [{ ts: T('07T07:25:00'), v: 'off' }, { ts: T('08T01:24:00'), v: 'on' }]; + ok('verrou-hysteresis-absent', constatsRevue(d).constats.some((x) => x.code === 'edf_absent_session' && /hysteresis/.test(x.texte))); } +// 12/10 : VW payee par la batterie (16:16-16:44, borne 3,4 kW, batterie -3,6 kW). +const vwBatterie = (batt = -3600, source = 'Solaire') => { const d = base(); + d.decisions.push({ ts: T('07T16:16:00'), profil: 'outside_window', source_mode: source, etat: 'hold_min_run', session_engagee: false, cible_matin_pct: 30, raison: 'Maintien jusqu au temps mini ON' }); + d.borneW = [{ ts: T('07T07:25:00'), v: 0 }, { ts: T('07T16:16:00'), v: 3363 }, { ts: T('07T16:44:00'), v: 0 }]; + d.batterieW = [{ ts: T('07T07:25:00'), v: 0 }, { ts: T('07T16:16:00'), v: batt }, { ts: T('07T16:44:00'), v: 0 }]; + return d; }; +{ const c = constatsRevue(vwBatterie()).constats.find((x) => x.code === 'voiture_payee_par_batterie'); + ok('vw-payee-1210', c && /de 16:16 a 16:44/.test(c.texte) && c.chiffres.kwh === 1.57 && /hold_min_run/.test(c.texte), JSON.stringify(c)); } +ok('vw-solaire-pur-rien', !constatsRevue(vwBatterie(600)).constats.some((x) => x.code === 'voiture_payee_par_batterie')); +ok('vw-sur-reseau-rien', !constatsRevue(vwBatterie(-3600, 'Réseau HC autorisé')).constats.some((x) => x.code === 'voiture_payee_par_batterie')); +{ const d = base(); d.interventions = [{ ts: T('08T05:58:00'), texte: 'contacteur EDF ferme hors du systeme (levier ou appli)' }]; + const r = constatsRevue(d); + ok('intervention-info', r.constats.length === 1 && r.constats[0].gravite === 'info' && /Intervention a 05:58/.test(r.texte)); } + console.log(echecs ? `\n${echecs} ECHEC(S)` : '\nREVUE OK'); process.exit(echecs ? 1 : 0); diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index ea6a488..efbcaa9 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -33,7 +33,7 @@ import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from ' import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs'; import { donneesRevue, constatsRevue } from './revue-jour.mjs'; import { detecterSituation, lireChoix, DUREE_MIN as ARBITRAGE_DUREE_MIN } from './arbitrage.mjs'; -import { pilesAAlerter } from './piles.mjs'; +import { pilesAAlerter, lireAcquittements } from './piles.mjs'; import { DOMAINES, lireCertificat, certificatsAAlerter } from './certificats.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'; @@ -138,6 +138,7 @@ const TRANSITIONS_SUIVIES = [ ['input_select.victron_evcs_vehicle', 'intervention','Vehicule selectionne'], ['input_select.victron_evcs_strategy', 'intervention','Strategie'], ['input_select.victron_evcs_night_charge_mode', 'intervention','Charge de la voiture la nuit'], + ['input_boolean.evcs_battery_manual_hc', 'intervention','Charge batterie manuelle en heures creuses'], ['switch.tt_chauffe_eau_pleumeur_switch', 'chauffe_eau', 'Chauffe-eau sous tension'], ['input_boolean.evcs_balance_ecs_nuit', 'chauffe_eau', 'Chauffe-eau en HC de nuit (jour faible)'], ['input_text.evcs_balance_ecs_nuit_jour', 'chauffe_eau', 'Decision du jour pour le chauffe-eau'], @@ -1288,7 +1289,7 @@ async function publierRevueHistorique(client) { // relayee par SPARK sur WhatsApp, historisee dans revue_alerte. La voiture // bloquee reste a l'arbitre (pas de doublon). Un episode qui dure (EDF en heures // pleines) ne donne qu'une alerte : on prolonge la ligne existante. -const CODES_GLISSANTS = new Set(['edf_heures_pleines', 'charge_max_bridee_en_cours', 'charge_max_bridee', +const CODES_GLISSANTS = new Set(['edf_heures_pleines', 'charge_max_bridee_en_cours', 'charge_max_bridee', 'voiture_payee_par_batterie', 'batterie_sous_objectif_nuit', 'batterie_sous_objectif_midi']); let revueGlissanteSchema = false; async function revueGlissante(sql, client, st) { @@ -2160,10 +2161,15 @@ async function publierPiles() { const etats = await r.json(); const ign = String(etats.find((x) => x.entity_id === 'input_text.evcs_piles_ignorees')?.state || '') .split(',').filter((x) => x && x !== 'unknown'); - const p = pilesAAlerter(etats, Date.now(), ign); - await publierCapteur('sensor.evcs_piles', p.alertes.length, { + const avant = etats.find((x) => x.entity_id === 'sensor.evcs_piles')?.attributes?.alertes || []; + const acquit = etats.find((x) => x.entity_id === 'input_text.evcs_piles_acquittees')?.state; + const p = pilesAAlerter(etats, Date.now(), ign, { + precedentes: avant.map((a) => a.entite), acquittements: lireAcquittements(acquit) }); + // Etat = alertes NON acquittees (13/10) : ce que le concierge doit rappeler. + await publierCapteur('sensor.evcs_piles', p.a_rappeler.length, { friendly_name: 'Piles des capteurs a changer', icon: 'mdi:battery-alert-variant-outline', - alertes: p.alertes, texte: p.texte, ignorees: ign, + alertes: p.alertes, a_rappeler: p.a_rappeler, acquittees: p.acquittees, texte: p.texte, ignorees: ign, + format_acquittement: 'input_text.evcs_piles_acquittees = cle:AAAA-MM-JJ[,cle:AAAA-MM-JJ] (cle = attribut cle de l alerte)', }); pilesPubliees = true; return 1; diff --git a/tools/piles.mjs b/tools/piles.mjs index 5e2bb9f..f63f086 100644 --- a/tools/piles.mjs +++ b/tools/piles.mjs @@ -8,6 +8,10 @@ // ===================================================================== export const SEUIL_PCT = 20; // pile faible a 20 % et moins +// HYSTERESIS (13/10) : une alerte ne retombe qu'a 40 % et plus (en pratique : un +// changement de piles). GRENIER 13 -> 19 -> 6 % et Chambre Gerard ont fait +// partir 5 messages le 13/10, l'alerte disparaissant puis revenant autour de 20 %. +export const SEUIL_RETOUR_PCT = 40; export const MUET_H = 24; // indisponible depuis plus de 24 h // Pas des piles de capteurs : parc Victron, voiture, telephones, reglages GX. const EXCLUS = /^(sensor|number|binary_sensor)\.(victron_|gx_device_|airtria_8_|jeep_|iphone_|ipad_|samsung_)/; @@ -17,8 +21,31 @@ const EXCLUS = /^(sensor|number|binary_sensor)\.(victron_|gx_device_|airtria_8_| * @param {number} maintenantMs * @param {string[]} ignorees entites que le proprietaire a demande d'ignorer */ -export function pilesAAlerter(etats, maintenantMs, ignorees = []) { +// ACQUITTEMENT (13/10, demande du proprietaire) : « j'ai compris, je change les +// piles dans N jours » -> plus de rappel pour cet appareil jusqu'a la date dite. +// input_text.evcs_piles_acquittees = « cle:AAAA-MM-JJ,cle:AAAA-MM-JJ », cle = +// entite sans « sensor. » ; valable jusqu'a la fin de ce jour (heure de Paris). +// Ecrit par le concierge (SPARK) ; une cle d'un appareil qui n'est plus en +// alerte (piles changees) est simplement sans effet. +export function lireAcquittements(texte) { + const out = {}; + for (const part of String(texte || '').split(',')) { + const m = /^\s*([a-z0-9_]+)\s*:\s*(\d{4}-\d{2}-\d{2})\s*$/.exec(part); + if (!m) continue; + const fin = Date.parse(`${m[2]}T23:59:59+02:00`); + if (Number.isFinite(fin)) out[`sensor.${m[1]}`] = { jusquAu: m[2], finMs: fin }; + } + return out; +} + +/** + * @param {object} opts precedentes : entites en alerte au passage precedent (hysteresis) + * acquittements : resultat de lireAcquittements + */ +export function pilesAAlerter(etats, maintenantMs, ignorees = [], opts = {}) { const ign = new Set(ignorees.map((x) => x.trim()).filter(Boolean)); + const prec = new Set(opts.precedentes || []); + const acq = opts.acquittements || {}; const alertes = []; for (const s of etats) { const id = s.entity_id; @@ -38,15 +65,30 @@ export function pilesAAlerter(etats, maintenantMs, ignorees = []) { } if (enPct) { const n = Number(v); - if (Number.isFinite(n) && n <= SEUIL_PCT) alertes.push({ entite: id, nom, statut: 'faible', niveau: n, depuis: s.last_changed, + if (Number.isFinite(n) && (n <= SEUIL_PCT || (prec.has(id) && n < SEUIL_RETOUR_PCT))) alertes.push({ entite: id, nom, statut: 'faible', niveau: n, depuis: s.last_changed, texte: `${nom} : pile a ${n} %` }); - } else if (v === 'low') { + } else if (v === 'low' || (v === 'middle' && prec.has(id))) { + // Meme hysteresis pour les etats : Chambre Gerard battait entre low et + // middle le 13/10 (deux messages). L'alerte ne retombe qu'a « high ». alertes.push({ entite: id, nom, statut: 'faible', niveau: 'low', depuis: s.last_changed, texte: `${nom} : pile faible` }); } } alertes.sort((x, y) => (x.statut === y.statut ? x.nom.localeCompare(y.nom) : x.statut === 'faible' ? -1 : 1)); + for (const a of alertes) { + const k = acq[a.entite]; + a.acquittee = !!(k && k.finMs >= maintenantMs); + if (a.acquittee) a.jusqu_au = k.jusquAu; + a.cle = a.entite.replace(/^sensor\./, ''); + } + const aRappeler = alertes.filter((a) => !a.acquittee); + const acquittees = alertes.filter((a) => a.acquittee); + const jour = (iso) => iso.split('-').reverse().slice(0, 2).join('/'); return { alertes, - texte: alertes.length ? alertes.map((x) => `- ${x.texte}`).join('\n') : 'Toutes les piles des capteurs sont bonnes.', + a_rappeler: aRappeler, + acquittees, + texte: !alertes.length ? 'Toutes les piles des capteurs sont bonnes.' + : [...aRappeler.map((x) => `- ${x.texte}`), + ...(acquittees.length ? [`Deja vues, pas de rappel avant la date dite : ${acquittees.map((x) => `${x.nom} (jusqu'au ${jour(x.jusqu_au)})`).join(', ')}.`] : [])].join('\n'), }; } diff --git a/tools/revue-jour.mjs b/tools/revue-jour.mjs index 2293b71..eb168bf 100644 --- a/tools/revue-jour.mjs +++ b/tools/revue-jour.mjs @@ -21,6 +21,9 @@ const SEUIL_HP_MIN = 5; // fermeture EDF en heures pleines retenue const SEUIL_RESEAU_W = 100; // bruit de mesure du reseau (comme sensor.evcs_pv_diagnostic) const SEUIL_VOITURE_MIN = 10; // voiture bloquee pendant une session HC const SEUIL_OBJECTIF_PTS = 2; // batterie sous son objectif en fin de HC +const SEUIL_EDF_ABSENT_MIN = 10; // EDF demande mais absent (13/10) +const SEUIL_DECHARGE_W = 150; // la batterie DONNE (bruit ~ +-100 W) +const SEUIL_VOITURE_BATT_KWH = 0.5; // voiture payee par la batterie (12/10) const CHARGEURS_MAX_W = 7300; // 2 MultiPlus, 140 A (HANDOFF 07/10) const RENDEMENT_CHARGE = 0.92; @@ -70,10 +73,26 @@ export function enHc(ms, fenetres) { // Valeur d'une serie en escalier (derniere valeur connue a l'instant t). function aInstant(serie, t) { let v = null; - for (const p of serie) { if (p.ts <= t) v = p.v; else break; } + for (const p of serie || []) { if (p.ts <= t) v = p.v; else break; } return v; } +// Energie (kWh) d'une serie de puissance (W) entre a et b, minute par minute sur +// la valeur EN VIGUEUR (les puissances ne sont historisees qu'a leur changement : +// une valeur constante vaut jusqu'au point suivant). Seules les valeurs > 0 +// comptent. 13/10 : le compteur du disjoncteur EDF a ete REMPLACE (reparti de +// zero) et la revue glissante a annonce « 0 kWh achetes » pour 17 min a ~600 W. +// La puissance mesuree par le Victron ne depend d'aucun compteur remplacable. +export function energieKwh(serie, a, b, signe = 1) { + if (!serie || !serie.length) return null; + let kwh = 0; + for (let t = a; t < b; t += MIN) { + const w = signe * Number(aInstant(serie, t)); + if (Number.isFinite(w) && w > 0) kwh += w / 60000; + } + return kwh; +} + // Plages contigues ou `pred(t)` est vrai, echantillonnees a la minute. function plages(debut, fin, pred) { const out = []; @@ -104,7 +123,9 @@ function etatA(d, t) { `consigne reseau ${v(d.consigneW, ' W')}`, `courant de charge max fixe par l'ACM ${v(d.acmA, ' A')}`, `borne ${v(d.borneW, ' W')}`, - `session HC engagee ${x ? (x.session_engagee ? 'oui' : 'non') : 'inconnu'}`, + // La table `decision` n'enregistre que les changements d'ETAT du controleur : + // la session de nuit se lit dans le journal quand il est fourni (13/10). + `session HC engagee ${d.sessionNuit && d.sessionNuit.length ? (aInstant(d.sessionNuit, t) === 'on' ? 'oui' : 'non') : x ? (x.session_engagee ? 'oui' : 'non') : 'inconnu'}`, `mode de nuit ${mode || 'inconnu'}`, `budget voiture sans EDF ${v(d.budgetKwh, ' kWh', 2)}`, `SOC requis (bilan) ${v(d.requis, ' %', 1)}`, @@ -142,8 +163,7 @@ export function constatsRevue(d) { for (const p of fusion) { const min = (p.fin - p.debut) / MIN; if (min < SEUIL_HP_MIN) continue; - const k0 = aInstant(d.edfKwh, p.debut), k1 = aInstant(d.edfKwh, p.fin); - const kwh = k0 !== null && k1 !== null && k1 >= k0 ? k1 - k0 : null; + const kwh = energieKwh(d.reseauW, p.debut, p.fin); const ouverture = [...d.contacteur].reverse().find((c) => c.v === 'on' && c.ts <= p.debut + 2 * MIN && c.ts >= p.debut - 30 * MIN); const socDebut = aInstant(d.soc, p.debut); constats.push({ @@ -252,10 +272,80 @@ export function constatsRevue(d) { } } + // 5. EDF DEMANDE MAIS ABSENT (13/10). Nuit du 12-13/10 : session de nuit + // engagee a 01:24, le disjoncteur n'executait pas la fermeture ; reseau a 0 W, + // batterie 31 -> 26 % jusqu'au rearmement au levier a 05:57. La revue avait + // ecrit « Rien a signaler ». Demande = session HC engagee (en HC) ou verrou + // d'hysteresis ; absent = reseau <= 100 W ET batterie qui DONNE. + const surEtat = (serie, t) => aInstant(serie || [], t) === 'on'; + const absentes = plages(d.debut, d.fin, (t) => + ((surEtat(d.sessionNuit, t) && enHc(t, d.fenetres)) || surEtat(d.verrouHysteresis, t)) + && !((aInstant(d.reseauW, t) ?? 0) > SEUIL_RESEAU_W) + && (aInstant(d.batterieW, t) ?? 0) < -SEUIL_DECHARGE_W); + const fusionAbs = []; + for (const p of absentes) { + const der = fusionAbs[fusionAbs.length - 1]; + if (der && p.debut - der.fin <= 5 * MIN) der.fin = p.fin; else fusionAbs.push({ ...p }); + } + for (const p of fusionAbs) { + const min = (p.fin - p.debut) / MIN; + if (min < SEUIL_EDF_ABSENT_MIN) continue; + const s0 = aInstant(d.soc, p.debut), s1 = aInstant(d.soc, p.fin); + const donne = energieKwh(d.batterieW, p.debut, p.fin, -1); + const pourquoi = surEtat(d.sessionNuit, p.debut) ? 'session d\'heures creuses engagee' : 'verrou d\'hysteresis (batterie basse)'; + constats.push({ + code: 'edf_absent_session', gravite: 'a_examiner', debut: p.debut, fin: p.fin, + texte: `EDF demande (${pourquoi}) mais ABSENT de ${hhmm(p.debut)} a ${hhmm(p.fin)} (${duree(min)}) : reseau a 0 W` + + (s0 !== null && s1 !== null ? `, batterie ${Math.round(s0)} % -> ${Math.round(s1)} %` : '') + + (donne !== null ? ` (${f1(donne)} kWh donnes par la batterie au lieu d'EDF)` : '') + + ' ; l\'ordre de fermeture n\'a pas ete execute par le disjoncteur.', + chiffres: { minutes: Math.round(min), socDebut: s0, socFin: s1, kwh: donne === null ? null : Math.round(donne * 100) / 100 }, + etat: etatA(d, p.debut + MIN), contexte: '', + }); + } + + // 6. VOITURE PAYEE PAR LA BATTERIE (12/10). VW de 16:16 a 16:44 tenue par le + // temps mini puis la tolerance nuage, batterie a -40/-74 A : ~1,9 kWh de la + // batterie pour la voiture. Hors session reseau ; part batterie = min(borne, + // decharge de la batterie), minute par minute. + const payees = plages(d.debut, d.fin, (t) => { + const x = dec(t); + return (aInstant(d.borneW, t) ?? 0) > 500 && (aInstant(d.batterieW, t) ?? 0) < -SEUIL_DECHARGE_W + && !(x && /r[ée]seau/i.test(x.source_mode || '')) && !((aInstant(d.reseauW, t) ?? 0) > SEUIL_RESEAU_W); + }); + const fusionPay = []; + for (const p of payees) { + const der = fusionPay[fusionPay.length - 1]; + if (der && p.debut - der.fin <= 5 * MIN) der.fin = p.fin; else fusionPay.push({ ...p }); + } + for (const p of fusionPay) { + let kwh = 0; + for (let t = p.debut; t < p.fin; t += MIN) kwh += Math.min(aInstant(d.borneW, t) ?? 0, -(aInstant(d.batterieW, t) ?? 0)) / 60000; + if (kwh < SEUIL_VOITURE_BATT_KWH) continue; + const min = (p.fin - p.debut) / MIN; + const x = dec(p.debut); + constats.push({ + code: 'voiture_payee_par_batterie', gravite: 'a_examiner', debut: p.debut, fin: p.fin, + texte: `Voiture en charge payee par la batterie maison de ${hhmm(p.debut)} a ${hhmm(p.fin)} (${duree(min)}) : ${f1(kwh)} kWh pris a la batterie (etat ${x ? x.etat : 'inconnu'}).`, + chiffres: { minutes: Math.round(min), kwh: Math.round(kwh * 100) / 100 }, + etat: etatA(d, p.debut + 2 * MIN), contexte: (x?.raison || '').slice(0, 400), + }); + } + + // 7. INTERVENTIONS MANUELLES (info, 13/10) : une journee « tenue » grace a un + // geste du proprietaire n'est pas une journee sans probleme. + for (const iv of d.interventions || []) { + if (iv.ts < d.debut || iv.ts >= d.fin) continue; + constats.push({ code: 'intervention', gravite: 'info', debut: iv.ts, fin: iv.ts, + texte: `Intervention a ${hhmm(iv.ts)} : ${iv.texte}.`, chiffres: {}, etat: etatA(d, iv.ts), contexte: '' }); + } + constats.sort((a, b) => a.debut - b.debut); const texte = constats.length ? constats.map((c) => `- ${c.texte}`).join('\n') - : 'Rien a signaler : pas d\'EDF en heures pleines, pas de voiture bloquee pendant une session, batterie a son objectif en fin d\'heures creuses.'; + : 'Aucun des cas surveilles n\'est apparu : EDF en heures pleines, EDF demande mais absent, voiture bloquee pendant une session, ' + + 'voiture payee par la batterie, batterie sous son objectif en fin d\'heures creuses, recharge maximale bridee, intervention manuelle. ' + + 'Ce n\'est pas la preuve que tout s\'est bien passe : seuls ces cas sont verifies.'; return { constats, texte }; } @@ -284,6 +374,37 @@ export async function donneesRevue(client, fenetres, finMs, dureeMin = 24 * 60) (SELECT ts, valeur FROM mesure WHERE cle=$1 AND ts < $2 ORDER BY ts DESC LIMIT 1) UNION ALL (SELECT ts, valeur FROM mesure WHERE cle=$1 AND ts >= $2 AND ts < $3)) s ORDER BY ts`, [cle, d0, d1])) .map((r) => ({ ts: ms(r), v: Number(r.v) })); + // Etats discrets suivis par le journal (evenement), avec l'etat avant `debut`. + const etats = async (entite) => (await q(`SELECT ts, v FROM ( + (SELECT ts, detail->>'apres' v FROM evenement WHERE detail->>'entite'=$1 AND ts < $2 ORDER BY ts DESC LIMIT 1) + UNION ALL (SELECT ts, detail->>'apres' v FROM evenement WHERE detail->>'entite'=$1 AND ts >= $2 AND ts < $3)) s ORDER BY ts`, [entite, d0, d1])) + .map((r, i) => ({ ts: i === 0 && ms(r) < debut ? debut : ms(r), v: r.v })); + // Gestes manuels : contacteur manoeuvre hors du systeme (aucun auteur HA : + // levier ou appli), charge batterie manuelle armee, mode de nuit change + // autrement que par l'automatisation de 12:00. + const interventions = async () => { + // Contacteur : seulement un etat qui TIENT 5 min (la nuit du 12-13/10, les + // claquements sans auteur identifie ressemblaient sinon a des gestes). + const rows = await q(`SELECT ts, resume, detail FROM ( + SELECT ts, resume, detail, lead(ts) OVER (PARTITION BY detail->>'entite' ORDER BY ts) suivant + FROM evenement WHERE ts >= $1::timestamptz - interval '1 day' AND ts < $2::timestamptz + interval '1 day' AND detail->>'entite' IN + ('switch.edf_general_ok_switch','input_boolean.evcs_battery_manual_hc','input_select.victron_evcs_night_charge_mode')) e + WHERE ts >= $1 AND ts < $2 AND ( + (detail->>'entite'='switch.edf_general_ok_switch' AND detail->>'auteur' IS NULL AND NOT coalesce((detail->>'aller_retour')::boolean, false) + AND (suivant IS NULL OR suivant - ts >= interval '5 minutes')) + OR (detail->>'entite'='input_boolean.evcs_battery_manual_hc' AND detail->>'apres'='on') + OR (detail->>'entite'='input_select.victron_evcs_night_charge_mode' AND coalesce(detail->>'auteur_type','') <> 'automation' + -- remise automatique a « sans_edf » a 12:00 (evcs_charge_nuit_retour_sans_edf), pas toujours attribuee + AND NOT (detail->>'apres' = 'sans_edf' AND to_char(ts AT TIME ZONE 'Europe/Paris', 'HH24:MI') BETWEEN '11:59' AND '12:02'))) ORDER BY ts`, [d0, d1]); + return rows.map((r) => { + const e = r.detail?.entite, ap = r.detail?.apres; + const texte = e === 'switch.edf_general_ok_switch' + ? `contacteur EDF ${ap === 'on' ? 'ferme' : 'ouvert'} hors du systeme (levier ou appli)` + : e === 'input_boolean.evcs_battery_manual_hc' ? 'charge batterie manuelle en heures creuses armee' + : `mode de nuit change en « ${ap} »${r.detail?.auteur_nom ? ` (${r.detail.auteur_nom})` : ''}`; + return { ts: ms(r), texte }; + }); + }; const decisions = (await q(`SELECT * FROM ( (SELECT ts, profil, source_mode, etat, session_engagee, soc_pct, cible_matin_pct, raison FROM decision WHERE ts < $1 ORDER BY ts DESC LIMIT 1) UNION ALL (SELECT ts, profil, source_mode, etat, session_engagee, soc_pct, cible_matin_pct, raison FROM decision WHERE ts >= $1 AND ts < $2)) s ORDER BY ts`, [d0, d1])) @@ -303,5 +424,9 @@ export async function donneesRevue(client, fenetres, finMs, dureeMin = 24 * 60) .map((r) => ({ ts: ms(r), v: r.v })), soc: await serie('sensor.victron_battery_soc'), requis: await serie('input_number.evcs_balance_required_soc'), + batterieW: await serie('sensor.victron_battery_power'), + sessionNuit: await etats('input_boolean.victron_evcs_night_grid_session_committed'), + verrouHysteresis: await etats('input_boolean.victron_grid_hysteresis_latch'), + interventions: await interventions(), }; }