Commit graph

576 commits

Author SHA1 Message Date
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
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
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
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
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
Khalim Conn-Kowlessar
07dbaa5361 feat(modelling): detect single-glazing code 15 + glazing before/after pins
With the mapper now in main, cert 001431 parses: it lodges four single-glazed
windows — codes 1 ("Single") and 15 ("single glazing, known data", a single
pane with manufacturer U/g). The generator only detected code 1, so it missed
two panes. Detect {1, 15}; set the secondary target to code 11 ("Secondary
glazing - Normal emissivity", what the cert re-lodges; score-neutral vs 7 but
exact).

A deterministic green pin proves the overlay reproduces the after's 14 windows
exactly. The full-SAP before->after pins are xfail(strict) tripwires: the
overlay nails the windows, but the measure also re-lodges percent_draughtproofed
84->100 (sealed units draught-proof the replaced openings) plus a ~0.4 SAP
fabric residual the overlay doesn't model yet — a glazing-measure coupling to
close later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:23:57 +00:00
Khalim Conn-Kowlessar
2c36a8e1d6 Merge remote-tracking branch 'origin/main' into feature/bill-derivation
# Conflicts:
#	repositories/property/property_postgres_repository.py
#	tests/orchestration/fakes.py
2026-06-05 11:09:00 +00:00
Khalim Conn-Kowlessar
36f74360a5 feat(modelling): explicit park-home guard in the solid-wall generator
ADR-0019 warns that wall_construction code 8 is Park home (PH), NOT system-
built. It was already excluded (8 isn't in the constructable-options map), but
only implicitly. Add an explicit early-return + named constant so a park home
can never be mis-keyed as system-built, with a pin as the tripwire. A park
home's proprietary panel is never EWI/IWI-suitable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:49:50 +00:00
Khalim Conn-Kowlessar
86b875af35 review: clearer room-in-roof area variable names in heat_transmission
PR feedback (dancafc): the simplified room-in-roof branch used cryptic
locals. Rename for clarity (behaviour-unchanged; the geom dict keys and
the builder-function locals are untouched):

  rr_a_rr      -> rr_roof_area          (the worksheet's simplified A_RR)
  rr_common    -> rr_common_wall_area
  rr_gable     -> rr_gable_area
  a_rr_final   -> rr_residual_roof_area  (leftover roof-going area after
                                          deducting perimeter walls/gables
                                          /rooflights — takes the roof U)

Names now mirror the `rr_*_area_m2` geom keys they read from and say
"area of what". Added a one-line note that `rr_roof_area` is the RdSAP 10
§3.10.1 A_RR. Pyright unchanged; 1087 heat-transmission/cascade-pin tests
pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:18:58 +00:00
Khalim Conn-Kowlessar
f33bb9d52d feat(modelling): room-in-roof safety guard defers the roof generator
A room-in-roof carries its insulation on its own sloping/stud/gable surfaces
(RdSAP 10 §3.10, Table 17/18), which the roof overlay's flat
roof_insulation_thickness bump cannot model. Without a guard a RR with an
uninsulated loft fell through to the loft fallback and mis-recommended 300 mm
loft insulation. Return None when the main part lodges a sap_room_in_roof,
deferring until a dedicated RR branch lands (ADR-0021).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:06:08 +00:00
Khalim Conn-Kowlessar
218840db98 docs: handover for the open window-extraction work on the double_glazing fixture
Captures the diagnosis so the next agent doesn't re-derive it: what's done
(S0380.235-237), what's confirmed correct (calculator U-adjustment, party
wall, glazing labels), the worksheet pin targets, and the two open causes —
crucially the 000516 trap (byte-identical Summary data classified as a roof
window there but a wall window here, so flipping the U>3 rule regresses
000516). Includes a rebuildable tracer recipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:47:29 +00:00
Khalim Conn-Kowlessar
8323d9cf07 Merge branch 'feature/per-cert-mapper-validation' of https://github.com/Hestia-Homes/Model into feature/bill-derivation 2026-06-05 09:38:40 +00:00
Khalim Conn-Kowlessar
456a81df0a feat(modelling): wire glazing generator into the candidate pool
Slice 4 of the glazing generator (ADR-0022): run recommend_glazing in
_candidate_recommendations, threading the Property's PlanningRestrictions so a
protected dwelling is offered secondary glazing instead of double (mirrors
recommend_solid_wall). Price both Measure Types in the offline catalogue
(double £600/window, secondary £510 -- the legacy 0.85x scaling) and the
contingency table (0.15, the legacy windows_glazing rate); the
_GENERATOR_MEASURE_TYPES forcing test enforces both entries exist.

run_modelling tests pin the wiring end-to-end on an all-single-glazed dwelling:
double when unrestricted, secondary when listed. The first-run integration test
seeds a double_glazing Product because its lodged EPC has a single-glazed
window. _single_glazed_epc() deep-copies build_epc() (which shares its window
objects) so the mutation can't leak into other tests' baselines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:29:09 +00:00
Khalim Conn-Kowlessar
276dd1a500 feat(modelling): planning protection picks secondary over double glazing
Slice 3 of the glazing generator (ADR-0022): a conservation/listed/heritage
protection (PlanningRestrictions.blocks_external) hard-picks secondary_glazing
instead of double_glazing -- an internal second pane, since the external units
can't be replaced on a protected building. Each single-glazed window upgrades
to the secondary target pinned from cert 001431 (glazing_type=7, u_value=2.90,
solar_transmittance=0.85 -- the outer single pane still drives solar gain).

The before/after cascade pins for both measures remain deferred behind the
glazing-label mapper coverage (owned by another agent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:18:17 +00:00
Khalim Conn-Kowlessar
8d081cb9d6 feat(modelling): recommend_glazing upgrades single-glazed windows to double
The glazing Recommendation Generator (ADR-0022): detect single-glazed
windows (SAP10.2 Table U2 code 1) and emit one "Windows" Recommendation whose
single Option rewrites every single-glazed window to the double-glazing target
pinned from cert 001431's before->after (glazing_type=5, u_value=1.40,
solar_transmittance=0.72). The overlay writes the per-window U/g into
WindowTransmissionDetails because the calculator consumes those directly.
Priced as a flat per-window average x count. No single-glazed windows -> None.

Planning gate (-> secondary) and the before/after cascade pins land next; the
pins are blocked on glazing-label mapper coverage (owned by another agent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:17:09 +00:00
Khalim Conn-Kowlessar
6d4fa5dd3b docs: handover — fold in S0380.232-234 (case-19 PV closure) + open causes
Records the three PV slices shipped (D_PV off-peak exclusion, weighted
dwelling import price, Appendix G4 diverter), the resulting case-19 state
(SAP 50.33→51.34, rounds to lodged 51), and the two remaining case-19
causes (winter Appendix-M EPV monthly shape; fabric (33) +1.0). Adds the
`2100-5421` worst-offender diagnosis (a 352 m² uninsulated solid-wall
dwelling on the as-built-insulated-assumed roof-U front, not a flats bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:06:20 +00:00
Khalim Conn-Kowlessar
9521d52403 S0380.234: PV diverter (Appendix G4) — diverts surplus PV to the cylinder
SAP 10.2 Appendix G4 (PDF p.72-73). A PV diverter routes surplus PV
generation (the would-be export EPV,m × (1 − βm)) to an immersion heater
in the hot-water cylinder. Per G4 step 4:

    SPV,diverter,m = EPV,m × (1 − βm) × 0.8 × fPV,diverter,storageloss

(0.8 = cylinder heat-acceptance; fPV,diverter,storageloss = 0.9 for the
higher storage temperature), clamped to ≤ (62)m + (63a)m, and entered as
the negative worksheet (63b)m (step 5). The β factor is computed on the
PRE-diverter (219) per the §3a note (lines 5485-5486). Effects:
  - (64)m = (62)m + (63b)m → less main-system water-heating fuel (219);
  - export drops to EPV,ex,m = EPV,m(1 − βm) + (63b)m / 0.9 (§4 p.94
    line 5501); the onsite dwelling portion EPV,m × βm is unchanged.

Inclusion (G4 step 1) requires ALL of: a PV system connected to the
dwelling; a cylinder larger than (43) average daily HW use; no solar
water heating; no battery — else the diverter is disregarded.

Three layers:
  - extractor reads Summary §19 "Diverter present"; schema 21.0.0/21.0.1
    SapEnergySource gains `pv_diverter` (API `sap_energy_source.pv_diverter`);
  - `Renewables.pv_diverter_present` + domain `SapEnergySource.pv_diverter_present`,
    set in both the Elmhurst and API mapper paths;
  - `_pv_diverter_monthly_kwh` applies the G4 math after the β split;
    `cert_to_inputs` recomputes (219) and the PV export.

On simulated case 19 (electric storage heaters, 7-hour, PV + diverter):
SAP continuous 50.33 → 51.34 (worksheet 51.2221; both round to the
lodged 51), cost (255) 1847.5 → 1812.3 (ws 1816.6), CO2 (272) 3331 →
3120 (ws 3126), with (233a) dwelling 1280.6 (ws 1280.4). The residual
+0.11 SAP is an upstream winter Appendix-M monthly-EPV-shape gap +
fabric (33) +1.0, tracked as the next case-19 cause. Suite: 2412 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:59:12 +00:00
Khalim Conn-Kowlessar
d4a8c02b54 S0380.233: PV self-consumption credited at Table 12a weighted import rate
SAP 10.2 Appendix M1 §6 (PDF p.94, lines 5510-5513): "apply the normal
import electricity price to PV energy used within the dwelling and the
'electricity sold to grid, PV' price from Table 12 to the energy
exported. In the case of the former, use a weighted average of high and
low rates (Table 12a)."

`_pv_dwelling_import_price_gbp_per_kwh` was returning the bare off-peak
LOW rate (5.50 p/kWh on a 7-hour tariff) for the PV-used-in-dwelling
credit. PV self-consumption displaces the dwelling's "all other uses"
electricity (lighting / appliances / pumps), which on an off-peak tariff
bills at the Table 12a Grid 2 ALL_OTHER_USES weighted blend, not the low
rate. On simulated case 19 the worksheet (252)/(269) credits
PV-used-in-dwelling at 14.3110 p/kWh = 0.90 × 15.29 + 0.10 × 5.50; we
credited it at 5.50, under-crediting onsite PV by ~£0.088/kWh on every
off-peak PV cert.

Fix delegates to `_other_fuel_cost_gbp_per_kwh(tariff, prices)` (the same
ALL_OTHER_USES rate): STANDARD tariff still returns the flat Table 32
code 30 13.19 p/kWh (golden cohort unchanged — all 2412 tests pass);
off-peak returns the weighted high/low blend. Call sites now pass the
resolved `_rdsap_tariff(epc)`. The now-unused
`_off_peak_low_rate_gbp_per_kwh_via_meter_heuristic` (its only caller)
is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:57:40 +00:00
Khalim Conn-Kowlessar
275a521071 feat(modelling): per-window overlay surface on EpcSimulation
Slice 1 of the glazing generator (ADR-0022). `WindowOverlay` (all-optional
partial of one SapWindow) + `EpcSimulation.windows` keyed by sap_windows index.
The applicator folds it onto sap_windows[i]: glazing_type flat on the window,
u_value/solar_transmittance routed into its WindowTransmissionDetails (created
if absent) — the applicator's first nested write, because that's where the
calculator reads window heat loss and solar gain. Baseline left unmutated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:57:02 +00:00
Khalim Conn-Kowlessar
212b0c92ab S0380.232: D_PV excludes low-rate portion of off-peak electric main heating
SAP 10.2 Appendix M1 §3a (PDF p.93, lines 5470-5476): "E_space,m =
(211)m + (213)m + (215)m, where (211), (213) and/or (215) should be
included only where the fuel code applied to them in Section 10a of the
SAP worksheet is 30, 32, 34, 35 or 38 (i.e. electricity not at the
low-rate)."

The PV-eligible demand D_PV,m was adding 100% of the main space-heating
fuel (211)m whenever the main's Table-12 code was in the eligible set
(30, …), ignoring the off-peak high/low split that §10a already bills
via `_space_heating_fuel_cost_gbp_per_kwh`. Electric STORAGE heaters on
a 7-hour tariff are charged wholly at the low rate (Table 12a Grid 1 SH
fraction 0.00; worksheet (240) high-rate cost = 0), so none of (211)
may enter D_PV — but the cascade counted it all, inflating R_PV,m =
E_PV,m / D_PV,m and therefore the β onsite-PV split in the heating
months.

Fix mirrors the cost-side rate split: `_main_space_heating_high_rate_
fraction(main, tariff)` returns the high-rate portion (1.0 for
non-electric / STANDARD, the published Grid 1 SH fraction otherwise,
0.0 when the Grid 1 SH row is unwired → 100% low rate), and
`_pv_eligible_demand_monthly_kwh` scales the (211)m contribution by it.
Backward-compatible: STANDARD-tariff electric mains and the gas-main /
electric-secondary PV cohort are unchanged (fraction 1.0).

On simulated case 19 (electric storage heaters, 7-hour, PV) this takes
β_Jan 0.894 → 0.792, matching the worksheet 0.791, and the summer months
(no main heating) already pinned exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:07:04 +00:00
Khalim Conn-Kowlessar
2a29b29aa5 docs: handover — fold in S0380.227-231 + mapper parity work; refresh error landscape + worksheet asks
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:23:07 +00:00
Khalim Conn-Kowlessar
1a807a4c4c feat(modelling): price sloping-ceiling + flat-roof measures in the catalogue
Slice 4 (ADR-0021). The roof dispatcher can now emit sloping_ceiling_insulation
and flat_roof_insulation, so wire both into contingencies and the sample
catalogue; the forcing-function test now asserts every generator measure type
is both priced and has a contingency rate, so an offline/live run over a
sloping or flat roof never dies on a missing entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:20:38 +00:00
Khalim Conn-Kowlessar
13b18ce9fb feat(modelling): roof dispatcher insulates a flat roof
Slice 3 (ADR-0021). The dispatcher gains a flat-roof branch: a "flat"
roof_construction_type with no lodged thickness (uninsulated → None on the
Elmhurst path) gets a single flat_roof_insulation Option whose overlay raises
roof_insulation_thickness to 200 mm — tested before the loft fallback so a flat
roof's None doesn't trip the loft trigger. Pinned against the Elmhurst
before→after cert at 1e-4. Golden cohort roof firing unchanged (none across 57).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:16:00 +00:00
Khalim Conn-Kowlessar
7d40cddf3b feat(modelling): fold loft into the roof dispatcher; thatch routes to loft
Slice 2 (ADR-0021). `recommend_roof_insulation` now owns the loft branch as the
fallback — a plain pitched loft, a thatched roof (the covering doesn't block
insulating the loft floor), or an unlodged roof type all take loft (joist)
insulation at 300 mm when `roof_insulation_thickness == 0`. Sloping is tested
first; a no-access roof gets nothing. Retired the standalone
`recommend_loft_insulation`; the orchestrator and its tests now call the
dispatcher.

Pinned: thatch before→after (None→300) reproduces at 1e-4; the existing loft pin
still holds through the dispatcher. Behaviour-preserving on the golden cohort
(roof measure unchanged: none across all 57) — the dispatch is strictly more
precise (won't fire loft on a sloping/no-access roof).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:12:01 +00:00
Khalim Conn-Kowlessar
6484610b6c feat(modelling): recommend_roof_insulation insulates a sloping ceiling
Slice 1 of the roof-insulation generator (ADR-0021). New `recommend_roof_insulation`
dispatcher keys on the `roof_construction_type` string: a "sloping ceiling" roof
that is uninsulated (roof_insulation_thickness 0/None) gets a single
`sloping_ceiling_insulation` Option whose overlay raises roof_insulation_thickness
to 100 mm. Pinned against the Elmhurst before→after cert 001431 at 1e-4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:02:06 +00:00
Khalim Conn-Kowlessar
3684a142ac S0380.231: Dual-meter electric room heaters resolve to 10-hour tariff (RdSAP 10 §12 Rule 3)
RdSAP 10 §12 (PDF p.62) Dual-meter dispatch: "the choice between 7-hour
and 10-hour is made by the main heating type ... if the main system is a
direct-acting electric boiler (191), or electric room heaters ... it is
10-hour tariff." The electric room-heater codes — Table 4a 691 (panel/
convector/radiant), 692 (fan), 693 (portable), 694 (water-/oil-filled),
699 (assumed) — were missing from `_RULE_3_TEN_HOUR_CODES` (the long-
standing TODO there), so a Dual-meter room-heater cert fell through to
Rule 4 (7-hour default).

Compounded with S0380.230 (which routes room heaters to Table 12a
OTHER_DIRECT_ACTING_ELECTRIC): at 7-hour the high-rate fraction is 1.00
(all at 15.29 p), but at the correct 10-hour it is 0.50 split over the
10-hour rates (14.68 / 7.50 p) → blended ~11 p. Without this fix .230
over-charged and flipped the cluster from over- to under-rating.

1,000-cert 2026 API sample: cat-10 mean |err| 7.11 → 5.26, signed mean
+5.08 → -0.86 (now balanced, 22 over / 26 under — the systematic
directional bias is gone). Overall mean |err| 2.16 → 2.04. Full §4 suite
green (2406 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:51:09 +00:00
Khalim Conn-Kowlessar
0476b4b235 S0380.230: electric room heaters (cat 10) on off-peak bill at Table 12a direct-acting high rate
SAP 10.2 Table 12a Grid 1 (PDF p.191): an electric room heater (RdSAP
main_heating_category 10, e.g. SAP code 691) is direct-acting electric,
so it sits on the "Other systems including direct-acting electric" row —
7-hour high-rate fraction 1.00, 10-hour 0.50. It runs on demand, mostly
at the HIGH rate; it does NOT earn the 100%-low-rate of overnight storage
charging (which is category 7).

`_table_12a_system_for_main` only mapped ASHP, so an electric room heater
fell through to the "100% low-rate" fallback (5.50 p, £0.0550), under-
charging space heating by ~9.79 p/kWh and systematically OVER-rating the
cluster. Now maps electric cat-10 mains to OTHER_DIRECT_ACTING_ELECTRIC
(gated on `_is_electric_main`, so gas/solid-fuel cat-10 room heaters are
excluded). The same Table 12a fraction flows through cost, CO2 (Table
12d) and PE (Table 12e) — all three callers already pre-gate on electric.
Mirror of S0380.228 (same fallback bug for electric SECONDARY heating).

1,000-cert 2026 API sample (no worksheet for this cluster — ±0.5-vs-lodged
fallback bar): cat-10 mean |err| 9.49 → 7.11, %<0.5 10.4% → 16.7%;
headline %<0.5 42.5% → 42.9%, overall mean |err| 2.29 → 2.16. cat-7
(storage) and cat-2 (gas) unchanged. Full §4 suite green (2405 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:39:15 +00:00
Khalim Conn-Kowlessar
9a483b8711 docs: handover — fold in S0380.227-229 + PV diverter (G4) as the case-19 next slice
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:36:13 +00:00
Khalim Conn-Kowlessar
0f6b402345 S0380.229: primary loss applies for a dedicated water-heating boiler/circulator (WHS 911-931)
SAP 10.2 Table 3 (PDF p.160) row 1: primary circuit loss applies when
"hot water is heated by a heat generator (e.g. boiler) connected to a
hot water storage vessel via insulated or uninsulated pipes". The Table
4a hot-water-only codes (PDF p.166) 911 gas / 912 liquid / 913 solid
boiler-circulator + 921-931 range cooker with boiler are each a heat
generator feeding the cylinder through a primary loop.

`_primary_loss_applies` keyed only off the resolved DHW `main` — but for
these certs `_water_heating_main` returns the SPACE main (e.g. electric
storage heaters, SAP code 402, which has no primary loop), so every
boiler branch missed the gas water-boiler's primary circuit and (59)m
went to zero. New branch keys off `water_heating_code` ∈
`_WATER_HEATING_BOILER_CIRCULATOR_CODES`. 941 (electric HP for water
only) is excluded — HP DHW vessels follow the Table 3 integral-vessel
rules.

Simulated case 19 (electric storage main + WHS 911 + 210 L cylinder):
(62)m total HW demand 2493.30 → 3169.98 kWh/yr, matching the worksheet
(the missing 676.68 kWh/yr = the worksheet's (59) primary-loss annual
sum, h=5/p=0). The remaining (64)/(219) gap is the PV diverter (63b),
deferred to its own slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:35:12 +00:00
Khalim Conn-Kowlessar
ea4534f3af feat(modelling): system-built walls take EWI+IWI (blocked on basement-code fix)
System-built (precast/no-fines concrete) takes both solid-wall Options like
solid brick (ADR-0019), keyed on `wall_construction == 6` (WALL_SYSTEM_BUILT,
Elmhurst `SY`). A basement-suitability guard (`main_wall_is_basement`) is added
since a below-ground basement wall is never EWI/IWI-suitable.

This is currently inert: `B Basement wall` also maps to 6 (mapper.py:2100) and
`main_wall_is_basement` is derived as `wall_construction == 6`, so every code-6
wall reads as basement and is guarded out — the live cohort is unchanged. The
system-built EWI/IWI cascade pin is committed as a strict-xfail tripwire that
flips green the moment the calculator disambiguates system-built from basement
(MAIN wall_construction==6 with main_wall_is_basement False). `wall_construction
== 8` is Park home, not system-built — not keyed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:26:08 +00:00
Khalim Conn-Kowlessar
4911c56200 S0380.228: electric secondary on off-peak bills at Table 12a direct-acting high rate
SAP 10.2 Table 12a Grid 1 (PDF p.191): secondary heating is a direct-
acting electric room heater (RdSAP 10 §A.2.2 default), on the "Other
systems including direct-acting electric" row — 7-hour high-rate fraction
1.00, 10-hour 0.50. A room heater runs on demand, mostly at the high
rate; it does NOT earn the 100%-low-rate of overnight storage charging.

`_secondary_fuel_cost_gbp_per_kwh` previously returned the flat off-peak
LOW rate (5.50 p, £0.0550) for every off-peak electric secondary, under-
charging by 9.79 p/kWh. New `_secondary_off_peak_rate_gbp_per_kwh` mirrors
`_space_heating_fuel_cost_gbp_per_kwh`: it blends the Table 12a high-rate
fraction (OTHER_DIRECT_ACTING_ELECTRIC) against the Table 32 high/low
rates, with the 18-/24-hour fallback to the low rate.

Simulated case 19 (electric storage main + electric secondary, Dual/7-hour
meter) is the worksheet case (242): "Space heating - secondary
(1.00*15.29 + 0.00*5.50)" → 15.29 p/kWh = £0.1529. This was the primary
cat-7-cluster cost driver: total cost 1485.68 → 1835.53 (worksheet
1816.58), SAP cont 60.11 → 50.67 (worksheet ~51.22). Remaining +19 cost
is HW/space-heating kWh (next slices).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:00:38 +00:00
Khalim Conn-Kowlessar
5d4b55d7f9 S0380.227: dedicated DHW-only system is not separately timed (Table 2b note b)
SAP 10.2 Table 2b note b (PDF p.159) applies the ×0.9 temperature-factor
reduction only when DHW is "separately timed" relative to space heating
on a SHARED heat generator ("boiler systems, warm air systems and heat
pump systems"). Per RdSAP 10 §10.5.1 (PDF p.55) a separate boiler/
circulator providing DHW only (water-heating code 911 = "Gas boiler/
circulator for water heating only") is NOT the main space-heating system
— so there is no shared timer to apply the ×0.9 against. `_separately_
timed_dhw` now returns False when water_heating_code is not "from main /
2nd-main system" ({901,902,914}), mirroring the existing WHC 903 electric-
immersion carve-out.

Simulated case 19 (electric storage main SAP 402 + WHS 911 + 210 L
loose-jacket cylinder) is the worksheet case. The single flag drives both:
- (53) Temperature factor: 0.54 → 0.6000 (worksheet base, no ×0.9)
- (55) storage loss/day: → 3.4531; (56)/(57)m Jan → 107.0456 (1e-4)
- (59)m primary loss: h=3 (43.31) → h=5 (Jan 64.5792), worksheet-exact

This also worksheet-pins S0380.224's loose-jacket storage loss magnitude
at 1e-4, previously only direction-validated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:44:11 +00:00
Khalim Conn-Kowlessar
796dce9d69 docs: handover — fold in S0380.224-226 + simulated case 19 debug state
Bump HEAD/next-slice/baseline, note the committed scripts toolkit, and add
the active "simulated case 19" section: the electric-storage-heater +
loose-jacket worksheet the user generated, what S0380.226 unblocked, and
the prioritised cluster bugs it exposed (cost (255) -334 = the +9 SAP
driver; Table 2b TF x0.9; WHS-911 storage-vs-combi routing; fabric +1.0).
Updated the "what to generate" ask to the two highest-value follow-ups
(electric room heaters; Sheltered/Adjacent RR gables).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:14:05 +00:00
Khalim Conn-Kowlessar
9be95a0d3b feat(geospatial): one-read spatial reference (coords + restrictions)
Slice 3c.1. Ingestion will persist a UPRN's coordinates and planning
protections together as a write-through cache, so resolve them in a single
partition read rather than two. `SpatialReference` bundles the coordinates
(which drive the Solar fetch) and the `PlanningRestrictions` (which gate wall
insulation per ADR-0019/ADR-0020); `GeospatialRepository.spatial_for(uprn)`
returns it, and `coordinates_for`/`planning_restrictions_for` now delegate to
the one lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:13:39 +00:00
Khalim Conn-Kowlessar
19ed29e13c docs: handover — 1000-cert API accuracy study + next-steps + worksheet ask
Captures the wide-scale 2026-register study (41.8% <0.5, heating-driven
cluster table), the 7 slices shipped (S0380.219-225), the prioritised
remaining work (electric-heating clusters + worksheet-backed raises), and
the single highest-ROI worksheet to generate: an electric-storage-heater
house with a loose-jacket cylinder + a room-in-roof with Sheltered/
Adjacent gables + an extension — one document that validates the #1
accuracy cluster, pins the S0380.224 loose-jacket fix at 1e-4, closes the
gable_wall_type Table 4 raise, and exercises multi-bp fabric.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:37:03 +00:00
Khalim Conn-Kowlessar
c5182627ba feat(modelling): thread Property planning restrictions to the solid-wall gate
Slice 3b+3d (ADR-0019/0020). Property gains a planning_restrictions attribute
(default unrestricted); the ModellingOrchestrator threads it from the Property
through _plan_for -> _scored_candidate_groups -> _candidate_recommendations into
recommend_solid_wall, replacing the unrestricted default. run_modelling exposes
a planning_restrictions param so the offline harness can inspect restricted
properties. Integration test: a listed solid-brick dwelling that gets IWI when
unrestricted now yields no wall insulation. 145 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:32:26 +00:00
Khalim Conn-Kowlessar
9c0a373f7d S0380.225: §10.7 no-water-heating default — A-F → 12mm loose jacket
The §10.7 no-water-heating default cylinder raised UnmappedSapCode for
age bands A-F (2 certs in a 2026 sample, bands B + C) because Table 29's
"A to F: 12 mm loose jacket" row wasn't plumbed — the loose-jacket
storage-loss branch didn't exist. S0380.224 added it, so this slice
completes the Table 29 lookup.

Restructure _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE to carry
(cylinder_insulation_type, thickness_mm) per band — A-F → (loose jacket,
12), G/H → (factory, 25), I-M → (factory, 38) per RdSAP 10 Table 29
(PDF p.56) — and have the default read both, setting the loose-jacket
type for A-F instead of hardcoding factory. The strict-raise is retained
only for an absent / out-of-A-M age band (no Table 29 row).

Validated: certs 2211 (band B, SAP 49.8 vs lodged 52) and 3420 (band C,
11.2 vs 11) now compute. §4 + golden suite 2395 passed — the corpus
"no system" cert (age G, 25 mm factory) is unchanged. cert_to_inputs.py
pyright unchanged at 32; new test suppresses reportPrivateUsage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:28:25 +00:00
Khalim Conn-Kowlessar
dab2e759bf feat(geospatial): read planning restrictions co-located with coordinates
Slice 3a (ADR-0020). PlanningRestrictions relocated out of the solid-wall
generator into domain/geospatial/ as the shared, Property-level value object
(three distinct flags + measure-specific blocks_external/blocks_internal).
GeospatialRepository gains a non-abstract planning_restrictions_for defaulting
to None (sources without the flags need not implement it); GeospatialS3Repository
reads conservation_status/is_listed_building/is_heritage_building from the same
Open-UPRN partition as the coordinates (legacy column names — to confirm in the
S3 deep-dive). Shared _row_for helper dedups the partition lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:26:51 +00:00
Khalim Conn-Kowlessar
2e351be957 S0380.224: compute storage loss for loose-jacket cylinders (Table 2 Note 1)
`_cylinder_storage_loss_override` returned None for any cylinder whose
cylinder_insulation_type wasn't 1 (factory), so a loose-jacket cylinder
(code 2, RdSAP 10 field 7-11) fell to the cascade's zero-storage-loss
combi/instantaneous default — its real storage loss vanished. SAP 10.2
Table 2 Note 1 gives loose jacket a SEPARATE, ~2× higher loss factor
(L = 0.005 + 1.76/(t+12.8) vs factory 0.005 + 0.55/(t+4)); the
cylinder_storage_loss_factor_table_2 helper already implements it — only
the dispatch was missing.

Fix: a `_cylinder_storage_loss_insulation_label` resolver maps the lodged
code to the Table 2 branch (1 → factory_insulated, 2 → loose_jacket;
None/0/unknown → None, keeping the conservative no-loss default). The
override and the HW storage call now route through it instead of
hardcoding "factory_insulated".

Evidence + validation: a random 2026 register sample has 22 loose-jacket
certs that over-predicted SAP by +2.29 mean (18/22 too high, 1/22 within
0.5) — the exact signature of under-counted HW storage loss. After the
fix their mean error collapses to +0.45 and 11/22 land within 0.5, with
ZERO regression across the worksheet-validated cohort (§4 + golden suite
2394 passed — no validated cert lodges loose jacket, so none shifts).
Also unblocks the §10.7 A-F no-water-heating default (next slice) which
needs the loose-jacket branch. cert_to_inputs.py pyright unchanged at 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:19:35 +00:00
Khalim Conn-Kowlessar
7648032d73 feat(modelling): wire solid-wall insulation into the candidate pool
Slice 2e. recommend_solid_wall joins the orchestrator's fabric generator pool
(restrictions default unrestricted until slice 3 sources them); the harness
catalogue + contingencies (26%) gain external_wall_insulation /
internal_wall_insulation. run_modelling on an uninsulated solid-brick dwelling
(baseline SAP 36.6) now selects internal wall insulation into the optimised
package; the catalogue-completeness guard covers both new measure types.
Golden cohort 57/57 still error-free; IWI now fires on a real cohort cert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:15:56 +00:00
Khalim Conn-Kowlessar
0cef044503 feat(modelling): flat gate drops EWI on solid-wall insulation
Slice 2d. A flat can take IWI (its own unit) but not EWI (whole-block
coordination) — ADR-0019. _is_flat handles both ingestion representations:
the Elmhurst name form ('Flat') and the API stringified RdSAP code ('2' = Flat
per PROPERTY_TYPE_LOOKUP). Completes slice 2's eligibility surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:54:44 +00:00
Khalim Conn-Kowlessar
51ea4993a0 feat(modelling): planning-restriction gate on solid-wall insulation
Slice 2c. recommend_solid_wall takes a PlanningRestrictions value object
(defaults unrestricted): a conservation area removes the EWI Option (external
appearance), a listed or heritage building removes both EWI and IWI (protected
fabric) -> None when nothing survives (ADR-0019). Plus a guard that a cavity
wall yields no solid-wall Recommendation (it is handled by recommend_cavity
_wall). PlanningRestrictions will be sourced onto the Property from the
geospatial layer in slice 3 (ADR-0020).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:41:22 +00:00