Files
EVCS/src/energy-balance/RESOURCE_ESTIMATE.js
Tallagrand Thierry 8ad34f8c93 Bilan : une heure de nuit n'a aucune forme a minorer
Le drapeau « degrade » introduit la veille s'allumait de 20 h a 07 h toutes les
nuits. La qualite de forme d'une heure vient du taux de censure ; les heures
sans soleil portent un taux de 1 faute d'avoir quoi que ce soit a observer, pas
parce que la mesure serait douteuse. Comme les heures de deficit sont justement
les heures sans soleil, le drapeau se declenchait exactement quand le bilan
sert.

Une heure dont l'enveloppe demontree est nulle recoit desormais la qualite
'nuit'. Scenario epingle sur l'enveloppe et le taux de censure releves en
production, pas sur le fixture — dont la nuit valait 0 et qui ne reproduisait
donc pas le defaut. Preuve par mutation : avant 'minoree', apres 'nuit'.

Suite 37 -> 38. Documente la nuit du 25-26/08 : profil corrige verifie a
0,2 kWh/h pres, bilan qui predit le point bas a un point pres, et non-linearite
du SOC mesuree (huit points en dix-sept minutes pour 0,16 kWh).
2026-08-26 12:14:35 +02:00

235 lines
10 KiB
JavaScript

// EVCS Energy Balance — RESOURCE_ESTIMATE
// =====================================================================
// Rôle : estimer la production PV attendue heure par heure, en RESSOURCE
// (ce que l'installation peut produire) et non en mesure censurée.
//
// Trois sources ont été évaluées le 24/08/2026, deux ont été écartées :
//
// 1. Recalage de la prévision sur les heures non censurées — ÉCARTÉ.
// La censure est corrélée au beau temps : les heures exploitables de
// l'après-midi ne le sont que les jours médiocres. Le facteur mesuré va
// de 4,29 à 9 h (27 échantillons) à 0,48 à 15 h (3 échantillons).
// Données manquantes non aléatoires, aucun facteur transportable.
//
// 2. Forme issue de la géométrie solaire — ÉCARTÉ. Un modèle mono-plan
// ajusté sur les heures propres converge sur « plein est, 20° » et
// prédit alors 2,0 % de la journée à 18 h contre 5,8 % réellement
// mesurés. L'installation a manifestement plusieurs orientations
// (trois trackers), qu'un plan unique ne peut pas représenter.
//
// 3. Forme = enveloppe démontrée, magnitude = total prévu — RETENU du 24 au
// 25/08, puis RÉTROGRADÉ EN REPLI. L'enveloppe étant un maximum horaire
// pris sur des jours différents, elle surpondère le matin, dont la
// variabilité jour à jour est plus forte qu'à midi. Deux modèles
// 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.
//
// 4. RETENU (25/08) — COURBE HORAIRE de l'API Forecast.Solar, récupérée par
// le workflow "EVCS Solar Forecast V1" et publiée en CSV. Elle apporte
// forme ET magnitude d'un seul tenant, sur 6 jours, avec les trois plans
// réels de l'installation, et n'est censurée par rien. Le nœud s'en
// 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.
// =====================================================================
function num(v, fallback = NaN) {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function round2(v) {
return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;
}
// Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de
// chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux.
function unwrapSnapshot(raw) {
if (raw && typeof raw.data === 'string') {
try { return JSON.parse(raw.data); } catch { return raw; }
}
return (raw && raw.data) || raw;
}
const input = unwrapSnapshot($input.first().json);
const CFG = {
TIMEZONE: 'Europe/Paris',
// 40 h : assez pour qu'une traversée déficitaire commençant en fin
// d'après-midi se referme au croisement du lendemain matin. Avec 30 h, la
// fenêtre de la nuit restait ouverte et son heure de fin s'affichait « ? ».
// La courbe couvre aujourd'hui et demain, soit 48 h au plus.
HORIZON_HOURS: 40,
// Au-delà de cette part d'heures censurées, la forme de l'enveloppe est
// considérée comme minorée et signalée comme telle.
CENSORSHIP_QUALITY_LIMIT: 0.25,
};
const nowMs = num(input.nowMs, Date.now());
const todayRemainingKwh = Math.max(0, num(input.forecast_today_remaining_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
// 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.
// Absents au premier démarrage → on le dit, on ne fabrique pas de forme.
function read24(arrayKey, csvKey) {
const direct = input[arrayKey];
if (Array.isArray(direct) && direct.length === 24) return direct.map(v => num(v, NaN));
const csv = input[csvKey];
if (typeof csv !== 'string') return null;
const parts = csv.split(',').map(v => Number.parseFloat(v.trim()));
if (parts.length !== 24 || parts.some(v => !Number.isFinite(v))) return null;
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 envelope = envelopeRaw ? envelopeRaw.map(v => Math.max(0, num(v, 0))) : null;
const censorshipRaw = read24('censorship_rate', 'censorship_rate_csv');
const censorshipRate = censorshipRaw
? censorshipRaw.map(v => Math.max(0, Math.min(1, num(v, 1))))
: Array.from({ length: 24 }, () => 1);
const hourOfDay = (ms) =>
Number.parseInt(
new Intl.DateTimeFormat('fr-FR', {
timeZone: CFG.TIMEZONE, hour: '2-digit', hour12: false,
}).format(new Date(ms)),
10
);
const dayKey = (ms) =>
new Intl.DateTimeFormat('fr-CA', {
timeZone: CFG.TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date(ms));
const currentHourStart = Math.floor(nowMs / 3600000) * 3600000;
const today = dayKey(nowMs);
// Une heure ou l'enveloppe demontree est nulle est une heure de nuit : il n'y
// a jamais eu de soleil a y observer. Son taux de censure vaut 1 pour cette
// raison-la, pas parce que la mesure serait douteuse, et il n'existe aucune
// forme a minorer. La classer « minoree » faisait crier au loup TOUTES les
// nuits (les heures de deficit sont justement les heures sans soleil), donc
// exactement quand le bilan sert. Constate sur la nuit du 25-26/08 : statut
// « degrade » de 20 h a 07 h alors que le bilan a predit le point bas a un
// point pres.
const slots = [];
for (let i = 0; i < CFG.HORIZON_HOURS; i++) {
const start = currentHourStart + i * 3600000;
const hour = hourOfDay(start);
const nightHour = !!envelope && !(envelope[hour] > 0);
slots.push({
startMs: start,
hour,
day: dayKey(start),
weight: envelope ? envelope[hour] : 0,
quality: nightHour
? 'nuit'
: censorshipRate[hour] <= CFG.CENSORSHIP_QUALITY_LIMIT ? 'fiable' : 'minoree',
});
}
// Répartition de l'énergie prévue au prorata de la forme démontrée.
function distribute(daySlots, energyKwh) {
const totalWeight = daySlots.reduce((s, x) => s + x.weight, 0);
for (const slot of daySlots) {
slot.expectedKwh = totalWeight > 0 ? (slot.weight / totalWeight) * energyKwh : 0;
}
}
const todaySlots = 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(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 :
// c'est la mesure de confiance de cette projection.
const projected = slots.reduce((s, x) => s + x.expectedKwh, 0);
const projectedOnWeakShape = slots
.filter(x => x.quality === 'minoree')
.reduce((s, x) => s + x.expectedKwh, 0);
return [{
json: {
generatedAt: new Date(nowMs).toISOString(),
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 => ({
startMs: s.startMs,
hour: s.hour,
day: s.day,
expectedKwh: round2(s.expectedKwh),
shapeQuality: s.quality,
})),
todayExpectedRemainingKwh: round2(todaySlots.reduce((s, x) => s + x.expectedKwh, 0)),
tomorrowExpectedKwh: round2(tomorrowSlots.reduce((s, x) => s + x.expectedKwh, 0)),
source: {
todayRemainingKwh: round2(todayRemainingKwh),
tomorrowKwh: round2(tomorrowKwh),
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,
weakShapeHours: slots
.filter(x => x.quality === 'minoree' && x.expectedKwh > 0)
.map(x => x.hour)
.filter((h, i, a) => a.indexOf(h) === i),
},
cfg: CFG,
},
}];