From 9b24b5e29c3bf9afbedf45d29ca2e876c2d4a9bd Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Mon, 24 Aug 2026 15:31:01 +0200 Subject: [PATCH] Chantier etape 4 : le bilan, et description materielle consignee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BALANCE.js ne pilote rien : il publie les faits qui remplacent les seuils fixes — heure de croisement, energie a couvrir, SOC requis (remplacant de grid_soc_off), energie reseau strictement necessaire, surplus disponible pour le vehicule, indice de confiance. Sans une de ses entrees il se declare inutilisable et les controleurs gardent leurs regles. Deux niveaux de prudence. Le premier jet n'utilisait que le profil p80 et exigeait 48 % de SOC pour franchir la matinee du 24/08, soit plus cher que le seuil fixe de 30 % qu'il remplace. Corrige : la decision reseau, reevaluee a chaque cycle et reversible, utilise le profil typique p50 ; le prudent p80 sert a la planification. La marge de securite de 10 % ajoutee au depart etait une constante arbitraire et un double comptage : mesure faite, p80(somme) egale somme(p80) sur toutes les fenetres, les heures de consommation etant fortement correlees. Marge supprimee. Rejeu fige de la matinee du 24/08 (SOC 21 %, capacite 15 kWh, production reellement mesuree) : croisement a 08 h, 0,82 kWh a couvrir contre 0,80 calcule a la main, SOC requis 27,5 %, une session reseau de 0,97 kWh. Suite a 21 scenarios. MANUEL_COMPLET section 0 : description materielle complete relevee dans VRM et completee par le proprietaire — 2 MultiPlus-II 48/5000 en parallele, 53 panneaux sur trois orientations (10 est/sud-est, 30 sud, 10 ouest, 3 sur un petit MPPT), parc Pylontech, borne 32 A. Explique pourquoi aucun modele solaire mono-plan ne peut representer la production, et pourquoi Forecast.Solar mal configure se trompe de repartition. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ --- docs/CHANTIER_BILAN_ENERGETIQUE.md | 40 +++- docs/MANUEL_COMPLET.md | 68 +++++++ src/energy-balance/BALANCE.js | 216 +++++++++++++++++++++ tests/energy-balance/run.mjs | 12 ++ tests/energy-balance/scenarios-balance.mjs | 116 +++++++++++ 5 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 src/energy-balance/BALANCE.js create mode 100644 tests/energy-balance/scenarios-balance.mjs diff --git a/docs/CHANTIER_BILAN_ENERGETIQUE.md b/docs/CHANTIER_BILAN_ENERGETIQUE.md index 2e0acdb..f62e37a 100644 --- a/docs/CHANTIER_BILAN_ENERGETIQUE.md +++ b/docs/CHANTIER_BILAN_ENERGETIQUE.md @@ -150,7 +150,45 @@ calcul en saison basse. décisions fondées sur la prévision (EVCS, chauffe-eau, conseiller IA). À corriger dans la configuration de l'intégration (azimut et inclinaison par plan), puis refaire la comparaison. -- [ ] Étape 4 — bilan et ses sorties. +- [x] **Étape 4 — le bilan** (24/08). `src/energy-balance/BALANCE.js`. Ne pilote + rien : publie les faits qui remplacent les seuils fixes — heure de croisement, + énergie à couvrir, **SOC requis** (remplaçant de `grid_soc_off`), énergie + réseau strictement nécessaire, surplus disponible pour le véhicule, indice de + confiance. Sans une de ses entrées, il se déclare inutilisable et les + contrôleurs gardent leurs règles. + + **Deux niveaux de prudence, et c'est un choix de conception, pas un détail.** + Le premier jet n'utilisait que le profil prudent (p80) et exigeait **48 % de + SOC** pour franchir la matinée du 24/08 — alors que le seuil fixe de 30 % + aurait été moins coûteux. Un bilan plus gourmand que le seuil bête qu'il + remplace n'a aucun intérêt. Corrigé : la décision réseau, réévaluée à chaque + cycle et réversible, utilise le profil **typique (p50)** ; le profil prudent + (p80) sert à la planification qui engage plusieurs heures (cible de nuit, + dimensionnement du stockage). Se tromper par optimisme ne coûte qu'un + démarrage réseau une heure plus tard, le plancher dur protégeant la batterie ; + se tromper par excès de prudence coûte un import réseau immédiat et réel. + + Vérifié aussi : la marge de sécurité de 10 % que j'avais ajoutée était une + constante arbitraire de plus, et un double comptage. Mesure du 24/08 : + `p80(somme des heures)` égale `somme(p80 par heure)` sur toutes les fenêtres + testées — les heures de consommation sont fortement corrélées. La prudence est + donc entièrement portée par le percentile, qui est mesuré. Marge supprimée. + + **Rejeu de la matinée du 24/08 (scénario figé)** — SOC réel 21 %, capacité + 15 kWh, production horaire réellement mesurée : + + | | | + |---|---| + | Croisement PV/consommation | **08 h** | + | Énergie à couvrir | **0,82 kWh** (calcul manuel sur les mesures : 0,80) | + | SOC requis (décision) | **27,5 %** au lieu du seuil fixe de 30 % | + | Réseau nécessaire | oui, **0,97 kWh** — une session, puis terminé | + | SOC requis (planification, p80) | 38,1 % | + | Déficit calculé sur forme fiable | oui | + + Suite portée à **21 scénarios**. + +- [ ] Étape 5 — publication dans n8n (workflow + helpers), puis dashboard. ## Backlog diff --git a/docs/MANUEL_COMPLET.md b/docs/MANUEL_COMPLET.md index 5bacb03..34cd63a 100644 --- a/docs/MANUEL_COMPLET.md +++ b/docs/MANUEL_COMPLET.md @@ -6,6 +6,74 @@ --- +## 0. Installation physique (matériel réel) + +> Relevé le 24/08/2026 depuis la console VRM (Paramètres → Appareils) et +> complété par le propriétaire. **À tenir à jour** : cette section existe pour +> qu'il n'ait jamais à redécrire son installation. + +### Onduleurs +**2 × MultiPlus-II 48/5000/70-50**, *two units configured in parallel* +(instance VRM 276, id produit 2623, micrologiciel 2623556). +Numéros de série : `HQ2244WY9T6` (Phase 1 Maître) · `HQ23467ZN6F` (Phase 1 +Unité parallèle 1). Puissance nominale du couple : **9000 W** +(`sensor.victron_ve_bus_nominal_inverter_power`). +⚠️ Le VE.Bus en parallèle n'expose **qu'une seule instance D-Bus** couvrant les +deux unités : `vebus/276/…` est déjà la grandeur système, pas celle d'un +onduleur isolé. Les unités individuelles n'apparaissent que sous +`vebus/276/Devices/N/…`. + +### Champ photovoltaïque — 53 panneaux, **trois orientations** + +| Chargeur solaire | Instance | Panneaux | Orientation | +|---|---|---|---| +| SmartSolar Charger **MPPT 250/70 rev2** (`AIRTRIA MPPT1`, s/n HQ2235HKX24) | 279 | **10** | **est / sud-est** | +| SmartSolar **MPPT RS 450/200** (s/n HQ2449YR9PT) | 280 | **30** | **plein sud** | +| ″ (second tracker du même RS 450/200) | 280 | **10** | **plein ouest** | +| SmartSolar Charger **MPPT 150/35** (`V1`, s/n HQ2238U24HP) | 277 | **3** | un tracker | + +`V2` (instance 288) et `V3` (instance 278) apparaissent dans VRM avec +« dernière vue il y a un an » : appareils obsolètes, à ignorer. + +**Conséquence majeure pour tout calcul de production** : avec de l'est, du sud +et de l'ouest, la courbe de production journalière ne peut PAS être représentée +par un modèle solaire à plan unique. Une tentative d'ajustement mono-plan +(24/08) converge sur « plein est, 20° » et prédit 2,0 % de la journée à 18 h +contre 5,8 % réellement mesurés. La forme doit venir de la mesure, ou d'un +modèle à trois plans. Voir `docs/CHANTIER_BILAN_ENERGETIQUE.md`. + +⚠️ **Forecast.Solar est mal configuré** : il attend une journée orientée +l'après-midi (15,1 % de l'énergie le matin 07-11 h) alors que l'installation +produit le matin (39,3 % mesurés). Il faut y déclarer **un plan par +orientation** — est/sud-est, sud, ouest. + +### Batterie +**8 × Pylontech US2000C** (`AIRTRIA 8 US2000C`, Battery Monitor, instance VRM +512, fabricant PYLON, interface CAN-bus), organisés en **deux packs de 4 reliés +par un hub**. Le premier pack a deux ans de plus que le second et a vécu avant +la climatisation du garage. +**État au 24/08/2026 : un module en panne et isolé → 7 modules actifs**, un +huitième commence à faiblir. Mélanger des packs d'âges différents est +déconseillé (contrainte de budget, pas un choix technique). +⚠️ `batteryUsableCapacityKwh: 17.5` est codé en dur dans `INPUT_PARSE.js` et +utilisé trois fois dans `WINDOWS_SOURCEV2.js` : avec 7 modules, cette valeur +surestime la réserve réelle de 15 à 20 %. Aucun capteur de capacité ou de santé +batterie n'est publié côté HA — à corriger. + +### Borne de recharge +**EV Charging Station 32A** (s/n HQ2239DNT7P, micrologiciel v2.08, instance VRM +40), position « EV charger on output ». +Deux véhicules alternent : **VW** (hybride, départ ~08:50) et **JEEP** +(départ ~06:30) — cf. §11.5. + +### Réseau +Victron **ESS sans injection** : la production est bridée en permanence à ce que +le système peut absorber (consommation + charge batterie). Voir +`docs/CHANTIER_BILAN_ENERGETIQUE.md` pour les conséquences sur toute mesure de +production. + +--- + ## 1. Architecture Globale ``` diff --git a/src/energy-balance/BALANCE.js b/src/energy-balance/BALANCE.js new file mode 100644 index 0000000..6ecedc2 --- /dev/null +++ b/src/energy-balance/BALANCE.js @@ -0,0 +1,216 @@ +// EVCS Energy Balance — BALANCE +// ===================================================================== +// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre. +// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux +// contrôleurs liront à la place de leurs constantes. +// +// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de +// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le +// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe +// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir +// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC — +// et non un palier arbitraire à 30 %. +// +// Entrées (toutes fournies, aucune constante physique en dur) : +// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE) +// consumption_p80[24] idem, valeur prudente +// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE) +// soc_percent SOC lissé pour le pilotage +// usable_capacity_kwh capacité réellement disponible du parc batterie +// soc_floor_percent plancher dur de sécurité +// +// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge +// disponible pour le véhicule · niveau de confiance. +// ===================================================================== + +function num(v, fallback = NaN) { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : fallback; +} + +function round1(v) { + return Number.isFinite(v) ? Math.round(v * 10) / 10 : null; +} + +function round2(v) { + return Number.isFinite(v) ? Math.round(v * 100) / 100 : null; +} + +const input = $input.first().json; + +const CFG = { + TIMEZONE: 'Europe/Paris', + // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la + // réponse n'aurait plus de sens opérationnel. + SEARCH_HOURS: 30, + // PAS de marge de sécurité supplémentaire. La prudence est déjà portée, + // entièrement, par le choix du percentile du profil de consommation (p80), + // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées, + // p80(somme des heures) est égal a somme(p80 par heure) — les heures de + // consommation sont fortement corrélées, une matinée chargée l'étant sur + // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux + // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une + // constante arbitraire de plus. +}; + +function profileOf(key) { + const a = input[key]; + return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null; +} +const consumptionP50 = profileOf('consumption_p50'); +const consumptionP80 = profileOf('consumption_p80'); + +const horizon = Array.isArray(input.horizon) ? input.horizon : null; + +const socPct = num(input.soc_percent, NaN); +const usableCapacityKwh = num(input.usable_capacity_kwh, NaN); +const socFloorPct = num(input.soc_floor_percent, 22); + +// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les +// contrôleurs retombent sur leurs règles historiques. +const missing = []; +if (!consumptionP50) missing.push('consumption_p50'); +if (!consumptionP80) missing.push('consumption_p80'); +if (!horizon || !horizon.length) missing.push('horizon'); +if (!Number.isFinite(socPct)) missing.push('soc_percent'); +if (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh'); + +if (missing.length) { + return [{ + json: { + usable: false, + missing, + reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`, + cfg: CFG, + }, + }]; +} + +// --------------------------------------------------------------------- +// Bilan heure par heure, calculé DEUX FOIS. +// +// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du +// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se +// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard, +// le plancher dur garantissant la batterie ; se tromper par excès de prudence +// coûte un import réseau bien réel, immédiatement. +// +// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan +// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation +// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait +// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête +// qu'il remplace n'a aucun intérêt. +// +// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu. +// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement +// du stockage, choix qui engagent plusieurs heures. +// --------------------------------------------------------------------- +function buildSlots(consumption) { + return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => { + const hour = num(slot.hour, 0); + const productionKwh = Math.max(0, num(slot.expectedKwh, 0)); + const consumptionKwh = consumption[hour] ?? 0; + return { + startMs: slot.startMs, + hour, + productionKwh: round2(productionKwh), + consumptionKwh: round2(consumptionKwh), + netKwh: round2(productionKwh - consumptionKwh), + shapeQuality: slot.shapeQuality || 'inconnue', + }; + }); +} + +function coverage(slots) { + const crossingIndex = slots.findIndex(s => s.netKwh > 0); + const found = crossingIndex >= 0; + const deficitSlots = found ? slots.slice(0, crossingIndex) : slots; + const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0); + const socNeededPct = (energyToCoverKwh / usableCapacityKwh) * 100; + const requiredSocPct = Math.min(100, socFloorPct + socNeededPct); + const socMarginPct = socPct - requiredSocPct; + return { + crossingIndex, found, deficitSlots, slots, + energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct, + gridNeeded: socMarginPct < 0, + gridEnergyNeededKwh: socMarginPct < 0 ? (Math.abs(socMarginPct) / 100) * usableCapacityKwh : 0, + }; +} + +const typical = coverage(buildSlots(consumptionP50)); +const prudent = coverage(buildSlots(consumptionP80)); + +const slots = typical.slots; +const crossingFound = typical.found; +const crossingIndex = typical.crossingIndex; +const crossing = crossingFound ? slots[crossingIndex] : null; +const deficitSlots = typical.deficitSlots; + +// --------------------------------------------------------------------- +// Marge disponible pour le véhicule : surplus attendu après le croisement, +// une fois la maison servie. C'est ce qui peut être chargé sans toucher au +// réseau ni entamer la réserve. +// --------------------------------------------------------------------- +const surplusAfterCrossingKwh = crossingFound + ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0) + : 0; + +// --------------------------------------------------------------------- +// Confiance : la part du calcul qui repose sur une forme de production minorée +// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau », +// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées, +// une heure censurée étant par construction une heure de surplus. +// --------------------------------------------------------------------- +const deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length; +const confidence = { + deficitHoursOnWeakShape: deficitOnWeakShape, + deficitTrustworthy: deficitOnWeakShape === 0, + surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0) + .some(s => s.shapeQuality === 'minoree'), +}; + +return [{ + json: { + usable: true, + generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(), + + crossing: { + found: crossingFound, + hour: crossing ? crossing.hour : null, + startMs: crossing ? crossing.startMs : null, + hoursFromNow: crossingFound ? crossingIndex : null, + }, + + coverage: { + // Décision réseau immédiate — remplace input_number.victron_grid_soc_off. + energyToCoverKwh: round2(typical.energyToCoverKwh), + requiredSocPct: round1(typical.requiredSocPct), + currentSocPct: round1(socPct), + socMarginPct: round1(typical.socMarginPct), + gridNeeded: typical.gridNeeded, + gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh), + + // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau. + prudent: { + energyToCoverKwh: round2(prudent.energyToCoverKwh), + requiredSocPct: round1(prudent.requiredSocPct), + socMarginPct: round1(prudent.socMarginPct), + crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null, + }, + }, + + vehicle: { + surplusAvailableKwh: round2(surplusAfterCrossingKwh), + }, + + confidence, + slots, + + inputs: { + usableCapacityKwh: round2(usableCapacityKwh), + socFloorPct: round1(socFloorPct), + }, + + cfg: CFG, + }, +}]; diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs index b789964..bdccbc8 100644 --- a/tests/energy-balance/run.mjs +++ b/tests/energy-balance/run.mjs @@ -11,6 +11,7 @@ import vm from 'node:vm'; 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'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -51,12 +52,23 @@ const SUMMARIZE = { tomorrowExpected: o.tomorrowExpectedKwh, shapeConfidenceBelow: o.source.shapeConfidence != null && o.source.shapeConfidence < 0.5, }), + BALANCE: o => o.usable ? ({ + usable: true, + crossingFound: o.crossing.found, + crossingHour: o.crossing.hour, + energyToCoverKwh: o.coverage.energyToCoverKwh, + requiredSocPct: o.coverage.requiredSocPct, + gridNeeded: o.coverage.gridNeeded, + prudentRequiredSocPct: o.coverage.prudent.requiredSocPct, + deficitTrustworthy: o.confidence.deficitTrustworthy, + }) : ({ usable: false, missing: o.missing.join(',') }), }; const all = [ ...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })), ...profileScenarios, ...resourceScenarios, + ...balanceScenarios, ]; let failed = 0; diff --git a/tests/energy-balance/scenarios-balance.mjs b/tests/energy-balance/scenarios-balance.mjs new file mode 100644 index 0000000..1c93907 --- /dev/null +++ b/tests/energy-balance/scenarios-balance.mjs @@ -0,0 +1,116 @@ +// Scénarios du nœud BALANCE. +// Le scénario central est un REJEU de la matinée du 24/08/2026 : la production +// horaire est celle réellement mesurée ce jour-là (heures 06-09 h non +// censurées, donc fiables), le SOC est celui relevé à 06:43, et la capacité +// est celle du parc réel (7 modules actifs, ~15 kWh). +// C'est la situation qui a déclenché tout le chantier : le seuil fixe de 30 % +// et le yoyo EDF de 2 à 4 minutes. + +const P50 = [0.70,0.70,0.70,0.70,0.60,0.70,0.70,0.80,0.90,1.40,1.40,2.00, + 2.40,1.90,2.10,1.90,1.80,2.00,1.50,1.70,1.60,1.30,1.30,1.10]; +const P80 = [1.00,0.70,0.70,0.70,0.70,0.80,1.30,1.80,1.70,2.00,2.50,3.10, + 3.50,2.60,2.50,2.30,2.20,2.40,2.30,1.90,1.90,1.60,1.40,1.20]; + +// Production horaire réellement mesurée le 24/08/2026. +const PROD_24_08 = { + 6: 0.03, 7: 0.65, 8: 2.70, 9: 5.62, 10: 7.17, 11: 9.33, + 12: 4.37, 13: 2.10, 14: 1.20, 15: 0.90, 16: 0.70, 17: 0.50, + 18: 0.30, 19: 0.10, 20: 0.00, 21: 0.00, 22: 0.00, 23: 0.00, +}; + +function horizonFrom(startIso, hours, production) { + const h0 = Math.floor(Date.parse(startIso) / 3600000) * 3600000; + return Array.from({ length: hours }, (_, i) => { + const ms = h0 + i * 3600000; + // parseInt et non Number : en fr-FR le format renvoie « 06 h ». + const hour = Number.parseInt(new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', hour: '2-digit', hour12: false, + }).format(new Date(ms)), 10); + return { + startMs: ms, + hour, + expectedKwh: production[hour] ?? 0, + shapeQuality: hour >= 12 && hour <= 19 ? 'minoree' : 'fiable', + }; + }); +} + +export const balanceScenarios = [ + { + node: 'BALANCE', + id: 'bilan-rejeu-24-08-matin', + label: 'Rejeu réel du 24/08 06:43 : croisement à 08 h, 0,82 kWh à couvrir, SOC requis 27,5 % au lieu du seuil fixe de 30 %', + snapshot: { + nowMs: Date.parse('2026-08-24T06:43:00+02:00'), + consumption_p50: P50, + consumption_p80: P80, + horizon: horizonFrom('2026-08-24T06:43:00+02:00', 18, PROD_24_08), + soc_percent: 21, + usable_capacity_kwh: 15, + soc_floor_percent: 22, + }, + expect: { + usable: true, + crossingHour: 8, + energyToCoverKwh: 0.82, + requiredSocPct: 27.5, + gridNeeded: true, + prudentRequiredSocPct: 38.1, + deficitTrustworthy: true, + }, + }, + { + node: 'BALANCE', + id: 'bilan-soc-suffisant-pas-de-reseau', + label: 'Même matinée avec un SOC de 45 % : aucun réseau nécessaire', + snapshot: { + nowMs: Date.parse('2026-08-24T06:43:00+02:00'), + consumption_p50: P50, + consumption_p80: P80, + horizon: horizonFrom('2026-08-24T06:43:00+02:00', 18, PROD_24_08), + soc_percent: 45, + usable_capacity_kwh: 15, + soc_floor_percent: 22, + }, + expect: { + usable: true, + gridNeeded: false, + requiredSocPct: 27.5, + }, + }, + { + node: 'BALANCE', + id: 'bilan-nuit-sans-croisement', + label: 'Nuit sans production sur tout l\'horizon : le déficit complet est annoncé, pas masqué', + snapshot: { + nowMs: Date.parse('2026-08-24T22:00:00+02:00'), + consumption_p50: P50, + consumption_p80: P80, + horizon: horizonFrom('2026-08-24T22:00:00+02:00', 6, {}), + soc_percent: 60, + usable_capacity_kwh: 15, + soc_floor_percent: 22, + }, + expect: { + usable: true, + crossingFound: false, + gridNeeded: false, + }, + }, + { + node: 'BALANCE', + id: 'bilan-entrees-manquantes', + label: 'Capacité batterie absente : le bilan se déclare inutilisable, les contrôleurs gardent leurs règles', + snapshot: { + nowMs: Date.parse('2026-08-24T06:43:00+02:00'), + consumption_p50: P50, + consumption_p80: P80, + horizon: horizonFrom('2026-08-24T06:43:00+02:00', 6, PROD_24_08), + soc_percent: 21, + }, + expect: { + usable: false, + missing: 'usable_capacity_kwh', + }, + }, +];