Advisor: premier câblage réel — cible SOC matin (toggle, bornes, fraîcheur 120min)

- nouveau toggle input_boolean.evcs_advisor_apply_night_target (package + carte, OFF par défaut)
- snapshot/INPUT_PARSE: advisor_apply/target/status/last_run (+parseAdvisorRunToMs DD/MM HH:mm)
- WINDOWS: batteryMorningTargetPct = advisor si ON+ok+frais, clamp [max(22,hardFloor),90],
  sinon règles forecast; traçabilité '(ADVISOR)' dans la reason DECISION
- golden 26→29 (appliqué / stale / toggle off)
- HANDOFF §20 (surveillance + rollback 3 niveaux)
This commit is contained in:
Tallagrand Thierry
2026-08-09 07:32:46 +02:00
parent 6ca6ea99de
commit 98c9202bad
8 changed files with 214 additions and 7 deletions

View File

@@ -896,3 +896,67 @@ VRAI signal de fin (VDF no-demand ou status 3) — et tiendra désormais.
--- ---
*Document mis à jour le : 09/08/2026 (~07:05) — Fix F déployé, golden 26/26* *Document mis à jour le : 09/08/2026 (~07:05) — Fix F déployé, golden 26/26*
## 20. Session du 09/08/2026 — PREMIER CÂBLAGE RÉEL de l'advisor : cible SOC matin
### Décision utilisateur
Météo Bretagne au beau fixe (47-58 kWh/j prévus) = conditions idéales pour
tester le câblage avec risque minimal. Périmètre STRICTEMENT limité au plan
§16 : `night_target_soc` uniquement, derrière un toggle, rollback 1 clic.
### Ce qui est câblé
`batteryMorningTargetPct` (WINDOWS_SOURCEV2) suit
`input_number.evcs_advisor_night_target_soc` SI ET SEULEMENT SI :
1. `input_boolean.evcs_advisor_apply_night_target` = ON (nouveau toggle,
initial OFF, package evcs_advisor_shadow.yaml + carte dashboard) ;
2. `input_text.evcs_advisor_status` = 'ok' (exact) ;
3. recommandation fraîche ≤ 120 min (parse `DD/MM HH:mm` de advisor_last_run,
année courante, garde passage d'année — parseAdvisorRunToMs INPUT_PARSE) ;
4. valeur clampée [max(22, hard floor), 90].
Sinon → règles forecast historiques (`batteryMorningTargetRulePct`), aucun
changement de comportement. Le toggle OFF = état d'origine.
### Chaîne technique
- Snapshot : +advisor_apply_night_target, +advisor_night_target_soc,
+advisor_status, +advisor_last_run (states() d'une entité absente →
'unknown' → toggle vu OFF → sûr AVANT le reload HA).
- INPUT_PARSE : params.advisorApplyNightTarget / advisorNightTargetSocPct /
advisorStatusOk / advisorLastRunMs.
- WINDOWS : override borné + export advisorNightTargetApplied +
batteryMorningTargetRulePct (preview) ; BCL passthrough ; DECISION : la
reason affiche `target=X% (ADVISOR)` quand appliqué → visible logbook/carte.
- Impact aval automatique : gridTakeoverTriggerPct, grid_forced vs hc_allowed,
recovery post-charge, bande anti-rebond — tous dérivés de la cible.
### Golden 26 → 29
- `night-advisor-target-applique` : SOC 30, advisor 22 ON/ok/frais →
grid_hc_allowed (au lieu de grid_forced par la règle 35) ;
- `night-advisor-target-stale` : > 120 min → fallback règles (grid_forced) ;
- `night-advisor-toggle-off` : toggle OFF → règles inchangées.
### Sécurités NON modifiées
Hard floor 22 %, hysteresis grid ON ≤ 20 %, recovery urgent départ, latch
anti-créneaux, collant fin de charge (Fix A/E2/F). L'advisor ne peut RIEN
piloter d'autre (VALIDATE_PARSE borne night_target_soc 22-90 côté advisor,
clamp identique côté contrôleur).
### Rollback (3 niveaux)
1. Toggle « APPLIQUER cible SOC matin » OFF → effet au cycle suivant (30 s) ;
2. Kill-switch advisor OFF → plus de runs → fraîcheur > 120 min → fallback
auto en ≤ 2 h même si le toggle reste ON ;
3. `git revert` du commit + `node tools/n8n-push.mjs workflows/evcs-surplus-controller-v2.6.json`.
### Surveillance (2-3 nuits)
- Carte Conseiller IA : « Cible SOC matin (agent) » vs « (réel) » doivent
converger quand le toggle est ON ;
- Logbook « Victron EVCS - Reason » la nuit : `target=X% (ADVISOR)` ;
- Le matin : SOC batterie au lever du soleil ≈ cible advisor, remontée solaire OK ;
- Si SOC matin < 22 % ou EDF nuit anormal → toggle OFF + capture + analyse.
### Action utilisateur requise après ce déploiement
Recharger HA pour créer le toggle : Outils développeur → YAML →
« ENTRÉES BOOLÉENNES (input_boolean) » Recharger (ou redémarrage HA), puis
activer le toggle sur la carte Conseiller IA (dashboard EVCS Master rechargé).
---
*Document mis à jour le : 09/08/2026 (~07:35) — câblage advisor night_target déployé (toggle OFF par défaut), golden 29/29*

View File

@@ -360,6 +360,7 @@ const batteryMorningTargetPct =
Number.isFinite(nightPrev.batteryMorningTargetPct) Number.isFinite(nightPrev.batteryMorningTargetPct)
? nightPrev.batteryMorningTargetPct ? nightPrev.batteryMorningTargetPct
: params.evcsNightTargetWeakForecastPct; : params.evcsNightTargetWeakForecastPct;
const advisorNightTargetApplied = !!nightPrev.advisorNightTargetApplied;
const maxBatteryDischargePoints = const maxBatteryDischargePoints =
Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0; Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0;
const gridTakeoverTriggerPct = const gridTakeoverTriggerPct =
@@ -824,6 +825,7 @@ return [{
batteryHardFloorPct, batteryHardFloorPct,
batteryMorningTargetPct, batteryMorningTargetPct,
advisorNightTargetApplied,
maxBatteryDischargePoints, maxBatteryDischargePoints,
gridTakeoverTriggerPct, gridTakeoverTriggerPct,

View File

@@ -1273,7 +1273,7 @@ else {
// ===================================================== // =====================================================
if (nightEvcs?.nightSessionActive) { if (nightEvcs?.nightSessionActive) {
reason.push( reason.push(
`Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}% | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%` `Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}%${nightEvcs.advisorNightTargetApplied ? ' (ADVISOR)' : ''} | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`
); );
if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) { if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {

View File

@@ -118,6 +118,10 @@
"prev_surplus_state": states("input_text.victron_evcs_surplus_state"), "prev_surplus_state": states("input_text.victron_evcs_surplus_state"),
"evcs_night_session_start_soc": states("input_text.victron_evcs_night_session_start_soc"), "evcs_night_session_start_soc": states("input_text.victron_evcs_night_session_start_soc"),
"evcs_night_session_start_at": states("input_text.victron_evcs_night_session_start_at"), "evcs_night_session_start_at": states("input_text.victron_evcs_night_session_start_at"),
"advisor_apply_night_target": states("input_boolean.evcs_advisor_apply_night_target"),
"advisor_night_target_soc": states("input_number.evcs_advisor_night_target_soc"),
"advisor_status": states("input_text.evcs_advisor_status"),
"advisor_last_run": states("input_text.evcs_advisor_last_run"),
"allow_grid_hc_midday": states("input_boolean.victron_evcs_allow_grid_hc_midday"), "allow_grid_hc_midday": states("input_boolean.victron_evcs_allow_grid_hc_midday"),
"probe_enabled": states("input_boolean.victron_evcs_probe_enabled"), "probe_enabled": states("input_boolean.victron_evcs_probe_enabled"),

View File

@@ -158,6 +158,33 @@ function cleanText(v, fallback = '') {
return s; return s;
} }
// Advisor IA : input_text.evcs_advisor_last_run au format "DD/MM HH:mm"
// (pas d'année). On suppose l'année courante ; si le résultat est dans le
// futur (> 1 jour), c'est un passage d'année → on retire un an.
function parseAdvisorRunToMs(s) {
const m = (s ?? '').toString().trim().match(/^(\d{2})\/(\d{2}) (\d{2}):(\d{2})$/);
if (!m) return null;
const nowMsLocal = Date.now();
const year = Number(
new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric' })
.format(new Date(nowMsLocal))
);
const build = (y) => {
const guessUtc = Date.UTC(y, Number(m[2]) - 1, Number(m[1]), Number(m[3]), Number(m[4]), 0);
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
let ts = guessUtc - off1;
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
if (off2 !== off1) ts = guessUtc - off2;
return ts;
};
let ts = build(year);
if (ts - nowMsLocal > 24 * 3600 * 1000) ts = build(year - 1);
return ts;
}
function finiteOrNull(v) { function finiteOrNull(v) {
return Number.isFinite(v) ? v : null; return Number.isFinite(v) ? v : null;
} }
@@ -336,6 +363,14 @@ const params = {
allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on', allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on',
allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on', allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on',
// Advisor IA — premier câblage réel (09/08/2026, HANDOFF §20) :
// cible SOC matin recommandée, appliquée UNIQUEMENT si le toggle est ON.
// Garde-fous consommés dans WINDOWS_SOURCEV2 (statut ok + fraîcheur + bornes).
advisorApplyNightTarget: low(input.advisor_apply_night_target || 'off') === 'on',
advisorNightTargetSocPct: num(input.advisor_night_target_soc, NaN),
advisorStatusOk: cleanText(input.advisor_status).toLowerCase() === 'ok',
advisorLastRunMs: parseAdvisorRunToMs(cleanText(input.advisor_last_run)),
probeEnabled: low(input.probe_enabled || 'off') === 'on', probeEnabled: low(input.probe_enabled || 'off') === 'on',
probeMinutes: Math.max(1, num(input.probe_minutes, 3)), probeMinutes: Math.max(1, num(input.probe_minutes, 3)),
probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)), probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),

View File

@@ -257,13 +257,40 @@ const nightForecastKwh =
? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0) ? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0)
: parsed.todayRemainingKwh; : parsed.todayRemainingKwh;
const batteryMorningTargetPct = const batteryMorningTargetRulePct =
nightForecastKwh >= params.evcsNightStrongForecastKwh nightForecastKwh >= params.evcsNightStrongForecastKwh
? params.evcsNightTargetStrongForecastPct ? params.evcsNightTargetStrongForecastPct
: nightForecastKwh >= params.evcsNightMediumForecastKwh : nightForecastKwh >= params.evcsNightMediumForecastKwh
? params.evcsNightTargetMediumForecastPct ? params.evcsNightTargetMediumForecastPct
: params.evcsNightTargetWeakForecastPct; : params.evcsNightTargetWeakForecastPct;
// =====================================================
// ADVISOR IA — PREMIER CÂBLAGE RÉEL (09/08/2026, HANDOFF §20)
// La cible SOC matin suit la recommandation de l'advisor UNIQUEMENT si :
// - toggle input_boolean.evcs_advisor_apply_night_target ON (rollback 1 clic)
// - dernier run advisor en statut 'ok'
// - recommandation fraîche (≤ 120 min, l'advisor tourne toutes les 15 min)
// - valeur bornée [max(22, hard floor), 90]
// Sinon : règles forecast historiques (comportement inchangé).
// =====================================================
const ADVISOR_TARGET_MAX_AGE_MIN = 120;
const advisorTargetAgeMin = minutesSince(params.advisorLastRunMs, nowMs);
const advisorNightTargetApplied =
params.advisorApplyNightTarget &&
params.advisorStatusOk &&
Number.isFinite(params.advisorNightTargetSocPct) &&
advisorTargetAgeMin !== null &&
advisorTargetAgeMin <= ADVISOR_TARGET_MAX_AGE_MIN;
const batteryMorningTargetPct =
advisorNightTargetApplied
? clamp(
params.advisorNightTargetSocPct,
Math.max(22, batteryHardFloorPct),
90
)
: batteryMorningTargetRulePct;
const maxBatteryDischargePoints = const maxBatteryDischargePoints =
nightSessionActive && Number.isFinite(nightSessionStartSocPct) nightSessionActive && Number.isFinite(nightSessionStartSocPct)
? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct) ? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)
@@ -706,6 +733,8 @@ return [{
batteryHardFloorPct, batteryHardFloorPct,
batteryMorningTargetPct, batteryMorningTargetPct,
batteryMorningTargetRulePct,
advisorNightTargetApplied,
maxBatteryDischargePoints, maxBatteryDischargePoints,
gridTakeoverTriggerPct, gridTakeoverTriggerPct,

View File

@@ -135,6 +135,79 @@ export const scenarios = [
}, },
}, },
// ===== ADVISOR IA — premier câblage réel (09/08/2026, HANDOFF §20) =====
// Référence : night-soc-sous-cible (SOC 30 < cible règle 35 → grid_forced).
// Avec la cible advisor 22 APPLIQUÉE : SOC 30 ≥ 22 → grid_hc_allowed.
{
id: 'night-advisor-target-applique',
label: 'Nuit HC, SOC 30, advisor cible 22 appliquée (toggle ON, ok, frais) → hc_allowed au lieu de grid_forced',
time: NIGHT,
overrides: {
...VEHICLE_NIGHT, soc_percent: '30', allow_grid_hc_night: 'on',
advisor_apply_night_target: 'on',
advisor_night_target_soc: '22',
advisor_status: 'ok',
advisor_last_run: '08/08 01:20',
},
expect: {
profile: 'night_hc',
sourceMode: 'grid_hc_allowed',
state: 'night_wait_grid_takeover',
decision: 'hold',
desiredCharging: false,
currentA: 6,
takeoverNeeded: true,
edfOnAction: true,
takeoverHelperService: 'turn_on',
},
},
{
id: 'night-advisor-target-stale',
label: 'Nuit HC, SOC 30, advisor cible 22 mais recommandation > 120 min → fallback règles (grid_forced)',
time: NIGHT,
overrides: {
...VEHICLE_NIGHT, soc_percent: '30', allow_grid_hc_night: 'on',
advisor_apply_night_target: 'on',
advisor_night_target_soc: '22',
advisor_status: 'ok',
advisor_last_run: '07/08 20:00',
},
expect: {
profile: 'night_hc',
sourceMode: 'grid_forced',
state: 'night_wait_grid_takeover',
decision: 'hold',
desiredCharging: false,
currentA: 6,
takeoverNeeded: true,
edfOnAction: true,
takeoverHelperService: 'turn_on',
},
},
{
id: 'night-advisor-toggle-off',
label: 'Nuit HC, SOC 30, advisor cible 22 mais toggle APPLIQUER OFF → règles inchangées (grid_forced)',
time: NIGHT,
overrides: {
...VEHICLE_NIGHT, soc_percent: '30', allow_grid_hc_night: 'on',
advisor_apply_night_target: 'off',
advisor_night_target_soc: '22',
advisor_status: 'ok',
advisor_last_run: '08/08 01:20',
},
expect: {
profile: 'night_hc',
sourceMode: 'grid_forced',
state: 'night_wait_grid_takeover',
decision: 'hold',
desiredCharging: false,
currentA: 6,
takeoverNeeded: true,
edfOnAction: true,
takeoverHelperService: 'turn_on',
},
},
// ================= FIN DE CHARGE NUIT (P8) ================= // ================= FIN DE CHARGE NUIT (P8) =================
{ {
id: 'night-vw-pleine-transition', id: 'night-vw-pleine-transition',

File diff suppressed because one or more lines are too long