From e675ec434c2cbefe7a9d903e70ed56173efa854f Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Sat, 12 Sep 2026 08:57:46 +0200 Subject: [PATCH] Ancrer l echelle de gravite sur des incidents reels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apres specialisation, l observateur discriminait parfaitement (5/5 dans les deux sens) mais notait l oscillation « moyenne » au lieu de « haute » : j avais pose quatre niveaux sans jamais dire ce que chacun signifie. Defaut de briefing, pas de detection. L echelle est desormais ancree sur des cas mesures de cette installation — la consigne reseau qui evince 5,6 kW de solaire pour « moyenne », le contacteur qui bascule six fois en dix minutes pour « haute » — avec une regle explicite : UNE OSCILLATION EST TOUJOURS DE GRAVITE HAUTE, parce que c est la repetition qui fait le degat et que c est precisement ce qu aucune regle locale ne peut voir. Mesure apres ancrage : oscillation « haute » 3/3, systeme calme « aucune » 3/3. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj --- src/advisor/BUILD_PROMPT.js | 19 ++++++++++++++++--- workflows/evcs-advisor-shadow-v1.json | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/advisor/BUILD_PROMPT.js b/src/advisor/BUILD_PROMPT.js index 5a16230..5896c8b 100644 --- a/src/advisor/BUILD_PROMPT.js +++ b/src/advisor/BUILD_PROMPT.js @@ -203,9 +203,22 @@ const systemPrompt = [ '"piste":"ce qu il faudrait verifier ou corriger, ou \'\' si rien",', '"confidence":<0.0-1.0>}', '', - 'gravite "aucune" quand tout est coherent — c est le cas le plus frequent et de', - 'loin. N invente pas un probleme pour justifier une reponse : un observateur qui', - 'signale en permanence ne sert a rien.', + 'ECHELLE DE GRAVITE, ancree sur des cas reels de cette installation :', + '- aucune : tout est coherent. C EST LE CAS LE PLUS FREQUENT ET DE LOIN.', + ' N invente pas un probleme pour justifier une reponse.', + '- faible : un ecart sans consequence mesurable — une valeur un peu hors cible,', + ' un etat transitoire qui se resorbe seul.', + '- moyenne : quelque chose coute de l energie ou de l argent sans danger materiel.', + ' Exemple reel : la consigne reseau laissee haute en plein soleil, qui', + ' evince la production — 5,6 kW de solaire remplaces par du reseau.', + '- haute : un actionneur bascule de facon repetee, OU le materiel est sollicite', + ' pres de ses limites, OU un automate defait ce qu un autre vient de', + ' faire. Exemple reel : le contacteur reseau qui bascule six fois en', + ' dix minutes le 12/09 — chaque bascule coute 50 a 150 Wh et use le', + ' relais, et aucun automate ne le voyait.', + 'UNE OSCILLATION EST TOUJOURS DE GRAVITE HAUTE, meme si chaque bascule prise', + 'isolement parait anodine : c est la repetition qui fait le degat, et c est', + 'precisement ce qu aucune regle locale ne peut voir.', ].join('\n'); function nombre(v, unite, defaut) { diff --git a/workflows/evcs-advisor-shadow-v1.json b/workflows/evcs-advisor-shadow-v1.json index 7f94762..e8a94ae 100644 --- a/workflows/evcs-advisor-shadow-v1.json +++ b/workflows/evcs-advisor-shadow-v1.json @@ -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 // 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 // <<< CARTE GENEREE — ne pas editer a la main, voir tools/carte-decisions.mjs\n 'QUI ECRIT SUR QUOI (carte generee depuis les sources) :',\n '- Contacteur reseau EDF : proprietaire = Battery / Grid Hysteresis Fix V1, AUTRES ECRIVAINS : EVCS Surplus Controller V2.6, automations, scripts',\n '- Courant de la borne : proprietaire = EVCS Surplus Controller V2.6, AUTRES ECRIVAINS : scripts',\n '- Verrou hysteresis : proprietaire = Battery / Grid Hysteresis Fix V1, AUTRES ECRIVAINS : scripts',\n 'SEUILS DE SOC FIGES dans le code : 20, 22, 25, 35, 40, 60, 80, 85, 90, 95, 100 % — chacun peut contredire le bilan.',\n 'Quand un actionneur bascule de facon incoherente, le coupable le plus probable',\n 'est un ecrivain AUTRE QUE SON PROPRIETAIRE. Nomme-le.',\n // CARTE GENEREE >>>\n '',\n 'TU NE PILOTES RIEN ET TU NE CHOISIS AUCUNE STRATEGIE.',\n 'La planification energetique est faite par le bilan, qui est calcule et mesure.',\n 'Ton seul travail est de REGARDER LE SYSTEME SE COMPORTER et de dire ce qui cloche.',\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 '\"piste\":\"ce qu il faudrait verifier ou corriger, ou \\'\\' si rien\",',\n '\"confidence\":<0.0-1.0>}',\n '',\n 'gravite \"aucune\" quand tout est coherent — c est le cas le plus frequent et de',\n 'loin. N invente pas un probleme pour justifier une reponse : un observateur qui',\n 'signale en permanence ne sert a rien.',\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" + "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 // <<< CARTE GENEREE — ne pas editer a la main, voir tools/carte-decisions.mjs\n 'QUI ECRIT SUR QUOI (carte generee depuis les sources) :',\n '- Contacteur reseau EDF : proprietaire = Battery / Grid Hysteresis Fix V1, AUTRES ECRIVAINS : EVCS Surplus Controller V2.6, automations, scripts',\n '- Courant de la borne : proprietaire = EVCS Surplus Controller V2.6, AUTRES ECRIVAINS : scripts',\n '- Verrou hysteresis : proprietaire = Battery / Grid Hysteresis Fix V1, AUTRES ECRIVAINS : scripts',\n 'SEUILS DE SOC FIGES dans le code : 20, 22, 25, 35, 40, 60, 80, 85, 90, 95, 100 % — chacun peut contredire le bilan.',\n 'Quand un actionneur bascule de facon incoherente, le coupable le plus probable',\n 'est un ecrivain AUTRE QUE SON PROPRIETAIRE. Nomme-le.',\n // CARTE GENEREE >>>\n '',\n 'TU NE PILOTES RIEN ET TU NE CHOISIS AUCUNE STRATEGIE.',\n 'La planification energetique est faite par le bilan, qui est calcule et mesure.',\n 'Ton seul travail est de REGARDER LE SYSTEME SE COMPORTER et de dire ce qui cloche.',\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 '\"piste\":\"ce qu il faudrait verifier ou corriger, ou \\'\\' si rien\",',\n '\"confidence\":<0.0-1.0>}',\n '',\n 'ECHELLE DE GRAVITE, ancree sur des cas reels de cette installation :',\n '- aucune : tout est coherent. C EST LE CAS LE PLUS FREQUENT ET DE LOIN.',\n ' N invente pas un probleme pour justifier une reponse.',\n '- faible : un ecart sans consequence mesurable — une valeur un peu hors cible,',\n ' un etat transitoire qui se resorbe seul.',\n '- moyenne : quelque chose coute de l energie ou de l argent sans danger materiel.',\n ' Exemple reel : la consigne reseau laissee haute en plein soleil, qui',\n ' evince la production — 5,6 kW de solaire remplaces par du reseau.',\n '- haute : un actionneur bascule de facon repetee, OU le materiel est sollicite',\n ' pres de ses limites, OU un automate defait ce qu un autre vient de',\n ' faire. Exemple reel : le contacteur reseau qui bascule six fois en',\n ' dix minutes le 12/09 — chaque bascule coute 50 a 150 Wh et use le',\n ' relais, et aucun automate ne le voyait.',\n 'UNE OSCILLATION EST TOUJOURS DE GRAVITE HAUTE, meme si chaque bascule prise',\n 'isolement parait anodine : c est la repetition qui fait le degat, et c est',\n 'precisement ce qu aucune regle locale ne peut voir.',\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" } }, {