Commit graph

7203 commits

Author SHA1 Message Date
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
Daniel Roth
c22ee3821b Merge branch 'main' into feature/handle-new-magicplan-response-structure 2026-06-08 09:57:26 +00:00
Daniel Roth
41a40c9ba0 Fix Pylance unknowns in SQLModel table tests and correct pytest paths 2026-06-08 09:56:54 +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
545bb8c328 fix(mapper): map cylinder "No Insulation" to insulation_type=None
The Elmhurst §15.1 "Insulated: No Insulation" label was lodged but absent
from `_ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10`, so
`_elmhurst_cylinder_insulation_code` raised UnmappedElmhurstLabel — blocking
the parse of every Solar PV example cert (the solar `before` lodges "No
Insulation"). An uninsulated cylinder has no insulation *type*, so per the
no-misleading-insulation convention it maps to `cylinder_insulation_type =
None` rather than naming a material; the lodged 0 mm thickness carries the
storage-loss signal the SAP 10.2 Table 2 dispatch needs.

Slice 1 of the Solar PV Recommendation Generator (ADR-0026).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:52:06 +00:00
Daniel Roth
7a1aaf4965 Window carries no opening_type — ventilation table is the sole persistence point 🟩 2026-06-08 09:45:15 +00:00
Khalim Conn-Kowlessar
222704cbc2 docs(modelling): ADR-0026 solar PV eligibility, sizing, overlay, costing
Designed via grill-with-docs. API-first (Google Solar -> typed SolarPotential):
one Solar PV Recommendation, up to 5 conservatively-sized configs x battery
on/off; new PV overlay surface (per-roof-segment arrays, diverter conditional on
cylinder, export-capable ensured); overshading generation-calibrated from the
API's expected generation; eligibility offers conservation areas (only listed/
heritage block); composite costing from EA rates (contingency 0.15). CONTEXT
gains Solar Potential, Solar PV Recommendation, Solar PV Eligibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:44:02 +00:00
Daniel Roth
3f5b3cf172 Window carries no opening_type — ventilation table is the sole persistence point 🟥 2026-06-08 09:43:41 +00:00
Khalim Conn-Kowlessar
d0f57a0e94 docs: session-4 handover — floor_heat_loss=3 resolved (U=0.7), 7536 re-pinned
Code 3 = "(other premises below)" = above partially heated space (§3.12 →
U=0.7), confirmed 9/9 on single-BP certs (the diagnostic that dodged the
lossy-floors[] contamination). Records the 7536 re-pin and the lesson that
"irreducible residual" golden notes can mask a real mapper bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:26:21 +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
75ef250ec8 docs: session-4 handover — exposed-floor fix shipped, floor-3 enum unconfirmed
Record that profiler lead #1 (floor_codes=3) is not a clean single cause
(bimodal + confounded), that the paired worksheet certs confirm only
codes 1/6/7 (code 3 unmapped → needs a worksheet for 0380-2087), and that
immersion_type=2 / main_control=2107 / roof_codes=1 are scatter, not
dispatch bugs. The exposed-floor-on-flats fix (§3.12) shipped at b40e0f67.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:53:25 +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
ae34ca4d74 docs: session-3 API-profiling handover (raises cleared, profiler-driven leads)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 20:39:52 +00:00
Khalim Conn-Kowlessar
28b1da1e06 feat(diag): profile API SAP error against raw-API characteristics
Joins each computed cert's signed error (eval _results.csv) with a rich
feature set extracted from its RAW API JSON (not the mapped
EpcPropertyData), then ranks (feature, value) buckets by error carried
and by |mean signed| bias. Surfaces systematic API-path handling gaps —
a field the mapper silently drops still shows as an error-correlated
bucket. Companion to eval_api_sap_accuracy.py / decompose_api_cost_error.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 20:38:19 +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
11fb82b485 test(modelling): pin boiler-1/2 ASHP to the Vaillant overlay snapshot
The boiler-1 and boiler-2 after-certs predate the Vaillant product swap (they
lodge the old aroTHERM index 101413), so rather than wait on regenerated certs
these now snapshot the Vaillant overlay's own output on the before (SAP/CO2/PE
at 1e-4), taken as correct because the same overlay reproduces the corrected
Vaillant cert at delta 0 in the boiler-3 (system-boiler) pin. Drops the xfail
markers and the two stale, now-unreferenced after-cert fixtures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 20:16:58 +00:00
Khalim Conn-Kowlessar
1c5675a063 fix(mapper): floor_heat_loss code 8 → no floor heat loss (extension over heated space)
API floor_heat_loss=8 is observed on EXTENSION building parts whose
floor sits over a heated space within the SAME dwelling (an upper-storey
extension over a heated room). RdSAP 10 §3 gives an internal floor
between heated storeys no floor heat loss — mechanically identical to a
code-6 party floor. `_api_floor_type_str` had no entry for 8, raising
UnmappedApiCode and blocking certs 0370-2254-6520-2426-5971 and
0997-1206-9806-0715-2904.

Map code 8 to the code-6 no-heat-loss string "(another dwelling below)"
(consumed by heat_transmission's party-floor suppression; != "Ground
floor" so the §5 (12) suspended-timber rule stays inert). Empirically
confirmed against both certs: the no-heat-loss treatment lands them
within 0.5 of lodged (0370-2254 68.92 vs 69; 0997-1206 40.68 vs 41),
whereas Ground-floor / unheated / external mappings miss 0997 by ~4 SAP.

Eval computed 906→908. Regression green (only the pre-existing
test_total_floor_area fails); pyright net-zero (38=38).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:54:00 +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
037daa98ef test: reflect ASHP winning more often after the Vaillant swap
The efficient Vaillant aroTHERM plus 5 kW (ADR-0025) now raises SAP even on gas
dwellings, so the Optimiser selects the ASHP bundle far more often -- a deliberate
product shift (user-confirmed). Updated the integration/harness tests:
- ARA multi-measure: ASHP is additive to the kept fabric package -> add to set.
- ARA listed_uprn: is_listed blocks walls AND ASHP (blocks_internal); the gate is
  now observable via ASHP (selected unrestricted, blocked listed).
- report three-measures: ASHP + solid-floor displace the fabric stack; assert
  their triggers (property_type, main_heating_category / floor).
- console hhr + solid_wall coverage tests: assert the measure is OFFERED as a
  candidate (the wiring/eligibility intent), since ASHP now out-competes them in
  selection; also assert the optimised package leads with ASHP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:49:12 +00:00
Khalim Conn-Kowlessar
b089bde1e6 docs(modelling): add Products term to CONTEXT (ADR-0025)
The rich catalogue collection over Product carrying cost-composition behaviour;
records the catalogue-math vs dwelling-interpretation split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:33:09 +00:00
Khalim Conn-Kowlessar
32f1916619 test(modelling): finalize Vaillant ASHP swap on the cascade pins
The representative heat pump is now the Vaillant aroTHERM plus 5 kW (index
110257, committed with the generator). boiler-3's after-cert is re-sourced from
the corrected Vaillant relodgement and its cascade pin passes at delta 0
(SAP 63.85 -> 72.30). boiler-1 and boiler-2(instant-HW) pins are xfail pending
their own corrected Vaillant after-certs (_ASHP_PRODUCT_REPIN_REASON).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:33:09 +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
c7acf43b52 test(modelling): pin ASHP radiator-count clamp to table bounds
Slice 6 of ADR-0025 costing. Characterises the distribution clamp built in the
tracer: a radiator proxy below 4 prices as the 4-rad band, above 12 as the
12-rad band, in-range exact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:44:57 +00:00
Khalim Conn-Kowlessar
fd9e020b09 test(modelling): pin ASHP heat-pump band round-up at the edges
Slice 5 of ADR-0025 costing. Characterises the pump-band selection built in
the tracer: a design heat loss is rounded UP to the smallest covering band
{5,8,11,15,16+} kW, and loads above the largest band take the top rate. Edge
pins (5.0/5.01/8.0/8.01/11.0/15.0/15.01/25.0) lock the boundaries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:44:19 +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
fb350036b1 docs: session-2 API-accuracy handover (fabric+tariff fixes, worksheet path)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:13:21 +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
d9c7638b3c test(modelling): ASHP from a system gas boiler with an existing cylinder
Pins the cylinder-OVERWRITE path the earlier ASHP pins did not exercise:
boiler-1/boiler-2 added a cylinder where none existed, whereas boiler-3's
before is a mains-gas regular boiler (SAP code 101) that already heats its
own cylinder (size 2 / insulation type 2 / 80 mm). The fixed _ASHP_OVERLAY
overwrites it to the heat-pump cylinder (size 4 / insulation type 1 / 50 mm)
and switches the dwelling off mains gas (fuel 26->30, code 101->index 101413
+ category 4). The existing overlay reproduces the re-lodged after at delta 0
on SAP / CO2 / primary energy -- no overlay change needed; immersion_heating_
type is None in both, so that field (the electric-with-cylinder case) is
untouched here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:20:58 +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
6b04514645 fix(mapper): resolve gas-boiler main fuel from §14.2 mains-gas meter
A Summary §14.0 Table 4b gas boiler (SAP code 101-119) lodges no §14.0
"Fuel Type" string in the newer Elmhurst export. The carrier was resolved
only from §15.0 "Water Heating Fuel Type" — fine when the same boiler
heats the water, but a gas boiler paired with a SEPARATE electric
immersion lodges §15.0 "Electricity", so `_elmhurst_gas_boiler_main_fuel`
returned None and the cascade strict-raised MissingMainFuelType.

Cert 001431 boiler-1/boiler-2 "before" variants are exactly this config:
§14.0 SAP code 102/104 (mains-gas boiler), §15.0 electric immersion
(code 909), §14.2 Meters "Main gas: Yes". The meter flag is the
authoritative carrier signal — a 101-119 boiler on mains gas burns mains
gas — so adopt it (SAP10 main_fuel 26 per _ELMHURST_MAIN_FUEL_TO_SAP10
"Mains gas") when §15.0 can't disambiguate. §15.0 gas/LPG still wins when
present (keeps LPG-vs-mains-gas precision); no mains-gas meter + non-gas
§15.0 still strict-raises rather than guessing.

Spec: SAP 10.2 Table 4b "Seasonal efficiency for gas and liquid fuel
boilers" (PDF p.168), rows 101-119. Both certs now resolve main_fuel=26
and compute (was: hard raise).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:48:04 +00:00
Khalim Conn-Kowlessar
ec76acc3d8 test(modelling): ASHP pin from a gas boiler with instantaneous HW + fuel tripwire
Second ASHP before/after (boiler 2): a gas boiler whose hot water is electric/
instantaneous (water-heating SAP code 909, no cylinder). The cascade pin passes
at 1e-4, exercising the overlay's water_heating_code reset 909 -> 901 that the
boiler-1 pin (already 901) did not. (After lodges control 2209 vs the overlay's
2210 — SAP-equivalent zone controls.)

Adds an xfail(strict) tripwire test_gas_boiler_instant_hw_before_baselines: the
raw before is not scorable on its own because the mapper maps the 'BGB' gas-
boiler EES code to an empty main_fuel_type (boiler-1's 'RGE' resolves to 26),
so Sap10Calculator raises MissingMainFuelType. Harmless to the pin (the overlay
overwrites fuel -> 30); flips green when the mapper derives mains gas from the
gas-boiler SAP code (separate mapper-front fix). ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:40:25 +00:00
Khalim Conn-Kowlessar
bb8307413f fix(mapper): read sloping_ceiling_insulation_thickness for roof code 8
A "Pitched, sloping ceiling" (roof_construction == 8) lodges its
insulation in the dedicated `sloping_ceiling_insulation_thickness` field,
not `roof_insulation_thickness` (which stays None — the loft-joist field
is meaningless for a slope-following ceiling). The schema dataclasses
dropped that field, so `from_dict` discarded it and the cascade treated
the slope as uninsulated; worse, the pre-1950 None-fallback forced 0 mm
(U=2.30), over-stating roof heat loss ~74%.

Surface the field on SapBuildingPart (schemas 21.0.0 / 21.0.1) and prefer
it in `_api_resolve_sloping_ceiling_thickness` when it carries a NUMERIC
thickness: "100mm" now reaches Table 17 column (1a) "Insulated slope –
sloping ceiling, mineral wool/EPS" (RdSAP 10 §5.11.3 p.44 — 100 mm →
U=0.40) instead of 2.30. Categorical lodgements ("AB" As Built / "NI")
are not measured thicknesses, so they fall through to the existing
as-built rule (Table 18 col (3) via is_pitched_sloping_ceiling).

Cert 9884-3059-9202-7506 (code 8, age B, sloping 100 mm): SAP −5.54 → +0.06.
Cert 8036-2925-6600-0202: −4.94 → +1.55. No regressions in the roof-8
cohort (the "AB" certs are unchanged). Eval headline 43.8% → 44.3% within
0.5; golden fixtures incl. 6035 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:37:17 +00:00
Khalim Conn-Kowlessar
98f71d2554 feat(diag): per-component cost decomposition for API SAP errors
Mirror of eval_api_sap_accuracy.py that decomposes each cert's SAP error
into per-component energy/cost deltas WITHOUT generating an Elmhurst
worksheet. Calibrates the consumer price from the certs we already get
right (gas £0.0809/kWh n=291, elec £0.2839/kWh n=326 over |SAP err|<0.4),
then for every cert compares our_component_kWh × price to the lodged
heating_cost_current / hot_water_cost_current / lighting_cost_current and
back-calculates a numeric energy target (lodged_cost / price).

Clusters errors by (component × direction). On the 905-cert sample this
reveals heat:high (we over-state heating energy → under-rate SAP) as the
dominant broken cluster: 332 certs, only 36.7% within 0.5. Output CSV at
<cache>/_cost_decomposition.csv.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:37:05 +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