From 6dc5ea25cb114449641ce7ac33e4ac6eed3ddc76 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Mon, 24 Aug 2026 15:58:10 +0200 Subject: [PATCH] Chantier etapes 5b et 5c : recalcul quotidien des profils et fenetre de deficit suivante MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5c — BALANCE ne repondait qu'a "combien jusqu'a ce que le solaire prenne le relais". A 15 h la reponse est zero : exacte mais inutile, le deficit qui approche etant celui de la nuit. Ajout de la prochaine traversee deficitaire (heure de debut, energie, SOC a atteindre avant). Ce SOC n'est PAS ecrete a 100 % : au-dela, meme une batterie pleine ne passe pas, et le manque est publie comme storage_shortfall_kwh — c'est le chiffre du dimensionnement. 5b — Workflow "EVCS Energy Profiles Daily V1" (N6UaB4ZozsbRPrFb), actif, 03:30 : lit /api/history/period sur 30 jours glissants et republie les quatre CSV (conso p50 et p80, enveloppe de production, taux de censure). CONSUMPTION_PROFILE devient DAILY_PROFILES et sait desormais decoder la reponse brute de l'API en plus des series deja pretes. Piege trouve : sans end_time, /api/history/period ne renvoie qu'UNE journee, silencieusement. Mes premieres mesures "sur 3 jours" n'en couvraient qu'une. Validation croisee : profils calcules depuis l'API REST (etats bruts, 11 j) contre profils calcules depuis les statistiques long terme (28 j) — 0,20 kWh/h d'ecart absolu moyen, par deux chemins de donnees independants. BUILD_HA_CALLS_PROFILES refuse d'ecraser les profils en place si l'historique ne couvre pas les 24 heures. Meme garantie de perimetre que le bilan. Suite a 27 scenarios. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ --- docs/CHANTIER_BILAN_ENERGETIQUE.md | 25 ++- src/energy-balance/BALANCE.js | 75 ++++++++ src/energy-balance/BUILD_HA_CALLS.js | 20 +- src/energy-balance/BUILD_HA_CALLS_PROFILES.js | 62 +++++++ ...NSUMPTION_PROFILE.js => DAILY_PROFILES.js} | 123 ++++++++++++- tests/energy-balance/run.mjs | 14 +- tests/energy-balance/scenarios-balance.mjs | 32 ++++ tests/energy-balance/scenarios-profile.mjs | 8 +- tests/energy-balance/scenarios-shadow.mjs | 29 +++ tools/gen-energy-profiles-workflow.mjs | 115 ++++++++++++ tools/lib-node-map.mjs | 9 + tools/n8n-push.mjs | 3 +- workflows/evcs-energy-balance-v1.json | 4 +- workflows/evcs-energy-profiles-daily-v1.json | 171 ++++++++++++++++++ 14 files changed, 668 insertions(+), 22 deletions(-) create mode 100644 src/energy-balance/BUILD_HA_CALLS_PROFILES.js rename src/energy-balance/{CONSUMPTION_PROFILE.js => DAILY_PROFILES.js} (52%) create mode 100644 tools/gen-energy-profiles-workflow.mjs create mode 100644 workflows/evcs-energy-profiles-daily-v1.json diff --git a/docs/CHANTIER_BILAN_ENERGETIQUE.md b/docs/CHANTIER_BILAN_ENERGETIQUE.md index f62e37a..0048dbc 100644 --- a/docs/CHANTIER_BILAN_ENERGETIQUE.md +++ b/docs/CHANTIER_BILAN_ENERGETIQUE.md @@ -188,7 +188,30 @@ calcul en saison basse. Suite portée à **21 scénarios**. -- [ ] Étape 5 — publication dans n8n (workflow + helpers), puis dashboard. +- [x] **Étape 5 — publication en mode ombre** (24/08). Workflow n8n + **EVCS Energy Balance V1** (`QoSfPeyGq35orF1K`), actif, cycle 5 min. + Périmètre d'écriture garanti par test de mutation : l'injection d'un + `turn_on` sur `switch.edf_general_ok_switch` fait rougir la suite. + Kill-switch `input_boolean.evcs_balance_enabled`. +- [x] **Étape 5b — recalcul quotidien des profils** (24/08). Workflow + **EVCS Energy Profiles Daily V1** (`N6UaB4ZozsbRPrFb`), actif, 03:30. + Lit `/api/history/period` et republie les quatre CSV. + ⚠️ Piège trouvé : **sans `end_time`, l'API ne renvoie qu'UNE journée**, en + silence. Mes trois premières mesures « sur 3 jours » n'en couvraient qu'une. + Refus explicite d'écraser les profils si l'historique ne couvre pas les 24 h. + **Validation croisée** : profils calculés depuis l'API REST (états bruts, + 11 jours) contre profils calculés depuis les statistiques long terme + (28 jours) → **0,20 kWh/h d'écart absolu moyen**, deux chemins de données + totalement indépendants. +- [x] **Étape 5c — prochaine fenêtre de déficit** (24/08). Le bilan ne + répondait qu'à « combien jusqu'à ce que le solaire prenne le relais » : à + 15 h la réponse est zéro, exacte mais inutile, car le déficit qui approche + est celui de la nuit. Ajout de la prochaine traversée déficitaire — heure de + début, énergie, SOC à atteindre avant. **Ce SOC n'est volontairement pas + écrêté à 100 %** : au-delà, même une batterie pleine ne passe pas, et l'écart + est publié comme `storage_shortfall_kwh`. C'est le chiffre du dimensionnement + du stockage. +- [ ] Étape 6 — dashboard, après quelques jours d'observation. ## Backlog diff --git a/src/energy-balance/BALANCE.js b/src/energy-balance/BALANCE.js index 6ecedc2..8118e6c 100644 --- a/src/energy-balance/BALANCE.js +++ b/src/energy-balance/BALANCE.js @@ -140,6 +140,59 @@ function coverage(slots) { const typical = coverage(buildSlots(consumptionP50)); const prudent = coverage(buildSlots(consumptionP80)); +// --------------------------------------------------------------------- +// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026) +// +// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire +// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est +// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à +// cette heure-là, car le déficit qui approche est celui de la nuit. +// +// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en +// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint +// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et +// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil. +// +// Profil PRUDENT : contrairement à la décision réseau immédiate, cette +// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit +// tombée, il n'y a plus de solaire pour rattraper une sous-estimation. +// --------------------------------------------------------------------- +function nextDeficitWindow(slots) { + const firstSurplus = slots.findIndex(s => s.netKwh > 0); + if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' }; + + const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0); + if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' }; + + let end = slots.findIndex((s, i) => i > start && s.netKwh > 0); + if (end < 0) end = slots.length; + + const energyKwh = slots.slice(start, end) + .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0); + + // Surplus encore récupérable d'ici là : de quoi remplir, ou pas. + const surplusBeforeKwh = slots.slice(0, start) + .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0); + + return { + found: true, + startHour: slots[start].hour, + startMs: slots[start].startMs, + hoursFromNow: start, + endHour: end < slots.length ? slots[end].hour : null, + complete: end < slots.length, + energyKwh, + // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie + // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est + // exactement le signal de sous-dimensionnement du stockage — l'écrêter le + // masquerait. + requiredSocAtStartPct: socFloorPct + (energyKwh / usableCapacityKwh) * 100, + surplusBeforeKwh, + }; +} + +const nextDeficit = nextDeficitWindow(prudent.slots); + const slots = typical.slots; const crossingFound = typical.found; const crossingIndex = typical.crossingIndex; @@ -199,6 +252,28 @@ return [{ }, }, + // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire. + nextDeficit: nextDeficit.found ? { + found: true, + startHour: nextDeficit.startHour, + hoursFromNow: nextDeficit.hoursFromNow, + endHour: nextDeficit.endHour, + complete: nextDeficit.complete, + energyKwh: round2(nextDeficit.energyKwh), + requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct), + surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh), + // Franchissable avec le stockage actuel, batterie pleine ? + feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100, + // Si non : capacité utile manquante. C'est le chiffre du dimensionnement. + storageShortfallKwh: Math.max( + 0, + ((nextDeficit.requiredSocAtStartPct - 100) / 100) * usableCapacityKwh + ), + reachable: nextDeficit.surplusBeforeKwh >= 0 + && (socPct + (nextDeficit.surplusBeforeKwh / usableCapacityKwh) * 100) + >= nextDeficit.requiredSocAtStartPct, + } : { found: false, reason: nextDeficit.reason }, + vehicle: { surplusAvailableKwh: round2(surplusAfterCrossingKwh), }, diff --git a/src/energy-balance/BUILD_HA_CALLS.js b/src/energy-balance/BUILD_HA_CALLS.js index ecc198b..762566b 100644 --- a/src/energy-balance/BUILD_HA_CALLS.js +++ b/src/energy-balance/BUILD_HA_CALLS.js @@ -72,13 +72,31 @@ setNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh); setNumber('input_number.evcs_balance_grid_energy_needed_kwh', c.gridEnergyNeededKwh); setNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surplusAvailableKwh); setBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded); + +// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas. +const nd = balance.nextDeficit || { found: false }; +if (nd.found) { + setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh); + setNumber('input_number.evcs_balance_next_deficit_required_soc', nd.requiredSocAtStartPct); + setNumber('input_number.evcs_balance_storage_shortfall_kwh', nd.storageShortfallKwh); + setText('input_text.evcs_balance_next_deficit_window', + `${String(nd.startHour).padStart(2, '0')} h → ${nd.endHour == null ? '?' : String(nd.endHour).padStart(2, '0') + ' h'}` + + (nd.feasibleWithCurrentStorage ? '' : ' (stockage insuffisant)')); +} else { + setText('input_text.evcs_balance_next_deficit_window', + nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\'horizon'); +} setText('input_text.evcs_balance_crossing_hour', balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\'horizon'); +const ndText = nd.found + ? ` | prochaine traversée ${nd.energyKwh} kWh dès ${nd.startHour} h → SOC requis ${nd.requiredSocAtStartPct}%${nd.feasibleWithCurrentStorage ? '' : ` (manque ${nd.storageShortfallKwh} kWh de stockage)`}` + : ''; + const reason = balance.crossing.found ? `Croisement à ${balance.crossing.hour} h, ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}% (actuel ${c.currentSocPct}%). ${c.gridNeeded ? `Réseau : ${c.gridEnergyNeededKwh} kWh.` : 'Réseau non nécessaire.'}` : `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`; -setText('input_text.evcs_balance_reason', reason); +setText('input_text.evcs_balance_reason', reason + ndText); // Trace longue durée, pour comparer jour après jour le seuil calculé au seuil // fixe encore en vigueur — c'est cette comparaison qui décidera du câblage. diff --git a/src/energy-balance/BUILD_HA_CALLS_PROFILES.js b/src/energy-balance/BUILD_HA_CALLS_PROFILES.js new file mode 100644 index 0000000..8aecbb8 --- /dev/null +++ b/src/energy-balance/BUILD_HA_CALLS_PROFILES.js @@ -0,0 +1,62 @@ +// EVCS Energy Balance — BUILD_HA_CALLS_PROFILES +// ===================================================================== +// Publie les quatre profils recalculés dans leurs helpers input_text. +// +// MÊME GARANTIE DE PÉRIMÈTRE que le workflow de bilan : ce nœud n'écrit que +// vers des entités `evcs_balance_*`, plus une trace logbook. La suite de tests +// échoue si une écriture en sort. +// +// Refus explicite de publier un profil douteux : si l'historique est trop court +// pour couvrir les 24 heures, on n'écrase PAS les profils en place. Mieux vaut +// un profil un peu ancien qu'un profil creux. +// ===================================================================== + +const profiles = $input.first().json; + +function stampParis() { + const p = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }).formatToParts(new Date()); + const g = t => p.find(x => x.type === t)?.value || ''; + return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`; +} + +const calls = []; +function setText(entity, value) { + calls.push({ json: { + domain: 'input_text', service: 'set_value', + data: { entity_id: entity, value: (value ?? '').toString().slice(0, 250) }, + } }); +} + +if (!profiles.complete) { + setText('input_text.evcs_balance_status', + `profils NON mis à jour : ${profiles.quality.unreliableHours.length} h sans assez de jours observés`); + calls.push({ json: { + domain: 'logbook', service: 'log', + data: { + name: 'EVCS Bilan — profils quotidiens', + message: `Recalcul refusé : historique insuffisant (${profiles.days} jour(s), heures creuses : ${profiles.quality.unreliableHours.join(', ')}). Profils précédents conservés.`, + entity_id: 'input_text.evcs_balance_status', + }, + } }); + return calls; +} + +setText('input_text.evcs_balance_consumption_p50', profiles.csv.p50); +setText('input_text.evcs_balance_consumption_p80', profiles.csv.p80); +setText('input_text.evcs_balance_envelope', profiles.csv.envelope); +setText('input_text.evcs_balance_censorship_rate', profiles.csv.censorshipRate); +setText('input_text.evcs_balance_last_run', stampParis()); + +calls.push({ json: { + domain: 'logbook', service: 'log', + data: { + name: 'EVCS Bilan — profils quotidiens', + message: `Profils recalculés sur ${profiles.days} jours (${profiles.quality.hoursUsed} h de consommation, ${profiles.quality.pvHoursUsed} h de production, ${profiles.quality.countersReset} remise(s) à zéro écartée(s)). Consommation typique ${profiles.totals.typicalDailyKwh} kWh/j, prudente ${profiles.totals.prudentDailyKwh} kWh/j. Production démontrée ${profiles.production.demonstratedDailyKwh} kWh/j, ${profiles.production.censoredDayHours} heure(s) de jour censurée(s).`, + entity_id: 'input_text.evcs_balance_consumption_p50', + }, +} }); + +return calls; diff --git a/src/energy-balance/CONSUMPTION_PROFILE.js b/src/energy-balance/DAILY_PROFILES.js similarity index 52% rename from src/energy-balance/CONSUMPTION_PROFILE.js rename to src/energy-balance/DAILY_PROFILES.js index d204da2..a7aba36 100644 --- a/src/energy-balance/CONSUMPTION_PROFILE.js +++ b/src/energy-balance/DAILY_PROFILES.js @@ -1,7 +1,13 @@ -// EVCS Energy Balance — CONSUMPTION_PROFILE +// EVCS Energy Balance — DAILY_PROFILES // ===================================================================== -// Rôle : produire le profil horaire de consommation de la maison, MESURÉ. -// Une valeur par heure de la journée, calculée sur l'historique disponible. +// Rôle : recalculer une fois par jour, à partir de l'historique Home Assistant, +// les quatre profils horaires dont le bilan a besoin : +// 1. consommation typique (p50) et prudente (p80), hors véhicule ; +// 2. enveloppe de production démontrée (production maximale observée) ; +// 3. taux de censure par heure (part des heures où la batterie était pleine). +// +// Tous sont publiés en CSV compact de 24 valeurs dans des helpers input_text +// (moins de 255 caractères), relus à chaque cycle par le bilan. // // « La consommation se lit, elle ne se devine pas. » Contrairement à la // production, la mesure de consommation n'est PAS censurée : elle ne dépend @@ -20,9 +26,10 @@ // percentiles. Le p80 est la valeur prudente (« une journée sur cinq consomme // plus que ça »), le p50 la valeur typique. // -// Entrée : séries de compteurs d'énergie cumulés, au format /api/history de HA -// ({ ts, state }), non nécessairement réguliers. -// Sortie : profile[24] + un CSV compact destiné à un helper input_text (255 car.) +// Entrée : réponse brute de /api/history/period (tableau de tableaux, le +// premier point de chaque liste portant son entity_id), reçue soit +// décodée, soit sous forme de chaîne dans `data` selon le réglage du +// nœud HTTP. Les tests peuvent fournir directement `series`. // ===================================================================== function num(v, fallback = NaN) { @@ -34,10 +41,21 @@ function round2(v) { return Number.isFinite(v) ? Math.round(v * 100) / 100 : null; } -const input = $input.first().json; +const raw = $input.first().json; const CFG = { TIMEZONE: 'Europe/Paris', + // Correspondance entité → rôle. Les seules entités que ce nœud sait lire. + ENTITIES: { + 'sensor.airtria_mpii_v1_energy_from_inverter_to_ac_out': 'inverter_to_ac_out', + 'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out': 'ac_in_to_ac_out', + 'sensor.evcs_hq2239dnt7p_total_energy': 'evcs_total', + 'sensor.gx_device_pv_energy': 'pv_energy', + 'sensor.victron_battery_soc': 'soc', + }, + // SOC à partir duquel l'heure est comptée comme censurée : la batterie n'est + // plus un puits, la production mesurée ne dit plus rien de la ressource. + CENSORSHIP_SOC_PCT: 90, // Un delta horaire au-delà de cette valeur n'est pas une consommation // plausible sur cette installation : c'est un artefact de compteur. MAX_PLAUSIBLE_HOURLY_KWH: 50, @@ -84,9 +102,59 @@ function deltaSeries(buckets) { return { deltas, resets }; } -const fromInverter = deltaSeries(bucketHourly(input.series?.inverter_to_ac_out)); -const fromGrid = deltaSeries(bucketHourly(input.series?.ac_in_to_ac_out)); -const fromEvcs = deltaSeries(bucketHourly(input.series?.evcs_total)); +// --------------------------------------------------------------------- +// 0. Normalisation de l'entrée : réponse /api/history OU séries déjà prêtes. +// --------------------------------------------------------------------- +function decode(rawInput) { + if (rawInput && rawInput.series) return rawInput.series; + + let history = rawInput && rawInput.history ? rawInput.history : rawInput; + if (history && typeof history.data === 'string') { + try { history = JSON.parse(history.data); } catch { history = null; } + } else if (history && history.data) { + history = history.data; + } + if (!Array.isArray(history)) return {}; + + const series = {}; + for (const list of history) { + if (!Array.isArray(list) || !list.length) continue; + const role = CFG.ENTITIES[list[0].entity_id]; + if (!role) continue; + // /api/history en mode minimal ne répète pas entity_id : chaque point porte + // seulement `state` et `last_changed`. + series[role] = list.map(p => ({ ts: p.last_changed || p.last_updated, state: p.state })); + } + return series; +} + +const series = decode(raw); +const input = { series, nowMs: raw && raw.nowMs }; + +const fromInverter = deltaSeries(bucketHourly(series.inverter_to_ac_out)); +const fromGrid = deltaSeries(bucketHourly(series.ac_in_to_ac_out)); +const fromEvcs = deltaSeries(bucketHourly(series.evcs_total)); +const fromPv = deltaSeries(bucketHourly(series.pv_energy)); + +// SOC : moyenne des relevés de chaque heure. Sert uniquement à savoir si +// l'heure était censurée, pas à un calcul d'énergie. +function hourlyMean(seriesPoints) { + const acc = new Map(); + for (const point of seriesPoints || []) { + const ms = Date.parse(point.ts ?? point.last_changed ?? point.last_updated); + const value = num(point.state ?? point.value); + if (!Number.isFinite(ms) || !Number.isFinite(value)) continue; + const h = hourBucketMs(ms); + const prev = acc.get(h) || { sum: 0, n: 0 }; + prev.sum += value; prev.n += 1; + acc.set(h, prev); + } + const out = new Map(); + for (const [h, v] of acc) out.set(h, v.sum / v.n); + return out; +} + +const socHourly = hourlyMean(series.soc); // --------------------------------------------------------------------- // 2. Consommation maison par heure, hors véhicule. @@ -112,6 +180,30 @@ for (const [ms, inverterKwh] of fromInverter.deltas) { days.add(new Date(ms).toISOString().slice(0, 10)); } +// --------------------------------------------------------------------- +// 2bis. Enveloppe de production et taux de censure. +// +// L'enveloppe est le maximum horaire jamais observé. C'est une borne sûre : +// une heure censurée ne peut pas produire un maximum, donc le maximum vient +// forcément d'une heure où le système avait de la place. +// --------------------------------------------------------------------- +const envelope = Array.from({ length: 24 }, () => 0); +const censusTotal = Array.from({ length: 24 }, () => 0); +const censusCensored = Array.from({ length: 24 }, () => 0); + +for (const [ms, pvKwh] of fromPv.deltas) { + const h = hourOfDay(ms); + if (pvKwh > envelope[h]) envelope[h] = pvKwh; + const soc = socHourly.get(ms); + if (soc === undefined) continue; + censusTotal[h]++; + if (soc >= CFG.CENSORSHIP_SOC_PCT) censusCensored[h]++; +} + +// Sans échantillon pour une heure, on suppose la pire hypothèse (censurée) : +// le bilan signalera une forme minorée plutôt que de la croire fiable. +const censorshipRate = censusTotal.map((n, h) => (n ? censusCensored[h] / n : 1)); + // --------------------------------------------------------------------- // 3. Percentiles par heure de la journée. // --------------------------------------------------------------------- @@ -149,6 +241,15 @@ return [{ csv: { p50: csv('p50'), p80: csv('p80'), + envelope: envelope.map(v => v.toFixed(2)).join(','), + censorshipRate: censorshipRate.map(v => v.toFixed(2)).join(','), + }, + + production: { + envelopeKwh: envelope.map(round2), + censorshipRate: censorshipRate.map(v => Math.round(v * 100) / 100), + demonstratedDailyKwh: round2(envelope.reduce((a, b) => a + b, 0)), + censoredDayHours: censorshipRate.filter((r, h) => envelope[h] > 0.2 && r > 0.25).length, }, totals: { typicalDailyKwh: dailyTotal('p50'), @@ -156,6 +257,8 @@ return [{ }, quality: { hoursUsed: fromInverter.deltas.size, + pvHoursUsed: fromPv.deltas.size, + socHoursUsed: socHourly.size, hoursWithEvcs: evcsHours, countersReset: fromInverter.resets + fromGrid.resets + fromEvcs.resets, unreliableHours: profile.filter(h => !h.reliable).map(h => h.hour), diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs index 2f7587f..a4a8df5 100644 --- a/tests/energy-balance/run.mjs +++ b/tests/energy-balance/run.mjs @@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'; import { scenarios as censorshipScenarios } from './scenarios.mjs'; import { profileScenarios, resourceScenarios } from './scenarios-profile.mjs'; import { balanceScenarios } from './scenarios-balance.mjs'; -import { shadowScenarios } from './scenarios-shadow.mjs'; +import { shadowScenarios, profilesShadowScenarios } from './scenarios-shadow.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -36,7 +36,7 @@ function runNode(nodeName, snapshot) { }; vm.createContext(sandbox); const out = vm.runInContext(`(function(){ ${codeCache.get(nodeName)} })()`, sandbox, { timeout: 15000 }); - return nodeName === 'BUILD_HA_CALLS' ? out : out[0].json; + return nodeName.startsWith('BUILD_HA_CALLS') ? out : out[0].json; } const SUMMARIZE = { @@ -47,7 +47,7 @@ const SUMMARIZE = { healthOk: o.health.ok, alerts: o.alerts.join('|'), }), - CONSUMPTION_PROFILE: o => ({ + DAILY_PROFILES: o => ({ days: o.days, complete: o.complete, hours: o.profile.length, @@ -72,6 +72,11 @@ const SUMMARIZE = { gridNeeded: o.coverage.gridNeeded, prudentRequiredSocPct: o.coverage.prudent.requiredSocPct, deficitTrustworthy: o.confidence.deficitTrustworthy, + nextDeficitFound: o.nextDeficit.found, + nextDeficitStartHour: o.nextDeficit.found ? o.nextDeficit.startHour : null, + nextDeficitEnergyKwh: o.nextDeficit.found ? o.nextDeficit.energyKwh : null, + nextDeficitRequiredSocPct: o.nextDeficit.found ? o.nextDeficit.requiredSocAtStartPct : null, + storageShortfallKwh: o.nextDeficit.found ? Math.round(o.nextDeficit.storageShortfallKwh * 100) / 100 : null, }) : ({ usable: false, missing: o.missing.join(',') }), }; @@ -86,8 +91,10 @@ SUMMARIZE.BUILD_HA_CALLS = (calls) => { wroteRequiredSoc: entities.includes('input_number.evcs_balance_required_soc'), wroteGridNeeded: entities.includes('input_boolean.evcs_balance_grid_needed'), hasLogbook: calls.some(c => c.json.domain === 'logbook'), + wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'), }; }; +SUMMARIZE.BUILD_HA_CALLS_PROFILES = SUMMARIZE.BUILD_HA_CALLS; const all = [ ...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })), @@ -95,6 +102,7 @@ const all = [ ...resourceScenarios, ...balanceScenarios, ...shadowScenarios, + ...profilesShadowScenarios, ]; let failed = 0; diff --git a/tests/energy-balance/scenarios-balance.mjs b/tests/energy-balance/scenarios-balance.mjs index 1c93907..b531af2 100644 --- a/tests/energy-balance/scenarios-balance.mjs +++ b/tests/energy-balance/scenarios-balance.mjs @@ -97,6 +97,38 @@ export const balanceScenarios = [ gridNeeded: false, }, }, + { + node: 'BALANCE', + id: 'bilan-prochaine-fenetre-deficit', + label: 'Projection à 15 h : le solaire couvre déjà, mais la nuit qui vient doit être annoncée', + snapshot: { + nowMs: Date.parse('2026-08-24T15:30:00+02:00'), + consumption_p50: P50, + consumption_p80: P80, + horizon: horizonFrom('2026-08-24T15:30:00+02:00', 24, { + ...PROD_24_08, + 15: 3.00, 16: 2.80, 17: 2.60, 18: 2.40, 19: 1.20, 20: 0.20, + // lendemain matin + 7: 1.50, 8: 3.00, 9: 5.00, + }), + soc_percent: 95, + usable_capacity_kwh: 15, + soc_floor_percent: 22, + }, + expect: { + usable: true, + // fenêtre en cours : le solaire couvre, rien à franchir + gridNeeded: false, + // mais la nuit qui vient doit être chiffrée + nextDeficitFound: true, + nextDeficitStartHour: 19, + nextDeficitEnergyKwh: 12.77, + // Au-dessus de 100 : même pleine, la batterie actuelle ne passe pas la + // nuit sur le profil prudent. C'est le chiffre du dimensionnement. + nextDeficitRequiredSocPct: 107.1, + storageShortfallKwh: 1.07, + }, + }, { node: 'BALANCE', id: 'bilan-entrees-manquantes', diff --git a/tests/energy-balance/scenarios-profile.mjs b/tests/energy-balance/scenarios-profile.mjs index 7132190..45d78db 100644 --- a/tests/energy-balance/scenarios-profile.mjs +++ b/tests/energy-balance/scenarios-profile.mjs @@ -31,7 +31,7 @@ function syntheticSeries({ days = 10, houseKwhPerHour = 1, evcsKwhAt14h = 3 } = export const profileScenarios = [ { - node: 'CONSUMPTION_PROFILE', + node: 'DAILY_PROFILES', id: 'profil-historique-reel', label: '28 jours réels : profil complet sur 24 heures, aucun compteur remis à zéro', snapshot: { series: history.series, nowMs: NOW }, @@ -45,7 +45,7 @@ export const profileScenarios = [ }, }, { - node: 'CONSUMPTION_PROFILE', + node: 'DAILY_PROFILES', id: 'evcs-retire-du-profil', label: 'La charge du véhicule est retirée : le profil reste plat à 1 kWh/h, y compris à 14 h', snapshot: { series: syntheticSeries(), nowMs: NOW }, @@ -56,7 +56,7 @@ export const profileScenarios = [ }, }, { - node: 'CONSUMPTION_PROFILE', + node: 'DAILY_PROFILES', id: 'compteur-remis-a-zero', label: 'Remise à zéro du compteur (redémarrage HA) : l\'échantillon est jeté, pas inventé', snapshot: (() => { @@ -74,7 +74,7 @@ export const profileScenarios = [ }, }, { - node: 'CONSUMPTION_PROFILE', + node: 'DAILY_PROFILES', id: 'historique-trop-court', label: '3 jours seulement : les heures sont marquées non fiables plutôt que publiées en silence', snapshot: { series: syntheticSeries({ days: 3 }), nowMs: NOW }, diff --git a/tests/energy-balance/scenarios-shadow.mjs b/tests/energy-balance/scenarios-shadow.mjs index cf15721..3502303 100644 --- a/tests/energy-balance/scenarios-shadow.mjs +++ b/tests/energy-balance/scenarios-shadow.mjs @@ -79,3 +79,32 @@ export const shadowScenarios = [ }, }, ]; + +// --- BUILD_HA_CALLS_PROFILES (workflow quotidien) ------------------------ +const PROFILES_OK = { + complete: true, + days: 28, + csv: { p50: '1,'.repeat(23) + '1', p80: '2,'.repeat(23) + '2', + envelope: '3,'.repeat(23) + '3', censorshipRate: '0,'.repeat(23) + '0' }, + totals: { typicalDailyKwh: 31.9, prudentDailyKwh: 42.8 }, + production: { demonstratedDailyKwh: 74.4, censoredDayHours: 8 }, + quality: { hoursUsed: 639, pvHoursUsed: 620, countersReset: 0, unreliableHours: [] }, +}; + +export const profilesShadowScenarios = [ + { + node: 'BUILD_HA_CALLS_PROFILES', + id: 'profils-perimetre-respecte', + label: 'Publication des profils : périmètre evcs_balance_* respecté', + snapshot: PROFILES_OK, + expect: { outOfScope: '', callCount: 6, hasLogbook: true }, + }, + { + node: 'BUILD_HA_CALLS_PROFILES', + id: 'profils-historique-insuffisant', + label: 'Historique trop court : les profils en place ne sont PAS écrasés', + snapshot: { ...PROFILES_OK, complete: false, days: 2, + quality: { ...PROFILES_OK.quality, unreliableHours: [3, 4, 5] } }, + expect: { outOfScope: '', wroteProfiles: false }, + }, +]; diff --git a/tools/gen-energy-profiles-workflow.mjs b/tools/gen-energy-profiles-workflow.mjs new file mode 100644 index 0000000..9ad6a53 --- /dev/null +++ b/tools/gen-energy-profiles-workflow.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// Génère le squelette du workflow "EVCS Energy Profiles Daily V1" : recalcul +// quotidien des profils horaires (consommation, enveloppe de production, taux +// de censure) à partir de l'historique Home Assistant. +// Le CODE des nodes est injecté depuis src/energy-balance/ par n8n-build.mjs. + +import fs from 'node:fs'; + +const HA_CRED = { id: 'uv3sknYYigxBkaVN', name: 'Bearer Auth account 2' }; +const HA_URL = 'http://10.10.10.17:8123'; + +const ENTITIES = [ + 'sensor.airtria_mpii_v1_energy_from_inverter_to_ac_out', + 'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out', + 'sensor.evcs_hq2239dnt7p_total_energy', + 'sensor.gx_device_pv_energy', + 'sensor.victron_battery_soc', +].join(','); + +// Fenêtre demandée : 30 jours. L'API ne renvoie que ce que la rétention +// conserve (10 jours au 24/08, 90 une fois la nouvelle rétention accumulée), +// donc la fenêtre s'élargira d'elle-même sans rien changer ici. +// ⚠️ `end_time` est OBLIGATOIRE : sans lui, /api/history/period ne renvoie +// qu'UNE SEULE journée, silencieusement. +const HISTORY_URL = + "={{ '" + HA_URL + "/api/history/period/' " + + '+ new Date(Date.now() - 30 * 86400000).toISOString() ' + + "+ '?end_time=' + encodeURIComponent(new Date().toISOString()) " + + "+ '&filter_entity_id=" + ENTITIES + "' " + + "+ '&minimal_response&significant_changes_only=0' }}"; + +const nodes = [ + { + name: 'Schedule Daily', + type: 'n8n-nodes-base.scheduleTrigger', + typeVersion: 1.2, + position: [0, 0], + parameters: { rule: { interval: [{ field: 'days', triggerAtHour: 3, triggerAtMinute: 30 }] } }, + }, + { + name: 'HA History', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [220, 0], + parameters: { + method: 'GET', + url: HISTORY_URL, + authentication: 'genericCredentialType', + genericAuthType: 'httpBearerAuth', + options: { + timeout: 120000, + response: { response: { responseFormat: 'text' } }, + }, + }, + credentials: { httpBearerAuth: HA_CRED }, + }, + { + name: 'DAILY_PROFILES', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [440, 0], + parameters: { jsCode: '// injecté depuis src/energy-balance/DAILY_PROFILES.js' }, + }, + { + name: 'BUILD_HA_CALLS_PROFILES', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [660, 0], + parameters: { jsCode: '// injecté depuis src/energy-balance/BUILD_HA_CALLS_PROFILES.js' }, + }, + { + name: 'Execute HA Service Profiles', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [880, 0], + parameters: { + method: 'POST', + url: `={{ '${HA_URL}/api/services/' + $json.domain + '/' + $json.service }}`, + authentication: 'genericCredentialType', + genericAuthType: 'httpBearerAuth', + sendHeaders: true, + headerParameters: { + parameters: [ + { name: 'Content-Type', value: 'application/json' }, + { name: 'accept', value: 'application/json' }, + ], + }, + sendBody: true, + specifyBody: 'json', + jsonBody: '={{ $json.data }}', + options: { timeout: 15000 }, + }, + credentials: { httpBearerAuth: HA_CRED }, + onError: 'continueRegularOutput', + }, +]; + +const connections = { + 'Schedule Daily': { main: [[{ node: 'HA History', type: 'main', index: 0 }]] }, + 'HA History': { main: [[{ node: 'DAILY_PROFILES', type: 'main', index: 0 }]] }, + DAILY_PROFILES: { main: [[{ node: 'BUILD_HA_CALLS_PROFILES', type: 'main', index: 0 }]] }, + BUILD_HA_CALLS_PROFILES: { main: [[{ node: 'Execute HA Service Profiles', type: 'main', index: 0 }]] }, +}; + +const workflow = { + id: 'N6UaB4ZozsbRPrFb', + name: 'EVCS Energy Profiles Daily V1', + nodes, + connections, + settings: { executionOrder: 'v1', callerPolicy: 'workflowsFromSameOwner' }, + staticData: null, +}; + +fs.writeFileSync('workflows/evcs-energy-profiles-daily-v1.json', JSON.stringify(workflow, null, 2) + '\n'); +console.log(`Squelette écrit : workflows/evcs-energy-profiles-daily-v1.json (${nodes.length} nodes)`); diff --git a/tools/lib-node-map.mjs b/tools/lib-node-map.mjs index 01daaec..2a6fa27 100644 --- a/tools/lib-node-map.mjs +++ b/tools/lib-node-map.mjs @@ -41,6 +41,15 @@ export const WORKFLOWS = [ { file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Balance', kind: 'template' }, ], }, + { + id: 'energy-profiles', + workflowFile: 'workflows/evcs-energy-profiles-daily-v1.json', + srcDir: 'src/energy-balance', + nodes: [ + { file: 'DAILY_PROFILES.js', node: 'DAILY_PROFILES', kind: 'jsCode' }, + { file: 'BUILD_HA_CALLS_PROFILES.js', node: 'BUILD_HA_CALLS_PROFILES', kind: 'jsCode' }, + ], + }, ]; // Rétro-compatibilité (outils historiques mono-workflow) diff --git a/tools/n8n-push.mjs b/tools/n8n-push.mjs index 3b02f05..22927fc 100644 --- a/tools/n8n-push.mjs +++ b/tools/n8n-push.mjs @@ -109,7 +109,8 @@ console.log('Syntaxe OK sur tous les nodes jsCode.'); const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json'; const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json'; const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json'; -const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json'; +const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json' + || path.basename(INPUT) === 'evcs-energy-profiles-daily-v1.json'; const skipGolden = process.argv.includes('--skip-golden'); const testSuite = isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' : diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index 78d70c0..a44bda7 100644 --- a/workflows/evcs-energy-balance-v1.json +++ b/workflows/evcs-energy-balance-v1.json @@ -111,7 +111,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 30,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\nfunction buildSlots(consumption) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0));\n const consumptionKwh = consumption[hour] ?? 0;\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\nfunction coverage(slots) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n const socNeededPct = (energyToCoverKwh / usableCapacityKwh) * 100;\n const requiredSocPct = Math.min(100, socFloorPct + socNeededPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? (Math.abs(socMarginPct) / 100) * usableCapacityKwh : 0,\n };\n}\n\nconst typical = coverage(buildSlots(consumptionP50));\nconst prudent = coverage(buildSlots(consumptionP80));\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" + "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 30,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\nfunction buildSlots(consumption) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0));\n const consumptionKwh = consumption[hour] ?? 0;\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\nfunction coverage(slots) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n const socNeededPct = (energyToCoverKwh / usableCapacityKwh) * 100;\n const requiredSocPct = Math.min(100, socFloorPct + socNeededPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? (Math.abs(socMarginPct) / 100) * usableCapacityKwh : 0,\n };\n}\n\nconst typical = coverage(buildSlots(consumptionP50));\nconst prudent = coverage(buildSlots(consumptionP80));\n\n// ---------------------------------------------------------------------\n// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)\n//\n// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire\n// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est\n// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à\n// cette heure-là, car le déficit qui approche est celui de la nuit.\n//\n// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en\n// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint\n// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et\n// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.\n//\n// Profil PRUDENT : contrairement à la décision réseau immédiate, cette\n// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit\n// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.\n// ---------------------------------------------------------------------\nfunction nextDeficitWindow(slots) {\n const firstSurplus = slots.findIndex(s => s.netKwh > 0);\n if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };\n\n const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);\n if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };\n\n let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);\n if (end < 0) end = slots.length;\n\n const energyKwh = slots.slice(start, end)\n .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n\n // Surplus encore récupérable d'ici là : de quoi remplir, ou pas.\n const surplusBeforeKwh = slots.slice(0, start)\n .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);\n\n return {\n found: true,\n startHour: slots[start].hour,\n startMs: slots[start].startMs,\n hoursFromNow: start,\n endHour: end < slots.length ? slots[end].hour : null,\n complete: end < slots.length,\n energyKwh,\n // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie\n // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est\n // exactement le signal de sous-dimensionnement du stockage — l'écrêter le\n // masquerait.\n requiredSocAtStartPct: socFloorPct + (energyKwh / usableCapacityKwh) * 100,\n surplusBeforeKwh,\n };\n}\n\nconst nextDeficit = nextDeficitWindow(prudent.slots);\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.\n nextDeficit: nextDeficit.found ? {\n found: true,\n startHour: nextDeficit.startHour,\n hoursFromNow: nextDeficit.hoursFromNow,\n endHour: nextDeficit.endHour,\n complete: nextDeficit.complete,\n energyKwh: round2(nextDeficit.energyKwh),\n requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),\n surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),\n // Franchissable avec le stockage actuel, batterie pleine ?\n feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,\n // Si non : capacité utile manquante. C'est le chiffre du dimensionnement.\n storageShortfallKwh: Math.max(\n 0,\n ((nextDeficit.requiredSocAtStartPct - 100) / 100) * usableCapacityKwh\n ),\n reachable: nextDeficit.surplusBeforeKwh >= 0\n && (socPct + (nextDeficit.surplusBeforeKwh / usableCapacityKwh) * 100)\n >= nextDeficit.requiredSocAtStartPct,\n } : { found: false, reason: nextDeficit.reason },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" } }, { @@ -123,7 +123,7 @@ 120 ], "parameters": { - "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS\n// =====================================================================\n// GARANTIE MODE OMBRE : ce nœud ne produit QUE des écritures vers des entités\n// dont le nom commence par `evcs_balance_`, plus une trace logbook. Aucun\n// switch, aucune consigne de borne, aucune stratégie, aucun helper de takeover.\n// Le bilan observe et publie ; il ne pilote rien tant que le câblage réel n'a\n// pas été décidé explicitement.\n//\n// Cette garantie est VÉRIFIÉE par tests/energy-balance/ : la suite échoue si\n// une écriture sort de ce périmètre. Ne pas ajouter d'autres entités ici sans\n// changer le test en connaissance de cause.\n// =====================================================================\n\nconst balance = $input.first().json;\nconst censorship = $('CENSORSHIP').first().json;\nconst rawSnapshot = $('HA Snapshot Balance').first().json;\nconst snapshot = (rawSnapshot && typeof rawSnapshot.data === 'string')\n ? (() => { try { return JSON.parse(rawSnapshot.data); } catch { return rawSnapshot; } })()\n : ((rawSnapshot && rawSnapshot.data) || rawSnapshot);\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\n\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n if (!Number.isFinite(value)) return;\n push('input_number', 'set_value', { entity_id: entity, value });\n}\nfunction setBool(entity, on) {\n push('input_boolean', on ? 'turn_on' : 'turn_off', { entity_id: entity });\n}\n\n// Kill-switch : à OFF, on ne touche à rien du tout, pas même au statut.\nconst enabled = (snapshot.balance_enabled ?? 'on').toString().toLowerCase() === 'on';\nif (!enabled) return [];\n\n// --- Ce que le bilan constate de la production, dans tous les cas ---------\nsetBool('input_boolean.evcs_balance_pv_censored', !!censorship.censorship.pvCensored);\nsetText('input_text.evcs_balance_censor_reason',\n censorship.censorship.pvCensored\n ? censorship.censorship.reasons.join(', ')\n : 'production non bridée');\nsetText('input_text.evcs_balance_last_run', stampParis());\n\n// --- Le bilan lui-même ---------------------------------------------------\nif (!balance.usable) {\n setText('input_text.evcs_balance_status', 'indisponible');\n setText('input_text.evcs_balance_reason',\n `Entrées manquantes : ${(balance.missing || []).join(', ')}. Les contrôleurs gardent leurs règles.`);\n return calls;\n}\n\nconst c = balance.coverage;\n\nsetText('input_text.evcs_balance_status', 'ok');\nsetNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);\nsetNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);\nsetNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);\nsetNumber('input_number.evcs_balance_grid_energy_needed_kwh', c.gridEnergyNeededKwh);\nsetNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surplusAvailableKwh);\nsetBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);\nsetText('input_text.evcs_balance_crossing_hour',\n balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\\'horizon');\n\nconst reason = balance.crossing.found\n ? `Croisement à ${balance.crossing.hour} h, ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}% (actuel ${c.currentSocPct}%). ${c.gridNeeded ? `Réseau : ${c.gridEnergyNeededKwh} kWh.` : 'Réseau non nécessaire.'}`\n : `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;\nsetText('input_text.evcs_balance_reason', reason);\n\n// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil\n// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.\npush('logbook', 'log', {\n name: 'EVCS Bilan énergétique (ombre)',\n message: `${reason} | seuil fixe actuel grid_soc_off = ${snapshot.grid_soc_off ?? 'n/a'}% | production ${censorship.censorship.pvCensored ? 'BRIDÉE (' + censorship.censorship.reasons.join(', ') + ')' : 'libre'}`,\n entity_id: 'input_text.evcs_balance_status',\n});\n\nreturn calls;\n" + "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS\n// =====================================================================\n// GARANTIE MODE OMBRE : ce nœud ne produit QUE des écritures vers des entités\n// dont le nom commence par `evcs_balance_`, plus une trace logbook. Aucun\n// switch, aucune consigne de borne, aucune stratégie, aucun helper de takeover.\n// Le bilan observe et publie ; il ne pilote rien tant que le câblage réel n'a\n// pas été décidé explicitement.\n//\n// Cette garantie est VÉRIFIÉE par tests/energy-balance/ : la suite échoue si\n// une écriture sort de ce périmètre. Ne pas ajouter d'autres entités ici sans\n// changer le test en connaissance de cause.\n// =====================================================================\n\nconst balance = $input.first().json;\nconst censorship = $('CENSORSHIP').first().json;\nconst rawSnapshot = $('HA Snapshot Balance').first().json;\nconst snapshot = (rawSnapshot && typeof rawSnapshot.data === 'string')\n ? (() => { try { return JSON.parse(rawSnapshot.data); } catch { return rawSnapshot; } })()\n : ((rawSnapshot && rawSnapshot.data) || rawSnapshot);\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\n\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n if (!Number.isFinite(value)) return;\n push('input_number', 'set_value', { entity_id: entity, value });\n}\nfunction setBool(entity, on) {\n push('input_boolean', on ? 'turn_on' : 'turn_off', { entity_id: entity });\n}\n\n// Kill-switch : à OFF, on ne touche à rien du tout, pas même au statut.\nconst enabled = (snapshot.balance_enabled ?? 'on').toString().toLowerCase() === 'on';\nif (!enabled) return [];\n\n// --- Ce que le bilan constate de la production, dans tous les cas ---------\nsetBool('input_boolean.evcs_balance_pv_censored', !!censorship.censorship.pvCensored);\nsetText('input_text.evcs_balance_censor_reason',\n censorship.censorship.pvCensored\n ? censorship.censorship.reasons.join(', ')\n : 'production non bridée');\nsetText('input_text.evcs_balance_last_run', stampParis());\n\n// --- Le bilan lui-même ---------------------------------------------------\nif (!balance.usable) {\n setText('input_text.evcs_balance_status', 'indisponible');\n setText('input_text.evcs_balance_reason',\n `Entrées manquantes : ${(balance.missing || []).join(', ')}. Les contrôleurs gardent leurs règles.`);\n return calls;\n}\n\nconst c = balance.coverage;\n\nsetText('input_text.evcs_balance_status', 'ok');\nsetNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);\nsetNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);\nsetNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);\nsetNumber('input_number.evcs_balance_grid_energy_needed_kwh', c.gridEnergyNeededKwh);\nsetNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surplusAvailableKwh);\nsetBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);\n\n// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas.\nconst nd = balance.nextDeficit || { found: false };\nif (nd.found) {\n setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);\n setNumber('input_number.evcs_balance_next_deficit_required_soc', nd.requiredSocAtStartPct);\n setNumber('input_number.evcs_balance_storage_shortfall_kwh', nd.storageShortfallKwh);\n setText('input_text.evcs_balance_next_deficit_window',\n `${String(nd.startHour).padStart(2, '0')} h → ${nd.endHour == null ? '?' : String(nd.endHour).padStart(2, '0') + ' h'}`\n + (nd.feasibleWithCurrentStorage ? '' : ' (stockage insuffisant)'));\n} else {\n setText('input_text.evcs_balance_next_deficit_window',\n nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\\'horizon');\n}\nsetText('input_text.evcs_balance_crossing_hour',\n balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\\'horizon');\n\nconst ndText = nd.found\n ? ` | prochaine traversée ${nd.energyKwh} kWh dès ${nd.startHour} h → SOC requis ${nd.requiredSocAtStartPct}%${nd.feasibleWithCurrentStorage ? '' : ` (manque ${nd.storageShortfallKwh} kWh de stockage)`}`\n : '';\n\nconst reason = balance.crossing.found\n ? `Croisement à ${balance.crossing.hour} h, ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}% (actuel ${c.currentSocPct}%). ${c.gridNeeded ? `Réseau : ${c.gridEnergyNeededKwh} kWh.` : 'Réseau non nécessaire.'}`\n : `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;\nsetText('input_text.evcs_balance_reason', reason + ndText);\n\n// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil\n// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.\npush('logbook', 'log', {\n name: 'EVCS Bilan énergétique (ombre)',\n message: `${reason} | seuil fixe actuel grid_soc_off = ${snapshot.grid_soc_off ?? 'n/a'}% | production ${censorship.censorship.pvCensored ? 'BRIDÉE (' + censorship.censorship.reasons.join(', ') + ')' : 'libre'}`,\n entity_id: 'input_text.evcs_balance_status',\n});\n\nreturn calls;\n" } }, { diff --git a/workflows/evcs-energy-profiles-daily-v1.json b/workflows/evcs-energy-profiles-daily-v1.json new file mode 100644 index 0000000..cb6414a --- /dev/null +++ b/workflows/evcs-energy-profiles-daily-v1.json @@ -0,0 +1,171 @@ +{ + "name": "EVCS Energy Profiles Daily V1", + "nodes": [ + { + "name": "Schedule Daily", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 0, + 0 + ], + "parameters": { + "rule": { + "interval": [ + { + "field": "days", + "triggerAtHour": 3, + "triggerAtMinute": 30 + } + ] + } + } + }, + { + "name": "HA History", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 220, + 0 + ], + "parameters": { + "method": "GET", + "url": "={{ 'http://10.10.10.17:8123/api/history/period/' + new Date(Date.now() - 30 * 86400000).toISOString() + '?end_time=' + encodeURIComponent(new Date().toISOString()) + '&filter_entity_id=sensor.airtria_mpii_v1_energy_from_inverter_to_ac_out,sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out,sensor.evcs_hq2239dnt7p_total_energy,sensor.gx_device_pv_energy,sensor.victron_battery_soc' + '&minimal_response&significant_changes_only=0' }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "options": { + "timeout": 120000, + "response": { + "response": { + "responseFormat": "text" + } + } + } + }, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "name": "DAILY_PROFILES", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 440, + 0 + ], + "parameters": { + "jsCode": "// EVCS Energy Balance — DAILY_PROFILES\n// =====================================================================\n// Rôle : recalculer une fois par jour, à partir de l'historique Home Assistant,\n// les quatre profils horaires dont le bilan a besoin :\n// 1. consommation typique (p50) et prudente (p80), hors véhicule ;\n// 2. enveloppe de production démontrée (production maximale observée) ;\n// 3. taux de censure par heure (part des heures où la batterie était pleine).\n//\n// Tous sont publiés en CSV compact de 24 valeurs dans des helpers input_text\n// (moins de 255 caractères), relus à chaque cycle par le bilan.\n//\n// « La consommation se lit, elle ne se devine pas. » Contrairement à la\n// production, la mesure de consommation n'est PAS censurée : elle ne dépend\n// d'aucune capacité d'absorption. C'est donc la seule des deux grandeurs qu'on\n// peut profiler directement.\n//\n// Consommation maison = énergie livrée aux charges AC (depuis l'onduleur ET en\n// transit depuis le réseau) MOINS l'énergie prise par la borne de recharge.\n// On retire l'EVCS parce que la voiture est une charge pilotable : elle ne doit\n// pas polluer le profil de la charge subie, sinon le système se croit\n// structurellement plus gourmand qu'il ne l'est les jours où la voiture charge.\n//\n// Méthode : ce n'est PAS une moyenne glissante toutes heures confondues. Pour\n// chaque heure de la journée on constitue la distribution des valeurs observées\n// à cette heure-là sur tous les jours de l'historique, puis on en tire des\n// percentiles. Le p80 est la valeur prudente (« une journée sur cinq consomme\n// plus que ça »), le p50 la valeur typique.\n//\n// Entrée : réponse brute de /api/history/period (tableau de tableaux, le\n// premier point de chaque liste portant son entity_id), reçue soit\n// décodée, soit sous forme de chaîne dans `data` selon le réglage du\n// nœud HTTP. Les tests peuvent fournir directement `series`.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst raw = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Correspondance entité → rôle. Les seules entités que ce nœud sait lire.\n ENTITIES: {\n 'sensor.airtria_mpii_v1_energy_from_inverter_to_ac_out': 'inverter_to_ac_out',\n 'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out': 'ac_in_to_ac_out',\n 'sensor.evcs_hq2239dnt7p_total_energy': 'evcs_total',\n 'sensor.gx_device_pv_energy': 'pv_energy',\n 'sensor.victron_battery_soc': 'soc',\n },\n // SOC à partir duquel l'heure est comptée comme censurée : la batterie n'est\n // plus un puits, la production mesurée ne dit plus rien de la ressource.\n CENSORSHIP_SOC_PCT: 90,\n // Un delta horaire au-delà de cette valeur n'est pas une consommation\n // plausible sur cette installation : c'est un artefact de compteur.\n MAX_PLAUSIBLE_HOURLY_KWH: 50,\n // En dessous de ce nombre de jours observés pour une heure donnée, le\n // percentile n'a pas de sens : on le signale au lieu de le publier en silence.\n MIN_DAYS_FOR_PERCENTILE: 5,\n};\n\n// ---------------------------------------------------------------------\n// 1. Regroupement horaire d'une série de compteur cumulé.\n// On garde la DERNIÈRE valeur de chaque heure : le delta entre deux heures\n// consécutives est l'énergie consommée pendant l'heure écoulée.\n// ---------------------------------------------------------------------\nfunction hourBucketMs(ms) {\n return Math.floor(ms / 3600000) * 3600000;\n}\n\nfunction bucketHourly(series) {\n const buckets = new Map();\n for (const point of series || []) {\n const ms = Date.parse(point.ts ?? point.last_changed ?? point.last_updated);\n const value = num(point.state ?? point.value);\n if (!Number.isFinite(ms) || !Number.isFinite(value)) continue;\n const h = hourBucketMs(ms);\n const prev = buckets.get(h);\n if (!prev || ms >= prev.ms) buckets.set(h, { ms, value });\n }\n return buckets;\n}\n\n// Deltas entre heures consécutives. Un delta négatif signale une remise à zéro\n// du compteur (redémarrage HA, réinitialisation d'intégration) : on jette\n// l'échantillon plutôt que de fabriquer une valeur.\nfunction deltaSeries(buckets) {\n const hours = [...buckets.keys()].sort((a, b) => a - b);\n const deltas = new Map();\n let resets = 0;\n for (let i = 1; i < hours.length; i++) {\n const d = buckets.get(hours[i]).value - buckets.get(hours[i - 1]).value;\n if (d < 0) { resets++; continue; }\n if (d > CFG.MAX_PLAUSIBLE_HOURLY_KWH) { resets++; continue; }\n deltas.set(hours[i - 1], d);\n }\n return { deltas, resets };\n}\n\n// ---------------------------------------------------------------------\n// 0. Normalisation de l'entrée : réponse /api/history OU séries déjà prêtes.\n// ---------------------------------------------------------------------\nfunction decode(rawInput) {\n if (rawInput && rawInput.series) return rawInput.series;\n\n let history = rawInput && rawInput.history ? rawInput.history : rawInput;\n if (history && typeof history.data === 'string') {\n try { history = JSON.parse(history.data); } catch { history = null; }\n } else if (history && history.data) {\n history = history.data;\n }\n if (!Array.isArray(history)) return {};\n\n const series = {};\n for (const list of history) {\n if (!Array.isArray(list) || !list.length) continue;\n const role = CFG.ENTITIES[list[0].entity_id];\n if (!role) continue;\n // /api/history en mode minimal ne répète pas entity_id : chaque point porte\n // seulement `state` et `last_changed`.\n series[role] = list.map(p => ({ ts: p.last_changed || p.last_updated, state: p.state }));\n }\n return series;\n}\n\nconst series = decode(raw);\nconst input = { series, nowMs: raw && raw.nowMs };\n\nconst fromInverter = deltaSeries(bucketHourly(series.inverter_to_ac_out));\nconst fromGrid = deltaSeries(bucketHourly(series.ac_in_to_ac_out));\nconst fromEvcs = deltaSeries(bucketHourly(series.evcs_total));\nconst fromPv = deltaSeries(bucketHourly(series.pv_energy));\n\n// SOC : moyenne des relevés de chaque heure. Sert uniquement à savoir si\n// l'heure était censurée, pas à un calcul d'énergie.\nfunction hourlyMean(seriesPoints) {\n const acc = new Map();\n for (const point of seriesPoints || []) {\n const ms = Date.parse(point.ts ?? point.last_changed ?? point.last_updated);\n const value = num(point.state ?? point.value);\n if (!Number.isFinite(ms) || !Number.isFinite(value)) continue;\n const h = hourBucketMs(ms);\n const prev = acc.get(h) || { sum: 0, n: 0 };\n prev.sum += value; prev.n += 1;\n acc.set(h, prev);\n }\n const out = new Map();\n for (const [h, v] of acc) out.set(h, v.sum / v.n);\n return out;\n}\n\nconst socHourly = hourlyMean(series.soc);\n\n// ---------------------------------------------------------------------\n// 2. Consommation maison par heure, hors véhicule.\n// ---------------------------------------------------------------------\nconst hourOfDay = (ms) =>\n Number.parseInt(\n new Intl.DateTimeFormat('fr-FR', {\n timeZone: CFG.TIMEZONE, hour: '2-digit', hour12: false,\n }).format(new Date(ms)),\n 10\n );\n\nconst byHourOfDay = Array.from({ length: 24 }, () => []);\nconst days = new Set();\nlet evcsHours = 0;\n\nfor (const [ms, inverterKwh] of fromInverter.deltas) {\n const gridKwh = fromGrid.deltas.get(ms) ?? 0;\n const evcsKwh = fromEvcs.deltas.get(ms) ?? 0;\n if (evcsKwh > 0) evcsHours++;\n const houseKwh = Math.max(0, inverterKwh + gridKwh - evcsKwh);\n byHourOfDay[hourOfDay(ms)].push(houseKwh);\n days.add(new Date(ms).toISOString().slice(0, 10));\n}\n\n// ---------------------------------------------------------------------\n// 2bis. Enveloppe de production et taux de censure.\n//\n// L'enveloppe est le maximum horaire jamais observé. C'est une borne sûre :\n// une heure censurée ne peut pas produire un maximum, donc le maximum vient\n// forcément d'une heure où le système avait de la place.\n// ---------------------------------------------------------------------\nconst envelope = Array.from({ length: 24 }, () => 0);\nconst censusTotal = Array.from({ length: 24 }, () => 0);\nconst censusCensored = Array.from({ length: 24 }, () => 0);\n\nfor (const [ms, pvKwh] of fromPv.deltas) {\n const h = hourOfDay(ms);\n if (pvKwh > envelope[h]) envelope[h] = pvKwh;\n const soc = socHourly.get(ms);\n if (soc === undefined) continue;\n censusTotal[h]++;\n if (soc >= CFG.CENSORSHIP_SOC_PCT) censusCensored[h]++;\n}\n\n// Sans échantillon pour une heure, on suppose la pire hypothèse (censurée) :\n// le bilan signalera une forme minorée plutôt que de la croire fiable.\nconst censorshipRate = censusTotal.map((n, h) => (n ? censusCensored[h] / n : 1));\n\n// ---------------------------------------------------------------------\n// 3. Percentiles par heure de la journée.\n// ---------------------------------------------------------------------\nfunction percentile(values, p) {\n if (!values.length) return null;\n const sorted = [...values].sort((a, b) => a - b);\n return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];\n}\n\nconst profile = byHourOfDay.map((values, hour) => ({\n hour,\n n: values.length,\n p20: round2(percentile(values, 0.2)),\n p50: round2(percentile(values, 0.5)),\n p80: round2(percentile(values, 0.8)),\n reliable: values.length >= CFG.MIN_DAYS_FOR_PERCENTILE,\n}));\n\nconst complete = profile.every(h => h.reliable);\n\n// CSV compact pour un helper input_text (limite 255 caractères) : 24 valeurs\n// au format 0.00, soit ~120 caractères. Le p50 et le p80 vont dans deux helpers\n// distincts, le choix du percentile appartient au consommateur.\nconst csv = (key) => profile.map(h => (h[key] == null ? '' : h[key].toFixed(2))).join(',');\n\nconst dailyTotal = (key) =>\n round2(profile.reduce((sum, h) => sum + (h[key] ?? 0), 0));\n\nreturn [{\n json: {\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n days: days.size,\n profile,\n complete,\n csv: {\n p50: csv('p50'),\n p80: csv('p80'),\n envelope: envelope.map(v => v.toFixed(2)).join(','),\n censorshipRate: censorshipRate.map(v => v.toFixed(2)).join(','),\n },\n\n production: {\n envelopeKwh: envelope.map(round2),\n censorshipRate: censorshipRate.map(v => Math.round(v * 100) / 100),\n demonstratedDailyKwh: round2(envelope.reduce((a, b) => a + b, 0)),\n censoredDayHours: censorshipRate.filter((r, h) => envelope[h] > 0.2 && r > 0.25).length,\n },\n totals: {\n typicalDailyKwh: dailyTotal('p50'),\n prudentDailyKwh: dailyTotal('p80'),\n },\n quality: {\n hoursUsed: fromInverter.deltas.size,\n pvHoursUsed: fromPv.deltas.size,\n socHoursUsed: socHourly.size,\n hoursWithEvcs: evcsHours,\n countersReset: fromInverter.resets + fromGrid.resets + fromEvcs.resets,\n unreliableHours: profile.filter(h => !h.reliable).map(h => h.hour),\n },\n cfg: CFG,\n },\n}];\n" + } + }, + { + "name": "BUILD_HA_CALLS_PROFILES", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 660, + 0 + ], + "parameters": { + "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS_PROFILES\n// =====================================================================\n// Publie les quatre profils recalculés dans leurs helpers input_text.\n//\n// MÊME GARANTIE DE PÉRIMÈTRE que le workflow de bilan : ce nœud n'écrit que\n// vers des entités `evcs_balance_*`, plus une trace logbook. La suite de tests\n// échoue si une écriture en sort.\n//\n// Refus explicite de publier un profil douteux : si l'historique est trop court\n// pour couvrir les 24 heures, on n'écrase PAS les profils en place. Mieux vaut\n// un profil un peu ancien qu'un profil creux.\n// =====================================================================\n\nconst profiles = $input.first().json;\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\nfunction setText(entity, value) {\n calls.push({ json: {\n domain: 'input_text', service: 'set_value',\n data: { entity_id: entity, value: (value ?? '').toString().slice(0, 250) },\n } });\n}\n\nif (!profiles.complete) {\n setText('input_text.evcs_balance_status',\n `profils NON mis à jour : ${profiles.quality.unreliableHours.length} h sans assez de jours observés`);\n calls.push({ json: {\n domain: 'logbook', service: 'log',\n data: {\n name: 'EVCS Bilan — profils quotidiens',\n message: `Recalcul refusé : historique insuffisant (${profiles.days} jour(s), heures creuses : ${profiles.quality.unreliableHours.join(', ')}). Profils précédents conservés.`,\n entity_id: 'input_text.evcs_balance_status',\n },\n } });\n return calls;\n}\n\nsetText('input_text.evcs_balance_consumption_p50', profiles.csv.p50);\nsetText('input_text.evcs_balance_consumption_p80', profiles.csv.p80);\nsetText('input_text.evcs_balance_envelope', profiles.csv.envelope);\nsetText('input_text.evcs_balance_censorship_rate', profiles.csv.censorshipRate);\nsetText('input_text.evcs_balance_last_run', stampParis());\n\ncalls.push({ json: {\n domain: 'logbook', service: 'log',\n data: {\n name: 'EVCS Bilan — profils quotidiens',\n message: `Profils recalculés sur ${profiles.days} jours (${profiles.quality.hoursUsed} h de consommation, ${profiles.quality.pvHoursUsed} h de production, ${profiles.quality.countersReset} remise(s) à zéro écartée(s)). Consommation typique ${profiles.totals.typicalDailyKwh} kWh/j, prudente ${profiles.totals.prudentDailyKwh} kWh/j. Production démontrée ${profiles.production.demonstratedDailyKwh} kWh/j, ${profiles.production.censoredDayHours} heure(s) de jour censurée(s).`,\n entity_id: 'input_text.evcs_balance_consumption_p50',\n },\n} });\n\nreturn calls;\n" + } + }, + { + "name": "Execute HA Service Profiles", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 880, + 0 + ], + "parameters": { + "method": "POST", + "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.data }}", + "options": { + "timeout": 15000 + } + }, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + }, + "onError": "continueRegularOutput" + } + ], + "connections": { + "Schedule Daily": { + "main": [ + [ + { + "node": "HA History", + "type": "main", + "index": 0 + } + ] + ] + }, + "HA History": { + "main": [ + [ + { + "node": "DAILY_PROFILES", + "type": "main", + "index": 0 + } + ] + ] + }, + "DAILY_PROFILES": { + "main": [ + [ + { + "node": "BUILD_HA_CALLS_PROFILES", + "type": "main", + "index": 0 + } + ] + ] + }, + "BUILD_HA_CALLS_PROFILES": { + "main": [ + [ + { + "node": "Execute HA Service Profiles", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner" + }, + "staticData": null, + "id": "N6UaB4ZozsbRPrFb" +}