Chauffe-eau en HC de nuit les jours faibles : decision quotidienne du bilan, workflow ECS sous controle du depot (VRM, prevision du jour), energie 24 h
This commit is contained in:
250
src/water-heater/NORMALIZE.js
Normal file
250
src/water-heater/NORMALIZE.js
Normal file
@@ -0,0 +1,250 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}];
|
||||
Reference in New Issue
Block a user