BOOST 28 A en plein soleil, 11/09 17:02 : consigne reseau calculee a
8242 W (maison 1802 + 28 A x 230), PV effondre a 121 W, reseau 7697 W,
batterie 0 A. L'ESS n'a qu'un moyen de prelever 8,2 kW quand le soleil
en donne 4 : brider les MPPT. La production etait refusee par notre
propre consigne.
Le commentaire qui justifiait une consigne genereuse ("elle ne coute
rien") datait de la nuit du 01/09, ou la PV valait zero. En plein jour
il est faux.
On ne peut pas soustraire la PV mesuree : elle vaut 121 W A CAUSE de
cette consigne. Le proprietaire l'a montre en la baissant a la main a
2280 W -- PV 4758 W en 50 secondes, batterie +9 A. On descend donc par
paliers et on laisse la physique repondre, sur deux signaux incensurables
et deja lus : les chargeurs declarent eux-memes s'ils se BRIDENT, et le
courant batterie dit si on est descendu trop bas.
`off` ne compte pas comme bride : la nuit les six le sont, et les
confondre ferait descendre la consigne au crepuscule -- un oscillateur
de plus. C'est ce que verifie le scenario de nuit.
Plancher : la consigne ne descend jamais sous la maison hors voiture,
garantie du 01/09 et du 04/09. On ne libere que la part voiture.
Golden 84 -> 89. Mutation en quatre directions (descente neutralisee /
`off` recompte comme bride / garde batterie retiree / plancher retire) :
chacune fait tomber un jeu DIFFERENT, aucune garde n'est couverte par
le test d'une autre.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
784 lines
32 KiB
JavaScript
784 lines
32 KiB
JavaScript
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;
|
|
}
|
|
|
|
// P11 (12/08/2026) : durée de charge EVCS "1h 49m 53s" / "8m 27s" / "0s"
|
|
// → secondes. Repart de 0 à chaque NOUVELLE connexion physique d'un véhicule
|
|
// (débranchement JEEP → branchement VW) → signal de reset du collant "plein".
|
|
function parseChargingTimeToSec(s) {
|
|
const t = (s ?? '').toString().toLowerCase();
|
|
if (!t) return null;
|
|
let sec = 0;
|
|
let matched = false;
|
|
const h = t.match(/(\d+)\s*h/); if (h) { sec += Number(h[1]) * 3600; matched = true; }
|
|
const m = t.match(/(\d+)\s*m/); if (m) { sec += Number(m[1]) * 60; matched = true; }
|
|
const s2 = t.match(/(\d+)\s*s/); if (s2){ sec += Number(s2[1]); matched = true; }
|
|
return matched ? sec : null;
|
|
}
|
|
|
|
// Advisor IA : input_text.evcs_advisor_last_run au format "DD/MM HH:mm"
|
|
// (pas d'année). On suppose l'année courante ; si le résultat est dans le
|
|
// futur (> 1 jour), c'est un passage d'année → on retire un an.
|
|
function parseAdvisorRunToMs(s) {
|
|
const m = (s ?? '').toString().trim().match(/^(\d{2})\/(\d{2}) (\d{2}):(\d{2})$/);
|
|
if (!m) return null;
|
|
|
|
const nowMsLocal = Date.now();
|
|
const year = Number(
|
|
new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric' })
|
|
.format(new Date(nowMsLocal))
|
|
);
|
|
|
|
const build = (y) => {
|
|
const guessUtc = Date.UTC(y, Number(m[2]) - 1, Number(m[1]), Number(m[3]), Number(m[4]), 0);
|
|
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
|
|
let ts = guessUtc - off1;
|
|
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
|
|
if (off2 !== off1) ts = guessUtc - off2;
|
|
return ts;
|
|
};
|
|
|
|
let ts = build(year);
|
|
if (ts - nowMsLocal > 24 * 3600 * 1000) ts = build(year - 1);
|
|
return ts;
|
|
}
|
|
|
|
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',
|
|
|
|
// 03/09/2026 — valeur MESUREE (576 paliers de SOC sur 8 jours,
|
|
// tools/mesure-courbe-batterie.mjs) : 0,169 kWh par point au-dessus de 33 %,
|
|
// soit 16,9 kWh. Le 17,5 precedent etait une valeur de plaque.
|
|
// NB : sous 33 % un point ne vaut plus que 0,097 kWh. Cette constante ne sert
|
|
// ici qu'a des ecarts SOC -> cible, tous deux au-dessus du genou. Si un calcul
|
|
// devait un jour traverser le genou, il faut la COURBE (src/energy-balance/
|
|
// BALANCE.js) et non cette constante.
|
|
// 16,9 kWh mesures sur SEPT modules (§41), soit 2,414 kWh par module. Le parc
|
|
// ayant change deux fois en une semaine, le nombre de modules se LIT au lieu
|
|
// d'etre code : `input_number.evcs_balance_modules_en_service`, le meme helper
|
|
// que le bilan energetique — les deux decrivent forcement le meme parc.
|
|
// Absent ou illisible => 7, donc aucun changement tant que personne ne l'ecrit.
|
|
batteryUsableCapacityKwh: 2.414 * (() => {
|
|
const n = Number(input.modules_en_service);
|
|
return Number.isFinite(n) && n >= 1 && n <= 16 ? n : 7;
|
|
})(),
|
|
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)),
|
|
|
|
// MODE DE FONCTIONNEMENT DES CHARGEURS SOLAIRES (03/09/2026)
|
|
// Chaque tracker declare lui-meme s'il est a son point de puissance maximale
|
|
// ('mppt_active' : il donne tout ce qu'il a) ou s'il se retient
|
|
// ('voltage_current_limited', 'off' : il y a de la ressource REFUSEE).
|
|
// Lectures inexploitables (integration muette) => listes vides => les gardes
|
|
// aval retombent sur le comportement anterieur. On ne devine pas un etat.
|
|
...(() => {
|
|
const modes = String(input.mppt_operation_modes || '')
|
|
.split(',')
|
|
.map(s => s.trim().toLowerCase())
|
|
.filter(s => s !== '' && s !== 'unavailable' && s !== 'unknown');
|
|
const atMaxPower = modes.filter(s => s === 'mppt_active').length;
|
|
// Un chargeur qui SE RETIENT (`voltage_current_limited`) prouve qu'il reste
|
|
// de la ressource refusee. Un chargeur `off` n'a rien a donner : la nuit,
|
|
// tous le sont. `mpptHoldingBackCount` confond les deux, ce qui est sans
|
|
// consequence pour la garde de provocation solaire (§37, demarrage de jour
|
|
// seulement) mais ferait descendre la consigne reseau au crepuscule si on
|
|
// s'en servait ici — un oscillateur de plus. On compte donc separement ce
|
|
// qui se declare BRIDE, et tout libelle inconnu retombe du cote prudent
|
|
// (pas de ressource refusee, donc comportement anterieur).
|
|
const limited = modes.filter(s => s.includes('limited')).length;
|
|
return {
|
|
mpptReadableCount: modes.length,
|
|
mpptAtMaxPowerCount: atMaxPower,
|
|
mpptHoldingBackCount: modes.length - atMaxPower,
|
|
mpptLimitedCount: limited,
|
|
};
|
|
})(),
|
|
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,
|
|
|
|
// Consigne de puissance réseau de l'ESS, lue telle qu'elle est appliquée.
|
|
// C'est ELLE qui décide si un contacteur fermé sert à quelque chose : au-dessus
|
|
// du SOC minimal, l'ESS en autoconsommation ne prélève que cette puissance et
|
|
// laisse la batterie porter le reste. Nuit du 01/09 : contacteur fermé,
|
|
// consigne 50 W, batterie à -803 W pour 761 W de maison.
|
|
gridSetpointW: num(input.grid_setpoint_w, NaN),
|
|
gridAvailable:
|
|
(low(input.grid_state || 'off') === 'on') ||
|
|
(num(input.victron_acin1_available ?? input.ac_in1_available, 0) === 1),
|
|
|
|
// FENETRE D'ETABLISSEMENT (31/08/2026) — age, en secondes, de la derniere
|
|
// transition du reseau. On retient la plus RECENTE des deux sources : la
|
|
// presence physique AC-IN (celle qui declenche reellement le transitoire) et
|
|
// le contacteur (qui peut bouger sans qu'AC-IN suive, disjoncteur ouvert).
|
|
// Horodatage absent ou nul => age tres grand => aucune fenetre, comportement
|
|
// historique. On ne devine jamais une transition qu'on n'a pas observee.
|
|
gridTransitionAgeSec: (() => {
|
|
const ts = Math.max(
|
|
num(input.acin1_last_changed_ts, 0),
|
|
num(input.grid_switch_last_changed_ts, 0)
|
|
);
|
|
if (!(ts > 0)) return Number.POSITIVE_INFINITY;
|
|
return Math.max(0, (nowMs / 1000) - ts);
|
|
})(),
|
|
|
|
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)),
|
|
evcsChargingTimeSec: parseChargingTimeToSec(input.evcs_charging_time),
|
|
// Energie deja livree par la session EN COURS (kWh). Le capteur de la borne
|
|
// s'appelle "last_session_energy" mais il suit la session courante et repart
|
|
// de zero a chaque nouvelle connexion (verifie le 07/09/2026 : 0 -> 0,1 -> 0,2
|
|
// ... pendant la charge). Sert a juger la credibilite d'un statut "charged".
|
|
evcsSessionEnergyKwh: Math.max(0, num(input.evcs_session_energy_kwh, 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)),
|
|
|
|
// P9 (09/08/2026) : deux véhicules alternent sur la borne.
|
|
// Le sélecteur input_select.victron_evcs_vehicle choisit l'heure de départ
|
|
// effective : VW = victron_evcs_departure_time (~08:50),
|
|
// JEEP = victron_evcs_departure_time_jeep (~06:30).
|
|
selectedVehicle: cleanText(input.evcs_vehicle, 'VW'),
|
|
departureTimeMin:
|
|
low(input.evcs_vehicle || 'vw') === 'jeep'
|
|
? parseTimeToMinutes(
|
|
input.departure_time_jeep,
|
|
parseTimeToMinutes(input.departure_time, null)
|
|
)
|
|
: parseTimeToMinutes(input.departure_time, null),
|
|
allowGridDepartureUrgent: low(input.allow_grid_departure_urgent || 'off') === 'on',
|
|
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',
|
|
|
|
// Advisor IA — premier câblage réel (09/08/2026, HANDOFF §20) :
|
|
// cible SOC matin recommandée, appliquée UNIQUEMENT si le toggle est ON.
|
|
// Garde-fous consommés dans WINDOWS_SOURCEV2 (statut ok + fraîcheur + bornes).
|
|
advisorApplyNightTarget: low(input.advisor_apply_night_target || 'off') === 'on',
|
|
advisorNightTargetSocPct: num(input.advisor_night_target_soc, NaN),
|
|
advisorStatusOk: cleanText(input.advisor_status).toLowerCase() === 'ok',
|
|
advisorLastRunMs: parseAdvisorRunToMs(cleanText(input.advisor_last_run)),
|
|
|
|
// BILAN ENERGETIQUE (03/09/2026). Meme format d'horodatage que l'advisor
|
|
// ("DD/MM HH:mm"), d'ou la reutilisation du parseur.
|
|
balanceRequiredSocPct: num(input.balance_required_soc, NaN),
|
|
balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN),
|
|
balanceStatusOk: cleanText(input.balance_status).toLowerCase() === 'ok',
|
|
balanceLastRunMs: parseAdvisorRunToMs(cleanText(input.balance_last_run)),
|
|
|
|
// Duree de la fenetre d'etablissement. Valeur mesuree le 31/08/2026 : le
|
|
// transitoire de fermeture dure ~150 s (import 1985 -> 25 W et 2600 -> 52 W,
|
|
// deux campagnes), on prend 180 s de marge. Reglable sans redeploiement via
|
|
// input_number.evcs_grid_settle_seconds ; absent => 180.
|
|
gridSettleSeconds: clamp(num(input.grid_settle_seconds, 180), 0, 900),
|
|
|
|
// Consigne réseau au repos. 50 W est le défaut Victron (légèrement positif
|
|
// pour ne jamais injecter), pas une valeur choisie ici.
|
|
// CONSIGNE DE REPOS : 200 W et non plus 50 (04/09/2026).
|
|
// 50 W etait le defaut Victron. Mesure sur la nuit du 03-04/09, 12 074
|
|
// echantillons du point de raccordement : 17 % sont NEGATIFS, mediane -18 W,
|
|
// excursions jusqu'a -356 W, episodes de 35 s au plus. Ce n'est pas de
|
|
// l'injection — la batterie se dechargeait a 744 W au meme moment — c'est le
|
|
// regulateur qui chasse autour d'une cible trop proche de zero. A 200 W le
|
|
// point de fonctionnement reste positif sur l'essentiel des excursions.
|
|
// ⚠️ Ne supprime pas les pointes les plus fortes : une excursion de -356 W
|
|
// passerait encore sous zero. Reduit leur nombre, ne les annule pas.
|
|
// CONSIGNE DE REPOS : 50 -> 200 (04/09) -> 700 (06/09).
|
|
//
|
|
// Elle ne s'applique que contacteur FERME sans session reseau EVCS — donc
|
|
// apres une coupure sur SOC bas, ou apres une fermeture par la regle de
|
|
// protection du parc. Contacteur ouvert, elle n'a aucun effet.
|
|
//
|
|
// A 200 W, une plaque chauffante qui cycle fait passer le point de
|
|
// fonctionnement sous zero : mesure du 06/09 sur la fenetre 12:07-12:22,
|
|
// 9 % des echantillons negatifs, minimum -477 W, mediane des negatifs
|
|
// -146 W. Le regulateur ne rattrape pas assez vite un delestage brutal de
|
|
// ~1 kW. A 700 W toute la courbe monte de 500 W : minimum +23 W, plus aucune
|
|
// excursion negative sur cette fenetre.
|
|
//
|
|
// Le surcout est faible et va dans le bon sens : les DEUX seules raisons de
|
|
// fermer le contacteur sans session EVCS sont « la batterie est a son
|
|
// plancher » et « la maison demande plus que le parc ne sait donner ». Dans
|
|
// les deux cas on VEUT que le reseau contribue plus que 200 W.
|
|
//
|
|
// ⚠️ Ne corrige PAS les gros a-coups. Le 06/09 a 13:41:40, le debranchement
|
|
// du vehicule a supprime 2,2 kW d'un coup alors que la batterie, a 93 % de
|
|
// SOC, n'absorbait rien et que le MPPT produisait 4 kW : -2 819 W pendant
|
|
// plus de deux minutes, le temps que le systeme bride la production. Aucune
|
|
// consigne n'absorbe cela. Ce qui l'evite est la correction du meme jour sur
|
|
// la regle de protection (elle porte desormais sur `maison - PV`) : avec une
|
|
// demande sur le parc de 741 W, le contacteur n'aurait pas ete ferme, et
|
|
// sans reseau il n'y a rien ou injecter.
|
|
gridSetpointIdleW: clamp(num(input.grid_setpoint_idle_w, 700), 0, 1500),
|
|
|
|
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)),
|
|
|
|
// DEPASSEMENT SOUPLE BATTERIE (01/09/2026) : horodatage du debut du
|
|
// depassement en cours, ecrit par HA_SERVICE_CALLS_OUTPUT. Vide/illisible
|
|
// => null => aucun depassement en cours, comportement de repli sur.
|
|
nightGridSessionCommittedPrev:
|
|
(input.night_grid_session_committed_prev ?? 'off').toString().trim().toLowerCase() === 'on',
|
|
batterySoftBreachAt: cleanText(input.battery_soft_breach_at),
|
|
batterySoftBreachMsPrev: parseParisStampToMs(cleanText(input.battery_soft_breach_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,
|
|
}
|
|
}];
|