Files
EVCS/tests/energy-balance/scenarios-profile.mjs
Tallagrand Thierry 98c7e6b1d0 Bilan : plus de faux statut degrade le soir (heure lue dans la courbe horaire)
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
2026-09-29 18:26:29 +02:00

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,
},
},
];