Charge HC manuelle : un plancher qui CONTRAINT le bilan, pas une cible qui le remplace

Decision du proprietaire, dans ses termes : « contraindre le bilan, un seul
arbitre ». Ce projet a deja paye cher les automates concurrents — charges
programmees de l ESS, consigne a 4200 W, plancher SOC du GX — et un bouton qui
ecrirait sa propre cible en aurait cree un quatrieme.

⚠️ LE FORCAGE EST EN AMONT, DANS coverage(), ET NULLE PART AILLEURS. Mesure sur
banc : ecrire directement dans balance_required_soc engage bien la session —
contacteur ferme, takeover ON — mais laisse la consigne reseau a 700 W, donc la
batterie recoit une centaine de watts. C est le defaut du 01/09 a l identique,
et le seul des trois resultats INDIAGNOSTICABLE depuis le tableau de bord : il a
l air de marcher. La raison est que required_soc ENGAGE la session et
grid_energy_needed_kwh la FINANCE. Pose dans coverage(), le plancher fait bouger
les deux ensemble par construction.

Sens unique, comme les cablages de §38, §39 et §40 : le plancher ne peut que
RELEVER l exigence. Un reglage trop bas est sans effet, il ne desarme rien.

DEUX NOMBRES ET PAS UN, et c est architectural. `..._target` est le reglage du
proprietaire ; `..._floor` est sa valeur EFFECTIVE, mise a zero hors des fenetres
d heures creuses par une automatisation Home Assistant, et c est elle que le
bilan lit. Le bilan repond a « de combien la batterie a besoin », pas a « quand
est-ce bon marche » : il ne contient aucune notion d heure creuse (zero
occurrence de hc_night ou hc_midday dans tout src/energy-balance/) et doit le
rester. Home Assistant detient le calendrier tarifaire, le bilan detient
l energie.

Sans ce filtrage le plancher fuit dans la journee et mord sur
BATTERY_CLOUD_LOCKS — eveningSocStartFloorPct = max(eveningSocStartMin,
balanceRequiredSocPct) — au point de bloquer le demarrage de la charge du
vehicule le soir. Ce n est pas theorique, c est une ligne qui s applique tous les
soirs.

Le bouton se desarme a la sortie de la fenetre ou il a servi : une demande de
charge est un geste ponctuel, pas un reglage permanent oublie.

⚠️ EFFET STRUCTUREL CONSIGNE, qu aucune echeance ne corrige : coverage() est
appelee pour le profil typique ET le profil prudent, donc le plancher releve
aussi required_soc_prudent, qui est le plancher de P12. Pendant une charge
manuelle, la voiture cesse de descendre la batterie. Coherent, mais une nuit ou
l on veut les deux on n aura que l un.

Bilan 48 -> 50. Mutations : plancher ignore -> le scenario qui le releve ;
plancher qui remplace au lieu de relever -> le contrefactuel a 25 %. Chacune
tombe sur un jeu different. gridEnergyNeededKwh ajoute au resume de test : il
manquait alors qu il est desormais consomme en aval par la consigne reseau.

Gabarit du bilan rendu contre Home Assistant : 42 champs, JSON valide, helper
absent lu « unknown » donc plancher nul — le repli est sur avant meme que les
helpers existent.

⚠️ AUCUN PUT VERS n8n. Le workflow du bilan tourne toujours sans ce changement.

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 07:57:43 +02:00
parent 50e3fcc2df
commit 2654592b64
6 changed files with 237 additions and 3 deletions

View File

@@ -66,6 +66,41 @@ const socPct = num(input.soc_percent, NaN);
const usableCapacityKwh = num(input.usable_capacity_kwh, NaN);
const socFloorPct = num(input.soc_floor_percent, 22);
// =====================================================
// CHARGE HC MANUELLE — UN PLANCHER, PAS UNE CIBLE (18/09/2026)
// =====================================================
// Le proprietaire veut pouvoir demander une recharge batterie en heures creuses
// quand le bilan ne l'a pas jugee necessaire — journee d'hiver courte, ciel
// couvert. La decision qu'il a tranchee : CONTRAINDRE le bilan plutot que de
// l'ignorer, pour garder un seul arbitre. Ce projet a deja paye cher les
// automates concurrents (charges programmees de l'ESS §26, consigne a 4200 W
// §27, plancher SOC du GX §27).
//
// ⚠️ LE FORCAGE DOIT ETRE ICI, EN AMONT, ET NULLE PART AILLEURS. Mesure du
// 18/09 sur banc : ecrire directement dans `input_number.evcs_balance_required_soc`
// engage bien la session — contacteur ferme, takeover ON — mais laisse la
// consigne reseau a 700 W, donc la batterie recoit une centaine de watts. C'est
// le defaut du 01/09 et du 04/09 a l'identique, et c'est le seul resultat des
// trois qui soit INDIAGNOSTICABLE depuis le tableau de bord : il a l'air de
// marcher. La raison : `required_soc` ENGAGE la session, `grid_energy_needed_kwh`
// la FINANCE, et les deux sont publiees separement. Pose ici, dans `coverage()`,
// le plancher fait bouger les deux ENSEMBLE par construction — c'est une
// consequence de la forme du code et non une discipline a tenir.
//
// ⚠️ ET CE PLANCHER EST DEJA FILTRE PAR L'HEURE QUAND IL ARRIVE ICI. Le bilan
// ignore totalement les fenetres d'heures creuses (aucune occurrence de
// `hc_night` / `hc_midday` dans ce sous-systeme) et doit le rester : il repond a
// « de combien la batterie a-t-elle besoin », pas a « quand est-ce bon marche ».
// C'est Home Assistant qui detient le calendrier tarifaire et qui ne publie une
// valeur non nulle que pendant une fenetre creuse. Sans ce filtrage en amont, un
// plancher a 85 % fuirait dans la journee et mordrait sur
// `BATTERY_CLOUD_LOCKS.js` — `eveningSocStartFloorPct = max(eveningSocStartMin,
// balanceRequiredSocPct)` — au point de bloquer la charge du vehicule le soir.
const manualHcFloorPct = (() => {
const v = num(input.manual_hc_floor_percent, 0);
return Number.isFinite(v) && v > socFloorPct && v <= 100 ? v : 0;
})();
// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les
// contrôleurs retombent sur leurs règles historiques.
const missing = [];
@@ -223,7 +258,13 @@ function coverage(slots) {
const found = crossingIndex >= 0;
const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;
const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);
const requiredSocRawPct = socForEnergy(energyToCoverKwh, socFloorPct);
// Le plancher manuel ne peut que RELEVER l'exigence, jamais l'abaisser — meme
// sens unique que les cablages de §38, §39 et §40 : le bilan ne pousse jamais
// dans la direction « couvre moins bien ».
const requiredSocRawPct = Math.max(
socForEnergy(energyToCoverKwh, socFloorPct),
manualHcFloorPct
);
const socNeededPct = requiredSocRawPct - socFloorPct;
const requiredSocPct = Math.min(100, requiredSocRawPct);
const socMarginPct = socPct - requiredSocPct;

View File

@@ -17,6 +17,8 @@
"forecast_updated": states("input_text.evcs_balance_forecast_updated"),
"grid_soc_off": states("input_number.victron_grid_soc_off"),
"manual_hc_floor_percent": states("input_number.evcs_battery_manual_hc_floor"),
"pv_power_w": states("sensor.victron_pv_power_system"),
"house_ac_power_w": states("sensor.victron_consumption_power_l1"),