R2: git = source de vérité — src/nodes/ + build/extract intégrés au push

- src/nodes/*.js (6 nodes + template snapshot) = source de vérité unique.
- n8n-push.mjs reconstruit le JSON depuis src/ avant syntaxe+golden+PUT
  (src gagne toujours; n8n-extract.mjs pour resynchroniser si édition UI).
- n8n-build.mjs --check pour vérifier la synchro.
- Copies historiques déplacées dans archive/ (mortes, dérivées de la prod).
- .gitignore: backups locaux de rollback.
- AGENTS.md: règle R2 pour toutes les futures sessions (dont Qwen local).
- Validé: idempotence, mutation/restauration, pipeline push complet en réel.
This commit is contained in:
Tallagrand Thierry
2026-08-07 21:03:52 +02:00
parent b10c90c74f
commit 1ae8129658
78 changed files with 5132 additions and 0 deletions

View File

@@ -0,0 +1,685 @@
function minutesSince(ts, nowMs) {
if (ts === null || nowMs === null) return null;
return Math.max(0, (nowMs - ts) / 60000);
}
function secondsSince(ts, nowMs) {
if (ts === null || nowMs === null) return null;
return Math.max(0, (nowMs - ts) / 1000);
}
function isWithinWindow(nowMin, startMin, stopMin) {
if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;
if (startMin === stopMin) return false;
if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;
return nowMin >= startMin || nowMin < stopMin;
}
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
const prev = $input.first().json;
const {
input,
nowMs,
now,
cfg,
parsed,
params,
history,
batteryTune
} = prev;
// =====================================================
// STRATEGY
// =====================================================
const userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();
const isStopMode = userStrategy === 'stop';
const forceGridRequested = userStrategy === 'force_grid_now';
const forceChargeRequested = userStrategy === 'force_charge_now';
const isForceMode = forceGridRequested || forceChargeRequested;
// =====================================================
// LIFECYCLE / TIMING
// =====================================================
const minutesSinceStart = minutesSince(history.lastStartMs, nowMs);
const minutesSinceStop = minutesSince(history.lastStopMs, nowMs);
const secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);
const minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);
const minRunElapsed =
minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;
const minStopElapsed =
minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;
const currentCooldownBaseSec = parsed.evcsChargingSwitchOn
? batteryTune.global.stepDownCooldownSeconds
: batteryTune.global.stepUpCooldownSeconds;
const currentCooldownElapsed =
secondsSinceCurrentChange === null
? true
: secondsSinceCurrentChange >= Math.max(
params.currentChangeCooldownSeconds,
currentCooldownBaseSec
);
// =====================================================
// DEPARTURE PRIORITY
// =====================================================
const minutesUntilDeparture =
params.departureTimeMin === null
? null
: (() => {
const rawDiff = params.departureTimeMin - now.minNow;
return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);
})();
const departurePriorityActive =
parsed.departurePriorityEnabled &&
Number.isFinite(minutesUntilDeparture) &&
minutesUntilDeparture >= 0 &&
minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&
parsed.evcsVehicleConnected;
// =====================================================
// MANUAL CHARGE SEMANTICS
// IMPORTANT:
// force_charge_now = bypass fenêtres pour la charge,
// MAIS PAS demande implicite de réseau.
// =====================================================
const manualChargeBypassWindows = forceChargeRequested;
const manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;
// Compatibilité ascendante : on garde le champ dans context,
// mais on le neutralise volontairement.
const forceChargeDepartureGridOverride = false;
// =====================================================
// SYSTEM DERIVED VALUES
// =====================================================
const acVoltageRef =
Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180
? parsed.vebusAcOutVoltageV
: cfg.nominalAcVoltageV;
const batteryVoltageRef =
Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0
? parsed.vebusDcVoltageV
: 51.2;
const houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);
const vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);
const totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);
const totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;
// P5 : plafond EVCS configurable quand le réseau alimente réellement AC-IN
// (input_number.evcs_plafond_courant_edf_on). Hors réseau : plafond câblé prudent.
const evcsDedicatedCapBaseW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);
const safeEvcsByDedicatedCapW = parsed.acInAvailable
? Math.max(0, params.gridCurrentMaxA * acVoltageRef)
: evcsDedicatedCapBaseW;
const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);
const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));
const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));
const physicalCapA = clamp(
systemCapCurrentA,
0,
parsed.acInAvailable
? Math.max(params.currentMaxAutoA, params.gridCurrentMaxA)
: params.currentMaxAutoA
);
const canRunAtMinCurrent = physicalCapA >= params.currentMinA;
const endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);
const energyNeededForEndDayGuardKwh = Math.max(
0,
((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh
);
const forecastBufferKwh =
parsed.todayRemainingKwh -
params.baseLoadRemainingKwh -
params.flexibleSafetyMarginKwh -
params.requiredEnergyKwh -
energyNeededForEndDayGuardKwh;
const projectedEndSocAfterCharge = clamp(
parsed.soc + (
(
parsed.todayRemainingKwh -
params.baseLoadRemainingKwh -
params.flexibleSafetyMarginKwh -
params.requiredEnergyKwh
) / cfg.batteryUsableCapacityKwh
) * 100,
0,
100
);
const currentReferenceA = clamp(
Number.isFinite(parsed.evcsCommandCurrentA)
? parsed.evcsCommandCurrentA
: Number.isFinite(parsed.evcsSetCurrentA)
? parsed.evcsSetCurrentA
: params.targetCurrentAutoA,
0,
32
);
const anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;
const anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;
const vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;
const vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;
const vebusDcVoltageSoftLow =
Number.isFinite(parsed.vebusDcVoltageV) &&
parsed.vebusDcVoltageV > 0 &&
parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;
const vebusDcVoltageHardLow =
Number.isFinite(parsed.vebusDcVoltageV) &&
parsed.vebusDcVoltageV > 0 &&
parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;
const houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;
// =====================================================
// WINDOWS / PROFILE
// =====================================================
const inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);
const inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);
const inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);
const inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);
const profile =
inNightHcWindow ? 'night_hc' :
inMorningWindow ? 'morning_solar' :
inMiddayWindow ? 'midday_hc_or_solar' :
inEveningWindow ? 'evening_high_soc' :
'outside_window';
// =====================================================
// EVCS NIGHT PREVIEW ENGINE
// =====================================================
const nightSessionActive =
profile === 'night_hc' &&
parsed.evcsVehicleConnected;
const nightSessionStartSocPct =
nightSessionActive
? (
Number.isFinite(history.nightSessionStartSocPct)
? history.nightSessionStartSocPct
: parsed.soc
)
: null;
const nightSessionStartAt =
nightSessionActive
? (history.nightSessionStartAt || '')
: '';
const nightSessionStartMs =
nightSessionActive
? (history.nightSessionStartMs ?? null)
: null;
const nightSessionNeedsCapture =
nightSessionActive &&
(
!Number.isFinite(history.nightSessionStartSocPct) ||
!nightSessionStartAt ||
nightSessionStartMs === null
);
const nightSessionNeedsClear =
!nightSessionActive &&
(
Number.isFinite(history.nightSessionStartSocPct) ||
!!history.nightSessionStartAt
);
const batteryHardFloorPct =
params.evcsNightBatteryHardFloorPct;
// P4 : la nuit avant minuit, today_remaining ~ 0 => utiliser la prévision du lendemain.
const nightForecastHour = now.hour;
const nightForecastKwh =
(nightForecastHour >= 20 || nightForecastHour < 8)
? Math.max(parsed.todayRemainingKwh, parsed.tomorrowKwh || 0)
: parsed.todayRemainingKwh;
const batteryMorningTargetPct =
nightForecastKwh >= params.evcsNightStrongForecastKwh
? params.evcsNightTargetStrongForecastPct
: nightForecastKwh >= params.evcsNightMediumForecastKwh
? params.evcsNightTargetMediumForecastPct
: params.evcsNightTargetWeakForecastPct;
const maxBatteryDischargePoints =
nightSessionActive && Number.isFinite(nightSessionStartSocPct)
? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)
: 0;
const gridTakeoverTriggerPct =
Math.max(batteryHardFloorPct, batteryMorningTargetPct);
const batteryBelowHardFloor =
parsed.soc <= batteryHardFloorPct;
const batteryBelowMorningTarget =
parsed.soc < batteryMorningTargetPct;
const batteryAtOrBelowGridTakeoverTrigger =
parsed.soc <= gridTakeoverTriggerPct;
const batteryKwhMissingToTarget =
nightSessionActive
? Math.max(
0,
((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh
)
: 0;
const batteryRecoveryMinutesNeeded =
nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0
? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60
: 0;
const batteryRecoveryUrgent =
nightSessionActive &&
Number.isFinite(minutesUntilDeparture) &&
minutesUntilDeparture >= 0 &&
(
batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin
>= minutesUntilDeparture
);
// P2 ANTI-CRENEAUX : continuité du takeover réseau nocturne.
// Une fois le réseau HC engagé pendant une session nuit, on y reste
// jusqu'à la fin de la session (fenêtre/débranchement) ou batterie quasi pleine.
// Évite le cycle batterie->jeep->réseau->batterie (pertes + charge hachée).
const NIGHT_TAKEOVER_RELEASE_SOC_PCT = 90;
const nightTakeoverPrev =
(input.night_takeover_prev ?? 'off').toString().trim().toLowerCase() === 'on';
// P8 (07/08/2026) : "charge terminée" COLLANT via l'état surplus du cycle
// précédent (helper input_text.victron_evcs_surplus_state relu par le snapshot).
// Chaîne : VDF stoppe la charge (state vehicle_full_or_paused) → ce flag s'allume
// → DECISION émet night_session_done_idle → le flag s'auto-entretient jusqu'au
// débranchement (state vehicle_not_connected) ou à la fin de la fenêtre nuit.
const prevSurplusState = (input.prev_surplus_state ?? '').toString().trim().toLowerCase();
const vehicleChargeCompleted =
nightSessionActive &&
(
prevSurplusState === 'vehicle_full_or_paused' ||
prevSurplusState === 'night_session_done_idle'
);
// P8 : correction du fix 04/08 (logique inversée : "|| vehicleChargeCompleted"
// GARDAIT le latch après la charge au lieu de le libérer).
const nightTakeoverLatched =
nightSessionActive &&
nightTakeoverPrev &&
parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT &&
!vehicleChargeCompleted;
const nightGridHcPreferred =
nightSessionActive &&
params.allowGridHcNight &&
!vehicleChargeCompleted;
const batteryAssistAllowed =
nightSessionActive &&
!nightGridHcPreferred &&
parsed.soc > batteryHardFloorPct &&
parsed.soc > gridTakeoverTriggerPct &&
!nightTakeoverLatched &&
!batteryRecoveryUrgent;
const nightGridTakeoverNeeded =
nightSessionActive &&
(
nightGridHcPreferred ||
batteryBelowHardFloor ||
batteryAtOrBelowGridTakeoverTrigger ||
batteryRecoveryUrgent ||
nightTakeoverLatched
);
const nightPreferredSourceMode =
!nightSessionActive
? 'blocked'
: vehicleChargeCompleted
? 'blocked'
: batteryAssistAllowed
? 'battery_assist_night'
: (
batteryBelowHardFloor ||
batteryBelowMorningTarget ||
batteryRecoveryUrgent
)
? 'grid_forced'
: 'grid_hc_allowed';
const batteryRecoveryAllowedNow =
nightSessionActive &&
nightGridTakeoverNeeded &&
(
batteryBelowMorningTarget ||
batteryRecoveryUrgent
);
const batteryTargetReached =
nightSessionActive &&
parsed.soc >= batteryMorningTargetPct;
// =====================================================
// PROBES / SOLAR POTENTIAL
// =====================================================
// IMPORTANT:
// On ne met plus effectivePvStartMinW à 0 en force_charge_now,
// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire.
const effectivePvStartMinW = params.pvStartMinW;
const morningProbeEligible =
profile === 'morning_solar' &&
params.probeEnabled &&
parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW &&
parsed.pvTrendW >= params.morningProbePvTrendMinW &&
parsed.soc >= params.morningSocMin &&
parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA;
const middayProbeEligible =
profile === 'midday_hc_or_solar' &&
params.probeEnabled &&
parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW &&
parsed.soc >= params.middaySocMin &&
parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA;
const afternoonProbeEligible =
profile === 'outside_window' &&
params.probeEnabled &&
parsed.soc >= params.afternoonProbeSocMin &&
parsed.batteryCurrentA >= params.afternoonBatteryMaxDischargeA &&
parsed.pvPowerAvg10mW >= params.afternoonProbePvAvgMinW;
const cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;
const batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);
// P3 PROVOCATION PV (installation sans injection) :
// Batteries quasi pleines => le MPPT bride la production à la conso maison.
// Les seuils PV observés, le forecast buffer et la cloud debt ne veulent
// alors plus rien dire (la dette ne peut d'ailleurs plus se rembourser,
// batterie pleine = courant nul). On autorise solar_potential sur la seule
// santé batterie : SOC très haut + batterie non déchargée.
// Les gardes aval (BATTERY_CLOUD_LOCKS / DECISION) restent actives.
const PV_PROVOCATION_SOC_MIN = 90;
const pvProvocationEligible =
parsed.soc >= PV_PROVOCATION_SOC_MIN &&
batteryOkForPotential;
const isHighBatteryForPotential =
pvProvocationEligible ||
(
parsed.soc >= 85 &&
forecastBufferKwh > 0 &&
cloudDebtOkForPotential &&
batteryOkForPotential
);
const potentialAllowed = userStrategy !== 'auto_solar';
// IMPORTANT:
// On impose un vrai PV > 0 pour le test sur pvPowerW.
// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.
const strongSolarSignal =
(
parsed.pvPowerW > 0 &&
parsed.pvPowerW >= effectivePvStartMinW
) ||
parsed.pvPowerAvg10mW >= params.pvHoldMinW ||
(departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);
// =====================================================
// SOURCE MODE
// SÉMANTIQUE CORRIGÉE:
// - force_charge_now bypass les fenêtres
// - mais ne devient jamais grid_forced à lui seul
// - outside_window = pas de réseau programmé
// - la charge locale peut rester autorisée
// =====================================================
let sourceMode = 'blocked';
if (isStopMode) {
sourceMode = 'blocked';
}
else if (forceGridRequested) {
// Demande explicite de réseau.
sourceMode = 'grid_forced';
}
else if (forceChargeRequested) {
// IMPORTANT:
// force_charge_now = intention de charger maintenant,
// sans exiger le réseau.
if (strongSolarSignal) {
sourceMode = 'solar_only';
}
else if (nightSessionActive && batteryAssistAllowed) {
sourceMode = 'battery_assist_night';
}
else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {
sourceMode = 'hybrid_probe';
}
else if (profile === 'morning_solar' && morningProbeEligible) {
sourceMode = 'hybrid_probe';
}
else {
// Fallback manuel local :
// on n'impose pas le grid, on laisse l'étage aval décider
// selon les garde-fous physiques / batterie.
sourceMode = 'solar_potential';
}
}
else if (profile === 'night_hc') {
if (nightSessionActive) {
sourceMode = nightPreferredSourceMode;
} else {
sourceMode = 'blocked';
}
}
else if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {
if (strongSolarSignal) {
sourceMode = 'solar_only';
}
else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {
sourceMode = 'hybrid_probe';
}
else if (profile === 'morning_solar' && morningProbeEligible) {
sourceMode = 'hybrid_probe';
}
else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {
sourceMode = 'grid_hc_allowed';
}
else {
sourceMode = 'blocked';
}
}
else if (profile === 'evening_high_soc') {
if (strongSolarSignal) {
sourceMode = 'solar_only';
}
else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
else {
sourceMode = 'blocked';
}
}
else if (profile === 'outside_window') {
if (strongSolarSignal) {
sourceMode = 'solar_only';
}
else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
else if (afternoonProbeEligible) {
sourceMode = 'hybrid_probe';
}
else {
// IMPORTANT:
// hors fenêtre = pas de réseau programmé.
// Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.
sourceMode = 'blocked';
}
}
const operatingMode = profile;
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
...prev,
strategy: {
userStrategy,
isStopMode,
isForceMode,
forceGridRequested,
forceChargeRequested,
},
lifecycle: {
minutesSinceStart,
minutesSinceStop,
secondsSinceCurrentChange,
minutesSinceProbeStart,
minRunElapsed,
minStopElapsed,
currentCooldownElapsed,
minutesUntilDeparture,
},
windows: {
inNightHcWindow,
inMorningWindow,
inMiddayWindow,
inEveningWindow,
},
derived: {
acVoltageRef,
batteryVoltageRef,
houseNonEvcsW,
vebusNonEvcsW,
totalVebusSoftCeilingW,
totalVebusHardCeilingW,
safeEvcsByDedicatedCapW,
safeEvcsByVebusW,
safeEvcsCombinedW,
systemCapCurrentA,
physicalCapA,
canRunAtMinCurrent,
endDayGuardSoc,
energyNeededForEndDayGuardKwh,
forecastBufferKwh,
projectedEndSocAfterCharge,
currentReferenceA,
anyVebusOverload,
anyVebusLowBattery,
vebusAcOutSoftReached,
vebusAcOutHardReached,
vebusDcVoltageSoftLow,
vebusDcVoltageHardLow,
houseLoadHighVsHeadroom,
},
context: {
departurePriorityActive,
forceChargeDepartureGridOverride,
manualChargeBypassWindows,
manualChargeLocalRequested,
morningProbeEligible,
middayProbeEligible,
afternoonProbeEligible,
pvProvocationEligible,
isHighBatteryForPotential,
strongSolarSignal,
effectivePvStartMinW,
profile,
operatingMode,
sourceMode,
},
// R1 : source de vérité UNIQUE du moteur nuit.
// BATTERY_CLOUD_LOCKS consomme cet objet, il ne recalcule plus rien.
nightEvcsPreview: {
nightSessionActive,
nightSessionNeedsCapture,
nightSessionNeedsClear,
nightSessionStartSocPct,
nightSessionStartAt,
nightSessionStartMs,
batteryHardFloorPct,
batteryMorningTargetPct,
maxBatteryDischargePoints,
gridTakeoverTriggerPct,
batteryBelowHardFloor,
batteryBelowMorningTarget,
batteryAtOrBelowGridTakeoverTrigger,
batteryKwhMissingToTarget,
batteryRecoveryMinutesNeeded,
batteryRecoveryUrgent,
batteryRecoveryAllowedNow,
batteryTargetReached,
batteryAssistAllowed,
nightGridTakeoverNeeded,
nightTakeoverLatched,
nightGridHcPreferred,
vehicleChargeCompleted,
nightPreferredSourceMode,
}
}
}];