RdSAP 10 §3.10.1 (PDF p.24) "Default U-values of the roof rooms":
> "The residual area (area of roof less the floor area of room(s)-in-
> roof) has a U-value from Table 16 : Roof U-values when loft
> insulation thickness is known according to its insulation thickness
> if at least half the area concerned is accessible, otherwise it is
> the default for the age band of the original property or extension."
Plus RdSAP 10 §3.9.1 step (d-e) (PDF p.21-22) — the Simplified A_RR
formula `12.5 × √(A_RR_floor / 1.5)` is the empirical estimator for
the total RR exposed shell; residual = A_RR − Σ lodged walls. The
worksheet applies this same formula to Detailed mode when the lodged
surface set has no roof-going entries (cert 000565 BP[0]:
12.5 × √(45/1.5) − (9.8 + 14.7) = 43.96 ≈ ws 43.97).
Pre-slice the cascade computed residual area ONLY in the Simplified
RR branch (via `_part_geometry`'s `rr_simplified_a_rr_m2` − rr_common
− rr_gable subtractions). The Detailed-RR branch in
`heat_transmission` iterated `rir.detailed_surfaces` and missed the
residual entirely. Cert 000565 routes all 5 BPs through Detailed mode
(the Elmhurst mapper translates Summary "Simplified" lodgements to
`SapRoomInRoofSurface` records when per-surface L×H is present), so
cascade total_external_element_area_m2 was 779.27 m² vs worksheet
(31) = 857.64 m² (Δ −78.37 m² → thermal_bridging cascade −11.76 W/K
under).
Slice span (1 file):
- `heat_transmission.py`: Detailed-RR branch adds residual area via
the §3.9.1 A_RR formula minus wall-going lodgements (gable_wall,
gable_wall_external, common_wall). Residual area contributes to
`rr_detailed_area` (→ part_external_area → (31) → thermal_bridging
multiplier) and to `roof` at `u_rr_default_all_elements`.
- Discriminator: residual fires only when no roof-going surface kinds
(slope, flat_ceiling, stud_wall) are lodged — true Detailed-mode
lodgements (cohort fixture 000516) lodge the entire roof shell
explicitly and have no residual.
Cert 000565 movement (HEAD `78c57c0d` → this slice):
- thermal_bridging_w_per_k: 116.89 → 129.35 ✓ vs ws 128.65 (Δ +0.70)
- total_external_area_m2: 779.27 → 862.34 ✓ vs ws 857.64 (Δ +4.70)
- roof_w_per_k: 34.64 → 63.72 (Δ −16.74 → +12.34)
- sap_score_continuous: 29.02 → 28.07 (Δ +0.51 → −0.44)
- sap_score (integer): 29 → 28 (temp regression
past 28.5 threshold)
- space_heating_kwh: −685 → +533
- main_heating_fuel: −403 → +321
- hot_water_kwh: ✓ 0 EXACT unchanged
Per user direction temporary continuous-SAP drift is acceptable when
fixing real spec-correct sub-component bugs; the absolute continuous-
SAP residual is now −0.44 (was +0.51) — slightly closer to zero
overall. The roof overshoot localises to:
- BP[4] Flat Ceiling 1 "Unknown PUR or PIR" lodgement (cascade 2.30
vs ws 0.15, over by +10.75 W/K) — Elmhurst-specific "Unknown +
known material" convention not yet wired
- BP[1] residual formula gives +3.68 m² over worksheet (Δ +1.29 W/K)
— Detailed-mode residual is spec-ambiguous for extensions with
non-2.45 m RR height; future slice may add a height-aware formula
Cohort safety: discriminator `has_roof_lodgement` filters out true
Detailed-mode lodgements (cohort fixtures 000474/000477/000480/
000487/000490/000516 all lodge slope/flat_ceiling/stud_wall surfaces).
Initial implementation broke 41 cohort pins; the discriminator
restores cohort behaviour exactly. Test baseline: 585 pass + 9
expected `000565` fails (was 585 + 8 — sap_score moved from passing
to failing during the slice's transient overshoot; expected per
user direction).
Pyright net-zero per touched file (test_summary_pdf_mapper_chain.py
13 → 13 preserved; heat_transmission.py 13 → 12 improved by −1).
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.