Le conseiller passe de planificateur a observateur
Idee du proprietaire, et elle est meilleure que la mienne : ce systeme n a pas besoin qu on lui suggere une strategie de plus, il a besoin que quelqu un VOIE que « je coupe EDF, non je remets, et hop je coupe » est absurde. Cinq automates ecrivent sur la meme installation, chacun localement correct, aucun ne voit les autres. Leurs pannes sont des CONTRADICTIONS, et elles ne se lisent que dans la SUITE des evenements — jamais dans un instantane. Le 12/09 au matin le contacteur reseau a bascule toutes les 4 minutes pendant dix minutes : une automatisation HA coupait a 35 % de SOC, l hysteresis rallumait. Aucun automate ne l a vu ; un humain oui. Sortie elargie : `constat`, `gravite` (aucune/faible/moyenne/haute) et `ce_qui_cloche`, valides strictement, publies dans trois helpers et dans le logbook. Le snapshot porte desormais l anciennete des cinq transitions cles (contacteur, verrou, takeover, etat EVCS, mode ACM). ⚠️ VALIDATION SCINDEE. Essai sur l oscillation reelle : le modele a produit un constat JUSTE et un `current_max_a` de 100, hors bornes. La validation tout-ou-rien jetait le diagnostic avec le champ fautif — c est-a-dire exactement ce qu on lui demande, perdu a cause de ce qu on ne lui demande plus. Les champs d ACTION gardent leurs bornes mais sont NEUTRALISES au lieu de faire echouer l observation ; `actionUtilisable` est le seul drapeau qui autoriserait un jour un cablage, et les compteurs ne comptent que lui. Tests advisor : +6. Deux attendus mis a la nouvelle semantique dans le meme commit (hors bornes -> observation gardee, action neutralisee). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
This commit is contained in:
@@ -34,10 +34,19 @@ setText('input_text.evcs_advisor_last_run', stampParis());
|
||||
setText('input_text.evcs_advisor_model', modelUsed);
|
||||
|
||||
if (status === 'ok' && advice) {
|
||||
// Observation — la sortie principale desormais.
|
||||
setText('input_text.evcs_advisor_constat', advice.constat);
|
||||
setText('input_text.evcs_advisor_gravite', advice.gravite);
|
||||
setText('input_text.evcs_advisor_ce_qui_cloche', advice.ceQuiCloche || 'rien de signale');
|
||||
|
||||
// Recommandations
|
||||
setText('input_text.evcs_advisor_strategy', advice.strategy);
|
||||
setNumber('input_number.evcs_advisor_night_target_soc', advice.nightTargetSoc);
|
||||
setNumber('input_number.evcs_advisor_current_max_a', advice.currentMaxA);
|
||||
// Les champs d action ne sont publies que s ils sont exploitables : un
|
||||
// helper numerique ne doit jamais porter une valeur hors bornes.
|
||||
if (advice.actionUtilisable) {
|
||||
setNumber('input_number.evcs_advisor_night_target_soc', advice.nightTargetSoc);
|
||||
setNumber('input_number.evcs_advisor_current_max_a', advice.currentMaxA);
|
||||
}
|
||||
setText('input_text.evcs_advisor_ev_charge_tonight', advice.evChargeTonight);
|
||||
setText('input_text.evcs_advisor_reason', `[${Math.round(advice.confidence * 100)}%] ${advice.reason}`);
|
||||
|
||||
@@ -63,7 +72,7 @@ if (status === 'ok' && advice) {
|
||||
// reference, pas contre la valeur appliquee qui peut elle-meme etre fausse.
|
||||
// Bilan indisponible => on ne compte rien plutot que de compter faux.
|
||||
const refSoc = Number(context.balanceRequiredSoc);
|
||||
const bilanUtilisable = context.balanceStatus === 'ok' && Number.isFinite(refSoc);
|
||||
const bilanUtilisable = advice.actionUtilisable && context.balanceStatus === 'ok' && Number.isFinite(refSoc);
|
||||
if (bilanUtilisable) {
|
||||
const ecart = Math.abs(advice.nightTargetSoc - refSoc);
|
||||
if (ecart <= 5) {
|
||||
@@ -81,7 +90,7 @@ if (status === 'ok' && advice) {
|
||||
// Trace logbook pour l'historique long terme
|
||||
push('logbook', 'log', {
|
||||
name: 'EVCS Advisor (ombre)',
|
||||
message: `${advice.strategy} | cible nuit ${advice.nightTargetSoc}% | ${advice.evChargeTonight === 'oui' ? 'charge VE cette nuit' : advice.evChargeTonight === 'non' ? 'pas de charge nuit' : 'VE sans objet'} | ${divergenceText} | ${advice.reason}`,
|
||||
message: `[${advice.gravite}] ${advice.constat}${advice.ceQuiCloche ? ` || ${advice.ceQuiCloche}` : ''} || ${advice.strategy} | cible nuit ${advice.nightTargetSoc}% | ${advice.evChargeTonight === 'oui' ? 'charge VE cette nuit' : advice.evChargeTonight === 'non' ? 'pas de charge nuit' : 'VE sans objet'} | ${divergenceText} | ${advice.reason}`,
|
||||
entity_id: 'input_text.evcs_advisor_strategy',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,6 +75,13 @@ const ctx = {
|
||||
bmsMaxChargeA: num(input.bms_max_charge_current_a, NaN),
|
||||
gridSetpointW: num(input.grid_setpoint_w, NaN),
|
||||
vehicleSelected: (input.vehicle_selected || 'inconnu').toString().trim(),
|
||||
// Historique recent des transitions, fourni par le snapshot. C est la SUITE
|
||||
// des evenements qui revele les contradictions entre automates ; un
|
||||
// instantane ne peut pas les montrer.
|
||||
evenements: String(input.evenements_recents || '')
|
||||
.split('|').map(x => x.trim()).filter(Boolean).slice(0, 12)
|
||||
.map(x => `- ${x}`),
|
||||
|
||||
mppt: (() => {
|
||||
const modes = String(input.mppt_operation_modes || '')
|
||||
.split(',').map(x => x.trim().toLowerCase())
|
||||
@@ -90,6 +97,23 @@ const ctx = {
|
||||
// Capacite REELLEMENT livree a la maison, deduite de la courbe mesuree le
|
||||
// 03/09 sur 576 paliers et du parc declare. Un US5000 compte pour DEUX
|
||||
// equivalents US2000C. Aucune constante : si le parc change, ceci suit.
|
||||
// GRANDEURS DERIVEES — on les CALCULE pour lui (12/09/2026, iteration 2).
|
||||
// Campagne de rejeu : sur le releve du 03/09 19:44 (PV 758 W, maison 1202 W)
|
||||
// il a recommande de charger la voiture a 28 A. Les deux nombres etaient dans
|
||||
// son prompt ; il n a pas fait la soustraction. Et sur celui du 05/09 20:40
|
||||
// (batterie a -97,4 A pour une limite declaree de 100 A) il a repondu « SOC
|
||||
// 79 % est suffisant » sans mentionner ni le courant ni la limite.
|
||||
// Un modele raisonne bien sur UN nombre, mal sur deux qu il doit combiner.
|
||||
// Meme principe que le bilan : on lui donne la grandeur, pas les ingredients.
|
||||
const maisonHorsVoitureW = Math.max(0, ctx.houseW - (ctx.evcsCharging ? ctx.evcsCurrentA * 230 : 0));
|
||||
const excedentSolaireW = Math.round(ctx.pvW - maisonHorsVoitureW);
|
||||
const margeDechargeA = Number.isFinite(ctx.bmsMaxDischargeA) && Number.isFinite(ctx.batteryCurrentA)
|
||||
? Math.round(ctx.bmsMaxDischargeA - Math.abs(Math.min(0, ctx.batteryCurrentA)))
|
||||
: null;
|
||||
const partDuPlafond = Number.isFinite(ctx.bmsMaxDischargeA) && ctx.bmsMaxDischargeA > 0
|
||||
? Math.round(100 * Math.abs(Math.min(0, ctx.batteryCurrentA)) / ctx.bmsMaxDischargeA)
|
||||
: null;
|
||||
|
||||
const kwhParPointHaut = 0.169 * ctx.modulesEnService / 7;
|
||||
const kwhParPointBas = 0.097 * ctx.modulesEnService / 7;
|
||||
const capaciteUtileKwh = (67 * kwhParPointHaut + 11 * kwhParPointBas) * 0.955;
|
||||
@@ -145,6 +169,19 @@ const systemPrompt = [
|
||||
'- L hiver la nuit a couvrir est plus longue de deux heures en temps solaire : la',
|
||||
' cible du matin y monte naturellement bien plus haut qu en ete.',
|
||||
'',
|
||||
'DEUX REGLES QUI PRIMENT SUR TOUT LE RESTE :',
|
||||
'1. EXCEDENT SOLAIRE. Charger un vehicule ne se decide pas sur la production',
|
||||
' mais sur ce qui RESTE une fois la maison servie. Cet excedent t est donne',
|
||||
' plus bas. S il est NEGATIF, charger la voiture revient a vider la batterie',
|
||||
' solaire : ne le fais pas, quelle que soit la prevision. S il est nul ou a',
|
||||
' peine positif ALORS QUE DES CHARGEURS SE BRIDENT, la ressource existe mais',
|
||||
' est refusee : la charger est gratuit.',
|
||||
'2. LIMITE DU PARC. La marge de decharge restante t est donnee plus bas. Si la',
|
||||
' batterie depasse 80 % de ce que le parc declare accepter, c est un sujet de',
|
||||
' PROTECTION DU MATERIEL, pas de confort : il faut appeler le reseau meme si',
|
||||
' le SOC parait confortable et meme hors heures creuses. Le proprietaire a un',
|
||||
' abonnement 12 kVA et prefere payer quelques kWh plutot que fragiliser le parc.',
|
||||
'',
|
||||
'STRATEGIES POSSIBLES :',
|
||||
'- auto_solar_plus : automatique, solaire prioritaire, reseau en heures creuses si besoin',
|
||||
'- auto_solar : solaire strict, jamais de reseau',
|
||||
@@ -152,11 +189,38 @@ const systemPrompt = [
|
||||
'- force_charge_now : charge locale immediate quelle que soit la source',
|
||||
'- stop : tout couper',
|
||||
'',
|
||||
'TA MISSION PRINCIPALE : REPERER CE QUI CLOCHE.',
|
||||
'Cinq automates ecrivent sur la meme installation : le controleur EVCS, un',
|
||||
'automate d hysteresis pour le contacteur reseau, un gestionnaire de charge',
|
||||
'batterie, un bilan energetique, et des automatisations Home Assistant. Chacun',
|
||||
'est localement correct et aucun ne voit les autres. Les pannes de ce systeme',
|
||||
'ne sont donc presque jamais des erreurs de calcul : ce sont des CONTRADICTIONS',
|
||||
'entre automates, et elles se voient dans la SUITE des evenements, pas dans',
|
||||
'un instantane.',
|
||||
'',
|
||||
'Exemple reel du 12/09 : le contacteur reseau a bascule toutes les 4 minutes',
|
||||
'pendant dix minutes — coupe, remis, coupe. Un automate coupait a 35 % de SOC,',
|
||||
'un autre rallumait. Chacun faisait son travail. Le resultat etait absurde, et',
|
||||
'seul un humain qui regardait l a vu.',
|
||||
'',
|
||||
'Un comportement est ABSURDE quand : un actionneur bascule plusieurs fois en',
|
||||
'peu de temps ; deux decisions se contredisent ; une grandeur reste figee alors',
|
||||
'que ce qui la pilote bouge ; une action est repetee sans effet ; une valeur',
|
||||
'contredit ce que le bilan demande.',
|
||||
'',
|
||||
'Tu as le DERNIER HISTORIQUE DES EVENEMENTS plus bas. Sers-t en.',
|
||||
'',
|
||||
'Reponds UNIQUEMENT avec un objet JSON valide, sans texte autour, au format EXACT :',
|
||||
'{"strategy":"auto_solar_plus|auto_solar|force_grid_now|force_charge_now|stop",',
|
||||
'{"constat":"ce que tu observes, factuel, max 200 caracteres",',
|
||||
'"gravite":"aucune|faible|moyenne|haute",',
|
||||
'"ce_qui_cloche":"la contradiction ou l incoherence, ou \'\' si rien",',
|
||||
'"strategy":"auto_solar_plus|auto_solar|force_grid_now|force_charge_now|stop",',
|
||||
'"night_target_soc":<entier 22-90>,"current_max_a":<entier 6-28>,',
|
||||
'"ev_charge_tonight":"oui|non|sans_objet","confidence":<0.0-1.0>,',
|
||||
'"reason":"une phrase courte en francais (max 200 caracteres)"}',
|
||||
'',
|
||||
'gravite "aucune" quand tout est coherent — c est le cas le plus frequent et',
|
||||
'il ne faut pas inventer un probleme pour justifier une reponse.',
|
||||
].join('\n');
|
||||
|
||||
function nombre(v, unite, defaut) {
|
||||
@@ -184,7 +248,9 @@ const userPrompt = [
|
||||
`- Batterie : SOC ${ctx.soc} %, courant ${ctx.batteryCurrentA} A (positif = charge)`,
|
||||
`- PV : ${Math.round(ctx.pvW)} W instantane, ${Math.round(ctx.pvAvg10mW)} W sur 10 min`,
|
||||
`- Chargeurs solaires : ${ctx.mppt.aFond}/${ctx.mppt.total} a pleine puissance, ${ctx.mppt.brides} qui se BRIDENT`,
|
||||
`- Maison : ${Math.round(ctx.houseW)} W`,
|
||||
`- Maison : ${Math.round(ctx.houseW)} W, dont voiture ${ctx.evcsCharging ? Math.round(ctx.evcsCurrentA * 230) : 0} W`,
|
||||
`- EXCEDENT SOLAIRE (production moins maison hors voiture) : ${excedentSolaireW} W`,
|
||||
`- MARGE DE DECHARGE du parc : ${margeDechargeA === null ? 'inconnue' : `${margeDechargeA} A restants`}${partDuPlafond === null ? '' : ` — la batterie est a ${partDuPlafond} % de son plafond`}`,
|
||||
`- Contacteur EDF : ${ctx.gridOn ? 'FERME' : 'ouvert'} (AC-IN ${ctx.acinAvailable ? 'present' : 'absent'})`,
|
||||
`- Consigne de puissance reseau : ${nombre(ctx.gridSetpointW, ' W')}`,
|
||||
`- Limites declarees par le parc : charge ${nombre(ctx.bmsMaxChargeA, ' A')}, decharge ${nombre(ctx.bmsMaxDischargeA, ' A')}`,
|
||||
@@ -196,6 +262,9 @@ const userPrompt = [
|
||||
'BILAN ENERGETIQUE :',
|
||||
...bilanLignes,
|
||||
'',
|
||||
'DERNIERS EVENEMENTS DU SYSTEME (le plus recent en premier) :',
|
||||
...(ctx.evenements.length ? ctx.evenements : ['- aucun evenement transmis']),
|
||||
'',
|
||||
'Quelle est ta recommandation ? JSON uniquement.',
|
||||
].join('\n');
|
||||
|
||||
|
||||
@@ -43,6 +43,14 @@
|
||||
| selectattr('entity_id','search','operation_mode')
|
||||
| map(attribute='state') | join(','),
|
||||
"vehicle_selected": states("input_select.victron_evcs_vehicle"),
|
||||
"evenements_recents": (
|
||||
[ "EDF " ~ states("switch.edf_general_ok_switch") ~ " depuis " ~ relative_time(states.switch.edf_general_ok_switch.last_changed),
|
||||
"verrou hysteresis " ~ states("input_boolean.victron_grid_hysteresis_latch") ~ " depuis " ~ relative_time(states.input_boolean.victron_grid_hysteresis_latch.last_changed),
|
||||
"takeover nuit " ~ states("input_boolean.victron_evcs_night_grid_takeover_needed") ~ " depuis " ~ relative_time(states.input_boolean.victron_evcs_night_grid_takeover_needed.last_changed),
|
||||
"etat EVCS " ~ states("input_text.victron_evcs_surplus_state") ~ " depuis " ~ relative_time(states.input_text.victron_evcs_surplus_state.last_changed),
|
||||
"mode ACM " ~ states("input_text.victron_charge_mode") ~ " depuis " ~ relative_time(states.input_text.victron_charge_mode.last_changed) ]
|
||||
| join("|")
|
||||
),
|
||||
|
||||
"user_strategy": states("input_select.victron_evcs_strategy"),
|
||||
"allow_grid_hc_night": states("input_boolean.victron_evcs_allow_grid_hc_night"),
|
||||
|
||||
@@ -55,15 +55,21 @@ try {
|
||||
const strategy = (obj.strategy ?? '').toString().trim().toLowerCase();
|
||||
if (!STRATEGIES.includes(strategy)) return fail('invalid', `strategy hors enum: ${strategy}`);
|
||||
|
||||
const nightTarget = Number(obj.night_target_soc);
|
||||
if (!Number.isFinite(nightTarget) || nightTarget < 22 || nightTarget > 90) {
|
||||
return fail('invalid', `night_target_soc hors bornes: ${obj.night_target_soc}`);
|
||||
}
|
||||
const nightTargetRaw = Number(obj.night_target_soc);
|
||||
const nightTargetValide = Number.isFinite(nightTargetRaw) && nightTargetRaw >= 22 && nightTargetRaw <= 90;
|
||||
const nightTarget = nightTargetValide ? nightTargetRaw : null;
|
||||
|
||||
const currentMax = Number(obj.current_max_a);
|
||||
if (!Number.isFinite(currentMax) || currentMax < 6 || currentMax > 28) {
|
||||
return fail('invalid', `current_max_a hors bornes: ${obj.current_max_a}`);
|
||||
}
|
||||
// ⚠️ L OBSERVATION NE DOIT PAS ETRE PERDUE A CAUSE D UN CHAMP D ACTION.
|
||||
// Essai du 12/09 : sur l oscillation du contacteur, le modele a produit un
|
||||
// constat JUSTE (« bascule toutes les 1-2 min depuis 06:25, gravite haute »)
|
||||
// ET un `current_max_a` de 100, hors bornes. Une validation tout-ou-rien
|
||||
// jetait le diagnostic avec le champ fautif — c est-a-dire exactement ce
|
||||
// qu on lui demande, perdu a cause de ce qu on ne lui demande plus.
|
||||
// Les champs d ACTION restent bornes aussi strictement qu avant ; seulement,
|
||||
// hors bornes, ils sont NEUTRALISES au lieu de faire echouer le constat.
|
||||
const currentMaxRaw = Number(obj.current_max_a);
|
||||
const currentMaxValide = Number.isFinite(currentMaxRaw) && currentMaxRaw >= 6 && currentMaxRaw <= 28;
|
||||
const currentMax = currentMaxValide ? currentMaxRaw : null;
|
||||
|
||||
const tonight = (obj.ev_charge_tonight ?? '').toString().trim().toLowerCase();
|
||||
if (!TONIGHT.includes(tonight)) return fail('invalid', `ev_charge_tonight hors enum: ${tonight}`);
|
||||
@@ -76,14 +82,34 @@ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
|
||||
const reason = (obj.reason ?? '').toString().trim();
|
||||
if (!reason) return fail('invalid', 'reason vide');
|
||||
|
||||
// ── OBSERVATION (12/09/2026) ───────────────────────────────────────────
|
||||
// La mission principale du conseiller n est plus de choisir une strategie
|
||||
// parmi cinq, mais de REPERER CE QUI CLOCHE. Les pannes de ce systeme sont
|
||||
// des contradictions entre automates — chacun localement correct — et elles
|
||||
// ne se voient que dans la suite des evenements. Le 12/09 le contacteur
|
||||
// reseau a bascule toutes les 4 minutes pendant dix minutes : aucun automate
|
||||
// ne l a vu, un humain oui.
|
||||
const GRAVITES = ['aucune', 'faible', 'moyenne', 'haute'];
|
||||
const constat = (obj.constat ?? '').toString().trim();
|
||||
if (!constat) return fail('invalid', 'constat vide');
|
||||
const gravite = (obj.gravite ?? '').toString().trim().toLowerCase();
|
||||
if (!GRAVITES.includes(gravite)) return fail('invalid', `gravite hors enum: ${obj.gravite}`);
|
||||
const ceQuiCloche = (obj.ce_qui_cloche ?? '').toString().trim();
|
||||
|
||||
return [{
|
||||
json: {
|
||||
status: 'ok',
|
||||
detail: '',
|
||||
advice: {
|
||||
constat: constat.slice(0, 200),
|
||||
gravite,
|
||||
ceQuiCloche: ceQuiCloche.slice(0, 200),
|
||||
strategy,
|
||||
nightTargetSoc: Math.round(nightTarget),
|
||||
currentMaxA: Math.round(currentMax),
|
||||
nightTargetSoc: nightTarget === null ? null : Math.round(nightTarget),
|
||||
currentMaxA: currentMax === null ? null : Math.round(currentMax),
|
||||
// Vrai seulement si TOUS les champs d action sont dans leurs bornes.
|
||||
// C est ce drapeau, et lui seul, qui autoriserait un jour un cablage.
|
||||
actionUtilisable: currentMaxValide && nightTargetValide,
|
||||
evChargeTonight: tonight,
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
reason: reason.slice(0, 250),
|
||||
|
||||
Reference in New Issue
Block a user