Chaque soir ~1 h, une heure de deficit encore ensoleillee etait classee « minoree » d'apres le taux de censure de l'enveloppe, alors que sa production venait de la courbe horaire VRM / Forecast.Solar. Le bilan passait `degrade` et le controleur l'ignorait (plancher du soir a 85 % fixe). Les heures lues dans la courbe sont desormais `courbe`. Verifie : 18:20 ok, chiffres identiques a 18:15 degrade. Bilan 72/72. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
367 lines
14 KiB
JavaScript
367 lines
14 KiB
JavaScript
// Scénarios du nœud CONSUMPTION_PROFILE.
|
|
// `fixture-history.json` = 28 jours réels (28/07 → 24/08/2026), un point horaire
|
|
// par compteur, au format renvoyé par /api/history.
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const history = JSON.parse(fs.readFileSync(path.join(HERE, 'fixture-history.json'), 'utf8'));
|
|
const NOW = Date.parse('2026-08-24T14:00:00Z');
|
|
|
|
// Petit historique synthétique : 10 jours, conso maison constante de 1 kWh/h,
|
|
// et une charge de véhicule de 3 kWh à 14 h tous les jours.
|
|
function syntheticSeries({ days = 10, houseKwhPerHour = 1, evcsKwhAt14h = 3 } = {}) {
|
|
const inverter = [];
|
|
const evcs = [];
|
|
let cumInverter = 0;
|
|
let cumEvcs = 0;
|
|
const start = Date.parse('2026-08-01T00:00:00+02:00');
|
|
for (let h = 0; h < days * 24; h++) {
|
|
const ts = new Date(start + h * 3600000);
|
|
const isEvcsHour = ts.getUTCHours() === 12; // 14 h Paris en août
|
|
cumInverter += houseKwhPerHour + (isEvcsHour ? evcsKwhAt14h : 0);
|
|
cumEvcs += isEvcsHour ? evcsKwhAt14h : 0;
|
|
inverter.push({ ts: ts.toISOString(), state: String(cumInverter) });
|
|
evcs.push({ ts: ts.toISOString(), state: String(cumEvcs) });
|
|
}
|
|
return { inverter_to_ac_out: inverter, ac_in_to_ac_out: [], evcs_total: evcs };
|
|
}
|
|
|
|
export const profileScenarios = [
|
|
{
|
|
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 },
|
|
expect: {
|
|
days: 28,
|
|
complete: true,
|
|
hours: 24,
|
|
countersReset: 0,
|
|
unreliableHours: '',
|
|
csvFitsHelper: true,
|
|
},
|
|
},
|
|
{
|
|
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 },
|
|
expect: {
|
|
p50AtHour14: 1,
|
|
p80AtHour14: 1,
|
|
complete: true,
|
|
},
|
|
},
|
|
{
|
|
node: 'DAILY_PROFILES',
|
|
id: 'compteur-remis-a-zero',
|
|
label: 'Remise à zéro du compteur (redémarrage HA) : l\'échantillon est jeté, pas inventé',
|
|
snapshot: (() => {
|
|
const s = syntheticSeries();
|
|
// 5e jour à 10 h : le compteur repart de zéro.
|
|
const idx = 4 * 24 + 10;
|
|
for (let i = idx; i < s.inverter_to_ac_out.length; i++) {
|
|
s.inverter_to_ac_out[i].state = String(Number(s.inverter_to_ac_out[i].state) - 100);
|
|
}
|
|
return { series: s, nowMs: NOW };
|
|
})(),
|
|
expect: {
|
|
countersReset: 1,
|
|
complete: true,
|
|
},
|
|
},
|
|
{
|
|
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 },
|
|
expect: {
|
|
complete: false,
|
|
unreliableHours: '0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23',
|
|
},
|
|
},
|
|
{
|
|
node: 'DAILY_PROFILES',
|
|
id: 'compteur-epars-attribution-horaire',
|
|
label:
|
|
'Compteur épars (réseau allumé seulement la nuit) : chaque kWh se range sous l\'heure qui l\'a consommé',
|
|
// Reproduit le défaut mesuré le 25/08/2026 : `ac_in_1_to_ac_out` n'émet que
|
|
// pendant les heures creuses. Entre deux rafales, le compteur reste muet
|
|
// pendant 21 heures. L'énergie d'un intervalle à trou appartient à l'heure
|
|
// qui a réveillé le compteur, pas à celle où il s'était endormi.
|
|
snapshot: (() => {
|
|
const inverter = [];
|
|
const grid = [];
|
|
let cumInverter = 0;
|
|
let cumGrid = 0;
|
|
const start = Date.parse('2026-08-01T00:00:00+02:00');
|
|
// Rafale réseau : 1 kWh à 01 h, 2 kWh à 02 h, 3 kWh à 03 h. Montants
|
|
// distincts pour qu'un décalage d'un cran soit impossible à confondre.
|
|
const burst = { 1: 1, 2: 2, 3: 3 };
|
|
for (let h = 0; h < 12 * 24; h++) {
|
|
const ts = new Date(start + h * 3600000);
|
|
const hourParis = Number.parseInt(
|
|
new Intl.DateTimeFormat('fr-FR', {
|
|
timeZone: 'Europe/Paris', hour: '2-digit', hour12: false,
|
|
}).format(ts),
|
|
10
|
|
);
|
|
cumInverter += 0.8;
|
|
inverter.push({ ts: ts.toISOString(), state: String(cumInverter.toFixed(2)) });
|
|
const kwh = burst[hourParis];
|
|
if (kwh) {
|
|
cumGrid += kwh;
|
|
// Le compteur n'émet QUE pendant les heures où il avance.
|
|
grid.push({ ts: ts.toISOString(), state: String(cumGrid.toFixed(2)) });
|
|
}
|
|
}
|
|
return {
|
|
series: { inverter_to_ac_out: inverter, ac_in_to_ac_out: grid, evcs_total: [] },
|
|
nowMs: NOW,
|
|
};
|
|
})(),
|
|
expect: {
|
|
// 0,8 de fond + la rafale de l'heure : 1,8 / 2,8 / 3,8.
|
|
p50Hours1a3: '1.8,2.8,3.8',
|
|
// 08 h ne consomme que le fond : c'est l'heure qui portait 2,90 kWh/h
|
|
// en production avant correction.
|
|
p50AtHour8: 0.8,
|
|
complete: true,
|
|
},
|
|
},
|
|
];
|
|
|
|
// --- 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');
|
|
// 00:45, l'heure exacte ou le bilan a decroche la nuit du 07 au 08/09.
|
|
const APRES_MINUIT = Date.parse('2026-09-08T00:45:00+02:00');
|
|
// Deux courbes horaires de 24 valeurs : une journee sans rien, et une journee
|
|
// a 30 kWh repartis de 8 h a 17 h (3 kWh par heure).
|
|
const JOURNEE_VIDE = Array.from({ length: 24 }, () => 0);
|
|
const JOURNEE_PLEINE = Array.from({ length: 24 }, (_, h) => (h >= 8 && h <= 17 ? 3 : 0));
|
|
|
|
export const resourceScenarios = [
|
|
// --- Correctif du 08/09/2026 : la courbe se juge sur la DATE qu'elle decrit,
|
|
// plus sur son age. Les quatre scenarios ci-dessous partagent la meme heure
|
|
// (00:45, juste apres minuit) et les memes courbes : seule la date publiee
|
|
// change. C'est ce qui les rend probants.
|
|
//
|
|
// Releve reel : la nuit du 07 au 08/09, plus aucune publication de 23:45 a
|
|
// 04:30. La derniere courbe decrivait le 7, dont toutes les heures etaient
|
|
// passees — le bilan a conclu « aucune production sur l'horizon ».
|
|
{
|
|
node: 'RESOURCE_ESTIMATE',
|
|
id: 'prevision-decalee-apres-minuit',
|
|
label: 'Publication d\'avant minuit : la courbe « lendemain » EST celle du jour, la production redevient visible',
|
|
snapshot: {
|
|
nowMs: APRES_MINUIT,
|
|
forecast_day0: '2026-09-07',
|
|
forecast_today_kwh: JOURNEE_VIDE,
|
|
forecast_tomorrow_kwh: JOURNEE_PLEINE,
|
|
forecast_today_remaining_kwh: '0',
|
|
forecast_tomorrow_kwh_total: '0',
|
|
envelope_kwh: envelope.envelope_kwh,
|
|
censorship_rate: envelope.censorship_rate,
|
|
},
|
|
expect: {
|
|
usable: true,
|
|
mode: 'courbe_horaire_api',
|
|
alignment: 'decalee_d_un_jour',
|
|
todayExpected: 30,
|
|
},
|
|
},
|
|
{
|
|
node: 'RESOURCE_ESTIMATE',
|
|
id: 'prevision-a-jour-lecture-directe',
|
|
label: 'CONTREFACTUEL : meme instant, courbe datee du jour → lecture directe',
|
|
snapshot: {
|
|
nowMs: APRES_MINUIT,
|
|
forecast_day0: '2026-09-08',
|
|
forecast_today_kwh: JOURNEE_PLEINE,
|
|
forecast_tomorrow_kwh: JOURNEE_VIDE,
|
|
forecast_today_remaining_kwh: '0',
|
|
envelope_kwh: envelope.envelope_kwh,
|
|
censorship_rate: envelope.censorship_rate,
|
|
},
|
|
expect: {
|
|
usable: true,
|
|
mode: 'courbe_horaire_api',
|
|
alignment: 'a_jour',
|
|
todayExpected: 30,
|
|
},
|
|
},
|
|
{
|
|
node: 'RESOURCE_ESTIMATE',
|
|
id: 'prevision-vieille-mais-du-bon-jour',
|
|
label: 'Publiee il y a cinq heures mais datee du jour : utilisable — l\'ancienne garde d\'age la refusait',
|
|
snapshot: {
|
|
nowMs: APRES_MINUIT,
|
|
forecast_day0: '2026-09-08',
|
|
forecast_updated: '07/09 19:45',
|
|
forecast_today_kwh: JOURNEE_PLEINE,
|
|
forecast_tomorrow_kwh: JOURNEE_VIDE,
|
|
forecast_today_remaining_kwh: '0',
|
|
envelope_kwh: envelope.envelope_kwh,
|
|
censorship_rate: envelope.censorship_rate,
|
|
},
|
|
expect: {
|
|
usable: true,
|
|
mode: 'courbe_horaire_api',
|
|
alignment: 'a_jour',
|
|
todayExpected: 30,
|
|
},
|
|
},
|
|
{
|
|
node: 'RESOURCE_ESTIMATE',
|
|
id: 'prevision-perimee-de-deux-jours',
|
|
label: 'CONTREFACTUEL : courbe datee de l\'avant-veille → inexploitable, repli sur l\'enveloppe',
|
|
snapshot: {
|
|
nowMs: APRES_MINUIT,
|
|
forecast_day0: '2026-09-06',
|
|
forecast_today_kwh: JOURNEE_PLEINE,
|
|
forecast_tomorrow_kwh: JOURNEE_PLEINE,
|
|
forecast_today_remaining_kwh: '0',
|
|
forecast_tomorrow_kwh_total: '0',
|
|
envelope_kwh: envelope.envelope_kwh,
|
|
censorship_rate: envelope.censorship_rate,
|
|
},
|
|
expect: {
|
|
usable: true,
|
|
mode: 'repli_enveloppe',
|
|
alignment: 'perimee',
|
|
todayExpected: 0,
|
|
},
|
|
},
|
|
|
|
{
|
|
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-nuit-forme-non-minoree',
|
|
label: 'Projection lancee en pleine nuit : une heure sans soleil n\'a aucune forme a minorer, elle ne doit pas degrader la confiance',
|
|
snapshot: {
|
|
// 22:30 : l'horizon couvre les heures 0 a 6, dont le taux de censure
|
|
// vaut 1 faute de soleil a observer, pas faute de mesure fiable.
|
|
nowMs: Date.parse('2026-08-25T22:30:00+02:00'),
|
|
forecast_today_remaining_kwh: '0',
|
|
forecast_tomorrow_kwh: '32',
|
|
// Enveloppe et taux de censure RELEVES EN PRODUCTION le 26/08/2026, pas
|
|
// ceux du fixture (capture plus ancienne ou la nuit valait 0). En
|
|
// production la nuit vaut 1 : c'est cette valeur-la qui declenchait le
|
|
// faux « degrade », et c'est donc elle qu'il faut tester.
|
|
envelope_kwh: [0,0,0,0,0,0,0,0.17,1.03,3.86,6.55,7.17,9.33,8.38,
|
|
7.78,6.37,3.77,4.78,2.55,2.72,0.91,0.16,0,0],
|
|
censorship_rate: [1,1,1,1,1,1,1,0,0,0,0,0,0.25,0.36,
|
|
0.44,0.63,0.71,0.88,0.80,0.75,0.08,0,1,1],
|
|
},
|
|
expect: {
|
|
usable: true,
|
|
qualityAtHour3: 'nuit',
|
|
// L'apres-midi, lui, reste bien signale comme minore.
|
|
qualityAtHour14: 'minoree',
|
|
},
|
|
},
|
|
{
|
|
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-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,
|
|
// 29/09 : une heure lue dans la courbe ne doit rien a l'enveloppe, meme
|
|
// censuree (14 h : taux 0,63) — plus de faux « minoree », plus de faux
|
|
// `degrade` du bilan chaque soir. La nuit reste la nuit.
|
|
qualityAtHour14: 'courbe',
|
|
qualityAtHour3: 'nuit',
|
|
shapeConfidenceBelow: false,
|
|
},
|
|
},
|
|
{
|
|
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,
|
|
// Repli sur l'enveloppe : la forme minoree reste signalee.
|
|
qualityAtHour14: 'minoree',
|
|
},
|
|
},
|
|
{
|
|
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,
|
|
},
|
|
},
|
|
];
|