🧠 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_boost (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 |
|---|---|
| Cheap electricity | Preheat boost — lower fake temp = more heating |
| Expensive electricity | Brake overlay — raise fake temp = less heating |
| Cold forecast + upcoming expensive | Preheat boost to build thermal mass |
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
dtper ramp step is capped at 60 seconds (prevents jumps after HA restarts)- Released immediately if indoor temperature falls below the comfort floor
- Held briefly after expensive period ends (
BRAKE_HOLD_MINUTES = 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
State Machine — Priority Order
The control loop evaluates blocks in strict priority order and returns on the first match:
1. Summer mode → outdoor ≥ summer_threshold → passthrough real temp
2. Safe mode → required sensor missing → passthrough real temp
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
5b. Preheat-boost → expensive imminent AND forecast is cold
6. Normal PI → default, with optional ramp-out from previous brake
Operating Modes
| Mode | Trigger | PI active | Brake active | Integral |
|---|---|---|---|---|
summer_mode |
outdoor ≥ summer threshold | No | No | — |
safe_mode |
sensor data missing | No | No | — |
normal |
default | Yes | No (or ramp-out) | Accumulates |
holiday |
holiday switch on | Yes (lower target) | No (or ramp-out) | Accumulates |
braking |
price expensive, comfort OK | Frozen¹ | Yes | Frozen |
pre_braking |
expensive imminent, within ramp_in | Frozen¹ | Yes (ramping in) | Frozen |
preheating |
expensive imminent + cold forecast | Yes + 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.
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).
Ramp timing from house inertia slider:
ramp_in = clamp(house_inertia × 10, 20 min, 60 min)
ramp_out = clamp(ramp_in × 0.8, 20 min, 60 min) # ramp-out is 20% faster than ramp-in
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_inminutes - Starts ramp so brake reaches full factor exactly when the slot starts
- No forecast dependency — brakes regardless of weather
- Mode:
pre_braking
Preheat-boost (block 5b) — forecast signal
- Triggers when expensive period is coming AND a simple cold-forecast heuristic (
_forecast_is_cold()) returns true - Adds
PREHEAT_BOOST_C = 4 °Cboost to PI demand - Only active when cold — pointless in warm weather
- Suppressed when indoor temperature is already at or above target
- Requires
switch.pumpsteer_preheat_boostto be on - Mode:
preheating
sensor.pumpsteer_thermal_outlook provides richer forecast analysis but does not
yet control block 5b. Preheat decisions in 2.1.x are still made by the simple
_forecast_is_cold() heuristic. Connecting ThermalOutlook.preheat_worthwhile to
block 5b is the next planned step.
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 releases immediately when indoor temperature falls below the comfort floor, regardless of price 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. No hold time is applied.
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 check — it short-circuits everything else.
Safe Mode
When any required sensor is missing or invalid:
- Real outdoor temperature is passed through unchanged
- PI is reset, brake ramp is cleared
statusattribute contains the specific failure reason- Resolves automatically when valid data returns
Thermal Outlook (2.1.0+)
sensor.pumpsteer_thermal_outlook is diagnostic only in 2.1.x. It does not
influence any control decisions. Preheat is still controlled by the simple
_forecast_is_cold() heuristic — not by this sensor.
sensor.pumpsteer_thermal_outlook exposes the result of analyze_thermal_outlook(),
which analyzes the 24-hour weather forecast to determine:
| 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 |
Connecting ThermalOutlook.preheat_worthwhile to block 5b is the next planned step.
See the Roadmap for details.
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