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

211 lines
6.4 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 cleanStateText(v) {
const s = (v ?? '').toString().trim();
const x = s.toLowerCase();
return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;
}
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 cleanParisStamp(v) {
const s = cleanStateText(v);
return parseParisStampToMs(s) !== null ? s : '';
}
function minutesSince(ts, nowMs) {
if (ts === null || nowMs === null) return null;
return Math.max(0, (nowMs - ts) / 60000);
}
const root = $input.first()?.json ?? {};
const cfg = root.cfg ?? {};
const now = root.now ?? {};
const nowMs = root.nowMs ?? null;
const input = root.input ?? {};
const normalized = root.normalized ?? {};
const { greenPowerW, autoPowerW, boostPowerW } = normalized;
const evcsPriorityActiveRaw = low(input.evcs_priority_active || 'off') === 'on';
const energyPriorityMode = cleanStateText(input.energy_priority_mode || 'normal').toLowerCase() || 'normal';
const energyPriorityReason = cleanStateText(input.energy_priority_reason);
const evcsPriorityUntilAt = cleanParisStamp(input.evcs_priority_until);
const evcsPriorityUntilMs = parseParisStampToMs(evcsPriorityUntilAt);
const evcsPriorityActive =
evcsPriorityActiveRaw &&
energyPriorityMode === 'evcs_priority' &&
(
evcsPriorityUntilMs === null ||
(nowMs !== null && evcsPriorityUntilMs > nowMs + 1000)
);
const waterHeaterSwitchOn = low(input.water_heater_switch || 'off') === 'on';
const waterHeaterEnergyTotalKwh = Math.max(0, num(input.water_heater_energy_total_kwh, 0));
const energyPrevKwh = num(input.energy_total_prev_kwh, NaN);
const energyPrevAt = cleanParisStamp(input.energy_prev_at);
const energyPrevMs = parseParisStampToMs(energyPrevAt);
const observedPowerPrevW = Math.max(0, num(input.observed_power_helper_w, 0));
const observedRegimePrev = cleanStateText(input.observed_regime_helper).toLowerCase();
const mismatchSinceAt = cleanParisStamp(input.mode_mismatch_since);
const mismatchSinceMsPrev = parseParisStampToMs(mismatchSinceAt);
const forcedOffByAdvisorPrev = low(input.forced_off_by_advisor || 'off') === 'on';
const forcedOffReasonPrev = cleanStateText(input.forced_off_reason);
const forcedOffAtPrev = cleanParisStamp(input.forced_off_at);
const forcedOffAtPrevMs = parseParisStampToMs(forcedOffAtPrev);
const pendingUserActionPrev = cleanStateText(input.pending_user_action_prev);
const restoreSurplusW = Math.max(0, num(input.restore_surplus_w, 2500));
const modeMismatchDelayMin = Math.max(1, num(input.mode_mismatch_delay_min, 15));
const sampleDurationMin = minutesSince(energyPrevMs, nowMs);
const energyDeltaKwh =
Number.isFinite(energyPrevKwh)
? Math.max(0, waterHeaterEnergyTotalKwh - energyPrevKwh)
: NaN;
const sampleIsUsable =
Number.isFinite(sampleDurationMin) &&
sampleDurationMin >= (cfg.sampleWindowMinMin ?? 2) &&
sampleDurationMin <= (cfg.sampleWindowMinMax ?? 15) &&
Number.isFinite(energyDeltaKwh);
let observedPowerW = 0;
// Coupe = 0 W (07/10/2026) : le compteur Tuya remonte avec retard, et la
// derniere chauffe apparaissait apres la coupure (« arret — 1595 W »).
if (!waterHeaterSwitchOn) {
observedPowerW = 0;
} else if (sampleIsUsable) {
observedPowerW = Math.max(0, (energyDeltaKwh * 60000) / sampleDurationMin);
} else if (waterHeaterSwitchOn && observedPowerPrevW > 0) {
observedPowerW = observedPowerPrevW;
}
const offThresholdW = Math.max(80, greenPowerW * 0.25);
const hpUpperW = (greenPowerW + autoPowerW) / 2;
const resistiveUpperW = (autoPowerW + boostPowerW) / 2;
let observedRegime = 'unknown';
if (!waterHeaterSwitchOn) {
observedRegime = 'off';
} else if (!sampleIsUsable && observedRegimePrev) {
observedRegime = observedRegimePrev;
} else if (observedPowerW < offThresholdW) {
observedRegime = 'off';
} else if (observedPowerW < hpUpperW) {
observedRegime = 'hp_low';
} else if (observedPowerW < resistiveUpperW) {
observedRegime = 'resistive_mid';
} else {
observedRegime = 'boost_high';
}
return [{
json: {
...root,
meta: {
...(root.meta ?? {}),
node: 'ecs_evcs_and_water_heater_observation'
},
observation: {
evcsPriorityActiveRaw,
energyPriorityMode,
energyPriorityReason,
evcsPriorityUntilAt,
evcsPriorityUntilMs,
evcsPriorityActive,
waterHeaterSwitchOn,
waterHeaterEnergyTotalKwh,
energyPrevKwh,
energyPrevAt,
energyPrevMs,
observedPowerPrevW,
observedRegimePrev,
mismatchSinceAt,
mismatchSinceMsPrev,
forcedOffByAdvisorPrev,
forcedOffReasonPrev,
forcedOffAtPrev,
forcedOffAtPrevMs,
pendingUserActionPrev,
restoreSurplusW,
modeMismatchDelayMin,
sampleDurationMin,
energyDeltaKwh,
sampleIsUsable,
observedPowerW,
observedRegime,
offThresholdW,
hpUpperW,
resistiveUpperW
}
}
}];