# 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: 1. **`Summary_NNNNNN.pdf`** — the assessor's `RdSAP Inputs` form: property type, age band, dimensions, walls, roof, floors, windows, heating, ventilation. This is what we encode as `EpcPropertyData`. 2. **`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 1. **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) 2. **Mirror the Summary PDF into `build_epc()`** — one `SapBuildingPart` per 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. 3. **Capture every populated worksheet line** as `LINE_NN_*` module-level constants. The cascade pin test (`test_section_cascade_pins.py`) parametrizes over `ALL_FIXTURES` and asserts each line individually at `abs=1e-4` against the actual `
_from_cert(epc)` output. Capture every line, scalar and monthly, all the way through §12 — the strict-pin sweep is the work in progress. 4. **Register the fixture** in `_elmhurst_fixtures.py`: add the import and append the module to `ALL_FIXTURES`. 5. **Run the conformance tests**: ``` python -m pytest domain/sap10_calculator/worksheet/tests/ \ -k elmhurst --no-cov -v ``` Each 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 area - `LINE_5_VOLUME_M3` ← line `(5)` Dwelling volume From `2. Ventilation rate`: - Scalars: `LINE_8` through `LINE_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 bridging - `LINE_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 m` storey-height convention is hardcoded in `dimensions.py` regardless 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 third `SapFloorDimension`. 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). `SapRoomInRoofSurface` carries `kind` ∈ {`slope`, `flat_ceiling`, `stud_wall`, `gable_wall`}, `area_m2`, optional `insulation_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's `A_RR = 12.5 × √(A_RR_floor/1.5)` formula at `u_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²K - `1` (Stone granite) / `3` (Solid brick) / `5` (Timber frame) / `6` (System built) → 0.0 - `4` (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`.