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 wall-clock time limit prevents blocking, configurable via solver.time_limit_seconds (default 59 seconds). The minimize_consumption strategy solves twice and splits that budget across both phases, so the total never exceeds it. 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_degraded appears on the schedule, current and last_solve topics — beside strategy on the first two, and on its own in the last-solve summary, which does not carry the strategy name. It is set when the requested strategy could not be carried out and a weaker one produced the schedule, which only minimize_consumption can do: its first phase establishes the minimum import volume that the second phase is then held to, and that phase can either find no solution at all (no volume is locked and the second phase solves for cost alone) or return a time-limited incumbent (locked, but an achievable volume rather than a proven minimum). Either way strategy still names what was asked for, so this flag is the only thing that distinguishes the result from a genuine one. An error last_solve payload carries no strategy information and omits it.
| 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.
inputs.prices accepts a list of topics, in priority order, instead of a single topic (a bare string is coerced to a one-element list). Each topic is resampled independently to the 15-minute solver grid; the results are then merged step by step, taking whichever source reports the highest confidence for that step, with earlier list entries winning ties. A source only competes for a given step if that step falls within the source's own real timestamp range — a shorter-horizon source is never held forward past its last known timestamp to outrank a longer-horizon one. This lets a lower-confidence predictive source extend the usable horizon beyond a shorter, higher-confidence source (e.g. day-ahead prices) without ever being able to override it while it has real data.
Published retained. Required — at least one configured price topic must be covering the current time (see §5, Confidence Model, for the multi-topic merge rule). Payload on each price topic 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 a step (hold-previous) function:
kwis the average power over the interval starting atts, so every 15-minute slot inside that interval takes the same value.
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
or a JSON object that also carries the departure target:
{
"soc": 55.0,
"target_soc_kwh": 51.8,
"window_latest": "2026-08-30T05:00:00Z"
}Set unit: percent in config to publish a percentage (0–100); mimirheim converts using capacity_kwh. The conversion applies to soc only — target_soc_kwh is always in kWh.
When both target_soc_kwh and window_latest are present, the SOC must reach the target by the deadline (hard constraint). A target beyond what the charger can physically deliver within the window is clamped to the deliverable energy with a warning, which forces flat-out charging instead of failing the whole solve.
The window bounds dispatch as well as the target: no charging is scheduled in a step starting before window_earliest, and no charging or V2H discharge is scheduled after window_latest — the vehicle has left. Because these topics are retained, a window_latest that has already passed is treated as an expired window rather than a permanent departure, so a stale value cannot disable the charger; publish a fresh window for the next trip.
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 dispatch schedule for the solved horizon:
{
"strategy": "minimize_cost",
"strategy_degraded": false,
"solve_time_utc": "2026-06-01T09:00:00Z",
"objective_value": 1.24,
"solve_status": "optimal",
"schedule": [
{
"t": 0,
"ts": "2026-06-01T09:00:00Z",
"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_time_utc is the 15-minute slot boundary that step t=0 refers to, and ts is the wall-clock start of each step derived from it. Both are anchored to the solve, not to the moment of publication. mimirheim re-publishes the retained schedule whenever the broker connection is restored, so a consumer should compare solve_time_utc against its own clock rather than assuming the first step is current.
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": "2026-06-01T09:00:00Z",
"grid_import_kw": 0.0,
"grid_export_kw": 0.0,
"strategy": "minimize_cost",
"strategy_degraded": false,
"solve_status": "optimal"
}t carries the wall-clock start of the schedule's first step, taken from the solve rather than from the publish time. After a broker reconnect the retained payload is re-published unchanged, so t may be in the past.
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",
"strategy_degraded": false,
"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.14 (pinned in
.python-version;uvwill fetch it automatically) 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. |
outputs.soc_ratchet |
string or null | derived | MQTT topic for the full-charge policy status. Derived as {prefix}/status/battery/{name}/soc_ratchet when absent. Published retained and read back on startup. |
soc_ratchet.enabled |
bool | false | Enable the periodic full-charge policy. When false nothing about the solve changes. |
soc_ratchet.target_interval_days |
float > 0 | 7.0 | How long the battery may go without a measured full charge. The default is a convention; use the pack manufacturer's interval where one is specified. |
soc_ratchet.full_threshold_pct |
float (0, 100] | 97.0 | Measured SOC, in percent of capacity, that counts as a full charge and resets the policy. |
soc_ratchet.step_pct |
float (0, 100] | 5.0 | Percentage points of capacity added to the minimum SOC per missed interval. The default is a convention. |
soc_ratchet.cap_pct |
float (0, 100] | 80.0 | Ceiling on the dynamic floor. Must be at least step_pct and strictly below full_threshold_pct; a floor that reaches the full-charge threshold would leave the battery pinned at the top with almost nothing to discharge. Configurable rather than fixed because a site knows better than a device how much reserve it needs. |
soc_ratchet.target_pct |
float (0, 100] | 100.0 | SOC the plan is asked to reach and hold. Must be at least full_threshold_pct. Separate from the threshold on purpose: this is what to plan for, the threshold is what a BMS reading has to show for the plan to have worked. |
soc_ratchet.hold_hours |
float 0–24 | 2.0 | How long the SOC must stay at or above target_pct once reached, in hours; rounded up to whole steps. Also the measured hold the reset requires. 0 restores a single-step touch. |
A lithium pack that is never charged to the top stops balancing its cells, and the capacity is lost quietly. A floor enforced by the inverter on its own is invisible to a planner: a schedule that targets below it is not refused, only under-delivered. This policy puts the mechanism inside the solve instead.
Three things happen once it is enabled. Each target_interval_days without a
measured full charge raises a dynamic minimum SOC by step_pct of capacity, up
to cap_pct, which narrows the usable window at no cost in the objective. Once
the due time falls inside the solve horizon, the SOC is constrained to reach
target_pct, so the charge actually completes. And it is then held there for
hold_hours, because reaching the top once is a touch, not a balance charge.
The constraint is on state at a time, not an instruction to charge at a time:
the solver still picks the cheapest quarter-hours to buy the energy in.
The hold is where the balancing happens. A passive BMS bleeds the highest cells
through resistors at tens of milliamps, and only while they sit in the upper
voltage knee; it also recalibrates its SOC estimate only once the charge
current has tapered at the voltage limit. Both need time at the top, and that
dwell does not happen by itself when a planner dictates the SOC trajectory, so
the plan asks for it explicitly. Two hours is the default: what a balancer at
tens of milliamps can correct scales with dwell time, the charge current needs
time of the same order to taper at the limit, and both are pack-specific, so
set hold_hours from the pack's documentation where it gives a figure.
target_pct and full_threshold_pct are two numbers because they answer two
questions. The plan aims for 100%, because only at the top of the configured
capacity are the cells in the knee where the balancer works. The reset accepts
97%, because a BMS reports an estimate and is not guaranteed to show a round
100 even after a completed charge. Planning for the threshold instead would
stop at the edge of the knee.
The target is a hard constraint, like the EV departure target, because a
partially completed balance charge is not a balance charge. That also makes it
the only shape that works under every strategy: minimize_consumption fixes
the total import volume in a first phase that sees only constraints, so a
priced preference would be invisible to it at any magnitude, and under
balanced a price competes with the configured weights. It needs no tuning
knob whose correct value would depend on your price curve.
Which step the target is pinned to is decided by the solver rather than
configured. On the few cycles where a charge is due, the model is solved once
with the objective "close the gap to each due battery's target" - a question
with no economics in it, and one whose only additions are gap variables that do
not restrict the feasible set, so it is feasible whenever the model itself is.
The resulting trajectory is a witness: the target is pinned to the first step
at or after the deadline where that trajectory reached target_pct, so the
constraint is one the witness already satisfies and the policy cannot make the
solve infeasible.
If the witness never reaches the target, the highest SOC it did reach is required instead - but only when the probe ran to optimality. A probe that merely ran out of time says nothing at all, so nothing is imposed and the status reports the target as unenforced rather than quietly rewriting it downward.
Read a reduced enforced_target_kwh as a floor rather than a verdict on the
hardware: usually it means the load, the import limit or the charge power is
outrunning the policy, but it can also be a reachable full charge the probe's
objective did not steer towards, in which case the next cycle retries. Both
outcomes are visible on the status topic - enforced_target_kwh below
full_target_kwh means a reduced floor was imposed, and enforced_target_kwh
absent while full_target_kwh is set means the probe could not settle it at
all.
Pinning to the first reachable step at or after the deadline is what handles an overdue battery: its deadline is step 0, and the target lands on an early quarter-hour it can genuinely be full by, rather than on a step that is infeasible now or on a horizon end that recedes by one step on every rolling solve. It is the first such step in the witness, not a proven earliest.
The hold is read off the same witness: from the pinned step, the run of
consecutive step boundaries the witness stayed at the target, up to
hold_hours worth of intervals (one boundary more than intervals, since the
SOC is measured at the end of each step). A run cut short - by the end of the horizon, or by a load the battery has to carry
with no grid to serve it - is enforced as far as it goes and reported as
enforced_hold_steps below hold_steps. The next rolling solve continues it,
because the reset needs a measured hold, not a planned one.
The hold only applies when the witness actually reaches target_pct. If it
never does - charge derating near the top on a short horizon is the usual
cause - the reduced target is enforced for a single step, as before. A pack in
that situation should have target_pct set to what it can reach, so the run
applies; that is what the field is for.
The policy resets only on a measured SOC that has stayed at or above
full_threshold_pct for hold_hours without dipping. Deliberately the
threshold, not target_pct: the plan asks for 100% because only at the top of
the configured capacity are the cells in the voltage knee, but the proof accepts
97% because a BMS cannot be relied
on to report a round 100, and a reset it can never see would pin the pack full
forever. A plan that intended a full charge and fell short does not count, and
neither does a reading that touched the top and fell away.
The timestamp recorded is when the hold completed, and it keeps advancing for
as long as the battery stays at the top, so the next interval counts from when
the pack last left the top rather than from when it first arrived there. A
pack that sits full for a day was balancing for that day. It is published
retained to outputs.soc_ratchet and read back from there on startup, so a
restart does not reset the policy. The run itself is not persisted: a restart
mid-hold costs one extra hold at the top.
When enabling this policy, switch off any dynamic SOC floor on the inverter side and keep only its static safety floor. Two ratchets working the same pack make both unpredictable, and a floor mimirheim cannot see is under-delivered silently; the intended end state is that mimirheim owns both the SOC band and the climb to full.
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 |
batteries.{name}.outputs.soc_ratchet |
{p}/status/battery/{name}/soc_ratchet |
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, hold-previous, for both prices and 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 | JSON attributes |
|---|---|---|---|---|
{device_name} Grid Forecast |
sensor (power) | outputs.current |
grid_import_kw - grid_export_kw (net, positive = import) |
forecast — array of {import, export} per step (kW, 3 dp) |
{device_name} Solve Status |
sensor | outputs.last_solve |
status |
— |
{device_name} {device_name} setpoint |
sensor (power) | {prefix}/device/{name}/setpoint |
kw |
forecast — array of {kw} per step (storage devices: also soc_kwh) |
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.
Every setpoint sensor and the grid sensor carry a forecast JSON attribute derived from outputs.schedule. The attribute is an array with one entry per solver time step, starting from the current step (t=0).
For battery and EV charger setpoint sensors, each entry contains:
kw— scheduled power in kW (negative = charging, positive = discharging)soc_kwh— terminal state of charge in kWh at the end of the step
For all other device setpoint sensors, each entry contains only kw. For the grid sensor, each entry contains import and export (both in kW, rounded to 3 decimal places).
In apexcharts-card, reference the forecast array via json_attributes_path: $.forecast:
- entity: sensor.mimirheim_home_battery_setpoint
attribute: forecast
transform: "return x.map(s => [s.kw, s.soc_kwh])"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.
