`_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES` held only {2101, 2102} — it was
keyed off the Table 4e "+0.6 °C" annotation rather than the actual interlock
criterion. SAP 10.2 §9.4.11 (PDF p.66): "A boiler system with no room
thermostat (or a device equivalent in this context, such as a flow switch or
boiler energy manager) ... must be considered as having no interlock", and
"TRVs alone ... do not perform the boiler interlock function". A fixed bypass
likewise provides no interlock (it keeps water circulating when TRVs close).
So control 2107 ("Programmer, TRVs and bypass") and 2111 ("TRVs and bypass")
lack interlock and must take the Table 4c(2) −5pp Space+DHW seasonal-efficiency
adjustment and the Table 4f footnote a) ×1.3 circulation-pump uplift — both of
which they previously missed. (2108 flow switch / 2109 boiler energy manager
carry interlock-equivalent devices → excluded; 2103-2106/2113 have a room
thermostat.) All affected certs are cat-2 gas boilers, where §9.4.11 applies.
Eval: 909 computed, 45.3% → 46.9% within 0.5 (+14 certs: 412 → 426), mean|err|
1.659 → 1.633. Bucket means corrected: control 2107 +1.50 → +0.32 (n=38),
2111 +1.48 → +0.16 (n=4). 32 improved / 10 regressed (all small; the six that
crossed out of ±0.5 were coincidentally-accurate offsetting-error certs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|---|---|---|
| .. | ||
| climate | ||
| docs | ||
| rdsap | ||
| tables | ||
| 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.