Le drapeau « degrade » introduit la veille s'allumait de 20 h a 07 h toutes les nuits. La qualite de forme d'une heure vient du taux de censure ; les heures sans soleil portent un taux de 1 faute d'avoir quoi que ce soit a observer, pas parce que la mesure serait douteuse. Comme les heures de deficit sont justement les heures sans soleil, le drapeau se declenchait exactement quand le bilan sert. Une heure dont l'enveloppe demontree est nulle recoit desormais la qualite 'nuit'. Scenario epingle sur l'enveloppe et le taux de censure releves en production, pas sur le fixture — dont la nuit valait 0 et qui ne reproduisait donc pas le defaut. Preuve par mutation : avant 'minoree', apres 'nuit'. Suite 37 -> 38. Documente la nuit du 25-26/08 : profil corrige verifie a 0,2 kWh/h pres, bilan qui predit le point bas a un point pres, et non-linearite du SOC mesuree (huit points en dix-sept minutes pour 0,16 kWh).
261 lines
9.6 KiB
JavaScript
261 lines
9.6 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');
|
|
|
|
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-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,
|
|
},
|
|
},
|
|
];
|