function num(v, fallback = NaN) { const n = Number.parseFloat(v); return Number.isFinite(n) ? n : fallback; } function low(v) { return (v ?? '').toString().trim().toLowerCase(); } function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } function round1(v) { return Math.round(v * 10) / 10; } function round2(v) { return Math.round(v * 100) / 100; } function to2(n) { return String(n).padStart(2, '0'); } function parseMaybeJson(v) { if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return v; } } function extractSnapshot(input) { if (typeof input === 'string') return parseMaybeJson(input); if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data); if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body); if (input?.data && typeof input.data === 'object') return input.data; if (input?.body && typeof input.body === 'object') return input.body; return input; } function parseTimeToMinutes(v, fallback = null) { const s = (v ?? '').toString().trim(); const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); if (!m) return fallback; const hh = Number(m[1]); const mm = Number(m[2]); if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback; return (hh % 24) * 60 + mm; } function getParisParts(ms = Date.now()) { const fmt = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = Object.fromEntries( fmt.formatToParts(new Date(ms)) .filter(p => p.type !== 'literal') .map(p => [p.type, p.value]) ); const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); const date = `${parts.year}-${parts.month}-${parts.day}`; const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`; return { year: Number(parts.year), month: Number(parts.month), day: Number(parts.day), hour, minute: Number(parts.minute), second: Number(parts.second), date, stamp, minNow: hour * 60 + Number(parts.minute), }; } function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { const dtf = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = Object.fromEntries( dtf.formatToParts(new Date(utcTs)) .filter(p => p.type !== 'literal') .map(p => [p.type, p.value]) ); const asIfUtc = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) === 24 ? 0 : Number(parts.hour), Number(parts.minute), Number(parts.second) ); const d = new Date(utcTs); const actualUtc = Date.UTC( d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ); return asIfUtc - actualUtc; } function parseParisStampToMs(s) { const text = (s ?? '').toString().trim(); const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); if (!m) return null; const y = Number(m[1]); const mo = Number(m[2]); const d = Number(m[3]); const h = Number(m[4]); const mi = Number(m[5]); const se = Number(m[6]); if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); let ts = guessUtc - off1; const off2 = getTzOffsetMs(ts, 'Europe/Paris'); if (off2 !== off1) ts = guessUtc - off2; return ts; } function cleanText(v, fallback = '') { const s = (v ?? '').toString().trim(); const x = s.toLowerCase(); if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback; return s; } // P11 (12/08/2026) : durée de charge EVCS "1h 49m 53s" / "8m 27s" / "0s" // → secondes. Repart de 0 à chaque NOUVELLE connexion physique d'un véhicule // (débranchement JEEP → branchement VW) → signal de reset du collant "plein". function parseChargingTimeToSec(s) { const t = (s ?? '').toString().toLowerCase(); if (!t) return null; let sec = 0; let matched = false; const h = t.match(/(\d+)\s*h/); if (h) { sec += Number(h[1]) * 3600; matched = true; } const m = t.match(/(\d+)\s*m/); if (m) { sec += Number(m[1]) * 60; matched = true; } const s2 = t.match(/(\d+)\s*s/); if (s2){ sec += Number(s2[1]); matched = true; } return matched ? sec : null; } // Horodatage "DD/MM HH:mm" publie par le bilan energetique // (input_text.evcs_balance_last_run). Le conseiller employait le meme format ; // son cablage a ete retire le 13/09/2026, la fonction sert desormais au bilan. // (pas d'année). On suppose l'année courante ; si le résultat est dans le // futur (> 1 jour), c'est un passage d'année → on retire un an. function parseRunStampToMs(s) { const m = (s ?? '').toString().trim().match(/^(\d{2})\/(\d{2}) (\d{2}):(\d{2})$/); if (!m) return null; const nowMsLocal = Date.now(); const year = Number( new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric' }) .format(new Date(nowMsLocal)) ); const build = (y) => { const guessUtc = Date.UTC(y, Number(m[2]) - 1, Number(m[1]), Number(m[3]), Number(m[4]), 0); const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); let ts = guessUtc - off1; const off2 = getTzOffsetMs(ts, 'Europe/Paris'); if (off2 !== off1) ts = guessUtc - off2; return ts; }; let ts = build(year); if (ts - nowMsLocal > 24 * 3600 * 1000) ts = build(year - 1); return ts; } function finiteOrNull(v) { return Number.isFinite(v) ? v : null; } const raw = $input.first().json; const input = extractSnapshot(raw); const nowMs = Date.now(); const now = getParisParts(nowMs); const CFG = { mobileNotifyDomain: 'notify', mobileNotifyService: 'mobile_app_iphone_16_de_tt', // 03/09/2026 — valeur MESUREE (576 paliers de SOC sur 8 jours, // tools/mesure-courbe-batterie.mjs) : 0,169 kWh par point au-dessus de 33 %, // soit 16,9 kWh. Le 17,5 precedent etait une valeur de plaque. // NB : sous 33 % un point ne vaut plus que 0,097 kWh. Cette constante ne sert // ici qu'a des ecarts SOC -> cible, tous deux au-dessus du genou. Si un calcul // devait un jour traverser le genou, il faut la COURBE (src/energy-balance/ // BALANCE.js) et non cette constante. // 16,9 kWh mesures sur SEPT modules (§41), soit 2,414 kWh par module. Le parc // ayant change deux fois en une semaine, le nombre de modules se LIT au lieu // d'etre code : `input_number.evcs_balance_modules_en_service`, le meme helper // que le bilan energetique — les deux decrivent forcement le meme parc. // Absent ou illisible => 7, donc aucun changement tant que personne ne l'ecrit. batteryUsableCapacityKwh: 2.414 * (() => { const n = Number(input.modules_en_service); return Number.isFinite(n) && n >= 1 && n <= 24 ? n : 7; })(), nominalAcVoltageV: 230, evcsDedicatedMaxW: 4900, systemReserveW: 300, vebusDcVoltageSoftFloorV: 49.0, vebusDcVoltageHardFloorV: 48.6, departurePriorityWindowMinutes: 240, sampleSeconds: 30, cloudDebtRecoveryThresholdKwh: 0.05, cloudDebtMeaningfulThresholdKwh: 0.01, }; // ===================================================== // PARSED SNAPSHOT // ===================================================== const parsed = { soc: clamp(num(input.soc_percent, 0), 0, 100), batteryCurrentA: num(input.battery_current_a, 0), pvPowerW: Math.max(0, num(input.pv_power_w, 0)), // MODE DE FONCTIONNEMENT DES CHARGEURS SOLAIRES (03/09/2026) // Chaque tracker declare lui-meme s'il est a son point de puissance maximale // ('mppt_active' : il donne tout ce qu'il a) ou s'il se retient // ('voltage_current_limited', 'off' : il y a de la ressource REFUSEE). // Lectures inexploitables (integration muette) => listes vides => les gardes // aval retombent sur le comportement anterieur. On ne devine pas un etat. ...(() => { const modes = String(input.mppt_operation_modes || '') .split(',') .map(s => s.trim().toLowerCase()) .filter(s => s !== '' && s !== 'unavailable' && s !== 'unknown'); const atMaxPower = modes.filter(s => s === 'mppt_active').length; // Un chargeur qui SE RETIENT (`voltage_current_limited`) prouve qu'il reste // de la ressource refusee. Un chargeur `off` n'a rien a donner : la nuit, // tous le sont. `mpptHoldingBackCount` confond les deux, ce qui est sans // consequence pour la garde de provocation solaire (§37, demarrage de jour // seulement) mais ferait descendre la consigne reseau au crepuscule si on // s'en servait ici — un oscillateur de plus. On compte donc separement ce // qui se declare BRIDE, et tout libelle inconnu retombe du cote prudent // (pas de ressource refusee, donc comportement anterieur). const limited = modes.filter(s => s.includes('limited')).length; return { mpptReadableCount: modes.length, mpptAtMaxPowerCount: atMaxPower, mpptHoldingBackCount: modes.length - atMaxPower, mpptLimitedCount: limited, }; })(), pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))), pvTrendW: num(input.pv_trend_w, 0), houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)), houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))), gridPowerW: num(input.grid_power_w, 0), gridIsOn: low(input.grid_state || 'off') === 'on', acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1, // Consigne de puissance réseau de l'ESS, lue telle qu'elle est appliquée. // C'est ELLE qui décide si un contacteur fermé sert à quelque chose : au-dessus // du SOC minimal, l'ESS en autoconsommation ne prélève que cette puissance et // laisse la batterie porter le reste. Nuit du 01/09 : contacteur fermé, // consigne 50 W, batterie à -803 W pour 761 W de maison. gridSetpointW: num(input.grid_setpoint_w, NaN), gridAvailable: (low(input.grid_state || 'off') === 'on') || (num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1), // FENETRE D'ETABLISSEMENT (31/08/2026) — age, en secondes, de la derniere // transition du reseau. On retient la plus RECENTE des deux sources : la // presence physique AC-IN (celle qui declenche reellement le transitoire) et // le contacteur (qui peut bouger sans qu'AC-IN suive, disjoncteur ouvert). // Horodatage absent ou nul => age tres grand => aucune fenetre, comportement // historique. On ne devine jamais une transition qu'on n'a pas observee. gridTransitionAgeSec: (() => { const ts = Math.max( num(input.acin1_last_changed_ts, 0), num(input.grid_switch_last_changed_ts, 0) ); if (!(ts > 0)) return Number.POSITIVE_INFINITY; return Math.max(0, (nowMs / 1000) - ts); })(), todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)), tomorrowKwh: Math.max(0, num(input.tomorrow_kwh, 0)), evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on', evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on', evcsStatusRaw: cleanText(input.evcs_status_raw), evcsModeRaw: [ input.evcs_mode_sensor, input.evcs_mode_fallback, input.evcs_mode ] .map(v => cleanText(v)) .find(v => v) || 'unknown', evcsSetCurrentA: num(input.evcs_set_current_a, NaN), evcsCommandCurrentA: num(input.evcs_command_current_a, NaN), // Mesure réelle côté borne evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)), evcsCurrentRealA: Math.max(0, num(input.evcs_current_a, 0)), evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)), evcsChargingTimeSec: parseChargingTimeToSec(input.evcs_charging_time), // P11-bis (19/09/2026) : age, en secondes, du dernier changement du // capteur de connexion. C'est le SECOND signal de fraicheur, independant // du charging_time de la borne — qui gele sur un swap trop rapide (14 s // le 19/09) et laisse alors P11 aveugle. last_changed du binary_sensor, // lui, bouge sur ce meme swap. Horodatage absent/nul => age infini => // aucun effet, comportement historique (on ne devine pas un branchement). evcsVehicleConnectedAgeSec: (() => { const ts = num(input.evcs_vehicle_connected_ts, 0); if (!(ts > 0)) return Number.POSITIVE_INFINITY; return Math.max(0, (nowMs / 1000) - ts); })(), // Energie deja livree par la session EN COURS (kWh). Le capteur de la borne // s'appelle "last_session_energy" mais il suit la session courante et repart // de zero a chaque nouvelle connexion (verifie le 07/09/2026 : 0 -> 0,1 -> 0,2 // ... pendant la charge). Sert a juger la credibilite d'un statut "charged". evcsSessionEnergyKwh: Math.max(0, num(input.evcs_session_energy_kwh, 0)), evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on', evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on', controllerEnabled: low(input.controller_enabled || 'off') === 'on', notificationsEnabled: low(input.notifications_enabled || 'off') === 'on', hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on', chargePermissionNow: low(input.charge_permission_now || 'off') === 'on', controlLatch: low(input.control_latch || 'off') === 'on', departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on', forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on', evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on', evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(), evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason), vebusOverload: low(input.vebus_overload || 'off') === 'on', vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on', vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on', vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on', vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)), vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)), vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV), vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN), vebusDcCurrentA: num(input.vebus_dc_current_a, NaN), vebusDcPowerW: num(input.vebus_dc_power_w, NaN), vebusState: num(input.vebus_state, NaN), vebusMainState: num(input.vebus_main_state, NaN), }; parsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto'); // ===================================================== // PARAMETERS // ===================================================== const params = { userStrategyRaw: input.victron_evcs_strategy || 'auto_solar', targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32), pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)), pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)), houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)), batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5), batteryDischargeHardA: num(input.battery_discharge_hard_a, -15), batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)), batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)), socStartMin: clamp(num(input.soc_start_min, 60), 0, 100), socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100), currentMinA: clamp(num(input.current_min_a, 6), 6, 32), currentMaxAutoA: clamp( num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 6), 6, 32), 32 ), gridCurrentMaxA: clamp(num(input.grid_current_max_a, 20), 6, 32), currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5), currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8), currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)), minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)), minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)), requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)), socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100), endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100), baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)), flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)), endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100), acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)), batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100), batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)), forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100), forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)), cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)), cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)), cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)), // P9 (09/08/2026) : deux véhicules alternent sur la borne. // Le sélecteur input_select.victron_evcs_vehicle choisit l'heure de départ // effective : VW = victron_evcs_departure_time (~08:50), // JEEP = victron_evcs_departure_time_jeep (~06:30). selectedVehicle: cleanText(input.evcs_vehicle, 'VW'), departureTimeMin: low(input.evcs_vehicle || 'vw') === 'jeep' ? parseTimeToMinutes( input.departure_time_jeep, parseTimeToMinutes(input.departure_time, null) ) : parseTimeToMinutes(input.departure_time, null), allowGridDepartureUrgent: low(input.allow_grid_departure_urgent || 'off') === 'on', dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30), dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30), hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60), hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60), morningStartMin: parseTimeToMinutes( input.morning_start_time, parseTimeToMinutes(input.day_start_time, 7 * 60 + 30) ), morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60), hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21), hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21), eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60), eveningStopMin: parseTimeToMinutes( input.evening_stop_time, parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30) ), allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on', allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on', // Advisor IA — premier câblage réel (09/08/2026, HANDOFF §20) : // cible SOC matin recommandée, appliquée UNIQUEMENT si le toggle est ON. // Garde-fous consommés dans WINDOWS_SOURCEV2 (statut ok + fraîcheur + bornes). // BILAN ENERGETIQUE (03/09/2026). Meme format d'horodatage que l'advisor // ("DD/MM HH:mm"), d'ou la reutilisation du parseur. balanceRequiredSocPct: num(input.balance_required_soc, NaN), // P12 — charge nuit sur batterie. Le plancher est le SOC requis PRUDENT (p80) // et non le p50 : la marge est mesuree, pas inventee. On descend la voiture // jusqu'a ce que la batterie n'ait plus que ce qu'il lui faut pour tenir // jusqu'au croisement PV. balanceRequiredSocPrudentPct: num(input.balance_required_soc_prudent, NaN), nightBatteryChargeEnabled: low(input.night_battery_charge_enabled || 'off') === 'on', nightBatteryDonePrev: low(input.night_battery_done_prev || 'off') === 'on', balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN), // ⚠️ NE PAS CONFONDRE LES DEUX GRANDEURS DU BILAN. // `balanceEnergyToCoverKwh` est l'energie que la batterie devra FOURNIR // jusqu'au croisement PV ; elle sert a CALCULER la cible // (`requiredSocPct = socForEnergy(energyToCoverKwh, floor)`) et ne tombe // jamais a zero tant qu'il reste de la nuit a traverser, meme batterie // pleine. Releve du 17/09 a 20:00 : 11,96 kWh « a couvrir » avec un SOC de // 91 %. La prendre pour un deficit ferait reclamer du reseau toute la nuit. // // `balanceGridEnergyNeededKwh` est le MANQUE : // `socMargin < 0 ? energyBetweenSoc(soc, requiredSoc) : 0` (BALANCE.js l.234) // — nul des que la batterie couvre, et converti par la COURBE par morceaux // mesuree le 03/09 (0,097 kWh/point sous le genou de 33 %, 0,169 au-dessus). // C'est donc la seule des deux qui soit juste sous le genou, c'est-a-dire au // SOC ou l'on decide de fermer le contacteur. balanceGridEnergyNeededKwh: num(input.balance_grid_energy_needed_kwh, NaN), // Ce que l'Auto Charge Manager autorise la batterie a PRENDRE (retour de // lecture de DVCC). A ne pas confondre avec `bms_max_charge_current_a`, qui // est le maximum tolere par le BMS — 250 A, soit plus de 12 kW, donc // au-dessus de GRID_SETPOINT_MAX_W : il ne bornerait rien. dvccChargeLimitA: num(input.dvcc_max_charge_current_a, NaN), balanceStatusOk: cleanText(input.balance_status).toLowerCase() === 'ok', balanceLastRunMs: parseRunStampToMs(cleanText(input.balance_last_run)), // Duree de la fenetre d'etablissement. Valeur mesuree le 31/08/2026 : le // transitoire de fermeture dure ~150 s (import 1985 -> 25 W et 2600 -> 52 W, // deux campagnes), on prend 180 s de marge. Reglable sans redeploiement via // input_number.evcs_grid_settle_seconds ; absent => 180. gridSettleSeconds: clamp(num(input.grid_settle_seconds, 180), 0, 900), // Consigne réseau au repos. 50 W est le défaut Victron (légèrement positif // pour ne jamais injecter), pas une valeur choisie ici. // CONSIGNE DE REPOS : 200 W et non plus 50 (04/09/2026). // 50 W etait le defaut Victron. Mesure sur la nuit du 03-04/09, 12 074 // echantillons du point de raccordement : 17 % sont NEGATIFS, mediane -18 W, // excursions jusqu'a -356 W, episodes de 35 s au plus. Ce n'est pas de // l'injection — la batterie se dechargeait a 744 W au meme moment — c'est le // regulateur qui chasse autour d'une cible trop proche de zero. A 200 W le // point de fonctionnement reste positif sur l'essentiel des excursions. // ⚠️ Ne supprime pas les pointes les plus fortes : une excursion de -356 W // passerait encore sous zero. Reduit leur nombre, ne les annule pas. // CONSIGNE DE REPOS : 50 -> 200 (04/09) -> 700 (06/09). // // Elle ne s'applique que contacteur FERME sans session reseau EVCS — donc // apres une coupure sur SOC bas, ou apres une fermeture par la regle de // protection du parc. Contacteur ouvert, elle n'a aucun effet. // // A 200 W, une plaque chauffante qui cycle fait passer le point de // fonctionnement sous zero : mesure du 06/09 sur la fenetre 12:07-12:22, // 9 % des echantillons negatifs, minimum -477 W, mediane des negatifs // -146 W. Le regulateur ne rattrape pas assez vite un delestage brutal de // ~1 kW. A 700 W toute la courbe monte de 500 W : minimum +23 W, plus aucune // excursion negative sur cette fenetre. // // Le surcout est faible et va dans le bon sens : les DEUX seules raisons de // fermer le contacteur sans session EVCS sont « la batterie est a son // plancher » et « la maison demande plus que le parc ne sait donner ». Dans // les deux cas on VEUT que le reseau contribue plus que 200 W. // // ⚠️ Ne corrige PAS les gros a-coups. Le 06/09 a 13:41:40, le debranchement // du vehicule a supprime 2,2 kW d'un coup alors que la batterie, a 93 % de // SOC, n'absorbait rien et que le MPPT produisait 4 kW : -2 819 W pendant // plus de deux minutes, le temps que le systeme bride la production. Aucune // consigne n'absorbe cela. Ce qui l'evite est la correction du meme jour sur // la regle de protection (elle porte desormais sur `maison - PV`) : avec une // demande sur le parc de 741 W, le contacteur n'aurait pas ete ferme, et // sans reseau il n'y a rien ou injecter. gridSetpointIdleW: clamp(num(input.grid_setpoint_idle_w, 700), 0, 1500), probeEnabled: low(input.probe_enabled || 'off') === 'on', probeMinutes: Math.max(1, num(input.probe_minutes, 3)), probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)), probeCurrentA: clamp( num(input.probe_current_a, clamp(num(input.current_min_a, 10), 6, 32)), clamp(num(input.current_min_a, 10), 6, 32), clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 10), 6, 32), 32) ), gridBatterySoftA: num(input.grid_battery_soft_a, -3), gridBatteryHardA: num(input.grid_battery_hard_a, -6), morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)), morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0), middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)), morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100), middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100), eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100), eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100), morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20), middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40), eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30), // ===================================================== // EVCS NIGHT ENGINE — NEW PARAMETERS // ===================================================== evcsNightBatteryHardFloorPct: clamp( num(input.evcs_night_battery_hard_floor_pct, 22), 0, 100 ), evcsNightStrongForecastKwh: Math.max( 0, num(input.evcs_night_strong_forecast_kwh, 18) ), evcsNightMediumForecastKwh: Math.max( 0, num(input.evcs_night_medium_forecast_kwh, 10) ), evcsNightTargetStrongForecastPct: clamp( num(input.evcs_night_target_strong_forecast_pct, 35), 0, 100 ), evcsNightTargetMediumForecastPct: clamp( num(input.evcs_night_target_medium_forecast_pct, 55), 0, 100 ), evcsNightTargetWeakForecastPct: clamp( num(input.evcs_night_target_weak_forecast_pct, 85), 0, 100 ), evcsNightBatteryRecoveryPowerKw: Math.max( 0.1, num(input.evcs_night_battery_recovery_power_kw, 2.5) ), evcsNightDepartureSafetyMin: Math.max( 0, num(input.evcs_night_departure_safety_min, 30) ), }; // ===================================================== // HISTORY / PERSISTED HELPERS // ===================================================== const nightSessionStartSocRaw = num( input.evcs_night_session_start_soc ?? input.night_session_start_soc, NaN ); const nightSessionStartAtRaw = cleanText( input.evcs_night_session_start_at ?? input.night_session_start_at ); // ===================================================== // VEHICLE DEMAND FILTER HISTORY RAW INPUTS // ===================================================== const evcsNoDemandSinceAtRaw = cleanText(input.evcs_no_demand_since_at); const evcsNoDemandFilterEvalAtRaw = cleanText(input.evcs_no_demand_filter_eval_at); const history = { lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none', blockedUntilAt: cleanText(input.blocked_until), probeStartedAt: cleanText(input.probe_started_at), lastStartAt: cleanText(input.last_start_at), lastStopAt: cleanText(input.last_stop_at), lastCurrentChangeAt: cleanText(input.last_current_change_at), lastSignature: cleanText(input.last_signature), cloudDebtStartedAt: cleanText(input.cloud_debt_started_at), evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until), blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)), probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)), lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)), lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)), lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)), cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)), // DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : horodatage du debut du // depassement en cours, ecrit par HA_SERVICE_CALLS_OUTPUT. Vide/illisible // => null => aucun depassement en cours, comportement de repli sur. nightGridSessionCommittedPrev: (input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on', batterySoftBreachAt: cleanText(input.battery_soft_breach_at), batterySoftBreachMsPrev: parseParisStampToMs(cleanText(input.battery_soft_breach_at)), evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)), // ===================================================== // EVCS NIGHT SESSION MEMORY // ===================================================== nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw) ? clamp(nightSessionStartSocRaw, 0, 100) : null, nightSessionStartAt: nightSessionStartAtRaw || '', nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw), // ===================================================== // VEHICLE DEMAND FILTER PERSISTENCE // ===================================================== evcsDeliveryRatioEma: num(input.evcs_delivery_ratio_ema, NaN), evcsDeliveryNoiseEma: num(input.evcs_delivery_noise_ema, NaN), evcsNoDemandScoreEma: num(input.evcs_no_demand_score_ema, NaN), evcsNoDemandSemanticActive: low(input.evcs_no_demand_semantic_active || 'off') === 'on', evcsNoDemandSinceAt: evcsNoDemandSinceAtRaw || '', evcsNoDemandSinceMs: parseParisStampToMs(evcsNoDemandSinceAtRaw), evcsNoDemandFilterEvalAt: evcsNoDemandFilterEvalAtRaw || '', evcsNoDemandFilterEvalMs: parseParisStampToMs(evcsNoDemandFilterEvalAtRaw), }; // ===================================================== // MISE A L'ECHELLE DU PARC (12/09/2026) // ===================================================== // Les bandes ci-dessous sont en AMPERES ABSOLUS, et elles decrivent un parc // de 4 modules -- celui sur lequel elles sont aujourd'hui en vigueur et // observees justes (l'arret dur du 11/09 a mordu au bon endroit, 87 A pour // une limite de 70). Avec quatre US5000 en plus, le parc triple : garder ces // ampere-la briderait le nouveau parc au niveau de l'ancien. // // On les met donc a l'echelle du parc DECLARE — le meme helper que la // capacite utile et que le bilan energetique, exprime en equivalents // US2000C (un US5000 en vaut DEUX). Une seule valeur a tenir a jour, et les // trois consommateurs restent forcement d'accord entre eux. // // ⚠️ On ne met PAS a l'echelle sur la limite BMS lue en direct : elle // s'effondre quand une cellule monte en tension (04/09 : plafond de charge // tombe de 150 a 20 A en cinq secondes). Une regle de confort ne doit pas // suivre un signal aussi nerveux. En revanche le BMS PLAFONNE : on ne // s'autorise jamais plus que ce que le parc declare accepter a l'instant. // // Parc absent ou illisible => echelle 1 et plafond inactif, donc comportement // d'aujourd'hui strictement inchange. const parkScale = (() => { const n = Number(input.modules_en_service); return Number.isFinite(n) && n >= 1 && n <= 24 ? n / 4 : 1; })(); const bmsDischargeCapA = (() => { const n = Number(input.bms_max_discharge_current_a); return Number.isFinite(n) && n > 0 ? n : Infinity; })(); const bmsChargeCapA = (() => { const n = Number(input.bms_max_charge_current_a); return Number.isFinite(n) && n > 0 ? n : Infinity; })(); // Decharge : valeurs NEGATIVES, donc le plafond est un `Math.max`. const scaleDischargeA = v => Math.max(-bmsDischargeCapA, v * parkScale); const scaleChargeA = v => Math.min(bmsChargeCapA, Math.max(0, v) * parkScale); // ===================================================== // BATTERY TUNING // ===================================================== const batteryTune = { global: { houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)), solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)), stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)), stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)), forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)), overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)), overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)), overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)), }, bands: { gt90: { dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_gt90_a, -50)), dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_gt90_a, -100)), chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_gt90_a, 5)), softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)), hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)), recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)), }, '80_90': { dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_80_90_a, -40)), dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_80_90_a, -80)), chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_80_90_a, 10)), softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)), hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)), recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)), }, '60_80': { dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_60_80_a, -20)), dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_60_80_a, -50)), chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_60_80_a, 40)), softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)), hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)), recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)), }, '40_60': { dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_40_60_a, -12)), dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_40_60_a, -30)), chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_40_60_a, 70)), softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)), hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)), recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)), }, '25_40': { dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_25_40_a, -5)), dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_25_40_a, -15)), chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_25_40_a, 110)), softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)), hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)), recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)), }, lt25: { dischargeSoftA: scaleDischargeA(num(input.batt_discharge_soft_lt25_a, -3)), dischargeHardA: scaleDischargeA(num(input.batt_discharge_hard_lt25_a, -8)), chargeReserveA: scaleChargeA(num(input.batt_charge_reserve_lt25_a, 150)), softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)), hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)), recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)), }, }, }; // ===================================================== // OPTIONAL DEBUG / SUMMARY // ===================================================== const evcsNightConfig = { batteryHardFloorPct: params.evcsNightBatteryHardFloorPct, strongForecastKwh: params.evcsNightStrongForecastKwh, mediumForecastKwh: params.evcsNightMediumForecastKwh, targetStrongForecastPct: params.evcsNightTargetStrongForecastPct, targetMediumForecastPct: params.evcsNightTargetMediumForecastPct, targetWeakForecastPct: params.evcsNightTargetWeakForecastPct, batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw, departureSafetyMin: params.evcsNightDepartureSafetyMin, sessionStartSocPct: history.nightSessionStartSocPct, sessionStartAt: history.nightSessionStartAt || null, }; const vehicleDemandHistoryDebug = { evcsDeliveryRatioEma: finiteOrNull(history.evcsDeliveryRatioEma), evcsDeliveryNoiseEma: finiteOrNull(history.evcsDeliveryNoiseEma), evcsNoDemandScoreEma: finiteOrNull(history.evcsNoDemandScoreEma), evcsNoDemandSemanticActive: history.evcsNoDemandSemanticActive, evcsNoDemandSinceAt: history.evcsNoDemandSinceAt || null, evcsNoDemandSinceMs: finiteOrNull(history.evcsNoDemandSinceMs), evcsNoDemandFilterEvalAt: history.evcsNoDemandFilterEvalAt || null, evcsNoDemandFilterEvalMs: finiteOrNull(history.evcsNoDemandFilterEvalMs), }; // ===================================================== // OUTPUT // ===================================================== return [{ json: { raw, input, nowMs, now, cfg: CFG, parsed, params, history, batteryTune, evcsNightConfig, vehicleDemandHistoryDebug, } }];