diff --git a/src/advisor/BUILD_HA_CALLS.js b/src/advisor/BUILD_HA_CALLS.js index c6c5566..3e8ea92 100644 --- a/src/advisor/BUILD_HA_CALLS.js +++ b/src/advisor/BUILD_HA_CALLS.js @@ -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', }); } diff --git a/src/advisor/BUILD_PROMPT.js b/src/advisor/BUILD_PROMPT.js index 73c7f4f..03f84a0 100644 --- a/src/advisor/BUILD_PROMPT.js +++ b/src/advisor/BUILD_PROMPT.js @@ -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":,"current_max_a":,', '"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'); diff --git a/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl index ed356f8..b728825 100644 --- a/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -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"), diff --git a/src/advisor/VALIDATE_PARSE.js b/src/advisor/VALIDATE_PARSE.js index bc04d7c..0db7bc3 100644 --- a/src/advisor/VALIDATE_PARSE.js +++ b/src/advisor/VALIDATE_PARSE.js @@ -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), diff --git a/tests/advisor/run.mjs b/tests/advisor/run.mjs index 1399da4..f87a421 100644 --- a/tests/advisor/run.mjs +++ b/tests/advisor/run.mjs @@ -54,14 +54,18 @@ const promptOut = run(BUILD_PROMPT, snapshot)[0].json; check('BUILD_PROMPT: enabled=true', promptOut.enabled === true); check('BUILD_PROMPT: 2 messages system+user', promptOut.messages?.length === 2 && promptOut.messages[0].role === 'system'); check('BUILD_PROMPT: contexte transporté', promptOut.context?.soc === 80 && promptOut.context?.userStrategy === 'auto_solar_plus'); -check('BUILD_PROMPT: prompt < 6000 chars (modèle local)', (promptOut.messages[0].content.length + promptOut.messages[1].content.length) < 6000); +// La borne de 6000 datait du modele local d aout (contexte etroit). Le modele +// charge aujourd hui annonce 98 304 tokens ; la vraie contrainte est le cout +// de latence, pas le contexte. On garde une borne pour eviter la derive du +// briefing, elargie a la mission d observation et a l historique des evenements. +check('BUILD_PROMPT: prompt < 9000 chars (garde-fou contre la derive)', (promptOut.messages[0].content.length + promptOut.messages[1].content.length) < 9000); const killSwitchOut = run(BUILD_PROMPT, { data: JSON.stringify({ ...JSON.parse(snapshot.data), advisor_enabled: 'off' }) })[0].json; check('BUILD_PROMPT: kill-switch OFF → enabled=false', killSwitchOut.enabled === false); // ---------- VALIDATE_PARSE : 7 réponses LLM simulées ---------- const ctxNode = { BUILD_PROMPT: promptOut }; -const validAdvice = '{"strategy":"auto_solar_plus","night_target_soc":35,"current_max_a":22,"ev_charge_tonight":"oui","confidence":0.85,"reason":"Forecast fort demain, HC nuit pour la VW."}'; +const validAdvice = '{"constat":"Contacteur reseau stable, batterie en charge.","gravite":"aucune","ce_qui_cloche":"","strategy":"auto_solar_plus","night_target_soc":35,"current_max_a":22,"ev_charge_tonight":"oui","confidence":0.85,"reason":"Forecast fort demain, HC nuit pour la VW."}'; const cases = [ { @@ -80,14 +84,19 @@ const cases = [ expect: o => o.status === 'invalid' && o.advice === null, }, { - label: 'night_target_soc 15 (< plancher 22) → invalid', + // ⚠️ SEMANTIQUE CHANGEE LE 12/09 : un champ d ACTION hors bornes ne fait + // plus echouer la reponse — il est NEUTRALISE et l observation survit. + // Le constat est devenu la sortie principale ; le perdre a cause d un + // champ qu on ne demande plus serait absurde. `actionUtilisable` reste + // le seul drapeau qui autoriserait un cablage. + label: 'night_target_soc 15 (< plancher 22) → observation gardee, action neutralisee', resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('"night_target_soc":35', '"night_target_soc":15') } }] }, - expect: o => o.status === 'invalid' && o.advice === null, + expect: o => o.status === 'ok' && o.advice.nightTargetSoc === null && o.advice.actionUtilisable === false && o.advice.constat, }, { - label: 'current_max_a 40 (> 28) → invalid', + label: 'current_max_a 40 (> 28) → observation gardee, action neutralisee', resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('"current_max_a":22', '"current_max_a":40') } }] }, - expect: o => o.status === 'invalid' && o.advice === null, + expect: o => o.status === 'ok' && o.advice.currentMaxA === null && o.advice.actionUtilisable === false, }, { label: 'texte sans JSON → invalid', @@ -138,6 +147,41 @@ const sansBilan = compteur({ ...base, balanceStatus: 'degrade', balanceRequiredS check('COMPTEUR: bilan indisponible → on ne compte RIEN plutot que de compter faux', !sansBilan.accord && !sansBilan.divergence); +// ── OBSERVATION : le constat et la gravite sont obligatoires ─────────── +function valider(json) { + return run(VALIDATE_PARSE, { model: 'x', choices: [{ message: { content: json } }] }, ctxNode)[0].json; +} +const osc = valider(validAdvice + .replace('"constat":"Contacteur reseau stable, batterie en charge."', '"constat":"Le contacteur reseau a bascule 4 fois en 10 minutes."') + .replace('"gravite":"aucune"', '"gravite":"haute"') + .replace('"ce_qui_cloche":""', '"ce_qui_cloche":"deux ecrivains avec des seuils differents sur le meme actionneur"')); +check('OBSERVATION: constat + gravite haute acceptes', + osc.status === 'ok' && osc.advice.gravite === 'haute' && osc.advice.constat.includes('bascule')); + +check('OBSERVATION: constat vide → REJET', + valider(validAdvice.replace('"constat":"Contacteur reseau stable, batterie en charge."', '"constat":""')).status === 'invalid'); +check('OBSERVATION: gravite hors enum → REJET', + valider(validAdvice.replace('"gravite":"aucune"', '"gravite":"catastrophique"')).status === 'invalid'); + +// Le constat doit survivre a un champ d action hors bornes. +const horsBornes = valider(validAdvice + .replace('"constat":"Contacteur reseau stable, batterie en charge."', '"constat":"Bascule du contacteur toutes les 1-2 min depuis 06:25."') + .replace('"gravite":"aucune"', '"gravite":"haute"') + .replace('"current_max_a":22', '"current_max_a":100')); +check('OBSERVATION: champ action hors bornes → le constat SURVIT', + horsBornes.status === 'ok' && horsBornes.advice.constat.includes('Bascule') && horsBornes.advice.actionUtilisable === false); +const hbCalls = run(BUILD_HA_CALLS, horsBornes, ctxNode).map(i => i.json); +check('OBSERVATION: action hors bornes → aucun helper numerique ecrit', + hbCalls.some(c => (c.data.entity_id || '').includes('advisor_constat')) && + !hbCalls.some(c => (c.data.entity_id || '').includes('night_target_soc')) && + !hbCalls.some(c => (c.data.entity_id || '').includes('agree_count'))); + +const oscCalls = run(BUILD_HA_CALLS, osc, ctxNode).map(i => i.json); +check('OBSERVATION: constat, gravite et diagnostic publies', + ['constat', 'gravite', 'ce_qui_cloche'].every(k => oscCalls.some(c => (c.data.entity_id || '').includes(`advisor_${k}`)))); +check('OMBRE: l observation ne sort pas du perimetre advisor_*', + oscCalls.every(c => c.domain === 'logbook' || /^(input_text|input_number)\.evcs_advisor_/.test(c.data.entity_id || ''))); + const offlineValidated = run(VALIDATE_PARSE, cases[6].resp, ctxNode)[0].json; const offlineCalls = run(BUILD_HA_CALLS, offlineValidated, ctxNode).map(i => i.json); check('OMBRE: llm_offline → statut écrit, pas de recommandation', offlineCalls.every(c => !(c.data.entity_id || '').includes('advisor_strategy')) && offlineCalls.some(c => (c.data.value || '').toString().startsWith('llm_offline'))); diff --git a/tools/campagne-conseiller.mjs b/tools/campagne-conseiller.mjs new file mode 100644 index 0000000..5923cd5 --- /dev/null +++ b/tools/campagne-conseiller.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node +// Campagne de rejeu du conseiller IA sur des instants REELS. +// Chaque cas rejoue un moment mesure dont on connait la suite. Cinq passages +// par cas : un seul ne mesure rien, le modele n'est pas deterministe, et +// l'ECART entre passages est souvent plus parlant que la justesse. +// node tools/campagne-conseiller.mjs [passages] +import fs from 'fs'; +import vm from 'vm'; + +const PASSAGES = Number(process.argv[2] || 5); +const URL = 'http://10.10.10.8:8000/v1/chat/completions'; +const code = fs.readFileSync('src/advisor/BUILD_PROMPT.js', 'utf8'); + +function buildPrompt(snapshot, nowIso) { + const ctx = vm.createContext({ + $input: { first: () => ({ json: { data: JSON.stringify(snapshot) } }) }, + Intl, Date: class extends Date { constructor(...a) { super(...(a.length ? a : [nowIso])); } }, + Math, Number, String, JSON, console, + }); + return vm.runInContext(`(function(){ ${code} })()`, ctx)[0].json; +} + +const BASE = { + advisor_enabled: 'on', advisor_agree_count: '0', advisor_diverge_count: '0', + advisor_prev_strategy: 'auto_solar_plus', + soc_percent: '50', battery_current_a: '0', + pv_power_w: '0', pv_power_10m_avg_w: '0', house_ac_power_w: '700', + grid_state: 'off', acin1_available: '0', + evcs_vehicle_connected: 'off', evcs_charging: 'off', evcs_current_a: '0', + evcs_surplus_state: 'vehicle_not_connected', evcs_source_mode: 'Bloqué', + user_strategy: 'auto_solar_plus', + allow_grid_hc_night: 'on', allow_grid_hc_midday: 'off', + night_target_soc_actual: '35', + current_max_auto_a: '22', plafond_edf_on_a: '20', + departure_time: '', departure_priority_enabled: 'off', required_energy_kwh: '0', + today_remaining_kwh: '0', tomorrow_kwh: '30', + balance_status: 'ok', balance_last_run: 'recent', + balance_required_soc: '40', balance_required_soc_prudent: '46', + balance_energy_to_cover_kwh: '2.0', balance_crossing_hour: '09 h', + balance_grid_needed: 'off', balance_grid_energy_needed_kwh: '0', + balance_pv_censored: 'off', balance_censor_reason: '', + balance_storage_shortfall_kwh: '6.8', + balance_next_deficit_kwh: '2.0', balance_next_deficit_window: '(cette nuit)', + balance_storm_level: 'Vert', balance_storm_reserve_soc: '38', + modules_en_service: '4', + bms_max_discharge_current_a: '100', bms_max_charge_current_a: '100', + grid_setpoint_w: '700', + mppt_operation_modes: 'off,off,off,off,off,off', + vehicle_selected: 'VW', +}; +const LIM = Array(6).fill('voltage_current_limited').join(','); +const MAX = Array(6).fill('mppt_active').join(','); + +export const CAS = [ + // ─────────── NUIT ET RESEAU ─────────── + { id:'1-nuit-bloquee-sans-vehicule', t:'2026-09-12T04:00:00+02:00', certain:true, + titre:"12/09 04:00 — SOC 20, HC ouvertes, aucun vehicule, bilan 59,1 %", + attendu:"AGIR : engager le reseau, cible ~59 %", + s:{ soc_percent:'20', battery_current_a:'0', house_ac_power_w:'612', + grid_state:'on', acin1_available:'1', night_target_soc_actual:'59.1', + tomorrow_kwh:'75.2', balance_required_soc:'59.1', balance_required_soc_prudent:'66.2', + balance_energy_to_cover_kwh:'3.13', balance_grid_needed:'on', + balance_grid_energy_needed_kwh:'3.4', balance_storage_shortfall_kwh:'6.77' } }, + + { id:'2-nuit-relachee-trop-tot', t:'2026-09-03T03:00:00+02:00', certain:true, + titre:"03/09 03:00 — SOC 45, bilan 50,4 % (releche a tort : batterie a 20 % a 06:30)", + attendu:"AGIR : tenir le reseau, cible ~50 %", + s:{ soc_percent:'45', battery_current_a:'2', house_ac_power_w:'700', + grid_state:'on', acin1_available:'1', evcs_vehicle_connected:'on', + evcs_surplus_state:'night_session_done_idle', evcs_source_mode:'Réseau HC autorisé', + night_target_soc_actual:'50.4', tomorrow_kwh:'33.9', + balance_required_soc:'50.4', balance_required_soc_prudent:'57', + balance_energy_to_cover_kwh:'4.3', balance_grid_needed:'on', balance_grid_energy_needed_kwh:'1.1' } }, + + { id:'3-nuit-couverte-ne-rien-faire', t:'2026-09-12T04:00:00+02:00', certain:true, + titre:"CONTREFACTUEL — meme nuit mais SOC 70, au-dessus des 59,1 % requis", + attendu:"NE RIEN FAIRE : auto_solar_plus, pas de reseau", + s:{ soc_percent:'70', battery_current_a:'-12', house_ac_power_w:'612', + grid_state:'off', acin1_available:'0', night_target_soc_actual:'59.1', + tomorrow_kwh:'75.2', balance_required_soc:'59.1', balance_required_soc_prudent:'66.2', + balance_energy_to_cover_kwh:'3.13', balance_grid_needed:'off' } }, + + { id:'4-hiver-nuit-longue', t:'2026-12-15T23:30:00+01:00', certain:false, + titre:"HIVER — 15/12 23:30, prevision 6 kWh demain, croisement tardif a 11 h", + attendu:"OUVERT : quelle cible pour une nuit d hiver ?", + s:{ soc_percent:'55', battery_current_a:'-18', house_ac_power_w:'1400', + grid_state:'off', acin1_available:'0', night_target_soc_actual:'85', + tomorrow_kwh:'6.2', balance_required_soc:'88', balance_required_soc_prudent:'97', + balance_energy_to_cover_kwh:'9.4', balance_grid_needed:'on', + balance_grid_energy_needed_kwh:'5.1', balance_crossing_hour:'11 h', + balance_storage_shortfall_kwh:'8.2' } }, + + // ─────────── SOLAIRE ET VOITURE ─────────── + { id:'5-soir-deficit-ne-pas-demarrer', t:'2026-09-03T19:44:00+02:00', certain:true, + titre:"03/09 19:44 — PV 758 W mais maison 1202 W : excedent NEGATIF (-444 W)", + attendu:"NE PAS DEMARRER la charge", + s:{ soc_percent:'94', battery_current_a:'-12.2', pv_power_w:'758', pv_power_10m_avg_w:'900', + house_ac_power_w:'1202', evcs_vehicle_connected:'on', vehicle_selected:'VW', + evcs_source_mode:'Potentiel solaire', mppt_operation_modes:MAX, + balance_required_soc:'99.7', balance_required_soc_prudent:'100', + balance_energy_to_cover_kwh:'11.66', balance_grid_needed:'on', tomorrow_kwh:'33.9' } }, + + { id:'6-censure-demarrer', t:'2026-09-03T18:50:00+02:00', certain:true, + titre:"03/09 18:50 — PV 769 W, maison 755 W, chargeurs BRIDES : ressource refusee", + attendu:"DEMARRER : la ressource est gratuite", + s:{ soc_percent:'94', battery_current_a:'0', pv_power_w:'769', pv_power_10m_avg_w:'800', + house_ac_power_w:'755', evcs_vehicle_connected:'on', vehicle_selected:'VW', + evcs_source_mode:'Potentiel solaire', mppt_operation_modes:LIM, + balance_pv_censored:'on', balance_censor_reason:'batterie_pleine_sans_puits', + balance_required_soc:'99.7', balance_required_soc_prudent:'100', + balance_energy_to_cover_kwh:'11.66', balance_grid_needed:'on', + balance_grid_energy_needed_kwh:'4.9', tomorrow_kwh:'33.9' } }, + + { id:'7-boost-evince-le-solaire', t:'2026-09-11T17:02:00+02:00', certain:true, + titre:"11/09 17:02 — BOOST 28 A, consigne 8260 W, PV effondre a 121 W", + attendu:"AGIR : reduire la demande reseau, le solaire est evince", + s:{ soc_percent:'96', battery_current_a:'0', pv_power_w:'121', pv_power_10m_avg_w:'150', + house_ac_power_w:'7695', evcs_vehicle_connected:'on', evcs_charging:'on', + evcs_current_a:'28', vehicle_selected:'JEEP', user_strategy:'force_grid_now', + grid_state:'on', acin1_available:'1', grid_setpoint_w:'8260', + evcs_source_mode:'Réseau forcé', evcs_surplus_state:'charging_hold_grid', + mppt_operation_modes:LIM, plafond_edf_on_a:'28', + balance_pv_censored:'on', balance_censor_reason:'production_bridee', + balance_required_soc:'62', balance_required_soc_prudent:'70', + balance_energy_to_cover_kwh:'5.2', balance_grid_needed:'on', + balance_grid_energy_needed_kwh:'1.4', balance_crossing_hour:'09 h', + today_remaining_kwh:'8.4', tomorrow_kwh:'75.2' } }, + + { id:'8-voiture-pleine-ne-pas-reessayer', t:'2026-09-07T14:28:00+02:00', certain:true, + titre:"07/09 14:28 — borne annonce charge terminee, vehicule branche immobile", + attendu:"NE RIEN FAIRE : ne pas relancer la charge", + s:{ soc_percent:'97', battery_current_a:'1', pv_power_w:'2114', pv_power_10m_avg_w:'2000', + house_ac_power_w:'1965', evcs_vehicle_connected:'on', evcs_current_a:'0', + vehicle_selected:'JEEP', evcs_surplus_state:'day_vehicle_full_idle', + evcs_source_mode:'Bloqué', mppt_operation_modes:LIM, + balance_pv_censored:'on', balance_censor_reason:'batterie_pleine_sans_puits', + balance_required_soc:'58', balance_required_soc_prudent:'66', + balance_energy_to_cover_kwh:'4.6', balance_grid_needed:'off', + balance_crossing_hour:'09 h', today_remaining_kwh:'12', tomorrow_kwh:'30' } }, + + // ─────────── PROTECTION DU MATERIEL ─────────── + { id:'9-pointe-cuisson-proteger-le-parc', t:'2026-09-05T20:40:00+02:00', certain:true, + titre:"05/09 20:40 — maison 4944 W, batterie a -97,4 A pour une limite de 100 A", + attendu:"AGIR : appeler le reseau, le parc est a sa limite", + s:{ soc_percent:'79', battery_current_a:'-97.4', pv_power_w:'320', pv_power_10m_avg_w:'500', + house_ac_power_w:'4944', grid_state:'off', acin1_available:'0', + balance_required_soc:'62', balance_required_soc_prudent:'70', + balance_energy_to_cover_kwh:'5.1', balance_grid_needed:'on', tomorrow_kwh:'32' } }, + + { id:'10-nuage-sur-charge-28a', t:'2026-09-11T18:00:00+02:00', certain:true, + titre:"11/09 18:00 — voiture a 28 A, consigne descendue a 1281 W, PV 6058 W", + attendu:"AGIR : garder de la marge reseau, un nuage tomberait sur la batterie", + s:{ soc_percent:'96', battery_current_a:'-0.7', pv_power_w:'6058', pv_power_10m_avg_w:'5500', + house_ac_power_w:'6845', evcs_vehicle_connected:'on', evcs_charging:'on', + evcs_current_a:'28', vehicle_selected:'JEEP', user_strategy:'force_grid_now', + grid_state:'on', acin1_available:'1', grid_setpoint_w:'1281', + evcs_source_mode:'Réseau forcé', evcs_surplus_state:'charging_hold_grid', + mppt_operation_modes:MAX, plafond_edf_on_a:'28', + balance_required_soc:'62', balance_required_soc_prudent:'70', + balance_energy_to_cover_kwh:'5.2', balance_grid_needed:'on', + balance_grid_energy_needed_kwh:'1.4', balance_crossing_hour:'09 h', + today_remaining_kwh:'4', tomorrow_kwh:'75.2' } }, + + // ─────────── CAS OUVERTS ─────────── + { id:'11-batterie-pleine-midi', t:'2026-09-11T12:00:00+02:00', certain:false, + titre:"11/09 12:00 — SOC 98, production refusee, AUCUN vehicule branche", + attendu:"OUVERT : y a-t-il quelque chose a faire ?", + s:{ soc_percent:'98', battery_current_a:'2.2', pv_power_w:'810', pv_power_10m_avg_w:'900', + house_ac_power_w:'702', mppt_operation_modes:LIM, + balance_pv_censored:'on', balance_censor_reason:'batterie_pleine_sans_puits', + balance_required_soc:'55', balance_required_soc_prudent:'63', + balance_energy_to_cover_kwh:'4.1', balance_grid_needed:'off', + balance_crossing_hour:'09 h', balance_next_deficit_kwh:'4.1', + balance_next_deficit_window:'(ce soir 20 h -> 09 h)', + today_remaining_kwh:'45', tomorrow_kwh:'75.2' } }, + + { id:'12-orage-annonce', t:'2026-09-12T15:30:00+02:00', certain:false, + titre:"Vigilance ORANGE annoncee, SOC 72, coupures frequentes sur ce site", + attendu:"OUVERT : jusqu ou constituer la reserve ?", + s:{ soc_percent:'72', battery_current_a:'35', pv_power_w:'4200', pv_power_10m_avg_w:'4000', + house_ac_power_w:'900', mppt_operation_modes:MAX, + balance_storm_level:'Orange', balance_storm_reserve_soc:'88', + balance_required_soc:'55', balance_required_soc_prudent:'62', + balance_energy_to_cover_kwh:'3.2', balance_grid_needed:'off', + balance_crossing_hour:'09 h', today_remaining_kwh:'18', tomorrow_kwh:'40' } }, + + { id:'13-voiture-empruntee-echeance', t:'2026-09-11T17:00:00+02:00', certain:false, + titre:"11/09 17:00 — voiture EMPRUNTEE a rendre a 19 h, 28 A possible, soleil present", + attendu:"OUVERT : vitesse contre solaire, arbitrage humain", + s:{ soc_percent:'96', battery_current_a:'-10', pv_power_w:'4255', pv_power_10m_avg_w:'4000', + house_ac_power_w:'4888', evcs_vehicle_connected:'on', evcs_charging:'on', + evcs_current_a:'20', vehicle_selected:'JEEP', user_strategy:'force_charge_now', + grid_state:'on', acin1_available:'1', grid_setpoint_w:'700', + evcs_source_mode:'Solaire', evcs_surplus_state:'charging_hold_manual_local', + mppt_operation_modes:MAX, plafond_edf_on_a:'28', + required_energy_kwh:'12', departure_time:'19:00', departure_priority_enabled:'on', + balance_required_soc:'62', balance_required_soc_prudent:'70', + balance_energy_to_cover_kwh:'5.2', balance_grid_needed:'on', + balance_grid_energy_needed_kwh:'1.4', balance_crossing_hour:'09 h', + today_remaining_kwh:'6', tomorrow_kwh:'75.2' } }, + + { id:'4b-hiver-parc-12', t:'2026-12-15T23:30:00+01:00', certain:false, + titre:"HIVER avec le parc de cet hiver — 12 equivalents (4 US2000C + 4 US5000)", + attendu:"OUVERT : la cible baisse-t-elle quand la reserve triple ?", + s:{ soc_percent:'55', battery_current_a:'-18', house_ac_power_w:'1400', + grid_state:'off', acin1_available:'0', night_target_soc_actual:'85', + tomorrow_kwh:'6.2', balance_required_soc:'52', balance_required_soc_prudent:'59', + balance_energy_to_cover_kwh:'9.4', balance_grid_needed:'on', + balance_grid_energy_needed_kwh:'0', balance_crossing_hour:'11 h', + balance_storage_shortfall_kwh:'0', + modules_en_service:'12', bms_max_discharge_current_a:'300', + bms_max_charge_current_a:'300' } }, + + { id:'4c-hiver-parc-16', t:'2026-12-15T23:30:00+01:00', certain:false, + titre:"HIVER avec 16 equivalents (vieux modules repares)", + attendu:"OUVERT : la cible baisse-t-elle encore ?", + s:{ soc_percent:'55', battery_current_a:'-18', house_ac_power_w:'1400', + grid_state:'off', acin1_available:'0', night_target_soc_actual:'85', + tomorrow_kwh:'6.2', balance_required_soc:'44', balance_required_soc_prudent:'50', + balance_energy_to_cover_kwh:'9.4', balance_grid_needed:'off', + balance_grid_energy_needed_kwh:'0', balance_crossing_hour:'11 h', + balance_storage_shortfall_kwh:'0', + modules_en_service:'16', bms_max_discharge_current_a:'400', + bms_max_charge_current_a:'400' } }, + + { id:'14-bilan-indisponible', t:'2026-09-12T04:00:00+02:00', certain:true, + titre:"ROBUSTESSE — meme nuit que le cas 1 mais bilan DEGRADE", + attendu:"Le dire dans sa raison et rester prudent", + s:{ soc_percent:'20', battery_current_a:'0', house_ac_power_w:'612', + grid_state:'on', acin1_available:'1', night_target_soc_actual:'35', + tomorrow_kwh:'75.2', balance_status:'degrade', + balance_required_soc:'unknown', balance_energy_to_cover_kwh:'unknown' } }, +]; + +async function demander(messages) { + const r = await fetch(URL, { method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ model:'qwen3-coder', temperature:0.1, max_tokens:600, messages }) }); + const j = await r.json(); + const c = j?.choices?.[0]?.message?.content ?? ''; + const t = c.replace(/[\s\S]*?<\/think>/gi,'').replace(/```(?:json)?/gi,'').trim(); + const a = t.indexOf('{'); if (a === -1) return { erreur: t.slice(0,120) }; + let d=0,b=-1; for (let i=a;i x.night_target_soc).filter(Number.isFinite); + const ecart = cibles.length ? Math.max(...cibles) - Math.min(...cibles) : null; + console.log(`\n═══ ${cas.id}${cas.certain ? '' : ' [OUVERT]'}`); + console.log(` ${cas.titre}`); + console.log(` attendu : ${cas.attendu}`); + for (const x of passages) { + console.log(x.erreur ? ` ✖ ${x.erreur}` + : ` ${String(x.strategy).padEnd(17)} cible ${String(x.night_target_soc).padStart(3)} % ${String(x.current_max_a).padStart(2)} A VE:${String(x.ev_charge_tonight).padEnd(11)} [${Math.round((x.confidence||0)*100)}%] ${String(x.reason).slice(0,110)}`); + } + if (ecart !== null) console.log(` → ecart de cible entre passages : ${ecart} pts`); +} +fs.writeFileSync('docs/mesures/campagne-conseiller-' + new Date().toISOString().slice(0,10) + '.json', + JSON.stringify(resultats, null, 2)); +console.log('\nJournal complet : docs/mesures/campagne-conseiller-*.json'); diff --git a/workflows/evcs-advisor-shadow-v1.json b/workflows/evcs-advisor-shadow-v1.json index 7828286..38673ce 100644 --- a/workflows/evcs-advisor-shadow-v1.json +++ b/workflows/evcs-advisor-shadow-v1.json @@ -51,7 +51,7 @@ "parameters": [ { "name": "template", - "value": "{{ {\n \"advisor_enabled\": states(\"input_boolean.evcs_advisor_enabled\"),\n \"advisor_agree_count\": states(\"input_number.evcs_advisor_agree_count\"),\n \"advisor_diverge_count\": states(\"input_number.evcs_advisor_diverge_count\"),\n \"advisor_prev_strategy\": states(\"input_text.evcs_advisor_strategy\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_surplus_state\": states(\"input_text.victron_evcs_surplus_state\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n\n \"balance_status\": states(\"input_text.evcs_balance_status\"),\n \"balance_last_run\": states(\"input_text.evcs_balance_last_run\"),\n \"balance_required_soc\": states(\"input_number.evcs_balance_required_soc\"),\n \"balance_required_soc_prudent\": states(\"input_number.evcs_balance_required_soc_prudent\"),\n \"balance_energy_to_cover_kwh\": states(\"input_number.evcs_balance_energy_to_cover_kwh\"),\n \"balance_crossing_hour\": states(\"input_text.evcs_balance_crossing_hour\"),\n \"balance_grid_needed\": states(\"input_boolean.evcs_balance_grid_needed\"),\n \"balance_grid_energy_needed_kwh\": states(\"input_number.evcs_balance_grid_energy_needed_kwh\"),\n \"balance_pv_censored\": states(\"input_boolean.evcs_balance_pv_censored\"),\n \"balance_censor_reason\": states(\"input_text.evcs_balance_censor_reason\"),\n \"balance_storage_shortfall_kwh\": states(\"input_number.evcs_balance_storage_shortfall_kwh\"),\n \"balance_next_deficit_kwh\": states(\"input_number.evcs_balance_next_deficit_kwh\"),\n \"balance_next_deficit_window\": states(\"input_text.evcs_balance_next_deficit_window\"),\n \"balance_storm_level\": states(\"input_text.evcs_balance_storm_level\"),\n \"balance_storm_reserve_soc\": states(\"input_number.evcs_balance_storm_reserve_soc\"),\n\n \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\n \"bms_max_discharge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_discharge_current\"),\n \"bms_max_charge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_charge_current\"),\n \"grid_setpoint_w\": states(\"number.gx_device_ac_power_setpoint\"),\n \"mppt_operation_modes\": states.sensor\n | selectattr('entity_id','search','mppt')\n | selectattr('entity_id','search','operation_mode')\n | map(attribute='state') | join(','),\n \"vehicle_selected\": states(\"input_select.victron_evcs_vehicle\"),\n\n \"user_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"allow_grid_hc_midday\": states(\"input_boolean.victron_evcs_allow_grid_hc_midday\"),\n \"night_target_soc_actual\": states(\"input_number.victron_evcs_night_target_soc\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"plafond_edf_on_a\": states(\"input_number.evcs_plafond_courant_edf_on\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\")\n} | tojson }}\n" + "value": "{{ {\n \"advisor_enabled\": states(\"input_boolean.evcs_advisor_enabled\"),\n \"advisor_agree_count\": states(\"input_number.evcs_advisor_agree_count\"),\n \"advisor_diverge_count\": states(\"input_number.evcs_advisor_diverge_count\"),\n \"advisor_prev_strategy\": states(\"input_text.evcs_advisor_strategy\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_surplus_state\": states(\"input_text.victron_evcs_surplus_state\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n\n \"balance_status\": states(\"input_text.evcs_balance_status\"),\n \"balance_last_run\": states(\"input_text.evcs_balance_last_run\"),\n \"balance_required_soc\": states(\"input_number.evcs_balance_required_soc\"),\n \"balance_required_soc_prudent\": states(\"input_number.evcs_balance_required_soc_prudent\"),\n \"balance_energy_to_cover_kwh\": states(\"input_number.evcs_balance_energy_to_cover_kwh\"),\n \"balance_crossing_hour\": states(\"input_text.evcs_balance_crossing_hour\"),\n \"balance_grid_needed\": states(\"input_boolean.evcs_balance_grid_needed\"),\n \"balance_grid_energy_needed_kwh\": states(\"input_number.evcs_balance_grid_energy_needed_kwh\"),\n \"balance_pv_censored\": states(\"input_boolean.evcs_balance_pv_censored\"),\n \"balance_censor_reason\": states(\"input_text.evcs_balance_censor_reason\"),\n \"balance_storage_shortfall_kwh\": states(\"input_number.evcs_balance_storage_shortfall_kwh\"),\n \"balance_next_deficit_kwh\": states(\"input_number.evcs_balance_next_deficit_kwh\"),\n \"balance_next_deficit_window\": states(\"input_text.evcs_balance_next_deficit_window\"),\n \"balance_storm_level\": states(\"input_text.evcs_balance_storm_level\"),\n \"balance_storm_reserve_soc\": states(\"input_number.evcs_balance_storm_reserve_soc\"),\n\n \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\n \"bms_max_discharge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_discharge_current\"),\n \"bms_max_charge_current_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_charge_current\"),\n \"grid_setpoint_w\": states(\"number.gx_device_ac_power_setpoint\"),\n \"mppt_operation_modes\": states.sensor\n | selectattr('entity_id','search','mppt')\n | selectattr('entity_id','search','operation_mode')\n | map(attribute='state') | join(','),\n \"vehicle_selected\": states(\"input_select.victron_evcs_vehicle\"),\n \"evenements_recents\": (\n [ \"EDF \" ~ states(\"switch.edf_general_ok_switch\") ~ \" depuis \" ~ relative_time(states.switch.edf_general_ok_switch.last_changed),\n \"verrou hysteresis \" ~ states(\"input_boolean.victron_grid_hysteresis_latch\") ~ \" depuis \" ~ relative_time(states.input_boolean.victron_grid_hysteresis_latch.last_changed),\n \"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),\n \"etat EVCS \" ~ states(\"input_text.victron_evcs_surplus_state\") ~ \" depuis \" ~ relative_time(states.input_text.victron_evcs_surplus_state.last_changed),\n \"mode ACM \" ~ states(\"input_text.victron_charge_mode\") ~ \" depuis \" ~ relative_time(states.input_text.victron_charge_mode.last_changed) ]\n | join(\"|\")\n ),\n\n \"user_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"allow_grid_hc_midday\": states(\"input_boolean.victron_evcs_allow_grid_hc_midday\"),\n \"night_target_soc_actual\": states(\"input_number.victron_evcs_night_target_soc\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"plafond_edf_on_a\": states(\"input_number.evcs_plafond_courant_edf_on\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\")\n} | tojson }}\n" } ] }, @@ -75,7 +75,7 @@ 0 ], "parameters": { - "jsCode": "// =====================================================\n// EVCS ADVISOR SHADOW V1 — BUILD_PROMPT\n// Construit le prompt LLM à partir du snapshot HA compact.\n// MODE OMBRE : ce workflow n'écrit que des helpers advisor_*.\n// =====================================================\n\nfunction num(v, d) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : d;\n}\nfunction onOff(v) {\n return (v ?? 'off').toString().trim().toLowerCase() === 'on';\n}\n\nconst raw = $input.first().json;\nconst input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw);\n\nconst ctx = {\n advisorEnabled: onOff(input.advisor_enabled),\n agreeCount: num(input.advisor_agree_count, 0),\n divergeCount: num(input.advisor_diverge_count, 0),\n prevAdvisorStrategy: (input.advisor_prev_strategy || 'unknown').toString(),\n\n soc: num(input.soc_percent, NaN),\n batteryCurrentA: num(input.battery_current_a, NaN),\n pvW: num(input.pv_power_w, 0),\n pvAvg10mW: num(input.pv_power_10m_avg_w, 0),\n houseW: num(input.house_ac_power_w, 0),\n gridOn: onOff(input.grid_state),\n acinAvailable: num(input.acin1_available, 0) === 1,\n\n vehicleConnected: onOff(input.evcs_vehicle_connected),\n evcsCharging: onOff(input.evcs_charging),\n evcsCurrentA: num(input.evcs_current_a, 0),\n surplusState: (input.evcs_surplus_state || '').toString(),\n sourceMode: (input.evcs_source_mode || '').toString(),\n\n userStrategy: (input.user_strategy || 'auto_solar_plus').toString().trim().toLowerCase(),\n allowGridHcNight: onOff(input.allow_grid_hc_night),\n allowGridHcMidday: onOff(input.allow_grid_hc_midday),\n nightTargetActual: num(input.night_target_soc_actual, 35),\n currentMaxAutoA: num(input.current_max_auto_a, 22),\n plafondEdfOnA: num(input.plafond_edf_on_a, 20),\n\n departureTime: (input.departure_time || '').toString(),\n departurePriority: onOff(input.departure_priority_enabled),\n requiredEnergyKwh: num(input.required_energy_kwh, 0),\n\n todayRemainingKwh: num(input.today_remaining_kwh, 0),\n tomorrowKwh: num(input.tomorrow_kwh, 0),\n\n // ── BILAN ENERGETIQUE ────────────────────────────────────────────────\n // Ces grandeurs REMPLACENT les regles de seuil. Elles n'etaient pas\n // transmises jusqu'au 12/09 : le conseiller raisonnait sur des paliers\n // que le bilan avait deja demontres faux, et il ne pouvait pas mieux faire.\n balanceStatus: (input.balance_status || 'indisponible').toString().trim(),\n balanceLastRun: (input.balance_last_run || '').toString().trim(),\n balanceRequiredSoc: num(input.balance_required_soc, NaN),\n balanceRequiredSocPrudent: num(input.balance_required_soc_prudent, NaN),\n balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN),\n balanceCrossingHour: (input.balance_crossing_hour || '').toString().trim(),\n balanceGridNeeded: onOff(input.balance_grid_needed),\n balanceGridEnergyNeededKwh: num(input.balance_grid_energy_needed_kwh, NaN),\n balancePvCensored: onOff(input.balance_pv_censored),\n balanceCensorReason: (input.balance_censor_reason || '').toString().trim(),\n balanceStorageShortfallKwh: num(input.balance_storage_shortfall_kwh, NaN),\n balanceNextDeficitKwh: num(input.balance_next_deficit_kwh, NaN),\n balanceNextDeficitWindow: (input.balance_next_deficit_window || '').toString().trim(),\n stormLevel: (input.balance_storm_level || 'indisponible').toString().trim(),\n stormReserveSoc: num(input.balance_storm_reserve_soc, NaN),\n\n // ── PARC ET MATERIEL, MESURES ────────────────────────────────────────\n modulesEnService: num(input.modules_en_service, 4),\n bmsMaxDischargeA: num(input.bms_max_discharge_current_a, NaN),\n bmsMaxChargeA: num(input.bms_max_charge_current_a, NaN),\n gridSetpointW: num(input.grid_setpoint_w, NaN),\n vehicleSelected: (input.vehicle_selected || 'inconnu').toString().trim(),\n mppt: (() => {\n const modes = String(input.mppt_operation_modes || '')\n .split(',').map(x => x.trim().toLowerCase())\n .filter(x => x && x !== 'unavailable' && x !== 'unknown');\n return {\n total: modes.length,\n aFond: modes.filter(x => x === 'mppt_active').length,\n brides: modes.filter(x => x.includes('limited')).length,\n };\n })(),\n};\n\n// Capacite REELLEMENT livree a la maison, deduite de la courbe mesuree le\n// 03/09 sur 576 paliers et du parc declare. Un US5000 compte pour DEUX\n// equivalents US2000C. Aucune constante : si le parc change, ceci suit.\nconst kwhParPointHaut = 0.169 * ctx.modulesEnService / 7;\nconst kwhParPointBas = 0.097 * ctx.modulesEnService / 7;\nconst capaciteUtileKwh = (67 * kwhParPointHaut + 11 * kwhParPointBas) * 0.955;\n\n// Heure Paris\nconst parisFmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n weekday: 'long', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n});\nconst nowParis = parisFmt.format(new Date());\n\nconst systemPrompt = [\n \"Tu es le PLANIFICATEUR ENERGIE d'une maison en autoconsommation, en Bretagne.\",\n 'Tu es en MODE OBSERVATION : tes reponses sont enregistrees et comparees au reel,',\n 'elles ne pilotent rien.',\n '',\n 'INSTALLATION, CHIFFRES MESURES (pas des ordres de grandeur) :',\n `- Batterie : ${ctx.modulesEnService} equivalents US2000C, soit ${capaciteUtileKwh.toFixed(1)} kWh`,\n ` reellement livres a la maison entre 100 % et le plancher de securite de 22 %.`,\n ` Un point de SOC vaut ${Math.round(kwhParPointHaut * 1000)} Wh au-dessus de 33 %,`,\n ` ${Math.round(kwhParPointBas * 1000)} Wh en dessous.`,\n '- Champ PV 16,1 kWc sur QUATRE orientations (sud-est 40, sud 35, ouest 45, un suiveur).',\n '- Victron ESS, AUCUNE injection possible : la production est bridee des que rien',\n ' ne peut l absorber. Une PV mesuree basse ne veut donc PAS dire ressource faible.',\n '- Borne EVCS 6-28 A. Reseau EDF commutable, heures creuses 01:24-07:24 et 12:24-14:24.',\n '- Chaque fermeture du contacteur coute 50 a 150 Wh de transitoire : une session',\n ' longue et dimensionnee vaut mieux que des creneaux repetes.',\n '',\n 'LA MAISON, telle qu elle consomme vraiment :',\n '- Quatre personnes. Les gros appels sont BREFS et cales sur les repas :',\n ' bouilloire vers 05:45, grille-pain vers 08:00, cuisson du soir entre 20 h et 23 h.',\n \"- L heure du diner varie de TROIS HEURES d un jour a l autre : un profil horaire\",\n ' moyen ne represente ni l un ni l autre.',\n '',\n 'CE QUI REMPLACE LES REGLES DE SEUIL :',\n 'un bilan energetique tourne toutes les 5 minutes et te donne, pour l instant present,',\n 'l heure de croisement ou la production repassera au-dessus de la consommation,',\n 'l energie que la batterie doit couvrir d ici la, et le SOC qui serait necessaire.',\n 'RAISONNE SUR CES CHIFFRES. Il n existe plus de palier du type',\n '\"prevision forte => cible basse\" : cette regle a ete mesuree fausse sur cette maison.',\n 'Si le bilan est indisponible ou perime, dis-le dans ta raison et reste prudent.',\n '',\n 'CE QUE TU DOIS SAVOIR ET QUE PERSONNE NE TE DIRA AUTREMENT :',\n '- SANS VEHICULE BRANCHE IL Y A QUAND MEME QUELQUE CHOSE A DECIDER : c est la MAISON',\n ' qui doit traverser la nuit. Une batterie au plancher pendant les heures creuses,',\n ' alors que le bilan reclame davantage, est une occasion manquee qui se paiera au',\n ' plein tarif le matin.',\n '- Les chargeurs solaires declarent eux-memes s ils se BRIDENT. Si certains se',\n ' brident, il reste de la ressource REFUSEE : lui donner un preneur (voiture,',\n ' batterie) ne coute rien. S ils sont tous a fond, la production mesuree EST la',\n ' ressource.',\n '- L hiver la nuit a couvrir est plus longue de deux heures en temps solaire : la',\n ' cible du matin y monte naturellement bien plus haut qu en ete.',\n '',\n 'STRATEGIES POSSIBLES :',\n '- auto_solar_plus : automatique, solaire prioritaire, reseau en heures creuses si besoin',\n '- auto_solar : solaire strict, jamais de reseau',\n '- force_grid_now : charge sur reseau EDF, immediate',\n '- force_charge_now : charge locale immediate quelle que soit la source',\n '- stop : tout couper',\n '',\n 'Reponds UNIQUEMENT avec un objet JSON valide, sans texte autour, au format EXACT :',\n '{\"strategy\":\"auto_solar_plus|auto_solar|force_grid_now|force_charge_now|stop\",',\n '\"night_target_soc\":,\"current_max_a\":,',\n '\"ev_charge_tonight\":\"oui|non|sans_objet\",\"confidence\":<0.0-1.0>,',\n '\"reason\":\"une phrase courte en francais (max 200 caracteres)\"}',\n].join('\\n');\n\nfunction nombre(v, unite, defaut) {\n return Number.isFinite(v) ? `${v}${unite}` : (defaut || 'inconnu');\n}\n\nconst bilanLignes = ctx.balanceStatus === 'ok'\n ? [\n `- heure de croisement production/consommation : ${ctx.balanceCrossingHour || 'inconnue'}`,\n `- energie que la batterie doit couvrir d ici la : ${nombre(ctx.balanceEnergyToCoverKwh, ' kWh')}`,\n `- SOC qui serait necessaire : ${nombre(ctx.balanceRequiredSoc, ' %')} (prudent : ${nombre(ctx.balanceRequiredSocPrudent, ' %')})`,\n `- reseau juge necessaire : ${ctx.balanceGridNeeded ? 'OUI' : 'non'}${Number.isFinite(ctx.balanceGridEnergyNeededKwh) ? ` (${ctx.balanceGridEnergyNeededKwh} kWh)` : ''}`,\n `- production actuellement bridee : ${ctx.balancePvCensored ? `OUI (${ctx.balanceCensorReason})` : 'non'}`,\n `- manque de stockage identifie : ${nombre(ctx.balanceStorageShortfallKwh, ' kWh')}`,\n `- prochaine traversee deficitaire : ${nombre(ctx.balanceNextDeficitKwh, ' kWh')} ${ctx.balanceNextDeficitWindow || ''}`,\n `- vigilance orage : ${ctx.stormLevel}${Number.isFinite(ctx.stormReserveSoc) ? `, reserve conseillee ${ctx.stormReserveSoc} %` : ''}`,\n `- calcule a ${ctx.balanceLastRun || 'heure inconnue'}`,\n ]\n : [`- INDISPONIBLE (statut ${ctx.balanceStatus}) : raisonne sans lui, et dis-le.`];\n\nconst userPrompt = [\n `Nous sommes ${nowParis} (heure de Paris).`,\n '',\n 'ETAT MESURE :',\n `- Batterie : SOC ${ctx.soc} %, courant ${ctx.batteryCurrentA} A (positif = charge)`,\n `- PV : ${Math.round(ctx.pvW)} W instantane, ${Math.round(ctx.pvAvg10mW)} W sur 10 min`,\n `- Chargeurs solaires : ${ctx.mppt.aFond}/${ctx.mppt.total} a pleine puissance, ${ctx.mppt.brides} qui se BRIDENT`,\n `- Maison : ${Math.round(ctx.houseW)} W`,\n `- Contacteur EDF : ${ctx.gridOn ? 'FERME' : 'ouvert'} (AC-IN ${ctx.acinAvailable ? 'present' : 'absent'})`,\n `- Consigne de puissance reseau : ${nombre(ctx.gridSetpointW, ' W')}`,\n `- Limites declarees par le parc : charge ${nombre(ctx.bmsMaxChargeA, ' A')}, decharge ${nombre(ctx.bmsMaxDischargeA, ' A')}`,\n `- Vehicule : ${ctx.vehicleConnected ? `BRANCHE (${ctx.vehicleSelected})` : 'AUCUN branche'}${ctx.evcsCharging ? `, en charge a ${ctx.evcsCurrentA} A` : ''}`,\n `- Controleur : ${ctx.surplusState} | source ${ctx.sourceMode} | strategie reelle ${ctx.userStrategy}`,\n `- Cible SOC matin actuellement appliquee : ${ctx.nightTargetActual} %`,\n `- Prevision : ${ctx.todayRemainingKwh} kWh restant aujourd hui, ${ctx.tomorrowKwh} kWh demain`,\n '',\n 'BILAN ENERGETIQUE :',\n ...bilanLignes,\n '',\n 'Quelle est ta recommandation ? JSON uniquement.',\n].join('\\n');\n\nreturn [{\n json: {\n enabled: ctx.advisorEnabled,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n context: ctx,\n }\n}];\n" + "jsCode": "// =====================================================\n// EVCS ADVISOR SHADOW V1 — BUILD_PROMPT\n// Construit le prompt LLM à partir du snapshot HA compact.\n// MODE OMBRE : ce workflow n'écrit que des helpers advisor_*.\n// =====================================================\n\nfunction num(v, d) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : d;\n}\nfunction onOff(v) {\n return (v ?? 'off').toString().trim().toLowerCase() === 'on';\n}\n\nconst raw = $input.first().json;\nconst input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw);\n\nconst ctx = {\n advisorEnabled: onOff(input.advisor_enabled),\n agreeCount: num(input.advisor_agree_count, 0),\n divergeCount: num(input.advisor_diverge_count, 0),\n prevAdvisorStrategy: (input.advisor_prev_strategy || 'unknown').toString(),\n\n soc: num(input.soc_percent, NaN),\n batteryCurrentA: num(input.battery_current_a, NaN),\n pvW: num(input.pv_power_w, 0),\n pvAvg10mW: num(input.pv_power_10m_avg_w, 0),\n houseW: num(input.house_ac_power_w, 0),\n gridOn: onOff(input.grid_state),\n acinAvailable: num(input.acin1_available, 0) === 1,\n\n vehicleConnected: onOff(input.evcs_vehicle_connected),\n evcsCharging: onOff(input.evcs_charging),\n evcsCurrentA: num(input.evcs_current_a, 0),\n surplusState: (input.evcs_surplus_state || '').toString(),\n sourceMode: (input.evcs_source_mode || '').toString(),\n\n userStrategy: (input.user_strategy || 'auto_solar_plus').toString().trim().toLowerCase(),\n allowGridHcNight: onOff(input.allow_grid_hc_night),\n allowGridHcMidday: onOff(input.allow_grid_hc_midday),\n nightTargetActual: num(input.night_target_soc_actual, 35),\n currentMaxAutoA: num(input.current_max_auto_a, 22),\n plafondEdfOnA: num(input.plafond_edf_on_a, 20),\n\n departureTime: (input.departure_time || '').toString(),\n departurePriority: onOff(input.departure_priority_enabled),\n requiredEnergyKwh: num(input.required_energy_kwh, 0),\n\n todayRemainingKwh: num(input.today_remaining_kwh, 0),\n tomorrowKwh: num(input.tomorrow_kwh, 0),\n\n // ── BILAN ENERGETIQUE ────────────────────────────────────────────────\n // Ces grandeurs REMPLACENT les regles de seuil. Elles n'etaient pas\n // transmises jusqu'au 12/09 : le conseiller raisonnait sur des paliers\n // que le bilan avait deja demontres faux, et il ne pouvait pas mieux faire.\n balanceStatus: (input.balance_status || 'indisponible').toString().trim(),\n balanceLastRun: (input.balance_last_run || '').toString().trim(),\n balanceRequiredSoc: num(input.balance_required_soc, NaN),\n balanceRequiredSocPrudent: num(input.balance_required_soc_prudent, NaN),\n balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN),\n balanceCrossingHour: (input.balance_crossing_hour || '').toString().trim(),\n balanceGridNeeded: onOff(input.balance_grid_needed),\n balanceGridEnergyNeededKwh: num(input.balance_grid_energy_needed_kwh, NaN),\n balancePvCensored: onOff(input.balance_pv_censored),\n balanceCensorReason: (input.balance_censor_reason || '').toString().trim(),\n balanceStorageShortfallKwh: num(input.balance_storage_shortfall_kwh, NaN),\n balanceNextDeficitKwh: num(input.balance_next_deficit_kwh, NaN),\n balanceNextDeficitWindow: (input.balance_next_deficit_window || '').toString().trim(),\n stormLevel: (input.balance_storm_level || 'indisponible').toString().trim(),\n stormReserveSoc: num(input.balance_storm_reserve_soc, NaN),\n\n // ── PARC ET MATERIEL, MESURES ────────────────────────────────────────\n modulesEnService: num(input.modules_en_service, 4),\n bmsMaxDischargeA: num(input.bms_max_discharge_current_a, NaN),\n bmsMaxChargeA: num(input.bms_max_charge_current_a, NaN),\n gridSetpointW: num(input.grid_setpoint_w, NaN),\n vehicleSelected: (input.vehicle_selected || 'inconnu').toString().trim(),\n // Historique recent des transitions, fourni par le snapshot. C est la SUITE\n // des evenements qui revele les contradictions entre automates ; un\n // instantane ne peut pas les montrer.\n evenements: String(input.evenements_recents || '')\n .split('|').map(x => x.trim()).filter(Boolean).slice(0, 12)\n .map(x => `- ${x}`),\n\n mppt: (() => {\n const modes = String(input.mppt_operation_modes || '')\n .split(',').map(x => x.trim().toLowerCase())\n .filter(x => x && x !== 'unavailable' && x !== 'unknown');\n return {\n total: modes.length,\n aFond: modes.filter(x => x === 'mppt_active').length,\n brides: modes.filter(x => x.includes('limited')).length,\n };\n })(),\n};\n\n// Capacite REELLEMENT livree a la maison, deduite de la courbe mesuree le\n// 03/09 sur 576 paliers et du parc declare. Un US5000 compte pour DEUX\n// equivalents US2000C. Aucune constante : si le parc change, ceci suit.\n// GRANDEURS DERIVEES — on les CALCULE pour lui (12/09/2026, iteration 2).\n// Campagne de rejeu : sur le releve du 03/09 19:44 (PV 758 W, maison 1202 W)\n// il a recommande de charger la voiture a 28 A. Les deux nombres etaient dans\n// son prompt ; il n a pas fait la soustraction. Et sur celui du 05/09 20:40\n// (batterie a -97,4 A pour une limite declaree de 100 A) il a repondu « SOC\n// 79 % est suffisant » sans mentionner ni le courant ni la limite.\n// Un modele raisonne bien sur UN nombre, mal sur deux qu il doit combiner.\n// Meme principe que le bilan : on lui donne la grandeur, pas les ingredients.\nconst maisonHorsVoitureW = Math.max(0, ctx.houseW - (ctx.evcsCharging ? ctx.evcsCurrentA * 230 : 0));\nconst excedentSolaireW = Math.round(ctx.pvW - maisonHorsVoitureW);\nconst margeDechargeA = Number.isFinite(ctx.bmsMaxDischargeA) && Number.isFinite(ctx.batteryCurrentA)\n ? Math.round(ctx.bmsMaxDischargeA - Math.abs(Math.min(0, ctx.batteryCurrentA)))\n : null;\nconst partDuPlafond = Number.isFinite(ctx.bmsMaxDischargeA) && ctx.bmsMaxDischargeA > 0\n ? Math.round(100 * Math.abs(Math.min(0, ctx.batteryCurrentA)) / ctx.bmsMaxDischargeA)\n : null;\n\nconst kwhParPointHaut = 0.169 * ctx.modulesEnService / 7;\nconst kwhParPointBas = 0.097 * ctx.modulesEnService / 7;\nconst capaciteUtileKwh = (67 * kwhParPointHaut + 11 * kwhParPointBas) * 0.955;\n\n// Heure Paris\nconst parisFmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n weekday: 'long', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n});\nconst nowParis = parisFmt.format(new Date());\n\nconst systemPrompt = [\n \"Tu es le PLANIFICATEUR ENERGIE d'une maison en autoconsommation, en Bretagne.\",\n 'Tu es en MODE OBSERVATION : tes reponses sont enregistrees et comparees au reel,',\n 'elles ne pilotent rien.',\n '',\n 'INSTALLATION, CHIFFRES MESURES (pas des ordres de grandeur) :',\n `- Batterie : ${ctx.modulesEnService} equivalents US2000C, soit ${capaciteUtileKwh.toFixed(1)} kWh`,\n ` reellement livres a la maison entre 100 % et le plancher de securite de 22 %.`,\n ` Un point de SOC vaut ${Math.round(kwhParPointHaut * 1000)} Wh au-dessus de 33 %,`,\n ` ${Math.round(kwhParPointBas * 1000)} Wh en dessous.`,\n '- Champ PV 16,1 kWc sur QUATRE orientations (sud-est 40, sud 35, ouest 45, un suiveur).',\n '- Victron ESS, AUCUNE injection possible : la production est bridee des que rien',\n ' ne peut l absorber. Une PV mesuree basse ne veut donc PAS dire ressource faible.',\n '- Borne EVCS 6-28 A. Reseau EDF commutable, heures creuses 01:24-07:24 et 12:24-14:24.',\n '- Chaque fermeture du contacteur coute 50 a 150 Wh de transitoire : une session',\n ' longue et dimensionnee vaut mieux que des creneaux repetes.',\n '',\n 'LA MAISON, telle qu elle consomme vraiment :',\n '- Quatre personnes. Les gros appels sont BREFS et cales sur les repas :',\n ' bouilloire vers 05:45, grille-pain vers 08:00, cuisson du soir entre 20 h et 23 h.',\n \"- L heure du diner varie de TROIS HEURES d un jour a l autre : un profil horaire\",\n ' moyen ne represente ni l un ni l autre.',\n '',\n 'CE QUI REMPLACE LES REGLES DE SEUIL :',\n 'un bilan energetique tourne toutes les 5 minutes et te donne, pour l instant present,',\n 'l heure de croisement ou la production repassera au-dessus de la consommation,',\n 'l energie que la batterie doit couvrir d ici la, et le SOC qui serait necessaire.',\n 'RAISONNE SUR CES CHIFFRES. Il n existe plus de palier du type',\n '\"prevision forte => cible basse\" : cette regle a ete mesuree fausse sur cette maison.',\n 'Si le bilan est indisponible ou perime, dis-le dans ta raison et reste prudent.',\n '',\n 'CE QUE TU DOIS SAVOIR ET QUE PERSONNE NE TE DIRA AUTREMENT :',\n '- SANS VEHICULE BRANCHE IL Y A QUAND MEME QUELQUE CHOSE A DECIDER : c est la MAISON',\n ' qui doit traverser la nuit. Une batterie au plancher pendant les heures creuses,',\n ' alors que le bilan reclame davantage, est une occasion manquee qui se paiera au',\n ' plein tarif le matin.',\n '- Les chargeurs solaires declarent eux-memes s ils se BRIDENT. Si certains se',\n ' brident, il reste de la ressource REFUSEE : lui donner un preneur (voiture,',\n ' batterie) ne coute rien. S ils sont tous a fond, la production mesuree EST la',\n ' ressource.',\n '- L hiver la nuit a couvrir est plus longue de deux heures en temps solaire : la',\n ' cible du matin y monte naturellement bien plus haut qu en ete.',\n '',\n 'DEUX REGLES QUI PRIMENT SUR TOUT LE RESTE :',\n '1. EXCEDENT SOLAIRE. Charger un vehicule ne se decide pas sur la production',\n ' mais sur ce qui RESTE une fois la maison servie. Cet excedent t est donne',\n ' plus bas. S il est NEGATIF, charger la voiture revient a vider la batterie',\n ' solaire : ne le fais pas, quelle que soit la prevision. S il est nul ou a',\n ' peine positif ALORS QUE DES CHARGEURS SE BRIDENT, la ressource existe mais',\n ' est refusee : la charger est gratuit.',\n '2. LIMITE DU PARC. La marge de decharge restante t est donnee plus bas. Si la',\n ' batterie depasse 80 % de ce que le parc declare accepter, c est un sujet de',\n ' PROTECTION DU MATERIEL, pas de confort : il faut appeler le reseau meme si',\n ' le SOC parait confortable et meme hors heures creuses. Le proprietaire a un',\n ' abonnement 12 kVA et prefere payer quelques kWh plutot que fragiliser le parc.',\n '',\n 'STRATEGIES POSSIBLES :',\n '- auto_solar_plus : automatique, solaire prioritaire, reseau en heures creuses si besoin',\n '- auto_solar : solaire strict, jamais de reseau',\n '- force_grid_now : charge sur reseau EDF, immediate',\n '- force_charge_now : charge locale immediate quelle que soit la source',\n '- stop : tout couper',\n '',\n 'TA MISSION PRINCIPALE : REPERER CE QUI CLOCHE.',\n 'Cinq automates ecrivent sur la meme installation : le controleur EVCS, un',\n 'automate d hysteresis pour le contacteur reseau, un gestionnaire de charge',\n 'batterie, un bilan energetique, et des automatisations Home Assistant. Chacun',\n 'est localement correct et aucun ne voit les autres. Les pannes de ce systeme',\n 'ne sont donc presque jamais des erreurs de calcul : ce sont des CONTRADICTIONS',\n 'entre automates, et elles se voient dans la SUITE des evenements, pas dans',\n 'un instantane.',\n '',\n 'Exemple reel du 12/09 : le contacteur reseau a bascule toutes les 4 minutes',\n 'pendant dix minutes — coupe, remis, coupe. Un automate coupait a 35 % de SOC,',\n 'un autre rallumait. Chacun faisait son travail. Le resultat etait absurde, et',\n 'seul un humain qui regardait l a vu.',\n '',\n 'Un comportement est ABSURDE quand : un actionneur bascule plusieurs fois en',\n 'peu de temps ; deux decisions se contredisent ; une grandeur reste figee alors',\n 'que ce qui la pilote bouge ; une action est repetee sans effet ; une valeur',\n 'contredit ce que le bilan demande.',\n '',\n 'Tu as le DERNIER HISTORIQUE DES EVENEMENTS plus bas. Sers-t en.',\n '',\n 'Reponds UNIQUEMENT avec un objet JSON valide, sans texte autour, au format EXACT :',\n '{\"constat\":\"ce que tu observes, factuel, max 200 caracteres\",',\n '\"gravite\":\"aucune|faible|moyenne|haute\",',\n '\"ce_qui_cloche\":\"la contradiction ou l incoherence, ou \\'\\' si rien\",',\n '\"strategy\":\"auto_solar_plus|auto_solar|force_grid_now|force_charge_now|stop\",',\n '\"night_target_soc\":,\"current_max_a\":,',\n '\"ev_charge_tonight\":\"oui|non|sans_objet\",\"confidence\":<0.0-1.0>,',\n '\"reason\":\"une phrase courte en francais (max 200 caracteres)\"}',\n '',\n 'gravite \"aucune\" quand tout est coherent — c est le cas le plus frequent et',\n 'il ne faut pas inventer un probleme pour justifier une reponse.',\n].join('\\n');\n\nfunction nombre(v, unite, defaut) {\n return Number.isFinite(v) ? `${v}${unite}` : (defaut || 'inconnu');\n}\n\nconst bilanLignes = ctx.balanceStatus === 'ok'\n ? [\n `- heure de croisement production/consommation : ${ctx.balanceCrossingHour || 'inconnue'}`,\n `- energie que la batterie doit couvrir d ici la : ${nombre(ctx.balanceEnergyToCoverKwh, ' kWh')}`,\n `- SOC qui serait necessaire : ${nombre(ctx.balanceRequiredSoc, ' %')} (prudent : ${nombre(ctx.balanceRequiredSocPrudent, ' %')})`,\n `- reseau juge necessaire : ${ctx.balanceGridNeeded ? 'OUI' : 'non'}${Number.isFinite(ctx.balanceGridEnergyNeededKwh) ? ` (${ctx.balanceGridEnergyNeededKwh} kWh)` : ''}`,\n `- production actuellement bridee : ${ctx.balancePvCensored ? `OUI (${ctx.balanceCensorReason})` : 'non'}`,\n `- manque de stockage identifie : ${nombre(ctx.balanceStorageShortfallKwh, ' kWh')}`,\n `- prochaine traversee deficitaire : ${nombre(ctx.balanceNextDeficitKwh, ' kWh')} ${ctx.balanceNextDeficitWindow || ''}`,\n `- vigilance orage : ${ctx.stormLevel}${Number.isFinite(ctx.stormReserveSoc) ? `, reserve conseillee ${ctx.stormReserveSoc} %` : ''}`,\n `- calcule a ${ctx.balanceLastRun || 'heure inconnue'}`,\n ]\n : [`- INDISPONIBLE (statut ${ctx.balanceStatus}) : raisonne sans lui, et dis-le.`];\n\nconst userPrompt = [\n `Nous sommes ${nowParis} (heure de Paris).`,\n '',\n 'ETAT MESURE :',\n `- Batterie : SOC ${ctx.soc} %, courant ${ctx.batteryCurrentA} A (positif = charge)`,\n `- PV : ${Math.round(ctx.pvW)} W instantane, ${Math.round(ctx.pvAvg10mW)} W sur 10 min`,\n `- Chargeurs solaires : ${ctx.mppt.aFond}/${ctx.mppt.total} a pleine puissance, ${ctx.mppt.brides} qui se BRIDENT`,\n `- Maison : ${Math.round(ctx.houseW)} W, dont voiture ${ctx.evcsCharging ? Math.round(ctx.evcsCurrentA * 230) : 0} W`,\n `- EXCEDENT SOLAIRE (production moins maison hors voiture) : ${excedentSolaireW} W`,\n `- MARGE DE DECHARGE du parc : ${margeDechargeA === null ? 'inconnue' : `${margeDechargeA} A restants`}${partDuPlafond === null ? '' : ` — la batterie est a ${partDuPlafond} % de son plafond`}`,\n `- Contacteur EDF : ${ctx.gridOn ? 'FERME' : 'ouvert'} (AC-IN ${ctx.acinAvailable ? 'present' : 'absent'})`,\n `- Consigne de puissance reseau : ${nombre(ctx.gridSetpointW, ' W')}`,\n `- Limites declarees par le parc : charge ${nombre(ctx.bmsMaxChargeA, ' A')}, decharge ${nombre(ctx.bmsMaxDischargeA, ' A')}`,\n `- Vehicule : ${ctx.vehicleConnected ? `BRANCHE (${ctx.vehicleSelected})` : 'AUCUN branche'}${ctx.evcsCharging ? `, en charge a ${ctx.evcsCurrentA} A` : ''}`,\n `- Controleur : ${ctx.surplusState} | source ${ctx.sourceMode} | strategie reelle ${ctx.userStrategy}`,\n `- Cible SOC matin actuellement appliquee : ${ctx.nightTargetActual} %`,\n `- Prevision : ${ctx.todayRemainingKwh} kWh restant aujourd hui, ${ctx.tomorrowKwh} kWh demain`,\n '',\n 'BILAN ENERGETIQUE :',\n ...bilanLignes,\n '',\n 'DERNIERS EVENEMENTS DU SYSTEME (le plus recent en premier) :',\n ...(ctx.evenements.length ? ctx.evenements : ['- aucun evenement transmis']),\n '',\n 'Quelle est ta recommandation ? JSON uniquement.',\n].join('\\n');\n\nreturn [{\n json: {\n enabled: ctx.advisorEnabled,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n context: ctx,\n }\n}];\n" } }, { @@ -235,7 +235,7 @@ -30 ], "parameters": { - "jsCode": "// =====================================================\n// EVCS ADVISOR SHADOW V1 — VALIDATE_PARSE\n// Valide STRICTEMENT la sortie LLM (locale OU cloud, format OpenAI).\n// Sortie invalide = REJET TOTAL (status invalid), jamais de clamp silencieux.\n// =====================================================\n\nconst resp = $input.first().json;\nconst context = $('BUILD_PROMPT').first().json.context;\n\nconst STRATEGIES = ['auto_solar_plus', 'auto_solar', 'force_grid_now', 'force_charge_now', 'stop'];\nconst TONIGHT = ['oui', 'non', 'sans_objet'];\n\nfunction fail(status, detail) {\n return [{\n json: {\n status,\n detail: (detail || '').toString().slice(0, 200),\n advice: null,\n modelUsed: (resp?.model || 'aucun').toString().slice(0, 60),\n context,\n }\n }];\n}\n\n// 1. Réponse LLM présente ? (les deux backends ont échoué => item d'erreur n8n)\nconst content = resp?.choices?.[0]?.message?.content;\nif (typeof content !== 'string' || !content.trim()) {\n return fail('llm_offline', resp?.error?.message || resp?.error || 'aucune réponse LLM');\n}\n\n// 2. Nettoyage : balises de raisonnement (...) et fences markdown\nlet text = content\n .replace(/[\\s\\S]*?<\\/think>/gi, '')\n .replace(/```(?:json)?/gi, '')\n .trim();\n\n// 3. Extraction du premier objet JSON équilibré\nconst start = text.indexOf('{');\nif (start === -1) return fail('invalid', 'aucun JSON dans la réponse');\nlet depth = 0, end = -1;\nfor (let i = start; i < text.length; i++) {\n if (text[i] === '{') depth++;\n else if (text[i] === '}') { depth--; if (depth === 0) { end = i; break; } }\n}\nif (end === -1) return fail('invalid', 'JSON non terminé');\n\nlet obj;\ntry {\n obj = JSON.parse(text.slice(start, end + 1));\n} catch (e) {\n return fail('invalid', `JSON invalide: ${e.message}`);\n}\n\n// 4. Validation stricte du schéma (enums + bornes)\nconst strategy = (obj.strategy ?? '').toString().trim().toLowerCase();\nif (!STRATEGIES.includes(strategy)) return fail('invalid', `strategy hors enum: ${strategy}`);\n\nconst nightTarget = Number(obj.night_target_soc);\nif (!Number.isFinite(nightTarget) || nightTarget < 22 || nightTarget > 90) {\n return fail('invalid', `night_target_soc hors bornes: ${obj.night_target_soc}`);\n}\n\nconst currentMax = Number(obj.current_max_a);\nif (!Number.isFinite(currentMax) || currentMax < 6 || currentMax > 28) {\n return fail('invalid', `current_max_a hors bornes: ${obj.current_max_a}`);\n}\n\nconst tonight = (obj.ev_charge_tonight ?? '').toString().trim().toLowerCase();\nif (!TONIGHT.includes(tonight)) return fail('invalid', `ev_charge_tonight hors enum: ${tonight}`);\n\nconst confidence = Number(obj.confidence);\nif (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {\n return fail('invalid', `confidence hors bornes: ${obj.confidence}`);\n}\n\nconst reason = (obj.reason ?? '').toString().trim();\nif (!reason) return fail('invalid', 'reason vide');\n\nreturn [{\n json: {\n status: 'ok',\n detail: '',\n advice: {\n strategy,\n nightTargetSoc: Math.round(nightTarget),\n currentMaxA: Math.round(currentMax),\n evChargeTonight: tonight,\n confidence: Math.round(confidence * 100) / 100,\n reason: reason.slice(0, 250),\n },\n modelUsed: (resp.model || 'inconnu').toString().slice(0, 60),\n context,\n }\n}];\n" + "jsCode": "// =====================================================\n// EVCS ADVISOR SHADOW V1 — VALIDATE_PARSE\n// Valide STRICTEMENT la sortie LLM (locale OU cloud, format OpenAI).\n// Sortie invalide = REJET TOTAL (status invalid), jamais de clamp silencieux.\n// =====================================================\n\nconst resp = $input.first().json;\nconst context = $('BUILD_PROMPT').first().json.context;\n\nconst STRATEGIES = ['auto_solar_plus', 'auto_solar', 'force_grid_now', 'force_charge_now', 'stop'];\nconst TONIGHT = ['oui', 'non', 'sans_objet'];\n\nfunction fail(status, detail) {\n return [{\n json: {\n status,\n detail: (detail || '').toString().slice(0, 200),\n advice: null,\n modelUsed: (resp?.model || 'aucun').toString().slice(0, 60),\n context,\n }\n }];\n}\n\n// 1. Réponse LLM présente ? (les deux backends ont échoué => item d'erreur n8n)\nconst content = resp?.choices?.[0]?.message?.content;\nif (typeof content !== 'string' || !content.trim()) {\n return fail('llm_offline', resp?.error?.message || resp?.error || 'aucune réponse LLM');\n}\n\n// 2. Nettoyage : balises de raisonnement (...) et fences markdown\nlet text = content\n .replace(/[\\s\\S]*?<\\/think>/gi, '')\n .replace(/```(?:json)?/gi, '')\n .trim();\n\n// 3. Extraction du premier objet JSON équilibré\nconst start = text.indexOf('{');\nif (start === -1) return fail('invalid', 'aucun JSON dans la réponse');\nlet depth = 0, end = -1;\nfor (let i = start; i < text.length; i++) {\n if (text[i] === '{') depth++;\n else if (text[i] === '}') { depth--; if (depth === 0) { end = i; break; } }\n}\nif (end === -1) return fail('invalid', 'JSON non terminé');\n\nlet obj;\ntry {\n obj = JSON.parse(text.slice(start, end + 1));\n} catch (e) {\n return fail('invalid', `JSON invalide: ${e.message}`);\n}\n\n// 4. Validation stricte du schéma (enums + bornes)\nconst strategy = (obj.strategy ?? '').toString().trim().toLowerCase();\nif (!STRATEGIES.includes(strategy)) return fail('invalid', `strategy hors enum: ${strategy}`);\n\nconst nightTargetRaw = Number(obj.night_target_soc);\nconst nightTargetValide = Number.isFinite(nightTargetRaw) && nightTargetRaw >= 22 && nightTargetRaw <= 90;\nconst nightTarget = nightTargetValide ? nightTargetRaw : null;\n\n// ⚠️ L OBSERVATION NE DOIT PAS ETRE PERDUE A CAUSE D UN CHAMP D ACTION.\n// Essai du 12/09 : sur l oscillation du contacteur, le modele a produit un\n// constat JUSTE (« bascule toutes les 1-2 min depuis 06:25, gravite haute »)\n// ET un `current_max_a` de 100, hors bornes. Une validation tout-ou-rien\n// jetait le diagnostic avec le champ fautif — c est-a-dire exactement ce\n// qu on lui demande, perdu a cause de ce qu on ne lui demande plus.\n// Les champs d ACTION restent bornes aussi strictement qu avant ; seulement,\n// hors bornes, ils sont NEUTRALISES au lieu de faire echouer le constat.\nconst currentMaxRaw = Number(obj.current_max_a);\nconst currentMaxValide = Number.isFinite(currentMaxRaw) && currentMaxRaw >= 6 && currentMaxRaw <= 28;\nconst currentMax = currentMaxValide ? currentMaxRaw : null;\n\nconst tonight = (obj.ev_charge_tonight ?? '').toString().trim().toLowerCase();\nif (!TONIGHT.includes(tonight)) return fail('invalid', `ev_charge_tonight hors enum: ${tonight}`);\n\nconst confidence = Number(obj.confidence);\nif (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {\n return fail('invalid', `confidence hors bornes: ${obj.confidence}`);\n}\n\nconst reason = (obj.reason ?? '').toString().trim();\nif (!reason) return fail('invalid', 'reason vide');\n\n// ── OBSERVATION (12/09/2026) ───────────────────────────────────────────\n// La mission principale du conseiller n est plus de choisir une strategie\n// parmi cinq, mais de REPERER CE QUI CLOCHE. Les pannes de ce systeme sont\n// des contradictions entre automates — chacun localement correct — et elles\n// ne se voient que dans la suite des evenements. Le 12/09 le contacteur\n// reseau a bascule toutes les 4 minutes pendant dix minutes : aucun automate\n// ne l a vu, un humain oui.\nconst GRAVITES = ['aucune', 'faible', 'moyenne', 'haute'];\nconst constat = (obj.constat ?? '').toString().trim();\nif (!constat) return fail('invalid', 'constat vide');\nconst gravite = (obj.gravite ?? '').toString().trim().toLowerCase();\nif (!GRAVITES.includes(gravite)) return fail('invalid', `gravite hors enum: ${obj.gravite}`);\nconst ceQuiCloche = (obj.ce_qui_cloche ?? '').toString().trim();\n\nreturn [{\n json: {\n status: 'ok',\n detail: '',\n advice: {\n constat: constat.slice(0, 200),\n gravite,\n ceQuiCloche: ceQuiCloche.slice(0, 200),\n strategy,\n nightTargetSoc: nightTarget === null ? null : Math.round(nightTarget),\n currentMaxA: currentMax === null ? null : Math.round(currentMax),\n // Vrai seulement si TOUS les champs d action sont dans leurs bornes.\n // C est ce drapeau, et lui seul, qui autoriserait un jour un cablage.\n actionUtilisable: currentMaxValide && nightTargetValide,\n evChargeTonight: tonight,\n confidence: Math.round(confidence * 100) / 100,\n reason: reason.slice(0, 250),\n },\n modelUsed: (resp.model || 'inconnu').toString().slice(0, 60),\n context,\n }\n}];\n" } }, { @@ -247,7 +247,7 @@ -30 ], "parameters": { - "jsCode": "// =====================================================\n// EVCS ADVISOR SHADOW V1 — BUILD_HA_CALLS\n// Écrit les helpers advisor_* + divergence + compteurs + logbook.\n// GARANTIE MODE OMBRE : ce node ne produit QUE des écritures vers\n// des entités advisor_* et le logbook. AUCUN switch, AUCUNE stratégie\n// réelle, AUCUNE commande borne. Ne pas ajouter d'autres entités ici.\n// =====================================================\n\nconst { status, detail, advice, modelUsed, context } = $input.first().json;\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n push('input_number', 'set_value', { entity_id: entity, value });\n}\n\n// Statut + horodatage + modèle : écrits à CHAQUE run\nsetText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : ''));\nsetText('input_text.evcs_advisor_last_run', stampParis());\nsetText('input_text.evcs_advisor_model', modelUsed);\n\nif (status === 'ok' && advice) {\n // Recommandations\n setText('input_text.evcs_advisor_strategy', advice.strategy);\n setNumber('input_number.evcs_advisor_night_target_soc', advice.nightTargetSoc);\n setNumber('input_number.evcs_advisor_current_max_a', advice.currentMaxA);\n setText('input_text.evcs_advisor_ev_charge_tonight', advice.evChargeTonight);\n setText('input_text.evcs_advisor_reason', `[${Math.round(advice.confidence * 100)}%] ${advice.reason}`);\n\n // Divergence vs réel (stratégie + cible nuit)\n const diffs = [];\n if (advice.strategy !== context.userStrategy) {\n diffs.push(`stratégie: agent=${advice.strategy} ≠ réel=${context.userStrategy}`);\n }\n if (Math.abs(advice.nightTargetSoc - context.nightTargetActual) > 10) {\n diffs.push(`cible nuit: agent=${advice.nightTargetSoc}% vs réel=${context.nightTargetActual}%`);\n }\n const divergenceText = diffs.length ? diffs.join(' | ') : 'accord avec le réel';\n setText('input_text.evcs_advisor_divergence', divergenceText);\n\n // ⚠️ LE COMPTEUR MESURAIT LE MAUVAIS CHAMP (corrige le 12/09/2026).\n // Il comparait la STRATEGIE, qui vaut `auto_solar_plus` la quasi-totalite du\n // temps : 517 accords contre 23 divergences, soit 95,7 % — un score qui\n // mesurait une constante, pas une decision. Pendant ce temps la cible SOC,\n // seul champ reellement cable, divergeait de treize points du bilan sans\n // que rien ne le compte (12/09 05:30 : conseiller 35 %, bilan 48,4 %).\n //\n // On compte desormais l'accord sur la CIBLE, et contre le BILAN — la\n // reference, pas contre la valeur appliquee qui peut elle-meme etre fausse.\n // Bilan indisponible => on ne compte rien plutot que de compter faux.\n const refSoc = Number(context.balanceRequiredSoc);\n const bilanUtilisable = context.balanceStatus === 'ok' && Number.isFinite(refSoc);\n if (bilanUtilisable) {\n const ecart = Math.abs(advice.nightTargetSoc - refSoc);\n if (ecart <= 5) {\n setNumber('input_number.evcs_advisor_agree_count', context.agreeCount + 1);\n } else {\n setNumber('input_number.evcs_advisor_diverge_count', context.divergeCount + 1);\n }\n setText(\n 'input_text.evcs_advisor_divergence',\n `cible: agent=${advice.nightTargetSoc}% vs bilan=${Math.round(refSoc)}% (ecart ${Math.round(ecart)} pts)`\n + (diffs.length ? ` | ${diffs.join(' | ')}` : '')\n );\n }\n\n // Trace logbook pour l'historique long terme\n push('logbook', 'log', {\n name: 'EVCS Advisor (ombre)',\n 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}`,\n entity_id: 'input_text.evcs_advisor_strategy',\n });\n}\n\nreturn calls;\n" + "jsCode": "// =====================================================\n// EVCS ADVISOR SHADOW V1 — BUILD_HA_CALLS\n// Écrit les helpers advisor_* + divergence + compteurs + logbook.\n// GARANTIE MODE OMBRE : ce node ne produit QUE des écritures vers\n// des entités advisor_* et le logbook. AUCUN switch, AUCUNE stratégie\n// réelle, AUCUNE commande borne. Ne pas ajouter d'autres entités ici.\n// =====================================================\n\nconst { status, detail, advice, modelUsed, context } = $input.first().json;\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n push('input_number', 'set_value', { entity_id: entity, value });\n}\n\n// Statut + horodatage + modèle : écrits à CHAQUE run\nsetText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : ''));\nsetText('input_text.evcs_advisor_last_run', stampParis());\nsetText('input_text.evcs_advisor_model', modelUsed);\n\nif (status === 'ok' && advice) {\n // Observation — la sortie principale desormais.\n setText('input_text.evcs_advisor_constat', advice.constat);\n setText('input_text.evcs_advisor_gravite', advice.gravite);\n setText('input_text.evcs_advisor_ce_qui_cloche', advice.ceQuiCloche || 'rien de signale');\n\n // Recommandations\n setText('input_text.evcs_advisor_strategy', advice.strategy);\n // Les champs d action ne sont publies que s ils sont exploitables : un\n // helper numerique ne doit jamais porter une valeur hors bornes.\n if (advice.actionUtilisable) {\n setNumber('input_number.evcs_advisor_night_target_soc', advice.nightTargetSoc);\n setNumber('input_number.evcs_advisor_current_max_a', advice.currentMaxA);\n }\n setText('input_text.evcs_advisor_ev_charge_tonight', advice.evChargeTonight);\n setText('input_text.evcs_advisor_reason', `[${Math.round(advice.confidence * 100)}%] ${advice.reason}`);\n\n // Divergence vs réel (stratégie + cible nuit)\n const diffs = [];\n if (advice.strategy !== context.userStrategy) {\n diffs.push(`stratégie: agent=${advice.strategy} ≠ réel=${context.userStrategy}`);\n }\n if (Math.abs(advice.nightTargetSoc - context.nightTargetActual) > 10) {\n diffs.push(`cible nuit: agent=${advice.nightTargetSoc}% vs réel=${context.nightTargetActual}%`);\n }\n const divergenceText = diffs.length ? diffs.join(' | ') : 'accord avec le réel';\n setText('input_text.evcs_advisor_divergence', divergenceText);\n\n // ⚠️ LE COMPTEUR MESURAIT LE MAUVAIS CHAMP (corrige le 12/09/2026).\n // Il comparait la STRATEGIE, qui vaut `auto_solar_plus` la quasi-totalite du\n // temps : 517 accords contre 23 divergences, soit 95,7 % — un score qui\n // mesurait une constante, pas une decision. Pendant ce temps la cible SOC,\n // seul champ reellement cable, divergeait de treize points du bilan sans\n // que rien ne le compte (12/09 05:30 : conseiller 35 %, bilan 48,4 %).\n //\n // On compte desormais l'accord sur la CIBLE, et contre le BILAN — la\n // reference, pas contre la valeur appliquee qui peut elle-meme etre fausse.\n // Bilan indisponible => on ne compte rien plutot que de compter faux.\n const refSoc = Number(context.balanceRequiredSoc);\n const bilanUtilisable = advice.actionUtilisable && context.balanceStatus === 'ok' && Number.isFinite(refSoc);\n if (bilanUtilisable) {\n const ecart = Math.abs(advice.nightTargetSoc - refSoc);\n if (ecart <= 5) {\n setNumber('input_number.evcs_advisor_agree_count', context.agreeCount + 1);\n } else {\n setNumber('input_number.evcs_advisor_diverge_count', context.divergeCount + 1);\n }\n setText(\n 'input_text.evcs_advisor_divergence',\n `cible: agent=${advice.nightTargetSoc}% vs bilan=${Math.round(refSoc)}% (ecart ${Math.round(ecart)} pts)`\n + (diffs.length ? ` | ${diffs.join(' | ')}` : '')\n );\n }\n\n // Trace logbook pour l'historique long terme\n push('logbook', 'log', {\n name: 'EVCS Advisor (ombre)',\n 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}`,\n entity_id: 'input_text.evcs_advisor_strategy',\n });\n}\n\nreturn calls;\n" } }, {