PR feedback (dancafc): the simplified room-in-roof branch used cryptic
locals. Rename for clarity (behaviour-unchanged; the geom dict keys and
the builder-function locals are untouched):
rr_a_rr -> rr_roof_area (the worksheet's simplified A_RR)
rr_common -> rr_common_wall_area
rr_gable -> rr_gable_area
a_rr_final -> rr_residual_roof_area (leftover roof-going area after
deducting perimeter walls/gables
/rooflights — takes the roof U)
Names now mirror the `rr_*_area_m2` geom keys they read from and say
"area of what". Added a one-line note that `rr_roof_area` is the RdSAP 10
§3.10.1 A_RR. Pyright unchanged; 1087 heat-transmission/cascade-pin tests
pass.
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.