Chantier etapes 5b et 5c : recalcul quotidien des profils et fenetre de deficit suivante

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
This commit is contained in:
Tallagrand Thierry
2026-08-24 15:58:10 +02:00
parent e36f050a42
commit 6dc5ea25cb
14 changed files with 668 additions and 22 deletions

View File

@@ -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),
},

View File

@@ -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.

View File

@@ -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;

View File

@@ -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),