Files
EVCS/src/nodes/WINDOWS_SOURCEV2.js

1310 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function minutesSince(ts, nowMs) {
if (ts === null || nowMs === null) return null;
return Math.max(0, (nowMs - ts) / 60000);
}
function secondsSince(ts, nowMs) {
if (ts === null || nowMs === null) return null;
return Math.max(0, (nowMs - ts) / 1000);
}
function isWithinWindow(nowMin, startMin, stopMin) {
if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;
if (startMin === stopMin) return false;
if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;
return nowMin >= startMin || nowMin < stopMin;
}
// Temps restant dans une fenetre, en minutes, ou null si on n'y est pas.
//
// ⚠️ IL VIT ICI, A COTE DE `isWithinWindow`, ET PAS DANS LE NOEUD QUI L'UTILISE.
// Ce qui serait duplique ailleurs n'est pas l'analyse des heures — INPUT_PARSE
// les a deja converties en minutes — c'est la LOGIQUE D'APPARTENANCE, et en
// particulier le traitement du passage de minuit. Une seconde interpretation
// divergerait le jour ou une borne bouge, et elles ont deja bouge une fois
// (01:30 -> 01:24 le 09/07/2026).
function minutesRemainingInWindow(nowMin, startMin, stopMin) {
if (!isWithinWindow(nowMin, startMin, stopMin)) return null;
if (startMin < stopMin) return stopMin - nowMin;
// Fenetre a cheval sur minuit : avant minuit il reste la fin de la nuit EN
// PLUS du reste de la journee. Atteignable par les valeurs de repli
// d'INPUT_PARSE (23:00 -> 06:00) si les helpers d'horaire sont illisibles.
return nowMin >= startMin ? stopMin + 1440 - nowMin : stopMin - nowMin;
}
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
const prev = $input.first().json;
const {
input,
nowMs,
now,
cfg,
parsed,
params,
history,
batteryTune
} = prev;
// =====================================================
// STRATEGY
// =====================================================
const userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();
const isStopMode = userStrategy === 'stop';
const forceGridRequested = userStrategy === 'force_grid_now';
const forceChargeRequested = userStrategy === 'force_charge_now';
const isForceMode = forceGridRequested || forceChargeRequested;
// =====================================================
// LIFECYCLE / TIMING
// =====================================================
const minutesSinceStart = minutesSince(history.lastStartMs, nowMs);
const minutesSinceStop = minutesSince(history.lastStopMs, nowMs);
const secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);
const minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);
const minRunElapsed =
minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;
const minStopElapsed =
minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;
const currentCooldownBaseSec = parsed.evcsChargingSwitchOn
? batteryTune.global.stepDownCooldownSeconds
: batteryTune.global.stepUpCooldownSeconds;
const currentCooldownElapsed =
secondsSinceCurrentChange === null
? true
: secondsSinceCurrentChange >= Math.max(
params.currentChangeCooldownSeconds,
currentCooldownBaseSec
);
// =====================================================
// DEPARTURE PRIORITY
// =====================================================
const minutesUntilDeparture =
params.departureTimeMin === null
? null
: (() => {
const rawDiff = params.departureTimeMin - now.minNow;
return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);
})();
const departurePriorityActive =
parsed.departurePriorityEnabled &&
Number.isFinite(minutesUntilDeparture) &&
minutesUntilDeparture >= 0 &&
minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&
parsed.evcsVehicleConnected;
// =====================================================
// MANUAL CHARGE SEMANTICS
// IMPORTANT:
// force_charge_now = bypass fenêtres pour la charge,
// MAIS PAS demande implicite de réseau.
// =====================================================
const manualChargeBypassWindows = forceChargeRequested;
const manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;
// Compatibilité ascendante : on garde le champ dans context,
// mais on le neutralise volontairement.
const forceChargeDepartureGridOverride = false;
// =====================================================
// SYSTEM DERIVED VALUES
// =====================================================
const acVoltageRef =
Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180
? parsed.vebusAcOutVoltageV
: cfg.nominalAcVoltageV;
const batteryVoltageRef =
Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0
? parsed.vebusDcVoltageV
: 51.2;
const houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);
const vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);
const totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);
const totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;
// =====================================================
// FENETRE D'ETABLISSEMENT DU RESEAU (31/08/2026)
// =====================================================
// Mesure du 31/08 : a la fermeture du contacteur, le reseau reprend d'un coup
// TOUTE la charge de la maison et le solaire bascule vers la batterie. Batterie
// preneuse, la production ne bouge pas ; batterie pleine, elle s'effondre (2663
// -> 559 W) faute de destination. Dans les deux cas l'import decroit ensuite
// jusqu'a la consigne en ~150 s (1985 -> 25 W, puis 2600 -> 52 W).
//
// Pendant cette phase, PV, import et courant batterie ne decrivent aucun regime
// permanent : la boucle de 30 s y prend cinq decisions, et la moyenne PV 10 min
// en garde la trace une dizaine de minutes de plus. Le systeme lit "plus de
// soleil", en conclut qu'il a besoin du reseau, et confirme ainsi sa propre
// premisse -- une boucle de rattrapage, comme les trois du 24/08.
//
// ASYMETRIE VOULUE : la fenetre ne s'ouvre qu'a l'ARRIVEE du reseau. A sa
// disparition, les onduleurs reprennent instantanement toute la charge : les
// gardes doivent agir plus vite, jamais moins vite.
const gridSettling =
parsed.gridAvailable &&
parsed.gridTransitionAgeSec < params.gridSettleSeconds;
const endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);
const energyNeededForEndDayGuardKwh = Math.max(
0,
((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh
);
const forecastBufferKwh =
parsed.todayRemainingKwh -
params.baseLoadRemainingKwh -
params.flexibleSafetyMarginKwh -
params.requiredEnergyKwh -
energyNeededForEndDayGuardKwh;
const projectedEndSocAfterCharge = clamp(
parsed.soc + (
(
parsed.todayRemainingKwh -
params.baseLoadRemainingKwh -
params.flexibleSafetyMarginKwh -
params.requiredEnergyKwh
) / cfg.batteryUsableCapacityKwh
) * 100,
0,
100
);
const currentReferenceA = clamp(
Number.isFinite(parsed.evcsCommandCurrentA)
? parsed.evcsCommandCurrentA
: Number.isFinite(parsed.evcsSetCurrentA)
? parsed.evcsSetCurrentA
: params.targetCurrentAutoA,
0,
32
);
const anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;
const anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;
const vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;
const vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;
const vebusDcVoltageSoftLow =
Number.isFinite(parsed.vebusDcVoltageV) &&
parsed.vebusDcVoltageV > 0 &&
parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;
const vebusDcVoltageHardLow =
Number.isFinite(parsed.vebusDcVoltageV) &&
parsed.vebusDcVoltageV > 0 &&
parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;
const houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;
// =====================================================
// WINDOWS / PROFILE
// =====================================================
const inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);
const inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);
const inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);
const inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);
const profile =
inNightHcWindow ? 'night_hc' :
inMorningWindow ? 'morning_solar' :
inMiddayWindow ? 'midday_hc_or_solar' :
inEveningWindow ? 'evening_high_soc' :
'outside_window';
// =====================================================
// EVCS NIGHT PREVIEW ENGINE
// =====================================================
const nightSessionActive =
profile === 'night_hc' &&
parsed.evcsVehicleConnected;
const nightSessionStartSocPct =
nightSessionActive
? (
Number.isFinite(history.nightSessionStartSocPct)
? history.nightSessionStartSocPct
: parsed.soc
)
: null;
const nightSessionStartAt =
nightSessionActive
? (history.nightSessionStartAt || '')
: '';
const nightSessionStartMs =
nightSessionActive
? (history.nightSessionStartMs ?? null)
: null;
const nightSessionNeedsCapture =
nightSessionActive &&
(
!Number.isFinite(history.nightSessionStartSocPct) ||
!nightSessionStartAt ||
nightSessionStartMs === null
);
const nightSessionNeedsClear =
!nightSessionActive &&
(
Number.isFinite(history.nightSessionStartSocPct) ||
!!history.nightSessionStartAt
);
const batteryHardFloorPct =
params.evcsNightBatteryHardFloorPct;
// P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain.
const nightForecastHour = now.hour;
const nightForecastKwh =
(nightForecastHour >= 20 || nightForecastHour < 8)
? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0)
: parsed.todayRemainingKwh;
const batteryMorningTargetRulePct =
nightForecastKwh >= params.evcsNightStrongForecastKwh
? params.evcsNightTargetStrongForecastPct
: nightForecastKwh >= params.evcsNightMediumForecastKwh
? params.evcsNightTargetMediumForecastPct
: params.evcsNightTargetWeakForecastPct;
// =====================================================
// LE CONSEILLER NE FIXE PLUS LA CIBLE (retire le 13/09/2026)
// =====================================================
// Le premier cablage reel (09/08/2026, HANDOFF §20) faisait suivre a la cible
// du matin `input_number.evcs_advisor_night_target_soc`, derriere un toggle,
// un statut `ok`, une fraicheur de 120 min et un bornage.
// Le 12/09 le conseiller est devenu OBSERVATEUR : il n'emet plus de cible, et
// ce helper est fige depuis. Les quatre gardes continuaient pourtant de passer
// toutes les quatre — la garde de fraicheur date le RUN, pas la VALEUR, et le
// conseiller tourne toujours. Activer le toggle aurait donc applique un chiffre
// perime sans qu'aucune garde ne s'y oppose. Un cablage qui ne peut plus que se
// tromper se retire ; le scenario `advisor-cible-ignoree` le verifie.
const batteryMorningTargetBasePct = batteryMorningTargetRulePct;
// =====================================================
// CIBLE DU MATIN : UNE ENERGIE A COUVRIR, PAS UN PALIER (03/09/2026)
// =====================================================
// La regle historique classe la journee du lendemain en trois paliers
// (>= 18 kWh -> 35 %, >= 10 -> 55 %, sinon 85 %) a partir de son TOTAL. Elle
// est donc aveugle a l'heure d'arrivee de l'energie : le 03/09, demain
// annoncait 33,9 kWh — « fort », donc 35 % — mais avec un matin creux
// (0,03 kWh a 7 h, 0,77 a 8 h, 1,81 a 9 h) et un croisement a 9 h.
//
// C'est le meme defaut que le plancher du soir (cf. le bloc BILAN de
// BATTERY_CLOUD_LOCKS), a l'autre bout de la nuit : un palier fige contre une
// energie mesurable. Le bilan publie la seconde, instant par instant.
//
// RELEVE DE LA NUIT 02-03/09, qui a motive ce cablage :
//
// heure requis SOC reel cible fixe
// 00:30 62,4 53 35
// 03:00 50,4 45 35 <- EDF relache ici, 5 points sous la ligne
// 04:30 41,7 37 35
// 06:00 37,1 22 35 <- hysteresis, puis reseau PLEIN TARIF
// de 07:24 a 10:33 (1,49 kWh)
//
// La batterie est restee sous la ligne toute la nuit et le bilan le savait des
// 00:30. Le `required_soc` decroit proprement avec la nuit qui se consomme
// (6,1 -> 0 kWh a couvrir) : c'est bien une cible instantanee, pas une
// constante deguisee.
//
// SENS UNIQUE, comme pour le plancher du soir : le bilan ne peut que RELEVER
// la cible, jamais l'abaisser. La direction corrigee est celle de l'echec
// observe (cible trop basse -> batterie a plat avant le croisement -> reseau
// au plein tarif le matin). L'abaisser — la regle dit 85 % l'hiver quand le
// bilan dira parfois moins — ouvrirait un risque neuf que rien n'a encore
// valide. Le principe unificateur : le bilan ne pousse JAMAIS dans le sens
// « couvre moins bien la nuit ».
//
// PLAFOND 90 % : au-dela, la batterie cesse d'etre un puits et la production
// du matin se fait evincer (cf. mesures du 31/08). Charger au reseau au-dela
// de 90 echangerait un kWh bon marche cette nuit contre plusieurs kWh censures
// demain. C'est deja la borne du chemin advisor : aucune constante nouvelle.
//
// PAS D'OSCILLATEUR : `required_soc` decroit de facon monotone pendant la nuit
// (l'energie restant a couvrir ne fait que diminuer), et la passe unique de
// recovery (`nightRecoveryDonePrev`, 27/08) reste en place.
//
// REPLI identique au plancher du soir : fraicheur, statut, valeur finie —
// sinon regle historique. Kill-switch existant : `evcs_balance_enabled` OFF
// fige `balance_last_run` et la cible retombe seule sur les paliers.
const BALANCE_TARGET_MAX_AGE_MIN = 30;
const balanceTargetAgeMin = minutesSince(params.balanceLastRunMs, nowMs);
const balanceMorningTargetApplied =
params.balanceStatusOk &&
Number.isFinite(params.balanceRequiredSocPct) &&
balanceTargetAgeMin !== null &&
balanceTargetAgeMin <= BALANCE_TARGET_MAX_AGE_MIN;
// ⚠️ 01/10/2026 — DECISION DU PROPRIETAIRE : LE BILAN FIXE LA CIBLE, DANS LES
// DEUX SENS. « La valeur de 35 % c'est idiot si le besoin est prevu 26,6 %, et
// c'est aussi idiot si la prevision est de 60 % pour une journee nuageuse. »
// Le sens unique ci-dessus (03/09) gardait le palier comme plancher : le
// 01/10 a 06:00, cible 35 (palier « forte prevision ») pour un besoin du bilan
// de 28,6 %. Le palier n'est plus qu'un REPLI quand le bilan est absent,
// perime ou degrade. Bornes : le plancher dur en bas, 90 % en haut.
const batteryMorningTargetPct = balanceMorningTargetApplied
? Math.min(90, Math.max(batteryHardFloorPct, params.balanceRequiredSocPct))
: batteryMorningTargetBasePct;
const maxBatteryDischargePoints =
nightSessionActive && Number.isFinite(nightSessionStartSocPct)
? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)
: 0;
const gridTakeoverTriggerPct =
Math.max(batteryHardFloorPct, batteryMorningTargetPct);
const batteryBelowHardFloor =
parsed.soc <= batteryHardFloorPct;
const batteryBelowMorningTarget =
parsed.soc < batteryMorningTargetPct;
const batteryAtOrBelowGridTakeoverTrigger =
parsed.soc <= gridTakeoverTriggerPct;
const batteryKwhMissingToTarget =
nightSessionActive
? Math.max(
0,
((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh
)
: 0;
const batteryRecoveryMinutesNeeded =
nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0
? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60
: 0;
// 01/10/2026 : l'urgence avant depart exige qu'il MANQUE de l'energie. Sans
// cette garde, « 0 min de recharge + 30 min de marge >= 30 min » la declenchait
// a 06:00 chaque jour de depart, batterie au-dessus de sa cible (01/10 : SOC 37,
// cible 35, EDF ferme 06:00-06:38 pour rien ; idem 21, 22, 25 et 27/09).
const batteryRecoveryUrgent =
nightSessionActive &&
batteryKwhMissingToTarget > 0 &&
Number.isFinite(minutesUntilDeparture) &&
minutesUntilDeparture >= 0 &&
(
batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin
>= minutesUntilDeparture
);
// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne.
// Une fois le réseau HC engagé pendant une session nuit, on y reste
// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine.
// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée).
const NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90;
const nightTakeoverPrev =
(input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on';
// P8 (07/08/2026) : "charge terminée" COLLANT via l'état surplus du cycle
// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot).
// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume
// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au
// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit.
const prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase();
// P11 (12/08/2026) : RESET du collant "véhicule plein" au CHANGEMENT DE
// VÉHICULE. Incident 12/08 : JEEP chargée → night_recovery_done_idle collant ;
// débranchement JEEP + branchement VW → l'état collant a survécu et bloquait
// la VW (« charge terminée » = celle de la JEEP, pas de la VW).
// Signal fiable : sensor.victron_evcs_charging_time repart de 0 à chaque
// nouvelle connexion physique. Sous ~90 s → session neuve → on neutralise le
// collant pour laisser le système RETESTER le véhicule fraîchement branché.
const P11_FRESH_SESSION_MAX_SEC = 90;
// P11-bis (19/09/2026) : le charging_time de la borne repart de 0 sur un
// debranchement FRANC, mais reste FIGE sur un swap trop rapide (14 s le 19/09 :
// VW debranchee, JEEP branchee, borne gelee a l'etat VW). P11 restait alors
// aveugle et le collant "vehicule plein" de la VW bloquait la JEEP. Le
// last_changed du capteur de connexion, lui, a bien bouge sur ce swap : c'est
// un SECOND signal de fraicheur, independant de la borne. Meme fenetre (90 s =
// 3 cycles de 30 s, assez pour capter un (re)branchement entre deux cycles).
// Cout d'un faux positif (glitch MQTT sub-seconde) : au pire un retest, que le
// statut 3 / VDF re-latche aussitot si le vehicule est vraiment plein.
const sessionFresh =
parsed.evcsVehicleConnected &&
(
params.resetBorneRequested || // echappatoire manuelle (bouton reset borne)
(Number.isFinite(parsed.evcsChargingTimeSec) &&
parsed.evcsChargingTimeSec <= P11_FRESH_SESSION_MAX_SEC) ||
(Number.isFinite(parsed.evcsVehicleConnectedAgeSec) &&
parsed.evcsVehicleConnectedAgeSec <= P11_FRESH_SESSION_MAX_SEC)
);
// Fix D (08/08/2026) : le statut natif EVCS "charged" (3) vaut aussi fin de
// charge, sans attendre le VehicleDemandFilter (désarmé si la session a été
// stoppée par une garde, ex battery_current_hard_stop → détection tardive
// observée la nuit du 07-08/08).
// Fix E (09/08/2026) : garde puissance ≤ 300 W pour ne jamais latcher "fini"
// sur un glitch de statut pendant une vraie charge. NB : le statut retombe
// (3→1/6) dès l'ouverture du relais → la persistance est assurée par l'état
// collant écrit par DECISION pendant la session (voir Fix E DECISION).
// P11 : sur une session neuve, on ignore aussi un éventuel statut 3 résiduel
// (le temps que la borne renégocie avec le nouveau véhicule).
// Fix H (07/09/2026) : un statut "charged" n'est pas cru sur parole.
// La borne l'a annonce le 07/09 a 14:28:18 apres 2 min 42 s de charge et
// 0,10 kWh livre, ce qui a latche `day_vehicle_full_idle` pour deux heures
// alors que le vehicule pouvait charger (il a repris a 16:30 et tenu).
//
// Aucun signal ELECTRIQUE instantane ne separe une vraie fin de charge d'une
// interruption : mesure a la seconde des deux coupures, elles sont
// superposables (VW 06/09, vraie fin : 1534 -> 550 -> 82 -> 11 -> 0 W ;
// JEEP 07/09, faux positif : 1626 -> 741 -> 100 -> 13 -> 0 W). Seule la
// DUREE de la session les distingue, et le systeme porte deja la grandeur qui
// dit ce qu'est une charge etablie : min_run_minutes (29 min en production).
//
// Deux formes de "charged" sont credibles, et une seule ne l'est pas :
// energie de session nulle -> le vehicule etait plein en arrivant (P10)
// session >= min_run_minutes -> charge menee a son terme
// entre les deux -> charge INTERROMPUE : on ne latche pas
// Convergence : une session interrompue redemarre a energie nulle, donc un
// second "charged" immediat retombe dans le premier cas et latche.
const chargedSessionEnergyKwh = Math.max(0, parsed.evcsSessionEnergyKwh || 0);
const chargedSessionEstablished =
Number.isFinite(parsed.evcsChargingTimeSec) &&
parsed.evcsChargingTimeSec >= params.minRunMinutes * 60;
const chargedCredible =
chargedSessionEnergyKwh <= 0 || chargedSessionEstablished;
const evcsStatusCharged =
!sessionFresh &&
Number.parseFloat(parsed.evcsStatusRaw) === 3 &&
Math.max(0, parsed.evcsAcPowerW || 0) <= 300 &&
chargedCredible;
const vehicleChargeCompleted =
nightSessionActive &&
!sessionFresh &&
(
prevSurplusState === 'vehicle_full_or_paused' ||
prevSurplusState === 'night_session_done_idle' ||
prevSurplusState === 'night_recovery_done_idle' ||
prevSurplusState === 'day_vehicle_full_idle' ||
evcsStatusCharged
);
// Fix A (08/08/2026) : recovery batterie post-charge = UNE SEULE passe.
// Sans flag collant, SOC==cible est un point dégénéré (engage soc<=cible,
// release soc>cible, l'ACM recharge pile à la cible) → rebond EDF ON/OFF
// période ~10 min observé la nuit du 07-08/08. Une fois la cible atteinte,
// l'état 'night_recovery_done_idle' (auto-entretenu via prev_surplus_state)
// coupe le takeover. Ré-engagement uniquement sous cible − 2 pts (bande),
// hard floor et fenêtre/débranchement restent prioritaires.
// ⚠ 'night_recovery_done_idle' ne doit matcher AUCUN pattern no-demand de
// l'hystérésis (full_or_paused / not_requesting_power / charge_complete /
// no_demand) — même contrainte que 'night_session_done_idle'.
// 27/08/2026 : la bande de 2 points fabriquait un oscillateur. Relevé de la
// nuit du 26-27/08 (cible 35) : engagement à SOC 33, relâchement à 35, trois
// cycles EDF de 52, 79 et 25 min, 2,5 kWh importés pour rien un matin de grand
// soleil ; la nuit du 20-21/08 en comptait six. Deux points de SOC valent
// ~0,34 kWh, soit une demi-heure de conso nocturne : ce n'est pas une
// hystérésis, c'est la période d'une horloge — d'autant plus régulière que la
// charge de nuit est parfaitement plate.
// La recovery batterie post-charge est donc UNE SEULE passe par session de
// nuit, ce qui était déjà l'intention affichée du Fix A ("relâché
// DÉFINITIVEMENT"). Aucun seuil de ré-engagement ici : le plancher dur reste
// un terme indépendant de nightGridTakeoverNeeded (voir plus bas), et
// l'hystérésis réseau garde sa propre bande 20/30. Le vrai critère de
// ré-engagement est un budget d'énergie restant à couvrir, pas un delta de
// SOC : il appartient au bilan énergétique, pas à une constante de plus ici.
const nightRecoveryDonePrev = prevSurplusState === 'night_recovery_done_idle';
const nightRecoveryDone =
vehicleChargeCompleted &&
(parsed.soc >= batteryMorningTargetPct || nightRecoveryDonePrev);
// =====================================================
// P9 (09/08/2026) : CHARGE RÉSEAU AVANT DÉPART, HORS FENÊTRE HC.
// Cas VW : départ 08:50 > fin HC 07:24 → fenêtre morte où le véhicule
// sous-chargé attendait un solaire insuffisant (observé le 09/08 : advisor
// force_grid_now, divergence n°10). Si le toggle est ON et que le départ du
// véhicule SÉLECTIONNÉ est dans ≤ 120 min : réseau autorisé (grid_hc_allowed)
// jusqu'à fin de charge. L'arrêt réutilise TOUTE la mécanique anti-rebond :
// VDF → vehicle_full_or_paused → état collant 'departure_done_idle'
// (réécrit chaque cycle, patterns hystérésis sûrs, coupure immédiate E2).
// =====================================================
const P9_DEPARTURE_LEAD_MIN = 120;
const departureWindowActive =
params.allowGridDepartureUrgent &&
parsed.evcsVehicleConnected &&
!inNightHcWindow &&
Number.isFinite(minutesUntilDeparture) &&
minutesUntilDeparture >= 0 &&
minutesUntilDeparture <= P9_DEPARTURE_LEAD_MIN;
const departureDonePrev =
prevSurplusState === 'departure_done_idle' ||
(!inNightHcWindow && prevSurplusState === 'vehicle_full_or_paused');
const departureChargeDone =
departureWindowActive &&
!sessionFresh &&
(departureDonePrev || evcsStatusCharged);
const departureUrgentActive =
departureWindowActive &&
!departureChargeDone;
// =====================================================
// P10 (09/08/2026) : MÉMOIRE "VÉHICULE PLEIN" EN JOURNÉE.
// Observé le 09/08 09:49 : VW pleine branchée depuis 07:00, le surplus
// solaire retentait une charge (statut "Charged", 0 W) toutes les ~15 min
// toute la journée (usure relais, bruit). Les états collants P8/P9 étaient
// scopés nuit/fenêtre départ. Ici : une fois le véhicule vu plein (statut 3
// ou VDF), état collant 'day_vehicle_full_idle' jusqu'au DÉBRANCHEMENT.
// - hérite des états "done" de la nuit (07:24 → pas de re-tentative) ;
// - la nuit hérite en retour (vehicleChargeCompleted inclut day_*) ;
// - échappatoires : force_charge_now / force_grid_now / débrancher-rebrancher.
// =====================================================
const dayVehicleFullPrev =
prevSurplusState === 'day_vehicle_full_idle' ||
(
!inNightHcWindow &&
(
prevSurplusState === 'vehicle_full_or_paused' ||
prevSurplusState === 'night_recovery_done_idle' ||
prevSurplusState === 'night_session_done_idle' ||
prevSurplusState === 'departure_done_idle'
)
);
const dayVehicleFullIdle =
parsed.evcsVehicleConnected &&
!sessionFresh &&
!inNightHcWindow &&
!isStopMode &&
!forceGridRequested &&
!forceChargeRequested &&
(dayVehicleFullPrev || evcsStatusCharged);
// P8 : correction du fix 04/08 (logique inversée : "|| vehicleChargeCompleted"
// GARDAIT le latch après la charge au lieu de le libérer).
const nightTakeoverLatched =
nightSessionActive &&
nightTakeoverPrev &&
parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT &&
!vehicleChargeCompleted;
// =====================================================
// P12 — CHARGE NUIT SUR LA BATTERIE (15/09/2026)
// =====================================================
// Idee du proprietaire : plutot qu'acheter du reseau la nuit pour charger la
// voiture, lui donner le solaire DEJA STOCKE, doucement, et laisser ainsi de la
// place dans la batterie pour la production du lendemain. Payer du reseau la
// nuit pour ecreter le solaire le lendemain, c'est perdre deux fois.
//
// Le mode `battery_assist_night` existe depuis toujours pour exactement ca, et
// il est MORT depuis toujours : il exige `batteryCurrentA >= plancher de
// decharge` (0 A), impossible la nuit puisque la maison tire sur la batterie
// (§11 du HANDOFF, ~ -14 A). On ne cree donc pas un chemin, on rend viable
// celui qui existe — en remplacant un test de COURANT impossible par un test
// d'ENERGIE : rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement.
//
// Le plancher n'est PAS une constante nouvelle : c'est `required_soc_prudent`,
// que le bilan publie deja, en profil p80. La marge est mesuree, pas inventee.
const nightBatterySurplusFloorPct = Math.max(
batteryHardFloorPct,
Number.isFinite(params.balanceRequiredSocPrudentPct)
? params.balanceRequiredSocPrudentPct
: Infinity,
);
// ⚠️ LE VERROU, ET IL N'EST PAS DECORATIF. Le plancher DESCEND pendant la nuit
// (il reste moins de nuit a couvrir) et le SOC aussi. Mesure du 15/09 :
//
// voiture en charge : SOC -7,1 pt/h, plancher -3,7 pt/h -> le surplus s'epuise
// voiture arretee : SOC -2,6 pt/h, plancher -3,7 pt/h -> le surplus REVIENT
//
// Sans verrou, la charge repartirait des qu'elle s'arrete : cinquieme
// oscillateur du projet, par construction. UNE SEULE PASSE de surplus par
// session de nuit, comme la recovery batterie depuis le 27/08.
// BUDGET VOITURE DE LA NUIT (30/09/2026, decisions du proprietaire).
// « Definir combien la voiture peut prendre sans utiliser EDF, et brider la
// charge a cette valeur ou autoriser une charge complete avec EDF. »
// Le bilan publie le budget ; il est FIGE au premier cycle de la session
// (avec l'index du compteur de la borne) dans input_text
// victron_evcs_night_budget = « budget|index », efface en fin de session.
// L'arret se juge sur le COMPTEUR, qui ne fait que monter : condition
// monotone, aucun oscillateur possible (meme logique que §40). Le plancher de
// SOC prudent reste un second arret, jamais retire.
const nightBudgetPrev = (() => {
const m = String(params.nightBudgetPrevRaw || '').match(/^\s*([\d.]+)\|([\d.]+)\s*$/);
return m ? { budgetKwh: Number(m[1]), refKwh: Number(m[2]) } : null;
})();
const nightBudgetCaptureOk =
nightSessionActive && !nightBudgetPrev &&
balanceMorningTargetApplied &&
Number.isFinite(params.balanceNightVehicleBudgetKwh) &&
Number.isFinite(params.evcsTotalEnergyKwh);
const nightBudget = !nightSessionActive
? null
: nightBudgetPrev || (nightBudgetCaptureOk
? { budgetKwh: Math.max(0, params.balanceNightVehicleBudgetKwh), refKwh: params.evcsTotalEnergyKwh }
: null);
const nightBudgetToWrite = nightSessionActive
? (nightBudgetCaptureOk ? `${Math.round(nightBudget.budgetKwh * 100) / 100}|${nightBudget.refKwh}` : null)
: (nightBudgetPrev ? '' : null);
const nightBudgetDeliveredKwh = nightBudget && Number.isFinite(params.evcsTotalEnergyKwh)
? Math.max(0, params.evcsTotalEnergyKwh - nightBudget.refKwh)
: null;
const nightBudgetReached =
!!nightBudget && nightBudgetDeliveredKwh !== null &&
nightBudgetDeliveredKwh >= nightBudget.budgetKwh - 0.05;
const nightChargeComplete = params.nightChargeMode === 'complete';
// Le verrou ne vaut QUE pendant la session de nuit (01/10/2026). Ecrit sous la
// forme `prev || …`, il restait vrai pour toujours une fois pose ; invisible
// tant que son ecriture n'atteignait pas HA (jamais relayee du 15/09 au 01/10).
// Fin de session (debranchement ou fin de fenetre HC) = verrou leve.
const nightBatterySurplusExhausted =
nightSessionActive && (
params.nightBatteryDonePrev ||
parsed.soc <= nightBatterySurplusFloorPct ||
nightBudgetReached
);
const nightBatterySurplusAvailable =
nightSessionActive &&
params.nightBatteryChargeEnabled &&
balanceMorningTargetApplied &&
Number.isFinite(params.balanceRequiredSocPrudentPct) &&
!vehicleChargeCompleted &&
// Le plancher est teste UNE SEULE FOIS, dans `nightBatterySurplusExhausted`.
// Le retester ici serait redondant — et une redondance ne se teste pas : la
// mutation du 15/09 l'a montre en ne cassant aucun scenario.
!nightBatterySurplusExhausted;
// La batterie d'abord, le reseau quand le surplus est epuise. C'est la soupape
// decrite par le proprietaire — « quitte a fermer EDF entre 6h et 7h24 pour
// completer » — et elle devient automatique : l'epuisement du surplus rend
// `nightGridHcPreferred` vrai, donc P7 prend le relais.
// « Sans EDF » (defaut) : la voiture ne prend JAMAIS le reseau la nuit ; une
// fois le budget batterie atteint (ou sans budget exploitable), elle attend le
// soleil. « Charge complete » : EDF en HC finit la charge, comme avant.
const nightGridHcPreferred =
nightSessionActive &&
params.allowGridHcNight &&
nightChargeComplete &&
!vehicleChargeCompleted &&
!nightBatterySurplusAvailable;
const nightCarWaitsNoGrid =
nightSessionActive &&
!nightChargeComplete &&
!vehicleChargeCompleted &&
!nightBatterySurplusAvailable;
const batteryAssistAllowed =
nightSessionActive &&
!nightGridHcPreferred &&
!nightCarWaitsNoGrid &&
parsed.soc > batteryHardFloorPct &&
parsed.soc > gridTakeoverTriggerPct &&
!nightTakeoverLatched &&
!batteryRecoveryUrgent;
// =====================================================
// SESSION RESEAU UNIQUE DE LA NUIT (03/09/2026)
// =====================================================
// §26 avait pose l'objectif — « une session reseau unique et dimensionnee » —
// et designe le bon critere : « un budget d'energie restant a couvrir, il
// appartient au bilan energetique ». Le bilan est cable depuis ce soir.
//
// CE QUI A ETE MESURE AVANT D'ECRIRE CECI, et qui a dicte la conception :
// la conversion kWh -> points de SOC du bilan est une constante unique
// (15 kWh). La jauge reelle vaut ~17 kWh au-dessus de 42 % (0,164 a
// 0,171 kWh/point sur trois fenetres independantes) mais s'effondre en
// dessous : 12,1 kWh entre 45 et 22 %, 8,2 kWh entre 33 et 20 %. Un point de
// SOC vaut donc MOITIE MOINS dans la bande ou la nuit se termine, et
// `required_soc` y est systematiquement trop BAS — c'est pourquoi la batterie
// est restee sous la ligne toute la nuit du 02-03/09, en descendant 1,75 fois
// plus vite qu'elle.
//
// CONSEQUENCE DE CONCEPTION : le relachement ne doit PAS etre un seuil de SOC.
// Il porterait sur une grandeur fausse, et tout seuil que le SOC repasse
// ensuite rouvre l'oscillateur eteint le 27/08.
//
// Donc : ENGAGEMENT sur le bilan (batterie sous la cible), RELACHEMENT a la
// FIN DE LA FENETRE. L'oscillation devient structurellement impossible : la
// condition de sortie est monotone, une fenetre ne se ferme qu'une fois. Le
// bit d'engagement est porte par
// input_boolean.victron_evcs_night_grid_session_committed — sans lui le
// systeme reevaluerait a chaque cycle et retomberait dans une bande.
//
// CE QUE CA FAIT CONCRETEMENT : l'ACM recharge jusqu'a la cible puis tient la
// batterie a 2 A (evcs_takeover_hold) ; la maison finit la nuit sur le reseau
// en HEURES CREUSES au lieu de vider la reserve, et la batterie arrive a
// l'aube pres de sa cible, donc avec de la place pour le solaire du matin —
// au lieu d'etre a 20 % avec le reseau encore ouvert a 10:33 (03/09).
//
// PORTEE : uniquement quand le bilan est exploitable, et uniquement pendant
// une session nuit (fenetre HC + vehicule branche). Sinon, comportement
// historique inchange. Le bit se nettoie tout seul a la fin de la session.
const nightGridSessionCommittedPrev =
(input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on';
const nightGridSessionUncovered =
balanceMorningTargetApplied && batteryBelowMorningTarget;
// ⚠️ SANS VEHICULE AUSSI (12/09/2026).
// Nuit du 11-12/09, voiture rendue la veille : le bilan a reclame entre 92 %
// et 59 % de SOC a CHAQUE calcul de la nuit, `reseau_necessaire` vrai partout,
// et rien ne s'est passe. SOC bloque a 20 % de 23:43 a l'aube, consigne reseau
// figee a 700 W depuis 18:36 — soit exactement la maison. L'hysteresis avait
// bien ferme le contacteur (SOC <= 20), mais fermer le contacteur ne suffit
// pas : il faut DEMANDER la puissance (lecon du 01/09). Personne ne la
// demandait, parce que la session exigeait un vehicule branche.
//
// Or le besoin n'a rien a voir avec la voiture : c'est la maison qui doit
// traverser la nuit, et le bilan sait exactement ce qu'il lui faut. La
// fenetre HC suffit. Le relachement reste la FIN DE FENETRE, donc monotone,
// donc toujours immune a l'oscillation (§40).
//
// Sans cette correction, le cout se paye le matin : a 07:24 le SOC est encore
// sous le seuil OFF de l'hysteresis (30), donc le contacteur reste ferme et
// la maison passe au PLEIN TARIF jusqu'au croisement PV — le 1,49 kWh deja
// consigne le 03/09.
// ⚠️ LA FENETRE DE MIDI EST AUSSI UNE FENETRE D'HEURES CREUSES (18/09/2026).
// L'engagement de session etait gate sur `night_hc` seul, parce que §40 traitait
// la nuit. Consequence mesuree : la charge HC manuelle demandee par le
// proprietaire ne pouvait RIEN faire a midi — le plancher relevait bien la cible
// du bilan, mais aucune session ne s'engageait, donc le contacteur ne se fermait
// pas et la consigne restait au repos. Le reglage aurait releve le plancher du
// soir (donc bloque la voiture) sans jamais charger la batterie.
//
// ⚠️ ET LE CRITERE N'EST PAS LE MEME AUX DEUX FENETRES. La nuit, on compare le
// SOC a `batteryMorningTargetPct`, qui contient le palier de prevision
// (35/55/85). A midi ce palier n'a aucun sens : il vaut 35 par beau temps, donc
// un SOC de 30 % en plein soleil engagerait une session reseau — c'est-a-dire la
// substitution du solaire par le reseau qu'on a mis des semaines a eteindre.
//
// A midi le critere honnete est le SOC requis du bilan lui-meme, qui
// s'auto-regule : le croisement PV etant passe, `energyToCoverKwh` vaut zero et
// `required_soc` retombe au plancher dur. Releve du 17/09 a 13:51, plein soleil :
// `required_soc` = 22,0 pour `energy_to_cover` = 0,0. Il ne remonte qu'un jour
// sombre — ou quand le proprietaire pose un plancher manuel, ce qui est
// exactement le cas d'usage.
//
// ET LA VOITURE (28/09/2026, strategie du proprietaire) : « JEEP branchee
// engage le reseau en HC midi si l'analyse de production le justifie ». Le
// bilan fait l'analyse (besoin saisi contre solaire disponible avant le depart,
// batterie gardee pour tenir jusqu'a 01:24) et publie un verdict ; on le relaie,
// sous les memes gardes de fraicheur et de statut que la cible.
const middayVehicleGridNeeded =
balanceMorningTargetApplied &&
params.balanceVehicleGridNeeded &&
parsed.evcsVehicleConnected;
const middayGridSessionUncovered =
balanceMorningTargetApplied &&
(
(Number.isFinite(params.balanceRequiredSocPct) &&
parsed.soc < params.balanceRequiredSocPct) ||
middayVehicleGridNeeded
);
const gridSessionInHcWindow =
profile === 'night_hc' || profile === 'midday_hc_or_solar';
const gridSessionUncovered =
profile === 'night_hc' ? nightGridSessionUncovered : middayGridSessionUncovered;
const nightGridSessionCommitted =
gridSessionInHcWindow &&
(gridSessionUncovered || nightGridSessionCommittedPrev);
const nightGridTakeoverNeeded =
nightGridSessionCommitted ||
(
nightSessionActive &&
(
nightGridSessionCommitted ||
nightGridHcPreferred ||
batteryBelowHardFloor ||
(
// Fix A : après fin de charge, le seul motif réseau restant est la
// recovery batterie, et elle ne se ré-engage pas une fois done.
vehicleChargeCompleted
? !nightRecoveryDone
: (
batteryAtOrBelowGridTakeoverTrigger ||
batteryRecoveryUrgent ||
nightTakeoverLatched
)
)
)
);
const nightPreferredSourceMode =
!nightSessionActive
? 'blocked'
: vehicleChargeCompleted
? 'blocked'
// « Sans EDF » ne bloque la voiture que si EDF n'est PAS deja engage pour
// la batterie (plancher dur, cible du matin, session du bilan, verrou) :
// proteger le materiel prime (regle du proprietaire, 05/09), et EDF deja
// ferme en HC, la voiture en profite au meme tarif.
: nightCarWaitsNoGrid && !nightGridTakeoverNeeded
? 'blocked'
: batteryAssistAllowed
? 'battery_assist_night'
: (
batteryBelowHardFloor ||
batteryBelowMorningTarget ||
batteryRecoveryUrgent
)
? 'grid_forced'
: 'grid_hc_allowed';
const batteryRecoveryAllowedNow =
nightSessionActive &&
nightGridTakeoverNeeded &&
(
batteryBelowMorningTarget ||
batteryRecoveryUrgent
);
const batteryTargetReached =
nightSessionActive &&
parsed.soc >= batteryMorningTargetPct;
// =====================================================
// PROBES / SOLAR POTENTIAL
// =====================================================
// IMPORTANT:
// On ne met plus effectivePvStartMinW à 0 en force_charge_now,
// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire.
const effectivePvStartMinW = params.pvStartMinW;
const morningProbeEligible =
profile === 'morning_solar' &&
params.probeEnabled &&
parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW &&
parsed.pvTrendW >= params.morningProbePvTrendMinW &&
parsed.soc >= params.morningSocMin &&
parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA;
const middayProbeEligible =
profile === 'midday_hc_or_solar' &&
params.probeEnabled &&
parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW &&
parsed.soc >= params.middaySocMin &&
parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA;
const afternoonProbeEligible =
profile === 'outside_window' &&
params.probeEnabled &&
parsed.soc >= params.afternoonProbeSocMin &&
parsed.batteryCurrentA >= params.afternoonBatteryMaxDischargeA &&
parsed.pvPowerAvg10mW >= params.afternoonProbePvAvgMinW;
const cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;
const batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);
// P3 PROVOCATION PV (installation sans injection) :
// Batteries quasi pleines => le MPPT bride la production à la conso maison.
// Les seuils PV observés, le forecast buffer et la cloud debt ne veulent
// alors plus rien dire (la dette ne peut d'ailleurs plus se rembourser,
// batterie pleine = courant nul). On autorise solar_potential sur la seule
// santé batterie : SOC très haut + batterie non déchargée.
// Les gardes aval (BATTERY_CLOUD_LOCKS / DECISION) restent actives.
const PV_PROVOCATION_SOC_MIN = 90;
const pvProvocationEligible =
parsed.soc >= PV_PROVOCATION_SOC_MIN &&
batteryOkForPotential;
const isHighBatteryForPotential =
pvProvocationEligible ||
(
parsed.soc >= 85 &&
forecastBufferKwh > 0 &&
cloudDebtOkForPotential &&
batteryOkForPotential
);
const potentialAllowed = userStrategy !== 'auto_solar';
// IMPORTANT:
// On impose un vrai PV > 0 pour le test sur pvPowerW.
// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.
const strongSolarSignal =
(
parsed.pvPowerW > 0 &&
parsed.pvPowerW >= effectivePvStartMinW
) ||
parsed.pvPowerAvg10mW >= params.pvHoldMinW ||
(departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);
// =====================================================
// SOURCE MODE
// SÉMANTIQUE CORRIGÉE:
// - force_charge_now bypass les fenêtres
// - mais ne devient jamais grid_forced à lui seul
// - outside_window = pas de réseau programmé
// - la charge locale peut rester autorisée
// =====================================================
let sourceMode = 'blocked';
if (isStopMode) {
sourceMode = 'blocked';
}
else if (forceGridRequested) {
// Demande explicite de réseau.
sourceMode = 'grid_forced';
}
else if (forceChargeRequested) {
// IMPORTANT:
// force_charge_now = intention de charger maintenant,
// sans exiger le réseau.
if (strongSolarSignal) {
sourceMode = 'solar_only';
}
else if (nightSessionActive && batteryAssistAllowed) {
sourceMode = 'battery_assist_night';
}
else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {
sourceMode = 'hybrid_probe';
}
else if (profile === 'morning_solar' && morningProbeEligible) {
sourceMode = 'hybrid_probe';
}
else {
// Fallback manuel local :
// on n'impose pas le grid, on laisse l'étage aval décider
// selon les garde-fous physiques / batterie.
sourceMode = 'solar_potential';
}
}
else if (profile === 'night_hc') {
if (nightSessionActive) {
sourceMode = nightPreferredSourceMode;
} else {
sourceMode = 'blocked';
}
}
else if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {
// Session de midi engagee et contacteur ferme : la voiture charge sur le
// reseau avec la batterie (« batterie et voiture ensemble », 28/09), meme au
// soleil. La consigne garde son credit solaire (HA_SERVICE_CALLS_OUTPUT) :
// elle ne preleve au reseau que ce que les chargeurs ne donnent pas.
if (profile === 'midday_hc_or_solar' && nightGridSessionCommitted && parsed.gridAvailable) {
sourceMode = 'grid_hc_allowed';
}
else if (strongSolarSignal) {
sourceMode = 'solar_only';
}
else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {
sourceMode = 'hybrid_probe';
}
else if (profile === 'morning_solar' && morningProbeEligible) {
sourceMode = 'hybrid_probe';
}
else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {
sourceMode = 'grid_hc_allowed';
}
else {
sourceMode = 'blocked';
}
}
else if (profile === 'evening_high_soc') {
if (strongSolarSignal) {
sourceMode = 'solar_only';
}
else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
else {
sourceMode = 'blocked';
}
}
else if (profile === 'outside_window') {
if (strongSolarSignal) {
sourceMode = 'solar_only';
}
else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
else if (afternoonProbeEligible) {
sourceMode = 'hybrid_probe';
}
else {
// IMPORTANT:
// hors fenêtre = pas de réseau programmé.
// Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.
sourceMode = 'blocked';
}
}
// P9 : le réseau avant départ prend le pas sur les modes spéculatifs du jour
// (blocked / solar_potential / hybrid_probe), mais JAMAIS sur le solaire réel
// (solar_only), ni sur stop / force_* (déjà traités en tête de cascade).
if (
departureUrgentActive &&
!isStopMode &&
!forceGridRequested &&
!forceChargeRequested &&
profile !== 'night_hc' &&
sourceMode !== 'solar_only'
) {
sourceMode = 'grid_hc_allowed';
}
// P9 : véhicule plein pendant la fenêtre départ → aucune source.
if (departureChargeDone && profile !== 'night_hc' && !isStopMode && !forceGridRequested && !forceChargeRequested) {
sourceMode = 'blocked';
}
// P10 : véhicule plein en journée (mémoire collante) → aucune source,
// y compris solaire (charger un véhicule plein ne sert à rien).
if (dayVehicleFullIdle && profile !== 'night_hc') {
sourceMode = 'blocked';
}
// =====================================================
// PLAFOND DE COURANT EVCS — calculé APRÈS sourceMode (31/08/2026)
// =====================================================
// P5 : plafond majoré (input_number.evcs_plafond_courant_edf_on, jusqu'à 28 A
// en BOOST) quand le réseau alimente réellement AC-IN ; sinon plafond câblé
// prudent, celui que les deux MultiPlus peuvent porter SEULS.
//
// SÉQUENCE D'OUVERTURE (31/08/2026) — pourquoi ce bloc a été déplacé ici.
// Le plafond majoré n'est légitime que tant que le réseau est là. Or c'est
// l'hystérésis (cycle 1 min) qui ouvre le contacteur, et le contrôleur EVCS ne
// l'apprenait qu'après coup : pendant ce temps les deux onduleurs portaient
// seuls la voiture au courant majoré plus la maison — le mécanisme du W-105.
//
// Correctif sans poignée de main entre les deux automates : le plafond majoré
// exige désormais que l'EVCS DEMANDE lui-même le réseau. Sa demande tombe au
// cycle où la fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir
// qu'ensuite (override disparu ET min_on écoulé, cycle 1 min). L'EVCS baisse
// donc AVANT, mécaniquement, sans rien coordonner.
// Le calcul doit venir après sourceMode ; rien entre les deux ne l'utilisait.
const evcsGridCeilingAllowed =
parsed.acInAvailable &&
(
sourceMode === 'grid_forced' ||
sourceMode === 'grid_hc_allowed' ||
forceGridRequested
);
const evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);
const safeEvcsByDedicatedCapW = evcsGridCeilingAllowed
? Math.max(0, params.gridCurrentMaxA * acVoltageRef)
: evcsDedicatedCapBaseW;
const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);
const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));
const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));
const physicalCapA = clamp(
systemCapCurrentA,
0,
evcsGridCeilingAllowed
? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)
: params.currentMaxAutoA
);
const canRunAtMinCurrent = physicalCapA >= params.currentMinA;
const operatingMode = profile;
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
...prev,
strategy: {
userStrategy,
isStopMode,
isForceMode,
forceGridRequested,
forceChargeRequested,
},
lifecycle: {
minutesSinceStart,
minutesSinceStop,
secondsSinceCurrentChange,
minutesSinceProbeStart,
minRunElapsed,
minStopElapsed,
currentCooldownElapsed,
minutesUntilDeparture,
},
windows: {
inNightHcWindow,
inMorningWindow,
inMiddayWindow,
inEveningWindow,
// Temps restant dans la fenetre d'HEURES CREUSES courante (nuit ou midi),
// null hors de toute fenetre creuse. Consomme par le dimensionnement de
// la consigne reseau : recharger la batterie sur le reseau n'a de sens
// economique que la-dedans, donc `null` y vaut « pas de part batterie »
// sans test supplementaire.
minutesRemainingInHcWindow:
minutesRemainingInWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin)
?? minutesRemainingInWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin),
},
derived: {
acVoltageRef,
batteryVoltageRef,
houseNonEvcsW,
vebusNonEvcsW,
totalVebusSoftCeilingW,
totalVebusHardCeilingW,
safeEvcsByDedicatedCapW,
safeEvcsByVebusW,
safeEvcsCombinedW,
systemCapCurrentA,
physicalCapA,
canRunAtMinCurrent,
evcsGridCeilingAllowed,
gridSettling,
gridTransitionAgeSec: parsed.gridTransitionAgeSec,
endDayGuardSoc,
energyNeededForEndDayGuardKwh,
forecastBufferKwh,
projectedEndSocAfterCharge,
currentReferenceA,
anyVebusOverload,
anyVebusLowBattery,
vebusAcOutSoftReached,
vebusAcOutHardReached,
vebusDcVoltageSoftLow,
vebusDcVoltageHardLow,
houseLoadHighVsHeadroom,
},
context: {
departurePriorityActive,
forceChargeDepartureGridOverride,
manualChargeBypassWindows,
manualChargeLocalRequested,
morningProbeEligible,
middayProbeEligible,
afternoonProbeEligible,
pvProvocationEligible,
isHighBatteryForPotential,
strongSolarSignal,
effectivePvStartMinW,
profile,
operatingMode,
sourceMode,
},
// R1 : source de vérité UNIQUE du moteur nuit.
// BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien.
nightEvcsPreview: {
nightSessionActive,
nightSessionNeedsCapture,
nightSessionNeedsClear,
nightSessionStartSocPct,
nightSessionStartAt,
nightSessionStartMs,
batteryHardFloorPct,
batteryMorningTargetPct,
batteryMorningTargetBasePct,
balanceMorningTargetApplied,
nightBatterySurplusFloorPct,
nightBatterySurplusAvailable,
nightBatterySurplusExhausted,
nightBudgetKwh: nightBudget ? nightBudget.budgetKwh : null,
nightBudgetDeliveredKwh,
nightBudgetReached,
nightBudgetToWrite,
nightChargeMode: params.nightChargeMode,
nightCarWaitsNoGrid,
nightGridSessionCommitted,
middayVehicleGridNeeded,
nightGridSessionUncovered,
balanceTargetAgeMin:
balanceTargetAgeMin === null ? null : Math.round(balanceTargetAgeMin * 10) / 10,
batteryMorningTargetRulePct,
maxBatteryDischargePoints,
gridTakeoverTriggerPct,
batteryBelowHardFloor,
batteryBelowMorningTarget,
batteryAtOrBelowGridTakeoverTrigger,
batteryKwhMissingToTarget,
batteryRecoveryMinutesNeeded,
batteryRecoveryUrgent,
batteryRecoveryAllowedNow,
batteryTargetReached,
batteryAssistAllowed,
nightGridTakeoverNeeded,
nightTakeoverLatched,
nightGridHcPreferred,
vehicleChargeCompleted,
nightRecoveryDone,
nightPreferredSourceMode,
// P9
departureUrgentActive,
departureChargeDone,
// P10
dayVehicleFullIdle,
// P11
sessionFresh,
chargedSessionEnergyKwh,
chargedSessionEstablished,
chargedCredible,
}
}
}];