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:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Backups locaux de workflows (rollback) — jamais dans git
|
||||||
|
backup-workflow-*.json
|
||||||
|
modified/prepush-*.json
|
||||||
|
|
||||||
|
# Sorties temporaires
|
||||||
|
node_modules/
|
||||||
14
AGENTS.md
14
AGENTS.md
@@ -25,6 +25,20 @@
|
|||||||
|
|
||||||
## 📂 LIRE UN WORKFLOW n8n (méthode obligatoire)
|
## 📂 LIRE UN WORKFLOW n8n (méthode obligatoire)
|
||||||
|
|
||||||
|
### ⭐ R2 (07/08/2026) — SOURCE DE VÉRITÉ = src/nodes/
|
||||||
|
Le code des nodes du workflow EVCS V2.6 vit dans **`src/nodes/*.js`**
|
||||||
|
(+ `HA_SNAPSHOT_TEMPLATE.json.tpl`). RÈGLES :
|
||||||
|
- Pour MODIFIER un node : éditer `src/nodes/<NODE>.js`, puis
|
||||||
|
`node tools/n8n-push.mjs workflows/evcs-surplus-controller-v2.6.json`
|
||||||
|
(build src→JSON + syntaxe + suite golden 19 scénarios + backup + PUT, tout automatique).
|
||||||
|
- Pour LIRE un node : lire `src/nodes/<NODE>.js` directement (PAS le MCP, pas le JSON).
|
||||||
|
- JAMAIS éditer le JSON du workflow ni l'UI n8n directement : le prochain push
|
||||||
|
écrase avec src/. (Si ça arrive quand même : `node tools/n8n-extract.mjs` resynchronise.)
|
||||||
|
- Tests : `node tests/golden/run.mjs` (19 scénarios figés). Toute évolution voulue
|
||||||
|
du comportement → mettre à jour `tests/golden/scenarios.mjs` dans le même commit.
|
||||||
|
- Les dossiers `archive/` (ex decoupesurplus/, WINDOWS/, BATTERY/...) sont des
|
||||||
|
copies mortes : NE PLUS UTILISER.
|
||||||
|
|
||||||
Le MCP `n8n-mcp_get_workflow` renvoie le workflow ENTIER (~357K, trop gros
|
Le MCP `n8n-mcp_get_workflow` renvoie le workflow ENTIER (~357K, trop gros
|
||||||
pour le contexte). opencode l'écrit alors dans un fichier
|
pour le contexte). opencode l'écrit alors dans un fichier
|
||||||
`~/.local/share/opencode/tool-output/tool_*` et te le tronque.
|
`~/.local/share/opencode/tool-output/tool_*` et te le tronque.
|
||||||
|
|||||||
37
HANDOFF.md
37
HANDOFF.md
@@ -625,3 +625,40 @@ Effets quand vehicleChargeCompleted :
|
|||||||
3. Batterie sous cible matin (35 été) : EDF maintenu + recharge ACM jusqu'à cible
|
3. Batterie sous cible matin (35 été) : EDF maintenu + recharge ACM jusqu'à cible
|
||||||
4. 07:24 : fin de fenêtre → retour profil jour
|
4. 07:24 : fin de fenêtre → retour profil jour
|
||||||
|
|
||||||
|
## 15. Session du 07/08/2026 (soir) — R2 : Git = source de vérité du code des nodes
|
||||||
|
|
||||||
|
### Nouveau layout
|
||||||
|
- **`src/nodes/`** = SOURCE DE VÉRITÉ : INPUT_PARSE.js, WINDOWS_SOURCEV2.js,
|
||||||
|
BATTERY_CLOUD_LOCKS.js, DECISION_ENGINEV2.js, HA_SERVICE_CALLS_OUTPUT.js,
|
||||||
|
BUILD_ACTIONS.js, HA_SNAPSHOT_TEMPLATE.json.tpl.
|
||||||
|
- `workflows/evcs-surplus-controller-v2.6.json` = artefact déployable, reconstruit
|
||||||
|
automatiquement depuis src/ à chaque push (src GAGNE toujours).
|
||||||
|
- `tools/lib-node-map.mjs` (cartographie), `tools/n8n-extract.mjs` (JSON→src,
|
||||||
|
uniquement pour resynchroniser après une édition UI n8n — à éviter),
|
||||||
|
`tools/n8n-build.mjs [--check]` (src→JSON / vérif synchro).
|
||||||
|
- `archive/` : toutes les copies historiques (decoupesurplus/, WINDOWS/, BATTERY/,
|
||||||
|
DECISION/, HYSTERESIS/, EVCSV2.2.json, surplus.js...) — copies mortes, dérivées.
|
||||||
|
- `.gitignore` : backup-workflow-*.json + modified/prepush-*.json (rollbacks locaux).
|
||||||
|
|
||||||
|
### WORKFLOW DE MODIFICATION DÉSORMAIS (règle inscrite dans AGENTS.md)
|
||||||
|
```
|
||||||
|
éditer src/nodes/<NODE>.js
|
||||||
|
node tools/n8n-push.mjs workflows/evcs-surplus-controller-v2.6.json
|
||||||
|
→ build src→JSON → node --check → GOLDEN 19/19 (bloquant) → backup serveur → PUT
|
||||||
|
git commit (src + json + scénarios si évolution voulue) && git push
|
||||||
|
```
|
||||||
|
JAMAIS d'édition directe du JSON ou de l'UI n8n (écrasée au prochain push).
|
||||||
|
|
||||||
|
### Validations effectuées
|
||||||
|
- Extract/build idempotents (extract → build → --check : zéro diff).
|
||||||
|
- Test mutation : édition src → build injecte 1 node → restauration git+extract OK.
|
||||||
|
- Pipeline complet de push exécuté en réel : build (no-op) → golden 19/19 → PUT OK.
|
||||||
|
- Nota : tailles `ls` ≠ longueur de code (octets UTF-8 vs caractères, accents FR).
|
||||||
|
|
||||||
|
### Plan de maintenabilité : état
|
||||||
|
- R1 ✅ déduplication moteur nuit (07/08)
|
||||||
|
- R3 ✅ suite golden 19 scénarios bloquante (07/08)
|
||||||
|
- R2 ✅ git source de vérité + build/push (07/08)
|
||||||
|
- Suivant : agent IA planificateur en MODE OMBRE (jamais dans la boucle 30 s) —
|
||||||
|
helpers advisor_*, validation bornée des sorties, kill-switch, cf. HANDOFF §13/§14.
|
||||||
|
|
||||||
|
|||||||
11
archive/README.md
Normal file
11
archive/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Archive — copies historiques (NE PLUS UTILISER)
|
||||||
|
|
||||||
|
Depuis R2 (07/08/2026), la source de vérité du code des nodes du workflow
|
||||||
|
"HA Snapshot EVCS Surplus Controller V2.6" est **`src/nodes/`** (un fichier
|
||||||
|
par node), injectée automatiquement dans `workflows/*.json` par
|
||||||
|
`tools/n8n-build.mjs` à chaque `tools/n8n-push.mjs`.
|
||||||
|
|
||||||
|
Tout ce qui se trouve ici (decoupesurplus/, WINDOWS/, BATTERY/, DECISION/,
|
||||||
|
HYSTERESIS/, versions P1/P2/P3...) sont des copies de travail des sessions
|
||||||
|
2026-05→2026-07, qui ont TOUTES dérivé de la production. Conservées uniquement
|
||||||
|
pour l'archéologie.
|
||||||
897
src/nodes/BATTERY_CLOUD_LOCKS.js
Normal file
897
src/nodes/BATTERY_CLOUD_LOCKS.js
Normal file
@@ -0,0 +1,897 @@
|
|||||||
|
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 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 nightPreferredSourceMode = nightPrev.nightPreferredSourceMode || 'blocked';
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// PROFILE-BASED THRESHOLDS
|
||||||
|
// =====================================================
|
||||||
|
const profileSocStartFloor =
|
||||||
|
context.profile === 'morning_solar' ? params.morningSocMin :
|
||||||
|
context.profile === 'midday_hc_or_solar' ? params.middaySocMin :
|
||||||
|
context.profile === 'evening_high_soc' ? params.eveningSocStartMin :
|
||||||
|
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;
|
||||||
|
const solarAbundance =
|
||||||
|
parsed.pvPowerAvg10mW >= PV_ABUNDANCE_AVG_MIN_W &&
|
||||||
|
parsed.batteryCurrentA >= ABUNDANCE_BATTERY_CHARGE_MIN_A;
|
||||||
|
const effectiveSocStartFloor = solarAbundance
|
||||||
|
? Math.min(profileSocStartFloor, ABUNDANCE_SOC_FLOOR_PCT)
|
||||||
|
: profileSocStartFloor;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 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 &&
|
||||||
|
cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&
|
||||||
|
!cloudDebtActive;
|
||||||
|
|
||||||
|
const batteryAssistSolarHoldOk =
|
||||||
|
batteryAssistSolarPolicyEligible &&
|
||||||
|
commonSafetyHoldOk &&
|
||||||
|
!isGridSource &&
|
||||||
|
parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&
|
||||||
|
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
|
||||||
|
cloudToleranceOk;
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// START / HOLD CONDITIONS
|
||||||
|
// =====================================================
|
||||||
|
const rawSolarStartOk =
|
||||||
|
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
|
||||||
|
commonSafetyStartOk &&
|
||||||
|
parsed.soc >= effectiveSocStartFloor &&
|
||||||
|
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
|
||||||
|
(
|
||||||
|
context.sourceMode === 'solar_potential' ||
|
||||||
|
parsed.pvPowerW >= context.effectivePvStartMinW
|
||||||
|
);
|
||||||
|
|
||||||
|
const solarStartOk =
|
||||||
|
rawSolarStartOk || batteryAssistSolarStartOk;
|
||||||
|
|
||||||
|
const rawSolarHoldOk =
|
||||||
|
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
|
||||||
|
commonSafetyHoldOk &&
|
||||||
|
parsed.soc >= profileSocHoldFloor &&
|
||||||
|
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
|
||||||
|
(
|
||||||
|
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
|
||||||
|
const gridAllowedBySoc =
|
||||||
|
strategy.forceGridRequested ||
|
||||||
|
context.profile === 'night_hc' ||
|
||||||
|
parsed.soc <= 35;
|
||||||
|
|
||||||
|
const gridStartOk =
|
||||||
|
gridAllowedBySoc &&
|
||||||
|
isGridSource &&
|
||||||
|
commonSafetyStartOk &&
|
||||||
|
parsed.gridAvailable &&
|
||||||
|
parsed.batteryCurrentA >= gridBatterySoftDynamicA;
|
||||||
|
|
||||||
|
const gridHoldOk =
|
||||||
|
gridAllowedBySoc &&
|
||||||
|
isGridSource &&
|
||||||
|
commonSafetyHoldOk &&
|
||||||
|
parsed.gridAvailable &&
|
||||||
|
parsed.batteryCurrentA >= gridBatteryHardDynamicA;
|
||||||
|
|
||||||
|
const batteryAssistNightStartOk =
|
||||||
|
context.sourceMode === 'battery_assist_night' &&
|
||||||
|
commonSafetyStartOk &&
|
||||||
|
nightSessionActive &&
|
||||||
|
batteryAssistAllowed &&
|
||||||
|
parsed.soc > batteryHardFloorPct &&
|
||||||
|
parsed.soc > gridTakeoverTriggerPct &&
|
||||||
|
parsed.batteryCurrentA >= profileBatteryDischargeFloorA;
|
||||||
|
|
||||||
|
const batteryAssistNightHoldOk =
|
||||||
|
context.sourceMode === 'battery_assist_night' &&
|
||||||
|
commonSafetyHoldOk &&
|
||||||
|
nightSessionActive &&
|
||||||
|
batteryAssistAllowed &&
|
||||||
|
parsed.soc > batteryHardFloorPct &&
|
||||||
|
parsed.soc > gridTakeoverTriggerPct &&
|
||||||
|
parsed.batteryCurrentA >= batteryDischargeHardDynamicA;
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 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 &&
|
||||||
|
parsed.batteryCurrentA >= profileBatteryDischargeFloorA;
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
const probeFailedNow =
|
||||||
|
probeModeActive &&
|
||||||
|
probeElapsedReached &&
|
||||||
|
!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;
|
||||||
|
|
||||||
|
const solarShouldStepUp =
|
||||||
|
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,
|
||||||
|
|
||||||
|
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,
|
||||||
|
maxBatteryDischargePoints,
|
||||||
|
gridTakeoverTriggerPct,
|
||||||
|
|
||||||
|
batteryBelowHardFloor,
|
||||||
|
batteryBelowMorningTarget,
|
||||||
|
batteryAtOrBelowGridTakeoverTrigger,
|
||||||
|
|
||||||
|
batteryKwhMissingToTarget,
|
||||||
|
batteryRecoveryMinutesNeeded,
|
||||||
|
batteryRecoveryUrgent,
|
||||||
|
batteryRecoveryAllowedNow,
|
||||||
|
batteryTargetReached,
|
||||||
|
|
||||||
|
batteryAssistAllowed,
|
||||||
|
nightGridTakeoverNeeded,
|
||||||
|
nightTakeoverLatched,
|
||||||
|
vehicleChargeCompleted,
|
||||||
|
nightPreferredSourceMode,
|
||||||
|
},
|
||||||
|
|
||||||
|
safeguards: {
|
||||||
|
isGridSource,
|
||||||
|
commonSafetyStartOk,
|
||||||
|
commonSafetyHoldOk,
|
||||||
|
profileSocStartFloor,
|
||||||
|
effectiveSocStartFloor,
|
||||||
|
solarAbundance,
|
||||||
|
profileSocHoldFloor,
|
||||||
|
profileBatteryDischargeFloorA,
|
||||||
|
batteryCurrentHardExceeded,
|
||||||
|
mustStopHard,
|
||||||
|
gridBatteryProtectionStop,
|
||||||
|
},
|
||||||
|
|
||||||
|
conditions: {
|
||||||
|
rawSolarStartOk,
|
||||||
|
rawSolarHoldOk,
|
||||||
|
batteryAssistSolarPolicyEligible,
|
||||||
|
batteryAssistSolarStartOk,
|
||||||
|
batteryAssistSolarHoldOk,
|
||||||
|
|
||||||
|
solarStartOk,
|
||||||
|
solarHoldOk,
|
||||||
|
gridStartOk,
|
||||||
|
gridHoldOk,
|
||||||
|
|
||||||
|
batteryAssistNightStartOk,
|
||||||
|
batteryAssistNightHoldOk,
|
||||||
|
|
||||||
|
probeStartOk,
|
||||||
|
probeHoldGuardOk,
|
||||||
|
probeWindowActive,
|
||||||
|
probeElapsedReached,
|
||||||
|
probeSuccessNow,
|
||||||
|
probeFailedNow,
|
||||||
|
|
||||||
|
solarShouldStepUp,
|
||||||
|
gridShouldStepUp,
|
||||||
|
batteryAssistNightShouldStepUp,
|
||||||
|
|
||||||
|
solarShouldStepDownBattery,
|
||||||
|
solarShouldStepDownCloudDebt,
|
||||||
|
gridShouldStepDownBattery,
|
||||||
|
batteryAssistNightShouldStepDown,
|
||||||
|
|
||||||
|
shouldStepDownVebusPower,
|
||||||
|
shouldStepDownVebusVoltage,
|
||||||
|
shouldStepDownOverload,
|
||||||
|
shouldStopOverloadAtMin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
223
src/nodes/BUILD_ACTIONS.js
Normal file
223
src/nodes/BUILD_ACTIONS.js
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
const root = $input.first().json || {};
|
||||||
|
const decision = root.decision || {};
|
||||||
|
const calls = root.ha_service_calls || {};
|
||||||
|
const context = root.context || {};
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
function pushAction(action, call, enabled = true) {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (!call) return;
|
||||||
|
if (call.execute === false) return;
|
||||||
|
|
||||||
|
const { execute, ...rest } = call;
|
||||||
|
|
||||||
|
actions.push({
|
||||||
|
json: {
|
||||||
|
action,
|
||||||
|
...rest
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// HELPERS TOUJOURS MIS À JOUR
|
||||||
|
// =====================================================
|
||||||
|
[
|
||||||
|
'helper_state',
|
||||||
|
'helper_current_decision',
|
||||||
|
'helper_reason',
|
||||||
|
'helper_operating_mode',
|
||||||
|
'helper_source_mode',
|
||||||
|
'helper_target_current',
|
||||||
|
'helper_budget_remaining',
|
||||||
|
'helper_projected_soc',
|
||||||
|
'helper_cloud_debt',
|
||||||
|
'helper_dynamic_ok',
|
||||||
|
'helper_must_stop',
|
||||||
|
'helper_evcs_priority_active',
|
||||||
|
'helper_energy_priority_mode',
|
||||||
|
'helper_energy_priority_reason',
|
||||||
|
'helper_evcs_priority_until',
|
||||||
|
|
||||||
|
// ===================================================
|
||||||
|
// NIGHT EVCS HELPERS
|
||||||
|
// ===================================================
|
||||||
|
'helper_night_target_soc',
|
||||||
|
'helper_night_grid_trigger_soc',
|
||||||
|
'helper_night_preferred_source_mode',
|
||||||
|
'helper_night_grid_takeover_needed',
|
||||||
|
'helper_night_battery_recovery_urgent',
|
||||||
|
'helper_night_session_active',
|
||||||
|
|
||||||
|
// ===================================================
|
||||||
|
// VEHICLE DEMAND FILTER HISTORY HELPERS
|
||||||
|
// ===================================================
|
||||||
|
'helper_evcs_delivery_ratio_ema',
|
||||||
|
'helper_evcs_delivery_noise_ema',
|
||||||
|
'helper_evcs_no_demand_score_ema',
|
||||||
|
'helper_evcs_no_demand_semantic_active',
|
||||||
|
].forEach(name => pushAction(name, calls[name], true));
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// HELPERS / TIMESTAMPS CONDITIONNELS
|
||||||
|
// =====================================================
|
||||||
|
pushAction(
|
||||||
|
'helper_cloud_debt_started_at',
|
||||||
|
calls.helper_cloud_debt_started_at,
|
||||||
|
!!decision.set_cloud_debt_started_at
|
||||||
|
);
|
||||||
|
|
||||||
|
pushAction(
|
||||||
|
'helper_block_reason',
|
||||||
|
calls.helper_block_reason,
|
||||||
|
!!decision.set_block_reason
|
||||||
|
);
|
||||||
|
|
||||||
|
pushAction(
|
||||||
|
'helper_blocked_until',
|
||||||
|
calls.helper_blocked_until,
|
||||||
|
!!decision.set_blocked_until
|
||||||
|
);
|
||||||
|
|
||||||
|
pushAction(
|
||||||
|
'helper_probe_started_at',
|
||||||
|
calls.helper_probe_started_at,
|
||||||
|
!!decision.set_probe_started_at
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// NIGHT SESSION MEMORY
|
||||||
|
// =====================================================
|
||||||
|
pushAction(
|
||||||
|
'helper_night_session_start_soc',
|
||||||
|
calls.helper_night_session_start_soc,
|
||||||
|
!!decision.set_night_session_start || !!decision.clear_night_session_start
|
||||||
|
);
|
||||||
|
|
||||||
|
pushAction(
|
||||||
|
'helper_night_session_start_at',
|
||||||
|
calls.helper_night_session_start_at,
|
||||||
|
!!decision.set_night_session_start || !!decision.clear_night_session_start
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// VEHICLE DEMAND FILTER TIMESTAMPS
|
||||||
|
// =====================================================
|
||||||
|
pushAction(
|
||||||
|
'helper_evcs_no_demand_since_at',
|
||||||
|
calls.helper_evcs_no_demand_since_at,
|
||||||
|
!!calls.helper_evcs_no_demand_since_at?.execute
|
||||||
|
);
|
||||||
|
|
||||||
|
pushAction(
|
||||||
|
'helper_evcs_no_demand_filter_eval_at',
|
||||||
|
calls.helper_evcs_no_demand_filter_eval_at,
|
||||||
|
!!calls.helper_evcs_no_demand_filter_eval_at?.execute
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// DEMANDE RÉSEAU EDF SI SOURCE GRID
|
||||||
|
// =====================================================
|
||||||
|
pushAction(
|
||||||
|
'edf_general_ok_on',
|
||||||
|
calls.edf_general_ok_on,
|
||||||
|
context.sourceMode === 'grid_hc_allowed' || context.sourceMode === 'grid_forced'
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// AUTO START OFF SI BESOIN
|
||||||
|
// =====================================================
|
||||||
|
pushAction(
|
||||||
|
'evcs_auto_start_off',
|
||||||
|
calls.evcs_auto_start_off,
|
||||||
|
decision.auto_start_action === 'turn_off'
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// SI ON ARRÊTE : CHARGING OFF D'ABORD
|
||||||
|
// =====================================================
|
||||||
|
pushAction(
|
||||||
|
'evcs_charging',
|
||||||
|
calls.evcs_charging,
|
||||||
|
decision.charging_action === 'turn_off'
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// PERMISSION / LATCH
|
||||||
|
// =====================================================
|
||||||
|
pushAction(
|
||||||
|
'helper_permission',
|
||||||
|
calls.helper_permission,
|
||||||
|
decision.permission_action !== 'none'
|
||||||
|
);
|
||||||
|
|
||||||
|
pushAction(
|
||||||
|
'helper_latch',
|
||||||
|
calls.helper_latch,
|
||||||
|
decision.latch_action !== 'none'
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// COURANT SI NÉCESSAIRE
|
||||||
|
// =====================================================
|
||||||
|
pushAction(
|
||||||
|
'evcs_set_current',
|
||||||
|
calls.evcs_set_current,
|
||||||
|
decision.current_action === 'set'
|
||||||
|
);
|
||||||
|
|
||||||
|
pushAction(
|
||||||
|
'helper_last_current_change_at',
|
||||||
|
calls.helper_last_current_change_at,
|
||||||
|
!!decision.set_last_current_change_at
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// SI ON DÉMARRE : CHARGING ON À LA FIN
|
||||||
|
// =====================================================
|
||||||
|
pushAction(
|
||||||
|
'evcs_charging',
|
||||||
|
calls.evcs_charging,
|
||||||
|
decision.charging_action === 'turn_on'
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// TIMESTAMPS START / STOP
|
||||||
|
// =====================================================
|
||||||
|
pushAction(
|
||||||
|
'helper_last_start_at',
|
||||||
|
calls.helper_last_start_at,
|
||||||
|
!!decision.set_last_start_at
|
||||||
|
);
|
||||||
|
|
||||||
|
pushAction(
|
||||||
|
'helper_last_stop_at',
|
||||||
|
calls.helper_last_stop_at,
|
||||||
|
!!decision.set_last_stop_at
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// SIGNATURE + LOGBOOK SI CHANGEMENT
|
||||||
|
// =====================================================
|
||||||
|
pushAction(
|
||||||
|
'helper_signature',
|
||||||
|
calls.helper_signature,
|
||||||
|
!!decision.signature_changed
|
||||||
|
);
|
||||||
|
|
||||||
|
pushAction(
|
||||||
|
'logbook',
|
||||||
|
calls.logbook,
|
||||||
|
!!decision.signature_changed
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// NOTIFICATIONS
|
||||||
|
// =====================================================
|
||||||
|
pushAction(
|
||||||
|
'mobile_notification',
|
||||||
|
calls.mobile_notification,
|
||||||
|
!!decision.should_notify
|
||||||
|
);
|
||||||
|
|
||||||
|
return actions;
|
||||||
1291
src/nodes/DECISION_ENGINEV2.js
Normal file
1291
src/nodes/DECISION_ENGINEV2.js
Normal file
File diff suppressed because it is too large
Load Diff
1060
src/nodes/HA_SERVICE_CALLS_OUTPUT.js
Normal file
1060
src/nodes/HA_SERVICE_CALLS_OUTPUT.js
Normal file
File diff suppressed because it is too large
Load Diff
212
src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl
Normal file
212
src/nodes/HA_SNAPSHOT_TEMPLATE.json.tpl
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
{{ {
|
||||||
|
"soc_percent": states("sensor.victron_battery_soc"),
|
||||||
|
"battery_current_a": states("sensor.victron_battery_current"),
|
||||||
|
|
||||||
|
"pv_power_w": states("sensor.victron_pv_power_system"),
|
||||||
|
"pv_power_10m_avg_w": states("sensor.victron_pv_power_10m_avg"),
|
||||||
|
"pv_trend_w": states("sensor.victron_pv_power_trend_w"),
|
||||||
|
|
||||||
|
"house_ac_power_w": states("sensor.victron_consumption_power_l1"),
|
||||||
|
"house_ac_power_prev_w": states("sensor.victron_consumption_power_l1"),
|
||||||
|
|
||||||
|
"grid_power_w": states("sensor.victron_grid_power_l1"),
|
||||||
|
"grid_state": states("switch.edf_general_ok_switch"),
|
||||||
|
"victron_acin1_available": states("sensor.victron_ac_in1_available"),
|
||||||
|
|
||||||
|
"today_remaining_kwh": states("sensor.energy_production_today_remaining"),
|
||||||
|
"tomorrow_kwh": states("sensor.energy_production_tomorrow"),
|
||||||
|
|
||||||
|
"evcs_device_connected": states("binary_sensor.victron_evcs_device_connected"),
|
||||||
|
"evcs_vehicle_connected": states("binary_sensor.victron_evcs_vehicle_connected"),
|
||||||
|
"evcs_status_raw": states("sensor.victron_evcs_status_raw"),
|
||||||
|
"evcs_mode_sensor": states("sensor.victron_evcs_mode"),
|
||||||
|
"evcs_mode_fallback": states("input_text.sensor_victron_evcs_mode"),
|
||||||
|
"evcs_set_current_a": states("sensor.victron_evcs_set_current"),
|
||||||
|
"evcs_command_current_a": states("number.victron_evcs_set_current_command"),
|
||||||
|
"evcs_current_a": states("sensor.victron_evcs_current"),
|
||||||
|
"evcs_ac_power_w": states("sensor.victron_evcs_ac_power"),
|
||||||
|
"evcs_charging_switch": states("switch.victron_evcs_charging"),
|
||||||
|
"evcs_auto_start_switch": states("switch.victron_evcs_auto_start"),
|
||||||
|
|
||||||
|
"controller_enabled": states("input_boolean.victron_evcs_surplus_auto_enabled"),
|
||||||
|
"notifications_enabled": states("input_boolean.victron_evcs_surplus_notifications"),
|
||||||
|
"hard_stop_enabled": states("input_boolean.victron_evcs_hard_stop_enabled"),
|
||||||
|
"charge_permission_now": states("input_boolean.victron_evcs_charge_permission_now"),
|
||||||
|
"control_latch": states("input_boolean.victron_evcs_control_latch"),
|
||||||
|
"departure_priority_enabled": states("input_boolean.victron_evcs_departure_priority_enabled"),
|
||||||
|
"forecast_mode_enabled": states("input_boolean.victron_evcs_forecast_mode_enabled"),
|
||||||
|
|
||||||
|
"target_current_auto_a": states("input_number.victron_evcs_target_current_auto"),
|
||||||
|
"budget_remaining_kwh": states("input_number.victron_evcs_budget_remaining_kwh"),
|
||||||
|
"projected_end_soc_after_charge": states("input_number.victron_evcs_projected_end_soc_after_charge"),
|
||||||
|
|
||||||
|
"pv_start_min_w": states("input_number.victron_evcs_pv_start_min_w"),
|
||||||
|
"pv_hold_min_w": states("input_number.victron_evcs_pv_hold_min_w"),
|
||||||
|
"house_ac_headroom_w": states("input_number.victron_evcs_house_ac_headroom_w"),
|
||||||
|
"battery_discharge_soft_a": states("input_number.victron_evcs_battery_discharge_soft_a"),
|
||||||
|
"battery_discharge_hard_a": states("input_number.victron_evcs_battery_discharge_hard_a"),
|
||||||
|
"battery_reserve_up_a": states("input_number.victron_evcs_battery_reserve_up_a"),
|
||||||
|
"battery_neutral_band_a": states("input_number.victron_evcs_battery_neutral_band_a"),
|
||||||
|
"soc_start_min": states("input_number.victron_evcs_soc_start_min"),
|
||||||
|
"soc_stop_min": states("input_number.victron_evcs_soc_stop_min"),
|
||||||
|
"current_min_a": states("input_number.victron_evcs_current_min_a"),
|
||||||
|
"current_max_auto_a": states("input_number.victron_evcs_current_max_auto_a"),
|
||||||
|
"grid_current_max_a": states("input_number.evcs_plafond_courant_edf_on"),
|
||||||
|
"current_step_up_a": states("input_number.victron_evcs_current_step_up_a"),
|
||||||
|
"current_step_down_a": states("input_number.victron_evcs_current_step_down_a"),
|
||||||
|
"current_change_cooldown_seconds": states("input_number.victron_evcs_current_change_cooldown_seconds"),
|
||||||
|
"min_run_minutes": states("input_number.victron_evcs_min_run_minutes"),
|
||||||
|
"min_stop_minutes": states("input_number.victron_evcs_min_stop_minutes"),
|
||||||
|
|
||||||
|
"required_energy_kwh": states("input_number.victron_evcs_required_energy_kwh"),
|
||||||
|
"soc_comfort_target": states("input_number.victron_evcs_soc_comfort_target"),
|
||||||
|
"end_of_day_soc_guard": states("input_number.victron_evcs_end_of_day_soc_guard"),
|
||||||
|
|
||||||
|
"base_load_remaining_kwh": states("input_number.victron_base_load_remaining_kwh"),
|
||||||
|
"flexible_safety_margin_kwh": states("input_number.victron_flexible_safety_margin_kwh"),
|
||||||
|
"end_of_day_soc_target": states("input_number.victron_end_of_day_soc_target"),
|
||||||
|
"ac_loads_max_safe_w": states("input_number.victron_ac_loads_max_safe_w"),
|
||||||
|
|
||||||
|
"battery_full_soc": states("input_number.victron_evcs_battery_full_soc"),
|
||||||
|
"battery_full_forecast_min_kwh": states("input_number.victron_evcs_battery_full_forecast_min_kwh"),
|
||||||
|
"forecast_commit_soc_floor": states("input_number.victron_evcs_forecast_commit_soc_floor"),
|
||||||
|
"forecast_commit_min_kwh": states("input_number.victron_evcs_forecast_commit_min_kwh"),
|
||||||
|
|
||||||
|
"cloud_debt_kwh": states("input_number.victron_evcs_cloud_debt_kwh"),
|
||||||
|
"cloud_debt_max_kwh": states("input_number.victron_evcs_cloud_debt_max_kwh"),
|
||||||
|
"cloud_tolerance_minutes": states("input_number.victron_evcs_cloud_tolerance_minutes"),
|
||||||
|
"cloud_debt_started_at": states("input_datetime.victron_evcs_cloud_debt_started_at"),
|
||||||
|
"operating_mode_helper": states("input_text.victron_evcs_operating_mode"),
|
||||||
|
|
||||||
|
"departure_time": states("input_datetime.victron_evcs_departure_time"),
|
||||||
|
"last_start_at": states("input_datetime.victron_evcs_last_start_at"),
|
||||||
|
"last_stop_at": states("input_datetime.victron_evcs_last_stop_at"),
|
||||||
|
"last_current_change_at": states("input_datetime.victron_evcs_last_current_change_at"),
|
||||||
|
|
||||||
|
"day_start_time": states("input_datetime.victron_flexible_day_start_time"),
|
||||||
|
"day_stop_time": states("input_datetime.victron_flexible_day_stop_time"),
|
||||||
|
|
||||||
|
"vebus_overload": states("binary_sensor.victron_ve_bus_overload"),
|
||||||
|
"vebus_l1_overload": states("binary_sensor.victron_ve_bus_l1_overload"),
|
||||||
|
"vebus_low_battery": states("binary_sensor.victron_ve_bus_low_battery"),
|
||||||
|
"vebus_l1_low_battery": states("binary_sensor.victron_ve_bus_l1_low_battery"),
|
||||||
|
|
||||||
|
"vebus_ac_out_l1_power_w": states("sensor.victron_ve_bus_ac_out_l1_power"),
|
||||||
|
"vebus_ac_out_l1_current_a": states("sensor.victron_ve_bus_ac_out_l1_current"),
|
||||||
|
"vebus_ac_out_l1_voltage_v": states("sensor.victron_ve_bus_ac_out_l1_voltage"),
|
||||||
|
|
||||||
|
"vebus_dc_voltage_v": states("sensor.victron_ve_bus_dc_voltage"),
|
||||||
|
"vebus_dc_current_a": states("sensor.victron_ve_bus_dc_current"),
|
||||||
|
"vebus_dc_power_w": states("sensor.victron_ve_bus_dc_power_system"),
|
||||||
|
|
||||||
|
"vebus_state": states("sensor.victron_ve_bus_state"),
|
||||||
|
"vebus_main_state": states("sensor.victron_ve_bus_main_state"),
|
||||||
|
|
||||||
|
"last_signature": states("input_text.victron_evcs_control_last_signature"),
|
||||||
|
|
||||||
|
"hc_night_start_time": states("input_datetime.victron_evcs_hc_night_start"),
|
||||||
|
"hc_night_stop_time": states("input_datetime.victron_evcs_hc_night_stop"),
|
||||||
|
"morning_start_time": states("input_datetime.victron_evcs_morning_start"),
|
||||||
|
"morning_stop_time": states("input_datetime.victron_evcs_morning_stop"),
|
||||||
|
"hc_midday_start_time": states("input_datetime.victron_evcs_hc_midday_start"),
|
||||||
|
"hc_midday_stop_time": states("input_datetime.victron_evcs_hc_midday_stop"),
|
||||||
|
"evening_start_time": states("input_datetime.victron_evcs_evening_start"),
|
||||||
|
"evening_stop_time": states("input_datetime.victron_evcs_evening_stop"),
|
||||||
|
|
||||||
|
"allow_grid_hc_night": states("input_boolean.victron_evcs_allow_grid_hc_night"),
|
||||||
|
"night_takeover_prev": states("input_boolean.victron_evcs_night_grid_takeover_needed"),
|
||||||
|
"prev_surplus_state": states("input_text.victron_evcs_surplus_state"),
|
||||||
|
"allow_grid_hc_midday": states("input_boolean.victron_evcs_allow_grid_hc_midday"),
|
||||||
|
"probe_enabled": states("input_boolean.victron_evcs_probe_enabled"),
|
||||||
|
|
||||||
|
"probe_minutes": states("input_number.victron_evcs_probe_minutes"),
|
||||||
|
"probe_retry_minutes": states("input_number.victron_evcs_probe_retry_minutes"),
|
||||||
|
"probe_current_a": states("input_number.victron_evcs_probe_current_a"),
|
||||||
|
|
||||||
|
"grid_battery_soft_a": states("input_number.victron_evcs_grid_battery_soft_a"),
|
||||||
|
"grid_battery_hard_a": states("input_number.victron_evcs_grid_battery_hard_a"),
|
||||||
|
|
||||||
|
"morning_probe_pv_avg_min_w": states("input_number.victron_evcs_morning_probe_pv_avg_min_w"),
|
||||||
|
"morning_probe_pv_trend_min_w": states("input_number.victron_evcs_morning_probe_pv_trend_min_w"),
|
||||||
|
"midday_probe_pv_avg_min_w": states("input_number.victron_evcs_midday_probe_pv_avg_min_w"),
|
||||||
|
"afternoon_probe_pv_avg_min_w": states("input_number.victron_evcs_afternoon_probe_pv_avg_min_w"),
|
||||||
|
"afternoon_probe_soc_min": states("input_number.victron_evcs_afternoon_probe_soc_min"),
|
||||||
|
"afternoon_battery_max_discharge_a": states("input_number.victron_evcs_afternoon_battery_max_discharge_a"),
|
||||||
|
"morning_soc_min": states("input_number.victron_evcs_morning_soc_min"),
|
||||||
|
"midday_soc_min": states("input_number.victron_evcs_midday_soc_min"),
|
||||||
|
"evening_soc_start_min": states("input_number.victron_evcs_evening_soc_start_min"),
|
||||||
|
"evening_soc_stop_min": states("input_number.victron_evcs_evening_soc_stop_min"),
|
||||||
|
|
||||||
|
"morning_battery_max_discharge_a": states("input_number.victron_evcs_morning_battery_max_discharge_a"),
|
||||||
|
"midday_battery_max_discharge_a": states("input_number.victron_evcs_midday_battery_max_discharge_a"),
|
||||||
|
"evening_battery_max_discharge_a": states("input_number.victron_evcs_evening_battery_max_discharge_a"),
|
||||||
|
|
||||||
|
"last_block_reason": states("input_text.victron_evcs_last_block_reason"),
|
||||||
|
"blocked_until": states("input_datetime.victron_evcs_blocked_until"),
|
||||||
|
"probe_started_at": states("input_datetime.victron_evcs_probe_started_at"),
|
||||||
|
|
||||||
|
"victron_evcs_strategy": states("input_select.victron_evcs_strategy"),
|
||||||
|
"evcs_priority_manual_override": states("input_boolean.victron_evcs_priority_manual_override"),
|
||||||
|
"evcs_priority_manual_mode": states("input_select.victron_evcs_priority_manual_mode"),
|
||||||
|
"evcs_priority_manual_reason": states("input_text.victron_evcs_priority_manual_reason"),
|
||||||
|
"evcs_priority_manual_until": states("input_datetime.victron_evcs_priority_manual_until"),
|
||||||
|
|
||||||
|
"house_spike_grace_minutes": states("input_number.victron_evcs_house_spike_grace_minutes"),
|
||||||
|
"solar_potential_grace_minutes": states("input_number.victron_evcs_solar_potential_grace_minutes"),
|
||||||
|
"step_up_cooldown_seconds": states("input_number.victron_evcs_step_up_cooldown_seconds"),
|
||||||
|
"step_down_cooldown_seconds": states("input_number.victron_evcs_step_down_cooldown_seconds"),
|
||||||
|
"force_mode_auto_revert_minutes": states("input_number.victron_evcs_force_mode_auto_revert_minutes"),
|
||||||
|
|
||||||
|
"overload_step_down_a": states("input_number.victron_evcs_overload_step_down_a"),
|
||||||
|
"overload_hold_seconds": states("input_number.victron_evcs_overload_hold_seconds"),
|
||||||
|
"overload_stop_after_seconds": states("input_number.victron_evcs_overload_stop_after_seconds"),
|
||||||
|
|
||||||
|
"batt_discharge_soft_gt90_a": states("input_number.victron_evcs_batt_discharge_soft_gt90_a"),
|
||||||
|
"batt_discharge_hard_gt90_a": states("input_number.victron_evcs_batt_discharge_hard_gt90_a"),
|
||||||
|
"batt_charge_reserve_gt90_a": states("input_number.victron_evcs_batt_charge_reserve_gt90_a"),
|
||||||
|
"batt_soft_tol_gt90_min": states("input_number.victron_evcs_batt_soft_tol_gt90_min"),
|
||||||
|
"batt_hard_tol_gt90_sec": states("input_number.victron_evcs_batt_hard_tol_gt90_sec"),
|
||||||
|
"batt_recovery_gt90_min": states("input_number.victron_evcs_batt_recovery_gt90_min"),
|
||||||
|
|
||||||
|
"batt_discharge_soft_80_90_a": states("input_number.victron_evcs_batt_discharge_soft_80_90_a"),
|
||||||
|
"batt_discharge_hard_80_90_a": states("input_number.victron_evcs_batt_discharge_hard_80_90_a"),
|
||||||
|
"batt_charge_reserve_80_90_a": states("input_number.victron_evcs_batt_charge_reserve_80_90_a"),
|
||||||
|
"batt_soft_tol_80_90_min": states("input_number.victron_evcs_batt_soft_tol_80_90_min"),
|
||||||
|
"batt_hard_tol_80_90_sec": states("input_number.victron_evcs_batt_hard_tol_80_90_sec"),
|
||||||
|
"batt_recovery_80_90_min": states("input_number.victron_evcs_batt_recovery_80_90_min"),
|
||||||
|
|
||||||
|
"batt_discharge_soft_60_80_a": states("input_number.victron_evcs_batt_discharge_soft_60_80_a"),
|
||||||
|
"batt_discharge_hard_60_80_a": states("input_number.victron_evcs_batt_discharge_hard_60_80_a"),
|
||||||
|
"batt_charge_reserve_60_80_a": states("input_number.victron_evcs_batt_charge_reserve_60_80_a"),
|
||||||
|
"batt_soft_tol_60_80_min": states("input_number.victron_evcs_batt_soft_tol_60_80_min"),
|
||||||
|
"batt_hard_tol_60_80_sec": states("input_number.victron_evcs_batt_hard_tol_60_80_sec"),
|
||||||
|
"batt_recovery_60_80_min": states("input_number.victron_evcs_batt_recovery_60_80_min"),
|
||||||
|
|
||||||
|
"batt_discharge_soft_40_60_a": states("input_number.victron_evcs_batt_discharge_soft_40_60_a"),
|
||||||
|
"batt_discharge_hard_40_60_a": states("input_number.victron_evcs_batt_discharge_hard_40_60_a"),
|
||||||
|
"batt_charge_reserve_40_60_a": states("input_number.victron_evcs_batt_charge_reserve_40_60_a"),
|
||||||
|
"batt_soft_tol_40_60_min": states("input_number.victron_evcs_batt_soft_tol_40_60_min"),
|
||||||
|
"batt_hard_tol_40_60_sec": states("input_number.victron_evcs_batt_hard_tol_40_60_sec"),
|
||||||
|
"batt_recovery_40_60_min": states("input_number.victron_evcs_batt_recovery_40_60_min"),
|
||||||
|
|
||||||
|
"batt_discharge_soft_25_40_a": states("input_number.victron_evcs_batt_discharge_soft_25_40_a"),
|
||||||
|
"batt_discharge_hard_25_40_a": states("input_number.victron_evcs_batt_discharge_hard_25_40_a"),
|
||||||
|
"batt_charge_reserve_25_40_a": states("input_number.victron_evcs_batt_charge_reserve_25_40_a"),
|
||||||
|
"batt_soft_tol_25_40_min": states("input_number.victron_evcs_batt_soft_tol_25_40_min"),
|
||||||
|
"batt_hard_tol_25_40_sec": states("input_number.victron_evcs_batt_hard_tol_25_40_sec"),
|
||||||
|
"batt_recovery_25_40_min": states("input_number.victron_evcs_batt_recovery_25_40_min"),
|
||||||
|
|
||||||
|
"batt_discharge_soft_lt25_a": states("input_number.victron_evcs_batt_discharge_soft_lt25_a"),
|
||||||
|
"batt_discharge_hard_lt25_a": states("input_number.victron_evcs_batt_discharge_hard_lt25_a"),
|
||||||
|
"batt_charge_reserve_lt25_a": states("input_number.victron_evcs_batt_charge_reserve_lt25_a"),
|
||||||
|
"batt_soft_tol_lt25_min": states("input_number.victron_evcs_batt_soft_tol_lt25_min"),
|
||||||
|
"batt_hard_tol_lt25_sec": states("input_number.victron_evcs_batt_hard_tol_lt25_sec"),
|
||||||
|
"batt_recovery_lt25_min": states("input_number.victron_evcs_batt_recovery_lt25_min"),
|
||||||
|
|
||||||
|
"evcs_delivery_ratio_ema": states("input_number.victron_evcs_delivery_ratio_ema"),
|
||||||
|
"evcs_delivery_noise_ema": states("input_number.victron_evcs_delivery_noise_ema"),
|
||||||
|
"evcs_no_demand_score_ema": states("input_number.victron_evcs_no_demand_score_ema"),
|
||||||
|
"evcs_no_demand_semantic_active": states("input_boolean.victron_evcs_no_demand_semantic_active"),
|
||||||
|
"evcs_no_demand_since_at": states("input_datetime.victron_evcs_no_demand_since_at"),
|
||||||
|
"evcs_no_demand_filter_eval_at": states("input_datetime.victron_evcs_no_demand_filter_eval_at")
|
||||||
|
} | tojson }}
|
||||||
590
src/nodes/INPUT_PARSE.js
Normal file
590
src/nodes/INPUT_PARSE.js
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
function num(v, fallback = NaN) {
|
||||||
|
const n = Number.parseFloat(v);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function low(v) {
|
||||||
|
return (v ?? '').toString().trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v, min, max) {
|
||||||
|
return Math.min(Math.max(v, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(v) {
|
||||||
|
return Math.round(v * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(v) {
|
||||||
|
return Math.round(v * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function to2(n) {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMaybeJson(v) {
|
||||||
|
if (typeof v !== 'string') return v;
|
||||||
|
try {
|
||||||
|
return JSON.parse(v);
|
||||||
|
} catch {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSnapshot(input) {
|
||||||
|
if (typeof input === 'string') return parseMaybeJson(input);
|
||||||
|
if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);
|
||||||
|
if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);
|
||||||
|
if (input?.data && typeof input.data === 'object') return input.data;
|
||||||
|
if (input?.body && typeof input.body === 'object') return input.body;
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeToMinutes(v, fallback = null) {
|
||||||
|
const s = (v ?? '').toString().trim();
|
||||||
|
const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
||||||
|
if (!m) return fallback;
|
||||||
|
|
||||||
|
const hh = Number(m[1]);
|
||||||
|
const mm = Number(m[2]);
|
||||||
|
|
||||||
|
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback;
|
||||||
|
return (hh % 24) * 60 + mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: Number(parts.year),
|
||||||
|
month: Number(parts.month),
|
||||||
|
day: Number(parts.day),
|
||||||
|
hour,
|
||||||
|
minute: Number(parts.minute),
|
||||||
|
second: Number(parts.second),
|
||||||
|
date,
|
||||||
|
stamp,
|
||||||
|
minNow: hour * 60 + Number(parts.minute),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;
|
||||||
|
|
||||||
|
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 cleanText(v, fallback = '') {
|
||||||
|
const s = (v ?? '').toString().trim();
|
||||||
|
const x = s.toLowerCase();
|
||||||
|
if (['unknown', 'unavailable', 'none', 'null', 'undefined'].includes(x)) return fallback;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteOrNull(v) {
|
||||||
|
return Number.isFinite(v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = $input.first().json;
|
||||||
|
const input = extractSnapshot(raw);
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const now = getParisParts(nowMs);
|
||||||
|
|
||||||
|
const CFG = {
|
||||||
|
mobileNotifyDomain: 'notify',
|
||||||
|
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
|
||||||
|
|
||||||
|
batteryUsableCapacityKwh: 17.5,
|
||||||
|
nominalAcVoltageV: 230,
|
||||||
|
evcsDedicatedMaxW: 4900,
|
||||||
|
systemReserveW: 300,
|
||||||
|
|
||||||
|
vebusDcVoltageSoftFloorV: 49.0,
|
||||||
|
vebusDcVoltageHardFloorV: 48.6,
|
||||||
|
|
||||||
|
departurePriorityWindowMinutes: 240,
|
||||||
|
sampleSeconds: 30,
|
||||||
|
|
||||||
|
cloudDebtRecoveryThresholdKwh: 0.05,
|
||||||
|
cloudDebtMeaningfulThresholdKwh: 0.01,
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// PARSED SNAPSHOT
|
||||||
|
// =====================================================
|
||||||
|
const parsed = {
|
||||||
|
soc: clamp(num(input.soc_percent, 0), 0, 100),
|
||||||
|
batteryCurrentA: num(input.battery_current_a, 0),
|
||||||
|
|
||||||
|
pvPowerW: Math.max(0, num(input.pv_power_w, 0)),
|
||||||
|
pvPowerAvg10mW: Math.max(0, num(input.pv_power_10m_avg_w, num(input.pv_power_w, 0))),
|
||||||
|
pvTrendW: num(input.pv_trend_w, 0),
|
||||||
|
|
||||||
|
houseAcPowerW: Math.max(0, num(input.house_ac_power_w, 0)),
|
||||||
|
houseAcPowerPrevW: Math.max(0, num(input.house_ac_power_prev_w, num(input.house_ac_power_w, 0))),
|
||||||
|
gridPowerW: num(input.grid_power_w, 0),
|
||||||
|
|
||||||
|
gridIsOn: low(input.grid_state || 'off') === 'on',
|
||||||
|
acInAvailable: num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1,
|
||||||
|
gridAvailable:
|
||||||
|
(low(input.grid_state || 'off') === 'on') ||
|
||||||
|
(num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),
|
||||||
|
|
||||||
|
todayRemainingKwh: Math.max(0, num(input.today_remaining_kwh, 0)),
|
||||||
|
tomorrowKwh: Math.max(0, num(input.tomorrow_kwh, 0)),
|
||||||
|
|
||||||
|
evcsDeviceConnected: low(input.evcs_device_connected || 'off') === 'on',
|
||||||
|
evcsVehicleConnected: low(input.evcs_vehicle_connected || 'off') === 'on',
|
||||||
|
evcsStatusRaw: cleanText(input.evcs_status_raw),
|
||||||
|
evcsModeRaw: [
|
||||||
|
input.evcs_mode_sensor,
|
||||||
|
input.evcs_mode_fallback,
|
||||||
|
input.evcs_mode
|
||||||
|
]
|
||||||
|
.map(v => cleanText(v))
|
||||||
|
.find(v => v) || 'unknown',
|
||||||
|
|
||||||
|
evcsSetCurrentA: num(input.evcs_set_current_a, NaN),
|
||||||
|
evcsCommandCurrentA: num(input.evcs_command_current_a, NaN),
|
||||||
|
|
||||||
|
// Mesure réelle côté borne
|
||||||
|
evcsCurrentA: Math.max(0, num(input.evcs_current_a, 0)),
|
||||||
|
evcsCurrentRealA: Math.max(0, num(input.evcs_current_a, 0)),
|
||||||
|
|
||||||
|
evcsAcPowerW: Math.max(0, num(input.evcs_ac_power_w, 0)),
|
||||||
|
evcsChargingSwitchOn: low(input.evcs_charging_switch || 'off') === 'on',
|
||||||
|
evcsAutoStartSwitchOn: low(input.evcs_auto_start_switch || 'off') === 'on',
|
||||||
|
|
||||||
|
controllerEnabled: low(input.controller_enabled || 'off') === 'on',
|
||||||
|
notificationsEnabled: low(input.notifications_enabled || 'off') === 'on',
|
||||||
|
hardStopEnabledLegacy: low(input.hard_stop_enabled || 'off') === 'on',
|
||||||
|
chargePermissionNow: low(input.charge_permission_now || 'off') === 'on',
|
||||||
|
controlLatch: low(input.control_latch || 'off') === 'on',
|
||||||
|
departurePriorityEnabled: low(input.departure_priority_enabled || 'off') === 'on',
|
||||||
|
forecastModeEnabled: low(input.forecast_mode_enabled || 'off') === 'on',
|
||||||
|
|
||||||
|
evcsPriorityManualOverride: low(input.evcs_priority_manual_override || 'off') === 'on',
|
||||||
|
evcsPriorityManualMode: cleanText(input.evcs_priority_manual_mode || 'normal', 'normal').toLowerCase(),
|
||||||
|
evcsPriorityManualReason: cleanText(input.evcs_priority_manual_reason),
|
||||||
|
|
||||||
|
vebusOverload: low(input.vebus_overload || 'off') === 'on',
|
||||||
|
vebusL1Overload: low(input.vebus_l1_overload || 'off') === 'on',
|
||||||
|
vebusLowBattery: low(input.vebus_low_battery || 'off') === 'on',
|
||||||
|
vebusL1LowBattery: low(input.vebus_l1_low_battery || 'off') === 'on',
|
||||||
|
|
||||||
|
vebusAcOutPowerW: Math.max(0, num(input.vebus_ac_out_l1_power_w, 0)),
|
||||||
|
vebusAcOutCurrentA: Math.max(0, num(input.vebus_ac_out_l1_current_a, 0)),
|
||||||
|
vebusAcOutVoltageV: num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV),
|
||||||
|
|
||||||
|
vebusDcVoltageV: num(input.vebus_dc_voltage_v, NaN),
|
||||||
|
vebusDcCurrentA: num(input.vebus_dc_current_a, NaN),
|
||||||
|
vebusDcPowerW: num(input.vebus_dc_power_w, NaN),
|
||||||
|
|
||||||
|
vebusState: num(input.vebus_state, NaN),
|
||||||
|
vebusMainState: num(input.vebus_main_state, NaN),
|
||||||
|
};
|
||||||
|
|
||||||
|
parsed.evcsModeIsAuto = low(parsed.evcsModeRaw).includes('auto');
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// PARAMETERS
|
||||||
|
// =====================================================
|
||||||
|
const params = {
|
||||||
|
userStrategyRaw: input.victron_evcs_strategy || 'auto_solar',
|
||||||
|
|
||||||
|
targetCurrentAutoA: clamp(num(input.target_current_auto_a, 6), 0, 32),
|
||||||
|
pvStartMinW: Math.max(0, num(input.pv_start_min_w, 1800)),
|
||||||
|
pvHoldMinW: Math.max(0, num(input.pv_hold_min_w, 1200)),
|
||||||
|
houseAcHeadroomW: Math.max(0, num(input.house_ac_headroom_w, 1200)),
|
||||||
|
|
||||||
|
batteryDischargeSoftA: num(input.battery_discharge_soft_a, -5),
|
||||||
|
batteryDischargeHardA: num(input.battery_discharge_hard_a, -15),
|
||||||
|
batteryReserveUpA: Math.max(0, num(input.battery_reserve_up_a, 8)),
|
||||||
|
batteryNeutralBandA: Math.max(0, num(input.battery_neutral_band_a, 3)),
|
||||||
|
|
||||||
|
socStartMin: clamp(num(input.soc_start_min, 60), 0, 100),
|
||||||
|
socStopMin: clamp(num(input.soc_stop_min, 35), 0, 100),
|
||||||
|
|
||||||
|
currentMinA: clamp(num(input.current_min_a, 6), 6, 32),
|
||||||
|
currentMaxAutoA: clamp(
|
||||||
|
num(input.current_max_auto_a, 16),
|
||||||
|
clamp(num(input.current_min_a, 6), 6, 32),
|
||||||
|
32
|
||||||
|
),
|
||||||
|
gridCurrentMaxA: clamp(num(input.grid_current_max_a, 20), 6, 32),
|
||||||
|
currentStepUpA: clamp(num(input.current_step_up_a, 1), 1, 5),
|
||||||
|
currentStepDownA: clamp(num(input.current_step_down_a, 2), 1, 8),
|
||||||
|
|
||||||
|
currentChangeCooldownSeconds: Math.max(5, num(input.current_change_cooldown_seconds, 60)),
|
||||||
|
minRunMinutes: Math.max(0, num(input.min_run_minutes, 3)),
|
||||||
|
minStopMinutes: Math.max(0, num(input.min_stop_minutes, 10)),
|
||||||
|
|
||||||
|
requiredEnergyKwh: Math.max(0, num(input.required_energy_kwh, 0)),
|
||||||
|
socComfortTarget: clamp(num(input.soc_comfort_target, 85), 0, 100),
|
||||||
|
endOfDaySocGuard: clamp(num(input.end_of_day_soc_guard, 85), 0, 100),
|
||||||
|
baseLoadRemainingKwh: Math.max(0, num(input.base_load_remaining_kwh, 0)),
|
||||||
|
flexibleSafetyMarginKwh: Math.max(0, num(input.flexible_safety_margin_kwh, 0)),
|
||||||
|
endOfDaySocTarget: clamp(num(input.end_of_day_soc_target, 85), 0, 100),
|
||||||
|
|
||||||
|
acLoadsMaxSafeW: Math.max(0, num(input.ac_loads_max_safe_w, 8500)),
|
||||||
|
batteryFullSoc: clamp(num(input.battery_full_soc, 90), 0, 100),
|
||||||
|
batteryFullForecastMinKwh: Math.max(0, num(input.battery_full_forecast_min_kwh, 8)),
|
||||||
|
forecastCommitSocFloor: clamp(num(input.forecast_commit_soc_floor, 35), 0, 100),
|
||||||
|
forecastCommitMinKwh: Math.max(0, num(input.forecast_commit_min_kwh, 25)),
|
||||||
|
|
||||||
|
cloudDebtPrevKwh: Math.max(0, num(input.cloud_debt_kwh, 0)),
|
||||||
|
cloudDebtMaxKwh: Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2)),
|
||||||
|
cloudToleranceMinutes: Math.max(1, num(input.cloud_tolerance_minutes, 20)),
|
||||||
|
|
||||||
|
departureTimeMin: parseTimeToMinutes(input.departure_time, null),
|
||||||
|
dayStartTimeMin: parseTimeToMinutes(input.day_start_time, 7 * 60 + 30),
|
||||||
|
dayStopTimeMin: parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30),
|
||||||
|
|
||||||
|
hcNightStartMin: parseTimeToMinutes(input.hc_night_start_time, 23 * 60),
|
||||||
|
hcNightStopMin: parseTimeToMinutes(input.hc_night_stop_time, 6 * 60),
|
||||||
|
morningStartMin: parseTimeToMinutes(
|
||||||
|
input.morning_start_time,
|
||||||
|
parseTimeToMinutes(input.day_start_time, 7 * 60 + 30)
|
||||||
|
),
|
||||||
|
morningStopMin: parseTimeToMinutes(input.morning_stop_time, 11 * 60),
|
||||||
|
hcMiddayStartMin: parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 21),
|
||||||
|
hcMiddayStopMin: parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 21),
|
||||||
|
eveningStartMin: parseTimeToMinutes(input.evening_start_time, 17 * 60),
|
||||||
|
eveningStopMin: parseTimeToMinutes(
|
||||||
|
input.evening_stop_time,
|
||||||
|
parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30)
|
||||||
|
),
|
||||||
|
|
||||||
|
allowGridHcNight: low(input.allow_grid_hc_night || 'off') === 'on',
|
||||||
|
allowGridHcMidday: low(input.allow_grid_hc_midday || 'off') === 'on',
|
||||||
|
|
||||||
|
probeEnabled: low(input.probe_enabled || 'off') === 'on',
|
||||||
|
probeMinutes: Math.max(1, num(input.probe_minutes, 3)),
|
||||||
|
probeRetryMinutes: Math.max(5, num(input.probe_retry_minutes, 45)),
|
||||||
|
probeCurrentA: clamp(
|
||||||
|
num(input.probe_current_a, clamp(num(input.current_min_a, 10), 6, 32)),
|
||||||
|
clamp(num(input.current_min_a, 10), 6, 32),
|
||||||
|
clamp(num(input.current_max_auto_a, 16), clamp(num(input.current_min_a, 10), 6, 32), 32)
|
||||||
|
),
|
||||||
|
|
||||||
|
gridBatterySoftA: num(input.grid_battery_soft_a, -3),
|
||||||
|
gridBatteryHardA: num(input.grid_battery_hard_a, -6),
|
||||||
|
|
||||||
|
morningProbePvAvgMinW: Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500)),
|
||||||
|
morningProbePvTrendMinW: num(input.morning_probe_pv_trend_min_w, 0),
|
||||||
|
middayProbePvAvgMinW: Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200)),
|
||||||
|
|
||||||
|
morningSocMin: clamp(num(input.morning_soc_min, 35), 0, 100),
|
||||||
|
middaySocMin: clamp(num(input.midday_soc_min, 75), 0, 100),
|
||||||
|
eveningSocStartMin: clamp(num(input.evening_soc_start_min, 85), 0, 100),
|
||||||
|
eveningSocStopMin: clamp(num(input.evening_soc_stop_min, 80), 0, 100),
|
||||||
|
|
||||||
|
morningBatteryMaxDischargeA: num(input.morning_battery_max_discharge_a, -20),
|
||||||
|
middayBatteryMaxDischargeA: num(input.midday_battery_max_discharge_a, -40),
|
||||||
|
eveningBatteryMaxDischargeA: num(input.evening_battery_max_discharge_a, -30),
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// EVCS NIGHT ENGINE — NEW PARAMETERS
|
||||||
|
// =====================================================
|
||||||
|
evcsNightBatteryHardFloorPct: clamp(
|
||||||
|
num(input.evcs_night_battery_hard_floor_pct, 22),
|
||||||
|
0,
|
||||||
|
100
|
||||||
|
),
|
||||||
|
|
||||||
|
evcsNightStrongForecastKwh: Math.max(
|
||||||
|
0,
|
||||||
|
num(input.evcs_night_strong_forecast_kwh, 18)
|
||||||
|
),
|
||||||
|
|
||||||
|
evcsNightMediumForecastKwh: Math.max(
|
||||||
|
0,
|
||||||
|
num(input.evcs_night_medium_forecast_kwh, 10)
|
||||||
|
),
|
||||||
|
|
||||||
|
evcsNightTargetStrongForecastPct: clamp(
|
||||||
|
num(input.evcs_night_target_strong_forecast_pct, 35),
|
||||||
|
0,
|
||||||
|
100
|
||||||
|
),
|
||||||
|
|
||||||
|
evcsNightTargetMediumForecastPct: clamp(
|
||||||
|
num(input.evcs_night_target_medium_forecast_pct, 55),
|
||||||
|
0,
|
||||||
|
100
|
||||||
|
),
|
||||||
|
|
||||||
|
evcsNightTargetWeakForecastPct: clamp(
|
||||||
|
num(input.evcs_night_target_weak_forecast_pct, 85),
|
||||||
|
0,
|
||||||
|
100
|
||||||
|
),
|
||||||
|
|
||||||
|
evcsNightBatteryRecoveryPowerKw: Math.max(
|
||||||
|
0.1,
|
||||||
|
num(input.evcs_night_battery_recovery_power_kw, 2.5)
|
||||||
|
),
|
||||||
|
|
||||||
|
evcsNightDepartureSafetyMin: Math.max(
|
||||||
|
0,
|
||||||
|
num(input.evcs_night_departure_safety_min, 30)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// HISTORY / PERSISTED HELPERS
|
||||||
|
// =====================================================
|
||||||
|
const nightSessionStartSocRaw = num(
|
||||||
|
input.evcs_night_session_start_soc ?? input.night_session_start_soc,
|
||||||
|
NaN
|
||||||
|
);
|
||||||
|
|
||||||
|
const nightSessionStartAtRaw = cleanText(
|
||||||
|
input.evcs_night_session_start_at ?? input.night_session_start_at
|
||||||
|
);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// VEHICLE DEMAND FILTER HISTORY RAW INPUTS
|
||||||
|
// =====================================================
|
||||||
|
const evcsNoDemandSinceAtRaw = cleanText(input.evcs_no_demand_since_at);
|
||||||
|
const evcsNoDemandFilterEvalAtRaw = cleanText(input.evcs_no_demand_filter_eval_at);
|
||||||
|
|
||||||
|
const history = {
|
||||||
|
lastBlockReason: cleanText(input.last_block_reason, 'none') || 'none',
|
||||||
|
blockedUntilAt: cleanText(input.blocked_until),
|
||||||
|
probeStartedAt: cleanText(input.probe_started_at),
|
||||||
|
lastStartAt: cleanText(input.last_start_at),
|
||||||
|
lastStopAt: cleanText(input.last_stop_at),
|
||||||
|
lastCurrentChangeAt: cleanText(input.last_current_change_at),
|
||||||
|
lastSignature: cleanText(input.last_signature),
|
||||||
|
cloudDebtStartedAt: cleanText(input.cloud_debt_started_at),
|
||||||
|
evcsPriorityManualUntilAt: cleanText(input.evcs_priority_manual_until),
|
||||||
|
|
||||||
|
blockedUntilMs: parseParisStampToMs(cleanText(input.blocked_until)),
|
||||||
|
probeStartedMs: parseParisStampToMs(cleanText(input.probe_started_at)),
|
||||||
|
lastStartMs: parseParisStampToMs(cleanText(input.last_start_at)),
|
||||||
|
lastStopMs: parseParisStampToMs(cleanText(input.last_stop_at)),
|
||||||
|
lastCurrentChangeMs: parseParisStampToMs(cleanText(input.last_current_change_at)),
|
||||||
|
cloudDebtStartedMsPrev: parseParisStampToMs(cleanText(input.cloud_debt_started_at)),
|
||||||
|
evcsPriorityManualUntilMs: parseParisStampToMs(cleanText(input.evcs_priority_manual_until)),
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// EVCS NIGHT SESSION MEMORY
|
||||||
|
// =====================================================
|
||||||
|
nightSessionStartSocPct: Number.isFinite(nightSessionStartSocRaw)
|
||||||
|
? clamp(nightSessionStartSocRaw, 0, 100)
|
||||||
|
: null,
|
||||||
|
|
||||||
|
nightSessionStartAt: nightSessionStartAtRaw || '',
|
||||||
|
nightSessionStartMs: parseParisStampToMs(nightSessionStartAtRaw),
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// VEHICLE DEMAND FILTER PERSISTENCE
|
||||||
|
// =====================================================
|
||||||
|
evcsDeliveryRatioEma: num(input.evcs_delivery_ratio_ema, NaN),
|
||||||
|
evcsDeliveryNoiseEma: num(input.evcs_delivery_noise_ema, NaN),
|
||||||
|
evcsNoDemandScoreEma: num(input.evcs_no_demand_score_ema, NaN),
|
||||||
|
evcsNoDemandSemanticActive: low(input.evcs_no_demand_semantic_active || 'off') === 'on',
|
||||||
|
|
||||||
|
evcsNoDemandSinceAt: evcsNoDemandSinceAtRaw || '',
|
||||||
|
evcsNoDemandSinceMs: parseParisStampToMs(evcsNoDemandSinceAtRaw),
|
||||||
|
|
||||||
|
evcsNoDemandFilterEvalAt: evcsNoDemandFilterEvalAtRaw || '',
|
||||||
|
evcsNoDemandFilterEvalMs: parseParisStampToMs(evcsNoDemandFilterEvalAtRaw),
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// BATTERY TUNING
|
||||||
|
// =====================================================
|
||||||
|
const batteryTune = {
|
||||||
|
global: {
|
||||||
|
houseSpikeGraceMinutes: Math.max(0, num(input.house_spike_grace_minutes, 10)),
|
||||||
|
solarPotentialGraceMinutes: Math.max(0, num(input.solar_potential_grace_minutes, 12)),
|
||||||
|
stepUpCooldownSeconds: Math.max(5, num(input.step_up_cooldown_seconds, params.currentChangeCooldownSeconds)),
|
||||||
|
stepDownCooldownSeconds: Math.max(5, num(input.step_down_cooldown_seconds, params.currentChangeCooldownSeconds)),
|
||||||
|
forceModeAutoRevertMinutes: Math.max(0, num(input.force_mode_auto_revert_minutes, 120)),
|
||||||
|
overloadStepDownA: Math.max(1, num(input.overload_step_down_a, 2)),
|
||||||
|
overloadHoldSeconds: Math.max(5, num(input.overload_hold_seconds, 30)),
|
||||||
|
overloadStopAfterSeconds: Math.max(10, num(input.overload_stop_after_seconds, 120)),
|
||||||
|
},
|
||||||
|
|
||||||
|
bands: {
|
||||||
|
gt90: {
|
||||||
|
dischargeSoftA: num(input.batt_discharge_soft_gt90_a, -50),
|
||||||
|
dischargeHardA: num(input.batt_discharge_hard_gt90_a, -100),
|
||||||
|
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_gt90_a, 5)),
|
||||||
|
softToleranceMin: Math.max(0, num(input.batt_soft_tol_gt90_min, 12)),
|
||||||
|
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_gt90_sec, 20)),
|
||||||
|
recoveryMin: Math.max(0, num(input.batt_recovery_gt90_min, 6)),
|
||||||
|
},
|
||||||
|
|
||||||
|
'80_90': {
|
||||||
|
dischargeSoftA: num(input.batt_discharge_soft_80_90_a, -40),
|
||||||
|
dischargeHardA: num(input.batt_discharge_hard_80_90_a, -80),
|
||||||
|
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_80_90_a, 10)),
|
||||||
|
softToleranceMin: Math.max(0, num(input.batt_soft_tol_80_90_min, 10)),
|
||||||
|
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_80_90_sec, 20)),
|
||||||
|
recoveryMin: Math.max(0, num(input.batt_recovery_80_90_min, 6)),
|
||||||
|
},
|
||||||
|
|
||||||
|
'60_80': {
|
||||||
|
dischargeSoftA: num(input.batt_discharge_soft_60_80_a, -20),
|
||||||
|
dischargeHardA: num(input.batt_discharge_hard_60_80_a, -50),
|
||||||
|
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_60_80_a, 40)),
|
||||||
|
softToleranceMin: Math.max(0, num(input.batt_soft_tol_60_80_min, 6)),
|
||||||
|
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_60_80_sec, 15)),
|
||||||
|
recoveryMin: Math.max(0, num(input.batt_recovery_60_80_min, 8)),
|
||||||
|
},
|
||||||
|
|
||||||
|
'40_60': {
|
||||||
|
dischargeSoftA: num(input.batt_discharge_soft_40_60_a, -12),
|
||||||
|
dischargeHardA: num(input.batt_discharge_hard_40_60_a, -30),
|
||||||
|
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_40_60_a, 70)),
|
||||||
|
softToleranceMin: Math.max(0, num(input.batt_soft_tol_40_60_min, 4)),
|
||||||
|
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_40_60_sec, 10)),
|
||||||
|
recoveryMin: Math.max(0, num(input.batt_recovery_40_60_min, 10)),
|
||||||
|
},
|
||||||
|
|
||||||
|
'25_40': {
|
||||||
|
dischargeSoftA: num(input.batt_discharge_soft_25_40_a, -5),
|
||||||
|
dischargeHardA: num(input.batt_discharge_hard_25_40_a, -15),
|
||||||
|
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_25_40_a, 110)),
|
||||||
|
softToleranceMin: Math.max(0, num(input.batt_soft_tol_25_40_min, 2)),
|
||||||
|
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_25_40_sec, 5)),
|
||||||
|
recoveryMin: Math.max(0, num(input.batt_recovery_25_40_min, 12)),
|
||||||
|
},
|
||||||
|
|
||||||
|
lt25: {
|
||||||
|
dischargeSoftA: num(input.batt_discharge_soft_lt25_a, -3),
|
||||||
|
dischargeHardA: num(input.batt_discharge_hard_lt25_a, -8),
|
||||||
|
chargeReserveA: Math.max(0, num(input.batt_charge_reserve_lt25_a, 150)),
|
||||||
|
softToleranceMin: Math.max(0, num(input.batt_soft_tol_lt25_min, 1)),
|
||||||
|
hardToleranceSec: Math.max(0, num(input.batt_hard_tol_lt25_sec, 2)),
|
||||||
|
recoveryMin: Math.max(0, num(input.batt_recovery_lt25_min, 15)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// OPTIONAL DEBUG / SUMMARY
|
||||||
|
// =====================================================
|
||||||
|
const evcsNightConfig = {
|
||||||
|
batteryHardFloorPct: params.evcsNightBatteryHardFloorPct,
|
||||||
|
strongForecastKwh: params.evcsNightStrongForecastKwh,
|
||||||
|
mediumForecastKwh: params.evcsNightMediumForecastKwh,
|
||||||
|
targetStrongForecastPct: params.evcsNightTargetStrongForecastPct,
|
||||||
|
targetMediumForecastPct: params.evcsNightTargetMediumForecastPct,
|
||||||
|
targetWeakForecastPct: params.evcsNightTargetWeakForecastPct,
|
||||||
|
batteryRecoveryPowerKw: params.evcsNightBatteryRecoveryPowerKw,
|
||||||
|
departureSafetyMin: params.evcsNightDepartureSafetyMin,
|
||||||
|
sessionStartSocPct: history.nightSessionStartSocPct,
|
||||||
|
sessionStartAt: history.nightSessionStartAt || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const vehicleDemandHistoryDebug = {
|
||||||
|
evcsDeliveryRatioEma: finiteOrNull(history.evcsDeliveryRatioEma),
|
||||||
|
evcsDeliveryNoiseEma: finiteOrNull(history.evcsDeliveryNoiseEma),
|
||||||
|
evcsNoDemandScoreEma: finiteOrNull(history.evcsNoDemandScoreEma),
|
||||||
|
evcsNoDemandSemanticActive: history.evcsNoDemandSemanticActive,
|
||||||
|
evcsNoDemandSinceAt: history.evcsNoDemandSinceAt || null,
|
||||||
|
evcsNoDemandSinceMs: finiteOrNull(history.evcsNoDemandSinceMs),
|
||||||
|
evcsNoDemandFilterEvalAt: history.evcsNoDemandFilterEvalAt || null,
|
||||||
|
evcsNoDemandFilterEvalMs: finiteOrNull(history.evcsNoDemandFilterEvalMs),
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// OUTPUT
|
||||||
|
// =====================================================
|
||||||
|
return [{
|
||||||
|
json: {
|
||||||
|
raw,
|
||||||
|
input,
|
||||||
|
nowMs,
|
||||||
|
now,
|
||||||
|
cfg: CFG,
|
||||||
|
parsed,
|
||||||
|
params,
|
||||||
|
history,
|
||||||
|
batteryTune,
|
||||||
|
evcsNightConfig,
|
||||||
|
vehicleDemandHistoryDebug,
|
||||||
|
}
|
||||||
|
}];
|
||||||
685
src/nodes/WINDOWS_SOURCEV2.js
Normal file
685
src/nodes/WINDOWS_SOURCEV2.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
32
tools/lib-node-map.mjs
Normal file
32
tools/lib-node-map.mjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// R2 — Cartographie src/nodes/ ↔ workflow JSON (source de vérité : src/nodes/).
|
||||||
|
// Utilisé par tools/n8n-extract.mjs (JSON → src) et tools/n8n-build.mjs (src → JSON).
|
||||||
|
|
||||||
|
export const WORKFLOW_FILE = 'workflows/evcs-surplus-controller-v2.6.json';
|
||||||
|
export const SRC_DIR = 'src/nodes';
|
||||||
|
|
||||||
|
export const NODE_MAP = [
|
||||||
|
{ file: 'INPUT_PARSE.js', node: 'INPUT_PARSE', kind: 'jsCode' },
|
||||||
|
{ file: 'WINDOWS_SOURCEV2.js', node: 'WINDOWS_SOURCEV2', kind: 'jsCode' },
|
||||||
|
{ file: 'BATTERY_CLOUD_LOCKS.js', node: 'BATTERY_CLOUD_LOCKS', kind: 'jsCode' },
|
||||||
|
{ file: 'DECISION_ENGINEV2.js', node: 'DECISION_ENGINEV2', kind: 'jsCode' },
|
||||||
|
{ file: 'HA_SERVICE_CALLS_OUTPUT.js', node: 'HA_SERVICE_CALLS_OUTPUT', kind: 'jsCode' },
|
||||||
|
{ file: 'BUILD_ACTIONS.js', node: 'Build EVCS Surplus Controller V2.0 Actions', kind: 'jsCode' },
|
||||||
|
{ file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot EVCS Surplus Controller V2.2', kind: 'template' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getCode(workflowNode, kind) {
|
||||||
|
if (kind === 'jsCode') return workflowNode.parameters.jsCode;
|
||||||
|
if (kind === 'template') {
|
||||||
|
return workflowNode.parameters.bodyParameters.parameters.find(p => p.name === 'template').value;
|
||||||
|
}
|
||||||
|
throw new Error(`kind inconnu: ${kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCode(workflowNode, kind, code) {
|
||||||
|
if (kind === 'jsCode') { workflowNode.parameters.jsCode = code; return; }
|
||||||
|
if (kind === 'template') {
|
||||||
|
workflowNode.parameters.bodyParameters.parameters.find(p => p.name === 'template').value = code;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`kind inconnu: ${kind}`);
|
||||||
|
}
|
||||||
38
tools/n8n-build.mjs
Normal file
38
tools/n8n-build.mjs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// R2 — BUILD : src/nodes/*.js → workflow JSON (src/ = SOURCE DE VÉRITÉ, src gagne).
|
||||||
|
// Usage : node tools/n8n-build.mjs [--check]
|
||||||
|
// --check : vérifie que le JSON est déjà synchronisé avec src/ (exit 1 sinon),
|
||||||
|
// sans rien écrire.
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { WORKFLOW_FILE, SRC_DIR, NODE_MAP, getCode, setCode } from './lib-node-map.mjs';
|
||||||
|
|
||||||
|
const CHECK = process.argv.includes('--check');
|
||||||
|
const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8'));
|
||||||
|
|
||||||
|
let changes = 0;
|
||||||
|
for (const { file, node, kind } of NODE_MAP) {
|
||||||
|
const srcPath = path.join(SRC_DIR, file);
|
||||||
|
if (!fs.existsSync(srcPath)) throw new Error(`Fichier source manquant: ${srcPath}`);
|
||||||
|
const srcCode = fs.readFileSync(srcPath, 'utf8');
|
||||||
|
const n = wf.nodes.find(x => x.name === node);
|
||||||
|
if (!n) throw new Error(`Node introuvable dans le JSON: ${node}`);
|
||||||
|
if (getCode(n, kind) !== srcCode) {
|
||||||
|
changes++;
|
||||||
|
console.log(`${CHECK ? 'DÉSYNCHRONISÉ' : 'inject'}: ${file} → "${node}"`);
|
||||||
|
if (!CHECK) setCode(n, kind, srcCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CHECK) {
|
||||||
|
console.log(changes ? `\n${changes} node(s) désynchronisé(s) : lancer node tools/n8n-build.mjs` : 'JSON synchronisé avec src/nodes/');
|
||||||
|
process.exit(changes ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes) {
|
||||||
|
fs.writeFileSync(WORKFLOW_FILE, JSON.stringify(wf, null, 2) + '\n');
|
||||||
|
console.log(`\nBuild OK: ${changes} node(s) injecté(s) dans ${WORKFLOW_FILE}`);
|
||||||
|
} else {
|
||||||
|
console.log('Build: aucun changement (déjà synchronisé)');
|
||||||
|
}
|
||||||
21
tools/n8n-extract.mjs
Normal file
21
tools/n8n-extract.mjs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// R2 — EXTRACT : workflow JSON → src/nodes/*.js (+ template snapshot).
|
||||||
|
// À utiliser UNIQUEMENT pour (re)synchroniser src/ après une modification faite
|
||||||
|
// directement dans l'UI n8n (à éviter). En temps normal : éditer src/nodes/,
|
||||||
|
// puis tools/n8n-push.mjs reconstruit le JSON automatiquement.
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { WORKFLOW_FILE, SRC_DIR, NODE_MAP, getCode } from './lib-node-map.mjs';
|
||||||
|
|
||||||
|
const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8'));
|
||||||
|
fs.mkdirSync(SRC_DIR, { recursive: true });
|
||||||
|
|
||||||
|
for (const { file, node, kind } of NODE_MAP) {
|
||||||
|
const n = wf.nodes.find(x => x.name === node);
|
||||||
|
if (!n) throw new Error(`Node introuvable: ${node}`);
|
||||||
|
const code = getCode(n, kind);
|
||||||
|
fs.writeFileSync(path.join(SRC_DIR, file), code);
|
||||||
|
console.log(`${file} ← "${node}" (${code.length} chars)`);
|
||||||
|
}
|
||||||
|
console.log(`\nExtraction OK dans ${SRC_DIR}/`);
|
||||||
@@ -14,6 +14,21 @@ if (!INPUT) {
|
|||||||
console.error('Usage: node tools/n8n-push.mjs <inputFile> [workflowId]');
|
console.error('Usage: node tools/n8n-push.mjs <inputFile> [workflowId]');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// R2 : src/nodes/ = SOURCE DE VÉRITÉ pour le workflow EVCS.
|
||||||
|
// Le build injecte src/nodes/*.js dans le JSON AVANT toute vérification :
|
||||||
|
// toute édition faite directement dans le JSON (ou l'UI n8n non ré-extraite)
|
||||||
|
// est écrasée par src/. Workflow EVCS uniquement.
|
||||||
|
if (path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json' && fs.existsSync('src/nodes')) {
|
||||||
|
console.log('Build src/nodes/ → JSON (source de vérité : src/)...');
|
||||||
|
const { spawnSync } = await import('child_process');
|
||||||
|
const b = spawnSync('node', ['tools/n8n-build.mjs'], { stdio: 'inherit' });
|
||||||
|
if (b.status !== 0) {
|
||||||
|
console.error('ABANDON: échec du build src → JSON.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const local = JSON.parse(fs.readFileSync(INPUT, 'utf8'));
|
const local = JSON.parse(fs.readFileSync(INPUT, 'utf8'));
|
||||||
const WORKFLOW_ID = process.argv[3] || local.id || 'ATswvGfG4KJUls0K';
|
const WORKFLOW_ID = process.argv[3] || local.id || 'ATswvGfG4KJUls0K';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user