Slice 62: hand-built _elmhurst_worksheet_001479.py — skeleton + 11 RED pins

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-25 08:11:03 +00:00
parent 0e4f4c051a
commit ee98dbe0ec
2 changed files with 268 additions and 0 deletions

View file

@ -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 / 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 ground + 30.77 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 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 (000474000516):
- **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

View file

@ -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,
}