Skip to content

Audit: the pump models were invented, and everything computed from them - #24

Draft
enoch85 wants to merge 122 commits into
mainfrom
audit/safety-fixes-and-en442
Draft

Audit: the pump models were invented, and everything computed from them#24
enoch85 wants to merge 122 commits into
mainfrom
audit/safety-fixes-and-en442

Conversation

@enoch85

@enoch85 enoch85 commented Jul 13, 2026

Copy link
Copy Markdown
Owner

To anyone coming here; this is an AI experiment. I let it loose for a few days, and see what it produces. Seems like mostly garbage so far. :)


A repo-wide audit of a heat-pump controller that runs on real hardware. 101 commits, ~40 defects.

Every fix is red-first, mutation-tested, and verified against a live Home Assistant.


The tariff — the feature this integration is named after

  • It billed the wrong quantity. The Swedish effect tariff bills the hourly mean. The code billed the 15-minute mean — a citation I invented. A hot-water cycle at 9 kW in an otherwise idle hour was recorded as a 9 kW peak instead of 3 kW, and the pump was throttled all month to defend a number on no bill. (Ellevio: "the measurement uses hourly averages"; Energimarknadsinspektionen: "per timme".)
  • The rate was two different unsourced numbers (50.0 in production, 81.25 in the simulator, labelled "fictional"). 81.25 SEK/kW/month is Ellevio's published rate. One copy now, sourced.
  • A milliwatt sensor was read as megawatts. mW/MW differ only in case, and the lookup case-folded. 5000 mW became 5,000,000 kW → recorded as the month's peak → every real hour looked safe against it, so peak protection went silent for the rest of the month.
  • On the night the clocks go back, an hour of the month's peak was deleted. Wall-clock 02:00 happens twice; PEP 495 ignores fold when comparing same-zone datetimes, so the two hours merged and a 9 kW hour was recorded as 1 kW.
  • An hour the meter slept through was billed as the month's peak. Meter reads 9 kW, goes unavailable for 50 minutes, returns at 1 kW → the hour was billed at 8.33 kW from two samples, while the log said "peak billing is suspended" ten times. It wasn't.
  • Peak protection never fired at all for anyone without a whole-house meter.
  • The monthly peak never reset on a month boundary in a running instance.

The heat-pump models were invented — and everything was computed from them

  • COP curves labelled "Real-world COP curve (tested and validated)", sourced to "NIBE datasheet, Swedish NIBE forum validation". They are in neither. The F750 and F730 shipped byte-identical curves. Max output claimed 8.0 kW against a published 4.994.
  • The air-source derate cited "the EN 14511 rating points trace a near-linear decline". They trace a rise. The citation was invented and the sign was backwards — and the headline finding rested on it.
  • The houses were invented too, and three of five paired a pump with a house it was twice too big for — which is why the ground-source houses "never engaged the emergency ladder". That was a fact about my sizing, not the controller.
  • Now: every EN 14511 rating point transcribed verbatim from the manufacturer, houses sized from NIBE's declared Pdesignh, and a PROVENANCE table where every physical constant is either SOURCED (quote the document) or ASSUMED (state the measured sensitivity). A test enforces it.

Control safety

  • Leaving the weather dropdown blank silently switched off the main control law. Math WC — the EN 442 emitter law, which votes on 100% of cycles and reads the pump's own sensors — bailed out if no forecast was present. The weather entity is optional. Result on an air-source pump: 13× more resistive heat than physics forced, 1265 minutes above the comfort ceiling.
  • Turning the thermostat OFF did not turn the optimiser off. It reset the offset once, then resumed driving the pump five minutes later while still displaying OFF — and RestoreEntity carried that lie across a reboot.
  • An unloaded integration still wrote to the heat pump — the curve offset and the exhaust fan. Removing the integration ended with it commanding the pump one last time.
  • A hot-water boost we started, and then disowned. boost_dhw turned on the immersion heater without recording that we did it, so the unload cleanup decided the household must have, and left it running with nothing to stop it.
  • int(-1.9) == -1, so the pump always did less than the engine asked.
  • The emergency ladder fired in July.

The tests and the simulator could not fail

  • The simulator's "first-law energy audit" was an identity — it could not fail, so it could not detect.
  • 21 tests for overshoot protection never called the code. Others asserted a literal against itself.
  • The simulator was validating an implementation nobody runs: the billed quantity was computed twice, with two different formulas. The DST bug lived in both, independently — so neither could catch the other. One definition now; break it and the simulation fails.

🛑 Known, not fixed — your call

  • F-124: a saturated compressor is a positive-feedback trap. Raising the offset raises the setpoint instantly, but a saturated compressor cannot follow — so degree-minutes fall faster, and the emergency layer raises the offset again. Every machine that saturates is made worse by the optimiser: 2–5× the resistive heat of a do-nothing controller, house cooked to ~30 °C. Reproduced on the sourced models, under both published sizing conventions. Marked xfail(strict) so it cannot be forgotten. Fixing it means deciding what a heat pump should do when it physically cannot meet its own curve — a heat-pump decision, not a code cleanup.
  • The effect charge was repealed. EIFS 2022:1 repealed June 2026; Ellevio dropped its charge 1 June 2026; a new model is due 12 Apr 2027. Several DSOs still levy one, so the feature isn't dead — but the rate is one company's, it's no longer that company's, and it isn't configurable. Product decision.
  • boost_dhw accepts target_temp and duration that reach nothing — NIBE's temporary lux is a switch and owns both. Removing them would break automations that pass them.

Reading this diff

  • Net executable production code: +724 lines. That is what changed on the pump.
  • The rest is tests (~40 defects, each red-first) and the sourced reasoning behind them.
  • ~40% of the commits are me fixing my own bugs from earlier in the audit — including a commit correcting a claim I made four times without ever executing it. That's in here on purpose.

Verified live: HA restarts clean, 28 entities, 5 services, 0 errors; config flow, options flow, reconfigure, services and the thermostat all exercised end-to-end through the real API and the frontend. 2650 tests pass.

enoch85 added 30 commits July 12, 2026 17:11
… model

Repository-wide audit. The changes fall into three groups.

SAFETY (control path)
- Emergency tiers are dispatched from EmergencyLayerDecision.tier rather than
  reconstructed from weights and offset magnitudes, so a retuned weight or a
  damped offset can no longer fall through into the cost-layer override path.
- The DM aux-limit check runs before every other branch in the thermal layer.
  It previously sat below three early returns, so a "too warm" reading or the
  anti-windup cooldown could silence it while degree minutes sat past the limit.
- The adapter no longer substitutes plausible constants for missing outdoor,
  supply or degree-minute readings, and no longer fabricates degree minutes. A
  broken installation now degrades instead of writing a curve offset from
  invented data.
- Safety recoveries bypass the offset-volatility blocker, which exists to damp
  price-driven flip-flopping and must never defer a recovery.
- DHW rate limiting applies to starts only, never to stops.
- The coordinator survives errors in its aligned refresh, and shutdown is
  idempotent and cannot re-arm a timer on a dead coordinator.
- Monthly peaks prune on a month boundary in a running instance, and
  peak_this_month takes the highest tracked peak rather than the latest.
- The savings calculator refuses to guess an unknown price unit instead of
  assuming ore (a 100x error).

FLOW TEMPERATURE
Replace Andre Kuehne's formula with the EN 442 emitter law (utils/emitter.py).
Kuehne's HC is Vaillant's dimensionless heating-curve label; the code fed it a
building heat-loss coefficient in kW/K. The resulting curve rose 0.22 C of
supply per -1 C outdoor where a radiator house needs ~0.76, so it demanded
deeper heat cuts the colder it got: at a design point requiring 50 C it asked
for 31.7 C and commanded -11.06 C of offset on an already-correct curve.

NIBE's own published curve 9 reads 41.0 C at 0 C outdoor. The emitter law gives
40.6 C; a straight line between the same anchors gives 38.7 C.

The compensation offset is now a bounded trim on the pump's curve, and the
climate safety margin is an asymmetric tolerance rather than an addition to the
setpoint, so a correctly tuned curve is left alone instead of being told to add
heat at every outdoor temperature.

Underfloor heating gets its own emitter exponent (EN 1264, n~1.1) and its own
design flow temperature, replacing a fixed subtraction from a radiator curve
that reduced it twice and pinned concrete slabs to a flat 25 C target.

HeatPumpProfile.calculate_optimal_flow_temp is removed: a profile describes the
pump, and the flow temperature a house needs is a property of its emitters.

SIMULATION
The harness is now load-bearing. Flow is capped by compressor capacity, so
degree minutes can run away and the deep-DM paths execute for the first time.
The plant and the pump's curve both obey the emitter law. The effect layer sees
the peak the plant actually produced. Safety invariants fail the run with a
non-zero exit code. Prices are parsed by the real GESpotAdapter.

Also: single-instance enforcement, a temperature-delta device class for the
offset sensor, the DHW boost ceiling lowered to the declared maximum, Swedish,
Norwegian, Danish and Finnish translations brought back into parity with
strings.json, and an AST-based magic-number check.
A yardstick, not a proposal. It charges the fabric when power is cheap and
coasts when it is dear, inside a 1 C comfort band, and knows nothing about
degree minutes, weather, peaks or the pump.

On the captured SE4 day (41x spread between cheapest and dearest quarter) it
saves 5.1% of the spot bill on the timber house and 8.0% on the concrete one,
holding indoor temperature inside the band. The decision engine saves 0.7% and
2.2% on the same runs.

It also raises the effect tariff by 15 and 27 SEK, because charging hard sets
new peaks. That is the gap the layered engine exists to fill: charge when power
is cheap AND spread the charge so it never sets a monthly peak.

Run with --battery.
The fabric is the only battery this integration has, and it is what lets it
beat the pump's own curve: the pump cannot see the price. Charging it means
running the house warm while power is cheap and coasting while it is dear, so
the house must be free to move.

Two layers independently prevented that. The comfort layer escalated at the
tolerance, answering a deliberate +0.8 C charge with weight 0.77 and an offset
of -7.67 - slamming the heating off before the fabric held any heat. The price
layer separately drove its own pre-heat offset to zero once the house rose more
than preheat_overshoot_allowed above target.

Comfort now escalates at THERMAL_BATTERY_BAND. Inside the band it is a weak
spring: it returns the house to target when nothing else has a reason to move
it, and yields to a price signal that does. Outside the band it is in charge
again, and the hard safety floor is untouched. Overshoot is measured from the
band edge, as the cold side already was, so the response ramps from zero at the
edge instead of jumping.

The cold branch also used LAYER_WEIGHT_COMFORT_MAX (0.5), a constant const.py
marks as legacy, while overshoot escalated from LAYER_WEIGHT_COMFORT_HIGH (0.7):
the system answered a house that was too warm more firmly than one that was too
cold. It now escalates on the same scale as overshoot.

This is necessary but not sufficient. The aggregate is a weighted mean over all
layers, so near-zero layers act as ballast: the price layer asks for +2.80 and
the pump is told +1.70, which the integer register write truncates to +1. On a
concrete slab that is 0.5 kW of surplus and 0.035 K/h. No layer can dominate the
aggregate, by construction, and that is what still holds the amplitude down.
scripts/test_decision_scenarios.py, test_seasonal_defaults.py and
visualize_price_optimization.py all hardcoded /workspaces/EffektGuard, a
devcontainer path that does not exist in a normal checkout. Every one of them
died on import, so the scenario tester - the tool built for exactly this kind of
tuning, with switches for per-layer weights - has been unrunnable.

Its first scenario is worth the repair on its own. At -10 ore/kWh, with the grid
paying to be consumed from and the house one degree above target (a charged
thermal battery, which is precisely what free power should buy), the aggregate
reduces heating.
Three defects, all in the layer that is supposed to be the whole point of the
integration, and all found by running scripts/test_decision_scenarios.py.

THE DAY SWITCHES OPTIMISATION OFF WHEN IT MATTERS MOST

The classifier short-circuited to "uniform prices, all NORMAL, no optimisation"
whenever p25 == p90. That guard is for fallback mode, where the adapter has no
data and invents 96 identical quarters. But p25 == p90 does not mean the day is
flat - it means the day has a PLATEAU, which is true whenever the middle 65% of
quarters share one price.

A day of 83 quarters at 120 ore and 13 at MINUS 10 ore satisfies it. Every
quarter, including the ones where the grid is paying to be consumed from, was
classified NORMAL and the price layer bid +0.00. A high-wind day - most of the
day near zero, a dear evening - has the same shape. Optimisation switched itself
off on precisely the days worth optimising.

Uniformity is now the absence of a spread, measured relative to the day's mean
magnitude so that the test is invariant to the price unit and survives negative
prices.

RANK IS NOT MAGNITUDE

The percentiles say nothing about how far apart the prices actually are. Rank
alone called 88 quarters at 40 ore VERY_CHEAP on a day whose median was 40 ore,
earning each of them +4 C of pre-heat, and called a 60 ore quarter a PEAK worth
shutting the heating off for. A band must now be earned by rank AND by a real
distance from the day's median; a quarter that fails on magnitude falls back to
NORMAL rather than to the next band along.

The median, not the mean: one absurd quarter drags the mean upward and every
ordinary quarter then looks cheap beside it - 95 quarters at 50 ore alongside a
single 5000 ore spike came out as VERY_CHEAP.

A PEAK COULD NEVER BE VOLATILE

    is_volatile = is_brief_run and current_classification != PEAK

VOLATILE_MIN_DURATION_MINUTES is 45: the compressor's ramp-up plus its
cool-down. A run shorter than that is one the pump physically cannot act on.
That is a fact about the machine and it does not care what the price is doing,
so it holds for a peak exactly as it holds for a cheap period.

Excluding PEAK meant an isolated fifteen-minute spike always took critical
weight and commanded a full -10 C shutdown for an event the house cannot feel
and the pump cannot reach, leaving the offset flip-flopping - the exact
behaviour the volatility guard exists to prevent. A 30-minute spike now damps to
-2.2 C instead of -10.0 C.

The real SE4 day is unchanged by all of this (10/14/47/15/10).
An exhaust-air heat pump pulling more air through the evaporator is not also
receiving a free COP improvement. Those are the same joules:

    Q_cond = P_el + Q_evap                       (first law, steady state)
    d(Q_cond)|P_el = d(Q_evap) = P_el * d(COP)   (at constant electrical input)

calculate_net_thermal_gain added both terms.

NIBE's S735 manual settles it. It publishes four points at identical conditions
(A20(12)W35, minimum compressor frequency) with exhaust airflow as the only
variable - a controlled experiment from the manufacturer. Over the 90 to 252
m3/h step the measured heat-output rise is +0.410 kW, P_el*dCOP is +0.387 kW,
and dQ_evap is +0.404 kW. One number, three ways.

With the double-count removed, break-even lands where the physics puts it: at an
outdoor temperature of indoor minus the evaporator's temperature drop, about
+9 C. The evaporator recovers only that drop from the extra air, while the
building has to warm every cubic metre of it the whole way from outdoor to
indoor. Below break-even - which is the entire Swedish heating season -
enhancing is a net thermal LOSS:

    +15 C  +0.22 kW        +5 C  -0.14 kW
    +12 C  +0.12 kW         0 C  -0.29 kW
     +9 C   break-even    -10 C  -0.65 kW

The feature is kept and now declines to enhance when it cannot pay. The
`if net_gain <= 0` branch it needed to do that was unreachable code until now.

Six tests asserted the double-counted gains, including one that required a
+0.9 kW gain at 0 C outdoor. estimate_cop_improvement, the compressor_input_kw
argument and three constants went with the term.
Degree minutes are NEGATIVE. The thermal-mass buffer multiplied them:

    warning = -540 * 1.3 = -702

which does not tighten a threshold, it deepens it. The concrete slab - six to
twelve hours of thermal lag, the system that most needs early warning - was made
to wait 162 degree minutes LONGER for help than a radiator system that recovers
in under an hour. It now divides: -540 / 1.3 = -415.

    concrete_ufh  6-12 h lag   warns at -415   (was -702)
    timber        2-4 h lag    warns at -470   (was -621)
    radiator      <1 h lag     warns at -540   (unchanged)

Ten tests asserted the inversion. One of them, test_concrete_activates_t1_
earlier_than_radiator, contradicted its own name and rationalised the result in
its docstring: the slab "can absorb more energy without immediate indoor
temperature impact" - which is the argument for acting sooner, not later.
Precisely because the debt does not reach the room for six hours, waiting until
a radiator system's threshold commits hours of deficit that cannot be recovered.
Another, in test_emergency_layer_evaluate.py, worked the bug out in its own
docstring ("Actually the multiplier makes it MORE negative (later warning), not
tighter") and then adjusted the test to match it.

The two thermal-debt layers also computed their thresholds separately, and only
EmergencyLayer applied the buffer. Between the proactive layer handing over and
the emergency layer picking up lay a band of degree minutes in which neither
responded - and it was widest for the concrete slab, which can least afford it.
ProactiveLayer did not even know the heating type. Both now read one shared
ladder, apply_thermal_mass_buffer, so they cannot diverge again. The gap is zero
degree minutes for every emitter.

The auxiliary-heat limit is hardware and is never buffered.
The coordinator tolerates a missing NIBE at startup, because MyUplink can take
the best part of a minute to publish its entities. It did so by returning
startup_pending whenever the first successful update had not happened yet, and
nothing ever bounded that.

So a user who picked the wrong entity - or who has no NIBE at all - kept a
config entry that stayed loaded and green indefinitely. The entities sat at
unavailable, last_update_success stayed True, and after one informational line
nothing was ever logged again. The integration reported that it was fine, for as
long as Home Assistant stayed up, while reading nothing and controlling nothing.

Waiting is right. Waiting forever is a silent failure, and this integration
writes to a heat pump: "I am fine" has to mean it.

After STARTUP_MAX_GRACE_ATTEMPTS cycles - a generous margin over any plausible
MyUplink start-up - a missing pump becomes UpdateFailed, so the entry reports the
problem and names the likely cause. The counter resets on the first successful
read, so a slow start is still just a slow start.
The offset works by raising the pump's calculated supply setpoint, and that
produces heat only while the compressor has frequency left to give. Above 100 Hz
for a quarter of an hour it has none. The setpoint goes up, the compressor
cannot follow, and all that is bought is wear - plus a DEEPER degree-minute
deficit, because DM is the integral of (BT25 - S1) and S1 just rose while BT25
could not.

The auxiliary heater exists for exactly this moment. NIBE places "start
addition" where it does so the compressor need not grind at maximum for hours.
Demanding more from a saturated compressor to spare a few kWh at COP 1.0 trades
cheap electricity for expensive compressor.

Everything needed to see this was already here and none of it was connected.
CompressorHealthMonitor tracks continuous time above 80 Hz and above 100 Hz and
reports HIGH when the compressor has been at maximum for more than fifteen
minutes - "compressor at maximum capacity for extended period". The coordinator
computed that verdict and wrote it to a debug log. Nothing consumed it. The
profiles' min_runtime_minutes and min_rest_minutes are dead in the same way:
declared on every model and the base class, read by nothing. The decision engine
was free to command +10 into a machine that had nothing left to give.

The verdict is now a control input. At HIGH risk the engine HOLDS the offset at
what the pump is already being asked for. It never reduces it, and it stands
aside entirely for the absolute safety paths - a house below MIN_TEMP_LIMIT, or
degree minutes past the aux-start, gets everything the machine has. Wear is a
cost; a cold house is a failure.

This costs no comfort, and that is forced rather than argued: the extra offset
was not producing heat, so declining to ask for it cannot take any away.

The monitor's risk levels were bare string literals; they are constants now.
The pre-heat layer fires when the forecast shows a drop of at least
WEATHER_FORECAST_DROP_THRESHOLD within WEATHER_FORECAST_HORIZON - a fixed twelve
hours, for every house, whatever it was built of.

A concrete slab does not get into thermal debt from a sudden plunge. The pump's
own curve catches that: it is reactive, but it is fast. The slab gets into debt
from a slow, deep slide that nothing notices - and a twelve-hour window cannot
see one:

    cold snap                     drop within 12 h   fires?
    15 C over  6 h (plunge)            -15.0 C        yes
    15 C over 24 h                      -7.5 C        yes
    15 C over 48 h (two days)           -3.8 C        NO
    20 C over 72 h (three days)         -3.3 C        NO

Within any twelve hours of a two-day slide the temperature falls less than the
four degrees needed to trigger, so the pre-heat never fires at all. The slab is
drained slowly, over days, with nothing watching - while the sudden plunge that
DOES trigger it is the case that needed it least.

Everything required to see this was already here and unreachable.
UFH_CONCRETE_PREDICTION_HORIZON is 24 hours and says so in its own comment: "6+
hour lag, needs 24h for extreme cold". AdaptiveThermalModel returns it
correctly - and the engine passes the STATIC ThermalModel, whose
get_prediction_horizon() returned a hardcoded 12.0 for every thermal mass and
admitted as much in its docstring. The pre-heat layer did not even ask: it took
thermal_mass in its constructor and used it only to scale its weight.

ThermalModel now derives the horizon from thermal mass, using the same
thresholds the engine already uses to infer the heating type - so a house cannot
be concrete for its heating curve and something else for its forecast. The
engine passes it to the pre-heat layer as a FLOOR: the model may only extend
WEATHER_FORECAST_HORIZON, never shrink it. Seeing further ahead costs a little
early pre-heat; seeing less far can cost the cold snap entirely, and the twelve
hours that are too few for a slab are perfectly adequate for a radiator.

Measured on a 100 mm slab plus 60 mm screed (2-node transient): the room moves
+1.0 C in 2.4-4.6 h, but the slab reaches only 63% of its response in about
fourteen hours. Six hours is the lag. A day is the horizon.
The pre-heat layer's whole job is to charge the building fabric before a cold
snap lands. It asked for +0.83 C, and on the simulator's validated plant models
that took 28.4 hours to fill the storage band on a radiator house and 34.6 on a
concrete slab - against forecast horizons of twelve and twenty-four. The battery
could never be charged before the cold arrived. Not once.

The constant's own comment recorded the struggle: "tuned Oct 20, was 0.5 -> 0.6
-> 0.7 -> 0.77". It was being nudged in hundredths when it needed to be tripled.

It is now SIZED, not tuned. The fabric must reach the edge of the storage band
within the horizon the house is given:

    energy to fill the band = C_fabric * THERMAL_BATTERY_BAND
    surplus the offset buys = offset * DEFAULT_CURVE_SENSITIVITY * dQ/dFlow
    time to fill            = energy / surplus   <=   the forecast horizon

                       offset +0.83     offset +2.00     horizon
    radiator (tau 30h)     28.4 h           9.6 h          12 h
    concrete   (tau 80h)   34.6 h          14.8 h          24 h

WEATHER_GENTLE_OFFSET is renamed WEATHER_PREHEAT_OFFSET, because it is no longer
gentle and never should have been: a fraction of a degree cannot move a
building.

It cannot cook the house. The comfort layer takes charge at the edge of
THERMAL_BATTERY_BAND, so the pre-heat charges the fabric quickly and then hands
over; the compressor-wear guard stops it demanding more from a machine already
at maximum. Six simulation runs hold comfort between 21.66 and 22.47 C.
_async_update_data is Home Assistant's READ hook. It is public, debounced, and
called by anything that wants the coordinator refreshed - a reload, an options
change, a service. The heat-pump writes lived inside it, so:

    reset_peak_tracking - a service whose entire job is to clear a stored
    counter - wrote a curve offset to the heat pump.

So did every HA reload and every options change.

Writes now belong to the control loop. _do_aligned_refresh is the only thing on
a clock (update_interval is None), and it is the one that applies. Services that
genuinely mean to command the pump - force_offset, boost_heating, and enabling
optimization - call async_refresh_and_apply and still take effect at once.
Bookkeeping services refresh and stop there.

Giving the write path a single name exposed a race it had all along. Both
writers are long coroutines that await at every step, and asyncio interleaves
them freely:

    12:05:10  the aligned refresh reads the world and starts deciding
    12:05:11  force_offset(+3) sets the override, decides, and writes +3
    12:05:12  the aligned refresh - which snapshotted the engine BEFORE the
              override existed - finishes and writes +0.5

The user's forced offset is gone, overwritten by a decision that predates it,
and the service reports success. The same interleaving corrupts _apply_offset's
rate limiting, which reads last_offset_timestamp and then writes it. _drive_the_pump
is now the sole owner of the write path and holds a lock. Reads are deliberately
left free to overlap: they touch no hardware.

Three existing tests were passing for the wrong reason once the split landed -
they asserted that something was NOT written, and nothing writes on the read
path at all. The startup-grace guard, the volatility blocker's manual-override
bypass, and the update loop's re-arm were all unprotected. They drive the pump
now, and each was mutation-checked: break the guard, the test goes red.

Verified on a live Home Assistant: the read hook decides and does not apply, the
aligned tick reaches _apply_offset, and the pump is still driven.
The learning model scores its own confidence, and drives the heat pump through
the pre-heat layer once that score passes 0.7. One of the three terms:

    consistency = 1.0 - min( std(rates) / max(mean(rates), 0.1), 1.0 )

The max(mean, 0.1) is guarding the division. What it actually does is turn no
signal into a perfect signal. If every rate is identical, std is 0, and:

    consistency = 1.0 - min(0 / 0.1, 1.0) = 1.0        perfect

Every rate IS identical when a 0.1 C indoor sensor is sampled every five minutes
and the house is holding steady - and always, when the sensor has failed. So, on
a full deque:

    a flatlined sensor, 672 identical readings   consistency 1.000   confidence 0.867  ENGAGES
    a house genuinely, measurably heating        consistency 0.000   confidence 0.467  does not

The house that told us nothing scores highest. A failed indoor sensor earns
maximum confidence in what we have learned from it. Simulated over 90 days at the
coordinator's real cadence, learning switched itself on at day 4, off by day 7,
and on again at day 60 - each time feeding heating_efficiency and
thermal_decay_rate, computed from that flat line, into the pre-heat layer at
weight 0.65. It does not converge. It flickers, and it flickers on exactly when
the data has gone degenerate.

A signal too weak to carry information now scores zero, never one. Half marks for
having said nothing yet (else: consistency = 0.5) are gone with it.

The change is one-directional by construction, and checked over 200,000 random
rate-sets: not once does the new metric score higher than the old. Confidence can
only fall, so learning can only engage less often - it cannot switch on anywhere
it was not already on. At the production cadence it now engages on 0 of 89 days
and the flicker is gone, and it is inert for a reason rather than by luck: a
0.1 C sensor read every five minutes quantises the building's response into steps
of 1.2 C/h, larger than any real heating rate, so there is genuinely nothing there
to learn. The model says so instead of pretending otherwise.

The README promised a 7-14 day learning timeline. The observation window is a
rolling 672 entries - 56 hours at this cadence - so day 90 sees exactly what day 3
saw, and no such timeline can occur. It now says what the code does.

Widening the cadence so the signal clears the sensor's resolution is what would
make learning genuinely work. That is a deliberate decision about putting a
never-validated learned model in the control path of real heating equipment, and
it is not made here.
CLAUDE.md points every contributor at .github/copilot-instructions.md as "the
single source of truth for this repository's rules, architecture, and
implementation guidelines", to be read at the start of every session. It has been
describing a codebase that is not this one.

Architecture. It documented a "Validation Layer (utils/validators.py)" and a
cross-cutting "Safety (utils/safety.py)". Neither file has ever existed. Safety
is DecisionEngine._safety_layer() - a method, not a file - and EmergencyLayer in
optimization/thermal_layer.py. The module map named thermal_model.py,
effect_manager.py and price_analyzer.py; the real modules are thermal_layer.py,
effect_layer.py and price_layer.py. It listed number.py as an entity platform,
which was deliberately removed (see PLATFORMS in __init__.py).

Safety constants. It presented DM_THRESHOLD_EXTENDED as an import and
DM_THRESHOLD_WARNING as a constant. Neither exists: the warning threshold is
computed per climate zone and outdoor temperature. Its three worked climate
examples were wrong in all nine numbers, verified against
ClimateZoneDetector.get_expected_dm_range():

    Stockholm at -10C   doc -450/-700/-700     actual -490/-740/-740
    Kiruna    at -30C   doc -800/-1200/-1200   actual -1000/-1400/-1400
    Paris     at  +5C   doc -200/-350/-350     actual -100/-250/-250

Only critical = -1500 was right. The table now also records that normal_max
equals warning in every zone, so the WARNING band has zero width - a known open
finding, flagged so nobody quietly "corrects" the table to hide it.

The examples. The canonical "use constants, don't hardcode" example imported
DM_THRESHOLD_CRITICAL and OPTIMAL_FLOW_DELTA_SPF_4 - neither exists, so it would
fail at import - and annotated UFH_CONCRETE_PREDICTION_HORIZON as 12.0 hours when
the constant is 24.0. Its correct branch produced the same wrong number as the
hardcoded branch it was warning against. The "document your research basis"
example made the same 12.0 claim. Every name in these examples is now real, and
the pedagogical ones use real constants so that nobody copies a fiction into
const.py.

Every .py path and every constant name in the file is now checked against the
tree and against const.py. The only names that do not resolve are the ones the
document explicitly says do not exist.
docs/CLIMATE_ZONES.md is the document a maintainer opens to answer "what degree
minutes are normal here?" - the most safety-critical question in the project. All
seventeen of its DM rows were wrong.

The winter averages the tables derive from had drifted: Cold said -10 where the
code holds -8.0, Very Cold -15 where it holds -12.0, Standard +5 where it holds
0.0. And the tables listed each zone's BASE range against its coldest row rather
than against its winter average - so even Extreme Cold, whose average was right,
came out wrong at every temperature:

    Stockholm at -10 C   doc -450 to -700, warning -700     code -490 to -740, -740
    Kiruna    at -30 C   doc -800 to -1200, warning -1200   code -1000 to -1400, -1400
    Paris     at  +5 C   doc -200 to -350, warning -350     code -100 to -250, -250

The adjustment formula was stated with the subtraction backwards -
(zone_avg - outdoor) where the code computes (outdoor - zone_avg) - which yields
the opposite sign. The worked example underneath then wrote the correct number
anyway, so the document contradicted itself and showed its working in the wrong
direction. Anyone deriving a threshold by hand from that formula gets a sign
error on the thermal-debt safety net.

get_expected_dm_range's own docstring was the source: it cited the base ranges as
though they were the adjusted ones, and the document repeated it faithfully.

Two things a reader now cannot miss: the WARNING band has zero width in every
zone (normal_max == warning, DM_CRITICAL_T1_MARGIN is 0), so a DM past "normal"
is already past "warning" and the intermediate states are unreachable; and on an
F750 the pump's own start addition fires at -700, before Stockholm's -740 warning
is ever reached, so -1500 is not the number that governs what actually happens.

Every table is regenerated from ClimateZoneDetector, and a test now parses them
back out of the markdown and checks each row against it. The reason the docs in
this repository drifted so far is that no test had ever read one.
docs/architecture/08 reproduced a version of _aggregate_layers that no longer
exists, and whose behaviour was the defect fixed in Tranche A. It showed a single
"critical layer override" tie-breaking on

    if abs(max_offset) > abs(min_offset):

With the emergency layer asking for +10.0 at DM -1520 and a cost layer at
critical weight asking for -10.0, abs(+10) > abs(-10) is False - so it returned
-10.0. Maximum cooling, in a thermal-debt emergency. The doc's stated philosophy,
"take the stronger absolute vote... when in doubt, protect the heat pump", WAS
the bug, written down as a principle. A maintainer restoring the documented
algorithm re-introduces it.

The real _aggregate_layers is an ordered cascade, not a vote: safety, then the
EMERGENCY tier, then the recovery tiers (where a critical cost layer may moderate
the response but never reverse it), then any other critical layer, then the
weighted average. First match returns. The invariant is that a cost layer must
never reduce heating while thermal-debt recovery is in progress, and the cascade
is how that is enforced.

The document also asserted that only Safety and Effect ever reach weight 1.0.
Both cost layers promote themselves to critical weight - the price layer in PEAK
quarters, the effect layer at the monthly peak - which is precisely why the
emergency steps have to come first. Reasoning from the doc's claim produced real
defects (F-044).

The old algorithm is kept in the page, quoted inside a warning that says what it
did and why it is not to be restored. The behaviour it would break is covered by
tests/unit/optimization/test_safety_priority_inversion.py.
04_weather_preheating documented a thermal-decay algorithm - a heat_loss_rate, an
expected_temp_end, a deficit, a 1.0/thermal_mass safety margin, a -2.0 ×
thermal_mass dynamic threshold and a +3.0 C cap. Not one of those variables exists
in weather_layer.py. Its worked example concluded "+3.0 C", overstating the layer's
authority by 3.6x against the +0.83 the code actually emitted. The real layer
decides WHETHER to pre-heat, not how much: it scans the forecast as far ahead as the
building's thermal mass justifies, fires on a >=4 C drop or on confirmed indoor
cooling, and applies a constant. The page now says so, and records why the horizon
has to follow thermal mass - a two-day slide shows only -3.8 C in any twelve-hour
window, so a fixed 12 h horizon never fired on the case that most needed it.

11_airflow_optimization added a "+20% COP improvement" term worth +1.32 kW and
concluded a net gain of +1.03 kW. That term double-counts: extracting more heat from
more air and improving the COP are the same joules described twice
(Q_cond = P_el + Q_evap, so d(Q_cond) = d(Q_evap) = P_el · d(COP), an identity).
With it removed, calculate_net_thermal_gain returns -0.31 kW at 0 C and -0.65 kW at
-10 C. Every row at or below +5 C is negative; the only positive gain is +0.03 kW at
+10 C, and enhanced airflow is a cold-weather measure, so the one temperature where
it helps is the one where nobody needs it. The compressor threshold was printed as
50.0 where the constant is 61.0, so those columns were wrong too. Both tables are
regenerated from the code.

06_learning_integration documented the learning subsystem as live and driving a
0.65-weight prediction layer. It does not drive anything: confidence cannot reach
the 70% gate at the coordinator's real 5-minute observation cadence, and the
"Day 1-3 / Day 4-7 / Day 8-14" timeline cannot happen because the observation window
is a rolling 56 hours. Before d111114 the gate WAS passed - by a flatlined sensor,
which scored perfect consistency. The page now leads with that.

Also removed a stale comment in weather_layer.py annotating WEATHER_PREHEAT_OFFSET
as "+0.5C" when the constant is 2.0.
.github/copilot-instructions.md carries a binding rule: never guess NIBE
behaviour, verify it against research. For most of this project's life that rule
could not be obeyed.

The code and docs cite fifteen research documents as the authority for
safety-critical thresholds - Forum_Summary.md, Swedish_NIBE_Forum_Findings.md,
DHW_RESEARCH_FINDINGS.md and a dozen more. Every one of them is gitignored.
Anyone cloning this repository inherited a set of limits governing real heating
equipment whose justification they could not read, check, or challenge. A number
with a confident citation to a document nobody has is worse than a number with no
citation: it looks settled.

docs/research/ replaces the dangling citations with sources you can obtain -
published European standards, NIBE's own manuals, and calculations written out in
full so they can be redone:

  01_degree_minutes        What DM is; NIBE menu 4.9.3 (F750 IHB GB 1301-1);
                           why "start addition" at -700 - not -1500 - is the
                           number that governs a real F750; and why DM is
                           structurally blind to under-heating EffektGuard itself
                           causes, since lowering the offset lowers S1 and DM
                           IMPROVES while the house cools.
  02_emitter_law           EN 442-1 §3.23/§3.31, EN 12831, EN 1264. The derivation
                           behind utils/emitter.py, validated against NIBE's own
                           published curve 9: the emitter law lands 0.20 C from it
                           where a straight line is out by 2.37 C.
  03_concrete_slab         The two-node transient for the owner's floor. Six hours
                           is the LAG; the slab is only 63% charged at fourteen.
                           Why the horizon is 24 h and the pre-heat is +2.0 C - the
                           old +0.83 needed 28-35 h to fill a band it had 12-24 h
                           to fill, so it could never charge the battery before the
                           cold arrived.
  04_exhaust_air_recovery  Why "extra heat extracted" and "improved COP" are the
                           same joules (Q_cond = P_el + Q_evap), proven from NIBE's
                           own S735 tables - four points at identical conditions
                           with airflow as the only variable, where the two terms
                           come out at +0.404 and +0.387 kW. The same number.

Each note states plainly what is NOT sourced, so that nothing gets laundered into
fact by being filed next to a standard: the -1500 figure itself, the "~20% COP
improvement", the stevedvo and glyn.hudson case studies.

A test checks the numbers these notes quote against the code they justify, runs
the worked example in 02 and asserts its printed result, and fails if anyone
restores the double-counted COP term. Research that has drifted from the code is
exactly what this directory was created to replace.
Home Assistant permits exactly one state class with device_class=MONETARY:

    DEVICE_CLASS_STATE_CLASSES[SensorDeviceClass.MONETARY] == {TOTAL}

and TOTAL tells the recorder to keep a running SUM. Two sensors were MONETARY.

savings_estimate was MONETARY + TOTAL, and its value is savings.monthly_estimate
- a forward-looking projection that rises and falls with the forecast. So the
long-term statistics accumulated a projection as though it were a meter, and the
number that landed in the Energy dashboard meant nothing. The only state class
MONETARY allows is the one that is wrong for this quantity, so it is not MONETARY.

current_price was MONETARY with no state class at all, and a unit read off the
spot-price entity - typically "öre/kWh", which is not a currency. A price per
kilowatt-hour is a rate, not an amount of money. The comment beside it read "monetary
device_class doesn't support state_class", which is untrue, and the cost of believing
it was that the sensor a user most wants to plot produced no statistics whatsoever.
It is now a MEASUREMENT, which is what a price is: min, max, mean.

The unit on savings_estimate stays hardcoded "SEK", and that is not an oversight -
it is the trap. Deriving the currency from the user's spot-price entity looks like
internationalisation and is a 100x error: that entity reports öre/kWh, while
monthly_estimate is kronor (its tariff component is
SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH, and SavingsCalculator DROPS the spot
component outright when the price unit is not SEK-compatible, rather than guessing
a rate). I made exactly that change, and a live Home Assistant recorded unit='öre'
against a value in kronor before I caught it. Showing a Norwegian a SEK figure
computed from a Swedish grid tariff IS a real problem - with the tariff model, not
the label. That is F-107, and it is the owner's call.

Also: coordinator.py called hass.components.persistent_notification.async_create,
and hass.components was removed from Home Assistant - HomeAssistant.components
raises AttributeError and homeassistant.loader.Components is gone (checked against
2026.2.3; hacs.json floors at 2025.10.0, so it was broken across the whole supported
range). The "# type: ignore[attr-defined]" on that line carried the comment "not in
type stubs", which was false: it was silencing an error that was correct. It now
imports the real API at module top. No test could have caught it - hass is a
MagicMock in every coordinator test, and a MagicMock answers hass.components.anything
cheerfully - so the test asks Home Assistant directly instead.

Verified on a live Home Assistant, read back from the recorder database:
  current_electricity_price   device_class None, state_class measurement, öre/kWh
  estimated_monthly_savings   device_class None, state_class None,        SEK
Two APIs were being handed something that looks right to a reader and is not what
the framework compares against.

supports_response=True. hass.services.async_register expects a SupportsResponse
enum, and Home Assistant compares it by IDENTITY:

    response is not SupportsResponse.NONE    -> True  for a bare True
    response is SupportsResponse.OPTIONAL    -> False for a bare True

So calculate_optimal_schedule was advertised as response-REQUIRED, not optional.
The first check passing is the only reason it works at all; the second is already
wrong, and an isinstance check on HA's side would end it. The handler returns a
dict when it has data and nothing when it does not, which is precisely OPTIONAL.

The existing test asserted `supports_response is True` - it was holding the defect
in place. It now asserts the enum.

config_entry on the coordinator. DataUpdateCoordinator.__init__ takes a config_entry
keyword; omitting it makes HA fall back to a deprecated ContextVar whose deprecation
reads breaks_in_ha_version="2026.8". That is next month. It works today only because
this coordinator happens to be constructed inside async_setup_entry, where the
ContextVar is set - so coordinator.config_entry is None for a coordinator built
anywhere else, and several call sites read it without checking.

Verified on a live Home Assistant: clean boot, no errors, and no config_entry
deprecation warning in the log.
async_reload_entry's docstring asserted: "Entity selections are in entry.data and
don't trigger this listener." They do. Home Assistant's async_update_entry fires
update_listeners whenever the entry changed at all - its own docstring says so, and
it does not discriminate between data and options - and switch.py writes feature
flags straight into entry.data.

The code is right; only its account of itself was wrong, which is the more
dangerous of the two. Handling just the runtime settings here IS correct, but for
reasons the docstring did not give: the switch flags are read from entry.data at
the point of use, so they need nothing done here, and entity selections change
through the reconfigure flow, which calls async_update_reload_and_abort and
schedules a FULL reload - so the adapters, which are built from entry.data at
setup, do get rebuilt. Anyone reasoning from the old premise would have concluded
that entity changes could be hot-reloaded, and they cannot: async_update_config
never touches the adapters.

Also removed a dead branch in _create_coordinator that read entry.options
["weather_entity"]. weather_entity is only ever written to entry.data - by the
config flow and by the reconfigure flow - so "check options first, fall back to
data" checked something that is never there.
CLAUDE.md sends every contributor to .github/copilot-instructions.md and calls it
"the single source of truth for this repository's rules, architecture, and
implementation guidelines", to be read at the start of every session. A false
claim in that file is not a documentation nit. It is an instruction.

The section that teaches you how to write a good docstring held this up as the
example to copy:

    """Calculate optimal flow temperature using André Kühne's formula.
    ...
    Formula: TFlow = 2.55 x (HC x (Tset - Tout))^0.78 + Tset
    """

Kühne appears zero times in the codebase. It was removed and replaced by the EN 442
emitter law, because it was being fed a heat-loss coefficient where the derivation
requires a dimensionless relative load - a dimensionally inconsistent input to a
structurally correct law, which produces numbers that look plausible and are not.
It drove the flow temperature of a real heat pump. A contributor following the
rulebook reintroduces it. The example is now the real signature from utils/emitter.py.

(The 0.78 was never the problem: it is 1/n for n = 1.3, the inverse emitter exponent.
The structure was right and the input was not. docs/research/02_emitter_law.md.)

An earlier pass at this file corrected the climate table at the top and left an
identical copy of it 700 lines further down - Stockholm -700 where the code gives
-740, Kiruna -1200 vs -1400, Paris -350 vs -250. Correcting one copy of a wrong
number and not the other is arguably worse than correcting neither: the file now
contradicted itself on the most safety-critical figure in the project.

Also: all three UFH prediction horizons were wrong (12/6/2 h against the constants'
24/12/6, so a slab would be given half the look-ahead it needs to see a two-day cold
slide); the "verify your work" snippet imported optimization.thermal_model, which
has never existed; the coordinator example showed update_interval=timedelta(minutes=5)
when this integration deliberately sets it to None and drives a clock-aligned timer;
and "Always Check Research Before Implementing" pointed at four documents that are
gitignored and absent, so rule 13 - "never guess NIBE behaviour, verify with research
docs" - could not be obeyed by anyone who cloned this repo. It now points at
docs/research/, which is in the repository.

A test reads the file and checks it: the fenced code examples must not contain the
removed formula, every climate figure printed beside a city must be one
ClimateZoneDetector actually produces, the horizons must match the constants, every
module it tells you to import must exist, and the research it cites must be research
that is here. Mutation-checked: reintroduce Kühne, drift Stockholm back to -700, drop
the horizon to 12 h, or re-cite an absent document, and it goes red.

The docs in this repository drifted to being largely wrong because no test had ever
read one.
strings.json translates the six switches. It carried no entity.sensor block at
all, and not one of the twenty-four sensor descriptions set a translation_key -
they set a hardcoded English name= instead. So Gradminuter read "Degree Minutes",
Framledningstemperatur read "Supply Temperature", and Kompressorns hälsostatus read
"Compressor Health Status", whatever language Home Assistant was running in.

The primary audience for this integration is Swedish. This is the same defect as
F-065, fixed earlier for the options flow on the same reasoning: a Swedish owner was
reading the DHW target temperature and schedule fields - the settings that directly
drive the heat pump - as raw English. The sensors are the other half of that screen.

All twenty-four now carry a translation_key, with names in strings.json and in every
locale (en, sv, no, da, fi). The hardcoded name= is gone rather than left as a
fallback: Home Assistant resolves the translation when a key is set and only falls
back to name=, so keeping both leaves an English string that does nothing until
someone edits it, and then goes on doing nothing.

Two things worth recording, because both would have passed unnoticed:

  * EntityDescription.name defaults to the UNDEFINED sentinel, not None. A
    `getattr(d, "name", None)` check is TRUTHY on a sensor that has no name, so the
    test that was meant to prove the English strings were gone would have passed
    whether they were or not.

  * An existing test asserted `sensor.name == "Optional Features Status"` - it was
    holding the English name in place. It now asserts the translation_key.

Verified on a live Home Assistant: 22 sensors registered, every friendly_name
resolved. Since name= no longer exists in the code, those names can only be coming
from en.json's new entity.sensor block - had the lookup been wrong, they would have
been blank or raw keys.

Nothing here touches the heat pump. It is the label on the dial, not the dial.
…rred"

_validate_and_convert_dhw_config raises vol.Invalid with a message that says
exactly what is wrong and what the permitted range is:

    DHW target temperature must be between 45.0-60.0°C

async_step_init called it and caught nothing. An exception that escapes a
config-flow step is not shown to the user: Home Assistant logs a traceback and
renders the generic "Unknown error occurred". So that sentence was written on every
rejected save and never read once. The user is told that something failed, not what,
and the input they typed is discarded.

It now lands where they are looking - collected into `errors`, with the real message
passed through as a description placeholder, and the form re-shown. The string is
translated into all five locales, so the Swedish owner reads
"Ogiltig varmvatteninställning: ..." rather than an English stack of nothing.

Verified on a live Home Assistant: clean boot, no errors.

Nothing here reaches the heat pump. It reaches the person trying to configure one.
The audit filed _service_last_called as a defect: "a module-level global, so
cooldowns leak across reloads and config entries". Both halves are wrong, and
acting on it would have removed a guard rather than a leak.

Across config entries: manifest.json sets "single_config_entry": true, so there is
never a second entry for it to leak into.

Across reloads: that is the point. These cooldowns rate-limit the only two services
that can hurt the machine - boost_heating commands MAX_OFFSET, +10 °C, and boost_dhw
fires the immersion heater through NIBE's temporary lux. State held on the
coordinator dies with the coordinator, and Home Assistant's reload button unloads
and re-creates it. So a cooldown living there would be cleared by a reload: drive the
pump to +10 °C, reload the integration, do it again. A cooldown a reload clears is
not a cooldown.

The reasoning now sits beside the declaration, where someone would go to "fix" it,
and a test holds it: the cooldown state must not appear on the coordinator, and no
unload or setup path may clear it. Mutation-checked - clear it in
_async_unregister_services and the test goes red.

One thing worth recording, because I wrote it wrong first. The obvious behavioural
test is importlib.reload() - and it FAILS, because reload() re-executes the module
body and resets the dict. That is the opposite of what a config-entry reload does:
Home Assistant does not re-import the module, it calls async_unload_entry and
async_setup_entry on the one already in sys.modules, and module state simply
survives. The test would have failed while the production behaviour was correct, and
"fixing" the code to satisfy it would have introduced the very bug it was meant to
prevent.

The icons.json half of F-075 was already closed; all four options-flow sections are
present.
async_setup_entry stores the coordinator, awaits the first refresh, and then forwards
setup to the platforms. The first refresh runs _read_and_decide to completion, and
that ends by calling _schedule_aligned_refresh() - so by the time the platforms are
set up, the clock-aligned control loop is already armed and ticking.

That last step was not guarded. If a platform's setup raises, the exception
propagates out of async_setup_entry and the coordinator is simply abandoned - with
its five-minute timer live. It is no longer reachable through the config entry, but
the timer holds a reference to it, so every five minutes it reads the world, decides,
and writes a curve offset to the heat pump. Home Assistant then retries the setup,
_create_coordinator builds a second one, and that arms its own timer. The retry after
that builds a third.

Two coordinators, one heat pump, conflicting curve offsets, forever. That sentence is
already in this codebase - _schedule_aligned_refresh carries it, from the F-061 fix -
and the setup path walked into it through another door.

Home Assistant does not save us here, and the reason is worth writing down. It runs
the entry's async_on_unload callbacks - which is what calls async_shutdown() and
cancels the timer - in exactly ONE of its failure branches, the generic
`except (SystemExit, Exception)`. ConfigEntryNotReady, ConfigEntryError and
ConfigEntryAuthFailed do not. A platform reporting "not ready" during startup is the
ordinary case, and it is precisely the one that leaks.

Anything that fails after the coordinator is live now shuts it down and takes it out
of hass.data, whatever the exception was. async_shutdown() is the right tool: it sets
_shutdown_requested (so an in-flight refresh cannot re-arm the timer on a dead
object), cancels the aligned timer, and unsubscribes the power-sensor listener that
had just been registered.

ConfigEntryNotReady is logged without a traceback - it is expected, HA will retry, and
a stack trace there reads like a crash.

Mutation-checked: leave the guard in place but drop the async_shutdown() call, and the
behavioural test goes red. Verified on a live Home Assistant: clean boot, setup
complete, no errors.
Found on the owner's live Home Assistant: the config entry had gespot_entity = None,
because it was created before GE-Spot was installed. The adapter is honest about
that - it raises ValueError("No GE-Spot entity configured"). The coordinator caught
it and fabricated:

    except (AttributeError, KeyError, ValueError, TypeError) as err:
        _LOGGER.warning("Price data unavailable, using fallback: %s", err)
        price_data = get_fallback_prices()        # 96 quarters, all priced 1.0

That is the F-013/F-014 pattern exactly. The NIBE adapter used to invent degree
minutes; it was made to raise instead; and here the same fabrication had simply
moved one layer up.

The audit called this "price optimisation is silently inert". It is worse than
inert. The invented prices are a weighted vote in the control decision:

    price_data = None         ->  offset +1.00 °C   (price layer abstains)
    price_data = fabricated   ->  offset +0.27 °C   ("[Spot Price] Q89: NORMAL")

A 73% cut in the heat commanded, on the strength of a number nobody measured - and
the reasoning string told the user "[Spot Price] ... NORMAL" as though a real price
had been analysed, while the price sensor published the invented 1.0 as the going
rate, with enable_price_optimization switched on.

There is no fallback now. When there is no price, price_data is None, the price
layer abstains, and the thermal, comfort and safety layers decide alone - which is
what the engine already did correctly. get_fallback_prices() is deleted, not left
lying about: if it is there, someone will call it.

And the user is told. A _LOGGER.warning is not telling anyone; a Home Assistant
repair issue is, translated into all five locales.

One bug of my own, and it took the live system to find it. _clear_price_source_issue
was guarded on an instance flag - and that flag is reset by every restart while the
repair issue, which HA persists, is not. So an issue raised before a restart could
never be cleared after one: the user would be nagged forever about a price source
they had already configured, with nothing they could do about it. The unit test of
the raise path passed perfectly well throughout.

Verified end to end on a live Home Assistant, by reproducing the owner's actual
condition: cleared gespot_entity -> the adapter raised, nothing was fabricated, the
repair issue appeared in HA's registry, and the decision reasoning contained no spot
price at all. Restored the entity -> the issue cleared.
A re-audit of d5856fe, which was incomplete - and incomplete in the way that
matters, because the test I wrote to prevent exactly this is what let it through.

Faced with a warning paragraph that necessarily names the thing it forbids ("this
example used to show André Kühne's formula... do not reintroduce it"), the line-based
filter kept the rest of the paragraph and the test tripped on its own correction. I
narrowed the assertion to fenced code blocks so it would pass. It passed. And the
Project Context section went on saying, for another commit:

    Mathematical formulas from OEM research (André Kühne, Timbones)

Kühne appears zero times in the codebase. It was removed and replaced by the EN 442
emitter law, and the rulebook was still crediting it as the source of this project's
mathematics. The filter was what was wrong, not the assertion. It is paragraph-aware
now, and the check covers prose as well as code.

Two more claims, found by reading the file end to end:

A second flow-temperature model, offered as "OEM Research": "SPF 4.0+ systems: Flow =
Outdoor + 27 °C". That is a LINEAR rule - the very thing the emitter law was chosen
over. Against NIBE's own published curve 9 (41.0 °C at 0 °C outdoor), EN 442 lands
0.20 °C away and a straight line is out by 2.37 °C, more than ten times worse. Its
OPTIMAL_FLOW_DELTA_SPF_* constants do not exist. Sitting in the document that tells
contributors how to implement, it invited someone to build the model this project
deliberately replaced.

And "DM -1500 auxiliary limit, validated in Swedish forums", which the same file
contradicts 190 lines earlier: the -1500 figure is attributed to forum anecdote and is
NOT sourced in this repository, and it is not the number that governs a real F750
anyway - the pump's own start addition fires at -700.

Generalising the filter then blinded a different test, which is the part worth
recording. Excluding whole paragraphs that carry a denial marker swallowed the
paragraph holding the SECOND copy of the degree-minute table - because I had written
"not sourced" in it, about DM -1500 - so that table silently dropped out of the climate
check. A drifting second copy of that table is precisely what d5856fe was about. A
warning may name a formula it forbids; a number is never legitimately wrong. Numbers
are checked against every word of the file now, corrections included.

Mutation-checked, 4/4 red: re-credit Kühne in prose; re-add the linear flow rule; drift
Stockholm back to -700 in either copy of the table.
There was no diagnostics hook. This integration commands a curve offset from nine
weighted layers, a climate-zone degree-minute band recomputed per house, a
compressor-wear risk and a 96-quarter price curve - and when it got that wrong, the
owner's only recourse was to copy a log line.

The dump carries the DECISION, not just the entity states: the offset commanded,
every layer's vote and weight behind it, the NIBE state it was read from, the
degree-minute band actually in force (computed, so quoting the constants would prove
nothing), and - the one people forget - whether the price and weather sources were
even live. A missing price source silently withdraws the entire price layer (F-123),
and without that fact the offset is inexplicable.

It does not carry the home's coordinates. The decision engine holds the latitude,
because that is how the climate zone is detected, and a diagnostics file is something
the owner pastes into a public issue tracker. The climate ZONE goes in instead: it is
what the thresholds derive from, and it identifies nobody.

PARALLEL_UPDATES was declared on no platform. Home Assistant defaults a
coordinator-based integration to 0 - unlimited concurrent entity service calls - and
climate.set_hvac_mode reaches set_optimization_enabled(), which calls
async_refresh_and_apply() and drives the pump. The control lock in _drive_the_pump
already serialises the write, so nothing was broken; declaring 1 on climate and 0 on
the read-only platforms says out loud which entity touches hardware.

One note on the tests. The first version of the serialisability check was built from
MagicMocks and failed with "MagicMock is not JSON serializable" - which proved
nothing, because a mock can never be serialised. It is built from a real
OptimizationDecision and a real ClimateZoneDetector now, so "Home Assistant can serve
this" and "the latitude is genuinely absent" both mean something. A dump that cannot
be serialised is a download button that 500s, and that is exactly the failure that
passes unit tests.

Verified end to end through Home Assistant's own endpoint, not a stub:
GET /api/diagnostics/config_entry/{id} -> HTTP 200, with all nine layer votes, the
live degree-minute band, and no coordinates anywhere in the file.
CLIMATE_ZONES.md was corrected and given a test. The test parses its TABLE rows. So
the prose in the other documents went on being wrong, and the same figures kept
turning up in architecture/00, architecture/02, architecture/10 and the README.

The trap is that -450 to -700 is a REAL Stockholm range. It is what the code produces
at -8 C, the Cold zone's actual winter average. Every one of those documents asserted
it at -10 C, where the code gives -490 to -740. You cannot catch that by looking for a
bad number, because it is not a bad number: it is a good number attached to the wrong
temperature. The root of it is one constant - the Cold zone's winter_avg_low is -8.0,
and four documents still said -10.0, so every threshold they derived from it was
40 degree-minutes shallow.

The same shape of hole hid the removed flow-temperature model. The rulebook was cleaned
of Kühne and given a test; the test read the rulebook. Meanwhile the README went on
advertising "André Kühne + Timbones formulas" to users, architecture/10 derived four
worked examples from it, and CLIMATE_ZONES named it as the weather-compensation model.
Kühne appears zero times in the codebase. A guard scoped to one file is a guard with a
hole the shape of every other file.

So there is now one guard over every markdown file in the repository. Wherever a
document names a climate zone, gives an outdoor temperature and prints a degree-minute
range, that range must be the one ClimateZoneDetector computes at that temperature; no
document may misstate a zone's winter average; and no document may teach the model that
was removed. The four worked examples in architecture/10 are recomputed from
en442_flow_temp rather than retyped.

This is the P3 recommendation the audit made and nobody implemented: generate the
numbers from const.py, or at least refuse to let them drift.

Two holes in the guard itself, both caught by mutation-testing it rather than by
reading it:

  * it read "Winter avg: -10 C" but not "winter_avg_low: -10.0 C" - the constant's own
    name, in the code blocks people actually copy. Drifting that back passed cleanly.

  * it matched denial markers against un-normalised text, so a paragraph wrapping
    "**was\nremoved**" across a line looked like a live claim - on the one document
    whose entire purpose is to explain what was removed. Four separate holes in these
    guards have now come from that, so markers are matched against whitespace-normalised
    paragraphs.

Mutation-checked: re-advertise Kühne in the README, drift a winter average in either
form, or strip the denial from the research note, and it goes red.
@enoch85

enoch85 commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

🔴 Turning the thermostat off did not turn the optimiser off

9a10f08. The climate entity offers HVACMode.OFF and documents it as "Optimization disabled (safety
monitoring only)"
.

What it did:

async def async_set_hvac_mode(self, hvac_mode):
    self._attr_hvac_mode = hvac_mode                    # a private copy
    if hvac_mode == HVACMode.OFF:
        await self.coordinator.set_optimization_enabled(False)

and set_optimization_enabled(False) resets the curve offset to 0.0 once. That is the whole of
it. It writes no flag anywhere.

The coordinator's master gate reads something else entirely — the config entry:

if not self.entry.data.get("enable_optimization", True):
    decision = OptimizationDecision(offset=0.0, reasoning="Optimization disabled by user")

Nothing sets that key except the enable_optimization switch entity. The thermostat never touched
it. Driving the real entity:

user sets the thermostat to:              off
entry.data['enable_optimization'] =       True    <- the only thing the gate reads
next aligned refresh, five minutes later: optimises, decides an offset, writes it to the pump

The user turned the heating optimiser off. It went quiet for one cycle, then went back to driving
their heat pump — with the thermostat still displaying OFF.
And RestoreEntity carried that display
faithfully across a restart, so the lie survived a reboot: the entity restored OFF from its own
last state while the coordinator, whose gate had never been told anything, resumed control.

The switch and the thermostat could also simply disagree — switch off, thermostat HEAT. One fact, two
answers.

This is the same disease as the billed quantity computed twice, the DST hour counted twice, and the
hot-water boost whose ownership was recorded in one path of three: a second copy of a fact that
nothing keeps in step with the first.

There is one piece of state now. hvac_mode is a view of entry.data["enable_optimization"],
async_set_hvac_mode writes that key by the same mechanism the switch uses, and RestoreEntity is
deleted — the config entry survives a restart on its own, and it is what the coordinator reads, which
the entity's own last state never was.

A view that nobody tells about the change is not a view

022b8ff. Found only because I verified the fix on a running Home Assistant.

The thermostat and every switch now read the same fact — but a view only updates when something asks
it to, and hot-reloading the entry asked nobody:

switch: on   14:14:19
switch: on   14:14:59
switch: off  14:15:19      <- the coordinator's next aligned refresh, not the change

Five minutes of a thermostat that said OFF and a master switch that said ON. The truth was never in
doubt — the pump genuinely was no longer being optimised — but the user could not have known that from
looking. async_reload_entry now calls coordinator.async_update_listeners(), which covers all six
switches, the thermostat, and every options change.

Verified live, end to end

step result
thermostat → off entry gate enable_optimization = False; optimiser stops
restart HA thermostat off, switch off — read from the entry, not a restored display
thermostat → heat gate True, thermostat heat, switch onall immediately, no 5-minute lag
"Optimization enabled", 0 EffektGuard errors

Mutation tested both: not writing the gate fails; a display that stops following the gate fails;
dropping the immediate action fails; dropping the listener notification fails.

What I checked and did not file

The engine is handed enable_optimization in engine.config and never reads it — the gate is the
coordinator's, on entry.data. That is duplication, not a second dead switch. I checked all five
switch keys before saying so:
three are read by the layers, two by the coordinator. None is dead.

Unchanged and still yours

F-124 still reproduces on --coldsnap. The effect-charge repeal, F-119, F-112, F-107, F-132b remain
owner decisions, plus whether boost_dhw keeps two arguments that do nothing.

Gate green: black, check_hardcoded_values, 2650 passed, simulator (nominal, --dst,
--selftest) exit 0.

enoch85 added 17 commits July 14, 2026 14:51
The same story was being told four times: in the commit message, in a code comment,
in a test docstring, and in the pull-request comment. Three of those are archaeology
and git already has them.

The comments in the code are now what a future reader needs in order not to break the
thing - the invariant, the failure in a sentence, a pointer to the test - and no longer
a narrative of how I found it. The pump-model and tariff docstrings keep every source
and every measured number, because those ARE the deliverable: Ellevio's hourly-average
rule, Ei's regulation, the EN 14511 rating points, and the F-124 evidence tables the
owner needs in order to decide.

No test deleted, no behaviour changed. 2650 pass.

What this does NOT do is make the branch small, and it is worth being straight about
why. The diff is +21,716 lines and it does not compress much further:

    net EXECUTABLE production code      +749     what actually runs on the heat pump
    test code                         +5,745     ~40 defects, each with a red-first test
    prose (why each fix exists)       ~10,000
    simulator                         +2,586
    docs                                 +825

Capping every test docstring at fifteen lines would save 1,410 lines - 6.5% - and would
delete the citations that are the point of them. The size is the tests, and the tests
are the only reason any of these defects are known.
Rule 0 (share what can be shared) and rule 3 (numbers live in const.py), applied to
what this audit itself added.

FOUR PUMP PROFILES CARRIED THE SAME ARITHMETIC. An AST sweep for identical function
bodies across the package found exactly two duplicate pairs, and both were mine:
F750/F730 and F1155/S1155 each built the display COP curve with a copy of the same
interpolation. It is now `seasonal_cop_proxy()` in models/base.py, and the only thing
that differs - the source filter, because a brine machine is anchored on its two
published COPs at 0 C brine - is an argument. The F2040 keeps its own: its source IS
the outdoor air, so its curve is keyed directly on the rating points, and that is a
different thing rather than the same thing written twice.

The curve's magic numbers went with it: the tabulated temperatures, the -20 C anchor
and the 27 K span were hardcoded in four files and are now three constants.

Verified by execution, not by reading: every profile's cop_curve is byte-identical
before and after.

    NibeF750  {7: 4.72, 5: 4.55, 0: 4.13, -5: 3.7, -10: 3.28, -15: 2.85, -20: 2.43}
    NibeF730  {7: 5.32, 5: 5.11, 0: 4.57, -5: 4.04, -10: 3.5,  -15: 2.97, -20: 2.43}
    NibeF1155 {7: 4.87, 5: 4.79, 0: 4.58, -5: 4.37, -10: 4.16, -15: 3.96, -20: 3.75}
    NibeS1155 {7: 4.87, 5: 4.79, 0: 4.58, -5: 4.37, -10: 4.16, -15: 3.96, -20: 3.75}
    NibeF2040 {7: 4.65, 2: 3.76, -7: 2.68}

SEVENTEEN IMPORTS WERE NOT AT THE TOP OF THEIR FILE (rule 16, whose stated purpose is
avoiding circular imports). `__init__.py` imported `.const` at the top and then AGAIN,
with more names, inside two functions; `_create_coordinator` imported all seven of its
collaborators locally; `dhw_optimizer` already imported thermal_layer at the top and
imported from it again on line 2611. None of them was circular - thermal_layer does not
import dhw_optimizer, and the adapters do not import the package root - so all seventeen
are hoisted.

THREE ARE LEFT AND THEY STAY. The `recorder` and `history` imports sit inside `try:`
blocks because recorder is an OPTIONAL integration (`after_dependencies` in the
manifest) and can be disabled. That is Home Assistant's own pattern for an optional
dependency, not a lint violation, and hoisting them would fail setup on an install
without it.

WHAT I DID NOT CHURN. `Any` survives in four files, and it is Home Assistant's API:
`async_set_temperature(**kwargs: Any)`, `extra_state_attributes -> dict[str, Any]`,
`async_step_*(user_input: dict[str, Any] | None)`. Narrowing a framework signature is
not a tightening, it is a Liskov violation. Rule 15 is about our data structures, and
ours use dataclasses and TypedDicts.

Tested on the real stack, not by inspection: HA restarts clean, loads the refactored
S1155 profile, 28 entities, 5 services, zero EffektGuard errors. Through the frontend's
own websocket (Playwright): thermostat off -> master switch off, thermostat heat ->
master switch on, both immediately. The one `unknown` entity is nibe_power, which has no
meter to read and now refuses to invent one.
Home Assistant stays up, driving the modbus NIBE against LIVE SE4 spot prices through
GE-Spot. This records what the integration actually does with them.

Detached from any agent session on purpose: it restarts Home Assistant if it dies and
appends a twelve-column snapshot every fifteen minutes - offset, degree minutes, indoor,
supply, outdoor, price, today's peak, the month's peak, the thermostat's mode, the
EffektGuard error count, and how many times HA had to be restarted.

Everything here is already in ha.log, but that is half a gigabyte of DEBUG and it
rotates. This is the few columns that answer "how did the week go", in a file that will
still be there.

The first version of it wrote a broken CSV. `grep -c` prints 0 AND exits 1 when there
are no matches, so `|| echo 0` appended a SECOND zero and split every row in half. A
corrupt record over seven days is worse than no record, and it would have looked fine
until the day someone tried to read it.

Output: .ha-config/week_watch.csv (git-excluded with the rest of .ha-config).
The server rebooted and the week-long observation did not come back. Three separate
problems, and all three were mine.

TWO WATCHERS WERE RUNNING. `pkill -f week_watch.sh` does not reliably reach a process
in its own `setsid` session, so when I "restarted" the watcher after fixing the CSV
bug, the OLD copy was still alive. Both appended to the same file on the same
fifteen-minute cadence. The result is worse than either alone: half the rows written
by the fixed version, half by the broken one.

    2026-07-14T15:09:57Z,...,heat,0,0      <- the fixed copy
    2026-07-14T15:24:03Z,...,heat,0        <- the old copy, still splitting rows
    0,0

`flock` on a lockfile now makes a second copy exit immediately and say so. Verified by
starting one: "another week_watch is already running - exiting", and exactly one
process left holding the lock.

THE MALFORMED ROWS ARE DROPPED, not patched. Two of the three samples were unusable.
A record that is half-good looks fine until the day somebody tries to read it, so the
writer now COUNTS THE FIELDS before it appends and refuses anything that is not
twelve, logging the skip instead.

AND THE BOX STARTS NOTHING ON BOOT. pid 1 is `docker-init -- sleep infinity`. There is
no systemd, no cron, and `/usr/local/bin/entrypoint.sh` is read-only and does nothing
but `exec "$@"`. A reboot kills Home Assistant and the watcher and NOTHING brings them
back. I described this as a week-long run without ever checking that it could survive
a reboot - which is the same mistake as asserting a mechanism I had not executed, and
it is the third time.

That is a property of the box and not something a script can fix. What a script CAN do
is make the recovery one idempotent command, so:

    bash scripts/start_week.sh      # after every reboot

It starts Home Assistant if it is not answering on :8125, starts the watcher if the
lock is free, is safe to run any number of times, and prints what it found.
The reboot killed the NIBE modbus simulator, and start_week.sh did not bring it back.
It started Home Assistant and it started the watcher, and it forgot the pump they were
both there to watch.

So EffektGuard came up with no BT1, no BT25 and no degree minutes - and did exactly the
right thing. It refused to control the heat pump on incomplete data and said so, every
cycle. That refusal is what the week recorded:

    16:25   indoor 21.3  supply 35.8  price 145.4  errors 0
    16:40   indoor 21.3  supply 35.8  price 145.4  errors 6
    16:55   indoor 21.3  supply 35.8  price 145.4  errors 12
    ...
    18:55   indoor 21.3  supply 35.8  price 145.4  errors 60

A frozen house, a frozen price, and the error count climbing by six every fifteen
minutes. Both processes healthy, both watching nothing. There is no defect in the
integration here - the guard that refuses to drive a pump on stale sensors is an audit
fix, and it worked.

The simulator is part of the stack now: start_week.sh starts it, and the watcher
restarts it if it dies, the same way it does for Home Assistant. Verified by killing
it - "NIBE simulator : down - starting / up", zero new EffektGuard errors afterwards,
34 sensor reads and decisions flowing again.

AND THE LOCK I ADDED LAST TIME WAS WORSE THAN NO LOCK.

`exec 9>"$LOCK"` and `flock -n 9` - and an fd opened that way is INHERITED BY CHILDREN.
Killing the watcher left its `sleep 900` child holding the lock, so the lock outlived
the process that took it: start_week.sh reported "watcher already running" when nothing
was running, and a new watcher could never acquire it. The observation would have
stayed dead for a week and the bootstrap would have said it was fine.

That is now a pid file, which cannot be inherited, checked against /proc and against
the command line so a recycled pid cannot impersonate it. Verified: a second copy exits
with "another week_watch is already running (pid N)", and exactly one process remains.

Three attempts at single-instancing, three bugs, all mine: pkill that missed a setsid
session, a flock a corpse could hold, and a bootstrap that forgot the pump.
- Return applied integer offset from adapter writes; add forced write mode for safety transitions

- Make OFF transition atomic: neutralize pump and owned actuators before persisting disabled state

- Route explicit services through shared command path with startup-observation bypass but safety floor preserved

- Report applied offset (not requested decision) in climate/sensor surfaces

- Add and update regression tests for OFF-through-cooldown, one-shot overrides, writer serialization, and service contracts
…ets one peak

Two tariff-layer defects, both verified before fixing:

- Main wrote version-1 peak records with quarter_of_day. This branch reads
  period_of_day but still declared version 1, so HA handed the old payload
  straight to PeakEvent.from_dict: KeyError inside async_setup_entry, setup
  failed for every upgrading install. The store is now EffectStore at version 2;
  migration discards quarter-era records - a 15-minute mean is not an hourly
  mean and cannot be converted into one. The learning store keeps its own
  version constant so the bump cannot touch it.

- The top-3 selection was date-blind. Ellevio: the three billed peaks come from
  three different days - only a day's highest hour counts. One cold Saturday
  could fill all three slots, overstating the bill and understating the margin
  the pump was then throttled against. record_period_measurement now keeps at
  most one peak per calendar day; a higher hour replaces its own day's entry
  and a lower one cannot evict another day.

Red-first: 3 migration tests and 5 one-per-day tests failed before the fix.
Existing fixtures that recorded three same-day peaks were re-dated - physically
one hour is one record, so the old fixtures asserted an impossible history.
f1d2e0f was pushed with 6 failing tests, two of them hanging: the stand-in
read/decide doubles were never given the new explicit_command parameter, the
startup doubles lacked current_offset, and the new OFF test's fixture did not
async-mock is_enhanced_ventilation_active - so the commit's own regression test
died on TypeError before its first assertion.

Production repairs that fell out of re-reviewing it:

- One door per command again: _write_curve_offset and _write_enhanced_ventilation
  passed force_write via two literal call sites, which the structural one-door
  test rightly counts as two doors. Collapsed to one call each.

- The OFF transition trusted a fan it could not see: is_enhanced_ventilation_active
  returning None (switch unavailable) skipped neutralization while the gate still
  flipped OFF, leaving an enhanced fan running with nothing to stop it. OFF now
  refuses when the pump HAS a ventilation switch that cannot confirm normal;
  pumps without one are unaffected (new NibeAdapter.has_ventilation_control).

Test doubles were completed rather than production loosened - the fake has to
be the object, which is the same lesson the flow_temp @Property taught.
get_enhancement_stats() and its decision-history bookkeeping were removed from
AirflowOptimizer, but the airflow_thermal_gain sensor's attribute block still
called it - AttributeError on every state render, on exactly the pumps the
sensor exists for (F750/F730). No test caught it because the fixtures'
coordinator is a MagicMock, and a MagicMock answers any method cheerfully - the
same trap that hid the removed hass.components API. The new test renders the
attributes against a REAL AirflowOptimizer.

Also stops test_manual_override_bypass.py littering the repo root: its
hand-rolled hass never stubbed config.path(), so HA's Store created a literal
./MagicMock/ directory on every run.
Live repro: boost_dhw switched temporary lux ON, and the next applied refresh
switched it OFF because prices were high. The service did nothing but flash a
switch.

boost_dhw now opens a window the ordinary stop path defers to. Only safety
outranks the user: the thermal-debt abort still ends the boost, and unload
still cleans it up. When the window expires EffektGuard turns the switch off
itself, through the same owned door - so the duration argument finally does
something real.

target_temp is REMOVED, not documented around: temporary lux is a switch, the
pump heats to its own lux temperature, and a parameter that is validated and
then reaches nothing is a promise the service cannot keep. The repo rule is no
backward-compatibility shims, so it goes. A boost is also refused while
optimization is OFF, matching force_offset and boost_heating.

Red-first: 6 tests failed before the fix.
The accumulator stored (timestamp, power); the coordinator stamped the finished
hour with whatever source the BOUNDARY cycle happened to have. A meter that
died mid-hour handed the hour to the pump's phase currents - and when the
meter answered again at the top of the next hour, fifty-five minutes of
pump-only samples were recorded as a billable whole-house-meter measurement.

Samples now carry their source into the accumulator, and the completed hour
derives what it may be recorded AS: all meter -> billable; meter and pump
currents mixed, or pump-only -> control-grade, never shown as a bill; anything
weaker in the mix -> not a measurement. The baseline-savings gate reads the
recorded event's own is_billable instead of the cycle's source.

And the harness now bills what Ellevio bills: the tariff top-3 applies the
22:00-06:00 half-weighting (effective_tariff_power_kw - production's own
definition), which it used to skip. Night hours are exactly where this
optimiser puts load, so every simulated tariff figure was overstated.

Red-first: 3 tests failed before the fix, including the real coordinator
driven through a meter dropout with the meter returning at the boundary.
…one declaration

Three plant-model corrections, all against published sources:

- The plant's additive heat waited for EffektGuard's -1500 emergency floor. No
  factory-default NIBE does: the F750/F730 arm 'start addition' at DM -700
  (IHB GB 1301-1, menu 4.9.3), the S1155/F1155 controllers near -460, the
  VVM 320 that pairs with an F2040 near -760 - and the elpatron then works DM
  back UP. Each profile now carries its own sourced aux_start_dm and the plant
  reads it. Re-measured, the coldsnap DM settles at -771 on the F2040 - the
  hardware value, exactly where Swedish forum reports say a real pump's DM
  asymptotes - and the F-124 headline shrinks to its honest size: 1.4-1.7x the
  physically forced resistive heat on datasheet-sized machines, house held in
  band by the pump's own elpatron. The earlier '2-5x, cooked to ~30 C' figures
  were measurements of a plant no factory ships; the xfail and its docstring
  now carry the corrected numbers, and raises=AssertionError stops a crash
  from ever impersonating the finding again (the old call passed a keyword the
  layer does not have and died before its assertion).

- The F2040 capacity model spliced the COLD-climate Pdesignh (9.0 kW at -22)
  onto the AVERAGE-climate Psup (1.1 kW at -10) - a 7.9 kW compressor that
  appears in no NIBE document. One complete declaration now: Pdesignh(avg)
  8.2 - Psup 1.1 = 7.1 kW anchored at -10 C, held below.

- FLOW_EXERGY_PENALTY_PER_K claimed to be 'the mean of the two' fitted values
  (-0.00552, -0.00277) while being -0.0046, the mean of nothing. It is now
  -0.00415, which is.

All five simulator modes rerun: nominal/selftest/dst PASS; coldsnap and
undersized still FAIL on the aux-over-physics bound - the instrument can
still detect F-124, it just no longer exaggerates it.
…t declare a phantom

The concrete-slab research listed its model's time constants - 1.25 h fast,
70 h slow - one row above a claim that the slab 'reaches 63% of its response
in ~14 h'. 63% is one time constant of a first-order system; integrating the
documented matrix gives ~19% at 14 h and ~29% at 24 h. The wrong number was
repeated by the rulebook (three places), a layer docstring, a test docstring
and a const comment; all now carry the computed figures. The conclusion
SURVIVES and strengthens: 24 h is the minimum horizon, and deep cold needs
days - which is what the owner said before the model existed.

The same document declared WEATHER_PREHEAT_OFFSET = 2.0 in a fenced code
block, citing a guard test that does not exist. Neither the constant nor the
test ever landed: production ships WEATHER_GENTLE_OFFSET = 0.83, whose sizing
arithmetic (28-35 h to fill the thermal band, against 12-24 h horizons) is a
REAL open finding - but it is a control-tuning decision on live pumps and is
now recorded as OPEN, owner's call, instead of as shipped code.

The hole that let it through is closed: the doc parser only checked names it
already knew (hasattr filter), so a phantom passed silently. A new guard fails
any fenced NAME = value in docs/research that const.py does not have; prose
mentions of deleted constants stay legal.
…n hour

Five smaller defects from the external review, each verified before fixing:

- The diagnostics dump quoted the RAW climate-zone DM band while production
  enforces the thermal-mass-adjusted one - a slab-house dump said -414 where
  the code intervened at -318. The dump now reports the enforced band, the
  raw zone range under its own label, and the heating type it used.

- async_unload_entry shut the coordinator down BEFORE asking HA to unload the
  platforms. A platform that refuses leaves the entry loaded: live entities,
  dead coordinator, every sensor frozen on its last value. Platforms unload
  first now; the coordinator dies only after they actually did.

- Peak protection compared the last cycle's INSTANTANEOUS reading against a
  monthly record that is an HOURLY MEAN. A five-minute oven spike read as an
  hour of itself. The decision path now feeds the projected hour mean - the
  accumulated hour plus the current draw persisted to the boundary - which is
  the same quantity the record is made of.

- The power validator flagged every cold-weather cycle where the elpatron was
  doing its job ('exceeds max 2.06 kW' on a machine with a 3.5 kW immersion
  heater configured). The ceiling is now compressor + immersion; only a
  reading the hardware cannot produce is flagged, as a unit/scaling warning.

- week_watch.sh recorded a sensor that does not exist (monthly peak column
  permanently blank) and carried a literal dev/dev login; entity corrected,
  credentials read from env with the devbox defaults.

Plus the stray HA import inside the stdlib block in airflow_optimizer.
…nobody has

F-041: the DHW planner measured the distance to the next demand period by
subtracting aware local datetimes - wall-clock arithmetic, which loses the
repeated hour on the fall-back night. 00:30 to 06:00 across the transition is
6.5 real hours, not 5.5; the planner heated an hour short of what it believed.
The label stays local (tomorrow's 06:00 is tomorrow's 06:00); the distance is
now taken on the absolute time line, like every other duration in the branch.

F-114: adaptive_learning cited Forum_Summary.md and Enhancement_Proposals.md -
neither exists in this repository - and the F750's DM tuning block called
itself 'validated in Swedish NIBE forum'. Only -60 is sourced (menu 4.9.3);
-240/-400/-500 are forum anecdote and now say ASSUMED, so nobody mistakes them
for datasheet values. The numbers themselves are unchanged - changing them is
a control decision.
The audit's narrative lived four times: commit message, PR comment, ledger,
and code comment. The code keeps what the next reader needs - the invariant,
the failure in one sentence, the test that proves it - and git keeps the rest.
No test deleted, no behavior changed, 206 lines removed.
… fail

The full-ledger sweep (F-001..F-145 verified at HEAD by three independent
passes) closed every remaining item that needs no owner decision:

- F-077: the per-model dm_threshold_start/extended/warning/critical and
  min_runtime/min_rest fields were read by nothing - thresholds come from
  ClimateZoneDetector and wear limits from the coordinator. Deleted from the
  base profile and all five models; the required-attributes test now pins
  aux_start_dm, which IS wired.
- F-086: MINUTES_PER_QUARTER and QUARTER_INTERVAL_MINUTES were the same 15
  under two names. One name now.
- F-080: the compressor Hz validator clamped at a literal 150 while warning
  about '0-120' - it now uses COMPRESSOR_HZ_MAX so the code, the clamp and
  the message agree.
- F-091: LAYER_WEIGHT_COMFORT_MAX called itself 'legacy - unused' while
  comfort_layer reads it every cycle.
- F-099: run_all_tests.sh silently REWROTE unformatted files and printed
  'FIXED' - a formatting regression could never fail. It exits 1 now.
- F-100: the simulator's dt_util monkeypatch leaked on a crashed run,
  poisoning every test that ran after it. try/finally restores the clock.
- F-096/097/098 (test honesty): a rate-limit test asserted its own local
  literal (now asserts the production cooldown); a peak test asserted
  weight >= 0.0 which cannot fail (now pins the quiet-layer contract); the
  peak-protection fixture set config keys the engine never reads
  (target_temperature/tolerance 5.0 - the engine ran on defaults under
  every assertion in the file).

Still open, verified and deliberate: F-107/F-111 (tariff product decision),
F-112 (parked in stash), F-124, F-130b, F-132b, F-141, F-049, F-028, F-059,
F-088/F-089/F-093 (behavior-touching consolidations), F-114 values, F-137/
F-138 (leads). Everything else in the ledger is FIXED at this commit.
@enoch85

enoch85 commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

The review was checked, the review was right, and the branch is now split

An external review landed with 11 P1 + 4 P2 findings. Per the standing rule, none was taken on faith: every claim was re-verified against code, and where it made a factual claim I recomputed it independently (the Ellevio one-peak-per-day rule against ellevio.se; the slab eigenvalues from the doc's own matrix; the strict xfail run with --runxfail). Every finding survived verification. Eleven commits follow, each red-first.

The commits, one by one

commit what it fixes
f1d2e0f (landed at session start, reviewed after the fact) Actuator path: adapter returns the applied integer; OFF neutralizes before it persists; explicit services bypass startup observation but keep the safety floor. The runtime logic verified correct — but it shipped with six failing tests, two of them hanging.
83437f1 Upgrades no longer break setup. Main wrote quarter_of_day records into a version-1 store; this branch read period_of_day from the same version — KeyError inside async_setup_entry for every upgrading install. Store is now v2 with a migration that discards quarter-era records (a 15-min mean is not an hourly mean). And a day gets one peak: Ellevio bills the three highest hours from three different days; the top-3 was date-blind, so one cold Saturday could fill all three slots.
8964f86 Repair of f1d2e0f's test debt (stand-ins missing the new parameter; the commit's own OFF test died on TypeError before its first assertion) plus two production repairs found re-reviewing it: force_write collapsed to one door per command, and OFF now refuses when a configured ventilation switch cannot confirm the fan is normal — it used to skip neutralization and flip the gate anyway, leaving an enhanced fan nothing would stop.
6087b2d A bug this branch introduced, found by re-audit: the airflow sensor called get_enhancement_stats(), which the branch had deleted — AttributeError on every state render, on exactly the pumps the sensor exists for. No test caught it because MagicMock answers deleted methods cheerfully; the new test renders against the real optimizer. Also stops a test littering ./MagicMock/ into the repo root.
372b29b boost_dhw did nothing but flash a switch: the next price cycle turned it back off. A user boost now opens a window ordinary optimization may not close — only the thermal-debt safety abort and unload outrank it. duration is real (we turn it off); target_temp is removed — it was validated and then reached nothing.
150dd95 A billing hour's provenance is every sample's. The hour was stamped with the closing cycle's source, so a meter-dropout hour, half-measured at the pump's phase currents, became a billable whole-house-meter hour when the meter answered at the boundary. And the simulator's tariff now applies the 22–06 night halving it was skipping — night is exactly where this optimiser puts load, so every simulated tariff figure was overstated.
6b878e2 The simulated elpatron fires where NIBE arms it — each pump's factory start-addition (F750/F730 −700 per IHB GB 1301-1 menu 4.9.3; S/F11xx −460; F2040+VVM −760), not EffektGuard's −1500 floor. Re-measured, DM asymptotes at −771 on the F2040 — the hardware value, exactly where Swedish forum reports say a real pump settles — and F-124 shrinks to its honest size: 1.4–1.7× the physically forced aux, house held in band. The earlier "2–5×, cooked to 30 °C" was a measurement of a plant no factory ships. Also: the F2040 keeps one complete ErP declaration (the 9.0−1.1=7.9 kW splice appears in no NIBE document), and the strict xfail now actually reaches its assertion (raises=AssertionError — the old call died on a TypeError and CI counted the crash as the expected xfail).
6d7f668 The slab doc contradicted its own eigenvalues. It listed time constants of 1.25 h and 70 h one row above "reaches 63% in ~14 h" — integrating its own matrix gives 19% at 14 h, 29% at 24 h. Fixed in the doc, the rulebook (×3), two layer docstrings and a test. The conclusion strengthens: 24 h is the floor, deep cold needs days — the owner's words before the model existed. Also: the doc had declared WEATHER_PREHEAT_OFFSET = 2.0 as shipped code. It never shipped; production is 0.83, whose sizing arithmetic cannot fill the thermal band inside the horizon — now recorded as an open owner decision (F-130b), and a new guard fails any fenced constant declaration the code does not have.
48c5556 Five P2s: diagnostics report the thermal-mass-adjusted DM band production enforces (−318, not −414); platforms unload before the coordinator dies; peak protection compares the projected hour mean (not a 5-minute spike) against the hourly-mean record; the power validator's ceiling is compressor+immersion, so an elpatron doing its job no longer warns every cycle all winter; the week-watcher records a sensor that exists and reads its login from env.
5917e29 The last wall-clock subtraction (the DHW planner lost the repeated hour on fall-back night and heated an hour short of its own plan) and the last citations to documents nobody has (Forum_Summary.md; the F750 DM block now marks −240/−400/−500 ASSUMED — forum anecdote, not datasheet).
32479aa −206 lines of audit narrative cut from code comments. The invariant, one sentence of failure, and the test pointer stay; the retelling lives in git. No behavior change, no test deleted.
a329722 The full-ledger sweep. All ~145 findings verified against HEAD by three independent passes; every remaining no-owner-decision item closed: dead per-model tuning knobs deleted, duplicate quarter constant collapsed, the compressor-Hz clamp agrees with its own message, the test runner no longer silently rewrites unformatted files and calls it a pass, the simulator's clock monkeypatch can no longer leak out of a crashed run, and three dishonest tests (self-asserting literal; weight >= 0.0; a fixture whose config keys the engine never read) now assert things that can fail.

The experiment you asked for: your new tests, main's code

Running this branch's suite against main: 556 tests fail and 40 files cannot even import. That is the measured before/after of the audit — the suite is not passing for its own sake; point it at the old code and it convicts.

The split, as requested

The review proposed five topical PRs. The production change-set is interlocked through const.py and coordinator.py (the storage-version rename alone breaks any file-level topical split at intermediate stack levels), so the mechanically honest cut is a three-PR stack, each level green, with the five topics as sections in the production PR:

Gate at HEAD

black ✓ · check_hardcoded_values --check ✓ · 2698 passed, 2 xfailed · simulator: nominal/--selftest/--dst PASS ×5, --coldsnap/--undersized FAIL on the aux-over-physics bound (F-124 still detected, at its honest size) · live HA restarted on this code.

Still yours

F-107/F-111 (tariff configurability after the Ellevio repeal), F-124 (what should a pump do when it cannot meet its own curve), F-130b (pre-heat sizing 0.83 → 2.0), F-112 (ladder rescale — parked in stash@{0}), F-132b (learning confidence ceiling), F-141 (two DHW block thresholds), F-049 (dead WARNING/CAUTION band), F-028 (Legionella deadline), and the S-series DHW actuation (register 697 — needs a real pump).

enoch85 added 2 commits July 15, 2026 19:56
The line-level necessity audit the owner ordered: every line added by this
branch is either code fixing a confirmed defect, a load-bearing invariant
comment (the invariant, one sentence of failure, a test pointer), or deleted.
Net -579 lines of production prose; no behavior changed; every claim that
stayed was verified against code or a named source before it was allowed to
stay.

False claims found in production comments and fixed rather than kept:
- a comment cited record_quarter_measurement, a method that does not exist
  (the call is record_period_measurement, and the unit is the hour);
- two comments called BT1 the indoor sensor - BT1 is the OUTDOOR sensor by
  const.py's own register map (40004); the room sensor is BT50;
- the rulebook still quoted the retracted curve-9 figures (0.20/2.37 C) that
  docs/research/02 explicitly corrects to 0.19/0.64 C.
…t claim verified

The owner's order, executed: tests that existed to prove a point during the
audit are gone; tests that guard production stay. Deleted: characterization
tests that passed both before and after their fix, tautologies asserting
local literals against themselves, placeholder tests whose only assertion
was 'is not None', documentation tests counting entries in their own lists,
and duplicate coverage (one flatlined-sensor guard survives, not two). Net
-3,812 test lines; the suite drops from 2,698 to 2,648 tests and every
remaining one passes; both strict xfails (F-124, F-132b) stay.

Every factual claim in every kept docstring was verified against code at
HEAD or a named source - unverifiable sentences were deleted rather than
trusted. False claims found and fixed:
- worked examples computed with 240 V against a 230 V constant, and with the
  old 50 SEK/kW tariff against the sourced 81.25 (162/312/1219 SEK, not
  100/250/750);
- an airflow reference table claiming a 50% base compressor threshold and
  POSITIVE winter net gains - production computes 61% and a net LOSS at
  every heating-season temperature;
- a 6.5 kW immersion figure where the F750 profile ships 3.5 kW delivery
  setting; a 0.99 layer weight where the constant is 0.91; a '150 SEK'
  figure the formula computes as 244; stale multiply-era thermal-mass
  comments where production divides; a class docstring describing a smart
  reload-vs-hot-reload detection that has never existed;
- stale line-number citations and pre-correction simulator numbers,
  removed everywhere.

Red-first discipline is untouched: the deleted tests defended nothing, and
run against main the remaining suite still fails by the hundreds.
@enoch85

enoch85 commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

The scaffolding comes down: −4,391 lines, 51 debug tests deleted, and every kept claim verified

Your three orders — trim it, remove the debug tests, verify everything — executed as two commits. Nine parallel verification passes covered every file the branch touches; each factual claim in kept prose was checked against code at HEAD or a named source, and claims that could not be verified were deleted rather than trusted.

The commits

commit what
e4cebe6 Every production line justifies itself, or it is gone. Line-level necessity audit of the whole production diff: each added line is defect-fix code, a load-bearing invariant comment (invariant + one failure sentence + test pointer), or deleted. Net −579 production lines, zero behavior change.
1ee55a8 51 debug tests deleted, every kept claim verified. Characterization tests that pass before AND after their fix, tautologies asserting local literals against themselves, is not None placeholders, documentation tests counting their own lists, duplicate coverage — gone. Net −3,812 test lines. Suite: 2,698 → 2,648 tests, all passing, both strict xfails (F-124, F-132b) intact.

The false-claims ledger — what "verify everything" actually found

Every one of these was in prose the tests happily passed around. All fixed:

  1. Worked examples computed with 240 V against the 230 V constant (three sites).
  2. Savings examples computed with the old 50 SEK/kW tariff against the sourced 81.25 — "100/250/750 SEK" where the formula yields 162/312/1219.
  3. An airflow reference table claiming a 50% base compressor threshold and positive winter gains — production computes 61% and a net loss at every heating-season temperature.
  4. A 6.5 kW immersion heater in a comment where the F750 profile ships the 3.5 kW delivery setting.
  5. A "150 SEK" figure the formula computes as 244.
  6. A layer weight quoted as 0.99 where the constant is 0.91.
  7. Comments describing the multiply-era thermal-mass math (−442, "−340 × 1.3") — production divides.
  8. A class docstring describing a "smart runtime-vs-critical reload detection" that has never existed (the code always hot-reloads — and the tests below it asserted exactly that).
  9. A production comment citing record_quarter_measurement — a method that does not exist.
  10. Two comments calling BT1 the indoor sensor — BT1 is the outdoor sensor by const.py's own register map; the room sensor is BT50.
  11. The rulebook's curve-9 figures (0.20/2.37 °C) — the retracted numbers its own research note corrects to 0.19/0.64.
  12. Stale line-number citations and every remaining pre-correction simulator figure ("2–5×", "cooked to ~30 °C", "241 kWh", per-locale drift counts), swept from all test docstrings.

Unverifiable war stories (mutation-run tallies, "verified live" anecdotes, owner quotes, session dates) were deleted wholesale — if it can't be checked, it doesn't get to sound like evidence.

What this does to the branch

What deletion did NOT touch: red-first discipline. The deleted tests defended nothing — run against main, the remaining suite still fails by the hundreds.

enoch85 added 2 commits July 15, 2026 20:10
MyUplink exposes temporary lux as a switch; nibe_heatpump and generic Modbus
do not - those users bridge register 48132 with an input_boolean helper and
an automation. The lux door hardcoded the switch.* service domain and the
config flow accepted only switch entities, locking every non-MyUplink
install out of hot-water optimization for no reason.

One door, one generic service: _set_temporary_lux now calls
homeassistant.turn_on/turn_off, which drives both domains, and both lux
selectors accept switch and input_boolean. Ownership, unload cleanup, the
user-boost window and the safety stop are unchanged - they all pass through
the same door they always did.

Red-first: 3 tests failed before the fix.
Main released v0.5.0 while this branch was in flight; without this, merging
the branch would quietly revert the manifest to the beta version.
@enoch85

enoch85 commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Two follow-up commits, and v0.5.0 shipped from main

commit what
5e59e4d A helper is a valid hot-water actuator (issue #18, from panosnl's questions): the lux door now calls homeassistant.turn_on/turn_off — which drives a switch and an input_boolean alike — and both config-flow lux selectors accept either domain. A nibe_heatpump/Modbus user bridges register 48132 with a helper and gets hot-water optimization; MyUplink setups are unchanged. Ownership, unload cleanup, the user-boost window and the safety stop all still pass through the same single door. Red-first: 3 tests.
(tip) Carry the v0.5.0 release version — main released while this branch was in flight; without this, merging would silently revert the manifest to the beta version.

Unrelated to this PR but done alongside: v0.5.0 is released from main (19821cf) — main's suite green at 1,175, the beta tag and release removed, house-format notes set. His questions on issue #18 are answered inline there.

Stack re-cut from this tip and force-pushed (#25/#26/#27, still drafts; tip byte-identical; level-1 suite green at 1,093). Gate here: black ✓ · ratchet ✓ · 2,652 passed, 2 strict xfails · sims unchanged (coldsnap still correctly fails on F-124).

enoch85 added 2 commits July 15, 2026 20:25
Owner's correction, verified against main: the offset path recomputes pending
demand every cycle as (calculated - register), so truncation applies exactly
the whole degrees the demand covers and leaves the fraction pending - nothing
is lost, and the pump never does MORE than the engine asked. The audit's
round() 'fix' would write up to half a degree the engine never requested and
oscillate back when the recomputed demand reversed sign. Reverted to main's
semantics; tests now pin the never-over-apply property and the pending-
fraction convergence instead of the rounding claim.
…ees it does

The owner's decision, recorded last session: HIS grid company measures 15-minute
intervals, so the billing period returns to the quarter - as configuration, not as a
claim about Sweden. Operator models vary across thousands of DSOs (F-107), which is
precisely why the government ordered the effect-charge framework repealed and rebuilt.
The audit's hour model cited Ellevio and Ei correctly, but they describe operators the
owner is not billed by. 81.25 kr/kW/month stays as the simulator's illustrative rate.

THE CHANGE WAS HALF-LANDED AND THE TREE WAS BROKEN. The previous session flipped
BILLING_PERIOD_MINUTES to 15, renamed is_daytime_hour -> is_daytime_period and
billing_hour -> billing_period, and stopped. The suite could not collect
(test_effect_manager imports is_daytime_hour), and - worse - sensor.py and
coordinator.py still called projected_hour_mean and completed.billing_hour: A HOME
ASSISTANT RESTART WOULD HAVE CRASHED THE INTEGRATION. Completed here:

  * effect_layer: the one stale is_daytime_hour call inside record_period_measurement.
  * coordinator: projected_period_mean; completed.billing_period.
  * sensor: peak_billing_period / peak_billing_period_time - and the time is formatted
    HH:MM from the quarter index; the old f"{period:02d}:00" would have printed
    "50:00" for the 12:30 quarter.
  * sim_harness: DST_FALL_BACK_PERIODS = 100 (a 25-hour day in quarter-periods),
    billing_periods_by_day, and the --dst gate now fails on anything but 100.
    Measured: 96 / 100 / 92.
  * MAX_BILLING_OBSERVATION_GAP_MINUTES = 10: strictly below the period length, or a
    single-sample period could never be refused; one dropped 5-minute cycle tolerated,
    two refused.
  * v1 store records MIGRATE now instead of being discarded: a v1 quarter peak is the
    SAME billed quantity again. Provenance cannot be reconstructed, so they convert as
    POWER_SOURCE_NONE - control-grade until live measurement replaces them.

Nineteen test files re-expressed in period semantics; three renamed to say what they
now test (the_billing_period_survives_the_clocks_going_back, the_tariff_bills_the_
owners_period, a_period_the_meter_slept_through, a_billing_period_remembers_where_its_
samples_came_from). Under this model a sustained 9 kW hot-water quarter genuinely IS a
9 kW billing peak - there is no quiet 45 minutes to average it away, and tests that
celebrated that averaging now pin the period's own time-weighted mean instead.

ONE MUTANT SURVIVES, AND IT IS EQUIVALENT RATHER THAN UNCAUGHT. Making the period
rollover PEP 495-ambiguous (comparing local datetimes) no longer reproduces the DST
merge that deleted an hour's peak: with quarter periods the fold lands on a period
boundary whose neighbours carry different labels (02:45 -> 02:00), so consecutive
comparisons never see the two same-digit quarters. The pathological case - two samples
exactly an hour apart, both labelled 02:00 - merges into one period whose internal gap
is 3600 s, and the 10-minute observation guard refuses it. The absolute-time comparison
stays: it is what makes projected_period_mean correct, and defence in depth is free.

Full gate green: black, check_hardcoded_values, 2653 passed, simulator nominal /
--dst / --selftest exit 0. Live Home Assistant restarted on this code: loads clean,
zero errors, deciding.
@enoch85

enoch85 commented Jul 16, 2026

Copy link
Copy Markdown
Owner Author

The owner's tariff bills the 15-minute period — completed and landed

65116a9 (completes the model change begun last session), plus 926760c.

Your decision — your DSO measures 15-minute intervals — is now carried through the whole codebase, as configuration, not a claim about Sweden: operator models vary across thousands of DSOs (F-107), and the audit's hour model cited Ellevio/Ei correctly for operators you are not billed by. 81.25 kr/kW/month stays as the simulator's illustrative rate.

The change was half-landed, and the tree was broken

The flip to 15 minutes was committed to the working tree with the suite unable to collect, and — worse — sensor.py and coordinator.py still calling projected_hour_mean / completed.billing_hour: a Home Assistant restart would have crashed the integration. Completed:

  • coordinator, effect layer, sensor: all stale hour-model calls renamed; the peak-time attribute now formats HH:MM from the quarter index (the old formatter would have printed "50:00" for the 12:30 quarter)
  • simulator: the DST fall-back day now gates on 100 periods (measured: 96 / 100 / 92)
  • observation gap: 10 min — strictly below the period, or a single-sample period could never be refused; one dropped cycle tolerated, two refused
  • v1 store records migrate instead of being discarded — a v1 quarter peak is the same billed quantity again; provenance can't be reconstructed, so they convert as unbillable control thresholds
  • 19 test files re-expressed in period semantics, 3 renamed to say what they now test

Under this model a sustained 9 kW hot-water quarter genuinely is a 9 kW billing peak — there is no quiet 45 minutes to average it away. The tests that celebrated that averaging now pin the period's own time-weighted mean.

One mutant survives, and it is equivalence, not a gap

Making the period rollover PEP 495-ambiguous no longer reproduces the DST merge that deleted an hour's peak: with quarters, the fold lands on a period boundary whose neighbours carry different labels (02:45 → 02:00), so consecutive comparisons never see the two same-digit quarters. The pathological case (two samples an hour apart, both labelled 02:00) merges into a period with a 3600 s internal gap — refused by the 10-minute observation guard. The absolute-time comparison stays anyway: it is what makes projected_period_mean correct.

Verified

Gate green: black, check_hardcoded_values, 2653 passed, simulator nominal / --dst / --selftest exit 0. Live HA restarted on this code: loads clean, 0 errors, deciding. The week-long live observation continues uninterrupted (153 samples, no anomalies).

The owner asked for a more realistic simulation: an arctic winter, real weather, real
physics on the pump. All three are now real, and none of them is invented:

  WEATHER   Kiruna, January 2024, hourly, from the Open-Meteo ERA5 archive
            (scripts/simulation/data/weather_kiruna_jan2024.json). Minimum -36.8 C;
            mean -14.2 C; 211 of 744 hours below -20 C. That month is the real cold
            snap: SMHI's corrected archive has Kiruna Flygplats at -36.7 C on 4-5
            January, Vittangi (Kiruna municipality) at -44.6 C on the 5th - the
            coldest measured in Sweden since 1999 - and Kvikkjokk-Arrenjarka's -43.6 C
            on the 3rd, a station record in a series begun 1887.

  PRICES    Nord Pool SE1 (Kiruna's bidding zone), the SAME dates, via
            elprisetjustnu.se (scripts/simulation/data/prices_se1_jan2024.json).
            Including the real 5 January spike to 589 ore/kWh - two days after the
            deepest cold - and a real negative hour (-2.3 ore). No shape replay, no
            re-stamping: the weather and the prices are the same real days.

  PHYSICS   The F2040 installer manual (IHB EN 1848-8/231846, p.65 - the PDF was
            downloaded and the row read verbatim): "Min. / Max. air temp: -20 / 43 C".
            The profile has carried that number since the datasheet audit, REFERENCED
            NOWHERE: the plant held the -7 C capacity forever, making phantom
            compressor heat through 28% of a real Kiruna January. capacity_kw_at now
            returns zero strictly below the floor - a hard edge, because the manual
            gives a range, not a derating. At exactly -20.0 the machine is in range
            and every existing datasheet pin still holds.

            ONLY the F2040. NIBE publishes no outdoor floor for the brine or
            exhaust-air machines - their compressor blocks are source-side (F730:
            exhaust air < 6 C; F750: < 16 C, per their own manuals) - so the model
            imposes none, because inventing one is exactly the unsourced physics this
            audit exists to remove.

            And the plant may not lie about it: zeroing capacity alone left
            compressor_on True, so the simulated NibeState reported hz > 0 and
            is_heating=True for a machine that was physically stopped, and the
            DecisionEngine under test was optimising a fiction. One rule -
            compressor_available() - now drives the physics, the reported state, and
            the start counter (no phantom compressor starts at -30 C).

--arctic runs the five houses through that month at Kiruna's latitude (67.86 N, which
selects the integration's own Arctic climate zone - the zone logic runs for real, for
the first time). The houses stay Stockholm-designed (EN 14825 cold, -22 C) against a
site whose Boverket DVUT is -29.4 C, deliberately: resizing them would need per-site
assumptions, and the mismatch is itself the story.

WHAT THE REAL MONTH FOUND, attributed against the do-nothing baseline on the same data:

  airsource_f2040   The house freezes to -13 C indoor - and the BASELINE freezes to
                    the SAME -13 C. This is the machine's envelope, not the
                    controller: 210 blocked compressor hours (now reported as
                    compressor_blocked_hours, so the run attributes itself) against a
                    3 kW backup heater and a 12 kW design load. An F2040 cannot heat
                    this house in Kiruna. NIBE's manual says so; now the model does.

  wooden_f750,      A REAL controller finding, mild but genuine: the do-nothing
  concrete_f1155,   baseline holds the comfort band for the ENTIRE month (0 minutes
  villa_s1155       below), the optimiser drops out for 90-340 minutes - while saving
                    nothing (1649 vs 1653 SEK on the wooden house). Price-chasing
                    coasts into deep-cold hours that a constant curve rides out: the
                    F-124 family, now visible on real weather. Recorded, not fixed -
                    F-124 is owner-gated.

  apartment_f730    Mostly sizing: the baseline also starves (2625 min below band vs
                    the optimiser's 2950) - at -36.8 C the house needs 5.2 kW against
                    the F730's published 5.35 with no immersion margin.

--arctic exits 1, like --coldsnap, and for the same honest reason: it finds things.
The gate scenarios (nominal, --dst, --selftest) are untouched and green; every prior
scenario's numbers are byte-identical (the cutoff only changes behaviour below -20 C,
which no other scenario reaches).

Sources verified before use, and one of my own claims died in verification: I believed
the -43.6 C record was 5 January; SMHI's blog and corrected archive say 3 January (the
5th belongs to Vittangi's -44.6). Kiruna DVUT from Boverket's 1991-2020 dataset
(1-dygn -29.4 C). The provenance guard rejected my first LATITUDE entries for naming
no reference - the fourth time it has caught its own author - and the entries now
carry the URLs.
@enoch85

enoch85 commented Jul 16, 2026

Copy link
Copy Markdown
Owner Author

A real arctic January (2813fb0)

You asked for a more realistic simulation — arctic winter, real weather, real pump physics. All three are now real, none invented:

Weather Kiruna, January 2024, hourly ERA5 (Open-Meteo archive). Min −36.8 °C, 211 of 744 hours below −20. The real cold snap: SMHI has Kiruna Flygplats at −36.7 on Jan 4–5, Vittangi at −44.6 on the 5th (coldest in Sweden since 1999), Kvikkjokk-Arrenjarka's −43.6 station record on the 3rd.
Prices Nord Pool SE1, the same dates (elprisetjustnu.se) — including the real Jan 5 spike to 589 öre/kWh two days after the deepest cold, and a real negative hour. No shape replay: same real days.
Physics The F2040 manual (IHB EN 1848-8/231846 p.65, PDF downloaded and read verbatim): "Min./Max. air temp: −20/43 °C". The profile carried that number, referenced nowhere — the plant made phantom compressor heat through 28% of a real Kiruna January. capacity_kw_at is now zero strictly below the floor. Only the F2040: NIBE publishes no outdoor floor for the brine/exhaust machines (their blocks are source-side: F730 exhaust <6 °C, F750 <16 °C, per their manuals), so none is invented.

And the plant may not lie about it: zeroing capacity alone left the simulated NibeState reporting hz > 0, is_heating=True for a stopped machine — the DecisionEngine would have been optimising a fiction. One rule (compressor_available) now drives physics, reported state, and the start counter.

--arctic runs all five houses through that month at Kiruna's latitude (67.86 N — the integration's own Arctic climate zone logic runs for real, for the first time). Houses stay Stockholm-designed (−22 °C) against a site whose Boverket DVUT is −29.4 °C — deliberate; the mismatch is the story.

What the real month found (vs the do-nothing baseline on the same data)

  • airsource_f2040 — the machine's envelope, not the controller. Freezes to −13 °C indoor; the baseline freezes to the same −13 °C. 210 blocked compressor hours (now a reported stat, so the run attributes itself) vs a 3 kW backup and a 12 kW load. An F2040 cannot heat this house in Kiruna. The manual says so; now the model does.
  • The GSHP + F750 houses — a real controller finding, mild but genuine. Do-nothing holds the comfort band the entire month (0 min below); the optimiser drops out 90–340 min while saving nothing (1649 vs 1653 SEK). Price-chasing coasts into deep-cold hours a constant curve rides out — the F-124 family, now visible on real weather. Recorded, not fixed: F-124 is yours.
  • apartment_f730 — mostly sizing: baseline also starves (2625 vs 2950 min); at −36.8 °C the house needs 5.2 kW against a published 5.35.

--arctic exits 1, like --coldsnap, for the same honest reason: it finds things. The gate scenarios are untouched and byte-identical (nothing else reaches −20 °C).

Fact-checking that fixed my own claims

I believed the −43.6 record was Jan 5; SMHI's blog and corrected archive say Jan 3 (the 5th belongs to Vittangi). And the provenance guard rejected my first latitude entries for naming no reference — the fourth time it has caught its own author.

Gate green: black, check_hardcoded_values, 2665 passed, nominal/--dst/--selftest exit 0, --coldsnap still exits 1 (F-124, unchanged). Live HA and the week-long observation untouched and running.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant