A 440 mm (>420 mm) solid brick AS-BUILT wall computed U = 1.70 (the
220 mm bucket default) instead of the RdSAP-correct 1.10. The §5.7
Table 13 thickness path only fired for *insulated* brick (external/
internal + thickness > 0); the as-built case fell through to the
Table 6 cavity/solid age-band default.
Spec: RdSAP 10 Specification (9th June 2025), §5.7 "U-values for
uninsulated brick walls, age bands A to E", Table 13 (PDF p.40):
≤200 mm → 2.5, 200–280 mm → 1.7, 280–420 mm → 1.4, >420 mm → 1.1.
Table 6 footnote (b) on the "Solid brick as built" row (PDF p.40):
"Or from 5.7 if wall thickness is other than 200mm to 280mm" — the
thickness table supersedes the flat 1.7 default whenever a documentary
wall thickness is lodged (200–280 mm gives 1.7 either way). The §5.8 /
Table 14 dry-lining R is added on top only when the wall is dry-lined,
per the §5.7 closing sentence.
Validated against the user-generated Elmhurst worksheet "simulated
case 21" (replica of API cert 2818-3053-3203-2655-9204: mid-terrace,
age band B, solid brick as-built 440 mm, room-in-roof). New §3 cascade
pin `test_section_3_wall_u_by_thickness_case21_match_pdf` routes the
Summary through the real extractor + mapper and pins:
(31) 155.1000, (33) 175.6208, (36) 23.2650, (37) 198.8858 — all 1e-4.
External walls Main U → 1.1000; Sheltered RR gable → 1/(1/1.10+0.5) =
0.71 (was 0.92). Pinned on §3 only (case-6 precedent): its code-908
instantaneous multi-point gas water heater has a separate §4 (219) gap.
Cross-check: sim case 20 (220 mm) stays at 1.70 — unchanged.
API SAP accuracy (scripts/eval_api_sap_accuracy.py, 896 computed certs):
% |err| < 0.5 SAP vs lodged: 42.6% → 43.8%; mean |err| 2.045 → 2.010.
Regression: tests/domain/sap10_calculator/ (1861), backend/
documents_parser/tests/ (574), datatypes/epc/ + rdsap golden fixtures
all green (pre-existing test_total_floor_area excepted). pyright strict
net-zero. No solid-brick fixture pin shifted (200–280 mm unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2026 API sample raised UnmappedApiCode on `gable_wall_type` 2 (10 certs)
and 3 (4 certs) — the two RR gable variants beyond Party(0)/Exposed(1).
Sim case 21 (an Elmhurst replica of API cert 2818-3053-3203-2655-9204:
gable_wall_type_1=2, gable_wall_type_2=3) lodges them as "Sheltered" and
"Connected", confirming **2=Sheltered, 3=Connected**.
- Mapper: `_API_TYPE_1_GABLE_TYPE_TO_KIND` gains 2 → `gable_wall_sheltered`,
3 → `connected_wall` (U=0, area deducts — already handled).
- Calculator: new `gable_wall_sheltered` branch. The API path lodges no
per-gable U, so the cascade DERIVES it as RdSAP 10 Table 4 (p.22)
Sheltered = 1/(1/U_wall + 0.5) — back-solved + validated against case 21
(U_wall 1.10 → 0.71) and case 20 (1.70 → 0.92). A lodged U (Summary path)
still rides through as an override.
API sample: 14 raises clear → `computed` 882 → 896, `raise:ValueError` 16 → 2.
Summary path unchanged (Sheltered stays `gable_wall_external` + lodged U, so
cert 000487's hand-built fixture is untouched). 2861 pass (lone
test_total_floor_area pre-existing); pyright strict net-zero (32=32 / 12=12).
NOTE: the derived Sheltered U on cert 2818 lands at 0.92 not 0.71 because the
cascade computes its 440 mm solid-brick wall U as 1.70 (the 220 mm default) —
a SEPARATE wall-U-vs-thickness bug (next slice, validated by case 21's 1.10).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Locks sim case 20 (storage heaters + Detailed RR + loose-jacket cylinder)
as a golden vector: _elmhurst_worksheet_001431_case20.build_epc() routes the
Summary PDF through extractor → mapper → calculator, registered in
test_e2e_elmhurst_sap_score with all 11 SapResult headline pins at 1e-4.
10 pinned exact off slices 1-2 (window extractor, RR stud walls); this slice
closes the last one, co2_kg_per_yr (was 3797.62 vs (272) 3815.4060).
Root cause: on a dual-rate (E7) meter the CO2 path ignored the tariff's
high/low Table-12 electricity codes that the cost path already uses:
- Secondary (direct-acting portable heaters, on-peak) keyed the monthly
Table 12d cascade on standard code 30 (0.15405) instead of the E7 HIGH
code 32 → (263) 0.1616. SAP 10.2 Table 12a Grid 1 direct-acting electric
is 100% high-rate; mirrors the cost side billing it at 15.29 p/kWh.
- Main storage heaters fell through `_table_12a_system_for_main`=None to
the FLAT annual factor (0.136) rather than the dual-rate LOW code: per
the Table 12a design intent ("storage … 100% low rate") they charge
off-peak → E7 LOW code 31 → (261) 0.1357.
case-20 co2 now EXACT. 2433 calculator + 112 golden + documents_parser tests
pass — no dual-meter/storage cohort regression; pyright strict net-zero (32=32).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 fuel codes the calculator now puts on SapResult are its own codes — raw
gov-API enums or already-Table-32, depending on the source mapper (ADR-0015).
sap_code_to_fuel now runs the code through table_32.to_table_32_code
(promoted from private _to_table_32_code) — T32-first, then API-translate,
the SAME normalization the calculator's pricing/CO2 helpers use — before the
Table-32 -> Fuel dispatch, so the bill's carrier matches what the calculator
billed (incl. the API/T32 collision codes, e.g. 20 = wood-logs not heat-net).
Falls back to the raw code for billing fuels the price table omits (the 41-58
heat-network range), which resolve to HEAT_NETWORK -> UnpricedFuel — stricter
than, and intentionally divergent from, the calculator's lossy
default-to-mains-gas for an unpriced code (ADR-0014 §5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ADR-0014 BillDerivation attributes each end-use (HEATING / HOT_WATER /
SECONDARY / APPLIANCES / COOKING) to a fuel carrier and credits PV
export. SapResult already carried the per-end-use kWh but not WHICH
fuel each end-use burns, nor the annual exported kWh — so a downstream
SapResult->EnergyBreakdown adapter could not pick the right tariff.
Surfaces five output-only fields, threaded exactly like the recently
merged appliances/cooking change (2f039aeb):
main_heating_fuel_code RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
main_2_heating_fuel_code code column (the lodged fuel code, e.g.
secondary_heating_fuel_code mains gas 26). None when the corresponding
hot_water_fuel_code system is absent / fuel not resolvable.
pv_exported_kwh_per_yr SAP 10.2 Appendix M1 §3-4 annual export kWh
(0.0 when no PV).
cert_to_inputs.py populates the four fuel codes from the existing
resolvers the cost/CO2 cascade already uses — `_main_fuel_code`,
`_secondary_fuel_code`, `_water_heating_fuel_code` (not reinvented);
Main 2 is the second `main_heating_details` entry, guarded for length.
There is a single CalculatorInputs construction site (cert_to_demand_
inputs delegates to cert_to_inputs). `pv_exported_kwh_per_yr` already
existed on CalculatorInputs; SapResult collapses its Optional to 0.0.
HARD CONSTRAINT honoured — output-only, zero rating drift. These fields
do NOT feed ECF / total_fuel_cost_gbp / co2_kg_per_yr / primary_energy_*
/ sap_score / any monthly value. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical: calculator suite 1658 -> 1661 passed (+3 new tests),
4 skipped, 0 failed before and after. pyright net-zero (51 -> 51 in
domain/; no new errors in the touched test files).
New tests: a synthetic threading test (four fuel codes + PV export pass
unchanged through calculate_sap_from_inputs; None PV collapses to 0.0)
and a cert-level pin (mains-gas combi cert 000516 -> main fuel code 26,
no Main 2, secondary 30, HW 26). Synthetic CalculatorInputs / SapResult
fixtures updated for the new SapResult fields (defaults cover Inputs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>