Heat Pump Cost Optimizer

Documentation

Danfoss ECL110 control over MQTT

Some heat pumps are driven by a separate weather-compensated controller rather than by a switch. The Danfoss ECL110 is the common one here: it runs its own heat curve, and the standard way to bias that curve is the manual displace value — a parallel shift of the whole curve, in degrees.

This page is for installs that have such a controller on MQTT, or a controller whose heat-curve offset Home Assistant exposes as a number entity (Without MQTT). Everything else in the integration works without either.

What the integration sends

Each optimization produces two outputs for the controller:

The displace schedule is derived from the power schedule, not chosen separately: planned power is normalised against the pump's min and max, mapped onto the configured displace range, given a weather anticipation bias over the first eight hours (wind and rain push it up, expected sun pulls it down), and nudged up slightly below 0 °C outdoors. The result is then passed through the same first-order lag the controller itself is assumed to have, so the optimizer never asks for a change faster than the loop can follow.

Before publishing, the coordinator adds the learned heat-curve bias if curve learning is enabled (it may only cool an over-hot curve, never heat, and is clamped well inside your configured range), clamps the result to your configured range, and rounds to a whole number — the ECL110 takes integers.

The fixed modes do not plan a displace at all: comfort commands +4 °C (or your maximum, if that is lower), boost commands your maximum, off your minimum.

The live peak guard can override any of them mid-window: while it is suppressing, it publishes the plan's displace minus 2 °C, and re-publishes the plan's own value when it releases.

Topics and payloads

The preferred path is a plain number on the direct set topic:

4

For a setup that expects JSON, the same command is also published to the legacy topic when it is configured:

{
  "source": "heatpump_optimizer",
  "reason": "scheduled_update",
  "timestamp": "2026-01-01T12:00:00+00:00",
  "command": {
    "type": "ecl110_control",
    "heat_pump_on": true,
    "displace": 4
  },
  "context": {
    "price": 1.23,
    "mode": "pre_heat",
    "pre_heat_urgency": 0.6
  }
}

The status topic is subscribed to, not written. Both shapes are accepted: a bare number, or a JSON object carrying displace (or command.displace) and optionally effective_displace, which overrides the modelled value below.

Publishing goes through Home Assistant's own mqtt.publish service, so the MQTT integration has to be set up. The topics have no shipped default: each one is exactly what you stored, an empty or absent topic leaves that arm off, and nothing is published or subscribed until a topic is set — so an install without an ECL110 never touches these topics at all.

Options

All eleven settings live on the options page Heat curve control, under Advanced settings: the eight below, and the three of the offset entity. They are not asked during initial setup, because only owners of such a controller can answer them; installs that never open the page run no ECL110 surface at all — the topic options below suggest empty and a topic only exists once you store one.

Option Default What it is
ecl110_displace_set_topic (empty) Topic the offset is published to, as a plain number
ecl110_command_topic (empty) Legacy JSON command topic
ecl110_state_topic (empty) Topic the controller reports its state on
ecl110_mqtt_qos 1 MQTT quality of service, 0–2
ecl110_mqtt_retain off Retain the last offset on the broker
ecl110_displace_min −20 °C Largest downward shift allowed, −30 to 0
ecl110_displace_max +20 °C Largest upward shift allowed, 0 to +30
ecl110_pid_time_constant_hours 1.5 h Roughly how long the controller takes to act on a new offset, 0.25–6

The min and max are worth setting to what your curve can actually tolerate. They bound both the plan and every override, including the peak guard's.

Sensors

Two sensors report the ECL110 path. Both follow the MQTT topics and the offset entity: with none stored they are unavailable and disabled by default, and on an install whose topics were configured at setup they are enabled. Both are diagnostic, so they sit with the other advisories on the device page. On an install that adds the topics later, enable them from the integration's device page.

Sensor Unit What it shows
ECL110 Displace °C The displace the current plan step asks for, falling back to the last value commanded or reported
ECL110 Effective Displace °C The modelled shift the controller has actually reached; its attributes carry the legacy command topic and the status topic

PI/PID dynamics

The ECL110 does not jump to a new offset. Its manual documents PI-style loop dynamics, so a commanded shift arrives gradually, and treating the command as if it took effect instantly would make the thermal model expect heat that has not been delivered yet.

The integration models this as a first-order lag with the time constant you configure: each interval, the effective displace moves a fraction interval / time constant of the remaining distance toward the command, and the result is clamped to the same min/max range. That modelled value is what ECL110 Effective Displace publishes, and it is replaced by the controller's own figure whenever a status payload reports effective_displace.

The lag is applied twice on purpose: the optimizer smooths its planned displace schedule with it, so the plan only asks for shifts the controller can follow, and the coordinator then tracks what the controller has reached, so the model prices the heat that has actually arrived rather than the heat requested.

Without MQTT: a number entity

The same displace can be written to a Home Assistant number (or input_number) that offsets your controller's heat curve, instead of or as well as the MQTT topics. Set Heat-curve offset entity on the same page. Every source of the displace reaches it unchanged: the plan, the fixed modes, boost, the peak guard and the learned curve bias. With curve learning on, an entity-only install now moves the real pump, where with no topic it moved nothing.

What the entity must be (kind offset). A whole-curve offset whose neutral value is 0, which the controller forgets on its own restart. Not an absolute curve point, and not a parameter the controller stores in its non-volatile memory: those are kind persistent (below), with its own write budget. Prefer offset wherever your controller has such an entity. The integration cannot tell a volatile entity from a stored one; your choice of entity and kind is the declaration. It does check one thing: an entity whose own range excludes 0 (for example 20 to 70) is refused at save, and again at every write, because it is a curve point, not an offset. An absolute entity that declares a range including 0 passes that check, so pick the offset entity deliberately. An entity the optimizer already writes for something else (a set-point, the valve, the compressor frequency or the mode) is refused too.

What is written. The displace integer, the one the MQTT topics carry, as a temperature difference in the entity's own unit: kelvin, °C or no unit as is, °F times 9/5; any other unit is refused. It is bounded to ±Largest offset written to the entity (default 3 K, 0.5 to 5 K) and to the entity's own range; where the entity's range is the narrower one, the value saturates and a warning is logged once. The 3 K default and its 5 K ceiling come from a web survey of controller documentation (handoff-body/r10-ghc-research 52f5a442, "Recommended defaults"): ±5 K is the user heating correction a Luxtronik controller offers [documented, a third-party app's docs], and 3 K is an [estimate] between that and the +1 to +2 offset steps an SG-Ready boost uses on NIBE [documented]. No source states a generic value.

How often. Nothing is written while the entity already reads the target (within half its step). Each write is recorded: a reading that still differs within 90 s proves nothing; after that one more write is sent, and if the entity still does not hold it a warning is logged and that value is not sent again until the target changes or the reading matches. More than 2 × 1440 / optimization interval writes in 24 hours (96 at the default 30 minutes) is treated as a loop bug: refused and logged. An entity that is missing, unavailable, of another domain or unit, or whose service call fails, is not written and the failure is logged once; the MQTT topics are published first and are never affected by it. A stale plan is not a reason for silence: the peak guard and a mode change publish without checking the plan's age, as they do over MQTT.

Turning it off. Switching the optimizer to off writes 0 to the entity, and so does unloading the integration (a reload after an options change, a disable, or Home Assistant stopping), so the curve is back where the controller had it. Only a value the optimizer wrote is restored; an install that never wrote leaves the entity alone. The MQTT topics get the same: once the optimizer has published to them, off and unload publish displace 0 to each configured topic, retained when retain is on. While off, nothing else is written. A write the optimizer made before a crash is restored at the next start's first off or unload. When Home Assistant is stopping, the controller's own integration may already be gone; the 0 is then attempted, the failure logged, and the restore kept for the next off or unload. The MQTT topics are not written on off.

Example: a Luxtronik controller with the SHI interface (alpha innotec, Novelan and others). python-luxtronik (Bouni/python-luxtronik at 02afea84bd5bf3ee87445de6f2a42b8029983169) describes the SHI holding heating_offset as an offset applied to the current return-temperature set-point, volatile, and in effect only while the SHI heating mode is offset; the luxtronik_shi integration exposes it as a number in K, −20 to 20, step 0.5. Measured by one user on one installation (an alpha innotec LWDV 91-1/3, 2026-11-07): +2 K raised the return target from 27.7 °C to 29.7 °C at that operating point, and the same user reports +1 K giving exactly +1 K at another outdoor temperature, so on that installation it behaves as a parallel shift. The optimizer does not own the heating mode: after a controller restart the mode can fall back to off, the offset reads back as written, and it has no effect, which the integration cannot detect. Check the mode after a controller restart. The heating-curve parameters themselves (the end point, and the setting the controller calls Parallelverschiebung) are stored parameters: kind persistent, not this kind.

Kind persistent: a stored curve parameter

For a controller with no volatile offset, set What the entity is to Persistent and name the curve setting the controller stores (a foot point, a parallel shift, an end point). The options page then shows three more settings. Every write lands in the controller's non-volatile memory, which wears, so this kind writes far less often than kind offset. Prefer kind offset wherever your controller has a volatile register: on a Luxtronik, python-luxtronik's README states that parameter writes go to the controller's NAND memory while the SHI offset registers are volatile [documented].

The baseline. Before its first write the optimizer reads the entity and records that value, with the entity it was read from, in its own store. That recorded value is the baseline: every write is the baseline plus the displace, as a difference in the entity's unit (°F times 9/5), in whole steps of the entity, never more than Largest displacement of a stored parameter away from the baseline (default 3 K, 0.5 to 5 K) and never outside the entity's own range. A restart keeps the baseline, so a value the optimizer wrote is never mistaken for yours. The entity must read a value inside its own range when you save the page, and at every write; otherwise nothing is written.

Your own change wins. If the entity, after a write and its 90 s grace, reads neither what the optimizer wrote nor what it read before that write, the change is yours: the baseline moves by the same amount, logged once, and the optimizer keeps displacing from there. A change that would move the baseline outside the entity's own range moves it to that range's edge instead, so the write back on off is always a value the entity accepts; the optimizer then displaces from that edge, which can write over part of your change. A reading still at the value before the write is a write that did not land: it is sent once more, and if it still does not land a warning is logged and that value is not sent again until the target changes.

How often. Nothing is written while the entity already reads the target. After a write, the next waits at least Least time between writes to a stored parameter (default 60 min, 15 min to 6 h), counted on the wall clock so it holds across a restart. Writes in one local day, the write back on off included, are capped at Most writes a day to a stored parameter (default 12, 0 to 48), and one is always kept back for that write back: a displace that would leave none is refused, logged once a day. 0 writes nothing at all, and neither does 1: its one write is the one kept back. The offset kind's 24-hour loop guard does not apply to this kind; the daily cap replaces it.

Turning it off. Switching the optimizer off, and every unload (a reload after an options change, a disable, Home Assistant stopping), writes the baseline back to the entity it was read from, even if you have since named another entity or cleared it; the new entity is only read and written after that. A write back that fails is logged, kept, and tried again at the next off or unload. Removing the integration writes nothing: if a baseline is still owed then, a warning names the entity and the value to set by hand.

Where the defaults come from. A web survey of controller documentation (handoff-body/r10-ghc-research 52f5a442, BODY.md, "Recommended defaults"; [documented], [forum], [estimate] and [snippet only] mark the evidence) found no manufacturer endurance figure for a generic curve parameter, so these rest partly on forum posts and estimates; lower them if your controller's maker documents a quota.

A stored curve parameter is rarely a parallel shift. Kind persistent writes the baseline plus the displace, which moves the water target by the displace only if the parameter shifts the whole curve. Many controllers' curve settings are not shifts. On a Luxtronik (controller firmware V3.92.3, from one outside contributor's static analysis of its heating-curve function, checked against the targets that contributor's controller reported [third party]) the two curve parameters are the end point E and the Parallelverschiebung F, 5 to 35 °C, and the target at outdoor temperature T is

F + (E − 20) × (F − T) / (20 − (T − F) / 2)

before the circuit's minimum and maximum clamp it (the return target for the heating circuit, the flow target for a mixing circuit). F is the outdoor temperature at which the target equals the outdoor temperature, and moving it changes the slope too: it is neither a parallel shift nor a foot point the curve turns about. With E = 30, raising F from 22 to 23 °C raises the target by 1.15 K at −10 °C, 1.25 K at 6 °C and 1.35 K at 15 °C, so a displace d written to F moves the return target by more than d, by an amount that varies with the outdoor temperature. On a Luxtronik, use kind offset on the SHI heating offset, which is the parallel shift (above); kind persistent on F over-shifts. Whether another controller's stored setting is a true parallel shift was not measured here.

Rendered from docs/ecl110.md, which is the source of truth; this page changes only when that file does.