From 283ef422038f17f138cbbee52d4a66544a501dcb Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Sun, 13 Sep 2026 13:41:19 +0200 Subject: [PATCH] pv_mid passe aussi a 130 A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le patch precedent n'avait releve que pv_boost : au-dessus de 80 % de SOC c'est pv_mid qui commande, donc son plafond de 110 A restait le plus bas et rien n'etait observable cet apres-midi. Table finale : 150 / 130 / 80 / 30, toujours decroissante. Applique a 13:31. Le courant est passe de 90 a 116 A dans la minute — mais la charge de la maison a chute de 2,6 kW au meme moment, et le courant n'a repris que 1,3 kW des 2,6 liberes tandis qu'un chargeur de plus se bridait. Ce n'est donc PAS une preuve propre, et je ne la presente pas comme telle. Ce que le releve montre vraiment : dans la tranche 80-90, le courant a plafonne vers 116 A sans jamais approcher les 130, pendant que la tension montait de 50,82 a 51,07 V vers la consigne. A ce niveau de SOC c'est la TENSION qui contraint, pas le courant. La ou le plafond mordait : a 12:36-12:42, SOC 78-80, le courant etait a 129 A pour une DVCC de 130 — colle dessus. C'est la tranche pv_boost, donc c'est la charge du matin qui dira ce que le 150 A libere. Cellules a 2 mV et aucune alarme sur toute la sequence. Golden ACM 20/20, attendu de acm-jour-mid mis a jour dans le meme commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj --- HANDOFF.md | 34 +++++++++++++- tests/golden-acm/scenarios.mjs | 8 +++- tools/patch-20260913b-pvmid-130a.mjs | 45 +++++++++++++++++++ .../victron-auto-charge-manager-v1.2.json | 2 +- 4 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 tools/patch-20260913b-pvmid-130a.mjs diff --git a/HANDOFF.md b/HANDOFF.md index d36e52a..b7eb772 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -6076,5 +6076,37 @@ pourquoi une consigne poussee a 11:11 ne s'applique qu'a 11:15. millivolts. S'il s'ouvre, on s'arrete a ce palier. 3. Nombre de chargeurs `voltage_current_limited` a charge maison comparable. +### Suite immediate : `pv_mid` 110 → 130 A +Le premier patch n'avait releve que `pv_boost`, donc rien n'etait observable a +SOC 88. Table finale : **150 / 130 / 80 / 30**, toujours decroissante. + +Applique a 13:31 (cycle ACM de 11:30 UTC). Ce qui s'est passe dans la minute : + +| | 13:29:54 | 13:31:09 | +|---|---:|---:| +| DVCC | 110 A | **130 A** | +| courant pris | 90 A | **116 A** | +| tension | 50,82 V | 50,94 V | +| maison | 6 159 W | 3 561 W | + +⚠️ **A NE PAS PRESENTER COMME UNE PREUVE PROPRE** : la charge de la maison a +chute de 2,6 kW dans le meme intervalle. Le courant n'a repris que 1,3 kW des +2,6 kW liberes, et le nombre de chargeurs brides est monte de 1/7 a 2/7 — donc +l'essentiel de ce qui a ete libere n'a PAS ete absorbe. + +Ce que le releve montre vraiment : dans la tranche 80-90, le plafond de courant +n'etait que marginalement contraignant. Le courant a plafonne vers **116 A sans +jamais approcher les 130** et la tension montait vers la consigne (50,82 → +51,07 V) : a ce niveau de SOC, c'est la TENSION qui contraint, pas le courant. +Puis a 13:35, SOC 93, l'ACM est passe en `day_pv_topoff` (80 A / 52,4 V) et +6 chargeurs sur 7 se sont brides — ecretage NORMAL de haut de charge, batterie +quasi pleine, a ne pas confondre avec le sujet. + +**Ou le plafond mordait vraiment** : a 12:36-12:42, SOC 78-80, le courant etait +a **129 A pour une DVCC de 130** — colle dessus. C'est la tranche `pv_boost`, et +c'est donc la charge du MATIN qui dira ce que le 150 A libere. + +Cellules a 2 mV d'ecart et aucune alarme sur toute la sequence. + --- -*Document mis a jour le : 13/09/2026 — plafond de charge 130 → 150 A, pv_mid 51,2 → 51,5 V, golden-acm 20/20* +*Document mis a jour le : 13/09/2026 — table de charge 150/130/80/30, pv_mid 51,2 → 51,5 V, golden-acm 20/20* diff --git a/tests/golden-acm/scenarios.mjs b/tests/golden-acm/scenarios.mjs index c983187..8dd4141 100644 --- a/tests/golden-acm/scenarios.mjs +++ b/tests/golden-acm/scenarios.mjs @@ -154,10 +154,14 @@ export const scenarios = [ }, { id: 'acm-jour-mid', - label: 'Jour, SOC 85 entre 80 et 90 : 110 A a 51,5 V (la colonne des tensions redescendait entre 80 et 90)', + // 13/09/2026 — 110 -> 130 A, meme raison que pv_boost : le 110 A venait de + // l'ANCIEN parc. A 12:49, SOC 82, la batterie prenait encore 127 A juste + // avant que l'ACM ne la ramene a son plafond. La table reste decroissante : + // 150 / 130 / 80 / 30. + label: 'Jour, SOC 85 entre 80 et 90 : 130 A a 51,5 V', time: h(ETE, '11:00'), overrides: { ...parcNeuf, soc_percent: '85', pv_power_w: '4000', battery_voltage_v: '50.5' }, - expect: { mode: 'day_pv_mid', profil: 'pv_mid', courantA: 110, tensionV: 51.5 }, + expect: { mode: 'day_pv_mid', profil: 'pv_mid', courantA: 130, tensionV: 51.5 }, }, { id: 'acm-jour-topoff-52v4', diff --git a/tools/patch-20260913b-pvmid-130a.mjs b/tools/patch-20260913b-pvmid-130a.mjs new file mode 100644 index 0000000..38cb743 --- /dev/null +++ b/tools/patch-20260913b-pvmid-130a.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// `pv_mid` (SOC 80-90) passe de 110 a 130 A. +// +// Suite directe du patch du meme jour, qui n'avait releve que `pv_boost` +// (130 -> 150 A) : au-dessus de 80 % de SOC c'est `pv_mid` qui commande, donc +// son plafond de 110 A restait le plus bas des deux et la mesure de l'apres-midi +// ne pouvait rien montrer. +// +// Ce que la mesure du 13/09 avait etabli, et qui vaut pour cette tranche aussi : +// - le BMS declare autoriser 250 A de charge et 53,2 V ; +// - les 135 cellules du nouveau parc tiennent dans 2 mV, aucune alarme, +// aucun module ne bloque la charge ; +// - a 12:49, SOC 82, la batterie prenait encore 127 A — donc bien au-dessus +// des 110 A que cette tranche autorise — juste avant que l'ACM ne la +// ramene a son plafond a 12:50. +// C'est le meme constat que pour pv_boost : le 110 A venait de l'ANCIEN parc. +// +// La table reste decroissante : 150 / 130 / 80 / 30. `pv_topoff` (80 A, +// 52,4 V, regle le 06/09 sur mesure) et `pv_hold` (30 A, 50,6 V, delibere : +// on atteint le haut, on n'y sejourne pas) ne sont pas touches. +// +// ⚠️ Contrairement au 150 A de pv_boost, celui-ci est observable IMMEDIATEMENT +// tant que le SOC est entre 80 et 90. +// +// node tools/patch-20260913b-pvmid-130a.mjs +import fs from 'fs'; + +const F = 'workflows/victron-auto-charge-manager-v1.2.json'; +const w = JSON.parse(fs.readFileSync(F, 'utf8')); +const n = w.nodes.find((x) => x.name === 'Compute Charge Targets'); +if (!n) { console.error('noeud "Compute Charge Targets" introuvable'); process.exit(1); } + +const avant = ' pv_mid: { current_a: 110, voltage_v: 51.5 },'; +const apres = ' pv_mid: { current_a: 130, voltage_v: 51.5 },'; +if (n.parameters.jsCode.includes(apres)) { + console.log('deja applique'); +} else { + if (!n.parameters.jsCode.includes(avant)) { + console.error('ancre introuvable — le patch 20260913 (pv_mid a 51,5 V) doit etre applique d abord'); + process.exit(1); + } + n.parameters.jsCode = n.parameters.jsCode.replace(avant, apres); + fs.writeFileSync(F, JSON.stringify(w, null, 2) + '\n'); + console.log(apres.trim()); +} diff --git a/workflows/victron-auto-charge-manager-v1.2.json b/workflows/victron-auto-charge-manager-v1.2.json index 69fcc3c..3d7df9a 100644 --- a/workflows/victron-auto-charge-manager-v1.2.json +++ b/workflows/victron-auto-charge-manager-v1.2.json @@ -73,7 +73,7 @@ }, { "parameters": { - "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction timeToMinutes(hh, mm) {\n return hh * 60 + mm;\n}\n\nfunction inRange(minNow, startMin, endMin) {\n return minNow >= startMin && minNow < endMin;\n}\n\nfunction getParisNowParts() {\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())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const localStamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour: Number(parts.hour),\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n localStamp,\n minNow: Number(parts.hour) * 60 + Number(parts.minute),\n };\n}\n\nfunction forecastBand(kwh) {\n if (!Number.isFinite(kwh)) return 'unknown';\n if (kwh >= 25) return 'sunny';\n if (kwh >= 12) return 'mixed';\n return 'dark';\n}\n\nconst cfg = {\n winterMonths: [11, 12, 1, 2],\n\n windows: {\n nightHC_start: timeToMinutes(1, 24),\n nightHC_end: timeToMinutes(7, 24),\n\n middayHC_start: timeToMinutes(12, 24),\n middayHC_end: timeToMinutes(14, 24),\n\n winterDay_start: timeToMinutes(7, 15),\n winterDay_end: timeToMinutes(16, 45),\n\n summerDay_start: timeToMinutes(7, 30),\n summerDay_end: timeToMinutes(19, 30),\n },\n\n profiles: {\n night_slow: { current_a: 30, voltage_v: 50.1 },\n takeover_hold: { current_a: 2, voltage_v: 50.1 },\n pv_boost: { current_a: 150, voltage_v: 51.5 },\n pv_mid: { current_a: 110, voltage_v: 51.5 },\n pv_topoff: { current_a: 80, voltage_v: 52.4 },\n pv_hold: { current_a: 30, voltage_v: 50.6 },\n midday_grid_catchup: { current_a: 30, voltage_v: 50.8 },\n emergency_rescue: { current_a: 60, voltage_v: 50.4 },\n },\n\n voltagePublishDelta: 0.05,\n currentPublishDelta: 0.5,\n\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt'\n};\n\nconst MQTT_TOPIC_CHARGE_CURRENT = 'W/c0619ab34ac5/settings/0/Settings/SystemSetup/MaxChargeCurrent';\nconst MQTT_TOPIC_CHARGE_VOLTAGE = 'W/c0619ab34ac5/settings/0/Settings/SystemSetup/MaxChargeVoltage';\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\n\nconst soc = num(input.soc_percent, 0);\nconst batteryVoltageV = num(input.battery_voltage_v, NaN);\nconst batteryCurrentA = num(input.battery_current_a, NaN);\nconst pvPowerW = num(input.pv_power_w, 0);\nconst edfSwitch = low(input.edf_switch || 'off') === 'on' ? 'on' : 'off';\n\nconst todayRemainingKwh = num(input.today_remaining_kwh, 0);\nconst tomorrowKwh = num(input.tomorrow_kwh, 0);\n\nconst currentMaxChargeCurrentA = num(input.current_max_charge_current_a, 0);\nconst currentMaxChargeVoltageV = num(input.current_max_charge_voltage_v, 0);\n\nconst autoEnabled = low(input.auto_enabled || 'off') === 'on';\nconst nightTakeover = low(input.night_takeover || 'off') === 'on';\nconst nightTargetSoc = num(input.night_target_soc, 35);\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst overrideEnabled = low(input.override_enabled || 'off') === 'on';\nconst overrideProfile = low(input.override_profile || 'auto');\nconst overrideCurrentA = num(input.override_current_a, NaN);\nconst overrideVoltageV = num(input.override_voltage_v, NaN);\nconst overrideUntil = (input.override_until ?? '').toString().trim();\nconst overrideReason = (input.override_reason ?? '').toString().trim();\n\nconst isWinter = cfg.winterMonths.includes(now.month);\nconst inNightHC = inRange(now.minNow, cfg.windows.nightHC_start, cfg.windows.nightHC_end);\nconst inMiddayHC = inRange(now.minNow, cfg.windows.middayHC_start, cfg.windows.middayHC_end);\nconst inDayWindow = isWinter\n ? inRange(now.minNow, cfg.windows.winterDay_start, cfg.windows.winterDay_end)\n : inRange(now.minNow, cfg.windows.summerDay_start, cfg.windows.summerDay_end);\n\nconst forecastRefKwh = now.hour < 8 ? Math.max(todayRemainingKwh, tomorrowKwh, 0) : todayRemainingKwh;\nconst forecastState = forecastBand(forecastRefKwh);\n\nfunction profileValues(profileName) {\n if (profileName === 'manual') {\n return {\n current_a: Number.isFinite(overrideCurrentA) ? overrideCurrentA : cfg.profiles.pv_boost.current_a,\n voltage_v: Number.isFinite(overrideVoltageV) ? overrideVoltageV : cfg.profiles.pv_boost.voltage_v,\n };\n }\n return cfg.profiles[profileName] || null;\n}\n\nconst overrideActive =\n overrideEnabled &&\n overrideProfile !== 'auto' &&\n /^\\d{4}-\\d{2}-\\d{2}/.test(overrideUntil) &&\n now.localStamp < overrideUntil;\n\nlet mode = 'idle';\nlet effectiveProfile = 'auto_idle';\nlet targetCurrentA = currentMaxChargeCurrentA || 30;\nlet targetVoltageV = currentMaxChargeVoltageV || 50.5;\nlet criticalAlert = false;\nlet reason = [];\n\nif (!autoEnabled) {\n mode = 'automation_disabled';\n effectiveProfile = 'manual_keep_current';\n targetCurrentA = currentMaxChargeCurrentA || 30;\n targetVoltageV = currentMaxChargeVoltageV || 50.5;\n reason.push(\"Automatisation désactivée, conservation des réglages actuels\");\n} else {\n if (soc < 20 && !inNightHC && !inMiddayHC) {\n mode = 'emergency_rescue';\n effectiveProfile = 'emergency_rescue';\n targetCurrentA = cfg.profiles.emergency_rescue.current_a;\n targetVoltageV = cfg.profiles.emergency_rescue.voltage_v;\n criticalAlert = true;\n reason.push(`SOC critique ${soc}%`);\n }\n\n // P4 : takeover EVCS nocturne. EDF est ON pour la maison + le VE (HC),\n // la batterie doit seulement être RAMENÉE à la cible matin puis MAINTENUE,\n // pas rechargée à fond (cible adaptée au forecast : été ~35%, hiver ~85%).\n else if (nightTakeover && edfSwitch === 'on') {\n const holdTargetSoc = Math.min(95, Math.max(20, nightTargetSoc));\n if (soc < holdTargetSoc - 1) {\n mode = 'evcs_takeover_recover';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Takeover EVCS : recharge vers cible matin ${holdTargetSoc}% (SOC ${soc}%)`);\n } else {\n mode = 'evcs_takeover_hold';\n effectiveProfile = 'takeover_hold';\n targetCurrentA = cfg.profiles.takeover_hold.current_a;\n targetVoltageV = cfg.profiles.takeover_hold.voltage_v;\n reason.push(`Takeover EVCS : maintien SOC ~${holdTargetSoc}% (maison+VE sur HC, batterie en pause)`);\n }\n }\n\n else if (isWinter && inNightHC) {\n let targetSoc;\n if (forecastState === 'sunny') targetSoc = 40;\n else if (forecastState === 'mixed') targetSoc = 60;\n else targetSoc = 80;\n\n if (soc < targetSoc - 1) {\n mode = 'winter_night_charge_window';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Hiver HC nuit, cible SOC ${targetSoc}% selon forecast ${forecastState}`);\n } else {\n mode = 'winter_night_idle';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Hiver HC nuit, SOC ${soc}% déjà suffisant`);\n }\n }\n\n else if (!isWinter && inNightHC) {\n if (soc < 22) {\n mode = 'summer_night_rescue_window';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(\"Été HC nuit, secours SOC < 22%\");\n } else {\n mode = 'summer_night_idle';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(\"Été HC nuit, pas de besoin réseau\");\n }\n }\n\n else if (\n inMiddayHC &&\n soc < 85 &&\n forecastRefKwh < 8 &&\n pvPowerW < 1500\n ) {\n mode = 'midday_catchup_window';\n effectiveProfile = 'midday_grid_catchup';\n targetCurrentA = cfg.profiles.midday_grid_catchup.current_a;\n targetVoltageV = cfg.profiles.midday_grid_catchup.voltage_v;\n reason.push(`HC midi rattrapage : SOC ${soc}% | forecast ${forecastRefKwh} kWh | PV ${pvPowerW} W`);\n }\n\n else if (inDayWindow) {\n if (soc < 80) {\n mode = 'day_pv_boost';\n effectiveProfile = 'pv_boost';\n targetCurrentA = cfg.profiles.pv_boost.current_a;\n targetVoltageV = cfg.profiles.pv_boost.voltage_v;\n reason.push(`Jour SOC bas ${soc}% : capture PV max`);\n } else if (soc < 90) {\n mode = 'day_pv_mid';\n effectiveProfile = 'pv_mid';\n targetCurrentA = cfg.profiles.pv_mid.current_a;\n targetVoltageV = cfg.profiles.pv_mid.voltage_v;\n reason.push(`Jour SOC intermédiaire ${soc}%`);\n } else if (soc < 95) {\n mode = 'day_pv_topoff';\n effectiveProfile = 'pv_topoff';\n targetCurrentA = cfg.profiles.pv_topoff.current_a;\n targetVoltageV = cfg.profiles.pv_topoff.voltage_v;\n reason.push(`Jour top-off vers 95% | SOC ${soc}%`);\n } else {\n mode = 'day_pv_hold';\n effectiveProfile = 'pv_hold';\n targetCurrentA = cfg.profiles.pv_hold.current_a;\n targetVoltageV = cfg.profiles.pv_hold.voltage_v;\n reason.push(`Jour maintien haut SOC ${soc}%`);\n }\n\n if (\n Number.isFinite(batteryVoltageV) &&\n soc < 95 &&\n pvPowerW > 2000 &&\n batteryVoltageV >= (targetVoltageV - 0.15)\n ) {\n targetVoltageV = Math.max(targetVoltageV, 51.3);\n targetCurrentA = Math.max(targetCurrentA, 80);\n if (soc >= 90) {\n effectiveProfile = 'pv_topoff';\n mode = 'day_pv_topoff';\n } else {\n effectiveProfile = 'pv_boost';\n }\n reason.push(`Batterie proche consigne (${round1(batteryVoltageV)}V), relèvement anti-bridage`);\n }\n\n if (\n Number.isFinite(batteryCurrentA) &&\n soc < 95 &&\n pvPowerW > 6000 &&\n batteryCurrentA >= (targetCurrentA - 3)\n ) {\n targetCurrentA = Math.max(targetCurrentA, soc >= 90 ? 80 : 130);\n reason.push(`Courant batterie proche limite (${round1(batteryCurrentA)}A), relèvement du plafond`);\n }\n }\n\n\n else {\n mode = 'off_window_idle';\n effectiveProfile = 'pv_hold';\n targetCurrentA = cfg.profiles.pv_hold.current_a;\n targetVoltageV = cfg.profiles.pv_hold.voltage_v;\n reason.push(\"Hors fenêtre utile\");\n }\n}\n\nif (overrideActive) {\n const ov = profileValues(overrideProfile);\n if (ov) {\n targetCurrentA = ov.current_a;\n targetVoltageV = ov.voltage_v;\n effectiveProfile = `override_${overrideProfile}`;\n mode = `${mode}_override`;\n reason.unshift(\n `Override actif jusqu'à ${overrideUntil} | profil=${overrideProfile}` +\n (overrideReason ? ` | ${overrideReason}` : '')\n );\n }\n} else if (overrideEnabled && overrideProfile !== 'auto') {\n reason.push(\"Override configuré mais inactif ou expiré\");\n}\n\ntargetCurrentA = round1(targetCurrentA);\ntargetVoltageV = round1(targetVoltageV);\n\nconst shouldPublishCurrent =\n !Number.isFinite(currentMaxChargeCurrentA) ||\n Math.abs(targetCurrentA - currentMaxChargeCurrentA) >= cfg.currentPublishDelta;\n\nconst shouldPublishVoltage =\n !Number.isFinite(currentMaxChargeVoltageV) ||\n Math.abs(targetVoltageV - currentMaxChargeVoltageV) >= cfg.voltagePublishDelta;\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n mode,\n effectiveProfile,\n `i=${targetCurrentA}`,\n `v=${targetVoltageV}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\nconst result = {\n timestamp_paris: now.localStamp,\n\n inputs: {\n soc_percent: soc,\n battery_voltage_v: Number.isFinite(batteryVoltageV) ? batteryVoltageV : null,\n battery_current_a: Number.isFinite(batteryCurrentA) ? batteryCurrentA : null,\n pv_power_w: pvPowerW,\n edf_switch_observed: edfSwitch,\n today_remaining_kwh: todayRemainingKwh,\n tomorrow_kwh: tomorrowKwh,\n current_max_charge_current_a: currentMaxChargeCurrentA,\n current_max_charge_voltage_v: currentMaxChargeVoltageV,\n auto_enabled: autoEnabled,\n notifications_enabled: notificationsEnabled,\n last_signature: lastSignature || null,\n\n override_enabled: overrideEnabled,\n override_profile: overrideProfile,\n override_current_a: Number.isFinite(overrideCurrentA) ? overrideCurrentA : null,\n override_voltage_v: Number.isFinite(overrideVoltageV) ? overrideVoltageV : null,\n override_until: overrideUntil || null,\n override_reason: overrideReason || null\n },\n\n computed: {\n is_winter: isWinter,\n in_night_hc: inNightHC,\n in_midday_hc: inMiddayHC,\n in_day_window: inDayWindow,\n forecast_reference_kwh: round1(forecastRefKwh),\n forecast_state: forecastState,\n override_active: overrideActive\n },\n\n decision: {\n mode,\n effective_profile: effectiveProfile,\n target_current_a: targetCurrentA,\n target_voltage_v: targetVoltageV,\n should_publish_current: shouldPublishCurrent,\n should_publish_voltage: shouldPublishVoltage,\n critical_alert: criticalAlert,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_current: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_target_charge_current',\n value: targetCurrentA\n }\n },\n\n helper_voltage: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_target_charge_voltage',\n value: targetVoltageV\n }\n },\n\n helper_soc_live: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_soc_live',\n value: soc\n }\n },\n\n helper_mode: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_mode',\n value: mode\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_profile: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_effective_charge_profile',\n value: effectiveProfile\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_last_signature',\n value: signature\n }\n },\n\n mqtt_current: {\n domain: 'mqtt',\n service: 'publish',\n data: {\n topic: MQTT_TOPIC_CHARGE_CURRENT,\n payload: JSON.stringify({ value: targetCurrentA }),\n retain: false\n }\n },\n\n mqtt_voltage: {\n domain: 'mqtt',\n service: 'publish',\n data: {\n topic: MQTT_TOPIC_CHARGE_VOLTAGE,\n payload: JSON.stringify({ value: targetVoltageV }),\n retain: false\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Auto Charge',\n message: `${mode} | profile=${effectiveProfile} | I=${targetCurrentA}A | V=${targetVoltageV}V | SOC=${soc}% | ${shortReason}`,\n entity_id: 'sensor.victron_battery_soc'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: 'Victron Auto Charge',\n message: `${mode} | profile=${effectiveProfile} | I=${targetCurrentA}A | V=${targetVoltageV}V | SOC=${soc}%`,\n data: {\n push: {\n \"interruption-level\": \"time-sensitive\",\n sound: { name: \"default\" }\n },\n ttl: 0,\n priority: \"high\"\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];\n" + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction timeToMinutes(hh, mm) {\n return hh * 60 + mm;\n}\n\nfunction inRange(minNow, startMin, endMin) {\n return minNow >= startMin && minNow < endMin;\n}\n\nfunction getParisNowParts() {\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())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const localStamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour: Number(parts.hour),\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n localStamp,\n minNow: Number(parts.hour) * 60 + Number(parts.minute),\n };\n}\n\nfunction forecastBand(kwh) {\n if (!Number.isFinite(kwh)) return 'unknown';\n if (kwh >= 25) return 'sunny';\n if (kwh >= 12) return 'mixed';\n return 'dark';\n}\n\nconst cfg = {\n winterMonths: [11, 12, 1, 2],\n\n windows: {\n nightHC_start: timeToMinutes(1, 24),\n nightHC_end: timeToMinutes(7, 24),\n\n middayHC_start: timeToMinutes(12, 24),\n middayHC_end: timeToMinutes(14, 24),\n\n winterDay_start: timeToMinutes(7, 15),\n winterDay_end: timeToMinutes(16, 45),\n\n summerDay_start: timeToMinutes(7, 30),\n summerDay_end: timeToMinutes(19, 30),\n },\n\n profiles: {\n night_slow: { current_a: 30, voltage_v: 50.1 },\n takeover_hold: { current_a: 2, voltage_v: 50.1 },\n pv_boost: { current_a: 150, voltage_v: 51.5 },\n pv_mid: { current_a: 130, voltage_v: 51.5 },\n pv_topoff: { current_a: 80, voltage_v: 52.4 },\n pv_hold: { current_a: 30, voltage_v: 50.6 },\n midday_grid_catchup: { current_a: 30, voltage_v: 50.8 },\n emergency_rescue: { current_a: 60, voltage_v: 50.4 },\n },\n\n voltagePublishDelta: 0.05,\n currentPublishDelta: 0.5,\n\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt'\n};\n\nconst MQTT_TOPIC_CHARGE_CURRENT = 'W/c0619ab34ac5/settings/0/Settings/SystemSetup/MaxChargeCurrent';\nconst MQTT_TOPIC_CHARGE_VOLTAGE = 'W/c0619ab34ac5/settings/0/Settings/SystemSetup/MaxChargeVoltage';\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\n\nconst soc = num(input.soc_percent, 0);\nconst batteryVoltageV = num(input.battery_voltage_v, NaN);\nconst batteryCurrentA = num(input.battery_current_a, NaN);\nconst pvPowerW = num(input.pv_power_w, 0);\nconst edfSwitch = low(input.edf_switch || 'off') === 'on' ? 'on' : 'off';\n\nconst todayRemainingKwh = num(input.today_remaining_kwh, 0);\nconst tomorrowKwh = num(input.tomorrow_kwh, 0);\n\nconst currentMaxChargeCurrentA = num(input.current_max_charge_current_a, 0);\nconst currentMaxChargeVoltageV = num(input.current_max_charge_voltage_v, 0);\n\nconst autoEnabled = low(input.auto_enabled || 'off') === 'on';\nconst nightTakeover = low(input.night_takeover || 'off') === 'on';\nconst nightTargetSoc = num(input.night_target_soc, 35);\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst overrideEnabled = low(input.override_enabled || 'off') === 'on';\nconst overrideProfile = low(input.override_profile || 'auto');\nconst overrideCurrentA = num(input.override_current_a, NaN);\nconst overrideVoltageV = num(input.override_voltage_v, NaN);\nconst overrideUntil = (input.override_until ?? '').toString().trim();\nconst overrideReason = (input.override_reason ?? '').toString().trim();\n\nconst isWinter = cfg.winterMonths.includes(now.month);\nconst inNightHC = inRange(now.minNow, cfg.windows.nightHC_start, cfg.windows.nightHC_end);\nconst inMiddayHC = inRange(now.minNow, cfg.windows.middayHC_start, cfg.windows.middayHC_end);\nconst inDayWindow = isWinter\n ? inRange(now.minNow, cfg.windows.winterDay_start, cfg.windows.winterDay_end)\n : inRange(now.minNow, cfg.windows.summerDay_start, cfg.windows.summerDay_end);\n\nconst forecastRefKwh = now.hour < 8 ? Math.max(todayRemainingKwh, tomorrowKwh, 0) : todayRemainingKwh;\nconst forecastState = forecastBand(forecastRefKwh);\n\nfunction profileValues(profileName) {\n if (profileName === 'manual') {\n return {\n current_a: Number.isFinite(overrideCurrentA) ? overrideCurrentA : cfg.profiles.pv_boost.current_a,\n voltage_v: Number.isFinite(overrideVoltageV) ? overrideVoltageV : cfg.profiles.pv_boost.voltage_v,\n };\n }\n return cfg.profiles[profileName] || null;\n}\n\nconst overrideActive =\n overrideEnabled &&\n overrideProfile !== 'auto' &&\n /^\\d{4}-\\d{2}-\\d{2}/.test(overrideUntil) &&\n now.localStamp < overrideUntil;\n\nlet mode = 'idle';\nlet effectiveProfile = 'auto_idle';\nlet targetCurrentA = currentMaxChargeCurrentA || 30;\nlet targetVoltageV = currentMaxChargeVoltageV || 50.5;\nlet criticalAlert = false;\nlet reason = [];\n\nif (!autoEnabled) {\n mode = 'automation_disabled';\n effectiveProfile = 'manual_keep_current';\n targetCurrentA = currentMaxChargeCurrentA || 30;\n targetVoltageV = currentMaxChargeVoltageV || 50.5;\n reason.push(\"Automatisation désactivée, conservation des réglages actuels\");\n} else {\n if (soc < 20 && !inNightHC && !inMiddayHC) {\n mode = 'emergency_rescue';\n effectiveProfile = 'emergency_rescue';\n targetCurrentA = cfg.profiles.emergency_rescue.current_a;\n targetVoltageV = cfg.profiles.emergency_rescue.voltage_v;\n criticalAlert = true;\n reason.push(`SOC critique ${soc}%`);\n }\n\n // P4 : takeover EVCS nocturne. EDF est ON pour la maison + le VE (HC),\n // la batterie doit seulement être RAMENÉE à la cible matin puis MAINTENUE,\n // pas rechargée à fond (cible adaptée au forecast : été ~35%, hiver ~85%).\n else if (nightTakeover && edfSwitch === 'on') {\n const holdTargetSoc = Math.min(95, Math.max(20, nightTargetSoc));\n if (soc < holdTargetSoc - 1) {\n mode = 'evcs_takeover_recover';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Takeover EVCS : recharge vers cible matin ${holdTargetSoc}% (SOC ${soc}%)`);\n } else {\n mode = 'evcs_takeover_hold';\n effectiveProfile = 'takeover_hold';\n targetCurrentA = cfg.profiles.takeover_hold.current_a;\n targetVoltageV = cfg.profiles.takeover_hold.voltage_v;\n reason.push(`Takeover EVCS : maintien SOC ~${holdTargetSoc}% (maison+VE sur HC, batterie en pause)`);\n }\n }\n\n else if (isWinter && inNightHC) {\n let targetSoc;\n if (forecastState === 'sunny') targetSoc = 40;\n else if (forecastState === 'mixed') targetSoc = 60;\n else targetSoc = 80;\n\n if (soc < targetSoc - 1) {\n mode = 'winter_night_charge_window';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Hiver HC nuit, cible SOC ${targetSoc}% selon forecast ${forecastState}`);\n } else {\n mode = 'winter_night_idle';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Hiver HC nuit, SOC ${soc}% déjà suffisant`);\n }\n }\n\n else if (!isWinter && inNightHC) {\n if (soc < 22) {\n mode = 'summer_night_rescue_window';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(\"Été HC nuit, secours SOC < 22%\");\n } else {\n mode = 'summer_night_idle';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(\"Été HC nuit, pas de besoin réseau\");\n }\n }\n\n else if (\n inMiddayHC &&\n soc < 85 &&\n forecastRefKwh < 8 &&\n pvPowerW < 1500\n ) {\n mode = 'midday_catchup_window';\n effectiveProfile = 'midday_grid_catchup';\n targetCurrentA = cfg.profiles.midday_grid_catchup.current_a;\n targetVoltageV = cfg.profiles.midday_grid_catchup.voltage_v;\n reason.push(`HC midi rattrapage : SOC ${soc}% | forecast ${forecastRefKwh} kWh | PV ${pvPowerW} W`);\n }\n\n else if (inDayWindow) {\n if (soc < 80) {\n mode = 'day_pv_boost';\n effectiveProfile = 'pv_boost';\n targetCurrentA = cfg.profiles.pv_boost.current_a;\n targetVoltageV = cfg.profiles.pv_boost.voltage_v;\n reason.push(`Jour SOC bas ${soc}% : capture PV max`);\n } else if (soc < 90) {\n mode = 'day_pv_mid';\n effectiveProfile = 'pv_mid';\n targetCurrentA = cfg.profiles.pv_mid.current_a;\n targetVoltageV = cfg.profiles.pv_mid.voltage_v;\n reason.push(`Jour SOC intermédiaire ${soc}%`);\n } else if (soc < 95) {\n mode = 'day_pv_topoff';\n effectiveProfile = 'pv_topoff';\n targetCurrentA = cfg.profiles.pv_topoff.current_a;\n targetVoltageV = cfg.profiles.pv_topoff.voltage_v;\n reason.push(`Jour top-off vers 95% | SOC ${soc}%`);\n } else {\n mode = 'day_pv_hold';\n effectiveProfile = 'pv_hold';\n targetCurrentA = cfg.profiles.pv_hold.current_a;\n targetVoltageV = cfg.profiles.pv_hold.voltage_v;\n reason.push(`Jour maintien haut SOC ${soc}%`);\n }\n\n if (\n Number.isFinite(batteryVoltageV) &&\n soc < 95 &&\n pvPowerW > 2000 &&\n batteryVoltageV >= (targetVoltageV - 0.15)\n ) {\n targetVoltageV = Math.max(targetVoltageV, 51.3);\n targetCurrentA = Math.max(targetCurrentA, 80);\n if (soc >= 90) {\n effectiveProfile = 'pv_topoff';\n mode = 'day_pv_topoff';\n } else {\n effectiveProfile = 'pv_boost';\n }\n reason.push(`Batterie proche consigne (${round1(batteryVoltageV)}V), relèvement anti-bridage`);\n }\n\n if (\n Number.isFinite(batteryCurrentA) &&\n soc < 95 &&\n pvPowerW > 6000 &&\n batteryCurrentA >= (targetCurrentA - 3)\n ) {\n targetCurrentA = Math.max(targetCurrentA, soc >= 90 ? 80 : 130);\n reason.push(`Courant batterie proche limite (${round1(batteryCurrentA)}A), relèvement du plafond`);\n }\n }\n\n\n else {\n mode = 'off_window_idle';\n effectiveProfile = 'pv_hold';\n targetCurrentA = cfg.profiles.pv_hold.current_a;\n targetVoltageV = cfg.profiles.pv_hold.voltage_v;\n reason.push(\"Hors fenêtre utile\");\n }\n}\n\nif (overrideActive) {\n const ov = profileValues(overrideProfile);\n if (ov) {\n targetCurrentA = ov.current_a;\n targetVoltageV = ov.voltage_v;\n effectiveProfile = `override_${overrideProfile}`;\n mode = `${mode}_override`;\n reason.unshift(\n `Override actif jusqu'à ${overrideUntil} | profil=${overrideProfile}` +\n (overrideReason ? ` | ${overrideReason}` : '')\n );\n }\n} else if (overrideEnabled && overrideProfile !== 'auto') {\n reason.push(\"Override configuré mais inactif ou expiré\");\n}\n\ntargetCurrentA = round1(targetCurrentA);\ntargetVoltageV = round1(targetVoltageV);\n\nconst shouldPublishCurrent =\n !Number.isFinite(currentMaxChargeCurrentA) ||\n Math.abs(targetCurrentA - currentMaxChargeCurrentA) >= cfg.currentPublishDelta;\n\nconst shouldPublishVoltage =\n !Number.isFinite(currentMaxChargeVoltageV) ||\n Math.abs(targetVoltageV - currentMaxChargeVoltageV) >= cfg.voltagePublishDelta;\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n mode,\n effectiveProfile,\n `i=${targetCurrentA}`,\n `v=${targetVoltageV}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\nconst result = {\n timestamp_paris: now.localStamp,\n\n inputs: {\n soc_percent: soc,\n battery_voltage_v: Number.isFinite(batteryVoltageV) ? batteryVoltageV : null,\n battery_current_a: Number.isFinite(batteryCurrentA) ? batteryCurrentA : null,\n pv_power_w: pvPowerW,\n edf_switch_observed: edfSwitch,\n today_remaining_kwh: todayRemainingKwh,\n tomorrow_kwh: tomorrowKwh,\n current_max_charge_current_a: currentMaxChargeCurrentA,\n current_max_charge_voltage_v: currentMaxChargeVoltageV,\n auto_enabled: autoEnabled,\n notifications_enabled: notificationsEnabled,\n last_signature: lastSignature || null,\n\n override_enabled: overrideEnabled,\n override_profile: overrideProfile,\n override_current_a: Number.isFinite(overrideCurrentA) ? overrideCurrentA : null,\n override_voltage_v: Number.isFinite(overrideVoltageV) ? overrideVoltageV : null,\n override_until: overrideUntil || null,\n override_reason: overrideReason || null\n },\n\n computed: {\n is_winter: isWinter,\n in_night_hc: inNightHC,\n in_midday_hc: inMiddayHC,\n in_day_window: inDayWindow,\n forecast_reference_kwh: round1(forecastRefKwh),\n forecast_state: forecastState,\n override_active: overrideActive\n },\n\n decision: {\n mode,\n effective_profile: effectiveProfile,\n target_current_a: targetCurrentA,\n target_voltage_v: targetVoltageV,\n should_publish_current: shouldPublishCurrent,\n should_publish_voltage: shouldPublishVoltage,\n critical_alert: criticalAlert,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_current: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_target_charge_current',\n value: targetCurrentA\n }\n },\n\n helper_voltage: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_target_charge_voltage',\n value: targetVoltageV\n }\n },\n\n helper_soc_live: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_soc_live',\n value: soc\n }\n },\n\n helper_mode: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_mode',\n value: mode\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_profile: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_effective_charge_profile',\n value: effectiveProfile\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_last_signature',\n value: signature\n }\n },\n\n mqtt_current: {\n domain: 'mqtt',\n service: 'publish',\n data: {\n topic: MQTT_TOPIC_CHARGE_CURRENT,\n payload: JSON.stringify({ value: targetCurrentA }),\n retain: false\n }\n },\n\n mqtt_voltage: {\n domain: 'mqtt',\n service: 'publish',\n data: {\n topic: MQTT_TOPIC_CHARGE_VOLTAGE,\n payload: JSON.stringify({ value: targetVoltageV }),\n retain: false\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Auto Charge',\n message: `${mode} | profile=${effectiveProfile} | I=${targetCurrentA}A | V=${targetVoltageV}V | SOC=${soc}% | ${shortReason}`,\n entity_id: 'sensor.victron_battery_soc'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: 'Victron Auto Charge',\n message: `${mode} | profile=${effectiveProfile} | I=${targetCurrentA}A | V=${targetVoltageV}V | SOC=${soc}%`,\n data: {\n push: {\n \"interruption-level\": \"time-sensitive\",\n sound: { name: \"default\" }\n },\n ttl: 0,\n priority: \"high\"\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];\n" }, "id": "a98c5bda-cf34-447e-942e-37ae75a45d5c", "name": "Compute Charge Targets",