Commit graph

613 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
2413bc87da feat(modelling): solid-fuel(coal)->gas boiler upgrade + boiler_flue_type end-state
Pin the coal-boiler-with-cylinder upgrade and add the `boiler_flue_type`
end-state field. A solid-fuel (coal) boiler (fuel 11, SAP code 153) on a
mains-gas street converts to a gas condensing boiler (fuel 11->26, code 102) —
the non-gas->gas path for a solid-fuel system, eligible because code 153 is in
the wet-boiler solid-fuel range 151-161 and mains gas is present.

New `boiler_flue_type` HeatingOverlay field, routed to main_heating_details[0]
and set to 2 (room-sealed/balanced) on both boiler shapes: every relodged after
lodges flue type 2, but coal's before lodged none. The field is SAP-inert (the
cascade score is unchanged by it), so it is written purely for end-state
fidelity — the overlay now represents the installed condensing boiler's flue.
Validated via the overlay-equality unit tests.

The coal after predates the user-locked "always add a cylinder thermostat when
absent" rule, so it stale-lodged thermostat 'N'; the pin corrects it to the
rule's end-state 'Y' in-test (the gas with-cylinder after got the same
correction by re-lodging). The cylinder is already 80 mm insulated, so the
jacket is skipped and only the thermostat is added; controls (2106) are
unchanged. Cascade-pinned delta 0 (SAP/CO2/PE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:27:07 +00:00
Khalim Conn-Kowlessar
63dd69ff8b feat(modelling): gas combi boiler upgrade + controls-when-inadequate
Extend the gas-boiler-upgrade Option to combi (no-cylinder) dwellings and add
the controls upgrade shared by both boiler shapes. A dwelling has a cylinder or
it does not, so the one `gas_boiler_upgrade` Option is shaped per dwelling:

- no cylinder -> a gas condensing combi (Table 4b code 104), no cylinder fields
  touched;
- a cylinder  -> a regular boiler (code 102) heating it, with the conditional
  cylinder jacket/thermostat (slice 1).

Controls: bring an inadequate boiler control up to full programmer + room
thermostat + TRVs (SAP 10.2 Table 4e Group 1 code 2106). "Inadequate" = the
Group-1 codes with NO room thermostat (2101, 2102, 2107, 2108, 2109, 2111) —
these lack boiler interlock (Table 4c(2) / footnote c) p.171), so adding a room
thermostat genuinely improves SAP. Room-thermostatted (2103/2104/2105/2106/2113)
or better zone controls (2110/2112) are left unchanged — never downgraded, so
no phantom uplift. The with-cylinder cert (control 2106) is therefore untouched
and its pin still holds at delta 0.

Validated by the combi before/after re-lodgement (cert 001431, gas boiler
upgrade - no cylinder): control 2111 "TRVs and bypass" -> 2106, fan flue
False->True, SAP code 112 -> 104. Cascade-pinned delta 0 (SAP/CO2/PE). Removed
the slice-1 placeholder test asserting no boiler Option fires without a cylinder
(the combi Option now correctly fires there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:28:00 +00:00
Khalim Conn-Kowlessar
31c74ab500 feat(modelling): gas-boiler-upgrade-with-cylinder option in the heating rec
Add the first boiler-upgrade option to the single "Heating & Hot Water"
Recommendation (ADR-0024 expansion): a dwelling whose existing wet boiler heats
a hot-water cylinder is offered a new gas condensing boiler, with the cylinder
jacketed when under-insulated and given a thermostat when absent. One competing
Option (the Optimiser picks <=1), folded into one composite Plan line.

The end-state is read from the Elmhurst before/after re-lodgements (cert 001431,
gas boiler upgrade - with cylinder), which REVISE ADR-0024:

- Target is always a gas condensing boiler, not fuel-preserving: every after
  lodges fuel 26. Gas->gas always; a non-gas wet boiler ->gas only with a
  mains-gas connection; electric boilers are left alone (electrification is the
  upgrade path). Eligibility = wet-boiler SAP code (Table 4a/4b 101-141 /
  151-161 / 191-196) + not an electric boiler + mains gas present.
- End-state is a Table 4b SAP code, not a PCDB index: code 102 (regular boiler
  + cylinder). The calculator derives the condensing seasonal efficiency from
  the code, so no efficiency input exists or is needed.
- A modern condensing boiler has a fanned flue: the after flips
  `fan_flue_present` False->True on every cert (SAP 10.2 Table 4f flue-fan +
  the Table 4b condensing-efficiency basis). Added as a new HeatingOverlay
  field, routed to main_heating_details[0].
- Cylinder thermostat is always added when absent (user-locked); the jacket is
  the 80 mm `cylinder_insulation_type=2` end-state, applied only when the
  cylinder is below 80 mm (never downgrading a better one). Both are conditional
  per-dwelling components, not a frozen overlay.

Cascade-pinned delta-0 (SAP/CO2/PE) against the relodged after via
`_assert_overlay_reproduces_after`. NB the absolute SAP on this dwelling is
subject to a separate Summary-path mapper roof-fidelity gap (we read the roof
better-insulated than Elmhurst, scoring ~75 vs the printed 56); the gap is
identical on before+after (the boiler measure never touches the roof) so it
cancels and the pin still proves the exact heating field-delta. Tracked on the
calculator branch.

Wires the new `gas_boiler_upgrade` MeasureType through contingencies (0.26),
the offline sample catalogue, the catalogue-coverage list, and the ARA
first-run integration seed (the option fires on any mains-gas boiler+cylinder
dwelling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:16:46 +00:00
Khalim Conn-Kowlessar
7942a8101a feat(modelling): considered_measures allowlist on the orchestrator
Add domain/modelling/considered_measures.py::restrict_to_considered_measures —
the pure allowlist that limits a run to a chosen set of MeasureType (mirroring
the legacy engine's `inclusions`). It filters at the Option level, so a
multi-option Recommendation (e.g. Heating & Hot Water competing HHRSH against
an ASHP bundle) is kept with only its allowed Options; a Recommendation left
with none is dropped. None = consider everything (unrestricted default).

Thread `considered_measures: frozenset[MeasureType] | None` through
ModellingOrchestrator.run -> _plan_for -> _scored_candidate_groups /
_candidate_recommendations (applies the filter) and _measure_dependencies
(suppresses a forced dependency whose required measure is outside the
allowlist, so a restricted run forces nothing it is not considering). The
local-run seam (harness.console.run_modelling) gains the same param.

The Optimiser still freely chooses among survivors — including none. Tests:
the pure filter (3 cases) + an orchestrator-seam test proving a
{solar_pv}-restricted run yields only solar_pv options. 257 pass + 3 xfail;
pyright clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:32:11 +00:00
Khalim Conn-Kowlessar
9ef97be958 refactor(modelling): type measure_type fields as MeasureType
Tighten the recommendation/plan vocabulary off generic str:
MeasureOption.measure_type and PlanMeasure.measure_type are now MeasureType
(also _GlazingTarget.measure_type, MeasureDependency.triggers ->
frozenset[MeasureType], and the optimiser's chosen/required-type locals).
Because MeasureType is a StrEnum the change is transparent to persistence
(the `recommendation` varchar column), the optimiser group-by key, and every
`== "solar_pv"`-style comparison — so pyright now enforces the enum at every
construction site with no runtime behaviour change.

The catalogue boundary stays str: ProductRepository.get(measure_type: str)
and Product.measure_type are unchanged (they map arbitrary DB/JSON rows), so
the fake product repos in tests need no edit. Test construction helpers coerce
their str arg via MeasureType(...); direct constructions use members.

Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3
xfail; pyright clean on production + tests (pre-existing moto + property-
override-rowcount baselines untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:13:31 +00:00
Khalim Conn-Kowlessar
d58ac60d29 feat(modelling): MeasureType StrEnum as the canonical measure vocabulary
Introduce domain/modelling/measure_type.py — a StrEnum with one member per
modelled measure (the 15 the generators emit). A StrEnum so each member *is*
its string value: it persists straight into the `recommendation` varchar
column, is the optimiser's group-by key, and compares equal to the catalogue /
EPC strings — so it replaces the per-generator string constants with no
persistence or optimiser change.

Repoint every generator's measure-type constant/literal to a MeasureType
member (wall, solid_wall, roof, floor, glazing, lighting, ventilation,
heating, solar). Field annotations stay `str` for now; tightening them to
MeasureType is the next slice.

This is the enum the historical engine deferred (engine.py:970
"TODO - formalise property measure types into an enum") and the vocabulary the
forthcoming `considered_measures` allowlist will speak (mirroring the legacy
`inclusions`).

Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3
xfail; pyright clean on the enum + generators.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:54:04 +00:00
Khalim Conn-Kowlessar
24492aa4ba Merge origin/main into feature/bill-derivation (calculator + mapper fixes)
Pulls in 42 commits of calculator/mapper accuracy fixes from the per-cert
mapper-validation and floor/roof/heating fronts.

Conflict resolutions:
- mapper `_is_elmhurst_roof_window`: main dropped the branch's "wall location →
  vertical" guard (it broke cert 000516's rooflight), but that re-broke cert
  001431's two External-wall U>3.0 windows (which must stay vertical). The two
  certs lodge a BYTE-IDENTICAL §11 row, so neither location nor U separates
  them — the real discriminator is the room-in-roof context. Replaced the
  unconditional U>3.0 backstop with one gated on the BP having a room-in-roof
  (`_elmhurst_bp_has_room_in_roof`): 000516's Main BP has a "Room in roof type
  1" (→ rooflight), 001431's does not (→ vertical). Validated against BOTH —
  full Elmhurst worksheet suite 1038 pass + the 001431 window-extraction pin.
- property_postgres_repository: kept main's `ids_by_uprn` method + the branch's
  `_restrictions_of` helper.
- sap_fuel.py: the branch relocated it to domain/billing/ (already carrying
  main's to_table_32_code normalization), so kept the old path deleted.

Fallout from main's fabric fixes (validated by the boiler-3 real-cert pin which
still reproduces at delta 0):
- re-pinned the boiler-1 + boiler-instant-hw ASHP snapshot scores;
- main's §14.2 gas-boiler main-fuel derivation resolved the BGB/102 baseline
  gap, so `test_gas_boiler_instant_hw_before_baselines` is now a passing test
  (was an xfail tripwire).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:12:21 +00:00
Khalim Conn-Kowlessar
09cb8ceb9d feat(modelling): recommend_solar — eligibility + competing array Options
Slice 6 of the Solar PV Recommendation Generator (ADR-0026). `recommend_solar`
emits one "Solar PV" Recommendation of up to five conservatively-sized configs
× {no battery, battery} = ≤10 competing Options (a free Optimiser candidate).
Each Option folds a SolarOverlay built from the chosen config: one
PhotovoltaicArray per non-north segment (peak_power = panels × panelCapacityW /
1000; orientation/pitch from geometry; generation-calibrated overshading),
is_dwelling_export_capable set True absolutely, a diverter when the dwelling
has a cylinder (None for a combi), a 5 kWh battery for the battery variant, and
the per-config composite cost from Products.solar_bundle_cost.

Eligibility = house/bungalow ∧ not listed/heritage (blocks_internal, the same
gate as ASHP — a conservation area does NOT block PV) ∧ no existing PV ∧ a
feasible SolarPotential. Flats and existing-PV top-up are deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:14:24 +00:00
Khalim Conn-Kowlessar
46bca47365 feat(modelling): Products.solar_bundle_cost + committed solar rate sheet
Slice 7 of the Solar PV Recommendation Generator (ADR-0026). Adds the
composite per-dwelling Solar PV cost on the Products collection (ADR-0025
pattern): pv_system(kWp band, nearest of the ECOPV06-13 EA bands 1.0→4.5 kWp,
floor/cap at the ends) + scaffolding(£900 first elevation + £450 each
additional, default 2) + enabling base (EICR £150 + DNO £50 + 2-way consumer
unit £330) + [diverter £980 if cylinder] + [battery if the with-battery
variant] → Cost(total, contingency_rate 0.15).

Rates are data in the committed solar_rates.json (Southern Housing "SOLAR PV &
BATTERY" EA column), loaded via SolarRates.from_json/.default and injectable.
The £2,000 / 5 kWh battery is NOT on the rate sheet — a flagged estimate
(battery_estimate=true), confirmed with the user to stand in until a DB rate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:10:27 +00:00
Khalim Conn-Kowlessar
9dddfa00c8 feat(modelling): SolarOverlay + _fold_solar (sixth overlay surface)
Slice 5 of the Solar PV Recommendation Generator (ADR-0026). Adds the flat
`SolarOverlay` and `_fold_solar`, the sixth Simulation Overlay surface: like
the ventilation/lighting overlays it targets no building part and folds its
fields onto `sap_energy_source` (home of the SAP Appendix M PV inputs) —
photovoltaic_arrays (absolute target, one PhotovoltaicArray per non-north
segment, replacing the dwelling's existing arrays), pv_diverter_present,
pv_connection, is_dwelling_export_capable (set True absolutely), pv_batteries.
Omitted fields leave the baseline unchanged (combi → no diverter); the
baseline is never mutated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:10:15 +00:00
Khalim Conn-Kowlessar
c03f4ff123 feat(modelling): conservative PV config selection (5-config spread)
Slice 4 of the Solar PV Recommendation Generator (ADR-0026).
`select_conservative_configs` turns Google's full solarPanelConfigs ladder
into up to five competing array configs for the Optimiser: drop north-facing
planes (within 30° of due north, wrap-aware), cap usable panels at ~70% of
maxArrayPanelsCount (imagery misses obstructions; MCS edge setback), collapse
rungs that trim to the same usable size keeping the higher-generation layout,
then sample five spanning min→max by expected generation. Returns () when
nothing usable remains.

Real London example → 5 rungs at 4/12/19/26/34 panels (all ≤34.3 = 70% of
49); synthetic cases pin the north-drop and the 70% cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:02:15 +00:00
Khalim Conn-Kowlessar
82c3422788 feat(modelling): generation-calibrated PV overshading derivation
Slice 3 of the Solar PV Recommendation Generator (ADR-0026). Per roof segment,
back-solve the effective overshading factor ZPV from Google's expected
generation against SAP's own unshaded annual output:

    ZPV = (yearlyEnergyDcKwh × 0.955) / (0.8 × kWp × S)

reusing the calculator's Appendix U3.3 annual solar radiation S via a new
public seam `pv_annual_solar_radiation_kwh_per_m2`. Dividing Google's
generation by SAP's S cancels orientation/tilt and isolates shading; the
result snaps to the RdSAP bucket {1:1.0, 2:0.8, 3:0.5, 4:0.35} via the
ADR-0026 midpoint cutpoints (≥0.90→1, 0.65–0.90→2, 0.425–0.65→3, <0.425→4;
ZPV>1→1). The real London example's planes all back-solve to ZPV>1 → code 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:59:48 +00:00
Khalim Conn-Kowlessar
f31d5bcff9 feat(modelling): typed SolarPotential projection over Google buildingInsights
Slice 2 of the Solar PV Recommendation Generator (ADR-0026). Adds the
strictly-typed `SolarPotential` domain projection over the raw Google Solar
`buildingInsights` JSON that Ingestion persists (SolarRepository): the
`solarPanelConfigs` ladder, each rung broken into its roof segments with
Google's continuous azimuth/tilt mapped to the SAP octant
(`azimuth_to_sap_octant`, 0°=N clockwise → 1=N..8=NW, matching the
calculator's ORIENTATION_BY_SAP10_CODE) and RdSAP §11.1 pitch code
(`pitch_to_sap_code`, snap to {0→1,30→2,45→3,60→4,90→5}).

Pinned against the real London buildingInsights example (mirrored into
fixtures from the user-provided RTF): 400 W panels, maxArrayPanelsCount 49,
46-rung ladder, per-segment SE/NW/NE/SW octants at ~32° → pitch code 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:55:55 +00:00
Khalim Conn-Kowlessar
8741fbdfac fix(floor): floor_heat_loss=3 → above partially heated space, U=0.7 (RdSAP §3.12)
The API `floor_heat_loss` code is authoritative — confirmed by joining each
single-BP cert's code to its independent `floors[].description` (which the
gov register publishes alongside the code):

  code 1 ↔ "To external air"        (exposed,        9/9)
  code 2 ↔ "To unheated space"      (semi-exposed,   6/6)
  code 3 ↔ "(other premises below)" (partially htd,  9/9)
  code 6 ↔ "(another dwelling below)" (party,      176/176)
  code 7 ↔ "Solid"/"Suspended …"    (ground,     all)

Code 3 was mis-mapped to "To unheated space" (semi-exposed) and, on
mid-/top-floor flats, had its floor area zeroed entirely by the
dwelling-level exposure heuristic. RdSAP 10 §3.12 (PDF p.25) classes a
flat's floor over non-domestic "other premises … heated, but at different
times" as "above a partially heated space" → the §5.14 (PDF p.47) constant
U=0.7 W/m²K — distinct from semi-exposed (Table 20) and party (no loss).

Fix: the mapper sets `is_above_partially_heated_space` on the floor=0
dimension for code 3 (string → "(other premises below)" for fidelity), and
the heat-transmission step lets that per-BP lodgement override the flat
suppression upward (mirroring the existing exposed / "another dwelling
below" overrides). The cascade already routes is_above_partial → U=0.7.

Re-pins golden cert 7536-3827: its Ext2 (bp3) lodges code 3, but the cert's
lossy `floors[]` summary dropped that description, so a prior agent guessed
"code 3 = ground" (U=1.12) and concluded the residual was an irreducible
"register-rounding" artifact. It was this bug: Ext2 floor U 1.12 → 0.70,
PE -6.1952 → -5.6414, CO2 -0.1639 → -0.1492 (both toward 0), SAP unchanged.

Eval: 909 computed, 45.1% → 45.3% within 0.5, mean|err| 1.702 → 1.659,
<1.0 59.5% → 60.2%. 13 code-3 certs improve (0380 +3.71 → -0.63, 0350
+7.82 → +0.83, 2610 +7.47 → -1.29); the few that overshoot were already
failing and carry independent fabric bugs (9763's walls = 8 W/K for 60 m²).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:25:04 +00:00
Khalim Conn-Kowlessar
b40e0f67b8 fix(floor): exposed floor on a flat carries heat loss (RdSAP §3.12)
A mid-/top-floor flat whose lowest floor is lodged as an exposed floor
(API floor_heat_loss=1) had its floor area zeroed by the dwelling-level
exposure heuristic, which keys only on the flat label and defaults
has_exposed_floor=False (assuming the floor sits over another *heated*
dwelling). RdSAP 10 §3.12 (PDF p.25) is explicit:

  "Otherwise the floor area of the flat ... is:
     - an exposed floor if there is an open space below"

i.e. a flat cantilevered over a passageway IS a heat-loss floor on
Table 20. The per-BP `is_exposed_floor` lodgement is authoritative and
now overrides the dwelling-level suppression upward, mirroring the
existing "another dwelling below" party override (which suppresses
downward). The code-1↔"E To external air" enum is confirmed by the
paired API+Summary worksheet certs (0350, 3800).

Eval: 45.1% → 45.3% within 0.5 (909 computed); cert 3836 +6.79 → +0.77,
5717 +1.31 → -0.07 and 0997 +0.76 → +0.05 cross into <0.5. Two
already-failing under-rated certs (7636, 2241) shift further — both are
dominated by independent cost-side over-counts the exposed floor merely
unmasks (7636 walls = 8.98 W/K for 33.87 m² is the real defect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:47:52 +00:00
Khalim Conn-Kowlessar
a8e5563ace fix(warm-air): Table 11 secondary fraction for category 9 → 0.10
main_heating_category=9 (warm-air systems, NOT heat pump) had no entry
in _SECONDARY_HEATING_FRACTION_BY_CATEGORY, so a warm-air main with a
lodged secondary raised UnmappedSapCode in
_secondary_heating_fraction_for_category — the last calc_raise in the
API sample (cert 0380-2197-2590-2996-2715: warm air mains gas code 506 +
electric room-heater secondary).

SAP 10.2 Table 11 (p.188): a gas/oil warm-air unit falls under "All gas,
liquid and solid fuel systems" (0.10), and electric warm air under
"Other electric systems" (also 0.10) — so 0.10 regardless of fuel. The
warm-air efficiency (Table 4a code→eff: 506→0.70) and Table 4f fan
energy were already wired; this was the only missing dispatch entry.

0380 now computes: SAP 78.1 vs lodged 77 (+1.1; the residual is per-cert
fabric/PV, not the warm-air dispatch — a faithful 0380 worksheet isn't
available, sim case 28 diverges at SAP 57 / code 502 / condensing unit).
Eval: zero raises remain, computed 908→909; mean|err| 1.703→1.702.
Regression green (2448 pass incl. golden 6035 + cohort); pyright
net-zero (44=44).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 20:26:32 +00:00
Khalim Conn-Kowlessar
f40485887d fix(u-value): RdSAP10 ignores gov-API wall insulation conductivity → §5.8 default λ
The gov EPC API field wall_insulation_thermal_conductivity is OUTPUT
metadata in the openly-published EPC, not an input to the RdSAP10 tool
(Elmhurst) that produced it — its wall entry is Type + Insulation +
thickness only, with no conductivity field. So the RdSAP10 reduced-data
method always uses the SAP 10.2 §5.8 (p.41) default λ=0.04 W/m·K,
whatever code the register lodged.

`_resolve_wall_insulation_lambda_w_per_mk` previously mapped only code 1
(→0.04) and RAISED on others, blocking cert 2090-6909-8060-5201-6401
(code 3 on an internally-insulated 360mm solid-brick wall) with
calc_raise:ValueError. Now it returns the §5.8 default for any code.

Validated: 2090 computes to SAP 73.97 vs lodged 74 (err -0.03); λ of
0.04 / 0.03 / 0.025 all round to 74, and Elmhurst exposes no conductivity
input, so 0.04 is the spec-faithful RdSAP10 value. Eval computed
905→906; mean|err| 1.708→1.706. Regression green (only the 2 pre-existing
stone-wall U failures); pyright net-zero (69=69).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:39:01 +00:00
Khalim Conn-Kowlessar
dd92ba5972 refactor(modelling): load ASHP rates from a committed costs file
Slice 10 of ADR-0025 costing. The Southern Housing rate table moves from code
constants into ashp_rates.json (structured rows the flat scalar catalogue can't
hold), loaded via AshpRates.from_json. Products takes an injected AshpRates
(default: the committed sheet), so rates are now data -- tunable (e.g.
reuse_distribution_fraction) without a code change, and ready for ETL/DB-supplied
rates later. Behaviour-preserving: the 6 pinned cost tests still hold against the
default, plus a new test proving injected rates drive the total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:19:53 +00:00
Khalim Conn-Kowlessar
449d8c5b95 fix(hw): direct-acting electric boiler (191) → zero primary circuit loss
SAP 10.2 Table 3 (PDF p.160) names "Direct-acting electric boiler"
verbatim in the primary-loss zero list (alongside electric immersion,
combi, CPSU, integral-vessel heat pump). RdSAP 10 §12 (p.62) classifies
SAP code 191 as the direct-acting electric boiler. Its cylinder is
immersion-heated with no primary pipework, so no primary circuit loss
applies — but `_primary_loss_applies` had no 191 branch, so a 191 main
(main_heating_category 2, "Boiler and radiators, electric") fell through
to the cat-{1,2} boiler branch and accrued ~1177 kWh/yr of phantom
primary loss on the electric-flat segment.

Validated against the cert-2474 worksheet: §4 (59) primary loss = 0,
(64) HW output 1760 (cylinder) + (64a) shower 581. Cert 2474 HW kWh
3585 → 2408; SAP 64.66 → 70.35 (the residual to the lodged 78 is an
Unknown-meter data-fidelity artifact — the register recorded meter_type=3
"Unknown" but the lodged rating used an 18-hour off-peak meter, per RdSAP
§12 / the example worksheets).

Eval mean|err| 1.720 → 1.708 (headline 45.0%, flat ±1 cert — the
electric-flat segment is dominated by the meter data-fidelity artifact).
Regression green (2448 pass incl. golden 6035 + ASHP cohort 1e-4);
pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:31:27 +00:00
Khalim Conn-Kowlessar
f06a048a6f feat(modelling): ASHP option carries the composite per-dwelling cost
Slice 9 of ADR-0025 costing. _ashp_option now prices via Products.ashp_bundle_
cost(ashp_cost_inputs(epc)) instead of the flat catalogue scalar; the catalogue
row is still read for its material_id. Pinned on boiler-3: gas reuse dwelling
composes to 15600.60 (decommission 720 + pump 9720 + cylinder 2382.60 + reuse
distribution 2778) with 25% contingency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:17:06 +00:00
Khalim Conn-Kowlessar
6f136a8d6a feat(modelling): classify ASHP existing system by fuel code
Slice 8 of ADR-0025 costing. _existing_system keys on the heating fuel code,
not the mains_gas flag -- the 001431 electric fixtures all lodge mains_gas=True
(gas available at the property) while heating electrically (fuel 30), which the
flag-based check misread as gas (and would have wrongly reused a non-existent
wet system). Electric/gas/oil/LPG map to their categories; empty details ->
NONE; unrecognised -> OTHER (gas-line fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:10:45 +00:00
Khalim Conn-Kowlessar
f182f36802 feat(modelling): ashp_cost_inputs reads a dwelling into AshpCostInputs
Slice 7 of ADR-0025 costing: the modelling-layer interpretation half of the
split. ashp_cost_inputs derives existing system (mains_gas/fuel/SAP-code),
size band (floor area <= 75 m2), design heat loss (floor_area x 0.05 -- the
chosen proxy over HLC, ADR updated), radiator count (habitable + 3, floor-area
fallback) and reusable-wet-system flag. Catalogue math (Products) stays
EPC-free. ADR-0025 updated to record the floor-area pump-sizing choice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:59:27 +00:00
Khalim Conn-Kowlessar
9860f06864 feat(modelling): ASHP decommission fallbacks for off-sheet systems
Slice 4 of ADR-0025 costing. ASHP is offered to any house regardless of fuel,
so _decommission now prices a fallback instead of raising: no system -> 0,
electric room/panel heaters -> electric-storage line, anything else -> gas
line (representative default). Never blocks ASHP eligibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:42:14 +00:00
Khalim Conn-Kowlessar
22612a19eb feat(modelling): ASHP reuse case prices flush + half distribution
Slice 3 of ADR-0025 costing. When the dwelling has a reusable wet system,
_distribution charges a power-flush (168) plus _REUSE_DISTRIBUTION_FRACTION
(0.5) of the full radiator band -- a documented stand-in for partial radiator
upsizing at ASHP flow temps, the headline uncertainty in the model. Without a
wet system the full new distribution is priced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:41:06 +00:00
Khalim Conn-Kowlessar
cf7c2e017d feat(modelling): ASHP decommission cost by existing system
Slice 2 of ADR-0025 costing. _decommission maps the existing system to its
Southern Housing line: gas/oil flat 720, LPG 960 (tank+fuel removal),
electric-storage 570/840 by property-size band. Unmapped systems raise for
now -- the no-system/electric-other/other fallbacks land in the next slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:36:12 +00:00
Khalim Conn-Kowlessar
d23b84209d feat(modelling): Products.ashp_bundle_cost composite ASHP cost (tracer)
First slice of the per-dwelling ASHP bundle costing (ADR-0025). Products is
the rich catalogue collection over Product, owning the catalogue math: given
a typed AshpCostInputs it sums the applicable Southern Housing rate lines
(decommission + heat-pump band + fixed cylinder + full wet distribution) into
a Cost with the separate 25% ASHP contingency. Pure -- no EpcPropertyData or
calculator. Pinned exact (1e-9) against the real rate sheet. Reuse branch,
decommission variants, fallbacks, band edges and radiator clamp follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:32:54 +00:00
Khalim Conn-Kowlessar
2bc73fb08d fix(cost): HP-DHW from PCDB heat pump bills Table 12a ASHP_APP_N WH split
When DHW is heated by the main heat pump (WHC 901/902/914 = "from main
system") and the main carries a PCDB Table 362 record,
`_hot_water_fuel_cost_gbp_per_kwh` billed the electric HW at 100% off-peak
low rate (its long-standing TODO). SAP 10.2 Table 12a Grid 1 WH column
(PDF p.191) puts HP-DHW on the ASHP/GSHP-from-database row: 0.70
high-rate fraction at 7-hour and 10-hour → 0.70×14.68 + 0.30×7.50 =
12.526 p/kWh (10-hour), not 7.50 p. The low-rate collapse over-credited
the cat-4 HP-DHW cluster.

Fix: pass the cert WHC into the helper and, for HP-DHW (WHC ∈ {901,902,
914} + PCDB-HP main), bill at the ASHP_APP_N WH blended rate. Electric
IMMERSION (WHC 903) is a different Table 12a row (off-peak immersion 0.17
/ Table 13) and stays on the 100%-low-rate fallback until that slice
lands.

cat-4 cluster (20 certs): mean|err| 2.43→2.11, mean signed +0.06→-0.52
(now per-cert scatter, no systematic bias); cert 9472 +6.4→+3.2, 2789
+6.8→+4.0, 4135 +2.7→within 0.5. Headline mean|err| 1.727→1.720.
Regression green (2447 pass incl. golden 6035 + ASHP cohort at 1e-4);
pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:54:01 +00:00
Khalim Conn-Kowlessar
e41a0bc0d7 fix(cost): PCDB heat pump without SAP code bills Table 12a ASHP_APP_N split
A heat pump that resolves via its PCDB Table 362 index alone (API path,
data_source=1, no Table-4a SAP code) had sap_main_heating_code=None, so
`_table_12a_system_for_main` fell through the 211-227/521-524 code-range
gate to None → the "100% off-peak low-rate" fallback. On a Dual meter
(RdSAP §12 Rule 3 routes heat pumps to the 10-hour tariff) this billed
space heating at 7.50 p/kWh instead of the SAP 10.2 Table 12a Grid 1
(PDF p.191) ASHP/GSHP-from-database row: 0.80 high-rate fraction →
0.80×14.68 + 0.20×7.50 = 13.244 p/kWh. The collapse over-credited the
whole cat-4 heat-pump cluster.

Fix: route any main with a PCDB heat-pump record to ASHP_APP_N regardless
of SAP code (a Table 362 record IS an Appendix-N heat pump by
definition). ASHP_APP_N and GSHP_APP_N share the 0.80 SH fraction at
7h/10h, so ASHP_APP_N is the canonical Appendix-N row for the SH split.

cat-4 cluster (20 certs): within-0.5 45%→50%, mean signed +1.43→+0.06,
mean|err| 3.81→2.43; cert 9472 +15.0→+6.4, 2789 +13.4→+6.8. Headline
45.0%→45.1%, mean|err| 1.757→1.727. Regression green (only the
pre-existing test_total_floor_area fails); pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:48:37 +00:00
Khalim Conn-Kowlessar
4d1a58b828 fix(tariff): Unknown meter + storage/CPSU main → off-peak (§12)
Electric storage heaters (and CPSU) charge overnight and cannot run
economically on a single rate, so their presence is physical evidence the
dwelling is on an off-peak tariff. RdSAP 10 §12 (PDF p.62) applied Rules
1-4 only for a Dual meter; an "Unknown" (code 3) meter returned STANDARD
without consulting the heating type, so a cat-7 storage main billed its
overnight charge at the standard 13.19 p/kWh instead of the 7-hour low
rate (5.50 p/kWh) — ~2.4x too high → large under-rate.

Two coupled fixes:
- `rdsap_tariff_for_cert`: for an Unknown meter, infer the off-peak tariff
  from a Rule-1 CPSU (→10-hour) or Rule-2 storage (→7-hour) main; keep
  STANDARD otherwise. Direct-acting/room heaters/heat pumps (Rule 3) are
  NOT off-peak evidence (run on demand, exist on single-rate meters) so
  they stay STANDARD — billing them 100% at the low rate over-credits.
- `_fuel_cost` now resolves its tariff via the §12-aware `_rdsap_tariff`
  (not the raw `tariff_from_meter_type`), so the off-peak branch fires for
  these storage certs and the legacy scalar fields bill the low rate.

Mirrors `_is_off_peak_meter`'s existing Unknown+electric heuristic (which
already routes HW/secondary off-peak), closing the main-space-heating gap.
Meter-3 electric cluster: mean |err| 11.18 → 6.52, within-1.0 3 → 5 (cert
7336 -26.1 → -0.16, 0380 -19.9 → +1.0). Eval headline 44.9% → 45.0%, mean
|err| 1.82 → 1.76, mean signed -0.08 → +0.02. A few storage certs overshoot
(other residuals the standard rate was masking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:02:34 +00:00
Khalim Conn-Kowlessar
678aa7affd fix(cascade): main-roof U ignores Room-in-Roof "no insulation" leak
The main pitched/flat roof U-value was derived from the JOINED text of
every roofs[] entry. A room-in-roof carries its own §3.9/§3.10 shell
area + U-value cascade (Table 17 / Table 18 col 4), so a multi-roof cert
lodged "Pitched, insulated (assumed) | Roof room(s), no insulation
(assumed)" leaked the RR's "no insulation" marker into the main roof's
u_roof → U=2.30 applied to the WHOLE main roof, ~3x over-stating its heat
loss. This is the 4700-family regular-roof-U leak.

`_joined_main_roof_descriptions` drops "Roof room(s)" entries before the
main-roof u_roof, falling back to the unfiltered join only for pure-RR
dwellings (every entry an RR) to preserve their prior behaviour. The RR
shell U is unaffected (computed separately) — golden 6035 stays green.

RR-leak cluster (18 certs, RR "no insulation" + a non-RR primary roof):
mean |err| 6.14 → 4.85, within-1.0 0 → 8, within-0.5 0 → 3. Eval headline
44.8% → 44.9%, mean |err| 1.851 → 1.824, mean signed -0.152 → -0.081. Two
certs overshoot (other residuals the leak was masking); the spec rule is
applied uniformly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:27:41 +00:00
Khalim Conn-Kowlessar
a64e857b94 fix(u-value): "Unknown" roof insulation → Table 18 default, not 2.30
A roof lodged "Unknown loft insulation" carries roof_insulation_thickness
"NI" (Not Indicated → parsed to 0) or "ND" (None): the thickness is
UNDETERMINED, not zero. RdSAP 10 §5.11.4 (p.44) is deterministic here —
"U-values in Table 18 are used when thickness of insulation cannot be
determined" — so the roof takes the Table 18 age-band default (column (1)
pitched / column (3) flat), NOT the uninsulated 2.30 the Table 16 row-0
lookup returns for a parsed-0 thickness. The "Unknown" text is RdSAP's
rendering of the undetermined-thickness observation, distinct from a
genuine "no insulation" lodgement (which keeps 2.30).

u_roof gains an "unknown"-description branch ahead of the parsed-0 → 2.30
path, gated on undetermined thickness (None or 0). Top-floor flats with
"Pitched/Flat, Unknown ... insulation" were the worst electric-flat
under-raters: roof U=2.30 gave HLP ~3.7 on dwellings rated SAP 69-70.

Cluster (14 certs, roof desc contains "unknown", no "no insulation"):
mean |err| 7.79 → 1.82, within-0.5 1→4, within-1.0 1→6. Cert 9836
roof_w_per_k 58.2→10.1, SAP -27.8 → -3.5. Eval headline 44.4% → 44.8%,
mean |err| 1.944 → 1.851. Two certs overshoot (other residuals the wrong
roof-U was masking); the spec value is applied uniformly regardless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:20:18 +00:00
Khalim Conn-Kowlessar
3aed8f858a fix(cascade): suppress floor heat loss for "another dwelling below" (code 6)
A floor lodged API floor_heat_loss=6 ("another dwelling below") sits over
another heated dwelling, so it is a party floor with no heat loss (RdSAP
10 §3). The mapper mapped code 6 → None and the heat-transmission step
drove floor exposure solely from the dwelling-level `has_exposed_floor`
flag — which is keyed only on the dwelling_type label and defaults a
"Ground-floor flat" to an exposed floor. So a ground-floor flat above a
basement dwelling kept its full ground-floor heat-loss area.

Map code 6 → "(another dwelling below)" (still != "Ground floor", so the
§5 (12) suspended-timber rule stays inert) and have the cascade suppress
that BP's floor when its floor_type carries the signal, mirroring the
roof's existing "another dwelling above" per-BP party override.

Cert 2115-4121-4711-9361-3686 (ground-floor flat, floor_heat_loss=6):
floor_w_per_k 47.85 → 0; SAP -23.44 → -4.41. Cert 0350-…-6435 -12.38 →
-0.55; 0926-…-9024 -2.35 → -0.82. Eval mean |err| 1.982 → 1.944.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:05:33 +00:00
Khalim Conn-Kowlessar
9f17a27766 feat(modelling): ASHP overlay resets water_heating_code to the HP end-state
The ASHP bundle is a fixed whole-system end-state (confirmed: always the same
contractor cylinder), so the hot-water arrangement is fixed too. The overlay now
sets water_heating_code=901 ("from main system") absolutely, so a combi (909/611)
or electric (903/908) before is reset to HW-from-the-heat-pump — previously the
overlay relied on the before already lodging 901 (true for boiler-1, not in
general). No-op for the boiler-1 pin (stays 1e-4). Cascade pins for combi /
electric-with-cylinder befores await example certs. ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:18:58 +00:00
Khalim Conn-Kowlessar
0f89845321 feat(modelling): wire the ASHP bundle into the candidate pool
recommend_heating now receives planning_restrictions in the orchestrator (the
ASHP planning gate); the ASHP bundle joins the free candidate pool for every
house/bungalow. Catalogue + contingency (legacy 0.25) gain air_source_heat_pump;
report.py _triggers_for explains the ASHP trigger; the harness forcing test
covers it. Integration tests seed an air_source_heat_pump MaterialRow (ASHP
fires on every house, the broadest trigger yet). NB the optimiser correctly does
NOT select ASHP for an EPC-band goal — gas->electric does not improve the SAP
cost-rating; ASHP is a CO2/PE measure, selectable once non-EPC goals land. ASHP
bundle COMPLETE (S5-S7). ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:12:07 +00:00
Khalim Conn-Kowlessar
a1fc697d93 test(modelling): ASHP before/after cascade pin (001431) at 1e-4
A typical mains-gas combi house re-lodged as an air-source heat pump closes at
1e-4 (gas-boiler 1 example from the technical specialist). Closes one named gap
the pin surfaced: a whole-system replacement to a PCDB-indexed system left the
old Table 4a sap_main_heating_code (104) beside the new heat-pump index, and the
stale code won the calculator's efficiency dispatch (hot water billed at boiler
not HP efficiency, ΔSAP 3.98). _fold_heating now enforces the mutual exclusion
of the two efficiency anchors (setting an index clears the SAP code and vice
versa). Also fixed a pre-existing pyright annotation in the lighting applicator
test. ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:01:40 +00:00
Khalim Conn-Kowlessar
a9da21c4b6 feat(modelling): recommend_heating offers the ASHP bundle
Adds the air-source heat-pump Option to the competing "Heating & Hot Water"
bundles. Its overlay is the absolute heat-pump end-state (fixed representative
PCDB index 101413 + category 4 + control 2210 + HWP cylinder + single meter +
off mains gas), pinned against the relodged after-cert next slice. Eligibility
is physical/planning only (ADR-0024, research-grounded): any non-flat
house/bungalow, not listed/heritage (PlanningRestrictions.blocks_internal —
conservation is offered with a caveat, not excluded), not already a heat pump;
floor area / built form / fuel / fabric are deliberately not gates. recommend_
heating gains a restrictions param (defaulted). An already-HHR electric house
now correctly gets ASHP as a better end-state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:56:00 +00:00
Khalim Conn-Kowlessar
27375d93a4 fix(u-value): solid brick as-built U by thickness — §5.7 Table 13
A 440 mm (>420 mm) solid brick AS-BUILT wall computed U = 1.70 (the
220 mm bucket default) instead of the RdSAP-correct 1.10. The §5.7
Table 13 thickness path only fired for *insulated* brick (external/
internal + thickness > 0); the as-built case fell through to the
Table 6 cavity/solid age-band default.

Spec: RdSAP 10 Specification (9th June 2025), §5.7 "U-values for
uninsulated brick walls, age bands A to E", Table 13 (PDF p.40):
  ≤200 mm → 2.5, 200–280 mm → 1.7, 280–420 mm → 1.4, >420 mm → 1.1.
Table 6 footnote (b) on the "Solid brick as built" row (PDF p.40):
"Or from 5.7 if wall thickness is other than 200mm to 280mm" — the
thickness table supersedes the flat 1.7 default whenever a documentary
wall thickness is lodged (200–280 mm gives 1.7 either way). The §5.8 /
Table 14 dry-lining R is added on top only when the wall is dry-lined,
per the §5.7 closing sentence.

Validated against the user-generated Elmhurst worksheet "simulated
case 21" (replica of API cert 2818-3053-3203-2655-9204: mid-terrace,
age band B, solid brick as-built 440 mm, room-in-roof). New §3 cascade
pin `test_section_3_wall_u_by_thickness_case21_match_pdf` routes the
Summary through the real extractor + mapper and pins:
  (31) 155.1000, (33) 175.6208, (36) 23.2650, (37) 198.8858 — all 1e-4.
External walls Main U → 1.1000; Sheltered RR gable → 1/(1/1.10+0.5) =
0.71 (was 0.92). Pinned on §3 only (case-6 precedent): its code-908
instantaneous multi-point gas water heater has a separate §4 (219) gap.

Cross-check: sim case 20 (220 mm) stays at 1.70 — unchanged.

API SAP accuracy (scripts/eval_api_sap_accuracy.py, 896 computed certs):
% |err| < 0.5 SAP vs lodged: 42.6% → 43.8%; mean |err| 2.045 → 2.010.

Regression: tests/domain/sap10_calculator/ (1861), backend/
documents_parser/tests/ (574), datatypes/epc/ + rdsap golden fixtures
all green (pre-existing test_total_floor_area excepted). pyright strict
net-zero. No solid-brick fixture pin shifted (200–280 mm unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:40:06 +00:00
Khalim Conn-Kowlessar
cdf211393c feat(mapper): map API gable_wall_type 2/3 (Sheltered/Connected) — clears 14 raises
The 2026 API sample raised UnmappedApiCode on `gable_wall_type` 2 (10 certs)
and 3 (4 certs) — the two RR gable variants beyond Party(0)/Exposed(1).
Sim case 21 (an Elmhurst replica of API cert 2818-3053-3203-2655-9204:
gable_wall_type_1=2, gable_wall_type_2=3) lodges them as "Sheltered" and
"Connected", confirming **2=Sheltered, 3=Connected**.

- Mapper: `_API_TYPE_1_GABLE_TYPE_TO_KIND` gains 2 → `gable_wall_sheltered`,
  3 → `connected_wall` (U=0, area deducts — already handled).
- Calculator: new `gable_wall_sheltered` branch. The API path lodges no
  per-gable U, so the cascade DERIVES it as RdSAP 10 Table 4 (p.22)
  Sheltered = 1/(1/U_wall + 0.5) — back-solved + validated against case 21
  (U_wall 1.10 → 0.71) and case 20 (1.70 → 0.92). A lodged U (Summary path)
  still rides through as an override.

API sample: 14 raises clear → `computed` 882 → 896, `raise:ValueError` 16 → 2.
Summary path unchanged (Sheltered stays `gable_wall_external` + lodged U, so
cert 000487's hand-built fixture is untouched). 2861 pass (lone
test_total_floor_area pre-existing); pyright strict net-zero (32=32 / 12=12).

NOTE: the derived Sheltered U on cert 2818 lands at 0.92 not 0.71 because the
cascade computes its 440 mm solid-brick wall U as 1.70 (the 220 mm default) —
a SEPARATE wall-U-vs-thickness bug (next slice, validated by case 21's 1.10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:59:50 +00:00
Khalim Conn-Kowlessar
7dfe3f2c99 feat(test): case-20 cascade fixture + close its CO2 via E7 per-end-use codes
Locks sim case 20 (storage heaters + Detailed RR + loose-jacket cylinder)
as a golden vector: _elmhurst_worksheet_001431_case20.build_epc() routes the
Summary PDF through extractor → mapper → calculator, registered in
test_e2e_elmhurst_sap_score with all 11 SapResult headline pins at 1e-4.
10 pinned exact off slices 1-2 (window extractor, RR stud walls); this slice
closes the last one, co2_kg_per_yr (was 3797.62 vs (272) 3815.4060).

Root cause: on a dual-rate (E7) meter the CO2 path ignored the tariff's
high/low Table-12 electricity codes that the cost path already uses:
  - Secondary (direct-acting portable heaters, on-peak) keyed the monthly
    Table 12d cascade on standard code 30 (0.15405) instead of the E7 HIGH
    code 32 → (263) 0.1616. SAP 10.2 Table 12a Grid 1 direct-acting electric
    is 100% high-rate; mirrors the cost side billing it at 15.29 p/kWh.
  - Main storage heaters fell through `_table_12a_system_for_main`=None to
    the FLAT annual factor (0.136) rather than the dual-rate LOW code: per
    the Table 12a design intent ("storage … 100% low rate") they charge
    off-peak → E7 LOW code 31 → (261) 0.1357.

case-20 co2 now EXACT. 2433 calculator + 112 golden + documents_parser tests
pass — no dual-meter/storage cohort regression; pyright strict net-zero (32=32).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:23:10 +00:00
Khalim Conn-Kowlessar
b55ab3727f feat(modelling): wire the HHR storage bundle into the candidate pool
recommend_heating joins the free candidate pool in _candidate_recommendations;
the HHR storage bundle reaches the optimised package for an electric/off-gas
dwelling. Catalogue + contingency (legacy 0.10) gain
high_heat_retention_storage_heaters; report.py _triggers_for explains the
heating trigger (electric/off-gas main); the harness _GENERATOR_MEASURE_TYPES
forcing test covers it. ASHP + boiler bundles still to come. ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:22:50 +00:00
Khalim Conn-Kowlessar
b3badc9b10 test(modelling): HHRSH before/after cascade pins (001431) at 1e-4
The same absolute-target HHR overlay reproduces the common relodged after from
two different base systems (existing electric storage; "no system present"
electric) — proving the bundle is a true whole-system end-state. Closes one
named gap the pin surfaced: the relodged HHR cylinder lodges
cylinder_thermostat='Y', so HeatingOverlay + _fold_heating + the HHRSH overlay
gain cylinder_thermostat (ΔSAP 0.065 -> <1e-4). ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:09:23 +00:00
Khalim Conn-Kowlessar
b883e75da8 feat(modelling): recommend_heating offers the HHR storage bundle
The heating Recommendation Generator (HHRSH first). Emits one "Heating & Hot
Water" Recommendation whose competing whole-system bundles the Optimiser picks
from; this slice builds the high-heat-retention storage Option. Its overlay is
the absolute HHR end-state (Table 4a code 409 + control 2404 + dual off-peak
meter + off-peak electric cylinder), pinned against the relodged after-cert in
the next slice. Eligibility translates legacy is_high_heat_retention_valid to
structured predicates (electric or off-gas main, not already HHR/heat-pump).
mains_gas and the heat emitter are unchanged by the measure, so unset. ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:05:02 +00:00
Khalim Conn-Kowlessar
efa10fe6cb S0380.239: system-build walls take masonry structural infiltration (0.35)
RdSAP 10 §2 (Ventilation, "Walls" row): "Structural infiltration: 0.25
for steel or timber frame or 0.35 for masonry construction ... System
build: treated as masonry." `_is_timber_or_steel_frame` wrongly included
wall_construction code 6 (system build) alongside code 5 (timber frame),
handing system-build dwellings the 0.25 structural ACH instead of 0.35.

On the cat-10 room-heater fixture (ref 001431, walls SY System Build →
code 6) this under-stated the infiltration rate (18) by exactly 0.10
(0.45 vs worksheet 0.55), dropping the effective air change (25), the
ventilation heat loss (38)m = 0.33 × (25)m × (5), and the heat-transfer
coefficient (39) — so space-heating demand (98) came out 404 kWh low
((211) 11158.6 vs worksheet 11563.2). Restrict the 0.25 branch to code 5
only; code 6 (and everything else) is masonry at 0.35.

Pins the rating-block (38)m ventilation heat loss mean = 83.3613 W/K at
abs 1e-4 and asserts the classifier treats the system-build wall as
masonry. §4 suite green (2415 passed, 1 skipped); no existing fixture
relied on system-build → 0.25.

Residual after this slice: SAP +0.03 / cost -£0.95 — a small fabric (33)
gap (-0.15 W/K) plus lighting (232) +1.0 kWh remain as separate causes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:00:02 +00:00
Khalim Conn-Kowlessar
cbdee9ec3c S0380.238: single-point instantaneous water heaters incur no distribution loss
Water heating SAP code 909 (electric instantaneous) and 907 (single-point
gas) heat water at the point of use, serving one outlet with no
distribution pipework. Per SAP 10.2 §4 (p.23, l.1416): "'Single-point'
heaters, which are located at the point of use and serve only one outlet,
do not have distribution losses either." So worksheet (46)m = 0 and the
heat-required line collapses to SAP 10.2 worksheet l.7704
  (62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m
        = 0.85 × (45)m   (all loss terms zero for a no-cylinder system).

`distribution_loss_monthly_kwh` already supported the
`is_instantaneous_at_point_of_use` flag (and its docstring already named
codes 907/909), but `water_heating_from_cert` hard-coded it to False, so
the cascade applied (46)m = 0.15 × (45)m to single-point heaters. That
0.15 distribution loss exactly cancelled the 0.85 reduction, leaving
(62)m = (45)m. On the cat-10 room-heater fixture (ref 001431, code 909)
that over-stated the water fuel (219) as 2082.6250 instead of the
worksheet's 1770.2313, and inflated the (65)m heat gains (692.47 vs
worksheet 442.55) which in turn suppressed space-heating demand.

Thread the cert's existing instantaneous flag (`_INSTANTANEOUS_WATER_CODES`
= {907, 909}) through `_water_heating_worksheet_and_gains` into both the
demand-pass and final `water_heating_from_cert` calls.

Pins (219) water fuel = 1770.2313 at abs 1e-4 via the extractor → mapper →
rating cascade. §4 suite green (2414 passed, 1 skipped); no existing
fixture exercised the 907/909 path. The residual space-heating fuel gap
((211) 11158.59 vs worksheet 11563.17) this exposes is a separate cause —
next slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:00:02 +00:00
Khalim Conn-Kowlessar
2f6a1e2479 feat(modelling): HeatingOverlay surface + _fold_heating (5-location fold)
The 5th EpcSimulation overlay surface and the deepest applicator fold yet: a
heating bundle is a whole-system replacement, so _fold_heating routes its
absolute-target fields across main_heating_details[0] (fuel/emitter/control +
sap_main_heating_code OR index+category), sap_heating (water_heating_* +
cylinder), the top-level EpcPropertyData (has_hot_water_cylinder), and
sap_energy_source (meter_type, mains_gas). ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:59:26 +00:00
Khalim Conn-Kowlessar
d559298de2 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-05 18:59:25 +00:00
Khalim Conn-Kowlessar
3c0ac98122 feat(calculator): thread per-end-use fuel codes + PV export onto SapResult
ADR-0014 BillDerivation attributes each end-use (HEATING / HOT_WATER /
SECONDARY / APPLIANCES / COOKING) to a fuel carrier and credits PV
export. SapResult already carried the per-end-use kWh but not WHICH
fuel each end-use burns, nor the annual exported kWh — so a downstream
SapResult->EnergyBreakdown adapter could not pick the right tariff.

Surfaces five output-only fields, threaded exactly like the recently
merged appliances/cooking change (2f039aeb):
  main_heating_fuel_code      RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
  main_2_heating_fuel_code    code column (the lodged fuel code, e.g.
  secondary_heating_fuel_code mains gas 26). None when the corresponding
  hot_water_fuel_code         system is absent / fuel not resolvable.
  pv_exported_kwh_per_yr      SAP 10.2 Appendix M1 §3-4 annual export kWh
                              (0.0 when no PV).

cert_to_inputs.py populates the four fuel codes from the existing
resolvers the cost/CO2 cascade already uses — `_main_fuel_code`,
`_secondary_fuel_code`, `_water_heating_fuel_code` (not reinvented);
Main 2 is the second `main_heating_details` entry, guarded for length.
There is a single CalculatorInputs construction site (cert_to_demand_
inputs delegates to cert_to_inputs). `pv_exported_kwh_per_yr` already
existed on CalculatorInputs; SapResult collapses its Optional to 0.0.

HARD CONSTRAINT honoured — output-only, zero rating drift. These fields
do NOT feed ECF / total_fuel_cost_gbp / co2_kg_per_yr / primary_energy_*
/ sap_score / any monthly value. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical: calculator suite 1658 -> 1661 passed (+3 new tests),
4 skipped, 0 failed before and after. pyright net-zero (51 -> 51 in
domain/; no new errors in the touched test files).

New tests: a synthetic threading test (four fuel codes + PV export pass
unchanged through calculate_sap_from_inputs; None PV collapses to 0.0)
and a cert-level pin (mains-gas combi cert 000516 -> main fuel code 26,
no Main 2, secondary 30, HW 26). Synthetic CalculatorInputs / SapResult
fixtures updated for the new SapResult fields (defaults cover Inputs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:59:24 +00:00
Khalim Conn-Kowlessar
f7863f986d feat(modelling): wire the lighting generator into the candidate pool
Slice 4 of the lighting generator (ADR-0023): run recommend_lighting in
_candidate_recommendations (no planning gate). Price low_energy_lighting in the
offline catalogue + contingency table (0.26, the legacy rate); the
_GENERATOR_MEASURE_TYPES forcing test enforces both. A run_modelling test pins
the wiring end-to-end (an incandescent-lit dwelling gets the LED upgrade in the
optimised package).

Downstream updates, all because lighting now fires on any cert with non-LED
bulbs: report.py gains the low_energy_lighting trigger (the non-LED counts); the
two golden-cert report tests and the multi-measure integration test now expect
low_energy_lighting alongside the fabric measures (the sample/golden EPCs lodge
low-energy-unknown bulbs); first-run integration seeds a low_energy_lighting
MaterialRow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:39:54 +00:00
Khalim Conn-Kowlessar
dbbfb8ea28 feat(modelling): recommend_lighting upgrades all non-LED bulbs to LED
Slice 2 of the lighting generator (ADR-0023): detect non-LED bulbs
(incandescent + CFL + low-energy-unknown > 0) and emit one "Lighting"
Recommendation whose single low_energy_lighting Option converts every bulb to
LED — the overlay sets led = total, the other three counts 0. Priced as a flat
per-bulb average x the non-LED count, contingency 0.26; the description names
"LED" while the measure_type stays MEASURE_MAP-aligned. None when already
all-LED or no bulb counts are lodged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:23:27 +00:00
Khalim Conn-Kowlessar
139c90c885 feat(modelling): whole-dwelling LightingOverlay surface on EpcSimulation
Slice 1 of the lighting generator (ADR-0023): the first whole-dwelling,
top-level overlay surface. LightingOverlay carries the four fixed-lighting
bulb-count fields by their exact EPC names (all Optional, absolute counts) +
EpcSimulation.lighting. The applicator's _fold_lighting writes the non-None
counts directly onto the result EpcPropertyData by name (setattr) — simpler
than ventilation's nested fold since the counts live top-level. Baseline
unmutated; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:21:19 +00:00