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', + }, + }, +];