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