
Tässä ohjeessa rakennetaan käyttöveden DHW-ohjauslogiikka, joka hyödyntää PV-ylijäämää ja hintaikkuna-fallbackia.
Tavoite
Kotitalouden käyttövesi riittää yhdestä lämmityksestä per päivä — noin 50 minuuttia, keskiteho noin 2 kW. Tavoite on ajoittaa tämä lämmitys mahdollisimman halvaksi ja vihreäksi:
- Ensisijainen: PV-ylijäämä — jos kello on yli 14:00 ja verkkovienti ylittää 2 500 W, käynnistetään lämmitys aurinkosähköllä
- Fallback: hintaikkuna — jos PV-lämmitystä ei tapahdu klo 15 mennessä, lämmitys käynnistetään ikkunan 15–18 aikana silloin kun nykyinen vartti on järjestelmän halpa-kynnyksen alapuolella
Thermia Calibra 12 -lämpöpumpun boost-tila (SG1+SG2 molemmat ON) käynnistää käyttövesijakson välittömästi.
Arkkitehtuuri
EnergyHubin periaatteen mukaan jokainen kerros tekee vain oman asiansa:
Thermia Modbus (rekisteri 1: running_priority)
↓
HA MQTT-telemetria (energyhub/telemetry/heatpump)
↓ running_priority: 3 = DHW, 4 = Heat, 7 = Legionella
Node-RED State Collector
↓ m_hp_hot_water_active, m_hp_running_priority
Priority Resolver → hpMode: boost
↓
HA command receiver (energyhub/command/hp/mode)
↓
Shelly Pro 2: SG1=ON, SG2=ON → Thermia Boost
Shelly EM3 mittaa Thermian todellisen tehon — toteuma varmistetaan mittaamalla, ei pelkällä komentosignaalilla. Tämä periaate — komento ei ole sama kuin toteuma — on EnergyHubin ohjauslogiikan kulmakivi, ja siihen palataan tämän ohjeen lopussa.
Thermian Smart Grid -ohjaus
Thermia Calibra 12:n SG-releet toimivat näin:
| SG1 | SG2 | Tila |
|---|---|---|
| OFF | OFF | Normal |
| ON | OFF | EVU (block) |
| OFF | ON | Comfort |
| ON | ON | Boost |
Boost käynnistää käyttövesijakson välittömästi ohittaen normaalin prioriteettijonon.
Tärkeä havainto Modbus-rekistereistä
Thermian virallisen dokumentaation (Modbus protocol for Mega, Genesis platform, v12.00) footnote *1 mukaan rekisterin 1 (Currently running: First prioritised demand) arvot ovat:
- 1 = Manual operation
- 2 = Defrost
- 3 = Hot water
- 4 = Heat
- 5 = Active Cooling
- 6 = Pool
- 7 = Anti legionella
- 98 = Standby
- 99 = No demand
Node-RED tunnistaa käyttövesijakson suoraan tästä arvosta:
javascript
const runningPriority = payload.running_priority ?? 0;
global.set('m_hp_running_priority', runningPriority);
global.set('m_hp_hot_water_active', runningPriority === 3);
global.set('m_hp_legionella_active', runningPriority === 7);
HA-konfiguraatio
Heatpump-telemetria (40_optimization.yaml)
MQTT-payload lähettää käyttöveden lämpötilan ja running_priority-arvon minuutin välein. Huomaa että arvot lähetetään null-arvona, jos sensori on unknown tai unavailable — ei nollana:
yaml
topic: "energyhub/telemetry/heatpump"
retain: false
payload: >
{% set outdoor = states('sensor.modbus_m_hp_outdoor_temp_c') %}
{% set tw = states('sensor.modbus_m_hp_tap_water_top_c') %}
{% set supply = states('sensor.modbus_m_hp_supply_c') %}
{% set ret = states('sensor.modbus_m_hp_return_c') %}
{% set setp = states('sensor.modbus_m_hp_supply_setpoint_c') %}
{% set comp = states('sensor.modbus_m_hp_compressor_speed_pct') %}
{% set rp = states('sensor.modbus_m_hp_running_first_priority') %}
{% set imm = states('sensor.modbus_m_hp_immersion_heater_step') %}
{% set shelly = states('sensor.m_hp_shelly_total_power_w') %}
{% set hd = states('sensor.modbus_m_hp_heat_demand_pct') %}
{% set dstart = states('sensor.modbus_m_hp_dhw_start_setpoint_c') %}
{% set dstop = states('sensor.modbus_m_hp_dhw_stop_setpoint_c') %}
{
"outdoor_c": {{ outdoor | float | round(1) if is_number(outdoor) else 'null' }},
"tap_water_c": {{ tw | float | round(1) if is_number(tw) else 'null' }},
"supply_c": {{ supply | float | round(1) if is_number(supply) else 'null' }},
"return_c": {{ ret | float | round(1) if is_number(ret) else 'null' }},
"setpoint_c": {{ setp | float | round(1) if is_number(setp) else 'null' }},
"compressor_pct": {{ comp | float | round(1) if is_number(comp) else 'null' }},
"running_priority": {{ rp | int if is_number(rp) else 'null' }},
"immersion_active": {{ 1 if (is_number(imm) and imm | int > 0) else 0 }},
"shelly_total_w": {{ shelly | float | round(0) if is_number(shelly) else 'null' }},
"heat_demand_pct": {{ hd | float | round(1) if is_number(hd) else 'null' }},
"dhw_start_setpoint_c": {{ dstart | float | round(1) if is_number(dstart) else 'null' }},
"dhw_stop_setpoint_c": {{ dstop | float | round(1) if is_number(dstop) else 'null' }},
"ts": "{{ now().isoformat() }}"
}
Kaksi tärkeää huomiota:
retain: false — mittausdata ei koskaan retainoida. Tämä on EnergyHubin MQTT-periaate: telemetriatopicit ovat tuoreita tilannekuvia, eivät pysyviä totuuksia. Retained-telemetria johtaisi siihen että uudelleenkäynnistyksen jälkeen järjestelmä lukee vanhan arvon ikään kuin se olisi tuore.
null, ei 0, puuttuvalle arvolle — yleinen sudenkuoppa on suodattaa unknown/unavailable nollaksi (| float(0), | int(0)). Tällöin Node-RED vastaanottaa täysin validin nollan, eikä myöhempi payload-validointi voi erottaa sitä oikeasta mittauksesta. Erityisen vaarallinen on running_priority | int(0), koska 0 ei ole dokumentoitu Thermian tila lainkaan. Lähettämällä null puuttuva arvo pysyy tunnistettavana: Node-RED voi säilyttää edellisen tunnetun arvon sen sijaan että tulkitsisi nollan todeksi. Tämä on sama periaate kuin telemetrian payload-validoinnissa — virheellinen tai puuttuva mittaus ei saa muuttua näennäisen validiksi arvoksi.
Shelly-ohjaus (31_load_heatpump.yaml)
Boost edellyttää molemmat releet ON (SG1+SG2):
yaml
- conditions:
- condition: state
entity_id: input_select.c_hp_mode
state: "boost"
sequence:
- action: switch.turn_on
target:
entity_id: switch.shellypro2_ec62609153c8_output_0 # SG1
- action: switch.turn_on
target:
entity_id: switch.shellypro2_ec62609153c8_output_1 # SG2
Node-RED DHW-logiikka
Parametrit (EH Parameters)
javascript
// DHW käyttövesiohjaus — ajat Suomen aikavyöhykkeessä
global.set('p_dhw_pv_start_hour', 14); // Suomen aikaa
global.set('p_dhw_pv_min_export_w', 2500);
global.set('p_dhw_pv_trigger_c', 52.0); // PV-boost vain jos vesi alle tämän
global.set('p_dhw_price_window_start', 15); // Suomen aikaa
global.set('p_dhw_price_window_end', 18); // Suomen aikaa
global.set('p_dhw_price_trigger_c', 55.0); // hintaboost vain jos vesi alle tämän
global.set('p_dhw_boost_timeout_min', 40);
global.set('p_dhw_confirm_power_w', 1200);
global.set('p_dhw_confirm_delay_ms', 120000);
Lämpötilarajojen logiikka: PV-triggerin raja (52 °C) on tiukempi kuin hintaikkunan (55 °C). PV-ylijäämällä lämmitetään vain jos vesi on aidosti viilentynyt — ilmainen energia ei ole peruste lämmittää jo lämmintä vettä. Hintaikkuna taas on päivän viimeinen mahdollisuus, joten sen raja on löysempi: mieluummin varmistetaan lämmin vesi illaksi hieman korkeammalla rajalla kuin jätetään lämmittämättä. Rajojen ero muodostaa samalla suojakerroksen ulkoisia lämmityksiä vastaan — tästä konkreettinen esimerkki kehitysaskelissa.
Lue järjestelmän tila
DHW-laskenta sijoitetaan trueSolarExcessW-laskennan jälkeen, jotta PV-ylijäämä on käytettävissä:
javascript
s.trueSolarExcessW = gridExportW;
// DHW-tila — lasketaan trueSolarExcessW:n jälkeen
s.hpHotWaterActive = global.get('m_hp_hot_water_active') ?? false;
s.hpLegionellaActive = global.get('m_hp_legionella_active') ?? false;
s.hpImmersionActive = global.get('m_hp_immersion_active') ?? false;
s.hpShellyTotalW = global.get('m_hp_shelly_total_w') ?? 0;
// Suomen aikavyöhyke — toimii sekä kesä- että talviajassa
s.hour = parseInt(new Intl.DateTimeFormat('fi-FI', {
timeZone: 'Europe/Helsinki',
hour: 'numeric',
hour12: false
}).format(new Date()), 10);
// Päivämäärä Suomen ajassa — käytetään päivätilan nollaukseen (ks. alla)
s.dateFi = new Intl.DateTimeFormat('fi-FI', {
timeZone: 'Europe/Helsinki',
year: 'numeric', month: '2-digit', day: '2-digit'
}).format(new Date());
// PV-trigger
s.isDhwPvTrigger =
s.hour >= (global.get('p_dhw_pv_start_hour') ?? 14) &&
s.tapWaterC < (global.get('p_dhw_pv_trigger_c') ?? 52) &&
s.trueSolarExcessW > (global.get('p_dhw_pv_min_export_w') ?? 2500);
// Hintaikkuna-trigger
s.isDhwPriceWindow =
s.hour >= (global.get('p_dhw_price_window_start') ?? 15) &&
s.hour < (global.get('p_dhw_price_window_end') ?? 18);
Päivätilan nollaus aikaleimalla — ei ajastettua nollausta
Päivittäin nollattavat liput (dhw_heated_today, dhw_failed_today) on houkuttelevaa nollata yhdellä ajastetulla inject-nodella esimerkiksi keskiyöllä. Tämä on kuitenkin hauras ratkaisu: jos Node-RED käynnistyy uudelleen juuri nollaushetkellä — päivitys, kontin uudelleenkäynnistys, sähkökatko — ajastettu laukaisu jää väliin, eikä lippu nollaudu. Seuraus on hiljainen: dhw_heated_today jää edellisen päivän true-arvoon, eikä järjestelmä lämmitä käyttövettä lainkaan seuraavana päivänä. Mikään ei kaadu, mitään ei näy lokissa — lämmitys vain jää tekemättä.
Robustimpi ratkaisu on tallentaa nollauksen yhteydessä päivämäärä ja verrata sitä joka syklillä. Kun päivä vaihtuu (Suomen aikaa), liput nollataan. Tämä selviää uudelleenkäynnistyksestä, koska tarkistus tehdään jokaisella ajolla, ei kerran vuorokaudessa:
javascript
// Päivätilan nollaus aikaleiman perusteella (selviää restartista).
// Sijoitetaan Priority Resolverin alkuun, ennen DHW-logiikkaa.
const dhwHeatedDate = global.get('dhw_heated_date') || '';
if (dhwHeatedDate !== s.dateFi) {
// Päivä vaihtui (tai ensimmäinen ajo) — nollataan päivän tila
global.set('dhw_heated_today', false);
global.set('dhw_failed_today', false);
global.set('dhw_cancelled_today', false);
global.set('dhw_boost_active', false);
global.set('dhw_boost_since', 0);
global.set('dhw_hot_water_seen', 0);
global.set('dhw_pending_source', null);
global.set('dhw_heated_date', s.dateFi);
node.log('DHW: päivä vaihtui (' + s.dateFi + '), päivätila nollattu');
}
Sama *_date-malli sopii kaikille päivittäin nollattaville lipuille koko järjestelmässä. Periaate: älä luota ajastettuun kertatapahtumaan tilan nollaamisessa, vaan johda tila aina nykyhetkestä.
Priority Resolver — DHW-lohko
javascript
// === DHW / HP ===
const dhwBoostActive = global.get('dhw_boost_active') ?? false;
const dhwBoostSince = global.get('dhw_boost_since') ?? 0;
const dhwHotStart = global.get('dhw_hot_water_seen') ?? 0;
const dhwHeatedToday = global.get('dhw_heated_today') ?? false;
const dhwFailedToday = global.get('dhw_failed_today') ?? false;
const dhwCancelledToday = global.get('dhw_cancelled_today') ?? false;
const dhwTimeoutMs = (global.get('p_dhw_boost_timeout_min') ?? 40) * 60 * 1000;
const confirmPowerW = global.get('p_dhw_confirm_power_w') ?? 1200;
const confirmDelayMs = global.get('p_dhw_confirm_delay_ms') ?? 120000;
const nowMs = Date.now();
// Onko Thermia ottanut DHW-jakson vastaan?
const heatingConfirmed =
s.hpHotWaterActive ||
(s.hpShellyTotalW > confirmPowerW && dhwBoostSince > 0 &&
(nowMs - dhwBoostSince) > confirmDelayMs);
if (heatingConfirmed && dhwBoostActive && dhwHotStart === 0) {
global.set('dhw_hot_water_seen', nowMs);
}
if (dhwBoostActive) {
if (!s.cap.hpBoostAllowed || s.hpLegionellaActive) {
// Capability poistui kesken boostin (safety, EVU, manuaalinen esto) tai
// legionella-ajo alkoi. Keskeytetään boost siististi. Tämä EI ole
// lämmityksen epäonnistuminen vaan ulkoinen keskeytys, joten käytetään
// omaa lippua (dhw_cancelled_today) — ei dhw_failed_today.
decisions.hpMode = 'normal';
reasons.hpMode = !s.cap.hpBoostAllowed
? 'DHW: boost keskeytetty, hpBoostAllowed=false'
: 'DHW: boost keskeytetty, legionella-ajo käynnissä';
global.set('dhw_boost_active', false);
global.set('dhw_boost_since', 0);
global.set('dhw_hot_water_seen', 0);
global.set('dhw_cancelled_today', true);
node.warn(reasons.hpMode);
} else if (heatingConfirmed && dhwHotStart > 0 && (nowMs - dhwHotStart) > confirmDelayMs) {
// Thermia otti työn — palautetaan normal
decisions.hpMode = 'normal';
reasons.hpMode = `DHW: Thermia lämmittää, boost pois (${s.hpShellyTotalW}W)`;
global.set('dhw_boost_active', false);
global.set('dhw_boost_since', 0);
global.set('dhw_hot_water_seen', 0);
global.set('dhw_heated_today', true);
global.set('dhw_last_source', global.get('dhw_pending_source') || 'pv');
} else if (dhwBoostSince && (nowMs - dhwBoostSince) > dhwTimeoutMs) {
// Timeout — Thermia ei reagoinut. Merkitään päivän yritys epäonnistuneeksi,
// jotta sama trigger ei käynnistä boostia heti uudelleen samana päivänä.
decisions.hpMode = 'normal';
reasons.hpMode = 'DHW: boost timeout, palautus normal';
global.set('dhw_boost_active', false);
global.set('dhw_boost_since', 0);
global.set('dhw_failed_today', true);
node.warn('DHW boost timeout — Thermia ei reagoinut, päivän yritys merkitty epäonnistuneeksi');
} else {
decisions.hpMode = 'boost';
reasons.hpMode = `DHW: boost päällä ${Math.round((nowMs-dhwBoostSince)/60000)} min`;
}
} else if (
!dhwHeatedToday && !dhwFailedToday && !dhwCancelledToday && s.cap.hpBoostAllowed &&
!s.hpImmersionActive && !s.hpLegionellaActive && s.isDhwPvTrigger
) {
// PV-trigger
decisions.hpMode = 'boost';
reasons.hpMode = `DHW PV: klo ${s.hour}, vesi ${s.tapWaterC}°C, vienti ${s.trueSolarExcessW.toFixed(0)}W`;
global.set('dhw_boost_active', true);
global.set('dhw_boost_since', nowMs);
global.set('dhw_hot_water_seen', 0);
global.set('dhw_pending_source', 'pv');
} else if (
!dhwHeatedToday && !dhwFailedToday && !dhwCancelledToday && s.cap.hpBoostAllowed &&
!s.hpImmersionActive && !s.hpLegionellaActive && s.isDhwPriceWindow &&
s.tapWaterC < (global.get('p_dhw_price_trigger_c') ?? 55) &&
s.isCheapSlot
) {
// Hintaikkuna-fallback
decisions.hpMode = 'boost';
reasons.hpMode = `DHW hinta: klo ${s.hour}, vesi ${s.tapWaterC}°C`;
global.set('dhw_boost_active', true);
global.set('dhw_boost_since', nowMs);
global.set('dhw_hot_water_seen', 0);
global.set('dhw_pending_source', 'price');
} else {
// Normaali HP-logiikka jatkuu...
}
Huomaa kaksi suojaa trigger-ehdoissa: !dhwHeatedToday estää toisen lämmityksen onnistuneen jälkeen, ja !dhwFailedToday estää loputtoman uusintayrityksen, jos lämmitys epäonnistui timeoutin kautta. Ilman jälkimmäistä boost voisi käynnistyä uudelleen heti timeoutin jälkeen niin kauan kuin trigger-ehdot (aika, lämpötila, vienti) pysyvät voimassa — kuluttaen energiaa ja jättäen boostin päälle pitkiksi ajoiksi ilman tulosta. Jos haluat sallia uuden yrityksen samana päivänä esimerkiksi manuaalisesti, nollaa dhw_failed_today erikseen.
Käyttöönottotulos
Ensimmäinen automaattinen DHW PV-ohjaus 9.6.2026:
| Mittaus | Arvo | Vahvistustapa |
|---|---|---|
| Lähtötilanne | 48.5 °C | tap_water_c (Modbus) |
| Lopputilanne | 57.8 °C | tap_water_c (Modbus) |
| Lähde | PV-ylijäämä | trueSolarExcessW |
| Teho Shellyllä mitattu | ~2 650 W | Shelly EM3 (toteuma) |
| Boost-kesto | ~27 min | dhw_boost_since |
| dhw_heated_today | True | running_priority=3 + teho |
| dhw_last_source | pv | dhw_pending_source |
Thermia jatkoi lämmitysjakson loppuun itsenäisesti boost-releiden avauduttua — tämä on tarkoituksenmukaista. Boost toimii ”pyyntönä” Thermialle, ei koko lämmitysjakson ylläpitäjänä.
Mitä dhw_heated_today tarkoittaa tässä versiossa: lippu asetetaan kun lämmitysjakso on onnistuneesti käynnistynyt — eli Thermia on ottanut DHW-jakson vastaan (running_priority = 3) tai teho on noussut vahvistusrajan yli. Se ei siis vielä takaa että vesi saavutti lopullisen tavoitelämpötilan; jos Thermia keskeyttäisi jakson kesken, lippu olisi silti päällä. Käytännössä Thermia vie aloitetun käyttövesijakson loppuun, joten ero on pieni, mutta se on hyvä tiedostaa. Lopullisen lämpötilan varmistus on listattu kehitysaskeleisiin.
Opitut asiat
Rekisteriarvo 3 = Hot water — Thermian dokumentaatio osoittaa selvästi että running_priority arvo 3 on käyttövesi (ei 2 kuten aluksi oletettiin). Tarkista aina valmistajan dokumentaatio ennen koodausta.
Aikavyöhyke koodiin, ei parametreihin — Node-RED pyörii UTC-ajassa. Ratkaisu: Intl.DateTimeFormat API Suomen ajan laskemiseen koodissa. Näin parametrit ilmoitetaan Suomen ajassa ja logiikka toimii oikein sekä kesä- että talviajassa ilman manuaalista UTC-offsetin ylläpitoa. Tämä koskee myös päivämäärää: päivän vaihtumisen tunnistuksessa käytetään Suomen aikaa, ei UTC:tä — muuten päivätila nollautuisi väärään aikaan (Suomen aikaa klo 02–03 yöllä).
Päivätila johdetaan nykyhetkestä, ei ajastetusta tapahtumasta — alkuperäinen toteutus nollasi dhw_heated_today-lipun ajastetulla inject-nodella keskiyöllä. Tämä ei selviä uudelleenkäynnistyksestä: jos Node-RED on alhaalla nollaushetkellä, lippu jää päälle ja koko seuraavan päivän lämmitys jää tekemättä — hiljaa, ilman virhettä. Korjaus on *_date-malli: tallennetaan nollauspäivämäärä ja verrataan sitä joka syklillä. Sama periaate koskee kaikkia päivittäin nollattavia tiloja.
Epäonnistunut lämmitys merkitään, jotta sitä ei yritetä loputtomasti — jos boost-timeout laukeaa eikä Thermia reagoinut, pelkkä dhw_boost_active = false ei riitä: trigger-ehdot ovat yhä voimassa, joten boost käynnistyisi heti uudelleen. dhw_failed_today-lippu katkaisee tämän silmukan. Yritys joko onnistuu (dhw_heated_today) tai epäonnistuu (dhw_failed_today) — kumpikin estää uuden yrityksen samana päivänä.
Keskeytys ja epäonnistuminen ovat eri asioita — jos boost-jakso keskeytyy ulkoisesta syystä (capability hpBoostAllowed poistuu, safety-tila, legionella-ajo alkaa), se ei ole lämmityksen epäonnistuminen vaan ulkoinen keskeytys. Siksi käytetään omaa dhw_cancelled_today-lippua eikä dhw_failed_today-lippua. Erottelu on tärkeä myöhempää diagnostiikkaa varten: ”Thermia ei reagoinut pyyntöön” on eri ongelma kuin ”ylempi prioriteetti keskeytti jakson”. Ongoing boost -haara tarkistaa nämä ehdot jokaisella syklillä, joten boost ei jää päälle vaikka tilanne muuttuu kesken jakson.
Legionella-ajon aikana ei ohjata boostia — kun running_priority === 7 (anti-legionella), Thermia tekee oman korkean lämpötilan jaksonsa. EnergyHubin ei pidä ohjata boostia tämän päälle: trigger-ehdoissa on !s.hpLegionellaActive, ja ongoing boost keskeytetään jos legionella alkaa kesken oman jakson.
Ei HA-template-sensoreita — template-sensorit paketeissa eivät toimi !include_dir_named-direktiivin kanssa tässä HA-versiossa. Tilamuunnokset tehdään suoraan Node-RED State Collectorissa — tämä on myös arkkitehtuurisesti puhtaampi ratkaisu.
Puuttuva arvo on null, ei nolla — HA:n MQTT-templateissa unknown/unavailable-tila ei saa suodattua nollaksi (| float(0)). Nolla on validi luku, jonka Node-RED tulkitsee oikeaksi mittaukseksi — esimerkiksi running_priority = 0 ei ole edes dokumentoitu Thermian tila. Lähettämällä null puuttuva arvo pysyy tunnistettavana ja vastaanottava pää voi säilyttää edellisen tunnetun arvon. Tämä on sama periaate kuin telemetrian payload-validoinnissa: virheellinen mittaus ei saa naamioitua näennäisen validiksi.
Boost pois heti kun Thermia ottaa työn — boost-signaali on pyyntö, ei ylläpitäjä. Kun running_priority muuttuu arvoon 3, boost palautetaan normaaliksi ja Thermia hoitaa loput itse.
Parametrit, koodin oletusarvot ja dokumentaatio kertovat samaa tarinaa — tämän ohjeen aiemmassa versiossa parametrilohko asetti lämpötilarajaksi 48 °C mutta koodin ??-fallbackit ja esimerkkianalyysi käyttivät arvoja 52/55 °C. Ristiriita on vaarallinen juuri siksi että ??-fallback peittää sen: jos globaali jää jostain syystä asettamatta, järjestelmä toimii eri rajoilla kuin parametrilohko väittää — hiljaa. Sääntö: fallback-arvon on aina oltava sama kuin parametrilohkon asettama arvo, ja dokumentaation esimerkkien on perustuttava tuotantoarvoihin.
Komento ei ole sama kuin toteuma — tämä on koko ohjauslogiikan tärkein periaate. Boost-releen kytkeminen päälle ei vielä todista, että Thermia oikeasti lämmittää vettä. Siksi DHW-jakso vahvistetaan kahdesta lähteestä: running_priority-arvosta (3 = DHW) ja Shelly EM3:n mittaamasta tehosta (shelly_total_w > 1200 W). Vasta kun toteuma on varmistettu, jakso merkitään tehdyksi. Sama ajattelu pätee koko EnergyHubiin: lataus, lämmitys ja tehonrajoitus varmistetaan fyysisestä mittauksesta, ei pelkästä komennon lähetyksestä.
Seuraavat kehitysaskeleet
Saunapäivän toinen lämmitys — kun saunaohjauksen rautapuoli on kunnossa, lisätään ehto saunan käynnistymiselle; tarvitsee erillisen dhw_sauna_extra_allowed-flagin joka ohittaa dhw_heated_today-, dhw_failed_today- ja dhw_cancelled_today-liput
Yksi lämmitys per päivä — nykyinen strategia on tarkoituksella konservatiivinen; jos sauna on käynyt, toinen lämmitys vaatii erillisen logiikan
Toteuman täysi validointi (lämpötilan kehitys) — nykyinen vahvistus tarkistaa että teho nousee (shelly_total_w > 1200 W). Tämä voi teoriassa antaa väärän positiivisen, jos lämpöpumppu kuluttaa tehoa muuhun kuin käyttöveteen ja running_priority puuttuu. Robustimpi vahvistus vaatisi lisäksi että käyttöveden lämpötila kehittyy oikeaan suuntaan boost-jakson aikana (esim. tap_water_c noussut vähintään 0.5°C aloituksesta). Tällöin myös kesken jäänyt lämmitys (Thermia-vika, väärä SG-tila) havaitaan, ei vain käynnistymättä jäänyt.
Ulkoisen käyttövesijakson tunnistus — jos Thermia lämmittää käyttöveden omalla aikataulullaan, manuaalisesti tai legionella-ajon yhteydessä, EnergyHub ei nykyisellään tiedä että päivän käyttövesi on jo hoidettu, ja voi myöhemmin yrittää turhaa boostia. Yleinen havainto ratkaisisi tämän: jos running_priority === 3 on nähty tai veden lämpötila ylittää tavoitteen ilman omaa boostia, merkitään dhw_heated_today = true lähteellä external. Havaittu tuotannossa: puolipilvisenä saunapäivänä aamusauna lämmitti käyttöveden noin 55 °C:een, mutta koska lämmitys ei tapahtunut EnergyHubin oman boostin kautta, dhw_heated_today jäi arvoon false. Järjestelmä ei siis ”tiennyt” että päivän käyttövesi oli jo hoidettu. Tällä kertaa turha boost ei lauennut, koska trigger-ehtojen lämpötilaraja (PV-trigger vaatii veden alle 52 °C, hintaikkuna alle 55 °C) suojasi: vesi oli jo liian lämmin. Suoja oli kuitenkin sattumanvarainen — jos sauna olisi lämmittänyt veden vain esimerkiksi 53 °C:een, hintaikkuna olisi voinut käynnistää turhan boost-jakson illalla. Tämä on hyvä esimerkki kerroksellisen suunnittelun arvosta (yksi lippu väärässä tilassa, mutta toinen ehto esti virheen) — ja samalla konkreettinen syy toteuttaa ulkoisen lämmityksen tunnistus, jotta suoja ei jää yhden lämpötilarajan varaan.