Chantier etape 3 : estimateur de ressource PV, apres deux approches ecartees

RESOURCE_ESTIMATE.js. Recalage de la prevision : ecarte, la censure est
correlee au beau temps (facteur 4,29 a 9 h sur 27 echantillons contre 0,48 a
15 h sur 3), les donnees manquantes ne le sont pas au hasard. Forme
geometrique : ecartee, un plan unique ajuste sur les heures propres converge
sur plein est 20 degres et predit 2,0 % de la journee a 18 h contre 5,8 %
mesures — le champ a plusieurs orientations que ce modele ne represente pas.

Retenu : forme = enveloppe horaire demontree (comportement reel du champ, sans
modele), magnitude = prevision Forecast.Solar (meteo, insensible a notre
batterie). L'enveloppe de l'apres-midi etant elle-meme censuree, le noeud
publie un indice de confiance et signale les heures minorees au lieu de les
presenter comme fiables. Sans enveloppe, il se declare inutilisable plutot que
d'inventer une forme.

Defaut trouve au passage : l'orientation declaree a Forecast.Solar ne
correspond pas au champ. Matin 07-11 h, 15,1 % de la journee prevus contre
39,3 % reels ; apres-midi 14-18 h, 52,1 % contre 35,2 %. Les totaux
journaliers tiennent, la repartition intra-journaliere est inutilisable.

Suite portee a 17 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:15:49 +02:00
parent d0149dd2a0
commit 180a1f3db5
5 changed files with 294 additions and 3 deletions

View File

@@ -0,0 +1,146 @@
// 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. RETENU — FORME = enveloppe horaire réellement démontrée (production
// maximale jamais observée à chaque heure). Elle encode le comportement
// réel du champ, orientations multiples comprises, sans aucun modèle.
// MAGNITUDE = prévision Forecast.Solar (énergie restante du jour), qui
// porte la météo et ignore totalement l'état de notre batterie.
//
// LIMITE ASSUMÉE ET SIGNALÉE : l'enveloppe de l'après-midi est elle-même
// censurée (77 à 96 % des heures après 13 h le sont), donc sous-estimée. Les
// heures du matin sont propres (censure de 0 à 4 %). Le champ `envelopeQuality`
// dit, heure par heure, si la forme est fiable ou minorée — à charge du
// consommateur d'en tenir compte plutôt que de le découvrir trop tard.
//
// 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;
}
const input = $input.first().json;
const CFG = {
TIMEZONE: 'Europe/Paris',
HORIZON_HOURS: 30,
// 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));
// Enveloppe et taux de censure par heure : produits par le calcul quotidien.
// Absents au premier démarrage → on le dit, on ne fabrique pas de forme.
const envelope = Array.isArray(input.envelope_kwh) && input.envelope_kwh.length === 24
? input.envelope_kwh.map(v => Math.max(0, num(v, 0)))
: null;
const censorshipRate = Array.isArray(input.censorship_rate) && input.censorship_rate.length === 24
? input.censorship_rate.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);
const slots = [];
for (let i = 0; i < CFG.HORIZON_HOURS; i++) {
const start = currentHourStart + i * 3600000;
const hour = hourOfDay(start);
slots.push({
startMs: start,
hour,
day: dayKey(start),
weight: envelope ? envelope[hour] : 0,
quality: 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);
distribute(todaySlots, todayRemainingKwh);
distribute(tomorrowSlots, tomorrowKwh);
// 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: !!envelope,
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,
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,
},
}];