Chantier etape 4 : le bilan, et description materielle consignee
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
216
src/energy-balance/BALANCE.js
Normal file
216
src/energy-balance/BALANCE.js
Normal file
@@ -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,
|
||||
},
|
||||
}];
|
||||
Reference in New Issue
Block a user