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:
@@ -123,8 +123,33 @@ calcul en saison basse.
|
|||||||
(une heure censurée est une heure où la batterie n'avait plus de place, donc
|
(une heure censurée est une heure où la batterie n'avait plus de place, donc
|
||||||
une heure de surplus). La grandeur qui pilote la décision EDF est donc
|
une heure de surplus). La grandeur qui pilote la décision EDF est donc
|
||||||
mesurée sur des données propres.
|
mesurée sur des données propres.
|
||||||
- [ ] Étape 3 — estimateur de ressource PV = prévision recalée sur les heures
|
- [x] **Étape 3 — estimateur de ressource PV** (24/08).
|
||||||
exploitables de l'étape 1.
|
`src/energy-balance/RESOURCE_ESTIMATE.js`. **Deux approches essayées et
|
||||||
|
écartées, documentées dans l'en-tête du nœud** :
|
||||||
|
1. *Recalage de la prévision* — impossible, la censure est corrélée au beau
|
||||||
|
temps (facteur 4,29 à 9 h sur 27 échantillons, 0,48 à 15 h sur 3).
|
||||||
|
2. *Forme issue de la géométrie solaire* — un plan unique ajusté sur les
|
||||||
|
heures propres converge sur « plein est, 20° » et prédit 2,0 % de la
|
||||||
|
journée à 18 h contre 5,8 % mesurés. Le champ a plusieurs orientations
|
||||||
|
(trois trackers) qu'un plan unique ne représente pas.
|
||||||
|
|
||||||
|
Retenu : **forme = enveloppe horaire démontrée** (comportement réel du champ,
|
||||||
|
orientations multiples comprises, sans modèle) × **magnitude = prévision
|
||||||
|
Forecast.Solar** (météo, insensible à l'état de notre batterie). Le nœud
|
||||||
|
publie un indice de confiance par heure : l'enveloppe de l'après-midi étant
|
||||||
|
elle-même censurée, elle est signalée « minorée » au lieu d'être présentée
|
||||||
|
comme fiable. Sans enveloppe fournie, le nœud se déclare inutilisable plutôt
|
||||||
|
que d'inventer une forme. Suite portée à 17 scénarios.
|
||||||
|
|
||||||
|
- [ ] **DÉFAUT TROUVÉ — orientation Forecast.Solar mal déclarée.** Forme prévue
|
||||||
|
contre forme mesurée, en part de la journée : matin 07-11 h **15,1 % prévus
|
||||||
|
contre 39,3 % réels** ; après-midi 14-18 h **52,1 % prévus contre 35,2 %**.
|
||||||
|
La prévision attend une journée orientée l'après-midi, l'installation produit
|
||||||
|
le matin. Les totaux journaliers restent à peu près justes, mais la
|
||||||
|
répartition intra-journalière est inutilisable — ce qui dégrade toutes les
|
||||||
|
décisions fondées sur la prévision (EVCS, chauffe-eau, conseiller IA).
|
||||||
|
À corriger dans la configuration de l'intégration (azimut et inclinaison par
|
||||||
|
plan), puis refaire la comparaison.
|
||||||
- [ ] Étape 4 — bilan et ses sorties.
|
- [ ] Étape 4 — bilan et ses sorties.
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|||||||
146
src/energy-balance/RESOURCE_ESTIMATE.js
Normal file
146
src/energy-balance/RESOURCE_ESTIMATE.js
Normal 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,
|
||||||
|
},
|
||||||
|
}];
|
||||||
55
tests/energy-balance/fixture-envelope.json
Normal file
55
tests/energy-balance/fixture-envelope.json
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"_source": "Enveloppe horaire (production maximale observee) et taux de censure, calcules sur les statistiques HA du 28/07 au 24/08/2026.",
|
||||||
|
"envelope_kwh": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0.46,
|
||||||
|
1.42,
|
||||||
|
4.4,
|
||||||
|
6.93,
|
||||||
|
7.17,
|
||||||
|
9.33,
|
||||||
|
8.38,
|
||||||
|
7.79,
|
||||||
|
6.37,
|
||||||
|
4.69,
|
||||||
|
5.93,
|
||||||
|
4.82,
|
||||||
|
4.34,
|
||||||
|
1.88,
|
||||||
|
0.44,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"censorship_rate": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.04,
|
||||||
|
0.04,
|
||||||
|
0.59,
|
||||||
|
0.77,
|
||||||
|
0.81,
|
||||||
|
0.88,
|
||||||
|
0.92,
|
||||||
|
0.96,
|
||||||
|
0.92,
|
||||||
|
0.85,
|
||||||
|
0.08,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import path from 'node:path';
|
|||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { scenarios as censorshipScenarios } from './scenarios.mjs';
|
import { scenarios as censorshipScenarios } from './scenarios.mjs';
|
||||||
import { profileScenarios } from './scenarios-profile.mjs';
|
import { profileScenarios, resourceScenarios } from './scenarios-profile.mjs';
|
||||||
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
|
||||||
@@ -45,11 +45,18 @@ const SUMMARIZE = {
|
|||||||
p50AtHour14: o.profile[14].p50,
|
p50AtHour14: o.profile[14].p50,
|
||||||
p80AtHour14: o.profile[14].p80,
|
p80AtHour14: o.profile[14].p80,
|
||||||
}),
|
}),
|
||||||
|
RESOURCE_ESTIMATE: o => ({
|
||||||
|
usable: o.usable,
|
||||||
|
todayExpected: o.todayExpectedRemainingKwh,
|
||||||
|
tomorrowExpected: o.tomorrowExpectedKwh,
|
||||||
|
shapeConfidenceBelow: o.source.shapeConfidence != null && o.source.shapeConfidence < 0.5,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const all = [
|
const all = [
|
||||||
...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })),
|
...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })),
|
||||||
...profileScenarios,
|
...profileScenarios,
|
||||||
|
...resourceScenarios,
|
||||||
];
|
];
|
||||||
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|||||||
@@ -84,3 +84,61 @@ export const profileScenarios = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// --- RESOURCE_ESTIMATE -------------------------------------------------
|
||||||
|
const envelope = JSON.parse(fs.readFileSync(path.join(HERE, 'fixture-envelope.json'), 'utf8'));
|
||||||
|
const MIDDAY = Date.parse('2026-08-24T12:30:00+02:00');
|
||||||
|
|
||||||
|
export const resourceScenarios = [
|
||||||
|
{
|
||||||
|
node: 'RESOURCE_ESTIMATE',
|
||||||
|
id: 'ressource-repartition-prevision',
|
||||||
|
label: 'Prévision du jour répartie sur la forme démontrée, sans dépasser le total prévu',
|
||||||
|
snapshot: {
|
||||||
|
nowMs: MIDDAY,
|
||||||
|
forecast_today_remaining_kwh: '15.9',
|
||||||
|
forecast_tomorrow_kwh: '29.1',
|
||||||
|
envelope_kwh: envelope.envelope_kwh,
|
||||||
|
censorship_rate: envelope.censorship_rate,
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
usable: true,
|
||||||
|
todayExpected: 15.9,
|
||||||
|
tomorrowExpected: 29.1,
|
||||||
|
// Le milieu et la fin de journée reposent sur une forme minorée :
|
||||||
|
// la confiance doit s'effondrer, pas rester silencieusement à 1.
|
||||||
|
shapeConfidenceBelow: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'RESOURCE_ESTIMATE',
|
||||||
|
id: 'ressource-matin-forme-fiable',
|
||||||
|
label: 'Projection faite au petit matin : la forme du matin est propre, la confiance doit être bonne',
|
||||||
|
snapshot: {
|
||||||
|
nowMs: Date.parse('2026-08-24T06:30:00+02:00'),
|
||||||
|
forecast_today_remaining_kwh: '30',
|
||||||
|
forecast_tomorrow_kwh: '0',
|
||||||
|
envelope_kwh: envelope.envelope_kwh,
|
||||||
|
censorship_rate: envelope.censorship_rate,
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
usable: true,
|
||||||
|
todayExpected: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'RESOURCE_ESTIMATE',
|
||||||
|
id: 'ressource-sans-enveloppe',
|
||||||
|
label: 'Aucune enveloppe fournie : le nœud se déclare inutilisable au lieu d\'inventer une forme',
|
||||||
|
snapshot: {
|
||||||
|
nowMs: MIDDAY,
|
||||||
|
forecast_today_remaining_kwh: '15.9',
|
||||||
|
forecast_tomorrow_kwh: '29.1',
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
usable: false,
|
||||||
|
todayExpected: 0,
|
||||||
|
tomorrowExpected: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user