1310 lines
52 KiB
JavaScript
1310 lines
52 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;
|
||
}
|
||
|
||
// 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,
|
||
}
|
||
}
|
||
}];
|