Incident du 18/09 13:08-13:14 : clic BOOST 28 A, le controleur demande le reseau, l'hysteresis met ~1 min a fermer le contacteur, et pendant ce trou il MAINTIENT 13 A sur la batterie (hold_min_run_wait_grid_takeover). Le garde batterie mord a -32,4 A, 23 secondes AVANT l'arrivee d'EDF, et pose 20 min de verrou — que rien ne leve quand le reseau arrive. Resultat : contacteur ferme, import qui tourne, voiture a l'arret. Trois volets : 1. hold_min_run_wait_grid_takeover retombe au courant MINI — miroir de la sequence d'ouverture (§30) : on ne tient pas la puissance sur la source qu'on quitte. A 6 A le garde n'aurait jamais mordu. 2. Le stop encode si le reseau etait present : absent => blockReason 'grid_battery_guard_no_grid'. Present => verrou plein inchange (incident du 01/09, consigne insuffisante) — c'est ce qui interdit l'oscillateur stop/retry a reseau present. 3. BCL leve le verrou '_no_grid' des que le reseau arrive. Reseau toujours absent : la branche anterieure wait_grid_source protege deja (epingle). Golden 108 -> 113. Mutations en trois directions, chacune tombant sur un scenario different. Piege du fixture note en commentaire : ses seuils ne sont pas a l'echelle du parc reel (fenetre garde reseau / hard general etroite). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
1393 lines
45 KiB
JavaScript
1393 lines
45 KiB
JavaScript
function clamp(v, min, max) {
|
|
return Math.min(Math.max(v, min), max);
|
|
}
|
|
|
|
function round1(v) {
|
|
return Math.round(v * 10) / 10;
|
|
}
|
|
|
|
function round3(v) {
|
|
return Math.round(v * 1000) / 1000;
|
|
}
|
|
|
|
function to2(n) {
|
|
return String(n).padStart(2, '0');
|
|
}
|
|
|
|
function finiteOr(v, fallback) {
|
|
return Number.isFinite(v) ? v : fallback;
|
|
}
|
|
|
|
function sigmoid(x) {
|
|
return 1 / (1 + Math.exp(-x));
|
|
}
|
|
|
|
function alphaFromTau(dtSec, tauSec) {
|
|
const dt = Math.max(1, finiteOr(dtSec, 60));
|
|
const tau = Math.max(1, finiteOr(tauSec, 60));
|
|
return Math.min(1, Math.max(0.001, 1 - Math.exp(-dt / tau)));
|
|
}
|
|
|
|
function ema(prev, sample, alpha) {
|
|
if (!Number.isFinite(sample)) return Number.isFinite(prev) ? prev : 0;
|
|
if (!Number.isFinite(prev)) return sample;
|
|
return prev + alpha * (sample - prev);
|
|
}
|
|
|
|
function formatParisStampFromMs(ms) {
|
|
const fmt = new Intl.DateTimeFormat('fr-FR', {
|
|
timeZone: 'Europe/Paris',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
|
|
const parts = Object.fromEntries(
|
|
fmt.formatToParts(new Date(ms))
|
|
.filter(p => p.type !== 'literal')
|
|
.map(p => [p.type, p.value])
|
|
);
|
|
|
|
const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);
|
|
return `${parts.year}-${parts.month}-${parts.day} ${to2(hour)}:${parts.minute}:${parts.second}`;
|
|
}
|
|
|
|
const prev = $input.first().json;
|
|
|
|
const {
|
|
now,
|
|
nowMs,
|
|
parsed,
|
|
params,
|
|
history,
|
|
strategy,
|
|
lifecycle,
|
|
context,
|
|
derived,
|
|
locks,
|
|
cloud,
|
|
modes,
|
|
safeguards,
|
|
conditions,
|
|
batteryTune,
|
|
nightEvcs,
|
|
} = prev;
|
|
|
|
// =====================================================
|
|
// DECISION INIT
|
|
// =====================================================
|
|
let desiredCharging = false;
|
|
let desiredPermission = false;
|
|
let desiredLatch = false;
|
|
let desiredCurrentA = params.currentMinA;
|
|
|
|
let state = 'idle';
|
|
let currentDecision = 'hold';
|
|
let dynamicOk = false;
|
|
let mustStop = false;
|
|
|
|
let reason = [];
|
|
|
|
let setCloudDebtStartedAt = false;
|
|
let setProbeStartedAt = false;
|
|
|
|
// EVCS night session memory
|
|
let setNightSessionStart = false;
|
|
let clearNightSessionStart = false;
|
|
|
|
let notifyWanted = false;
|
|
let notifyTitle = 'Victron EVCS Surplus';
|
|
let notifyMessage = '';
|
|
|
|
let nextBlockReason = 'none';
|
|
let nextBlockedUntilStamp = now.stamp;
|
|
|
|
// =====================================================
|
|
// HELPERS
|
|
// =====================================================
|
|
function setOff({
|
|
stateName,
|
|
decisionName = 'stop',
|
|
reasonText,
|
|
dynamic = false,
|
|
mustStopFlag = false,
|
|
blockReason = 'none',
|
|
blockedUntilStamp = now.stamp,
|
|
}) {
|
|
desiredCharging = false;
|
|
desiredPermission = false;
|
|
desiredLatch = false;
|
|
desiredCurrentA = params.currentMinA;
|
|
|
|
state = stateName;
|
|
currentDecision = decisionName;
|
|
dynamicOk = dynamic;
|
|
mustStop = mustStopFlag;
|
|
nextBlockReason = blockReason;
|
|
nextBlockedUntilStamp = blockedUntilStamp;
|
|
|
|
if (reasonText) reason.push(reasonText);
|
|
}
|
|
|
|
function setOn({
|
|
stateName,
|
|
decisionName = 'hold',
|
|
currentA = params.currentMinA,
|
|
reasonText,
|
|
dynamic = true,
|
|
mustStopFlag = false,
|
|
}) {
|
|
desiredCharging = true;
|
|
desiredPermission = true;
|
|
desiredLatch = true;
|
|
desiredCurrentA = clamp(
|
|
currentA,
|
|
params.currentMinA,
|
|
Math.max(params.currentMinA, derived.physicalCapA || params.currentMinA)
|
|
);
|
|
|
|
state = stateName;
|
|
currentDecision = decisionName;
|
|
dynamicOk = dynamic;
|
|
mustStop = mustStopFlag;
|
|
|
|
if (reasonText) reason.push(reasonText);
|
|
}
|
|
|
|
// =====================================================
|
|
// SESSION MEMORY SIDE EFFECTS
|
|
// =====================================================
|
|
if (nightEvcs?.nightSessionNeedsCapture) {
|
|
setNightSessionStart = true;
|
|
}
|
|
|
|
if (nightEvcs?.nightSessionNeedsClear) {
|
|
clearNightSessionStart = true;
|
|
}
|
|
|
|
// =====================================================
|
|
// MANUAL LOCAL CHARGE SEMANTICS
|
|
// IMPORTANT:
|
|
// force_charge_now = bypass fenêtres pour la charge locale,
|
|
// sans imposer le réseau.
|
|
// =====================================================
|
|
const manualChargeBypassWindows = !!context.manualChargeBypassWindows;
|
|
const manualChargeLocalRequested = !!context.manualChargeLocalRequested;
|
|
|
|
const manualLocalChargeAllowed =
|
|
!!strategy.forceChargeRequested &&
|
|
manualChargeBypassWindows &&
|
|
manualChargeLocalRequested &&
|
|
!safeguards.mustStopHard &&
|
|
!!derived.canRunAtMinCurrent;
|
|
|
|
// =====================================================
|
|
// EVCS NUMERIC NO-DEMAND FILTER
|
|
// Objectif:
|
|
// détecter "voiture branchée + session armée + commande claire
|
|
// mais plus de demande réelle" sans seuils fixes bruts.
|
|
// IMPORTANT:
|
|
// on n'utilise PAS le PV comme critère.
|
|
// =====================================================
|
|
const prevDeliveryRatioEma = finiteOr(history.evcsDeliveryRatioEma, NaN);
|
|
const prevDeliveryNoiseEma = finiteOr(history.evcsDeliveryNoiseEma, NaN);
|
|
const prevNoDemandScoreEma = finiteOr(history.evcsNoDemandScoreEma, NaN);
|
|
const prevNoDemandSemanticActive = !!history.evcsNoDemandSemanticActive;
|
|
const prevNoDemandSinceMs = finiteOr(history.evcsNoDemandSinceMs, NaN);
|
|
const prevNoDemandFilterEvalMs = finiteOr(history.evcsNoDemandFilterEvalMs, NaN);
|
|
|
|
const noDemandDtSec =
|
|
Number.isFinite(prevNoDemandFilterEvalMs) && Number.isFinite(nowMs)
|
|
? clamp((nowMs - prevNoDemandFilterEvalMs) / 1000, 1, 600)
|
|
: 60;
|
|
|
|
// Constantes dynamiques du filtre = temps de réponse du filtre,
|
|
// pas des seuils métiers bruts.
|
|
const deliveryTauSec = Math.max(15, finiteOr(params.evcsDeliveryTauSec, 45));
|
|
const noiseTauSec = Math.max(deliveryTauSec, finiteOr(params.evcsDeliveryNoiseTauSec, 180));
|
|
const scoreTauSec = Math.max(20, finiteOr(params.evcsNoDemandScoreTauSec, 150));
|
|
const armTauSec = Math.max(20, finiteOr(params.evcsNoDemandArmTauSec, 180));
|
|
|
|
const alphaDelivery = alphaFromTau(noDemandDtSec, deliveryTauSec);
|
|
const alphaNoise = alphaFromTau(noDemandDtSec, noiseTauSec);
|
|
const alphaScore = alphaFromTau(noDemandDtSec, scoreTauSec);
|
|
|
|
const acVoltageRef = finiteOr(derived.acVoltageRef, 230);
|
|
|
|
const commandCurrentA = clamp(
|
|
finiteOr(
|
|
derived.currentReferenceA,
|
|
finiteOr(
|
|
parsed.evcsCommandCurrentA,
|
|
finiteOr(parsed.evcsSetCurrentA, finiteOr(params.currentMinA, 6))
|
|
)
|
|
),
|
|
0,
|
|
32
|
|
);
|
|
|
|
const expectedPowerW = Math.max(1, commandCurrentA * acVoltageRef);
|
|
|
|
const realCurrentA = Math.abs(
|
|
finiteOr(
|
|
parsed.evcsCurrentRealA,
|
|
acVoltageRef > 180
|
|
? finiteOr(parsed.evcsAcPowerW, 0) / acVoltageRef
|
|
: 0
|
|
)
|
|
);
|
|
|
|
const realPowerW = Math.max(0, finiteOr(parsed.evcsAcPowerW, 0));
|
|
|
|
const currentDeliveryRatioRaw = clamp(
|
|
realCurrentA / Math.max(commandCurrentA, 0.1),
|
|
0,
|
|
1.25
|
|
);
|
|
|
|
const powerDeliveryRatioRaw = clamp(
|
|
realPowerW / expectedPowerW,
|
|
0,
|
|
1.25
|
|
);
|
|
|
|
const deliveryRatioRaw = clamp(
|
|
Math.max(currentDeliveryRatioRaw, powerDeliveryRatioRaw),
|
|
0,
|
|
1
|
|
);
|
|
|
|
const deliveryRatioEma = ema(prevDeliveryRatioEma, deliveryRatioRaw, alphaDelivery);
|
|
|
|
const deliveryResidualAbs = Math.abs(deliveryRatioRaw - deliveryRatioEma);
|
|
const deliveryNoiseEma = ema(prevDeliveryNoiseEma, deliveryResidualAbs, alphaNoise);
|
|
|
|
const adaptiveQuietBand = clamp(
|
|
0.02 + 3.0 * deliveryNoiseEma,
|
|
0.02,
|
|
0.35
|
|
);
|
|
|
|
const noDemandEvidenceRaw = clamp(
|
|
(adaptiveQuietBand - deliveryRatioEma) / adaptiveQuietBand,
|
|
0,
|
|
1
|
|
);
|
|
|
|
const sessionElapsedSec = Math.max(
|
|
0,
|
|
finiteOr(lifecycle.minutesSinceStart, 0) * 60
|
|
);
|
|
|
|
const sessionArmFactor = 1 - Math.exp(-sessionElapsedSec / armTauSec);
|
|
|
|
const currentMinRefA = Math.max(1, finiteOr(params.currentMinA, 6));
|
|
const commandStrength = commandCurrentA / currentMinRefA;
|
|
|
|
const commandFactor = clamp(
|
|
sigmoid((commandStrength - 1.0) * 2.2),
|
|
0,
|
|
1
|
|
);
|
|
|
|
// Source effectivement livrable.
|
|
// IMPORTANT:
|
|
// on évite de conclure "voiture pleine" si en réalité la source n'est
|
|
// pas disponible (ex: attente grid takeover).
|
|
const manualLocalNoDemandEligible =
|
|
manualLocalChargeAllowed &&
|
|
(
|
|
context.sourceMode === 'solar_only' ||
|
|
context.sourceMode === 'solar_potential' ||
|
|
context.sourceMode === 'blocked'
|
|
);
|
|
|
|
const sourceDeliverableForNoDemand =
|
|
manualLocalNoDemandEligible
|
|
? true
|
|
: (context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')
|
|
? !!parsed.gridAvailable
|
|
: (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')
|
|
? !!conditions.solarHoldOk || !!conditions.solarStartOk
|
|
: context.sourceMode === 'hybrid_probe'
|
|
? !!conditions.probeHoldGuardOk || !!conditions.probeSuccessNow || !!conditions.probeStartOk
|
|
: context.sourceMode === 'battery_assist_night'
|
|
? !!conditions.batteryAssistNightHoldOk || !!conditions.batteryAssistNightStartOk
|
|
: false;
|
|
|
|
const evcsSessionArmedForNoDemand =
|
|
!!cloud.sessionObservedActive &&
|
|
!!parsed.evcsVehicleConnected &&
|
|
!!parsed.evcsChargingSwitchOn &&
|
|
!!parsed.chargePermissionNow &&
|
|
!!parsed.controlLatch &&
|
|
commandCurrentA > 0 &&
|
|
sourceDeliverableForNoDemand;
|
|
|
|
const noDemandScoreRaw = evcsSessionArmedForNoDemand
|
|
? clamp(
|
|
noDemandEvidenceRaw * sessionArmFactor * commandFactor,
|
|
0,
|
|
1
|
|
)
|
|
: 0;
|
|
|
|
const noDemandScoreEma = ema(prevNoDemandScoreEma, noDemandScoreRaw, alphaScore);
|
|
|
|
const adaptiveDecisionCenter = clamp(
|
|
0.72 - 0.20 * deliveryNoiseEma,
|
|
0.55,
|
|
0.82
|
|
);
|
|
|
|
const adaptiveDecisionBand = clamp(
|
|
0.16 + 0.80 * deliveryNoiseEma,
|
|
0.12,
|
|
0.34
|
|
);
|
|
|
|
const noDemandScoreOn = clamp(
|
|
adaptiveDecisionCenter + adaptiveDecisionBand / 2,
|
|
0.60,
|
|
0.95
|
|
);
|
|
|
|
const noDemandScoreOff = clamp(
|
|
adaptiveDecisionCenter - adaptiveDecisionBand / 2,
|
|
0.15,
|
|
noDemandScoreOn - 0.05
|
|
);
|
|
|
|
let evcsVehicleNoDemandSemantic = prevNoDemandSemanticActive;
|
|
|
|
if (!evcsSessionArmedForNoDemand) {
|
|
evcsVehicleNoDemandSemantic = false;
|
|
} else if (prevNoDemandSemanticActive) {
|
|
evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOff;
|
|
} else {
|
|
evcsVehicleNoDemandSemantic = noDemandScoreEma >= noDemandScoreOn;
|
|
}
|
|
|
|
const evcsNoDemandSinceMs =
|
|
evcsVehicleNoDemandSemantic
|
|
? (
|
|
prevNoDemandSemanticActive && Number.isFinite(prevNoDemandSinceMs)
|
|
? prevNoDemandSinceMs
|
|
: nowMs
|
|
)
|
|
: null;
|
|
|
|
const evcsNoDemandSinceMinutes =
|
|
Number.isFinite(evcsNoDemandSinceMs) && Number.isFinite(nowMs)
|
|
? Math.max(0, (nowMs - evcsNoDemandSinceMs) / 60000)
|
|
: null;
|
|
|
|
const vehicleDemandFilter = {
|
|
dtSec: round3(noDemandDtSec),
|
|
|
|
commandCurrentA: round3(commandCurrentA),
|
|
realCurrentA: round3(realCurrentA),
|
|
expectedPowerW: round3(expectedPowerW),
|
|
realPowerW: round3(realPowerW),
|
|
|
|
currentDeliveryRatioRaw: round3(currentDeliveryRatioRaw),
|
|
powerDeliveryRatioRaw: round3(powerDeliveryRatioRaw),
|
|
deliveryRatioRaw: round3(deliveryRatioRaw),
|
|
deliveryRatioEma: round3(deliveryRatioEma),
|
|
deliveryNoiseEma: round3(deliveryNoiseEma),
|
|
|
|
adaptiveQuietBand: round3(adaptiveQuietBand),
|
|
noDemandEvidenceRaw: round3(noDemandEvidenceRaw),
|
|
|
|
sessionElapsedSec: round3(sessionElapsedSec),
|
|
sessionArmFactor: round3(sessionArmFactor),
|
|
commandFactor: round3(commandFactor),
|
|
sourceDeliverableForNoDemand,
|
|
evcsSessionArmedForNoDemand,
|
|
|
|
noDemandScoreRaw: round3(noDemandScoreRaw),
|
|
noDemandScoreEma: round3(noDemandScoreEma),
|
|
|
|
noDemandScoreOn: round3(noDemandScoreOn),
|
|
noDemandScoreOff: round3(noDemandScoreOff),
|
|
|
|
evcsVehicleNoDemandSemantic,
|
|
evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,
|
|
evcsNoDemandSinceMinutes: Number.isFinite(evcsNoDemandSinceMinutes)
|
|
? round3(evcsNoDemandSinceMinutes)
|
|
: null,
|
|
|
|
nextHistory: {
|
|
evcsDeliveryRatioEma: round3(deliveryRatioEma),
|
|
evcsDeliveryNoiseEma: round3(deliveryNoiseEma),
|
|
evcsNoDemandScoreEma: round3(noDemandScoreEma),
|
|
evcsNoDemandSemanticActive: evcsVehicleNoDemandSemantic,
|
|
evcsNoDemandSinceMs: Number.isFinite(evcsNoDemandSinceMs) ? evcsNoDemandSinceMs : null,
|
|
evcsNoDemandFilterEvalMs: nowMs
|
|
}
|
|
};
|
|
|
|
// =====================================================
|
|
// DECISION TREE
|
|
// =====================================================
|
|
if (!parsed.controllerEnabled) {
|
|
desiredCharging = parsed.evcsChargingSwitchOn;
|
|
desiredPermission = parsed.chargePermissionNow;
|
|
desiredLatch = parsed.controlLatch;
|
|
desiredCurrentA = clamp(
|
|
derived.currentReferenceA || params.currentMinA,
|
|
params.currentMinA,
|
|
params.currentMaxAutoA
|
|
);
|
|
|
|
state = 'controller_disabled';
|
|
currentDecision = 'hold';
|
|
dynamicOk = false;
|
|
mustStop = false;
|
|
reason.push('Contrôleur désactivé');
|
|
}
|
|
|
|
else if (strategy.isStopMode) {
|
|
setOff({
|
|
stateName: 'stopped_user',
|
|
decisionName: 'stop_stop_mode',
|
|
reasonText: 'Arrêt manuel via Stratégie STOP',
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
blockReason: 'manual_stop',
|
|
blockedUntilStamp: now.stamp,
|
|
});
|
|
}
|
|
|
|
else if (parsed.evcsModeIsAuto) {
|
|
setOff({
|
|
stateName: 'mode_auto_conflict',
|
|
decisionName: 'stop_auto_mode_conflict',
|
|
reasonText: 'Borne en mode Auto interne',
|
|
dynamic: false,
|
|
mustStopFlag: true,
|
|
blockReason: 'mode_auto_conflict',
|
|
blockedUntilStamp: now.stamp,
|
|
});
|
|
}
|
|
|
|
else if (!parsed.evcsDeviceConnected || !parsed.evcsVehicleConnected) {
|
|
setOff({
|
|
stateName: 'vehicle_not_connected',
|
|
decisionName: 'stop_not_connected',
|
|
reasonText: 'Véhicule ou borne non connecté',
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
blockReason: 'vehicle_not_connected',
|
|
blockedUntilStamp: now.stamp,
|
|
});
|
|
}
|
|
|
|
// =====================================================
|
|
// Fix F (09/08/2026) : MÉMOIRE "CHARGE TERMINÉE" PRIORITAIRE.
|
|
// Incident 09/08 06:51→06:56 : la VW pleine relançait des micro-charges
|
|
// (sur batterie, EDF OFF) → la garde mustStopHard émettait
|
|
// 'battery_current_hard_stop' qui ÉCRASAIT l'état collant dans le helper
|
|
// → completed perdu au cycle suivant → re-P7 → EDF rallumé (rebond).
|
|
// Ici : tant que la session nuit est active et la charge terminée, l'état
|
|
// collant est réécrit à CHAQUE cycle, AU-DESSUS des gardes. Aucune perte de
|
|
// protection : ce setOff coupe la charge aussi fermement qu'un hard stop
|
|
// (chg/perm/latch OFF + mustStop si la borne est encore armée).
|
|
// =====================================================
|
|
else if (
|
|
context.profile === 'night_hc' &&
|
|
nightEvcs?.vehicleChargeCompleted &&
|
|
!manualLocalChargeAllowed &&
|
|
!strategy.forceGridRequested
|
|
) {
|
|
setOff({
|
|
stateName: nightEvcs?.nightRecoveryDone
|
|
? 'night_recovery_done_idle'
|
|
: 'night_session_done_idle',
|
|
decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',
|
|
reasonText: nightEvcs?.nightRecoveryDone
|
|
? 'Charge véhicule terminée + batterie à la cible : veille nuit, réseau relâché'
|
|
: 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)',
|
|
dynamic: false,
|
|
mustStopFlag: !!cloud.sessionObservedActive,
|
|
blockReason: 'none',
|
|
blockedUntilStamp: now.stamp,
|
|
});
|
|
}
|
|
|
|
// =====================================================
|
|
// P9 (09/08/2026) : véhicule plein pendant la fenêtre "départ imminent"
|
|
// (jour, hors HC) → veille COLLANTE 'departure_done_idle', réécrite chaque
|
|
// cycle au-dessus des gardes (même principe que Fix F). Le réseau est
|
|
// relâché, l'hystérésis coupe immédiatement (cut E2 étendu).
|
|
// =====================================================
|
|
else if (
|
|
nightEvcs?.departureChargeDone &&
|
|
!manualLocalChargeAllowed &&
|
|
!strategy.forceGridRequested
|
|
) {
|
|
setOff({
|
|
stateName: 'departure_done_idle',
|
|
decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',
|
|
reasonText: 'Charge terminée avant départ : veille, réseau relâché',
|
|
dynamic: false,
|
|
mustStopFlag: !!cloud.sessionObservedActive,
|
|
blockReason: 'none',
|
|
blockedUntilStamp: now.stamp,
|
|
});
|
|
}
|
|
|
|
// =====================================================
|
|
// P10 (09/08/2026) : véhicule plein en JOURNÉE → veille collante
|
|
// 'day_vehicle_full_idle' jusqu'au débranchement. Évite les re-tentatives
|
|
// solaires cycliques sur un véhicule plein (usure relais). Réécrite chaque
|
|
// cycle au-dessus des gardes (principe Fix F). Échappatoires : force_*.
|
|
// =====================================================
|
|
else if (
|
|
nightEvcs?.dayVehicleFullIdle &&
|
|
!manualLocalChargeAllowed &&
|
|
!strategy.forceGridRequested
|
|
) {
|
|
setOff({
|
|
stateName: 'day_vehicle_full_idle',
|
|
decisionName: cloud.sessionObservedActive ? 'stop_vehicle_full_or_paused' : 'hold',
|
|
reasonText: 'Véhicule plein : veille jour, aucune nouvelle tentative jusqu\'au débranchement',
|
|
dynamic: false,
|
|
mustStopFlag: !!cloud.sessionObservedActive,
|
|
blockReason: 'none',
|
|
blockedUntilStamp: now.stamp,
|
|
});
|
|
}
|
|
|
|
else if (safeguards.mustStopHard) {
|
|
setOff({
|
|
stateName:
|
|
derived.anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :
|
|
derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :
|
|
derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
|
|
safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
|
|
'hard_stop',
|
|
decisionName: 'stop_hard',
|
|
reasonText: 'Hard stop de protection',
|
|
dynamic: false,
|
|
mustStopFlag: true,
|
|
blockReason:
|
|
safeguards.batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
|
|
derived.vebusDcVoltageHardLow ? 'vebus_dc_voltage_stop' :
|
|
derived.anyVebusLowBattery ? 'vebus_low_battery_stop' :
|
|
derived.vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
|
|
'battery_protection_stop',
|
|
blockedUntilStamp: formatParisStampFromMs(nowMs + 15 * 60000),
|
|
});
|
|
}
|
|
|
|
// =====================================================
|
|
// ACTIVE SESSION BRANCH
|
|
// =====================================================
|
|
else if (cloud.sessionObservedActive) {
|
|
if (cloud.enteringCloudDebt) {
|
|
setCloudDebtStartedAt = true;
|
|
}
|
|
|
|
if (conditions.shouldStopOverloadAtMin) {
|
|
setOff({
|
|
stateName: 'vebus_overload_stop_min_current',
|
|
decisionName: 'stop',
|
|
reasonText: 'Arrêt: surcharge convertisseur persistante au courant mini',
|
|
dynamic: false,
|
|
mustStopFlag: true,
|
|
blockReason: 'vebus_overload_persistent',
|
|
blockedUntilStamp: formatParisStampFromMs(
|
|
nowMs + batteryTune.global.overloadStopAfterSeconds * 1000
|
|
),
|
|
});
|
|
}
|
|
|
|
else if (safeguards.gridBatteryProtectionStop) {
|
|
setOff({
|
|
stateName: 'stop_grid_battery_guard',
|
|
decisionName: 'stop',
|
|
reasonText: `Arrêt charge réseau: batterie trop sollicitée (${round1(parsed.batteryCurrentA)}A)`,
|
|
dynamic: false,
|
|
mustStopFlag: true,
|
|
// Un verrou meurt avec sa cause (incident du 18/09 : BOOST demande le
|
|
// reseau, l'hysteresis met ~1 min a fermer, le garde mord 23 s AVANT
|
|
// l'arrivee d'EDF et posait 20 min de verrou reseau present). On encode
|
|
// ICI si le reseau etait la : absent => '_no_grid', et BCL levera le
|
|
// verrou des que le reseau arrive. Present => la cause est ailleurs
|
|
// (consigne insuffisante, incident du 01/09) et le verrou plein tient —
|
|
// c'est ce qui interdit l'oscillateur stop/retry a reseau present.
|
|
blockReason: parsed.gridAvailable ? 'grid_battery_guard' : 'grid_battery_guard_no_grid',
|
|
blockedUntilStamp: formatParisStampFromMs(nowMs + 20 * 60000),
|
|
});
|
|
}
|
|
|
|
else if (conditions.probeFailedNow) {
|
|
setOff({
|
|
stateName: 'probe_failed_lock',
|
|
decisionName: 'stop',
|
|
reasonText: 'Mode sonde échoué',
|
|
dynamic: false,
|
|
mustStopFlag: true,
|
|
blockReason: 'probe_failed',
|
|
blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),
|
|
});
|
|
}
|
|
|
|
else if (derived.physicalCapA < params.currentMinA) {
|
|
if (lifecycle.minRunElapsed) {
|
|
setOff({
|
|
stateName: 'stop_power_cap',
|
|
decisionName: 'stop_power_cap',
|
|
reasonText: 'Capacité système insuffisante',
|
|
dynamic: false,
|
|
mustStopFlag: true,
|
|
blockReason: 'system_capacity_stop',
|
|
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
|
|
});
|
|
} else {
|
|
setOn({
|
|
stateName: 'hold_min_run_power_cap',
|
|
decisionName: 'hold',
|
|
currentA: params.currentMinA,
|
|
reasonText: "Maintien jusqu'au temps mini ON",
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
else if (conditions.probeWindowActive && !conditions.probeSuccessNow) {
|
|
if (!conditions.probeHoldGuardOk) {
|
|
setOff({
|
|
stateName: 'probe_stop_guard',
|
|
decisionName: 'stop',
|
|
reasonText: 'Sonde interrompue: garde-fous non respectés',
|
|
dynamic: false,
|
|
mustStopFlag: true,
|
|
blockReason: 'probe_failed',
|
|
blockedUntilStamp: formatParisStampFromMs(nowMs + params.probeRetryMinutes * 60000),
|
|
});
|
|
} else {
|
|
setOn({
|
|
stateName: 'probe_hold',
|
|
decisionName: 'hold',
|
|
currentA: Math.min(params.probeCurrentA, derived.physicalCapA),
|
|
reasonText: `Mode sonde actif à ${round1(Math.min(params.probeCurrentA, derived.physicalCapA))}A`,
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
else {
|
|
const activeSourceMode =
|
|
conditions.probeSuccessNow
|
|
? 'solar_only'
|
|
: context.sourceMode;
|
|
|
|
const holdConditionsOk =
|
|
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced')
|
|
? conditions.gridHoldOk
|
|
: (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential')
|
|
? conditions.solarHoldOk
|
|
: activeSourceMode === 'hybrid_probe'
|
|
? conditions.probeHoldGuardOk
|
|
: activeSourceMode === 'battery_assist_night'
|
|
? conditions.batteryAssistNightHoldOk
|
|
: false;
|
|
|
|
const manualLocalHoldOverride =
|
|
manualLocalChargeAllowed &&
|
|
(
|
|
activeSourceMode === 'solar_only' ||
|
|
activeSourceMode === 'solar_potential' ||
|
|
activeSourceMode === 'blocked'
|
|
);
|
|
|
|
const effectiveHoldConditionsOk =
|
|
holdConditionsOk || manualLocalHoldOverride;
|
|
|
|
// =================================================
|
|
// VEHICLE FULL / PAUSED - NUMERIC FILTER DECISION
|
|
// NB Fix F (09/08/2026) : le cas "charge terminée en session nuit" est
|
|
// désormais intercepté PLUS HAUT dans la cascade (mémoire prioritaire),
|
|
// avant les gardes. Ici ne restent que la détection VDF initiale et
|
|
// les autres sources.
|
|
// IMPORTANT:
|
|
// - uniquement en session active
|
|
// - uniquement si la source est réellement livrable
|
|
// - sans utiliser le PV comme critère
|
|
// =================================================
|
|
if (
|
|
vehicleDemandFilter.evcsVehicleNoDemandSemantic &&
|
|
vehicleDemandFilter.sourceDeliverableForNoDemand
|
|
) {
|
|
setOff({
|
|
stateName: 'vehicle_full_or_paused',
|
|
decisionName: 'stop_vehicle_full_or_paused',
|
|
reasonText: 'Véhicule branché mais ne demande plus réellement de puissance',
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
blockReason: 'none',
|
|
blockedUntilStamp: now.stamp,
|
|
});
|
|
|
|
reason.push(`DeliveryRatioEma=${vehicleDemandFilter.deliveryRatioEma}`);
|
|
reason.push(`NoiseEma=${vehicleDemandFilter.deliveryNoiseEma}`);
|
|
reason.push(`NoDemandScore=${vehicleDemandFilter.noDemandScoreEma}`);
|
|
reason.push(`ScoreOn=${vehicleDemandFilter.noDemandScoreOn}`);
|
|
reason.push(`ScoreOff=${vehicleDemandFilter.noDemandScoreOff}`);
|
|
|
|
if (vehicleDemandFilter.evcsNoDemandSinceMinutes !== null) {
|
|
reason.push(`NoDemand depuis ${round1(vehicleDemandFilter.evcsNoDemandSinceMinutes)} min`);
|
|
}
|
|
}
|
|
|
|
else if (manualLocalHoldOverride && activeSourceMode === 'blocked') {
|
|
setOn({
|
|
stateName: 'charging_hold_manual_local',
|
|
decisionName: 'hold',
|
|
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
|
|
reasonText: 'Maintien manuel local hors fenêtre sans réseau',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
|
|
else if (activeSourceMode === 'blocked') {
|
|
if (lifecycle.minRunElapsed) {
|
|
setOff({
|
|
stateName: 'stop_source_lost',
|
|
decisionName: 'stop',
|
|
reasonText: 'Source autorisée perdue',
|
|
dynamic: false,
|
|
mustStopFlag: true,
|
|
blockReason: context.profile === 'outside_window' ? 'outside_window' : 'source_unavailable',
|
|
blockedUntilStamp:
|
|
context.profile === 'outside_window' && Number.isFinite(locks.nextAllowedWindowStartMs)
|
|
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
|
|
: formatParisStampFromMs(nowMs + params.minStopMinutes * 60000),
|
|
});
|
|
} else {
|
|
setOn({
|
|
stateName: 'hold_min_run_source_lost',
|
|
decisionName: 'hold',
|
|
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
|
|
reasonText: "Source perdue mais maintien jusqu'au mini ON",
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
else if (
|
|
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
|
|
!parsed.gridAvailable
|
|
) {
|
|
if (lifecycle.minRunElapsed) {
|
|
setOff({
|
|
stateName:
|
|
context.profile === 'night_hc'
|
|
? 'night_wait_grid_takeover_runtime'
|
|
: 'wait_grid_source_runtime',
|
|
decisionName: 'hold',
|
|
reasonText:
|
|
context.profile === 'night_hc'
|
|
? 'Relais réseau demandé pendant la session nuit EVCS'
|
|
: 'Source réseau demandée mais pas encore disponible',
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
blockReason:
|
|
context.profile === 'night_hc' ? 'grid_hc_unavailable' : 'grid_unavailable',
|
|
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
|
|
});
|
|
} else {
|
|
setOn({
|
|
stateName: 'hold_min_run_wait_grid_takeover',
|
|
decisionName: 'hold',
|
|
// Miroir de la sequence d'ouverture (§30) : quand on bascule VERS le
|
|
// reseau, on ne tient pas la puissance sur la source qu'on quitte.
|
|
// Tenir currentReferenceA (13 A le 18/09) pendant les ~80 s ou
|
|
// l'hysteresis n'a pas encore ferme le contacteur fait porter ~3 kW
|
|
// a la batterie et declenche le garde — courant MINI en attendant.
|
|
currentA: Math.min(params.currentMinA, derived.physicalCapA),
|
|
reasonText: "Attente du réseau au courant mini",
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
else if (!effectiveHoldConditionsOk) {
|
|
if (lifecycle.minRunElapsed) {
|
|
setOff({
|
|
stateName: 'stop_conditions_lost',
|
|
decisionName: 'stop',
|
|
reasonText: 'Conditions de maintien perdues',
|
|
dynamic: false,
|
|
mustStopFlag: true,
|
|
blockReason: 'hold_conditions_lost',
|
|
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
|
|
});
|
|
} else {
|
|
setOn({
|
|
stateName: 'hold_min_run',
|
|
decisionName: 'hold',
|
|
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
|
|
reasonText: "Maintien jusqu'au temps mini ON",
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// SÉQUENCE D'OUVERTURE (31/08/2026) : un plafond n'est pas une cible qu'on
|
|
// approche, c'est une limite qu'on respecte. Cette réduction attendait le
|
|
// cooldown de courant — un compteur de confort — et ne descendait que d'un
|
|
// pas à la fois : de 28 A à 20 A elle prenait plusieurs cooldowns, pendant
|
|
// lesquels les onduleurs portaient un courant qu'ils n'ont plus le droit de
|
|
// porter (le plafond retombe à la disparition du réseau). Désormais :
|
|
// descente IMMÉDIATE et DIRECTE au plafond. Réduire est toujours sûr ; c'est
|
|
// la montée qui doit être temporisée, et elle l'est.
|
|
else if (derived.currentReferenceA > derived.physicalCapA) {
|
|
setOn({
|
|
stateName: 'charging_step_down_system_cap',
|
|
decisionName: 'step_down_fast',
|
|
currentA: Math.max(params.currentMinA, derived.physicalCapA),
|
|
reasonText: `Réduction immédiate au plafond système ${derived.physicalCapA}A`,
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
|
|
else if (conditions.shouldStepDownOverload) {
|
|
setOn({
|
|
stateName: 'charging_step_down_overload',
|
|
decisionName: 'step_down_fast',
|
|
currentA: Math.max(
|
|
params.currentMinA,
|
|
Math.min(
|
|
derived.currentReferenceA - Math.max(batteryTune.global.overloadStepDownA, params.currentStepDownA),
|
|
derived.physicalCapA
|
|
)
|
|
),
|
|
reasonText: 'Baisse rapide: surcharge convertisseur',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
|
|
else if (conditions.shouldStepDownVebusPower) {
|
|
setOn({
|
|
stateName: 'charging_step_down_vebus_power',
|
|
decisionName: 'step_down_fast',
|
|
currentA: Math.max(
|
|
params.currentMinA,
|
|
Math.min(
|
|
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
|
|
derived.physicalCapA
|
|
)
|
|
),
|
|
reasonText: 'Baisse rapide: puissance VE.Bus élevée',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
|
|
else if (conditions.shouldStepDownVebusVoltage) {
|
|
setOn({
|
|
stateName: 'charging_step_down_dc_voltage',
|
|
decisionName: 'step_down_fast',
|
|
currentA: Math.max(
|
|
params.currentMinA,
|
|
Math.min(
|
|
derived.currentReferenceA - Math.max(params.currentStepDownA + 1, 3),
|
|
derived.physicalCapA
|
|
)
|
|
),
|
|
reasonText: 'Baisse rapide: tension DC basse',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
|
|
else if (
|
|
activeSourceMode === 'battery_assist_night' &&
|
|
conditions.batteryAssistNightShouldStepDown
|
|
) {
|
|
setOn({
|
|
stateName: 'charging_step_down_battery_assist_night',
|
|
decisionName: 'step_down',
|
|
currentA: Math.max(
|
|
params.currentMinA,
|
|
Math.min(
|
|
derived.currentReferenceA - params.currentStepDownA,
|
|
derived.physicalCapA
|
|
)
|
|
),
|
|
reasonText:
|
|
nightEvcs?.batteryRecoveryUrgent
|
|
? 'Réduction: approche départ, préparation takeover réseau'
|
|
: 'Réduction: assistance batterie nuit devient trop coûteuse',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
|
|
else if (
|
|
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
|
|
conditions.gridShouldStepDownBattery
|
|
) {
|
|
setOn({
|
|
stateName:
|
|
activeSourceMode === 'grid_forced'
|
|
? 'charging_step_down_grid_forced_guard'
|
|
: 'charging_step_down_grid_guard',
|
|
decisionName: 'step_down',
|
|
currentA: Math.max(
|
|
params.currentMinA,
|
|
Math.min(
|
|
derived.currentReferenceA - params.currentStepDownA,
|
|
derived.physicalCapA
|
|
)
|
|
),
|
|
reasonText:
|
|
activeSourceMode === 'grid_forced'
|
|
? 'Réduction réseau forcé: la batterie commence à participer'
|
|
: 'Réduction réseau: la batterie commence à participer',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
|
|
else if (
|
|
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
|
|
conditions.solarShouldStepDownCloudDebt
|
|
) {
|
|
setOn({
|
|
stateName: 'charging_step_down_cloud_debt',
|
|
decisionName: 'step_down',
|
|
currentA: Math.max(
|
|
params.currentMinA,
|
|
Math.min(
|
|
derived.currentReferenceA - Math.max(params.currentStepDownA, 2),
|
|
derived.physicalCapA
|
|
)
|
|
),
|
|
reasonText: 'Réduction: dette nuage trop élevée',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
|
|
else if (
|
|
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
|
|
conditions.solarShouldStepDownBattery
|
|
) {
|
|
setOn({
|
|
stateName: 'charging_step_down_battery',
|
|
decisionName: 'step_down',
|
|
currentA: Math.max(
|
|
params.currentMinA,
|
|
Math.min(
|
|
derived.currentReferenceA - params.currentStepDownA,
|
|
derived.physicalCapA
|
|
)
|
|
),
|
|
reasonText: 'Réduction: batterie trop sollicitée',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
|
|
else if (
|
|
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') &&
|
|
conditions.gridShouldStepUp
|
|
) {
|
|
setOn({
|
|
stateName:
|
|
activeSourceMode === 'grid_forced'
|
|
? (
|
|
nightEvcs?.batteryRecoveryUrgent
|
|
? 'night_departure_deadline_grid_forced'
|
|
: 'charging_step_up_grid_forced'
|
|
)
|
|
: 'charging_step_up_grid_hc',
|
|
decisionName: 'step_up',
|
|
currentA: clamp(
|
|
derived.currentReferenceA + params.currentStepUpA,
|
|
params.currentMinA,
|
|
derived.physicalCapA
|
|
),
|
|
reasonText:
|
|
activeSourceMode === 'grid_forced'
|
|
? (
|
|
nightEvcs?.batteryRecoveryUrgent
|
|
? 'Hausse prudente: priorité départ, réseau forcé'
|
|
: 'Hausse prudente du courant en charge réseau forcée'
|
|
)
|
|
: 'Hausse prudente du courant en HC réseau',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
|
|
else if (
|
|
activeSourceMode === 'battery_assist_night' &&
|
|
conditions.batteryAssistNightShouldStepUp
|
|
) {
|
|
setOn({
|
|
stateName: 'charging_step_up_battery_assist_night',
|
|
decisionName: 'step_up',
|
|
currentA: clamp(
|
|
derived.currentReferenceA + params.currentStepUpA,
|
|
params.currentMinA,
|
|
derived.physicalCapA
|
|
),
|
|
reasonText: 'Hausse prudente du courant en assistance batterie nuit',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
|
|
else if (
|
|
(activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') &&
|
|
conditions.solarShouldStepUp
|
|
) {
|
|
setOn({
|
|
stateName:
|
|
conditions.probeSuccessNow
|
|
? 'probe_success_step_up'
|
|
: (modes.batteryPriorityMode
|
|
? 'charging_step_up_battery'
|
|
: 'charging_step_up_forecast'),
|
|
decisionName: 'step_up',
|
|
currentA: clamp(
|
|
derived.currentReferenceA + params.currentStepUpA,
|
|
params.currentMinA,
|
|
derived.physicalCapA
|
|
),
|
|
reasonText: 'Hausse prudente du courant',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
|
|
else {
|
|
setOn({
|
|
stateName:
|
|
manualLocalHoldOverride ? 'charging_hold_manual_local' :
|
|
conditions.probeSuccessNow ? 'probe_success_hold' :
|
|
activeSourceMode === 'battery_assist_night' ? 'charging_hold_battery_assist_night' :
|
|
(activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced') ? 'charging_hold_grid' :
|
|
cloud.cloudDebtActive ? 'charging_hold_cloud_tolerance' :
|
|
'charging_hold',
|
|
decisionName: 'hold',
|
|
currentA: Math.min(derived.currentReferenceA, derived.physicalCapA),
|
|
reasonText:
|
|
manualLocalHoldOverride
|
|
? 'Maintien manuel local du courant'
|
|
: activeSourceMode === 'battery_assist_night'
|
|
? 'Maintien du courant en assistance batterie nuit'
|
|
: 'Maintien du courant',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// =====================================================
|
|
// START BRANCH
|
|
// =====================================================
|
|
else {
|
|
const startConditionsOk =
|
|
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced')
|
|
? conditions.gridStartOk
|
|
: (context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential')
|
|
? conditions.solarStartOk
|
|
: context.sourceMode === 'hybrid_probe'
|
|
? conditions.probeStartOk
|
|
: context.sourceMode === 'battery_assist_night'
|
|
? conditions.batteryAssistNightStartOk
|
|
: false;
|
|
|
|
const manualLocalStartOverride =
|
|
manualLocalChargeAllowed &&
|
|
(
|
|
context.sourceMode === 'solar_only' ||
|
|
context.sourceMode === 'solar_potential' ||
|
|
context.sourceMode === 'blocked'
|
|
);
|
|
|
|
if (!derived.canRunAtMinCurrent) {
|
|
setOff({
|
|
stateName: 'blocked_by_capacity_min_current',
|
|
decisionName: 'hold',
|
|
reasonText: 'Capacité insuffisante pour courant mini',
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
blockReason: 'system_capacity_stop',
|
|
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
|
|
});
|
|
}
|
|
|
|
else if (manualLocalStartOverride) {
|
|
setOn({
|
|
stateName: 'start_manual_local_min_current',
|
|
decisionName: 'start',
|
|
currentA: params.currentMinA,
|
|
reasonText:
|
|
context.profile === 'outside_window'
|
|
? 'Démarrage manuel local hors fenêtre sans réseau'
|
|
: 'Démarrage manuel local sans réseau',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
|
|
nextBlockReason = 'none';
|
|
nextBlockedUntilStamp = now.stamp;
|
|
setProbeStartedAt = false;
|
|
}
|
|
|
|
// P8 : véhicule plein en session nuit → veille auto-entretenue.
|
|
// Le state est relu au cycle suivant (prev_surplus_state) : il maintient
|
|
// vehicleChargeCompleted jusqu'au débranchement ou à la fin de fenêtre.
|
|
// Fix A (08/08/2026) : une fois la recovery batterie terminée (SOC ≥ cible),
|
|
// l'état devient 'night_recovery_done_idle' (collant) → takeover relâché
|
|
// définitivement, plus de rebond EDF autour de la cible.
|
|
else if (context.profile === 'night_hc' && nightEvcs?.vehicleChargeCompleted) {
|
|
setOff({
|
|
stateName: nightEvcs?.nightRecoveryDone
|
|
? 'night_recovery_done_idle'
|
|
: 'night_session_done_idle',
|
|
decisionName: 'hold',
|
|
reasonText: nightEvcs?.nightRecoveryDone
|
|
? 'Charge véhicule terminée + batterie à la cible : veille nuit, réseau relâché'
|
|
: 'Charge véhicule terminée : veille nuit, réseau relâché (recharge batterie si sous cible)',
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
blockReason: 'none',
|
|
blockedUntilStamp: now.stamp,
|
|
});
|
|
}
|
|
|
|
else if (context.sourceMode === 'blocked') {
|
|
setOff({
|
|
stateName:
|
|
context.profile === 'outside_window'
|
|
? 'blocked_outside_window'
|
|
: (context.profile === 'night_hc'
|
|
? 'night_no_grid_hc_lock'
|
|
: 'idle_wait_source'),
|
|
decisionName: 'hold',
|
|
reasonText:
|
|
context.profile === 'outside_window'
|
|
? 'Hors fenêtre préférée sans source suffisante'
|
|
: context.profile === 'night_hc'
|
|
? 'Nuit sans autorisation HC réseau'
|
|
: 'Source non autorisée ou insuffisante',
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
blockReason:
|
|
context.profile === 'outside_window'
|
|
? 'outside_window'
|
|
: context.profile === 'night_hc'
|
|
? 'grid_hc_unavailable'
|
|
: 'source_unavailable',
|
|
blockedUntilStamp:
|
|
Number.isFinite(locks.nextAllowedWindowStartMs)
|
|
? formatParisStampFromMs(locks.nextAllowedWindowStartMs)
|
|
: formatParisStampFromMs(nowMs + 15 * 60000),
|
|
});
|
|
}
|
|
|
|
else if (
|
|
(context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced') &&
|
|
!parsed.gridAvailable
|
|
) {
|
|
setOff({
|
|
stateName:
|
|
context.profile === 'night_hc'
|
|
? 'night_wait_grid_takeover'
|
|
: 'wait_grid_source',
|
|
decisionName: 'hold',
|
|
reasonText:
|
|
context.profile === 'night_hc'
|
|
? (
|
|
nightEvcs?.batteryRecoveryUrgent
|
|
? 'Réseau demandé: urgence recharge batterie avant départ'
|
|
: 'Prise de relais réseau demandée pour session nuit EVCS'
|
|
)
|
|
: 'Source réseau demandée mais pas encore disponible',
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
blockReason:
|
|
context.profile === 'night_hc'
|
|
? 'grid_hc_unavailable'
|
|
: 'grid_unavailable',
|
|
blockedUntilStamp: formatParisStampFromMs(nowMs + 10 * 60000),
|
|
});
|
|
}
|
|
|
|
else if (locks.startBlockedByReason) {
|
|
setOff({
|
|
stateName: 'wait_reason_lock',
|
|
decisionName: 'hold',
|
|
reasonText: `Blocage anti-cliquetis: ${history.lastBlockReason}`,
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
blockReason: history.lastBlockReason,
|
|
blockedUntilStamp: history.blockedUntilAt || now.stamp,
|
|
});
|
|
}
|
|
|
|
else if (!startConditionsOk) {
|
|
setOff({
|
|
stateName: 'idle_wait_conditions',
|
|
decisionName: 'hold',
|
|
reasonText:
|
|
context.sourceMode === 'battery_assist_night'
|
|
? 'Conditions de démarrage nuit batterie non réunies'
|
|
: 'Conditions de démarrage non réunies',
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
blockReason: 'none',
|
|
blockedUntilStamp: now.stamp,
|
|
});
|
|
}
|
|
|
|
else if (!lifecycle.minStopElapsed) {
|
|
setOff({
|
|
stateName: 'wait_min_stop',
|
|
decisionName: 'hold',
|
|
reasonText: 'Temps mini OFF non écoulé',
|
|
dynamic: false,
|
|
mustStopFlag: false,
|
|
blockReason: 'none',
|
|
blockedUntilStamp: now.stamp,
|
|
});
|
|
}
|
|
|
|
else {
|
|
const startCurrentA =
|
|
context.sourceMode === 'hybrid_probe'
|
|
? clamp(params.probeCurrentA, params.currentMinA, derived.physicalCapA)
|
|
: params.currentMinA;
|
|
|
|
setOn({
|
|
stateName:
|
|
context.sourceMode === 'grid_forced' ? (
|
|
nightEvcs?.batteryRecoveryUrgent
|
|
? 'night_departure_deadline_grid_forced'
|
|
: 'start_grid_forced_min_current'
|
|
) :
|
|
context.sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :
|
|
context.sourceMode === 'battery_assist_night' ? 'start_battery_assist_night_min_current' :
|
|
context.sourceMode === 'hybrid_probe' ? 'probe_start_6a' :
|
|
context.sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' :
|
|
modes.batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :
|
|
modes.forecastCommitMode ? 'start_min_current_forecast_commit' :
|
|
'start_min_current',
|
|
decisionName: 'start',
|
|
currentA: startCurrentA,
|
|
reasonText:
|
|
context.sourceMode === 'grid_forced'
|
|
? (
|
|
nightEvcs?.batteryRecoveryUrgent
|
|
? 'Démarrage réseau forcé: priorité départ / recharge batterie'
|
|
: 'Démarrage en charge réseau forcée'
|
|
)
|
|
: context.sourceMode === 'grid_hc_allowed'
|
|
? 'Démarrage en fenêtre HC réseau'
|
|
: context.sourceMode === 'battery_assist_night'
|
|
? 'Démarrage nuit en assistance batterie contrôlée'
|
|
: context.sourceMode === 'hybrid_probe'
|
|
? 'Démarrage en mode sonde'
|
|
: context.sourceMode === 'solar_potential'
|
|
? 'Démarrage par potentiel solaire confirmé'
|
|
: modes.batteryFullHarvestMode
|
|
? 'Démarrage harvest solaire'
|
|
: modes.forecastCommitMode
|
|
? 'Démarrage forecast commit'
|
|
: 'Démarrage prudent à courant mini',
|
|
dynamic: true,
|
|
mustStopFlag: false,
|
|
});
|
|
|
|
nextBlockReason = 'none';
|
|
nextBlockedUntilStamp = now.stamp;
|
|
setProbeStartedAt = context.sourceMode === 'hybrid_probe';
|
|
}
|
|
}
|
|
|
|
// =====================================================
|
|
// CONTEXT REASONS
|
|
// =====================================================
|
|
if (nightEvcs?.nightSessionActive) {
|
|
reason.push(
|
|
`Nuit EVCS: target=${round1(nightEvcs.batteryMorningTargetPct)}%${nightEvcs.balanceMorningTargetApplied ? ' (BILAN)' : ''} | trigger réseau=${round1(nightEvcs.gridTakeoverTriggerPct)}%`
|
|
);
|
|
|
|
if (Number.isFinite(nightEvcs.nightSessionStartSocPct)) {
|
|
reason.push(
|
|
`SOC départ session nuit=${round1(nightEvcs.nightSessionStartSocPct)}% | décharge autorisée=${round1(nightEvcs.maxBatteryDischargePoints)} pts`
|
|
);
|
|
}
|
|
|
|
if (nightEvcs.batteryRecoveryUrgent) {
|
|
reason.push("Urgence départ: il faut anticiper la reprise réseau");
|
|
}
|
|
}
|
|
|
|
if (manualLocalChargeAllowed) {
|
|
reason.push(`ManualLocalCharge=on`);
|
|
reason.push(`ManualLocalProfile=${context.profile}`);
|
|
reason.push(`ManualLocalSource=${context.sourceMode}`);
|
|
}
|
|
|
|
reason.push(`VehicleDemandFilter delivery=${vehicleDemandFilter.deliveryRatioEma}`);
|
|
reason.push(`VehicleDemandFilter noise=${vehicleDemandFilter.deliveryNoiseEma}`);
|
|
reason.push(`VehicleDemandFilter score=${vehicleDemandFilter.noDemandScoreEma}`);
|
|
reason.push(`VehicleDemandFilter noDemand=${vehicleDemandFilter.evcsVehicleNoDemandSemantic ? 'on' : 'off'}`);
|
|
|
|
// =====================================================
|
|
// OUTPUT
|
|
// =====================================================
|
|
return [{
|
|
json: {
|
|
...prev,
|
|
vehicleDemandFilter,
|
|
decision: {
|
|
desiredCharging,
|
|
desiredPermission,
|
|
desiredLatch,
|
|
desiredCurrentA,
|
|
|
|
state,
|
|
currentDecision,
|
|
dynamicOk,
|
|
mustStop,
|
|
reason,
|
|
|
|
nextBlockReason,
|
|
nextBlockedUntilStamp,
|
|
|
|
setCloudDebtStartedAt,
|
|
setProbeStartedAt,
|
|
|
|
setNightSessionStart,
|
|
clearNightSessionStart,
|
|
|
|
notifyWanted,
|
|
notifyTitle,
|
|
notifyMessage,
|
|
}
|
|
}
|
|
}];
|