Un depassement souple de la batterie doit durer pour compter

La table des bandes SOC declarait depuis toujours un courant souple tolere
pendant des minutes et un courant dur tolere quelques secondes. Les deux
tolerances etaient calculees, exportees dans le debug, et lues par aucune
decision : le controleur comparait un echantillon instantane a un plancher,
toutes les 30 s.

A SOC 94 la batterie est en float et son courant oscille entre +1,8 et -2,6 A
d'un echantillon a l'autre. Le test devenait un tirage a pile ou face et la
charge du vehicule s'arretait sur du bruit : deux fois le 01/09, quatre a six
fois par jour la semaine precedente. Chaque arret coutait environ 0,45 kWh,
la batterie etant pleine et le vehicule le seul preneur.

Le correctif est purement temporel : aucun seuil n'est deplace, aucune
constante ajoutee. La tolerance de la bande est branchee sur les trois
conditions de MAINTIEN, via un helper de persistance qui porte le debut du
depassement en cours. Le plancher de demarrage et le garde dur restent
instantanes : la tolerance est du confort, jamais du risque.

Golden 56 -> 61. Preuve par mutation dans les deux sens : tolerance supprimee
-> 2 echecs cibles, tolerance infinie -> 3 echecs cibles, l'arret dur restant
vert dans les deux cas.

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-09-01 20:10:28 +02:00
parent a7ebaabb8b
commit 10987a04d8
9 changed files with 428 additions and 11 deletions

View File

@@ -453,6 +453,58 @@ const profileSpecificDischargeFloorA =
const profileBatteryDischargeFloorA =
Math.max(profileSpecificDischargeFloorA, batteryDischargeSoftDynamicA);
// =====================================================
// DEPASSEMENT SOUPLE DU PLANCHER DE DECHARGE (01/09/2026)
// =====================================================
// La table des bandes SOC (INPUT_PARSE) declare depuis toujours DEUX regimes :
// un courant SOUPLE tolere pendant des MINUTES (softToleranceMin : 12 min a
// SOC > 90, 1 min sous 25) et un courant DUR tolere quelques SECONDES. Seuls
// les seuils de courant etaient lus ; la dimension temporelle etait calculee,
// exportee dans le debug, et branchee sur AUCUNE decision. Le controleur
// comparait donc un echantillon instantane, toutes les 30 s, a un plancher.
//
// Incident du 01/09 (14:26 et 14:56, SOC 94, batterie en float) : le courant
// batterie oscillait entre +1,8 et -2,6 A d'un echantillon a l'autre, soit le
// bruit de regulation d'une batterie qui ne fait rien. Le test devenait un
// tirage a pile ou face et la charge du vehicule s'arretait dessus. Chaque
// arret coutait ~0,45 kWh de production refusee : batterie pleine, le vehicule
// etait le SEUL preneur, donc la PV s'effondrait de 6,6 a 2,5 kW en 30 s, et
// la reprise mettait 21 min a remonter de 6 A a 20 A. 1 a 6 arrets par jour
// sur la semaine du 26 au 31/08.
//
// Le correctif est PUREMENT TEMPOREL : aucun seuil n'est deplace, aucune
// constante n'est ajoutee. On applique la table telle qu'elle est ecrite :
// un depassement souple doit DURER pour compter. Comme le bruit change de
// signe en quelques secondes, le compteur se remet a zero de lui-meme ; une
// vraie decharge, elle, reste negative a chaque echantillon et finit par
// depasser la tolerance de sa bande.
//
// CE QUI N'EST PAS TOUCHE : le plancher de DEMARRAGE reste instantane (ne pas
// lancer une charge sur une batterie qui se decharge deja, c'est la frontiere
// "delestage et non charge prioritaire"), et le garde DUR reste instantane —
// hardToleranceSec n'est volontairement PAS branche, cela ne pourrait que
// ralentir une securite, et c'est le risque qui prime.
const batterySoftBreachNow =
sessionObservedActive &&
parsed.batteryCurrentA < profileBatteryDischargeFloorA;
const batterySoftBreachStartedMs = batterySoftBreachNow
? (history.batterySoftBreachMsPrev !== null
? history.batterySoftBreachMsPrev
: nowMs)
: null;
const batterySoftBreachMinutes =
batterySoftBreachStartedMs !== null
? Math.max(0, (nowMs - batterySoftBreachStartedMs) / 60000)
: 0;
// Vrai tant que le depassement n'a pas dure plus que la tolerance de sa bande.
// Une tolerance a 0 minute redonne exactement le comportement instantane.
const batterySoftDischargeHoldOk =
!batterySoftBreachNow ||
batterySoftBreachMinutes <= batterySoftToleranceMinutes;
// =====================================================
// GRID RELAX IN force_charge_now
// =====================================================
@@ -557,7 +609,7 @@ const batteryAssistSolarHoldOk =
commonSafetyHoldOk &&
!isGridSource &&
parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
batterySoftDischargeHoldOk &&
cloudToleranceOk;
// =====================================================
@@ -591,7 +643,7 @@ const rawSolarHoldOk =
parsed.soc >= effectiveSocHoldFloor &&
(
gridSettling ||
parsed.batteryCurrentA >= profileBatteryDischargeFloorA
batterySoftDischargeHoldOk
) &&
(
gridSettling ||
@@ -655,7 +707,7 @@ const probeStartOk =
const probeHoldGuardOk =
probeModeActive &&
commonSafetyHoldOk &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA;
batterySoftDischargeHoldOk;
const probeWindowActive =
probeModeActive &&
@@ -824,6 +876,14 @@ return [{
batteryHardToleranceSeconds,
batteryRecoveryMinutes,
// Depassement souple en cours (01/09/2026). startedMs = null quand il
// n'y a pas de depassement : c'est ce que HA_SERVICE_CALLS_OUTPUT lit
// pour ecrire ou effacer le helper de persistance.
batterySoftBreachNow,
batterySoftBreachStartedMs,
batterySoftBreachMinutes,
batterySoftDischargeHoldOk,
gridBatterySoftDynamicA,
gridBatteryHardDynamicA,
gridForceChargeRelaxSoftA,

View File

@@ -67,6 +67,12 @@ pushAction(
!!decision.set_cloud_debt_started_at
);
pushAction(
'helper_battery_soft_breach_at',
calls.helper_battery_soft_breach_at,
!!calls.helper_battery_soft_breach_at?.execute
);
pushAction(
'helper_block_reason',
calls.helper_block_reason,

View File

@@ -387,6 +387,13 @@ if (derived.gridSettling) {
if (gridSessionActive) {
reason.push(`Consigne reseau=${gridSetpointTargetW}W`);
}
// Visible dans le logbook uniquement pendant un depassement souple : c'est le
// seul moment ou l'information sert, et la raison est tronquee a 255 caracteres.
if (batteryPolicy.batterySoftBreachNow) {
reason.push(
`Batt souple ${round1(batteryPolicy.batterySoftBreachMinutes)}/${round1(batteryPolicy.batterySoftToleranceMinutes)}min`
);
}
reason.push(`Cap=${derived.physicalCapA}A`);
reason.push(`Ref=${round1(derived.currentReferenceA)}A`);
reason.push(`Target=${round1(desiredCurrentA)}A`);
@@ -498,6 +505,19 @@ const cloudDebtStartedAtToWrite =
? formatParisStampFromMs(cloud.cloudDebtStartedMs)
: now.stamp;
// DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : chaine vide quand il n'y a pas
// de depassement en cours. Le helper n'est reecrit que sur changement reel
// (debut ou fin), pas a chaque cycle : la valeur du debut doit RESTER figee
// pendant toute la duree du depassement, sinon le compteur ne monte jamais.
const batterySoftBreachAtToWrite =
batteryPolicy.batterySoftBreachStartedMs !== null &&
batteryPolicy.batterySoftBreachStartedMs !== undefined
? formatParisStampFromMs(batteryPolicy.batterySoftBreachStartedMs)
: '';
const setBatterySoftBreachAt =
batterySoftBreachAtToWrite !== (history.batterySoftBreachAt || '');
const probeStartedAtToWrite = now.stamp;
const lastStartAtToWrite = now.stamp;
const lastStopAtToWrite = now.stamp;
@@ -587,6 +607,10 @@ return [{
house_spike_detected: batteryPolicy.houseSpikeDetected,
solar_potential_context: batteryPolicy.solarPotentialContext,
batt_soft_breach_now: !!batteryPolicy.batterySoftBreachNow,
batt_soft_breach_minutes: round1(batteryPolicy.batterySoftBreachMinutes || 0),
batt_soft_discharge_hold_ok: batteryPolicy.batterySoftDischargeHoldOk !== false,
evcs_priority_active: evcsPriorityActive,
energy_priority_mode: energyPriorityMode,
energy_priority_reason: energyPriorityReason,
@@ -797,6 +821,16 @@ return [{
}
},
helper_battery_soft_breach_at: {
execute: setBatterySoftBreachAt,
domain: 'input_text',
service: 'set_value',
data: {
entity_id: 'input_text.victron_evcs_battery_soft_breach_at',
value: batterySoftBreachAtToWrite
}
},
helper_last_current_change_at: {
execute: setLastCurrentChangeAt,
domain: 'input_datetime',

View File

@@ -84,6 +84,7 @@
"cloud_debt_max_kwh": states("input_number.victron_evcs_cloud_debt_max_kwh"),
"cloud_tolerance_minutes": states("input_number.victron_evcs_cloud_tolerance_minutes"),
"cloud_debt_started_at": states("input_datetime.victron_evcs_cloud_debt_started_at"),
"battery_soft_breach_at": states("input_text.victron_evcs_battery_soft_breach_at"),
"operating_mode_helper": states("input_text.victron_evcs_operating_mode"),
"departure_time": states("input_datetime.victron_evcs_departure_time"),

View File

@@ -538,6 +538,12 @@ const history = {
lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),
lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),
cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),
// DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : horodatage du debut du
// depassement en cours, ecrit par HA_SERVICE_CALLS_OUTPUT. Vide/illisible
// => null => aucun depassement en cours, comportement de repli sur.
batterySoftBreachAt: cleanText(input.battery_soft_breach_at),
batterySoftBreachMsPrev: parseParisStampToMs(cleanText(input.battery_soft_breach_at)),
evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),
// =====================================================