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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The Modelling glazing overlay's draught-proofing recompute (RdSAP 10 §8.1 —
a count over openable windows + doors) needs every openable window captured
with its draught_proofed flag. cert 001431's §11 lodges 17 windows but only
14 surfaced, via two distinct gaps:
1. Extractor (_extract_windows_from_layout): the one "Double glazing, known
data" row whose §11 Data-Source cell is "BFRC data" was rejected — it is
laid out as a standalone keyword line with the U-value on the next line
and lodges no Frame Type/Factor/Gap cells, so it never matched the joined
"<source> <U>" Manufacturer-line shape. Now anchored by a standalone
data-source form, with the RdSAP 10 §3.7 default frame factor (0.7) for
the absent frame cell.
2. Mapper (_is_elmhurst_roof_window): the two "Double pre 2002" rows
(U 3.1 / 3.4 > 3.0) were reclassified as roof windows by the U-value
backstop even though both are lodged on an "External wall". A window
lodged on a wall is vertical by definition; guard the U-value backstop so
it only fires when location/BP give no roof signal. The backstop's only
pinned cert (000516 W6) hand-builds its sap_roof_windows and so is
unaffected.
With both closed: 17 sap_windows, 0 misrouted to sap_roof_windows, 14
draught-proofed — reconstructing Elmhurst's lodged 84% (16/19 = (14 windows
+ 2 doors) / (17 windows + 2 doors)). Full calculator + modelling +
orchestration suites green (1885 pass); the 2 glazing draught-proofing
xfails remain (the overlay recompute is the glazing agent's front).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Slice 3 of the lighting generator (ADR-0023): two real Elmhurst before/after
cascade pins, sourced from the low_energy_lighting recommendation folder. Both
close cleanly at 1e-4 with NO xfail — lighting changes only bulb counts →
Appendix L (232), no fabric coupling (contrast glazing's draught-proofing).
- zero existing LEDs: 20 incandescent → 20 LED
- some existing LEDs: 5 LED + 15 incandescent → 20 LED (partial-upgrade path)
The overlay-emits-correct-counts assertion lives in the Slice 2 unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
PR feedback (dancafc): the SQLModel column was Optional[str], but the
domain `SapBuildingPart.wall_insulation_thickness` is Optional[Union[str,
int]] — `_api_resolve_wall_insulation_thickness` returns an int mm when the
API lodges `wall_insulation_thickness == "measured"` (SAP 10.2 §5.7 /
Table 8). The plain str column round-trips that int back as the string
"100", corrupting the Table 8 insulated-wall U-value lookup.
This column was missed in the round-trip-fidelity §1 JSONB sweep
(#1129) — its `Union[str, int]` sibling `roof_insulation_thickness` was
converted, but `wall_insulation_thickness` was not, and no 21.0.0/21.0.1
fixture lodges "measured" so the gap stayed latent. Convert to JSONB
(matching `roof_insulation_thickness` / `flat_roof_insulation_thickness`),
align the column type to Optional[Union[str, int]] (also removes a pyright
type-mismatch), record it in the migration doc §1, and add a round-trip
guard test asserting an int survives as an int (fails as '100' == 100 on
the old str column).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The merged per-cert-mapper-validation work disambiguates Elmhurst 'SY System
build' from 'B Basement wall' (both lodged wall_construction=6), so
main_wall_is_basement is no longer wrongly True for system-built and the
solid-wall generator offers EWI+IWI. The strict xfail now XPASSes; drop the
marker so it stands as a real green cascade pin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The worksheet build_epc() fixtures wrapped a module-level SECTION_6_VERTICAL_
WINDOWS tuple in list(), so every call returned the SAME SapWindow objects. A
test that mutated a returned window (the glazing slices flip glazing_type to
single) leaked that change into every later build_epc() -- which surfaced as
double_glazing-product failures in the first-run integration tests only when
test_console ran first in the same process.
Deep-copy the windows per call in all six fixtures (000474/477/480/487/490/516)
so each EpcPropertyData owns an independent window graph, and drop the
now-redundant defensive copy at the glazing test's call site.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
The double_glazing / secondary_glazing Elmhurst before→after Summaries the
glazing generator's cascade pins will use (ADR-0022). NB: these don't parse
yet — cert 001431 lodges several unmapped glazing labels ("Secondary glazing",
"Secondary glazing - Normal emissivity", "Triple pre 2002", truncated
"Double…"/"Triple…" variants) that the mapper must cover first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
The gov-EPC API mapper sets BOTH roof_construction (int) and
roof_construction_type (str, derived via _API_ROOF_CONSTRUCTION_TO_STR),
but the Elmhurst mapper set only the string — leaving roof_construction
None on every site-notes cert. The SAP cascade reads the STRING (so SAP
cross-mapper parity always held), but consumers of the int (e.g.
domain/sap10_ml/transform.py ML aggregates `main_dwelling_roof_
construction`) silently saw None on the Elmhurst path.
New `_elmhurst_roof_construction_int` maps the Elmhurst roof-type code to
the same SAP10 int the API lodges (F→1, PN→3, PA→4, PS→8, S/A→7),
harvested from the committed Summary fixtures. Unlike the wall map it
returns None (not a strict-raise) for unmapped codes: the int is not
cascade-load-bearing, so an unknown roof must not block the cert (vaulted
5 / thatched 6 / NR omitted until a fixture surfaces them).
The 6 hand-built U985 reference fixtures gain the matching
roof_construction int (4/4/3 etc.) so test_from_elmhurst_site_notes_
matches_hand_built_* still asserts structural parity. SAP output is
unchanged (cascade reads the string). §4 suite green (2407 passed); the
two pre-existing stone-§5.6 sap10_ml failures are unrelated/out of scope.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
RdSAP10 `wall_construction == 6` is canonically WALL_SYSTEM_BUILT — a
WALL TYPE — but the gov-EPC basement heuristic hijacked it: Elmhurst
lodges both "SY System build" and "B Basement wall" as code 6, and the
API lodges basements as code 6 too, so a system-built wall was
mis-flagged `main_wall_is_basement` → wrong RdSAP §5.17 / Table 23
u_basement_wall/u_basement_floor overrides, and downstream the solid-wall
Recommendation Generator couldn't offer EWI/IWI on system-built walls.
System-built stays the wall type on its canonical code 6; the basement
signal moves OFF code 6 to a dedicated `is_basement` (SapAlternativeWall)
/ `wall_is_basement` (SapBuildingPart) Optional[bool] flag:
- Elmhurst: `_elmhurst_wall_is_basement` sets it from the distinct
"SY"/"B" labels (False for SY, True for B, None otherwise).
- gov-EPC API: per-wall code 6 can't be told apart at lodging time, so
`from_api_response` post-processes via `_clear_basement_flag_when_
system_built` — when the cert addendum marks the dwelling system-built,
the code-6 basement heuristic is cleared. A genuine basement (no
addendum signal) keeps the code-6 fallback.
- `main_wall_is_basement` / `is_basement_wall` honour the flag when set,
else fall back to the code-6 heuristic — so untouched API basements and
the cert 000565 "B" cohort are unchanged.
`EpcPropertyData.system_build` is a derived property over the wall type:
the MAIN wall is system-built iff `wall_construction == 6` and it is not
flagged basement. System-built lives on `wall_construction`; the basement
attribute is separate.
Acceptance: a system-built main wall (Elmhurst SY, or API addendum
system_build) → wall_construction == 6, main_wall_is_basement is False,
system_build is True; a genuine basement main wall → main_wall_is_basement
is True, system_build is False. Full §4 suite green (2404 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RdSAP10 `wall_construction == 6` is canonically WALL_SYSTEM_BUILT, but
the gov-EPC basement heuristic hijacked it: Elmhurst lodges both "SY
System build" and "B Basement wall" as code 6, so a system-built wall
was mis-flagged `main_wall_is_basement` and routed to the RdSAP §5.17
`u_basement_wall` override instead of the system-built U-value table.
System-built stays on its canonical code 6; the basement signal moves
to an explicit `is_basement` (SapAlternativeWall) / `wall_is_basement`
(SapBuildingPart) Optional[bool] flag, set by the Elmhurst mapper from
the distinct "SY"/"B" codes via `_elmhurst_wall_is_basement` (True for
B, False for SY, None otherwise). The `main_wall_is_basement` /
`is_basement_wall` properties honour the flag when set and fall back to
the gov-EPC API code-6 heuristic when None — so the API path (basement
lodged as integer 6, no flag) and the cert 000565 "B" cohort are
unchanged.
Acceptance (a recommendation-summary generator depends on it): a
system-built MAIN wall reports wall_construction == 6 AND
main_wall_is_basement is False; a genuine basement main wall still
reports main_wall_is_basement is True.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>