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, } } }];