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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
292 lines
12 KiB
JavaScript
292 lines
12 KiB
JavaScript
// 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));
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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;
|
|
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,
|
|
},
|
|
},
|
|
|
|
// 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),
|
|
},
|
|
|
|
confidence,
|
|
slots,
|
|
|
|
inputs: {
|
|
usableCapacityKwh: round2(usableCapacityKwh),
|
|
socFloorPct: round1(socFloorPct),
|
|
},
|
|
|
|
cfg: CFG,
|
|
},
|
|
}];
|