Skip to content

RW2: real physical & economic limits — grid cap, EV/DHW losses, enforce-or-reject, ignored-window honesty (specs/003 Phase 2) - #27

Merged
kipp-ing merged 11 commits into
mainfrom
feat/003-rw2-limits
Jul 16, 2026
Merged

kipp-ing merged 11 commits into
mainfrom
feat/003-rw2-limits

Conversation

@kipp-ing

Copy link
Copy Markdown
Member

RW2 — the plan respects real physical & economic limits (specs/003 Phase 2)

One golden-capture cycle covering bug#2 + T201–T206, per handover-rw2-fixes.md. Every change is default-preserving unless noted; golden diffs are recaptured and eyeballed per commit.

What's in here

  • bug#2 / reason fix — peak-price discharge is now expensive_grid (new PlanReason), not pv_surplus, in both backends. This is the mislabel observed live on the real home (evening −4 kW slots tagged as PV at night).
  • FR-201 grid/main-fuse cap (T201) — grid_import_limit_kw / grid_export_limit_kw solver params bound each settlement leg per slot; an unsatisfiable cap returns INFEASIBLE (now actually reachable — the appsi wrapper used to raise into ERROR before status mapping). SC-RW2a Hypothesis property test.
  • FR-203 EV/DHW losses (T202) — EV charge_efficiency (0.9) and DHW heating_efficiency (0.98, wired to both the converter factor and the storage charge leg). Golden changes are exactly the predicted physics (DHW ×1/0.98, EV deficit/0.9, objectives rise).
  • FR-204 terminal floor (T203) — landed with RW1; this adds the missing regression tests (20 % start stays idle on flat prices; arbitrage ends ≥ measured start).
  • FR-205 enforce-or-reject (T204) — HP min_modulation_pct and EV min_charge_kw now bind (semi-continuous, both backends); defrost_lockout_minutes, south_facing_windows=True, and implausible EV phase/power combos are rejected at the pydantic layer; flex_cost_per_hour_early is priced (linear earliness term in A, price shading in B); forbidden-window discharge gating and EV-no-window→zero-demand get SC-RW2c guards.
  • FR-206 ignored windows (T205) — shared partition (solvers/windows.py) in both backends: past/beyond-horizon demand deadlines and unknown devices are rejected with a warning and surfaced in diagnostics["ignored_windows"], never clamped. Two artifacts of the old clamp were fixed honestly: pool_pump.yaml's year-2030 deadline → deadline_offset_hours, and a thermal test whose deadline predated its own price series.
  • FR-207/208 tank draw + per-zone gains (T206) — NodeSpec.gains series overlaid per zone (internal_gains solve param): occupant gains offset heating in their zone only; hot-water draws (negative gains) force reheat on the tank state. Backend B warns loudly that it ignores the series until T404.

Contract tightenings (need the ha-hemm follow-up before Pi rollout)

  • south_facing_windows=True and defrost_lockout_minutes≠0 now fail manifest construction — the ha-hemm device flow exposes both fields, so the follow-up ha-hemm release must drop/guard them before this core version reaches the real home (pin gates this).
  • min_charge_kw and min_modulation_pct now change plans where set.

Gate

  • make ci green (ruff + format + mypy + check-clock + 420 unit tests, incl. new tests/test_rw2_limits.py with 24 RW2 tests).
  • Goldens recaptured on macOS; per the platform-degenerate-tie lesson please confirm the Linux container CI agrees.
  • Container + warp CI on this PR = the docker gate; nothing touches gs until green and released, core-first.

https://claude.ai/code/session_01DuXc9AjYvJVrexaa5SwixT

kipp-ing added 10 commits July 16, 2026 15:48
…205)

_determine_reason in both backends labeled every negative-power slot
pv_surplus, so on the real home the 19:45-21:15 peak discharge showed a
PV reason at night. Mirror cheap_threshold with a top-quartile
expensive_threshold: discharge at/above it is expensive_grid; below it
the pv_surplus fallback remains. Goldens carry no reason field - no
parity impact.

Claude-Session: https://claude.ai/code/session_01DuXc9AjYvJVrexaa5SwixT
…201)

grid_import_limit_kw / grid_export_limit_kw (None = legacy unbounded)
bound each settlement leg per slot; a cap the fixed loads cannot satisfy
returns INFEASIBLE instead of silently over-drawing the fuse. Solving
with load_solutions=False makes INFEASIBLE actually reachable - the
appsi wrapper used to raise into the ERROR path before status mapping.
SC-RW2a property test over randomized instances; goldens bit-identical.

Claude-Session: https://claude.ai/code/session_01DuXc9AjYvJVrexaa5SwixT
EVChargerManifest gains charge_efficiency (default 0.9, onboard AC
charger + cable losses) wired into its StorageSpec; WaterHeaterManifest
gains heating_efficiency (default 0.98) wired into BOTH energy paths -
the converter factor (tank q_in) and the storage charge leg - so the
two states stay consistent. No discharge fields where no discharge path
exists (would be accepted-but-ignored, FR-205). Backend B picks both up
through the shared StorageSpec/ConverterSpec.

Goldens recaptured; diff is the predicted direction only: DHW slots
scale by 1/0.98, EV SoC-target charging draws deficit/0.9 extra input,
objectives rise (full_house 11.488->11.538, onboarding 4.619->5.286).
ev_departure is unchanged - its window is an energy-input requirement.

Claude-Session: https://claude.ai/code/session_01DuXc9AjYvJVrexaa5SwixT
The floor itself landed with RW1 (both backends anchor terminal
neutrality to the measured start); this adds the missing FR-204 tests:
a 20 % start on flat prices stays idle instead of being force-charged
toward 50 %, and arbitrage from an 8.1 kWh start ends at or above it.
Backend B asserts with 0.5 kWh slack for its DP level-grid drift.

Claude-Session: https://claude.ai/code/session_01DuXc9AjYvJVrexaa5SwixT
…06, T205)

Shared partition (solvers/windows.py) used by BOTH backends: windows
with a past deadline, a demand deadline beyond the horizon, or an
unknown device are rejected with a warning and surfaced in
SolverResult.diagnostics["ignored_windows"] - never clamped into the
horizon (the old behavior forced e.g. a 30h SoC target to complete by
end-of-horizon and applied past-deadline windows at slot 0). Restrictive
requirements (forbidden window, max runtime, comfort band) still
truncate safely at the horizon edge. Reason annotation now derives from
applied windows only.

Fallout fixed honestly rather than papered over: pool_pump.yaml's
deliberate year-2030 deadline relied on the clamp - now expressed as
deadline_offset_hours: 4; test_hold_temp_band_flat_price_cold_day's
deadline predated its own price series by 4 months and passed only via
the slot-0 clamp artifact. Goldens bit-identical.

Claude-Session: https://claude.ai/code/session_01DuXc9AjYvJVrexaa5SwixT
…204 part 1)

Enforced (semi-continuous minimums, both backends):
- HP min_modulation_pct -> ConverterSpec.min_input_kw / SinkSpec
  min_power_kw floor while on; controllable sinks switch from a hard
  lower bound (always-on) to off-or->=min semantics.
- EV min_charge_kw -> StorageSpec.min_charge_kw: charge slots run at
  >= the wallbox minimum or not at all.

Rejected at the pydantic layer (fail loud, no silent no-ops):
- HeatPumpManifest.defrost_lockout_minutes != 0 (no solver model yet)
- RoomManifest.south_facing_windows=True (solar gains not modeled;
  window_area_m2 still binds via envelope losses)
- EVChargerManifest phase consistency (max_charge_kw <= phases x 32 A
  x 230 V) and min_charge_kw <= max_charge_kw.

Testdata cleaned of the rejected no-ops (heat_pump.json defrost,
room_living.json south_facing + tiered-config hints). Goldens
recaptured: tie-break/FP noise only (objectives move <= 1e-5) - the
modulation floor binds without material cost in those scenarios, and
the new SC-RW2c tests prove each field can fail when ignored.
test_solve_ev_with_energy_constraint summed slots[:32] against an
inclusive-deadline constraint over 33 slots - off-by-one previously
hidden by tie-breaking.

Claude-Session: https://claude.ai/code/session_01DuXc9AjYvJVrexaa5SwixT
The field was accepted on every ConstraintWindow and priced nothing.
Backend A adds a linear objective term: flex x hours-before-deadline x
delivered fraction of the required energy (charge leg for storage, so
discharge is never rewarded). Backend B shades its slot-price sort with
the same per-kWh earliness cost (storage charge-to-target, sinks, and
converter input-energy targets; thermal-output targets have no per-kWh
input basis and stay unshaded). flex=0 (the default) is a no-op -
goldens bit-identical. Test proves a priced window charges near the
deadline instead of the cheap early slots, in both backends.

Claude-Session: https://claude.ai/code/session_01DuXc9AjYvJVrexaa5SwixT
… T206)

NodeSpec gains a per-slot 'gains' series (kW), overlaid per zone via
apply_internal_gains (mirrors apply_generation_forecast) and consumed
by the Backend A node balance: positive = occupant/appliance heat gain,
negative = extraction - a hot-water draw on the tank node, completing
the FR-207 'energy in - draw - standby loss' tank model (temperature
state, standby loss, and measured start landed earlier). Gains are
keyed by device_id so they attribute to exactly one zone, never
duplicated across rooms (FR-208). solve() grows an optional
internal_gains param (default None, behaviour-preserving; goldens
bit-identical). Backend B warns loudly that it ignores the series until
T404 makes it consume-or-refuse.

Claude-Session: https://claude.ai/code/session_01DuXc9AjYvJVrexaa5SwixT
The EV charge-efficiency change made full_house's night-slot allocation
degenerate: macOS and Linux HiGHS return different equal-cost vertices
(energy swaps between slots 0 and 6). Same mechanism as the existing
full_house/dhw entry - per-device totals still must match the golden.

Claude-Session: https://claude.ai/code/session_01DuXc9AjYvJVrexaa5SwixT
kipp-ing added a commit to hemm-ems/ha-hemm that referenced this pull request Jul 16, 2026
Pin points at the RW2 core release (hemm-ems/hemm#27); container CI on
this branch's PR is the docker gate once that release is on PyPI.

Claude-Session: https://claude.ai/code/session_01DuXc9AjYvJVrexaa5SwixT
@kipp-ing
kipp-ing merged commit 3ac7035 into main Jul 16, 2026
6 of 7 checks passed
@kipp-ing
kipp-ing deleted the feat/003-rw2-limits branch July 16, 2026 15:56
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