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