// Bornes de la consigne de puissance reseau. Le maximum est celui declare par // l'entite elle-meme (number.airtria_mpii_v1_ac_power_setpoint_l1, max 10000 W), // pas un chiffre choisi ici. La bande morte evite de reecrire la meme valeur // toutes les 30 s sans empecher la correction d'une derive externe. const GRID_SETPOINT_MAX_W = 10000; const GRID_SETPOINT_DEADBAND_W = 100; function round1(v) { return Math.round(v * 10) / 10; } function round2(v) { return Math.round(v * 100) / 100; } function round3(v) { return Math.round(v * 1000) / 1000; } function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } function to2(n) { return String(n).padStart(2, '0'); } function formatParisStampFromMs(ms) { const fmt = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = Object.fromEntries( fmt.formatToParts(new Date(ms)) .filter(p => p.type !== 'literal') .map(p => [p.type, p.value]) ); const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`; } const prev = $input.first().json; const { input, now, nowMs, cfg, parsed, params, history, strategy, windows, lifecycle, context, derived, batteryTune, batteryPolicy, locks, cloud, modes, safeguards, conditions, decision, nightEvcs, vehicleDemandFilter, } = prev; // ===================================================== // SAFE ACCESS NIGHT EVCS // ===================================================== const night = nightEvcs ?? { nightSessionActive: false, nightSessionNeedsCapture: false, nightSessionNeedsClear: false, nightSessionStartSocPct: null, nightSessionStartAt: '', batteryHardFloorPct: null, batteryMorningTargetPct: null, maxBatteryDischargePoints: null, gridTakeoverTriggerPct: null, batteryRecoveryUrgent: false, nightGridTakeoverNeeded: false, nightPreferredSourceMode: 'blocked', }; // ===================================================== // SAFE ACCESS VEHICLE DEMAND FILTER // ===================================================== const vdf = vehicleDemandFilter ?? { dtSec: null, commandCurrentA: null, realCurrentA: null, expectedPowerW: null, realPowerW: null, currentDeliveryRatioRaw: null, powerDeliveryRatioRaw: null, deliveryRatioRaw: null, deliveryRatioEma: null, deliveryNoiseEma: null, adaptiveQuietBand: null, noDemandEvidenceRaw: null, sessionElapsedSec: null, sessionArmFactor: null, commandFactor: null, sourceDeliverableForNoDemand: false, evcsSessionArmedForNoDemand: false, noDemandScoreRaw: null, noDemandScoreEma: null, noDemandScoreOn: null, noDemandScoreOff: null, evcsVehicleNoDemandSemantic: false, evcsNoDemandSinceMs: null, evcsNoDemandSinceMinutes: null, nextHistory: { evcsDeliveryRatioEma: null, evcsDeliveryNoiseEma: null, evcsNoDemandScoreEma: null, evcsNoDemandSemanticActive: false, evcsNoDemandSinceMs: null, evcsNoDemandFilterEvalMs: null } }; // ===================================================== // FINAL ACTIONS // ===================================================== let desiredCurrentA = decision.desiredCurrentA; desiredCurrentA = decision.desiredCharging ? clamp( desiredCurrentA, params.currentMinA, Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA) ) : params.currentMinA; const chargingAction = decision.desiredCharging === parsed.evcsChargingSwitchOn ? 'none' : decision.desiredCharging ? 'turn_on' : 'turn_off'; const permissionAction = decision.desiredPermission === parsed.chargePermissionNow ? 'none' : decision.desiredPermission ? 'turn_on' : 'turn_off'; const latchAction = decision.desiredLatch === parsed.controlLatch ? 'none' : decision.desiredLatch ? 'turn_on' : 'turn_off'; const autoStartAction = parsed.controllerEnabled && parsed.evcsVehicleConnected && parsed.evcsAutoStartSwitchOn ? 'turn_off' : 'none'; const currentAction = decision.desiredCharging && ( !Number.isFinite(derived.currentReferenceA) || Math.abs(desiredCurrentA - derived.currentReferenceA) >= 0.5 ) ? 'set' : 'none'; const setLastStartAt = chargingAction === 'turn_on'; const setLastStopAt = chargingAction === 'turn_off'; const setLastCurrentChangeAt = currentAction === 'set'; // ===================================================== // CLEAN BLOCKING LOGIC // ===================================================== const blockedUntilToWrite = decision.nextBlockReason === 'none' ? now.stamp : decision.nextBlockedUntilStamp; const needsBlockReasonUpdate = decision.nextBlockReason !== history.lastBlockReason; // ===================================================== // GLOBAL EVCS PRIORITY // AUTO + OVERRIDE MANUEL // ===================================================== const forceWindowMinutes = Math.max( 30, batteryTune?.global?.forceModeAutoRevertMinutes ?? 120 ); const activeChargeIntent = decision.desiredCharging || ['start', 'hold', 'step_up', 'step_down', 'step_down_fast'].includes(decision.currentDecision); const evcsVehicleNotRequestingPowerState = decision.state === 'vehicle_full_or_paused' || decision.currentDecision === 'stop_vehicle_full_or_paused'; // ------------------------- // PRIORITÉ AUTO // ------------------------- let autoEvcsPriorityActive = false; let autoEnergyPriorityMode = 'normal'; let autoEnergyPriorityReason = 'Aucune priorité EVCS'; let autoEvcsPriorityUntil = now.stamp; if ( parsed.evcsVehicleConnected && !strategy.isStopMode && !evcsVehicleNotRequestingPowerState ) { if (strategy.forceChargeRequested || strategy.forceGridRequested) { autoEvcsPriorityActive = true; autoEnergyPriorityMode = 'evcs_priority'; autoEnergyPriorityReason = strategy.forceGridRequested ? 'EVCS prioritaire : force_grid_now' : 'EVCS prioritaire : force_charge_now'; autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + forceWindowMinutes * 60000); } else if (night.nightSessionActive) { autoEvcsPriorityActive = true; autoEnergyPriorityMode = 'evcs_priority'; autoEnergyPriorityReason = night.batteryRecoveryUrgent ? 'EVCS prioritaire : session nuit + urgence départ' : night.nightGridTakeoverNeeded ? 'EVCS prioritaire : session nuit + takeover réseau' : 'EVCS prioritaire : session nuit active'; autoEvcsPriorityUntil = Number.isFinite(lifecycle.minutesUntilDeparture) ? formatParisStampFromMs( nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000 ) : formatParisStampFromMs(nowMs + forceWindowMinutes * 60000); } else if (context.departurePriorityActive && Number.isFinite(lifecycle.minutesUntilDeparture)) { autoEvcsPriorityActive = true; autoEnergyPriorityMode = 'evcs_priority'; autoEnergyPriorityReason = `EVCS prioritaire : départ dans ${Math.round(lifecycle.minutesUntilDeparture)} min`; autoEvcsPriorityUntil = formatParisStampFromMs( nowMs + Math.max(1, lifecycle.minutesUntilDeparture) * 60000 ); } else if (activeChargeIntent && context.profile === 'midday_hc_or_solar') { autoEvcsPriorityActive = true; autoEnergyPriorityMode = 'evcs_priority'; autoEnergyPriorityReason = 'EVCS prioritaire : recharge active fenêtre midi'; autoEvcsPriorityUntil = formatParisStampFromMs(nowMs + 90 * 60000); } } // ------------------------- // OVERRIDE MANUEL // ------------------------- const manualPriorityOverrideActive = parsed.evcsPriorityManualOverride && parsed.evcsPriorityManualMode === 'evcs_priority' && history.evcsPriorityManualUntilMs !== null && history.evcsPriorityManualUntilMs > nowMs + 1000; let evcsPriorityActive = autoEvcsPriorityActive; let energyPriorityMode = autoEnergyPriorityMode; let energyPriorityReason = autoEnergyPriorityReason; let evcsPriorityUntil = autoEvcsPriorityUntil; let evcsPrioritySource = 'auto'; if (manualPriorityOverrideActive && !evcsVehicleNotRequestingPowerState) { evcsPriorityActive = true; energyPriorityMode = 'evcs_priority'; energyPriorityReason = parsed.evcsPriorityManualReason || 'EVCS prioritaire : override manuel'; evcsPriorityUntil = history.evcsPriorityManualUntilAt || autoEvcsPriorityUntil; evcsPrioritySource = 'manual'; } // ===================================================== // REASONS / LABELS / SIGNATURE // ===================================================== const reason = [...decision.reason]; // Budget voiture de la nuit (30/09) : dire pourquoi la voiture s'arrete. if (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) { const f = (x) => String(Math.round(x * 100) / 100).replace('.', ','); reason.push(`Budget batterie nuit ${f(night.nightBudgetDeliveredKwh ?? 0)}/${f(night.nightBudgetKwh)} kWh` + (night.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil' : night.nightBudgetReached ? ' — atteint, EDF finit la charge' : '')); } else if (night.nightCarWaitsNoGrid) { reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil'); } reason.push(`Stratégie=${strategy.userStrategy}`); reason.push(`Profil=${context.profile}`); reason.push(`Source=${context.sourceMode}`); reason.push( `Mode=${ modes.batteryFullHarvestMode ? 'battery_full_harvest_mode' : modes.forecastCommitMode ? 'forecast_commit_mode' : 'battery_priority_mode' }` ); reason.push(`SOC=${round1(parsed.soc)}%`); reason.push(`Batt=${round1(parsed.batteryCurrentA)}A`); reason.push(`PV=${Math.round(parsed.pvPowerW)}W`); reason.push(`PV10=${Math.round(parsed.pvPowerAvg10mW)}W`); // ===================================================== // CONSIGNE DE PUISSANCE RESEAU (01/09/2026) // ===================================================== // Fermer le contacteur ne suffit PAS. Au-dessus du SOC minimal, l'ESS en // autoconsommation ne preleve que `ac_power_setpoint` et laisse la batterie // porter tout le reste : nuit du 01/09, contacteur ferme, consigne 50 W, // batterie a -803 W pour 761 W de maison, voiture arretee par le garde // "batterie trop sollicitee". La consigne est donc le levier qui rend une // session reseau REELLE, et il doit appartenir au controleur, pas rester un // reglage manuel permanent (celui-ci provoquait la substitution du solaire // par le reseau en journee, cf. HANDOFF 27/28). // // La valeur n'est pas une constante : c'est le besoin mesure du moment, la // maison telle qu'elle consomme plus la voiture au courant que le controleur // s'autorise. Le plafond joue le role de marge : quand la voiture n'est pas a // son plafond, la consigne est deja genereuse, et l'exces ne peut pas partir // dans la batterie (DVCC tenue a 2 A par l'Auto Charge Manager pendant un // takeover). Hors session, retour a la valeur de repos. // ⚠️ LA SESSION ENGAGEE COMPTE AUSSI (04/09/2026). // Nuit du 03-04/09 : la session reseau unique a tenu le contacteur toute la // nuit — une seule bascule, comme voulu. Mais a 02:50, la voiture finie, le // sourceMode est passe a 'blocked' et la consigne est retombee de 5078 W a // 50 W. Contacteur ferme, reseau disponible, heures creuses, et l'ESS ne // demandait que 50 W pendant que la maison tirait 770 W SUR LA BATTERIE. // Trois heures et demie, SOC 44 % -> 17 %, 2,60 kWh pris a la batterie quand // le reseau etait la et bon marche. // // C'est le defaut du 01/09 revenu ailleurs : fermer le contacteur ne suffit // pas, la consigne doit demander la puissance. J'avais conditionne la consigne // au sourceMode, puis cree la veille une NOUVELLE raison de garder le // contacteur ferme — l'engagement de session — sans la brancher ici. La // session tenait le contacteur, pas l'energie. // // Regle : tant que le contacteur est ferme A NOTRE DEMANDE, la consigne doit // couvrir la maison. Peu importe laquelle des deux raisons le tient. const gridSessionActive = parsed.acInAvailable && ( context.sourceMode === 'grid_forced' || context.sourceMode === 'grid_hc_allowed' || night.nightGridSessionCommitted === true ); // ===================================================== // DIMENSIONNEMENT SUR LE DEFICIT DU BILAN (18/09/2026) // ===================================================== // Ce que la consigne remplace : `maison + plafond de la borne`, ou le plafond // de la borne n'a AUCUNE relation physique avec ce dont la batterie a besoin. // Qu'il tombe juste est un accident — releve du 17/09 a 13:51, voiture partie // depuis cinq minutes, consigne a 7032 W pour une maison a 933 W. // // La consigne additionne desormais des besoins NOMMES : // maison toujours ; // voiture seulement si un vehicule est branche ; // batterie seulement dans une fenetre d'heures creuses. // // ⚠️ MAX ET NON ADDITION, et ce n'est pas de la prudence. La consigne est une // AUTORISATION, pas une prediction de consommation — le commentaire de la bande // asymetrique le dit plus bas : « la consigne dit ce que le reseau a le DROIT // de servir, DVCC dit ce que la batterie a le droit de prendre ». Additionner // deux autorisations independantes n'est pas plus honnete, seulement plus // permissif. Mesure a l'appui : avec l'addition, // `consigne-session-engagee-couvre-la-maison` passe de 5595 a 6341 W, soit // 746 W d'autorisation en plus sur une nuit engagee qui fonctionne deja — une // regression sur le chemin DOMINANT pour corriger un chemin emprunte zero fois // en treize jours. // // ⚠️ ET LE PLAFOND DU TERME BATTERIE EST DVCC, PAS LE BMS. Le BMS declare // 250 A, soit 12,2 kW a 49 V : au-dessus de GRID_SETPOINT_MAX_W, donc il ne // bornerait rien. Mesure : en fin de fenetre (14 min restantes, manque // 5,672 kWh) la division rendait 17 kW, ecretes a 10 000 — PIRE que les 5437 // d'aujourd'hui. Avec DVCC le terme vaut 1538 W et la consigne 2380 W. // Composer les deux leviers, ce n'est pas les confondre : le reseau n'est // autorise a servir la batterie qu'a hauteur de ce que la batterie est // autorisee a prendre. // // Corollaire : aucun plancher d'horizon n'est necessaire. Un horizon qui tend // vers zero donne `min(enorme, DVCC) = DVCC`. Il ne reste qu'une garde d'une // minute, qui est de l'arithmetique defensive et non une constante calibree. // // INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre // sous la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 — « tant // que le contacteur est ferme A NOTRE DEMANDE, la consigne doit couvrir la // maison » — est donc garantie par construction. A DVCC nul, le plafond vaut // exactement la maison. const SETPOINT_BALANCE_MAX_AGE_MIN = 30; const setpointBalanceAgeMin = Number.isFinite(params.balanceLastRunMs) ? (nowMs - params.balanceLastRunMs) / 60000 : null; // Le manque, pas l'energie a couvrir : voir le commentaire d'INPUT_PARSE. const balanceUsableForSetpoint = params.balanceStatusOk && Number.isFinite(params.balanceGridEnergyNeededKwh) && setpointBalanceAgeMin !== null && setpointBalanceAgeMin >= -5 && setpointBalanceAgeMin <= SETPOINT_BALANCE_MAX_AGE_MIN; const dvccChargeLimitW = Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA >= 0 ? params.dvccChargeLimitA * derived.batteryVoltageRef : null; // Toute lecture manquante retombe sur la formule d'avant : degradation vers // l'existant, jamais pire. const setpointFromBalance = balanceUsableForSetpoint && dvccChargeLimitW !== null; const evcsSetpointShareW = parsed.evcsVehicleConnected ? derived.physicalCapA * derived.acVoltageRef : 0; // Recharger la batterie sur le reseau n'a de sens economique QUE dans une // fenetre creuse. Hors fenetre le terme vaut zero — ce n'est pas un repli // faute d'horizon, c'est le comportement voulu, et le probleme de la division // par un temps qui tend vers zero disparait par construction. const hcMinutesLeft = Number.isFinite(windows.minutesRemainingInHcWindow) ? windows.minutesRemainingInHcWindow : null; const batterySetpointShareW = setpointFromBalance && hcMinutesLeft !== null && params.balanceGridEnergyNeededKwh > 0 ? Math.min( (params.balanceGridEnergyNeededKwh * 1000) / Math.max(hcMinutesLeft / 60, 1 / 60), dvccChargeLimitW ) : 0; // ===================================================== // LE REPLI NE DOIT PAS RESSUSCITER LE DEFAUT (18/09/2026) // ===================================================== // Quand la garde du bilan tombe -- statut degrade, mesure perimee, champ // illisible -- la consigne revenait a `maison + plafond de borne`, y compris // sans voiture. C'est-a-dire au defaut d'origine, et precisement au moment ou // le systeme est deja degrade. Mesure sur le chemin concerne (midi, fenetre HC // autorisee, aucun vehicule, bilan indisponible) : 5725 W autorises pour une // maison a 900 W. Avec la part voiture : 1130 W. // // La branche de repli emploie donc la meme part voiture que la branche // nominale. L'invariant du 01/09 et du 04/09 reste tenu PAR LA FORME : le // plafond vaut `maison + un terme positif ou nul`, il ne descend donc jamais // sous la maison, quelle que soit la branche empruntee. // // ⚠️ NE PAS REINTRODUIRE UN DISJOINT `nightGridSessionCommitted || // forceGridRequested` SUR LA PART VOITURE. Il a ete ecrit, mesure, puis // retire : il ECRASE le terme batterie du bilan, puisque `max(4825 emprunte, // 1538 mesure)` retient l'emprunt. Cout mesure, trois attendus figes d'un coup : // nuit-sans-vehicule-session-engage 2380 -> 5437 // consigne-bilan-fin-de-fenetre-bornee-par-dvcc 2380 -> 5437 // consigne-bilan-plancher-nuage-exclut-la-batterie 4219 -> 6301 // Ce disjoint avait un sens tant que la consigne n'avait AUCUN terme batterie. // Depuis qu'elle en a un, dimensionne sur le manque reel, il n'est plus une // protection mais un court-circuit. // // Et si l'idee revient malgre tout : pendant une panne de bilan, // `nightGridSessionCommitted` ne peut engager aucune session NOUVELLE, il ne // survit que par `committedPrev`. Dans le repli, ce sont donc // `forceGridRequested` et le terme vehicule qui portent reellement. const gridSetpointCeilingW = clamp( Math.round( setpointFromBalance ? derived.houseNonEvcsW + Math.max(evcsSetpointShareW, batterySetpointShareW) : derived.houseNonEvcsW + evcsSetpointShareW ), params.gridSetpointIdleW, GRID_SETPOINT_MAX_W ); const gridSetpointCurrentW = Number.isFinite(parsed.gridSetpointW) ? parsed.gridSetpointW : null; // ===================================================== // CREDIT SOLAIRE SUR LA CONSIGNE (11/09/2026) // ===================================================== // Defaut mesure ce jour-la : BOOST 28 A en plein soleil, consigne calculee a // 8242 W (maison 1802 + 28 A x 230). L'ESS n'a qu'un moyen de prelever 8,2 kW // quand le soleil en donne 4 : brider les MPPT. Releve 17:02 -- PV 121 W, // reseau 7697 W, batterie 0 A, SOC 96 %. La production etait refusee, et par // NOTRE consigne. Le commentaire ci-dessus ("une consigne trop HAUTE ne coute // rien") a ete ecrit la nuit du 01/09, ou la PV valait zero : il n'y avait // alors rien a evincer. En plein jour il est faux. // // ⚠️ ON NE PEUT PAS SOUSTRAIRE LA PV MESUREE. Elle vaut 121 W *a cause* de // cette consigne : mesure censuree, la soustraire ne bouge rien et le systeme // reste sur son mauvais equilibre. Preuve le meme jour a 17:22 -- consigne // descendue A LA MAIN a 2280 W : PV 4758 W en 50 secondes, batterie +9 A. La // ressource etait la, seule la consigne l'evincait. // // On DESCEND donc, et on laisse la physique repondre. Deux signaux, tous deux // deja lus et tous deux INCENSURABLES : // - les chargeurs declarent eux-memes s'ils se BRIDENT (mpptLimitedCount). // `off` ne compte pas : la nuit ils sont tous `off` et il n'y a rien a // liberer -- c'est ce qui garantit que le comportement de nuit (§31, §57) // est strictement inchange ; // - le courant batterie dit si on est descendu trop bas, via la politique // par bande de SOC deja calibree (batterySoftBreachNow, §35). // La valeur courante vit dans le GX : l'etat est deja persiste, aucun helper // nouveau n'est necessaire. // // PLANCHER : la consigne ne descend JAMAIS sous la maison hors voiture. C'est // la garantie du 01/09 et du 04/09 -- le reseau, quand le contacteur est ferme // a notre demande, couvre la maison. On ne libere que la part VOITURE, qui est // exactement celle qui evince le solaire. Le solaire pourrait souvent couvrir // la maison en plus, mais l'affirmer demanderait ses propres scenarios. // ⚠️ SECOND PLANCHER : LE NUAGE (ajoute 11/09 18:05, apres incident). // Premiere version de la descente : consigne tombee a 1281 W alors que le // solaire portait tout (PV 6058 W, reseau 1163 W, voiture a 28 A). Un nuage a // fait chuter la PV de 4,6 kW en une minute ; le reseau etant plafonne a // 1281 W, la difference est tombee entierement sur la batterie -- 87 A pour // une limite dure de 70 A -> `battery_current_hard_stop`, charge coupee. // // Sans injection, la production peut s'effondrer de plusieurs kW en quelques // secondes. Une boucle a 30 s ne peut pas rattraper cela : il faut que le // reseau soit DEJA autorise a prendre le relais. La consigne ne descend donc // jamais au point ou la perte totale du solaire depasserait ce que la batterie // sait encaisser. // // Le budget vient de la politique batterie deja calibree par bande de SOC // (§35), pas d'une constante nouvelle, et on garde 20 % de marge sous la // limite DURE -- c'est elle qui coupe la charge, et une coupure est plus // couteuse que quelques centaines de watts de solaire non captes. // // Contrepartie assumee, et elle est reelle : voiture a 28 A, la descente est // nettement moins profonde qu'a 10 A. C'est proportionne -- c'est le courant // de la voiture qui cree l'exposition. Et c'est la regle du proprietaire : // protéger le materiel prime sur l'economie de kilowattheures. // `derived.batteryVoltageRef` est deja la tension retenue en amont (mesure // vebus, repli 51,2 V) : meme grandeur que celle qui sert a convertir le // courant batterie en puissance dans BATTERY_CLOUD_LOCKS. const batterySurgeBudgetW = Math.max( 0, Math.abs(batteryPolicy.batteryDischargeHardDynamicA) * derived.batteryVoltageRef * 0.8 ); // ⚠️ LE PLANCHER NUAGE SUIT LA VOITURE, PAS LA BATTERIE. Il existe pour que la // consigne ne descende pas au point ou la perte totale du solaire depasserait // ce que la batterie sait encaisser — et le commentaire ci-dessus dit pourquoi : // « c'est le courant de la voiture qui cree l'exposition ». Une batterie qu'on // recharge depuis le reseau n'est PAS une exposition : si le solaire s'effondre, // la recharge ralentit et personne ne se fait couper. L'y inclure remonterait le // plancher sans aucune contrepartie de securite, et contre l'objectif. // Une seule branche depuis le 18/09/2026 : le plancher nuage se construit sur // la maison et la voiture dans les deux cas. La distinction n'avait de sens que // tant que le repli gardait le plafond de borne emprunte. const gridSetpointLoadW = derived.houseNonEvcsW + evcsSetpointShareW; const gridSetpointCloudFloorW = Math.round( Math.max(0, gridSetpointLoadW - batterySurgeBudgetW) ); const gridSetpointHouseFloorW = clamp( Math.max(Math.round(derived.houseNonEvcsW), gridSetpointCloudFloorW), params.gridSetpointIdleW, GRID_SETPOINT_MAX_W ); const solarResourceRefused = parsed.mpptReadableCount > 0 && parsed.mpptLimitedCount > 0; // Pendant la fenetre d'etablissement (§29) les mesures PV et batterie ne // decrivent aucun regime permanent : on ne descend pas sur elles. const gridSetpointDescendOk = gridSessionActive && solarResourceRefused && !batteryPolicy.batterySoftBreachNow && !derived.gridSettling && gridSetpointCurrentW !== null; // ⚠️ LE PLAFOND EST UN PLAFOND, PAS UNE CIBLE (corrige 11/09 17:52). // Premiere version de ce correctif, en production 12 minutes : la consigne // descendait bien a 2241 W et la PV remontait a 7074 W -- puis, tous les // chargeurs repassant `mppt_active`, la condition de descente devenait fausse, // le besoin revenait au plafond et la consigne etait REECRITE a 7100 W. La PV // s'effondrait a 1785 W, les chargeurs se bridaient, on redescendait. // Oscillateur de periode ~5 min. Releve : 17:40:30 -> 2241 / 17:47:19 -> 7100 // / 17:47:28 PV 1785 / 17:48:30 -> 7368. // // Une fois la consigne descendue, on la TIENT tant que le solaire produit. Ce // n'est pas un etat a memoriser : la valeur courante est dans le GX, et le // plancher maison la fait remonter d'elle-meme si la maison grossit. // // La nuit, les chargeurs sont `off` (ils ne produisent pas) : on retombe sur le // comportement historique -- la consigne monte au plafond pour que le reseau // porte maison et voiture (§31, §57). C'est cette distinction qui empeche le // « on tient » de casser les nuits HC. const solarProducing = parsed.mpptReadableCount > 0 && parsed.mpptAtMaxPowerCount > 0; let gridSetpointNeedW; let gridSetpointDescending = false; if (!gridSessionActive) { gridSetpointNeedW = params.gridSetpointIdleW; } else if (solarProducing && !batteryPolicy.batterySoftBreachNow && gridSetpointCurrentW !== null) { // On tient la valeur acquise, bornee par la maison et par le plafond. gridSetpointNeedW = clamp( Math.min(gridSetpointCurrentW, gridSetpointCeilingW), gridSetpointHouseFloorW, gridSetpointCeilingW ); } else { gridSetpointNeedW = gridSetpointCeilingW; } if (gridSetpointDescendOk) { // Un chargeur bride PROUVE qu'il reste de la ressource : on peut descendre // franchement. Une descente trop forte est rattrapee au cycle suivant (30 s // ou la batterie porte l'ecart, soit quelques dizaines de Wh), alors qu'une // descente trop timide laisse des kW de solaire refuses pendant dix minutes. const minStepW = Math.max( GRID_SETPOINT_DEADBAND_W, Math.round(params.currentStepUpA * derived.acVoltageRef) ); const baseW = Math.min(gridSetpointCurrentW, gridSetpointCeilingW); const gapW = baseW - gridSetpointHouseFloorW; if (gapW > 0) { const stepW = Math.max(minStepW, Math.round(gapW / 2)); gridSetpointNeedW = Math.max(gridSetpointHouseFloorW, baseW - stepW); gridSetpointDescending = true; } } // La bande est ASYMETRIQUE, et c'est le point. Une consigne trop BASSE fait // porter la difference par la batterie : c'est le defaut de la nuit du 01/09, // il se corrige immediatement. Une consigne trop HAUTE ne coute rien : l'ESS // ne preleve que ce qu'il peut placer, et le courant de charge de la batterie // est tenu par ailleurs (DVCC, Auto Charge Manager). Deux leviers, deux roles // -- la consigne dit ce que le reseau a le DROIT de servir, DVCC dit ce que la // batterie a le droit de prendre. Ne pas confondre les deux. // // On ecrit donc avec une marge d'un pas de courant EVCS (grandeur du systeme, // pas une constante inventee) pour que le bruit de la maison ne rende pas la // consigne insuffisante au cycle suivant, et on ne redescend que si elle est // devenue franchement inutile. Sans cela la boucle 30 s reecrivait a chaque // cycle en poursuivant le bruit. const gridSetpointSlackW = gridSessionActive && !gridSetpointDescending ? Math.max(GRID_SETPOINT_DEADBAND_W, Math.round(params.currentStepUpA * derived.acVoltageRef)) : 0; const gridSetpointTargetW = clamp( gridSetpointNeedW + gridSetpointSlackW, 0, GRID_SETPOINT_MAX_W ); const gridSetpointNeedsWrite = gridSetpointCurrentW === null || gridSetpointCurrentW < gridSetpointNeedW || gridSetpointCurrentW > gridSetpointNeedW + 2 * gridSetpointSlackW + GRID_SETPOINT_DEADBAND_W; reason.push(`Grid=${parsed.gridAvailable ? 'on' : 'off'} (${Math.round(parsed.gridPowerW)}W)`); // Trace de la fenetre d'etablissement : sans elle, une lecture du logbook // pendant le transitoire donne l'impression que le systeme decide n'importe // quoi. Il decide, au contraire, de ne PAS decider sur ces mesures. if (derived.gridSettling) { reason.push(`ETABLISSEMENT reseau ${Math.round(parsed.gridTransitionAgeSec)}s/${params.gridSettleSeconds}s (mesures PV/batterie non fiables)`); } if (gridSessionActive) { reason.push( gridSetpointDescending ? `Consigne reseau=${gridSetpointTargetW}W (CREDIT SOLAIRE, ${parsed.mpptLimitedCount}/${parsed.mpptReadableCount} brides)` : `Consigne reseau=${gridSetpointTargetW}W` ); } // Visible dans le logbook uniquement pendant un depassement souple : c'est le // seul moment ou l'information sert, et la raison est tronquee a 255 caracteres. if (batteryPolicy.batterySoftBreachNow) { reason.push( `Batt souple ${round1(batteryPolicy.batterySoftBreachMinutes)}/${round1(batteryPolicy.batterySoftToleranceMinutes)}min` ); } // Idem : visible seulement quand le bilan energetique DURCIT le plancher du // soir. Sans cette trace, un blocage de charge un soir de SOC 94 % est // incomprehensible a la lecture du logbook. if (safeguards.eveningFloorFromBalance) { reason.push( `Plancher soir=${round1(safeguards.eveningSocStartFloorPct)}% (BILAN, ${round1(safeguards.balanceEnergyToCoverKwh)}kWh a couvrir)` ); } reason.push(`Cap=${derived.physicalCapA}A`); reason.push(`Ref=${round1(derived.currentReferenceA)}A`); reason.push(`Target=${round1(desiredCurrentA)}A`); reason.push(`Debt=${round2(cloud.cloudDebtNextKwh)}kWh`); reason.push(`Vehicle=${parsed.evcsVehicleConnected ? 'on' : 'off'}`); reason.push(`EVCS mode=${parsed.evcsModeRaw}`); reason.push(`VE.Bus AC=${Math.round(parsed.vebusAcOutPowerW)}W`); reason.push( `VE.Bus DC=${ Number.isFinite(parsed.vebusDcVoltageV) ? round1(parsed.vebusDcVoltageV) : 'n/a' }V` ); reason.push(`PrioEVCS=${evcsPriorityActive ? 'on' : 'off'}(${evcsPrioritySource})`); reason.push(`VDF delivery=${Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : 'na'}`); reason.push(`VDF noise=${Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : 'na'}`); reason.push(`VDF score=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}`); reason.push(`VDF noDemand=${vdf.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`); if (night.nightSessionActive) { reason.push(`NightTarget=${round1(night.batteryMorningTargetPct ?? 0)}%`); reason.push(`NightTrigger=${round1(night.gridTakeoverTriggerPct ?? 0)}%`); if (Number.isFinite(night.nightSessionStartSocPct)) { reason.push(`StartSOC=${round1(night.nightSessionStartSocPct)}%`); reason.push(`AllowDrop=${round1(night.maxBatteryDischargePoints ?? 0)}pts`); } reason.push(`NightPreferred=${night.nightPreferredSourceMode ?? 'blocked'}`); reason.push(`NightTakeover=${night.nightGridTakeoverNeeded ? 'on' : 'off'}`); reason.push(`NightUrgent=${night.batteryRecoveryUrgent ? 'on' : 'off'}`); } const shortReason = reason.join(' | ').slice(0, 255); const sourceModeLabel = context.sourceMode === 'blocked' ? 'Bloqué' : context.sourceMode === 'solar_only' ? 'Solaire' : context.sourceMode === 'solar_potential' ? 'Potentiel solaire' : context.sourceMode === 'hybrid_probe' ? 'Probe hybride' : context.sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' : context.sourceMode === 'grid_forced' ? 'Réseau forcé' : context.sourceMode === 'battery_assist_night' ? 'Assistance batterie nuit' : context.sourceMode; const signature = [ decision.state, `profile=${context.profile}`, `source=${context.sourceMode}`, `chg=${decision.desiredCharging ? '1' : '0'}`, `perm=${decision.desiredPermission ? '1' : '0'}`, `l=${decision.desiredLatch ? '1' : '0'}`, `cur=${round1(desiredCurrentA)}`, `block=${decision.nextBlockReason}`, `stop=${decision.mustStop ? '1' : '0'}`, `prio=${evcsPriorityActive ? '1' : '0'}`, `night=${night.nightSessionActive ? '1' : '0'}`, `ntrg=${Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : 'na'}`, `nurg=${night.batteryRecoveryUrgent ? '1' : '0'}`, `vdf=${vdf.evcsVehicleNoDemandSemantic ? '1' : '0'}`, `vdfs=${Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : 'na'}` ].join('|'); const signatureChanged = signature !== history.lastSignature; // ===================================================== // NOTIFICATIONS // ===================================================== let notifyWanted = false; let notifyTitle = 'Victron EVCS Surplus'; let notifyMessage = shortReason; if (parsed.notificationsEnabled && signatureChanged) { if (decision.currentDecision === 'start') { notifyWanted = true; notifyTitle = '⚡ EVCS démarrage'; } else if (decision.currentDecision === 'step_up') { notifyWanted = true; notifyTitle = '⬆️ EVCS hausse courant'; } else if ( decision.currentDecision === 'step_down' || decision.currentDecision === 'step_down_fast' ) { notifyWanted = true; notifyTitle = '⬇️ EVCS baisse courant'; } else if ( decision.mustStop || decision.currentDecision === 'stop' || decision.currentDecision === 'stop_hard' || decision.currentDecision === 'stop_power_cap' ) { notifyWanted = true; notifyTitle = '⛔ EVCS arrêt'; } else if (decision.state === 'mode_auto_conflict') { notifyWanted = true; notifyTitle = '⚠️ EVCS mode Auto'; } else if (decision.state === 'vehicle_full_or_paused') { notifyWanted = true; notifyTitle = '🚗 EVCS véhicule plein ou en pause'; } } // ===================================================== // TIMESTAMPS TO WRITE // ===================================================== const cloudDebtStartedAtToWrite = cloud.cloudDebtStartedMs ? formatParisStampFromMs(cloud.cloudDebtStartedMs) : now.stamp; // DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : chaine vide quand il n'y a pas // de depassement en cours. Le helper n'est reecrit que sur changement reel // (debut ou fin), pas a chaque cycle : la valeur du debut doit RESTER figee // pendant toute la duree du depassement, sinon le compteur ne monte jamais. const batterySoftBreachAtToWrite = batteryPolicy.batterySoftBreachStartedMs !== null && batteryPolicy.batterySoftBreachStartedMs !== undefined ? formatParisStampFromMs(batteryPolicy.batterySoftBreachStartedMs) : ''; const setBatterySoftBreachAt = batterySoftBreachAtToWrite !== (history.batterySoftBreachAt || ''); const probeStartedAtToWrite = now.stamp; const lastStartAtToWrite = now.stamp; const lastStopAtToWrite = now.stamp; const lastCurrentChangeAtToWrite = now.stamp; // ===================================================== // NIGHT SESSION MEMORY // ===================================================== const nightSessionStartSocToWrite = decision.clearNightSessionStart ? '' : decision.setNightSessionStart ? String(round1(parsed.soc)) : ( Number.isFinite(history.nightSessionStartSocPct) ? String(round1(history.nightSessionStartSocPct)) : '' ); const nightSessionStartAtToWrite = decision.clearNightSessionStart ? '' : decision.setNightSessionStart ? now.stamp : (history.nightSessionStartAt || ''); // ===================================================== // OUTPUT // ===================================================== return [{ json: { timestamp_paris: now.stamp, inputs: input, computed: { operating_mode: context.operatingMode, profile: context.profile, source_mode: context.sourceMode, source_mode_label: sourceModeLabel, user_strategy: strategy.userStrategy, in_night_hc_window: windows.inNightHcWindow, in_morning_window: windows.inMorningWindow, in_midday_window: windows.inMiddayWindow, in_evening_window: windows.inEveningWindow, // D'ou vient la consigne reseau. Sans cette trace, un repli est // SILENCIEUX : la consigne reprend la formule d'avant sans que rien ne le // dise, et c'est precisement l'information qui manquera le jour ou elle se // comportera mal. `bilan` = dimensionnee sur le manque, `heritee` = repli. setpoint_source: setpointFromBalance ? 'bilan' : 'heritee', setpoint_battery_share_w: Math.round(batterySetpointShareW), setpoint_evcs_share_w: Math.round(evcsSetpointShareW), dvcc_limit_w: dvccChargeLimitW === null ? null : Math.round(dvccChargeLimitW), hc_minutes_left: hcMinutesLeft, departure_priority_active: context.departurePriorityActive, minutes_until_departure: lifecycle.minutesUntilDeparture, battery_priority_mode: modes.batteryPriorityMode, battery_full_harvest_mode: modes.batteryFullHarvestMode, forecast_commit_mode: modes.forecastCommitMode, physical_cap_current_a: derived.physicalCapA, can_run_at_min_current: derived.canRunAtMinCurrent, forecast_buffer_kwh: round2(derived.forecastBufferKwh), projected_end_soc_after_charge: round1(derived.projectedEndSocAfterCharge), cloud_debt_next_kwh: round2(cloud.cloudDebtNextKwh), cloud_debt_duration_minutes: round1(cloud.cloudDebtDurationMinutes), cloud_debt_active: cloud.cloudDebtActive, cloud_tolerance_ok: cloud.cloudToleranceOk, start_blocked_by_reason: locks.startBlockedByReason, probe_window_active: conditions.probeWindowActive, probe_success_now: conditions.probeSuccessNow, probe_failed_now: conditions.probeFailedNow, any_vebus_overload: derived.anyVebusOverload, any_vebus_low_battery: derived.anyVebusLowBattery, vebus_ac_out_soft_reached: derived.vebusAcOutSoftReached, vebus_ac_out_hard_reached: derived.vebusAcOutHardReached, vebus_dc_voltage_soft_low: derived.vebusDcVoltageSoftLow, vebus_dc_voltage_hard_low: derived.vebusDcVoltageHardLow, soc_band: batteryPolicy.socBand, batt_discharge_soft_dynamic_a: round1(batteryPolicy.batteryDischargeSoftDynamicA), batt_discharge_hard_dynamic_a: round1(batteryPolicy.batteryDischargeHardDynamicA), batt_charge_reserve_dynamic_a: round1(batteryPolicy.batteryChargeReserveDynamicA), batt_soft_tolerance_min: round1(batteryPolicy.batterySoftToleranceMinutes), batt_hard_tolerance_sec: round1(batteryPolicy.batteryHardToleranceSeconds), batt_recovery_min: round1(batteryPolicy.batteryRecoveryMinutes), grid_battery_soft_dynamic_a: round1(batteryPolicy.gridBatterySoftDynamicA), grid_battery_hard_dynamic_a: round1(batteryPolicy.gridBatteryHardDynamicA), house_spike_detected: batteryPolicy.houseSpikeDetected, solar_potential_context: batteryPolicy.solarPotentialContext, batt_soft_breach_now: !!batteryPolicy.batterySoftBreachNow, batt_soft_breach_minutes: round1(batteryPolicy.batterySoftBreachMinutes || 0), batt_soft_discharge_hold_ok: batteryPolicy.batterySoftDischargeHoldOk !== false, evcs_priority_active: evcsPriorityActive, energy_priority_mode: energyPriorityMode, energy_priority_reason: energyPriorityReason, evcs_priority_until: evcsPriorityUntil, evcs_priority_source: evcsPrioritySource, evcs_priority_manual_override_active: manualPriorityOverrideActive, night_session_active: night.nightSessionActive, night_session_needs_capture: !!night.nightSessionNeedsCapture, night_session_needs_clear: !!night.nightSessionNeedsClear, night_session_start_soc_pct: Number.isFinite(night.nightSessionStartSocPct) ? round1(night.nightSessionStartSocPct) : null, night_session_start_at: night.nightSessionStartAt || null, battery_hard_floor_pct: Number.isFinite(night.batteryHardFloorPct) ? round1(night.batteryHardFloorPct) : null, battery_morning_target_pct: Number.isFinite(night.batteryMorningTargetPct) ? round1(night.batteryMorningTargetPct) : null, max_battery_discharge_points: Number.isFinite(night.maxBatteryDischargePoints) ? round1(night.maxBatteryDischargePoints) : null, grid_takeover_trigger_pct: Number.isFinite(night.gridTakeoverTriggerPct) ? round1(night.gridTakeoverTriggerPct) : null, battery_recovery_urgent: !!night.batteryRecoveryUrgent, night_grid_takeover_needed: !!night.nightGridTakeoverNeeded, night_preferred_source_mode: night.nightPreferredSourceMode || 'blocked', evcs_delivery_ratio_ema: Number.isFinite(vdf.deliveryRatioEma) ? round3(vdf.deliveryRatioEma) : null, evcs_delivery_noise_ema: Number.isFinite(vdf.deliveryNoiseEma) ? round3(vdf.deliveryNoiseEma) : null, evcs_no_demand_score_ema: Number.isFinite(vdf.noDemandScoreEma) ? round3(vdf.noDemandScoreEma) : null, evcs_vehicle_no_demand_semantic: !!vdf.evcsVehicleNoDemandSemantic, evcs_no_demand_since_minutes: Number.isFinite(vdf.evcsNoDemandSinceMinutes) ? round3(vdf.evcsNoDemandSinceMinutes) : null, evcs_source_deliverable_for_no_demand: !!vdf.sourceDeliverableForNoDemand, evcs_session_armed_for_no_demand: !!vdf.evcsSessionArmedForNoDemand, evcs_no_demand_score_on: Number.isFinite(vdf.noDemandScoreOn) ? round3(vdf.noDemandScoreOn) : null, evcs_no_demand_score_off: Number.isFinite(vdf.noDemandScoreOff) ? round3(vdf.noDemandScoreOff) : null }, decision: { state: decision.state, current_decision: decision.currentDecision, desired_charging: decision.desiredCharging, desired_permission: decision.desiredPermission, desired_latch: decision.desiredLatch, desired_current_a: round1(desiredCurrentA), charging_action: chargingAction, permission_action: permissionAction, latch_action: latchAction, current_action: currentAction, auto_start_action: autoStartAction, set_last_start_at: setLastStartAt, set_last_stop_at: setLastStopAt, set_last_current_change_at: setLastCurrentChangeAt, set_cloud_debt_started_at: decision.setCloudDebtStartedAt, set_probe_started_at: decision.setProbeStartedAt, set_night_session_start: !!decision.setNightSessionStart, clear_night_session_start: !!decision.clearNightSessionStart, set_block_reason: needsBlockReasonUpdate || !parsed.controllerEnabled, block_reason: decision.nextBlockReason, set_blocked_until: true, blocked_until: blockedUntilToWrite, dynamic_ok: decision.dynamicOk, must_stop: decision.mustStop, short_reason: shortReason, reason, signature, signature_changed: signatureChanged, should_notify: notifyWanted, notify_title: notifyTitle, notify_message: notifyMessage, evcs_priority_active: evcsPriorityActive, energy_priority_mode: energyPriorityMode, energy_priority_reason: energyPriorityReason, evcs_priority_until: evcsPriorityUntil, evcs_priority_source: evcsPrioritySource }, ha_service_calls: { helper_state: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_surplus_state', value: decision.state } }, helper_current_decision: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_surplus_current_decision', value: decision.currentDecision } }, helper_reason: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_surplus_reason', value: shortReason } }, helper_operating_mode: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_operating_mode', value: context.operatingMode } }, helper_source_mode: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_source_mode', value: sourceModeLabel } }, helper_target_current: { execute: true, domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_target_current_auto', value: round1(desiredCurrentA) } }, helper_budget_remaining: { execute: true, domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_budget_remaining_kwh', value: round2(derived.forecastBufferKwh) } }, helper_projected_soc: { execute: true, domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge', value: round1(derived.projectedEndSocAfterCharge) } }, helper_cloud_debt: { execute: true, domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_cloud_debt_kwh', value: round2(cloud.cloudDebtNextKwh) } }, helper_dynamic_ok: { execute: true, domain: 'input_boolean', service: decision.dynamicOk ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_dynamic_ok' } }, helper_must_stop: { execute: true, domain: 'input_boolean', service: decision.mustStop ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_must_stop' } }, // Bouton reset borne (19/09/2026) : momentane. Il a deja fait son effet // dans WINDOWS (sessionFresh forcee ce cycle) ; on le rebascule off pour // qu'il ne force pas un retest en boucle. N'ecrit que s'il etait ON. helper_reset_borne_ack: { execute: !!params.resetBorneRequested, domain: 'input_boolean', service: 'turn_off', data: { entity_id: 'input_boolean.evcs_reset_borne' } }, helper_cloud_debt_started_at: { execute: true, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at', datetime: cloudDebtStartedAtToWrite } }, helper_battery_soft_breach_at: { execute: setBatterySoftBreachAt, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_battery_soft_breach_at', value: batterySoftBreachAtToWrite } }, helper_last_current_change_at: { execute: setLastCurrentChangeAt, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_last_current_change_at', datetime: lastCurrentChangeAtToWrite } }, helper_last_start_at: { execute: setLastStartAt, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_last_start_at', datetime: lastStartAtToWrite } }, helper_last_stop_at: { execute: setLastStopAt, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_last_stop_at', datetime: lastStopAtToWrite } }, helper_signature: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_control_last_signature', value: signature } }, helper_block_reason: { execute: needsBlockReasonUpdate || !parsed.controllerEnabled, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_last_block_reason', value: decision.nextBlockReason } }, helper_blocked_until: { execute: true, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_blocked_until', datetime: blockedUntilToWrite } }, helper_probe_started_at: { execute: decision.setProbeStartedAt, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_probe_started_at', datetime: probeStartedAtToWrite } }, helper_evcs_priority_active: { execute: true, domain: 'input_boolean', service: evcsPriorityActive ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_priority_active' } }, helper_energy_priority_mode: { execute: true, domain: 'input_select', service: 'select_option', data: { entity_id: 'input_select.victron_energy_priority_mode', option: energyPriorityMode } }, helper_energy_priority_reason: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_energy_priority_reason', value: energyPriorityReason } }, helper_evcs_priority_until: { execute: true, domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_priority_until', datetime: evcsPriorityUntil } }, helper_night_target_soc: { execute: true, domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_night_target_soc', value: round1( Number.isFinite(night.batteryMorningTargetPct) ? night.batteryMorningTargetPct : 0 ) } }, helper_night_grid_trigger_soc: { execute: true, domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_night_grid_trigger_soc', value: round1( Number.isFinite(night.gridTakeoverTriggerPct) ? night.gridTakeoverTriggerPct : 0 ) } }, helper_night_preferred_source_mode: { execute: true, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_night_preferred_source_mode', value: night.nightPreferredSourceMode || 'blocked' } }, // SESSION RESEAU UNIQUE (03/09/2026) : ce bit est ce qui rend la session // UNIQUE. Sans persistance, l'engagement serait reevalue a chaque cycle // et le systeme retomberait dans une bande — donc dans un oscillateur. // Ecrit uniquement sur changement reel. helper_night_grid_session_committed: { execute: night.nightGridSessionCommitted !== history.nightGridSessionCommittedPrev, domain: 'input_boolean', service: night.nightGridSessionCommitted ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_night_grid_session_committed' } }, // P12 — LE VERROU ANTI-OSCILLATEUR. // // Le plancher de surplus (`required_soc_prudent`) DESCEND pendant la nuit, // et le SOC aussi. Mesure du 15/09 : voiture en charge, le SOC descend de // 7,1 pt/h et le plancher de 3,7 ; voiture ARRETEE, le SOC ne descend plus // que de 2,6 pt/h quand le plancher continue a 3,7. Le surplus // REAPPARAITRAIT donc tout seul et la charge repartirait — cinquieme // oscillateur du projet, par construction. // // Une seule passe de surplus par session de nuit, comme la recovery // batterie depuis le 27/08. Ecrit uniquement sur changement reel. helper_night_battery_done: { execute: !!night.nightBatterySurplusExhausted !== !!params.nightBatteryDonePrev, domain: 'input_boolean', service: night.nightBatterySurplusExhausted ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_night_battery_done' } }, helper_night_grid_takeover_needed: { execute: true, domain: 'input_boolean', // P9 : la fenêtre "départ imminent" (jour) tient aussi le helper // takeover → override hystérésis + maintien batterie ACM pendant // la charge réseau pré-départ. service: (night.nightGridTakeoverNeeded || night.departureUrgentActive) ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_night_grid_takeover_needed' } }, helper_night_battery_recovery_urgent: { execute: true, domain: 'input_boolean', service: night.batteryRecoveryUrgent ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_night_battery_recovery_urgent' } }, helper_night_session_active: { execute: true, domain: 'input_boolean', service: night.nightSessionActive ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_night_session_active' } }, helper_night_session_start_soc: { execute: decision.setNightSessionStart || decision.clearNightSessionStart, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_night_session_start_soc', value: nightSessionStartSocToWrite } }, // Budget voiture de la nuit (30/09) : fige a l'ouverture, efface en fin. helper_night_budget: { execute: night.nightBudgetToWrite !== null && night.nightBudgetToWrite !== undefined, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_night_budget', value: night.nightBudgetToWrite ?? '' } }, helper_night_session_start_at: { execute: decision.setNightSessionStart || decision.clearNightSessionStart, domain: 'input_text', service: 'set_value', data: { entity_id: 'input_text.victron_evcs_night_session_start_at', value: nightSessionStartAtToWrite } }, // ================================================= // VEHICLE DEMAND FILTER HISTORY PERSISTENCE // ================================================= helper_evcs_delivery_ratio_ema: { execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryRatioEma), domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_delivery_ratio_ema', value: round3(vdf.nextHistory.evcsDeliveryRatioEma) } }, helper_evcs_delivery_noise_ema: { execute: Number.isFinite(vdf?.nextHistory?.evcsDeliveryNoiseEma), domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_delivery_noise_ema', value: round3(vdf.nextHistory.evcsDeliveryNoiseEma) } }, helper_evcs_no_demand_score_ema: { execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandScoreEma), domain: 'input_number', service: 'set_value', data: { entity_id: 'input_number.victron_evcs_no_demand_score_ema', value: round3(vdf.nextHistory.evcsNoDemandScoreEma) } }, helper_evcs_no_demand_semantic_active: { execute: true, domain: 'input_boolean', service: vdf?.nextHistory?.evcsNoDemandSemanticActive ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_no_demand_semantic_active' } }, helper_evcs_no_demand_since_at: { execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandSinceMs), domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_no_demand_since_at', datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandSinceMs) } }, helper_evcs_no_demand_filter_eval_at: { execute: Number.isFinite(vdf?.nextHistory?.evcsNoDemandFilterEvalMs), domain: 'input_datetime', service: 'set_datetime', data: { entity_id: 'input_datetime.victron_evcs_no_demand_filter_eval_at', datetime: formatParisStampFromMs(vdf.nextHistory.evcsNoDemandFilterEvalMs) } }, // ================================================= // PHYSICAL EDF GRID TAKEOVER // ================================================= edf_general_ok_on: { execute: ['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) && !parsed.gridAvailable, domain: 'switch', service: 'turn_on', data: { entity_id: 'switch.edf_general_ok_switch' } }, grid_power_setpoint: { execute: gridSetpointNeedsWrite, domain: 'number', service: 'set_value', data: { entity_id: 'number.gx_device_ac_power_setpoint', value: gridSetpointTargetW } }, evcs_auto_start_off: { execute: autoStartAction === 'turn_off', domain: 'switch', service: 'turn_off', data: { entity_id: 'switch.victron_evcs_auto_start' } }, evcs_charging: { execute: chargingAction !== 'none', domain: 'switch', service: decision.desiredCharging ? 'turn_on' : 'turn_off', data: { entity_id: 'switch.victron_evcs_charging' } }, helper_permission: { execute: permissionAction !== 'none', domain: 'input_boolean', service: decision.desiredPermission ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_charge_permission_now' } }, helper_latch: { execute: latchAction !== 'none', domain: 'input_boolean', service: decision.desiredLatch ? 'turn_on' : 'turn_off', data: { entity_id: 'input_boolean.victron_evcs_control_latch' } }, evcs_set_current: { execute: currentAction === 'set', domain: 'number', service: 'set_value', data: { entity_id: 'number.victron_evcs_set_current_command', value: round1(desiredCurrentA) } }, logbook: { execute: signatureChanged, domain: 'logbook', service: 'log', data: { name: 'Victron EVCS Surplus', message: shortReason } }, mobile_notification: { execute: notifyWanted, domain: cfg.mobileNotifyDomain, service: cfg.mobileNotifyService, data: { title: notifyTitle, message: notifyMessage || shortReason } } } } }];