diff --git a/src/nodes/BATTERY_CLOUD_LOCKS.js b/src/nodes/BATTERY_CLOUD_LOCKS.js index 9cbf0b5..a2db398 100644 --- a/src/nodes/BATTERY_CLOUD_LOCKS.js +++ b/src/nodes/BATTERY_CLOUD_LOCKS.js @@ -247,6 +247,14 @@ const blockStillRelevant = ) : history.lastBlockReason === 'grid_battery_guard' ? isGridSource + // Garde batterie declenche RESEAU ABSENT (18/09) : sa cause est le + // trou entre la demande de reseau et la fermeture du contacteur. + // Le reseau arrive => la cause a disparu => le verrou se leve. Si la + // batterie est encore sollicitee au retry, le garde re-mord et pose + // cette fois 'grid_battery_guard' (reseau present) — verrou plein. + // Convergence en une tentative, meme patron que Fix H. + : history.lastBlockReason === 'grid_battery_guard_no_grid' + ? (isGridSource && !parsed.gridAvailable) : history.lastBlockReason === 'vebus_overload_persistent' ? derived.anyVebusOverload : false; diff --git a/src/nodes/DECISION_ENGINEV2.js b/src/nodes/DECISION_ENGINEV2.js index ab5306f..09a53f7 100644 --- a/src/nodes/DECISION_ENGINEV2.js +++ b/src/nodes/DECISION_ENGINEV2.js @@ -614,7 +614,14 @@ else if (cloud.sessionObservedActive) { reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`, dynamic: false, mustStopFlag: true, - blockReason: 'grid_battery_guard', + // Un verrou meurt avec sa cause (incident du 18/09 : BOOST demande le + // reseau, l'hysteresis met ~1 min a fermer, le garde mord 23 s AVANT + // l'arrivee d'EDF et posait 20 min de verrou reseau present). On encode + // ICI si le reseau etait la : absent => '_no_grid', et BCL levera le + // verrou des que le reseau arrive. Present => la cause est ailleurs + // (consigne insuffisante, incident du 01/09) et le verrou plein tient — + // c'est ce qui interdit l'oscillateur stop/retry a reseau present. + blockReason: parsed.gridAvailable ? 'grid_battery_guard' : 'grid_battery_guard_no_grid', blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000), }); } @@ -803,8 +810,13 @@ else if (cloud.sessionObservedActive) { setOn({ stateName: 'hold_min_run_wait_grid_takeover', decisionName: 'hold', - currentA: Math.min(derived.currentReferenceA, derived.physicalCapA), - reasonText: "Maintien jusqu'au mini ON en attente du réseau", + // Miroir de la sequence d'ouverture (§30) : quand on bascule VERS le + // reseau, on ne tient pas la puissance sur la source qu'on quitte. + // Tenir currentReferenceA (13 A le 18/09) pendant les ~80 s ou + // l'hysteresis n'a pas encore ferme le contacteur fait porter ~3 kW + // a la batterie et declenche le garde — courant MINI en attendant. + currentA: Math.min(params.currentMinA, derived.physicalCapA), + reasonText: "Attente du réseau au courant mini", dynamic: false, mustStopFlag: false, }); diff --git a/tests/golden/run.mjs b/tests/golden/run.mjs index 6164c4b..3e5265a 100644 --- a/tests/golden/run.mjs +++ b/tests/golden/run.mjs @@ -113,6 +113,7 @@ function summarize(out) { decision: d.current_decision, desiredCharging: d.desired_charging, currentA: d.desired_current_a, + blockReason: d.block_reason || null, takeoverNeeded: !!c.night_grid_takeover_needed, edfOnAction: !!calls.edf_general_ok_on?.execute, takeoverHelperService: calls.helper_night_grid_takeover_needed?.service || null, diff --git a/tests/golden/scenarios.mjs b/tests/golden/scenarios.mjs index 058a7f8..8c86abf 100644 --- a/tests/golden/scenarios.mjs +++ b/tests/golden/scenarios.mjs @@ -2845,4 +2845,150 @@ export const scenarios = [ takeoverNeeded: true, }, }, + + // =================================================================== + // BOOST 28 A du 18/09/2026 — le verrou survivait a sa cause (HANDOFF) + // Releve reel : clic BOOST 13:08:50, contacteur ferme 13:09:53, garde + // batterie a -32,4 A a 13:09:30 (23 s AVANT l'arrivee d'EDF), verrou + // 20 min reseau present. Trois volets, un scenario chacun + contrefactuel. + // =================================================================== + { + id: 'boost-attente-reseau-courant-mini', + label: "Session active, force_grid demande, reseau PAS ENCORE la → on retombe au courant MINI (le 13 A tenu sur la batterie est le defaut du 18/09)", + time: MIDDAY, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on', + // Charge solaire en cours a 13 A, min_run PAS ecoule (start recent). + last_start_at: '2026-08-08 12:58:30', + evcs_current_a: '13', evcs_set_current_a: '13', evcs_command_current_a: '13', + evcs_ac_power_w: '2990', + victron_evcs_strategy: 'force_grid_now', + // Le contacteur n'est PAS encore ferme : c'est la fenetre de l'incident. + grid_state: 'off', victron_acin1_available: '0', grid_power_w: '0', + soc_percent: '55', pv_power_w: '1200', pv_power_10m_avg_w: '1400', + house_ac_power_w: '3900', vebus_ac_out_l1_power_w: '3900', + // Batterie sollicitee mais SOUS le garde dur : la charge continue. + battery_current_a: '-20', + }, + expect: { + sourceMode: 'grid_forced', + state: 'hold_min_run_wait_grid_takeover', + decision: 'hold', + desiredCharging: true, + currentA: 6, + }, + }, + { + id: 'boost-garde-sans-reseau-emet-no-grid', + label: "Le garde batterie mord RESEAU ABSENT → blockReason 'grid_battery_guard_no_grid' (et pas le verrou plein)", + time: MIDDAY, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on', + last_start_at: '2026-08-08 12:58:30', + evcs_current_a: '13', evcs_set_current_a: '13', evcs_command_current_a: '13', + evcs_ac_power_w: '2990', + victron_evcs_strategy: 'force_grid_now', + grid_state: 'off', victron_acin1_available: '0', grid_power_w: '0', + // Le releve reel du 13:09:30 : -32,4 A, sous le garde dur de sa bande. + soc_percent: '55', pv_power_w: '300', pv_power_10m_avg_w: '400', + house_ac_power_w: '3900', vebus_ac_out_l1_power_w: '3900', + // ⚠️ Fenetre etroite VOULUE : le garde reseau (grid_battery_hard_a -20) + // doit mordre SANS que le hard general de la bande 40-60 (~-20,9 dans ce + // fixture) ne prenne la main — c'est exactement la hierarchie de + // production du 18/09 (-32,4 A avec un parc 13/4 ou le hard general est + // a -98 A). Le fixture n'etant pas a l'echelle du parc reel, on se place + // entre les deux seuils DU FIXTURE. + battery_current_a: '-20.5', + }, + expect: { + state: 'stop_grid_battery_guard', + decision: 'stop', + desiredCharging: false, + blockReason: 'grid_battery_guard_no_grid', + }, + }, + { + id: 'boost-verrou-no-grid-leve-a-l-arrivee-du-reseau', + label: "Verrou '_no_grid' pose, le reseau ARRIVE → le verrou se leve, la charge repart (le 18/09 elle restait bloquee 20 min)", + time: MIDDAY, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + evcs_charging_switch: 'off', control_latch: 'off', charge_permission_now: 'off', + victron_evcs_strategy: 'force_grid_now', + // Le verrou du releve reel : pose il y a 30 s, 20 min devant lui. + last_block_reason: 'grid_battery_guard_no_grid', + blocked_until: '2026-08-08 13:19:30', + last_stop_at: '2026-08-08 12:59:30', + prev_surplus_state: 'stop_grid_battery_guard', + // Le contacteur s'est ferme 23 s apres le stop : reseau PRESENT. + grid_state: 'on', victron_acin1_available: '1', grid_power_w: '900', + soc_percent: '55', pv_power_w: '1200', pv_power_10m_avg_w: '1400', + house_ac_power_w: '900', vebus_ac_out_l1_power_w: '900', + battery_current_a: '2', + acin1_last_changed_ts: String(Date.parse(MIDDAY) / 1000 - 400), + grid_switch_last_changed_ts: String(Date.parse(MIDDAY) / 1000 - 400), + }, + expect: { + sourceMode: 'grid_forced', + state: 'start_grid_forced_min_current', + decision: 'start', + desiredCharging: true, + }, + }, + { + id: 'boost-verrou-plein-tient-reseau-present', + label: "CONTREFACTUEL anti-oscillateur : verrou 'grid_battery_guard' (garde mordu RESEAU PRESENT, incident du 01/09) → il tient ses 20 min malgre le reseau", + time: MIDDAY, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + evcs_charging_switch: 'off', control_latch: 'off', charge_permission_now: 'off', + victron_evcs_strategy: 'force_grid_now', + last_block_reason: 'grid_battery_guard', + blocked_until: '2026-08-08 13:19:30', + last_stop_at: '2026-08-08 12:59:30', + prev_surplus_state: 'stop_grid_battery_guard', + grid_state: 'on', victron_acin1_available: '1', grid_power_w: '900', + soc_percent: '55', pv_power_w: '1200', pv_power_10m_avg_w: '1400', + house_ac_power_w: '900', vebus_ac_out_l1_power_w: '900', + battery_current_a: '2', + acin1_last_changed_ts: String(Date.parse(MIDDAY) / 1000 - 400), + grid_switch_last_changed_ts: String(Date.parse(MIDDAY) / 1000 - 400), + }, + expect: { + state: 'wait_reason_lock', + decision: 'hold', + desiredCharging: false, + blockReason: 'grid_battery_guard', + }, + }, + { + id: 'boost-verrou-no-grid-tient-reseau-toujours-absent', + // NOTE : ce n'est pas le verrou qui protege ici mais la branche anterieure + // wait_grid_source (source reseau demandee, reseau absent => arret). La + // clause de pertinence '_no_grid && !gridAvailable' dans BCL est donc une + // ceinture DEFENSIVE (elle couvre un futur reordonnancement de branches) ; + // la propriete observable epinglee est : AUCUNE tentative sur la batterie. + label: "Verrou '_no_grid', reseau toujours absent → arret en attente du reseau, aucun retry sur la batterie", + time: MIDDAY, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + evcs_charging_switch: 'off', control_latch: 'off', charge_permission_now: 'off', + victron_evcs_strategy: 'force_grid_now', + last_block_reason: 'grid_battery_guard_no_grid', + blocked_until: '2026-08-08 13:19:30', + last_stop_at: '2026-08-08 12:59:30', + prev_surplus_state: 'stop_grid_battery_guard', + grid_state: 'off', victron_acin1_available: '0', grid_power_w: '0', + soc_percent: '55', pv_power_w: '300', pv_power_10m_avg_w: '400', + house_ac_power_w: '900', vebus_ac_out_l1_power_w: '900', + battery_current_a: '-4', + }, + expect: { + state: 'wait_grid_source', + desiredCharging: false, + blockReason: 'grid_unavailable', + }, + }, ]; diff --git a/workflows/evcs-surplus-controller-v2.6.json b/workflows/evcs-surplus-controller-v2.6.json index faf0237..f4c2582 100644 --- a/workflows/evcs-surplus-controller-v2.6.json +++ b/workflows/evcs-surplus-controller-v2.6.json @@ -81,7 +81,7 @@ }, { "parameters": { - "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n\n return {\n date,\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction hhmmssFromMinutes(minOfDay) {\n const mm = ((minOfDay % 1440) + 1440) % 1440;\n const hh = Math.floor(mm / 60);\n const mi = mm % 60;\n return `${to2(hh)}:${to2(mi)}:00`;\n}\n\nfunction nextDailyOccurrenceMs(startMin, refMs) {\n if (!Number.isFinite(startMin)) return null;\n\n const today = getParisParts(refMs).date;\n const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;\n const todayMs = parseParisStampToMs(todayStamp);\n\n if (todayMs !== null && todayMs > refMs + 1000) return todayMs;\n\n const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;\n const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;\n return parseParisStampToMs(tomorrowStamp);\n}\n\nconst prev = $input.first().json;\n\nconst {\n nowMs,\n cfg,\n parsed,\n params,\n history,\n batteryTune,\n strategy,\n derived,\n context,\n lifecycle,\n} = prev;\n\n// =====================================================\n// SESSION / SOURCE\n// =====================================================\nconst sessionObservedActive =\n parsed.evcsChargingSwitchOn ||\n parsed.controlLatch ||\n parsed.chargePermissionNow;\n\nconst isGridSource =\n context.sourceMode === 'grid_hc_allowed' ||\n context.sourceMode === 'grid_forced';\n\nconst isLocalSolarSource =\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential';\n\n// =====================================================\n// SOC BAND / BATTERY DYNAMIC POLICY\n// =====================================================\nconst socBand =\n parsed.soc > 90 ? 'gt90' :\n parsed.soc > 80 ? '80_90' :\n parsed.soc > 60 ? '60_80' :\n parsed.soc > 40 ? '40_60' :\n parsed.soc > 25 ? '25_40' :\n 'lt25';\n\nconst band = batteryTune.bands[socBand];\n\nconst dischargeModeFactor =\n strategy.forceChargeRequested ? 1.2 :\n strategy.forceGridRequested ? 0.7 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst chargeReserveModeFactor =\n strategy.forceChargeRequested ? 0.8 :\n strategy.forceGridRequested ? 1.1 :\n strategy.userStrategy === 'auto_solar' ? 1.1 :\n 1.0;\n\nconst toleranceModeFactor =\n strategy.forceChargeRequested ? 1.25 :\n strategy.forceGridRequested ? 0.8 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;\nconst batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;\nconst batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);\nconst batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;\nconst batteryHardToleranceSeconds = band.hardToleranceSec;\nconst batteryRecoveryMinutes = band.recoveryMin;\n\n// =====================================================\n// CONTEXT HELPERS\n// =====================================================\nconst houseSpikeThresholdW = 1500;\nconst houseSpikeDetected =\n (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;\n\nconst solarPotentialContext =\n context.sourceMode === 'solar_potential' ||\n (\n parsed.soc >= 80 &&\n derived.forecastBufferKwh > 0 &&\n parsed.pvPowerW < context.effectivePvStartMinW &&\n derived.houseNonEvcsW < 1200\n );\n\n// =====================================================\n// OUTSIDE-WINDOW LOCAL BYPASS\n// IMPORTANT:\n// en auto_solar_plus ou force_charge_now, un contexte local solaire\n// ne doit plus rester bloqué juste parce qu'on est hors fenêtre.\n// =====================================================\nconst outsideWindowLocalBypass =\n context.profile === 'outside_window' &&\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\n// =====================================================\n// REASON-BASED LOCKS\n// =====================================================\nconst nextWindowCandidates = [\n nextDailyOccurrenceMs(params.morningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),\n nextDailyOccurrenceMs(params.eveningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),\n].filter(v => Number.isFinite(v));\n\nconst nextAllowedWindowStartMs =\n nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;\n\nconst blockActive =\n history.blockedUntilMs !== null &&\n history.blockedUntilMs > nowMs + 1000;\n\nconst blockStillRelevant =\n history.lastBlockReason === 'outside_window'\n ? (context.profile === 'outside_window' && !outsideWindowLocalBypass)\n : history.lastBlockReason === 'probe_failed'\n ? context.sourceMode === 'hybrid_probe'\n : history.lastBlockReason === 'battery_protection_stop'\n ? (\n parsed.batteryCurrentA < -params.batteryNeutralBandA ||\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageSoftLow\n )\n : history.lastBlockReason === 'grid_battery_guard'\n ? isGridSource\n : history.lastBlockReason === 'vebus_overload_persistent'\n ? derived.anyVebusOverload\n : false;\n\nconst startBlockedByReason = blockActive && blockStillRelevant;\n\n// =====================================================\n// CLOUD DEBT\n// =====================================================\nconst batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;\nlet cloudDebtNextKwh = params.cloudDebtPrevKwh;\n\nif (sessionObservedActive) {\n if (batteryNetPowerW < 0 && !isGridSource) {\n cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;\n } else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n }\n} else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n}\n\ncloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);\n\nconst cloudDebtMeaningful =\n cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;\n\nconst cloudEventDischargingNow =\n sessionObservedActive &&\n !isGridSource &&\n parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&\n cloudDebtMeaningful;\n\nconst continuingCloudEvent =\n cloudEventDischargingNow &&\n history.cloudDebtStartedMsPrev !== null &&\n params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;\n\nconst enteringCloudDebt =\n cloudEventDischargingNow && !continuingCloudEvent;\n\nconst cloudDebtActive = cloudEventDischargingNow;\n\nconst cloudDebtStartedMs =\n cloudEventDischargingNow\n ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)\n : null;\n\nconst cloudDebtDurationMinutes =\n cloudDebtActive && cloudDebtStartedMs !== null\n ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)\n : 0;\n\nconst effectiveCloudToleranceMinutes =\n params.cloudToleranceMinutes +\n (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +\n (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);\n\nconst cloudToleranceOk =\n cloudDebtNextKwh <= params.cloudDebtMaxKwh &&\n (\n !cloudDebtActive ||\n cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes\n );\n\n// =====================================================\n// LEGACY / FORECAST MODES\n// =====================================================\nconst batteryFullHarvestMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.batteryFullSoc &&\n parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);\n\nconst forecastCommitMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.forecastCommitSocFloor &&\n parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&\n derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;\n\nconst batteryPriorityMode =\n !batteryFullHarvestMode && !forecastCommitMode;\n\n// =====================================================\n// EVCS NIGHT ENGINE — R1 (07/08/2026)\n// Le moteur nuit est calculé UNE SEULE FOIS dans WINDOWS_SOURCEV2\n// (payload nightEvcsPreview). Ce node CONSOMME, il ne recalcule plus.\n// TOUTE évolution de la logique nuit se fait dans WINDOWS_SOURCEV2.\n// Fallback sûr si preview absent : session inactive / 'blocked'.\n// =====================================================\nconst nightPrev = prev.nightEvcsPreview || {};\n\nconst nightSessionActive = !!nightPrev.nightSessionActive;\nconst nightSessionNeedsCapture = !!nightPrev.nightSessionNeedsCapture;\nconst nightSessionNeedsClear = !!nightPrev.nightSessionNeedsClear;\nconst nightSessionStartSocPct =\n Number.isFinite(nightPrev.nightSessionStartSocPct) ? nightPrev.nightSessionStartSocPct : null;\nconst nightSessionStartAt = nightPrev.nightSessionStartAt || '';\nconst nightSessionStartMs =\n Number.isFinite(nightPrev.nightSessionStartMs) ? nightPrev.nightSessionStartMs : null;\n\nconst batteryHardFloorPct =\n Number.isFinite(nightPrev.batteryHardFloorPct)\n ? nightPrev.batteryHardFloorPct\n : params.evcsNightBatteryHardFloorPct;\nconst batteryMorningTargetPct =\n Number.isFinite(nightPrev.batteryMorningTargetPct)\n ? nightPrev.batteryMorningTargetPct\n : params.evcsNightTargetWeakForecastPct;\nconst balanceMorningTargetApplied = !!nightPrev.balanceMorningTargetApplied;\nconst nightGridSessionCommitted = !!nightPrev.nightGridSessionCommitted;\nconst maxBatteryDischargePoints =\n Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0;\nconst gridTakeoverTriggerPct =\n Number.isFinite(nightPrev.gridTakeoverTriggerPct)\n ? nightPrev.gridTakeoverTriggerPct\n : Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor = !!nightPrev.batteryBelowHardFloor;\nconst batteryBelowMorningTarget = !!nightPrev.batteryBelowMorningTarget;\nconst batteryAtOrBelowGridTakeoverTrigger = !!nightPrev.batteryAtOrBelowGridTakeoverTrigger;\n\nconst batteryKwhMissingToTarget =\n Number.isFinite(nightPrev.batteryKwhMissingToTarget) ? nightPrev.batteryKwhMissingToTarget : 0;\nconst batteryRecoveryMinutesNeeded =\n Number.isFinite(nightPrev.batteryRecoveryMinutesNeeded) ? nightPrev.batteryRecoveryMinutesNeeded : 0;\nconst batteryRecoveryUrgent = !!nightPrev.batteryRecoveryUrgent;\nconst batteryRecoveryAllowedNow = !!nightPrev.batteryRecoveryAllowedNow;\nconst batteryTargetReached = !!nightPrev.batteryTargetReached;\n\nconst batteryAssistAllowed = !!nightPrev.batteryAssistAllowed;\nconst nightGridTakeoverNeeded = !!nightPrev.nightGridTakeoverNeeded;\nconst nightTakeoverLatched = !!nightPrev.nightTakeoverLatched;\nconst vehicleChargeCompleted = !!nightPrev.vehicleChargeCompleted;\nconst nightRecoveryDone = !!nightPrev.nightRecoveryDone;\nconst departureUrgentActive = !!nightPrev.departureUrgentActive;\nconst departureChargeDone = !!nightPrev.departureChargeDone;\nconst dayVehicleFullIdle = !!nightPrev.dayVehicleFullIdle;\nconst nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked';\n\n// =====================================================\n// PROFILE-BASED THRESHOLDS\n// =====================================================\n// =====================================================\n// PLANCHER DU SOIR : UN NIVEAU FIXE, REMPLACE PAR L'ENERGIE A COUVRIR (03/09/2026)\n// =====================================================\n// `evening_soc_start_min` = 85 % est une tentative de dire « garde de quoi\n// passer la nuit ». C'est un NIVEAU, pris a un instant, et il ne sait rien de\n// la nuit qui vient : ni sa duree, ni la consommation a couvrir, ni l'heure a\n// laquelle le solaire prendra le relais.\n//\n// Le bilan energetique calcule exactement cela et le publie —\n// `evcs_balance_required_soc`, deduit de l'energie a couvrir jusqu'au\n// croisement production/consommation. Personne ne le lisait : `grep\n// evcs_balance` sur les deux workflows de decision renvoyait 0.\n//\n// Le 03/09 a 19:44, les deux nombres coexistaient dans le meme systeme :\n// plancher du soir 85 % (SOC 94, donc charge autorisee) contre SOC requis\n// 99,7 % pour tenir jusqu'au croisement de 9 h. La charge du soir a fait\n// passer le manque de la nuit de 0,86 a 2,21 kWh.\n//\n// SENS UNIQUE, ET C'EST DELIBERE : le bilan ne peut que RENFORCER le plancher,\n// jamais l'abaisser (Math.max). L'assouplir — le bilan dira souvent moins de\n// 85 % par forte prevision d'ete — autoriserait des charges qui n'ont pas lieu\n// aujourd'hui : c'est une decision distincte, avec ses propres risques, qui\n// doit etre validee pour elle-meme et non glissee dans ce correctif.\n// Corollaire : le max rend la garde robuste sans borne ni ecretage — une\n// valeur nulle, negative ou aberrante ne peut pas relacher le plancher.\n//\n// PORTEE : plancher de DEMARRAGE. Le plancher de maintien reste\n// min(profileSocHoldFloor, ...) donc inchange a 80 % : relever le plancher de\n// demarrage ne coupe aucune charge en cours.\n//\n// L'abondance P6 continue de primer : PV moyen >= 4 kW ET batterie qui monte a\n// >= +20 A est une PREUVE MESUREE que la ressource depasse ce que le systeme\n// absorbe — le cas ou la voiture est effectivement gratuite.\n//\n// REPLI : lecture absente, statut non 'ok', ou bilan plus vieux que six de ses\n// cycles => regle historique inchangee. Le kill-switch existe deja et n'a pas\n// besoin d'etre double : `input_boolean.evcs_balance_enabled` a OFF arrete les\n// ecritures du bilan, `balance_last_run` se fige, et le plancher retombe seul\n// sur 85 % en une demi-heure.\nconst BALANCE_MAX_AGE_MIN = 30;\nconst balanceAgeMin =\n params.balanceLastRunMs !== null && params.balanceLastRunMs !== undefined\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\nconst balanceFresh =\n balanceAgeMin !== null &&\n balanceAgeMin >= -5 &&\n balanceAgeMin <= BALANCE_MAX_AGE_MIN;\nconst balanceEveningFloorUsable =\n balanceFresh &&\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct);\n\nconst eveningSocStartFloorPct = balanceEveningFloorUsable\n ? Math.max(params.eveningSocStartMin, params.balanceRequiredSocPct)\n : params.eveningSocStartMin;\nconst eveningFloorFromBalance =\n balanceEveningFloorUsable && eveningSocStartFloorPct > params.eveningSocStartMin;\n\nconst profileSocStartFloor =\n context.profile === 'morning_solar' ? params.morningSocMin :\n context.profile === 'midday_hc_or_solar' ? params.middaySocMin :\n context.profile === 'evening_high_soc' ? eveningSocStartFloorPct :\n params.socStartMin;\n\n// P6 ABONDANCE : PV moyen élevé + batterie en charge franche => le plancher SOC\n// de démarrage perd son sens (la batterie continue de monter même avec l'EVCS à 6 A).\n// Jours nuageux ou batterie peu chargée par le PV : rien ne change.\nconst PV_ABUNDANCE_AVG_MIN_W = 4000;\nconst ABUNDANCE_BATTERY_CHARGE_MIN_A = 20;\nconst ABUNDANCE_SOC_FLOOR_PCT = 40;\n\n// 24/08/2026 — L'abondance se PROUVE au démarrage (batterie qui monte franchement,\n// >= +20 A, avant qu'on touche à quoi que ce soit) mais elle se MAINTIENT tant que\n// le PV reste fort ET que la batterie n'est pas devenue fournisseuse. Sans cette\n// distinction, la charge du véhicule — qui détourne précisément le surplus — fait\n// tomber le courant batterie sous +20 A et détruit sa propre condition\n// d'autorisation : système bistable, oscillation à la période de min_run.\nconst solarAbundanceStart =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A;\nconst solarAbundanceHold =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= 0;\nconst solarAbundance = solarAbundanceStart; // conservé pour la traçabilité/debug\n\nconst abundanceSocFloor = (abundant) =>\n abundant ? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT) : profileSocStartFloor;\n\nconst effectiveSocStartFloor = abundanceSocFloor(solarAbundanceStart);\n\nconst profileSocHoldFloor =\n context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :\n context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :\n context.profile === 'evening_high_soc' ? params.eveningSocStopMin :\n params.socStopMin;\n\n// INVARIANT (24/08/2026) : on ne doit JAMAIS avoir le droit de DÉMARRER une charge\n// qu'on n'a pas le droit de MAINTENIR. Le plancher de maintien doit donc rester\n// <= au plancher de démarrage effectif.\n// Incident du 24/08 midi : bascule de profil vers midday_hc_or_solar à 12:24 avec\n// SOC 58 % => plancher de démarrage 40 % (abondance P6) mais plancher de maintien\n// max(29, 75-15) = 60 % => démarrage autorisé, maintien refusé, cycle limite de\n// 3 min (min_run) sur toute la bande [40 %, 60 %], et ~2,6 kW de PV écrêtés à\n// chaque creux faute de preneur (ESS sans injection).\nconst effectiveSocHoldFloor = Math.min(\n profileSocHoldFloor,\n abundanceSocFloor(solarAbundanceHold)\n);\n\nconst profileSpecificDischargeFloorA =\n context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :\n context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :\n context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :\n params.batteryDischargeSoftA;\n\nconst profileBatteryDischargeFloorA =\n Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);\n\n// =====================================================\n// DEPASSEMENT SOUPLE DU PLANCHER DE DECHARGE (01/09/2026)\n// =====================================================\n// La table des bandes SOC (INPUT_PARSE) declare depuis toujours DEUX regimes :\n// un courant SOUPLE tolere pendant des MINUTES (softToleranceMin : 12 min a\n// SOC > 90, 1 min sous 25) et un courant DUR tolere quelques SECONDES. Seuls\n// les seuils de courant etaient lus ; la dimension temporelle etait calculee,\n// exportee dans le debug, et branchee sur AUCUNE decision. Le controleur\n// comparait donc un echantillon instantane, toutes les 30 s, a un plancher.\n//\n// Incident du 01/09 (14:26 et 14:56, SOC 94, batterie en float) : le courant\n// batterie oscillait entre +1,8 et -2,6 A d'un echantillon a l'autre, soit le\n// bruit de regulation d'une batterie qui ne fait rien. Le test devenait un\n// tirage a pile ou face et la charge du vehicule s'arretait dessus. Chaque\n// arret coutait ~0,45 kWh de production refusee : batterie pleine, le vehicule\n// etait le SEUL preneur, donc la PV s'effondrait de 6,6 a 2,5 kW en 30 s, et\n// la reprise mettait 21 min a remonter de 6 A a 20 A. 1 a 6 arrets par jour\n// sur la semaine du 26 au 31/08.\n//\n// Le correctif est PUREMENT TEMPOREL : aucun seuil n'est deplace, aucune\n// constante n'est ajoutee. On applique la table telle qu'elle est ecrite :\n// un depassement souple doit DURER pour compter. Comme le bruit change de\n// signe en quelques secondes, le compteur se remet a zero de lui-meme ; une\n// vraie decharge, elle, reste negative a chaque echantillon et finit par\n// depasser la tolerance de sa bande.\n//\n// CE QUI N'EST PAS TOUCHE : le plancher de DEMARRAGE reste instantane (ne pas\n// lancer une charge sur une batterie qui se decharge deja, c'est la frontiere\n// \"delestage et non charge prioritaire\"), et le garde DUR reste instantane —\n// hardToleranceSec n'est volontairement PAS branche, cela ne pourrait que\n// ralentir une securite, et c'est le risque qui prime.\nconst batterySoftBreachNow =\n sessionObservedActive &&\n parsed.batteryCurrentA < profileBatteryDischargeFloorA;\n\nconst batterySoftBreachStartedMs = batterySoftBreachNow\n ? (history.batterySoftBreachMsPrev !== null\n ? history.batterySoftBreachMsPrev\n : nowMs)\n : null;\n\nconst batterySoftBreachMinutes =\n batterySoftBreachStartedMs !== null\n ? Math.max(0, (nowMs - batterySoftBreachStartedMs) / 60000)\n : 0;\n\n// Vrai tant que le depassement n'a pas dure plus que la tolerance de sa bande.\n// Une tolerance a 0 minute redonne exactement le comportement instantane.\nconst batterySoftDischargeHoldOk =\n !batterySoftBreachNow ||\n batterySoftBreachMinutes <= batterySoftToleranceMinutes;\n\n// =====================================================\n// GRID RELAX IN force_charge_now\n// =====================================================\n// Assouplissement uniquement pour la garde réseau,\n// sans toucher aux sécurités dures globales.\nconst gridForceChargeRelaxSoftA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 28 :\n parsed.soc >= 80 ? 16 :\n parsed.soc >= 60 ? 8 :\n parsed.soc >= 40 ? 4 :\n 0\n )\n : 0;\n\nconst gridForceChargeRelaxHardA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 39 :\n parsed.soc >= 80 ? 29 :\n parsed.soc >= 60 ? 18 :\n parsed.soc >= 40 ? 8 :\n 0\n )\n : 0;\n\nconst gridBatterySoftDynamicABase =\n Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);\n\nconst gridBatteryHardDynamicABase =\n Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);\n\nlet gridBatterySoftDynamicA =\n gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;\n\nlet gridBatteryHardDynamicA =\n gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;\n\n// Garantit que le hard reste plus permissif que le soft d'au moins 3 A\nif (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {\n gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;\n}\n\n// =====================================================\n// BASE SAFETY\n// =====================================================\nconst commonSafetyStartOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusOverload &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.vebusAcOutSoftReached &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\nconst commonSafetyHoldOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageHardLow &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\n// =====================================================\n// EXCEDENT SOLAIRE — LA GRANDEUR QUI DECIDE D'UN DEMARRAGE (03/09/2026)\n// =====================================================\n// Incident du 03/09 19:44 : PV 758 W, maison 1202 W, batterie deja a -12 A,\n// nuit qui tombe. Le controleur a lance 1,6 kW de charge parce que\n// pv_start_min_w (700 W) est une PUISSANCE PV ABSOLUE : \"758 >= 700, donc il y\n// a du soleil\". L'excedent, lui, valait -444 W. En 33 minutes la voiture a pris\n// 0,897 kWh et la batterie en a rendu 1,479 kWh, la veille d'une nuit que le\n// bilan energetique annoncait deja deficitaire (SOC requis 99,7 %).\n//\n// La grandeur juste n'est pas la production, c'est ce qui RESTE une fois la\n// maison servie. Sans injection et reseau absent, elle vaut la puissance\n// batterie au signe pres :\n//\n// excedent = PV - maison(hors EVCS) ~= puissance batterie\n//\n// C'est aussi ce qui separe les deux situations que la mesure PV seule confond\n// (PV_mesuree = min(ressource, conso + absorption batterie)) :\n//\n// - CENSURE : batterie pleine, MPPT bride faute de puits. La production se\n// cale sur la maison, l'excedent vaut ~0, la batterie ne bouge pas. Il y a\n// de la ressource REFUSEE : la provoquer avec la voiture est legitime, et\n// c'est toute la raison d'etre de P3 / de l'assistance batterie.\n// - DEFICIT reel : soir, nuages. L'excedent est NEGATIF, la batterie porte\n// deja la maison. Charger la voiture, c'est la vider.\n//\n// Zero n'est donc pas un seuil de plus : c'est la frontiere physique entre les\n// deux, et elle etait invisible tant qu'on regardait la production seule.\n// Aucune constante n'est introduite ; le chemin \"soleil reel\" reutilise\n// pv_start_min_w tel quel, applique a la bonne grandeur.\n//\n// PORTEE : DEMARRAGE uniquement. Les conditions de MAINTIEN sont inchangees —\n// une charge etablie a deja son plancher de decharge et sa tolerance temporelle\n// (01/09), et couper une charge en cours sur une mesure d'excedent bruitee\n// recreerait l'oscillateur qu'on vient d'eteindre.\nconst solarSurplusW = parsed.pvPowerW - derived.houseNonEvcsW;\n\n// LA PROVOCATION DOIT ETRE JUSTIFIEE PAR LES CHARGEURS EUX-MEMES (03/09/2026)\n// =====================================================================\n// Un excedent proche de zero est AMBIGU : c'est la signature de la censure\n// (production bridee, calee sur la maison) ET celle de l'equilibre de fin de\n// journee (production reelle qui rejoint la consommation). Le releve du 03/09\n// contient les deux a 51 minutes d'ecart, avec le meme excedent :\n//\n// 18:50 PV 769 W, maison 755 W, excedent +14 W -> 7 trackers LIMITES\n// 19:41 PV 817 W, maison 816 W, excedent +1 W -> 7 trackers MPPT_ACTIVE\n//\n// Le premier cache de la ressource refusee, le second n'a plus rien a donner.\n// Aucun seuil de puissance ne les separe. Les chargeurs, eux, le disent :\n// 'mppt_active' = je suis a mon point de puissance maximale, je donne tout ;\n// 'voltage_current_limited' / 'off' = je me retiens faute de preneur.\n//\n// Signal DIRECT (le materiel lui-meme, pas un proxy), INSTANTANE (30 s, la\n// cadence de la boucle) et, sur l'incident du 03/09, il a bascule a 19:20 —\n// 24 minutes avant le drapeau `pv_censored` du bilan energetique, qui tourne\n// toutes les 5 min et dont l'un des motifs est justement le proxy SOC >= 90\n// qu'on cherchait a remplacer.\n//\n// ⚠️ DEMARRAGE UNIQUEMENT, et c'est structurel, pas une precaution :\n// gager le sourceMode ou le MAINTIEN sur ce signal ferait que la charge, en\n// donnant un preneur au MPPT, detruirait sa propre condition d'autorisation —\n// exactement le bistable de l'abondance P6 corrige le 24/08. La garde n'est\n// lue que quand la voiture est a l'arret, instant ou l'etat des trackers\n// decrit la situation SANS elle. Verifie le 01/09 a 14:22-14:34 : les trackers\n// restaient limites PENDANT une charge de 3,4 kW sous 6,9 kW de PV — a midi la\n// ressource depasse ce que la voiture peut prendre, le signal ne s'auto-annule\n// donc pas non plus.\n//\n// Aucune lecture exploitable (integration muette) => on ne sait pas => on\n// retombe sur le comportement anterieur. On ne devine jamais un etat.\nconst mpptReadable = parsed.mpptReadableCount > 0;\nconst mpptAllAtMaxPower = mpptReadable && parsed.mpptHoldingBackCount === 0;\nconst solarProvocationJustified = !mpptAllAtMaxPower;\n\n// Chemin PROVOCATION (batterie haute, ou sourceMode solar_potential) : on\n// n'exige pas d'excedent mesurable — il est censure par construction — mais on\n// exige que la batterie ne porte pas deja la maison.\nconst solarSurplusProvocationOk = solarSurplusW >= 0;\n\n// Chemin SOLEIL REEL : l'excedent doit atteindre le seuil de demarrage.\nconst solarSurplusStartOk =\n context.sourceMode === 'solar_potential'\n ? (solarSurplusProvocationOk && solarProvocationJustified)\n : solarSurplusW >= context.effectivePvStartMinW;\n\n// =====================================================\n// BATTERY-ASSISTED SOLAR START/HOLD\n// IMPORTANT:\n// Installation sans injection:\n// le PV visible peut rester bas tant que l'EVCS ne démarre pas.\n// On autorise donc un démarrage/maintien local si la batterie est haute\n// et que la dette nuage reste faible.\n// =====================================================\nconst batteryAssistSolarPolicyEligible =\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\nconst batteryAssistSolarStartSocMin = 90;\nconst batteryAssistSolarHoldSocMin = 85;\n\nconst batteryAssistSolarDebtStartMaxKwh = Math.max(\n cfg.cloudDebtRecoveryThresholdKwh,\n Math.min(params.cloudDebtMaxKwh, 0.05)\n);\n\nconst batteryAssistSolarStartOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyStartOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) &&\n parsed.soc > batteryAssistSolarHoldSocMin &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n solarSurplusProvocationOk &&\n solarProvocationJustified &&\n cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&\n !cloudDebtActive;\n\nconst batteryAssistSolarHoldOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyHoldOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&\n batterySoftDischargeHoldOk &&\n cloudToleranceOk;\n\n// =====================================================\n// START / HOLD CONDITIONS\n// =====================================================\n// FENETRE D'ETABLISSEMENT (31/08/2026) — cf. WINDOWS_SOURCEV2 pour la mesure.\n// Pendant les ~150 s qui suivent l'arrivee du reseau, PV et courant batterie ne\n// mesurent rien d'exploitable. On NE DEMARRE PAS sur ces valeurs, et on ne\n// laisse pas une charge en cours s'arreter a cause d'elles. Les gardes de\n// securite (commonSafety*, planchers SOC, surcharge, pas de tampon) restent\n// evaluees normalement : c'est le risque qui prime, jamais le confort.\nconst gridSettling = derived.gridSettling === true;\n\nconst rawSolarStartOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n !gridSettling &&\n commonSafetyStartOk &&\n parsed.soc >= effectiveSocStartFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n solarSurplusStartOk;\n\nconst solarStartOk =\n rawSolarStartOk || batteryAssistSolarStartOk;\n\nconst rawSolarHoldOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyHoldOk &&\n parsed.soc >= effectiveSocHoldFloor &&\n (\n gridSettling ||\n batterySoftDischargeHoldOk\n ) &&\n (\n gridSettling ||\n parsed.pvPowerW >= params.pvHoldMinW ||\n cloudToleranceOk\n );\n\nconst solarHoldOk =\n rawSolarHoldOk || batteryAssistSolarHoldOk;\n\n// FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now\n// P9 (09/08/2026) : la fenêtre départ imminent autorise aussi le réseau.\nconst gridAllowedBySoc = \n strategy.forceGridRequested ||\n context.profile === 'night_hc' ||\n departureUrgentActive ||\n parsed.soc <= 35;\n\nconst gridStartOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyStartOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA;\n\nconst gridHoldOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyHoldOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatteryHardDynamicA;\n\n// P12 (15/09/2026) — le mode `battery_assist_night` etait MORT depuis toujours.\n// Il exige `batteryCurrentA >= plancher de decharge du profil` (0 A), condition\n// impossible la nuit puisque la maison tire sur la batterie (§11, ~ -14 A). Il\n// n'a donc jamais demarre une seule fois.\n//\n// Quand P12 est actif, on remplace ce test de COURANT par un test d'ENERGIE :\n// rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement PV. Le\n// plancher vient du bilan (`required_soc_prudent`, profil p80), il est calcule\n// dans WINDOWS_SOURCEV2 et transmis ici — ce node CONSOMME, il ne recalcule pas.\n//\n// ⚠️ LES GARDES DE COURANT NE SONT PAS SUPPRIMEES POUR AUTANT :\n// - `commonSafetyStartOk` / `commonSafetyHoldOk` restent evalues normalement ;\n// - le MAINTIEN garde `batteryDischargeHardDynamicA`, l'arret dur, qui n'est\n// jamais contourne ;\n// - la garde SOUPLE et sa tolerance temporelle (§35) continuent de s'appliquer\n// par `batterySoftDischargeHoldOk` en aval, et c'est elle qui arretera la\n// charge quand la bande de SOC deviendra trop basse pour le courant tire.\n// Ce qu'on retire, c'est uniquement le plancher de PROFIL, qui interdisait de\n// demarrer quoi que ce soit des que la batterie n'etait pas a l'equilibre.\nconst nightBatterySurplusAvailable = !!nightPrev.nightBatterySurplusAvailable;\nconst nightBatterySurplusFloorPct = Number.isFinite(nightPrev.nightBatterySurplusFloorPct)\n ? nightPrev.nightBatterySurplusFloorPct\n : Infinity;\n\nconst batteryAssistNightStartOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyStartOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n (\n nightBatterySurplusAvailable\n ? parsed.soc > nightBatterySurplusFloorPct\n : (\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA\n )\n );\n\nconst batteryAssistNightHoldOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyHoldOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.batteryCurrentA >= batteryDischargeHardDynamicA &&\n (\n nightBatterySurplusAvailable\n ? parsed.soc > nightBatterySurplusFloorPct\n : parsed.soc > gridTakeoverTriggerPct\n );\n\n// =====================================================\n// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE\n// =====================================================\nconst probeModeActive = context.sourceMode === 'hybrid_probe';\n\nconst probeStartOk =\n probeModeActive &&\n commonSafetyStartOk &&\n (context.morningProbeEligible || context.middayProbeEligible || context.afternoonProbeEligible);\n\nconst probeHoldGuardOk =\n probeModeActive &&\n commonSafetyHoldOk &&\n batterySoftDischargeHoldOk;\n\nconst probeWindowActive =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart < params.probeMinutes;\n\nconst probeElapsedReached =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart >= params.probeMinutes;\n\nconst probeSuccessNow =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n (\n parsed.pvPowerW >= params.pvHoldMinW &&\n parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)\n );\n\n// Une sonde ne peut pas etre declaree ratee sur une mesure prise pendant\n// l'etablissement : c'est le reseau qui masque la production, pas la sonde qui\n// echoue. On differe le verdict, on ne l'invente pas.\nconst probeFailedNow =\n probeModeActive &&\n probeElapsedReached &&\n !gridSettling &&\n !probeSuccessNow;\n\n// =====================================================\n// STEP-UP LOGIC\n// =====================================================\nconst stepUpByBatteryReserve =\n batteryPriorityMode &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA;\n\nconst stepUpByForecastMode =\n !batteryPriorityMode &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW\n ) &&\n derived.forecastBufferKwh >= -1 &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&\n cloudToleranceOk;\n\n// Pas de montee en courant sur des mesures d'etablissement. La descente, elle,\n// n'est pas bridee : c'est la direction sure.\nconst solarShouldStepUp =\n !gridSettling &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n (\n stepUpByBatteryReserve ||\n stepUpByForecastMode ||\n parsed.pvPowerW >= (context.effectivePvStartMinW + 400)\n );\n\nconst gridShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA &&\n !derived.houseLoadHighVsHeadroom;\n\nconst batteryAssistNightShouldStepUp =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA &&\n batteryAssistAllowed;\n\n// =====================================================\n// STEP-DOWN LOGIC\n// =====================================================\nconst solarShouldStepDownBattery =\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst solarShouldStepDownCloudDebt =\n !cloudToleranceOk &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst gridShouldStepDownBattery =\n parsed.batteryCurrentA < gridBatterySoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst batteryAssistNightShouldStepDown =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA &&\n (\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA ||\n parsed.soc <= gridTakeoverTriggerPct ||\n batteryRecoveryUrgent\n );\n\nconst shouldStepDownVebusPower =\n derived.vebusAcOutSoftReached &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownVebusVoltage =\n derived.vebusDcVoltageSoftLow &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownOverload =\n derived.anyVebusOverload &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStopOverloadAtMin =\n derived.anyVebusOverload &&\n derived.currentReferenceA <= params.currentMinA &&\n sessionObservedActive;\n\n// =====================================================\n// HARD STOPS / PROTECTIONS\n// =====================================================\nconst batteryCurrentHardExceeded =\n parsed.batteryCurrentA <= batteryDischargeHardDynamicA;\n\n// overload volontairement exclu du hard stop immédiat\nconst mustStopHard =\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageHardLow ||\n derived.vebusAcOutHardReached ||\n batteryCurrentHardExceeded;\n\nconst gridBatteryProtectionStop =\n sessionObservedActive &&\n isGridSource &&\n parsed.batteryCurrentA < gridBatteryHardDynamicA;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n batteryPolicy: {\n socBand,\n batteryDischargeSoftDynamicA,\n batteryDischargeHardDynamicA,\n batteryChargeReserveDynamicA,\n batterySoftToleranceMinutes,\n batteryHardToleranceSeconds,\n batteryRecoveryMinutes,\n\n // Depassement souple en cours (01/09/2026). startedMs = null quand il\n // n'y a pas de depassement : c'est ce que HA_SERVICE_CALLS_OUTPUT lit\n // pour ecrire ou effacer le helper de persistance.\n batterySoftBreachNow,\n batterySoftBreachStartedMs,\n batterySoftBreachMinutes,\n batterySoftDischargeHoldOk,\n\n gridBatterySoftDynamicA,\n gridBatteryHardDynamicA,\n gridForceChargeRelaxSoftA,\n gridForceChargeRelaxHardA,\n\n batteryAssistSolarStartSocMin,\n batteryAssistSolarHoldSocMin,\n batteryAssistSolarDebtStartMaxKwh,\n\n houseSpikeDetected,\n solarPotentialContext,\n outsideWindowLocalBypass,\n },\n\n locks: {\n nextAllowedWindowStartMs,\n blockActive,\n blockStillRelevant,\n startBlockedByReason,\n },\n\n cloud: {\n sessionObservedActive,\n batteryNetPowerW,\n cloudDebtNextKwh,\n cloudDebtMeaningful,\n cloudEventDischargingNow,\n continuingCloudEvent,\n enteringCloudDebt,\n cloudDebtActive,\n cloudDebtStartedMs,\n cloudDebtDurationMinutes,\n effectiveCloudToleranceMinutes,\n cloudToleranceOk,\n },\n\n modes: {\n batteryFullHarvestMode,\n forecastCommitMode,\n batteryPriorityMode,\n },\n\n nightEvcs: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n balanceMorningTargetApplied,\n nightGridSessionCommitted,\n // P12 — passe-plat du verrou anti-oscillateur vers la sortie.\n nightBatterySurplusExhausted: !!nightPrev.nightBatterySurplusExhausted,\n nightBatterySurplusAvailable: !!nightPrev.nightBatterySurplusAvailable,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n vehicleChargeCompleted,\n nightRecoveryDone,\n departureUrgentActive,\n departureChargeDone,\n dayVehicleFullIdle,\n nightPreferredSourceMode,\n },\n\n safeguards: {\n isGridSource,\n commonSafetyStartOk,\n commonSafetyHoldOk,\n profileSocStartFloor,\n effectiveSocStartFloor,\n solarAbundance,\n solarAbundanceHold,\n profileSocHoldFloor,\n effectiveSocHoldFloor,\n profileBatteryDischargeFloorA,\n\n // Excedent solaire (03/09/2026) : la grandeur qui autorise un demarrage.\n // Plancher du soir issu du bilan energetique (03/09/2026).\n eveningSocStartFloorPct,\n eveningFloorFromBalance,\n balanceRequiredSocPct: params.balanceRequiredSocPct,\n balanceEnergyToCoverKwh: params.balanceEnergyToCoverKwh,\n balanceAgeMin: balanceAgeMin === null ? null : Math.round(balanceAgeMin * 10) / 10,\n balanceEveningFloorUsable,\n\n solarSurplusW,\n solarSurplusProvocationOk,\n solarSurplusStartOk,\n\n // Etat declare par les chargeurs solaires (03/09/2026).\n mpptReadableCount: parsed.mpptReadableCount,\n mpptHoldingBackCount: parsed.mpptHoldingBackCount,\n mpptAllAtMaxPower,\n solarProvocationJustified,\n batteryCurrentHardExceeded,\n mustStopHard,\n gridBatteryProtectionStop,\n },\n\n conditions: {\n rawSolarStartOk,\n rawSolarHoldOk,\n batteryAssistSolarPolicyEligible,\n batteryAssistSolarStartOk,\n batteryAssistSolarHoldOk,\n\n solarStartOk,\n solarHoldOk,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n gridStartOk,\n gridHoldOk,\n\n batteryAssistNightStartOk,\n nightBatterySurplusAvailable,\n nightBatterySurplusFloorPct,\n batteryAssistNightHoldOk,\n\n probeStartOk,\n probeHoldGuardOk,\n probeWindowActive,\n probeElapsedReached,\n probeSuccessNow,\n probeFailedNow,\n\n solarShouldStepUp,\n gridShouldStepUp,\n batteryAssistNightShouldStepUp,\n\n solarShouldStepDownBattery,\n solarShouldStepDownCloudDebt,\n gridShouldStepDownBattery,\n batteryAssistNightShouldStepDown,\n\n shouldStepDownVebusPower,\n shouldStepDownVebusVoltage,\n shouldStepDownOverload,\n shouldStopOverloadAtMin,\n }\n }\n}];\n" + "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction getParisParts(ms = Date.now()) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n\n return {\n date,\n hour,\n minute: Number(parts.minute),\n second: Number(parts.second),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction hhmmssFromMinutes(minOfDay) {\n const mm = ((minOfDay % 1440) + 1440) % 1440;\n const hh = Math.floor(mm / 60);\n const mi = mm % 60;\n return `${to2(hh)}:${to2(mi)}:00`;\n}\n\nfunction nextDailyOccurrenceMs(startMin, refMs) {\n if (!Number.isFinite(startMin)) return null;\n\n const today = getParisParts(refMs).date;\n const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;\n const todayMs = parseParisStampToMs(todayStamp);\n\n if (todayMs !== null && todayMs > refMs + 1000) return todayMs;\n\n const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;\n const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;\n return parseParisStampToMs(tomorrowStamp);\n}\n\nconst prev = $input.first().json;\n\nconst {\n nowMs,\n cfg,\n parsed,\n params,\n history,\n batteryTune,\n strategy,\n derived,\n context,\n lifecycle,\n} = prev;\n\n// =====================================================\n// SESSION / SOURCE\n// =====================================================\nconst sessionObservedActive =\n parsed.evcsChargingSwitchOn ||\n parsed.controlLatch ||\n parsed.chargePermissionNow;\n\nconst isGridSource =\n context.sourceMode === 'grid_hc_allowed' ||\n context.sourceMode === 'grid_forced';\n\nconst isLocalSolarSource =\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential';\n\n// =====================================================\n// SOC BAND / BATTERY DYNAMIC POLICY\n// =====================================================\nconst socBand =\n parsed.soc > 90 ? 'gt90' :\n parsed.soc > 80 ? '80_90' :\n parsed.soc > 60 ? '60_80' :\n parsed.soc > 40 ? '40_60' :\n parsed.soc > 25 ? '25_40' :\n 'lt25';\n\nconst band = batteryTune.bands[socBand];\n\nconst dischargeModeFactor =\n strategy.forceChargeRequested ? 1.2 :\n strategy.forceGridRequested ? 0.7 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst chargeReserveModeFactor =\n strategy.forceChargeRequested ? 0.8 :\n strategy.forceGridRequested ? 1.1 :\n strategy.userStrategy === 'auto_solar' ? 1.1 :\n 1.0;\n\nconst toleranceModeFactor =\n strategy.forceChargeRequested ? 1.25 :\n strategy.forceGridRequested ? 0.8 :\n strategy.userStrategy === 'auto_solar' ? 0.8 :\n 1.0;\n\nconst batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;\nconst batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;\nconst batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);\nconst batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;\nconst batteryHardToleranceSeconds = band.hardToleranceSec;\nconst batteryRecoveryMinutes = band.recoveryMin;\n\n// =====================================================\n// CONTEXT HELPERS\n// =====================================================\nconst houseSpikeThresholdW = 1500;\nconst houseSpikeDetected =\n (parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;\n\nconst solarPotentialContext =\n context.sourceMode === 'solar_potential' ||\n (\n parsed.soc >= 80 &&\n derived.forecastBufferKwh > 0 &&\n parsed.pvPowerW < context.effectivePvStartMinW &&\n derived.houseNonEvcsW < 1200\n );\n\n// =====================================================\n// OUTSIDE-WINDOW LOCAL BYPASS\n// IMPORTANT:\n// en auto_solar_plus ou force_charge_now, un contexte local solaire\n// ne doit plus rester bloqué juste parce qu'on est hors fenêtre.\n// =====================================================\nconst outsideWindowLocalBypass =\n context.profile === 'outside_window' &&\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\n// =====================================================\n// REASON-BASED LOCKS\n// =====================================================\nconst nextWindowCandidates = [\n nextDailyOccurrenceMs(params.morningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),\n nextDailyOccurrenceMs(params.eveningStartMin, nowMs),\n nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),\n].filter(v => Number.isFinite(v));\n\nconst nextAllowedWindowStartMs =\n nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;\n\nconst blockActive =\n history.blockedUntilMs !== null &&\n history.blockedUntilMs > nowMs + 1000;\n\nconst blockStillRelevant =\n history.lastBlockReason === 'outside_window'\n ? (context.profile === 'outside_window' && !outsideWindowLocalBypass)\n : history.lastBlockReason === 'probe_failed'\n ? context.sourceMode === 'hybrid_probe'\n : history.lastBlockReason === 'battery_protection_stop'\n ? (\n parsed.batteryCurrentA < -params.batteryNeutralBandA ||\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageSoftLow\n )\n : history.lastBlockReason === 'grid_battery_guard'\n ? isGridSource\n // Garde batterie declenche RESEAU ABSENT (18/09) : sa cause est le\n // trou entre la demande de reseau et la fermeture du contacteur.\n // Le reseau arrive => la cause a disparu => le verrou se leve. Si la\n // batterie est encore sollicitee au retry, le garde re-mord et pose\n // cette fois 'grid_battery_guard' (reseau present) — verrou plein.\n // Convergence en une tentative, meme patron que Fix H.\n : history.lastBlockReason === 'grid_battery_guard_no_grid'\n ? (isGridSource && !parsed.gridAvailable)\n : history.lastBlockReason === 'vebus_overload_persistent'\n ? derived.anyVebusOverload\n : false;\n\nconst startBlockedByReason = blockActive && blockStillRelevant;\n\n// =====================================================\n// CLOUD DEBT\n// =====================================================\nconst batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;\nlet cloudDebtNextKwh = params.cloudDebtPrevKwh;\n\nif (sessionObservedActive) {\n if (batteryNetPowerW < 0 && !isGridSource) {\n cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;\n } else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n }\n} else if (batteryNetPowerW > 0) {\n cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;\n}\n\ncloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);\n\nconst cloudDebtMeaningful =\n cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;\n\nconst cloudEventDischargingNow =\n sessionObservedActive &&\n !isGridSource &&\n parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&\n cloudDebtMeaningful;\n\nconst continuingCloudEvent =\n cloudEventDischargingNow &&\n history.cloudDebtStartedMsPrev !== null &&\n params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;\n\nconst enteringCloudDebt =\n cloudEventDischargingNow && !continuingCloudEvent;\n\nconst cloudDebtActive = cloudEventDischargingNow;\n\nconst cloudDebtStartedMs =\n cloudEventDischargingNow\n ? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)\n : null;\n\nconst cloudDebtDurationMinutes =\n cloudDebtActive && cloudDebtStartedMs !== null\n ? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)\n : 0;\n\nconst effectiveCloudToleranceMinutes =\n params.cloudToleranceMinutes +\n (solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +\n (houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);\n\nconst cloudToleranceOk =\n cloudDebtNextKwh <= params.cloudDebtMaxKwh &&\n (\n !cloudDebtActive ||\n cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes\n );\n\n// =====================================================\n// LEGACY / FORECAST MODES\n// =====================================================\nconst batteryFullHarvestMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.batteryFullSoc &&\n parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);\n\nconst forecastCommitMode =\n parsed.forecastModeEnabled &&\n context.profile !== 'night_hc' &&\n context.profile !== 'outside_window' &&\n parsed.soc >= params.forecastCommitSocFloor &&\n parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&\n derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;\n\nconst batteryPriorityMode =\n !batteryFullHarvestMode && !forecastCommitMode;\n\n// =====================================================\n// EVCS NIGHT ENGINE — R1 (07/08/2026)\n// Le moteur nuit est calculé UNE SEULE FOIS dans WINDOWS_SOURCEV2\n// (payload nightEvcsPreview). Ce node CONSOMME, il ne recalcule plus.\n// TOUTE évolution de la logique nuit se fait dans WINDOWS_SOURCEV2.\n// Fallback sûr si preview absent : session inactive / 'blocked'.\n// =====================================================\nconst nightPrev = prev.nightEvcsPreview || {};\n\nconst nightSessionActive = !!nightPrev.nightSessionActive;\nconst nightSessionNeedsCapture = !!nightPrev.nightSessionNeedsCapture;\nconst nightSessionNeedsClear = !!nightPrev.nightSessionNeedsClear;\nconst nightSessionStartSocPct =\n Number.isFinite(nightPrev.nightSessionStartSocPct) ? nightPrev.nightSessionStartSocPct : null;\nconst nightSessionStartAt = nightPrev.nightSessionStartAt || '';\nconst nightSessionStartMs =\n Number.isFinite(nightPrev.nightSessionStartMs) ? nightPrev.nightSessionStartMs : null;\n\nconst batteryHardFloorPct =\n Number.isFinite(nightPrev.batteryHardFloorPct)\n ? nightPrev.batteryHardFloorPct\n : params.evcsNightBatteryHardFloorPct;\nconst batteryMorningTargetPct =\n Number.isFinite(nightPrev.batteryMorningTargetPct)\n ? nightPrev.batteryMorningTargetPct\n : params.evcsNightTargetWeakForecastPct;\nconst balanceMorningTargetApplied = !!nightPrev.balanceMorningTargetApplied;\nconst nightGridSessionCommitted = !!nightPrev.nightGridSessionCommitted;\nconst maxBatteryDischargePoints =\n Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0;\nconst gridTakeoverTriggerPct =\n Number.isFinite(nightPrev.gridTakeoverTriggerPct)\n ? nightPrev.gridTakeoverTriggerPct\n : Math.max(batteryHardFloorPct, batteryMorningTargetPct);\n\nconst batteryBelowHardFloor = !!nightPrev.batteryBelowHardFloor;\nconst batteryBelowMorningTarget = !!nightPrev.batteryBelowMorningTarget;\nconst batteryAtOrBelowGridTakeoverTrigger = !!nightPrev.batteryAtOrBelowGridTakeoverTrigger;\n\nconst batteryKwhMissingToTarget =\n Number.isFinite(nightPrev.batteryKwhMissingToTarget) ? nightPrev.batteryKwhMissingToTarget : 0;\nconst batteryRecoveryMinutesNeeded =\n Number.isFinite(nightPrev.batteryRecoveryMinutesNeeded) ? nightPrev.batteryRecoveryMinutesNeeded : 0;\nconst batteryRecoveryUrgent = !!nightPrev.batteryRecoveryUrgent;\nconst batteryRecoveryAllowedNow = !!nightPrev.batteryRecoveryAllowedNow;\nconst batteryTargetReached = !!nightPrev.batteryTargetReached;\n\nconst batteryAssistAllowed = !!nightPrev.batteryAssistAllowed;\nconst nightGridTakeoverNeeded = !!nightPrev.nightGridTakeoverNeeded;\nconst nightTakeoverLatched = !!nightPrev.nightTakeoverLatched;\nconst vehicleChargeCompleted = !!nightPrev.vehicleChargeCompleted;\nconst nightRecoveryDone = !!nightPrev.nightRecoveryDone;\nconst departureUrgentActive = !!nightPrev.departureUrgentActive;\nconst departureChargeDone = !!nightPrev.departureChargeDone;\nconst dayVehicleFullIdle = !!nightPrev.dayVehicleFullIdle;\nconst nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked';\n\n// =====================================================\n// PROFILE-BASED THRESHOLDS\n// =====================================================\n// =====================================================\n// PLANCHER DU SOIR : UN NIVEAU FIXE, REMPLACE PAR L'ENERGIE A COUVRIR (03/09/2026)\n// =====================================================\n// `evening_soc_start_min` = 85 % est une tentative de dire « garde de quoi\n// passer la nuit ». C'est un NIVEAU, pris a un instant, et il ne sait rien de\n// la nuit qui vient : ni sa duree, ni la consommation a couvrir, ni l'heure a\n// laquelle le solaire prendra le relais.\n//\n// Le bilan energetique calcule exactement cela et le publie —\n// `evcs_balance_required_soc`, deduit de l'energie a couvrir jusqu'au\n// croisement production/consommation. Personne ne le lisait : `grep\n// evcs_balance` sur les deux workflows de decision renvoyait 0.\n//\n// Le 03/09 a 19:44, les deux nombres coexistaient dans le meme systeme :\n// plancher du soir 85 % (SOC 94, donc charge autorisee) contre SOC requis\n// 99,7 % pour tenir jusqu'au croisement de 9 h. La charge du soir a fait\n// passer le manque de la nuit de 0,86 a 2,21 kWh.\n//\n// SENS UNIQUE, ET C'EST DELIBERE : le bilan ne peut que RENFORCER le plancher,\n// jamais l'abaisser (Math.max). L'assouplir — le bilan dira souvent moins de\n// 85 % par forte prevision d'ete — autoriserait des charges qui n'ont pas lieu\n// aujourd'hui : c'est une decision distincte, avec ses propres risques, qui\n// doit etre validee pour elle-meme et non glissee dans ce correctif.\n// Corollaire : le max rend la garde robuste sans borne ni ecretage — une\n// valeur nulle, negative ou aberrante ne peut pas relacher le plancher.\n//\n// PORTEE : plancher de DEMARRAGE. Le plancher de maintien reste\n// min(profileSocHoldFloor, ...) donc inchange a 80 % : relever le plancher de\n// demarrage ne coupe aucune charge en cours.\n//\n// L'abondance P6 continue de primer : PV moyen >= 4 kW ET batterie qui monte a\n// >= +20 A est une PREUVE MESUREE que la ressource depasse ce que le systeme\n// absorbe — le cas ou la voiture est effectivement gratuite.\n//\n// REPLI : lecture absente, statut non 'ok', ou bilan plus vieux que six de ses\n// cycles => regle historique inchangee. Le kill-switch existe deja et n'a pas\n// besoin d'etre double : `input_boolean.evcs_balance_enabled` a OFF arrete les\n// ecritures du bilan, `balance_last_run` se fige, et le plancher retombe seul\n// sur 85 % en une demi-heure.\nconst BALANCE_MAX_AGE_MIN = 30;\nconst balanceAgeMin =\n params.balanceLastRunMs !== null && params.balanceLastRunMs !== undefined\n ? (nowMs - params.balanceLastRunMs) / 60000\n : null;\nconst balanceFresh =\n balanceAgeMin !== null &&\n balanceAgeMin >= -5 &&\n balanceAgeMin <= BALANCE_MAX_AGE_MIN;\nconst balanceEveningFloorUsable =\n balanceFresh &&\n params.balanceStatusOk &&\n Number.isFinite(params.balanceRequiredSocPct);\n\nconst eveningSocStartFloorPct = balanceEveningFloorUsable\n ? Math.max(params.eveningSocStartMin, params.balanceRequiredSocPct)\n : params.eveningSocStartMin;\nconst eveningFloorFromBalance =\n balanceEveningFloorUsable && eveningSocStartFloorPct > params.eveningSocStartMin;\n\nconst profileSocStartFloor =\n context.profile === 'morning_solar' ? params.morningSocMin :\n context.profile === 'midday_hc_or_solar' ? params.middaySocMin :\n context.profile === 'evening_high_soc' ? eveningSocStartFloorPct :\n params.socStartMin;\n\n// P6 ABONDANCE : PV moyen élevé + batterie en charge franche => le plancher SOC\n// de démarrage perd son sens (la batterie continue de monter même avec l'EVCS à 6 A).\n// Jours nuageux ou batterie peu chargée par le PV : rien ne change.\nconst PV_ABUNDANCE_AVG_MIN_W = 4000;\nconst ABUNDANCE_BATTERY_CHARGE_MIN_A = 20;\nconst ABUNDANCE_SOC_FLOOR_PCT = 40;\n\n// 24/08/2026 — L'abondance se PROUVE au démarrage (batterie qui monte franchement,\n// >= +20 A, avant qu'on touche à quoi que ce soit) mais elle se MAINTIENT tant que\n// le PV reste fort ET que la batterie n'est pas devenue fournisseuse. Sans cette\n// distinction, la charge du véhicule — qui détourne précisément le surplus — fait\n// tomber le courant batterie sous +20 A et détruit sa propre condition\n// d'autorisation : système bistable, oscillation à la période de min_run.\nconst solarAbundanceStart =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A;\nconst solarAbundanceHold =\n parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&\n parsed.batteryCurrentA >= 0;\nconst solarAbundance = solarAbundanceStart; // conservé pour la traçabilité/debug\n\nconst abundanceSocFloor = (abundant) =>\n abundant ? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT) : profileSocStartFloor;\n\nconst effectiveSocStartFloor = abundanceSocFloor(solarAbundanceStart);\n\nconst profileSocHoldFloor =\n context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :\n context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :\n context.profile === 'evening_high_soc' ? params.eveningSocStopMin :\n params.socStopMin;\n\n// INVARIANT (24/08/2026) : on ne doit JAMAIS avoir le droit de DÉMARRER une charge\n// qu'on n'a pas le droit de MAINTENIR. Le plancher de maintien doit donc rester\n// <= au plancher de démarrage effectif.\n// Incident du 24/08 midi : bascule de profil vers midday_hc_or_solar à 12:24 avec\n// SOC 58 % => plancher de démarrage 40 % (abondance P6) mais plancher de maintien\n// max(29, 75-15) = 60 % => démarrage autorisé, maintien refusé, cycle limite de\n// 3 min (min_run) sur toute la bande [40 %, 60 %], et ~2,6 kW de PV écrêtés à\n// chaque creux faute de preneur (ESS sans injection).\nconst effectiveSocHoldFloor = Math.min(\n profileSocHoldFloor,\n abundanceSocFloor(solarAbundanceHold)\n);\n\nconst profileSpecificDischargeFloorA =\n context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :\n context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :\n context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :\n params.batteryDischargeSoftA;\n\nconst profileBatteryDischargeFloorA =\n Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);\n\n// =====================================================\n// DEPASSEMENT SOUPLE DU PLANCHER DE DECHARGE (01/09/2026)\n// =====================================================\n// La table des bandes SOC (INPUT_PARSE) declare depuis toujours DEUX regimes :\n// un courant SOUPLE tolere pendant des MINUTES (softToleranceMin : 12 min a\n// SOC > 90, 1 min sous 25) et un courant DUR tolere quelques SECONDES. Seuls\n// les seuils de courant etaient lus ; la dimension temporelle etait calculee,\n// exportee dans le debug, et branchee sur AUCUNE decision. Le controleur\n// comparait donc un echantillon instantane, toutes les 30 s, a un plancher.\n//\n// Incident du 01/09 (14:26 et 14:56, SOC 94, batterie en float) : le courant\n// batterie oscillait entre +1,8 et -2,6 A d'un echantillon a l'autre, soit le\n// bruit de regulation d'une batterie qui ne fait rien. Le test devenait un\n// tirage a pile ou face et la charge du vehicule s'arretait dessus. Chaque\n// arret coutait ~0,45 kWh de production refusee : batterie pleine, le vehicule\n// etait le SEUL preneur, donc la PV s'effondrait de 6,6 a 2,5 kW en 30 s, et\n// la reprise mettait 21 min a remonter de 6 A a 20 A. 1 a 6 arrets par jour\n// sur la semaine du 26 au 31/08.\n//\n// Le correctif est PUREMENT TEMPOREL : aucun seuil n'est deplace, aucune\n// constante n'est ajoutee. On applique la table telle qu'elle est ecrite :\n// un depassement souple doit DURER pour compter. Comme le bruit change de\n// signe en quelques secondes, le compteur se remet a zero de lui-meme ; une\n// vraie decharge, elle, reste negative a chaque echantillon et finit par\n// depasser la tolerance de sa bande.\n//\n// CE QUI N'EST PAS TOUCHE : le plancher de DEMARRAGE reste instantane (ne pas\n// lancer une charge sur une batterie qui se decharge deja, c'est la frontiere\n// \"delestage et non charge prioritaire\"), et le garde DUR reste instantane —\n// hardToleranceSec n'est volontairement PAS branche, cela ne pourrait que\n// ralentir une securite, et c'est le risque qui prime.\nconst batterySoftBreachNow =\n sessionObservedActive &&\n parsed.batteryCurrentA < profileBatteryDischargeFloorA;\n\nconst batterySoftBreachStartedMs = batterySoftBreachNow\n ? (history.batterySoftBreachMsPrev !== null\n ? history.batterySoftBreachMsPrev\n : nowMs)\n : null;\n\nconst batterySoftBreachMinutes =\n batterySoftBreachStartedMs !== null\n ? Math.max(0, (nowMs - batterySoftBreachStartedMs) / 60000)\n : 0;\n\n// Vrai tant que le depassement n'a pas dure plus que la tolerance de sa bande.\n// Une tolerance a 0 minute redonne exactement le comportement instantane.\nconst batterySoftDischargeHoldOk =\n !batterySoftBreachNow ||\n batterySoftBreachMinutes <= batterySoftToleranceMinutes;\n\n// =====================================================\n// GRID RELAX IN force_charge_now\n// =====================================================\n// Assouplissement uniquement pour la garde réseau,\n// sans toucher aux sécurités dures globales.\nconst gridForceChargeRelaxSoftA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 28 :\n parsed.soc >= 80 ? 16 :\n parsed.soc >= 60 ? 8 :\n parsed.soc >= 40 ? 4 :\n 0\n )\n : 0;\n\nconst gridForceChargeRelaxHardA =\n strategy.forceChargeRequested && isGridSource\n ? (\n parsed.soc >= 90 ? 39 :\n parsed.soc >= 80 ? 29 :\n parsed.soc >= 60 ? 18 :\n parsed.soc >= 40 ? 8 :\n 0\n )\n : 0;\n\nconst gridBatterySoftDynamicABase =\n Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);\n\nconst gridBatteryHardDynamicABase =\n Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);\n\nlet gridBatterySoftDynamicA =\n gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;\n\nlet gridBatteryHardDynamicA =\n gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;\n\n// Garantit que le hard reste plus permissif que le soft d'au moins 3 A\nif (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {\n gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;\n}\n\n// =====================================================\n// BASE SAFETY\n// =====================================================\nconst commonSafetyStartOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusOverload &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.vebusAcOutSoftReached &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\nconst commonSafetyHoldOk =\n derived.canRunAtMinCurrent &&\n !derived.anyVebusLowBattery &&\n !derived.vebusDcVoltageHardLow &&\n parsed.evcsDeviceConnected &&\n parsed.evcsVehicleConnected &&\n !parsed.evcsModeIsAuto;\n\n// =====================================================\n// EXCEDENT SOLAIRE — LA GRANDEUR QUI DECIDE D'UN DEMARRAGE (03/09/2026)\n// =====================================================\n// Incident du 03/09 19:44 : PV 758 W, maison 1202 W, batterie deja a -12 A,\n// nuit qui tombe. Le controleur a lance 1,6 kW de charge parce que\n// pv_start_min_w (700 W) est une PUISSANCE PV ABSOLUE : \"758 >= 700, donc il y\n// a du soleil\". L'excedent, lui, valait -444 W. En 33 minutes la voiture a pris\n// 0,897 kWh et la batterie en a rendu 1,479 kWh, la veille d'une nuit que le\n// bilan energetique annoncait deja deficitaire (SOC requis 99,7 %).\n//\n// La grandeur juste n'est pas la production, c'est ce qui RESTE une fois la\n// maison servie. Sans injection et reseau absent, elle vaut la puissance\n// batterie au signe pres :\n//\n// excedent = PV - maison(hors EVCS) ~= puissance batterie\n//\n// C'est aussi ce qui separe les deux situations que la mesure PV seule confond\n// (PV_mesuree = min(ressource, conso + absorption batterie)) :\n//\n// - CENSURE : batterie pleine, MPPT bride faute de puits. La production se\n// cale sur la maison, l'excedent vaut ~0, la batterie ne bouge pas. Il y a\n// de la ressource REFUSEE : la provoquer avec la voiture est legitime, et\n// c'est toute la raison d'etre de P3 / de l'assistance batterie.\n// - DEFICIT reel : soir, nuages. L'excedent est NEGATIF, la batterie porte\n// deja la maison. Charger la voiture, c'est la vider.\n//\n// Zero n'est donc pas un seuil de plus : c'est la frontiere physique entre les\n// deux, et elle etait invisible tant qu'on regardait la production seule.\n// Aucune constante n'est introduite ; le chemin \"soleil reel\" reutilise\n// pv_start_min_w tel quel, applique a la bonne grandeur.\n//\n// PORTEE : DEMARRAGE uniquement. Les conditions de MAINTIEN sont inchangees —\n// une charge etablie a deja son plancher de decharge et sa tolerance temporelle\n// (01/09), et couper une charge en cours sur une mesure d'excedent bruitee\n// recreerait l'oscillateur qu'on vient d'eteindre.\nconst solarSurplusW = parsed.pvPowerW - derived.houseNonEvcsW;\n\n// LA PROVOCATION DOIT ETRE JUSTIFIEE PAR LES CHARGEURS EUX-MEMES (03/09/2026)\n// =====================================================================\n// Un excedent proche de zero est AMBIGU : c'est la signature de la censure\n// (production bridee, calee sur la maison) ET celle de l'equilibre de fin de\n// journee (production reelle qui rejoint la consommation). Le releve du 03/09\n// contient les deux a 51 minutes d'ecart, avec le meme excedent :\n//\n// 18:50 PV 769 W, maison 755 W, excedent +14 W -> 7 trackers LIMITES\n// 19:41 PV 817 W, maison 816 W, excedent +1 W -> 7 trackers MPPT_ACTIVE\n//\n// Le premier cache de la ressource refusee, le second n'a plus rien a donner.\n// Aucun seuil de puissance ne les separe. Les chargeurs, eux, le disent :\n// 'mppt_active' = je suis a mon point de puissance maximale, je donne tout ;\n// 'voltage_current_limited' / 'off' = je me retiens faute de preneur.\n//\n// Signal DIRECT (le materiel lui-meme, pas un proxy), INSTANTANE (30 s, la\n// cadence de la boucle) et, sur l'incident du 03/09, il a bascule a 19:20 —\n// 24 minutes avant le drapeau `pv_censored` du bilan energetique, qui tourne\n// toutes les 5 min et dont l'un des motifs est justement le proxy SOC >= 90\n// qu'on cherchait a remplacer.\n//\n// ⚠️ DEMARRAGE UNIQUEMENT, et c'est structurel, pas une precaution :\n// gager le sourceMode ou le MAINTIEN sur ce signal ferait que la charge, en\n// donnant un preneur au MPPT, detruirait sa propre condition d'autorisation —\n// exactement le bistable de l'abondance P6 corrige le 24/08. La garde n'est\n// lue que quand la voiture est a l'arret, instant ou l'etat des trackers\n// decrit la situation SANS elle. Verifie le 01/09 a 14:22-14:34 : les trackers\n// restaient limites PENDANT une charge de 3,4 kW sous 6,9 kW de PV — a midi la\n// ressource depasse ce que la voiture peut prendre, le signal ne s'auto-annule\n// donc pas non plus.\n//\n// Aucune lecture exploitable (integration muette) => on ne sait pas => on\n// retombe sur le comportement anterieur. On ne devine jamais un etat.\nconst mpptReadable = parsed.mpptReadableCount > 0;\nconst mpptAllAtMaxPower = mpptReadable && parsed.mpptHoldingBackCount === 0;\nconst solarProvocationJustified = !mpptAllAtMaxPower;\n\n// Chemin PROVOCATION (batterie haute, ou sourceMode solar_potential) : on\n// n'exige pas d'excedent mesurable — il est censure par construction — mais on\n// exige que la batterie ne porte pas deja la maison.\nconst solarSurplusProvocationOk = solarSurplusW >= 0;\n\n// Chemin SOLEIL REEL : l'excedent doit atteindre le seuil de demarrage.\nconst solarSurplusStartOk =\n context.sourceMode === 'solar_potential'\n ? (solarSurplusProvocationOk && solarProvocationJustified)\n : solarSurplusW >= context.effectivePvStartMinW;\n\n// =====================================================\n// BATTERY-ASSISTED SOLAR START/HOLD\n// IMPORTANT:\n// Installation sans injection:\n// le PV visible peut rester bas tant que l'EVCS ne démarre pas.\n// On autorise donc un démarrage/maintien local si la batterie est haute\n// et que la dette nuage reste faible.\n// =====================================================\nconst batteryAssistSolarPolicyEligible =\n !strategy.forceGridRequested &&\n (\n strategy.forceChargeRequested ||\n strategy.userStrategy === 'auto_solar_plus'\n ) &&\n isLocalSolarSource;\n\nconst batteryAssistSolarStartSocMin = 90;\nconst batteryAssistSolarHoldSocMin = 85;\n\nconst batteryAssistSolarDebtStartMaxKwh = Math.max(\n cfg.cloudDebtRecoveryThresholdKwh,\n Math.min(params.cloudDebtMaxKwh, 0.05)\n);\n\nconst batteryAssistSolarStartOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyStartOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) &&\n parsed.soc > batteryAssistSolarHoldSocMin &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n solarSurplusProvocationOk &&\n solarProvocationJustified &&\n cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&\n !cloudDebtActive;\n\nconst batteryAssistSolarHoldOk =\n batteryAssistSolarPolicyEligible &&\n commonSafetyHoldOk &&\n !isGridSource &&\n parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&\n batterySoftDischargeHoldOk &&\n cloudToleranceOk;\n\n// =====================================================\n// START / HOLD CONDITIONS\n// =====================================================\n// FENETRE D'ETABLISSEMENT (31/08/2026) — cf. WINDOWS_SOURCEV2 pour la mesure.\n// Pendant les ~150 s qui suivent l'arrivee du reseau, PV et courant batterie ne\n// mesurent rien d'exploitable. On NE DEMARRE PAS sur ces valeurs, et on ne\n// laisse pas une charge en cours s'arreter a cause d'elles. Les gardes de\n// securite (commonSafety*, planchers SOC, surcharge, pas de tampon) restent\n// evaluees normalement : c'est le risque qui prime, jamais le confort.\nconst gridSettling = derived.gridSettling === true;\n\nconst rawSolarStartOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n !gridSettling &&\n commonSafetyStartOk &&\n parsed.soc >= effectiveSocStartFloor &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&\n solarSurplusStartOk;\n\nconst solarStartOk =\n rawSolarStartOk || batteryAssistSolarStartOk;\n\nconst rawSolarHoldOk =\n (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&\n commonSafetyHoldOk &&\n parsed.soc >= effectiveSocHoldFloor &&\n (\n gridSettling ||\n batterySoftDischargeHoldOk\n ) &&\n (\n gridSettling ||\n parsed.pvPowerW >= params.pvHoldMinW ||\n cloudToleranceOk\n );\n\nconst solarHoldOk =\n rawSolarHoldOk || batteryAssistSolarHoldOk;\n\n// FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now\n// P9 (09/08/2026) : la fenêtre départ imminent autorise aussi le réseau.\nconst gridAllowedBySoc = \n strategy.forceGridRequested ||\n context.profile === 'night_hc' ||\n departureUrgentActive ||\n parsed.soc <= 35;\n\nconst gridStartOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyStartOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA;\n\nconst gridHoldOk =\n gridAllowedBySoc &&\n isGridSource &&\n commonSafetyHoldOk &&\n parsed.gridAvailable &&\n parsed.batteryCurrentA >= gridBatteryHardDynamicA;\n\n// P12 (15/09/2026) — le mode `battery_assist_night` etait MORT depuis toujours.\n// Il exige `batteryCurrentA >= plancher de decharge du profil` (0 A), condition\n// impossible la nuit puisque la maison tire sur la batterie (§11, ~ -14 A). Il\n// n'a donc jamais demarre une seule fois.\n//\n// Quand P12 est actif, on remplace ce test de COURANT par un test d'ENERGIE :\n// rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement PV. Le\n// plancher vient du bilan (`required_soc_prudent`, profil p80), il est calcule\n// dans WINDOWS_SOURCEV2 et transmis ici — ce node CONSOMME, il ne recalcule pas.\n//\n// ⚠️ LES GARDES DE COURANT NE SONT PAS SUPPRIMEES POUR AUTANT :\n// - `commonSafetyStartOk` / `commonSafetyHoldOk` restent evalues normalement ;\n// - le MAINTIEN garde `batteryDischargeHardDynamicA`, l'arret dur, qui n'est\n// jamais contourne ;\n// - la garde SOUPLE et sa tolerance temporelle (§35) continuent de s'appliquer\n// par `batterySoftDischargeHoldOk` en aval, et c'est elle qui arretera la\n// charge quand la bande de SOC deviendra trop basse pour le courant tire.\n// Ce qu'on retire, c'est uniquement le plancher de PROFIL, qui interdisait de\n// demarrer quoi que ce soit des que la batterie n'etait pas a l'equilibre.\nconst nightBatterySurplusAvailable = !!nightPrev.nightBatterySurplusAvailable;\nconst nightBatterySurplusFloorPct = Number.isFinite(nightPrev.nightBatterySurplusFloorPct)\n ? nightPrev.nightBatterySurplusFloorPct\n : Infinity;\n\nconst batteryAssistNightStartOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyStartOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n (\n nightBatterySurplusAvailable\n ? parsed.soc > nightBatterySurplusFloorPct\n : (\n parsed.soc > gridTakeoverTriggerPct &&\n parsed.batteryCurrentA >= profileBatteryDischargeFloorA\n )\n );\n\nconst batteryAssistNightHoldOk =\n context.sourceMode === 'battery_assist_night' &&\n commonSafetyHoldOk &&\n nightSessionActive &&\n batteryAssistAllowed &&\n parsed.soc > batteryHardFloorPct &&\n parsed.batteryCurrentA >= batteryDischargeHardDynamicA &&\n (\n nightBatterySurplusAvailable\n ? parsed.soc > nightBatterySurplusFloorPct\n : parsed.soc > gridTakeoverTriggerPct\n );\n\n// =====================================================\n// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE\n// =====================================================\nconst probeModeActive = context.sourceMode === 'hybrid_probe';\n\nconst probeStartOk =\n probeModeActive &&\n commonSafetyStartOk &&\n (context.morningProbeEligible || context.middayProbeEligible || context.afternoonProbeEligible);\n\nconst probeHoldGuardOk =\n probeModeActive &&\n commonSafetyHoldOk &&\n batterySoftDischargeHoldOk;\n\nconst probeWindowActive =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart < params.probeMinutes;\n\nconst probeElapsedReached =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n lifecycle.minutesSinceProbeStart !== null &&\n lifecycle.minutesSinceProbeStart >= params.probeMinutes;\n\nconst probeSuccessNow =\n probeModeActive &&\n sessionObservedActive &&\n history.probeStartedMs !== null &&\n (\n parsed.pvPowerW >= params.pvHoldMinW &&\n parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)\n );\n\n// Une sonde ne peut pas etre declaree ratee sur une mesure prise pendant\n// l'etablissement : c'est le reseau qui masque la production, pas la sonde qui\n// echoue. On differe le verdict, on ne l'invente pas.\nconst probeFailedNow =\n probeModeActive &&\n probeElapsedReached &&\n !gridSettling &&\n !probeSuccessNow;\n\n// =====================================================\n// STEP-UP LOGIC\n// =====================================================\nconst stepUpByBatteryReserve =\n batteryPriorityMode &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA;\n\nconst stepUpByForecastMode =\n !batteryPriorityMode &&\n (\n parsed.pvPowerW >= params.pvHoldMinW ||\n parsed.pvPowerAvg10mW >= params.pvHoldMinW\n ) &&\n derived.forecastBufferKwh >= -1 &&\n derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&\n cloudToleranceOk;\n\n// Pas de montee en courant sur des mesures d'etablissement. La descente, elle,\n// n'est pas bridee : c'est la direction sure.\nconst solarShouldStepUp =\n !gridSettling &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n (\n stepUpByBatteryReserve ||\n stepUpByForecastMode ||\n parsed.pvPowerW >= (context.effectivePvStartMinW + 400)\n );\n\nconst gridShouldStepUp =\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n parsed.batteryCurrentA >= gridBatterySoftDynamicA &&\n !derived.houseLoadHighVsHeadroom;\n\nconst batteryAssistNightShouldStepUp =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA < derived.physicalCapA &&\n derived.physicalCapA >= params.currentMinA &&\n !derived.vebusAcOutSoftReached &&\n !derived.vebusDcVoltageSoftLow &&\n !derived.houseLoadHighVsHeadroom &&\n parsed.batteryCurrentA >= batteryChargeReserveDynamicA &&\n batteryAssistAllowed;\n\n// =====================================================\n// STEP-DOWN LOGIC\n// =====================================================\nconst solarShouldStepDownBattery =\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst solarShouldStepDownCloudDebt =\n !cloudToleranceOk &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst gridShouldStepDownBattery =\n parsed.batteryCurrentA < gridBatterySoftDynamicA &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst batteryAssistNightShouldStepDown =\n context.sourceMode === 'battery_assist_night' &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA &&\n (\n parsed.batteryCurrentA <= batteryDischargeSoftDynamicA ||\n parsed.soc <= gridTakeoverTriggerPct ||\n batteryRecoveryUrgent\n );\n\nconst shouldStepDownVebusPower =\n derived.vebusAcOutSoftReached &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownVebusVoltage =\n derived.vebusDcVoltageSoftLow &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStepDownOverload =\n derived.anyVebusOverload &&\n lifecycle.currentCooldownElapsed &&\n derived.currentReferenceA > params.currentMinA;\n\nconst shouldStopOverloadAtMin =\n derived.anyVebusOverload &&\n derived.currentReferenceA <= params.currentMinA &&\n sessionObservedActive;\n\n// =====================================================\n// HARD STOPS / PROTECTIONS\n// =====================================================\nconst batteryCurrentHardExceeded =\n parsed.batteryCurrentA <= batteryDischargeHardDynamicA;\n\n// overload volontairement exclu du hard stop immédiat\nconst mustStopHard =\n derived.anyVebusLowBattery ||\n derived.vebusDcVoltageHardLow ||\n derived.vebusAcOutHardReached ||\n batteryCurrentHardExceeded;\n\nconst gridBatteryProtectionStop =\n sessionObservedActive &&\n isGridSource &&\n parsed.batteryCurrentA < gridBatteryHardDynamicA;\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n\n batteryPolicy: {\n socBand,\n batteryDischargeSoftDynamicA,\n batteryDischargeHardDynamicA,\n batteryChargeReserveDynamicA,\n batterySoftToleranceMinutes,\n batteryHardToleranceSeconds,\n batteryRecoveryMinutes,\n\n // Depassement souple en cours (01/09/2026). startedMs = null quand il\n // n'y a pas de depassement : c'est ce que HA_SERVICE_CALLS_OUTPUT lit\n // pour ecrire ou effacer le helper de persistance.\n batterySoftBreachNow,\n batterySoftBreachStartedMs,\n batterySoftBreachMinutes,\n batterySoftDischargeHoldOk,\n\n gridBatterySoftDynamicA,\n gridBatteryHardDynamicA,\n gridForceChargeRelaxSoftA,\n gridForceChargeRelaxHardA,\n\n batteryAssistSolarStartSocMin,\n batteryAssistSolarHoldSocMin,\n batteryAssistSolarDebtStartMaxKwh,\n\n houseSpikeDetected,\n solarPotentialContext,\n outsideWindowLocalBypass,\n },\n\n locks: {\n nextAllowedWindowStartMs,\n blockActive,\n blockStillRelevant,\n startBlockedByReason,\n },\n\n cloud: {\n sessionObservedActive,\n batteryNetPowerW,\n cloudDebtNextKwh,\n cloudDebtMeaningful,\n cloudEventDischargingNow,\n continuingCloudEvent,\n enteringCloudDebt,\n cloudDebtActive,\n cloudDebtStartedMs,\n cloudDebtDurationMinutes,\n effectiveCloudToleranceMinutes,\n cloudToleranceOk,\n },\n\n modes: {\n batteryFullHarvestMode,\n forecastCommitMode,\n batteryPriorityMode,\n },\n\n nightEvcs: {\n nightSessionActive,\n nightSessionNeedsCapture,\n nightSessionNeedsClear,\n nightSessionStartSocPct,\n nightSessionStartAt,\n nightSessionStartMs,\n\n batteryHardFloorPct,\n batteryMorningTargetPct,\n balanceMorningTargetApplied,\n nightGridSessionCommitted,\n // P12 — passe-plat du verrou anti-oscillateur vers la sortie.\n nightBatterySurplusExhausted: !!nightPrev.nightBatterySurplusExhausted,\n nightBatterySurplusAvailable: !!nightPrev.nightBatterySurplusAvailable,\n maxBatteryDischargePoints,\n gridTakeoverTriggerPct,\n\n batteryBelowHardFloor,\n batteryBelowMorningTarget,\n batteryAtOrBelowGridTakeoverTrigger,\n\n batteryKwhMissingToTarget,\n batteryRecoveryMinutesNeeded,\n batteryRecoveryUrgent,\n batteryRecoveryAllowedNow,\n batteryTargetReached,\n\n batteryAssistAllowed,\n nightGridTakeoverNeeded,\n nightTakeoverLatched,\n vehicleChargeCompleted,\n nightRecoveryDone,\n departureUrgentActive,\n departureChargeDone,\n dayVehicleFullIdle,\n nightPreferredSourceMode,\n },\n\n safeguards: {\n isGridSource,\n commonSafetyStartOk,\n commonSafetyHoldOk,\n profileSocStartFloor,\n effectiveSocStartFloor,\n solarAbundance,\n solarAbundanceHold,\n profileSocHoldFloor,\n effectiveSocHoldFloor,\n profileBatteryDischargeFloorA,\n\n // Excedent solaire (03/09/2026) : la grandeur qui autorise un demarrage.\n // Plancher du soir issu du bilan energetique (03/09/2026).\n eveningSocStartFloorPct,\n eveningFloorFromBalance,\n balanceRequiredSocPct: params.balanceRequiredSocPct,\n balanceEnergyToCoverKwh: params.balanceEnergyToCoverKwh,\n balanceAgeMin: balanceAgeMin === null ? null : Math.round(balanceAgeMin * 10) / 10,\n balanceEveningFloorUsable,\n\n solarSurplusW,\n solarSurplusProvocationOk,\n solarSurplusStartOk,\n\n // Etat declare par les chargeurs solaires (03/09/2026).\n mpptReadableCount: parsed.mpptReadableCount,\n mpptHoldingBackCount: parsed.mpptHoldingBackCount,\n mpptAllAtMaxPower,\n solarProvocationJustified,\n batteryCurrentHardExceeded,\n mustStopHard,\n gridBatteryProtectionStop,\n },\n\n conditions: {\n rawSolarStartOk,\n rawSolarHoldOk,\n batteryAssistSolarPolicyEligible,\n batteryAssistSolarStartOk,\n batteryAssistSolarHoldOk,\n\n solarStartOk,\n solarHoldOk,\n gridSettling,\n gridTransitionAgeSec: parsed.gridTransitionAgeSec,\n gridStartOk,\n gridHoldOk,\n\n batteryAssistNightStartOk,\n nightBatterySurplusAvailable,\n nightBatterySurplusFloorPct,\n batteryAssistNightHoldOk,\n\n probeStartOk,\n probeHoldGuardOk,\n probeWindowActive,\n probeElapsedReached,\n probeSuccessNow,\n probeFailedNow,\n\n solarShouldStepUp,\n gridShouldStepUp,\n batteryAssistNightShouldStepUp,\n\n solarShouldStepDownBattery,\n solarShouldStepDownCloudDebt,\n gridShouldStepDownBattery,\n batteryAssistNightShouldStepDown,\n\n shouldStepDownVebusPower,\n shouldStepDownVebusVoltage,\n shouldStepDownOverload,\n shouldStopOverloadAtMin,\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -178,7 +178,7 @@ }, { "parameters": { - "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction finiteOr(v, fallback) {\n return Number.isFinite(v) ? v : fallback;\n}\n\nfunction sigmoid(x) {\n return 1 / (1 + Math.exp(-x));\n}\n\nfunction alphaFromTau(dtSec, tauSec) {\n const dt = Math.max(1, finiteOr(dtSec, 60));\n const tau = Math.max(1, finiteOr(tauSec, 60));\n return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau)));\n}\n\nfunction ema(prev, sample, alpha) {\n if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0;\n if (!Number.isFinite(prev)) return sample;\n return prev + alpha * (sample - prev);\n}\n\nfunction formatParisStampFromMs(ms) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n now,\n nowMs,\n parsed,\n params,\n history,\n strategy,\n lifecycle,\n context,\n derived,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n batteryTune,\n nightEvcs,\n} = prev;\n\n// =====================================================\n// DECISION INIT\n// =====================================================\nlet desiredCharging = false;\nlet desiredPermission = false;\nlet desiredLatch = false;\nlet desiredCurrentA = params.currentMinA;\n\nlet state = 'idle';\nlet currentDecision = 'hold';\nlet dynamicOk = false;\nlet mustStop = false;\n\nlet reason = [];\n\nlet setCloudDebtStartedAt = false;\nlet setProbeStartedAt = false;\n\n// EVCS night session memory\nlet setNightSessionStart = false;\nlet clearNightSessionStart = false;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = '';\n\nlet nextBlockReason = 'none';\nlet nextBlockedUntilStamp = now.stamp;\n\n// =====================================================\n// HELPERS\n// =====================================================\nfunction setOff({\n stateName,\n decisionName = 'stop',\n reasonText,\n dynamic = false,\n mustStopFlag = false,\n blockReason = 'none',\n blockedUntilStamp = now.stamp,\n}) {\n desiredCharging = false;\n desiredPermission = false;\n desiredLatch = false;\n desiredCurrentA = params.currentMinA;\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n nextBlockReason = blockReason;\n nextBlockedUntilStamp = blockedUntilStamp;\n\n if (reasonText) reason.push(reasonText);\n}\n\nfunction setOn({\n stateName,\n decisionName = 'hold',\n currentA = params.currentMinA,\n reasonText,\n dynamic = true,\n mustStopFlag = false,\n}) {\n desiredCharging = true;\n desiredPermission = true;\n desiredLatch = true;\n desiredCurrentA = clamp(\n currentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n );\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n\n if (reasonText) reason.push(reasonText);\n}\n\n// =====================================================\n// SESSION MEMORY SIDE EFFECTS\n// =====================================================\nif (nightEvcs?.nightSessionNeedsCapture) {\n setNightSessionStart = true;\n}\n\nif (nightEvcs?.nightSessionNeedsClear) {\n clearNightSessionStart = true;\n}\n\n// =====================================================\n// MANUAL LOCAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge locale,\n// sans imposer le réseau.\n// =====================================================\nconst manualChargeBypassWindows = !!context.manualChargeBypassWindows;\nconst manualChargeLocalRequested = !!context.manualChargeLocalRequested;\n\nconst manualLocalChargeAllowed =\n !!strategy.forceChargeRequested &&\n manualChargeBypassWindows &&\n manualChargeLocalRequested &&\n !safeguards.mustStopHard &&\n !!derived.canRunAtMinCurrent;\n\n// =====================================================\n// EVCS NUMERIC NO-DEMAND FILTER\n// Objectif:\n// détecter \"voiture branchée + session armée + commande claire\n// mais plus de demande réelle\" sans seuils fixes bruts.\n// IMPORTANT:\n// on n'utilise PAS le PV comme critère.\n// =====================================================\nconst prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN);\nconst prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN);\nconst prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN);\nconst prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive;\nconst prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN);\nconst prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN);\n\nconst noDemandDtSec =\n Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs)\n ? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600)\n : 60;\n\n// Constantes dynamiques du filtre = temps de réponse du filtre,\n// pas des seuils métiers bruts.\nconst deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45));\nconst noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180));\nconst scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150));\nconst armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180));\n\nconst alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec);\nconst alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec);\nconst alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec);\n\nconst acVoltageRef = finiteOr(derived.acVoltageRef, 230);\n\nconst commandCurrentA = clamp(\n finiteOr(\n derived.currentReferenceA,\n finiteOr(\n parsed.evcsCommandCurrentA,\n finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6))\n )\n ),\n 0,\n 32\n);\n\nconst expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef);\n\nconst realCurrentA = Math.abs(\n finiteOr(\n parsed.evcsCurrentRealA,\n acVoltageRef > 180\n ? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef\n : 0\n )\n);\n\nconst realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0));\n\nconst currentDeliveryRatioRaw = clamp(\n realCurrentA / Math.max(commandCurrentA, 0.1),\n 0,\n 1.25\n);\n\nconst powerDeliveryRatioRaw = clamp(\n realPowerW / expectedPowerW,\n 0,\n 1.25\n);\n\nconst deliveryRatioRaw = clamp(\n Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw),\n 0,\n 1\n);\n\nconst deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery);\n\nconst deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma);\nconst deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise);\n\nconst adaptiveQuietBand = clamp(\n 0.02 + 3.0 * deliveryNoiseEma,\n 0.02,\n 0.35\n);\n\nconst noDemandEvidenceRaw = clamp(\n (adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand,\n 0,\n 1\n);\n\nconst sessionElapsedSec = Math.max(\n 0,\n finiteOr(lifecycle.minutesSinceStart, 0) * 60\n);\n\nconst sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec);\n\nconst currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6));\nconst commandStrength = commandCurrentA / currentMinRefA;\n\nconst commandFactor = clamp(\n sigmoid((commandStrength - 1.0) * 2.2),\n 0,\n 1\n);\n\n// Source effectivement livrable.\n// IMPORTANT:\n// on évite de conclure \"voiture pleine\" si en réalité la source n'est\n// pas disponible (ex: attente grid takeover).\nconst manualLocalNoDemandEligible =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\nconst sourceDeliverableForNoDemand =\n manualLocalNoDemandEligible\n ? true\n : (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? !!parsed.gridAvailable\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? !!conditions.solarHoldOk || !!conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk\n : false;\n\nconst evcsSessionArmedForNoDemand =\n !!cloud.sessionObservedActive &&\n !!parsed.evcsVehicleConnected &&\n !!parsed.evcsChargingSwitchOn &&\n !!parsed.chargePermissionNow &&\n !!parsed.controlLatch &&\n commandCurrentA > 0 &&\n sourceDeliverableForNoDemand;\n\nconst noDemandScoreRaw = evcsSessionArmedForNoDemand\n ? clamp(\n noDemandEvidenceRaw * sessionArmFactor * commandFactor,\n 0,\n 1\n )\n : 0;\n\nconst noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore);\n\nconst adaptiveDecisionCenter = clamp(\n 0.72 - 0.20 * deliveryNoiseEma,\n 0.55,\n 0.82\n);\n\nconst adaptiveDecisionBand = clamp(\n 0.16 + 0.80 * deliveryNoiseEma,\n 0.12,\n 0.34\n);\n\nconst noDemandScoreOn = clamp(\n adaptiveDecisionCenter + adaptiveDecisionBand / 2,\n 0.60,\n 0.95\n);\n\nconst noDemandScoreOff = clamp(\n adaptiveDecisionCenter - adaptiveDecisionBand / 2,\n 0.15,\n noDemandScoreOn - 0.05\n);\n\nlet evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive;\n\nif (!evcsSessionArmedForNoDemand) {\n evcsVehicleNoDemandSemantic = false;\n} else if (prevNoDemandSemanticActive) {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff;\n} else {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn;\n}\n\nconst evcsNoDemandSinceMs =\n evcsVehicleNoDemandSemantic\n ? (\n prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs)\n ? prevNoDemandSinceMs\n : nowMs\n )\n : null;\n\nconst evcsNoDemandSinceMinutes =\n Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs)\n ? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000)\n : null;\n\nconst vehicleDemandFilter = {\n dtSec: round3(noDemandDtSec),\n\n commandCurrentA: round3(commandCurrentA),\n realCurrentA: round3(realCurrentA),\n expectedPowerW: round3(expectedPowerW),\n realPowerW: round3(realPowerW),\n\n currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw),\n powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw),\n deliveryRatioRaw: round3(deliveryRatioRaw),\n deliveryRatioEma: round3(deliveryRatioEma),\n deliveryNoiseEma: round3(deliveryNoiseEma),\n\n adaptiveQuietBand: round3(adaptiveQuietBand),\n noDemandEvidenceRaw: round3(noDemandEvidenceRaw),\n\n sessionElapsedSec: round3(sessionElapsedSec),\n sessionArmFactor: round3(sessionArmFactor),\n commandFactor: round3(commandFactor),\n sourceDeliverableForNoDemand,\n evcsSessionArmedForNoDemand,\n\n noDemandScoreRaw: round3(noDemandScoreRaw),\n noDemandScoreEma: round3(noDemandScoreEma),\n\n noDemandScoreOn: round3(noDemandScoreOn),\n noDemandScoreOff: round3(noDemandScoreOff),\n\n evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes)\n ? round3(evcsNoDemandSinceMinutes)\n : null,\n\n nextHistory: {\n evcsDeliveryRatioEma: round3(deliveryRatioEma),\n evcsDeliveryNoiseEma: round3(deliveryNoiseEma),\n evcsNoDemandScoreEma: round3(noDemandScoreEma),\n evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandFilterEvalMs: nowMs\n }\n};\n\n// =====================================================\n// DECISION TREE\n// =====================================================\nif (!parsed.controllerEnabled) {\n desiredCharging = parsed.evcsChargingSwitchOn;\n desiredPermission = parsed.chargePermissionNow;\n desiredLatch = parsed.controlLatch;\n desiredCurrentA = clamp(\n derived.currentReferenceA || params.currentMinA,\n params.currentMinA,\n params.currentMaxAutoA\n );\n\n state = 'controller_disabled';\n currentDecision = 'hold';\n dynamicOk = false;\n mustStop = false;\n reason.push('Contrôleur désactivé');\n}\n\nelse if (strategy.isStopMode) {\n setOff({\n stateName: 'stopped_user',\n decisionName: 'stop_stop_mode',\n reasonText: 'Arrêt manuel via Stratégie STOP',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'manual_stop',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (parsed.evcsModeIsAuto) {\n setOff({\n stateName: 'mode_auto_conflict',\n decisionName: 'stop_auto_mode_conflict',\n reasonText: 'Borne en mode Auto interne',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'mode_auto_conflict',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {\n setOff({\n stateName: 'vehicle_not_connected',\n decisionName: 'stop_not_connected',\n reasonText: 'Véhicule ou borne non connecté',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'vehicle_not_connected',\n blockedUntilStamp: now.stamp,\n });\n}\n\n// =====================================================\n// Fix F (09/08/2026) : MÉMOIRE \"CHARGE TERMINÉE\" PRIORITAIRE.\n// Incident 09/08 06:51→06:56 : la VW pleine relançait des micro-charges\n// (sur batterie, EDF OFF) → la garde mustStopHard émettait\n// 'battery_current_hard_stop' qui ÉCRASAIT l'état collant dans le helper\n// → completed perdu au cycle suivant → re-P7 → EDF rallumé (rebond).\n// Ici : tant que la session nuit est active et la charge terminée, l'état\n// collant est réécrit à CHAQUE cycle, AU-DESSUS des gardes. Aucune perte de\n// protection : ce setOff coupe la charge aussi fermement qu'un hard stop\n// (chg/perm/latch OFF + mustStop si la borne est encore armée).\n// =====================================================\nelse if (\n context.profile === 'night_hc' &&\n nightEvcs?.vehicleChargeCompleted &&\n !manualLocalChargeAllowed &&\n !strategy.forceGridRequested\n) {\n setOff({\n stateName: nightEvcs?.nightRecoveryDone\n ? 'night_recovery_done_idle'\n : 'night_session_done_idle',\n decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',\n reasonText: nightEvcs?.nightRecoveryDone\n ? 'Charge véhicule terminée + batterie à la cible : veille nuit, réseau relâché'\n : 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)',\n dynamic: false,\n mustStopFlag: !!cloud.sessionObservedActive,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n}\n\n// =====================================================\n// P9 (09/08/2026) : véhicule plein pendant la fenêtre \"départ imminent\"\n// (jour, hors HC) → veille COLLANTE 'departure_done_idle', réécrite chaque\n// cycle au-dessus des gardes (même principe que Fix F). Le réseau est\n// relâché, l'hystérésis coupe immédiatement (cut E2 étendu).\n// =====================================================\nelse if (\n nightEvcs?.departureChargeDone &&\n !manualLocalChargeAllowed &&\n !strategy.forceGridRequested\n) {\n setOff({\n stateName: 'departure_done_idle',\n decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',\n reasonText: 'Charge terminée avant départ : veille, réseau relâché',\n dynamic: false,\n mustStopFlag: !!cloud.sessionObservedActive,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n}\n\n// =====================================================\n// P10 (09/08/2026) : véhicule plein en JOURNÉE → veille collante\n// 'day_vehicle_full_idle' jusqu'au débranchement. Évite les re-tentatives\n// solaires cycliques sur un véhicule plein (usure relais). Réécrite chaque\n// cycle au-dessus des gardes (principe Fix F). Échappatoires : force_*.\n// =====================================================\nelse if (\n nightEvcs?.dayVehicleFullIdle &&\n !manualLocalChargeAllowed &&\n !strategy.forceGridRequested\n) {\n setOff({\n stateName: 'day_vehicle_full_idle',\n decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',\n reasonText: 'Véhicule plein : veille jour, aucune nouvelle tentative jusqu\\'au débranchement',\n dynamic: false,\n mustStopFlag: !!cloud.sessionObservedActive,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (safeguards.mustStopHard) {\n setOff({\n stateName:\n derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n 'hard_stop',\n decisionName: 'stop_hard',\n reasonText: 'Hard stop de protection',\n dynamic: false,\n mustStopFlag: true,\n blockReason:\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :\n derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n 'battery_protection_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),\n });\n}\n\n// =====================================================\n// ACTIVE SESSION BRANCH\n// =====================================================\nelse if (cloud.sessionObservedActive) {\n if (cloud.enteringCloudDebt) {\n setCloudDebtStartedAt = true;\n }\n\n if (conditions.shouldStopOverloadAtMin) {\n setOff({\n stateName: 'vebus_overload_stop_min_current',\n decisionName: 'stop',\n reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'vebus_overload_persistent',\n blockedUntilStamp: formatParisStampFromMs(\n nowMs + batteryTune.global.overloadStopAfterSeconds * 1000\n ),\n });\n }\n\n else if (safeguards.gridBatteryProtectionStop) {\n setOff({\n stateName: 'stop_grid_battery_guard',\n decisionName: 'stop',\n reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'grid_battery_guard',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),\n });\n }\n\n else if (conditions.probeFailedNow) {\n setOff({\n stateName: 'probe_failed_lock',\n decisionName: 'stop',\n reasonText: 'Mode sonde échoué',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n }\n\n else if (derived.physicalCapA < params.currentMinA) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_power_cap',\n decisionName: 'stop_power_cap',\n reasonText: 'Capacité système insuffisante',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_power_cap',\n decisionName: 'hold',\n currentA: params.currentMinA,\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {\n if (!conditions.probeHoldGuardOk) {\n setOff({\n stateName: 'probe_stop_guard',\n decisionName: 'stop',\n reasonText: 'Sonde interrompue: garde-fous non respectés',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'probe_hold',\n decisionName: 'hold',\n currentA: Math.min(params.probeCurrentA, derived.physicalCapA),\n reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else {\n const activeSourceMode =\n conditions.probeSuccessNow\n ? 'solar_only'\n : context.sourceMode;\n\n const holdConditionsOk =\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')\n ? conditions.gridHoldOk\n : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')\n ? conditions.solarHoldOk\n : activeSourceMode === 'hybrid_probe'\n ? conditions.probeHoldGuardOk\n : activeSourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightHoldOk\n : false;\n\n const manualLocalHoldOverride =\n manualLocalChargeAllowed &&\n (\n activeSourceMode === 'solar_only' ||\n activeSourceMode === 'solar_potential' ||\n activeSourceMode === 'blocked'\n );\n\n const effectiveHoldConditionsOk =\n holdConditionsOk || manualLocalHoldOverride;\n\n // =================================================\n // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION\n // NB Fix F (09/08/2026) : le cas \"charge terminée en session nuit\" est\n // désormais intercepté PLUS HAUT dans la cascade (mémoire prioritaire),\n // avant les gardes. Ici ne restent que la détection VDF initiale et\n // les autres sources.\n // IMPORTANT:\n // - uniquement en session active\n // - uniquement si la source est réellement livrable\n // - sans utiliser le PV comme critère\n // =================================================\n if (\n vehicleDemandFilter.evcsVehicleNoDemandSemantic &&\n vehicleDemandFilter.sourceDeliverableForNoDemand\n ) {\n setOff({\n stateName: 'vehicle_full_or_paused',\n decisionName: 'stop_vehicle_full_or_paused',\n reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n\n reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);\n reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);\n reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);\n reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);\n reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);\n\n if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {\n reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);\n }\n }\n\n else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {\n setOn({\n stateName: 'charging_hold_manual_local',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: 'Maintien manuel local hors fenêtre sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (activeSourceMode === 'blocked') {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_source_lost',\n decisionName: 'stop',\n reasonText: 'Source autorisée perdue',\n dynamic: false,\n mustStopFlag: true,\n blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',\n blockedUntilStamp:\n context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_source_lost',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Source perdue mais maintien jusqu'au mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover_runtime'\n : 'wait_grid_source_runtime',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? 'Relais réseau demandé pendant la session nuit EVCS'\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_wait_grid_takeover',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au mini ON en attente du réseau\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (!effectiveHoldConditionsOk) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_conditions_lost',\n decisionName: 'stop',\n reasonText: 'Conditions de maintien perdues',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'hold_conditions_lost',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n // SÉQUENCE D'OUVERTURE (31/08/2026) : un plafond n'est pas une cible qu'on\n // approche, c'est une limite qu'on respecte. Cette réduction attendait le\n // cooldown de courant — un compteur de confort — et ne descendait que d'un\n // pas à la fois : de 28 A à 20 A elle prenait plusieurs cooldowns, pendant\n // lesquels les onduleurs portaient un courant qu'ils n'ont plus le droit de\n // porter (le plafond retombe à la disparition du réseau). Désormais :\n // descente IMMÉDIATE et DIRECTE au plafond. Réduire est toujours sûr ; c'est\n // la montée qui doit être temporisée, et elle l'est.\n else if (derived.currentReferenceA > derived.physicalCapA) {\n setOn({\n stateName: 'charging_step_down_system_cap',\n decisionName: 'step_down_fast',\n currentA: Math.max(params.currentMinA, derived.physicalCapA),\n reasonText: `Réduction immédiate au plafond système ${derived.physicalCapA}A`,\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownOverload) {\n setOn({\n stateName: 'charging_step_down_overload',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: surcharge convertisseur',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusPower) {\n setOn({\n stateName: 'charging_step_down_vebus_power',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: puissance VE.Bus élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusVoltage) {\n setOn({\n stateName: 'charging_step_down_dc_voltage',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: tension DC basse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepDown\n ) {\n setOn({\n stateName: 'charging_step_down_battery_assist_night',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réduction: approche départ, préparation takeover réseau'\n : 'Réduction: assistance batterie nuit devient trop coûteuse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepDownBattery\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? 'charging_step_down_grid_forced_guard'\n : 'charging_step_down_grid_guard',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? 'Réduction réseau forcé: la batterie commence à participer'\n : 'Réduction réseau: la batterie commence à participer',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownCloudDebt\n ) {\n setOn({\n stateName: 'charging_step_down_cloud_debt',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: dette nuage trop élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownBattery\n ) {\n setOn({\n stateName: 'charging_step_down_battery',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: batterie trop sollicitée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepUp\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'charging_step_up_grid_forced'\n )\n : 'charging_step_up_grid_hc',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Hausse prudente: priorité départ, réseau forcé'\n : 'Hausse prudente du courant en charge réseau forcée'\n )\n : 'Hausse prudente du courant en HC réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepUp\n ) {\n setOn({\n stateName: 'charging_step_up_battery_assist_night',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant en assistance batterie nuit',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepUp\n ) {\n setOn({\n stateName:\n conditions.probeSuccessNow\n ? 'probe_success_step_up'\n : (modes.batteryPriorityMode\n ? 'charging_step_up_battery'\n : 'charging_step_up_forecast'),\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else {\n setOn({\n stateName:\n manualLocalHoldOverride ? 'charging_hold_manual_local' :\n conditions.probeSuccessNow ? 'probe_success_hold' :\n activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :\n cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :\n 'charging_hold',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText:\n manualLocalHoldOverride\n ? 'Maintien manuel local du courant'\n : activeSourceMode === 'battery_assist_night'\n ? 'Maintien du courant en assistance batterie nuit'\n : 'Maintien du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n}\n\n// =====================================================\n// START BRANCH\n// =====================================================\nelse {\n const startConditionsOk =\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? conditions.gridStartOk\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightStartOk\n : false;\n\n const manualLocalStartOverride =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\n if (!derived.canRunAtMinCurrent) {\n setOff({\n stateName: 'blocked_by_capacity_min_current',\n decisionName: 'hold',\n reasonText: 'Capacité insuffisante pour courant mini',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (manualLocalStartOverride) {\n setOn({\n stateName: 'start_manual_local_min_current',\n decisionName: 'start',\n currentA: params.currentMinA,\n reasonText:\n context.profile === 'outside_window'\n ? 'Démarrage manuel local hors fenêtre sans réseau'\n : 'Démarrage manuel local sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = false;\n }\n\n // P8 : véhicule plein en session nuit → veille auto-entretenue.\n // Le state est relu au cycle suivant (prev_surplus_state) : il maintient\n // vehicleChargeCompleted jusqu'au débranchement ou à la fin de fenêtre.\n // Fix A (08/08/2026) : une fois la recovery batterie terminée (SOC ≥ cible),\n // l'état devient 'night_recovery_done_idle' (collant) → takeover relâché\n // définitivement, plus de rebond EDF autour de la cible.\n else if (context.profile === 'night_hc' && nightEvcs?.vehicleChargeCompleted) {\n setOff({\n stateName: nightEvcs?.nightRecoveryDone\n ? 'night_recovery_done_idle'\n : 'night_session_done_idle',\n decisionName: 'hold',\n reasonText: nightEvcs?.nightRecoveryDone\n ? 'Charge véhicule terminée + batterie à la cible : veille nuit, réseau relâché'\n : 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (context.sourceMode === 'blocked') {\n setOff({\n stateName:\n context.profile === 'outside_window'\n ? 'blocked_outside_window'\n : (context.profile === 'night_hc'\n ? 'night_no_grid_hc_lock'\n : 'idle_wait_source'),\n decisionName: 'hold',\n reasonText:\n context.profile === 'outside_window'\n ? 'Hors fenêtre préférée sans source suffisante'\n : context.profile === 'night_hc'\n ? 'Nuit sans autorisation HC réseau'\n : 'Source non autorisée ou insuffisante',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'outside_window'\n ? 'outside_window'\n : context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'source_unavailable',\n blockedUntilStamp:\n Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + 15 * 60000),\n });\n }\n\n else if (\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover'\n : 'wait_grid_source',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réseau demandé: urgence recharge batterie avant départ'\n : 'Prise de relais réseau demandée pour session nuit EVCS'\n )\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (locks.startBlockedByReason) {\n setOff({\n stateName: 'wait_reason_lock',\n decisionName: 'hold',\n reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,\n dynamic: false,\n mustStopFlag: false,\n blockReason: history.lastBlockReason,\n blockedUntilStamp: history.blockedUntilAt || now.stamp,\n });\n }\n\n else if (!startConditionsOk) {\n setOff({\n stateName: 'idle_wait_conditions',\n decisionName: 'hold',\n reasonText:\n context.sourceMode === 'battery_assist_night'\n ? 'Conditions de démarrage nuit batterie non réunies'\n : 'Conditions de démarrage non réunies',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (!lifecycle.minStopElapsed) {\n setOff({\n stateName: 'wait_min_stop',\n decisionName: 'hold',\n reasonText: 'Temps mini OFF non écoulé',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else {\n const startCurrentA =\n context.sourceMode === 'hybrid_probe'\n ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)\n : params.currentMinA;\n\n setOn({\n stateName:\n context.sourceMode === 'grid_forced' ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'start_grid_forced_min_current'\n ) :\n context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :\n context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :\n context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :\n context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :\n modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :\n modes.forecastCommitMode ? 'start_min_current_forecast_commit' :\n 'start_min_current',\n decisionName: 'start',\n currentA: startCurrentA,\n reasonText:\n context.sourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Démarrage réseau forcé: priorité départ / recharge batterie'\n : 'Démarrage en charge réseau forcée'\n )\n : context.sourceMode === 'grid_hc_allowed'\n ? 'Démarrage en fenêtre HC réseau'\n : context.sourceMode === 'battery_assist_night'\n ? 'Démarrage nuit en assistance batterie contrôlée'\n : context.sourceMode === 'hybrid_probe'\n ? 'Démarrage en mode sonde'\n : context.sourceMode === 'solar_potential'\n ? 'Démarrage par potentiel solaire confirmé'\n : modes.batteryFullHarvestMode\n ? 'Démarrage harvest solaire'\n : modes.forecastCommitMode\n ? 'Démarrage forecast commit'\n : 'Démarrage prudent à courant mini',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = context.sourceMode === 'hybrid_probe';\n }\n}\n\n// =====================================================\n// CONTEXT REASONS\n// =====================================================\nif (nightEvcs?.nightSessionActive) {\n reason.push(\n `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}%${nightEvcs.balanceMorningTargetApplied ? ' (BILAN)' : ''} | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`\n );\n\n if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {\n reason.push(\n `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`\n );\n }\n\n if (nightEvcs.batteryRecoveryUrgent) {\n reason.push(\"Urgence départ: il faut anticiper la reprise réseau\");\n }\n}\n\nif (manualLocalChargeAllowed) {\n reason.push(`ManualLocalCharge=on`);\n reason.push(`ManualLocalProfile=${context.profile}`);\n reason.push(`ManualLocalSource=${context.sourceMode}`);\n}\n\nreason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);\nreason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);\nreason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);\nreason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n vehicleDemandFilter,\n decision: {\n desiredCharging,\n desiredPermission,\n desiredLatch,\n desiredCurrentA,\n\n state,\n currentDecision,\n dynamicOk,\n mustStop,\n reason,\n\n nextBlockReason,\n nextBlockedUntilStamp,\n\n setCloudDebtStartedAt,\n setProbeStartedAt,\n\n setNightSessionStart,\n clearNightSessionStart,\n\n notifyWanted,\n notifyTitle,\n notifyMessage,\n }\n }\n}];\n" + "jsCode": "function clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round3(v) {\n return Math.round(v * 1000) / 1000;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\n}\n\nfunction finiteOr(v, fallback) {\n return Number.isFinite(v) ? v : fallback;\n}\n\nfunction sigmoid(x) {\n return 1 / (1 + Math.exp(-x));\n}\n\nfunction alphaFromTau(dtSec, tauSec) {\n const dt = Math.max(1, finiteOr(dtSec, 60));\n const tau = Math.max(1, finiteOr(tauSec, 60));\n return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau)));\n}\n\nfunction ema(prev, sample, alpha) {\n if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0;\n if (!Number.isFinite(prev)) return sample;\n return prev + alpha * (sample - prev);\n}\n\nfunction formatParisStampFromMs(ms) {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date(ms))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;\n}\n\nconst prev = $input.first().json;\n\nconst {\n now,\n nowMs,\n parsed,\n params,\n history,\n strategy,\n lifecycle,\n context,\n derived,\n locks,\n cloud,\n modes,\n safeguards,\n conditions,\n batteryTune,\n nightEvcs,\n} = prev;\n\n// =====================================================\n// DECISION INIT\n// =====================================================\nlet desiredCharging = false;\nlet desiredPermission = false;\nlet desiredLatch = false;\nlet desiredCurrentA = params.currentMinA;\n\nlet state = 'idle';\nlet currentDecision = 'hold';\nlet dynamicOk = false;\nlet mustStop = false;\n\nlet reason = [];\n\nlet setCloudDebtStartedAt = false;\nlet setProbeStartedAt = false;\n\n// EVCS night session memory\nlet setNightSessionStart = false;\nlet clearNightSessionStart = false;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Victron EVCS Surplus';\nlet notifyMessage = '';\n\nlet nextBlockReason = 'none';\nlet nextBlockedUntilStamp = now.stamp;\n\n// =====================================================\n// HELPERS\n// =====================================================\nfunction setOff({\n stateName,\n decisionName = 'stop',\n reasonText,\n dynamic = false,\n mustStopFlag = false,\n blockReason = 'none',\n blockedUntilStamp = now.stamp,\n}) {\n desiredCharging = false;\n desiredPermission = false;\n desiredLatch = false;\n desiredCurrentA = params.currentMinA;\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n nextBlockReason = blockReason;\n nextBlockedUntilStamp = blockedUntilStamp;\n\n if (reasonText) reason.push(reasonText);\n}\n\nfunction setOn({\n stateName,\n decisionName = 'hold',\n currentA = params.currentMinA,\n reasonText,\n dynamic = true,\n mustStopFlag = false,\n}) {\n desiredCharging = true;\n desiredPermission = true;\n desiredLatch = true;\n desiredCurrentA = clamp(\n currentA,\n params.currentMinA,\n Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)\n );\n\n state = stateName;\n currentDecision = decisionName;\n dynamicOk = dynamic;\n mustStop = mustStopFlag;\n\n if (reasonText) reason.push(reasonText);\n}\n\n// =====================================================\n// SESSION MEMORY SIDE EFFECTS\n// =====================================================\nif (nightEvcs?.nightSessionNeedsCapture) {\n setNightSessionStart = true;\n}\n\nif (nightEvcs?.nightSessionNeedsClear) {\n clearNightSessionStart = true;\n}\n\n// =====================================================\n// MANUAL LOCAL CHARGE SEMANTICS\n// IMPORTANT:\n// force_charge_now = bypass fenêtres pour la charge locale,\n// sans imposer le réseau.\n// =====================================================\nconst manualChargeBypassWindows = !!context.manualChargeBypassWindows;\nconst manualChargeLocalRequested = !!context.manualChargeLocalRequested;\n\nconst manualLocalChargeAllowed =\n !!strategy.forceChargeRequested &&\n manualChargeBypassWindows &&\n manualChargeLocalRequested &&\n !safeguards.mustStopHard &&\n !!derived.canRunAtMinCurrent;\n\n// =====================================================\n// EVCS NUMERIC NO-DEMAND FILTER\n// Objectif:\n// détecter \"voiture branchée + session armée + commande claire\n// mais plus de demande réelle\" sans seuils fixes bruts.\n// IMPORTANT:\n// on n'utilise PAS le PV comme critère.\n// =====================================================\nconst prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN);\nconst prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN);\nconst prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN);\nconst prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive;\nconst prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN);\nconst prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN);\n\nconst noDemandDtSec =\n Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs)\n ? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600)\n : 60;\n\n// Constantes dynamiques du filtre = temps de réponse du filtre,\n// pas des seuils métiers bruts.\nconst deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45));\nconst noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180));\nconst scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150));\nconst armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180));\n\nconst alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec);\nconst alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec);\nconst alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec);\n\nconst acVoltageRef = finiteOr(derived.acVoltageRef, 230);\n\nconst commandCurrentA = clamp(\n finiteOr(\n derived.currentReferenceA,\n finiteOr(\n parsed.evcsCommandCurrentA,\n finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6))\n )\n ),\n 0,\n 32\n);\n\nconst expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef);\n\nconst realCurrentA = Math.abs(\n finiteOr(\n parsed.evcsCurrentRealA,\n acVoltageRef > 180\n ? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef\n : 0\n )\n);\n\nconst realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0));\n\nconst currentDeliveryRatioRaw = clamp(\n realCurrentA / Math.max(commandCurrentA, 0.1),\n 0,\n 1.25\n);\n\nconst powerDeliveryRatioRaw = clamp(\n realPowerW / expectedPowerW,\n 0,\n 1.25\n);\n\nconst deliveryRatioRaw = clamp(\n Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw),\n 0,\n 1\n);\n\nconst deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery);\n\nconst deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma);\nconst deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise);\n\nconst adaptiveQuietBand = clamp(\n 0.02 + 3.0 * deliveryNoiseEma,\n 0.02,\n 0.35\n);\n\nconst noDemandEvidenceRaw = clamp(\n (adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand,\n 0,\n 1\n);\n\nconst sessionElapsedSec = Math.max(\n 0,\n finiteOr(lifecycle.minutesSinceStart, 0) * 60\n);\n\nconst sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec);\n\nconst currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6));\nconst commandStrength = commandCurrentA / currentMinRefA;\n\nconst commandFactor = clamp(\n sigmoid((commandStrength - 1.0) * 2.2),\n 0,\n 1\n);\n\n// Source effectivement livrable.\n// IMPORTANT:\n// on évite de conclure \"voiture pleine\" si en réalité la source n'est\n// pas disponible (ex: attente grid takeover).\nconst manualLocalNoDemandEligible =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\nconst sourceDeliverableForNoDemand =\n manualLocalNoDemandEligible\n ? true\n : (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? !!parsed.gridAvailable\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? !!conditions.solarHoldOk || !!conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk\n : false;\n\nconst evcsSessionArmedForNoDemand =\n !!cloud.sessionObservedActive &&\n !!parsed.evcsVehicleConnected &&\n !!parsed.evcsChargingSwitchOn &&\n !!parsed.chargePermissionNow &&\n !!parsed.controlLatch &&\n commandCurrentA > 0 &&\n sourceDeliverableForNoDemand;\n\nconst noDemandScoreRaw = evcsSessionArmedForNoDemand\n ? clamp(\n noDemandEvidenceRaw * sessionArmFactor * commandFactor,\n 0,\n 1\n )\n : 0;\n\nconst noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore);\n\nconst adaptiveDecisionCenter = clamp(\n 0.72 - 0.20 * deliveryNoiseEma,\n 0.55,\n 0.82\n);\n\nconst adaptiveDecisionBand = clamp(\n 0.16 + 0.80 * deliveryNoiseEma,\n 0.12,\n 0.34\n);\n\nconst noDemandScoreOn = clamp(\n adaptiveDecisionCenter + adaptiveDecisionBand / 2,\n 0.60,\n 0.95\n);\n\nconst noDemandScoreOff = clamp(\n adaptiveDecisionCenter - adaptiveDecisionBand / 2,\n 0.15,\n noDemandScoreOn - 0.05\n);\n\nlet evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive;\n\nif (!evcsSessionArmedForNoDemand) {\n evcsVehicleNoDemandSemantic = false;\n} else if (prevNoDemandSemanticActive) {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff;\n} else {\n evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn;\n}\n\nconst evcsNoDemandSinceMs =\n evcsVehicleNoDemandSemantic\n ? (\n prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs)\n ? prevNoDemandSinceMs\n : nowMs\n )\n : null;\n\nconst evcsNoDemandSinceMinutes =\n Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs)\n ? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000)\n : null;\n\nconst vehicleDemandFilter = {\n dtSec: round3(noDemandDtSec),\n\n commandCurrentA: round3(commandCurrentA),\n realCurrentA: round3(realCurrentA),\n expectedPowerW: round3(expectedPowerW),\n realPowerW: round3(realPowerW),\n\n currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw),\n powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw),\n deliveryRatioRaw: round3(deliveryRatioRaw),\n deliveryRatioEma: round3(deliveryRatioEma),\n deliveryNoiseEma: round3(deliveryNoiseEma),\n\n adaptiveQuietBand: round3(adaptiveQuietBand),\n noDemandEvidenceRaw: round3(noDemandEvidenceRaw),\n\n sessionElapsedSec: round3(sessionElapsedSec),\n sessionArmFactor: round3(sessionArmFactor),\n commandFactor: round3(commandFactor),\n sourceDeliverableForNoDemand,\n evcsSessionArmedForNoDemand,\n\n noDemandScoreRaw: round3(noDemandScoreRaw),\n noDemandScoreEma: round3(noDemandScoreEma),\n\n noDemandScoreOn: round3(noDemandScoreOn),\n noDemandScoreOff: round3(noDemandScoreOff),\n\n evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes)\n ? round3(evcsNoDemandSinceMinutes)\n : null,\n\n nextHistory: {\n evcsDeliveryRatioEma: round3(deliveryRatioEma),\n evcsDeliveryNoiseEma: round3(deliveryNoiseEma),\n evcsNoDemandScoreEma: round3(noDemandScoreEma),\n evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic,\n evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,\n evcsNoDemandFilterEvalMs: nowMs\n }\n};\n\n// =====================================================\n// DECISION TREE\n// =====================================================\nif (!parsed.controllerEnabled) {\n desiredCharging = parsed.evcsChargingSwitchOn;\n desiredPermission = parsed.chargePermissionNow;\n desiredLatch = parsed.controlLatch;\n desiredCurrentA = clamp(\n derived.currentReferenceA || params.currentMinA,\n params.currentMinA,\n params.currentMaxAutoA\n );\n\n state = 'controller_disabled';\n currentDecision = 'hold';\n dynamicOk = false;\n mustStop = false;\n reason.push('Contrôleur désactivé');\n}\n\nelse if (strategy.isStopMode) {\n setOff({\n stateName: 'stopped_user',\n decisionName: 'stop_stop_mode',\n reasonText: 'Arrêt manuel via Stratégie STOP',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'manual_stop',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (parsed.evcsModeIsAuto) {\n setOff({\n stateName: 'mode_auto_conflict',\n decisionName: 'stop_auto_mode_conflict',\n reasonText: 'Borne en mode Auto interne',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'mode_auto_conflict',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {\n setOff({\n stateName: 'vehicle_not_connected',\n decisionName: 'stop_not_connected',\n reasonText: 'Véhicule ou borne non connecté',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'vehicle_not_connected',\n blockedUntilStamp: now.stamp,\n });\n}\n\n// =====================================================\n// Fix F (09/08/2026) : MÉMOIRE \"CHARGE TERMINÉE\" PRIORITAIRE.\n// Incident 09/08 06:51→06:56 : la VW pleine relançait des micro-charges\n// (sur batterie, EDF OFF) → la garde mustStopHard émettait\n// 'battery_current_hard_stop' qui ÉCRASAIT l'état collant dans le helper\n// → completed perdu au cycle suivant → re-P7 → EDF rallumé (rebond).\n// Ici : tant que la session nuit est active et la charge terminée, l'état\n// collant est réécrit à CHAQUE cycle, AU-DESSUS des gardes. Aucune perte de\n// protection : ce setOff coupe la charge aussi fermement qu'un hard stop\n// (chg/perm/latch OFF + mustStop si la borne est encore armée).\n// =====================================================\nelse if (\n context.profile === 'night_hc' &&\n nightEvcs?.vehicleChargeCompleted &&\n !manualLocalChargeAllowed &&\n !strategy.forceGridRequested\n) {\n setOff({\n stateName: nightEvcs?.nightRecoveryDone\n ? 'night_recovery_done_idle'\n : 'night_session_done_idle',\n decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',\n reasonText: nightEvcs?.nightRecoveryDone\n ? 'Charge véhicule terminée + batterie à la cible : veille nuit, réseau relâché'\n : 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)',\n dynamic: false,\n mustStopFlag: !!cloud.sessionObservedActive,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n}\n\n// =====================================================\n// P9 (09/08/2026) : véhicule plein pendant la fenêtre \"départ imminent\"\n// (jour, hors HC) → veille COLLANTE 'departure_done_idle', réécrite chaque\n// cycle au-dessus des gardes (même principe que Fix F). Le réseau est\n// relâché, l'hystérésis coupe immédiatement (cut E2 étendu).\n// =====================================================\nelse if (\n nightEvcs?.departureChargeDone &&\n !manualLocalChargeAllowed &&\n !strategy.forceGridRequested\n) {\n setOff({\n stateName: 'departure_done_idle',\n decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',\n reasonText: 'Charge terminée avant départ : veille, réseau relâché',\n dynamic: false,\n mustStopFlag: !!cloud.sessionObservedActive,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n}\n\n// =====================================================\n// P10 (09/08/2026) : véhicule plein en JOURNÉE → veille collante\n// 'day_vehicle_full_idle' jusqu'au débranchement. Évite les re-tentatives\n// solaires cycliques sur un véhicule plein (usure relais). Réécrite chaque\n// cycle au-dessus des gardes (principe Fix F). Échappatoires : force_*.\n// =====================================================\nelse if (\n nightEvcs?.dayVehicleFullIdle &&\n !manualLocalChargeAllowed &&\n !strategy.forceGridRequested\n) {\n setOff({\n stateName: 'day_vehicle_full_idle',\n decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',\n reasonText: 'Véhicule plein : veille jour, aucune nouvelle tentative jusqu\\'au débranchement',\n dynamic: false,\n mustStopFlag: !!cloud.sessionObservedActive,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n}\n\nelse if (safeguards.mustStopHard) {\n setOff({\n stateName:\n derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n 'hard_stop',\n decisionName: 'stop_hard',\n reasonText: 'Hard stop de protection',\n dynamic: false,\n mustStopFlag: true,\n blockReason:\n safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :\n derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :\n derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :\n derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :\n 'battery_protection_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),\n });\n}\n\n// =====================================================\n// ACTIVE SESSION BRANCH\n// =====================================================\nelse if (cloud.sessionObservedActive) {\n if (cloud.enteringCloudDebt) {\n setCloudDebtStartedAt = true;\n }\n\n if (conditions.shouldStopOverloadAtMin) {\n setOff({\n stateName: 'vebus_overload_stop_min_current',\n decisionName: 'stop',\n reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'vebus_overload_persistent',\n blockedUntilStamp: formatParisStampFromMs(\n nowMs + batteryTune.global.overloadStopAfterSeconds * 1000\n ),\n });\n }\n\n else if (safeguards.gridBatteryProtectionStop) {\n setOff({\n stateName: 'stop_grid_battery_guard',\n decisionName: 'stop',\n reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,\n dynamic: false,\n mustStopFlag: true,\n // Un verrou meurt avec sa cause (incident du 18/09 : BOOST demande le\n // reseau, l'hysteresis met ~1 min a fermer, le garde mord 23 s AVANT\n // l'arrivee d'EDF et posait 20 min de verrou reseau present). On encode\n // ICI si le reseau etait la : absent => '_no_grid', et BCL levera le\n // verrou des que le reseau arrive. Present => la cause est ailleurs\n // (consigne insuffisante, incident du 01/09) et le verrou plein tient —\n // c'est ce qui interdit l'oscillateur stop/retry a reseau present.\n blockReason: parsed.gridAvailable ? 'grid_battery_guard' : 'grid_battery_guard_no_grid',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),\n });\n }\n\n else if (conditions.probeFailedNow) {\n setOff({\n stateName: 'probe_failed_lock',\n decisionName: 'stop',\n reasonText: 'Mode sonde échoué',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n }\n\n else if (derived.physicalCapA < params.currentMinA) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_power_cap',\n decisionName: 'stop_power_cap',\n reasonText: 'Capacité système insuffisante',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_power_cap',\n decisionName: 'hold',\n currentA: params.currentMinA,\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {\n if (!conditions.probeHoldGuardOk) {\n setOff({\n stateName: 'probe_stop_guard',\n decisionName: 'stop',\n reasonText: 'Sonde interrompue: garde-fous non respectés',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'probe_failed',\n blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'probe_hold',\n decisionName: 'hold',\n currentA: Math.min(params.probeCurrentA, derived.physicalCapA),\n reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n else {\n const activeSourceMode =\n conditions.probeSuccessNow\n ? 'solar_only'\n : context.sourceMode;\n\n const holdConditionsOk =\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')\n ? conditions.gridHoldOk\n : (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')\n ? conditions.solarHoldOk\n : activeSourceMode === 'hybrid_probe'\n ? conditions.probeHoldGuardOk\n : activeSourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightHoldOk\n : false;\n\n const manualLocalHoldOverride =\n manualLocalChargeAllowed &&\n (\n activeSourceMode === 'solar_only' ||\n activeSourceMode === 'solar_potential' ||\n activeSourceMode === 'blocked'\n );\n\n const effectiveHoldConditionsOk =\n holdConditionsOk || manualLocalHoldOverride;\n\n // =================================================\n // VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION\n // NB Fix F (09/08/2026) : le cas \"charge terminée en session nuit\" est\n // désormais intercepté PLUS HAUT dans la cascade (mémoire prioritaire),\n // avant les gardes. Ici ne restent que la détection VDF initiale et\n // les autres sources.\n // IMPORTANT:\n // - uniquement en session active\n // - uniquement si la source est réellement livrable\n // - sans utiliser le PV comme critère\n // =================================================\n if (\n vehicleDemandFilter.evcsVehicleNoDemandSemantic &&\n vehicleDemandFilter.sourceDeliverableForNoDemand\n ) {\n setOff({\n stateName: 'vehicle_full_or_paused',\n decisionName: 'stop_vehicle_full_or_paused',\n reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n\n reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);\n reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);\n reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);\n reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);\n reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);\n\n if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {\n reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);\n }\n }\n\n else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {\n setOn({\n stateName: 'charging_hold_manual_local',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: 'Maintien manuel local hors fenêtre sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (activeSourceMode === 'blocked') {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_source_lost',\n decisionName: 'stop',\n reasonText: 'Source autorisée perdue',\n dynamic: false,\n mustStopFlag: true,\n blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',\n blockedUntilStamp:\n context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_source_lost',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Source perdue mais maintien jusqu'au mini ON\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover_runtime'\n : 'wait_grid_source_runtime',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? 'Relais réseau demandé pendant la session nuit EVCS'\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run_wait_grid_takeover',\n decisionName: 'hold',\n // Miroir de la sequence d'ouverture (§30) : quand on bascule VERS le\n // reseau, on ne tient pas la puissance sur la source qu'on quitte.\n // Tenir currentReferenceA (13 A le 18/09) pendant les ~80 s ou\n // l'hysteresis n'a pas encore ferme le contacteur fait porter ~3 kW\n // a la batterie et declenche le garde — courant MINI en attendant.\n currentA: Math.min(params.currentMinA, derived.physicalCapA),\n reasonText: \"Attente du réseau au courant mini\",\n dynamic: false,\n mustStopFlag: false,\n });\n }\n }\n\n else if (!effectiveHoldConditionsOk) {\n if (lifecycle.minRunElapsed) {\n setOff({\n stateName: 'stop_conditions_lost',\n decisionName: 'stop',\n reasonText: 'Conditions de maintien perdues',\n dynamic: false,\n mustStopFlag: true,\n blockReason: 'hold_conditions_lost',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n } else {\n setOn({\n stateName: 'hold_min_run',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText: \"Maintien jusqu'au temps mini ON\",\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n\n // SÉQUENCE D'OUVERTURE (31/08/2026) : un plafond n'est pas une cible qu'on\n // approche, c'est une limite qu'on respecte. Cette réduction attendait le\n // cooldown de courant — un compteur de confort — et ne descendait que d'un\n // pas à la fois : de 28 A à 20 A elle prenait plusieurs cooldowns, pendant\n // lesquels les onduleurs portaient un courant qu'ils n'ont plus le droit de\n // porter (le plafond retombe à la disparition du réseau). Désormais :\n // descente IMMÉDIATE et DIRECTE au plafond. Réduire est toujours sûr ; c'est\n // la montée qui doit être temporisée, et elle l'est.\n else if (derived.currentReferenceA > derived.physicalCapA) {\n setOn({\n stateName: 'charging_step_down_system_cap',\n decisionName: 'step_down_fast',\n currentA: Math.max(params.currentMinA, derived.physicalCapA),\n reasonText: `Réduction immédiate au plafond système ${derived.physicalCapA}A`,\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownOverload) {\n setOn({\n stateName: 'charging_step_down_overload',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: surcharge convertisseur',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusPower) {\n setOn({\n stateName: 'charging_step_down_vebus_power',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: puissance VE.Bus élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (conditions.shouldStepDownVebusVoltage) {\n setOn({\n stateName: 'charging_step_down_dc_voltage',\n decisionName: 'step_down_fast',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),\n derived.physicalCapA\n )\n ),\n reasonText: 'Baisse rapide: tension DC basse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepDown\n ) {\n setOn({\n stateName: 'charging_step_down_battery_assist_night',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réduction: approche départ, préparation takeover réseau'\n : 'Réduction: assistance batterie nuit devient trop coûteuse',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepDownBattery\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? 'charging_step_down_grid_forced_guard'\n : 'charging_step_down_grid_guard',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? 'Réduction réseau forcé: la batterie commence à participer'\n : 'Réduction réseau: la batterie commence à participer',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownCloudDebt\n ) {\n setOn({\n stateName: 'charging_step_down_cloud_debt',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - Math.max(params.currentStepDownA, 2),\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: dette nuage trop élevée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepDownBattery\n ) {\n setOn({\n stateName: 'charging_step_down_battery',\n decisionName: 'step_down',\n currentA: Math.max(\n params.currentMinA,\n Math.min(\n derived.currentReferenceA - params.currentStepDownA,\n derived.physicalCapA\n )\n ),\n reasonText: 'Réduction: batterie trop sollicitée',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&\n conditions.gridShouldStepUp\n ) {\n setOn({\n stateName:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'charging_step_up_grid_forced'\n )\n : 'charging_step_up_grid_hc',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText:\n activeSourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Hausse prudente: priorité départ, réseau forcé'\n : 'Hausse prudente du courant en charge réseau forcée'\n )\n : 'Hausse prudente du courant en HC réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n activeSourceMode === 'battery_assist_night' &&\n conditions.batteryAssistNightShouldStepUp\n ) {\n setOn({\n stateName: 'charging_step_up_battery_assist_night',\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant en assistance batterie nuit',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else if (\n (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&\n conditions.solarShouldStepUp\n ) {\n setOn({\n stateName:\n conditions.probeSuccessNow\n ? 'probe_success_step_up'\n : (modes.batteryPriorityMode\n ? 'charging_step_up_battery'\n : 'charging_step_up_forecast'),\n decisionName: 'step_up',\n currentA: clamp(\n derived.currentReferenceA + params.currentStepUpA,\n params.currentMinA,\n derived.physicalCapA\n ),\n reasonText: 'Hausse prudente du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n\n else {\n setOn({\n stateName:\n manualLocalHoldOverride ? 'charging_hold_manual_local' :\n conditions.probeSuccessNow ? 'probe_success_hold' :\n activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :\n (activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :\n cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :\n 'charging_hold',\n decisionName: 'hold',\n currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),\n reasonText:\n manualLocalHoldOverride\n ? 'Maintien manuel local du courant'\n : activeSourceMode === 'battery_assist_night'\n ? 'Maintien du courant en assistance batterie nuit'\n : 'Maintien du courant',\n dynamic: true,\n mustStopFlag: false,\n });\n }\n }\n}\n\n// =====================================================\n// START BRANCH\n// =====================================================\nelse {\n const startConditionsOk =\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')\n ? conditions.gridStartOk\n : (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')\n ? conditions.solarStartOk\n : context.sourceMode === 'hybrid_probe'\n ? conditions.probeStartOk\n : context.sourceMode === 'battery_assist_night'\n ? conditions.batteryAssistNightStartOk\n : false;\n\n const manualLocalStartOverride =\n manualLocalChargeAllowed &&\n (\n context.sourceMode === 'solar_only' ||\n context.sourceMode === 'solar_potential' ||\n context.sourceMode === 'blocked'\n );\n\n if (!derived.canRunAtMinCurrent) {\n setOff({\n stateName: 'blocked_by_capacity_min_current',\n decisionName: 'hold',\n reasonText: 'Capacité insuffisante pour courant mini',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'system_capacity_stop',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (manualLocalStartOverride) {\n setOn({\n stateName: 'start_manual_local_min_current',\n decisionName: 'start',\n currentA: params.currentMinA,\n reasonText:\n context.profile === 'outside_window'\n ? 'Démarrage manuel local hors fenêtre sans réseau'\n : 'Démarrage manuel local sans réseau',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = false;\n }\n\n // P8 : véhicule plein en session nuit → veille auto-entretenue.\n // Le state est relu au cycle suivant (prev_surplus_state) : il maintient\n // vehicleChargeCompleted jusqu'au débranchement ou à la fin de fenêtre.\n // Fix A (08/08/2026) : une fois la recovery batterie terminée (SOC ≥ cible),\n // l'état devient 'night_recovery_done_idle' (collant) → takeover relâché\n // définitivement, plus de rebond EDF autour de la cible.\n else if (context.profile === 'night_hc' && nightEvcs?.vehicleChargeCompleted) {\n setOff({\n stateName: nightEvcs?.nightRecoveryDone\n ? 'night_recovery_done_idle'\n : 'night_session_done_idle',\n decisionName: 'hold',\n reasonText: nightEvcs?.nightRecoveryDone\n ? 'Charge véhicule terminée + batterie à la cible : veille nuit, réseau relâché'\n : 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (context.sourceMode === 'blocked') {\n setOff({\n stateName:\n context.profile === 'outside_window'\n ? 'blocked_outside_window'\n : (context.profile === 'night_hc'\n ? 'night_no_grid_hc_lock'\n : 'idle_wait_source'),\n decisionName: 'hold',\n reasonText:\n context.profile === 'outside_window'\n ? 'Hors fenêtre préférée sans source suffisante'\n : context.profile === 'night_hc'\n ? 'Nuit sans autorisation HC réseau'\n : 'Source non autorisée ou insuffisante',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'outside_window'\n ? 'outside_window'\n : context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'source_unavailable',\n blockedUntilStamp:\n Number.isFinite(locks.nextAllowedWindowStartMs)\n ? formatParisStampFromMs(locks.nextAllowedWindowStartMs)\n : formatParisStampFromMs(nowMs + 15 * 60000),\n });\n }\n\n else if (\n (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&\n !parsed.gridAvailable\n ) {\n setOff({\n stateName:\n context.profile === 'night_hc'\n ? 'night_wait_grid_takeover'\n : 'wait_grid_source',\n decisionName: 'hold',\n reasonText:\n context.profile === 'night_hc'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Réseau demandé: urgence recharge batterie avant départ'\n : 'Prise de relais réseau demandée pour session nuit EVCS'\n )\n : 'Source réseau demandée mais pas encore disponible',\n dynamic: false,\n mustStopFlag: false,\n blockReason:\n context.profile === 'night_hc'\n ? 'grid_hc_unavailable'\n : 'grid_unavailable',\n blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),\n });\n }\n\n else if (locks.startBlockedByReason) {\n setOff({\n stateName: 'wait_reason_lock',\n decisionName: 'hold',\n reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,\n dynamic: false,\n mustStopFlag: false,\n blockReason: history.lastBlockReason,\n blockedUntilStamp: history.blockedUntilAt || now.stamp,\n });\n }\n\n else if (!startConditionsOk) {\n setOff({\n stateName: 'idle_wait_conditions',\n decisionName: 'hold',\n reasonText:\n context.sourceMode === 'battery_assist_night'\n ? 'Conditions de démarrage nuit batterie non réunies'\n : 'Conditions de démarrage non réunies',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else if (!lifecycle.minStopElapsed) {\n setOff({\n stateName: 'wait_min_stop',\n decisionName: 'hold',\n reasonText: 'Temps mini OFF non écoulé',\n dynamic: false,\n mustStopFlag: false,\n blockReason: 'none',\n blockedUntilStamp: now.stamp,\n });\n }\n\n else {\n const startCurrentA =\n context.sourceMode === 'hybrid_probe'\n ? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)\n : params.currentMinA;\n\n setOn({\n stateName:\n context.sourceMode === 'grid_forced' ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'night_departure_deadline_grid_forced'\n : 'start_grid_forced_min_current'\n ) :\n context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :\n context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :\n context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :\n context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :\n modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :\n modes.forecastCommitMode ? 'start_min_current_forecast_commit' :\n 'start_min_current',\n decisionName: 'start',\n currentA: startCurrentA,\n reasonText:\n context.sourceMode === 'grid_forced'\n ? (\n nightEvcs?.batteryRecoveryUrgent\n ? 'Démarrage réseau forcé: priorité départ / recharge batterie'\n : 'Démarrage en charge réseau forcée'\n )\n : context.sourceMode === 'grid_hc_allowed'\n ? 'Démarrage en fenêtre HC réseau'\n : context.sourceMode === 'battery_assist_night'\n ? 'Démarrage nuit en assistance batterie contrôlée'\n : context.sourceMode === 'hybrid_probe'\n ? 'Démarrage en mode sonde'\n : context.sourceMode === 'solar_potential'\n ? 'Démarrage par potentiel solaire confirmé'\n : modes.batteryFullHarvestMode\n ? 'Démarrage harvest solaire'\n : modes.forecastCommitMode\n ? 'Démarrage forecast commit'\n : 'Démarrage prudent à courant mini',\n dynamic: true,\n mustStopFlag: false,\n });\n\n nextBlockReason = 'none';\n nextBlockedUntilStamp = now.stamp;\n setProbeStartedAt = context.sourceMode === 'hybrid_probe';\n }\n}\n\n// =====================================================\n// CONTEXT REASONS\n// =====================================================\nif (nightEvcs?.nightSessionActive) {\n reason.push(\n `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}%${nightEvcs.balanceMorningTargetApplied ? ' (BILAN)' : ''} | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`\n );\n\n if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {\n reason.push(\n `SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`\n );\n }\n\n if (nightEvcs.batteryRecoveryUrgent) {\n reason.push(\"Urgence départ: il faut anticiper la reprise réseau\");\n }\n}\n\nif (manualLocalChargeAllowed) {\n reason.push(`ManualLocalCharge=on`);\n reason.push(`ManualLocalProfile=${context.profile}`);\n reason.push(`ManualLocalSource=${context.sourceMode}`);\n}\n\nreason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);\nreason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);\nreason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);\nreason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);\n\n// =====================================================\n// OUTPUT\n// =====================================================\nreturn [{\n json: {\n ...prev,\n vehicleDemandFilter,\n decision: {\n desiredCharging,\n desiredPermission,\n desiredLatch,\n desiredCurrentA,\n\n state,\n currentDecision,\n dynamicOk,\n mustStop,\n reason,\n\n nextBlockReason,\n nextBlockedUntilStamp,\n\n setCloudDebtStartedAt,\n setProbeStartedAt,\n\n setNightSessionStart,\n clearNightSessionStart,\n\n notifyWanted,\n notifyTitle,\n notifyMessage,\n }\n }\n}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2,