mimirheim is an open-source Python service that computes an optimal energy dispatch schedule for a residential home. Given forecasts of electricity prices, PV generation, and household load, it determines the best schedule for every controllable device — battery, EV, deferrable loads — over a rolling 24-hour planning horizon.
mimirheim is a pure strategist. It never controls hardware. It reads all inputs from MQTT and publishes its schedule back to MQTT. Home Assistant, Node-RED, or any other automation platform is responsible for executing that schedule on actual devices.
New here? See the Quick Start guide for a step-by-step setup walkthrough.
- Core Principle
- Mathematical Model
- Devices
- Objectives and Strategy
- Confidence Model
- Input Schema
- Output Schema
- Running mimirheim
- Configuration
- Readiness and Staleness
- Home Assistant Integration
- Testing
[HA / Node-RED / scripts]
│
├── publishes: {prefix}/input/prices (retained)
├── publishes: {topic_forecast} for each PV (retained)
├── publishes: {topic_forecast} for each load (retained)
├── publishes: {soc.topic} for each battery (retained)
└── publishes: {prefix}/input/trigger (not retained; fires each solve cycle)
│
MQTT broker
│
┌─────────────────┐
│ mimirheim service │
│ │
│ validate │
│ → solve MILP │
│ → publish │
└─────────────────┘
│
MQTT broker
│
[HA reads schedule, runs automations, controls battery / EV charger / etc.]
mimirheim has no knowledge of any device API, vendor protocol, or home automation platform. It only speaks MQTT.
| Layer | Owner | Responsibility |
|---|---|---|
| Data provision | HA / Node-RED / scripts | Fetch prices, PV forecasts, device state; publish to MQTT |
| Optimisation | mimirheim | Compute optimal schedule; publish to MQTT |
| Execution | HA / Node-RED / scripts | Read schedule from MQTT; control physical devices |
These layers are fully independent. mimirheim can be tested, replaced, or upgraded without touching HA.
mimirheim solves a Mixed-Integer Linear Programme (MILP) over a discrete time horizon
At every time step
Where
Efficiency varies with power level. Each device direction (charge / discharge) is modelled as a list of segments, each with its own efficiency coefficient and power cap. This keeps the model fully linear while approximating the real curve.
A deferrable load has a fixed power draw
If either window bound is missing or stale, the load is excluded from the current solve.
When no building thermal model is configured, the space heating heat pump uses a simple degree-days constraint. A heat demand
where
When building_thermal is configured on a heat pump device, the degree-days constraint is replaced by a first-order thermal dynamics model. The building is modelled as a lumped thermal mass with heat loss to the outdoor environment:
where:
-
$T_{in}[t]$ — indoor temperature (°C) at step$t$ (decision variable, bounded by comfort limits) -
$T_{prev}$ — indoor temperature at the previous step ($T_{in}[t-1]$ for$t > 0$ ; measured value for$t = 0$ ) -
$C$ — building thermal capacity (kWh/K, from config) -
$L$ — heat loss coefficient (kW/K, from config) -
$P_{heat}[t]$ — thermal power delivered to the building at step$t$ (kW) -
$T_{out}[t]$ — outdoor temperature forecast at step$t$ (°C, from MQTT) -
$\alpha = 1 - \Delta t \cdot L / C$ — thermal decay factor -
$\beta_{out} = \Delta t \cdot L / C$ — outdoor coupling factor
The comfort band imposes hard bounds on
The solver optimises the HP schedule to minimise energy cost while keeping indoor temperature within the configured comfort range. The BTM enables pre-heating: running the HP when electricity is cheap to store heat in the building fabric for later hours.
Where:
-
$c[t]$ — per-step price confidence (1.0 for certain prices, <1.0 for forecasts) -
$x^{ex}[t], x^{im}[t]$ — export/import prices in EUR/kWh -
$\delta_d$ — battery/EV wear cost per kWh throughput
Confidence is applied to revenue/cost terms only, never to constraints. Physical feasibility always holds regardless of forecast confidence.
CBC (COIN-OR Branch and Cut, free, EPL 2.0, embedded via python-mip). The solver interface is abstracted behind a SolverBackend protocol; any compliant backend can be substituted. A 59-second time limit prevents blocking. CBC is approximately 100× faster than HiGHS on this problem class due to aggressive Gomory cut generation on temperature-coupled binary variables.
Maturity note — Battery, PV and deferrable loads are the most thoroughly tested and deployed device types. EV chargers, hybrid inverters and all thermal devices (boiler, space heating HP, combi heat pump) are implemented and unit-tested but have seen limited real-world deployment. If you run mimirheim with any of these devices, feedback and bug reports are very welcome. Pull requests with additional integration tests or golden scenarios are especially appreciated.
| Device | Config section | Variables |
|---|---|---|
| Battery | batteries |
charge[t], discharge[t], soc[t] |
| PV array | pv_arrays |
Fixed parameter (forecast, no decision variable) |
| EV charger | ev_chargers |
ev_charge_seg[t,i], ev_discharge_seg[t,i] (V2H if segments provided) |
| Deferrable load | deferrable_loads |
u[s] — binary start time selector |
| Static load | static_loads |
Fixed parameter (forecast, no decision variable) |
| Grid | grid |
import[t], export[t] |
| Hybrid inverter | hybrid_inverters |
charge_seg[t,i], discharge_seg[t,i], soc[t], pv[t] (clipped forecast) |
| Thermal boiler | thermal_boilers |
T_tank[t], on[t] (binary) |
| Space heating HP | space_heating_hps |
hp_on[t] (on/off) or SOS2 mode variable; optional T_indoor[t] (BTM) |
| Combi heat pump | combi_heat_pumps |
T_tank[t], dhw_mode[t], sh_mode[t], hp_on[t]; optional T_indoor[t] (BTM) |
Multiple instances of each device type are supported. Each named instance in its config section is independent.
At least one static_loads entry is required when any generation or storage device is configured. Without it, the power balance is incomplete and the solver will over-export.
The EV charger uses the same piecewise segment structure as the battery. Discharge (V2H) is enabled by providing non-empty discharge_segments; leave it empty if the hardware does not support it.
A hybrid inverter combines battery storage with a directly coupled PV input. The PV forecast is provided via MQTT in the same timestamped format as pv_arrays. Battery charge and discharge use the same piecewise efficiency segment structure as the standalone battery.
An electric resistance boiler with a hot-water tank. Tank temperature is tracked as a decision variable across the horizon. The solver may run the boiler element (on[t] = 1) at any step to raise the tank temperature, subject to a minimum tank temperature (to satisfy DHW demand) and a maximum temperature (safety limit). The binary on[t] variable incurs a minimum run-time constraint to avoid short cycling.
A heat pump that delivers space heating only (no DHW). Two dispatch modes are supported:
- On/off — a binary
hp_on[t]variable; heat output at full COP when on. - SOS2 (modulating) — a Special Ordered Set of Type 2 variable that allows continuous power modulation between a minimum and maximum operating point.
If building_thermal is configured, indoor temperature is tracked as a decision variable and the degree-days heat demand constraint is replaced by the BTM thermal dynamics model (see §2).
A heat pump that covers both domestic hot water (DHW) and space heating (SH) using a single compressor. DHW and SH are mutually exclusive at each time step — the heat pump can operate in exactly one mode per step (or be idle). The DHW mode maintains the hot-water tank temperature; the SH mode delivers space heating.
If building_thermal is configured, indoor temperature tracking follows the same BTM model as the space heating HP. The DHW tank dynamics are unaffected by the BTM configuration.
The active optimisation strategy is read from the MQTT topic {prefix}/input/strategy. Publish a retained value to change strategy without restarting mimirheim. The default when the topic has not been received is minimize_cost.
| Strategy | Behaviour |
|---|---|
minimize_cost |
Maximise revenue and minimise import cost. Default. |
minimize_consumption |
Minimise grid import first, maximise revenue within that envelope (two solves). |
balanced |
Equal weighting of cost and grid import minimisation; user-configurable weights. |
When strategy is balanced, relative weights can be tuned in config:
objectives:
balanced_weights:
cost_weight: 1.0
self_sufficiency_weight: 1.0Both default to 1.0. Only the ratio matters.
Under a net-of-meter (NoM) tariff, import and export prices are symmetric and the minimize_cost objective already naturally produces near-zero exchange. When prices are perfectly flat, the solver is indifferent among solutions with equal net cost but different exchange magnitudes.
Setting exchange_shaping_weight to a small positive value adds the term lambda × Σ_t(import_t + export_t) to the objective. This breaks indifference in favour of lower total exchange without distorting dispatch on any step where there is a real price signal.
objectives:
exchange_shaping_weight: 1e-4 # 0.0 (default) = disabledChoose a value orders of magnitude smaller than typical energy prices so it cannot reverse an economically justified decision. A value of 1e-4 EUR/kWh is appropriate for typical European retail tariffs (0.20–0.35 EUR/kWh).
Independent of strategy, hard limits on grid power can be enforced across the full horizon:
constraints:
max_import_kw: 10.0 # null = no cap (default)
max_export_kw: 0.0 # 0.0 enforces zero exportConfidence is a per-step floating-point value in [0.0, 1.0] supplied externally in the prices payload. mimirheim does not compute or decay confidence internally.
| Value | Meaning |
|---|---|
1.0 |
Guaranteed. Published day-ahead prices. |
0.7 |
Reasonably likely. Near-term PV forecast. |
0.3 |
Speculative. ML-predicted prices 36 h ahead. |
A step with confidence: 0.3 contributes 30% of its face-value revenue signal to the objective. The solver still plans that step — it will still charge the battery if the discounted value justifies it — but makes more conservative decisions.
Published retained. Required. Payload is a JSON array of timestamped price steps:
[
{
"ts": "2026-03-30T13:00:00+00:00",
"import_eur_per_kwh": 0.22,
"export_eur_per_kwh": 0.18,
"confidence": 1.0
},
{
"ts": "2026-03-30T14:00:00+00:00",
"import_eur_per_kwh": 0.21,
"export_eur_per_kwh": 0.17
}
]- At least one step covering the current time is required.
tsis an ISO 8601 UTC datetime marking the start of that price period.confidenceis optional per step; defaults to 1.0.- Steps can be at any resolution (typically hourly from day-ahead markets). mimirheim resamples them to the 15-minute solver grid using a step function: the price for a given
tsapplies until the next timestamp in the array. - The planning horizon ends at the last
tsin the array. Steps withtsbeforesolve_start(now, floored to the nearest 15-minute boundary) are ignored.
Published retained. Required for each configured PV array. Payload is a JSON array of timestamped power steps:
[
{"ts": "2026-03-30T13:00:00+00:00", "kw": 0.0, "confidence": 1.0},
{"ts": "2026-03-30T14:00:00+00:00", "kw": 2.4, "confidence": 0.9},
{"ts": "2026-03-30T15:00:00+00:00", "kw": 4.1, "confidence": 0.85}
]kwis the forecast output power in kilowatts. Must be non-negative.confidenceis optional per step; defaults to 1.0.- mimirheim resamples to the 15-minute solver grid using linear interpolation between adjacent known points.
Published retained. Required for each configured static load. Same timestamped format as PV forecast.
[
{"ts": "2026-03-30T13:00:00+00:00", "kw": 0.45},
{"ts": "2026-03-30T14:00:00+00:00", "kw": 0.42}
]Not retained. Sending any message (including an empty payload) to this topic instructs mimirheim to attempt a solve immediately. mimirheim checks whether all required inputs are present and whether forecast coverage reaches the minimum horizon, then either runs the solve or logs a warning explaining which requirement is not met.
Data topics (prices, forecasts, battery SOC) do not trigger solves. Publish to the trigger topic only after all forecast and sensor data have been refreshed for the current cycle.
Published retained. Required for each configured battery that has inputs.soc configured. Payload is a plain numeric string:
5.2
Set unit: percent in config to publish a percentage (0–100); mimirheim converts using capacity_kwh.
Published retained. Required for each configured EV charger that has inputs configured. Payload is a plain numeric string:
20.0
Set unit: percent in config to publish a percentage (0–100); mimirheim converts using capacity_kwh.
The plug state is published separately on inputs.plugged_in_topic as a boolean-like string: true/false, on/off, or 1/0. When false, the EV device is excluded from the solve.
window_earliestandwindow_latest(ISO 8601 UTC, optional) constrain the charging window.
Published retained. Optional. Two payload formats are accepted:
Plain text (for manual publishing with mosquitto_pub etc.):
minimize_cost
JSON object (published by the Home Assistant MQTT select entity via autodiscovery):
{"strategy": "minimize_cost"}Valid values: minimize_cost, minimize_consumption, balanced. Defaults to minimize_cost when absent.
Set via the HA MQTT text entity that mimirheim registers in the autodiscovery payload. The user types a value
directly into the text field in the HA UI, or an automation publishes to the topic.
Payload must be a plain ISO 8601 datetime string. The timezone offset is optional: a string without an offset is interpreted as UTC. All three forms below are equivalent:
2026-03-30T15:00:00
2026-03-30T15:00:00Z
2026-03-30T15:00:00+00:00
A non-UTC offset is preserved as supplied. Use UTC (or no offset) unless the automation explicitly works in a fixed local timezone.
Both topic_window_earliest and topic_window_latest must be present for the deferrable load to be included in the binary scheduling problem.
Published retained by the HA automation that physically starts the device. Payload must be a plain ISO 8601 datetime string using the same format rules as the window topics: a string without an offset is interpreted as UTC. Examples:
2026-03-30T15:00:00
2026-03-30T15:00:00Z
2026-03-30T15:00:00+00:00
This topic is optional. When present and the timestamp indicates the load is currently running (i.e. between start_time and start_time + duration), mimirheim discards the window and treats the remaining steps as a fixed power draw in the power balance. No binary variable is used. This prevents the load from disappearing from the model mid-run as its window becomes invalid.
The four states mimirheim infers from this topic:
topic_committed_start_time value |
Condition | mimirheim behaviour |
|---|---|---|
| absent or not configured | — | Binary optimisation within window |
| present | start_time in the future |
Binary optimisation (treat as absent) |
| present | solve_start ∈ [start_time, start_time + duration) |
Fixed draw for remaining steps |
| present | solve_start ≥ start_time + duration |
Run complete; device excluded |
mimirheim never publishes to or clears topic_committed_start_time. That is the automation's responsibility.
Published retained by mimirheim after each successful solve. Payload is a bare ISO 8601 UTC datetime string:
"2026-03-30T15:00:00Z"
The value is the UTC datetime of the first nonzero-power step in the schedule for this load. It represents the solver's optimal start time given current prices and the configured window.
This topic is only published when the load is in binary scheduling state (i.e. a window is active and the load has not yet physically started). When the load is running or its committed start time is in the past, nothing is published — the previous retained value remains.
An HA automation can subscribe to this topic and act on it, for example by pre-programming a smart plug to switch on at the recommended time.
Published retained. Required for each configured hybrid inverter that has inputs.soc configured. Payload is a plain numeric string:
25.6
Set unit: percent in config to publish a percentage (0–100); mimirheim converts using capacity_kwh.
Published retained. Required for each configured hybrid inverter. Same timestamped format as pv_arrays.*.topic_forecast.
[
{"ts": "2026-03-30T13:00:00+00:00", "kw": 0.0},
{"ts": "2026-03-30T14:00:00+00:00", "kw": 1.8}
]Published retained. Required for each configured thermal boiler that has inputs configured. Payload is a plain numeric string representing the current tank temperature in degrees Celsius:
58.3
Published retained. Required for each configured space heating HP that has inputs configured. Payload is a plain numeric string representing the heat energy demand for the horizon in kWh:
12.4
This topic is not required when building_thermal is configured — in that case the solver tracks indoor temperature directly and does not need a pre-computed demand figure.
Published retained. Required for each configured combi heat pump that has inputs configured. Payload is a plain numeric string representing the current DHW tank temperature in degrees Celsius:
51.7
Published retained. Required for each configured combi heat pump that has inputs configured. Payload is a plain numeric string representing the space heating demand for the horizon in kWh:
8.2
This topic is not required when building_thermal is configured on the combi heat pump.
Published retained. Required when building_thermal.inputs is configured on a space heating HP or combi heat pump. Payload is a plain numeric string (degrees Celsius) or a JSON object:
20.5
{"temp_c": 20.5}BTM outdoor temperature forecast — topic from *.building_thermal.inputs.topic_outdoor_temp_forecast_c
Published retained. Required when building_thermal.inputs is configured. Payload is a JSON array of outdoor temperature values in degrees Celsius, one value per 15-minute step starting from the current solve time:
[5.2, 5.0, 4.8, 4.7, 4.5, 4.3, 4.1, 4.0]The array must contain at least as many values as the active horizon length. mimirheim raises an error at solve time if the forecast is too short.
Published retained after every successful solve. Contains the complete 96-step dispatch schedule:
{
"strategy": "minimize_cost",
"objective_value": 1.24,
"solve_status": "optimal",
"schedule": [
{
"t": 0,
"grid_import_kw": 0.0,
"grid_export_kw": 0.0,
"devices": {
"home_battery": {"kw": -2.4, "type": "battery"},
"roof_pv": {"kw": 3.1, "type": "pv"},
"base_load": {"kw": 0.42, "type": "static_load"}
}
}
]
}solve_status is one of "optimal" (proven optimal), "feasible" (time-limited incumbent), or "infeasible" (no feasible solution; schedule is empty and previous retained schedule remains unchanged).
Device kw sign convention: positive = producing / discharging, negative = consuming / charging.
Published retained alongside the schedule. Contains the current-step summary for HA automations that don't need the full schedule:
{
"t": 0,
"grid_import_kw": 0.0,
"grid_export_kw": 0.0,
"strategy": "minimize_cost",
"solve_status": "optimal"
}Published retained for each device in the current step (t=0):
{"kw": -2.4, "type": "battery"}HA automations can subscribe directly to mimirheim/device/home_battery/setpoint rather than parsing the full schedule JSON.
Published retained after every solve attempt, including failures. Allows monitoring tools to detect problems without reading logs.
On success:
{
"status": "ok",
"solve_status": "optimal",
"generated_at": "2026-03-30T14:15:00+00:00"
}On failure (infeasible, exception, stale inputs):
{
"status": "error",
"detail": "Solve returned infeasible — check device configuration.",
"generated_at": "2026-03-30T14:15:00+00:00"
}Published retained. "online" on broker connect (birth message). "offline" on clean shutdown and as MQTT last-will on unclean disconnect. Used by HA availability_topic to mark all mimirheim entities unavailable when the service is down.
- Python 3.12
uv(dependency management)
uv sync # create .venv, install all dependenciesuv run python -m mimirheim --config config.yamlOr directly:
uv run mimirheim/__main__.py --config config.yamlmimirheim logs to stdout. It connects to the configured MQTT broker and subscribes to all required input topics. It does not solve on a timer — a solve is triggered by a message on {prefix}/input/trigger. It runs until SIGTERM or SIGINT.
When debug.dump_dir is set in config and the mimirheim.solver logger is at DEBUG level, mimirheim writes a pair of JSON files (input_*.json + output_*.json) after each solve. These are in the same format as the golden file test fixtures. Up to debug.max_dumps pairs are kept; oldest are removed automatically.
mimirheim is configured from a single YAML file passed via --config. All fields are validated by Pydantic at startup; an invalid config prints a human-readable error and exits.
All MQTT topics are derived from mqtt.topic_prefix automatically. The only
required fields are the physical device parameters. The outputs: block may be
omitted entirely.
mqtt:
host: localhost
port: 1883
client_id: mimir
topic_prefix: mimir
grid:
import_limit_kw: 20.0
export_limit_kw: 20.0
batteries:
home_battery:
capacity_kwh: 13.5
min_soc_kwh: 1.0
charge_segments:
- power_max_kw: 5.0
efficiency: 0.95
discharge_segments:
- power_max_kw: 5.0
efficiency: 0.95
wear_cost_eur_per_kwh: 0.005
inputs:
soc:
unit: kwh # topic derived to mimir/input/battery/home_battery/soc
pv_arrays:
roof_pv:
max_power_kw: 8.0 # topic derived to mimir/input/pv/roof_pv/forecast
static_loads:
base_load: {} # topic derived to mimir/input/baseload/base_load/forecast| Field | Type | Default | Description |
|---|---|---|---|
host |
string | — | Broker hostname or IP. |
port |
int | 1883 | Broker port. |
client_id |
string | — | MQTT client identifier. Must be unique on the broker. |
topic_prefix |
string | mimirheim |
Prefix for all fixed mimirheim topics ({prefix}/input/prices, etc.) |
All four fields are optional. When a field is absent (or the entire outputs: block
is omitted), the topic is derived from mqtt.topic_prefix. Explicit values override
the derived default.
| Field | Derived topic (prefix = mimirheim) |
Description |
|---|---|---|
schedule |
mimir/strategy/schedule |
Full horizon schedule JSON (retained). |
current |
mimir/strategy/current |
Current-step summary (retained). |
last_solve |
mimir/status/last_solve |
Solve status after each attempt (retained). |
availability |
mimir/status/availability |
Birth ("online") and last-will ("offline") (retained). |
| Field | Type | Description |
|---|---|---|
import_limit_kw |
float ≥ 0 | Maximum grid import, kW. Physical connection limit. |
export_limit_kw |
float ≥ 0 | Maximum grid export, kW. |
| Field | Type | Default | Description |
|---|---|---|---|
capacity_kwh |
float > 0 | — | Usable capacity in kWh. |
min_soc_kwh |
float ≥ 0 | 0.0 | Minimum SOC the solver will discharge to. |
charge_segments |
list (min 1) | — | Piecewise efficiency segments for charging. |
discharge_segments |
list (min 1) | — | Piecewise efficiency segments for discharging. |
wear_cost_eur_per_kwh |
float ≥ 0 | 0.0 | Degradation cost per kWh throughput. |
capabilities.staged_power |
bool | false | Hardware only accepts discrete power stages. |
capabilities.zero_export_mode |
bool | false | Battery inverter has a boolean zero-export mode register. When true, the inverter autonomously prevents grid export using local CT measurements. mimirheim publishes true/false once per solve cycle; the hardware performs real-time enforcement. |
outputs.zero_export_mode |
string or null | null | MQTT topic for the zero-export mode flag. Only published when capabilities.zero_export_mode is true. |
inputs.soc.topic |
string or null | derived | MQTT topic for SOC readings. Derived as {prefix}/input/battery/{name}/soc when absent. |
inputs.soc.unit |
kwh or percent |
— | Unit of the published SOC value. |
Each charge_segments / discharge_segments entry:
| Field | Type | Description |
|---|---|---|
power_max_kw |
float > 0 | Power cap for this segment in kW. |
efficiency |
float (0, 1] | Round-trip efficiency fraction. |
| Field | Type | Default | Description |
|---|---|---|---|
max_power_kw |
float > 0 | — | Peak array output in kW (used to clip implausible forecast values). |
topic_forecast |
string or null | derived | MQTT topic for the timestamped power forecast (list[{ts, kw, confidence}]). Derived as {prefix}/input/pv/{name}/forecast when absent. |
capabilities.power_limit |
bool | false | Inverter accepts a continuous production limit setpoint in kW. When True, mimirheim adds a decision variable per step and may curtail below the forecast. |
capabilities.on_off |
bool | false | Inverter supports discrete on/off control. When True, mimirheim treats PV as a binary per step: either the full forecast or zero. Mutually exclusive with power_limit. Requires outputs.on_off_mode. |
capabilities.zero_export_mode |
bool | false | Inverter has a discrete zero-export mode register. When True, mimirheim publishes a boolean command to the configured output topic alongside the power limit. |
outputs.power_limit_kw |
string or null | derived | MQTT topic for the production limit setpoint in kW. Only published when capabilities.power_limit is true. Derived as {prefix}/output/pv/{name}/power_limit_kw. |
outputs.zero_export_mode |
string or null | derived | MQTT topic for the zero-export mode boolean command. Only published when capabilities.zero_export is true. Derived as {prefix}/output/pv/{name}/zero_export_mode. |
outputs.on_off_mode |
string or null | derived | MQTT topic for the on/off command. "true" = inverter on (producing); "false" = inverter off (curtailed by mimirheim). Derived as {prefix}/output/pv/{name}/on_off_mode. |
| Field | Type | Default | Description |
|---|---|---|---|
capacity_kwh |
float > 0 | — | Vehicle battery capacity in kWh. |
min_soc_kwh |
float ≥ 0 | 0.0 | Minimum SOC. |
charge_segments |
list (min 1) | — | Efficiency segments for charging. |
discharge_segments |
list | [] |
Efficiency segments for V2H discharge. Empty = no V2H. |
wear_cost_eur_per_kwh |
float ≥ 0 | 0.0 | Degradation cost per kWh throughput. |
capabilities.staged_power |
bool | false | Hardware only accepts discrete power stages. |
capabilities.zero_export_mode |
bool | false | EV charger has a boolean zero-export mode register. When true, the charger autonomously prevents grid export using local CT measurements. mimirheim publishes true/false once per solve cycle. |
outputs.zero_export_mode |
string or null | derived | MQTT topic for the zero-export mode flag. Only published when capabilities.zero_export_mode is true. Derived as {prefix}/output/ev/{name}/exchange_mode. |
inputs.soc.topic |
string or null | derived | MQTT topic for EV SOC/state payload. Derived as {prefix}/input/ev/{name}/soc. |
inputs.soc.unit |
kwh or percent |
— | Unit of the SOC value. |
inputs.plugged_in_topic |
string or null | derived | MQTT topic for plug state (boolean-like payload). Derived as {prefix}/input/ev/{name}/plugged_in. |
The departure target is supplied at runtime via the EV state MQTT payload (not config):
| MQTT field | Type | Description |
|---|---|---|
target_soc_kwh |
float ≥ 0 or null | Required SOC at departure (hard constraint). Omit or set null when no trip is planned. |
window_latest |
ISO 8601 UTC datetime or null | Departure deadline by which target_soc_kwh must be reached. |
| Field | Type | Default | Description |
|---|---|---|---|
power_profile |
list of float > 0 | — | Per-step power draw in kW, one entry per 15-minute step of the run cycle. The array length determines the run duration. Example: [2.0, 0.8, 0.8, 2.5] models a 4-step cycle. |
topic_window_earliest |
string or null | derived | MQTT topic publishing earliest permitted start (ISO 8601 UTC). Derived as {prefix}/input/deferrable/{name}/window_earliest. |
topic_window_latest |
string or null | derived | MQTT topic publishing latest permitted end (ISO 8601 UTC). Derived as {prefix}/input/deferrable/{name}/window_latest. |
topic_committed_start_time |
string or null | derived | Optional MQTT topic where the automation publishes the actual start datetime when the load physically begins (retained, ISO 8601 UTC). Derived as {prefix}/input/deferrable/{name}/committed_start. |
topic_recommended_start_time |
string or null | derived | MQTT topic to which mimirheim publishes the solver-recommended start datetime (ISO 8601 UTC, retained) after each solve. Only published when the load is in binary scheduling state (not running or committed). Derived as {prefix}/output/deferrable/{name}/recommended_start. |
| Field | Type | Description |
|---|---|---|
topic_forecast |
string or null | MQTT topic for the timestamped load forecast (list[{ts, kw, confidence}]). Derived as {prefix}/input/baseload/{name}/forecast when absent. |
| Field | Type | Default | Description |
|---|---|---|---|
capacity_kwh |
float > 0 | — | Battery capacity in kWh. |
min_soc_kwh |
float ≥ 0 | 0.0 | Minimum SOC the solver will discharge to. |
charge_segments |
list (min 1) | — | Piecewise efficiency segments for charging. |
discharge_segments |
list (min 1) | — | Piecewise efficiency segments for discharging. |
wear_cost_eur_per_kwh |
float ≥ 0 | 0.0 | Degradation cost per kWh throughput. |
max_pv_kw |
float > 0 | — | Peak PV input power in kW (used to clip the forecast). |
topic_pv_forecast |
string or null | derived | MQTT topic for the PV power forecast. Derived as {prefix}/input/hybrid/{name}/pv_forecast. |
inputs.soc.topic |
string or null | derived | MQTT topic for SOC readings. Derived as {prefix}/input/hybrid/{name}/soc. |
inputs.soc.unit |
kwh or percent |
— | Unit of the published SOC value. |
| Field | Type | Default | Description |
|---|---|---|---|
capacity_litres |
float > 0 | — | Tank volume in litres. |
min_temp_c |
float | — | Minimum tank temperature required to satisfy DHW demand (°C). |
max_temp_c |
float | — | Maximum permissible tank temperature (°C). |
heat_loss_coeff_kw_per_k |
float > 0 | — | Tank heat loss coefficient (kW/K). |
element_power_kw |
float > 0 | — | Electrical power of the heating element (kW). |
cop |
float > 0 | 1.0 | Coefficient of performance (1.0 for pure resistance). |
min_run_steps |
int ≥ 0 | 0 | Minimum consecutive steps the element must run once switched on (avoids short cycling). |
inputs.topic_current_temp |
string or null | derived | MQTT topic for the current tank temperature (plain float, °C). Derived as {prefix}/input/thermal_boiler/{name}/temp_c. |
| Field | Type | Default | Description |
|---|---|---|---|
elec_power_kw |
float > 0 | — | Electrical input power at full operation (kW). |
cop |
float > 0 | — | Coefficient of performance for space heating. |
mode |
on_off or sos2 |
on_off |
Dispatch mode. on_off: binary on/off; sos2: continuous modulation. |
min_power_fraction |
float ∈ (0, 1] | 0.3 | Minimum operating point as a fraction of elec_power_kw (SOS2 mode only). |
min_run_steps |
int ≥ 0 | 0 | Minimum consecutive running steps (on/off mode only). |
inputs.topic_heat_needed_kwh |
string or null | derived | MQTT topic for the heat demand over the horizon (plain float, kWh). Required when building_thermal is not configured. Derived as {prefix}/input/space_heating/{name}/heat_needed_kwh. |
inputs.topic_heat_produced_today_kwh |
string or null | derived | MQTT topic for the cumulative heat produced today (plain float, kWh). Informational only; the solver does not read this topic. Derived as {prefix}/input/space_heating/{name}/heat_produced_today_kwh. |
building_thermal |
object or null | null | Optional building thermal model (BTM). When set, replaces the degree-days demand constraint with first-order temperature dynamics. |
building_thermal fields:
| Field | Type | Default | Description |
|---|---|---|---|
thermal_capacity_kwh_per_k |
float > 0 | — | Building thermal mass (kWh/K). |
heat_loss_coeff_kw_per_k |
float > 0 | — | Building heat loss coefficient (kW/K). |
comfort_min_c |
float | 19.0 | Minimum required indoor temperature (°C). |
comfort_max_c |
float | 24.0 | Maximum allowed indoor temperature (°C). |
inputs.topic_current_indoor_temp_c |
string or null | derived | MQTT topic for the current indoor temperature (plain float or JSON {temp_c: float}, °C). Derived as {prefix}/input/space_heating/{name}/btm/indoor_temp_c. |
inputs.topic_outdoor_temp_forecast_c |
string or null | derived | MQTT topic for the outdoor temperature forecast (JSON array of floats, °C, one per 15-minute step). Derived as {prefix}/input/space_heating/{name}/btm/outdoor_forecast_c. |
| Field | Type | Default | Description |
|---|---|---|---|
capacity_litres |
float > 0 | — | DHW tank volume in litres. |
min_temp_c |
float | — | Minimum DHW tank temperature (°C). |
max_temp_c |
float | — | Maximum DHW tank temperature (°C). |
heat_loss_coeff_kw_per_k |
float > 0 | — | DHW tank heat loss coefficient (kW/K). |
elec_power_kw |
float > 0 | — | Electrical input power at full operation (kW). |
cop_dhw |
float > 0 | — | COP when operating in DHW mode. |
cop_sh |
float > 0 | — | COP when operating in SH mode. |
min_run_steps |
int ≥ 0 | 0 | Minimum consecutive steps per operating mode. |
inputs.topic_current_temp |
string or null | derived | MQTT topic for the current DHW tank temperature (plain float, °C). Derived as {prefix}/input/combi_hp/{name}/temp_c. |
inputs.topic_heat_needed_kwh |
string or null | derived | MQTT topic for the SH heat demand over the horizon (plain float, kWh). Required when building_thermal is not configured. Derived as {prefix}/input/combi_hp/{name}/sh_heat_needed_kwh. |
building_thermal |
object or null | null | Optional building thermal model. Same structure and semantics as for space_heating_hps. Only SH mode is affected; DHW tank dynamics are independent. |
| Field | Type | Default | Description |
|---|---|---|---|
min_horizon_hours |
float ≥ 0 | 1.0 | Minimum forecast coverage in hours required before a solve is permitted. A trigger received with less coverage is ignored. |
warn_below_hours |
float ≥ 0 | 8.0 | Log a warning when forecast coverage is below this threshold, but proceed with the solve. |
max_gap_hours |
float ≥ 0 | 2.0 | Log a warning when any forecast series has a gap wider than this threshold within the active horizon. |
| Field | Type | Default | Description |
|---|---|---|---|
balanced_weights.cost_weight |
float ≥ 0 | 1.0 | Weight on revenue/cost terms when strategy is balanced. |
balanced_weights.self_sufficiency_weight |
float ≥ 0 | 1.0 | Weight on grid import penalty. |
min_dispatch_gain_eur |
float ≥ 0 | 0.0 | Minimum projected saving in EUR required to dispatch storage. Below this threshold mimirheim publishes an idle schedule. 0.0 disables. |
exchange_shaping_weight |
float ≥ 0 | 0.0 | Weight for the optional secondary term lambda × Σ_t(import_t + export_t). Breaks solver indifference on flat or near-symmetric tariffs by favouring lower total exchange. Must be orders of magnitude smaller than typical prices (e.g. 1e-4). 0.0 disables. |
| Field | Type | Default | Description |
|---|---|---|---|
max_import_kw |
float or null | null | Hard cap on grid import across the horizon. |
max_export_kw |
float or null | null | Hard cap on grid export. 0.0 enforces zero export. |
See Section 11.
| Field | Type | Default | Description |
|---|---|---|---|
dump_dir |
path or null | null | Directory for solve dump files. Null = disabled. |
max_dumps |
int ≥ 0 | 50 | Maximum retained dump file pairs. 0 = unlimited. |
All MQTT topics mimirheim reads and writes follow a predictable naming convention
derived from mqtt.topic_prefix. No topic string needs to appear in the
configuration for a standard single-broker deployment; every topic field
defaults to None and is filled in at startup by the schema validator.
To override the derived topic for any field, set it explicitly in the YAML. Override only when you need to read a sensor from a non-mimirheim namespace (e.g. a Home Assistant entity topic) or when sharing a topic between multiple instances.
Global topics (prefix = mimirheim):
| Config field | Derived topic |
|---|---|
outputs.schedule |
mimir/strategy/schedule |
outputs.current |
mimir/strategy/current |
outputs.last_solve |
mimir/status/last_solve |
outputs.availability |
mimir/status/availability |
inputs.prices |
mimir/input/prices |
reporting.notify_topic |
mimir/status/dump_available |
Device input topics ({p} = topic prefix):
| Config field | Derived topic |
|---|---|
batteries.{name}.inputs.soc.topic |
{p}/input/battery/{name}/soc |
ev_chargers.{name}.inputs.soc.topic |
{p}/input/ev/{name}/soc |
ev_chargers.{name}.inputs.plugged_in_topic |
{p}/input/ev/{name}/plugged_in |
hybrid_inverters.{name}.inputs.soc.topic |
{p}/input/hybrid/{name}/soc |
hybrid_inverters.{name}.topic_pv_forecast |
{p}/input/hybrid/{name}/pv_forecast |
pv_arrays.{name}.topic_forecast |
{p}/input/pv/{name}/forecast |
static_loads.{name}.topic_forecast |
{p}/input/baseload/{name}/forecast |
deferrable_loads.{name}.topic_window_earliest |
{p}/input/deferrable/{name}/window_earliest |
deferrable_loads.{name}.topic_window_latest |
{p}/input/deferrable/{name}/window_latest |
deferrable_loads.{name}.topic_committed_start_time |
{p}/input/deferrable/{name}/committed_start |
thermal_boilers.{name}.inputs.topic_current_temp |
{p}/input/thermal_boiler/{name}/temp_c |
space_heating_hps.{name}.inputs.topic_heat_needed_kwh |
{p}/input/space_heating/{name}/heat_needed_kwh |
space_heating_hps.{name}.inputs.topic_heat_produced_today_kwh |
{p}/input/space_heating/{name}/heat_produced_today_kwh |
space_heating_hps.{name}.building_thermal.inputs.topic_current_indoor_temp_c |
{p}/input/space_heating/{name}/btm/indoor_temp_c |
space_heating_hps.{name}.building_thermal.inputs.topic_outdoor_temp_forecast_c |
{p}/input/space_heating/{name}/btm/outdoor_forecast_c |
combi_heat_pumps.{name}.inputs.topic_current_temp |
{p}/input/combi_hp/{name}/temp_c |
combi_heat_pumps.{name}.inputs.topic_heat_needed_kwh |
{p}/input/combi_hp/{name}/sh_heat_needed_kwh |
combi_heat_pumps.{name}.building_thermal.inputs.topic_current_indoor_temp_c |
{p}/input/combi_hp/{name}/btm/indoor_temp_c |
combi_heat_pumps.{name}.building_thermal.inputs.topic_outdoor_temp_forecast_c |
{p}/input/combi_hp/{name}/btm/outdoor_forecast_c |
Device output topics:
| Config field | Derived topic |
|---|---|
batteries.{name}.outputs.exchange_mode |
{p}/output/battery/{name}/exchange_mode |
ev_chargers.{name}.outputs.exchange_mode |
{p}/output/ev/{name}/exchange_mode |
ev_chargers.{name}.outputs.loadbalance_cmd |
{p}/output/ev/{name}/loadbalance |
pv_arrays.{name}.outputs.power_limit_kw |
{p}/output/pv/{name}/power_limit_kw |
pv_arrays.{name}.outputs.zero_export_mode |
{p}/output/pv/{name}/zero_export_mode |
pv_arrays.{name}.outputs.on_off_mode |
{p}/output/pv/{name}/on_off_mode |
deferrable_loads.{name}.topic_recommended_start_time |
{p}/output/deferrable/{name}/recommended_start |
Device output topics are derived for all devices regardless of whether the corresponding capability is enabled. A disabled capability means the topic is present in the config but never published.
See IMPLEMENTATION_DETAILS §14 for the derivation mechanics and override pattern.
mimirheim tracks the freshness of every required input. A solve is attempted only when all required inputs are present, sensor readings are within their staleness window, and forecast data covers at least the minimum horizon.
mimirheim does not solve on a fixed timer. A solve is triggered exclusively by a message on {prefix}/input/trigger. The trigger is typically published by an external scheduler (a cron job, an HA automation, or the mimirheim_helpers/scheduler companion tool) after all data sources have been updated for the current cycle.
If a trigger arrives while inputs are insufficient, mimirheim logs a warning and takes no action. The trigger is not queued or replayed.
Battery SOC, EV state, and thermal device readings are required before mimirheim will solve. mimirheim checks only that a value has been received at least once since startup; there is no staleness window. The most recently retained message on the broker is always used as the current state.
| Input |
|---|
{batteries.*.inputs.soc.topic} |
{ev_chargers.*.inputs.soc.topic} |
{ev_chargers.*.inputs.plugged_in_topic} |
{hybrid_inverters.*.inputs.soc.topic} (when inputs is configured) |
{hybrid_inverters.*.topic_pv_forecast} |
{thermal_boilers.*.inputs.topic_current_temp} (when inputs is configured) |
{space_heating_hps.*.inputs.topic_heat_needed_kwh} (when inputs is configured) |
{space_heating_hps.*.building_thermal.inputs.topic_current_indoor_temp_c} (when BTM inputs is configured) |
{space_heating_hps.*.building_thermal.inputs.topic_outdoor_temp_forecast_c} (when BTM inputs is configured) |
{combi_heat_pumps.*.inputs.topic_current_temp} (when inputs is configured) |
{combi_heat_pumps.*.inputs.topic_heat_needed_kwh} (when inputs is configured) |
{combi_heat_pumps.*.building_thermal.inputs.topic_current_indoor_temp_c} (when BTM inputs is configured) |
{combi_heat_pumps.*.building_thermal.inputs.topic_outdoor_temp_forecast_c} (when BTM inputs is configured) |
Electricity prices and power forecasts are not checked for age. A Nordpool day-ahead price published at 13:00 is still valid at 21:00. Instead, mimirheim checks how far ahead the data extends.
At trigger time, mimirheim computes:
solve_start = now, floored to the nearest 15-minute boundary
horizon_end = min(last_ts for each forecast series that lies at or after solve_start)
n_steps = (horizon_end − solve_start) in 15-minute steps
The solve is blocked if n_steps falls below readiness.min_horizon_hours × 4. A warning is logged (but the solve proceeds) if n_steps falls below readiness.warn_below_hours × 4.
| Input | Required |
|---|---|
{prefix}/input/prices |
At least min_horizon_hours of future coverage |
{pv_arrays.*.topic_forecast} |
At least min_horizon_hours of future coverage |
{static_loads.*.topic_forecast} |
At least min_horizon_hours of future coverage |
If any forecast series has a gap wider than readiness.max_gap_hours within the active horizon, mimirheim logs a warning. Gap detection is informational — it does not block the solve. Gaps are filled by the resampler (step function for prices, linear interpolation for power).
| Input | Behaviour when absent |
|---|---|
{prefix}/input/strategy |
Strategy defaults to minimize_cost; solve is not blocked |
{deferrable_loads.*.topic_window_earliest} |
Deferrable load excluded from this solve |
{deferrable_loads.*.topic_window_latest} |
Deferrable load excluded from this solve |
{space_heating_hps.*.inputs.topic_heat_needed_kwh} |
Space heating HP excluded when BTM is not configured and no demand is present |
{combi_heat_pumps.*.inputs.topic_heat_needed_kwh} |
Combi HP SH mode excluded when BTM is not configured and no SH demand is present |
All forecast and sensor input topics must be published with retain=True. On mimirheim restart, the broker delivers the last retained value for each topic immediately on subscribe. Without retention, mimirheim loses state on restart and blocks until fresh messages arrive.
mimirheim can publish MQTT discovery payloads so that HA automatically creates entities for the schedule outputs and per-device setpoints.
Enable in config:
homeassistant:
enabled: true
discovery_prefix: homeassistant # default; change only if HA uses a custom prefix
device_name: mimirheim # human-readable label in HA device registry
# device_id: my-mimirheim # optional; defaults to mqtt.client_idDiscovery payloads are published retained on every broker connection. The following HA entities are created:
| Entity | HA type | State topic | Value |
|---|---|---|---|
{device_name} Grid Import |
sensor (power) | outputs.current |
grid_import_kw |
{device_name} Grid Export |
sensor (power) | outputs.current |
grid_export_kw |
{device_name} Solve Status |
sensor | outputs.last_solve |
status |
{device_name} {device_name} setpoint |
sensor (power) | {prefix}/device/{name}/setpoint |
kw |
The last row is repeated for every configured device across all device sections.
All entities use outputs.availability as their availability_topic, so they appear as unavailable in HA when mimirheim is offline.
The simplest way to publish a battery SOC from HA to mimirheim is an automation triggered by the sensor state change:
automation:
trigger:
- platform: state
entity_id: sensor.battery_soc_kwh
action:
- service: mqtt.publish
data:
topic: mimir/input/bat/soc
retain: true
payload: "{{ states('sensor.battery_soc_kwh') | float }}"For PV and load forecasts, publish a flat JSON array (one value per 15-minute step) retained to the configured topic_forecast topic.
uv run pytest # unit + scenario tests (fast, no broker required)
uv run pytest -m integration # integration tests (require in-process broker, ~20 s)
uv run pytest --update-golden # regenerate golden files after solver changesUnit tests (tests/unit/) — device constraint logic, objective builder, config schema validation, input parser, MQTT publisher, HA discovery, and readiness state. All run without a broker or solver binary. Coverage includes happy paths and validation rejection (sad paths) for every Pydantic model.
Scenario tests (tests/scenarios/) — golden file regression. build_and_solve() is a pure function; each scenario is a directory with input.json, config.yaml, and golden.json. The test calls the solver and compares the result field-by-field. Golden files are committed and updated only deliberately with --update-golden.
| Scenario | Assertion |
|---|---|
flat_price |
Battery does not cycle; no economic incentive to do so |
high_price_spread |
Battery charges when prices are low, discharges when high |
ev_not_plugged |
Schedule produced without EV (available: false); no crash |
Integration tests (tests/integration/) — full MQTT round-trip against an amqtt in-process broker. Marked @pytest.mark.integration and excluded from the default test run. Covers retained message delivery on connect, the happy-path schedule publish, and the infeasible-solve status path.
