diff --git a/src/advisor/BUILD_HA_CALLS.js b/src/advisor/BUILD_HA_CALLS.js index c009d64..c6c5566 100644 --- a/src/advisor/BUILD_HA_CALLS.js +++ b/src/advisor/BUILD_HA_CALLS.js @@ -52,11 +52,30 @@ if (status === 'ok' && advice) { const divergenceText = diffs.length ? diffs.join(' | ') : 'accord avec le réel'; setText('input_text.evcs_advisor_divergence', divergenceText); - // Compteurs accord/divergence sur la stratégie (base des critères de bascule) - if (advice.strategy === context.userStrategy) { - setNumber('input_number.evcs_advisor_agree_count', context.agreeCount + 1); - } else { - setNumber('input_number.evcs_advisor_diverge_count', context.divergeCount + 1); + // ⚠️ LE COMPTEUR MESURAIT LE MAUVAIS CHAMP (corrige le 12/09/2026). + // Il comparait la STRATEGIE, qui vaut `auto_solar_plus` la quasi-totalite du + // temps : 517 accords contre 23 divergences, soit 95,7 % — un score qui + // mesurait une constante, pas une decision. Pendant ce temps la cible SOC, + // seul champ reellement cable, divergeait de treize points du bilan sans + // que rien ne le compte (12/09 05:30 : conseiller 35 %, bilan 48,4 %). + // + // On compte desormais l'accord sur la CIBLE, et contre le BILAN — la + // 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); + if (bilanUtilisable) { + const ecart = Math.abs(advice.nightTargetSoc - refSoc); + if (ecart <= 5) { + setNumber('input_number.evcs_advisor_agree_count', context.agreeCount + 1); + } else { + setNumber('input_number.evcs_advisor_diverge_count', context.divergeCount + 1); + } + setText( + 'input_text.evcs_advisor_divergence', + `cible: agent=${advice.nightTargetSoc}% vs bilan=${Math.round(refSoc)}% (ecart ${Math.round(ecart)} pts)` + + (diffs.length ? ` | ${diffs.join(' | ')}` : '') + ); } // Trace logbook pour l'historique long terme diff --git a/src/advisor/BUILD_PROMPT.js b/src/advisor/BUILD_PROMPT.js index 8a84dd2..73c7f4f 100644 --- a/src/advisor/BUILD_PROMPT.js +++ b/src/advisor/BUILD_PROMPT.js @@ -48,8 +48,52 @@ const ctx = { todayRemainingKwh: num(input.today_remaining_kwh, 0), tomorrowKwh: num(input.tomorrow_kwh, 0), + + // ── BILAN ENERGETIQUE ──────────────────────────────────────────────── + // Ces grandeurs REMPLACENT les regles de seuil. Elles n'etaient pas + // transmises jusqu'au 12/09 : le conseiller raisonnait sur des paliers + // que le bilan avait deja demontres faux, et il ne pouvait pas mieux faire. + balanceStatus: (input.balance_status || 'indisponible').toString().trim(), + balanceLastRun: (input.balance_last_run || '').toString().trim(), + balanceRequiredSoc: num(input.balance_required_soc, NaN), + balanceRequiredSocPrudent: num(input.balance_required_soc_prudent, NaN), + balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN), + balanceCrossingHour: (input.balance_crossing_hour || '').toString().trim(), + balanceGridNeeded: onOff(input.balance_grid_needed), + balanceGridEnergyNeededKwh: num(input.balance_grid_energy_needed_kwh, NaN), + balancePvCensored: onOff(input.balance_pv_censored), + balanceCensorReason: (input.balance_censor_reason || '').toString().trim(), + balanceStorageShortfallKwh: num(input.balance_storage_shortfall_kwh, NaN), + balanceNextDeficitKwh: num(input.balance_next_deficit_kwh, NaN), + balanceNextDeficitWindow: (input.balance_next_deficit_window || '').toString().trim(), + stormLevel: (input.balance_storm_level || 'indisponible').toString().trim(), + stormReserveSoc: num(input.balance_storm_reserve_soc, NaN), + + // ── PARC ET MATERIEL, MESURES ──────────────────────────────────────── + modulesEnService: num(input.modules_en_service, 4), + bmsMaxDischargeA: num(input.bms_max_discharge_current_a, NaN), + bmsMaxChargeA: num(input.bms_max_charge_current_a, NaN), + gridSetpointW: num(input.grid_setpoint_w, NaN), + vehicleSelected: (input.vehicle_selected || 'inconnu').toString().trim(), + mppt: (() => { + const modes = String(input.mppt_operation_modes || '') + .split(',').map(x => x.trim().toLowerCase()) + .filter(x => x && x !== 'unavailable' && x !== 'unknown'); + return { + total: modes.length, + aFond: modes.filter(x => x === 'mppt_active').length, + brides: modes.filter(x => x.includes('limited')).length, + }; + })(), }; +// 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. +const kwhParPointHaut = 0.169 * ctx.modulesEnService / 7; +const kwhParPointBas = 0.097 * ctx.modulesEnService / 7; +const capaciteUtileKwh = (67 * kwhParPointHaut + 11 * kwhParPointBas) * 0.955; + // Heure Paris const parisFmt = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', @@ -59,44 +103,98 @@ const parisFmt = new Intl.DateTimeFormat('fr-FR', { const nowParis = parisFmt.format(new Date()); const systemPrompt = [ - 'Tu es le PLANIFICATEUR ÉNERGIE d\'une maison autonome française. Tu es en MODE OBSERVATION :', - 'tes recommandations sont enregistrées pour comparaison, elles ne pilotent rien.', + "Tu es le PLANIFICATEUR ENERGIE d'une maison en autoconsommation, en Bretagne.", + 'Tu es en MODE OBSERVATION : tes reponses sont enregistrees et comparees au reel,', + 'elles ne pilotent rien.', '', - 'INSTALLATION : Victron ESS SANS injection réseau. Batterie 17,5 kWh utile (plancher sécurité 22%).', - 'PV ~9 kWc. Borne EVCS 6-28 A. Réseau EDF commutable (cher en HP, bon marché en HC).', - 'Heures creuses : 01:24-07:24 et 12:24-14:24. Véhicules : Jeep, moto Zéro, VW hybride (arrive en fin d\'après-midi).', + 'INSTALLATION, CHIFFRES MESURES (pas des ordres de grandeur) :', + `- Batterie : ${ctx.modulesEnService} equivalents US2000C, soit ${capaciteUtileKwh.toFixed(1)} kWh`, + ` reellement livres a la maison entre 100 % et le plancher de securite de 22 %.`, + ` Un point de SOC vaut ${Math.round(kwhParPointHaut * 1000)} Wh au-dessus de 33 %,`, + ` ${Math.round(kwhParPointBas * 1000)} Wh en dessous.`, + '- Champ PV 16,1 kWc sur QUATRE orientations (sud-est 40, sud 35, ouest 45, un suiveur).', + '- Victron ESS, AUCUNE injection possible : la production est bridee des que rien', + ' ne peut l absorber. Une PV mesuree basse ne veut donc PAS dire ressource faible.', + '- Borne EVCS 6-28 A. Reseau EDF commutable, heures creuses 01:24-07:24 et 12:24-14:24.', + '- Chaque fermeture du contacteur coute 50 a 150 Wh de transitoire : une session', + ' longue et dimensionnee vaut mieux que des creneaux repetes.', '', - 'STRATÉGIES POSSIBLES (input_select) :', - '- auto_solar_plus : automatique, solaire prioritaire + réseau HC la nuit si véhicule branché (défaut recommandé)', - '- auto_solar : automatique, solaire strict (jamais de réseau)', - '- force_grid_now : charge VE immédiate sur réseau EDF (à réserver aux urgences/gros besoins)', - '- force_charge_now : charge locale immédiate quelle que soit la source (secours manuel)', - '- stop : tout couper', + 'LA MAISON, telle qu elle consomme vraiment :', + '- Quatre personnes. Les gros appels sont BREFS et cales sur les repas :', + ' bouilloire vers 05:45, grille-pain vers 08:00, cuisson du soir entre 20 h et 23 h.', + "- L heure du diner varie de TROIS HEURES d un jour a l autre : un profil horaire", + ' moyen ne represente ni l un ni l autre.', '', - 'PRINCIPES : maximiser l\'autoconsommation solaire ; réseau seulement en HC ou urgence ;', - 'ne jamais viser un SOC matin < 22% ; forecast demain fort (>=18 kWh) => cible matin basse (~35%),', - 'moyen (>=10) => ~55%, faible => ~85%. Plafond courant : 20 A max si EDF ON, sinon 22 A auto.', + 'CE QUI REMPLACE LES REGLES DE SEUIL :', + 'un bilan energetique tourne toutes les 5 minutes et te donne, pour l instant present,', + 'l heure de croisement ou la production repassera au-dessus de la consommation,', + 'l energie que la batterie doit couvrir d ici la, et le SOC qui serait necessaire.', + 'RAISONNE SUR CES CHIFFRES. Il n existe plus de palier du type', + '"prevision forte => cible basse" : cette regle a ete mesuree fausse sur cette maison.', + 'Si le bilan est indisponible ou perime, dis-le dans ta raison et reste prudent.', '', - 'Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, au format EXACT :', + 'CE QUE TU DOIS SAVOIR ET QUE PERSONNE NE TE DIRA AUTREMENT :', + '- SANS VEHICULE BRANCHE IL Y A QUAND MEME QUELQUE CHOSE A DECIDER : c est la MAISON', + ' qui doit traverser la nuit. Une batterie au plancher pendant les heures creuses,', + ' alors que le bilan reclame davantage, est une occasion manquee qui se paiera au', + ' plein tarif le matin.', + '- Les chargeurs solaires declarent eux-memes s ils se BRIDENT. Si certains se', + ' brident, il reste de la ressource REFUSEE : lui donner un preneur (voiture,', + ' batterie) ne coute rien. S ils sont tous a fond, la production mesuree EST la', + ' ressource.', + '- 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.', + '', + 'STRATEGIES POSSIBLES :', + '- auto_solar_plus : automatique, solaire prioritaire, reseau en heures creuses si besoin', + '- auto_solar : solaire strict, jamais de reseau', + '- force_grid_now : charge sur reseau EDF, immediate', + '- force_charge_now : charge locale immediate quelle que soit la source', + '- stop : tout couper', + '', + '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",', '"night_target_soc":,"current_max_a":,', '"ev_charge_tonight":"oui|non|sans_objet","confidence":<0.0-1.0>,', - '"reason":"une phrase courte en français (max 200 caractères)"}', + '"reason":"une phrase courte en francais (max 200 caracteres)"}', ].join('\n'); +function nombre(v, unite, defaut) { + return Number.isFinite(v) ? `${v}${unite}` : (defaut || 'inconnu'); +} + +const bilanLignes = ctx.balanceStatus === 'ok' + ? [ + `- heure de croisement production/consommation : ${ctx.balanceCrossingHour || 'inconnue'}`, + `- energie que la batterie doit couvrir d ici la : ${nombre(ctx.balanceEnergyToCoverKwh, ' kWh')}`, + `- SOC qui serait necessaire : ${nombre(ctx.balanceRequiredSoc, ' %')} (prudent : ${nombre(ctx.balanceRequiredSocPrudent, ' %')})`, + `- reseau juge necessaire : ${ctx.balanceGridNeeded ? 'OUI' : 'non'}${Number.isFinite(ctx.balanceGridEnergyNeededKwh) ? ` (${ctx.balanceGridEnergyNeededKwh} kWh)` : ''}`, + `- production actuellement bridee : ${ctx.balancePvCensored ? `OUI (${ctx.balanceCensorReason})` : 'non'}`, + `- manque de stockage identifie : ${nombre(ctx.balanceStorageShortfallKwh, ' kWh')}`, + `- prochaine traversee deficitaire : ${nombre(ctx.balanceNextDeficitKwh, ' kWh')} ${ctx.balanceNextDeficitWindow || ''}`, + `- vigilance orage : ${ctx.stormLevel}${Number.isFinite(ctx.stormReserveSoc) ? `, reserve conseillee ${ctx.stormReserveSoc} %` : ''}`, + `- calcule a ${ctx.balanceLastRun || 'heure inconnue'}`, + ] + : [`- INDISPONIBLE (statut ${ctx.balanceStatus}) : raisonne sans lui, et dis-le.`]; + const userPrompt = [ `Nous sommes ${nowParis} (heure de Paris).`, '', - 'ÉTAT ACTUEL :', - `- Batterie : SOC ${ctx.soc}%, courant ${ctx.batteryCurrentA} A (positif = charge)`, - `- PV : ${Math.round(ctx.pvW)} W instantané, ${Math.round(ctx.pvAvg10mW)} W moyenne 10 min`, - `- Maison : ${Math.round(ctx.houseW)} W | Réseau EDF : ${ctx.gridOn ? 'ON' : 'OFF'} (AC-IN ${ctx.acinAvailable ? 'présent' : 'absent'})`, - `- Véhicule : ${ctx.vehicleConnected ? 'BRANCHÉ' : 'non branché'}${ctx.evcsCharging ? `, en charge à ${ctx.evcsCurrentA} A` : ''}`, - `- État contrôleur : ${ctx.surplusState} | Source : ${ctx.sourceMode}`, - `- Stratégie réelle actuelle : ${ctx.userStrategy}`, - `- Besoin véhicule : ${ctx.requiredEnergyKwh} kWh, départ ${ctx.departureTime}${ctx.departurePriority ? ' (priorité départ ON)' : ''}`, - `- Prévision solaire : ${ctx.todayRemainingKwh} kWh restant aujourd'hui, ${ctx.tomorrowKwh} kWh demain`, - `- Cible SOC matin actuelle : ${ctx.nightTargetActual}% | HC nuit autorisé : ${ctx.allowGridHcNight ? 'oui' : 'non'}`, + 'ETAT MESURE :', + `- 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`, + `- 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')}`, + `- Vehicule : ${ctx.vehicleConnected ? `BRANCHE (${ctx.vehicleSelected})` : 'AUCUN branche'}${ctx.evcsCharging ? `, en charge a ${ctx.evcsCurrentA} A` : ''}`, + `- Controleur : ${ctx.surplusState} | source ${ctx.sourceMode} | strategie reelle ${ctx.userStrategy}`, + `- Cible SOC matin actuellement appliquee : ${ctx.nightTargetActual} %`, + `- Prevision : ${ctx.todayRemainingKwh} kWh restant aujourd hui, ${ctx.tomorrowKwh} kWh demain`, + '', + 'BILAN ENERGETIQUE :', + ...bilanLignes, '', '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 8367424..ed356f8 100644 --- a/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -18,6 +18,32 @@ "evcs_surplus_state": states("input_text.victron_evcs_surplus_state"), "evcs_source_mode": states("input_text.victron_evcs_source_mode"), + "balance_status": states("input_text.evcs_balance_status"), + "balance_last_run": states("input_text.evcs_balance_last_run"), + "balance_required_soc": states("input_number.evcs_balance_required_soc"), + "balance_required_soc_prudent": states("input_number.evcs_balance_required_soc_prudent"), + "balance_energy_to_cover_kwh": states("input_number.evcs_balance_energy_to_cover_kwh"), + "balance_crossing_hour": states("input_text.evcs_balance_crossing_hour"), + "balance_grid_needed": states("input_boolean.evcs_balance_grid_needed"), + "balance_grid_energy_needed_kwh": states("input_number.evcs_balance_grid_energy_needed_kwh"), + "balance_pv_censored": states("input_boolean.evcs_balance_pv_censored"), + "balance_censor_reason": states("input_text.evcs_balance_censor_reason"), + "balance_storage_shortfall_kwh": states("input_number.evcs_balance_storage_shortfall_kwh"), + "balance_next_deficit_kwh": states("input_number.evcs_balance_next_deficit_kwh"), + "balance_next_deficit_window": states("input_text.evcs_balance_next_deficit_window"), + "balance_storm_level": states("input_text.evcs_balance_storm_level"), + "balance_storm_reserve_soc": states("input_number.evcs_balance_storm_reserve_soc"), + + "modules_en_service": states("input_number.evcs_balance_modules_en_service"), + "bms_max_discharge_current_a": states("sensor.airtria_8_us2000c_maximum_allowed_discharge_current"), + "bms_max_charge_current_a": states("sensor.airtria_8_us2000c_maximum_allowed_charge_current"), + "grid_setpoint_w": states("number.gx_device_ac_power_setpoint"), + "mppt_operation_modes": states.sensor + | selectattr('entity_id','search','mppt') + | selectattr('entity_id','search','operation_mode') + | map(attribute='state') | join(','), + "vehicle_selected": states("input_select.victron_evcs_vehicle"), + "user_strategy": states("input_select.victron_evcs_strategy"), "allow_grid_hc_night": states("input_boolean.victron_evcs_allow_grid_hc_night"), "allow_grid_hc_midday": states("input_boolean.victron_evcs_allow_grid_hc_midday"), diff --git a/tests/advisor/run.mjs b/tests/advisor/run.mjs index 10830e9..1399da4 100644 --- a/tests/advisor/run.mjs +++ b/tests/advisor/run.mjs @@ -112,8 +112,31 @@ const calls = run(BUILD_HA_CALLS, okValidated, ctxNode).map(i => i.json); const ALLOWED = /^(input_text|input_number)\.evcs_advisor_/; const violations = calls.filter(c => !(c.domain === 'logbook' || ALLOWED.test(c.data.entity_id || ''))); check('OMBRE: aucune écriture hors advisor_*/logbook', violations.length === 0, JSON.stringify(violations)); -check('OMBRE: écrit strategy + reason + divergence + compteur', calls.some(c => (c.data.entity_id || '').includes('advisor_strategy')) && calls.some(c => (c.data.entity_id || '').includes('advisor_divergence')) && calls.some(c => (c.data.entity_id || '').includes('agree_count'))); -check('OMBRE: accord détecté (agent=réel=auto_solar_plus)', calls.some(c => (c.data.entity_id || '').includes('agree_count')) && !calls.some(c => (c.data.entity_id || '').includes('diverge_count'))); +check('OMBRE: écrit strategy + reason', calls.some(c => (c.data.entity_id || '').includes('advisor_strategy')) && calls.some(c => (c.data.entity_id || '').includes('advisor_reason'))); + +// ── LE COMPTEUR MESURE LA CIBLE, CONTRE LE BILAN (12/09/2026) ────────── +// Il comparait la STRATEGIE, qui vaut auto_solar_plus la quasi-totalite du +// temps : 517/23, soit 95,7 % — un score qui mesurait une constante. La cible, +// seul champ cable, divergeait de 13 points du bilan sans etre comptee. +function compteur(ctx) { + const node = { BUILD_PROMPT: { ...promptOut, context: ctx } }; + const validated = run(VALIDATE_PARSE, cases[0].resp, node)[0].json; + const out = run(BUILD_HA_CALLS, validated, node).map(i => i.json); + const id = k => out.some(c => (c.data.entity_id || '').includes(k)); + return { accord: id('agree_count'), divergence: id('diverge_count'), ecart: id('advisor_divergence') }; +} +const base = promptOut.context; + +// L'agent dit 35 (le validAdvice des cas ci-dessus). +const proche = compteur({ ...base, balanceStatus: 'ok', balanceRequiredSoc: 33 }); +check('COMPTEUR: cible a 2 points du bilan → accord', proche.accord && !proche.divergence); + +const loin = compteur({ ...base, balanceStatus: 'ok', balanceRequiredSoc: 48.4 }); +check('COMPTEUR: cible a 13 points du bilan → divergence (le cas reel du 12/09)', loin.divergence && !loin.accord); + +const sansBilan = compteur({ ...base, balanceStatus: 'degrade', balanceRequiredSoc: 48.4 }); +check('COMPTEUR: bilan indisponible → on ne compte RIEN plutot que de compter faux', + !sansBilan.accord && !sansBilan.divergence); const offlineValidated = run(VALIDATE_PARSE, cases[6].resp, ctxNode)[0].json; const offlineCalls = run(BUILD_HA_CALLS, offlineValidated, ctxNode).map(i => i.json); diff --git a/workflows/evcs-advisor-shadow-v1.json b/workflows/evcs-advisor-shadow-v1.json index 7bdae19..7828286 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 \"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\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\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 ÉNERGIE d\\'une maison autonome française. Tu es en MODE OBSERVATION :',\n 'tes recommandations sont enregistrées pour comparaison, elles ne pilotent rien.',\n '',\n 'INSTALLATION : Victron ESS SANS injection réseau. Batterie 17,5 kWh utile (plancher sécurité 22%).',\n 'PV ~9 kWc. Borne EVCS 6-28 A. Réseau EDF commutable (cher en HP, bon marché en HC).',\n 'Heures creuses : 01:24-07:24 et 12:24-14:24. Véhicules : Jeep, moto Zéro, VW hybride (arrive en fin d\\'après-midi).',\n '',\n 'STRATÉGIES POSSIBLES (input_select) :',\n '- auto_solar_plus : automatique, solaire prioritaire + réseau HC la nuit si véhicule branché (défaut recommandé)',\n '- auto_solar : automatique, solaire strict (jamais de réseau)',\n '- force_grid_now : charge VE immédiate sur réseau EDF (à réserver aux urgences/gros besoins)',\n '- force_charge_now : charge locale immédiate quelle que soit la source (secours manuel)',\n '- stop : tout couper',\n '',\n 'PRINCIPES : maximiser l\\'autoconsommation solaire ; réseau seulement en HC ou urgence ;',\n 'ne jamais viser un SOC matin < 22% ; forecast demain fort (>=18 kWh) => cible matin basse (~35%),',\n 'moyen (>=10) => ~55%, faible => ~85%. Plafond courant : 20 A max si EDF ON, sinon 22 A auto.',\n '',\n 'Réponds 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 français (max 200 caractères)\"}',\n].join('\\n');\n\nconst userPrompt = [\n `Nous sommes ${nowParis} (heure de Paris).`,\n '',\n 'ÉTAT ACTUEL :',\n `- Batterie : SOC ${ctx.soc}%, courant ${ctx.batteryCurrentA} A (positif = charge)`,\n `- PV : ${Math.round(ctx.pvW)} W instantané, ${Math.round(ctx.pvAvg10mW)} W moyenne 10 min`,\n `- Maison : ${Math.round(ctx.houseW)} W | Réseau EDF : ${ctx.gridOn ? 'ON' : 'OFF'} (AC-IN ${ctx.acinAvailable ? 'présent' : 'absent'})`,\n `- Véhicule : ${ctx.vehicleConnected ? 'BRANCHÉ' : 'non branché'}${ctx.evcsCharging ? `, en charge à ${ctx.evcsCurrentA} A` : ''}`,\n `- État contrôleur : ${ctx.surplusState} | Source : ${ctx.sourceMode}`,\n `- Stratégie réelle actuelle : ${ctx.userStrategy}`,\n `- Besoin véhicule : ${ctx.requiredEnergyKwh} kWh, départ ${ctx.departureTime}${ctx.departurePriority ? ' (priorité départ ON)' : ''}`,\n `- Prévision solaire : ${ctx.todayRemainingKwh} kWh restant aujourd'hui, ${ctx.tomorrowKwh} kWh demain`,\n `- Cible SOC matin actuelle : ${ctx.nightTargetActual}% | HC nuit autorisé : ${ctx.allowGridHcNight ? 'oui' : 'non'}`,\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 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" } }, { @@ -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 // Compteurs accord/divergence sur la stratégie (base des critères de bascule)\n if (advice.strategy === context.userStrategy) {\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\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 // 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" } }, { diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index d749b0d..9c18e31 100644 --- a/workflows/evcs-energy-balance-v1.json +++ b/workflows/evcs-energy-balance-v1.json @@ -111,7 +111,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 40,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\nfunction buildSlots(consumption) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0));\n const consumptionKwh = consumption[hour] ?? 0;\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\n// =====================================================================\n// COURBE D'ENERGIE DE LA BATTERIE — MESUREE, PAS SUPPOSEE (03/09/2026)\n// =====================================================================\n// La conversion energie <-> points de SOC etait une CONSTANTE\n// (`usable_capacity_kwh`, 15 kWh) la ou la realite est une COURBE.\n//\n// Mesure : 576 paliers de SOC sur 8 jours (`tools/mesure-courbe-batterie.mjs`).\n// Pour chaque palier ou le SOC reste a une valeur puis DESCEND d'un point, on\n// integre la puissance batterie — en ecartant les paliers ou la batterie a\n// charge ou ou le reseau a apporte quoi que ce soit.\n//\n// tranche paliers kWh/point\n// 21-31 88 0,097 <- effondrement pres du genou\n// 35-44 78 0,171\n// 45-54 76 0,169\n// 55-64 85 0,163\n// 65-74 80 0,171\n// 75-84 81 0,170\n// 85-94 62 0,169\n//\n// Plate a 0,169 de 33 a 93, genou net a 32-33, puis 0,097 : sous le genou, un\n// point de SOC vaut 57 % de ce qu'il vaut au-dessus. Coherent avec §0 (un\n// module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la\n// jauge deja signale au §28 — ceci lui donne sa courbe.\n//\n// CE QUE LA CONSTANTE FAISAIT : 15 kWh se situe ENTRE les deux pentes, donc\n// l'erreur CHANGE DE SIGNE selon l'energie a couvrir —\n// 1 kWh a couvrir : SOC requis 28,7 % annonce contre 32,3 % reel (-3,6)\n// 4,3 kWh : 50,7 contre 52,1 (-1,4)\n// 8 kWh : 75,3 contre 74,0 (+1,3)\n// 11,7 kWh : 99,7 contre 95,7 (+4,1)\n// Trop BAS en fin de nuit (la direction dangereuse), trop HAUT en debut.\n//\n// Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par\n// le bilan et 13,65 par INPUT_PARSE. Les deux constantes existantes etaient\n// fausses, dans des sens opposes.\n//\n// ⚠️ A RE-MESURER apres toute evolution du parc (extension prevue cet hiver) :\n// `node tools/mesure-courbe-batterie.mjs 8`, puis mettre a jour ces trois\n// nombres. Ce ne sont pas des reglages, ce sont des resultats de mesure.\n//\n// ⚠️ CES PENTES ONT ETE MESUREES SUR SEPT MODULES. Retirer ou ajouter un module\n// divise ou multiplie la capacite sans changer la FORME de la courbe : on les\n// exprime donc par module, et on les multiplie par le nombre reellement en\n// service. Ce nombre se lit dans un helper — `input_number\n// .evcs_balance_modules_en_service` — et non dans le code : le parc a change\n// deux fois en une semaine et changera encore cet hiver. Helper absent ou\n// illisible => 7, la valeur sur laquelle la mesure a ete faite, donc aucun\n// changement de comportement tant que personne ne l'ecrit.\nconst MODULES_MESURE = 7;\nconst modulesEnService = (() => {\n const n = num(input.modules_en_service, MODULES_MESURE);\n return Number.isFinite(n) && n >= 1 && n <= 16 ? n : MODULES_MESURE;\n})();\nconst echelleParc = modulesEnService / MODULES_MESURE;\n\nconst BATTERY_CURVE = {\n kneePct: 33,\n kwhPerPointAbove: 0.169 * echelleParc,\n kwhPerPointBelow: 0.097 * echelleParc,\n};\n\n// ⚠️ LE GENOU EST CONSERVE PAR PRUDENCE, PAS PAR MESURE. L'hypothese la mieux\n// etayee est qu'il vient des cellules faibles du pack ancien, donc qu'il\n// disparait avec lui — auquel cas la pente reste plate et la reserve reelle est\n// MEILLEURE qu'annoncee ici. Le garder revient a se tromper du bon cote.\n// A trancher en remesurant : `node tools/mesure-courbe-batterie.mjs 8`.\n\n// Energie que la batterie peut delivrer en descendant de socHi a socLo.\nfunction energyBetweenSoc(socLo, socHi) {\n if (!Number.isFinite(socLo) || !Number.isFinite(socHi) || socHi <= socLo) return 0;\n const knee = BATTERY_CURVE.kneePct;\n const lowSpan = Math.max(0, Math.min(socHi, knee) - Math.min(socLo, knee));\n const highSpan = Math.max(0, Math.max(socHi, knee) - Math.max(socLo, knee));\n return lowSpan * BATTERY_CURVE.kwhPerPointBelow\n + highSpan * BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC necessaire pour delivrer energyKwh en descendant jusqu'a floorPct.\n// NON ecrete : au-dela de 100 la valeur reste le signal de sous-dimensionnement\n// (cf. §26). L'extrapolation au-dessus de 100 utilise la pente haute.\nfunction socForEnergy(energyKwh, floorPct) {\n if (!Number.isFinite(energyKwh) || energyKwh <= 0) return floorPct;\n const knee = Math.max(floorPct, BATTERY_CURVE.kneePct);\n const belowKwh = (knee - floorPct) * BATTERY_CURVE.kwhPerPointBelow;\n if (energyKwh <= belowKwh) {\n return floorPct + energyKwh / BATTERY_CURVE.kwhPerPointBelow;\n }\n return knee + (energyKwh - belowKwh) / BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC atteint en ajoutant addKwh a partir de socPct.\nfunction socAfterEnergy(socPct, addKwh) {\n if (!Number.isFinite(addKwh) || addKwh <= 0) return socPct;\n return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0);\n}\n\nfunction coverage(slots) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n const requiredSocRawPct = socForEnergy(energyToCoverKwh, socFloorPct);\n const socNeededPct = requiredSocRawPct - socFloorPct;\n const requiredSocPct = Math.min(100, requiredSocRawPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0,\n };\n}\n\nconst typical = coverage(buildSlots(consumptionP50));\nconst prudent = coverage(buildSlots(consumptionP80));\n\n// ---------------------------------------------------------------------\n// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)\n//\n// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire\n// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est\n// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à\n// cette heure-là, car le déficit qui approche est celui de la nuit.\n//\n// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en\n// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint\n// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et\n// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.\n//\n// Profil PRUDENT : contrairement à la décision réseau immédiate, cette\n// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit\n// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.\n// ---------------------------------------------------------------------\nfunction nextDeficitWindow(slots) {\n const firstSurplus = slots.findIndex(s => s.netKwh > 0);\n if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };\n\n const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);\n if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };\n\n let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);\n if (end < 0) end = slots.length;\n\n const energyKwh = slots.slice(start, end)\n .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n\n // Surplus encore récupérable d'ici là : de quoi remplir, ou pas.\n const surplusBeforeKwh = slots.slice(0, start)\n .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);\n\n return {\n found: true,\n startHour: slots[start].hour,\n startMs: slots[start].startMs,\n hoursFromNow: start,\n endHour: end < slots.length ? slots[end].hour : null,\n complete: end < slots.length,\n energyKwh,\n // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie\n // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est\n // exactement le signal de sous-dimensionnement du stockage — l'écrêter le\n // masquerait.\n requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),\n surplusBeforeKwh,\n };\n}\n\nconst nextDeficit = nextDeficitWindow(prudent.slots);\n\n// ---------------------------------------------------------------------\n// RÉSERVE D'ORAGE (27/08/2026)\n//\n// Les coupures réseau sont fréquentes ici lors des orages. Point de départ\n// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et\n// la batterie continuent de fonctionner, seul le filet de secours disparaît.\n// La question n'est donc pas « combien stocker pour survivre » mais « la\n// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ».\n//\n// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut :\n// rien à inventer, aucune constante de plus. Le profil prudent est le bon\n// choix ici parce qu'une coupure retire précisément la possibilité de\n// rattraper une sous-estimation.\n//\n// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est\n// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de\n// l'émetteur, pas un nombre choisi ici.\n// ---------------------------------------------------------------------\nconst STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge'];\nconst STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange');\n\nconst stormRaw = typeof input.storm_alert_level === 'string'\n ? input.storm_alert_level.trim().toLowerCase()\n : '';\nconst stormIndex = STORM_SCALE.indexOf(stormRaw);\nconst stormKnown = stormIndex >= 0;\nconst stormActive = stormIndex >= STORM_ACTIVE_FROM;\n\n// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs\n// (« Orages », « Vent violent »…). Purement informatif.\nconst stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object')\n ? input.storm_alert_detail\n : {};\nconst stormPhenomena = Object.keys(stormDetail)\n .filter(k => typeof stormDetail[k] === 'string'\n && STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM)\n .sort();\n\n// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ;\n// en déficit, c'est celle qu'on est en train de traverser.\nconst inSurplusNow = prudent.found && prudent.crossingIndex === 0;\nconst stormWindow = (inSurplusNow && nextDeficit.found)\n ? {\n kwh: nextDeficit.energyKwh,\n socPct: nextDeficit.requiredSocAtStartPct,\n startHour: nextDeficit.startHour,\n }\n : {\n kwh: prudent.energyToCoverKwh,\n socPct: prudent.requiredSocPct,\n startHour: null,\n };\nconst stormMarginPct = socPct - stormWindow.socPct;\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.\n nextDeficit: nextDeficit.found ? {\n found: true,\n startHour: nextDeficit.startHour,\n hoursFromNow: nextDeficit.hoursFromNow,\n endHour: nextDeficit.endHour,\n complete: nextDeficit.complete,\n energyKwh: round2(nextDeficit.energyKwh),\n requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),\n surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),\n // Franchissable avec le stockage actuel, batterie pleine ?\n feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,\n // Si non : capacité utile manquante. C'est le chiffre du dimensionnement.\n storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n // Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui\n // compte. Publiée en permanence — elle documente le risque même par temps\n // calme ; `active` dit seulement si la vigilance la rend d'actualité.\n storm: {\n level: stormKnown ? stormRaw : 'indisponible',\n known: stormKnown,\n active: stormActive,\n phenomena: stormPhenomena,\n windowStartHour: stormWindow.startHour,\n reserveKwh: round2(stormWindow.kwh),\n reserveSocPct: round1(stormWindow.socPct),\n socMarginPct: round1(stormMarginPct),\n covered: stormMarginPct >= 0,\n shortfallKwh: stormMarginPct < 0\n ? round2(energyBetweenSoc(socPct, stormWindow.socPct))\n : 0,\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" + "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 40,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\nfunction buildSlots(consumption) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0));\n const consumptionKwh = consumption[hour] ?? 0;\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\n// =====================================================================\n// COURBE D'ENERGIE DE LA BATTERIE — MESUREE, PAS SUPPOSEE (03/09/2026)\n// =====================================================================\n// La conversion energie <-> points de SOC etait une CONSTANTE\n// (`usable_capacity_kwh`, 15 kWh) la ou la realite est une COURBE.\n//\n// Mesure : 576 paliers de SOC sur 8 jours (`tools/mesure-courbe-batterie.mjs`).\n// Pour chaque palier ou le SOC reste a une valeur puis DESCEND d'un point, on\n// integre la puissance batterie — en ecartant les paliers ou la batterie a\n// charge ou ou le reseau a apporte quoi que ce soit.\n//\n// tranche paliers kWh/point\n// 21-31 88 0,097 <- effondrement pres du genou\n// 35-44 78 0,171\n// 45-54 76 0,169\n// 55-64 85 0,163\n// 65-74 80 0,171\n// 75-84 81 0,170\n// 85-94 62 0,169\n//\n// Plate a 0,169 de 33 a 93, genou net a 32-33, puis 0,097 : sous le genou, un\n// point de SOC vaut 57 % de ce qu'il vaut au-dessus. Coherent avec §0 (un\n// module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la\n// jauge deja signale au §28 — ceci lui donne sa courbe.\n//\n// CE QUE LA CONSTANTE FAISAIT : 15 kWh se situe ENTRE les deux pentes, donc\n// l'erreur CHANGE DE SIGNE selon l'energie a couvrir —\n// 1 kWh a couvrir : SOC requis 28,7 % annonce contre 32,3 % reel (-3,6)\n// 4,3 kWh : 50,7 contre 52,1 (-1,4)\n// 8 kWh : 75,3 contre 74,0 (+1,3)\n// 11,7 kWh : 99,7 contre 95,7 (+4,1)\n// Trop BAS en fin de nuit (la direction dangereuse), trop HAUT en debut.\n//\n// Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par\n// le bilan et 13,65 par INPUT_PARSE. Les deux constantes existantes etaient\n// fausses, dans des sens opposes.\n//\n// ⚠️ A RE-MESURER apres toute evolution du parc (extension prevue cet hiver) :\n// `node tools/mesure-courbe-batterie.mjs 8`, puis mettre a jour ces trois\n// nombres. Ce ne sont pas des reglages, ce sont des resultats de mesure.\n//\n// ⚠️ CES PENTES ONT ETE MESUREES SUR SEPT MODULES. Retirer ou ajouter un module\n// divise ou multiplie la capacite sans changer la FORME de la courbe : on les\n// exprime donc par module, et on les multiplie par le nombre reellement en\n// service. Ce nombre se lit dans un helper — `input_number\n// .evcs_balance_modules_en_service` — et non dans le code : le parc a change\n// deux fois en une semaine et changera encore cet hiver. Helper absent ou\n// illisible => 7, la valeur sur laquelle la mesure a ete faite, donc aucun\n// changement de comportement tant que personne ne l'ecrit.\nconst MODULES_MESURE = 7;\nconst modulesEnService = (() => {\n const n = num(input.modules_en_service, MODULES_MESURE);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n : MODULES_MESURE;\n})();\nconst echelleParc = modulesEnService / MODULES_MESURE;\n\nconst BATTERY_CURVE = {\n kneePct: 33,\n kwhPerPointAbove: 0.169 * echelleParc,\n kwhPerPointBelow: 0.097 * echelleParc,\n};\n\n// ⚠️ LE GENOU EST CONSERVE PAR PRUDENCE, PAS PAR MESURE. L'hypothese la mieux\n// etayee est qu'il vient des cellules faibles du pack ancien, donc qu'il\n// disparait avec lui — auquel cas la pente reste plate et la reserve reelle est\n// MEILLEURE qu'annoncee ici. Le garder revient a se tromper du bon cote.\n// A trancher en remesurant : `node tools/mesure-courbe-batterie.mjs 8`.\n\n// Energie que la batterie peut delivrer en descendant de socHi a socLo.\nfunction energyBetweenSoc(socLo, socHi) {\n if (!Number.isFinite(socLo) || !Number.isFinite(socHi) || socHi <= socLo) return 0;\n const knee = BATTERY_CURVE.kneePct;\n const lowSpan = Math.max(0, Math.min(socHi, knee) - Math.min(socLo, knee));\n const highSpan = Math.max(0, Math.max(socHi, knee) - Math.max(socLo, knee));\n return lowSpan * BATTERY_CURVE.kwhPerPointBelow\n + highSpan * BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC necessaire pour delivrer energyKwh en descendant jusqu'a floorPct.\n// NON ecrete : au-dela de 100 la valeur reste le signal de sous-dimensionnement\n// (cf. §26). L'extrapolation au-dessus de 100 utilise la pente haute.\nfunction socForEnergy(energyKwh, floorPct) {\n if (!Number.isFinite(energyKwh) || energyKwh <= 0) return floorPct;\n const knee = Math.max(floorPct, BATTERY_CURVE.kneePct);\n const belowKwh = (knee - floorPct) * BATTERY_CURVE.kwhPerPointBelow;\n if (energyKwh <= belowKwh) {\n return floorPct + energyKwh / BATTERY_CURVE.kwhPerPointBelow;\n }\n return knee + (energyKwh - belowKwh) / BATTERY_CURVE.kwhPerPointAbove;\n}\n\n// SOC atteint en ajoutant addKwh a partir de socPct.\nfunction socAfterEnergy(socPct, addKwh) {\n if (!Number.isFinite(addKwh) || addKwh <= 0) return socPct;\n return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0);\n}\n\nfunction coverage(slots) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n const requiredSocRawPct = socForEnergy(energyToCoverKwh, socFloorPct);\n const socNeededPct = requiredSocRawPct - socFloorPct;\n const requiredSocPct = Math.min(100, requiredSocRawPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0,\n };\n}\n\nconst typical = coverage(buildSlots(consumptionP50));\nconst prudent = coverage(buildSlots(consumptionP80));\n\n// ---------------------------------------------------------------------\n// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)\n//\n// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire\n// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est\n// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à\n// cette heure-là, car le déficit qui approche est celui de la nuit.\n//\n// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en\n// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint\n// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et\n// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.\n//\n// Profil PRUDENT : contrairement à la décision réseau immédiate, cette\n// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit\n// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.\n// ---------------------------------------------------------------------\nfunction nextDeficitWindow(slots) {\n const firstSurplus = slots.findIndex(s => s.netKwh > 0);\n if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };\n\n const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);\n if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };\n\n let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);\n if (end < 0) end = slots.length;\n\n const energyKwh = slots.slice(start, end)\n .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n\n // Surplus encore récupérable d'ici là : de quoi remplir, ou pas.\n const surplusBeforeKwh = slots.slice(0, start)\n .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);\n\n return {\n found: true,\n startHour: slots[start].hour,\n startMs: slots[start].startMs,\n hoursFromNow: start,\n endHour: end < slots.length ? slots[end].hour : null,\n complete: end < slots.length,\n energyKwh,\n // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie\n // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est\n // exactement le signal de sous-dimensionnement du stockage — l'écrêter le\n // masquerait.\n requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),\n surplusBeforeKwh,\n };\n}\n\nconst nextDeficit = nextDeficitWindow(prudent.slots);\n\n// ---------------------------------------------------------------------\n// RÉSERVE D'ORAGE (27/08/2026)\n//\n// Les coupures réseau sont fréquentes ici lors des orages. Point de départ\n// souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et\n// la batterie continuent de fonctionner, seul le filet de secours disparaît.\n// La question n'est donc pas « combien stocker pour survivre » mais « la\n// batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ».\n//\n// C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut :\n// rien à inventer, aucune constante de plus. Le profil prudent est le bon\n// choix ici parce qu'une coupure retire précisément la possibilité de\n// rattraper une sous-estimation.\n//\n// Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est\n// vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de\n// l'émetteur, pas un nombre choisi ici.\n// ---------------------------------------------------------------------\nconst STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge'];\nconst STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange');\n\nconst stormRaw = typeof input.storm_alert_level === 'string'\n ? input.storm_alert_level.trim().toLowerCase()\n : '';\nconst stormIndex = STORM_SCALE.indexOf(stormRaw);\nconst stormKnown = stormIndex >= 0;\nconst stormActive = stormIndex >= STORM_ACTIVE_FROM;\n\n// Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs\n// (« Orages », « Vent violent »…). Purement informatif.\nconst stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object')\n ? input.storm_alert_detail\n : {};\nconst stormPhenomena = Object.keys(stormDetail)\n .filter(k => typeof stormDetail[k] === 'string'\n && STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM)\n .sort();\n\n// En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ;\n// en déficit, c'est celle qu'on est en train de traverser.\nconst inSurplusNow = prudent.found && prudent.crossingIndex === 0;\nconst stormWindow = (inSurplusNow && nextDeficit.found)\n ? {\n kwh: nextDeficit.energyKwh,\n socPct: nextDeficit.requiredSocAtStartPct,\n startHour: nextDeficit.startHour,\n }\n : {\n kwh: prudent.energyToCoverKwh,\n socPct: prudent.requiredSocPct,\n startHour: null,\n };\nconst stormMarginPct = socPct - stormWindow.socPct;\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.\n nextDeficit: nextDeficit.found ? {\n found: true,\n startHour: nextDeficit.startHour,\n hoursFromNow: nextDeficit.hoursFromNow,\n endHour: nextDeficit.endHour,\n complete: nextDeficit.complete,\n energyKwh: round2(nextDeficit.energyKwh),\n requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),\n surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),\n // Franchissable avec le stockage actuel, batterie pleine ?\n feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,\n // Si non : capacité utile manquante. C'est le chiffre du dimensionnement.\n storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n // Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui\n // compte. Publiée en permanence — elle documente le risque même par temps\n // calme ; `active` dit seulement si la vigilance la rend d'actualité.\n storm: {\n level: stormKnown ? stormRaw : 'indisponible',\n known: stormKnown,\n active: stormActive,\n phenomena: stormPhenomena,\n windowStartHour: stormWindow.startHour,\n reserveKwh: round2(stormWindow.kwh),\n reserveSocPct: round1(stormWindow.socPct),\n socMarginPct: round1(stormMarginPct),\n covered: stormMarginPct >= 0,\n shortfallKwh: stormMarginPct < 0\n ? round2(energyBetweenSoc(socPct, stormWindow.socPct))\n : 0,\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" } }, { diff --git a/workflows/evcs-surplus-controller-v2.6.json b/workflows/evcs-surplus-controller-v2.6.json index 49f0b4a..9caafe6 100644 --- a/workflows/evcs-surplus-controller-v2.6.json +++ b/workflows/evcs-surplus-controller-v2.6.json @@ -94,7 +94,7 @@ }, { "parameters": { - "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction parseTimeToMinutes(v, fallback = null) {\n const s = (v ?? '').toString().trim();\n const m = s.match(/^(\\d{1,2}):(\\d{2})(?::(\\d{2}))?$/);\n if (!m) return fallback;\n\n const hh = Number(m[1]);\n const mm = Number(m[2]);\n\n if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback;\n return (hh % 24) * 60 + mm;\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n minNow: hour * 60 + Number(parts.minute),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanText(v, fallback = '') {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback;\n return s;\n}\n\n// P11 (12/08/2026) : durée de charge EVCS \"1h 49m 53s\" / \"8m 27s\" / \"0s\"\n// → secondes. Repart de 0 à chaque NOUVELLE connexion physique d'un véhicule\n// (débranchement JEEP → branchement VW) → signal de reset du collant \"plein\".\nfunction parseChargingTimeToSec(s) {\n const t = (s ?? '').toString().toLowerCase();\n if (!t) return null;\n let sec = 0;\n let matched = false;\n const h = t.match(/(\\d+)\\s*h/); if (h) { sec += Number(h[1]) * 3600; matched = true; }\n const m = t.match(/(\\d+)\\s*m/); if (m) { sec += Number(m[1]) * 60; matched = true; }\n const s2 = t.match(/(\\d+)\\s*s/); if (s2){ sec += Number(s2[1]); matched = true; }\n return matched ? sec : null;\n}\n\n// Advisor IA : input_text.evcs_advisor_last_run au format \"DD/MM HH:mm\"\n// (pas d'année). On suppose l'année courante ; si le résultat est dans le\n// futur (> 1 jour), c'est un passage d'année → on retire un an.\nfunction parseAdvisorRunToMs(s) {\n const m = (s ?? '').toString().trim().match(/^(\\d{2})\\/(\\d{2}) (\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const nowMsLocal = Date.now();\n const year = Number(\n new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric' })\n .format(new Date(nowMsLocal))\n );\n\n const build = (y) => {\n const guessUtc = Date.UTC(y, Number(m[2]) - 1, Number(m[1]), Number(m[3]), Number(m[4]), 0);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n };\n\n let ts = build(year);\n if (ts - nowMsLocal > 24 * 3600 * 1000) ts = build(year - 1);\n return ts;\n}\n\nfunction finiteOrNull(v) {\n return Number.isFinite(v) ? v : null;\n}\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\n\nconst nowMs = Date.now();\nconst now = getParisParts(nowMs);\n\nconst CFG = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n\n // 03/09/2026 — valeur MESUREE (576 paliers de SOC sur 8 jours,\n // tools/mesure-courbe-batterie.mjs) : 0,169 kWh par point au-dessus de 33 %,\n // soit 16,9 kWh. Le 17,5 precedent etait une valeur de plaque.\n // NB : sous 33 % un point ne vaut plus que 0,097 kWh. Cette constante ne sert\n // ici qu'a des ecarts SOC -> cible, tous deux au-dessus du genou. Si un calcul\n // devait un jour traverser le genou, il faut la COURBE (src/energy-balance/\n // BALANCE.js) et non cette constante.\n // 16,9 kWh mesures sur SEPT modules (§41), soit 2,414 kWh par module. Le parc\n // ayant change deux fois en une semaine, le nombre de modules se LIT au lieu\n // d'etre code : `input_number.evcs_balance_modules_en_service`, le meme helper\n // que le bilan energetique — les deux decrivent forcement le meme parc.\n // Absent ou illisible => 7, donc aucun changement tant que personne ne l'ecrit.\n batteryUsableCapacityKwh: 2.414 * (() => {\n const n = Number(input.modules_en_service);\n return Number.isFinite(n) && n >= 1 && n <= 16 ? n : 7;\n })(),\n nominalAcVoltageV: 230,\n evcsDedicatedMaxW: 4900,\n systemReserveW: 300,\n\n vebusDcVoltageSoftFloorV: 49.0,\n vebusDcVoltageHardFloorV: 48.6,\n\n departurePriorityWindowMinutes: 240,\n sampleSeconds: 30,\n\n cloudDebtRecoveryThresholdKwh: 0.05,\n cloudDebtMeaningfulThresholdKwh: 0.01,\n};\n\n// =====================================================\n// PARSED SNAPSHOT\n// =====================================================\nconst parsed = {\n soc: clamp(num(input.soc_percent, 0), 0, 100),\n batteryCurrentA: num(input.battery_current_a, 0),\n\n pvPowerW: Math.max(0, num(input.pv_power_w, 0)),\n\n // MODE DE FONCTIONNEMENT DES CHARGEURS SOLAIRES (03/09/2026)\n // Chaque tracker declare lui-meme s'il est a son point de puissance maximale\n // ('mppt_active' : il donne tout ce qu'il a) ou s'il se retient\n // ('voltage_current_limited', 'off' : il y a de la ressource REFUSEE).\n // Lectures inexploitables (integration muette) => listes vides => les gardes\n // aval retombent sur le comportement anterieur. On ne devine pas un etat.\n ...(() => {\n const modes = String(input.mppt_operation_modes || '')\n .split(',')\n .map(s => s.trim().toLowerCase())\n .filter(s => s !== '' && s !== 'unavailable' && s !== 'unknown');\n const atMaxPower = modes.filter(s => s === 'mppt_active').length;\n // Un chargeur qui SE RETIENT (`voltage_current_limited`) prouve qu'il reste\n // de la ressource refusee. Un chargeur `off` n'a rien a donner : la nuit,\n // tous le sont. `mpptHoldingBackCount` confond les deux, ce qui est sans\n // consequence pour la garde de provocation solaire (§37, demarrage de jour\n // seulement) mais ferait descendre la consigne reseau au crepuscule si on\n // s'en servait ici — un oscillateur de plus. On compte donc separement ce\n // qui se declare BRIDE, et tout libelle inconnu retombe du cote prudent\n // (pas de ressource refusee, donc comportement anterieur).\n const limited = modes.filter(s => s.includes('limited')).length;\n return {\n mpptReadableCount: modes.length,\n mpptAtMaxPowerCount: atMaxPower,\n mpptHoldingBackCount: modes.length - atMaxPower,\n mpptLimitedCount: limited,\n };\n })(),\n pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))),\n pvTrendW: num(input.pv_trend_w, 0),\n\n houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)),\n houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))),\n gridPowerW: num(input.grid_power_w, 0),\n\n gridIsOn: low(input.grid_state || 'off') === 'on',\n acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1,\n\n // Consigne de puissance réseau de l'ESS, lue telle qu'elle est appliquée.\n // C'est ELLE qui décide si un contacteur fermé sert à quelque chose : au-dessus\n // du SOC minimal, l'ESS en autoconsommation ne prélève que cette puissance et\n // laisse la batterie porter le reste. Nuit du 01/09 : contacteur fermé,\n // consigne 50 W, batterie à -803 W pour 761 W de maison.\n gridSetpointW: num(input.grid_setpoint_w, NaN),\n gridAvailable:\n (low(input.grid_state || 'off') === 'on') ||\n (num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),\n\n // FENETRE D'ETABLISSEMENT (31/08/2026) — age, en secondes, de la derniere\n // transition du reseau. On retient la plus RECENTE des deux sources : la\n // presence physique AC-IN (celle qui declenche reellement le transitoire) et\n // le contacteur (qui peut bouger sans qu'AC-IN suive, disjoncteur ouvert).\n // Horodatage absent ou nul => age tres grand => aucune fenetre, comportement\n // historique. On ne devine jamais une transition qu'on n'a pas observee.\n gridTransitionAgeSec: (() => {\n const ts = Math.max(\n num(input.acin1_last_changed_ts, 0),\n num(input.grid_switch_last_changed_ts, 0)\n );\n if (!(ts > 0)) return Number.POSITIVE_INFINITY;\n return Math.max(0, (nowMs / 1000) - ts);\n })(),\n\n todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)),\n tomorrowKwh: Math.max(0, num(input.tomorrow_kwh, 0)),\n\n evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on',\n evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on',\n evcsStatusRaw: cleanText(input.evcs_status_raw),\n evcsModeRaw: [\n input.evcs_mode_sensor,\n input.evcs_mode_fallback,\n input.evcs_mode\n ]\n .map(v => cleanText(v))\n .find(v => v) || 'unknown',\n\n evcsSetCurrentA: num(input.evcs_set_current_a, NaN),\n evcsCommandCurrentA: num(input.evcs_command_current_a, NaN),\n\n // Mesure réelle côté borne\n evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)),\n evcsCurrentRealA: Math.max(0, num(input.evcs_current_a, 0)),\n\n evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)),\n evcsChargingTimeSec: parseChargingTimeToSec(input.evcs_charging_time),\n // Energie deja livree par la session EN COURS (kWh). Le capteur de la borne\n // s'appelle \"last_session_energy\" mais il suit la session courante et repart\n // de zero a chaque nouvelle connexion (verifie le 07/09/2026 : 0 -> 0,1 -> 0,2\n // ... pendant la charge). Sert a juger la credibilite d'un statut \"charged\".\n evcsSessionEnergyKwh: Math.max(0, num(input.evcs_session_energy_kwh, 0)),\n evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',\n evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',\n\n controllerEnabled: low(input.controller_enabled || 'off') === 'on',\n notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',\n hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',\n chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',\n controlLatch: low(input.control_latch || 'off') === 'on',\n departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',\n forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',\n\n evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',\n evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),\n evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),\n\n vebusOverload: low(input.vebus_overload || 'off') === 'on',\n vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',\n vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',\n vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',\n\n vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),\n vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),\n vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),\n\n vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),\n vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),\n vebusDcPowerW: num(input.vebus_dc_power_w, NaN),\n\n vebusState: num(input.vebus_state, NaN),\n vebusMainState: num(input.vebus_main_state, NaN),\n};\n\nparsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');\n\n// =====================================================\n// PARAMETERS\n// =====================================================\nconst params = {\n userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',\n\n targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),\n pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),\n pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),\n houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),\n\n batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),\n batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),\n batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),\n batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),\n\n socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),\n socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),\n\n currentMinA: clamp(num(input.current_min_a, 6), 6, 32),\n currentMaxAutoA: clamp(\n num(input.current_max_auto_a, 16),\n clamp(num(input.current_min_a, 6), 6, 32),\n 32\n ),\n gridCurrentMaxA: clamp(num(input.grid_current_max_a, 20), 6, 32),\n currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),\n currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),\n\n currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),\n minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),\n minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),\n\n requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),\n socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),\n endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),\n baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),\n flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),\n endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),\n\n acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),\n batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),\n batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),\n forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),\n forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),\n\n cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),\n cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),\n cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),\n\n // P9 (09/08/2026) : deux véhicules alternent sur la borne.\n // Le sélecteur input_select.victron_evcs_vehicle choisit l'heure de départ\n // effective : VW = victron_evcs_departure_time (~08:50),\n // JEEP = victron_evcs_departure_time_jeep (~06:30).\n selectedVehicle: cleanText(input.evcs_vehicle, 'VW'),\n departureTimeMin:\n low(input.evcs_vehicle || 'vw') === 'jeep'\n ? parseTimeToMinutes(\n input.departure_time_jeep,\n parseTimeToMinutes(input.departure_time, null)\n )\n : parseTimeToMinutes(input.departure_time, null),\n allowGridDepartureUrgent: low(input.allow_grid_departure_urgent || 'off') === 'on',\n dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),\n dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),\n\n hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),\n hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),\n morningStartMin: parseTimeToMinutes(\n input.morning_start_time,\n parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)\n ),\n morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),\n hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),\n hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),\n eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),\n eveningStopMin: parseTimeToMinutes(\n input.evening_stop_time,\n parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)\n ),\n\n allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on',\n allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on',\n\n // Advisor IA — premier câblage réel (09/08/2026, HANDOFF §20) :\n // cible SOC matin recommandée, appliquée UNIQUEMENT si le toggle est ON.\n // Garde-fous consommés dans WINDOWS_SOURCEV2 (statut ok + fraîcheur + bornes).\n advisorApplyNightTarget: low(input.advisor_apply_night_target || 'off') === 'on',\n advisorNightTargetSocPct: num(input.advisor_night_target_soc, NaN),\n advisorStatusOk: cleanText(input.advisor_status).toLowerCase() === 'ok',\n advisorLastRunMs: parseAdvisorRunToMs(cleanText(input.advisor_last_run)),\n\n // BILAN ENERGETIQUE (03/09/2026). Meme format d'horodatage que l'advisor\n // (\"DD/MM HH:mm\"), d'ou la reutilisation du parseur.\n balanceRequiredSocPct: num(input.balance_required_soc, NaN),\n balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN),\n balanceStatusOk: cleanText(input.balance_status).toLowerCase() === 'ok',\n balanceLastRunMs: parseAdvisorRunToMs(cleanText(input.balance_last_run)),\n\n // Duree de la fenetre d'etablissement. Valeur mesuree le 31/08/2026 : le\n // transitoire de fermeture dure ~150 s (import 1985 -> 25 W et 2600 -> 52 W,\n // deux campagnes), on prend 180 s de marge. Reglable sans redeploiement via\n // input_number.evcs_grid_settle_seconds ; absent => 180.\n gridSettleSeconds: clamp(num(input.grid_settle_seconds, 180), 0, 900),\n\n // Consigne réseau au repos. 50 W est le défaut Victron (légèrement positif\n // pour ne jamais injecter), pas une valeur choisie ici.\n // CONSIGNE DE REPOS : 200 W et non plus 50 (04/09/2026).\n // 50 W etait le defaut Victron. Mesure sur la nuit du 03-04/09, 12 074\n // echantillons du point de raccordement : 17 % sont NEGATIFS, mediane -18 W,\n // excursions jusqu'a -356 W, episodes de 35 s au plus. Ce n'est pas de\n // l'injection — la batterie se dechargeait a 744 W au meme moment — c'est le\n // regulateur qui chasse autour d'une cible trop proche de zero. A 200 W le\n // point de fonctionnement reste positif sur l'essentiel des excursions.\n // ⚠️ Ne supprime pas les pointes les plus fortes : une excursion de -356 W\n // passerait encore sous zero. Reduit leur nombre, ne les annule pas.\n // CONSIGNE DE REPOS : 50 -> 200 (04/09) -> 700 (06/09).\n //\n // Elle ne s'applique que contacteur FERME sans session reseau EVCS — donc\n // apres une coupure sur SOC bas, ou apres une fermeture par la regle de\n // protection du parc. Contacteur ouvert, elle n'a aucun effet.\n //\n // A 200 W, une plaque chauffante qui cycle fait passer le point de\n // fonctionnement sous zero : mesure du 06/09 sur la fenetre 12:07-12:22,\n // 9 % des echantillons negatifs, minimum -477 W, mediane des negatifs\n // -146 W. Le regulateur ne rattrape pas assez vite un delestage brutal de\n // ~1 kW. A 700 W toute la courbe monte de 500 W : minimum +23 W, plus aucune\n // excursion negative sur cette fenetre.\n //\n // Le surcout est faible et va dans le bon sens : les DEUX seules raisons de\n // fermer le contacteur sans session EVCS sont « la batterie est a son\n // plancher » et « la maison demande plus que le parc ne sait donner ». Dans\n // les deux cas on VEUT que le reseau contribue plus que 200 W.\n //\n // ⚠️ Ne corrige PAS les gros a-coups. Le 06/09 a 13:41:40, le debranchement\n // du vehicule a supprime 2,2 kW d'un coup alors que la batterie, a 93 % de\n // SOC, n'absorbait rien et que le MPPT produisait 4 kW : -2 819 W pendant\n // plus de deux minutes, le temps que le systeme bride la production. Aucune\n // consigne n'absorbe cela. Ce qui l'evite est la correction du meme jour sur\n // la regle de protection (elle porte desormais sur `maison - PV`) : avec une\n // demande sur le parc de 741 W, le contacteur n'aurait pas ete ferme, et\n // sans reseau il n'y a rien ou injecter.\n gridSetpointIdleW: clamp(num(input.grid_setpoint_idle_w, 700), 0, 1500),\n\n probeEnabled: low(input.probe_enabled || 'off') === 'on',\n probeMinutes: Math.max(1, num(input.probe_minutes, 3)),\n probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),\n probeCurrentA: clamp(\n num(input.probe_current_a, clamp(num(input.current_min_a, 10), 6, 32)),\n clamp(num(input.current_min_a, 10), 6, 32),\n clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 10), 6, 32), 32)\n ),\n\n gridBatterySoftA: num(input.grid_battery_soft_a, -3),\n gridBatteryHardA: num(input.grid_battery_hard_a, -6),\n\n morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)),\n morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0),\n middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)),\n\n morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100),\n middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100),\n eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100),\n eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100),\n\n morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20),\n middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40),\n eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30),\n\n // =====================================================\n // EVCS NIGHT ENGINE — NEW PARAMETERS\n // =====================================================\n evcsNightBatteryHardFloorPct: clamp(\n num(input.evcs_night_battery_hard_floor_pct, 22),\n 0,\n 100\n ),\n\n evcsNightStrongForecastKwh: Math.max(\n 0,\n num(input.evcs_night_strong_forecast_kwh, 18)\n ),\n\n evcsNightMediumForecastKwh: Math.max(\n 0,\n num(input.evcs_night_medium_forecast_kwh, 10)\n ),\n\n evcsNightTargetStrongForecastPct: clamp(\n num(input.evcs_night_target_strong_forecast_pct, 35),\n 0,\n 100\n ),\n\n evcsNightTargetMediumForecastPct: clamp(\n num(input.evcs_night_target_medium_forecast_pct, 55),\n 0,\n 100\n ),\n\n evcsNightTargetWeakForecastPct: clamp(\n num(input.evcs_night_target_weak_forecast_pct, 85),\n 0,\n 100\n ),\n\n evcsNightBatteryRecoveryPowerKw: Math.max(\n 0.1,\n num(input.evcs_night_battery_recovery_power_kw, 2.5)\n ),\n\n evcsNightDepartureSafetyMin: Math.max(\n 0,\n num(input.evcs_night_departure_safety_min, 30)\n ),\n};\n\n// =====================================================\n// HISTORY / PERSISTED HELPERS\n// =====================================================\nconst nightSessionStartSocRaw = num(\n input.evcs_night_session_start_soc ?? input.night_session_start_soc,\n NaN\n);\n\nconst nightSessionStartAtRaw = cleanText(\n input.evcs_night_session_start_at ?? input.night_session_start_at\n);\n\n// =====================================================\n// VEHICLE DEMAND FILTER HISTORY RAW INPUTS\n// =====================================================\nconst evcsNoDemandSinceAtRaw = cleanText(input.evcs_no_demand_since_at);\nconst evcsNoDemandFilterEvalAtRaw = cleanText(input.evcs_no_demand_filter_eval_at);\n\nconst history = {\n lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none',\n blockedUntilAt: cleanText(input.blocked_until),\n probeStartedAt: cleanText(input.probe_started_at),\n lastStartAt: cleanText(input.last_start_at),\n lastStopAt: cleanText(input.last_stop_at),\n lastCurrentChangeAt: cleanText(input.last_current_change_at),\n lastSignature: cleanText(input.last_signature),\n cloudDebtStartedAt: cleanText(input.cloud_debt_started_at),\n evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until),\n\n blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)),\n probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)),\n lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)),\n lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),\n lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),\n cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),\n\n // DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : horodatage du debut du\n // depassement en cours, ecrit par HA_SERVICE_CALLS_OUTPUT. Vide/illisible\n // => null => aucun depassement en cours, comportement de repli sur.\n nightGridSessionCommittedPrev:\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on',\n batterySoftBreachAt: cleanText(input.battery_soft_breach_at),\n batterySoftBreachMsPrev: parseParisStampToMs(cleanText(input.battery_soft_breach_at)),\n evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),\n\n // =====================================================\n // EVCS NIGHT SESSION MEMORY\n // =====================================================\n nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw)\n ? clamp(nightSessionStartSocRaw, 0, 100)\n : null,\n\n nightSessionStartAt: nightSessionStartAtRaw || '',\n nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw),\n\n // =====================================================\n // VEHICLE DEMAND FILTER PERSISTENCE\n // =====================================================\n evcsDeliveryRatioEma: num(input.evcs_delivery_ratio_ema, NaN),\n evcsDeliveryNoiseEma: num(input.evcs_delivery_noise_ema, NaN),\n evcsNoDemandScoreEma: num(input.evcs_no_demand_score_ema, NaN),\n evcsNoDemandSemanticActive: low(input.evcs_no_demand_semantic_active || 'off') === 'on',\n\n evcsNoDemandSinceAt: evcsNoDemandSinceAtRaw || '',\n evcsNoDemandSinceMs: parseParisStampToMs(evcsNoDemandSinceAtRaw),\n\n evcsNoDemandFilterEvalAt: evcsNoDemandFilterEvalAtRaw || '',\n evcsNoDemandFilterEvalMs: parseParisStampToMs(evcsNoDemandFilterEvalAtRaw),\n};\n\n// =====================================================\n// MISE A L'ECHELLE DU PARC (12/09/2026)\n// =====================================================\n// Les bandes ci-dessous sont en AMPERES ABSOLUS, et elles decrivent un parc\n// de 4 modules -- celui sur lequel elles sont aujourd'hui en vigueur et\n// observees justes (l'arret dur du 11/09 a mordu au bon endroit, 87 A pour\n// une limite de 70). Avec quatre US5000 en plus, le parc triple : garder ces\n// ampere-la briderait le nouveau parc au niveau de l'ancien.\n//\n// On les met donc a l'echelle du parc DECLARE — le meme helper que la\n// capacite utile et que le bilan energetique, exprime en equivalents\n// US2000C (un US5000 en vaut DEUX). Une seule valeur a tenir a jour, et les\n// trois consommateurs restent forcement d'accord entre eux.\n//\n// ⚠️ On ne met PAS a l'echelle sur la limite BMS lue en direct : elle\n// s'effondre quand une cellule monte en tension (04/09 : plafond de charge\n// tombe de 150 a 20 A en cinq secondes). Une regle de confort ne doit pas\n// suivre un signal aussi nerveux. En revanche le BMS PLAFONNE : on ne\n// s'autorise jamais plus que ce que le parc declare accepter a l'instant.\n//\n// Parc absent ou illisible => echelle 1 et plafond inactif, donc comportement\n// d'aujourd'hui strictement inchange.\nconst parkScale = (() => {\n const n = Number(input.modules_en_service);\n return Number.isFinite(n) && n >= 1 && n <= 32 ? n / 4 : 1;\n})();\nconst bmsDischargeCapA = (() => {\n const n = Number(input.bms_max_discharge_current_a);\n return Number.isFinite(n) && n > 0 ? n : Infinity;\n})();\nconst bmsChargeCapA = (() => {\n const n = Number(input.bms_max_charge_current_a);\n return Number.isFinite(n) && n > 0 ? n : Infinity;\n})();\n// Decharge : valeurs NEGATIVES, donc le plafond est un `Math.max`.\nconst scaleDischargeA = v => Math.max(-bmsDischargeCapA, v * parkScale);\nconst scaleChargeA = v => Math.min(bmsChargeCapA, Math.max(0, v) * parkScale);\n\n// =====================================================\n// BATTERY TUNING\n// =====================================================\nconst batteryTune = {\n global: {\n houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)),\n solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)),\n stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)),\n stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)),\n forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)),\n overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)),\n overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)),\n overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)),\n },\n\n bands: {\n gt90: {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_gt90_a, -50)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_gt90_a, -100)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_gt90_a, 5)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)),\n },\n\n '80_90': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_80_90_a, -40)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_80_90_a, -80)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_80_90_a, 10)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)),\n },\n\n '60_80': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_60_80_a, -20)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_60_80_a, -50)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_60_80_a, 40)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)),\n recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)),\n },\n\n '40_60': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_40_60_a, -12)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_40_60_a, -30)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_40_60_a, 70)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)),\n recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)),\n },\n\n '25_40': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_25_40_a, -5)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_25_40_a, -15)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_25_40_a, 110)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)),\n recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)),\n },\n\n lt25: {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_lt25_a, -3)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_lt25_a, -8)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_lt25_a, 150)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)),\n recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)),\n },\n },\n};\n\n// =====================================================\n// OPTIONAL DEBUG / SUMMARY\n// =====================================================\nconst evcsNightConfig = {\n batteryHardFloorPct: params.evcsNightBatteryHardFloorPct,\n strongForecastKwh: params.evcsNightStrongForecastKwh,\n mediumForecastKwh: params.evcsNightMediumForecastKwh,\n targetStrongForecastPct: params.evcsNightTargetStrongForecastPct,\n targetMediumForecastPct: params.evcsNightTargetMediumForecastPct,\n targetWeakForecastPct: params.evcsNightTargetWeakForecastPct,\n batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw,\n departureSafetyMin: params.evcsNightDepartureSafetyMin,\n sessionStartSocPct: history.nightSessionStartSocPct,\n sessionStartAt: history.nightSessionStartAt || null,\n};\n\nconst vehicleDemandHistoryDebug = {\n evcsDeliveryRatioEma: finiteOrNull(history.evcsDeliveryRatioEma),\n evcsDeliveryNoiseEma: finiteOrNull(history.evcsDeliveryNoiseEma),\n evcsNoDemandScoreEma: finiteOrNull(history.evcsNoDemandScoreEma),\n evcsNoDemandSemanticActive: history.evcsNoDemandSemanticActive,\n evcsNoDemandSinceAt: history.evcsNoDemandSinceAt || null,\n evcsNoDemandSinceMs: finiteOrNull(history.evcsNoDemandSinceMs),\n evcsNoDemandFilterEvalAt: history.evcsNoDemandFilterEvalAt || null,\n evcsNoDemandFilterEvalMs: finiteOrNull(history.evcsNoDemandFilterEvalMs),\n};\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n raw,\n input,\n nowMs,\n now,\n cfg: CFG,\n parsed,\n params,\n history,\n batteryTune,\n evcsNightConfig,\n vehicleDemandHistoryDebug,\n }\n}];\n" + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction parseTimeToMinutes(v, fallback = null) {\n const s = (v ?? '').toString().trim();\n const m = s.match(/^(\\d{1,2}):(\\d{2})(?::(\\d{2}))?$/);\n if (!m) return fallback;\n\n const hh = Number(m[1]);\n const mm = Number(m[2]);\n\n if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback;\n return (hh % 24) * 60 + mm;\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n minNow: hour * 60 + Number(parts.minute),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanText(v, fallback = '') {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback;\n return s;\n}\n\n// P11 (12/08/2026) : durée de charge EVCS \"1h 49m 53s\" / \"8m 27s\" / \"0s\"\n// → secondes. Repart de 0 à chaque NOUVELLE connexion physique d'un véhicule\n// (débranchement JEEP → branchement VW) → signal de reset du collant \"plein\".\nfunction parseChargingTimeToSec(s) {\n const t = (s ?? '').toString().toLowerCase();\n if (!t) return null;\n let sec = 0;\n let matched = false;\n const h = t.match(/(\\d+)\\s*h/); if (h) { sec += Number(h[1]) * 3600; matched = true; }\n const m = t.match(/(\\d+)\\s*m/); if (m) { sec += Number(m[1]) * 60; matched = true; }\n const s2 = t.match(/(\\d+)\\s*s/); if (s2){ sec += Number(s2[1]); matched = true; }\n return matched ? sec : null;\n}\n\n// Advisor IA : input_text.evcs_advisor_last_run au format \"DD/MM HH:mm\"\n// (pas d'année). On suppose l'année courante ; si le résultat est dans le\n// futur (> 1 jour), c'est un passage d'année → on retire un an.\nfunction parseAdvisorRunToMs(s) {\n const m = (s ?? '').toString().trim().match(/^(\\d{2})\\/(\\d{2}) (\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const nowMsLocal = Date.now();\n const year = Number(\n new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric' })\n .format(new Date(nowMsLocal))\n );\n\n const build = (y) => {\n const guessUtc = Date.UTC(y, Number(m[2]) - 1, Number(m[1]), Number(m[3]), Number(m[4]), 0);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n };\n\n let ts = build(year);\n if (ts - nowMsLocal > 24 * 3600 * 1000) ts = build(year - 1);\n return ts;\n}\n\nfunction finiteOrNull(v) {\n return Number.isFinite(v) ? v : null;\n}\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\n\nconst nowMs = Date.now();\nconst now = getParisParts(nowMs);\n\nconst CFG = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n\n // 03/09/2026 — valeur MESUREE (576 paliers de SOC sur 8 jours,\n // tools/mesure-courbe-batterie.mjs) : 0,169 kWh par point au-dessus de 33 %,\n // soit 16,9 kWh. Le 17,5 precedent etait une valeur de plaque.\n // NB : sous 33 % un point ne vaut plus que 0,097 kWh. Cette constante ne sert\n // ici qu'a des ecarts SOC -> cible, tous deux au-dessus du genou. Si un calcul\n // devait un jour traverser le genou, il faut la COURBE (src/energy-balance/\n // BALANCE.js) et non cette constante.\n // 16,9 kWh mesures sur SEPT modules (§41), soit 2,414 kWh par module. Le parc\n // ayant change deux fois en une semaine, le nombre de modules se LIT au lieu\n // d'etre code : `input_number.evcs_balance_modules_en_service`, le meme helper\n // que le bilan energetique — les deux decrivent forcement le meme parc.\n // Absent ou illisible => 7, donc aucun changement tant que personne ne l'ecrit.\n batteryUsableCapacityKwh: 2.414 * (() => {\n const n = Number(input.modules_en_service);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n : 7;\n })(),\n nominalAcVoltageV: 230,\n evcsDedicatedMaxW: 4900,\n systemReserveW: 300,\n\n vebusDcVoltageSoftFloorV: 49.0,\n vebusDcVoltageHardFloorV: 48.6,\n\n departurePriorityWindowMinutes: 240,\n sampleSeconds: 30,\n\n cloudDebtRecoveryThresholdKwh: 0.05,\n cloudDebtMeaningfulThresholdKwh: 0.01,\n};\n\n// =====================================================\n// PARSED SNAPSHOT\n// =====================================================\nconst parsed = {\n soc: clamp(num(input.soc_percent, 0), 0, 100),\n batteryCurrentA: num(input.battery_current_a, 0),\n\n pvPowerW: Math.max(0, num(input.pv_power_w, 0)),\n\n // MODE DE FONCTIONNEMENT DES CHARGEURS SOLAIRES (03/09/2026)\n // Chaque tracker declare lui-meme s'il est a son point de puissance maximale\n // ('mppt_active' : il donne tout ce qu'il a) ou s'il se retient\n // ('voltage_current_limited', 'off' : il y a de la ressource REFUSEE).\n // Lectures inexploitables (integration muette) => listes vides => les gardes\n // aval retombent sur le comportement anterieur. On ne devine pas un etat.\n ...(() => {\n const modes = String(input.mppt_operation_modes || '')\n .split(',')\n .map(s => s.trim().toLowerCase())\n .filter(s => s !== '' && s !== 'unavailable' && s !== 'unknown');\n const atMaxPower = modes.filter(s => s === 'mppt_active').length;\n // Un chargeur qui SE RETIENT (`voltage_current_limited`) prouve qu'il reste\n // de la ressource refusee. Un chargeur `off` n'a rien a donner : la nuit,\n // tous le sont. `mpptHoldingBackCount` confond les deux, ce qui est sans\n // consequence pour la garde de provocation solaire (§37, demarrage de jour\n // seulement) mais ferait descendre la consigne reseau au crepuscule si on\n // s'en servait ici — un oscillateur de plus. On compte donc separement ce\n // qui se declare BRIDE, et tout libelle inconnu retombe du cote prudent\n // (pas de ressource refusee, donc comportement anterieur).\n const limited = modes.filter(s => s.includes('limited')).length;\n return {\n mpptReadableCount: modes.length,\n mpptAtMaxPowerCount: atMaxPower,\n mpptHoldingBackCount: modes.length - atMaxPower,\n mpptLimitedCount: limited,\n };\n })(),\n pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))),\n pvTrendW: num(input.pv_trend_w, 0),\n\n houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)),\n houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))),\n gridPowerW: num(input.grid_power_w, 0),\n\n gridIsOn: low(input.grid_state || 'off') === 'on',\n acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1,\n\n // Consigne de puissance réseau de l'ESS, lue telle qu'elle est appliquée.\n // C'est ELLE qui décide si un contacteur fermé sert à quelque chose : au-dessus\n // du SOC minimal, l'ESS en autoconsommation ne prélève que cette puissance et\n // laisse la batterie porter le reste. Nuit du 01/09 : contacteur fermé,\n // consigne 50 W, batterie à -803 W pour 761 W de maison.\n gridSetpointW: num(input.grid_setpoint_w, NaN),\n gridAvailable:\n (low(input.grid_state || 'off') === 'on') ||\n (num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),\n\n // FENETRE D'ETABLISSEMENT (31/08/2026) — age, en secondes, de la derniere\n // transition du reseau. On retient la plus RECENTE des deux sources : la\n // presence physique AC-IN (celle qui declenche reellement le transitoire) et\n // le contacteur (qui peut bouger sans qu'AC-IN suive, disjoncteur ouvert).\n // Horodatage absent ou nul => age tres grand => aucune fenetre, comportement\n // historique. On ne devine jamais une transition qu'on n'a pas observee.\n gridTransitionAgeSec: (() => {\n const ts = Math.max(\n num(input.acin1_last_changed_ts, 0),\n num(input.grid_switch_last_changed_ts, 0)\n );\n if (!(ts > 0)) return Number.POSITIVE_INFINITY;\n return Math.max(0, (nowMs / 1000) - ts);\n })(),\n\n todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)),\n tomorrowKwh: Math.max(0, num(input.tomorrow_kwh, 0)),\n\n evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on',\n evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on',\n evcsStatusRaw: cleanText(input.evcs_status_raw),\n evcsModeRaw: [\n input.evcs_mode_sensor,\n input.evcs_mode_fallback,\n input.evcs_mode\n ]\n .map(v => cleanText(v))\n .find(v => v) || 'unknown',\n\n evcsSetCurrentA: num(input.evcs_set_current_a, NaN),\n evcsCommandCurrentA: num(input.evcs_command_current_a, NaN),\n\n // Mesure réelle côté borne\n evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)),\n evcsCurrentRealA: Math.max(0, num(input.evcs_current_a, 0)),\n\n evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)),\n evcsChargingTimeSec: parseChargingTimeToSec(input.evcs_charging_time),\n // Energie deja livree par la session EN COURS (kWh). Le capteur de la borne\n // s'appelle \"last_session_energy\" mais il suit la session courante et repart\n // de zero a chaque nouvelle connexion (verifie le 07/09/2026 : 0 -> 0,1 -> 0,2\n // ... pendant la charge). Sert a juger la credibilite d'un statut \"charged\".\n evcsSessionEnergyKwh: Math.max(0, num(input.evcs_session_energy_kwh, 0)),\n evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',\n evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',\n\n controllerEnabled: low(input.controller_enabled || 'off') === 'on',\n notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',\n hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',\n chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',\n controlLatch: low(input.control_latch || 'off') === 'on',\n departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',\n forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',\n\n evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',\n evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),\n evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),\n\n vebusOverload: low(input.vebus_overload || 'off') === 'on',\n vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',\n vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',\n vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',\n\n vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),\n vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),\n vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),\n\n vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),\n vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),\n vebusDcPowerW: num(input.vebus_dc_power_w, NaN),\n\n vebusState: num(input.vebus_state, NaN),\n vebusMainState: num(input.vebus_main_state, NaN),\n};\n\nparsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');\n\n// =====================================================\n// PARAMETERS\n// =====================================================\nconst params = {\n userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',\n\n targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),\n pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),\n pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),\n houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),\n\n batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),\n batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),\n batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),\n batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),\n\n socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),\n socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),\n\n currentMinA: clamp(num(input.current_min_a, 6), 6, 32),\n currentMaxAutoA: clamp(\n num(input.current_max_auto_a, 16),\n clamp(num(input.current_min_a, 6), 6, 32),\n 32\n ),\n gridCurrentMaxA: clamp(num(input.grid_current_max_a, 20), 6, 32),\n currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),\n currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),\n\n currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),\n minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),\n minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),\n\n requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),\n socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),\n endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),\n baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),\n flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),\n endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),\n\n acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),\n batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),\n batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),\n forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),\n forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),\n\n cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),\n cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),\n cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),\n\n // P9 (09/08/2026) : deux véhicules alternent sur la borne.\n // Le sélecteur input_select.victron_evcs_vehicle choisit l'heure de départ\n // effective : VW = victron_evcs_departure_time (~08:50),\n // JEEP = victron_evcs_departure_time_jeep (~06:30).\n selectedVehicle: cleanText(input.evcs_vehicle, 'VW'),\n departureTimeMin:\n low(input.evcs_vehicle || 'vw') === 'jeep'\n ? parseTimeToMinutes(\n input.departure_time_jeep,\n parseTimeToMinutes(input.departure_time, null)\n )\n : parseTimeToMinutes(input.departure_time, null),\n allowGridDepartureUrgent: low(input.allow_grid_departure_urgent || 'off') === 'on',\n dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),\n dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),\n\n hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),\n hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),\n morningStartMin: parseTimeToMinutes(\n input.morning_start_time,\n parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)\n ),\n morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),\n hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),\n hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),\n eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),\n eveningStopMin: parseTimeToMinutes(\n input.evening_stop_time,\n parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)\n ),\n\n allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on',\n allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on',\n\n // Advisor IA — premier câblage réel (09/08/2026, HANDOFF §20) :\n // cible SOC matin recommandée, appliquée UNIQUEMENT si le toggle est ON.\n // Garde-fous consommés dans WINDOWS_SOURCEV2 (statut ok + fraîcheur + bornes).\n advisorApplyNightTarget: low(input.advisor_apply_night_target || 'off') === 'on',\n advisorNightTargetSocPct: num(input.advisor_night_target_soc, NaN),\n advisorStatusOk: cleanText(input.advisor_status).toLowerCase() === 'ok',\n advisorLastRunMs: parseAdvisorRunToMs(cleanText(input.advisor_last_run)),\n\n // BILAN ENERGETIQUE (03/09/2026). Meme format d'horodatage que l'advisor\n // (\"DD/MM HH:mm\"), d'ou la reutilisation du parseur.\n balanceRequiredSocPct: num(input.balance_required_soc, NaN),\n balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN),\n balanceStatusOk: cleanText(input.balance_status).toLowerCase() === 'ok',\n balanceLastRunMs: parseAdvisorRunToMs(cleanText(input.balance_last_run)),\n\n // Duree de la fenetre d'etablissement. Valeur mesuree le 31/08/2026 : le\n // transitoire de fermeture dure ~150 s (import 1985 -> 25 W et 2600 -> 52 W,\n // deux campagnes), on prend 180 s de marge. Reglable sans redeploiement via\n // input_number.evcs_grid_settle_seconds ; absent => 180.\n gridSettleSeconds: clamp(num(input.grid_settle_seconds, 180), 0, 900),\n\n // Consigne réseau au repos. 50 W est le défaut Victron (légèrement positif\n // pour ne jamais injecter), pas une valeur choisie ici.\n // CONSIGNE DE REPOS : 200 W et non plus 50 (04/09/2026).\n // 50 W etait le defaut Victron. Mesure sur la nuit du 03-04/09, 12 074\n // echantillons du point de raccordement : 17 % sont NEGATIFS, mediane -18 W,\n // excursions jusqu'a -356 W, episodes de 35 s au plus. Ce n'est pas de\n // l'injection — la batterie se dechargeait a 744 W au meme moment — c'est le\n // regulateur qui chasse autour d'une cible trop proche de zero. A 200 W le\n // point de fonctionnement reste positif sur l'essentiel des excursions.\n // ⚠️ Ne supprime pas les pointes les plus fortes : une excursion de -356 W\n // passerait encore sous zero. Reduit leur nombre, ne les annule pas.\n // CONSIGNE DE REPOS : 50 -> 200 (04/09) -> 700 (06/09).\n //\n // Elle ne s'applique que contacteur FERME sans session reseau EVCS — donc\n // apres une coupure sur SOC bas, ou apres une fermeture par la regle de\n // protection du parc. Contacteur ouvert, elle n'a aucun effet.\n //\n // A 200 W, une plaque chauffante qui cycle fait passer le point de\n // fonctionnement sous zero : mesure du 06/09 sur la fenetre 12:07-12:22,\n // 9 % des echantillons negatifs, minimum -477 W, mediane des negatifs\n // -146 W. Le regulateur ne rattrape pas assez vite un delestage brutal de\n // ~1 kW. A 700 W toute la courbe monte de 500 W : minimum +23 W, plus aucune\n // excursion negative sur cette fenetre.\n //\n // Le surcout est faible et va dans le bon sens : les DEUX seules raisons de\n // fermer le contacteur sans session EVCS sont « la batterie est a son\n // plancher » et « la maison demande plus que le parc ne sait donner ». Dans\n // les deux cas on VEUT que le reseau contribue plus que 200 W.\n //\n // ⚠️ Ne corrige PAS les gros a-coups. Le 06/09 a 13:41:40, le debranchement\n // du vehicule a supprime 2,2 kW d'un coup alors que la batterie, a 93 % de\n // SOC, n'absorbait rien et que le MPPT produisait 4 kW : -2 819 W pendant\n // plus de deux minutes, le temps que le systeme bride la production. Aucune\n // consigne n'absorbe cela. Ce qui l'evite est la correction du meme jour sur\n // la regle de protection (elle porte desormais sur `maison - PV`) : avec une\n // demande sur le parc de 741 W, le contacteur n'aurait pas ete ferme, et\n // sans reseau il n'y a rien ou injecter.\n gridSetpointIdleW: clamp(num(input.grid_setpoint_idle_w, 700), 0, 1500),\n\n probeEnabled: low(input.probe_enabled || 'off') === 'on',\n probeMinutes: Math.max(1, num(input.probe_minutes, 3)),\n probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),\n probeCurrentA: clamp(\n num(input.probe_current_a, clamp(num(input.current_min_a, 10), 6, 32)),\n clamp(num(input.current_min_a, 10), 6, 32),\n clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 10), 6, 32), 32)\n ),\n\n gridBatterySoftA: num(input.grid_battery_soft_a, -3),\n gridBatteryHardA: num(input.grid_battery_hard_a, -6),\n\n morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)),\n morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0),\n middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)),\n\n morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100),\n middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100),\n eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100),\n eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100),\n\n morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20),\n middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40),\n eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30),\n\n // =====================================================\n // EVCS NIGHT ENGINE — NEW PARAMETERS\n // =====================================================\n evcsNightBatteryHardFloorPct: clamp(\n num(input.evcs_night_battery_hard_floor_pct, 22),\n 0,\n 100\n ),\n\n evcsNightStrongForecastKwh: Math.max(\n 0,\n num(input.evcs_night_strong_forecast_kwh, 18)\n ),\n\n evcsNightMediumForecastKwh: Math.max(\n 0,\n num(input.evcs_night_medium_forecast_kwh, 10)\n ),\n\n evcsNightTargetStrongForecastPct: clamp(\n num(input.evcs_night_target_strong_forecast_pct, 35),\n 0,\n 100\n ),\n\n evcsNightTargetMediumForecastPct: clamp(\n num(input.evcs_night_target_medium_forecast_pct, 55),\n 0,\n 100\n ),\n\n evcsNightTargetWeakForecastPct: clamp(\n num(input.evcs_night_target_weak_forecast_pct, 85),\n 0,\n 100\n ),\n\n evcsNightBatteryRecoveryPowerKw: Math.max(\n 0.1,\n num(input.evcs_night_battery_recovery_power_kw, 2.5)\n ),\n\n evcsNightDepartureSafetyMin: Math.max(\n 0,\n num(input.evcs_night_departure_safety_min, 30)\n ),\n};\n\n// =====================================================\n// HISTORY / PERSISTED HELPERS\n// =====================================================\nconst nightSessionStartSocRaw = num(\n input.evcs_night_session_start_soc ?? input.night_session_start_soc,\n NaN\n);\n\nconst nightSessionStartAtRaw = cleanText(\n input.evcs_night_session_start_at ?? input.night_session_start_at\n);\n\n// =====================================================\n// VEHICLE DEMAND FILTER HISTORY RAW INPUTS\n// =====================================================\nconst evcsNoDemandSinceAtRaw = cleanText(input.evcs_no_demand_since_at);\nconst evcsNoDemandFilterEvalAtRaw = cleanText(input.evcs_no_demand_filter_eval_at);\n\nconst history = {\n lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none',\n blockedUntilAt: cleanText(input.blocked_until),\n probeStartedAt: cleanText(input.probe_started_at),\n lastStartAt: cleanText(input.last_start_at),\n lastStopAt: cleanText(input.last_stop_at),\n lastCurrentChangeAt: cleanText(input.last_current_change_at),\n lastSignature: cleanText(input.last_signature),\n cloudDebtStartedAt: cleanText(input.cloud_debt_started_at),\n evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until),\n\n blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)),\n probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)),\n lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)),\n lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),\n lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),\n cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),\n\n // DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : horodatage du debut du\n // depassement en cours, ecrit par HA_SERVICE_CALLS_OUTPUT. Vide/illisible\n // => null => aucun depassement en cours, comportement de repli sur.\n nightGridSessionCommittedPrev:\n (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on',\n batterySoftBreachAt: cleanText(input.battery_soft_breach_at),\n batterySoftBreachMsPrev: parseParisStampToMs(cleanText(input.battery_soft_breach_at)),\n evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),\n\n // =====================================================\n // EVCS NIGHT SESSION MEMORY\n // =====================================================\n nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw)\n ? clamp(nightSessionStartSocRaw, 0, 100)\n : null,\n\n nightSessionStartAt: nightSessionStartAtRaw || '',\n nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw),\n\n // =====================================================\n // VEHICLE DEMAND FILTER PERSISTENCE\n // =====================================================\n evcsDeliveryRatioEma: num(input.evcs_delivery_ratio_ema, NaN),\n evcsDeliveryNoiseEma: num(input.evcs_delivery_noise_ema, NaN),\n evcsNoDemandScoreEma: num(input.evcs_no_demand_score_ema, NaN),\n evcsNoDemandSemanticActive: low(input.evcs_no_demand_semantic_active || 'off') === 'on',\n\n evcsNoDemandSinceAt: evcsNoDemandSinceAtRaw || '',\n evcsNoDemandSinceMs: parseParisStampToMs(evcsNoDemandSinceAtRaw),\n\n evcsNoDemandFilterEvalAt: evcsNoDemandFilterEvalAtRaw || '',\n evcsNoDemandFilterEvalMs: parseParisStampToMs(evcsNoDemandFilterEvalAtRaw),\n};\n\n// =====================================================\n// MISE A L'ECHELLE DU PARC (12/09/2026)\n// =====================================================\n// Les bandes ci-dessous sont en AMPERES ABSOLUS, et elles decrivent un parc\n// de 4 modules -- celui sur lequel elles sont aujourd'hui en vigueur et\n// observees justes (l'arret dur du 11/09 a mordu au bon endroit, 87 A pour\n// une limite de 70). Avec quatre US5000 en plus, le parc triple : garder ces\n// ampere-la briderait le nouveau parc au niveau de l'ancien.\n//\n// On les met donc a l'echelle du parc DECLARE — le meme helper que la\n// capacite utile et que le bilan energetique, exprime en equivalents\n// US2000C (un US5000 en vaut DEUX). Une seule valeur a tenir a jour, et les\n// trois consommateurs restent forcement d'accord entre eux.\n//\n// ⚠️ On ne met PAS a l'echelle sur la limite BMS lue en direct : elle\n// s'effondre quand une cellule monte en tension (04/09 : plafond de charge\n// tombe de 150 a 20 A en cinq secondes). Une regle de confort ne doit pas\n// suivre un signal aussi nerveux. En revanche le BMS PLAFONNE : on ne\n// s'autorise jamais plus que ce que le parc declare accepter a l'instant.\n//\n// Parc absent ou illisible => echelle 1 et plafond inactif, donc comportement\n// d'aujourd'hui strictement inchange.\nconst parkScale = (() => {\n const n = Number(input.modules_en_service);\n return Number.isFinite(n) && n >= 1 && n <= 24 ? n / 4 : 1;\n})();\nconst bmsDischargeCapA = (() => {\n const n = Number(input.bms_max_discharge_current_a);\n return Number.isFinite(n) && n > 0 ? n : Infinity;\n})();\nconst bmsChargeCapA = (() => {\n const n = Number(input.bms_max_charge_current_a);\n return Number.isFinite(n) && n > 0 ? n : Infinity;\n})();\n// Decharge : valeurs NEGATIVES, donc le plafond est un `Math.max`.\nconst scaleDischargeA = v => Math.max(-bmsDischargeCapA, v * parkScale);\nconst scaleChargeA = v => Math.min(bmsChargeCapA, Math.max(0, v) * parkScale);\n\n// =====================================================\n// BATTERY TUNING\n// =====================================================\nconst batteryTune = {\n global: {\n houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)),\n solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)),\n stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)),\n stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)),\n forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)),\n overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)),\n overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)),\n overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)),\n },\n\n bands: {\n gt90: {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_gt90_a, -50)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_gt90_a, -100)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_gt90_a, 5)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)),\n },\n\n '80_90': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_80_90_a, -40)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_80_90_a, -80)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_80_90_a, 10)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)),\n recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)),\n },\n\n '60_80': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_60_80_a, -20)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_60_80_a, -50)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_60_80_a, 40)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)),\n recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)),\n },\n\n '40_60': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_40_60_a, -12)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_40_60_a, -30)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_40_60_a, 70)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)),\n recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)),\n },\n\n '25_40': {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_25_40_a, -5)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_25_40_a, -15)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_25_40_a, 110)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)),\n recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)),\n },\n\n lt25: {\n dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_lt25_a, -3)),\n dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_lt25_a, -8)),\n chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_lt25_a, 150)),\n softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)),\n hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)),\n recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)),\n },\n },\n};\n\n// =====================================================\n// OPTIONAL DEBUG / SUMMARY\n// =====================================================\nconst evcsNightConfig = {\n batteryHardFloorPct: params.evcsNightBatteryHardFloorPct,\n strongForecastKwh: params.evcsNightStrongForecastKwh,\n mediumForecastKwh: params.evcsNightMediumForecastKwh,\n targetStrongForecastPct: params.evcsNightTargetStrongForecastPct,\n targetMediumForecastPct: params.evcsNightTargetMediumForecastPct,\n targetWeakForecastPct: params.evcsNightTargetWeakForecastPct,\n batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw,\n departureSafetyMin: params.evcsNightDepartureSafetyMin,\n sessionStartSocPct: history.nightSessionStartSocPct,\n sessionStartAt: history.nightSessionStartAt || null,\n};\n\nconst vehicleDemandHistoryDebug = {\n evcsDeliveryRatioEma: finiteOrNull(history.evcsDeliveryRatioEma),\n evcsDeliveryNoiseEma: finiteOrNull(history.evcsDeliveryNoiseEma),\n evcsNoDemandScoreEma: finiteOrNull(history.evcsNoDemandScoreEma),\n evcsNoDemandSemanticActive: history.evcsNoDemandSemanticActive,\n evcsNoDemandSinceAt: history.evcsNoDemandSinceAt || null,\n evcsNoDemandSinceMs: finiteOrNull(history.evcsNoDemandSinceMs),\n evcsNoDemandFilterEvalAt: history.evcsNoDemandFilterEvalAt || null,\n evcsNoDemandFilterEvalMs: finiteOrNull(history.evcsNoDemandFilterEvalMs),\n};\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n raw,\n input,\n nowMs,\n now,\n cfg: CFG,\n parsed,\n params,\n history,\n batteryTune,\n evcsNightConfig,\n vehicleDemandHistoryDebug,\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2,