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:
207
src/water-heater/OBSERVATION.js
Normal file
207
src/water-heater/OBSERVATION.js
Normal file
@@ -0,0 +1,207 @@
|
||||
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;
|
||||
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
|
||||
}
|
||||
}
|
||||
}];
|
||||
Reference in New Issue
Block a user