Files
EVCS/src/nodes/WINDOWS_SOURCEV2.js
Tallagrand Thierry f18c6e07f6 Nuit : la recovery batterie est une passe unique, pas un oscillateur
La bande de 2 points du Fix A engageait le takeover a SOC 33 et le relachait
a 35 : 0,34 kWh, soit une demi-heure de conso nocturne plate. Trois cycles EDF
la nuit du 26-27/08, six la nuit du 20-21/08, 2,5 kWh importes pour rien.

Une seule passe de recovery par session de nuit. Le plancher dur reste un
terme independant, l hysteresis garde sa bande 20/30, et le vrai critere de
re-engagement (un budget d energie) appartient au bilan, pas a une constante
de plus ici.

Golden 40 -> 42, preuve par mutation. Documente aussi deux acteurs non lus :
les charges programmees de l ESS (45 % la nuit, contre notre cible 35 %) et
le regime majoritairement hors reseau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-27 10:33:21 +02:00

886 lines
29 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;
}
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;
// P5 : plafond EVCS configurable quand le réseau alimente réellement AC-IN
// (input_number.evcs_plafond_courant_edf_on). Hors réseau : plafond câblé prudent.
const evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);
const safeEvcsByDedicatedCapW = parsed.acInAvailable
? 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,
parsed.acInAvailable
? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)
: params.currentMaxAutoA
);
const canRunAtMinCurrent = physicalCapA >= params.currentMinA;
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;
// =====================================================
// ADVISOR IA — PREMIER CÂBLAGE RÉEL (09/08/2026, HANDOFF §20)
// La cible SOC matin suit la recommandation de l'advisor UNIQUEMENT si :
// - toggle input_boolean.evcs_advisor_apply_night_target ON (rollback 1 clic)
// - dernier run advisor en statut 'ok'
// - recommandation fraîche (≤ 120 min, l'advisor tourne toutes les 15 min)
// - valeur bornée [max(22, hard floor), 90]
// Sinon : règles forecast historiques (comportement inchangé).
// =====================================================
const ADVISOR_TARGET_MAX_AGE_MIN = 120;
const advisorTargetAgeMin = minutesSince(params.advisorLastRunMs, nowMs);
const advisorNightTargetApplied =
params.advisorApplyNightTarget &&
params.advisorStatusOk &&
Number.isFinite(params.advisorNightTargetSocPct) &&
advisorTargetAgeMin !== null &&
advisorTargetAgeMin <= ADVISOR_TARGET_MAX_AGE_MIN;
const batteryMorningTargetPct =
advisorNightTargetApplied
? clamp(
params.advisorNightTargetSocPct,
Math.max(22, batteryHardFloorPct),
90
)
: batteryMorningTargetRulePct;
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;
const batteryRecoveryUrgent =
nightSessionActive &&
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;
const sessionFresh =
parsed.evcsVehicleConnected &&
Number.isFinite(parsed.evcsChargingTimeSec) &&
parsed.evcsChargingTimeSec <= 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).
const evcsStatusCharged =
!sessionFresh &&
Number.parseFloat(parsed.evcsStatusRaw) === 3 &&
Math.max(0, parsed.evcsAcPowerW || 0) <= 300;
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;
const nightGridHcPreferred =
nightSessionActive &&
params.allowGridHcNight &&
!vehicleChargeCompleted;
const batteryAssistAllowed =
nightSessionActive &&
!nightGridHcPreferred &&
parsed.soc > batteryHardFloorPct &&
parsed.soc > gridTakeoverTriggerPct &&
!nightTakeoverLatched &&
!batteryRecoveryUrgent;
const nightGridTakeoverNeeded =
nightSessionActive &&
(
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'
: 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') {
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';
}
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,
},
derived: {
acVoltageRef,
batteryVoltageRef,
houseNonEvcsW,
vebusNonEvcsW,
totalVebusSoftCeilingW,
totalVebusHardCeilingW,
safeEvcsByDedicatedCapW,
safeEvcsByVebusW,
safeEvcsCombinedW,
systemCapCurrentA,
physicalCapA,
canRunAtMinCurrent,
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,
batteryMorningTargetRulePct,
advisorNightTargetApplied,
maxBatteryDischargePoints,
gridTakeoverTriggerPct,
batteryBelowHardFloor,
batteryBelowMorningTarget,
batteryAtOrBelowGridTakeoverTrigger,
batteryKwhMissingToTarget,
batteryRecoveryMinutesNeeded,
batteryRecoveryUrgent,
batteryRecoveryAllowedNow,
batteryTargetReached,
batteryAssistAllowed,
nightGridTakeoverNeeded,
nightTakeoverLatched,
nightGridHcPreferred,
vehicleChargeCompleted,
nightRecoveryDone,
nightPreferredSourceMode,
// P9
departureUrgentActive,
departureChargeDone,
// P10
dayVehicleFullIdle,
// P11
sessionFresh,
}
}
}];