// ===================================================================== // Diagnostics publies pour l'agent vocal OpenClaw — calcules ICI, pas par lui. // ===================================================================== // // POURQUOI (25/09/2026). Le banc PV d'OpenClaw a montre que la fiche de l'agent // est sur le fil du rasoir : 96 caracteres ajoutes dans le bloc CELLULE, sans // aucun rapport avec la substitution, ont fait tomber G5 (substitution EDF) de // 7/7 a 1/7. Une regle qu'un ajout sans rapport fait basculer cassera au // prochain ajout. On sort donc les deux jugements du modele : ils sont calcules // ici, testes (tests/diagnostics-agent/run.mjs), et l'agent n'a plus qu'a LIRE // un verdict et l'expliquer. // // Fonctions PURES (etats HA en entree, objet en sortie) : aucun appel reseau, // pour que les tests rejouent exactement ce qui tourne. // ===================================================================== // Les sept chargeurs lus par l'agent (MANUEL §0, HANDOFF §77). Le RS 450/200 // expose un mode d'appareil ET un mode par tracker : on les compte tous, comme // le banc. export const MPPT_MODES = [ 'sensor.airtria_mppt1_mppt_operation_mode', 'sensor.mppt_450_200_hq2449yr9pt_mppt_operation_mode', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_1_operation_mode', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_2_operation_mode', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_3_operation_mode', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_4_operation_mode', 'sensor.v1_mppt_operation_mode', ]; // Bruit de mesure du compteur reseau, PAS un reglage de decision : contacteur // ferme, l'ESS preleve quelques dizaines de watts meme a consigne nulle. En // dessous, « import ≈ 0 » au sens de la regle du 31/08. export const IMPORT_BRUIT_W = 100; // Sous cette puissance PV, il n'y a pas de production a discuter (nuit, aube). export const PV_NUIT_W = 50; function nombre(st, id) { const s = st.get(id); if (!s) return null; const v = Number(s.state); return Number.isFinite(v) ? v : null; } // --------------------------------------------------------------------- // G5 — bridage normal, vraie baisse, ou substitution par EDF ? // --------------------------------------------------------------------- // Regle du 31/08 (HANDOFF §26-§31, §77) : un chargeur qui se retient prouve // qu'il reste de la ressource refusee. Si le reseau n'importe rien, c'est le // bridage normal d'une installation sans injection. S'il importe, EDF fournit // ce que le solaire aurait pu donner : c'est la SUBSTITUTION, l'anomalie. // L'import se verifie AVANT de conclure au bridage, par construction : c'est // l'ordre des tests ci-dessous. export function diagnosticPv(st) { const pv = nombre(st, 'sensor.victron_pv_power_system'); const conso = nombre(st, 'sensor.victron_consumption_power_l1'); const reseau = nombre(st, 'sensor.victron_grid_power_l1'); // > 0 = import const soc = nombre(st, 'sensor.victron_battery_soc'); const courant = nombre(st, 'sensor.victron_battery_current'); // > 0 = charge const edf = st.get('switch.edf_general_ok_switch')?.state ?? null; const modes = MPPT_MODES .map((id) => String(st.get(id)?.state ?? '').trim().toLowerCase()) .filter((m) => m !== '' && m !== 'unavailable' && m !== 'unknown'); const limites = modes.filter((m) => m.includes('limited')).length; const actifs = modes.filter((m) => m === 'mppt_active').length; const importW = reseau === null ? null : Math.max(0, reseau); const attributs = { pv_w: pv, conso_w: conso, import_w: importW, soc_pct: soc, courant_batterie_a: courant, contacteur_edf: edf, mppt_lisibles: modes.length, mppt_bridés: limites, mppt_a_fond: actifs, part_reseau_pct: importW !== null && conso ? Math.round((100 * importW) / conso) : null, }; const verdict = (etat, explication) => ({ etat, explication, ...attributs }); if (pv === null || conso === null || reseau === null) { return verdict('indetermine', 'Mesure PV, consommation ou reseau indisponible : ne pas conclure.'); } if (limites === 0 && pv < PV_NUIT_W) { return verdict('pas_de_production', 'Pas de production solaire (nuit ou aube) : rien a diagnostiquer.'); } if (modes.length === 0) { return verdict('indetermine', 'Aucun mode de chargeur lisible : impossible de distinguer bridage et baisse.'); } if (limites > 0) { if (importW > IMPORT_BRUIT_W) { return verdict('substitution_edf', `ANOMALIE : ${limites} chargeur(s) bride(s) alors que le reseau importe ${Math.round(importW)} W ` + `— EDF fournit ce que le solaire pourrait donner. A signaler.`); } return verdict('bridage_normal', `${limites} chargeur(s) se retiennent et le reseau n'importe rien : bridage normal d'une ` + `installation sans injection. De la ressource solaire reste disponible pour une charge de plus.`); } if (importW > IMPORT_BRUIT_W) { return verdict('reseau_en_appoint', `Tous les chargeurs donnent leur maximum ; le reseau complete (${Math.round(importW)} W). ` + `Le solaire n'est pas gache.`); } return verdict('ressource_limitee', 'Tous les chargeurs donnent leur maximum : la production est limitee par le soleil, rien n\'est gache.'); } // --------------------------------------------------------------------- // G13b — detail cellule : une valeur perimee ne doit plus etre VISIBLE // --------------------------------------------------------------------- // Le releve serie tourne toutes les 10 min (deploy/releve-cellules/compose.yaml, // --suivi 600). Perime au-dela de 3 pas, comme la garde anti-blocage de // pylontech-console.mjs (§75) : au-dela, le lecteur est considere fige. // Perime => ni ecart ni module publies : l'agent ne peut pas citer ce qu'il ne // voit pas. export const RELEVE_PAS_MIN = 10; export const RELEVE_PERIME_MIN = 3 * RELEVE_PAS_MIN; export function etatCellules(r) { if (!r || r.age_min === null || r.age_min === undefined) { return { etat: 'muet', attributs: { statut: 'muet', explication: 'Aucun releve cellule en base.' } }; } const age = Number(r.age_min); if (age > RELEVE_PERIME_MIN) { return { etat: 'perime', attributs: { statut: 'perime', age_releve_min: age, seuil_perime_min: RELEVE_PERIME_MIN, explication: `Releve cellule muet depuis ${age} min : aucune valeur actuelle a donner.`, }, }; } return { etat: r.spread_max === null ? 'muet' : Number(r.spread_max), attributs: { statut: r.spread_max === null ? 'muet' : 'frais', age_releve_min: age, seuil_perime_min: RELEVE_PERIME_MIN, module_le_plus_sensible: r.weakest || null, // Repere pack x 10 + rang (11 = pack 1 module 1, 21 = pack 2 module 1). ...(r.weakest_repere ? { repere_le_plus_sensible: Number(r.weakest_repere) } : {}), // Nombre de modules dans le releve : 9 attendus quand les deux packs sont // lus (4 US5000 + 5 US2000C). Moins = un lecteur muet, l'ecart ne couvre // alors qu'une partie du parc. ...(r.modules_lus !== undefined ? { modules_lus: r.modules_lus } : {}), }, }; } // --------------------------------------------------------------------- // G1 + G9 — production PV reelle du jour (MANUEL §0) // --------------------------------------------------------------------- // Somme EXACTE des trois rendements journaliers de niveau appareil, jamais un // sous-tracker du 450/200 (deja inclus dans son total : double comptage). // // ⚠️ Piege releve le 25/09 : les trois chargeurs ne se remettent PAS a zero au // meme instant (00:37, 00:41 et 00:45 cette nuit-la). Entre minuit et leur remise // a zero, un rendement non nul date d'HIER : sa derniere modification est // anterieure a minuit. Il compte alors pour zero aujourd'hui (il n'y a de toute // facon pas de soleil a cette heure), avec une mention, plutot que de gonfler le // total du jour de celui de la veille. export const RENDEMENTS_JOUR = [ ['airtria_mppt1', 'sensor.airtria_mppt1_yield_today'], ['mppt_450_200', 'sensor.mppt_450_200_hq2449yr9pt_yield_today'], ['v1', 'sensor.v1_yield_today'], ]; export function minuitLocal(maintenant) { const jour = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris' }).format(maintenant); // Heure de Paris a minuit exprimee en UTC : on cherche le decalage du jour. const decalage = new Date(maintenant.toLocaleString('en-US', { timeZone: 'Europe/Paris' })) - new Date(maintenant.toLocaleString('en-US', { timeZone: 'UTC' })); return new Date(Date.parse(`${jour}T00:00:00Z`) - decalage); } export function productionJour(st, maintenant = new Date()) { const minuit = minuitLocal(maintenant); const detail = {}; const muets = []; const pasRemisAZero = []; let total = 0; for (const [nom, id] of RENDEMENTS_JOUR) { const s = st.get(id); const v = s ? Number(s.state) : NaN; if (!s || !Number.isFinite(v)) { muets.push(nom); detail[nom] = null; continue; } const change = s.last_changed ? new Date(s.last_changed) : null; if (v > 0 && change && change < minuit) { pasRemisAZero.push(nom); detail[nom] = 0; continue; } detail[nom] = Math.round(v * 100) / 100; total += v; } total = Math.round(total * 10) / 10; if (muets.length === RENDEMENTS_JOUR.length) { return { etat: 'indisponible', attributs: { statut: 'indisponible', chargeurs_muets: muets, explication: 'Aucun des trois chargeurs ne publie son rendement : production du jour inconnue.' } }; } const partiel = muets.length > 0; return { etat: total, attributs: { statut: partiel ? 'partiel' : 'complet', chargeurs_muets: muets, detail_kwh: detail, ...(pasRemisAZero.length ? { pas_encore_remis_a_zero: pasRemisAZero } : {}), explication: partiel ? `Production partielle : au moins ${total} kWh, ${muets.join(', ')} ne publie pas.` : `Production reelle du jour : ${total} kWh (somme des trois chargeurs).`, }, }; } // --------------------------------------------------------------------- // G6 + G7 — la voiture charge-t-elle sur le solaire ? RELAIS du controleur // --------------------------------------------------------------------- // ⚠️ Ce n'est PAS un second jugement. Le controleur EVCS (n8n, 30 s) decide seul, // avec des regles que l'agent ne connait pas : excedent >= 0 ET un chargeur qui // se retient pour la provocation (§37), plancher du soir tire du bilan (§11.8.E), // tolerance batterie (§11.8.C), collant « vehicule plein ». Un agent qui // recalculerait finirait par le contredire — les automates concurrents ont deja // coute cher (§26, §27). On publie donc SA decision, et en attributs de quoi // l'expliquer (excedent, chargeurs brides), sans decider. export const SEUIL_CHARGE_W = 300; // meme seuil que les sessions de charge // ⚠️ Correspondance EXPLICITE, libelle par libelle, de la table // `sourceModeLabel` de src/nodes/HA_SERVICE_CALLS_OUTPUT.js. Defaut du // 25/09 : la premiere version classait « tout ce qui n'est pas Reseau » comme // solaire, et a publie `en_charge_solaire` de 01:25 a 06:00 pendant que la JEEP // chargeait sur la BATTERIE en pleine nuit (« Assistance batterie nuit »). L'agent // l'a repete fidelement. Un libelle inconnu donne `en_charge` sans origine : // on ne devine jamais d'ou vient l'energie. export const ORIGINE_PAR_SOURCE = { 'Solaire': 'en_charge_solaire', 'Potentiel solaire': 'en_charge_solaire', 'Probe hybride': 'en_charge_sonde', 'Assistance batterie nuit': 'en_charge_batterie', 'Réseau HC autorisé': 'en_charge_reseau', 'Réseau forcé': 'en_charge_reseau', }; const PHRASE_ORIGINE = { en_charge_solaire: 'sur le solaire', en_charge_sonde: 'en essai court pour provoquer la production solaire', en_charge_batterie: 'sur la batterie de la maison (assistance de nuit)', en_charge_reseau: 'sur le reseau EDF', en_charge: 'depuis une source que le controleur ne precise pas', }; export function chargeSolaire(st) { const txt = (id) => { const s = st.get(id)?.state; return s === undefined || s === 'unavailable' || s === 'unknown' ? null : String(s); }; const branche = txt('binary_sensor.victron_evcs_vehicle_connected'); const puissance = nombre(st, 'sensor.victron_evcs_ac_power'); const source = txt('input_text.victron_evcs_source_mode'); const strategie = txt('input_select.victron_evcs_strategy'); const raisonLongue = txt('input_text.victron_evcs_surplus_reason'); const raison = raisonLongue ? raisonLongue.split(' | ')[0].trim() : null; const pv = diagnosticPv(st); const excedent = pv.pv_w !== null && pv.conso_w !== null ? Math.round(pv.pv_w - pv.conso_w) : null; const attributs = { source_controleur: source, strategie, etat_controleur: txt('input_text.victron_evcs_surplus_state'), decision_controleur: txt('input_text.victron_evcs_surplus_current_decision'), dernier_blocage: txt('input_text.victron_evcs_last_block_reason'), raison_controleur: raison, puissance_borne_w: puissance, excedent_w: excedent, mppt_bridés: pv.mppt_bridés, diagnostic_pv: pv.etat, }; const r = (etat, explication) => ({ etat, attributs: { ...attributs, explication } }); if (branche === null || puissance === null || source === null) { return r('indetermine', 'Etat de la borne ou du controleur illisible : ne pas conclure.'); } if (branche !== 'on') { return r('non_branchee', 'Aucun vehicule branche. Une fois branche, le controleur decidera lui-meme.'); } if (puissance > SEUIL_CHARGE_W) { const etat = ORIGINE_PAR_SOURCE[source] || 'en_charge'; return r(etat, `La voiture charge (${Math.round(puissance)} W) ${PHRASE_ORIGINE[etat]} ` + `(source decidee par le controleur : ${source}).`); } return r('en_attente', `Voiture branchee mais pas en charge. Le controleur dit : ${raison || 'sans raison publiee'} (source : ${source}).`); } // --------------------------------------------------------------------- // Bilan de la nuit — les donnees du rapport du matin, pour l'agent // --------------------------------------------------------------------- // Incident du 25/09 ~11:49 : a « bilan energetique de la nuit », l'agent, qui // n'avait de source que pour la session EN COURS (aucune a cette heure), a // invente « 0,56 kWh, zero reseau, arrivee a la cible de 35 %, arretee vers // 4 h ». Realite : JEEP 7,9 kWh dont 2,2 reseau, 01:25 -> 06:42. Les « 35 % » // etaient la cible de la BATTERIE MAISON ; la voiture n'a aucun capteur de SOC. // On publie donc ce que calcule tools/rapport-nuit.mjs (donneesNuit), le meme // code que le courriel de 07:30 : les deux ne peuvent pas diverger. // La nuit decrite va de 18:00 la veille a 10:00 ce matin ; avant 10:00 elle est // `en_cours` (fenetre plafonnee a maintenant, rien d'extrapole). const hParis = (t) => (t ? new Date(t).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }) : null); const r1 = (x) => (x === null || x === undefined || !Number.isFinite(Number(x)) ? null : Math.round(Number(x) * 10) / 10); export function bilanNuit(d, texte, maintenant = new Date()) { const enCours = maintenant < d.fin; const vehicules = (d.sessions || []).map((s) => ({ vehicule: s.vehicule ?? 'non identifie', debut: hParis(s.debut), fin: s.terminee ? hParis(s.fin) : null, terminee: !!s.terminee, energie_kwh: r1(s.energie_compteur_kwh ?? s.energie_kwh), dont_reseau_kwh: r1(s.energie_reseau_kwh), source_controleur: s.source_mode ?? null, })); return { etat: enCours ? 'en_cours' : 'complet', attributs: { nuit_du: d.dateLue, fenetre: `${hParis(d.debut)} la veille -> ${hParis(d.fin)}${enCours ? ' (en cours, arretee a maintenant)' : ''}`, reseau_fermetures: (d.fermetures || []).map((f) => hParis(f.ts)), reseau_importe_kwh: r1(d.e?.importe), voiture_totale_kwh: r1(d.e?.voiture), vehicules, batterie_maison_depart_pct: r1(d.b?.depart ?? d.b?.haut), batterie_maison_plus_bas_pct: r1(d.b?.bas), batterie_maison_heure_plus_bas: hParis(d.b?.heure_bas), batterie_maison_maintenant_pct: r1(d.b?.maintenant), soc_voiture: 'aucun capteur : ne jamais en annoncer un', texte_rapport: texte, }, }; } // --------------------------------------------------------------------- // Raccordement reseau — un 0 « fige » n'est PAS une panne quand EDF est ouvert // --------------------------------------------------------------------- // Incident du 26/09 : l'agent a annonce « capteur reseau bloque a 7h24 ». C'etait // la fin des heures creuses : contacteur ouvert, AC-IN non raccorde, import // reellement nul. HA ne date une valeur qu'a son CHANGEMENT : un 0 legitime garde // le last_changed de l'ouverture. L'age de la mesure seul donnerait donc une // fausse panne tous les matins. Le seul signal d'une vraie panne de mesure est la // CONTRADICTION commande/mesure (MANUEL §0) : contacteur commande ferme alors que // le MultiPlus ne voit pas le reseau, ou que la puissance ne publie plus. // // Fenetre d'etablissement apres fermeture : meme helper que le workflow // d'hysteresis (HANDOFF §29), 180 s par defaut. export const ETABLISSEMENT_DEFAUT_S = 180; // Contacteur ferme, la puissance reseau bouge en permanence (bruit de l'ESS a // consigne). Inchangee pendant ce delai => elle ne publie plus. Valeur // PROVISOIRE, a confronter a la cadence reelle de sensor.victron_grid_power_l1. export const MESURE_FIGEE_MIN = 10; function ageMin(s, maintenant) { const t = s?.last_changed ? Date.parse(s.last_changed) : NaN; return Number.isFinite(t) ? Math.round((maintenant - t) / 60000) : null; } function heureParis(iso) { if (!iso) return null; return new Date(iso).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }); } export function etatReseau(st, maintenant = new Date()) { const sw = st.get('switch.edf_general_ok_switch'); const contacteur = sw?.state ?? null; const acIn = nombre(st, 'sensor.victron_ac_in1_available'); // 1 = reseau vu const entree = st.get('sensor.airtria_mpii_v1_active_ac_input')?.state ?? null; const gp = st.get('sensor.victron_grid_power_l1'); const reseau = nombre(st, 'sensor.victron_grid_power_l1'); const etablissementS = nombre(st, 'input_number.evcs_grid_settle_seconds') ?? ETABLISSEMENT_DEFAUT_S; const depuisMin = ageMin(sw, maintenant); const mesureAgeMin = ageMin(gp, maintenant); const attributs = { contacteur_edf: contacteur, depuis: sw?.last_changed ?? null, depuis_heure: heureParis(sw?.last_changed), ac_in_disponible: acIn, entree_active: entree, import_w: reseau === null ? null : Math.max(0, Math.round(reseau)), mesure_age_min: mesureAgeMin, seuil_mesure_figee_min: MESURE_FIGEE_MIN, }; const verdict = (etat, explication, extra = {}) => ({ etat, attributs: { ...attributs, ...extra, explication } }); if (contacteur !== 'on' && contacteur !== 'off') { return verdict('indetermine', 'Etat du contacteur EDF inconnu : ne rien affirmer sur le reseau.'); } if (contacteur === 'off') { // Reseau vu ou import mesure alors que le contacteur est commande ouvert. if (acIn === 1 || (reseau !== null && reseau > IMPORT_BRUIT_W)) { return verdict('incoherent', `ANOMALIE : contacteur EDF commande ouvert, mais le MultiPlus voit le reseau ` + `(ac_in ${acIn}, ${attributs.import_w ?? '?'} W). A signaler.`); } return verdict('non_raccorde', `Reseau EDF non raccorde depuis ${attributs.depuis_heure ?? '?'} (contacteur ouvert) : ` + `import nul, ce n'est pas une panne de capteur.`, { import_w: 0 }); } // Contacteur ferme. if (depuisMin !== null && depuisMin * 60 < etablissementS) { return verdict('etablissement', `Contacteur EDF ferme a ${attributs.depuis_heure}, reseau en cours d'etablissement.`); } if (acIn === 0) { return verdict('mesure_muette', `ANOMALIE : contacteur EDF ferme depuis ${attributs.depuis_heure ?? '?'} mais le MultiPlus ` + `ne voit pas le reseau (coupure EDF ou defaut de lecture). Import inconnu.`, { import_w: null }); } if (reseau === null) { return verdict('mesure_muette', 'Contacteur EDF ferme mais la puissance reseau ne publie pas : import inconnu.', { import_w: null }); } if (mesureAgeMin !== null && mesureAgeMin > MESURE_FIGEE_MIN && (depuisMin === null || depuisMin > MESURE_FIGEE_MIN)) { return verdict('mesure_muette', `Contacteur EDF ferme mais la puissance reseau n'a pas bouge depuis ${mesureAgeMin} min : ` + `mesure figee, import inconnu.`, { import_w: null }); } return verdict('raccorde', `Reseau EDF raccorde depuis ${attributs.depuis_heure ?? '?'}, import ${attributs.import_w} W.`); } // --------------------------------------------------------------------- // Energie importee d'EDF depuis minuit — le chiffre « Depuis reseau » de VRM // --------------------------------------------------------------------- // Demande du proprietaire le 26/09 : l'agent vocal doit lui eviter d'ouvrir VRM. // La question « combien ai-je importe » est une QUANTITE ; la puissance // instantanee (victron_grid_power_l1) n'y repond pas, elle vaut 0 des que le // contacteur est ouvert. // // SOURCE QUI FAIT FOI : le compteur du contacteur EDF (Tuya, 0,01 kWh). Premier // jour en service (26/09 12:19) : il donnait 0,8 comme VRM, quand le delta des // compteurs AC-in du MultiPlus donnait 0,7 (0,6 + 0,1). Ces compteurs VE.Bus // semblent avancer par pas de 0,1 kWh : sur une matinee, deux arrondis de ce // pas font 12 %. Ils restent publies en recoupement, et servent de REPLI si le // compteur EDF est muet ou remis a zero (statut repli_victron). // ⚠️ Compteurs sans last_reset : une valeur courante INFERIEURE a celle de // minuit est une remise a zero, pas un import negatif. export const COMPTEURS_IMPORT = [ 'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out', 'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_inverter', ]; export const COMPTEUR_EDF = 'sensor.edf_general_ok_energie_totale'; function delta(c, m) { if (!Number.isFinite(c) || !Number.isFinite(m)) return { muet: true }; if (c < m) return { remis: true }; return { v: c - m }; } // courant, minuit : { [entity_id]: nombre | null } export function importJour(courant, minuit) { const edf = delta(courant[COMPTEUR_EDF], minuit[COMPTEUR_EDF]); // Recoupement Victron (et repli). const detail = {}; const muets = []; let remis = null; let victron = 0; for (const id of COMPTEURS_IMPORT) { const court = id.replace('sensor.airtria_mpii_v1_energy_from_ac_in_1_to_', 'vers_'); const d = delta(courant[id], minuit[id]); if (d.remis) { remis = court; detail[court] = null; continue; } if (d.muet) { muets.push(court); detail[court] = null; continue; } detail[court] = Math.round(d.v * 100) / 100; victron += d.v; } const victronOk = remis === null && muets.length === 0; const victronKwh = victronOk ? Math.round(victron * 10) / 10 : null; const commun = { detail_victron_kwh: detail, victron_kwh: victronKwh }; if (edf.v !== undefined) { const total = Math.round(edf.v * 10) / 10; return { etat: total, attributs: { statut: 'complet', source: 'compteur_edf', ...commun, explication: `${total} kWh importes d'EDF depuis minuit.` } }; } if (victronOk) { return { etat: victronKwh, attributs: { statut: 'repli_victron', source: 'compteurs_multiplus', ...commun, explication: `Environ ${victronKwh} kWh importes d'EDF depuis minuit (compteur EDF ` + `${edf.remis ? 'remis a zero' : 'muet'}, valeur des compteurs du MultiPlus, a 0,1 kWh pres).` } }; } return { etat: 'indisponible', attributs: { statut: 'indisponible', ...commun, explication: 'Compteurs d\'energie muets ou remis a zero : import du jour inconnu.' } }; } // --------------------------------------------------------------------- // Consommation du jour (« Consommation Total » de VRM), 26/09/2026 // --------------------------------------------------------------------- // Ce que la sortie AC du MultiPlus a delivre depuis minuit : maison ET borne // (la borne est cablee sur la sortie AC, comme dans VRM). Deux sources : // depuis le reseau (ac_in_1 -> ac_out) et depuis l'onduleur (inverter -> // ac_out, c'est-a-dire solaire + batterie). Verifie le 26/09 a 17:11 : // 0,6 + 24,6 = 25,2 kWh pour ~25 affiches par VRM a 17 h. Compteurs VE.Bus a // 0,1 kWh pres, sans last_reset : une valeur INFERIEURE a celle de minuit est // une remise a zero (redemarrage du MultiPlus), jamais une conso negative. export const COMPTEURS_CONSO = [ ['depuis_reseau', 'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out'], ['depuis_onduleur', 'sensor.airtria_mpii_v1_energy_from_inverter_to_ac_out'], ]; export function consoJour(courant, minuit) { const detail = {}; const muets = []; const remis = []; let total = 0; for (const [nom, id] of COMPTEURS_CONSO) { const d = delta(courant[id], minuit[id]); if (d.remis) { remis.push(nom); detail[nom] = null; continue; } if (d.muet) { muets.push(nom); detail[nom] = null; continue; } detail[nom] = Math.round(d.v * 10) / 10; total += d.v; } total = Math.round(total * 10) / 10; const commun = { detail_kwh: detail }; if (remis.length) { return { etat: 'indisponible', attributs: { statut: 'indisponible', ...commun, explication: 'Compteur du MultiPlus remis a zero depuis minuit : consommation du jour inconnue.' } }; } if (muets.length === COMPTEURS_CONSO.length) { return { etat: 'indisponible', attributs: { statut: 'indisponible', ...commun, explication: 'Compteurs de consommation muets : consommation du jour inconnue.' } }; } if (muets.length) { return { etat: total, attributs: { statut: 'partiel', compteurs_muets: muets, ...commun, explication: `Consommation partielle : au moins ${total} kWh depuis minuit (compteur ${muets.join(', ')} muet).` } }; } return { etat: total, attributs: { statut: 'complet', ...commun, explication: `${total} kWh consommes depuis minuit (maison et borne), dont ` + `${detail.depuis_reseau} depuis le reseau.` } }; } // --------------------------------------------------------------------- // Projection du SOC au coucher du soleil — VOLONTAIREMENT « indetermine » // --------------------------------------------------------------------- // 27/09/2026 15:56 : a « a combien sera la batterie quand le solaire declinera ? », // l'agent a lu le reste a produire (6,8 kWh) et le SOC (42 %) et a annonce « vers // 90 % » de sa tete — sans consommation, sans capacite, sans vehicule. // Un calcul honnete existe (profil de conso mesure + courbe energie/SOC du // bilan), mais sa donnee d'entree, la prevision Forecast.Solar, sous-estime de // ~63 % sur 7 jours (sensor.evcs_prevision_contre_reel). Decision du // proprietaire : publier « indetermine » tant que la prevision n'est pas // corrigee, plutot qu'un chiffre faux. Seuls des FAITS sont donnes en attributs. export function projectionSoir(st) { const soc = nombre(st, 'sensor.victron_battery_soc'); const reste = nombre(st, 'sensor.energy_production_today_remaining'); const ecart = nombre(st, 'sensor.evcs_prevision_contre_reel'); return { etat: 'indetermine', attributs: { statut: 'indetermine', soc_actuel_pct: soc, reste_solaire_prevu_kwh: reste, reste_solaire_source: 'Forecast.Solar (sensor.energy_production_today_remaining), non corrige', source_prevision_du_bilan: st.get('input_select.evcs_balance_forecast_source')?.state ?? null, ecart_prevision_7j_pct: ecart, explication: 'Je ne peux pas estimer la batterie de ce soir : la prevision solaire ' + `sous-estime la production${ecart !== null ? ` d'environ ${Math.round(ecart)} % ces sept derniers jours` : ''}, ` + `et tout chiffre serait faux.${soc !== null ? ` La batterie est a ${Math.round(soc)} % en ce moment.` : ''}`, }, }; } // --------------------------------------------------------------------- // Prevision de la nuit — symetrique de evcs_bilan_nuit (28/09/2026) // --------------------------------------------------------------------- // Nuit du 27 au 28/09 : a « prevision de consommation de cette nuit et combien // sur EDF ? », l'agent a assemble des helpers qui ne veulent pas dire ce qu'il // croyait : le surplus SOLAIRE de demain presente comme energie batterie pour la // voiture, une projection de jour du controleur lue la nuit, et un contacteur // annonce ferme alors qu'il etait ouvert. Ce capteur ne relaie QUE le bilan // energetique (meme arbitre que la decision de nuit) et des etats lus. // Jamais projected_end_soc_after_charge ni budget_remaining (internes, de jour). export const BILAN_FRAIS_MIN = 30; // six cycles du bilan, comme la garde du soir (§11.8.E) function ageBilanMin(texte, maintenant) { const m = /^(\d{2})\/(\d{2}) (\d{2}):(\d{2})$/.exec(String(texte || '').trim()); if (!m) return null; const an = Number(new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris', year: 'numeric' }).format(maintenant)); // Heure de Paris -> instant : on passe par le decalage du jour. const iso = `${an}-${m[2]}-${m[1]}T${m[3]}:${m[4]}:00`; const decalage = new Date(maintenant.toLocaleString('en-US', { timeZone: 'Europe/Paris' })) - new Date(maintenant.toLocaleString('en-US', { timeZone: 'UTC' })); const t = Date.parse(iso + 'Z') - decalage; return Math.round((maintenant - t) / 60000); } export function previsionNuit(st, maintenant = new Date()) { const txt = (id) => { const s = st.get(id)?.state; return s === undefined || s === 'unavailable' || s === 'unknown' ? null : String(s); }; const statut = txt('input_text.evcs_balance_status'); const age = ageBilanMin(txt('input_text.evcs_balance_last_run'), maintenant); const aCouvrir = nombre(st, 'input_number.evcs_balance_energy_to_cover_kwh'); const reseauKwh = nombre(st, 'input_number.evcs_balance_grid_energy_needed_kwh'); const reseauNecessaire = txt('input_boolean.evcs_balance_grid_needed') === 'on'; const retour = txt('input_text.evcs_balance_crossing_hour'); // ⚠️ SOC REQUIS MAINTENANT, pas un SOC projete du matin (incident 28/09 21:16 : // l'agent a lu 57,5 comme « la batterie arrivera a 57-58 % au retour du // soleil »). Le bilan ne projette AUCUN SOC du matin ; ce capteur non plus. const socRequis = nombre(st, 'input_number.evcs_balance_required_soc'); const socPrudent = nombre(st, 'input_number.evcs_balance_required_soc_prudent'); const soc = nombre(st, 'sensor.victron_battery_soc'); const reseau = txt('sensor.evcs_reseau'); const session = txt('input_boolean.victron_evcs_night_grid_session_committed') === 'on'; const branche = txt('binary_sensor.victron_evcs_vehicle_connected'); const hcDebut = (txt('input_datetime.victron_evcs_hc_night_start') || '').slice(0, 5) || null; const hcFin = (txt('input_datetime.victron_evcs_hc_night_stop') || '').slice(0, 5) || null; // CE QUI SERA ACHETE, pas le manque de la batterie (incident 28/09 21:32 : // « 0,14 kWh a prendre sur EDF » annonces, 4,6 importes). Une session engagee // tient la batterie et fait passer TOUTE la maison sur le reseau jusqu'a la // fin des heures creuses : achat = maison sur la fenetre (calculee par le // bilan) + manque de la batterie. 0 publie = fenetre inconnue -> null. const maisonHc = nombre(st, 'input_number.evcs_balance_night_hc_house_kwh'); const maisonHcKwh = maisonHc !== null && maisonHc > 0 ? maisonHc : null; const manqueKwh = reseauNecessaire && reseauKwh !== null ? reseauKwh : 0; const importPrevu = maisonHcKwh !== null ? Math.round((maisonHcKwh + manqueKwh) * 10) / 10 : null; const marge = soc !== null && socRequis !== null ? Math.round((soc - socRequis) * 10) / 10 : null; // TROIS verdicts, pas deux (incident 28/09 21:16 : « la batterie suffit, sans // EDF » avec 0,5 point de marge sur le profil HABITUEL, alors que le profil // prudent demandait 68,5 % pour un SOC de 58 %). Le profil p50 est une // mediane : a zero marge, c'est pile ou face. La decision reelle est reprise // a l'ouverture des heures creuses, sur l'exigence recalculee a ce moment-la. const verdict = session ? 'edf_engage' : reseauNecessaire ? 'reseau_necessaire' : socPrudent !== null && soc !== null && soc >= socPrudent ? 'batterie_suffit' : 'juste'; const attributs = { maison_kwh_a_couvrir: aCouvrir, heure_retour_solaire: retour, reseau_necessaire: reseauNecessaire, // ⚠️ Plus de `reseau_necessaire_kwh` (29/09) : c'etait le manque de la // batterie, lu comme ce qui serait achete. Deux grandeurs, deux noms. import_prevu_kwh: importPrevu, maison_en_heures_creuses_kwh: maisonHcKwh, manque_batterie_kwh: manqueKwh, verdict, soc_actuel_pct: soc, soc_requis_maintenant_pct: socRequis, soc_requis_prudent_maintenant_pct: socPrudent, marge_points: marge, debut_heures_creuses: hcDebut, fin_heures_creuses: hcFin, contacteur_edf: reseau, session_reseau_nuit_engagee: session, voiture: branche === 'on' ? 'branchee' : branche === 'off' ? 'aucune' : 'inconnue', bilan_calcule_il_y_a_min: age, source_prevision: st.get('input_select.evcs_balance_forecast_source')?.state ?? null, reserve_prevision: st.get('input_select.evcs_balance_forecast_source')?.state === 'vrm' ? 'la production du matin vient de la prevision VRM, en essai depuis le 28/09 : l heure de retour du solaire reste une estimation' : 'la production du matin vient de Forecast.Solar, qui sous-estime ces jours-ci : l heure de retour du solaire peut etre plus precoce', }; if (statut !== 'ok' || age === null || age > BILAN_FRAIS_MIN || aCouvrir === null || retour === null) { return { etat: 'indisponible', attributs: { ...attributs, statut: 'indisponible', explication: 'Le bilan energetique n est pas a jour : prevision de la nuit indisponible.' } }; } const voiture = attributs.voiture === 'aucune' ? 'Aucun vehicule branche.' : attributs.voiture === 'branchee' ? 'Un vehicule est branche : sa charge s ajoute et sera decidee par le controleur.' : 'Etat du vehicule inconnu.'; const hc = hcDebut ? `a ${hcDebut}` : 'a l ouverture des heures creuses'; const kwh = (x) => String(x).replace('.', ','); const jusqua = hcFin ? `jusqu a ${hcFin}` : 'jusqu a la fin des heures creuses'; const plage = hcDebut && hcFin ? `de ${hcDebut} a ${hcFin}` : 'en heures creuses'; const detail = maisonHcKwh === null ? '' : manqueKwh > 0 ? ` (maison ${kwh(maisonHcKwh)}, batterie ${kwh(manqueKwh)})` : ''; const achat = importPrevu !== null ? `environ ${kwh(importPrevu)} kWh achetes en heures creuses${detail}, car la maison passe la nuit sur le reseau` : manqueKwh > 0 ? `au moins ${kwh(manqueKwh)} kWh achetes en heures creuses, plus la consommation de la maison` : null; const edf = { edf_engage: `EDF est branche pour la nuit, ${jusqua}${achat ? ` : ${achat}` : ''} ; la batterie est tenue jusqu au matin.`, reseau_necessaire: `EDF sera branche ${plage}${achat ? ` : ${achat}` : ''}.`, batterie_suffit: 'La batterie suffit jusqu au retour du solaire, meme si la maison consomme plus que d habitude : EDF ne devrait pas etre necessaire.', juste: `C est juste : la batterie suffit seulement si la maison consomme comme d habitude${marge !== null ? ` (marge ${kwh(marge)} point${Math.abs(marge) >= 2 ? 's' : ''})` : ''}. ` + `Le systeme refait le calcul ${hc} : si la soiree a consomme davantage, il prendra EDF en heures creuses` + `${maisonHcKwh !== null ? `, et la maison y achetera alors environ ${kwh(Math.round(maisonHcKwh * 10) / 10)} kWh` : ''}.`, }[verdict]; // LE JOUR (29/09, remarque de SPARK) : plus rien a couvrir avant le retour // du solaire = le solaire couvre deja la maison. Le texte « la maison doit // couvrir 0 kWh jusqu au retour du solaire vers 10 h » parlait alors de la // nuit ECOULEE, et une question « et cette nuit ? » posee a midi recevait // une reponse fausse. La vraie prevision se fait le soir, sur la soiree // reelle (facteur de nuit) et la decision de 01:24. Le jour, on le dit, avec // un repere provisoire : la prochaine traversee du bilan (profil PRUDENT). if (aCouvrir <= 0 && !session) { const fen = txt('input_text.evcs_balance_next_deficit_window'); const m = fen ? fen.match(/^(\d{2}) h → (\d{2}) h/) : null; const ndSoc = nombre(st, 'input_number.evcs_balance_next_deficit_required_soc'); const ndKwh = nombre(st, 'input_number.evcs_balance_next_deficit_kwh'); const repere = m && ndSoc !== null && ndKwh !== null ? ` Repere provisoire, profil prudent : pour passer de ${m[1]} h a ${m[2]} h sans EDF (environ ${kwh(ndKwh)} kWh), ` + `il faudrait la batterie a ${kwh(Math.round(ndSoc))} % a ${m[1]} h.` : ''; const achatSi = maisonHcKwh !== null ? ` Si EDF s engage ${hc}, la maison y achetera environ ${kwh(Math.round(maisonHcKwh * 10) / 10)} kWh ${jusqua}.` : ''; const texteJour = `Il est trop tot pour la prevision de la nuit : elle se calcule le soir, sur la consommation reelle ` + `de la soiree, et la decision d EDF se prend ${hc}.${repere}${achatSi} Batterie a ${soc !== null ? Math.round(soc) : '?'} % maintenant.`; return { etat: 'jour', attributs: { ...attributs, verdict: 'jour', statut: 'jour', prochaine_traversee: m ? `${m[1]} h -> ${m[2]} h` : null, prochaine_traversee_soc_requis_pct: m ? ndSoc : null, prochaine_traversee_kwh: m ? ndKwh : null, explication: texteJour, texte_rapport: texteJour } }; } const texte = `La maison doit couvrir environ ${aCouvrir} kWh jusqu au retour du solaire vers ${retour}. ` + `${edf} Batterie a ${soc !== null ? Math.round(soc) : '?'} %. ${voiture}`; const etat = verdict === 'edf_engage' || verdict === 'reseau_necessaire' ? (importPrevu ?? manqueKwh) : 0; return { etat, attributs: { ...attributs, statut: 'frais', explication: texte, texte_rapport: texte } }; } // --------------------------------------------------------------------- // Prevision VRM -> courbes horaires au format du bilan (28/09/2026) // --------------------------------------------------------------------- // VRM (API v2, stats?type=forecast&interval=hours) renvoie des paires // [ms UTC debut d'heure, Wh]. Le bilan attend, par jour DE PARIS, 24 valeurs en // kWh (index 0 = 00 h locale), une date AAAA-MM-JJ et un horodatage // « JJ/MM HH:MM » : exactement ce que produit la chaine Forecast.Solar, pour // que le bilan lise les deux sources indifferemment. const jourParis = (ms) => new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }); const heureNumParis = (ms) => Number(new Date(ms).toLocaleString('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false })); export function vrmVersCourbes(paires, maintenant = new Date()) { const jours = new Map(); for (const [ms, wh] of paires || []) { const v = Number(wh); if (!Number.isFinite(Number(ms)) || !Number.isFinite(v)) continue; const j = jourParis(Number(ms)); if (!jours.has(j)) jours.set(j, new Array(24).fill(0)); // Heure d'hiver/ete : l'heure en double du 25 octobre s'ajoute, jamais ne s'ecrase. jours.get(j)[heureNumParis(Number(ms)) % 24] += Math.max(0, v) / 1000; } const auj = jourParis(maintenant.getTime()); const dem = jourParis(maintenant.getTime() + 86400000); const csv = (a) => a.map((x) => (Math.round(x * 100) / 100).toFixed(2)).join(','); const total = (a) => Math.round(a.reduce((s, x) => s + x, 0) * 10) / 10; const tA = jours.get(auj), tD = jours.get(dem); const d = new Date(maintenant.toLocaleString('en-US', { timeZone: 'Europe/Paris' })); const p2 = (n) => String(n).padStart(2, '0'); return { ok: !!tA, day0: auj, today: tA ? { csv: csv(tA), totalKwh: total(tA), horaire: tA.map((x) => Math.round(x * 100) / 100) } : null, tomorrow: tD ? { csv: csv(tD), totalKwh: total(tD), horaire: tD.map((x) => Math.round(x * 100) / 100) } : null, updated: `${p2(d.getDate())}/${p2(d.getMonth() + 1)} ${p2(d.getHours())}:${p2(d.getMinutes())}`, }; } // --------------------------------------------------------------------- // La nuit de la maison, prevue par la soiree (28/09/2026) // --------------------------------------------------------------------- // Demande du proprietaire : « si tu superposes 20 nuits tu dois trouver un // nuage de points ». Sur 24 nuits (04/09 -> 27/09), l'energie consommee par la // maison de 21:30 a 09:00 (voiture exclue) suit celle de la soiree 18:00-21:30 : // nuit = 6,98 + 0,74 x soiree, r2 = 0,40. Une soiree chargee (four) annonce une // nuit chargee ; le profil horaire MOYEN du bilan ne le voit pas. // // Ce qui est publie n'est PAS un kWh a substituer au bilan : c'est un FACTEUR, // nuit prevue / nuit moyenne des MEMES donnees. Le profil du bilan vient des // compteurs d'energie, la regression des puissances integrees : le rapport // annule l'ecart de methode, que la difference ne ferait qu'ajouter. // // Garde-fous : au moins 15 nuits et r2 >= 0,2, sinon le modele ne vaut rien et // on le dit (`indisponible`). Hors 21:30 -> 09:00, pas de soiree complete a // lire : `hors_fenetre`. export const NUIT_MIN_NUITS = 15; export const NUIT_MIN_R2 = 0.2; function heureLocaleMs(jour, hhmm, reference) { // Meme calcul de decalage que minuitLocal, pour une heure de Paris donnee. const decalage = new Date(reference.toLocaleString('en-US', { timeZone: 'Europe/Paris' })) - new Date(reference.toLocaleString('en-US', { timeZone: 'UTC' })); return Date.parse(`${jour}T${hhmm}:00Z`) - decalage; } export function nuitMaisonPrevue(nuits, soireeKwh, maintenant = new Date()) { const jourDe = (d) => new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris' }).format(d); const hm = Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false }) .format(maintenant).replace(':', '')); const aujourdhui = jourDe(maintenant); const hier = jourDe(new Date(maintenant.getTime() - 86400000)); const demain = jourDe(new Date(maintenant.getTime() + 86400000)); const fenetre = hm >= 2130 ? { soir: aujourdhui, matin: demain } : hm < 900 ? { soir: hier, matin: aujourdhui } : null; const pts = (nuits || []).map((x) => [Number(x.soiree), Number(x.nuit)]).filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b)); const n = pts.length; let modele = null; if (n >= 2) { const mx = pts.reduce((s, p) => s + p[0], 0) / n, my = pts.reduce((s, p) => s + p[1], 0) / n; let sxy = 0, sxx = 0, syy = 0; for (const [x, y] of pts) { sxy += (x - mx) * (y - my); sxx += (x - mx) ** 2; syy += (y - my) ** 2; } if (sxx > 0 && syy > 0) { const b = sxy / sxx; modele = { a: my - b * mx, b, r2: (sxy * sxy) / (sxx * syy), moyenne: my }; } } const r2 = (v) => Math.round(v * 100) / 100; const attributs = { nuits: n, soiree_kwh: Number.isFinite(soireeKwh) ? r2(soireeKwh) : null, a: modele ? r2(modele.a) : null, b: modele ? Math.round(modele.b * 1000) / 1000 : null, r2: modele ? r2(modele.r2) : null, nuit_moyenne_kwh: modele ? r2(modele.moyenne) : null, debut_ms: fenetre ? heureLocaleMs(fenetre.soir, '21:30', maintenant) : null, fin_ms: fenetre ? heureLocaleMs(fenetre.matin, '09:00', maintenant) : null, }; if (!fenetre) return { etat: 'hors_fenetre', attributs: { ...attributs, facteur: null, statut: 'hors_fenetre' } }; if (!modele || n < NUIT_MIN_NUITS || modele.r2 < NUIT_MIN_R2 || !Number.isFinite(soireeKwh)) { return { etat: 'indisponible', attributs: { ...attributs, facteur: null, statut: 'indisponible', explication: !Number.isFinite(soireeKwh) ? 'soiree illisible' : n < NUIT_MIN_NUITS ? `seulement ${n} nuits (${NUIT_MIN_NUITS} requises)` : `modele trop faible (r2 ${modele ? r2(modele.r2) : '?'} < ${NUIT_MIN_R2})` } }; } const prevue = modele.a + modele.b * soireeKwh; return { etat: r2(prevue), attributs: { ...attributs, facteur: Math.round((prevue / modele.moyenne) * 1000) / 1000, statut: 'ok', explication: `soiree ${r2(soireeKwh)} kWh -> nuit prevue ${r2(prevue)} kWh (moyenne ${r2(modele.moyenne)}, ${n} nuits, r2 ${r2(modele.r2)})` } }; }