La consigne reseau se dimensionne sur le deficit du bilan

Elle empruntait le plafond de la borne — `maison + physicalCapA x V` — qui n a
aucune relation physique avec ce dont la batterie a besoin. Qu il tombe juste
etait un accident : le 17/09 a 13:51, voiture partie depuis cinq minutes,
consigne a 7032 W pour une maison a 933 W.

Elle additionne desormais des besoins NOMMES : la maison toujours, la voiture
seulement si un vehicule est branche, la batterie seulement dans une fenetre
d heures creuses — recharger la batterie sur le reseau n a de sens economique
que la. Hors fenetre le terme vaut zero, donc la division par un temps qui tend
vers zero disparait par construction et non par garde.

MAX ET NON ADDITION. La consigne est une AUTORISATION, pas une prediction : le
fichier le dit deja — « la consigne dit ce que le reseau a le DROIT de servir,
DVCC dit ce que la batterie a le droit de prendre ». Additionner deux
autorisations independantes n est pas plus honnete, seulement plus permissif.
Mesure : avec l addition, `consigne-session-engagee-couvre-la-maison` passe de
5595 a 6341 W — une autorisation en plus sur le chemin dominant (85 nuits sur
88 ont un vehicule) pour corriger un chemin emprunte zero fois en treize jours.

LE TERME BATTERIE EST BORNE PAR DVCC, PAS PAR LE BMS NI PAR LE TEMPS. Le BMS
declare 250 A, soit 12,2 kW : au-dessus de GRID_SETPOINT_MAX_W, il ne bornerait
rien. Et un plancher d horizon ne borne que le diviseur : en fin de fenetre
(14 min, manque 5,672 kWh) la division rend 17 kW, ecretes a 10 000, soit PIRE
que les 5437 empruntes qu on remplace. Avec DVCC le terme vaut 1538 W et la
consigne 2380 W. Corollaire : le plancher d horizon devient sans objet, il ne
reste qu une garde d une minute contre la division par zero.

Le manque vient de `evcs_balance_grid_energy_needed_kwh` et non de
`energy_to_cover_kwh` : la premiere est le MANQUE, nulle des que la batterie
couvre et convertie par la courbe mesuree par morceaux ; la seconde est
l energie que la batterie devra FOURNIR et ne tombe jamais a zero tant qu il
reste de la nuit — 11,96 kWh annonces le 17/09 a 20:00 avec un SOC de 91 %.

Le plancher nuage suit la voiture et PAS la batterie : il couvre l exposition
creee par le courant du vehicule ; une recharge batterie qui ralentit quand le
solaire s effondre ne coupe personne.

INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre sous
la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 est donc garantie
par construction. Toute lecture manquante retombe sur la formule d avant.

Golden 102 -> 104. `nuit-sans-vehicule-session-engage` passe de 5437 a 2380 et
recoit le manque explicitement, sans quoi il heritait du zero de la fixture et
decrivait un etat impossible (SOC 20 pour un requis de 59,1 avec batterie
couverte). Deux scenarios ajoutes : la fin de fenetre bornee par DVCC — celui
qui protege contre la regression de 10 kW — et le contrefactuel a vehicule
branche qui epingle le choix du max.

Mutations : plafond DVCC retire -> 2 echecs dont la fin de fenetre ; max
remplace par une addition -> le contrefactuel seul ; bilan ignore -> les deux
scenarios pilotes par le bilan. Les autres suites sont intactes
(hysteresis 24, ACM 20, advisor, bilan 48).

⚠️ RIEN N EST POUSSE VERS n8n : la regle du depot impose la validation du
proprietaire avant tout PUT.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
This commit is contained in:
Tallagrand Thierry
2026-09-18 02:11:56 +02:00
parent 8c2735c181
commit 8709e436df
7 changed files with 235 additions and 8 deletions

View File

@@ -359,8 +359,96 @@ const gridSessionActive =
night.nightGridSessionCommitted === true
);
// =====================================================
// DIMENSIONNEMENT SUR LE DEFICIT DU BILAN (18/09/2026)
// =====================================================
// Ce que la consigne remplace : `maison + plafond de la borne`, ou le plafond
// de la borne n'a AUCUNE relation physique avec ce dont la batterie a besoin.
// Qu'il tombe juste est un accident — releve du 17/09 a 13:51, voiture partie
// depuis cinq minutes, consigne a 7032 W pour une maison a 933 W.
//
// La consigne additionne desormais des besoins NOMMES :
// maison toujours ;
// voiture seulement si un vehicule est branche ;
// batterie seulement dans une fenetre d'heures creuses.
//
// ⚠️ MAX ET NON ADDITION, et ce n'est pas de la prudence. La consigne est une
// AUTORISATION, pas une prediction de consommation — le commentaire de la bande
// asymetrique le dit plus bas : « la consigne dit ce que le reseau a le DROIT
// de servir, DVCC dit ce que la batterie a le droit de prendre ». Additionner
// deux autorisations independantes n'est pas plus honnete, seulement plus
// permissif. Mesure a l'appui : avec l'addition,
// `consigne-session-engagee-couvre-la-maison` passe de 5595 a 6341 W, soit
// 746 W d'autorisation en plus sur une nuit engagee qui fonctionne deja — une
// regression sur le chemin DOMINANT pour corriger un chemin emprunte zero fois
// en treize jours.
//
// ⚠️ ET LE PLAFOND DU TERME BATTERIE EST DVCC, PAS LE BMS. Le BMS declare
// 250 A, soit 12,2 kW a 49 V : au-dessus de GRID_SETPOINT_MAX_W, donc il ne
// bornerait rien. Mesure : en fin de fenetre (14 min restantes, manque
// 5,672 kWh) la division rendait 17 kW, ecretes a 10 000 — PIRE que les 5437
// d'aujourd'hui. Avec DVCC le terme vaut 1538 W et la consigne 2380 W.
// Composer les deux leviers, ce n'est pas les confondre : le reseau n'est
// autorise a servir la batterie qu'a hauteur de ce que la batterie est
// autorisee a prendre.
//
// Corollaire : aucun plancher d'horizon n'est necessaire. Un horizon qui tend
// vers zero donne `min(enorme, DVCC) = DVCC`. Il ne reste qu'une garde d'une
// minute, qui est de l'arithmetique defensive et non une constante calibree.
//
// INVARIANT TENU PAR LA FORME : `maison + max(...)` ne peut jamais descendre
// sous la maison, quel que soit DVCC. La regle du 01/09 et du 04/09 — « tant
// que le contacteur est ferme A NOTRE DEMANDE, la consigne doit couvrir la
// maison » — est donc garantie par construction. A DVCC nul, le plafond vaut
// exactement la maison.
const SETPOINT_BALANCE_MAX_AGE_MIN = 30;
const setpointBalanceAgeMin = Number.isFinite(params.balanceLastRunMs)
? (nowMs - params.balanceLastRunMs) / 60000
: null;
// Le manque, pas l'energie a couvrir : voir le commentaire d'INPUT_PARSE.
const balanceUsableForSetpoint =
params.balanceStatusOk &&
Number.isFinite(params.balanceGridEnergyNeededKwh) &&
setpointBalanceAgeMin !== null &&
setpointBalanceAgeMin >= -5 &&
setpointBalanceAgeMin <= SETPOINT_BALANCE_MAX_AGE_MIN;
const dvccChargeLimitW =
Number.isFinite(params.dvccChargeLimitA) && params.dvccChargeLimitA >= 0
? params.dvccChargeLimitA * derived.batteryVoltageRef
: null;
// Toute lecture manquante retombe sur la formule d'avant : degradation vers
// l'existant, jamais pire.
const setpointFromBalance = balanceUsableForSetpoint && dvccChargeLimitW !== null;
const evcsSetpointShareW = parsed.evcsVehicleConnected
? derived.physicalCapA * derived.acVoltageRef
: 0;
// Recharger la batterie sur le reseau n'a de sens economique QUE dans une
// fenetre creuse. Hors fenetre le terme vaut zero — ce n'est pas un repli
// faute d'horizon, c'est le comportement voulu, et le probleme de la division
// par un temps qui tend vers zero disparait par construction.
const hcMinutesLeft = Number.isFinite(windows.minutesRemainingInHcWindow)
? windows.minutesRemainingInHcWindow
: null;
const batterySetpointShareW =
setpointFromBalance && hcMinutesLeft !== null && params.balanceGridEnergyNeededKwh > 0
? Math.min(
(params.balanceGridEnergyNeededKwh * 1000) / Math.max(hcMinutesLeft / 60, 1 / 60),
dvccChargeLimitW
)
: 0;
const gridSetpointCeilingW = clamp(
Math.round(derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef),
Math.round(
setpointFromBalance
? derived.houseNonEvcsW + Math.max(evcsSetpointShareW, batterySetpointShareW)
: derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef
),
params.gridSetpointIdleW,
GRID_SETPOINT_MAX_W
);
@@ -431,8 +519,16 @@ const batterySurgeBudgetW = Math.max(
0,
Math.abs(batteryPolicy.batteryDischargeHardDynamicA) * derived.batteryVoltageRef * 0.8
);
const gridSetpointLoadW =
derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef;
// ⚠️ LE PLANCHER NUAGE SUIT LA VOITURE, PAS LA BATTERIE. Il existe pour que la
// consigne ne descende pas au point ou la perte totale du solaire depasserait
// ce que la batterie sait encaisser — et le commentaire ci-dessus dit pourquoi :
// « c'est le courant de la voiture qui cree l'exposition ». Une batterie qu'on
// recharge depuis le reseau n'est PAS une exposition : si le solaire s'effondre,
// la recharge ralentit et personne ne se fait couper. L'y inclure remonterait le
// plancher sans aucune contrepartie de securite, et contre l'objectif.
const gridSetpointLoadW = setpointFromBalance
? derived.houseNonEvcsW + evcsSetpointShareW
: derived.houseNonEvcsW + derived.physicalCapA * derived.acVoltageRef;
const gridSetpointCloudFloorW = Math.round(
Math.max(0, gridSetpointLoadW - batterySurgeBudgetW)
);
@@ -736,6 +832,16 @@ return [{
in_midday_window: windows.inMiddayWindow,
in_evening_window: windows.inEveningWindow,
// D'ou vient la consigne reseau. Sans cette trace, un repli est
// SILENCIEUX : la consigne reprend la formule d'avant sans que rien ne le
// dise, et c'est precisement l'information qui manquera le jour ou elle se
// comportera mal. `bilan` = dimensionnee sur le manque, `heritee` = repli.
setpoint_source: setpointFromBalance ? 'bilan' : 'heritee',
setpoint_battery_share_w: Math.round(batterySetpointShareW),
setpoint_evcs_share_w: Math.round(evcsSetpointShareW),
dvcc_limit_w: dvccChargeLimitW === null ? null : Math.round(dvccChargeLimitW),
hc_minutes_left: hcMinutesLeft,
departure_priority_active: context.departurePriorityActive,
minutes_until_departure: lifecycle.minutesUntilDeparture,

View File

@@ -147,6 +147,8 @@
"night_battery_done_prev": states("input_boolean.victron_evcs_night_battery_done"),
"balance_required_soc": states("input_number.evcs_balance_required_soc"),
"balance_energy_to_cover_kwh": states("input_number.evcs_balance_energy_to_cover_kwh"),
"balance_grid_energy_needed_kwh": states("input_number.evcs_balance_grid_energy_needed_kwh"),
"dvcc_max_charge_current_a": states("sensor.victron_max_charge_current_setting"),
"balance_status": states("input_text.evcs_balance_status"),
"balance_last_run": states("input_text.evcs_balance_last_run"),
"allow_grid_hc_midday": states("input_boolean.victron_evcs_allow_grid_hc_midday"),

View File

@@ -478,6 +478,26 @@ const params = {
nightBatteryChargeEnabled: low(input.night_battery_charge_enabled || 'off') === 'on',
nightBatteryDonePrev: low(input.night_battery_done_prev || 'off') === 'on',
balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN),
// ⚠️ NE PAS CONFONDRE LES DEUX GRANDEURS DU BILAN.
// `balanceEnergyToCoverKwh` est l'energie que la batterie devra FOURNIR
// jusqu'au croisement PV ; elle sert a CALCULER la cible
// (`requiredSocPct = socForEnergy(energyToCoverKwh, floor)`) et ne tombe
// jamais a zero tant qu'il reste de la nuit a traverser, meme batterie
// pleine. Releve du 17/09 a 20:00 : 11,96 kWh « a couvrir » avec un SOC de
// 91 %. La prendre pour un deficit ferait reclamer du reseau toute la nuit.
//
// `balanceGridEnergyNeededKwh` est le MANQUE :
// `socMargin < 0 ? energyBetweenSoc(soc, requiredSoc) : 0` (BALANCE.js l.234)
// — nul des que la batterie couvre, et converti par la COURBE par morceaux
// mesuree le 03/09 (0,097 kWh/point sous le genou de 33 %, 0,169 au-dessus).
// C'est donc la seule des deux qui soit juste sous le genou, c'est-a-dire au
// SOC ou l'on decide de fermer le contacteur.
balanceGridEnergyNeededKwh: num(input.balance_grid_energy_needed_kwh, NaN),
// Ce que l'Auto Charge Manager autorise la batterie a PRENDRE (retour de
// lecture de DVCC). A ne pas confondre avec `bms_max_charge_current_a`, qui
// est le maximum tolere par le BMS — 250 A, soit plus de 12 kW, donc
// au-dessus de GRID_SETPOINT_MAX_W : il ne bornerait rien.
dvccChargeLimitA: num(input.dvcc_max_charge_current_a, NaN),
balanceStatusOk: cleanText(input.balance_status).toLowerCase() === 'ok',
balanceLastRunMs: parseRunStampToMs(cleanText(input.balance_last_run)),

View File

@@ -15,6 +15,23 @@ function isWithinWindow(nowMin, startMin, stopMin) {
return nowMin >= startMin || nowMin < stopMin;
}
// Temps restant dans une fenetre, en minutes, ou null si on n'y est pas.
//
// ⚠️ IL VIT ICI, A COTE DE `isWithinWindow`, ET PAS DANS LE NOEUD QUI L'UTILISE.
// Ce qui serait duplique ailleurs n'est pas l'analyse des heures — INPUT_PARSE
// les a deja converties en minutes — c'est la LOGIQUE D'APPARTENANCE, et en
// particulier le traitement du passage de minuit. Une seconde interpretation
// divergerait le jour ou une borne bouge, et elles ont deja bouge une fois
// (01:30 -> 01:24 le 09/07/2026).
function minutesRemainingInWindow(nowMin, startMin, stopMin) {
if (!isWithinWindow(nowMin, startMin, stopMin)) return null;
if (startMin < stopMin) return stopMin - nowMin;
// Fenetre a cheval sur minuit : avant minuit il reste la fin de la nuit EN
// PLUS du reste de la journee. Atteignable par les valeurs de repli
// d'INPUT_PARSE (23:00 -> 06:00) si les helpers d'horaire sont illisibles.
return nowMin >= startMin ? stopMin + 1440 - nowMin : stopMin - nowMin;
}
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
@@ -1031,6 +1048,14 @@ return [{
inMorningWindow,
inMiddayWindow,
inEveningWindow,
// Temps restant dans la fenetre d'HEURES CREUSES courante (nuit ou midi),
// null hors de toute fenetre creuse. Consomme par le dimensionnement de
// la consigne reseau : recharger la batterie sur le reseau n'a de sens
// economique que la-dedans, donc `null` y vaut « pas de part batterie »
// sans test supplementaire.
minutesRemainingInHcWindow:
minutesRemainingInWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin)
?? minutesRemainingInWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin),
},
derived: {

View File

@@ -171,5 +171,7 @@
"vebus_overload": "off",
"vebus_state": "9",
"victron_acin1_available": "0",
"victron_evcs_strategy": "auto_solar_plus"
"victron_evcs_strategy": "auto_solar_plus",
"balance_grid_energy_needed_kwh": "0.0",
"dvcc_max_charge_current_a": "30"
}

View File

@@ -2322,12 +2322,84 @@ export const scenarios = [
grid_setpoint_w: '700',
balance_required_soc: '59.1', balance_energy_to_cover_kwh: '3.13',
balance_status: 'ok', balance_last_run: '08/08 03:58',
// Le MANQUE, pose explicitement : sans lui le scenario heriterait du 0 de
// la fixture, ce qui decrirait une batterie couverte alors qu'il declare
// un SOC de 20 % pour un requis de 59,1 % — un etat impossible.
// Valeur calculee avec la courbe mesuree, parc de reference (7 modules,
// echelle 1) : 13 points sous le genou de 33 % a 0,097 = 1,261 kWh, puis
// 26,1 points a 0,169 = 4,411 kWh, soit 5,672 kWh. Ce scenario est le seul
// du corpus dont le deficit TRAVERSE le genou.
balance_grid_energy_needed_kwh: '5.672',
},
expect: {
profile: 'night_hc',
takeoverNeeded: true,
takeoverHelperService: 'turn_on',
sessionCommittedService: 'turn_on',
// 18/09/2026 : 5437 -> 2380. Les 5437 valaient 612 de maison + 4825
// empruntes au plafond d'une borne ou aucun vehicule n'est branche. La
// nouvelle valeur vaut 612 + le manque reel borne par DVCC (30 A, soit
// 1538 W a la tension de la fixture), plus la marge d'ecriture d'un pas
// de courant. ⚠️ Attendu calcule sur la FIXTURE et jamais sur la tension
// du jour : le recalculer sur l'installation vivante rendrait la suite
// rouge un matin ou la batterie est a 49 V sans que rien ne soit casse.
gridSetpointW: 2380,
},
},
{
id: 'consigne-bilan-fin-de-fenetre-bornee-par-dvcc',
label: "CONSIGNE 18/09 — meme manque a 07:10, 14 min de fenetre : c'est DVCC qui borne, pas le temps",
time: '2026-08-08T07:10:00+02:00',
overrides: {
evcs_vehicle_connected: 'off', evcs_device_connected: 'on',
grid_state: 'on', victron_acin1_available: '1',
soc_percent: '20', battery_current_a: '0',
pv_power_w: '0', pv_power_10m_avg_w: '0', pv_trend_w: '0',
house_ac_power_w: '612', vebus_ac_out_l1_power_w: '612',
evcs_ac_power_w: '0',
grid_setpoint_w: '700',
balance_required_soc: '59.1', balance_energy_to_cover_kwh: '3.13',
balance_status: 'ok', balance_last_run: '08/08 07:08',
balance_grid_energy_needed_kwh: '5.672',
},
expect: {
profile: 'night_hc',
// ⚠️ LE SCENARIO QUI PROTEGE CONTRE LA REGRESSION LA PLUS GRAVE de cette
// conception. 5,672 kWh a combler en 14 minutes font 17 kW : avec un
// simple plancher d'horizon, la consigne saturait sur GRID_SETPOINT_MAX_W
// (10 000 W), soit PIRE que les 5437 du plafond emprunte qu'on remplace.
// Le plafond DVCC ramene le terme a 1538 W, et la consigne a la meme
// valeur qu'a 04:00 — parce que ce qui borne n'est pas le temps qui
// reste, c'est ce que la batterie a le droit de prendre.
// Le plafond BMS n'aurait rien borne : 250 A a 49 V font 12,2 kW,
// au-dessus des 10 000 de l'ecretage.
gridSetpointW: 2380,
},
},
{
id: 'consigne-bilan-max-la-voiture-domine',
label: 'CONTREFACTUEL 18/09 — meme nuit, meme manque, mais VEHICULE BRANCHE : le max retient la voiture, pas la somme',
time: '2026-08-08T04:00:00+02:00',
overrides: {
evcs_vehicle_connected: 'on', evcs_device_connected: 'on',
grid_state: 'on', victron_acin1_available: '1',
soc_percent: '20', battery_current_a: '0',
pv_power_w: '0', pv_power_10m_avg_w: '0', pv_trend_w: '0',
house_ac_power_w: '612', vebus_ac_out_l1_power_w: '612',
evcs_ac_power_w: '0',
grid_setpoint_w: '700',
balance_required_soc: '59.1', balance_energy_to_cover_kwh: '3.13',
balance_status: 'ok', balance_last_run: '08/08 03:58',
balance_grid_energy_needed_kwh: '5.672',
},
expect: {
profile: 'night_hc',
// Terme voiture 4825 W contre terme batterie 1538 W : le `max` retient la
// voiture, et la consigne vaut ce qu'elle vaut aujourd'hui. Avec une
// ADDITION elle monterait de 1538 W de plus — une autorisation en plus sur
// le chemin DOMINANT (85 nuits sur 88 ont un vehicule branche) pour
// corriger un chemin emprunte zero fois. C'est ce scenario qui epingle le
// choix du max, et il doit rester a la valeur historique.
gridSetpointW: 5437,
},
},

File diff suppressed because one or more lines are too long