Files
EVCS/src/water-heater/NORMALIZE.js

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
}
}
}];