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
886 lines
29 KiB
JavaScript
886 lines
29 KiB
JavaScript
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,
|
||
}
|
||
}
|
||
}];
|