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,279 @@
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;
}
// =====================================================
// 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
);