250 lines
7.5 KiB
JavaScript
250 lines
7.5 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 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 clamp(v, min, max) {
|
|
return Math.min(Math.max(v, min), max);
|
|
}
|
|
|
|
function to2(n) {
|
|
return String(n).padStart(2, '0');
|
|
}
|
|
|
|
function getParisNowParts() {
|
|
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())
|
|
.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 cleanStateText(v) {
|
|
const s = (v ?? '').toString().trim();
|
|
const x = s.toLowerCase();
|
|
return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;
|
|
}
|
|
|
|
const cfg = {
|
|
mobileNotifyDomain: 'notify',
|
|
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
|
|
batteryUsableCapacityKwh: 17.5,
|
|
sampleWindowMinMin: 2,
|
|
sampleWindowMinMax: 15,
|
|
energyByDemandKwh: {
|
|
low: { green: 0.9, auto: 1.4, boost: 2.0 },
|
|
normal: { green: 1.4, auto: 2.2, boost: 3.1 },
|
|
high: { green: 2.2, auto: 3.4, boost: 4.6 },
|
|
three_showers: { green: 3.0, auto: 4.4, boost: 5.8 }
|
|
}
|
|
};
|
|
|
|
const raw = $input.first()?.json ?? {};
|
|
const input = extractSnapshot(raw);
|
|
const now = getParisNowParts();
|
|
const nowMs = Date.now();
|
|
|
|
const soc = clamp(num(input.soc_percent, 0), 0, 100);
|
|
const batteryCurrentA = num(input.battery_current_a, 0);
|
|
const pvPowerW = Math.max(0, num(input.pv_power_w, 0));
|
|
const pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW));
|
|
const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));
|
|
const gridPowerW = num(input.grid_power_w, 0);
|
|
const gridIsOn = low(input.grid_state || 'off') === 'on';
|
|
const acInAvailable = num(input.victron_acin1_available, 0) === 1;
|
|
const gridAvailable = gridIsOn || acInAvailable;
|
|
const todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));
|
|
const tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0));
|
|
const advisorEnabled = low(input.advisor_enabled || 'off') === 'on';
|
|
const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';
|
|
|
|
const hotWaterDemandRaw = low(input.hot_water_demand || 'normal');
|
|
const hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw)
|
|
? hotWaterDemandRaw
|
|
: 'normal';
|
|
|
|
const greenPowerW = Math.max(0, num(input.green_power_w, 620));
|
|
const autoPowerW = Math.max(0, num(input.auto_power_w, 1200));
|
|
const boostPowerW = Math.max(0, num(input.boost_power_w, 2000));
|
|
const strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18));
|
|
const mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10));
|
|
const strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500));
|
|
const mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500));
|
|
const boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100);
|
|
const boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100);
|
|
const endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100);
|
|
const endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);
|
|
const baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0));
|
|
const flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0));
|
|
const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000));
|
|
const lastSignature = cleanStateText(input.last_signature);
|
|
// Jour faible (07/10/2026) : decision du bilan, session reseau HC engagee,
|
|
// energie du chauffe-eau sur 24 h (service d'historisation), propriete de la coupure.
|
|
const ecsNuit = low(input.ecs_nuit) === 'on';
|
|
const hcSessionCommitted = low(input.hc_session_committed) === 'on';
|
|
const ecs24hKwh = num(input.ecs_24h_kwh, NaN);
|
|
const offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on';
|
|
const forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar';
|
|
|
|
return [{
|
|
json: {
|
|
meta: {
|
|
workflow: 'water_heater_advisor',
|
|
architecture: 'ecs_6nodes_simple_early_boost',
|
|
node: 'ecs_normalize_inputs',
|
|
timestamp_paris: now.stamp
|
|
},
|
|
cfg,
|
|
now,
|
|
nowMs,
|
|
input,
|
|
normalized: {
|
|
soc,
|
|
batteryCurrentA,
|
|
pvPowerW,
|
|
pvPowerAvg10mW,
|
|
houseAcPowerW,
|
|
gridPowerW,
|
|
gridIsOn,
|
|
acInAvailable,
|
|
gridAvailable,
|
|
todayRemainingKwh,
|
|
tomorrowKwh,
|
|
advisorEnabled,
|
|
notificationsEnabled,
|
|
hotWaterDemand,
|
|
greenPowerW,
|
|
autoPowerW,
|
|
boostPowerW,
|
|
strongForecastKwh,
|
|
mediumForecastKwh,
|
|
strongPvNowW,
|
|
mediumPvNowW,
|
|
boostMinSocStrongForecastPct,
|
|
boostMinSocMediumForecastPct,
|
|
endOfDaySocGuard,
|
|
endOfDaySocTarget,
|
|
baseLoadRemainingKwh,
|
|
flexibleSafetyMarginKwh,
|
|
acLoadsMaxSafeW,
|
|
lastSignature,
|
|
ecsNuit,
|
|
hcSessionCommitted,
|
|
ecs24hKwh,
|
|
offJourFaiblePrev,
|
|
forecastSource
|
|
}
|
|
}
|
|
}]; |