Nuit du 07 au 08/09 : 32 calculs du bilan sur 120 en statut degrade, tous entre 00 h et 03 h, avec « aucun croisement PV/conso sur l'horizon, 47,6 kWh a couvrir ». Le controleur, qui exige statut=ok, retombait sur le palier de 35 % — d'ou une cible du matin oscillant entre 35, 78,8 et 85 % au lieu de decroitre. Cause immediate : plus aucune publication de prevision de 23:45 a 04:30. Le noeud d'appel porte neverError, donc les executions restaient en success ; la reponse etait « socket hang up », et le noeud d'ecriture vers Home Assistant rendait 0 item la nuit contre 1 en journee — le .17 etait lui aussi injoignable, si bien que le message d'echec ne pouvait meme pas etre publie. Mais le defaut qu'on controle est ailleurs : les courbes sont publiees sans leur date, et PARSE_FORECAST prend days[0] pour « aujourd'hui ». Vrai a l'instant de la publication, faux des qu'on passe minuit sans rafraichir. A 00:45 le bilan a donc lu comme journee en cours celle du 7, dont toutes les heures etaient passees. Une courbe est desormais utilisable si elle DECRIT le jour dont on a besoin, pas si elle est recente : une prevision publiee a 23:45 pour le 8 reste exacte a 03:00, la nuit il n'y a rien a reactualiser. La date part dans evcs_balance_forecast_day0, et RESOURCE_ESTIMATE distingue trois cas — a jour, decalee d'un jour (la courbe du lendemain devient celle du jour, l'enveloppe mesuree reprend la main pour demain), perimee. Helper absent : ancienne garde d'age, comportement inchange. Le rapport de nuit signale un trou de plus de deux heures dans les publications. Il lit la base sur l'autre NAS, seul temoin qui survit quand le .17 est injoignable. Verifie sur la nuit ecoulee : 285 minutes. Tests 44 -> 48. Les quatre scenarios partagent la meme heure — 00:45, l'instant du decrochage — et les memes courbes : seule la date change. Quatre mutations, chacune sur un jeu different, chaque scenario ayant la sienne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
359 lines
13 KiB
JavaScript
359 lines
13 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,
|
|
},
|
|
},
|
|
{
|
|
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,
|
|
},
|
|
},
|
|
{
|
|
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,
|
|
},
|
|
},
|
|
];
|