421 lines
16 KiB
JavaScript
421 lines
16 KiB
JavaScript
function regimeRank(regime) {
|
|
switch (regime) {
|
|
case 'off': return 0;
|
|
case 'hp_low': return 1;
|
|
case 'resistive_mid': return 2;
|
|
case 'boost_high': return 3;
|
|
default: return -1;
|
|
}
|
|
}
|
|
|
|
function allowedRankForMode(mode) {
|
|
switch (mode) {
|
|
case 'off': return 0;
|
|
case 'green': return 1;
|
|
case 'auto': return 2;
|
|
case 'boost': return 3;
|
|
default: return 0;
|
|
}
|
|
}
|
|
|
|
function round1(v) {
|
|
return Math.round(v * 10) / 10;
|
|
}
|
|
|
|
const root = $input.first()?.json ?? {};
|
|
const now = root.now ?? {};
|
|
const nowMs = root.nowMs ?? null;
|
|
const normalized = root.normalized ?? {};
|
|
const observation = root.observation ?? {};
|
|
const energyModel = root.energyModel ?? {};
|
|
const baseDecision = root.baseDecision ?? {};
|
|
const input = root.input ?? {};
|
|
|
|
let {
|
|
recommendedMode,
|
|
adviceColor,
|
|
state,
|
|
reason,
|
|
baseRecommendedMode,
|
|
baseAdviceColor,
|
|
baseState
|
|
} = baseDecision;
|
|
|
|
reason = Array.isArray(reason) ? [...reason] : [];
|
|
|
|
const {
|
|
advisorEnabled,
|
|
soc,
|
|
batteryCurrentA,
|
|
pvPowerAvg10mW,
|
|
houseAcPowerW,
|
|
todayRemainingKwh,
|
|
tomorrowKwh,
|
|
baseLoadRemainingKwh,
|
|
flexibleSafetyMarginKwh,
|
|
acLoadsMaxSafeW,
|
|
hotWaterDemand,
|
|
gridIsOn,
|
|
acInAvailable,
|
|
gridAvailable
|
|
} = normalized;
|
|
|
|
const {
|
|
evcsPriorityActive,
|
|
energyPriorityReason,
|
|
waterHeaterSwitchOn,
|
|
observedPowerW,
|
|
observedRegime,
|
|
mismatchSinceAt,
|
|
mismatchSinceMsPrev,
|
|
forcedOffByAdvisorPrev,
|
|
forcedOffReasonPrev,
|
|
forcedOffAtPrev,
|
|
restoreSurplusW,
|
|
modeMismatchDelayMin,
|
|
sampleDurationMin,
|
|
energyDeltaKwh,
|
|
sampleIsUsable
|
|
} = observation;
|
|
|
|
const {
|
|
targetSoc,
|
|
energyNeededToReachTargetKwh,
|
|
greenEnergyKwh,
|
|
autoEnergyKwh,
|
|
boostEnergyKwh,
|
|
projectedGreenSoc,
|
|
projectedAutoSoc,
|
|
projectedBoostSoc,
|
|
greenBudgetMarginKwh,
|
|
autoBudgetMarginKwh,
|
|
boostBudgetMarginKwh,
|
|
forecastStrength,
|
|
netPvHeadroomW,
|
|
pvCoversHouseNow,
|
|
pvStartedWell,
|
|
greenAllowed,
|
|
autoAllowed,
|
|
boostAllowed,
|
|
batteryNotDrainingHard
|
|
} = energyModel;
|
|
|
|
if (advisorEnabled && evcsPriorityActive) {
|
|
if (recommendedMode === 'boost' || recommendedMode === 'auto') {
|
|
if (greenAllowed && batteryNotDrainingHard) {
|
|
recommendedMode = 'green';
|
|
adviceColor = 'orange';
|
|
state = 'evcs_priority_green_cap';
|
|
reason.push(`Priorité EVCS active : chauffe-eau plafonné à GREEN (${energyPriorityReason || 'raison non précisée'})`);
|
|
} else {
|
|
recommendedMode = 'off';
|
|
adviceColor = 'red';
|
|
state = 'evcs_priority_off_cap';
|
|
reason.push(`Priorité EVCS active : chauffe-eau déconseillé (${energyPriorityReason || 'raison non précisée'})`);
|
|
}
|
|
} else if (recommendedMode === 'green' && (!greenAllowed || !batteryNotDrainingHard)) {
|
|
recommendedMode = 'off';
|
|
adviceColor = 'red';
|
|
state = 'evcs_priority_green_blocked';
|
|
reason.push(`Priorité EVCS active : GREEN devient lui-même trop coûteux (${energyPriorityReason || 'raison non précisée'})`);
|
|
}
|
|
}
|
|
|
|
const selectedLoadKwh =
|
|
recommendedMode === 'green' ? greenEnergyKwh :
|
|
recommendedMode === 'auto' ? autoEnergyKwh :
|
|
recommendedMode === 'boost' ? boostEnergyKwh :
|
|
0;
|
|
|
|
const selectedProjectedSoc =
|
|
recommendedMode === 'green' ? projectedGreenSoc :
|
|
recommendedMode === 'auto' ? projectedAutoSoc :
|
|
recommendedMode === 'boost' ? projectedBoostSoc :
|
|
soc;
|
|
|
|
const selectedBudgetMarginKwh =
|
|
recommendedMode === 'green' ? greenBudgetMarginKwh :
|
|
recommendedMode === 'auto' ? autoBudgetMarginKwh :
|
|
recommendedMode === 'boost' ? boostBudgetMarginKwh :
|
|
todayRemainingKwh - baseLoadRemainingKwh - flexibleSafetyMarginKwh - energyNeededToReachTargetKwh;
|
|
|
|
const legacyAdviceOk = recommendedMode !== 'off';
|
|
|
|
const observedRank = regimeRank(observedRegime);
|
|
const allowedRank = allowedRankForMode(recommendedMode);
|
|
|
|
const mismatchActive =
|
|
waterHeaterSwitchOn &&
|
|
observedRank >= 0 &&
|
|
allowedRank >= 0 &&
|
|
observedRank > allowedRank;
|
|
|
|
let mismatchSeverity = 'none';
|
|
if (mismatchActive) {
|
|
if (recommendedMode === 'off') {
|
|
mismatchSeverity = 'critical';
|
|
} else if (observedRank - allowedRank >= 2) {
|
|
mismatchSeverity = 'high';
|
|
} else {
|
|
mismatchSeverity = 'medium';
|
|
}
|
|
}
|
|
|
|
const mismatchSinceMsEffective = mismatchActive ? (mismatchSinceMsPrev ?? nowMs) : null;
|
|
const mismatchSinceAtEffective = mismatchActive ? (mismatchSinceMsPrev ? mismatchSinceAt : now.stamp) : '';
|
|
const mismatchDurationMin = mismatchActive && mismatchSinceMsEffective !== null ? Math.max(0, (nowMs - mismatchSinceMsEffective) / 60000) : 0;
|
|
|
|
let pendingUserAction = '';
|
|
if (mismatchActive) {
|
|
if (recommendedMode === 'off') {
|
|
pendingUserAction = evcsPriorityActive
|
|
? 'Priorité EVCS : coupe temporairement le chauffe-eau ou laisse-le hors tension'
|
|
: 'Le chauffe-eau consomme alors qu\'il est déconseillé : coupe-le ou attends une meilleure fenêtre';
|
|
} else if (recommendedMode === 'green') {
|
|
pendingUserAction = observedRegime === 'boost_high'
|
|
? 'Passe le chauffe-eau en GREEN si possible, il semble en régime BOOST/résistance forte'
|
|
: 'Passe le chauffe-eau en GREEN si possible, il semble plus gourmand que prévu';
|
|
} else if (recommendedMode === 'auto') {
|
|
pendingUserAction = 'Le chauffe-eau semble trop gourmand pour AUTO : repasse-le en AUTO modéré ou GREEN si possible';
|
|
}
|
|
}
|
|
|
|
const firstMismatchRun = mismatchActive && (mismatchSinceMsPrev === null || mismatchDurationMin < 1);
|
|
|
|
const realConflictNow =
|
|
evcsPriorityActive &&
|
|
(
|
|
batteryCurrentA < -1 ||
|
|
pvPowerAvg10mW < restoreSurplusW ||
|
|
houseAcPowerW > acLoadsMaxSafeW ||
|
|
recommendedMode === 'off' ||
|
|
observedRegime === 'boost_high' ||
|
|
(recommendedMode === 'green' && observedRegime === 'resistive_mid')
|
|
);
|
|
|
|
const forceOffNow =
|
|
advisorEnabled &&
|
|
waterHeaterSwitchOn &&
|
|
!forcedOffByAdvisorPrev &&
|
|
mismatchActive &&
|
|
mismatchDurationMin >= modeMismatchDelayMin &&
|
|
realConflictNow;
|
|
|
|
const restoreEnergyOk =
|
|
pvPowerAvg10mW >= restoreSurplusW &&
|
|
batteryCurrentA >= -1 &&
|
|
houseAcPowerW <= acLoadsMaxSafeW;
|
|
|
|
const restoreNow =
|
|
advisorEnabled &&
|
|
forcedOffByAdvisorPrev &&
|
|
!waterHeaterSwitchOn &&
|
|
(
|
|
(!evcsPriorityActive && recommendedMode !== 'off') ||
|
|
restoreEnergyOk
|
|
);
|
|
|
|
let forcedOffByAdvisor = forcedOffByAdvisorPrev;
|
|
let forcedOffReason = forcedOffReasonPrev;
|
|
let forcedOffAt = forcedOffAtPrev;
|
|
let protectionState = 'none';
|
|
let powerSwitchAction = 'none';
|
|
let powerSwitchDesiredOn = waterHeaterSwitchOn;
|
|
|
|
if (forceOffNow) {
|
|
powerSwitchAction = 'turn_off';
|
|
powerSwitchDesiredOn = false;
|
|
forcedOffByAdvisor = true;
|
|
forcedOffReason = `Coupure temporaire ECS: priorité EVCS + régime ${observedRegime} trop gourmand`;
|
|
forcedOffAt = now.stamp;
|
|
protectionState = 'forced_off_now_evcs_priority';
|
|
state = 'forced_off_now_evcs_priority';
|
|
adviceColor = 'red';
|
|
reason.push(forcedOffReason);
|
|
}
|
|
else if (restoreNow) {
|
|
powerSwitchAction = 'turn_on';
|
|
powerSwitchDesiredOn = true;
|
|
forcedOffByAdvisor = false;
|
|
forcedOffReason = '';
|
|
protectionState = 'restored_after_evcs_priority';
|
|
state = 'restored_after_evcs_priority';
|
|
reason.push('Remise sous tension ECS : conflit EVCS terminé ou surplus redevenu suffisant');
|
|
}
|
|
else if (forcedOffByAdvisorPrev && !waterHeaterSwitchOn) {
|
|
protectionState = 'forced_off_wait_restore';
|
|
state = 'forced_off_wait_restore';
|
|
reason.push(`ECS temporairement coupé par l'advisor (${forcedOffReasonPrev || 'raison non précisée'})`);
|
|
}
|
|
else if (mismatchActive && evcsPriorityActive) {
|
|
protectionState = 'watch_mismatch_evcs_priority';
|
|
state = 'watch_mismatch_evcs_priority';
|
|
reason.push(`Écart de régime ECS sous priorité EVCS depuis ${round1(mismatchDurationMin)} min`);
|
|
}
|
|
|
|
if (!forceOffNow && !restoreNow && forcedOffByAdvisorPrev && waterHeaterSwitchOn) {
|
|
forcedOffByAdvisor = false;
|
|
forcedOffReason = '';
|
|
}
|
|
|
|
// =====================================================
|
|
// JOUR FAIBLE : EAU CHAUDE EN HC DE NUIT (07/10/2026, option b du proprietaire)
|
|
// =====================================================
|
|
// Le bilan decide une fois par jour (input_boolean.evcs_balance_ecs_nuit) : si
|
|
// le solaire prevu ne remplit pas la batterie pour la nuit, EDF sera ferme de
|
|
// toute facon -> le chauffe-eau est COUPE le jour et chauffe pendant une
|
|
// session reseau HC engagee (contacteur ferme), au tarif creux, jamais sur la
|
|
// batterie. Securite eau chaude : moins de ECS_EAU_FROIDE_KWH en 24 h -> il
|
|
// chauffe quand meme. Energie 24 h illisible -> aucune restriction (le
|
|
// comportement d'avant revient, jamais un ballon froid par panne de capteur).
|
|
// Jour normal : le chauffe-eau coupe par cette regle est remis sous tension.
|
|
const ECS_EAU_FROIDE_KWH = 1.5;
|
|
// Douches a 06:00 et 20:00 tous les jours (proprietaire, 07/10) : couper toute
|
|
// la journee laisserait l'eau froide a 20 h. Un jour faible, il chauffe donc
|
|
// AUSSI sur un vrai surplus solaire (jamais sur la batterie), et a partir de
|
|
// 16 h s'il n'a presque pas chauffe depuis 07:24 (securite de la douche du soir).
|
|
const ECS_SOIR_DEBUT_H = 16;
|
|
const ECS_SOIR_FIN_H = 20;
|
|
const {
|
|
ecsNuit = false, hcSessionCommitted = false, ecs24hKwh = NaN, ecsJourKwh = NaN,
|
|
offJourFaiblePrev = false, pvPowerW = 0, autoPowerW = 1200,
|
|
} = normalized;
|
|
let offJourFaible = false;
|
|
let jourFaibleState = 'none';
|
|
let coupeEnChauffe = null; // horodatage a ecrire si la coupure tombe en pleine chauffe
|
|
if (advisorEnabled && ecsNuit) {
|
|
const energieConnue = Number.isFinite(ecs24hKwh);
|
|
const eauFroide = energieConnue && ecs24hKwh < ECS_EAU_FROIDE_KWH;
|
|
const heuresCreusesEdf = hcSessionCommitted && gridIsOn;
|
|
// Surplus reel : sous tension, la production couvre la maison (chauffe-eau
|
|
// compris) ; hors tension, il reste de quoi l'alimenter. Reseau ouvert.
|
|
const solaireCouvre = !gridIsOn && (waterHeaterSwitchOn
|
|
? pvPowerW >= houseAcPowerW
|
|
: pvPowerW - houseAcPowerW >= autoPowerW);
|
|
const heure = Number.isFinite(now.hour) ? now.hour : -1;
|
|
// 07/10 : coupe en PLEINE CHAUFFE a la fin des HC de midi (BOOST encore a 2,3 kW
|
|
// a 14:24) -> l'eau n'est pas a 65 °C : il reprend aussi de 16 h a 20 h.
|
|
const coupeEnChauffeAujourdhui = (input.coupe_en_chauffe || '').startsWith(now.date || '?');
|
|
const securiteSoir = heure >= ECS_SOIR_DEBUT_H && heure < ECS_SOIR_FIN_H
|
|
&& (!(Number.isFinite(ecsJourKwh) && ecsJourKwh >= ECS_EAU_FROIDE_KWH) || coupeEnChauffeAujourdhui);
|
|
const veutChauffer = !energieConnue || heuresCreusesEdf || eauFroide || solaireCouvre || securiteSoir;
|
|
offJourFaible = true;
|
|
jourFaibleState = !energieConnue ? 'jour_faible_energie_illisible'
|
|
: heuresCreusesEdf ? 'jour_faible_chauffe_hc_edf'
|
|
: eauFroide ? 'jour_faible_eau_froide'
|
|
: securiteSoir ? 'jour_faible_securite_soir'
|
|
: solaireCouvre ? 'jour_faible_surplus_solaire'
|
|
: 'jour_faible_coupe';
|
|
if (veutChauffer !== waterHeaterSwitchOn) {
|
|
powerSwitchAction = veutChauffer ? 'turn_on' : 'turn_off';
|
|
powerSwitchDesiredOn = veutChauffer;
|
|
// Coupure alors qu'il chauffait (puissance mesuree au-dessus de l'arret).
|
|
if (!veutChauffer && Math.max(observedPowerW, normalized.observedPowerPrevW ?? 0) > 150) {
|
|
coupeEnChauffe = `${now.date} ${String(now.hour).padStart(2, '0')}:${String(now.minute).padStart(2, '0')}`;
|
|
}
|
|
} else {
|
|
powerSwitchAction = 'none';
|
|
powerSwitchDesiredOn = waterHeaterSwitchOn;
|
|
}
|
|
forcedOffByAdvisor = false;
|
|
forcedOffReason = '';
|
|
protectionState = jourFaibleState;
|
|
state = jourFaibleState;
|
|
reason.push(
|
|
jourFaibleState === 'jour_faible_chauffe_hc_edf' ? 'Jour faible : eau chaude en heures creuses sur EDF (session reseau engagee)'
|
|
: jourFaibleState === 'jour_faible_eau_froide' ? `Jour faible mais seulement ${round1(ecs24hKwh)} kWh en 24 h : chauffe pour garantir l eau chaude`
|
|
: jourFaibleState === 'jour_faible_energie_illisible' ? 'Jour faible, energie 24 h illisible : aucune restriction'
|
|
: jourFaibleState === 'jour_faible_securite_soir' ? 'Jour faible : chauffe pour la douche de 20 h (peu chauffe depuis 07:24)'
|
|
: jourFaibleState === 'jour_faible_surplus_solaire' ? 'Jour faible : surplus solaire reel, il chauffe sans toucher la batterie'
|
|
: 'Jour faible : chauffe-eau coupe, il chauffera en heures creuses sur EDF'
|
|
);
|
|
} else if (advisorEnabled && offJourFaiblePrev && !waterHeaterSwitchOn) {
|
|
powerSwitchAction = 'turn_on';
|
|
powerSwitchDesiredOn = true;
|
|
jourFaibleState = 'jour_normal_remise_sous_tension';
|
|
protectionState = jourFaibleState;
|
|
state = jourFaibleState;
|
|
reason.push('Jour normal : chauffe-eau remis sous tension (il chauffe au solaire)');
|
|
}
|
|
|
|
// =====================================================
|
|
// ALERTE « PASSE EN BOOST » (07/10/2026, proprietaire)
|
|
// =====================================================
|
|
// En heures creuses sur EDF, le ballon doit consommer AU MAXIMUM toute la
|
|
// fenetre (douches 06:00 et 20:00 a 65 °C). Constat du 07/10 a midi : en AUTO
|
|
// il etait ARRETE (0-36 W) alors que l'eau n'etait pas a 65 °C — passe en BOOST
|
|
// a 12:35 il est reparti a 1 235 W. AUTO s'arrete plus bas : 0 W ne prouve
|
|
// donc PAS une eau chaude. Regle : 15 min apres le debut de la fenetre, session
|
|
// engagee, contacteur ferme, ballon sous tension, et la puissance de la
|
|
// RESISTANCE jamais atteinte depuis le debut de la fenetre -> on le SIGNALE.
|
|
// Memoire de la fenetre (puissance max vue) dans le helper lui-meme ; le
|
|
// concierge n'envoie que `active: true`, une fois par `since` (debut de la
|
|
// fenetre). Limite assumee : ballon deja a 65 °C en BOOST = 0 W aussi, donc un
|
|
// message pour rien au plus par fenetre (aucune temperature dans HA).
|
|
const ALERTE_DELAI_MIN = 15;
|
|
const seuilResistanceW = ((normalized.greenPowerW ?? 620) + (normalized.autoPowerW ?? 1200)) / 2;
|
|
const minNow = Number.isFinite(now.minNow) ? now.minNow : null;
|
|
const fenetreHc = (normalized.hcWindows || []).find((w) => minNow !== null && (
|
|
w.debut <= w.fin ? (minNow >= w.debut && minNow < w.fin) : (minNow >= w.debut || minNow < w.fin)));
|
|
let alerteChauffeEau = '';
|
|
if (fenetreHc) {
|
|
const depuisDebutMin = (minNow - fenetreHc.debut + 1440) % 1440;
|
|
const h = String(Math.floor(fenetreHc.debut / 60)).padStart(2, '0') + ':' + String(fenetreHc.debut % 60).padStart(2, '0');
|
|
const jourFenetre = (fenetreHc.debut > fenetreHc.fin && minNow < fenetreHc.fin)
|
|
? new Date(nowMs - 86400000).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) : now.date;
|
|
const since = `${jourFenetre} ${h}`;
|
|
let prev = null;
|
|
try { prev = JSON.parse(input.alerte_prev || ''); } catch { prev = null; }
|
|
const maxPrev = prev && prev.since === since && Number.isFinite(prev.max_w) ? prev.max_w : 0;
|
|
const maxW = Math.round(Math.max(maxPrev, waterHeaterSwitchOn ? observedPowerW : 0));
|
|
const active = depuisDebutMin >= ALERTE_DELAI_MIN && hcSessionCommitted && gridIsOn
|
|
&& waterHeaterSwitchOn && maxW < seuilResistanceW;
|
|
alerteChauffeEau = JSON.stringify({
|
|
active, since, fenetre: fenetreHc.nom, max_w: maxW, puissance_w: Math.round(observedPowerW),
|
|
...(active ? { message: `🚿 Heures creuses EDF : le chauffe-eau ne chauffe pas a pleine puissance (${Math.round(observedPowerW)} W). Si tu n'es pas en BOOST, passe-le pour avoir 65 °C a ${fenetreHc.douche}.` } : {}),
|
|
});
|
|
}
|
|
|
|
return [{
|
|
json: {
|
|
...root,
|
|
meta: {
|
|
...(root.meta ?? {}),
|
|
node: 'ecs_final_arbitration_and_protection'
|
|
},
|
|
finalDecision: {
|
|
recommendedMode,
|
|
adviceColor,
|
|
state,
|
|
reason,
|
|
baseRecommendedMode,
|
|
baseAdviceColor,
|
|
baseState,
|
|
selectedLoadKwh,
|
|
selectedProjectedSoc,
|
|
selectedBudgetMarginKwh,
|
|
legacyAdviceOk,
|
|
mismatchActive,
|
|
mismatchSeverity,
|
|
mismatchSinceAtEffective,
|
|
mismatchSinceMsEffective,
|
|
mismatchDurationMin,
|
|
pendingUserAction,
|
|
firstMismatchRun,
|
|
realConflictNow,
|
|
forceOffNow,
|
|
restoreEnergyOk,
|
|
restoreNow,
|
|
forcedOffByAdvisor,
|
|
forcedOffReason,
|
|
forcedOffAt,
|
|
protectionState,
|
|
powerSwitchAction,
|
|
powerSwitchDesiredOn,
|
|
offJourFaible,
|
|
jourFaibleState,
|
|
alerteChauffeEau,
|
|
coupeEnChauffe
|
|
}
|
|
}
|
|
}]; |