Files
EVCS/workflows/evcs-advisor-shadow-v1.json
Tallagrand Thierry 3cde737af8 Le conseiller recoit enfin de quoi decider
Diagnostic du proprietaire, et il est juste : le conseiller n est pas
utilisable parce qu on lui donne des consignes fausses et des informations
partielles.

CONSIGNES FAUSSES. Son briefing enoncait mot pour mot la regle des paliers
(« forecast fort >= 18 kWh => cible ~35 % »), celle que le bilan a demontree
fausse le 03/09. Il declarait 17,5 kWh de batterie (mesure : 6,8 sur quatre
modules) et 9 kWc de panneaux (16,1 en realite). Retires. La capacite est
desormais CALCULEE depuis la courbe mesuree et le parc declare.

INFORMATIONS PARTIELLES. Il ne recevait AUCUNE sortie du bilan. Il ignorait
donc l heure de croisement, l energie a couvrir et le SOC requis -- les
seules grandeurs sur lesquelles la question se decide. +23 champs au
snapshot : bilan complet, parc, limites BMS, consigne reseau, modes des
chargeurs solaires, vehicule selectionne.

Ajoute aussi ce que personne ne lui disait : sans vehicule branche il y a
quand meme quelque chose a decider (c est la maison qui traverse la nuit),
les chargeurs declarent eux-memes s ils se brident, la cuisson du soir se
decale de trois heures, l hiver la nuit est plus longue de deux heures en
temps solaire.

PREUVE. Rejeu de la nuit bloquee du 12/09 a 04:00, sans aucune aide :
  avant  auto_solar_plus, cible 35 %, « prevision forte => cible basse »
  apres  force_grid_now,  cible 60 %, « bilan exige 3.13 kWh avant 09h »
Le bilan demandait 59,1 %. En production a la meme heure, il n avait rien vu
-- quatre fois de suite.

COMPTEUR. Il comparait la STRATEGIE, constante a auto_solar_plus : 517
accords contre 23, soit 95,7 % — un score qui mesurait une constante. Il
compare desormais la CIBLE, contre le BILAN, et ne compte rien quand le
bilan est indisponible plutot que de compter faux.

Tests advisor : 3 nouveaux sur le compteur (ecart 2 pts -> accord, 13 pts ->
divergence, bilan indisponible -> aucun comptage). Perimetre mode ombre
inchange et toujours verifie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
2026-09-12 05:56:54 +02:00

422 lines
32 KiB
JSON

{
"name": "EVCS Advisor Shadow V1",
"nodes": [
{
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
],
"parameters": {
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 15
}
]
}
}
},
{
"name": "HA Snapshot Advisor",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
200,
0
],
"parameters": {
"method": "POST",
"url": "http://10.10.10.17:8123/api/template",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "text/plain"
}
]
},
"sendBody": true,
"bodyParameters": {
"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"
}
]
},
"options": {
"timeout": 15000
}
},
"credentials": {
"httpBearerAuth": {
"id": "uv3sknYYigxBkaVN",
"name": "Bearer Auth account 2"
}
}
},
{
"name": "BUILD_PROMPT",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
400,
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\":<entier 22-90>,\"current_max_a\":<entier 6-28>,',\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"
}
},
{
"name": "IF_ENABLED",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
600,
0
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{ $json.enabled }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
]
}
}
},
{
"name": "LLM_LOCAL_MODELS",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
800,
-100
],
"parameters": {
"method": "GET",
"url": "http://10.10.10.8:8000/v1/models",
"options": {
"timeout": 5000
}
},
"onError": "continueRegularOutput",
"alwaysOutputData": true
},
{
"name": "LLM_LOCAL",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1000,
-100
],
"parameters": {
"method": "POST",
"url": "http://10.10.10.8:8000/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ model: $('LLM_LOCAL_MODELS').first().json.data[0].id, messages: $('BUILD_PROMPT').first().json.messages, temperature: 0.1, max_tokens: 6000 }) }}",
"options": {
"timeout": 120000
}
},
"onError": "continueRegularOutput",
"alwaysOutputData": true
},
{
"name": "IF_LOCAL_OK",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1200,
-100
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"combinator": "and",
"conditions": [
{
"leftValue": "={{ $json.choices !== undefined && $json.choices.length > 0 }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
]
}
}
},
{
"name": "LLM_CLOUD",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1400,
40
],
"parameters": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ model: 'anthropic/claude-haiku-4.5', messages: $('BUILD_PROMPT').first().json.messages, temperature: 0.1, max_tokens: 1000 }) }}",
"options": {
"timeout": 60000
}
},
"credentials": {
"httpHeaderAuth": {
"id": "eOM9xofDHT0KMoeY",
"name": "OpenRouter Advisor"
}
},
"onError": "continueRegularOutput",
"alwaysOutputData": true
},
{
"name": "VALIDATE_PARSE",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1600,
-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 (<think>...</think>) et fences markdown\nlet text = content\n .replace(/<think>[\\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"
}
},
{
"name": "BUILD_HA_CALLS",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1800,
-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"
}
},
{
"name": "Execute HA Service Advisor",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2000,
-30
],
"parameters": {
"method": "POST",
"url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.data }}",
"options": {
"timeout": 15000
}
},
"credentials": {
"httpBearerAuth": {
"id": "uv3sknYYigxBkaVN",
"name": "Bearer Auth account 2"
}
},
"onError": "continueRegularOutput"
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "HA Snapshot Advisor",
"type": "main",
"index": 0
}
]
]
},
"HA Snapshot Advisor": {
"main": [
[
{
"node": "BUILD_PROMPT",
"type": "main",
"index": 0
}
]
]
},
"BUILD_PROMPT": {
"main": [
[
{
"node": "IF_ENABLED",
"type": "main",
"index": 0
}
]
]
},
"IF_ENABLED": {
"main": [
[
{
"node": "LLM_LOCAL_MODELS",
"type": "main",
"index": 0
}
],
[]
]
},
"LLM_LOCAL_MODELS": {
"main": [
[
{
"node": "LLM_LOCAL",
"type": "main",
"index": 0
}
]
]
},
"LLM_LOCAL": {
"main": [
[
{
"node": "IF_LOCAL_OK",
"type": "main",
"index": 0
}
]
]
},
"IF_LOCAL_OK": {
"main": [
[
{
"node": "VALIDATE_PARSE",
"type": "main",
"index": 0
}
],
[
{
"node": "LLM_CLOUD",
"type": "main",
"index": 0
}
]
]
},
"LLM_CLOUD": {
"main": [
[
{
"node": "VALIDATE_PARSE",
"type": "main",
"index": 0
}
]
]
},
"VALIDATE_PARSE": {
"main": [
[
{
"node": "BUILD_HA_CALLS",
"type": "main",
"index": 0
}
]
]
},
"BUILD_HA_CALLS": {
"main": [
[
{
"node": "Execute HA Service Advisor",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"callerPolicy": "workflowsFromSameOwner"
},
"staticData": null,
"id": "tdfInZns7cqo8fU2"
}