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:
@@ -188,7 +188,30 @@ calcul en saison basse.
|
||||
|
||||
Suite portée à **21 scénarios**.
|
||||
|
||||
- [ ] Étape 5 — publication dans n8n (workflow + helpers), puis dashboard.
|
||||
- [x] **Étape 5 — publication en mode ombre** (24/08). Workflow n8n
|
||||
**EVCS Energy Balance V1** (`QoSfPeyGq35orF1K`), actif, cycle 5 min.
|
||||
Périmètre d'écriture garanti par test de mutation : l'injection d'un
|
||||
`turn_on` sur `switch.edf_general_ok_switch` fait rougir la suite.
|
||||
Kill-switch `input_boolean.evcs_balance_enabled`.
|
||||
- [x] **Étape 5b — recalcul quotidien des profils** (24/08). Workflow
|
||||
**EVCS Energy Profiles Daily V1** (`N6UaB4ZozsbRPrFb`), actif, 03:30.
|
||||
Lit `/api/history/period` et republie les quatre CSV.
|
||||
⚠️ Piège trouvé : **sans `end_time`, l'API ne renvoie qu'UNE journée**, en
|
||||
silence. Mes trois premières mesures « sur 3 jours » n'en couvraient qu'une.
|
||||
Refus explicite d'écraser les profils si l'historique ne couvre pas les 24 h.
|
||||
**Validation croisée** : profils calculés depuis l'API REST (états bruts,
|
||||
11 jours) contre profils calculés depuis les statistiques long terme
|
||||
(28 jours) → **0,20 kWh/h d'écart absolu moyen**, deux chemins de données
|
||||
totalement indépendants.
|
||||
- [x] **Étape 5c — prochaine fenêtre de déficit** (24/08). Le bilan ne
|
||||
répondait qu'à « combien jusqu'à ce que le solaire prenne le relais » : à
|
||||
15 h la réponse est zéro, exacte mais inutile, car le déficit qui approche
|
||||
est celui de la nuit. Ajout de la prochaine traversée déficitaire — heure de
|
||||
début, énergie, SOC à atteindre avant. **Ce SOC n'est volontairement pas
|
||||
écrêté à 100 %** : au-delà, même une batterie pleine ne passe pas, et l'écart
|
||||
est publié comme `storage_shortfall_kwh`. C'est le chiffre du dimensionnement
|
||||
du stockage.
|
||||
- [ ] Étape 6 — dashboard, après quelques jours d'observation.
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
62
src/energy-balance/BUILD_HA_CALLS_PROFILES.js
Normal file
62
src/energy-balance/BUILD_HA_CALLS_PROFILES.js
Normal 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;
|
||||
@@ -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),
|
||||
@@ -12,7 +12,7 @@ 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';
|
||||
import { shadowScenarios } from './scenarios-shadow.mjs';
|
||||
import { shadowScenarios, profilesShadowScenarios } from './scenarios-shadow.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
@@ -36,7 +36,7 @@ function runNode(nodeName, snapshot) {
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const out = vm.runInContext(`(function(){ ${codeCache.get(nodeName)} })()`, sandbox, { timeout: 15000 });
|
||||
return nodeName === 'BUILD_HA_CALLS' ? out : out[0].json;
|
||||
return nodeName.startsWith('BUILD_HA_CALLS') ? out : out[0].json;
|
||||
}
|
||||
|
||||
const SUMMARIZE = {
|
||||
@@ -47,7 +47,7 @@ const SUMMARIZE = {
|
||||
healthOk: o.health.ok,
|
||||
alerts: o.alerts.join('|'),
|
||||
}),
|
||||
CONSUMPTION_PROFILE: o => ({
|
||||
DAILY_PROFILES: o => ({
|
||||
days: o.days,
|
||||
complete: o.complete,
|
||||
hours: o.profile.length,
|
||||
@@ -72,6 +72,11 @@ const SUMMARIZE = {
|
||||
gridNeeded: o.coverage.gridNeeded,
|
||||
prudentRequiredSocPct: o.coverage.prudent.requiredSocPct,
|
||||
deficitTrustworthy: o.confidence.deficitTrustworthy,
|
||||
nextDeficitFound: o.nextDeficit.found,
|
||||
nextDeficitStartHour: o.nextDeficit.found ? o.nextDeficit.startHour : null,
|
||||
nextDeficitEnergyKwh: o.nextDeficit.found ? o.nextDeficit.energyKwh : null,
|
||||
nextDeficitRequiredSocPct: o.nextDeficit.found ? o.nextDeficit.requiredSocAtStartPct : null,
|
||||
storageShortfallKwh: o.nextDeficit.found ? Math.round(o.nextDeficit.storageShortfallKwh * 100) / 100 : null,
|
||||
}) : ({ usable: false, missing: o.missing.join(',') }),
|
||||
};
|
||||
|
||||
@@ -86,8 +91,10 @@ SUMMARIZE.BUILD_HA_CALLS = (calls) => {
|
||||
wroteRequiredSoc: entities.includes('input_number.evcs_balance_required_soc'),
|
||||
wroteGridNeeded: entities.includes('input_boolean.evcs_balance_grid_needed'),
|
||||
hasLogbook: calls.some(c => c.json.domain === 'logbook'),
|
||||
wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'),
|
||||
};
|
||||
};
|
||||
SUMMARIZE.BUILD_HA_CALLS_PROFILES = SUMMARIZE.BUILD_HA_CALLS;
|
||||
|
||||
const all = [
|
||||
...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })),
|
||||
@@ -95,6 +102,7 @@ const all = [
|
||||
...resourceScenarios,
|
||||
...balanceScenarios,
|
||||
...shadowScenarios,
|
||||
...profilesShadowScenarios,
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
@@ -97,6 +97,38 @@ export const balanceScenarios = [
|
||||
gridNeeded: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'BALANCE',
|
||||
id: 'bilan-prochaine-fenetre-deficit',
|
||||
label: 'Projection à 15 h : le solaire couvre déjà, mais la nuit qui vient doit être annoncée',
|
||||
snapshot: {
|
||||
nowMs: Date.parse('2026-08-24T15:30:00+02:00'),
|
||||
consumption_p50: P50,
|
||||
consumption_p80: P80,
|
||||
horizon: horizonFrom('2026-08-24T15:30:00+02:00', 24, {
|
||||
...PROD_24_08,
|
||||
15: 3.00, 16: 2.80, 17: 2.60, 18: 2.40, 19: 1.20, 20: 0.20,
|
||||
// lendemain matin
|
||||
7: 1.50, 8: 3.00, 9: 5.00,
|
||||
}),
|
||||
soc_percent: 95,
|
||||
usable_capacity_kwh: 15,
|
||||
soc_floor_percent: 22,
|
||||
},
|
||||
expect: {
|
||||
usable: true,
|
||||
// fenêtre en cours : le solaire couvre, rien à franchir
|
||||
gridNeeded: false,
|
||||
// mais la nuit qui vient doit être chiffrée
|
||||
nextDeficitFound: true,
|
||||
nextDeficitStartHour: 19,
|
||||
nextDeficitEnergyKwh: 12.77,
|
||||
// Au-dessus de 100 : même pleine, la batterie actuelle ne passe pas la
|
||||
// nuit sur le profil prudent. C'est le chiffre du dimensionnement.
|
||||
nextDeficitRequiredSocPct: 107.1,
|
||||
storageShortfallKwh: 1.07,
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'BALANCE',
|
||||
id: 'bilan-entrees-manquantes',
|
||||
|
||||
@@ -31,7 +31,7 @@ function syntheticSeries({ days = 10, houseKwhPerHour = 1, evcsKwhAt14h = 3 } =
|
||||
|
||||
export const profileScenarios = [
|
||||
{
|
||||
node: 'CONSUMPTION_PROFILE',
|
||||
node: 'DAILY_PROFILES',
|
||||
id: 'profil-historique-reel',
|
||||
label: '28 jours réels : profil complet sur 24 heures, aucun compteur remis à zéro',
|
||||
snapshot: { series: history.series, nowMs: NOW },
|
||||
@@ -45,7 +45,7 @@ export const profileScenarios = [
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'CONSUMPTION_PROFILE',
|
||||
node: 'DAILY_PROFILES',
|
||||
id: 'evcs-retire-du-profil',
|
||||
label: 'La charge du véhicule est retirée : le profil reste plat à 1 kWh/h, y compris à 14 h',
|
||||
snapshot: { series: syntheticSeries(), nowMs: NOW },
|
||||
@@ -56,7 +56,7 @@ export const profileScenarios = [
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'CONSUMPTION_PROFILE',
|
||||
node: 'DAILY_PROFILES',
|
||||
id: 'compteur-remis-a-zero',
|
||||
label: 'Remise à zéro du compteur (redémarrage HA) : l\'échantillon est jeté, pas inventé',
|
||||
snapshot: (() => {
|
||||
@@ -74,7 +74,7 @@ export const profileScenarios = [
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'CONSUMPTION_PROFILE',
|
||||
node: 'DAILY_PROFILES',
|
||||
id: 'historique-trop-court',
|
||||
label: '3 jours seulement : les heures sont marquées non fiables plutôt que publiées en silence',
|
||||
snapshot: { series: syntheticSeries({ days: 3 }), nowMs: NOW },
|
||||
|
||||
@@ -79,3 +79,32 @@ export const shadowScenarios = [
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// --- BUILD_HA_CALLS_PROFILES (workflow quotidien) ------------------------
|
||||
const PROFILES_OK = {
|
||||
complete: true,
|
||||
days: 28,
|
||||
csv: { p50: '1,'.repeat(23) + '1', p80: '2,'.repeat(23) + '2',
|
||||
envelope: '3,'.repeat(23) + '3', censorshipRate: '0,'.repeat(23) + '0' },
|
||||
totals: { typicalDailyKwh: 31.9, prudentDailyKwh: 42.8 },
|
||||
production: { demonstratedDailyKwh: 74.4, censoredDayHours: 8 },
|
||||
quality: { hoursUsed: 639, pvHoursUsed: 620, countersReset: 0, unreliableHours: [] },
|
||||
};
|
||||
|
||||
export const profilesShadowScenarios = [
|
||||
{
|
||||
node: 'BUILD_HA_CALLS_PROFILES',
|
||||
id: 'profils-perimetre-respecte',
|
||||
label: 'Publication des profils : périmètre evcs_balance_* respecté',
|
||||
snapshot: PROFILES_OK,
|
||||
expect: { outOfScope: '', callCount: 6, hasLogbook: true },
|
||||
},
|
||||
{
|
||||
node: 'BUILD_HA_CALLS_PROFILES',
|
||||
id: 'profils-historique-insuffisant',
|
||||
label: 'Historique trop court : les profils en place ne sont PAS écrasés',
|
||||
snapshot: { ...PROFILES_OK, complete: false, days: 2,
|
||||
quality: { ...PROFILES_OK.quality, unreliableHours: [3, 4, 5] } },
|
||||
expect: { outOfScope: '', wroteProfiles: false },
|
||||
},
|
||||
];
|
||||
|
||||
115
tools/gen-energy-profiles-workflow.mjs
Normal file
115
tools/gen-energy-profiles-workflow.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
// Génère le squelette du workflow "EVCS Energy Profiles Daily V1" : recalcul
|
||||
// quotidien des profils horaires (consommation, enveloppe de production, taux
|
||||
// de censure) à partir de l'historique Home Assistant.
|
||||
// Le CODE des nodes est injecté depuis src/energy-balance/ par n8n-build.mjs.
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
const HA_CRED = { id: 'uv3sknYYigxBkaVN', name: 'Bearer Auth account 2' };
|
||||
const HA_URL = 'http://10.10.10.17:8123';
|
||||
|
||||
const ENTITIES = [
|
||||
'sensor.airtria_mpii_v1_energy_from_inverter_to_ac_out',
|
||||
'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out',
|
||||
'sensor.evcs_hq2239dnt7p_total_energy',
|
||||
'sensor.gx_device_pv_energy',
|
||||
'sensor.victron_battery_soc',
|
||||
].join(',');
|
||||
|
||||
// Fenêtre demandée : 30 jours. L'API ne renvoie que ce que la rétention
|
||||
// conserve (10 jours au 24/08, 90 une fois la nouvelle rétention accumulée),
|
||||
// donc la fenêtre s'élargira d'elle-même sans rien changer ici.
|
||||
// ⚠️ `end_time` est OBLIGATOIRE : sans lui, /api/history/period ne renvoie
|
||||
// qu'UNE SEULE journée, silencieusement.
|
||||
const HISTORY_URL =
|
||||
"={{ '" + HA_URL + "/api/history/period/' " +
|
||||
'+ new Date(Date.now() - 30 * 86400000).toISOString() ' +
|
||||
"+ '?end_time=' + encodeURIComponent(new Date().toISOString()) " +
|
||||
"+ '&filter_entity_id=" + ENTITIES + "' " +
|
||||
"+ '&minimal_response&significant_changes_only=0' }}";
|
||||
|
||||
const nodes = [
|
||||
{
|
||||
name: 'Schedule Daily',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1.2,
|
||||
position: [0, 0],
|
||||
parameters: { rule: { interval: [{ field: 'days', triggerAtHour: 3, triggerAtMinute: 30 }] } },
|
||||
},
|
||||
{
|
||||
name: 'HA History',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [220, 0],
|
||||
parameters: {
|
||||
method: 'GET',
|
||||
url: HISTORY_URL,
|
||||
authentication: 'genericCredentialType',
|
||||
genericAuthType: 'httpBearerAuth',
|
||||
options: {
|
||||
timeout: 120000,
|
||||
response: { response: { responseFormat: 'text' } },
|
||||
},
|
||||
},
|
||||
credentials: { httpBearerAuth: HA_CRED },
|
||||
},
|
||||
{
|
||||
name: 'DAILY_PROFILES',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [440, 0],
|
||||
parameters: { jsCode: '// injecté depuis src/energy-balance/DAILY_PROFILES.js' },
|
||||
},
|
||||
{
|
||||
name: 'BUILD_HA_CALLS_PROFILES',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [660, 0],
|
||||
parameters: { jsCode: '// injecté depuis src/energy-balance/BUILD_HA_CALLS_PROFILES.js' },
|
||||
},
|
||||
{
|
||||
name: 'Execute HA Service Profiles',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [880, 0],
|
||||
parameters: {
|
||||
method: 'POST',
|
||||
url: `={{ '${HA_URL}/api/services/' + $json.domain + '/' + $json.service }}`,
|
||||
authentication: 'genericCredentialType',
|
||||
genericAuthType: 'httpBearerAuth',
|
||||
sendHeaders: true,
|
||||
headerParameters: {
|
||||
parameters: [
|
||||
{ name: 'Content-Type', value: 'application/json' },
|
||||
{ name: 'accept', value: 'application/json' },
|
||||
],
|
||||
},
|
||||
sendBody: true,
|
||||
specifyBody: 'json',
|
||||
jsonBody: '={{ $json.data }}',
|
||||
options: { timeout: 15000 },
|
||||
},
|
||||
credentials: { httpBearerAuth: HA_CRED },
|
||||
onError: 'continueRegularOutput',
|
||||
},
|
||||
];
|
||||
|
||||
const connections = {
|
||||
'Schedule Daily': { main: [[{ node: 'HA History', type: 'main', index: 0 }]] },
|
||||
'HA History': { main: [[{ node: 'DAILY_PROFILES', type: 'main', index: 0 }]] },
|
||||
DAILY_PROFILES: { main: [[{ node: 'BUILD_HA_CALLS_PROFILES', type: 'main', index: 0 }]] },
|
||||
BUILD_HA_CALLS_PROFILES: { main: [[{ node: 'Execute HA Service Profiles', type: 'main', index: 0 }]] },
|
||||
};
|
||||
|
||||
const workflow = {
|
||||
id: 'N6UaB4ZozsbRPrFb',
|
||||
name: 'EVCS Energy Profiles Daily V1',
|
||||
nodes,
|
||||
connections,
|
||||
settings: { executionOrder: 'v1', callerPolicy: 'workflowsFromSameOwner' },
|
||||
staticData: null,
|
||||
};
|
||||
|
||||
fs.writeFileSync('workflows/evcs-energy-profiles-daily-v1.json', JSON.stringify(workflow, null, 2) + '\n');
|
||||
console.log(`Squelette écrit : workflows/evcs-energy-profiles-daily-v1.json (${nodes.length} nodes)`);
|
||||
@@ -41,6 +41,15 @@ export const WORKFLOWS = [
|
||||
{ file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Balance', kind: 'template' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'energy-profiles',
|
||||
workflowFile: 'workflows/evcs-energy-profiles-daily-v1.json',
|
||||
srcDir: 'src/energy-balance',
|
||||
nodes: [
|
||||
{ file: 'DAILY_PROFILES.js', node: 'DAILY_PROFILES', kind: 'jsCode' },
|
||||
{ file: 'BUILD_HA_CALLS_PROFILES.js', node: 'BUILD_HA_CALLS_PROFILES', kind: 'jsCode' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Rétro-compatibilité (outils historiques mono-workflow)
|
||||
|
||||
@@ -109,7 +109,8 @@ console.log('Syntaxe OK sur tous les nodes jsCode.');
|
||||
const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json';
|
||||
const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json';
|
||||
const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json';
|
||||
const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json';
|
||||
const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json'
|
||||
|| path.basename(INPUT) === 'evcs-energy-profiles-daily-v1.json';
|
||||
const skipGolden = process.argv.includes('--skip-golden');
|
||||
const testSuite =
|
||||
isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' :
|
||||
|
||||
File diff suppressed because one or more lines are too long
171
workflows/evcs-energy-profiles-daily-v1.json
Normal file
171
workflows/evcs-energy-profiles-daily-v1.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user