From ee98dbe0ec534e4e62e55c3685d7299a97505786 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 25 May 2026 08:11:03 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2062:=20hand-built=20=5Felmhurst=5Fworksh?= =?UTF-8?q?eet=5F001479.py=20=E2=80=94=20skeleton=20+=2011=20RED=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-driven pivot from cascade chain-pin chase to the rigorous cohort pattern: a hand-built EpcPropertyData that cascades to the worksheet at 1e-4 is the ground truth for cross-mapper parity testing. Both the Elmhurst mapper and the API mapper should ultimately produce a hand- built-equivalent EpcPropertyData for cert 001479; every divergence from the hand-built is a mapper bug. This skeleton encodes the cert 001479 worksheet inputs: - 3 building parts (Main C, Ext1 L, Ext2 C) with per-bp wall U - Main party wall CU (cavity unfilled, U=0.50, lodged via WC_CAVITY=4) - Cantilevered upper-storey Ext2 with `is_exposed_floor=True` (U=1.20) - Ext2 PS sloping-ceiling roof at `roof_insulation_thickness=0` (Slice 57 PS+pre-1950 path → Table 16 row 0 U=2.30) - Main 300 mm joist roof insulation → U=0.14 - 8 Main windows (U=2.8, g=0.76) + 1 Ext1 window (U=1.4, g=0.72) - Worcester Greenstar 30i (PCDF 17507) main + SAP 605 gas fire secondary (Slice 58 mains-gas secondary fuel cost routing) - Sheltered sides 1, 2 intermittent fans, 90% draught-proof, 23 LEDs Adds an `001479` entry to `_FIXTURE_PINS` + `_FIXTURE_MODULES` in `test_e2e_elmhurst_sap_score.py` with the worksheet PDF's 11 cascade-output line refs: sap_score 69 (258) sap_score_continuous 69.0094 "SAP value" ecf 2.2215 (257) total_fuel_cost_gbp 600.4001 (255) co2_kg_per_yr 2687.3610 (272) space_heating_kwh_per_yr 8103.7054 Σ (98c) main_heating_fuel_kwh_per_yr 8194.7583 (211) secondary_heating_fuel_kwh_per_yr 2025.9264 (215) hot_water_kwh_per_yr 2358.3123 (219) pumps_fans_kwh_per_yr 160.0000 (231) lighting_kwh_per_yr 163.3584 (232) Current state of the hand-built cascade vs worksheet: Pin Cascade Expected PASS? sap_score_continuous 65.99 69.01 no, -3.02 total_fuel_cost_gbp 658.92 600.40 no, +58.52 main_heating_fuel_kwh_per_yr 9359.6 8194.8 no pumps_fans_kwh_per_yr 160.0 160.0 PASS lighting_kwh_per_yr 163.4 163.4 PASS (after LED/CFL split) (... 9 others all failing by various deltas) 2/11 pins green. The remaining ~3 SAP gap means the hand-built has input gaps that produce more loss/cost than Elmhurst's calc. Likely suspects (slice candidates): - HW demand: cascade likely over-counts (combi vs cylinder routing, Tcold model) - Internal gains: appliance + cooking energy share - §2 ventilation tuning (chimney/flue counts, suspended-floor flag) - Thermal mass parameter (250 default — confirm worksheet matches) - Multiple-glazed proportion (cascade reads None → may default unfavourably for solar gains) Documents source-data caveat in the fixture docstring: Summary §3 says Ext1 age "M 2023 onwards"; worksheet header says "Ext1: L". Hand-built uses 'L' to mirror the worksheet (which is the calc's input source of truth); Elmhurst mapper produces 'M' from the Summary — cross-mapper diff will flag this as a known caveat. All 6 cohort cascade pins remain green at 1e-4 (66/66 fixture pins). Pyright net-zero on the new fixture file. Co-Authored-By: Claude Opus 4.7 --- .../tests/_elmhurst_worksheet_001479.py | 256 ++++++++++++++++++ .../tests/test_e2e_elmhurst_sap_score.py | 12 + 2 files changed, 268 insertions(+) create mode 100644 packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_001479.py diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_001479.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_001479.py new file mode 100644 index 00000000..471f65d4 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_001479.py @@ -0,0 +1,256 @@ +"""Inputs + expected outputs from Elmhurst SAP10.2 worksheet P960-0001-001479. + +Source: Summary_001479.pdf + P960-0001-001479.pdf (GOV.UK EPB cert +`0535-9020-6509-0821-6222`, lodged 31 Oct 2025). Semi-detached house +on Howick Park Drive, PR1 0LX. **First cohort fixture with a real +GOV.UK API counterpart** — this is the cross-mapper parity-test +reference for the API mapper. + +Worksheet header: + Property Type House, Semi-Detached + Storeys 2 + Habitable Rooms 4 (all heated) + Property Age Band C, Ext1: L, Ext2: C + Sheltered Sides 1 + Living Area 17.13 m² / 28.0% + Thermal MassValue 250.00 (medium default) + Main Heating PCDF 17507 Worcester Greenstar 30i ErP + Mains gas, 89% winter / 86.6% summer + Controls SAP 2106 Programmer + Roomstat + TRVs + Boiler interlock yes, pump in heated space + Combi standard, gas/oil time-clock keep-hot + Secondary Heating SAP 605 — Flush-fitting live-effect gas fire, + sealed to chimney, 40% efficiency, MAINS GAS + Water Heating From Main Heating 1 (combi, no cylinder) + Mechanical Ventilation None + Intermittent Fans 2 + +Building parts: + Main: age C, 2 storeys (30.45 m² ground + 30.77 m² first); cavity + wall U=0.70 (worksheet); party wall CU (cavity unfilled, + U=0.50); 300 mm joist roof insulation U=0.14. + Ext1: age L (worksheet header — Summary §3 says "M 2023 onwards"; + this fixture mirrors the worksheet at 1e-4 since the + worksheet is the calculator's source of truth). 5.37 m² + single-storey extension at ground level. Filled-cavity wall + U=0.26; PS sloping-ceiling roof insulated U=0.15; + insulated floor U=0.20. + Ext2: age C, **cantilevered upper-storey** extension hanging over + the back garden — 1.92 m² with exposed timber floor at U=1.20 + (Table 20). Cavity wall U=0.70; PS sloping-ceiling roof + **uninsulated** at U=2.30 (Slice 57: pre-1950 PS + As Built + thickness → 0 mm). + +Distinct features vs prior cohort fixtures (000474–000516): +- **Cert has a real GOV.UK API counterpart** — first cross-mapper + parity-test fixture (0535-9020-6509-0821-6222). +- **Multi-age building parts** (C, L, C) — Slice 60 dwelling-wide y + bridging convention picks up the dwelling primary age (C → 0.15). +- **Cantilevered upper-storey Ext2** with exposed floor (1.20). +- **PS Pitched sloping-ceiling roofs** on Ext1 (insulated, 0.15) and + Ext2 (uninsulated, 2.30). +- **Per-window U lodgement** — 8 Main windows at U=2.8 (g=0.76), + 1 Ext1 window at U=1.4 (g=0.72) — manufacturer Argon-filled DG. +- **Mains-gas secondary heating** (SAP code 605, η=40%) — first + non-electric secondary in the cohort; exercises Slice 58's + secondary fuel cost routing through `secondary_fuel_type=26`. + +Source-data caveat: Summary §3 lodges Ext1 age band as `M 2023 +onwards`; the worksheet header records `Ext1: L` (2012-2022). The +hand-built encodes 'L' to reproduce the worksheet at 1e-4; the +Elmhurst mapper trusts the Summary (M) and will diverge on this field +during cross-mapper parity testing. +""" + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, + SapBuildingPart, + SapFloorDimension, + SapVentilation, + SapWindow, +) +from domain.ml.tests._fixtures import ( + make_main_heating_detail, + make_minimal_sap10_epc, + make_sap_heating, + make_window, +) + +_WC_CAVITY = 4 +_WALL_INSULATION_NONE = 4 # "As built" / uninsulated cavity +_WALL_INSULATION_FILLED_CAVITY = 2 + + +def build_epc() -> EpcPropertyData: + """EpcPropertyData mirroring the Elmhurst 001479 worksheet inputs. + + Floor `room_height_m` mirrors the worksheet `(2x)` height column, + which adds +0.25 m to every storey above the lowest per the SAP + convention (cohort 000474 docstring §"Storey height convention"). + """ + main = SapBuildingPart( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="C", + wall_construction=_WC_CAVITY, + wall_insulation_type=_WALL_INSULATION_NONE, + wall_thickness_measured=True, + # Summary §7 lodges "CU Cavity masonry unfilled" → U=0.50 per + # `u_party_wall`; Slice 55 added "CU" to the Elmhurst code map. + party_wall_construction=_WC_CAVITY, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=2.39, # lowest internal, no +0.25 + total_floor_area_m2=30.45, + party_wall_length_m=6.94, + heat_loss_perimeter_m=11.99, + floor=0, + ), + SapFloorDimension( + room_height_m=2.53, # = 2.28 internal + 0.25 floor-void + total_floor_area_m2=30.77, + party_wall_length_m=6.94, + heat_loss_perimeter_m=13.55, + floor=1, + ), + ], + wall_thickness_mm=280, + # Worksheet §3: 300 mm joist roof insulation → U=0.14. + roof_insulation_thickness=300, + ) + ext_1 = SapBuildingPart( + identifier=BuildingPartIdentifier.EXTENSION_1, + construction_age_band="L", # worksheet header (Summary §3 says M; + # cross-mapper diff will flag this) + wall_construction=_WC_CAVITY, + wall_insulation_type=_WALL_INSULATION_FILLED_CAVITY, + wall_thickness_measured=True, + # Ext1 sits flush against neighbours on no party walls + # (worksheet `Party wall length=0.00`). `party_wall_construction` + # is still type-required as int; 0 = "Unable to determine" + # (Slice 54 cohort convention) — the cascade multiplies by + # party_wall_length=0 so the U is irrelevant here. + party_wall_construction=0, + sap_floor_dimensions=[ + SapFloorDimension( + room_height_m=2.48, + total_floor_area_m2=5.37, + party_wall_length_m=0.0, + heat_loss_perimeter_m=6.67, + floor=0, + ), + ], + wall_thickness_mm=280, + # Worksheet §3 lodges Ext1 sloping-ceiling roof U=0.15 — cascade + # default for age L pitched roof with no thickness lodged matches. + ) + ext_2 = SapBuildingPart( + identifier=BuildingPartIdentifier.EXTENSION_2, + construction_age_band="C", + wall_construction=_WC_CAVITY, + wall_insulation_type=_WALL_INSULATION_NONE, + wall_thickness_measured=True, + # Ext2 has no party walls either (worksheet PWL=0). Use the + # "Unable to determine" sentinel 0 (cohort convention). + party_wall_construction=0, + sap_floor_dimensions=[ + # Cantilevered upper-storey extension: single floor_dim with + # `is_exposed_floor=True` routes through Table 20 → U=1.20. + SapFloorDimension( + room_height_m=2.10, + total_floor_area_m2=1.92, + party_wall_length_m=0.0, + heat_loss_perimeter_m=2.81, + floor=0, + is_exposed_floor=True, + ), + ], + wall_thickness_mm=280, + # Slice 57: PS sloping-ceiling + As Built + pre-1950 → thickness=0 + # → Table 16 row 0 U=2.30. + roof_insulation_thickness=0, + ) + + # §11 Windows: 8 Main + 1 Ext1. All double-glazed; Ext1 has a low-U + # Argon-filled unit (Manufacturer 1.40 / g=0.72). Heights default to + # 1.0 m per the Elmhurst W×H=Area area-preserving convention; widths + # set to lodged Area / 1.0 = lodged Area. + main_windows: tuple[SapWindow, ...] = ( + # Windows 1(Main) — area 3.34, orientation NW (8) + make_window(orientation=8, width=3.34, height=1.0, + solar_transmittance=0.76, u_value=2.8, window_location=0), + # Windows 2(Main) — area 0.73, NE (2) + make_window(orientation=2, width=0.73, height=1.0, + solar_transmittance=0.76, u_value=2.8, window_location=0), + # Windows 3(Main) — 6 entries + make_window(orientation=8, width=3.04, height=1.0, + solar_transmittance=0.76, u_value=2.8, window_location=0), + make_window(orientation=2, width=1.33, height=1.0, + solar_transmittance=0.76, u_value=2.8, window_location=0), + make_window(orientation=2, width=0.70, height=1.0, + solar_transmittance=0.76, u_value=2.8, window_location=0), + make_window(orientation=2, width=0.99, height=1.0, + solar_transmittance=0.76, u_value=2.8, window_location=0), + make_window(orientation=4, width=2.13, height=1.0, + solar_transmittance=0.76, u_value=2.8, window_location=0), + make_window(orientation=1, width=1.70, height=1.0, + solar_transmittance=0.76, u_value=2.8, window_location=0), + ) + ext_1_window = make_window( + # Windows 2(Ext1) — area 6.37, orientation SE (4) + orientation=4, width=6.37, height=1.0, + solar_transmittance=0.72, u_value=1.4, window_location=1, + ) + + return make_minimal_sap10_epc( + total_floor_area_m2=68.51, + country_code="ENG", + postcode="pr1 0lx", + sap_building_parts=[main, ext_1, ext_2], + habitable_rooms_count=4, + heated_rooms_count=4, + door_count=1, + # §13 Lightings: 17 LED + 6 CFL = 23 fittings, 73.91% LEL. + # SAP10 Appendix L scales each bulb type by its own efficacy ratio + # — keeping LED and CFL separate (not collapsed into `low_energy_*`) + # matches the worksheet's per-fitting lighting demand split. + led_fixed_lighting_bulbs_count=17, + cfl_fixed_lighting_bulbs_count=6, + incandescent_fixed_lighting_bulbs_count=0, + sap_windows=[*main_windows, ext_1_window], + percent_draughtproofed=90, + sap_ventilation=SapVentilation( + extract_fans_count=2, + sheltered_sides=1, + has_suspended_timber_floor=False, + has_draught_lobby=False, + ), + sap_heating=make_sap_heating( + main_heating_details=[ + make_main_heating_detail( + main_heating_index_number=17507, + main_heating_data_source=1, + ), + ], + # SAP code 605, 40%, mains gas (fuel 26) — exercises Slice 58. + secondary_heating_type=605, + secondary_fuel_type=26, + ), + ) + + +# ============================================================================ +# Cascade pins extracted from P960-0001-001479.pdf (Table 12 prices, +# Section 10a). All values at the worksheet's 4 d.p. precision. +# ============================================================================ +# (258) SAP rating = 69 +# "SAP value" = 69.0094 +# (257) Energy cost factor = 2.2215 +# (255) Total energy cost = 600.4001 +# (272) Total CO2 kg/year = 2687.3610 +# (98c) Σ monthly space heating = 8103.7054 kWh/yr +# (211) Main system 1 fuel = 8194.7583 kWh/yr +# (215) Secondary fuel = 2025.9264 kWh/yr +# (219) Water heating fuel = 2358.3123 kWh/yr +# (231) Pumps and fans = 160.0000 kWh/yr +# (232) Lighting electricity = 163.3584 kWh/yr diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py b/packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py index 9393ab1f..21e63bd9 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py @@ -33,6 +33,7 @@ from domain.sap.worksheet.tests import ( _elmhurst_worksheet_000487 as _w000487, _elmhurst_worksheet_000490 as _w000490, _elmhurst_worksheet_000516 as _w000516, + _elmhurst_worksheet_001479 as _w001479, ) from domain.sap.worksheet.tests._elmhurst_fixtures import ( ALL_FIXTURES as _ELMHURST_FIXTURES, @@ -129,6 +130,16 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = { lighting_kwh_per_yr=230.8853, pumps_fans_kwh_per_yr=160.0, ), + "001479": FixtureCascadePins( + sap_score=69, sap_score_continuous=69.0094, ecf=2.2215, + total_fuel_cost_gbp=600.4001, co2_kg_per_yr=2687.3610, + space_heating_kwh_per_yr=8103.7054, + main_heating_fuel_kwh_per_yr=8194.7583, + secondary_heating_fuel_kwh_per_yr=2025.9264, + hot_water_kwh_per_yr=2358.3123, + lighting_kwh_per_yr=163.3584, + pumps_fans_kwh_per_yr=160.0, + ), } @@ -139,6 +150,7 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = { "000487": _w000487, "000490": _w000490, "000516": _w000516, + "001479": _w001479, }