Idee du proprietaire, apres la nuit du 14 au 15/09 : 8 kWh achetes au reseau, la batterie encore a 69 % a 5 h du matin, et une prevision de 38,7 kWh le lendemain dont 10,5 auraient ete ecretes faute de place. Payer du reseau la nuit pour ecreter le solaire le lendemain, c est perdre deux fois. Le mode `battery_assist_night` existait pour exactement ca, et etait MORT depuis toujours : il exige `batteryCurrentA >= plancher de decharge` (0 A), impossible la nuit puisque la maison tire sur la batterie. On ne cree donc pas un chemin, on rend viable celui qui existe — en remplacant un test de COURANT impossible par un test d ENERGIE. Le plancher n est PAS une constante nouvelle : c est `required_soc_prudent`, que le bilan publie deja en profil p80. La voiture descend la batterie jusqu au niveau qui lui permet encore de tenir jusqu au croisement PV, et pas plus bas. La bascule vers le reseau est automatique quand le surplus est epuise — la soupape decrite par le proprietaire, sans aucune regle horaire. ⚠️ Verrou anti-oscillateur, et il n est pas decoratif. Le plancher descend pendant la nuit et le SOC aussi : voiture en charge le SOC perd 7,1 pt/h contre 3,7 pour le plancher, mais voiture ARRETEE il n en perd que 2,6. Le surplus reapparaitrait tout seul et la charge repartirait. Une seule passe par session de nuit, comme la recovery batterie depuis le 27/08. Les gardes de courant restent : commonSafety, l arret dur, et la garde souple avec sa tolerance temporelle — c est elle qui arretera la charge quand la bande de SOC deviendra trop basse pour le courant tire. Ce qu on retire est uniquement le plancher de PROFIL, qui interdisait de demarrer des que la batterie n etait pas a l equilibre. Cinq scenarios, six mutations qui mordent chacune sur un jeu different. Deux d entre elles ont trouve de vraies faiblesses : les attendus ne verifiaient que le choix du mode et pas que la voiture charge, et la garde de fraicheur du bilan n etait couverte par aucun scenario. Rien n est deploye et le toggle est a off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
1223 lines
44 KiB
JavaScript
1223 lines
44 KiB
JavaScript
function clamp(v, min, max) {
|
|
return Math.min(Math.max(v, min), max);
|
|
}
|
|
|
|
function to2(n) {
|
|
return String(n).padStart(2, '0');
|
|
}
|
|
|
|
function getParisParts(ms = Date.now()) {
|
|
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);
|
|
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
|
|
|
return {
|
|
date,
|
|
hour,
|
|
minute: Number(parts.minute),
|
|
second: Number(parts.second),
|
|
};
|
|
}
|
|
|
|
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
|
|
const dtf = new Intl.DateTimeFormat('en-US', {
|
|
timeZone,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
|
|
const parts = Object.fromEntries(
|
|
dtf.formatToParts(new Date(utcTs))
|
|
.filter(p => p.type !== 'literal')
|
|
.map(p => [p.type, p.value])
|
|
);
|
|
|
|
const asIfUtc = Date.UTC(
|
|
Number(parts.year),
|
|
Number(parts.month) - 1,
|
|
Number(parts.day),
|
|
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
|
|
Number(parts.minute),
|
|
Number(parts.second)
|
|
);
|
|
|
|
const d = new Date(utcTs);
|
|
const actualUtc = Date.UTC(
|
|
d.getUTCFullYear(),
|
|
d.getUTCMonth(),
|
|
d.getUTCDate(),
|
|
d.getUTCHours(),
|
|
d.getUTCMinutes(),
|
|
d.getUTCSeconds()
|
|
);
|
|
|
|
return asIfUtc - actualUtc;
|
|
}
|
|
|
|
function parseParisStampToMs(s) {
|
|
const text = (s ?? '').toString().trim();
|
|
const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
|
|
if (!m) return null;
|
|
|
|
const y = Number(m[1]);
|
|
const mo = Number(m[2]);
|
|
const d = Number(m[3]);
|
|
const h = Number(m[4]);
|
|
const mi = Number(m[5]);
|
|
const se = Number(m[6]);
|
|
|
|
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
|
|
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
|
|
let ts = guessUtc - off1;
|
|
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
|
|
|
|
if (off2 !== off1) ts = guessUtc - off2;
|
|
return ts;
|
|
}
|
|
|
|
function hhmmssFromMinutes(minOfDay) {
|
|
const mm = ((minOfDay % 1440) + 1440) % 1440;
|
|
const hh = Math.floor(mm / 60);
|
|
const mi = mm % 60;
|
|
return `${to2(hh)}:${to2(mi)}:00`;
|
|
}
|
|
|
|
function nextDailyOccurrenceMs(startMin, refMs) {
|
|
if (!Number.isFinite(startMin)) return null;
|
|
|
|
const today = getParisParts(refMs).date;
|
|
const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;
|
|
const todayMs = parseParisStampToMs(todayStamp);
|
|
|
|
if (todayMs !== null && todayMs > refMs + 1000) return todayMs;
|
|
|
|
const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;
|
|
const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;
|
|
return parseParisStampToMs(tomorrowStamp);
|
|
}
|
|
|
|
const prev = $input.first().json;
|
|
|
|
const {
|
|
nowMs,
|
|
cfg,
|
|
parsed,
|
|
params,
|
|
history,
|
|
batteryTune,
|
|
strategy,
|
|
derived,
|
|
context,
|
|
lifecycle,
|
|
} = prev;
|
|
|
|
// =====================================================
|
|
// SESSION / SOURCE
|
|
// =====================================================
|
|
const sessionObservedActive =
|
|
parsed.evcsChargingSwitchOn ||
|
|
parsed.controlLatch ||
|
|
parsed.chargePermissionNow;
|
|
|
|
const isGridSource =
|
|
context.sourceMode === 'grid_hc_allowed' ||
|
|
context.sourceMode === 'grid_forced';
|
|
|
|
const isLocalSolarSource =
|
|
context.sourceMode === 'solar_only' ||
|
|
context.sourceMode === 'solar_potential';
|
|
|
|
// =====================================================
|
|
// SOC BAND / BATTERY DYNAMIC POLICY
|
|
// =====================================================
|
|
const socBand =
|
|
parsed.soc > 90 ? 'gt90' :
|
|
parsed.soc > 80 ? '80_90' :
|
|
parsed.soc > 60 ? '60_80' :
|
|
parsed.soc > 40 ? '40_60' :
|
|
parsed.soc > 25 ? '25_40' :
|
|
'lt25';
|
|
|
|
const band = batteryTune.bands[socBand];
|
|
|
|
const dischargeModeFactor =
|
|
strategy.forceChargeRequested ? 1.2 :
|
|
strategy.forceGridRequested ? 0.7 :
|
|
strategy.userStrategy === 'auto_solar' ? 0.8 :
|
|
1.0;
|
|
|
|
const chargeReserveModeFactor =
|
|
strategy.forceChargeRequested ? 0.8 :
|
|
strategy.forceGridRequested ? 1.1 :
|
|
strategy.userStrategy === 'auto_solar' ? 1.1 :
|
|
1.0;
|
|
|
|
const toleranceModeFactor =
|
|
strategy.forceChargeRequested ? 1.25 :
|
|
strategy.forceGridRequested ? 0.8 :
|
|
strategy.userStrategy === 'auto_solar' ? 0.8 :
|
|
1.0;
|
|
|
|
const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;
|
|
const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;
|
|
const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);
|
|
const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;
|
|
const batteryHardToleranceSeconds = band.hardToleranceSec;
|
|
const batteryRecoveryMinutes = band.recoveryMin;
|
|
|
|
// =====================================================
|
|
// CONTEXT HELPERS
|
|
// =====================================================
|
|
const houseSpikeThresholdW = 1500;
|
|
const houseSpikeDetected =
|
|
(parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;
|
|
|
|
const solarPotentialContext =
|
|
context.sourceMode === 'solar_potential' ||
|
|
(
|
|
parsed.soc >= 80 &&
|
|
derived.forecastBufferKwh > 0 &&
|
|
parsed.pvPowerW < context.effectivePvStartMinW &&
|
|
derived.houseNonEvcsW < 1200
|
|
);
|
|
|
|
// =====================================================
|
|
// OUTSIDE-WINDOW LOCAL BYPASS
|
|
// IMPORTANT:
|
|
// en auto_solar_plus ou force_charge_now, un contexte local solaire
|
|
// ne doit plus rester bloqué juste parce qu'on est hors fenêtre.
|
|
// =====================================================
|
|
const outsideWindowLocalBypass =
|
|
context.profile === 'outside_window' &&
|
|
!strategy.forceGridRequested &&
|
|
(
|
|
strategy.forceChargeRequested ||
|
|
strategy.userStrategy === 'auto_solar_plus'
|
|
) &&
|
|
isLocalSolarSource;
|
|
|
|
// =====================================================
|
|
// REASON-BASED LOCKS
|
|
// =====================================================
|
|
const nextWindowCandidates = [
|
|
nextDailyOccurrenceMs(params.morningStartMin, nowMs),
|
|
nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),
|
|
nextDailyOccurrenceMs(params.eveningStartMin, nowMs),
|
|
nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),
|
|
].filter(v => Number.isFinite(v));
|
|
|
|
const nextAllowedWindowStartMs =
|
|
nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;
|
|
|
|
const blockActive =
|
|
history.blockedUntilMs !== null &&
|
|
history.blockedUntilMs > nowMs + 1000;
|
|
|
|
const blockStillRelevant =
|
|
history.lastBlockReason === 'outside_window'
|
|
? (context.profile === 'outside_window' && !outsideWindowLocalBypass)
|
|
: history.lastBlockReason === 'probe_failed'
|
|
? context.sourceMode === 'hybrid_probe'
|
|
: history.lastBlockReason === 'battery_protection_stop'
|
|
? (
|
|
parsed.batteryCurrentA < -params.batteryNeutralBandA ||
|
|
derived.anyVebusLowBattery ||
|
|
derived.vebusDcVoltageSoftLow
|
|
)
|
|
: history.lastBlockReason === 'grid_battery_guard'
|
|
? isGridSource
|
|
: history.lastBlockReason === 'vebus_overload_persistent'
|
|
? derived.anyVebusOverload
|
|
: false;
|
|
|
|
const startBlockedByReason = blockActive && blockStillRelevant;
|
|
|
|
// =====================================================
|
|
// CLOUD DEBT
|
|
// =====================================================
|
|
const batteryNetPowerW = parsed.batteryCurrentA * derived.batteryVoltageRef;
|
|
let cloudDebtNextKwh = params.cloudDebtPrevKwh;
|
|
|
|
if (sessionObservedActive) {
|
|
if (batteryNetPowerW < 0 && !isGridSource) {
|
|
cloudDebtNextKwh += (Math.abs(batteryNetPowerW) * cfg.sampleSeconds) / 3600000;
|
|
} else if (batteryNetPowerW > 0) {
|
|
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
|
|
}
|
|
} else if (batteryNetPowerW > 0) {
|
|
cloudDebtNextKwh -= (batteryNetPowerW * cfg.sampleSeconds) / 3600000;
|
|
}
|
|
|
|
cloudDebtNextKwh = clamp(cloudDebtNextKwh, 0, params.cloudDebtMaxKwh * 4);
|
|
|
|
const cloudDebtMeaningful =
|
|
cloudDebtNextKwh > cfg.cloudDebtMeaningfulThresholdKwh;
|
|
|
|
const cloudEventDischargingNow =
|
|
sessionObservedActive &&
|
|
!isGridSource &&
|
|
parsed.batteryCurrentA < -Math.max(1, params.batteryNeutralBandA) &&
|
|
cloudDebtMeaningful;
|
|
|
|
const continuingCloudEvent =
|
|
cloudEventDischargingNow &&
|
|
history.cloudDebtStartedMsPrev !== null &&
|
|
params.cloudDebtPrevKwh > cfg.cloudDebtRecoveryThresholdKwh;
|
|
|
|
const enteringCloudDebt =
|
|
cloudEventDischargingNow && !continuingCloudEvent;
|
|
|
|
const cloudDebtActive = cloudEventDischargingNow;
|
|
|
|
const cloudDebtStartedMs =
|
|
cloudEventDischargingNow
|
|
? (continuingCloudEvent ? history.cloudDebtStartedMsPrev : nowMs)
|
|
: null;
|
|
|
|
const cloudDebtDurationMinutes =
|
|
cloudDebtActive && cloudDebtStartedMs !== null
|
|
? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)
|
|
: 0;
|
|
|
|
const effectiveCloudToleranceMinutes =
|
|
params.cloudToleranceMinutes +
|
|
(solarPotentialContext ? batteryTune.global.solarPotentialGraceMinutes : 0) +
|
|
(houseSpikeDetected ? batteryTune.global.houseSpikeGraceMinutes : 0);
|
|
|
|
const cloudToleranceOk =
|
|
cloudDebtNextKwh <= params.cloudDebtMaxKwh &&
|
|
(
|
|
!cloudDebtActive ||
|
|
cloudDebtDurationMinutes <= effectiveCloudToleranceMinutes
|
|
);
|
|
|
|
// =====================================================
|
|
// LEGACY / FORECAST MODES
|
|
// =====================================================
|
|
const batteryFullHarvestMode =
|
|
parsed.forecastModeEnabled &&
|
|
context.profile !== 'night_hc' &&
|
|
context.profile !== 'outside_window' &&
|
|
parsed.soc >= params.batteryFullSoc &&
|
|
parsed.todayRemainingKwh >= params.batteryFullForecastMinKwh &&
|
|
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 2);
|
|
|
|
const forecastCommitMode =
|
|
parsed.forecastModeEnabled &&
|
|
context.profile !== 'night_hc' &&
|
|
context.profile !== 'outside_window' &&
|
|
parsed.soc >= params.forecastCommitSocFloor &&
|
|
parsed.todayRemainingKwh >= params.forecastCommitMinKwh &&
|
|
derived.projectedEndSocAfterCharge >= derived.endDayGuardSoc;
|
|
|
|
const batteryPriorityMode =
|
|
!batteryFullHarvestMode && !forecastCommitMode;
|
|
|
|
// =====================================================
|
|
// EVCS NIGHT ENGINE — R1 (07/08/2026)
|
|
// Le moteur nuit est calculé UNE SEULE FOIS dans WINDOWS_SOURCEV2
|
|
// (payload nightEvcsPreview). Ce node CONSOMME, il ne recalcule plus.
|
|
// TOUTE évolution de la logique nuit se fait dans WINDOWS_SOURCEV2.
|
|
// Fallback sûr si preview absent : session inactive / 'blocked'.
|
|
// =====================================================
|
|
const nightPrev = prev.nightEvcsPreview || {};
|
|
|
|
const nightSessionActive = !!nightPrev.nightSessionActive;
|
|
const nightSessionNeedsCapture = !!nightPrev.nightSessionNeedsCapture;
|
|
const nightSessionNeedsClear = !!nightPrev.nightSessionNeedsClear;
|
|
const nightSessionStartSocPct =
|
|
Number.isFinite(nightPrev.nightSessionStartSocPct) ? nightPrev.nightSessionStartSocPct : null;
|
|
const nightSessionStartAt = nightPrev.nightSessionStartAt || '';
|
|
const nightSessionStartMs =
|
|
Number.isFinite(nightPrev.nightSessionStartMs) ? nightPrev.nightSessionStartMs : null;
|
|
|
|
const batteryHardFloorPct =
|
|
Number.isFinite(nightPrev.batteryHardFloorPct)
|
|
? nightPrev.batteryHardFloorPct
|
|
: params.evcsNightBatteryHardFloorPct;
|
|
const batteryMorningTargetPct =
|
|
Number.isFinite(nightPrev.batteryMorningTargetPct)
|
|
? nightPrev.batteryMorningTargetPct
|
|
: params.evcsNightTargetWeakForecastPct;
|
|
const balanceMorningTargetApplied = !!nightPrev.balanceMorningTargetApplied;
|
|
const nightGridSessionCommitted = !!nightPrev.nightGridSessionCommitted;
|
|
const maxBatteryDischargePoints =
|
|
Number.isFinite(nightPrev.maxBatteryDischargePoints) ? nightPrev.maxBatteryDischargePoints : 0;
|
|
const gridTakeoverTriggerPct =
|
|
Number.isFinite(nightPrev.gridTakeoverTriggerPct)
|
|
? nightPrev.gridTakeoverTriggerPct
|
|
: Math.max(batteryHardFloorPct, batteryMorningTargetPct);
|
|
|
|
const batteryBelowHardFloor = !!nightPrev.batteryBelowHardFloor;
|
|
const batteryBelowMorningTarget = !!nightPrev.batteryBelowMorningTarget;
|
|
const batteryAtOrBelowGridTakeoverTrigger = !!nightPrev.batteryAtOrBelowGridTakeoverTrigger;
|
|
|
|
const batteryKwhMissingToTarget =
|
|
Number.isFinite(nightPrev.batteryKwhMissingToTarget) ? nightPrev.batteryKwhMissingToTarget : 0;
|
|
const batteryRecoveryMinutesNeeded =
|
|
Number.isFinite(nightPrev.batteryRecoveryMinutesNeeded) ? nightPrev.batteryRecoveryMinutesNeeded : 0;
|
|
const batteryRecoveryUrgent = !!nightPrev.batteryRecoveryUrgent;
|
|
const batteryRecoveryAllowedNow = !!nightPrev.batteryRecoveryAllowedNow;
|
|
const batteryTargetReached = !!nightPrev.batteryTargetReached;
|
|
|
|
const batteryAssistAllowed = !!nightPrev.batteryAssistAllowed;
|
|
const nightGridTakeoverNeeded = !!nightPrev.nightGridTakeoverNeeded;
|
|
const nightTakeoverLatched = !!nightPrev.nightTakeoverLatched;
|
|
const vehicleChargeCompleted = !!nightPrev.vehicleChargeCompleted;
|
|
const nightRecoveryDone = !!nightPrev.nightRecoveryDone;
|
|
const departureUrgentActive = !!nightPrev.departureUrgentActive;
|
|
const departureChargeDone = !!nightPrev.departureChargeDone;
|
|
const dayVehicleFullIdle = !!nightPrev.dayVehicleFullIdle;
|
|
const nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked';
|
|
|
|
// =====================================================
|
|
// PROFILE-BASED THRESHOLDS
|
|
// =====================================================
|
|
// =====================================================
|
|
// PLANCHER DU SOIR : UN NIVEAU FIXE, REMPLACE PAR L'ENERGIE A COUVRIR (03/09/2026)
|
|
// =====================================================
|
|
// `evening_soc_start_min` = 85 % est une tentative de dire « garde de quoi
|
|
// passer la nuit ». C'est un NIVEAU, pris a un instant, et il ne sait rien de
|
|
// la nuit qui vient : ni sa duree, ni la consommation a couvrir, ni l'heure a
|
|
// laquelle le solaire prendra le relais.
|
|
//
|
|
// Le bilan energetique calcule exactement cela et le publie —
|
|
// `evcs_balance_required_soc`, deduit de l'energie a couvrir jusqu'au
|
|
// croisement production/consommation. Personne ne le lisait : `grep
|
|
// evcs_balance` sur les deux workflows de decision renvoyait 0.
|
|
//
|
|
// Le 03/09 a 19:44, les deux nombres coexistaient dans le meme systeme :
|
|
// plancher du soir 85 % (SOC 94, donc charge autorisee) contre SOC requis
|
|
// 99,7 % pour tenir jusqu'au croisement de 9 h. La charge du soir a fait
|
|
// passer le manque de la nuit de 0,86 a 2,21 kWh.
|
|
//
|
|
// SENS UNIQUE, ET C'EST DELIBERE : le bilan ne peut que RENFORCER le plancher,
|
|
// jamais l'abaisser (Math.max). L'assouplir — le bilan dira souvent moins de
|
|
// 85 % par forte prevision d'ete — autoriserait des charges qui n'ont pas lieu
|
|
// aujourd'hui : c'est une decision distincte, avec ses propres risques, qui
|
|
// doit etre validee pour elle-meme et non glissee dans ce correctif.
|
|
// Corollaire : le max rend la garde robuste sans borne ni ecretage — une
|
|
// valeur nulle, negative ou aberrante ne peut pas relacher le plancher.
|
|
//
|
|
// PORTEE : plancher de DEMARRAGE. Le plancher de maintien reste
|
|
// min(profileSocHoldFloor, ...) donc inchange a 80 % : relever le plancher de
|
|
// demarrage ne coupe aucune charge en cours.
|
|
//
|
|
// L'abondance P6 continue de primer : PV moyen >= 4 kW ET batterie qui monte a
|
|
// >= +20 A est une PREUVE MESUREE que la ressource depasse ce que le systeme
|
|
// absorbe — le cas ou la voiture est effectivement gratuite.
|
|
//
|
|
// REPLI : lecture absente, statut non 'ok', ou bilan plus vieux que six de ses
|
|
// cycles => regle historique inchangee. Le kill-switch existe deja et n'a pas
|
|
// besoin d'etre double : `input_boolean.evcs_balance_enabled` a OFF arrete les
|
|
// ecritures du bilan, `balance_last_run` se fige, et le plancher retombe seul
|
|
// sur 85 % en une demi-heure.
|
|
const BALANCE_MAX_AGE_MIN = 30;
|
|
const balanceAgeMin =
|
|
params.balanceLastRunMs !== null && params.balanceLastRunMs !== undefined
|
|
? (nowMs - params.balanceLastRunMs) / 60000
|
|
: null;
|
|
const balanceFresh =
|
|
balanceAgeMin !== null &&
|
|
balanceAgeMin >= -5 &&
|
|
balanceAgeMin <= BALANCE_MAX_AGE_MIN;
|
|
const balanceEveningFloorUsable =
|
|
balanceFresh &&
|
|
params.balanceStatusOk &&
|
|
Number.isFinite(params.balanceRequiredSocPct);
|
|
|
|
const eveningSocStartFloorPct = balanceEveningFloorUsable
|
|
? Math.max(params.eveningSocStartMin, params.balanceRequiredSocPct)
|
|
: params.eveningSocStartMin;
|
|
const eveningFloorFromBalance =
|
|
balanceEveningFloorUsable && eveningSocStartFloorPct > params.eveningSocStartMin;
|
|
|
|
const profileSocStartFloor =
|
|
context.profile === 'morning_solar' ? params.morningSocMin :
|
|
context.profile === 'midday_hc_or_solar' ? params.middaySocMin :
|
|
context.profile === 'evening_high_soc' ? eveningSocStartFloorPct :
|
|
params.socStartMin;
|
|
|
|
// P6 ABONDANCE : PV moyen élevé + batterie en charge franche => le plancher SOC
|
|
// de démarrage perd son sens (la batterie continue de monter même avec l'EVCS à 6 A).
|
|
// Jours nuageux ou batterie peu chargée par le PV : rien ne change.
|
|
const PV_ABUNDANCE_AVG_MIN_W = 4000;
|
|
const ABUNDANCE_BATTERY_CHARGE_MIN_A = 20;
|
|
const ABUNDANCE_SOC_FLOOR_PCT = 40;
|
|
|
|
// 24/08/2026 — L'abondance se PROUVE au démarrage (batterie qui monte franchement,
|
|
// >= +20 A, avant qu'on touche à quoi que ce soit) mais elle se MAINTIENT tant que
|
|
// le PV reste fort ET que la batterie n'est pas devenue fournisseuse. Sans cette
|
|
// distinction, la charge du véhicule — qui détourne précisément le surplus — fait
|
|
// tomber le courant batterie sous +20 A et détruit sa propre condition
|
|
// d'autorisation : système bistable, oscillation à la période de min_run.
|
|
const solarAbundanceStart =
|
|
parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&
|
|
parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A;
|
|
const solarAbundanceHold =
|
|
parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&
|
|
parsed.batteryCurrentA >= 0;
|
|
const solarAbundance = solarAbundanceStart; // conservé pour la traçabilité/debug
|
|
|
|
const abundanceSocFloor = (abundant) =>
|
|
abundant ? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT) : profileSocStartFloor;
|
|
|
|
const effectiveSocStartFloor = abundanceSocFloor(solarAbundanceStart);
|
|
|
|
const profileSocHoldFloor =
|
|
context.profile === 'morning_solar' ? Math.max(params.socStopMin, params.morningSocMin - 10) :
|
|
context.profile === 'midday_hc_or_solar' ? Math.max(params.socStopMin, params.middaySocMin - 15) :
|
|
context.profile === 'evening_high_soc' ? params.eveningSocStopMin :
|
|
params.socStopMin;
|
|
|
|
// INVARIANT (24/08/2026) : on ne doit JAMAIS avoir le droit de DÉMARRER une charge
|
|
// qu'on n'a pas le droit de MAINTENIR. Le plancher de maintien doit donc rester
|
|
// <= au plancher de démarrage effectif.
|
|
// Incident du 24/08 midi : bascule de profil vers midday_hc_or_solar à 12:24 avec
|
|
// SOC 58 % => plancher de démarrage 40 % (abondance P6) mais plancher de maintien
|
|
// max(29, 75-15) = 60 % => démarrage autorisé, maintien refusé, cycle limite de
|
|
// 3 min (min_run) sur toute la bande [40 %, 60 %], et ~2,6 kW de PV écrêtés à
|
|
// chaque creux faute de preneur (ESS sans injection).
|
|
const effectiveSocHoldFloor = Math.min(
|
|
profileSocHoldFloor,
|
|
abundanceSocFloor(solarAbundanceHold)
|
|
);
|
|
|
|
const profileSpecificDischargeFloorA =
|
|
context.profile === 'morning_solar' ? params.morningBatteryMaxDischargeA :
|
|
context.profile === 'midday_hc_or_solar' ? params.middayBatteryMaxDischargeA :
|
|
context.profile === 'evening_high_soc' ? params.eveningBatteryMaxDischargeA :
|
|
params.batteryDischargeSoftA;
|
|
|
|
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
|
|
// =====================================================
|
|
// Assouplissement uniquement pour la garde réseau,
|
|
// sans toucher aux sécurités dures globales.
|
|
const gridForceChargeRelaxSoftA =
|
|
strategy.forceChargeRequested && isGridSource
|
|
? (
|
|
parsed.soc >= 90 ? 28 :
|
|
parsed.soc >= 80 ? 16 :
|
|
parsed.soc >= 60 ? 8 :
|
|
parsed.soc >= 40 ? 4 :
|
|
0
|
|
)
|
|
: 0;
|
|
|
|
const gridForceChargeRelaxHardA =
|
|
strategy.forceChargeRequested && isGridSource
|
|
? (
|
|
parsed.soc >= 90 ? 39 :
|
|
parsed.soc >= 80 ? 29 :
|
|
parsed.soc >= 60 ? 18 :
|
|
parsed.soc >= 40 ? 8 :
|
|
0
|
|
)
|
|
: 0;
|
|
|
|
const gridBatterySoftDynamicABase =
|
|
Math.max(params.gridBatterySoftA, batteryDischargeSoftDynamicA);
|
|
|
|
const gridBatteryHardDynamicABase =
|
|
Math.max(params.gridBatteryHardA, batteryDischargeHardDynamicA);
|
|
|
|
let gridBatterySoftDynamicA =
|
|
gridBatterySoftDynamicABase - gridForceChargeRelaxSoftA;
|
|
|
|
let gridBatteryHardDynamicA =
|
|
gridBatteryHardDynamicABase - gridForceChargeRelaxHardA;
|
|
|
|
// Garantit que le hard reste plus permissif que le soft d'au moins 3 A
|
|
if (gridBatteryHardDynamicA > (gridBatterySoftDynamicA - 3)) {
|
|
gridBatteryHardDynamicA = gridBatterySoftDynamicA - 3;
|
|
}
|
|
|
|
// =====================================================
|
|
// BASE SAFETY
|
|
// =====================================================
|
|
const commonSafetyStartOk =
|
|
derived.canRunAtMinCurrent &&
|
|
!derived.anyVebusOverload &&
|
|
!derived.anyVebusLowBattery &&
|
|
!derived.vebusDcVoltageSoftLow &&
|
|
!derived.vebusAcOutSoftReached &&
|
|
parsed.evcsDeviceConnected &&
|
|
parsed.evcsVehicleConnected &&
|
|
!parsed.evcsModeIsAuto;
|
|
|
|
const commonSafetyHoldOk =
|
|
derived.canRunAtMinCurrent &&
|
|
!derived.anyVebusLowBattery &&
|
|
!derived.vebusDcVoltageHardLow &&
|
|
parsed.evcsDeviceConnected &&
|
|
parsed.evcsVehicleConnected &&
|
|
!parsed.evcsModeIsAuto;
|
|
|
|
// =====================================================
|
|
// EXCEDENT SOLAIRE — LA GRANDEUR QUI DECIDE D'UN DEMARRAGE (03/09/2026)
|
|
// =====================================================
|
|
// Incident du 03/09 19:44 : PV 758 W, maison 1202 W, batterie deja a -12 A,
|
|
// nuit qui tombe. Le controleur a lance 1,6 kW de charge parce que
|
|
// pv_start_min_w (700 W) est une PUISSANCE PV ABSOLUE : "758 >= 700, donc il y
|
|
// a du soleil". L'excedent, lui, valait -444 W. En 33 minutes la voiture a pris
|
|
// 0,897 kWh et la batterie en a rendu 1,479 kWh, la veille d'une nuit que le
|
|
// bilan energetique annoncait deja deficitaire (SOC requis 99,7 %).
|
|
//
|
|
// La grandeur juste n'est pas la production, c'est ce qui RESTE une fois la
|
|
// maison servie. Sans injection et reseau absent, elle vaut la puissance
|
|
// batterie au signe pres :
|
|
//
|
|
// excedent = PV - maison(hors EVCS) ~= puissance batterie
|
|
//
|
|
// C'est aussi ce qui separe les deux situations que la mesure PV seule confond
|
|
// (PV_mesuree = min(ressource, conso + absorption batterie)) :
|
|
//
|
|
// - CENSURE : batterie pleine, MPPT bride faute de puits. La production se
|
|
// cale sur la maison, l'excedent vaut ~0, la batterie ne bouge pas. Il y a
|
|
// de la ressource REFUSEE : la provoquer avec la voiture est legitime, et
|
|
// c'est toute la raison d'etre de P3 / de l'assistance batterie.
|
|
// - DEFICIT reel : soir, nuages. L'excedent est NEGATIF, la batterie porte
|
|
// deja la maison. Charger la voiture, c'est la vider.
|
|
//
|
|
// Zero n'est donc pas un seuil de plus : c'est la frontiere physique entre les
|
|
// deux, et elle etait invisible tant qu'on regardait la production seule.
|
|
// Aucune constante n'est introduite ; le chemin "soleil reel" reutilise
|
|
// pv_start_min_w tel quel, applique a la bonne grandeur.
|
|
//
|
|
// PORTEE : DEMARRAGE uniquement. Les conditions de MAINTIEN sont inchangees —
|
|
// une charge etablie a deja son plancher de decharge et sa tolerance temporelle
|
|
// (01/09), et couper une charge en cours sur une mesure d'excedent bruitee
|
|
// recreerait l'oscillateur qu'on vient d'eteindre.
|
|
const solarSurplusW = parsed.pvPowerW - derived.houseNonEvcsW;
|
|
|
|
// LA PROVOCATION DOIT ETRE JUSTIFIEE PAR LES CHARGEURS EUX-MEMES (03/09/2026)
|
|
// =====================================================================
|
|
// Un excedent proche de zero est AMBIGU : c'est la signature de la censure
|
|
// (production bridee, calee sur la maison) ET celle de l'equilibre de fin de
|
|
// journee (production reelle qui rejoint la consommation). Le releve du 03/09
|
|
// contient les deux a 51 minutes d'ecart, avec le meme excedent :
|
|
//
|
|
// 18:50 PV 769 W, maison 755 W, excedent +14 W -> 7 trackers LIMITES
|
|
// 19:41 PV 817 W, maison 816 W, excedent +1 W -> 7 trackers MPPT_ACTIVE
|
|
//
|
|
// Le premier cache de la ressource refusee, le second n'a plus rien a donner.
|
|
// Aucun seuil de puissance ne les separe. Les chargeurs, eux, le disent :
|
|
// 'mppt_active' = je suis a mon point de puissance maximale, je donne tout ;
|
|
// 'voltage_current_limited' / 'off' = je me retiens faute de preneur.
|
|
//
|
|
// Signal DIRECT (le materiel lui-meme, pas un proxy), INSTANTANE (30 s, la
|
|
// cadence de la boucle) et, sur l'incident du 03/09, il a bascule a 19:20 —
|
|
// 24 minutes avant le drapeau `pv_censored` du bilan energetique, qui tourne
|
|
// toutes les 5 min et dont l'un des motifs est justement le proxy SOC >= 90
|
|
// qu'on cherchait a remplacer.
|
|
//
|
|
// ⚠️ DEMARRAGE UNIQUEMENT, et c'est structurel, pas une precaution :
|
|
// gager le sourceMode ou le MAINTIEN sur ce signal ferait que la charge, en
|
|
// donnant un preneur au MPPT, detruirait sa propre condition d'autorisation —
|
|
// exactement le bistable de l'abondance P6 corrige le 24/08. La garde n'est
|
|
// lue que quand la voiture est a l'arret, instant ou l'etat des trackers
|
|
// decrit la situation SANS elle. Verifie le 01/09 a 14:22-14:34 : les trackers
|
|
// restaient limites PENDANT une charge de 3,4 kW sous 6,9 kW de PV — a midi la
|
|
// ressource depasse ce que la voiture peut prendre, le signal ne s'auto-annule
|
|
// donc pas non plus.
|
|
//
|
|
// Aucune lecture exploitable (integration muette) => on ne sait pas => on
|
|
// retombe sur le comportement anterieur. On ne devine jamais un etat.
|
|
const mpptReadable = parsed.mpptReadableCount > 0;
|
|
const mpptAllAtMaxPower = mpptReadable && parsed.mpptHoldingBackCount === 0;
|
|
const solarProvocationJustified = !mpptAllAtMaxPower;
|
|
|
|
// Chemin PROVOCATION (batterie haute, ou sourceMode solar_potential) : on
|
|
// n'exige pas d'excedent mesurable — il est censure par construction — mais on
|
|
// exige que la batterie ne porte pas deja la maison.
|
|
const solarSurplusProvocationOk = solarSurplusW >= 0;
|
|
|
|
// Chemin SOLEIL REEL : l'excedent doit atteindre le seuil de demarrage.
|
|
const solarSurplusStartOk =
|
|
context.sourceMode === 'solar_potential'
|
|
? (solarSurplusProvocationOk && solarProvocationJustified)
|
|
: solarSurplusW >= context.effectivePvStartMinW;
|
|
|
|
// =====================================================
|
|
// BATTERY-ASSISTED SOLAR START/HOLD
|
|
// IMPORTANT:
|
|
// Installation sans injection:
|
|
// le PV visible peut rester bas tant que l'EVCS ne démarre pas.
|
|
// On autorise donc un démarrage/maintien local si la batterie est haute
|
|
// et que la dette nuage reste faible.
|
|
// =====================================================
|
|
const batteryAssistSolarPolicyEligible =
|
|
!strategy.forceGridRequested &&
|
|
(
|
|
strategy.forceChargeRequested ||
|
|
strategy.userStrategy === 'auto_solar_plus'
|
|
) &&
|
|
isLocalSolarSource;
|
|
|
|
const batteryAssistSolarStartSocMin = 90;
|
|
const batteryAssistSolarHoldSocMin = 85;
|
|
|
|
const batteryAssistSolarDebtStartMaxKwh = Math.max(
|
|
cfg.cloudDebtRecoveryThresholdKwh,
|
|
Math.min(params.cloudDebtMaxKwh, 0.05)
|
|
);
|
|
|
|
const batteryAssistSolarStartOk =
|
|
batteryAssistSolarPolicyEligible &&
|
|
commonSafetyStartOk &&
|
|
!isGridSource &&
|
|
parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) &&
|
|
parsed.soc > batteryAssistSolarHoldSocMin &&
|
|
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
|
|
solarSurplusProvocationOk &&
|
|
solarProvocationJustified &&
|
|
cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&
|
|
!cloudDebtActive;
|
|
|
|
const batteryAssistSolarHoldOk =
|
|
batteryAssistSolarPolicyEligible &&
|
|
commonSafetyHoldOk &&
|
|
!isGridSource &&
|
|
parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&
|
|
batterySoftDischargeHoldOk &&
|
|
cloudToleranceOk;
|
|
|
|
// =====================================================
|
|
// START / HOLD CONDITIONS
|
|
// =====================================================
|
|
// FENETRE D'ETABLISSEMENT (31/08/2026) — cf. WINDOWS_SOURCEV2 pour la mesure.
|
|
// Pendant les ~150 s qui suivent l'arrivee du reseau, PV et courant batterie ne
|
|
// mesurent rien d'exploitable. On NE DEMARRE PAS sur ces valeurs, et on ne
|
|
// laisse pas une charge en cours s'arreter a cause d'elles. Les gardes de
|
|
// securite (commonSafety*, planchers SOC, surcharge, pas de tampon) restent
|
|
// evaluees normalement : c'est le risque qui prime, jamais le confort.
|
|
const gridSettling = derived.gridSettling === true;
|
|
|
|
const rawSolarStartOk =
|
|
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
|
|
!gridSettling &&
|
|
commonSafetyStartOk &&
|
|
parsed.soc >= effectiveSocStartFloor &&
|
|
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
|
|
solarSurplusStartOk;
|
|
|
|
const solarStartOk =
|
|
rawSolarStartOk || batteryAssistSolarStartOk;
|
|
|
|
const rawSolarHoldOk =
|
|
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
|
|
commonSafetyHoldOk &&
|
|
parsed.soc >= effectiveSocHoldFloor &&
|
|
(
|
|
gridSettling ||
|
|
batterySoftDischargeHoldOk
|
|
) &&
|
|
(
|
|
gridSettling ||
|
|
parsed.pvPowerW >= params.pvHoldMinW ||
|
|
cloudToleranceOk
|
|
);
|
|
|
|
const solarHoldOk =
|
|
rawSolarHoldOk || batteryAssistSolarHoldOk;
|
|
|
|
// FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now
|
|
// P9 (09/08/2026) : la fenêtre départ imminent autorise aussi le réseau.
|
|
const gridAllowedBySoc =
|
|
strategy.forceGridRequested ||
|
|
context.profile === 'night_hc' ||
|
|
departureUrgentActive ||
|
|
parsed.soc <= 35;
|
|
|
|
const gridStartOk =
|
|
gridAllowedBySoc &&
|
|
isGridSource &&
|
|
commonSafetyStartOk &&
|
|
parsed.gridAvailable &&
|
|
parsed.batteryCurrentA >= gridBatterySoftDynamicA;
|
|
|
|
const gridHoldOk =
|
|
gridAllowedBySoc &&
|
|
isGridSource &&
|
|
commonSafetyHoldOk &&
|
|
parsed.gridAvailable &&
|
|
parsed.batteryCurrentA >= gridBatteryHardDynamicA;
|
|
|
|
// P12 (15/09/2026) — le mode `battery_assist_night` etait MORT depuis toujours.
|
|
// Il exige `batteryCurrentA >= plancher de decharge du profil` (0 A), condition
|
|
// impossible la nuit puisque la maison tire sur la batterie (§11, ~ -14 A). Il
|
|
// n'a donc jamais demarre une seule fois.
|
|
//
|
|
// Quand P12 est actif, on remplace ce test de COURANT par un test d'ENERGIE :
|
|
// rester au-dessus de ce qu'il faut pour tenir jusqu'au croisement PV. Le
|
|
// plancher vient du bilan (`required_soc_prudent`, profil p80), il est calcule
|
|
// dans WINDOWS_SOURCEV2 et transmis ici — ce node CONSOMME, il ne recalcule pas.
|
|
//
|
|
// ⚠️ LES GARDES DE COURANT NE SONT PAS SUPPRIMEES POUR AUTANT :
|
|
// - `commonSafetyStartOk` / `commonSafetyHoldOk` restent evalues normalement ;
|
|
// - le MAINTIEN garde `batteryDischargeHardDynamicA`, l'arret dur, qui n'est
|
|
// jamais contourne ;
|
|
// - la garde SOUPLE et sa tolerance temporelle (§35) continuent de s'appliquer
|
|
// par `batterySoftDischargeHoldOk` en aval, et c'est elle qui arretera la
|
|
// charge quand la bande de SOC deviendra trop basse pour le courant tire.
|
|
// Ce qu'on retire, c'est uniquement le plancher de PROFIL, qui interdisait de
|
|
// demarrer quoi que ce soit des que la batterie n'etait pas a l'equilibre.
|
|
const nightBatterySurplusAvailable = !!nightPrev.nightBatterySurplusAvailable;
|
|
const nightBatterySurplusFloorPct = Number.isFinite(nightPrev.nightBatterySurplusFloorPct)
|
|
? nightPrev.nightBatterySurplusFloorPct
|
|
: Infinity;
|
|
|
|
const batteryAssistNightStartOk =
|
|
context.sourceMode === 'battery_assist_night' &&
|
|
commonSafetyStartOk &&
|
|
nightSessionActive &&
|
|
batteryAssistAllowed &&
|
|
parsed.soc > batteryHardFloorPct &&
|
|
(
|
|
nightBatterySurplusAvailable
|
|
? parsed.soc > nightBatterySurplusFloorPct
|
|
: (
|
|
parsed.soc > gridTakeoverTriggerPct &&
|
|
parsed.batteryCurrentA >= profileBatteryDischargeFloorA
|
|
)
|
|
);
|
|
|
|
const batteryAssistNightHoldOk =
|
|
context.sourceMode === 'battery_assist_night' &&
|
|
commonSafetyHoldOk &&
|
|
nightSessionActive &&
|
|
batteryAssistAllowed &&
|
|
parsed.soc > batteryHardFloorPct &&
|
|
parsed.batteryCurrentA >= batteryDischargeHardDynamicA &&
|
|
(
|
|
nightBatterySurplusAvailable
|
|
? parsed.soc > nightBatterySurplusFloorPct
|
|
: parsed.soc > gridTakeoverTriggerPct
|
|
);
|
|
|
|
// =====================================================
|
|
// PROBE LOGIC — STRICTEMENT LIMITÉE AU MODE PROBE
|
|
// =====================================================
|
|
const probeModeActive = context.sourceMode === 'hybrid_probe';
|
|
|
|
const probeStartOk =
|
|
probeModeActive &&
|
|
commonSafetyStartOk &&
|
|
(context.morningProbeEligible || context.middayProbeEligible || context.afternoonProbeEligible);
|
|
|
|
const probeHoldGuardOk =
|
|
probeModeActive &&
|
|
commonSafetyHoldOk &&
|
|
batterySoftDischargeHoldOk;
|
|
|
|
const probeWindowActive =
|
|
probeModeActive &&
|
|
sessionObservedActive &&
|
|
history.probeStartedMs !== null &&
|
|
lifecycle.minutesSinceProbeStart !== null &&
|
|
lifecycle.minutesSinceProbeStart < params.probeMinutes;
|
|
|
|
const probeElapsedReached =
|
|
probeModeActive &&
|
|
sessionObservedActive &&
|
|
history.probeStartedMs !== null &&
|
|
lifecycle.minutesSinceProbeStart !== null &&
|
|
lifecycle.minutesSinceProbeStart >= params.probeMinutes;
|
|
|
|
const probeSuccessNow =
|
|
probeModeActive &&
|
|
sessionObservedActive &&
|
|
history.probeStartedMs !== null &&
|
|
(
|
|
parsed.pvPowerW >= params.pvHoldMinW &&
|
|
parsed.batteryCurrentA >= Math.min(0, batteryDischargeSoftDynamicA)
|
|
);
|
|
|
|
// Une sonde ne peut pas etre declaree ratee sur une mesure prise pendant
|
|
// l'etablissement : c'est le reseau qui masque la production, pas la sonde qui
|
|
// echoue. On differe le verdict, on ne l'invente pas.
|
|
const probeFailedNow =
|
|
probeModeActive &&
|
|
probeElapsedReached &&
|
|
!gridSettling &&
|
|
!probeSuccessNow;
|
|
|
|
// =====================================================
|
|
// STEP-UP LOGIC
|
|
// =====================================================
|
|
const stepUpByBatteryReserve =
|
|
batteryPriorityMode &&
|
|
parsed.batteryCurrentA >= batteryChargeReserveDynamicA;
|
|
|
|
const stepUpByForecastMode =
|
|
!batteryPriorityMode &&
|
|
(
|
|
parsed.pvPowerW >= params.pvHoldMinW ||
|
|
parsed.pvPowerAvg10mW >= params.pvHoldMinW
|
|
) &&
|
|
derived.forecastBufferKwh >= -1 &&
|
|
derived.projectedEndSocAfterCharge >= (derived.endDayGuardSoc - 5) &&
|
|
cloudToleranceOk;
|
|
|
|
// Pas de montee en courant sur des mesures d'etablissement. La descente, elle,
|
|
// n'est pas bridee : c'est la direction sure.
|
|
const solarShouldStepUp =
|
|
!gridSettling &&
|
|
lifecycle.currentCooldownElapsed &&
|
|
derived.currentReferenceA < derived.physicalCapA &&
|
|
derived.physicalCapA >= params.currentMinA &&
|
|
!derived.vebusAcOutSoftReached &&
|
|
!derived.vebusDcVoltageSoftLow &&
|
|
!derived.houseLoadHighVsHeadroom &&
|
|
(
|
|
stepUpByBatteryReserve ||
|
|
stepUpByForecastMode ||
|
|
parsed.pvPowerW >= (context.effectivePvStartMinW + 400)
|
|
);
|
|
|
|
const gridShouldStepUp =
|
|
lifecycle.currentCooldownElapsed &&
|
|
derived.currentReferenceA < derived.physicalCapA &&
|
|
derived.physicalCapA >= params.currentMinA &&
|
|
!derived.vebusAcOutSoftReached &&
|
|
!derived.vebusDcVoltageSoftLow &&
|
|
parsed.batteryCurrentA >= gridBatterySoftDynamicA &&
|
|
!derived.houseLoadHighVsHeadroom;
|
|
|
|
const batteryAssistNightShouldStepUp =
|
|
context.sourceMode === 'battery_assist_night' &&
|
|
lifecycle.currentCooldownElapsed &&
|
|
derived.currentReferenceA < derived.physicalCapA &&
|
|
derived.physicalCapA >= params.currentMinA &&
|
|
!derived.vebusAcOutSoftReached &&
|
|
!derived.vebusDcVoltageSoftLow &&
|
|
!derived.houseLoadHighVsHeadroom &&
|
|
parsed.batteryCurrentA >= batteryChargeReserveDynamicA &&
|
|
batteryAssistAllowed;
|
|
|
|
// =====================================================
|
|
// STEP-DOWN LOGIC
|
|
// =====================================================
|
|
const solarShouldStepDownBattery =
|
|
parsed.batteryCurrentA <= batteryDischargeSoftDynamicA &&
|
|
lifecycle.currentCooldownElapsed &&
|
|
derived.currentReferenceA > params.currentMinA;
|
|
|
|
const solarShouldStepDownCloudDebt =
|
|
!cloudToleranceOk &&
|
|
lifecycle.currentCooldownElapsed &&
|
|
derived.currentReferenceA > params.currentMinA;
|
|
|
|
const gridShouldStepDownBattery =
|
|
parsed.batteryCurrentA < gridBatterySoftDynamicA &&
|
|
lifecycle.currentCooldownElapsed &&
|
|
derived.currentReferenceA > params.currentMinA;
|
|
|
|
const batteryAssistNightShouldStepDown =
|
|
context.sourceMode === 'battery_assist_night' &&
|
|
lifecycle.currentCooldownElapsed &&
|
|
derived.currentReferenceA > params.currentMinA &&
|
|
(
|
|
parsed.batteryCurrentA <= batteryDischargeSoftDynamicA ||
|
|
parsed.soc <= gridTakeoverTriggerPct ||
|
|
batteryRecoveryUrgent
|
|
);
|
|
|
|
const shouldStepDownVebusPower =
|
|
derived.vebusAcOutSoftReached &&
|
|
lifecycle.currentCooldownElapsed &&
|
|
derived.currentReferenceA > params.currentMinA;
|
|
|
|
const shouldStepDownVebusVoltage =
|
|
derived.vebusDcVoltageSoftLow &&
|
|
lifecycle.currentCooldownElapsed &&
|
|
derived.currentReferenceA > params.currentMinA;
|
|
|
|
const shouldStepDownOverload =
|
|
derived.anyVebusOverload &&
|
|
lifecycle.currentCooldownElapsed &&
|
|
derived.currentReferenceA > params.currentMinA;
|
|
|
|
const shouldStopOverloadAtMin =
|
|
derived.anyVebusOverload &&
|
|
derived.currentReferenceA <= params.currentMinA &&
|
|
sessionObservedActive;
|
|
|
|
// =====================================================
|
|
// HARD STOPS / PROTECTIONS
|
|
// =====================================================
|
|
const batteryCurrentHardExceeded =
|
|
parsed.batteryCurrentA <= batteryDischargeHardDynamicA;
|
|
|
|
// overload volontairement exclu du hard stop immédiat
|
|
const mustStopHard =
|
|
derived.anyVebusLowBattery ||
|
|
derived.vebusDcVoltageHardLow ||
|
|
derived.vebusAcOutHardReached ||
|
|
batteryCurrentHardExceeded;
|
|
|
|
const gridBatteryProtectionStop =
|
|
sessionObservedActive &&
|
|
isGridSource &&
|
|
parsed.batteryCurrentA < gridBatteryHardDynamicA;
|
|
|
|
// =====================================================
|
|
// OUTPUT
|
|
// =====================================================
|
|
return [{
|
|
json: {
|
|
...prev,
|
|
|
|
batteryPolicy: {
|
|
socBand,
|
|
batteryDischargeSoftDynamicA,
|
|
batteryDischargeHardDynamicA,
|
|
batteryChargeReserveDynamicA,
|
|
batterySoftToleranceMinutes,
|
|
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,
|
|
gridForceChargeRelaxHardA,
|
|
|
|
batteryAssistSolarStartSocMin,
|
|
batteryAssistSolarHoldSocMin,
|
|
batteryAssistSolarDebtStartMaxKwh,
|
|
|
|
houseSpikeDetected,
|
|
solarPotentialContext,
|
|
outsideWindowLocalBypass,
|
|
},
|
|
|
|
locks: {
|
|
nextAllowedWindowStartMs,
|
|
blockActive,
|
|
blockStillRelevant,
|
|
startBlockedByReason,
|
|
},
|
|
|
|
cloud: {
|
|
sessionObservedActive,
|
|
batteryNetPowerW,
|
|
cloudDebtNextKwh,
|
|
cloudDebtMeaningful,
|
|
cloudEventDischargingNow,
|
|
continuingCloudEvent,
|
|
enteringCloudDebt,
|
|
cloudDebtActive,
|
|
cloudDebtStartedMs,
|
|
cloudDebtDurationMinutes,
|
|
effectiveCloudToleranceMinutes,
|
|
cloudToleranceOk,
|
|
},
|
|
|
|
modes: {
|
|
batteryFullHarvestMode,
|
|
forecastCommitMode,
|
|
batteryPriorityMode,
|
|
},
|
|
|
|
nightEvcs: {
|
|
nightSessionActive,
|
|
nightSessionNeedsCapture,
|
|
nightSessionNeedsClear,
|
|
nightSessionStartSocPct,
|
|
nightSessionStartAt,
|
|
nightSessionStartMs,
|
|
|
|
batteryHardFloorPct,
|
|
batteryMorningTargetPct,
|
|
balanceMorningTargetApplied,
|
|
nightGridSessionCommitted,
|
|
// P12 — passe-plat du verrou anti-oscillateur vers la sortie.
|
|
nightBatterySurplusExhausted: !!nightPrev.nightBatterySurplusExhausted,
|
|
nightBatterySurplusAvailable: !!nightPrev.nightBatterySurplusAvailable,
|
|
maxBatteryDischargePoints,
|
|
gridTakeoverTriggerPct,
|
|
|
|
batteryBelowHardFloor,
|
|
batteryBelowMorningTarget,
|
|
batteryAtOrBelowGridTakeoverTrigger,
|
|
|
|
batteryKwhMissingToTarget,
|
|
batteryRecoveryMinutesNeeded,
|
|
batteryRecoveryUrgent,
|
|
batteryRecoveryAllowedNow,
|
|
batteryTargetReached,
|
|
|
|
batteryAssistAllowed,
|
|
nightGridTakeoverNeeded,
|
|
nightTakeoverLatched,
|
|
vehicleChargeCompleted,
|
|
nightRecoveryDone,
|
|
departureUrgentActive,
|
|
departureChargeDone,
|
|
dayVehicleFullIdle,
|
|
nightPreferredSourceMode,
|
|
},
|
|
|
|
safeguards: {
|
|
isGridSource,
|
|
commonSafetyStartOk,
|
|
commonSafetyHoldOk,
|
|
profileSocStartFloor,
|
|
effectiveSocStartFloor,
|
|
solarAbundance,
|
|
solarAbundanceHold,
|
|
profileSocHoldFloor,
|
|
effectiveSocHoldFloor,
|
|
profileBatteryDischargeFloorA,
|
|
|
|
// Excedent solaire (03/09/2026) : la grandeur qui autorise un demarrage.
|
|
// Plancher du soir issu du bilan energetique (03/09/2026).
|
|
eveningSocStartFloorPct,
|
|
eveningFloorFromBalance,
|
|
balanceRequiredSocPct: params.balanceRequiredSocPct,
|
|
balanceEnergyToCoverKwh: params.balanceEnergyToCoverKwh,
|
|
balanceAgeMin: balanceAgeMin === null ? null : Math.round(balanceAgeMin * 10) / 10,
|
|
balanceEveningFloorUsable,
|
|
|
|
solarSurplusW,
|
|
solarSurplusProvocationOk,
|
|
solarSurplusStartOk,
|
|
|
|
// Etat declare par les chargeurs solaires (03/09/2026).
|
|
mpptReadableCount: parsed.mpptReadableCount,
|
|
mpptHoldingBackCount: parsed.mpptHoldingBackCount,
|
|
mpptAllAtMaxPower,
|
|
solarProvocationJustified,
|
|
batteryCurrentHardExceeded,
|
|
mustStopHard,
|
|
gridBatteryProtectionStop,
|
|
},
|
|
|
|
conditions: {
|
|
rawSolarStartOk,
|
|
rawSolarHoldOk,
|
|
batteryAssistSolarPolicyEligible,
|
|
batteryAssistSolarStartOk,
|
|
batteryAssistSolarHoldOk,
|
|
|
|
solarStartOk,
|
|
solarHoldOk,
|
|
gridSettling,
|
|
gridTransitionAgeSec: parsed.gridTransitionAgeSec,
|
|
gridStartOk,
|
|
gridHoldOk,
|
|
|
|
batteryAssistNightStartOk,
|
|
nightBatterySurplusAvailable,
|
|
nightBatterySurplusFloorPct,
|
|
batteryAssistNightHoldOk,
|
|
|
|
probeStartOk,
|
|
probeHoldGuardOk,
|
|
probeWindowActive,
|
|
probeElapsedReached,
|
|
probeSuccessNow,
|
|
probeFailedNow,
|
|
|
|
solarShouldStepUp,
|
|
gridShouldStepUp,
|
|
batteryAssistNightShouldStepUp,
|
|
|
|
solarShouldStepDownBattery,
|
|
solarShouldStepDownCloudDebt,
|
|
gridShouldStepDownBattery,
|
|
batteryAssistNightShouldStepDown,
|
|
|
|
shouldStepDownVebusPower,
|
|
shouldStepDownVebusVoltage,
|
|
shouldStepDownOverload,
|
|
shouldStopOverloadAtMin,
|
|
}
|
|
}
|
|
}];
|