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

@@ -140,6 +140,59 @@ function coverage(slots) {
const typical = coverage(buildSlots(consumptionP50));
const prudent = coverage(buildSlots(consumptionP80));
// ---------------------------------------------------------------------
// PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026)
//
// Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire
// prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est
// zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à
// cette heure-là, car le déficit qui approche est celui de la nuit.
//
// On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en
// cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint
// à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et
// qui dit combien de surplus il faut avoir stocké avant le coucher du soleil.
//
// Profil PRUDENT : contrairement à la décision réseau immédiate, cette
// projection engage plusieurs heures et n'est pas révisable à volonté — la nuit
// tombée, il n'y a plus de solaire pour rattraper une sous-estimation.
// ---------------------------------------------------------------------
function nextDeficitWindow(slots) {
const firstSurplus = slots.findIndex(s => s.netKwh > 0);
if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' };
const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0);
if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' };
let end = slots.findIndex((s, i) => i > start && s.netKwh > 0);
if (end < 0) end = slots.length;
const energyKwh = slots.slice(start, end)
.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);
// Surplus encore récupérable d'ici là : de quoi remplir, ou pas.
const surplusBeforeKwh = slots.slice(0, start)
.reduce((sum, s) => sum + Math.max(0, s.netKwh), 0);
return {
found: true,
startHour: slots[start].hour,
startMs: slots[start].startMs,
hoursFromNow: start,
endHour: end < slots.length ? slots[end].hour : null,
complete: end < slots.length,
energyKwh,
// NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie
// que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est
// exactement le signal de sous-dimensionnement du stockage — l'écrêter le
// masquerait.
requiredSocAtStartPct: socFloorPct + (energyKwh / usableCapacityKwh) * 100,
surplusBeforeKwh,
};
}
const nextDeficit = nextDeficitWindow(prudent.slots);
const slots = typical.slots;
const crossingFound = typical.found;
const crossingIndex = typical.crossingIndex;
@@ -199,6 +252,28 @@ return [{
},
},
// Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire.
nextDeficit: nextDeficit.found ? {
found: true,
startHour: nextDeficit.startHour,
hoursFromNow: nextDeficit.hoursFromNow,
endHour: nextDeficit.endHour,
complete: nextDeficit.complete,
energyKwh: round2(nextDeficit.energyKwh),
requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct),
surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh),
// Franchissable avec le stockage actuel, batterie pleine ?
feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,
// Si non : capacité utile manquante. C'est le chiffre du dimensionnement.
storageShortfallKwh: Math.max(
0,
((nextDeficit.requiredSocAtStartPct - 100) / 100) * usableCapacityKwh
),
reachable: nextDeficit.surplusBeforeKwh >= 0
&& (socPct + (nextDeficit.surplusBeforeKwh / usableCapacityKwh) * 100)
>= nextDeficit.requiredSocAtStartPct,
} : { found: false, reason: nextDeficit.reason },
vehicle: {
surplusAvailableKwh: round2(surplusAfterCrossingKwh),
},