Chantier etapes 5b et 5c : recalcul quotidien des profils et fenetre de deficit suivante

5c — BALANCE ne repondait qu'a "combien jusqu'a ce que le solaire prenne le
relais". A 15 h la reponse est zero : exacte mais inutile, le deficit qui
approche etant celui de la nuit. Ajout de la prochaine traversee deficitaire
(heure de debut, energie, SOC a atteindre avant). Ce SOC n'est PAS ecrete a
100 % : au-dela, meme une batterie pleine ne passe pas, et le manque est
publie comme storage_shortfall_kwh — c'est le chiffre du dimensionnement.

5b — Workflow "EVCS Energy Profiles Daily V1" (N6UaB4ZozsbRPrFb), actif,
03:30 : lit /api/history/period sur 30 jours glissants et republie les quatre
CSV (conso p50 et p80, enveloppe de production, taux de censure).
CONSUMPTION_PROFILE devient DAILY_PROFILES et sait desormais decoder la
reponse brute de l'API en plus des series deja pretes.

Piege trouve : sans end_time, /api/history/period ne renvoie qu'UNE journee,
silencieusement. Mes premieres mesures "sur 3 jours" n'en couvraient qu'une.

Validation croisee : profils calcules depuis l'API REST (etats bruts, 11 j)
contre profils calcules depuis les statistiques long terme (28 j) — 0,20 kWh/h
d'ecart absolu moyen, par deux chemins de donnees independants.

BUILD_HA_CALLS_PROFILES refuse d'ecraser les profils en place si l'historique
ne couvre pas les 24 heures. Meme garantie de perimetre que le bilan.
Suite a 27 scenarios.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
Tallagrand Thierry
2026-08-24 15:58:10 +02:00
parent e36f050a42
commit 6dc5ea25cb
14 changed files with 668 additions and 22 deletions

View File

@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
import { scenarios as censorshipScenarios } from './scenarios.mjs';
import { profileScenarios, resourceScenarios } from './scenarios-profile.mjs';
import { balanceScenarios } from './scenarios-balance.mjs';
import { shadowScenarios } from './scenarios-shadow.mjs';
import { shadowScenarios, profilesShadowScenarios } from './scenarios-shadow.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -36,7 +36,7 @@ function runNode(nodeName, snapshot) {
};
vm.createContext(sandbox);
const out = vm.runInContext(`(function(){ ${codeCache.get(nodeName)} })()`, sandbox, { timeout: 15000 });
return nodeName === 'BUILD_HA_CALLS' ? out : out[0].json;
return nodeName.startsWith('BUILD_HA_CALLS') ? out : out[0].json;
}
const SUMMARIZE = {
@@ -47,7 +47,7 @@ const SUMMARIZE = {
healthOk: o.health.ok,
alerts: o.alerts.join('|'),
}),
CONSUMPTION_PROFILE: o => ({
DAILY_PROFILES: o => ({
days: o.days,
complete: o.complete,
hours: o.profile.length,
@@ -72,6 +72,11 @@ const SUMMARIZE = {
gridNeeded: o.coverage.gridNeeded,
prudentRequiredSocPct: o.coverage.prudent.requiredSocPct,
deficitTrustworthy: o.confidence.deficitTrustworthy,
nextDeficitFound: o.nextDeficit.found,
nextDeficitStartHour: o.nextDeficit.found ? o.nextDeficit.startHour : null,
nextDeficitEnergyKwh: o.nextDeficit.found ? o.nextDeficit.energyKwh : null,
nextDeficitRequiredSocPct: o.nextDeficit.found ? o.nextDeficit.requiredSocAtStartPct : null,
storageShortfallKwh: o.nextDeficit.found ? Math.round(o.nextDeficit.storageShortfallKwh * 100) / 100 : null,
}) : ({ usable: false, missing: o.missing.join(',') }),
};
@@ -86,8 +91,10 @@ SUMMARIZE.BUILD_HA_CALLS = (calls) => {
wroteRequiredSoc: entities.includes('input_number.evcs_balance_required_soc'),
wroteGridNeeded: entities.includes('input_boolean.evcs_balance_grid_needed'),
hasLogbook: calls.some(c => c.json.domain === 'logbook'),
wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'),
};
};
SUMMARIZE.BUILD_HA_CALLS_PROFILES = SUMMARIZE.BUILD_HA_CALLS;
const all = [
...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })),
@@ -95,6 +102,7 @@ const all = [
...resourceScenarios,
...balanceScenarios,
...shadowScenarios,
...profilesShadowScenarios,
];
let failed = 0;

View File

@@ -97,6 +97,38 @@ export const balanceScenarios = [
gridNeeded: false,
},
},
{
node: 'BALANCE',
id: 'bilan-prochaine-fenetre-deficit',
label: 'Projection à 15 h : le solaire couvre déjà, mais la nuit qui vient doit être annoncée',
snapshot: {
nowMs: Date.parse('2026-08-24T15:30:00+02:00'),
consumption_p50: P50,
consumption_p80: P80,
horizon: horizonFrom('2026-08-24T15:30:00+02:00', 24, {
...PROD_24_08,
15: 3.00, 16: 2.80, 17: 2.60, 18: 2.40, 19: 1.20, 20: 0.20,
// lendemain matin
7: 1.50, 8: 3.00, 9: 5.00,
}),
soc_percent: 95,
usable_capacity_kwh: 15,
soc_floor_percent: 22,
},
expect: {
usable: true,
// fenêtre en cours : le solaire couvre, rien à franchir
gridNeeded: false,
// mais la nuit qui vient doit être chiffrée
nextDeficitFound: true,
nextDeficitStartHour: 19,
nextDeficitEnergyKwh: 12.77,
// Au-dessus de 100 : même pleine, la batterie actuelle ne passe pas la
// nuit sur le profil prudent. C'est le chiffre du dimensionnement.
nextDeficitRequiredSocPct: 107.1,
storageShortfallKwh: 1.07,
},
},
{
node: 'BALANCE',
id: 'bilan-entrees-manquantes',

View File

@@ -31,7 +31,7 @@ function syntheticSeries({ days = 10, houseKwhPerHour = 1, evcsKwhAt14h = 3 } =
export const profileScenarios = [
{
node: 'CONSUMPTION_PROFILE',
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 },
@@ -45,7 +45,7 @@ export const profileScenarios = [
},
},
{
node: 'CONSUMPTION_PROFILE',
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 },
@@ -56,7 +56,7 @@ export const profileScenarios = [
},
},
{
node: 'CONSUMPTION_PROFILE',
node: 'DAILY_PROFILES',
id: 'compteur-remis-a-zero',
label: 'Remise à zéro du compteur (redémarrage HA) : l\'échantillon est jeté, pas inventé',
snapshot: (() => {
@@ -74,7 +74,7 @@ export const profileScenarios = [
},
},
{
node: 'CONSUMPTION_PROFILE',
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 },

View File

@@ -79,3 +79,32 @@ export const shadowScenarios = [
},
},
];
// --- BUILD_HA_CALLS_PROFILES (workflow quotidien) ------------------------
const PROFILES_OK = {
complete: true,
days: 28,
csv: { p50: '1,'.repeat(23) + '1', p80: '2,'.repeat(23) + '2',
envelope: '3,'.repeat(23) + '3', censorshipRate: '0,'.repeat(23) + '0' },
totals: { typicalDailyKwh: 31.9, prudentDailyKwh: 42.8 },
production: { demonstratedDailyKwh: 74.4, censoredDayHours: 8 },
quality: { hoursUsed: 639, pvHoursUsed: 620, countersReset: 0, unreliableHours: [] },
};
export const profilesShadowScenarios = [
{
node: 'BUILD_HA_CALLS_PROFILES',
id: 'profils-perimetre-respecte',
label: 'Publication des profils : périmètre evcs_balance_* respecté',
snapshot: PROFILES_OK,
expect: { outOfScope: '', callCount: 6, hasLogbook: true },
},
{
node: 'BUILD_HA_CALLS_PROFILES',
id: 'profils-historique-insuffisant',
label: 'Historique trop court : les profils en place ne sont PAS écrasés',
snapshot: { ...PROFILES_OK, complete: false, days: 2,
quality: { ...PROFILES_OK.quality, unreliableHours: [3, 4, 5] } },
expect: { outOfScope: '', wroteProfiles: false },
},
];