Chantier : le bilan lit la courbe horaire au lieu de l'enveloppe
RESOURCE_ESTIMATE consomme desormais la courbe horaire publiee par le workflow de prevision : forme et magnitude d'un seul tenant, sur 6 jours, avec les trois plans reels et sans censure. Plus de repartition au prorata, plus de renormalisation — le noeud se simplifie. L'enveloppe est retrogradee en repli, utilisee seulement si la courbe est absente ou vieille de plus d'une heure, le champ `mode` disant laquelle a servi. Elle garde un role de repere de vraisemblance mais n'ecrete PLUS la prevision : etant minoree l'apres-midi, elle tronquerait a tort une prevision correcte. Piege corrige au passage : le nouveau champ `source` entrait en collision avec l'objet `source` deja present dans la sortie et etait silencieusement ecrase. Renomme en `mode`. Suite portee a 35 scenarios (courbe fraiche lue telle quelle, courbe perimee basculant en repli). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
@@ -303,12 +303,17 @@ journée comme preuve du défaut de configuration était lui-même gonflé. Le d
|
|||||||
était réel — un seul plan plein sud, 15 kWp, plafonné à 8 kW — mais j'en ai
|
était réel — un seul plan plein sud, 15 kWp, plafonné à 8 kW — mais j'en ai
|
||||||
surestimé l'ampleur en le mesurant contre une référence biaisée.
|
surestimé l'ampleur en le mesurant contre une référence biaisée.
|
||||||
|
|
||||||
**Conséquence pour le code** : l'enveloppe doit disparaître de
|
**Conséquence appliquée le 25/08** : `RESOURCE_ESTIMATE` lit désormais la courbe
|
||||||
`RESOURCE_ESTIMATE` au profit de la courbe horaire de l'API
|
horaire de l'API, publiée par le workflow **EVCS Solar Forecast V1**
|
||||||
(`estimate/watthours/period`), disponible sur 6 jours. Elle apporte à la fois la
|
(`07P8I349ugna9FH4`, 15 min). Elle apporte forme et magnitude d'un seul tenant,
|
||||||
forme et la magnitude, ce qui **simplifie** le nœud et supprime la référence
|
sur 6 jours, avec les trois plans réels, sans aucune censure — le nœud s'en
|
||||||
défectueuse. L'enveloppe reste utile pour une seule chose : le plafond physique
|
trouve simplifié : plus de répartition au prorata, plus de renormalisation.
|
||||||
de vraisemblance.
|
|
||||||
|
L'enveloppe est **rétrogradée en repli** : elle sert encore si la courbe est
|
||||||
|
absente ou vieille de plus d'une heure, et le champ `mode` dit laquelle des deux
|
||||||
|
a servi (`courbe_horaire_api` ou `repli_enveloppe`). Elle garde par ailleurs un
|
||||||
|
rôle de repère de vraisemblance, **sans écrêtage** : étant minorée l'après-midi,
|
||||||
|
elle tronquerait à tort une prévision correcte.
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
"consumption_p80_csv": states("input_text.evcs_balance_consumption_p80"),
|
"consumption_p80_csv": states("input_text.evcs_balance_consumption_p80"),
|
||||||
"envelope_csv": states("input_text.evcs_balance_envelope"),
|
"envelope_csv": states("input_text.evcs_balance_envelope"),
|
||||||
"censorship_rate_csv": states("input_text.evcs_balance_censorship_rate"),
|
"censorship_rate_csv": states("input_text.evcs_balance_censorship_rate"),
|
||||||
|
|
||||||
|
"forecast_today_csv": states("input_text.evcs_balance_forecast_today"),
|
||||||
|
"forecast_tomorrow_csv": states("input_text.evcs_balance_forecast_tomorrow"),
|
||||||
|
"forecast_updated": states("input_text.evcs_balance_forecast_updated"),
|
||||||
"grid_soc_off": states("input_number.victron_grid_soc_off"),
|
"grid_soc_off": states("input_number.victron_grid_soc_off"),
|
||||||
|
|
||||||
"pv_power_w": states("sensor.victron_pv_power_system"),
|
"pv_power_w": states("sensor.victron_pv_power_system"),
|
||||||
|
|||||||
@@ -17,17 +17,25 @@
|
|||||||
// mesurés. L'installation a manifestement plusieurs orientations
|
// mesurés. L'installation a manifestement plusieurs orientations
|
||||||
// (trois trackers), qu'un plan unique ne peut pas représenter.
|
// (trois trackers), qu'un plan unique ne peut pas représenter.
|
||||||
//
|
//
|
||||||
// 3. RETENU — FORME = enveloppe horaire réellement démontrée (production
|
// 3. Forme = enveloppe démontrée, magnitude = total prévu — RETENU du 24 au
|
||||||
// maximale jamais observée à chaque heure). Elle encode le comportement
|
// 25/08, puis RÉTROGRADÉ EN REPLI. L'enveloppe étant un maximum horaire
|
||||||
// réel du champ, orientations multiples comprises, sans aucun modèle.
|
// pris sur des jours différents, elle surpondère le matin, dont la
|
||||||
// MAGNITUDE = prévision Forecast.Solar (énergie restante du jour), qui
|
// variabilité jour à jour est plus forte qu'à midi. Deux modèles
|
||||||
// porte la météo et ignore totalement l'état de notre batterie.
|
// indépendants (géométrique local et ciel clair de Forecast.Solar) la
|
||||||
|
// contredisent de la même façon : c'est elle qui a tort.
|
||||||
//
|
//
|
||||||
// LIMITE ASSUMÉE ET SIGNALÉE : l'enveloppe de l'après-midi est elle-même
|
// 4. RETENU (25/08) — COURBE HORAIRE de l'API Forecast.Solar, récupérée par
|
||||||
// censurée (77 à 96 % des heures après 13 h le sont), donc sous-estimée. Les
|
// le workflow "EVCS Solar Forecast V1" et publiée en CSV. Elle apporte
|
||||||
// heures du matin sont propres (censure de 0 à 4 %). Le champ `envelopeQuality`
|
// forme ET magnitude d'un seul tenant, sur 6 jours, avec les trois plans
|
||||||
// dit, heure par heure, si la forme est fiable ou minorée — à charge du
|
// réels de l'installation, et n'est censurée par rien. Le nœud s'en
|
||||||
// consommateur d'en tenir compte plutôt que de le découvrir trop tard.
|
// trouve simplifié : plus de répartition au prorata, plus de
|
||||||
|
// renormalisation.
|
||||||
|
//
|
||||||
|
// REPLI : si la courbe est absente ou périmée, on retombe sur la méthode 3
|
||||||
|
// (total prévu réparti sur l'enveloppe), en le signalant par `source`.
|
||||||
|
//
|
||||||
|
// L'enveloppe garde un seul rôle : repère de vraisemblance. Elle n'écrête PAS
|
||||||
|
// la prévision — étant minorée l'après-midi, elle tronquerait à tort.
|
||||||
//
|
//
|
||||||
// Ce nœud ne décide rien.
|
// Ce nœud ne décide rien.
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -64,6 +72,25 @@ const nowMs = num(input.nowMs, Date.now());
|
|||||||
const todayRemainingKwh = Math.max(0, num(input.forecast_today_remaining_kwh, 0));
|
const todayRemainingKwh = Math.max(0, num(input.forecast_today_remaining_kwh, 0));
|
||||||
const tomorrowKwh = Math.max(0, num(input.forecast_tomorrow_kwh, 0));
|
const tomorrowKwh = Math.max(0, num(input.forecast_tomorrow_kwh, 0));
|
||||||
|
|
||||||
|
// Fraîcheur de la courbe : le workflow de prévision tourne au quart d'heure.
|
||||||
|
// Au-delà d'une heure sans mise à jour, on considère la courbe périmée et on
|
||||||
|
// repasse au repli plutôt que de projeter sur une météo dépassée.
|
||||||
|
const FRESHNESS_LIMIT_MIN = 60;
|
||||||
|
function parseStampParis(text) {
|
||||||
|
const m = /^(\d{2})\/(\d{2})\s+(\d{2}):(\d{2})$/.exec((text || '').toString().trim());
|
||||||
|
if (!m) return NaN;
|
||||||
|
const now = new Date(nowMs);
|
||||||
|
const guess = new Date(Date.UTC(now.getUTCFullYear(), +m[2] - 1, +m[1], +m[3] - 2, +m[4]));
|
||||||
|
// Passage d'année : une date future de plus d'un mois appartient à l'an dernier.
|
||||||
|
if (guess.getTime() - nowMs > 31 * 86400000) guess.setUTCFullYear(guess.getUTCFullYear() - 1);
|
||||||
|
return guess.getTime();
|
||||||
|
}
|
||||||
|
const forecastUpdatedMs = parseStampParis(input.forecast_updated);
|
||||||
|
const forecastAgeMin = Number.isFinite(forecastUpdatedMs)
|
||||||
|
? (nowMs - forecastUpdatedMs) / 60000
|
||||||
|
: Infinity;
|
||||||
|
const forecastFresh = forecastAgeMin <= FRESHNESS_LIMIT_MIN;
|
||||||
|
|
||||||
// Enveloppe et taux de censure par heure : produits par le calcul quotidien et
|
// Enveloppe et taux de censure par heure : produits par le calcul quotidien et
|
||||||
// stockés dans des helpers input_text sous forme de CSV compact. On accepte
|
// stockés dans des helpers input_text sous forme de CSV compact. On accepte
|
||||||
// aussi le tableau déjà décodé, ce qui rend le nœud testable hors n8n.
|
// aussi le tableau déjà décodé, ce qui rend le nœud testable hors n8n.
|
||||||
@@ -78,6 +105,10 @@ function read24(arrayKey, csvKey) {
|
|||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hourlyToday = read24('forecast_today_kwh', 'forecast_today_csv');
|
||||||
|
const hourlyTomorrow = read24('forecast_tomorrow_kwh', 'forecast_tomorrow_csv');
|
||||||
|
const hourlyAvailable = !!hourlyToday && forecastFresh;
|
||||||
|
|
||||||
const envelopeRaw = read24('envelope_kwh', 'envelope_csv');
|
const envelopeRaw = read24('envelope_kwh', 'envelope_csv');
|
||||||
const envelope = envelopeRaw ? envelopeRaw.map(v => Math.max(0, num(v, 0))) : null;
|
const envelope = envelopeRaw ? envelopeRaw.map(v => Math.max(0, num(v, 0))) : null;
|
||||||
|
|
||||||
@@ -125,8 +156,22 @@ function distribute(daySlots, energyKwh) {
|
|||||||
|
|
||||||
const todaySlots = slots.filter(s => s.day === today);
|
const todaySlots = slots.filter(s => s.day === today);
|
||||||
const tomorrowSlots = slots.filter(s => s.day !== today);
|
const tomorrowSlots = slots.filter(s => s.day !== today);
|
||||||
|
|
||||||
|
if (hourlyAvailable) {
|
||||||
|
// Lecture directe : chaque heure porte déjà son énergie prévue.
|
||||||
|
for (const slot of todaySlots) slot.expectedKwh = Math.max(0, hourlyToday[slot.hour] ?? 0);
|
||||||
|
const tom = hourlyTomorrow || hourlyToday;
|
||||||
|
for (const slot of tomorrowSlots) slot.expectedKwh = Math.max(0, tom[slot.hour] ?? 0);
|
||||||
|
} else {
|
||||||
distribute(todaySlots, todayRemainingKwh);
|
distribute(todaySlots, todayRemainingKwh);
|
||||||
distribute(tomorrowSlots, tomorrowKwh);
|
distribute(tomorrowSlots, tomorrowKwh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repère de vraisemblance, sans écrêtage : l'enveloppe est minorée l'après-midi.
|
||||||
|
for (const slot of slots) {
|
||||||
|
slot.aboveEnvelope = !!envelope && envelope[slot.hour] > 0
|
||||||
|
&& slot.expectedKwh > envelope[slot.hour] * 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
// Part de l'énergie projetée qui tombe sur des heures dont la forme est minorée :
|
// Part de l'énergie projetée qui tombe sur des heures dont la forme est minorée :
|
||||||
// c'est la mesure de confiance de cette projection.
|
// c'est la mesure de confiance de cette projection.
|
||||||
@@ -138,7 +183,10 @@ const projectedOnWeakShape = slots
|
|||||||
return [{
|
return [{
|
||||||
json: {
|
json: {
|
||||||
generatedAt: new Date(nowMs).toISOString(),
|
generatedAt: new Date(nowMs).toISOString(),
|
||||||
usable: !!envelope,
|
usable: hourlyAvailable || !!envelope,
|
||||||
|
// Quelle source a réellement fourni la courbe ? (`source` ci-dessous est
|
||||||
|
// le détail des entrées ; `mode` dit laquelle a gagné.)
|
||||||
|
mode: hourlyAvailable ? 'courbe_horaire_api' : 'repli_enveloppe',
|
||||||
|
|
||||||
horizon: slots.map(s => ({
|
horizon: slots.map(s => ({
|
||||||
startMs: s.startMs,
|
startMs: s.startMs,
|
||||||
@@ -155,6 +203,10 @@ return [{
|
|||||||
todayRemainingKwh: round2(todayRemainingKwh),
|
todayRemainingKwh: round2(todayRemainingKwh),
|
||||||
tomorrowKwh: round2(tomorrowKwh),
|
tomorrowKwh: round2(tomorrowKwh),
|
||||||
envelopeProvided: !!envelope,
|
envelopeProvided: !!envelope,
|
||||||
|
hourlyCurveAvailable: !!hourlyToday,
|
||||||
|
forecastAgeMin: Number.isFinite(forecastAgeMin) ? Math.round(forecastAgeMin) : null,
|
||||||
|
forecastFresh,
|
||||||
|
hoursAboveEnvelope: slots.filter(s => s.aboveEnvelope).length,
|
||||||
shapeConfidence: projected > 0 ? round2(1 - projectedOnWeakShape / projected) : null,
|
shapeConfidence: projected > 0 ? round2(1 - projectedOnWeakShape / projected) : null,
|
||||||
weakShapeHours: slots
|
weakShapeHours: slots
|
||||||
.filter(x => x.quality === 'minoree' && x.expectedKwh > 0)
|
.filter(x => x.quality === 'minoree' && x.expectedKwh > 0)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const SUMMARIZE = {
|
|||||||
}),
|
}),
|
||||||
RESOURCE_ESTIMATE: o => ({
|
RESOURCE_ESTIMATE: o => ({
|
||||||
usable: o.usable,
|
usable: o.usable,
|
||||||
|
mode: o.mode,
|
||||||
todayExpected: o.todayExpectedRemainingKwh,
|
todayExpected: o.todayExpectedRemainingKwh,
|
||||||
tomorrowExpected: o.tomorrowExpectedKwh,
|
tomorrowExpected: o.tomorrowExpectedKwh,
|
||||||
shapeConfidenceBelow: o.source.shapeConfidence != null && o.source.shapeConfidence < 0.5,
|
shapeConfidenceBelow: o.source.shapeConfidence != null && o.source.shapeConfidence < 0.5,
|
||||||
|
|||||||
@@ -126,6 +126,47 @@ export const resourceScenarios = [
|
|||||||
todayExpected: 30,
|
todayExpected: 30,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
node: 'RESOURCE_ESTIMATE',
|
||||||
|
id: 'ressource-courbe-horaire',
|
||||||
|
label: 'Courbe horaire fraîche : lue telle quelle, sans répartition ni renormalisation',
|
||||||
|
snapshot: {
|
||||||
|
nowMs: Date.parse('2026-08-25T09:30:00+02:00'),
|
||||||
|
forecast_today_remaining_kwh: '999',
|
||||||
|
forecast_tomorrow_kwh: '999',
|
||||||
|
forecast_updated: '25/08 09:30',
|
||||||
|
// Relevé réel du 25/08 : 43,6 kWh sur la journée.
|
||||||
|
forecast_today_csv: '0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.07,0.63,1.60,3.50,6.50,7.90,7.00,4.60,3.30,2.70,2.20,1.70,1.10,0.60,0.10,0.00,0.00',
|
||||||
|
forecast_tomorrow_csv: '0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.09,0.77,1.60,2.27,2.81,3.63,4.24,3.52,2.95,2.59,2.21,1.69,1.08,0.47,0.05,0.00,0.00',
|
||||||
|
envelope_kwh: envelope.envelope_kwh,
|
||||||
|
censorship_rate: envelope.censorship_rate,
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
usable: true,
|
||||||
|
mode: 'courbe_horaire_api',
|
||||||
|
// Reste de la journée à partir de 09 h : lu dans la courbe, pas recalculé.
|
||||||
|
todayExpected: 42.8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'RESOURCE_ESTIMATE',
|
||||||
|
id: 'ressource-courbe-perimee',
|
||||||
|
label: 'Courbe vieille de plus d\'une heure : repli sur l\'enveloppe, signalé',
|
||||||
|
snapshot: {
|
||||||
|
nowMs: Date.parse('2026-08-25T12:30:00+02:00'),
|
||||||
|
forecast_today_remaining_kwh: '15.9',
|
||||||
|
forecast_tomorrow_kwh: '29.1',
|
||||||
|
forecast_updated: '25/08 09:30',
|
||||||
|
forecast_today_csv: '0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.07,0.63,1.60,3.50,6.50,7.90,7.00,4.60,3.30,2.70,2.20,1.70,1.10,0.60,0.10,0.00,0.00',
|
||||||
|
envelope_kwh: envelope.envelope_kwh,
|
||||||
|
censorship_rate: envelope.censorship_rate,
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
usable: true,
|
||||||
|
mode: 'repli_enveloppe',
|
||||||
|
todayExpected: 15.9,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
node: 'RESOURCE_ESTIMATE',
|
node: 'RESOURCE_ESTIMATE',
|
||||||
id: 'ressource-sans-enveloppe',
|
id: 'ressource-sans-enveloppe',
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user