Commit graph

995 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
0ebd9cc7fd feat(modelling): domain Plan + PlanMeasure types (#1157)
Slice 2 of #1157. The per-Property output of one Scenario's modelling
run, per ADR-0017.

- PlanMeasure: a selected Measure Option frozen with its installed Cost
  and role-3 (final-package cascade) attributed MeasureImpact — the
  output counterpart of a Recommendation's candidate Option.
- Plan: the selected Plan Measures + baseline/post-retrofit Scores.
  Single-phase (ADR-0005); derives the persisted headline figures —
  cost_of_works, contingency_cost, co2_savings_kg_per_yr (kg; the mapper
  converts to tonnes), post_sap_continuous, and post_epc_rating (band
  from the rounded SAP via Epc.from_sap_score).

1 unit test, pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:40:27 +00:00
Khalim Conn-Kowlessar
62a968119c feat(modelling): domain Scenario + ScenarioPostgresRepository (#1157)
Slice 1 of the #1157 build. The FE creates a Scenario and passes only
its id to the pipeline; the Modelling stage reads it back here.

- domain/modelling/scenario.py: thin `Scenario(id, goal, goal_value,
  budget, is_default)` — the slice the stage uses today (goal/budget for
  the Optimiser later; is_default drives plan.is_default). No phases
  (ADR-0005); legacy file-path/aggregate columns not modelled.
- infrastructure/postgres/scenario_table.py: `ScenarioRow` SQLModel
  mirror of the live `scenario` table (ADR-0017), declaring only the
  read columns; goal mapped as its string value.
- ScenarioPostgresRepository.get_many(scenario_ids) -> list[Scenario]:
  bulk read, input-order-preserving, raises on a missing id.

The method shape lives on the concrete repo for now; it is promoted to
an @abstractmethod on the port when the real orchestrator is wired and
the bare-stub instantiations retire (keeps the stubbed Modelling wiring
composing meanwhile). 2 tests, pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:19:52 +00:00
Khalim Conn-Kowlessar
af477678c2 docs: handover post S0380.195 — 6035 OPEN, API-mapper roof/RR over-count lead
Retracts the premature "6035 = lodged divergence" claim (S0380.195 commit
msg + fixture docstring). The golden residual SAP -2 / PE +19.16 / CO2
+0.42t is REAL and exceeds the fallback bar. Section-level diff of 6035
(API) vs sim case 4 (site-notes, pins @1e-4) localised it to a
cross-mapper parity break: roof W/K 78.33 (site-notes) vs 130.73 (API),
a +52 over-count from the API RR scalar path + roof_construction=4. Next
agent starts there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:14:17 +00:00
Khalim Conn-Kowlessar
a0b6a952c3 feat(modelling): floor insulation-type overlay field + cascade pins (#1159)
Completes #1159 end-to-end with solid and suspended-floor before/after
cascade pins on cert 001431, both closing at delta 0.000000.

Adds floor_insulation_type_str to BuildingPartOverlay (the generic
field-fold applicator picks it up with no change) and has
recommend_floor_insulation set it to "Retro-fitted". Insulating an
as-built floor re-lodges its insulation as retro-fitted; the calculator
keys on this for a suspended timber floor's sealed/unsealed
determination (cert_to_inputs.py: "retro" + no U-value supplied →
sealed). Without it the suspended-floor cascade left a +1.40 SAP gap
(the floor stayed "unsealed", wrong U-value); with it the cascade
closes exactly. Solid floors are unaffected by the seal logic and stay
at delta 0; both Elmhurst after-certs lodge "Retro-fitted", so setting
it uniformly is faithful.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:41:54 +00:00
Khalim Conn-Kowlessar
44d62c0c9b feat(modelling): loft overlay 270→300 mm + Elmhurst cascade pin (#1158)
Completes #1158 end-to-end. recommend_loft_insulation now emits a
300 mm overlay (was 270 mm). The Elmhurst before/after re-lodgement of
the loft-insulation measure on cert 001431 lodges the after-cert at
300 mm roof insulation; pinning before→overlay→after requires the
overlay to match that depth — at 270 mm the cascade left a +0.173 SAP
residual, at 300 mm it closes at delta 0.000000 on SAP/CO2/PE.

Adds test_loft_overlay_reproduces_the_relodged_after and updates the
roof generator unit test's thickness assertion to 300.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:39:21 +00:00
Khalim Conn-Kowlessar
62fc27a5cc S0380.193: suspended-floor (12) sealed rule fires only on a SUPPLIED U-value
RdSAP 10 §5 (PDF p.29) "Floor infiltration (suspended timber ground
floor only)", age band A-E, splits on whether a floor U-value is
supplied:
  a) [U-value supplied] if floor U-value < 0.5 → "sealed", (12) = 0.1
  b) [no U-value supplied] retro-fitted insulation → "sealed" 0.1;
     otherwise "unsealed", (12) = 0.2

`_has_suspended_timber_floor_per_spec` fed the cascade's COMPUTED default
U into rule (a), so an as-built/uninsulated suspended-timber floor whose
default U happens to be < 0.5 was marked "sealed" (0.1) where Elmhurst
uses "unsealed" (0.2). That dropped (18) infiltration 0.85 → 0.75, (25)
effective ACH, HTC, and understated space heating ~450 kWh.

Fix: gate rule (a) on `floor_u_value_known` — a computed default U is not
a supplied value, so it falls through to (b). Verified against the
cert 001431 sim-case-2 worksheet: floor "As built", U=0.43 (matches the
worksheet's (28a) 0.4300 exactly), (12)=0.2 unsealed. Golden cert 6035
(also a suspended uninsulated floor) is unaffected — its U=0.63 ≥ 0.5
already routed to unsealed.

Promotes sim case 2 to the e2e harness as `001431_rr` (Main + Extension
+ Simplified room-in-roof — the 6035 archetype). All 11 Block-1 line
refs pin at abs=1e-4, locking BOTH this fix and S0380.192 (Simplified-RR
remaining area) end-to-end: SAP 69, cost 920.5046, CO2 4566.7090, space
15269.8593, main fuel 18178.4039. 2319 passed (+11), 0 failed; pyright
net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:16:25 +00:00
Khalim Conn-Kowlessar
4c10405071 feat(modelling): floor Recommendation Generator + ground-floor-area geometry
recommend_floor_insulation(epc, products) detects an uninsulated ground floor
(SapBuildingPart.floor_insulation_thickness blank/zero) and its construction
from floor_construction_type — 'Suspended timber' -> suspended_floor_insulation,
'Solid' -> solid_floor_insulation — emitting the matching single Option (a
floor is one construction, like a cavity wall) with the overlay
(floor_insulation_thickness = 100 mm) and a priced Cost (ground-floor area x
the Product's fully-loaded unit cost + contingency).

- building_geometry.ground_floor_area(epc, identifier): the lowest floor's
  (floor == 0) area. Pinned 14.85 m^2 on 000490 MAIN.
- BuildingPartOverlay gains floor_insulation_thickness (generic Applicator
  writes it unchanged). suspended (0.20) / solid (0.26) floor contingencies.

Progress on #1159 (generator + geometry); end-to-end + Elmhurst pin pending
the orchestrator (#1157) and parser. Four behaviour tests (suspended / solid
/ none / cost) + geometry pin. pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:12:29 +00:00
Khalim Conn-Kowlessar
3c87be8e1e feat(modelling): roof (loft) Recommendation Generator + roof-area geometry
recommend_loft_insulation(epc, products) detects an uninsulated main loft
(SapBuildingPart.roof_insulation_thickness == 0) and emits a
Recommendation("Roof") with one loft_insulation Option carrying the overlay
(roof_insulation_thickness = 270 mm, the recommended top-up) and a priced
Cost (roof area x the Product's fully-loaded unit cost + contingency).

- building_geometry.roof_area(epc, identifier): the part's greatest
  per-storey floor area (RdSAP 10 §3.8). Pinned 14.85 m^2 on 000490 MAIN.
- BuildingPartOverlay gains roof_insulation_thickness; the generic Overlay
  Applicator writes it with NO change (validated by the tracer) — the
  deep-module field-fold paying off.
- loft_insulation contingency (0.10) added.

Progress on #1158 (generator + geometry); end-to-end + Elmhurst pin pending
the orchestrator (#1157) and the parser fix. Four behaviour tests
(geometry pin; detect / none / cost). pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:05:38 +00:00
Khalim Conn-Kowlessar
d02b7348a6 Merge branch 'main' of https://github.com/Hestia-Homes/Model into feature/bill-derivation 2026-06-03 08:52:36 +00:00
Khalim Conn-Kowlessar
13dd5fe81a feat(modelling): per-measure scoring — marginal cascade + per-Option signal (#1156)
scoring.py adds the telescoping marginal cascade that serves two of the three
ADR-0016 scoring roles:
- marginal_impacts(scorer, baseline, overlays): applies overlays cumulatively
  in order and reports each measure's marginal MeasureImpact (sap_points +
  carbon/energy savings). Role 3 (final-package attribution) — the marginals
  telescope EXACTLY to the whole-package total.
- independent_option_impacts(scorer, baseline, options): role 1 — scores each
  Option's overlay independently vs baseline, scoring each DISTINCT overlay
  once (Options sharing an overlay reuse the result). Approximate signal for
  the optimiser; never surfaced as a measure's true impact.

Role 2 (whole-package re-score) is PackageScorer.score directly. Three
behaviour tests on the real Sap10Calculator / a counting stand-in (hand-built
EPD): single-overlay marginal == improvement-over-baseline; two-overlay
marginals telescope to the package total; per-Option dedup scores each
distinct overlay once. Closes #1156. pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:50:49 +00:00
Khalim Conn-Kowlessar
7a478cff6e feat(modelling): Package Scorer — compose overlays + score on the calculator
PackageScorer(calculator: SapCalculator).score(baseline, simulations) folds
the Simulation Overlays onto the baseline via the Overlay Applicator and
scores the throwaway EpcPropertyData on the injected deterministic SAP
calculator, returning Score(sap_continuous, co2_kg_per_yr,
primary_energy_kwh_per_yr). Depends on the SapCalculator abstraction, not a
concrete engine. This is the reusable scoring primitive (ADR-0016) — the
same call serves the optimiser's whole-package re-score and a future live
re-score of a user-assembled plan.

Two behaviour tests against the real Sap10Calculator on a hand-built EPD:
filling the main cavity improves SAP (right-directional through the real
physics); an empty package scores the unmodified baseline (pins the
SapResult->Score mapping). The Elmhurst before/after cascade PIN (#1154's
acceptance) lands once cert 001431 parses (external _extract_windows fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:41:30 +00:00
Khalim Conn-Kowlessar
bb2c0068ff feat(modelling): price the cavity Option from area x Product — closes #1155
recommend_cavity_wall now takes a ProductRepository and prices the Measure
Option: Cost(total = gross_heat_loss_wall_area(MAIN) x product.unit_cost_per_m2,
contingency_rate = product.contingency_rate). Detection is unchanged and runs
before pricing, so ineligible walls still return None without a catalogue hit.

Completes #1155 — the cavity-wall Recommendation Generator now detects an
uninsulated main cavity wall and emits a priced Option carrying the filled-
cavity overlay. Four behaviour tests (detection x3 + fully-loaded cost).
pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:35:52 +00:00
Khalim Conn-Kowlessar
b2c8980dd2 feat(modelling): ProductRepository + Postgres materials-table source
Product(measure_type, unit_cost_per_m2, contingency_rate). ProductRepository
is the DDD port abstracting the catalogue source; ProductPostgresRepository
reads the externally-owned material table (defensive SQLModel view
MaterialRow) and maps an active row to a Product — total_cost becomes the
fully-loaded unit_cost_per_m2 — joining the per-measure-type contingency
(contingencies.py, mirrors Costs.CONTINGENCIES; cavity 0.10). Strict-raise
on missing/inactive row. A JSON-backed impl will follow behind the same
port for ETL-gap costs.

Two DB tests against an ephemeral Postgres (map active row; raise on
inactive-only). Toward #1155 cost (4b). Also generalises the CONTEXT
Simulation Overlay wording: windows are targeted by index, building-part
association carried via window_location (_window_bp_index). pyright clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:32:38 +00:00
Khalim Conn-Kowlessar
0ba0575877 feat(modelling): shared gross heat-loss wall area geometry helper
domain/building_geometry.gross_heat_loss_wall_area(epc, identifier) sums
heat_loss_perimeter x room_height across a building part's storeys — the
heat-loss wall area (party walls excluded by construction), not total
wall area. Lives outside the calculator so Modelling cost quantities can
reuse it; the calculator computes the same quantity inline today and
should be DRY'd onto this later (coordinated with the calculator branch).

Pinned at 45.93 m^2 against the 000490 MAIN part. Toward #1155 cost
(behaviour 4). pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:53:12 +00:00
Khalim Conn-Kowlessar
214b38ff78 feat(modelling): wall Recommendation Generator — cavity-fill detection + overlay
recommend_cavity_wall(epc) detects an uninsulated main cavity wall
(wall_construction=4, wall_insulation_type=4) and emits a Recommendation
whose single Measure Option carries the Simulation Overlay setting MAIN
wall_insulation_type=2 (Table 6 'Filled cavity'; cf. domain/sap10_ml/
rdsap_uvalues.py u_wall). Returns None for already-insulated or
non-cavity main walls.

Recommendation/MeasureOption reshaped per design review: the target is
encoded in the Option's overlay (addresses a building part / window /
system), not a typed key on Recommendation — generalises to glazing and
heating without changing the type. CONTEXT partition wording generalised
to match.

Three behaviour tests (hand-built EPD, no PDF). Cost (behaviour 4 of
#1155) outstanding — needs net heat-loss wall area + ProductRepository.
WIP on #1155. pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:49:33 +00:00
Khalim Conn-Kowlessar
350f4c8e76 feat(modelling): Overlay Applicator folds EpcSimulation onto EpcPropertyData
EpcSimulation is the Simulation Overlay — a narrow all-optional partial
mirror of EpcPropertyData/SapBuildingPart (wall surface first), targeting
building parts by BuildingPartIdentifier (composition, not inheritance).
apply_simulations(baseline, simulations) deep-copies the baseline, folds
overlays in order (later wins on a shared field) via a generic non-None
field write, and returns a throwaway EpcPropertyData for the calculator;
the baseline is never mutated.

Four behaviour tests (hand-built EPD from the 000490 fixture, no PDF):
targeted-write-leaves-others-untouched, empty-overlay no-op, sequential
last-wins, baseline-immutability. pyright strict clean.

Slice 1 of the Modelling stage rebuild (ADR-0016). Closes #1153.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:13:51 +00:00
Khalim Conn-Kowlessar
e63d046b9d docs: handover post S0380.189 — TMP/Table 22 + the two open follow-ups
Point-in-time note for the next agent: what S0380.185-189 shipped (worksheet
PE/CO2 pins, the two D_PV electricity-vs-gain fixes, and the thermal-mass-
parameter Table 22 fix), the per-line diagnosis template, the two worksheet-
block / gains-vs-solar traps, and the ranked open slices (Summary-path fuel
derivation first, then pin the simulated 001431 case, then cert 6035).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:05:41 +00:00
Khalim Conn-Kowlessar
e03f08cdc8 S0380.189: thermal mass parameter per RdSAP 10 §5.16 Table 22, not hardcoded 250
The §7 mean-internal-temperature cascade hardcoded the thermal mass parameter
(TMP) to 250 kJ/m²K at all 5 call sites, ignoring construction. RdSAP 10
§5.16 Table 22 (PDF p.48) makes TMP construction-dependent:

  100 kJ/m²K — timber frame, cob, park home (regardless of internal
               insulation); OR masonry (stone/solid brick/cavity/system
               built) WITH internal insulation.
  250 kJ/m²K — masonry WITHOUT internal insulation.

A too-high TMP inflates the §7 time constant τ = Cm/(3.6·H) (e.g. 40 h vs
16 h), under-cuts the temperature reduction between heating periods, and
over-states mean internal temperature → over-states space heating.

`_thermal_mass_parameter_kj_per_m2_k(epc)` classifies the MAIN building's
wall via the RdSAP `wall_construction` codes (5/7/8 = timber/cob/park) and
`wall_insulation_type` codes (3/7 = internal); unknown/curtain fall back to
the masonry 250 (no regression on unlisted classes). 17-case parametrised
test covers every Table 22 branch.

Diagnosis (per-line walk vs the user-simulated 001431 worksheet, same
archetype as golden cert 6035): fabric (26-37), internal gains (73), climate
(96)m and HTC (39) all EXACT; the entire +8.78 PE / -1.76 SAP gap was §7 MIT
(92) +0.71 °C, traced to TMP 250 vs Table 22's 100 (solid brick WITH internal
insulation). Fix closes the simulated case to 1e-4 on PE and CO2.

Blast radius: only golden cert 6035 re-pins (solid brick + internal
insulation) — SAP resid -6 → -2, PE +46.42 → +19.16, CO2 +1.07 → +0.42. The
47 dr87 cohort, 6 U985 fixtures and 41-variant heating corpus are all
masonry-no-internal → TMP unchanged at 250, all still pass. 2290 pass
(+17 new), 0 fail; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:01:35 +00:00
Khalim Conn-Kowlessar
1382c8c886 docs: add AGENT_GUIDE.md — fresh-start onboarding for the SAP calculator
A single durable doc so agents can pick up the calculator without reading
historical handovers: (1) the accuracy bar for the two input paths
(site-notes 1e-4 vs worksheet; API 1e-4 when a worksheet exists, ±0.5
register fallback otherwise; cross-mapper parity); (2) the per-line-walk
debugging loop incl. comparing site-notes vs API; (3) the tools &
pipeline (Summary PDF → extractor → from_elmhurst_site_notes →
cert_to_inputs → calculate_sap_from_inputs → SapResult, plus the API
from_api_response front-end, section helpers, and where the test vectors
live). Pointer added from SAP_CALCULATOR.md; HANDOVER_* flagged as
point-in-time notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:32:29 +00:00
Khalim Conn-Kowlessar
72743eb8a4 S0380.188: D_PV,m uses lighting ELECTRICITY (L10) not the L12 gain — closes PV cohort to 1e-4
SAP 10.2 Appendix M1 §3a (p.93) defines PV-eligible demand as
  D_PV,m = E_L,m + E_A,m + E_cook,m + E_ES,m + (231)·n_m/365 + E_space,m + E_water,m
where E_L,m is the lighting ELECTRICITY (Appendix L eq L10, = line (232)).
The cascade fed `internal_gains_result.lighting_monthly_w` — the L12 internal
heat GAIN G_L,m = E_L,m × 0.85 ("assuming 15%" of lighting energy does not
become internal heat) — into D_PV, understating it by 15% of lighting on
every PV cert. That depressed the monthly β onsite/export split and
under-credited PV primary energy uniformly across the year.

Same gain-vs-electricity class as the cooking fix S0380.73 (L18 gain vs L20
electricity). Fix: scale the (shape-identical) lighting gain profile to the
annual E_L `lighting_kwh_per_yr` (= (232)), mirroring the (219)m hot-water
scale-to-annual. Magnitude-only, so the shape-weighted lighting CO2/PE
effective factor (Σkwh×f/Σkwh, magnitude-invariant) is unchanged; appliances
need no scaling (G_A = E_A, no 0.85). Diagnosis was empirical first (calc
lighting D_PV 95.1 vs worksheet (232) 111.88, ratio exactly 0.85) then
confirmed against the spec text (L9d/L10/L12, M1 §3a).

Impact (calc − full-precision dr87 worksheet): ALL 47 worksheet certs now
match at <1e-4 on BOTH PE (max |Δ| 0.0000 kWh/m²) and CO2 (max |Δ| 0.0000 kg)
— the convergence target, met cohort-wide. Combined with S0380.187 this
closes the entire gas+PV + ASHP PV residual. Re-pinned 47 worksheet residuals
to 0.0000 and 31 drifted lodged residuals (PV certs). SAP integers unchanged;
chain SAP 1e-4 intact (164 pass). 2273 pass, 0 regressions; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:05:12 +00:00
Khalim Conn-Kowlessar
a5d886187c S0380.187: include electric secondary heating in Appendix M1 D_PV,m — closes gas+PV PE/CO2 gap
The PV onsite/export β-split (SAP 10.2 Appendix M1 §3a, p.93) divides PV
generation by the monthly PV-eligible electricity demand D_PV,m. The cascade
included main and water electricity (when those fuels are electric) but had
no term for SECONDARY space heating. For the 10 cohort-2 gas-main +
electric-secondary + PV certs, the (215)m secondary electric fuel was dropped
from D_PV,m — understating demand in the heating months only, depressing the
monthly β, and under-crediting onsite PV primary energy.

Spec: Appendix M1 §3a counts E_space,m as the dwelling's TOTAL electric
space-heating demand; for a gas-main/electric-secondary dwelling that is the
secondary fuel. Diagnosis was decisive: E_PV (generation) matched the
worksheet exactly every month, the onsite (233a) split diverged ONLY in
heating months (Jun-Sep near-exact), and all 10 affected certs have PV while
all clean gas certs have none. Empirically adding (215)m to D_PV closed cert
3136 onsite 726.9 → 790.3 (worksheet 792.1).

Impact (calc − full-precision dr87 worksheet), the 10 certs:
  PE +0.5..+1.5 → +0.02..+0.046 kWh/m²; CO2 −0.5..−1.1 → +0.002..+0.0095 kg.
The whole 47-cert cohort now matches at PE <0.05 / CO2 <0.025. SAP integers
unchanged; chain SAP 1e-4 pins intact (164 pass). The uniform ~0.03 PE remnant
on PV certs is the separate (233a)/(233b) summer-month D_PV discrepancy.

Re-pinned the 10 worksheet + 9 lodged golden residuals (improvements).
2273 pass, 0 regressions; pyright net-zero (file's 32 errors pre-existing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:28:50 +00:00
Khalim Conn-Kowlessar
f179950519 feat(baseline): wire BillDerivation into the orchestrator and persist the Bill (ADR-0014)
The PropertyBaselineOrchestrator now reads the current Fuel Rates snapshot
once per batch, builds a BillDerivation, and prices each scored property's
SapResult -> EnergyBreakdown into a Bill carried on PropertyBaselinePerformance
(None only on the stub no-calculator path). The Bill is flattened onto nullable
bill_* flat columns (per-section kwh+cost, standing charges, SEG credit, total)
on the postgres table, with bill_total_annual_bill_gbp as the not-null
discriminator on read-back. Section absent from the bill stays None, not 0.

Updated all four orchestrator construction sites to inject the FuelRatesRepository
port (handler + three test sites), and the FE migration doc to reflect the
prefixed columns and that they are now populated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:51:18 +00:00
Khalim Conn-Kowlessar
82f7315f8d S0380.184: community electric-HP network CO2/PE uses monthly Table 12d/12e — closes CH3
SAP 10.2 worksheet block 12b/13b (367)/(467) for a community heating
electric heat pump (Table 4a code 304 → Table 12 fuel 41 "heat from
electric heat pump"). The HP meters grid electricity, so per Table 12
note (s)/(t) + block 12b/13b footnote (a) its emission/PE factor is the
MONTHLY Table 12d/12e cascade (fuel 41 = standard-electricity profile),
weighted by the network heat profile, then × 1/heat-source-eff (1/COP):

  (367)/(467) = [(307)+(310)] / COP × Σ((307+310)_m × factor_m)/Σ(...)

Per-line walk of CH3 (the displayed (367) 0.1535 / (467) 1.5717 are PDF
artifacts; the (373)/(473) totals reconcile only with):
  CO2 factor = 0.15040 (monthly Table 12d wtd) vs cascade annual 0.136
  PE  factor = 1.55692 (monthly Table 12e wtd) vs cascade annual 1.501

Pre-slice the cascade routed code 304 through the non-electric branch
(`_co2_factor_kg_per_kwh(main) × 1/COP` = annual × scaling). New
`_is_heat_network_electric_main` (heat-network main whose fuel has a
Table 12d monthly set — i.e. fuel 41) routes all four factor helpers
(main + HW, CO2 + PE) through the monthly cascade × 1/COP. Non-electric
heat networks (gas 51 / oil 53 / coal 54) have no monthly set → annual
path unchanged (CH1, CH6 untouched).

Closure (CH3 was already SAP+cost EXACT):
  CH3 (HP/Elec)  CO2 −75.32→+0.0000 (= [(307+310)/3]×(0.1504−0.136)),
                 PE −249.32→−0.0000 (× (1.5569−1.501))  — FULLY EXACT

Corpus now 40/41 EXACT on all four metrics. Only CH6 remains: its
worksheet lodges a manual DLF=1.0 ("two adjoining dwellings") absent
from the Summary PDF (byte-identical to CH4 bar fuel type) — an
architectural limit, not a cascade gap. 2226 pass + 1 skip + 0 fail
(tolerances 1e-4 all metrics); pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:43:16 +00:00
Khalim Conn-Kowlessar
f7dc9dbccb feat(baseline): Rebaseliner returns RebaselineResult carrying the SapResult
The Rebaseliner is the assemble-and-score step (ADR-0013 amendment); its
SapResult is the scored picture that Bill Derivation also prices (ADR-0014),
so rebaseline() now returns a RebaselineResult{effective, reason, sap_result}
instead of (Performance, reason). CalculatorRebaseliner sets sap_result on
both branches (the bill prices it whether lodged or calculated figures win);
StubRebaseliner returns sap_result=None (runs no calculator). Orchestrator
unpacks the result; the bill wiring lands in the next slice.

Also refreshes the stale ML-era docstrings in rebaseliner.py to the
assemble-and-score model (the calculator, not ML, is the rebaseliner
mechanism per ADR-0013).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:37:13 +00:00
Khalim Conn-Kowlessar
803da062a2 S0380.183: community-heating HW bills at heat-network rate (§10b) — closes CH2/CH4 fully
SAP 10.2 §10b: hot water for a community-heating dwelling bills at the
heat-network rate, not the cert-lodged fuel. Elmhurst §15.0 lodges
`water_heating_fuel_type = "Mains gas"` (3.48 p/kWh) as a placeholder on
community certs; the worksheet (342) Water-heating cost = (310) × the
S0380.171 CHP heat-fraction blend — the SAME rate as space heating (340).

Per-line walk of the CH2 block 10b:
  (340) space   = 11837.83 × 0.037955 = 449.3047  (cascade EXACT)
  (342) water   =  3854.12 × 0.037955 = 146.2830  (cascade billed
                  3854.12 × 0.0348 = 134.12 → −£12.16, the whole residual)
  (350) lighting + (351) standing → (355) 754.1502.

`_hot_water_fuel_cost_gbp_per_kwh`'s `inherit_main_for_community_heating`
path already routes HW cost through `_fuel_cost_gbp_per_kwh(main)` (the
CHP blend), but its gate `_is_community_heating_hw_from_main` excluded
code 302. S0380.182 wired the 302 CO2/PE credit via
`_heat_network_code_302_effective_factor`, which intercepts the HW
CO2/PE helpers ABOVE this predicate's branch — so extending the
predicate to include 302 now affects ONLY the cost path.

Closures:
  CH2 (CHP/Gas)  SAP +0.5277→−0.0000, cost −£12.16→−£0.00  — FULLY EXACT
  CH4 (CHP/Oil)  SAP +0.5277→−0.0000, cost −£12.16→−£0.00  — FULLY EXACT
  CH6 (CHP/Coal) SAP −7.49→−8.02, cost +£172.68→+£184.84 — its HW now
                 also bills the blend, compounding the DLF=1.0 quirk
                 (cascade DLF=1.45); same separate CH6 DLF front.

Corpus now 39 variants EXACT on all four metrics (CH2/CH4 join). Open:
CH3 CO2/PE (code-304 community-HP COP), CH6 all-metric (DLF=1.0 manual
override the Summary doesn't carry). 2225 pass + 1 skip + 0 fail
(tolerances 1e-4 all metrics); pyright net-zero 32→32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:29:57 +00:00
Khalim Conn-Kowlessar
5e75fb474c feat(baseline): EnergyBreakdown.from_sap_result + COOLING section
The SapResult -> EnergyBreakdown adapter (ADR-0014), a classmethod on the
target mirroring Performance.from_sap_result. Folds each positive per-end-use
delivered kWh into a billable EnergyLine: main/main-2/secondary heating and
hot water at their resolved fuel (sap_code_to_fuel); lighting/pumps-fans/
appliances/cooking/cooling as electricity. PV export carries to exported_kwh
for the SEG credit. Zero-kWh end uses emit no line; a positive kWh with no
fuel code raises rather than billing at a default (strict, mirrors the
calculator).

Adds BillSection.COOLING (electricity, from space_cooling_fuel_kwh_per_yr).
BillDerivation already prices any section it is given, so no change there.

Also corrects the ADR-0014 amendment: SapResult carries the calculator's own
fuel codes (raw API or Table-32 per mapper, ADR-0015); sap_fuel normalizes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:28:22 +00:00
Khalim Conn-Kowlessar
2cb4dd5833 feat(baseline): sap_code_to_fuel normalizes via the calculator's own helper
The fuel codes the calculator now puts on SapResult are its own codes — raw
gov-API enums or already-Table-32, depending on the source mapper (ADR-0015).
sap_code_to_fuel now runs the code through table_32.to_table_32_code
(promoted from private _to_table_32_code) — T32-first, then API-translate,
the SAME normalization the calculator's pricing/CO2 helpers use — before the
Table-32 -> Fuel dispatch, so the bill's carrier matches what the calculator
billed (incl. the API/T32 collision codes, e.g. 20 = wood-logs not heat-net).

Falls back to the raw code for billing fuels the price table omits (the 41-58
heat-network range), which resolve to HEAT_NETWORK -> UnpricedFuel — stricter
than, and intentionally divergent from, the calculator's lossy
default-to-mains-gas for an unpriced code (ADR-0014 §5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:24:39 +00:00