Bilan : un delta horaire appartient a l'heure qui l'a consomme

`bucketHourly` garde la derniere valeur de chaque heure : le delta entre H-1
et H est l'energie depensee PENDANT H. `deltaSeries` le rangeait sous H-1.

Discret sur un compteur dense, catastrophique sur un compteur epars — et les
compteurs de cette installation le sont : `ac_in_1_to_ac_out` ne peuple que 57
heures sur 267, `evcs_total` 57 sur 264, parce qu'ils n'emettent que lorsqu'ils
avancent. Une heure vide vaut « zero energie », donc toute l'energie d'un
intervalle a trou a ete consommee pendant l'heure FINALE. Rangee sous l'heure
initiale, l'import reseau de la nuit suivante atterrissait sur la derniere
heure de la nuit precedente.

Mesure du 25/08 : consumption_p50[8] = 2,90 kWh/h pour 0,80 reellement — six
echantillons sur douze portaient un delta etale sur 17 heures. Le bilan
reclamait environ deux fois trop d'energie le matin (3,37 kWh annonces a 06 h
contre 1,35 mesures), et donc un SOC requis de 44,5 % la ou 31 % suffisait.
L'enveloppe de production etait decalee de la meme heure, ce qui a contribue a
surestimer la part matinale de la production dans le journal de chantier.

Ajoute aussi un statut `degrade` : le bilan publie ses chiffres quand aucun
croisement n'apparait sur l'horizon ou quand les heures de deficit reposent sur
une forme minoree, mais il ne les presente plus comme fiables. La nuit du 25/08,
avant la mise en service de la courbe horaire, il a publie un SOC requis de
100 % avec un statut `ok`.

Tests 36 -> 37, dont `compteur-epars-attribution-horaire` (rafale reseau de
1/2/3 kWh a 01/02/03 h : montants distincts pour qu'un decalage d'un cran soit
impossible a confondre). Mutation verifiee : l'ancienne attribution le fait
echouer.

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-25 11:11:24 +02:00
parent 0c15eba67b
commit 17d65f1e40
9 changed files with 233 additions and 7 deletions

View File

@@ -56,6 +56,11 @@ const SUMMARIZE = {
csvFitsHelper: o.csv.p80.length <= 255 && o.csv.p50.length <= 255,
p50AtHour14: o.profile[14].p50,
p80AtHour14: o.profile[14].p80,
// Heures 1 à 3 et 8 : sert au scénario d'attribution horaire, où un
// compteur épars ne se réveille que la nuit.
p50Hours1a3: o.profile.slice(1, 4).map(h => h.p50).join(','),
p50AtHour8: o.profile[8].p50,
spannedGaps: o.quality.spannedGaps,
}),
RESOURCE_ESTIMATE: o => ({
usable: o.usable,
@@ -93,6 +98,7 @@ SUMMARIZE.BUILD_HA_CALLS = (calls) => {
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'),
status: (calls.find(c => c.json.data.entity_id === 'input_text.evcs_balance_status') || { json: { data: {} } }).json.data.value,
};
};
SUMMARIZE.BUILD_HA_CALLS_PROFILES = SUMMARIZE.BUILD_HA_CALLS;

View File

@@ -83,6 +83,55 @@ export const profileScenarios = [
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 -------------------------------------------------

View File

@@ -29,6 +29,16 @@ const BALANCE_OK = {
prudent: { requiredSocPct: 38.1 },
},
vehicle: { surplusAvailableKwh: 20.17 },
confidence: { deficitTrustworthy: true },
};
// Le même bilan, mais sans croisement trouvé sur l'horizon : le SOC requis
// n'est plus une exigence datée, seulement la somme d'un horizon entier. Le
// statut doit le dire.
const BALANCE_SANS_CROISEMENT = {
...BALANCE_OK,
crossing: { found: false, hour: null },
coverage: { ...BALANCE_OK.coverage, requiredSocPct: 100, energyToCoverKwh: 25.97 },
};
export const shadowScenarios = [
@@ -45,6 +55,22 @@ export const shadowScenarios = [
wroteRequiredSoc: true,
wroteGridNeeded: true,
hasLogbook: true,
status: 'ok',
},
},
{
node: 'BUILD_HA_CALLS',
id: 'ombre-sans-croisement-statut-degrade',
label:
'Aucun croisement sur l\'horizon : les chiffres sont publiés mais le statut passe à `degrade`',
snapshot: {
...BALANCE_SANS_CROISEMENT,
__nodes: { 'HA Snapshot Balance': SNAPSHOT, CENSORSHIP: CENSORSHIP_LIBRE },
},
expect: {
outOfScope: '',
wroteRequiredSoc: true,
status: 'degrade',
},
},
{