A Home Assistant custom integration (HACS) that computes a compensated outdoor temperature for a heat pump's weather-compensation curve, adjusted for:
- Indoor temperature vs. your target (closes the loop — your heat pump usually only sees outdoor temperature, not how it's actually going indoors)
- Forecast wind speed (extra heat loss)
- Forecast sun / cloud cover (passive solar gain)
- Electricity price via Nordpool (optional — let indoor temperature drift down, within limits you set, during expensive price periods)
ClimateOptimizer publishes a sensor,
sensor.<name>_compensated_outdoor_temperature, with the computed value and a
full attribute breakdown of why it's that value. By default it does not
write to your heat pump directly — you wire that sensor into your heat pump's
own external-temperature input if possible, or using special dedicated
hardware like OhmOnWifi/OhmigoWifi.
Optionally, via Configure, you can also have it push the value itself
every cycle — either to a number.* entity (e.g.
number.nibe_ohmigo_temperature), or directly to an OhmOnWifi/Ohmigo
device's own local API by hostname/IP, bypassing any HA entity entirely —
instead of you having to wire it up with a separate automation. Both are off
by default; see "Optional: push the value automatically" below.
Everything is configured from the Home Assistant UI — no YAML.
Each zone also gets a switch.<name>_active entity. It defaults to off —
in this "learn mode" state, the sensor publishes the raw outdoor temperature
unmodified (no compensation applied at all), while the heuristic (and the RC
shadow model, below) keep computing normally in the background. The
heuristic's actual recommendation is always visible as the
recommended_compensated_outdoor_temp_c attribute, alongside an active: true/false
flag, so you can watch what it would do before switching it on. Flip the
switch on when you're ready to let it actually influence your heat pump. The
switch's state is restored across Home Assistant restarts.
Think of the switch as a training/live toggle, not just a safety cutout. With it off, the published value equals the raw outdoor temperature exactly — a true no-op that can never behave worse than your heat pump's built-in weather-compensation curve did before this integration was installed, which is why off is the safe universal default. But "off" is not purely a fallback: the RC shadow model can only learn your heat pump's gain (how strongly a compensation nudge moves indoor temperature) while the switch is on, because that is the only time a real, deliberate compensation delta is actually applied to excite that signal (see the RC model section). Switching to live mode is therefore also what lets the model calibrate itself to your specific house — it is opt-in training, with the understood trade-off that the heuristic's fixed coefficients are uncalibrated until then and could respond sluggishly or oscillate on some houses while it does. The direction of any correction is always right (negative feedback toward your indoor target); only the magnitude is uncertain before calibration.
sensor.<name>_status reports ok, degraded, or error, with attributes
breaking down each source (outdoor_sensor_ok, indoor_sensor_ok,
wind_forecast_ok, cloud_sun_forecast_ok, price_ok if configured,
last_error). error means the outdoor sensor (the one required source) is
currently unavailable and the main sensor's value has gone stale; degraded
means the update is succeeding but a soft-degraded source (indoor sensor,
wind forecast, cloud/sun forecast, or price) is currently down. Wind and
cloud/sun are tracked separately since not every weather integration
provides both. Unlike every other entity here, this one is always available
— its whole job is to report problems, including when everything else would
otherwise show unavailable.
number.<name>_indoor_target_temperature lets you adjust the target live —
from a dashboard, a schedule, or an automation (e.g. lower it at night or
when away) — without touching the options dialog. It's backed by an
in-memory value rather than a config option, specifically so changing it
doesn't trigger a full reload (which would otherwise reset the RC model's
learning progress every time). Its state is restored across restarts.
Via Configure, there are two independent, headed sections for having
ClimateOptimizer push its value out itself instead of you wiring up a
separate automation — independent meaning both can be set at once and both
get pushed to every cycle, not an either/or choice. Both mirror exactly what
the main sensor is currently publishing — the raw outdoor temperature while
the activation switch is off/learn-mode, the compensated value once it's on
— so this is never a second, independently-gated output. Each channel
separately skips its own repeat push when its value hasn't moved by more
than 0.05°C since the last one it sent, so a real device register isn't
rewritten every cycle for no reason. A failed push on either channel is
logged as a warning and otherwise ignored, independently of the other —
neither ever affects sensor.<name>_compensated_outdoor_temperature itself.
- "OhmOnWifi direct API" (
ohmonwifi_host): the device's hostname or IP — e.g. its mDNS defaultohmonwifi.localfor a stock, unrenamed device, or an IP if you've renamed it or mDNS doesn't resolve reliably on your network. Unset (disabled) by default. When set, every cycle ClimateOptimizer calls the device's own local HTTP API directly (http://<host>/AT/?T=<value>, per Ohmigo's published API doc), with no Home Assistant entity in between. Saving the options dialog does a one-time live check against the device's/infoendpoint and rejects the save with an error if it can't reach it (typo'd address, device off, wrong network) — it does not re-validate on every subsequent update cycle after that. - "Push to a number entity" (
output_number_entity): anumber.*entity belonging to another integration — for examplenumber.nibe_ohmigo_temperatureif you've set up OhmOnWifi as a HA number entity yourself instead of using the direct option above. Every cycle callsnumber.set_valueon it. Unset (disabled) by default. Not validated at save time (HA already guarantees the entity exists, since it's picked from a live entity list).
- Add this repository to HACS as a custom repository (category: Integration), or once published, install directly from HACS.
- Restart Home Assistant.
- Settings → Devices & Services → Add Integration → ClimateOptimizer.
- Pick your indoor temperature sensor, an outdoor temperature sensor (a real
sensor entity — used as the current-temperature baseline, since it's
generally more accurate than a weather service's estimate), a weather
entity (used only for its wind/cloud forecast), an optional Nordpool price
entity, and a starting target indoor temperature (adjustable afterward via
the
number.<name>_indoor_target_temperatureentity, not this dialog). An optional heat-pump power sensor can be added later via Configure — see "Local history logging" below. - Tune coefficients, comfort bounds, and the price feature later via the integration's Configure (options) dialog — no reinstall needed.
The integration ships a small custom Lovelace card,
custom_components/climate_optimizer/www/climate-optimizer-card.js, and
registers it as a frontend resource automatically on startup — there's nothing
to add under Settings → Dashboards → Resources.
It's a diagnostic/demo card, not a polished production widget: it exists to prove every sensor/number/switch/select a zone exposes is reachable from a dashboard, and to make the Phase 2/3 shadow-mode output (RC model params, the MPC plan and its predicted trajectory) inspectable at a glance without digging through entity attributes. Add it to a dashboard with:
type: custom:climate-optimizer-card
entity: sensor.<name>_statusAny single entity belonging to the zone works — the card discovers the rest of
that zone's entities itself via the frontend entity registry, so it stays
correct even if you rename entities, and shows a Feature test: N/20 known ClimateOptimizer entities discovered line as a self-check. All 20 entities
exist regardless of configuration (e.g. the wind/power sensors just read
unavailable or pinned when their optional source isn't set up), so this
should normally read 20/20; a lower count means the card couldn't reach the
frontend entity registry and fell back to showing just the one configured
entity.
compensated_outdoor_temp = raw_outdoor_temp
- k_indoor * (indoor_target - indoor_actual) # colder than target -> read lower -> more heat
- k_wind * wind_speed_m_s # windier -> read lower -> more heat
+ k_sun * solar_effect # sunnier -> read higher -> less heat
+ price_adjustment # expensive price -> read higher -> less heat (optional, bounded)
All coefficients, comfort min/max bounds, and price thresholds are adjustable
in the options flow. The sensor's attributes include a per-term breakdown and a
plain-language reason string so the output is always explainable.
This is intentionally a simple, transparent heuristic, not a black-box model.
At or above a configurable outdoor temperature (heating_cutoff_c, default
18°C, options flow), compensation is suppressed entirely and the sensor
publishes the raw outdoor temperature unmodified — no indoor/wind/sun/price
adjustment at all, not even partial credit. Without this, a cold indoor
reading or a windy day could still push the compensated value below the raw
temperature even when it's already warm outside, which could trick the heat
pump's own curve into calling for heat on a warm day. heating_cutoff_engaged
is exposed as an attribute, and the reason string says so explicitly when it
kicks in. Active cooling (a mirrored curve for reversible heat pumps) is
intentionally out of scope — this only ever stops heating, it never starts
cooling.
A grey-box RC thermal model, fit online from live data via recursive least
squares, runs alongside the heuristic and exposes diagnostic sensors
(thermal time constant, heat-pump gain, solar gain, confidence, prediction
error) — purely for observation. It never influences
compensated_outdoor_temp_c; the heuristic above is still what actually runs.
Because the activation switch gates what's actually applied, the model only
learns heat-pump gain while a real compensation delta is being applied (it
needs real excitation on that signal) — it can still learn the envelope time
constant and solar gain from passive data the rest of the time. A future phase
will use this model for a proper multi-hour cost-optimizing controller once
it's proven accurate against real house data — the heuristic is structured so
that can slot in later without breaking existing sensors/automations.
The heat-pump gain is only added to the model once it's actually been excited. The gain parameter's only driver is the compensation delta that was really applied, which is zero whenever the activation switch is off or the summer heating-cutoff has kicked in (compensated equals raw, so nothing is applied even with the switch on). If the model carried a gain parameter during such an idle stretch — potentially the whole warm half of the year — that never-excited parameter's uncertainty would balloon every cycle (a property of the recursive-least-squares forgetting factor) and, after about two weeks, trip an internal covariance safeguard that then drags down confidence in the envelope and solar parameters that are being learned correctly from passive weather. So the estimator simply doesn't include the gain parameter until the first time a genuinely non-zero applied delta reaches it; at that point the gain dimension is added while every already-learned parameter (time constant, solar, wind) and its confidence is preserved exactly. Until then, the heat-pump gain sensor reads unavailable (honestly "not modelled yet", as distinct from a learned value of zero), and the MPC planner below reports "heat pump has not yet been excited" as its own distinct not-trustworthy reason. Once added, the gain parameter stays for good. (Known limitation, left for future work: if the switch is turned back off for another long idle stretch after gain has been added, that same slow uncertainty build-up can recur on the now-present gain dimension; solving it properly needs selective per-parameter forgetting, a more invasive change deferred on purpose.)
The model's learned state — its parameter estimates, the RLS covariance matrix, whether the gain dimension has been added yet, and the warmup/confidence and accepted/rejected counters — is persisted across Home Assistant restarts and reloads. It's written to HA's local storage (a debounced JSON store keyed by the config entry, so renaming a zone never orphans its learning), reloaded before the first cycle after a restart, and flushed on unload. Without this the estimator reset to a cold-start prior on every restart or deploy, which threw away accumulated learning and — observed in practice — could let the time constant drift up into its clip ceiling after frequent restarts, making a full heating season of learning impossible if restarts were at all common. Persistence is strictly additive and defensive: an empty, corrupt, version-mismatched, or wrong-dimensionality store is silently discarded in favour of a clean cold start, and any storage error is logged and swallowed — it can never break or delay the real published output. The stored state records both whether the wind term is enabled and whether the gain dimension has been added, so the two same-length shapes it can take (envelope + solar + wind, versus envelope + solar + gain) are never confused on reload. Note that toggling the optional wind term (below) still resets learning on purpose, because it changes the estimator's shape and the old saved state no longer matches; states saved by versions before the lazy-gain change are also discarded and cold-started, by design.
For houses expected to be genuinely wind-sensitive — old, leaky, exposed —
enable enable_wind_rc in options to add a 4th estimated parameter,
sensor.<name>_rc_model_wind_gain. It's off by default because for a
typical well-sealed house, wind speed is highly correlated with outdoor
temperature in normal weather data, and a small true wind effect can't be
reliably told apart from that correlation — enabling it just adds estimation
noise for no benefit. A leaky house's true wind sensitivity is large enough
to be statistically distinguishable, which is why this is a per-installation
choice rather than always on or always off. The wind term is an interaction
with the temperature gap ((T_out - T_in) × wind), not a plain additive
term — wind physically can't cause heat loss with no temperature difference
to amplify — and wind speed is normalised by a configurable reference speed
(rc_wind_reference_ms, default 5 m/s) to keep it numerically comparable to
the other terms. Turning this on changes the estimator's dimensionality, so
(like any options change) it triggers a reload and resets learning progress
for a fresh start.
A Model Predictive Control (MPC) planner runs alongside the heuristic and the RC model. Each cycle it uses the RC model's currently learned physical parameters plus multi-hour forecasts (electricity price, outdoor temperature, and — only if the wind term is enabled — wind) to plan a cost-minimising sequence of compensation deltas over a horizon (default 24 h), subject to your hard comfort bounds. It uses receding-horizon control: it re-solves the whole plan every cycle with the latest forecasts and only ever surfaces the first step, discarding the rest.
It is advisory only. Exactly like the RC model, it never influences
compensated_outdoor_temp_c — the heuristic pipeline gated by
switch.<name>_active remains the only thing that actually controls anything.
The MPC planner exists so its recommendations can be observed and evaluated
against reality over time, as groundwork before anyone trusts it to run heating.
The solver is dynamic programming over a discretised indoor-temperature
state (chosen over LP/QP: no scipy dependency, lightweight, and inherently
explainable — you can see exactly which constraint binds). A backward
value-iteration pass computes the cost-to-go at every (time, temperature) node
using the RC model's dynamics; a forward pass from the current indoor
temperature reads off the optimal control sequence. Comfort bounds are enforced
as a dominating penalty, so a feasible plan never violates them; if the house
starts outside the band or can't be held, it returns a least-violating
best-effort plan and flags itself infeasible rather than failing.
The planner is heating-only (it adds heat or coasts, never commands cooling — mirroring the summer-cutoff philosophy). Costs are in relative proxy units (price × °C), useful for ranking plans and reporting savings-vs-baseline, not a currency figure — the absolute thermal scale isn't identifiable from indoor-temperature dynamics alone. Savings are quoted against a myopic "hold the target" baseline, so they reflect both load-shifting (heat banked into the comfort band before price spikes) and energy-minimisation (riding cooler within comfort when price is flat).
An MPC plan is only as good as the RC model under it, and that model hasn't yet
had a long real-data validation run. The planner always computes a plan
(observing it is useful), but marks it not yet trustworthy unless the RC
estimate is both mature (confidence ≥ mpc_min_confidence and enough accepted
samples) and physically plausible (positive envelope time constant, a clearly
negative heat-pump gain, non-negative solar gain, and no parameter pinned at a
clip bound). The point is to never present a plan as reliable when the
underlying model isn't.
sensor.<name>_mpc_recommended_compensation_delta— the recommended first-step delta. Its attributes carry the whole plan: thereasonstring, thebinding_constraint, projected cost/savings, the predicted indoor-temperaturepredicted_trajectory, the full per-stepplan, the trust gate result, and explicit echoes of the RC parameters (gain, tau, solar, wind if enabled) the plan was actually computed from this cycle — so you can troubleshoot why a plan looks the way it does. (The live RC gain / tau / solar / wind sensors from Phase 2 track the same values continuously.)sensor.<name>_mpc_status—ok,not_trustworthy,infeasible,no_forecast,no_data, ormisconfigured, withtrustworthy,binding_constraintand forecast-coverage details in attributes.sensor.<name>_mpc_projected_savings— projected horizon savings vs the hold-target baseline, in relative proxy units.sensor.<name>_mpc_planned_next_indoor_temperature— the indoor temperature the plan predicts at the end of the first step.
Three advisory-only options (Configure dialog): the planning horizon
(mpc_horizon_hours, default 24 h), the assumed heating authority
(mpc_max_heating_delta_c, default 8 °C), and the minimum RC confidence for a
plan to be reported trustworthy (mpc_min_confidence, default 1.0). The
state-space granularity is fixed at sensible internal defaults.
- Solar is not yet forecast over the horizon (that would need per-hour future sun elevation): the planner assumes no solar gain across the horizon, which is the comfort-safe direction (it never counts on free heat it might not get). This is a natural next enhancement.
- If the weather/price forecast is shorter than the horizon, the last value is
held (persistence);
forecast_valid_stepsand thereasonstring report how many leading steps were real forecast. - Because the RC model treats a zero compensation delta as no heat-pump contribution (the pump's own baseline weather curve isn't separately modelled), the plan's absolute predicted temperatures are in the model's reference frame, not real wall-thermometer degrees. The relative decisions (when to spend heat given prices and thermal storage) are what's meaningful — another reason this stays advisory-only for now.
enable_data_logging (options flow) appends one JSON line per update cycle
to /config/climate_optimizer_data/<entry_id>.jsonl — the raw physical
inputs (indoor/outdoor temp, wind, solar effect, price) plus the computed
heuristic/RC/MPC results for that cycle. Purely local; nothing is
transmitted anywhere. This exists because Home Assistant's own recorder
purges history by default (commonly ~10 days) and its long-term statistics
only keep hourly aggregates — too coarse to properly re-fit the RC model or
backtest an MPC change later. With this on, real history survives and can be
replayed offline through a candidate model change without waiting for new
live data. The resolved file path is shown on sensor.<name>_status's
data_log_path attribute whenever logging is on.
Once the active file reaches 10 MB it's rotated: gzipped to a timestamped
sibling (<entry_id>.<UTC timestamp>.jsonl.gz) and a fresh <entry_id>.jsonl
starts logging from empty. Rotated files are never deleted automatically —
past data stays intact, just compressed; zcat/gunzip (or gzip.open in
Python) reads them directly as JSONL.
If an optional heat-pump power sensor is configured (Configure → "Heat pump
power sensor"), each logged record also gets power_w (the raw reading,
echoed live on sensor.<name>_power_draw) and a coarse cycle_energy_kwh /
cycle_cost estimate (power held constant since the previous logged cycle,
times the current price) — enough to compute a real cost trend offline,
instead of MPC's relative proxy-unit "savings" figure. Important: on many
installs the power sensor is shared with hot water production, so this figure
is NOT attributable to space heating alone. It's still useful for comparing
tuning changes (tier, wind term, k_price) against each other over matched
time windows, since hot-water usage is independent of those settings and
averages out — just don't read it as "the compensation saved €X".
Whenever MPC actually runs a cycle, the record also embeds the exact
multi-hour forecast it planned against (mpc_forecast_price,
mpc_forecast_outdoor_temp_c, mpc_forecast_wind_speed_ms,
mpc_forecast_solar_effect, plus mpc_horizon_hours/mpc_step_hours and
mpc_forecast_valid_steps) — not just the realised/actual values. This
matters because forecasts get revised over time; the realised outcome isn't
a substitute for what was actually known at decision time, so faithfully
replaying or backtesting a past MPC plan needs the forecast snapshot, not
just hindsight. Logged at whatever mpc_horizon_hours is currently
configured (not a separate fixed window), so it always matches what the
live solver is actually doing.
MIT — see LICENSE.