🧠 System Architecture

Contents

Overview

PumpSteer optimizes heat pump behavior by manipulating the perceived outdoor temperature — a “fake outdoor temperature” sent to the heat pump controller. The heat pump’s own heating curve does the rest.

The system calculates the fake temperature based on:

  • Indoor temperature — comfort control via PI controller
  • Electricity spot price — cost optimization via brake and preheat overlays
  • Weather forecast — anticipation of cold or warm periods

The architecture is PI-controller centric. All other signals are overlays on top of the PI output — never replacements for it.


Core Principles

1. PI Controller is the Primary Control Loop

The PI controller is the only feedback loop in the system.

  • Input: temperature error (target − indoor)
  • Output: heating demand (°C offset applied to outdoor temperature)
  • Integral term: handles steady-state error (e.g. a cold day requiring sustained heating)
fake_temp = outdoor − PI_demand
          + brake_overlay     (raises fake temp during expensive slots)
          − preheat_overlay   (lowers fake temp before expensive slots)

The PI integral is frozen (not decayed, not reset) while braking is active. When the brake releases, the PI immediately resumes with full context of prior thermal demand — no lag, no relearning period.

2. Feedforward is External and Price/Forecast-Based

Feedforward signals come exclusively from electricity price and weather forecast.

Signal source Effect
Non-expensive period before a costly block Opportunity for bounded preheat
Expensive electricity Brake overlay — raise fake temp = less heating
Forecast supports preheat + upcoming expensive Preheat overlay to build thermal reserve

Feedforward is applied as a bounded overlay on top of PI output.

3. Brake is a Bounded, Ramped Overlay

The brake raises the fake outdoor temperature by BRAKE_DELTA_C (default 10 °C), making the heat pump think it is warmer outside and reduce output.

Rules:

  • Always smooth — ramped in and out, never a hard step
  • dt per ramp step is capped at 60 seconds (prevents jumps after HA restarts)
  • Released immediately if indoor temperature falls below the comfort floor
  • A short non-expensive gap is bridged only when the next expensive period starts within BRAKE_HOLD_MINUTES (default 30 min)
  • PI integral is frozen while brake is active

4. No Double Influence

Each signal influences the system once only:

  • Price classification drives brake or preheat — never both simultaneously
  • Pre-brake (5a) is a pure price signal, independent of forecast
  • Preheat-boost (5b) is a forecast signal, only active when cold weather is coming

5. Simplicity Over Complexity

  • No ML, no black-box decisions
  • All behavior is explainable from inputs alone
  • If a behavior cannot be described simply, it does not belong in the system

Ohmigo Relay Guard — Output-path Safety

The optional Ohmigo Relay Guard is intentionally outside the PumpSteer control loop. It does not calculate temperature, classify price, alter PI state, or change thermal behavior.

The responsibilities are separate:

PumpSteer control → calculates virtual outdoor temperature
Ohmigo Push       → decides whether setpoints are sent to Ohmigo
Relay Guard       → keeps the configured Active signal path available

Relay recovery is eligible only when the guard is armed, Ohmigo Push explicitly reports On, and the configured relay explicitly reports Off. Unknown, unavailable or missing relay state fails closed and never causes a relay command.

This separation is important in safe_mode: PumpSteer may still send the real outdoor temperature through Ohmigo, so safe mode alone must not disable the physical Active signal path.

Relay recovery uses a short reconnect stabilization delay, at most three switch.turn_on attempts, and state verification after every attempt. Successful service dispatch is not treated as success until the relay itself reports On.


State Machine — Evaluation Order

Required inputs are validated before the control branches. Missing indoor/outdoor temperature or unusable price data currently enters safe_mode before optimization continues.

With required inputs available, the control branches are evaluated in order:

1. Summer mode      → outdoor ≥ summer_threshold → passthrough real temp
2. Precool          → warm-period risk detected
3. Aggressiveness 0 → pure PI, all price logic disabled
4. Braking          → current price is expensive AND comfort allows
5a. Pre-brake       → expensive imminent, within ramp_in window AND comfort allows
5b. Preheat-boost   → expensive imminent, forecast supports preheat, thermal headroom remains
6. Normal / holiday PI → default, including brake ramp-out or short-dip hold

A valid short-dip bridge is reported as brake_hold. It is not a separate price decision; it is the normal-control path preserving the existing brake factor while a nearby expensive period approaches.


Operating Modes

Mode Trigger PI active Brake active Integral
summer_mode outdoor ≥ summer threshold No No —
safe_mode required temperature or price input missing/invalid No No —
precool warm-period risk detected No Yes (precool brake ramp) —
normal default Yes No (or ramp-out) Frozen during ramp-out; otherwise accumulates
holiday holiday target active while otherwise in normal PI Yes (lower target) No (or ramp-out) Frozen during ramp-out; otherwise accumulates
braking price expensive, comfort OK Frozen¹ Yes Frozen
brake_hold short non-expensive gap before nearby expensive period Frozen¹ Held flat Frozen
pre_braking expensive imminent, within ramp_in, comfort OK Frozen¹ Yes (ramping in) Frozen
preheating expensive imminent + forecast worthwhile + headroom remaining Yes + tapered boost No Accumulates

¹ PI is computed with a frozen integral. At factor = 1.0 the PI output has no effect on fake_temp. It influences output only during ramp-in/ramp-out (0 < factor < 1) as part of the blend.

During precool, PI is computed only to advance its clock with a frozen integral; its output is discarded. The first PI computation after precool also freezes the integral, so it does not integrate the paused interval. Later unrestricted cycles resume ordinary integration. Existing PI resets clear this pause state.


Price Classification

Prices are classified relative to today’s price spread using P30 and P80 percentiles:

Category Condition
cheap Below P30, or below ABSOLUTE_CHEAP_LIMIT (0.50 SEK/kWh)
normal Between P30 and P80
expensive Above P80

Thresholds are cached per calendar day. Recomputing hourly caused mid-slot reclassification — P80 could shift just enough to flip an ongoing expensive slot to normal and release the brake unexpectedly. Daily caching keeps thresholds stable.

If P80 for the day is below ABSOLUTE_CHEAP_LIMIT, all slots are classified as cheap (no braking occurs on universally cheap days).

HISTORY_WEIGHT / HORIZON_WEIGHT and compute_price_thresholds() (72-hour trailing history) exist in settings.py / electricity_price.py but are not applied. Reserved for a future hybrid implementation.


Brake Ramp Mechanics

# Ramp factor update (each polling cycle, ~60s)
factor += dt_s / (ramp_in_min × 60)     # while brake requested
factor -= dt_s / (ramp_out_min × 60)    # while not requested and hold expired
dt_s = min(actual_dt, 60)               # cap prevents jumps after restarts
factor = clamp(factor, 0.0, 1.0)

# Output blend
fake_temp = pi_fake + (brake_temp − pi_fake) × factor
brake_temp = outdoor + BRAKE_DELTA_C

At factor = 0.0: pure PI output (no brake). At factor = 1.0: full brake (PI frozen, brake temp dominates).

During a valid bridge_short_dip, the existing brake factor is held flat. The bridge does not count as a new brake request and therefore does not continue ramping the factor upward. If the next expensive period is farther away than BRAKE_HOLD_MINUTES, the brake ramps out normally.

Ramp timing from house inertia slider:

ramp_in  = clamp(house_inertia × 6, 15 min, 60 min)
ramp_out = clamp(ramp_in × 0.5, 15 min, 60 min)

Pre-brake vs Preheat-boost

These two blocks are distinct and are often confused:

Pre-brake (block 5a) — pure price signal

  • Triggers when expensive period is within ramp_in minutes and indoor temperature is above the comfort floor
  • Starts ramp so brake reaches full factor exactly when the slot starts
  • If the comfort floor is crossed, pre-brake is not started and an existing pre-brake ramp is released
  • No forecast dependency — brakes regardless of weather
  • Mode: pre_braking

Preheat-boost (block 5b) — forecast signal

  • Triggers when an expensive period is coming and ThermalOutlook.preheat_worthwhile is true
  • Falls back to the simpler _forecast_is_cold() heuristic only when ThermalOutlook is unavailable
  • Scales the base PREHEAT_BOOST_C = 4 °C by preheat_strength, the preheat ramp, and remaining thermal headroom
  • Allows limited thermal charging above target, but tapers the extra boost linearly to zero at the configured headroom ceiling
  • Requires switch.pumpsteer_preheat_boost to be on
  • Mode: preheating

Preheat headroom follows saving level:

Saving level Maximum headroom above target
0 0.0 °C
1 0.3 °C
2 0.5 °C
3 0.7 °C
4 1.0 °C
5 1.5 °C

Below target, the headroom factor is 1.0. Between target and the ceiling it decreases linearly. At target + headroom, extra preheat boost is zero and PumpSteer returns to normal PI behavior.

Block 5a must never be forecast-gated. If forecast data is unavailable, the brake must still engage before the expensive slot. Forecast availability cannot be a dependency for price-based braking.


Comfort Floor

The brake and pre-brake release immediately when indoor temperature falls below the comfort floor, regardless of price, lookahead, or hold time:

comfort_floor = target − COMFORT_FLOOR_BY_AGGRESSIVENESS[aggressiveness]
Aggressiveness Allowed drop Comfort floor (target = 21 °C)
0 0 °C 21.0 °C (PI only, no price logic)
1 0.5 °C 20.5 °C
2 1.0 °C 20.0 °C
3 1.5 °C 19.5 °C (default)
4 2.0 °C 19.0 °C
5 3.0 °C 18.0 °C

When the comfort floor triggers, brake_hold is set to 0 and the brake releases immediately. Pre-brake is also blocked, and any active pre-brake ramp is allowed to ramp out. No price lookahead or short-dip bridge may override the comfort floor.


Summer Mode

When outdoor temperature reaches or exceeds summer_threshold (default 17 °C):

  • All control logic is bypassed
  • Real outdoor temperature is passed through unchanged
  • PI is reset, brake ramp is cleared
  • Heat pump operates on its own summer logic

Summer mode is the highest-priority control branch after required input validation. The current implementation validates required temperature and price inputs first; the roadmap tracks a future change where missing price data may fall back to comfort-only PI instead of safe passthrough.


Safe Mode

When required control input is missing or invalid:

  • Missing indoor/outdoor temperature enters safe mode
  • Missing or unusable price-list data also currently enters safe mode
  • If real outdoor temperature is available, it is passed through unchanged
  • If the outdoor sensor itself is unavailable, the PumpSteer sensor becomes unavailable
  • PI and both brake/preheat ramps are reset
  • status contains the specific failure reason
  • Safe mode resolves automatically when valid data returns

Thermal Outlook (2.1.0+)

sensor.pumpsteer_thermal_outlook exposes the result of analyze_thermal_outlook(), which analyzes the 24-hour weather forecast. Its preheat_worthwhile and preheat_strength values are also used by block 5b when ThermalOutlook is available. The simpler _forecast_is_cold() check remains only as a fallback when the richer outlook cannot be built.

ThermalOutlook determines:

Attribute Description
preheat_worthwhile True if cold enough, long enough, and no warm day incoming
preheat_strength 0.0–1.0, scales with how cold the forecast is
warming_trend Temperature rising in the next 6 hours
cooling_trend Temperature falling in the next 6 hours
precool_risk Warm period coming that will heat the house naturally
night_min_temp Lowest forecast temp in 22:00–06:00 window
day_max_temp Highest forecast temp in 06:00–22:00 window

ThermalOutlook and ThermalModel remain separate. ThermalOutlook actively influences preheat. ThermalModel collects valid cooling samples during braking and, after a completed brake phase, fits its cooling constant k when at least 20 samples are available. The fitted thermal_k is restored across Home Assistant restarts.

Rate history includes only valid indoor readings. Missing/non-finite readings, duplicate/backwards timestamps, or a gap longer than five minutes break that history; three minutes of fresh contiguous readings are needed before collecting another cooling sample. Cooling rates use elapsed time across DST transitions. Restoring thermal_k does not restore old temperature history.

ThermalModel is still diagnostic with respect to control decisions: it does not yet decide brake depth, override the comfort floor, or determine preheat headroom. Prediction helpers such as expected temperature drop and brake safety remain the next validation step.


What is Out of Scope

  • Machine learning control loops
  • Self-modifying parameters
  • Black-box optimization
  • Any behavior that cannot be explained from its inputs alone
  • Cloud dependencies