Commit graph

6337 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
ae5bbd0646 docs(modelling): handover — per-measure bill savings landed (telescoping cascade)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:02:18 +00:00
Khalim Conn-Kowlessar
b976c3abd2 feat(modelling): attribute per-measure bill savings via a telescoping cascade
`_plan_for` now scores the baseline + every cumulative prefix once
(`cascade_scores`, best-practice order) and reuses those Scores for both the
role-3 marginal attribution and a per-measure bill cascade: bill each prefix at
one Fuel Rates snapshot and take consecutive Bill deltas as each measure's
marginal delivered-kWh and £ saving. Saving is signed (ventilation is
negative) and telescopes exactly to the Plan headline savings, because the
Plan's baseline/post Bills are now the same cascade endpoints (`bills[0]` /
`bills[-1]`) — which also drops the redundant standalone baseline `calculate`.

`recommendation.kwh_savings` / `energy_cost_savings` are filled from these.
Adds `Bill.total_consumption_kwh` (shared by Plan + the orchestrator). Pinned
end-to-end on the real calculator: Σ per-measure savings == the Plan totals
(ADR-0014 amendment).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:01:11 +00:00
Khalim Conn-Kowlessar
7e79c30af1 feat(modelling): Plan Measure carries per-measure kwh/cost savings
`PlanMeasure` grows optional `kwh_savings` (delivered energy) and
`energy_cost_savings` (£) — its slice of the telescoping bill cascade, signed
so positive is a saving and `None` until billing runs. `RecommendationRow`
declares the matching live `recommendation.kwh_savings` /
`energy_cost_savings` columns and maps them in `from_domain` (None → NULL).
The vestigial `recommendation.energy_savings` stays undeclared (legacy = 0).
No FE migration — the columns already exist on the live table (ADR-0014 / 0017).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:58:06 +00:00
Khalim Conn-Kowlessar
fe59c4d8a2 S0380.208: case 7 combi e2e fixture — condensing-oil-combi path validated exact
Adds simulated case 7: case 6 (P960-0001-001431) with the heating swapped
to a CONDENSING OIL COMBI (SAP code 130, Table 4b 82/73) and the cylinder
removed — combi instantaneous DHW (WHC 901), Table 3a keep-hot combi loss
(61) = 600 kWh/yr, no primary/storage loss, boiler interlock PRESENT (no
−5pp). This is the heating archetype golden cert 0240-0200-5706-2365-8010
uses, which case 6 (SAP code 127, a *regular* condensing oil boiler +
cylinder) never exercised.

The cascade reproduces the case-7 worksheet EXACTLY at abs=1e-4 on every
top-level SapResult output with ZERO calculator changes:
  (211) 7865.4304  (213) 7556.9821  (219) 3496.8121  (98c) 12646.3783
  (255) 1123.3372  (257) 1.9631     (272) 5738.9315  (258) 73
This validates the SAP 10.2 Appendix D Eq D1 combi efficiency blend +
Table 3a keep-hot combi loss + Table 4b code 130 (82/73) path, and
exonerates the combi mechanism as the source of 0240's API-path residual
— which therefore lives in 0240's fabric/demand or the API mapper.

Test-only slice (no impl change). New fixture file: 0 pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:57:22 +00:00
Khalim Conn-Kowlessar
e79ffabfc5 refactor(modelling): expose cascade_scores for the role-3 + bill cascade
Pull the cumulative-prefix scoring out of `marginal_impacts` into a reusable
`cascade_scores(scorer, baseline, overlays) -> list[Score]` (index 0 the
baseline, one calculator run per prefix) plus a pure `marginals_from_scores`.
Each Score carries its SapResult, so the next slice's telescoping per-measure
bill cascade can re-bill the same prefixes the role-3 attribution already
scores — no extra `calculate` calls (ADR-0014 / ADR-0016). `marginal_impacts`
now delegates; behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:54:54 +00:00
Khalim Conn-Kowlessar
d36e42b582 docs(modelling): handover — plan-level Bill-Derivation landed; per-measure next
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:31:48 +00:00
Khalim Conn-Kowlessar
198122d145 feat(modelling): derive + persist plan-level post-retrofit bills (#1152 follow-up)
ModellingOrchestrator gains a constructor-injected FuelRatesRepository (mirrors
Baseline): run() resolves get_current() once and reuses one BillDerivation across
the batch. _plan_for prices the baseline and post-package end-states from the
SapResults already on their Scores (no extra calculate) and passes the Bills to
Plan. PlanRow mirror + from_domain gain the four live columns post_energy_bill /
energy_bill_savings / post_energy_consumption / energy_consumption_savings.
Pipeline/handler wire the fuel-rates repo. Integration tests assert the columns
persist: the multi-measure (fallback) plan shows positive bill+consumption
savings; the already-at-target zero-measure plan shows the current bill with
exactly zero savings. Fuel-switch measures price at the new fuel for free (we
bill the simulated end-state). 183 modelling/billing/orchestration/repo tests
pass, pyright strict clean. Plan-level only; per-measure savings next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:30:47 +00:00
Khalim Conn-Kowlessar
26de28aae8 feat(modelling): Plan carries baseline/post Bills and derives the energy figures
Plan gains optional baseline_bill / post_bill (the Bills derived for the
unmodified and post-package end-states at one Fuel Rates snapshot) and derives
the four plan-level columns: post_energy_bill (post total), energy_bill_savings
(baseline - post), post_energy_consumption (Σ post section kWh), and
energy_consumption_savings (baseline - post delivered kWh). All return None until
billing runs (persisted as NULL), so existing Plan construction and the
not-yet-wired orchestrator stay green. Plan-level only; per-measure savings are a
later slice (ADR-0014 amendment).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:23:20 +00:00
Khalim Conn-Kowlessar
2bbc401f0d feat(modelling): Score carries the scored SapResult for billing
Score gains sap_result: Optional[SapResult], populated by PackageScorer with the
calculator output its headline figures came from. This lets the Modelling stage
price the post-package (and baseline) end-state via Bill Derivation reusing a
SapResult already computed by the optimiser's re-score / the orchestrator's
baseline score — no second calculate (ADR-0014 amendment). The optimiser reads
only sap_continuous, so it stays domain-agnostic and the stub scorers (which omit
sap_result) are unaffected — all optimiser tests pass unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:20:45 +00:00
Khalim Conn-Kowlessar
ced6287baa refactor(billing): relocate Bill Derivation to domain/billing/ (cross-stage)
Bill / EnergyBreakdown / BillDerivation / sap_fuel were under
domain/property_baseline/ only because Baseline was built first. The Modelling
stage now needs them too, so move them (and their tests) to a neutral
domain/billing/ — Fuel/FuelRates already live in the shared domain/fuel_rates/.
Avoids a modelling -> property_baseline cross-stage import and a package name
that wrongly implies ownership (ADR-0011, ADR-0014 amendment). Pure git mv +
import rewrite across 10 files; 40 billing/baseline/repo tests pass, pyright
strict clean. CONTEXT.md Bill Derivation location updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:19:23 +00:00
Khalim Conn-Kowlessar
75ba5dd744 docs(modelling): ADR-0014 amendment — cross-stage billing + Modelling post-package bills
Records the /grill-with-docs design for the Modelling Bill-Derivation slice:
Bill Derivation is cross-stage (relocate Bill/EnergyBreakdown/BillDerivation/
sap_fuel to a neutral domain/billing/); Modelling bills the fully-overlaid
post-package SapResult (so fuel-switch measures price at the new fuel for free),
diffing against the baseline at the same FuelRates snapshot; the post-package
and baseline SapResults are captured from scores the optimiser/orchestrator
already compute (Score.sap_result), so no second calculate; FuelRatesRepository
is constructor-injected into ModellingOrchestrator mirroring Baseline; plan-level
columns this slice, per-measure telescoping bill cascade next (energy_savings is
vestigial, left NULL).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:17:03 +00:00
Khalim Conn-Kowlessar
6ac67a4c6f docs: add full 0240 worksheet input spec to the closure handover
Adds a "build THIS in Elmhurst" specification — dwelling, dual condensing
oil-combi (code 130) heating, combi/no-cylinder DHW (Table 3a keep-hot
600), per-element fabric W/K targets, room-in-roof gables, the 5 vertical
+ 6 roof-of-room windows, lighting (8 LED), no PV — so a generated
worksheet reproduces cert 0240 as closely as possible. Flags the three
load-bearing differences vs case 6 (combi code 130, no cylinder, boiler
interlock PRESENT → no -5pp) that the new worksheet must capture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:13:46 +00:00
Khalim Conn-Kowlessar
d4817ccdc7 docs: handover for closing golden cert 0240 to 1e-4
Records why case 6 (worksheet-validated dual-oil archetype) did not close
0240's residual: 0240 is API-only with an INTEGER-rounded register target
(PE 122, CO2 6.0), so 0 residual at 1e-4 is not well-posed without a
worksheet. 0240's unvalidated path vs case 6 is the condensing-combi
(code 130) + no-cylinder HW (Table 3a keep-hot 600 kWh) — case 6 used a
regular boiler + cylinder. Recommends generating an exact-0240 worksheet
(or a 'case 7' = case 6 with the combi swapped in) to get a 1e-4 target.
Notes the lodged RHI water_heating 2842.82 already matches the cascade
HW output exactly (HW demand is right; any residual is in efficiency).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:05:58 +00:00
Khalim Conn-Kowlessar
660dc54246 docs(modelling): handover — optimiser objective realigned to least-cost-to-target
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:21:31 +00:00
Khalim Conn-Kowlessar
641c1bd7f6 test(modelling): pin least-cost-to-target end-to-end through the orchestrator
The orchestrator already threads budget/target_sap/dependencies into
optimise_package, so no orchestrator change was needed. Add an integration test
proving the new objective end-to-end on the real calculator: a band-D property
(~57.4) with a goal of band D — already met — yields a Plan with NO measures and
zero cost (the old max-gain objective would have recommended wall+floor+vent,
improving within the band it is already in). Clarified that the existing
multi-measure test now exercises the max-gain fallback (goal C unreachable from
D, tops out ~61). Narrowed Optional sap_points/estimated_cost through locals to
keep pyright strict-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:20:45 +00:00
Khalim Conn-Kowlessar
af501fce0e feat(modelling): ventilation-aware selection — price the forced dependency in
The warm-start (and max-gain fallback) now price each forced Measure Dependency
the candidate triggers, not just inject it afterwards: optimise/optimise_min_cost
fold dependencies into each candidate's cost+gain via _augmented_cost_gain, and
optimise_package scores each dependency's true role-1 signal (_with_role1_signals)
instead of the 0.0 placeholder. This stops the min-cost objective (i) ignoring the
~£900 a wall drags in (a wall-free package reaching target can be cheaper) and
(ii) picking a small-gain wall whose mandatory ventilation (down to -5 SAP) makes
it net-negative, which repair cannot un-pick.

Budget is now a hard envelope: the constraint applies to the augmented (measure +
its ventilation) cost, so a wall that fits alone but whose ventilation would bust
the budget is DROPPED rather than forced over budget. This reverses the earlier
'forced regardless of budget' call (which made sense when selection was
ventilation-blind). Safety invariant intact — presence still injected on every
path; we just never recommend a wall we can't afford to ventilate. ADR-0016
amendment updated. 94 modelling+orchestration tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:16:26 +00:00
Khalim Conn-Kowlessar
7344f600e6 S0380.207: promote simulated case 6 to a full SapResult e2e fixture
With S0380.201-206 closing every line ref, the detached dual-oil case 6
(Main 1 radiators 51% / Main 2 underfloor 49%, different parts, no boiler
interlock, 6 roof-of-room rooflights) now matches its P960-0001-001431
worksheet to 1e-4 on the whole SapResult. Registered in
`test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS` (11 pins):
  SAP 72 / cont 71.6597, ECF 2.0316, cost 1162.5374, CO2 5953.6679,
  space heat (98c) 11991.9611, main fuel (211)+(213) 14736.9564,
  HW (219) 4902.8601, lighting (232) 357.6571, pumps (231) 356.0.

This was the validation target the S0380.200 handover set. Updated the
fixture docstring's stale "§3-windows-only" scope note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:14:48 +00:00
Khalim Conn-Kowlessar
d1ae87c7e9 S0380.206: Eq D1 Q_space uses the DHW boiler's own (204) share, not (202)
SAP 10.2 Appendix D §D2.1(2) Equation D1 blends the monthly water-heater
efficiency by the ratio of the boiler's space-heating load to its water
load. On a dual-main cert the DHW boiler does only its OWN share of space
heating ((204) for Main 1, (205) for Main 2), but the cascade fed Eq D1
the dwelling total ((202) = 1 − secondary). That over-weighted η_winter
and under-stated HW fuel — simulated case 6 (Main 1 serves DHW + 51% of
space heat) was HW −78 kWh vs the worksheet.

New `_water_heating_main_space_fraction` returns the DHW main's total-
space share via `_water_heating_main` (WHC-901 → Main 1 (204); WHC-914 →
Main 2 (205)); single-main / WHC-901 single systems get (202) = 1 −
(201), so they are unchanged. Case 6 (219) HW now 4902.8601 EXACT.

With S0380.205 (demand exact), case 6 now closes to 1e-4 on EVERY metric:
SAP cont 71.6597, ECF 2.0316, cost 1162.5374, (211)+(213) 14736.9564,
(219) 4902.8601, (231) 356, (232) 357.6571, CO2 5953.6679 (rating) /
4895.2137 (demand).

Re-pin: 0240 (dual combi, WHC 901, Main 1 51%) HW rises slightly → PE
+1.6893 → +1.8687, CO2 +0.0815 → +0.0907 (SAP 72 unchanged). Single-main
certs unchanged (2360 pass + 0 fail).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:10:42 +00:00
Khalim Conn-Kowlessar
e440e2df2e S0380.205: SAP 10.2 p.186 two-systems-different-parts MIT (weighted R + elsewhere blend)
When two main heating systems heat different parts of a dwelling, SAP
10.2 §7 (PDF p.186) adapts the mean-internal-temperature calculation:
- Table 9b weighted responsiveness: R = (1−(203))·R_sys1 + (203)·R_sys2.
- Rest-of-dwelling temperature (90)m = weighted average of T2 computed
  under EACH system's control schedule, weights (203)/[1−(91)] for sys2
  and [1−(203)−(91)]/[1−(91)] for sys1 (or sys2's control alone when
  (203) ≥ 1−(91)).

The cascade used Main 1's control + R=1.0 for the whole dwelling,
over-stating MIT by +0.037 °C on simulated case 6 (Main 1 radiators/2106
type 2 living + Main 2 underfloor/2110 type 3 elsewhere, R 1.0/0.75). That
inflated (97) heat loss by ~11 W → demand +61 kWh/yr.

`mean_internal_temperature_monthly` gains `main_2_control_type`,
`main_2_fraction`, `main_2_responsiveness`; cert_to_inputs derives them
from the second main detail (gated on main_heating_fraction > 0, so
single-main / DHW-only second mains pass the defaults → unchanged).
Case 6: (87) living, (90) elsewhere, (98c) demand 11991.96 and per-system
fuel (211)=7741.6458 / (213)=6995.3106 all match the worksheet to 1e-4.

Re-pin: golden 0240 (same 2106/2110 archetype, API-only) — PE +2.1519 →
+1.6893, CO2 +0.1051 → +0.0815 (both closer to zero; SAP 72 unchanged).
Single-main certs unchanged (2360 pass + 0 fail).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:02:56 +00:00
Khalim Conn-Kowlessar
2b1afa7339 S0380.204: extract Main Heating2's own emitter + control (§14.1)
Prerequisite for the SAP 10.2 p.186 two-systems-different-parts MIT.
When two main systems heat different parts of a dwelling, §14.1 Main
Heating2 lodges its OWN "Heat Emitter" + "Main Heating Controls Sap"
(simulated case 6: Main 1 radiators / control 2106 serving the living
area, Main 2 underfloor / control 2110 serving elsewhere). The extractor
+ mapper dropped both — `MainHeatingDetail.heat_emitter_type` and
`main_heating_control` came through as empty-string sentinels, so the
cascade saw system 2 as having no responsiveness (defaulted R=1.0) and no
control type.

- `MainHeating2` datatype gains `heat_emitter` + `heating_controls_sap`.
- The extractor reads them from the §14.1 block.
- `_map_elmhurst_main_heating_2` maps them via the same helpers as Main 1
  (`_elmhurst_heat_emitter_int` → underfloor-in-screed = emitter 2, Table
  4d R=0.75; `_elmhurst_sap_control_code` → 2110, Table 4e type 3),
  threading the dwelling floor + age band for the underfloor subtype.

Empty-string fallback preserved for the legacy DHW-only Main 2 (cert
000565 §14.1 omits emitter/control). No cascade output changes yet — the
MIT consumer lands in S0380.205. Full suite 2358 pass + 0 fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:53:32 +00:00
Khalim Conn-Kowlessar
2bf42d046e feat(modelling): optimise_package targets least-cost, falls back to max-gain
Rewire the objective per the ADR-0016 amendment. With a target_sap (Increasing
EPC): warm-start optimise_min_cost (cheapest package reaching target_gain =
target_sap - baseline within budget) -> inject dependencies -> re-score ->
repair toward target; if the warm-start is infeasible or the repaired package
still falls short on the true score, fall back to max-gain-within-budget (best
effort). Without a target_sap: max-gain (unchanged). The min-cost objective
stops at the target without overshooting into a higher band; surplus budget is
left unspent. Extracted _max_gain_package (no-target path + fallback) and
_repair_to_target (inject + re-score + greedy repair). Dependency injection and
the repair loop are preserved; all prior optimiser + dependency tests pass
unchanged. Ventilation-aware *selection* is the next slice; injection is still
post-warm-start here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:43:06 +00:00
Khalim Conn-Kowlessar
05a4f5f84a feat(modelling): optimise_min_cost — least-cost-to-target selector (#1152 follow-up)
Exact-enumeration sibling to optimise(): pick <=1 option per group to minimise
total cost subject to total gain >= target_gain and cost <= budget (None =
unconstrained). Ties broken toward higher gain ('recommend more'). Returns None
when no package within budget reaches the target (caller falls back to
max-gain); a non-positive target is met by the empty package. This is the
warm-start objective for an Increasing EPC goal per the ADR-0016 amendment
(least-cost-to-target, not max-gain). Dependency-blind for now; ventilation-aware
selection lands in a later slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:31:26 +00:00
Khalim Conn-Kowlessar
5620f49f18 docs(modelling): ADR-0016 amendment — optimiser objective is least-cost-to-target
The original ADR-0016 mis-specified the warm-start objective as maximise-gain-
subject-to-budget (with the target a repair floor); the rebuild faithfully
implemented that wrong objective. The intended behaviour is the legacy
StrategicOptimiser Case 1: minimise cost subject to (true) SAP gain >= target and
cost <= budget, falling back to max-gain-within-budget only when the target is
unreachable. For Increasing EPC this is least-cost-to-target: cheapest package
reaching the band, stops at the target (no overshoot into a higher band), surplus
budget unspent.

Also records: target predicate sap_continuous >= band floor (conservative, no
legacy slack — re-score+repair supersede it); ventilation-aware selection (the
forced dependency, -1 to -5 SAP, is folded into candidate evaluation with a real
negative role-1 signal, not just injected afterwards); presence-vs-awareness
enforcement; warm-start+re-score+repair structure and scalability rationale kept.
Sharpened the CONTEXT.md Optimised Package definition to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:26:02 +00:00
Khalim Conn-Kowlessar
a42e03529c S0380.203: RdSAP 10 §3.7 — "Roof of Room" rooflights deduct from the RR residual
A rooflight deducts from the gross area of the roof element it pierces
(RdSAP 10 §3.7, PDF p.19). A "Roof of Room" rooflight (window_wall_type=4
/ site-notes "Roof of Room") sits on the room-in-roof sloped ceiling, so
its area must deduct from the §3.10.1 RR residual roof — not the flat /
loft external roof.

The cascade deducted every rooflight from the regular roof (heat_
transmission line 814). Simulated case 6's worksheet is the first
worksheet evidence for "Roof of Room" rooflight billing: "Roof room Main
remaining area" net 55.54 = gross 61.73 − 6.19 rooflights (U_RR=0.30),
while "External roof Main" 14.52 carries no opening. New
`_bp_rr_roof_absorbs_rooflight` routes the rooflight area to the RR roof
(simplified A_RR_final or detailed §3.10.1 residual) ONLY when the BP's
RR contributes such a shell AND lodges no explicit roof surface (slope /
flat_ceiling / stud_wall). Case 6 roof (30) 20.2284 → 19.0523 EXACT;
demand gap +153 → +61 kWh/yr.

Preserved: certs 000565 (Ext2 stud walls) and 000516 (slopes) lodge
explicit roof surfaces → rooflight keeps deducting from the regular roof
(their 1e-4 worksheet pins hold). Simplified Type 1 RR is excluded too.

Re-pin (uniform spec application per [[feedback-software-no-special-
handling]] + worksheet-is-truth): API certs 6035 and 0240 are detailed-RR
gables-only like case 6 (no worksheet of their own for rooflights), so
their "Roof of Room" rooflights now deduct from the RR residual too. This
SUPERSEDES the unvalidated S0380.198 "deduct from loft" assumption.
- 6035: roof 78.0648 → 73.9176; the previously-"unexplained" +1.37 PE
  residual COLLAPSES to -0.14 (CO2 -0.0004 → -0.0362; SAP exact 70) —
  strong corroboration the rooflight-on-RR treatment is correct.
- 0240: PE +2.5812 → +2.1519, CO2 +0.1269 → +0.1051 (SAP 72 unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:19:37 +00:00
Daniel Roth
2d9aa51929 don't include sharepoint link when triggering pashub fetcher from hubspot etl 2026-06-03 14:54:54 +00:00
Khalim Conn-Kowlessar
3581513b7e S0380.202: SAP 10.2 Table 5a note a) second main-system pump gain (70)
The §5 (70) internal-gains mirror of S0380.201's Table 4f (230c). SAP
10.2 Table 5a note a) (PDF p.177) verbatim: "Where there are two main
heating systems serving different parts of the dwelling, assume each has
its own circulation pump and therefore include two figures from this
table. ... Where two main systems serve the same space a single pump is
assumed."

Simulated case 6 (dual oil, 51% radiators + 49% underfloor) lodges Main
1 "2013 or later" (3 W) + Main 2 unknown date (7 W) → worksheet (70) =
10 W in the 8 heating months. The cascade billed a single Main 1 pump
(3 W). New `_second_main_central_heating_pump_gain_w` adds the second
main's gain (at its own pump-age bucket), gated on a lodged
main_heating_fraction > 0 — the same genuine-second-space-heating-main
test as S0380.201, so DHW-only second mains (cert 000565 Main 2 combi via
WHC 914, fraction 0) keep a single pump (70)=3. Refactored the per-detail
pump predicate (`_main_detail_has_central_heating_pump`) and date bucket
(`_pump_date_category_for_detail`) out of the orchestrator.

Re-pin: golden 0240 (dual-main oil combi, both unknown date) (70) 7 → 14
W; the extra internal gain lowers space-heating demand → SAP cont 72.18 →
72.24 (integer 72 unchanged), PE +2.8092 → +2.5812, CO2 +0.1385 →
+0.1269 (both closer to zero). Validated against the case-6 worksheet.

This closes the (70) leg of case 6's space-demand gap. Remaining for full
case-6 closure: roof fabric (37) +1.176 W/K (room-in-roof shell) and HW
(216) Eq-D1 water efficiency −1.6%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:35:08 +00:00
Daniel Roth
d31d0683f5 get triggers working for abri address list 2026-06-03 14:23:08 +00:00
Khalim Conn-Kowlessar
d1f8d516f6 docs(modelling): handover — ventilation now a generator + dependency delegates
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:09:32 +00:00
Khalim Conn-Kowlessar
02afc04ce2 refactor(modelling): ventilation_dependency delegates to the generator + wraps
measure_dependency.py now owns only the selection semantics: the trigger set and
the forced-edge wrapping. It delegates production (detection + pricing) to
recommend_ventilation and wraps the returned Recommendation into the
MeasureDependency, picking the cheapest Option (one MEV today; readies the seam
for MEV-c / MVHR). The orchestrator's _measure_dependencies call is unchanged.
Trimmed the now-redundant option-detail assertions — those live in
test_ventilation_recommendation. 138 pass, behaviour-preserving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:04:17 +00:00
Khalim Conn-Kowlessar
631df921de feat(modelling): ventilation Recommendation Generator (detect + price)
recommend_ventilation(epc, products) does the same two jobs as wall/roof/floor —
detect applicability (the has_ventilation guard) and price the work (2 MEV units
+ contingency) — and returns a Recommendation. Ventilation is a Recommendation
like the others; what makes it special (forced when fabric is selected, excluded
from the free pool) stays in the Measure Dependency layer. Detect + price now
live in generators/, not inline in measure_dependency.py. Note it is NOT run by
the candidate-pool runner — it is consumed only by the dependency path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:01:14 +00:00
Khalim Conn-Kowlessar
4ed691603f docs: correct S0380.200 handover — interlock was already done; S0380.201 closed pumps
The flagged "priority" (per-main boiler interlock −5pp) was already
implemented (S0380.141 cylinder-thermostat path + S0380.177 room-
thermostat path); case 6 already produces (206)=79/(207)=84 exactly and
0240 is a combi with no cylinder. Records that S0380.201 closed the
secondary dual-system pump item and the remaining case-6 gaps (space
demand +1.28%, HW −1.6%) for full-SapResult promotion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:52:13 +00:00
Khalim Conn-Kowlessar
963db2ae23 S0380.201: SAP 10.2 Table 4f note c) second main-system circulation pump
Simulated case 6 (P960-0001-001431, dual oil boiler 51% rads + 49%
underfloor) worksheet (231) = 356 = (230c) central-heating pump 156 +
(230d) oil boiler pump 200. (230c) decomposes per SAP 10.2 Table 4f
note c) (PDF p.175): "Where there are two main heating systems include
two figures from this table" — Main 1 41 kWh (pump age "2013 or later")
+ Main 2 115 kWh (pump age unknown). The cascade summed only Main 1's
circulation pump, giving (231) = 241.

cert_to_inputs now adds the second main's circulation pump, gated on a
lodged main_heating_fraction > 0 (a genuine second SPACE-heating main —
the same test §9a uses to split space-heating demand). This excludes
DHW-only second mains (cert 000565 Main 2 = gas combi via WHC 914,
fraction 0); without the gate 000565's worksheet pins regressed +115 kWh.

Re-pin: golden 0240 (dual-main oil combi, API-only, no worksheet) gains
its Main 2 pump too (pumps_fans 315 → 430). Spec-correct per
note c and validated by the case-6 worksheet; SAP cont 72.55 → 72.18
(integer 73 → 72, resid +0 → -1), PE +1.9459 → +2.8092, CO2 +0.1226 →
+0.1385. The lodged 73 carries Elmhurst's own residual; the worksheet-
backed case 6 is the spec authority for the archetype.

Note: the boiler-interlock −5pp per-main determination the prior
handover flagged as the priority is already implemented (S0380.141
cylinder-thermostat path + S0380.177 room-thermostat path) — case 6
already produces (206)=79 / (207)=84 exactly, and 0240 is a combi with
no cylinder so correctly unpenalised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:51:13 +00:00
Khalim Conn-Kowlessar
143f8b0805 docs(modelling): handover — reflect generators/scoring/optimisation layout
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:50:21 +00:00
Khalim Conn-Kowlessar
84ec6da032 refactor(modelling): group domain/modelling into generators/scoring/optimisation
domain/modelling/ had grown to 15 flat modules. Group the behavioural ones into
subpackages — generators/ (wall/roof/floor Recommendation Generators), scoring/
(overlay applicator, package scorer, role-1/3 scoring), optimisation/ (optimiser
+ measure dependency) — and leave the shared value-object vocabulary
(recommendation, plan, scenario, product, contingencies, simulation) flat at the
top, since it is imported everywhere. Pure move + import-path rewrite across 89
import sites; no behaviour change. 136 pass, pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:48:36 +00:00
Khalim Conn-Kowlessar
90387c4a36 docs(modelling): handover — #1161 (ventilation Measure Dependency) closed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:37:03 +00:00
Khalim Conn-Kowlessar
0fec069988 feat(modelling): wire the ventilation Measure Dependency into the orchestrator (#1161)
ModellingOrchestrator builds the ventilation dependency per Property
(suppressed when already mechanically ventilated) and passes it to
optimise_package, so a selected wall measure forces MEV into the package before
the re-score. Ventilation joins the role-3 cascade in best-practice order
(walls -> roof -> ventilation -> floor) and persists as a Plan Measure carrying
its real negative marginal and its cost. Added the mechanical_ventilation
contingency rate (0.26, per legacy Costs.CONTINGENCIES). Integration test now
seeds the ventilation Product and asserts the forced measure persists with
<=0 SAP and 2x900 cost; the full-pipeline test seeds the Product too (the
dependency is built for every not-yet-ventilated dwelling). On 000490 the real
calculator scores MEV at -1.275 SAP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:34:40 +00:00
Khalim Conn-Kowlessar
1bf5b4102d feat(modelling): ventilation Measure Dependency builder + has_ventilation guard (#1161)
ventilation_dependency(epc, products) returns the forced 'fabric requires
ventilation' edge: triggers = MEASURES_NEEDING_VENTILATION (cavity/internal/
external wall, mirroring legacy assumptions.measures_needing_ventilation), and a
required Option installing decentralised MEV (mechanical_ventilation_kind=
EXTRACT_OR_PIV_OUTSIDE), priced at two fully-loaded units. Returns None when the
dwelling already lodges a mechanical ventilation kind (legacy has_ventilation
guard), so MEV is never forced onto an already-ventilated dwelling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:27:56 +00:00
Khalim Conn-Kowlessar
6b11c90295 feat(modelling): inject forced Measure Dependencies into the package (#1161)
MeasureDependency(triggers, required) is a data-declared 'A requires B' edge.
optimise_package gains a dependencies param: after the warm-start it injects any
dependency whose triggers intersect the selected measure-types, BEFORE the
whole-package re-score, so the dependency's (negative) SAP lands in the truthful
figure and the undershoot/repair decision (ADR-0016). Forced — injected
regardless of budget — but its cost counts toward package spend, so repair sees
less headroom. Repair candidates fold in any dependency they newly trigger, so
their marginal SAP-per-£ and incremental cost are truthful. The dependency never
competes in the optimiser pool. Returned selected includes the injected deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:25:40 +00:00
Khalim Conn-Kowlessar
558aaf6d09 docs: handover post S0380.200 — 6035+0240 closed; boiler-interlock −5pp OPEN
Captures the session's window/RR/dual-main work (S0380.196–200) and the
open priority: a spec-accurate per-system boiler-interlock −5pp (Table
4c(2)) adjustment. Root cause for case 6's remaining deltas (sys-1 eff 79
not 84 + HW 4824 vs 4902) is the "room thermostat present but no cylinder
thermostat → no interlock" path that the current {2101,2102} no-interlock
rule misses. 0240 shares the controls + cylinder_thermostat=N so it will
re-pin (apply spec uniformly). Secondary: dual-system Table 4f pumps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:21:49 +00:00
Khalim Conn-Kowlessar
7c59e9198a feat(modelling): Simulation Overlay grows a dwelling ventilation segment (#1161)
VentilationOverlay (all-optional partial of SapVentilation) + EpcSimulation.
ventilation; apply_simulations folds it onto sap_ventilation, creating one when
the baseline lodged none. This is the surface a Measure Dependency (ventilation)
writes — whole-dwelling, no building part.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:20:45 +00:00
Khalim Conn-Kowlessar
42d9411954 docs(modelling): handover — #1157 + #1160 closed, #1161 next
Brings HANDOVER_MODELLING.md fully current: #1157 (Plan persistence) and
#1160 (Optimiser) closed this session; records the locked design
decisions (multi-phase deferred, Plan Measure term, reuse-live-tables
via SQLModel mirrors, pure-Python knapsack not mip), the gotchas (mip/CBC
broken on aarch64, moto missing, drive-Modelling-directly for fixtures
without lodged perf, seed materials per fired measure type), and the
remaining work (#1161 ventilation Measure Dependency + deferred fronts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:11:43 +00:00
Khalim Conn-Kowlessar
8ae978a646 S0380.200: SAP 10.2 §9a two-main-heating split (203)/(205)/(207)/(213)
The cascade lumped a dwelling with two main heating systems into one:
`space_heating_fuel_monthly_kwh` hard-coded (203)=0 (a documented
scope-A placeholder) and the calculator's per-month fuel read only
main_1, so the full §8 space-heat demand billed against system 1's
efficiency. Simulated case 6 (one oil boiler feeding radiators 51% +
underfloor 49%) exposed it: main fuel ≈ demand/eff1 instead of the
worksheet's (211)+(213) per-system split.

Implements the SAP 10.2 §9a two-main model:
  (204) = (202) × (1 − (203))   → system 1 share of total heat
  (205) = (202) × (203)         → system 2 share of total heat
  (211)m = (98c)m × (204) × 100 / (206)
  (213)m = (98c)m × (205) × 100 / (207)
(203) = the second system's lodged `main_heating_fraction`; (207) = its
own seasonal efficiency via the new per-detail `_main_heating_detail_
efficiency` (the core of `_main_heating_efficiency`, now reused for
system 2). Calculator `_solve_month` aggregates main_1 + main_2 into
`main_heating_fuel_kwh`. Cost (§10a 241), CO2 (§12 262) and PE (§13 276)
main_2 paths were already wired and now activate.

Site-notes gap also fixed: §14.1 Main Heating2 omits the "Fuel Type"
cell when the second system shares Main 1's fuel (case 6: one oil boiler,
two emitters). `_map_elmhurst_main_heating_2` now inherits Main 1's
resolved fuel as a fallback.

Blast radius: only dual-main certs. 0240 (2× oil code 130, identical
Eq-D1 efficiency) is unchanged — its split collapses to the lumped total.
Suite: 2355 passed, 1 skipped. New code: 0 pyright errors.

NOTE: case 6 is not yet fully pinnable end-to-end — its two systems have
DIFFERENT efficiencies (radiators 55°C → 79%, underfloor 35°C → 84%), a
flow-temperature boiler-efficiency adjustment not yet modelled, and its
dual-system auxiliary pumps ((230c)+(230d)=356) differ from the cascade.
Both are separate follow-on features; this slice is the §9a fuel split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:09:43 +00:00
Khalim Conn-Kowlessar
34d4748a3a feat(modelling): wire the Optimiser into the orchestrator (#1160)
Slice 3b — closes #1160. ModellingOrchestrator._plan_for now runs the
full ADR-0016 flow instead of a single cavity measure:

  generate wall + roof + floor Recommendations → score each Option
  independently (role 1) into grouped ScoredOptions → optimise_package
  (grouped knapsack within budget + whole-package re-score + greedy
  repair toward the Scenario's SAP target) → attribute the selected set
  via the best-practice marginal cascade (role 3) → persist the Plan
  with its Plan Measures.

The repair target comes from the goal: INCREASING_EPC → the goal_value
band floor via Epc.sap_lower_bound(); other goals carry no SAP target
yet (later slice). Best-practice order walls → roof → floor.

Integration test: an uninsulated cavity wall + suspended floor (000490)
driven directly through the Modelling stage off a repo-seeded EPC
(the calculator fixture has no lodged recorded-performance fields, so
Baseline can't run it) persists a Plan with two attributed, priced Plan
Measures. The existing first-run test keeps full-pipeline coverage and
now exercises real modelling (its sample EPC's uninsulated solid floor
yields a floor measure). Replaces the single-measure cavity integration
test (subsumed). 138 pass; pyright strict clean.

Multi-phase remains descoped (ADR-0005); single-phase optimiser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:07:14 +00:00
Khalim Conn-Kowlessar
504f592a27 feat(modelling): Epc.sap_lower_bound() — band → minimum SAP (#1160)
Slice 3a. The inverse of Epc.from_sap_score: the minimum SAP rating in a
band (C → 69, B → 81, …), used as the Optimiser's repair target for an
INCREASING_EPC goal (goal_value "C" → target SAP 69). Keeps the
band-target derivation in the domain rather than re-coupling to
backend.app.utils.epc_to_sap_lower_bound. 8 tests incl. round-trip
through from_sap_score; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:50:40 +00:00
Khalim Conn-Kowlessar
2b1f90a7de S0380.199: site-notes "Roof of Room" windows → roof windows (cross-mapper parity with S0380.198)
The Elmhurst extractor crashed parsing simulated-case-6's room-in-roof
window rows: the §11 "Location" cell "Roof of Room in Roof" wraps across
the layout prefix/suffix blocks and leaked into the glazing-type phrase
("Double between 2002 Roof of Room and 2021 in Roof" → UnmappedElmhurst-
Label). Fix (`_parse_window_from_anchors`): detect the roof-of-room
location tokens, strip them from the before/after blocks so the glazing
phrase reconstructs cleanly, and set location="Roof of Room".

Mapper: `_is_elmhurst_roof_window` gains a "Roof of Room" location branch
(highest-confidence rooflight signal, above the BP-roof-type / U>3.0
gates); `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING` gains "Double between 2002
and 2021" → 2.30 (case 6 lodges the already-inclined roof-window U, so
the +0.30 inclination adjustment must not double-apply).

This is the site-notes mirror of S0380.198 (API window_wall_type=4):
both paths now route room-in-roof rooflights to (27a) at the inclined U.
Validated against the case-6 P960 worksheet at abs=1e-4:
  (27)  Windows      = 22.7408 (cascade 22.7407)
  (27a) Roof Windows = 13.0375 (cascade 13.0375, EXACT)
  (31)  ext area     = 336.13

Case 6 is pinned only on the §3 window line refs (new standalone test,
not added to the section-pin `_FIXTURES`) because its DUAL main heating
(51% rads + 49% underfloor, oil) makes the §10/§12 per-system lines
non-comparable to SapResult's aggregated fields — documented in the
fixture module. Summary mirrored to Summary_001431_case6.pdf.

Suite: 2355 passed, 1 skipped. New code: 0 pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:46:18 +00:00
Khalim Conn-Kowlessar
49e86344d2 feat(modelling): whole-package re-score + greedy repair (#1160)
Slice 2 of #1160 — the ADR-0016 truth step on top of the warm-start
knapsack. optimise_package(groups, scorer, baseline_epc, budget,
target_sap) -> OptimisedPackage:

  warm-start optimise() (role-1 signal) → re-score the chosen package on
  the real scorer (role-2 truth) → while the true SAP undershoots
  target_sap and budget remains, greedy-add the untreated-group Option
  with the best *marginal* SAP-per-£ (re-scored, not the role-1 signal),
  re-score, repeat until the target is met, nothing positive-marginal is
  affordable, or the budget is spent.

`Scorer` is a structural Protocol (PackageScorer satisfies it) so the
repair loop is tested with a stub scorer — no calculator, runs on ARM.
The key case: role-1 under-counts roof so the warm-start skips it, the
re-score undershoots, and repair adds roof back to hit the target. 3
repair tests + the 6 core tests; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:45:05 +00:00
Jun-te Kim
c614ff6388 save local changes 2026-06-03 12:41:56 +00:00
Khalim Conn-Kowlessar
77983caed8 feat(modelling): Optimiser core — exact grouped knapsack (#1160)
Slice 1 of #1160. Recycles the GainOptimiser/CostOptimiser formulation
(≤1 Option per Recommendation, maximise SAP gain subject to budget) as a
clean typed DDD function — but as an exact pure-Python multiple-choice
knapsack rather than the legacy `mip` MILP, since mip's CBC backend does
not load on aarch64 (so the legacy solver path can't run / be tested
here). At retrofit scale the candidate space Π(|group|+1) is tiny, so
exhaustive enumeration is exact and instant; ADR-0016 only needs the
knapsack as a warm-start signal anyway (the truthful figure comes from
the whole-package re-score + repair, next slice).

`optimise(groups, budget) -> list[ScoredOption]`: maximise total gain,
tie-break toward lower cost, skip-per-group covers "select none". 6 tests
(budget-bound selection, ≤1/group, unconstrained, budget-too-small,
empty groups, partial-affordability); pyright strict clean.

Multi-phase remains descoped (ADR-0005) — single-phase optimiser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:39:47 +00:00
Khalim Conn-Kowlessar
999eced9fb S0380.198: API window_wall_type=4 → roof window (SAP 10.2 §3 (27a) + Table 6e Note 2)
Cert 0240's SAP residual (-1) and a chunk of its PE/CO2 was an API-mapper
bug: it flattened ALL windows into sap_windows, so the 6 windows lodged
with window_wall_type=4 — the RdSAP code for a roof window ("Roof of Room"
rooflight / inclined glazing) — were billed as vertical wall glazing on
worksheet (27) at U=2.0, instead of roof windows on (27a) at the Table 6e
Note 2 inclination-adjusted U (DG 2002+ vertical 2.0 + 0.30 = 2.30) with
45°-inclined solar gains.

window_wall_type=4 is the discriminator, NOT window_type=2 (certs 0390 /
7536 lodge window_type=2 on ordinary main-wall windows). Fix: partition
the 21.0.1 API window list into sap_windows (wall_type≠4) + sap_roof_
windows (wall_type=4); `_api_sap_roof_window` mirrors the site-notes
`_map_elmhurst_roof_window` (vertical U from the glazing Table-24 lookup +
0.30 inclination; 45° pitch; g/FF from the same lookup).

Validated against the simulated-case-6 worksheet, which bills these
identical windows on (27a) at U_eff 2.1062 (= 2.30 with the §3.2 R=0.04
curtain transform). The inclined solar gain dominates the higher U-loss,
RAISING the SAP:
- 0240: SAP cont 72.14 → 72.55 (resid -1 → +0 EXACT), PE +3.91 → +1.95,
  CO2 +0.22 → +0.12
- 6035: 2 wall_type=4 rooflights — SAP still +0 exact, PE +1.84 → +1.37,
  CO2 +0.01 → -0.0004

Blast radius is exactly these two certs (only golden fixtures with
wall_type=4). Suite: 2354 passed, 1 skipped. New code: 0 pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:33:30 +00:00
Khalim Conn-Kowlessar
c7e2aa3755 feat(modelling): ModellingOrchestrator persists a Plan end-to-end (#1157)
Slice 4b — closes the #1157 tracer. ModellingOrchestrator.run(property_ids,
scenario_ids, portfolio_id) now does real work in one Unit of Work,
committed once (ADR-0011/0012/0016/0017):

  read Property (effective EPC) + Scenario via repos → recommend_cavity_wall
  → select its Option → PackageScorer.score (role-2 package total) +
  marginal_impacts (role-3 attribution) → build Plan/PlanMeasure →
  uow.plan.save → commit.

- AraFirstRunPipeline / ModellingStage thread portfolio_id from the trigger
  body (one source of truth); handler builds the real orchestrator
  (unit_of_work + Sap10Calculator), dropping the Scenario/Materials stubs.
- ScenarioRepository.get_many promoted to @abstractmethod now the bare-stub
  instantiations are gone.
- New ara_first_run-style integration test: a property with an uninsulated
  cavity wall yields a persisted Plan + one cavity_wall_insulation Plan
  Measure (priced from the Product, figures present, linked by plan_id).
  Numeric SAP correctness is pinned separately in test_elmhurst_cascade_pins.
- Existing pipeline integration test updated: seeds scenario 7 and runs the
  real Modelling stage (its already-insulated sample wall yields an empty
  package — no crash).

121 pass across repositories/modelling/orchestration/app; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:08:32 +00:00