Incident 27-28/09 : l'agent presentait le surplus solaire de demain comme de l'energie batterie et lisait une projection de jour du controleur la nuit. Ce capteur ne relaie que le bilan (meme arbitre que la decision de nuit) ; bilan > 30 min -> indisponible. Tests 65/65. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
617 lines
32 KiB
JavaScript
617 lines
32 KiB
JavaScript
// =====================================================================
|
|
// 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, non corrige',
|
|
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');
|
|
const socRequis = nombre(st, 'input_number.evcs_balance_required_soc');
|
|
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 attributs = {
|
|
maison_kwh_a_couvrir: aCouvrir, heure_retour_solaire: retour,
|
|
reseau_necessaire: reseauNecessaire, reseau_necessaire_kwh: reseauNecessaire ? reseauKwh : 0,
|
|
soc_actuel_pct: soc, soc_requis_au_retour_solaire_pct: socRequis,
|
|
contacteur_edf: reseau, session_reseau_nuit_engagee: session,
|
|
voiture: branche === 'on' ? 'branchee' : branche === 'off' ? 'aucune' : 'inconnue',
|
|
bilan_calcule_il_y_a_min: age,
|
|
reserve_prevision: '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 edf = reseauNecessaire
|
|
? `Environ ${reseauKwh} kWh a prendre sur EDF avant le retour du solaire.`
|
|
: 'La batterie suffit, sans EDF, jusqu au retour du solaire.';
|
|
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}` +
|
|
`${session ? ' La session reseau de la nuit est engagee.' : ''}`;
|
|
return { etat: reseauNecessaire ? reseauKwh : 0, attributs: { ...attributs, statut: 'frais', explication: texte, texte_rapport: texte } };
|
|
}
|