SAP 10.2 Table 4a (PDF p.163-164) heat-pump rows split efficiency into
two columns — "space" and "water":
Code System space water
211 Ground source HP with flow temp <= 35°C 230 170
213 Water source HP with flow temp <= 35°C 230 170
215 Gas-fired GSHP with flow temp <= 35°C 120 84
216 Gas-fired WSHP with flow temp <= 35°C 120 84
217 Gas-fired ASHP with flow temp <= 35°C 110 77
521 Warm-air electric GSHP 230 170
523 Warm-air electric WSHP 230 170
525 Warm-air gas-fired GSHP 120 84
526 Warm-air gas-fired WSHP 120 84
527 Warm-air gas-fired ASHP 110 77
The split reflects real physics: heat pumps lose efficiency raising
water to ~55°C DHW temperatures vs ~35°C space-heating flow. ASHP
"in other cases" (codes 214, 221, 223, 224) and the "other cases"
gas-fired rows (225-227) have space == water = 170 / 84 / 77 — no
distinct DHW column.
Pre-slice the cascade routed WHC ∈ {901, 902, 914} ("HW from main
heating") through `seasonal_efficiency(main_code)`, which only consults
the Space column. For SAP code 211 the cascade returned 2.30 (= space)
when the spec requires 1.70 (= water). HW fuel kWh undercounted by
26% on the heating-systems corpus gshp variant: cascade 841.47 kWh vs
worksheet 1138.46 kWh.
New `_TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY` dict (10 codes where Space
≠ Water) consulted in `_water_efficiency_with_category_inherit` before
falling through to the existing `seasonal_efficiency` path. Codes
where Space == Water keep the legacy inheritance — no behaviour
change. Non-HP main heating (boilers, storage heaters) likewise
unchanged.
Closures (gshp variant — SAP code 211 + WHC=901 + cylinder):
HW fuel kWh: 841.47 → 1138.45 (matches worksheet 1138.46)
ΔSAP_c: +0.9373 → -0.0178
Δcost: -£21.60 → +£0.41
ΔCO2: -34.98 → +7.06 kg/yr
ΔPE: -418.92 → +33.52 kWh/yr
No regressions on 40 other corpus variants — gshp is the only fixture
that lodges a heat-pump code with diverging Space/Water columns.
Cohort-1 ASHP closure (S0380.28 reciprocal interpolation) is unaffected
because that path runs through `heat_pump_record` PCDB Appendix N3
when a PCDB Table 362 record is lodged; this fix is the Table 4a
fallback for cases without a PCDB record.
Extended handover suite: 899 pass / 0 fail. Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|---|---|---|
| .. | ||
| climate | ||
| docs | ||
| rdsap | ||
| tables | ||
| tests | ||
| validation | ||
| worksheet | ||
| __init__.py | ||
| calculator.py | ||
| exceptions.py | ||
| README.md | ||
SAP calculation domain
Per-section worksheet calculators for SAP 10.2 / RdSAP 10. Each file mirrors a numbered section of the spec; tests live alongside under worksheet/tests/ and tests/.
sap/
├── calculator.py # top-level orchestrator → SapResult
├── worksheet/
│ ├── dimensions.py # §1 Overall dwelling dimensions
│ ├── ventilation.py # §2 Ventilation rate (+ RdSAP10 §4.1)
│ ├── heat_transmission.py # §3 Heat losses & HLP
│ ├── ... # §4 onward
│ └── tests/
│ ├── _xlsx_loader.py
│ ├── _elmhurst_fixtures.py # registry of Elmhurst conformance fixtures
│ ├── _elmhurst_worksheet_NNNNNN.py # one per worksheet pair
│ └── test_*.py
├── rdsap/ # cert → SapInputs cascade (RdSAP10 §5)
└── tables/ # Table U2 wind, Table 6 walls, Table 21 bridging, …
Spec references: domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-03-14.pdf (SAP 10.2, the active target per ADR-0010), domain/sap10_calculator/docs/specs/RdSAP 10 Specification 10-06-2025.pdf (RdSAP cascade). Canonical worked example: 2026-05-19-17-18 RdSap10Worksheet.xlsx at repo root — loaded by _xlsx_loader.py.
Validation contract. Per [[feedback-zero-error-strict]] the 6 Elmhurst U985 fixtures are deterministic test vectors: every line ref of every output must pin against the U985 PDF at abs=1e-4. See worksheet/tests/test_section_cascade_pins.py (per-section line refs, 768 rating + 90 demand pins) and test_e2e_elmhurst_sap_score.py::test_sap_result_pin (top-level SapResult fields). Tolerances are never widened. Current state: 930/930 pins green. The public API + architecture overview lives in domain/sap10_calculator/docs/SAP_CALCULATOR.md.
Adding a new Elmhurst conformance fixture
Each Elmhurst fixture is a real-cert ground-truth: we encode the cert as EpcPropertyData, then assert our §1/§2/§3 output matches the lodged worksheet line-by-line. The fixtures act as a regression net for every cert-shape variation (RR, extension, party-wall code, sheltered sides, …) we've seen in the wild.
Input: one PDF pair per cert
The assessor exports two PDFs from Elmhurst's RdSAP tool:
Summary_NNNNNN.pdf— the assessor'sRdSAP Inputsform: property type, age band, dimensions, walls, roof, floors, windows, heating, ventilation. This is what we encode asEpcPropertyData.UXXX-XXXX-NNNNNN.pdf— the calculator's full worksheet output: every populated line ref(1a)..(486)for the Energy Rating, EPC Costs, and Improved Dwelling variants. The Energy Rating variant (the first section) is canonical for line-ref tests.
NNNNNN is the cert's Full RefNo — both PDFs must match. Always capture from the Energy Rating section, not EPC Costs (the latter uses slightly different wind speeds for the BEDF fuel-price calc).
Steps
-
Drop a new fixture module at
worksheet/tests/_elmhurst_worksheet_NNNNNN.py. Copy the closest existing fixture as a starting template:- 3-storey with room-in-roof → start from
_elmhurst_worksheet_000487.py(RR + extension + alt wall) or_elmhurst_worksheet_000477.py(RR main-only) - 2-storey with extension(s) →
_elmhurst_worksheet_000474.py(Main + 2 ext, no RR) or_elmhurst_worksheet_000480.py(Main + 1 ext, with RR)
- 3-storey with room-in-roof → start from
-
Mirror the Summary PDF into
build_epc()— oneSapBuildingPartper Main/Extension. Field-by-field correspondence; the docstring at the top of the fixture should call out the source PDF date and the cert's distinguishing features. -
Capture every populated worksheet line as
LINE_NN_*module-level constants. The cascade pin test (test_section_cascade_pins.py) parametrizes overALL_FIXTURESand asserts each line individually atabs=1e-4against the actual<section>_from_cert(epc)output. Capture every line, scalar and monthly, all the way through §12 — the strict-pin sweep is the work in progress. -
Register the fixture in
_elmhurst_fixtures.py: add the import and append the module toALL_FIXTURES. -
Run the conformance tests:
python -m pytest domain/sap10_calculator/worksheet/tests/ \ -k elmhurst --no-cov -vEach fixture appears 3× (one parametrize per section), pytest id = the cert ref number.
Mapping the Summary PDF to EpcPropertyData
| Summary field | EpcPropertyData location |
Notes |
|---|---|---|
Property type |
epc.property_type via make_minimal_sap10_epc(...) |
drives mid/end/detached defaults |
Date Built (per part) |
SapBuildingPart.construction_age_band |
one-letter A..M |
Storeys |
NOT a stored field — sum across sap_floor_dimensions + 1 if RR |
§2 (9) uses dwelling height, not Σ across parts (LINE_9_STOREYS captures this) |
Floor Area / Room Height / Heat Loss Wall Perimeter / Party Wall Length |
one SapFloorDimension per storey of the part |
see Storey height convention below |
Walls.Type |
wall_construction |
3=solid brick, 4=cavity, 5=timber frame, 6=system built |
Walls.Insulation |
wall_insulation_type |
4=as-built; 2=filled cavity |
Party Wall Type |
party_wall_construction |
see Party wall U mapping below |
Roof.Type/Insulation/Thickness |
top-level epc.roofs[0] EnergyElement |
RdSAP cascade reads description string |
Floors.Type/Insulation |
top-level epc.floors[0] |
similar pattern |
Rooms in Roof block |
SapBuildingPart.sap_room_in_roof = SapRoomInRoof(floor_area=...) |
see Room-in-roof handling |
Total Number of Doors |
door_count= on make_minimal_sap10_epc |
|
Windows table (each W×H + area) |
one SapWindow per row in epc.sap_windows, with per-window u_value lodged when the cert names a U-value (mixed-glazing fixtures need this for the per-window curtain-resistance transform — slice 22). make_window(..., u_value=...) is the canonical helper. |
|
Intermittent fans |
fixture constant INTERMITTENT_FANS (consumed by §2 test) |
|
Draught Lobby / Draught Proofing % |
fixture constants HAS_DRAUGHT_LOBBY, WINDOW_PCT_DRAUGHT_PROOFED |
|
Sheltered Sides |
fixture constant LINE_19_SHELTERED_SIDES (also asserted) |
|
Mechanical Ventilation |
fixture constant MV_KIND |
default MechanicalVentilationKind.NATURAL |
Worksheet lines to capture
From the Energy Rating section's 1. Overall dwelling characteristics:
LINE_4_TFA_M2← line(4)Total floor areaLINE_5_VOLUME_M3← line(5)Dwelling volume
From 2. Ventilation rate:
- Scalars:
LINE_8throughLINE_21— every(N)line, including the pressure-test override(18)and shelter(19)/(20)/(21) - Monthly tuples:
LINE_22_WIND_SPEED_M_S,LINE_22A_WIND_FACTOR,LINE_22B_WIND_ADJUSTED_ACH,LINE_25_EFFECTIVE_ACH— twelve floats Jan..Dec
From 3. Heat losses and heat loss parameter:
LINE_31_TOTAL_EXTERNAL_AREA_M2←(31)Σ A external elements (excludes party wall)LINE_33_FABRIC_HEAT_LOSS_W_PER_K←(33)Σ (A × U) without bridgingLINE_36_THERMAL_BRIDGING_W_PER_K←(36)= y × (31)LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K←(37)= (33) + (36)
All four §3 aggregates are now pinned by test_section_cascade_pins.py::test_section_3_line_refs_match_pdf at abs=1e-4. RR detailed surfaces lodged via SapRoomInRoof.detailed_surfaces (slices 13–23) close the room-in-roof breakdown end-to-end for every fixture with detailed §3.10 lodgement (000477, 000480, 000516; 000487 still has the U=0.86 external-gable variant pending spec input).
Gotchas
Storey height convention (SapFloorDimension.room_height_m)
The worksheet's (2x) height column includes a +0.25 m floor-structure allowance on every storey above the lowest:
- floor=0 (lowest): internal room height as measured
- floor=1 / floor=2 / …: internal room height + 0.25
So a 2.91 m upper-storey internal height appears on the worksheet as 3.16 m. Mirror the worksheet number into the fixture, not the surveyor's tape measurement.
Room-in-roof
- §1 RdSAP
2.45 mstorey-height convention is hardcoded indimensions.pyregardless of any height the RR cert input claims. The worksheet line(2d)for an RR storey shows 2.45. - We encode it as
SapBuildingPart.sap_room_in_roof = SapRoomInRoof(floor_area=..., detailed_surfaces=[...]), NOT as a thirdSapFloorDimension. The dimensions calculator treats the RR as +1 storey, +floor_area to TFA, +floor_area × 2.45 to volume. - §3.10 Detailed RR is implemented (slices 13, 16, 23).
SapRoomInRoofSurfacecarrieskind∈ {slope,flat_ceiling,stud_wall,gable_wall},area_m2, optionalinsulation_thickness_mm+insulation_type. Slope/flat_ceiling/stud_wall route to roof per Table 17; gable_wall routes to party at U=0.25 per Table 4 "as common wall". The U=0.86 "external gable" variant (000487) is NOT yet implemented — open ticket. - Simplified Type 1 (RR lodged with only
floor_area) still works via the spec'sA_RR = 12.5 × √(A_RR_floor/1.5)formula atu_rr_default_all_elements(Table 18 col 4). Detailed lodgement supersedes when present.
Party wall U mapping
party_wall_construction integer codes resolve via domain.sap10_ml.rdsap_uvalues.u_party_wall:
0(Unknown / "Unable to determine") → 0.25 W/m²K1(Stone granite) /3(Solid brick) /5(Timber frame) /6(System built) → 0.04(Cavity, unfilled) → 0.5
Cross-check against the worksheet's Party walls Main row in §3 — that's the authoritative U for the cert.
Sheltered sides drives shelter factor
(19) varies per cert and the chain (20) = 1 - 0.075 × (19), (21) = (18) × (20) propagates through every monthly (22b)/(25). Read straight from the cert's Sheltered Sides field; not derivable from property type alone.
(12) suspended-timber-floor quirk
Some Elmhurst certs list a suspended timber floor on the inputs but lodge (12) = 0.0 in the worksheet. Mirror the worksheet, not the cert input: set HAS_SUSPENDED_TIMBER_FLOOR=False to get (12)=0. The SUSPENDED_TIMBER_FLOOR_SEALED flag only switches between 0.2 (unsealed) and 0.1 (sealed); it does not zero out the contribution. The =True/=False mapping in ventilation.py:185:
has_suspended_timber_floor |
..._sealed |
resulting (12) |
|---|---|---|
False |
(any) | 0.0 |
True |
False |
0.2 |
True |
True |
0.1 |
Effective monthly ACH (25) formula
Not equal to (22b) when (22b) < 1.0:
(25) = (22b) if (22b) ≥ 1.0
(25) = 0.5 + (22b)² × 0.5 otherwise
Don't try to compute it — read both (22b) and (25) straight off the worksheet and assert on both. The formula's here just so you recognise why they differ on tightly-sealed homes.
Wind speeds: Energy Rating vs EPC Costs
The same cert prints two Wind speed (22) tables — one in CALCULATION OF ENERGY RATING, one in CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY ENERGY. They differ (the latter is the BEDF-prices variant). Always capture from the Energy Rating section; that's what ventilation_from_inputs(...) calibrates against. The non-regional Table U2 default values are 5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7.