Elmhurst SAP10.2 worksheet conformance: §1/§2/§3 + 6 fixtures + README

Lands real-cert ground-truth conformance tests for the SAP10.2 worksheet,
asserting our §1 dimensions, §2 ventilation, and §3 heat-transmission
output line-by-line against six Elmhurst-lodged worksheets (000474,
000477, 000480, 000487, 000490, 000516). Each fixture covers a distinct
shape: with/without room-in-roof, single-part vs main+extensions, age
A and B, party-wall U=0.0 vs U=0.25, 1/2/3 sheltered sides, varying
draught-proofing %, and the (12) suspended-timber quirk.

§1/§2/§3 module updates back the new line-refs (LINE_31 external-element
area, LINE_33 fabric loss, LINE_37 total fabric loss; per-fixture (12)
floor / (15) window / (21) shelter-adjusted ach; SapRoomInRoof storey
contribution via the 2.45 m §3.9.1 convention).

The §3 test currently asserts invariants only ((33) = Σ per-element,
(37) = (33) + (36)) because SapRoomInRoof only carries floor_area —
gable/slope/stud/flat-ceiling sub-areas the worksheet itemizes are not
yet modelled. LINE_3* constants capture the worksheet ground truth for
when that gap closes.

Adds a SAP-domain README with a step-by-step guide for adding new
Elmhurst fixtures from the assessor's PDF pair (Summary + worksheet),
including the field-by-field cert → EpcPropertyData mapping table and
the gotchas surfaced across the six fixtures (storey-height +0.25
convention, party-wall U code mapping, has_suspended_timber_floor flag
truth table, (25) effective-ach formula, Energy Rating vs EPC Costs
wind-speed trap).

366 tests pass (was 360 pre-pairs 5-6).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 09:48:30 +00:00
parent a1c9d2a14d
commit 6455d48b9d
19 changed files with 4726 additions and 215 deletions

View file

@ -0,0 +1,140 @@
# 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: `docs/sap-spec/sap-10-3-full-specification-2026-01-13.pdf` (SAP), `docs/sap-spec/rdsap-10-specification-2025-06-10.pdf` (RdSAP cascade). Canonical worked example: `2026-05-19-17-18 RdSap10Worksheet.xlsx` at repo root — loaded by `_xlsx_loader.py`.
## 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 §1/§2 tests parametrize over `ALL_FIXTURES` and assert each line individually; §3 currently only checks invariants (the room-in-roof breakdown isn't yet computed by our code — see *Known gaps*).
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 packages/domain/src/domain/sap/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) | not currently structured per-window; we pass `window_total_area_m2` + area-weighted `window_avg_u_value` straight to `heat_transmission_from_cert(...)` | |
| `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)
The §3 test currently only checks invariants ((33) = Σ per-element, (37) = (33) + (36), area > 0). The `LINE_3*` constants are still worth capturing — they're the ground truth for when the room-in-roof gap closes.
## 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=...)`, NOT as a third `SapFloorDimension`. The dimensions calculator treats the RR as +1 storey, +floor_area to TFA, +floor_area × 2.45 to volume.
- Our §3 currently under-counts RR fixtures because `SapRoomInRoof` only carries `floor_area` — the gables, slopes, stud walls, flat ceiling that the worksheet lists individually are NOT yet modelled. This is the *one big known gap* (see `_elmhurst_worksheet_000487.py` comments).
### Party wall U mapping
`party_wall_construction` integer codes resolve via `domain.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`.

View file

@ -4,14 +4,15 @@ Builds the typed `Dimensions` aggregate that the rest of the worksheet
reads: total floor area, volume, gross/party wall areas, ground and top reads: total floor area, volume, gross/party wall areas, ground and top
floor areas, perimeter. Geometry is summed across every entry in floor areas, perimeter. Geometry is summed across every entry in
`epc.sap_building_parts` (main dwelling + every extension), so a cert with `epc.sap_building_parts` (main dwelling + every extension), so a cert with
N parts produces totals over all N. N parts produces totals over all N. Room-in-roof contributes one
additional storey per part where present (RdSAP §1.8 + §3.9).
Reference: SAP 10.3 specification (13-01-2026), §1 (pages 10-12); for Reference: SAP 10.3 specification (13-01-2026), §1 (pages 10-12); for
existing dwellings see RdSAP 10 §3 (areas and dimensions). existing dwellings see RdSAP 10 §3 (areas and dimensions).
Edge cases explicitly out of scope for the first slice (see ADR-0009 Edge cases explicitly out of scope for the first slice (see ADR-0009
Session A scope): porches, conservatories, integral garages, basements Session A scope): porches, conservatories, integral garages, basements
with non-fixed staircases, room-in-roof storey treatment. with non-fixed staircases.
""" """
from __future__ import annotations from __future__ import annotations
@ -23,6 +24,13 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingP
_DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5 _DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5
# Room-in-roof Simplified type 1 (true RR) storey height per RdSAP 10
# §3.9.1: assumed internal height 2.2 m (lower than 2.4 m to compensate
# for sloping parts) + 0.25 m floor structure between RR and storey
# below = 2.45 m. Simplified type 2 and Detailed assessment options are
# not yet handled — see TODO at the RR sum below.
_RR_SIMPLIFIED_STOREY_HEIGHT_M: Final[float] = 2.45
@dataclass(frozen=True) @dataclass(frozen=True)
class Dimensions: class Dimensions:
@ -68,27 +76,29 @@ def _part_top_floor(part: SapBuildingPart):
def dimensions_from_cert(epc: EpcPropertyData) -> Dimensions: def dimensions_from_cert(epc: EpcPropertyData) -> Dimensions:
"""Build the `Dimensions` aggregate from an EpcPropertyData.""" """Build the `Dimensions` aggregate from an EpcPropertyData.
§1 (Overall dwelling dimensions) mirrors the SAP10.2 worksheet form:
each `SapFloorDimension` is one storey row (1x), (2x), (3x) where
(3x) = (1x) × (2x). Line (4) Total floor area = Σ (1x), line (5)
Dwelling volume = Σ (3x). When no storeys are present (site-notes
baseline edge case), totals fall back to the certificate's
top-level TFA × default height defensive, not worksheet-faithful.
"""
parts = epc.sap_building_parts or [] parts = epc.sap_building_parts or []
# Khalim Comments - this section seems to implement the # §1 worksheet accumulators — these directly map to lines (4) and (5).
# worksheet section in page 132 and is unnecessarily sum_per_storey_area_m2 = 0.0 # Σ (1x)
# complicated. The sap building parts are pre-ordered, form sum_per_storey_volume_m3 = 0.0 # Σ (3x) = Σ (1x) × (2x)
# main building part to the extensions and the
# "identifier" field tells us if the part is the Main Dwelling
# of it's an extension. E.g. if it's an extension, identifier
# should be "Extension 1".
# We should strictly type the values on the EpcPropertyData
# domain model
# §2/§3 inputs (gross/party wall, perimeter, ground/top floor) — kept
# in this aggregate for now; carve-out is a follow-up.
ground_area = 0.0 ground_area = 0.0
ground_perim = 0.0 ground_perim = 0.0
top_area = 0.0 top_area = 0.0
gross_wall = 0.0 gross_wall = 0.0
party_wall = 0.0 party_wall = 0.0
total_storey_count = 0 total_storey_count = 0
weighted_height = 0.0
weighted_height_area = 0.0
for part in parts: for part in parts:
ground = _part_ground_floor(part) ground = _part_ground_floor(part)
top = _part_top_floor(part) top = _part_top_floor(part)
@ -104,17 +114,41 @@ def dimensions_from_cert(epc: EpcPropertyData) -> Dimensions:
total_storey_count += part_storeys total_storey_count += part_storeys
for fd in part.sap_floor_dimensions: for fd in part.sap_floor_dimensions:
fa = fd.total_floor_area_m2 or 0.0 fa = fd.total_floor_area_m2 or 0.0
weighted_height += fa * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M) fh = fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M
weighted_height_area += fa sum_per_storey_area_m2 += fa
sum_per_storey_volume_m3 += fa * fh
# Room-in-roof: counts as one additional storey per RdSAP §1.8 +
# §3.9. Both failing certs in the golden suite are Simplified
# type 1 (gable lengths only), which RdSAP §3.9.1 says uses a
# fixed 2.45 m storey height. TODO: handle Simplified type 2
# (RR with continuous common walls outside RR boundaries,
# §3.9.2) and Detailed (actual measured dimensions, §3.10 +
# Figure 4) — neither path appears in current corpus, but
# downstream calcs will silently use 2.45 m if we hit one.
rir = part.sap_room_in_roof
if rir is not None and rir.floor_area > 0:
sum_per_storey_area_m2 += rir.floor_area
sum_per_storey_volume_m3 += (
rir.floor_area * _RR_SIMPLIFIED_STOREY_HEIGHT_M
)
total_storey_count += 1
has_storeys = sum_per_storey_area_m2 > 0
avg_height = ( avg_height = (
weighted_height / weighted_height_area sum_per_storey_volume_m3 / sum_per_storey_area_m2
if weighted_height_area > 0 if has_storeys
else _DEFAULT_STOREY_HEIGHT_M else _DEFAULT_STOREY_HEIGHT_M
) )
return Dimensions( return Dimensions(
total_floor_area_m2=epc.total_floor_area_m2, total_floor_area_m2=(
volume_m3=epc.total_floor_area_m2 * avg_height, sum_per_storey_area_m2 if has_storeys else epc.total_floor_area_m2
),
volume_m3=(
sum_per_storey_volume_m3
if has_storeys
else epc.total_floor_area_m2 * _DEFAULT_STOREY_HEIGHT_M
),
storey_count=total_storey_count, storey_count=total_storey_count,
avg_storey_height_m=avg_height, avg_storey_height_m=avg_height,
ground_floor_area_m2=ground_area, ground_floor_area_m2=ground_area,

View file

@ -1,11 +1,23 @@
"""SAP 10.3 §3 — heat-transmission Heat Loss Coefficient. """SAP 10.2 §3 — heat-transmission Heat Loss Coefficient.
Conduction HLC summed across every building part: Σ U × A across walls, Conduction HLC = Σ A × U across every external element of the dwelling
roof, floor, party walls, windows, doors, plus thermal-bridging factor y (walls including any alternative-construction sub-areas, roof, floor,
multiplied by total exposed envelope area. party walls, windows, doors), plus thermal-bridging factor y × Σ exposed
area. Each contribution is broken out so callers can audit per SAP
worksheet line reference.
Returns a typed `HeatTransmission` breakdown so the orchestrator can audit Worksheet line mapping (SAP 10.2 §3, canonical xlsx rows 121-207):
each element's contribution. (26) solid doors
(27) windows uses effective U = 1/(1/U + 0.04) per §3.2 (curtain
allowance, R = 0.04 m²K/W); raw U from RdSAP Table 24
(28a) ground floor (per part)
(29a) external walls (main + alternative walls 1 & 2, RdSAP §1.4.2)
(30) roof (per part)
(31) Σ external element area
(32) party wall (U from RdSAP Table 15)
(33) fabric heat loss = Σ (A×U), without thermal bridging
(36) thermal bridging = y × Σ exposed area (RdSAP Table 21)
(37) total fabric heat loss = (33) + (36)
This is the calculator-vocabulary sibling of `domain.ml.envelope`. During This is the calculator-vocabulary sibling of `domain.ml.envelope`. During
Session A both modules coexist the legacy envelope.py continues to feed Session A both modules coexist the legacy envelope.py continues to feed
@ -16,7 +28,9 @@ layout").
U-value lookups cascade through `domain.ml.rdsap_uvalues` migrating to U-value lookups cascade through `domain.ml.rdsap_uvalues` migrating to
`domain.sap.rdsap.cascade_defaults` in Session B. `domain.sap.rdsap.cascade_defaults` in Session B.
Reference: SAP 10.3 specification §3 (pages 17-22); RdSAP 10 §5. Reference: SAP 10.2 specification §3 (pages 17-22); RdSAP 10 §5 (Tables
6-24); xlsx worked example at `2026-05-19-17-18 RdSap10Worksheet.xlsx`,
sheet `NonRegionalWeather`, rows 121-207.
""" """
from __future__ import annotations from __future__ import annotations
@ -24,13 +38,19 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Final, Optional from typing import Any, Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
SapAlternativeWall,
SapBuildingPart,
)
from domain.ml.rdsap_uvalues import ( from domain.ml.rdsap_uvalues import (
Country, Country,
WALL_UNKNOWN, WALL_UNKNOWN,
_described_as_insulated, _described_as_insulated,
thermal_bridging_y, thermal_bridging_y,
u_basement_floor,
u_basement_wall,
u_door, u_door,
u_floor, u_floor,
u_party_wall, u_party_wall,
@ -43,21 +63,27 @@ from domain.ml.rdsap_uvalues import (
_WALL_INSULATION_NONE: Final[int] = 4 _WALL_INSULATION_NONE: Final[int] = 4
_DEFAULT_DOOR_AREA_M2: Final[float] = 1.85 _DEFAULT_DOOR_AREA_M2: Final[float] = 1.85
_DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5 _DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5
# SAP10.2 §3.2 curtain/blind thermal resistance applied to windows (and
# roof windows) — turns raw window U into the worksheet's (27) effective U.
_WINDOW_CURTAIN_RESISTANCE_M2K_PER_W: Final[float] = 0.04
@dataclass(frozen=True) @dataclass(frozen=True)
class HeatTransmission: class HeatTransmission:
"""SAP 10.3 §3 conduction HLC broken down per element type, summed """SAP 10.2 §3 conduction HLC broken down per element type, summed
across all sap_building_parts (main dwelling + every extension).""" across all sap_building_parts. Each field maps to a worksheet line
so callers can audit against the canonical xlsx."""
walls_w_per_k: float walls_w_per_k: float # (29a) net main wall + alt walls 1&2
roof_w_per_k: float roof_w_per_k: float # (30)
floor_w_per_k: float floor_w_per_k: float # (28a)
party_walls_w_per_k: float party_walls_w_per_k: float # (32)
windows_w_per_k: float windows_w_per_k: float # (27) — uses effective U
doors_w_per_k: float doors_w_per_k: float # (26)
thermal_bridging_w_per_k: float thermal_bridging_w_per_k: float # (36)
total_w_per_k: float fabric_heat_loss_w_per_k: float # (33) = Σ (A×U), no bridging
total_external_element_area_m2: float # (31) Σ A across external elements
total_w_per_k: float # (37) = (33) + (36)
@dataclass(frozen=True) @dataclass(frozen=True)
@ -165,7 +191,7 @@ def heat_transmission_from_cert(
exposure = DwellingExposure() exposure = DwellingExposure()
parts = epc.sap_building_parts or [] parts = epc.sap_building_parts or []
if not parts: if not parts:
return HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) return HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
country = Country.from_code(epc.country_code) country = Country.from_code(epc.country_code)
roof_description = _joined_descriptions(epc.roofs) roof_description = _joined_descriptions(epc.roofs)
@ -173,9 +199,17 @@ def heat_transmission_from_cert(
floor_description = _joined_descriptions(epc.floors) floor_description = _joined_descriptions(epc.floors)
door_area = max(0, door_count) * _DEFAULT_DOOR_AREA_M2 door_area = max(0, door_count) * _DEFAULT_DOOR_AREA_M2
window_u = window_avg_u_value if (window_avg_u_value or 0) > 0 else u_window( window_u_raw = window_avg_u_value if (window_avg_u_value or 0) > 0 else u_window(
installed_year=None, glazing_type=None, frame_type=None installed_year=None, glazing_type=None, frame_type=None
) )
# SAP10.2 §3.2: effective window U includes the 0.04 m²K/W curtain
# resistance — matches the (27) column in the worksheet (raw U=2.0
# → effective 1/(0.5+0.04)=1.852).
window_u = (
1.0 / (1.0 / window_u_raw + _WINDOW_CURTAIN_RESISTANCE_M2K_PER_W)
if window_u_raw > 0
else 0.0
)
primary_age = parts[0].construction_age_band primary_age = parts[0].construction_age_band
door_uninsulated_u = u_door(country=country, age_band=primary_age, insulated=False, insulated_u_value=None) door_uninsulated_u = u_door(country=country, age_band=primary_age, insulated=False, insulated_u_value=None)
door_insulated_u = ( door_insulated_u = (
@ -193,6 +227,7 @@ def heat_transmission_from_cert(
windows = 0.0 windows = 0.0
doors = 0.0 doors = 0.0
bridging = 0.0 bridging = 0.0
total_external_area = 0.0
for i, part in enumerate(parts): for i, part in enumerate(parts):
geom = _part_geometry(part) geom = _part_geometry(part)
age_band = part.construction_age_band age_band = part.construction_age_band
@ -220,22 +255,34 @@ def heat_transmission_from_cert(
floor_perimeter = ground_fd.heat_loss_perimeter_m if ground_fd is not None else None floor_perimeter = ground_fd.heat_loss_perimeter_m if ground_fd is not None else None
floor_construction = _int_or_none(ground_fd.floor_construction) if ground_fd is not None else None floor_construction = _int_or_none(ground_fd.floor_construction) if ground_fd is not None else None
uw = u_wall( # RdSAP §5.17 / Table 23: a basement wall overrides the cascade for
country=country, age_band=age_band, # the main wall's U-value when the part's primary wall_construction
construction=wall_construction if wall_construction != WALL_UNKNOWN else None, # is the basement code. (Alt-wall sub-areas are handled below.)
insulation_thickness_mm=wall_ins_thickness, if part.main_wall_is_basement:
insulation_present=wall_ins_present, uw = u_basement_wall(age_band)
description=wall_description, else:
wall_insulation_type=wall_ins_type, uw = u_wall(
) country=country, age_band=age_band,
construction=wall_construction if wall_construction != WALL_UNKNOWN else None,
insulation_thickness_mm=wall_ins_thickness,
insulation_present=wall_ins_present,
description=wall_description,
wall_insulation_type=wall_ins_type,
)
ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=roof_description) ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=roof_description)
uf = u_floor( # When the part carries a basement, the WHOLE floor=0 is the
country=country, age_band=age_band, construction=floor_construction, # basement floor (per user-confirmed convention). Table 23 F-column
insulation_thickness_mm=floor_ins_thickness, # overrides the regular floor U-value cascade.
area_m2=floor_area, perimeter_m=floor_perimeter, if part.has_basement:
wall_thickness_mm=part.wall_thickness_mm, uf = u_basement_floor(age_band)
description=floor_description, else:
) uf = u_floor(
country=country, age_band=age_band, construction=floor_construction,
insulation_thickness_mm=floor_ins_thickness,
area_m2=floor_area, perimeter_m=floor_perimeter,
wall_thickness_mm=part.wall_thickness_mm,
description=floor_description,
)
upw = u_party_wall(party_wall_construction=party_construction) upw = u_party_wall(party_wall_construction=party_construction)
y = thermal_bridging_y(age_band=age_band) y = thermal_bridging_y(age_band=age_band)
@ -249,15 +296,43 @@ def heat_transmission_from_cert(
roof_area = geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0 roof_area = geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0
floor_area_total = geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0 floor_area_total = geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0
walls += uw * net_wall_area # RdSAP §1.4.2: a building part can have up to 2 alternative walls,
# each a sub-area of the gross wall with its OWN construction +
# insulation. Inherits the part's age band. Heat-loss arithmetic:
# main_net_area absorbs whatever remains after deducting openings
# and the alt-wall sub-areas.
alt_walls_contribution = 0.0
alt_walls_total_area = 0.0
for alt_wall in (part.sap_alternative_wall_1, part.sap_alternative_wall_2):
if alt_wall is None:
continue
alt_walls_total_area += alt_wall.wall_area
alt_walls_contribution += _alt_wall_w_per_k(
alt_wall=alt_wall,
country=country,
age_band=age_band,
wall_description=wall_description,
)
main_wall_area = max(0.0, net_wall_area - alt_walls_total_area)
walls += uw * main_wall_area + alt_walls_contribution
roof += ur * roof_area roof += ur * roof_area
floor += uf * floor_area_total floor += uf * floor_area_total
party += upw * party_area party += upw * party_area
windows += window_u * w_area windows += window_u * w_area
doors += door_u * d_area doors += door_u * d_area
bridging += y * (net_wall_area + party_area + roof_area + floor_area_total + w_area + d_area) # (31) — total external element area used by both the worksheet
# readout and the (36) thermal-bridging multiplier. Excludes the
# party wall (party walls have their own line (32)) per RdSAP
# §5.15: bridging applies to *exposed* area only.
part_external_area = (
main_wall_area + alt_walls_total_area + roof_area + floor_area_total + w_area + d_area
)
total_external_area += part_external_area
bridging += y * part_external_area
total = walls + roof + floor + party + windows + doors + bridging fabric_heat_loss = walls + roof + floor + party + windows + doors # (33)
total = fabric_heat_loss + bridging # (37)
return HeatTransmission( return HeatTransmission(
walls_w_per_k=walls, walls_w_per_k=walls,
roof_w_per_k=roof, roof_w_per_k=roof,
@ -266,5 +341,41 @@ def heat_transmission_from_cert(
windows_w_per_k=windows, windows_w_per_k=windows,
doors_w_per_k=doors, doors_w_per_k=doors,
thermal_bridging_w_per_k=bridging, thermal_bridging_w_per_k=bridging,
fabric_heat_loss_w_per_k=fabric_heat_loss,
total_external_element_area_m2=total_external_area,
total_w_per_k=total, total_w_per_k=total,
) )
def _alt_wall_w_per_k(
*,
alt_wall: SapAlternativeWall,
country: Country,
age_band: str,
wall_description: Optional[str],
) -> float:
"""U × A for one alternative-wall sub-area. RdSAP §1.4.2: inherits the
part's age band but carries its own construction + insulation. A
basement-wall sub-area (RdSAP §5.17 / Table 23) bypasses the cascade
entirely."""
if alt_wall.is_basement_wall:
return u_basement_wall(age_band) * alt_wall.wall_area
alt_thickness = _parse_thickness_mm(alt_wall.wall_insulation_thickness)
alt_insulation_present = (
alt_wall.wall_insulation_type != _WALL_INSULATION_NONE
or _described_as_insulated(wall_description)
)
alt_u = u_wall(
country=country,
age_band=age_band,
construction=(
alt_wall.wall_construction
if alt_wall.wall_construction != WALL_UNKNOWN
else None
),
insulation_thickness_mm=alt_thickness,
insulation_present=alt_insulation_present,
description=wall_description,
wall_insulation_type=alt_wall.wall_insulation_type,
)
return alt_u * alt_wall.wall_area

View file

@ -0,0 +1,33 @@
"""Registry of Elmhurst SAP10.2 worksheet conformance fixtures.
Each entry is a fixture module exposing:
build_epc() -> EpcPropertyData
INTERMITTENT_FANS, HAS_SUSPENDED_TIMBER_FLOOR, ..., MV_KIND # ventilation inputs
LINE_* constants (expected worksheet outputs)
The §1/§2/§3 conformance tests parametrize over this list so adding a
new worksheet is just: drop a new `_elmhurst_worksheet_<refno>.py` into
this directory and append it here.
"""
from types import ModuleType
from domain.sap.worksheet.tests import (
_elmhurst_worksheet_000474 as w000474,
_elmhurst_worksheet_000477 as w000477,
_elmhurst_worksheet_000480 as w000480,
_elmhurst_worksheet_000487 as w000487,
_elmhurst_worksheet_000490 as w000490,
_elmhurst_worksheet_000516 as w000516,
)
# Tuple of fixture modules to iterate over. Add new worksheets by
# importing the module above and appending here.
ALL_FIXTURES: tuple[ModuleType, ...] = (
w000487, w000480, w000477, w000474, w000490, w000516,
)
def fixture_id(fixture: ModuleType) -> str:
"""Pytest id — `000487`, `000480`, ... extracted from the module name."""
return fixture.__name__.rsplit("_", 1)[-1]

View file

@ -0,0 +1,165 @@
"""Inputs + expected outputs from Elmhurst SAP10.2 worksheet U985-0001-000474.
Source: PDF supplied by user 2026-05-20. Mid-Terrace house (M), age band B,
TFA 56.79 , **2 storeys** (ground + first no room-in-roof) on main
+ **2 extensions**. Gas combi boiler, no MV system, suspended timber
ground floor (Elmhurst did NOT tick §2 (12) same quirk as 000480),
no draught lobby, **78% draught-proofed** (not 100%), 2 sheltered sides,
2 intermittent fans, East Pennines region.
Distinct features vs prior fixtures:
- **3 building parts** (Main + Extension 1 + Extension 2)
- **No room-in-roof** anywhere exercises the non-RR path
- **2 storeys not 3** (ns=2) despite 3 parts: confirms ns is dwelling
height not Σ across parts
- Extension 2 is tiny (1.35 ) and single-storey exercises mismatched
storey counts across parts
- **78% draught-proofed** (not 100%) gives (15) = 0.094
- Per-storey-different heat loss perimeters on Main (lowest 7.07,
first 5.27) the upper storey is smaller than the ground
"""
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapBuildingPart,
SapFloorDimension,
)
from domain.ml.tests._fixtures import make_minimal_sap10_epc
from domain.sap.worksheet.ventilation import MechanicalVentilationKind
_WC_CAVITY = 4
def build_epc() -> EpcPropertyData:
"""EpcPropertyData mirroring the Elmhurst 000474 inputs."""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
wall_thickness_measured=False,
party_wall_construction=0,
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.80, # lowest internal
total_floor_area_m2=12.68,
party_wall_length_m=4.52, heat_loss_perimeter_m=7.07,
floor=0,
),
SapFloorDimension(
room_height_m=3.16, # = 2.91 + 0.25
total_floor_area_m2=12.68,
party_wall_length_m=4.52, heat_loss_perimeter_m=5.27,
floor=1,
),
],
wall_thickness_mm=380,
)
extension_1 = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
wall_thickness_measured=False,
party_wall_construction=0,
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.81, # lowest of ext, internal
total_floor_area_m2=15.04,
party_wall_length_m=3.56, heat_loss_perimeter_m=8.46,
floor=0,
),
SapFloorDimension(
room_height_m=3.13, # = 2.88 + 0.25
total_floor_area_m2=15.04,
party_wall_length_m=3.56, heat_loss_perimeter_m=8.46,
floor=1,
),
],
wall_thickness_mm=380,
)
extension_2 = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_2,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
wall_thickness_measured=False,
party_wall_construction=0,
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.59, # single-storey lowest
total_floor_area_m2=1.35,
party_wall_length_m=0.0, heat_loss_perimeter_m=3.30,
floor=0,
),
],
wall_thickness_mm=380,
)
return make_minimal_sap10_epc(
total_floor_area_m2=56.79,
country_code="ENG",
sap_building_parts=[main, extension_1, extension_2],
habitable_rooms_count=3,
heated_rooms_count=3,
door_count=2,
)
# ============================================================================
# Per-fixture ventilation inputs
# ============================================================================
INTERMITTENT_FANS: int = 2
HAS_SUSPENDED_TIMBER_FLOOR: bool = False # Elmhurst quirk — cert is suspended timber
# but worksheet (12) = 0.0 (same as 000480)
SUSPENDED_TIMBER_FLOOR_SEALED: bool = False
HAS_DRAUGHT_LOBBY: bool = False
WINDOW_PCT_DRAUGHT_PROOFED: float = 78.0 # NOT 100%
MV_KIND: MechanicalVentilationKind = MechanicalVentilationKind.NATURAL
# ============================================================================
# Expected worksheet outputs
# ============================================================================
# §1 Overall dwelling characteristics
LINE_4_TFA_M2: float = 56.7900
LINE_5_VOLUME_M3: float = 168.4069
# §2 Ventilation rate — scalars
LINE_8_OPENINGS_ACH: float = 0.1188 # 20 m³/h ÷ 168.41
LINE_9_STOREYS: int = 2 # dwelling height — even with 3 parts
LINE_10_ADDITIONAL_ACH: float = 0.1000 # (2-1) × 0.1
LINE_11_STRUCTURAL_ACH: float = 0.3500
LINE_12_FLOOR_ACH: float = 0.0000 # Elmhurst quirk
LINE_13_DRAUGHT_LOBBY_ACH: float = 0.0500
LINE_14_PCT_DRAUGHT_PROOFED: float = 78.0
LINE_15_WINDOW_ACH: float = 0.0940 # 0.25 - 0.2 × 0.78
LINE_16_INFILTRATION_RATE_ACH: float = 0.7128
LINE_18_PRESSURE_TEST_ACH: float = 0.7128
LINE_19_SHELTERED_SIDES: int = 2
LINE_20_SHELTER_FACTOR: float = 0.8500
LINE_21_SHELTER_ADJUSTED_ACH: float = 0.6058
# §2 Ventilation rate — monthly (Jan..Dec)
LINE_22_WIND_SPEED_M_S: tuple[float, ...] = (
5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7,
)
LINE_22A_WIND_FACTOR: tuple[float, ...] = (
1.2750, 1.2500, 1.2250, 1.1000, 1.0750, 0.9500,
0.9500, 0.9250, 1.0000, 1.0750, 1.1250, 1.1750,
)
LINE_22B_WIND_ADJUSTED_ACH: tuple[float, ...] = (
0.7725, 0.7573, 0.7422, 0.6664, 0.6513, 0.5756,
0.5756, 0.5604, 0.6058, 0.6513, 0.6816, 0.7119,
)
LINE_25_EFFECTIVE_ACH: tuple[float, ...] = (
0.7983, 0.7868, 0.7754, 0.7221, 0.7121, 0.6656,
0.6656, 0.6570, 0.6835, 0.7121, 0.7323, 0.7534,
)
# §3 Heat losses
LINE_31_TOTAL_EXTERNAL_AREA_M2: float = 153.3900
LINE_33_FABRIC_HEAT_LOSS_W_PER_K: float = 209.1084
LINE_36_THERMAL_BRIDGING_W_PER_K: float = 23.0085
LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: float = 232.1169

View file

@ -0,0 +1,124 @@
"""Inputs + expected outputs from Elmhurst SAP10.2 worksheet U985-0001-000477.
Source: PDF supplied by user 2026-05-20. Mid-Terrace house (M), age band B
(1900-1929), TFA 77.58 , 3 storeys (ground + first + room-in-roof) on
main only no extension. Gas combi boiler, no MV system, suspended
timber ground floor (Elmhurst ticked §2 (12)=0.2), no draught lobby,
100% draught-proofed, **2 sheltered sides**, 2 intermittent fans, East
Pennines region.
Distinct features vs prior fixtures:
- Main-only cert (no extension) exercises the single-part path
- 2 doors total (vs 1 on 000487)
- 2 intermittent fans (giving (8) = 0.0933, larger than the 1-fan certs)
- Room-in-roof has both gable walls as party (like 000480) but no slope
flat ceiling just stud walls (1.5/1.3 height) + slopes
"""
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapBuildingPart,
SapFloorDimension,
SapRoomInRoof,
)
from domain.ml.tests._fixtures import make_minimal_sap10_epc
from domain.sap.worksheet.ventilation import MechanicalVentilationKind
_WC_CAVITY = 4
def build_epc() -> EpcPropertyData:
"""EpcPropertyData mirroring the Elmhurst 000477 inputs."""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
wall_thickness_measured=False,
party_wall_construction=0, # "Unable to determine" → u_party_wall = 0.25
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.80, # lowest floor — internal room height (no +0.25)
total_floor_area_m2=31.26,
party_wall_length_m=14.21, heat_loss_perimeter_m=8.78,
floor=0,
),
SapFloorDimension(
room_height_m=2.88, # = 2.63 internal + 0.25 floor structure
total_floor_area_m2=31.26,
party_wall_length_m=14.21, heat_loss_perimeter_m=8.78,
floor=1,
),
],
sap_room_in_roof=SapRoomInRoof(
floor_area=15.06, construction_age_band="B",
),
wall_thickness_mm=380,
)
return make_minimal_sap10_epc(
total_floor_area_m2=77.58,
country_code="ENG",
sap_building_parts=[main],
habitable_rooms_count=4,
heated_rooms_count=4,
door_count=2,
)
# ============================================================================
# Per-fixture ventilation inputs
# ============================================================================
INTERMITTENT_FANS: int = 2
HAS_SUSPENDED_TIMBER_FLOOR: bool = True
SUSPENDED_TIMBER_FLOOR_SEALED: bool = False
HAS_DRAUGHT_LOBBY: bool = False
WINDOW_PCT_DRAUGHT_PROOFED: float = 100.0
MV_KIND: MechanicalVentilationKind = MechanicalVentilationKind.NATURAL
# ============================================================================
# Expected worksheet outputs
# ============================================================================
# §1 Overall dwelling characteristics
LINE_4_TFA_M2: float = 77.5800
LINE_5_VOLUME_M3: float = 214.4538
# §2 Ventilation rate — scalars
LINE_8_OPENINGS_ACH: float = 0.0933 # 20 m³/h ÷ 214.45
LINE_9_STOREYS: int = 3
LINE_10_ADDITIONAL_ACH: float = 0.2000
LINE_11_STRUCTURAL_ACH: float = 0.3500
LINE_12_FLOOR_ACH: float = 0.2000
LINE_13_DRAUGHT_LOBBY_ACH: float = 0.0500
LINE_14_PCT_DRAUGHT_PROOFED: float = 100.0
LINE_15_WINDOW_ACH: float = 0.0500
LINE_16_INFILTRATION_RATE_ACH: float = 0.9433
LINE_18_PRESSURE_TEST_ACH: float = 0.9433
LINE_19_SHELTERED_SIDES: int = 2
LINE_20_SHELTER_FACTOR: float = 0.8500
LINE_21_SHELTER_ADJUSTED_ACH: float = 0.8018
# §2 Ventilation rate — monthly (Jan..Dec)
LINE_22_WIND_SPEED_M_S: tuple[float, ...] = (
5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7,
)
LINE_22A_WIND_FACTOR: tuple[float, ...] = (
1.2750, 1.2500, 1.2250, 1.1000, 1.0750, 0.9500,
0.9500, 0.9250, 1.0000, 1.0750, 1.1250, 1.1750,
)
LINE_22B_WIND_ADJUSTED_ACH: tuple[float, ...] = (
1.0223, 1.0022, 0.9822, 0.8819, 0.8619, 0.7617,
0.7617, 0.7416, 0.8018, 0.8619, 0.9020, 0.9421,
)
LINE_25_EFFECTIVE_ACH: tuple[float, ...] = (
1.0223, 1.0022, 0.9823, 0.8889, 0.8714, 0.7901,
0.7901, 0.7750, 0.8214, 0.8714, 0.9068, 0.9438,
)
# §3 Heat losses
LINE_31_TOTAL_EXTERNAL_AREA_M2: float = 122.3600
LINE_33_FABRIC_HEAT_LOSS_W_PER_K: float = 160.8702
LINE_36_THERMAL_BRIDGING_W_PER_K: float = 18.3540
LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: float = 179.2242

View file

@ -0,0 +1,155 @@
"""Inputs + expected outputs from Elmhurst SAP10.2 worksheet U985-0001-000480.
Source: PDF supplied by user 2026-05-20. Mid-Terrace house (M, not
Enclosed), age band B (1900-1929), TFA 84.41 , 3 storeys (ground +
first + room-in-roof) on main + 2-storey extension. Gas combi boiler,
no MV system, **suspended timber typed on cert but §2 (12) = 0.0** (an
Elmhurst quirk worth noting see HAS_SUSPENDED_TIMBER_FLOOR below),
no draught lobby, 100% draught-proofed, **2 sheltered sides** (vs 3 on
000487), East Pennines region.
Differs from 000487 along several useful axes:
- Sheltered sides: 2 (vs 3) different (20) shelter factor
- Suspended timber floor (12): 0.0 (vs 0.2) different (16) sum
- Volume: 235.85 (vs 228.03)
- Room-in-roof has **both gables as party walls** (vs 1 party + 1 sheltered)
- No alternative wall (vs 1 timber-frame alt wall on the extension)
"""
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapBuildingPart,
SapFloorDimension,
SapRoomInRoof,
)
from domain.ml.tests._fixtures import make_minimal_sap10_epc
from domain.sap.worksheet.ventilation import MechanicalVentilationKind
_WC_CAVITY = 4
def build_epc() -> EpcPropertyData:
"""EpcPropertyData mirroring the Elmhurst 000480 inputs."""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
wall_thickness_measured=False,
party_wall_construction=0, # "Unable to determine" → u_party_wall = 0.25
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.72, # lowest floor — internal room height (no +0.25)
total_floor_area_m2=15.28,
party_wall_length_m=7.04, heat_loss_perimeter_m=4.34,
floor=0,
),
SapFloorDimension(
room_height_m=3.09, # 2.84 internal + 0.25 floor structure
total_floor_area_m2=15.28,
party_wall_length_m=7.04, heat_loss_perimeter_m=4.34,
floor=1,
),
],
sap_room_in_roof=SapRoomInRoof(
floor_area=19.83, construction_age_band="B",
),
wall_thickness_mm=380,
)
extension = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
wall_thickness_measured=False,
party_wall_construction=0,
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.70, # ext lowest — internal room height
total_floor_area_m2=17.01,
party_wall_length_m=7.84, heat_loss_perimeter_m=4.34,
floor=0,
),
SapFloorDimension(
room_height_m=3.09, # 2.84 internal + 0.25 floor structure
total_floor_area_m2=17.01,
party_wall_length_m=7.84, heat_loss_perimeter_m=4.34,
floor=1,
),
],
# NB: no alternative wall on this cert (vs 000487 which had a
# 1.43 m² timber-frame alt wall on the extension).
wall_thickness_mm=380,
)
return make_minimal_sap10_epc(
total_floor_area_m2=84.41,
country_code="ENG",
sap_building_parts=[main, extension],
habitable_rooms_count=4,
heated_rooms_count=4,
door_count=2, # cert lodges 2 doors total
)
# ============================================================================
# Per-fixture ventilation inputs
# ============================================================================
INTERMITTENT_FANS: int = 1
# Elmhurst quirk: cert lodges Suspended Timber floor (used for the floor
# U-value lookup, giving U=0.53) but the worksheet (12) entry is 0.0 — i.e.
# Elmhurst chose NOT to add the 0.2 (unsealed) infiltration premium for §2,
# possibly because the assessor judged the floor effectively sealed. We
# match the worksheet's choice; document the divergence from cert typing.
HAS_SUSPENDED_TIMBER_FLOOR: bool = False
SUSPENDED_TIMBER_FLOOR_SEALED: bool = False # unused while above is False
HAS_DRAUGHT_LOBBY: bool = False
WINDOW_PCT_DRAUGHT_PROOFED: float = 100.0
MV_KIND: MechanicalVentilationKind = MechanicalVentilationKind.NATURAL
# ============================================================================
# Expected worksheet outputs — every (NNN) line we can verify
# ============================================================================
# §1 Overall dwelling characteristics
LINE_4_TFA_M2: float = 84.4100
LINE_5_VOLUME_M3: float = 235.8482
# §2 Ventilation rate — scalars
LINE_8_OPENINGS_ACH: float = 0.0424
LINE_9_STOREYS: int = 3
LINE_10_ADDITIONAL_ACH: float = 0.2000
LINE_11_STRUCTURAL_ACH: float = 0.3500
LINE_12_FLOOR_ACH: float = 0.0000 # Elmhurst quirk — see HAS_SUSPENDED_TIMBER_FLOOR
LINE_13_DRAUGHT_LOBBY_ACH: float = 0.0500
LINE_14_PCT_DRAUGHT_PROOFED: float = 100.0
LINE_15_WINDOW_ACH: float = 0.0500
LINE_16_INFILTRATION_RATE_ACH: float = 0.6924
LINE_18_PRESSURE_TEST_ACH: float = 0.6924
LINE_19_SHELTERED_SIDES: int = 2
LINE_20_SHELTER_FACTOR: float = 0.8500
LINE_21_SHELTER_ADJUSTED_ACH: float = 0.5885
# §2 Ventilation rate — monthly (Jan..Dec). East Pennines, Table U2 default.
LINE_22_WIND_SPEED_M_S: tuple[float, ...] = (
5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7,
)
LINE_22A_WIND_FACTOR: tuple[float, ...] = (
1.2750, 1.2500, 1.2250, 1.1000, 1.0750, 0.9500,
0.9500, 0.9250, 1.0000, 1.0750, 1.1250, 1.1750,
)
LINE_22B_WIND_ADJUSTED_ACH: tuple[float, ...] = (
0.7504, 0.7357, 0.7210, 0.6474, 0.6327, 0.5591,
0.5591, 0.5444, 0.5885, 0.6327, 0.6621, 0.6915,
)
LINE_25_EFFECTIVE_ACH: tuple[float, ...] = (
0.7815, 0.7706, 0.7599, 0.7096, 0.7001, 0.6563,
0.6563, 0.6482, 0.6732, 0.7001, 0.7192, 0.7391,
)
# §3 Heat losses
LINE_31_TOTAL_EXTERNAL_AREA_M2: float = 132.0000
LINE_33_FABRIC_HEAT_LOSS_W_PER_K: float = 223.6239
LINE_36_THERMAL_BRIDGING_W_PER_K: float = 19.8000 # y(0.15) × (31)
LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: float = 243.4239

View file

@ -0,0 +1,169 @@
"""Inputs + expected outputs from Elmhurst SAP10.2 worksheet U985-0001-000487.
Source: PDF supplied by user 2026-05-20. Mid-Terrace house (Enclosed
Mid-Terrace), age band B (1900-1929), TFA 81.57 , dwelling sitting on
3 storeys (ground + first + room-in-roof) on main + 2-storey extension.
Gas combi boiler, no MV system, suspended timber ground floor, no
draught lobby, 100% draught-proofed, 3 sheltered sides (terraced),
East Pennines region.
This module is the **anchor fixture** for §1/§2/§3 conformance tests
each section's test asserts our calculator output against the worksheet
values captured below. Treat the LINE_X constants as authoritative; if
they diverge from our code, the bug is on our side.
"""
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapAlternativeWall,
SapBuildingPart,
SapFloorDimension,
SapRoomInRoof,
)
from domain.ml.tests._fixtures import make_minimal_sap10_epc
# RdSAP wall_construction code seen in the cert→worksheet mapping. The
# Summary lists "CA Cavity" for both main and extension walls. The alt
# wall is "TI Timber Frame".
_WC_CAVITY = 4
_WC_TIMBER_FRAME = 8
def build_epc() -> EpcPropertyData:
"""EpcPropertyData mirroring the Elmhurst inputs. Field-by-field
correspondence with the Summary_000487 PDF."""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4, # "A As Built"
wall_thickness_measured=False,
party_wall_construction=0, # "U Unable to determine" → u_party_wall returns 0.25
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.74, total_floor_area_m2=23.89,
party_wall_length_m=9.78, heat_loss_perimeter_m=5.03,
floor=0,
),
SapFloorDimension(
room_height_m=3.10, total_floor_area_m2=23.89,
party_wall_length_m=9.78, heat_loss_perimeter_m=5.03,
floor=1,
),
],
sap_room_in_roof=SapRoomInRoof(
floor_area=21.03, construction_age_band="B",
),
wall_thickness_mm=380,
)
extension = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
wall_thickness_measured=False,
party_wall_construction=0,
sap_floor_dimensions=[
# The worksheet labels the extension's storeys as (1c) "First
# floor" and (1d) "Second floor" rather than (1b) ground,
# because the extension sits at the dwelling's first-floor
# level upward (no ground storey). For our domain we keep
# floor=0 / floor=1 = lowest two storeys of the part.
SapFloorDimension(
room_height_m=2.74, total_floor_area_m2=7.13,
party_wall_length_m=6.25, heat_loss_perimeter_m=1.50,
floor=0,
),
SapFloorDimension(
room_height_m=3.10, total_floor_area_m2=5.63,
party_wall_length_m=0.0, heat_loss_perimeter_m=5.25,
floor=1,
),
],
sap_alternative_wall_1=SapAlternativeWall(
wall_area=1.43, wall_dry_lined="N",
wall_construction=_WC_TIMBER_FRAME,
wall_insulation_type=4,
wall_thickness_measured="N",
wall_insulation_thickness="150",
),
wall_thickness_mm=380,
)
return make_minimal_sap10_epc(
total_floor_area_m2=81.57,
country_code="ENG",
sap_building_parts=[main, extension],
habitable_rooms_count=3,
heated_rooms_count=3,
door_count=1,
)
# ============================================================================
# Per-fixture ventilation inputs (Elmhurst assessor choices that don't live
# on the EpcPropertyData domain object — we pass these into
# `ventilation_from_inputs` alongside the cert-derived geometry).
# ============================================================================
from domain.sap.worksheet.ventilation import MechanicalVentilationKind
INTERMITTENT_FANS: int = 1
HAS_SUSPENDED_TIMBER_FLOOR: bool = True
SUSPENDED_TIMBER_FLOOR_SEALED: bool = False
HAS_DRAUGHT_LOBBY: bool = False
WINDOW_PCT_DRAUGHT_PROOFED: float = 100.0
MV_KIND: MechanicalVentilationKind = MechanicalVentilationKind.NATURAL
# ============================================================================
# Expected worksheet outputs — every (NNN) line we can verify
# ============================================================================
# §1 Overall dwelling characteristics
LINE_4_TFA_M2: float = 81.5700
LINE_5_VOLUME_M3: float = 228.0303
# §2 Ventilation rate — scalars
LINE_8_OPENINGS_ACH: float = 0.0439
LINE_9_STOREYS: int = 3 # height of dwelling, not Σ across parts
LINE_10_ADDITIONAL_ACH: float = 0.2000 # (3-1) × 0.1
LINE_11_STRUCTURAL_ACH: float = 0.3500
LINE_12_FLOOR_ACH: float = 0.2000 # suspended timber unsealed
LINE_13_DRAUGHT_LOBBY_ACH: float = 0.0500
LINE_14_PCT_DRAUGHT_PROOFED: float = 100.0
LINE_15_WINDOW_ACH: float = 0.0500
LINE_16_INFILTRATION_RATE_ACH: float = 0.8939
LINE_18_PRESSURE_TEST_ACH: float = 0.8939 # no pressure test → = (16)
LINE_19_SHELTERED_SIDES: int = 3
LINE_20_SHELTER_FACTOR: float = 0.7750
LINE_21_SHELTER_ADJUSTED_ACH: float = 0.6927
# §2 Ventilation rate — monthly (Jan..Dec). Cert is in East Pennines
# region; values match Table U2 default that ships in our code.
LINE_22_WIND_SPEED_M_S: tuple[float, ...] = (
5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7,
)
LINE_22A_WIND_FACTOR: tuple[float, ...] = (
1.2750, 1.2500, 1.2250, 1.1000, 1.0750, 0.9500,
0.9500, 0.9250, 1.0000, 1.0750, 1.1250, 1.1750,
)
LINE_22B_WIND_ADJUSTED_ACH: tuple[float, ...] = (
0.8832, 0.8659, 0.8486, 0.7620, 0.7447, 0.6581,
0.6581, 0.6408, 0.6927, 0.7447, 0.7793, 0.8140,
)
LINE_25_EFFECTIVE_ACH: tuple[float, ...] = (
0.8901, 0.8749, 0.8601, 0.7903, 0.7773, 0.7165,
0.7165, 0.7053, 0.7399, 0.7773, 0.8037, 0.8313,
)
# §3 Heat losses (selected lines we can validate today)
LINE_31_TOTAL_EXTERNAL_AREA_M2: float = 127.2500
LINE_33_FABRIC_HEAT_LOSS_W_PER_K: float = 180.6975
LINE_36_THERMAL_BRIDGING_W_PER_K: float = 19.0875 # y(0.15) × (31)
LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: float = 199.7850
# §3 gap notes for the test docstrings:
# - Worksheet has Room-in-Roof gable / flat ceiling / stud wall / slope
# lines that contribute ~64 W/K (35% of fabric). Our SapRoomInRoof
# only carries floor_area, so these are NOT computed by our code.
# Tracked separately — needs a domain schema extension.
# - Window U is set per window in Elmhurst; we only carry an avg.

View file

@ -0,0 +1,155 @@
"""Inputs + expected outputs from Elmhurst SAP10.2 worksheet U985-0001-000490.
Source: PDF supplied by user 2026-05-20. End-Terrace house (E), age band
B (1900-1929), TFA 66.06 , **2 storeys + 2-storey extension** (no
room-in-roof). Gas combi boiler (Vaillant Ecotec Pro), no MV system,
**suspended timber ground floor on main (U=0.71)** + **exposed timber
floor on Extension 1 (U=1.20)**, no draught lobby, **100% draught-proofed**,
**1 sheltered side** (End-Terrace), 2 intermittent fans, East Pennines
region.
Distinct features vs prior fixtures:
- **End-terrace** with only 1 sheltered side (20)=0.925 lowest shelter
factor we have so far (000487=0.775, 000477/000480/000474=0.85)
- **First "Main + extension, no RR anywhere"** fixture (000474 has 2
extensions but no RR; 000487/000480 have RR + extension; 000477 is
main-only with RR). 000490 cleanly exercises the multi-part flat-roof
path.
- **Extension 1 has no ground floor** only first/second floors (cert
records "1st Storey: 0.00, 0.00, 0.00") meaning the extension hangs
off the main from the first storey upward
- DP = 100%, so (15) = 0.05 (lowest window-infiltration component)
"""
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapBuildingPart,
SapFloorDimension,
)
from domain.ml.tests._fixtures import make_minimal_sap10_epc
from domain.sap.worksheet.ventilation import MechanicalVentilationKind
_WC_CAVITY = 4
def build_epc() -> EpcPropertyData:
"""EpcPropertyData mirroring the Elmhurst 000490 inputs."""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
wall_thickness_measured=False,
party_wall_construction=0, # "U Unable to determine" → U=0.25
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.95, # lowest floor — internal room height
total_floor_area_m2=14.85,
party_wall_length_m=4.27, heat_loss_perimeter_m=7.42,
floor=0,
),
SapFloorDimension(
room_height_m=3.24, # = 2.99 internal + 0.25 floor structure
total_floor_area_m2=14.85,
party_wall_length_m=4.27, heat_loss_perimeter_m=7.42,
floor=1,
),
],
wall_thickness_mm=400,
)
extension = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
wall_thickness_measured=False,
party_wall_construction=0,
sap_floor_dimensions=[
# Cert records the extension at the dwelling's 1st/2nd-storey
# level (no ground floor). Within our domain the lowest floor
# of the part is still floor=0.
SapFloorDimension(
room_height_m=2.88, # 1st-of-ext internal
total_floor_area_m2=18.18,
party_wall_length_m=3.53, heat_loss_perimeter_m=8.68,
floor=0,
),
SapFloorDimension(
room_height_m=3.21, # = 2.96 internal + 0.25 floor structure
total_floor_area_m2=18.18,
party_wall_length_m=3.53, heat_loss_perimeter_m=8.68,
floor=1,
),
],
wall_thickness_mm=400,
)
return make_minimal_sap10_epc(
total_floor_area_m2=66.06,
country_code="ENG",
sap_building_parts=[main, extension],
habitable_rooms_count=4,
heated_rooms_count=4,
door_count=1,
)
# ============================================================================
# Per-fixture ventilation inputs
# ============================================================================
INTERMITTENT_FANS: int = 2
# Elmhurst lodged a suspended timber ground floor on the cert but the
# worksheet shows (12)=0.0 — same quirk as 000480. Mirror the worksheet,
# not the cert input: set has_suspended_timber_floor=False so floor_ach=0.
HAS_SUSPENDED_TIMBER_FLOOR: bool = False
SUSPENDED_TIMBER_FLOOR_SEALED: bool = False
HAS_DRAUGHT_LOBBY: bool = False
WINDOW_PCT_DRAUGHT_PROOFED: float = 100.0
MV_KIND: MechanicalVentilationKind = MechanicalVentilationKind.NATURAL
# ============================================================================
# Expected worksheet outputs
# ============================================================================
# §1 Overall dwelling characteristics
LINE_4_TFA_M2: float = 66.0600
LINE_5_VOLUME_M3: float = 202.6377
# §2 Ventilation rate — scalars
LINE_8_OPENINGS_ACH: float = 0.0987 # 20 m³/h ÷ 202.64
LINE_9_STOREYS: int = 2
LINE_10_ADDITIONAL_ACH: float = 0.1000
LINE_11_STRUCTURAL_ACH: float = 0.3500
LINE_12_FLOOR_ACH: float = 0.0000 # Elmhurst quirk: timber lodged but (12)=0
LINE_13_DRAUGHT_LOBBY_ACH: float = 0.0500
LINE_14_PCT_DRAUGHT_PROOFED: float = 100.0
LINE_15_WINDOW_ACH: float = 0.0500
LINE_16_INFILTRATION_RATE_ACH: float = 0.6487
LINE_18_PRESSURE_TEST_ACH: float = 0.6487
LINE_19_SHELTERED_SIDES: int = 1
LINE_20_SHELTER_FACTOR: float = 0.9250
LINE_21_SHELTER_ADJUSTED_ACH: float = 0.6000
# §2 Ventilation rate — monthly (Jan..Dec)
LINE_22_WIND_SPEED_M_S: tuple[float, ...] = (
5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7,
)
LINE_22A_WIND_FACTOR: tuple[float, ...] = (
1.2750, 1.2500, 1.2250, 1.1000, 1.0750, 0.9500,
0.9500, 0.9250, 1.0000, 1.0750, 1.1250, 1.1750,
)
LINE_22B_WIND_ADJUSTED_ACH: tuple[float, ...] = (
0.7651, 0.7501, 0.7351, 0.6601, 0.6450, 0.5700,
0.5700, 0.5550, 0.6000, 0.6450, 0.6751, 0.7051,
)
LINE_25_EFFECTIVE_ACH: tuple[float, ...] = (
0.7927, 0.7813, 0.7702, 0.7178, 0.7080, 0.6625,
0.6625, 0.6540, 0.6800, 0.7080, 0.7278, 0.7486,
)
# §3 Heat losses (reference — §3 test asserts invariants only).
LINE_31_TOTAL_EXTERNAL_AREA_M2: float = 164.8500
LINE_33_FABRIC_HEAT_LOSS_W_PER_K: float = 211.8936
LINE_36_THERMAL_BRIDGING_W_PER_K: float = 24.7275 # 0.15 × 164.85
LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: float = 236.6211

View file

@ -0,0 +1,133 @@
"""Inputs + expected outputs from Elmhurst SAP10.2 worksheet U985-0001-000516.
Source: PDF supplied by user 2026-05-20. Mid-Terrace house (M), age band
**A** (pre-1900), TFA 90.54 , 3 storeys (ground + first + room-in-roof)
on main only no extension. Gas combi boiler (Vaillant ecoTEC sustain
24), no MV system, **exposed timber floor** above unheated space (NOT
suspended timber over soil (12)=0), no draught lobby, **75%
draught-proofed**, **2 sheltered sides**, 2 intermittent fans, East
Pennines region.
Distinct features vs prior fixtures:
- **First age-band-A fixture** (pre-1900) exercises Table 6 row A
- **Party walls map to U=0.0** (Solid masonry `party_wall_construction=3`),
not 0.25 like 000487/000480/000477 first fixture to exercise this branch
- **(12) floor ACH = 0** despite a timber floor lodged on the cert the
floor is *exposed timber* above an unheated room (1.20 U) rather than
suspended timber over a ventilated crawlspace, so the ventilation
worksheet ticks (12)=0
- **DP% = 75** (not 100) (15) = 0.10
- Room-in-roof has **2 party gables** (both 13.11 × 0.25) same shape
as 000480 but on a single-part dwelling
"""
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapBuildingPart,
SapFloorDimension,
SapRoomInRoof,
)
from domain.ml.tests._fixtures import make_minimal_sap10_epc
from domain.sap.worksheet.ventilation import MechanicalVentilationKind
_WC_CAVITY = 4
_WC_SOLID_BRICK = 3 # party walls — RdSAP10 maps to U=0.0 (solid masonry)
def build_epc() -> EpcPropertyData:
"""EpcPropertyData mirroring the Elmhurst 000516 inputs."""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="A",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
wall_thickness_measured=False,
party_wall_construction=_WC_SOLID_BRICK, # Solid masonry → U=0.0
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.75, # lowest floor — internal room height (no +0.25)
total_floor_area_m2=35.76,
party_wall_length_m=18.14, heat_loss_perimeter_m=7.89,
floor=0,
),
SapFloorDimension(
room_height_m=3.00, # = 2.75 internal + 0.25 floor structure
total_floor_area_m2=35.76,
party_wall_length_m=18.14, heat_loss_perimeter_m=7.89,
floor=1,
),
],
sap_room_in_roof=SapRoomInRoof(
floor_area=19.02, construction_age_band="A",
),
wall_thickness_mm=400,
)
return make_minimal_sap10_epc(
total_floor_area_m2=90.54,
country_code="ENG",
sap_building_parts=[main],
habitable_rooms_count=3,
heated_rooms_count=3,
door_count=1,
)
# ============================================================================
# Per-fixture ventilation inputs
# ============================================================================
INTERMITTENT_FANS: int = 2
HAS_SUSPENDED_TIMBER_FLOOR: bool = False # exposed floor above unheated, (12)=0
SUSPENDED_TIMBER_FLOOR_SEALED: bool = False
HAS_DRAUGHT_LOBBY: bool = False
WINDOW_PCT_DRAUGHT_PROOFED: float = 75.0
MV_KIND: MechanicalVentilationKind = MechanicalVentilationKind.NATURAL
# ============================================================================
# Expected worksheet outputs
# ============================================================================
# §1 Overall dwelling characteristics
LINE_4_TFA_M2: float = 90.5400
LINE_5_VOLUME_M3: float = 252.2190
# §2 Ventilation rate — scalars
LINE_8_OPENINGS_ACH: float = 0.0793 # 20 m³/h ÷ 252.22
LINE_9_STOREYS: int = 3
LINE_10_ADDITIONAL_ACH: float = 0.2000
LINE_11_STRUCTURAL_ACH: float = 0.3500
LINE_12_FLOOR_ACH: float = 0.0000 # exposed floor, not suspended-over-void
LINE_13_DRAUGHT_LOBBY_ACH: float = 0.0500
LINE_14_PCT_DRAUGHT_PROOFED: float = 75.0
LINE_15_WINDOW_ACH: float = 0.1000 # 0.25 - 0.2 × 75/100
LINE_16_INFILTRATION_RATE_ACH: float = 0.7793
LINE_18_PRESSURE_TEST_ACH: float = 0.7793
LINE_19_SHELTERED_SIDES: int = 2
LINE_20_SHELTER_FACTOR: float = 0.8500
LINE_21_SHELTER_ADJUSTED_ACH: float = 0.6624
# §2 Ventilation rate — monthly (Jan..Dec)
LINE_22_WIND_SPEED_M_S: tuple[float, ...] = (
5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7,
)
LINE_22A_WIND_FACTOR: tuple[float, ...] = (
1.2750, 1.2500, 1.2250, 1.1000, 1.0750, 0.9500,
0.9500, 0.9250, 1.0000, 1.0750, 1.1250, 1.1750,
)
LINE_22B_WIND_ADJUSTED_ACH: tuple[float, ...] = (
0.8446, 0.8280, 0.8114, 0.7286, 0.7121, 0.6293,
0.6293, 0.6127, 0.6624, 0.7121, 0.7452, 0.7783,
)
LINE_25_EFFECTIVE_ACH: tuple[float, ...] = (
0.8566, 0.8428, 0.8292, 0.7655, 0.7535, 0.6980,
0.6980, 0.6877, 0.7194, 0.7535, 0.7777, 0.8029,
)
# §3 Heat losses (reference only — §3 test currently checks invariants;
# our calculator under-reports because RR slope/stud/gable sub-areas
# aren't yet modelled by SapRoomInRoof).
LINE_31_TOTAL_EXTERNAL_AREA_M2: float = 122.0100
LINE_33_FABRIC_HEAT_LOSS_W_PER_K: float = 211.3188
LINE_36_THERMAL_BRIDGING_W_PER_K: float = 18.3015 # 0.15 × 122.01
LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: float = 229.6203

View file

@ -0,0 +1,40 @@
"""Reader for the canonical SAP10.2 worksheet Excel — the source of
truth used to build line-by-line conformance tests against
`packages/domain/src/domain/sap/worksheet/`.
The Excel file lives at the repo root and has two sheets both contain
the full worksheet, differing only on weather-data source:
- `RegionalWeather`: 556 non-trivial rows
- `NonRegionalWeather`: 959 non-trivial rows
Each populated cell value can be looked up directly by sheet + cell ref
(e.g. Q23 holds line `(4)` Total Floor Area in the worked example). The
loader cache is process-wide so opening the workbook once per pytest
run covers every section.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any, Iterable
import openpyxl
_REPO_ROOT = Path(__file__).resolve().parents[7]
WORKSHEET_XLSX_PATH = _REPO_ROOT / "2026-05-19-17-18 RdSap10Worksheet.xlsx"
@lru_cache(maxsize=1)
def _workbook() -> openpyxl.Workbook:
return openpyxl.load_workbook(WORKSHEET_XLSX_PATH, data_only=True, read_only=True)
def load_cells(sheet_name: str, cells: Iterable[str]) -> dict[str, Any]:
"""Return `{cell_ref: value}` for the requested cells on `sheet_name`.
Values are read with `data_only=True` so formula cells yield their
last-computed result rather than the formula string. Cell refs use
standard Excel notation, e.g. "Q23", "U25".
"""
sheet = _workbook()[sheet_name]
return {ref: sheet[ref].value for ref in cells}

View file

@ -0,0 +1,556 @@
{
"uprn": 100011529400,
"roofs": [
{
"description": "Pitched, 150 mm loft insulation",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
{
"description": "Pitched, 100 mm loft insulation",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
}
],
"walls": [
{
"description": "Basement wall",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
{
"description": "Solid brick, as built, no insulation (assumed)",
"energy_efficiency_rating": 1,
"environmental_efficiency_rating": 1
}
],
"floors": [
{
"description": "Solid",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
{
"description": "Solid, no insulation (assumed)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"status": "entered",
"tenure": 1,
"window": {
"description": "Fully double glazed",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
},
"lighting": {
"description": "Excellent lighting efficiency",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
"postcode": "SK4 4EJ",
"hot_water": {
"description": "From main system",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"post_town": "STOCKPORT",
"built_form": 4,
"created_at": "2026-02-10 15:11:39",
"door_count": 2,
"region_code": 19,
"report_type": 2,
"sap_heating": {
"number_baths": 1,
"cylinder_size": 1,
"shower_outlets": [
{
"shower_outlet": {
"shower_wwhrs": 1,
"shower_outlet_type": 1
}
}
],
"number_baths_wwhrs": 0,
"water_heating_code": 901,
"water_heating_fuel": 26,
"main_heating_details": [
{
"has_fghrs": "N",
"main_fuel_type": 26,
"boiler_flue_type": 2,
"fan_flue_present": "Y",
"heat_emitter_type": 1,
"emitter_temperature": 0,
"main_heating_number": 1,
"main_heating_control": 2106,
"main_heating_category": 2,
"main_heating_fraction": 1,
"sap_main_heating_code": 104,
"central_heating_pump_age": 0,
"main_heating_data_source": 2
}
],
"immersion_heating_type": "NA",
"has_fixed_air_conditioning": "false"
},
"sap_version": 10.2,
"sap_windows": [
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.2,
"window_height": 1.8,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "Y",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.2,
"window_height": 1.8,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "Y",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 1.65,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "Y",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.83,
"window_height": 1.65,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "Y",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 1,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.2,
"window_height": 2.4,
"draught_proofed": "true",
"window_location": 2,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 1,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 1.6,
"draught_proofed": "true",
"window_location": 2,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 7,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.1,
"window_height": 1.5,
"draught_proofed": "true",
"window_location": 1,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 7,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.6,
"window_height": 0.9,
"draught_proofed": "true",
"window_location": 1,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"orientation": 1,
"window_type": 1,
"glazing_type": 13,
"window_width": 0.9,
"window_height": 1.2,
"draught_proofed": "true",
"window_location": 1,
"window_wall_type": 1,
"permanent_shutters_present": "Y",
"permanent_shutters_insulated": "N"
}
],
"schema_type": "RdSAP-Schema-21.0.1",
"uprn_source": "Energy Assessor",
"country_code": "ENG",
"main_heating": [
{
"description": "Boiler and radiators, mains gas",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"air_tightness": {
"description": "(not tested)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"dwelling_type": "Mid-terrace house",
"language_code": 1,
"pressure_test": 4,
"property_type": 0,
"address_line_1": "21 Ventnor Road",
"assessment_type": "RdSAP",
"completion_date": "2026-02-10",
"inspection_date": "2026-02-09",
"extensions_count": 2,
"measurement_type": 1,
"total_floor_area": 84,
"transaction_type": 1,
"conservatory_type": 1,
"heated_room_count": 5,
"registration_date": "2026-02-10",
"sap_energy_source": {
"mains_gas": "Y",
"meter_type": 2,
"pv_connection": 0,
"photovoltaic_supply": {
"none_or_no_details": {
"percent_roof_area": 0
}
},
"wind_turbines_count": 0,
"gas_smart_meter_present": "false",
"is_dwelling_export_capable": "false",
"wind_turbines_terrain_type": 2,
"electricity_smart_meter_present": "false"
},
"secondary_heating": {
"description": "None",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"extract_fans_count": 1,
"sap_building_parts": [
{
"identifier": "Main Dwelling",
"wall_dry_lined": "N",
"wall_thickness": 270,
"floor_heat_loss": 7,
"roof_construction": 4,
"wall_construction": 4,
"building_part_number": 1,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.75,
"quantity": "metres"
},
"floor_insulation": 0,
"total_floor_area": {
"value": 19.22,
"quantity": "square metres"
},
"party_wall_length": {
"value": 7,
"quantity": "metres"
},
"floor_construction": 4,
"heat_loss_perimeter": {
"value": 4.55,
"quantity": "metres"
}
},
{
"floor": 1,
"room_height": {
"value": 2.74,
"quantity": "metres"
},
"total_floor_area": {
"value": 17.99,
"quantity": "square metres"
},
"party_wall_length": {
"value": 7,
"quantity": "metres"
},
"heat_loss_perimeter": {
"value": 5.25,
"quantity": "metres"
}
}
],
"wall_insulation_type": 4,
"construction_age_band": "B",
"sap_alternative_wall_1": {
"wall_area": 14.24,
"wall_dry_lined": "N",
"wall_thickness": 260,
"wall_construction": 6,
"wall_insulation_type": 4,
"wall_thickness_measured": "Y",
"wall_insulation_thickness": "NI"
},
"party_wall_construction": 1,
"wall_thickness_measured": "Y",
"roof_insulation_location": 2,
"roof_insulation_thickness": "150mm",
"wall_insulation_thickness": "NI"
},
{
"identifier": "Extension 1",
"wall_dry_lined": "N",
"wall_thickness": 260,
"floor_heat_loss": 7,
"roof_construction": 4,
"wall_construction": 3,
"building_part_number": 2,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.73,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 9.77,
"quantity": "square metres"
},
"party_wall_length": {
"value": 3.8,
"quantity": "metres"
},
"floor_construction": 1,
"heat_loss_perimeter": {
"value": 6.37,
"quantity": "metres"
}
},
{
"floor": 1,
"room_height": {
"value": 2.76,
"quantity": "metres"
},
"total_floor_area": {
"value": 9.77,
"quantity": "square metres"
},
"party_wall_length": {
"value": 3.8,
"quantity": "metres"
},
"heat_loss_perimeter": {
"value": 6.37,
"quantity": "metres"
}
}
],
"wall_insulation_type": 4,
"construction_age_band": "B",
"party_wall_construction": 1,
"wall_thickness_measured": "Y",
"roof_insulation_location": 2,
"roof_insulation_thickness": "100mm",
"wall_insulation_thickness": "NI",
"floor_insulation_thickness": "NI"
},
{
"identifier": "Extension 2",
"wall_dry_lined": "N",
"wall_thickness": 260,
"floor_heat_loss": 7,
"roof_construction": 4,
"wall_construction": 3,
"building_part_number": 3,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.75,
"quantity": "metres"
},
"floor_insulation": 0,
"total_floor_area": {
"value": 13.86,
"quantity": "square metres"
},
"party_wall_length": {
"value": 7,
"quantity": "metres"
},
"floor_construction": 4,
"heat_loss_perimeter": {
"value": 1.98,
"quantity": "metres"
}
},
{
"floor": 1,
"room_height": {
"value": 2.74,
"quantity": "metres"
},
"total_floor_area": {
"value": 13.86,
"quantity": "square metres"
},
"party_wall_length": {
"value": 7,
"quantity": "metres"
},
"heat_loss_perimeter": {
"value": 1.98,
"quantity": "metres"
}
}
],
"wall_insulation_type": 4,
"construction_age_band": "B",
"sap_alternative_wall_1": {
"wall_area": 3.96,
"wall_dry_lined": "N",
"wall_thickness": 260,
"wall_construction": 6,
"wall_insulation_type": 4,
"wall_thickness_measured": "Y",
"wall_insulation_thickness": "NI"
},
"party_wall_construction": 1,
"wall_thickness_measured": "Y",
"roof_insulation_location": 2,
"roof_insulation_thickness": "150mm",
"wall_insulation_thickness": "NI"
}
],
"solar_water_heating": "N",
"habitable_room_count": 5,
"heating_cost_current": {
"value": 809,
"currency": "GBP"
},
"insulated_door_count": 0,
"co2_emissions_current": 2.8,
"energy_rating_average": 60,
"energy_rating_current": 70,
"lighting_cost_current": {
"value": 52,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": "Programmer, room thermostat and TRVs",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"blocked_chimneys_count": 2,
"has_hot_water_cylinder": "false",
"heating_cost_potential": {
"value": 647,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 214,
"currency": "GBP"
},
"mechanical_ventilation": 0,
"percent_draughtproofed": 100,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": {
"value": 161,
"currency": "GBP"
},
"indicative_cost": "\u00a37,500 - \u00a311,000",
"improvement_type": "Q",
"improvement_details": {
"improvement_number": 7
},
"improvement_category": 5,
"energy_performance_rating": 74,
"environmental_impact_rating": 74
},
{
"sequence": 2,
"typical_saving": {
"value": 207,
"currency": "GBP"
},
"indicative_cost": "\u00a38,000 - \u00a310,000",
"improvement_type": "U",
"improvement_details": {
"improvement_number": 34
},
"improvement_category": 5,
"energy_performance_rating": 78,
"environmental_impact_rating": 75
}
],
"co2_emissions_potential": 2.2,
"energy_rating_potential": 78,
"lighting_cost_potential": {
"value": 52,
"currency": "GBP"
},
"schema_version_original": "21.0.1",
"hot_water_cost_potential": {
"value": 215,
"currency": "GBP"
},
"renewable_heat_incentive": {
"water_heating": 2609.98,
"space_heating_existing_dwelling": 8458.7
},
"draughtproofed_door_count": 2,
"energy_consumption_current": 185,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 100,
"calculation_software_version": "5.02r0332",
"energy_consumption_potential": 139,
"environmental_impact_current": 68,
"current_energy_efficiency_band": "C",
"environmental_impact_potential": 75,
"led_fixed_lighting_bulbs_count": 21,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "C",
"co2_emissions_current_per_floor_area": 34,
"incandescent_fixed_lighting_bulbs_count": 0
}

View file

@ -0,0 +1,538 @@
{
"uprn": 10091698535,
"roofs": [
{
"description": "Roof room(s), insulated",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"walls": [
{
"description": "Solid brick, with internal insulation",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
{
"description": "Cavity wall, as built, insulated (assumed)",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"floors": [
{
"description": "Solid, no insulation (assumed)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
{
"description": "Solid, insulated (assumed)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"status": "entered",
"tenure": 1,
"window": {
"description": "Fully double glazed",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
},
"addendum": {
"addendum_numbers": [
8
]
},
"lighting": {
"description": "Excellent lighting efficiency",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
"postcode": "LL14 6HP",
"hot_water": {
"description": "From main system",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 5
},
"post_town": "WREXHAM",
"built_form": 1,
"created_at": "2026-04-20 11:36:55",
"door_count": 1,
"region_code": 19,
"report_type": 2,
"sap_heating": {
"number_baths": 1,
"cylinder_size": 6,
"shower_outlets": [
{
"shower_wwhrs": 1,
"shower_outlet_type": 1
},
{
"shower_wwhrs": 1,
"shower_outlet_type": 2
}
],
"number_baths_wwhrs": 0,
"water_heating_code": 901,
"water_heating_fuel": 29,
"cylinder_thermostat": "Y",
"main_heating_details": [
{
"has_fghrs": "N",
"main_fuel_type": 29,
"heat_emitter_type": 1,
"emitter_temperature": 0,
"main_heating_number": 1,
"main_heating_control": 2207,
"main_heating_category": 4,
"main_heating_fraction": 1,
"mcs_installed_heat_pump": "false",
"central_heating_pump_age": 0,
"main_heating_data_source": 1,
"main_heating_index_number": 106760
}
],
"cylinder_size_measured": 180,
"immersion_heating_type": "NA",
"cylinder_insulation_type": 1,
"has_fixed_air_conditioning": "false",
"cylinder_insulation_thickness": 50
},
"sap_version": 10.2,
"sap_windows": [
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.8,
"window_height": 1,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.8,
"window_height": 1,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.8,
"window_height": 1,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 7,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.8,
"window_height": 1,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 7,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 1,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.9,
"window_height": 0.9,
"draught_proofed": "true",
"window_location": 1,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 1,
"window_type": 1,
"glazing_type": 2,
"window_width": 2.3,
"window_height": 2,
"draught_proofed": "true",
"window_location": 2,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 1,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.8,
"window_height": 0.6,
"draught_proofed": "true",
"window_location": 1,
"window_wall_type": 5,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 1,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.8,
"window_height": 0.6,
"draught_proofed": "true",
"window_location": 1,
"window_wall_type": 5,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 3,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.9,
"window_height": 0.9,
"draught_proofed": "true",
"window_location": 2,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
}
],
"schema_type": "RdSAP-Schema-21.0.1",
"uprn_source": "Energy Assessor",
"country_code": "WLS",
"main_heating": [
{
"description": "Air source heat pump, radiators, electric",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 5
}
],
"air_tightness": {
"description": "(not tested)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"dwelling_type": "Detached house",
"language_code": 1,
"pressure_test": 4,
"property_type": 0,
"address_line_1": "Havelock Cottage",
"address_line_2": "Pen Y Lan",
"address_line_3": "Ruabon",
"assessment_type": "RdSAP",
"completion_date": "2026-04-20",
"inspection_date": "2026-04-20",
"extensions_count": 2,
"measurement_type": 1,
"total_floor_area": 91,
"transaction_type": 1,
"conservatory_type": 1,
"heated_room_count": 5,
"registration_date": "2026-04-20",
"sap_energy_source": {
"mains_gas": "N",
"meter_type": 1,
"pv_connection": 2,
"photovoltaic_supply": {
"pv_arrays": [
{
"pitch": 2,
"peak_power": 3.56,
"orientation": 7,
"overshading": 1
}
]
},
"wind_turbines_count": 0,
"gas_smart_meter_present": "false",
"is_dwelling_export_capable": "true",
"wind_turbines_terrain_type": 3,
"electricity_smart_meter_present": "true"
},
"secondary_heating": {
"description": "None",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"extract_fans_count": 2,
"lzc_energy_sources": [
9,
11
],
"sap_building_parts": [
{
"identifier": "Main Dwelling",
"wall_dry_lined": "N",
"wall_thickness": 470,
"floor_heat_loss": 7,
"sap_room_in_roof": {
"floor_area": 21.56,
"room_in_roof_details": {
"slope_height_1": 1.8,
"slope_height_2": 1.8,
"slope_length_1": 4.9,
"slope_length_2": 4.9,
"gable_wall_type_1": 0,
"gable_wall_type_2": 0,
"gable_wall_height_1": 2.38,
"gable_wall_height_2": 2.38,
"gable_wall_length_1": 4.4,
"gable_wall_length_2": 4.4,
"common_wall_height_1": 1.3,
"common_wall_length_1": 4.9,
"flat_ceiling_height_1": 1.5,
"flat_ceiling_length_1": 4.9,
"slope_insulation_type_1": 1,
"slope_insulation_type_2": 1,
"slope_insulation_thickness_1": "100mm",
"slope_insulation_thickness_2": "100mm",
"flat_ceiling_insulation_type_1": 0,
"flat_ceiling_insulation_thickness_1": "150mm"
},
"construction_age_band": "A"
},
"roof_construction": 5,
"wall_construction": 3,
"building_part_number": 1,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.03,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 21.56,
"quantity": "square metres"
},
"party_wall_length": {
"value": 0,
"quantity": "metres"
},
"floor_construction": 1,
"heat_loss_perimeter": {
"value": 13.7,
"quantity": "metres"
}
}
],
"wall_insulation_type": 3,
"construction_age_band": "A",
"party_wall_construction": "NA",
"wall_thickness_measured": "Y",
"roof_insulation_location": 4,
"roof_insulation_thickness": "ND",
"wall_insulation_thickness": "100mm",
"floor_insulation_thickness": "NI"
},
{
"identifier": "Extension 1",
"wall_dry_lined": "N",
"wall_thickness": 470,
"floor_heat_loss": 7,
"sap_room_in_roof": {
"floor_area": 21.07,
"room_in_roof_details": {
"slope_height_1": 1.2,
"slope_height_2": 1.2,
"slope_length_1": 4.3,
"slope_length_2": 4.3,
"gable_wall_type_1": 0,
"gable_wall_type_2": 3,
"gable_wall_height_1": 2.05,
"gable_wall_height_2": 2.05,
"gable_wall_length_1": 4.9,
"gable_wall_length_2": 4.9,
"common_wall_height_1": 1.4,
"common_wall_height_2": 1.4,
"common_wall_length_1": 4.3,
"common_wall_length_2": 4.3,
"flat_ceiling_height_1": 2.5,
"flat_ceiling_length_1": 4.3,
"slope_insulation_type_1": 1,
"slope_insulation_type_2": 1,
"slope_insulation_thickness_1": "100mm",
"slope_insulation_thickness_2": "100mm",
"flat_ceiling_insulation_type_1": 0,
"flat_ceiling_insulation_thickness_1": "150mm"
},
"construction_age_band": "A"
},
"roof_construction": 5,
"wall_construction": 3,
"building_part_number": 2,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.04,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 21.07,
"quantity": "square metres"
},
"party_wall_length": {
"value": 0,
"quantity": "metres"
},
"floor_construction": 1,
"heat_loss_perimeter": {
"value": 0.7,
"quantity": "metres"
}
}
],
"wall_insulation_type": 3,
"construction_age_band": "A",
"party_wall_construction": "NA",
"wall_thickness_measured": "Y",
"roof_insulation_location": 4,
"roof_insulation_thickness": "ND",
"wall_insulation_thickness": "100mm",
"floor_insulation_thickness": "NI"
},
{
"identifier": "Extension 2",
"wall_dry_lined": "N",
"wall_thickness": 370,
"floor_heat_loss": 7,
"roof_construction": 5,
"wall_construction": 4,
"building_part_number": 3,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.48,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 6.16,
"quantity": "square metres"
},
"party_wall_length": {
"value": 0,
"quantity": "metres"
},
"floor_construction": 1,
"heat_loss_perimeter": {
"value": 7.2,
"quantity": "metres"
}
}
],
"wall_insulation_type": 4,
"construction_age_band": "L",
"party_wall_construction": "NA",
"wall_thickness_measured": "Y",
"roof_insulation_location": 4,
"roof_insulation_thickness": "ND",
"wall_insulation_thickness": "NI",
"floor_insulation_thickness": "NI"
}
],
"solar_water_heating": "N",
"habitable_room_count": 5,
"heating_cost_current": {
"value": 680,
"currency": "GBP"
},
"insulated_door_count": 0,
"co2_emissions_current": 0.3,
"energy_rating_average": 60,
"energy_rating_current": 92,
"lighting_cost_current": {
"value": 77,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": "Time and temperature zone control",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
}
],
"has_hot_water_cylinder": "true",
"heating_cost_potential": {
"value": 680,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 474,
"currency": "GBP"
},
"mechanical_ventilation": 0,
"percent_draughtproofed": 100,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": {
"value": 796,
"currency": "GBP"
},
"indicative_cost": "\u00a35,000 - \u00a320,000",
"improvement_type": "V2",
"improvement_details": {
"improvement_number": 44
},
"improvement_category": 5,
"energy_performance_rating": 112,
"environmental_impact_rating": 102
}
],
"co2_emissions_potential": -0.2,
"energy_rating_potential": 112,
"lighting_cost_potential": {
"value": 77,
"currency": "GBP"
},
"schema_version_original": "21.0.1",
"hot_water_cost_potential": {
"value": 474,
"currency": "GBP"
},
"renewable_heat_incentive": {
"water_heating": 2895.13,
"space_heating_existing_dwelling": 5737.51
},
"draughtproofed_door_count": 1,
"energy_consumption_current": 42,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 100,
"calculation_software_version": "5.02r0342",
"energy_consumption_potential": -6,
"environmental_impact_current": 97,
"current_energy_efficiency_band": "A",
"environmental_impact_potential": 102,
"led_fixed_lighting_bulbs_count": 60,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "A",
"co2_emissions_current_per_floor_area": 3,
"incandescent_fixed_lighting_bulbs_count": 0
}

View file

@ -0,0 +1,589 @@
{
"uprn": 100010181275,
"roofs": [
{
"description": "Flat, insulated",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
},
{
"description": "Roof room(s), ceiling insulated",
"energy_efficiency_rating": 2,
"environmental_efficiency_rating": 2
}
],
"walls": [
{
"description": "Cavity wall, filled cavity",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"floors": [
{
"description": "Solid, no insulation (assumed)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"status": "entered",
"tenure": 1,
"window": {
"description": "Fully double glazed",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
},
"lighting": {
"description": "Below average lighting efficiency",
"energy_efficiency_rating": 2,
"environmental_efficiency_rating": 2
},
"postcode": "CW8 2XR",
"hot_water": {
"description": "From main system",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"post_town": "NORTHWICH",
"built_form": 1,
"created_at": "2026-05-06 15:55:20",
"door_count": 3,
"region_code": 19,
"report_type": 2,
"sap_heating": {
"number_baths": 1,
"cylinder_size": 1,
"shower_outlets": [
{
"shower_wwhrs": 1,
"shower_outlet_type": 2
},
{
"shower_wwhrs": 1,
"shower_outlet_type": 2
}
],
"number_baths_wwhrs": 0,
"water_heating_code": 901,
"water_heating_fuel": 26,
"main_heating_details": [
{
"has_fghrs": "N",
"main_fuel_type": 26,
"boiler_flue_type": 2,
"fan_flue_present": "Y",
"heat_emitter_type": 1,
"emitter_temperature": 0,
"main_heating_number": 1,
"main_heating_control": 2106,
"main_heating_category": 2,
"main_heating_fraction": 1,
"central_heating_pump_age": 0,
"main_heating_data_source": 1,
"main_heating_index_number": 18907
}
],
"immersion_heating_type": "NA",
"has_fixed_air_conditioning": "false"
},
"sap_version": 10.2,
"sap_windows": [
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 4,
"window_type": 2,
"glazing_type": 3,
"window_width": 1,
"window_height": 1.59,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 4,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 8,
"window_type": 2,
"glazing_type": 3,
"window_width": 1,
"window_height": 1.5,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 4,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 8,
"window_type": 2,
"glazing_type": 3,
"window_width": 1,
"window_height": 2.2,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 4,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 6,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 0.39,
"draught_proofed": "true",
"window_location": 1,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 4,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 0.4,
"draught_proofed": "true",
"window_location": 1,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 6,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 1.75,
"draught_proofed": "true",
"window_location": 1,
"window_wall_type": 1,
"permanent_shutters_present": "Y",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 4,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 0.7,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 4,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 2.8,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 8,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 2.04,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 2,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 1.85,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 2,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 0.29,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 2,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 3.75,
"draught_proofed": "true",
"window_location": 2,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 6,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 3.75,
"draught_proofed": "true",
"window_location": 2,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 8,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 9.32,
"draught_proofed": "true",
"window_location": 2,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
}
],
"schema_type": "RdSAP-Schema-21.0.1",
"uprn_source": "Energy Assessor",
"country_code": "ENG",
"main_heating": [
{
"description": "Boiler and radiators, mains gas",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"air_tightness": {
"description": "(not tested)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"dwelling_type": "Detached house",
"language_code": 1,
"pressure_test": 4,
"property_type": 0,
"address_line_1": "15 Cedarwood",
"address_line_2": "Cuddington",
"assessment_type": "RdSAP",
"completion_date": "2026-05-06",
"inspection_date": "2026-05-06",
"extensions_count": 2,
"measurement_type": 1,
"total_floor_area": 181,
"transaction_type": 1,
"conservatory_type": 1,
"heated_room_count": 5,
"registration_date": "2026-05-06",
"sap_energy_source": {
"mains_gas": "Y",
"meter_type": 2,
"pv_connection": 0,
"photovoltaic_supply": {
"none_or_no_details": {
"percent_roof_area": 0
}
},
"wind_turbines_count": 0,
"gas_smart_meter_present": "true",
"is_dwelling_export_capable": "false",
"wind_turbines_terrain_type": 2,
"electricity_smart_meter_present": "true"
},
"secondary_heating": {
"description": "None",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"sap_building_parts": [
{
"identifier": "Main Dwelling",
"wall_dry_lined": "N",
"wall_thickness": 280,
"floor_heat_loss": 7,
"sap_room_in_roof": {
"floor_area": 63.4,
"room_in_roof_details": {
"slope_height_1": 2.1,
"slope_height_2": 0.7,
"slope_length_1": 9.35,
"slope_length_2": 5.4,
"gable_wall_type_1": 0,
"gable_wall_type_2": 0,
"stud_wall_height_1": 1.1,
"stud_wall_height_2": 1.1,
"stud_wall_length_1": 11.1,
"stud_wall_length_2": 11.1,
"gable_wall_height_1": 2.45,
"gable_wall_height_2": 2.45,
"gable_wall_length_1": 6.15,
"gable_wall_length_2": 6.15,
"flat_ceiling_height_1": 2.35,
"flat_ceiling_height_2": 1.5,
"flat_ceiling_length_1": 11.1,
"flat_ceiling_length_2": 11.1,
"slope_insulation_thickness_1": "AB",
"slope_insulation_thickness_2": "AB",
"flat_ceiling_insulation_type_1": 0,
"stud_wall_insulation_thickness_1": "AB",
"stud_wall_insulation_thickness_2": "AB",
"flat_ceiling_insulation_thickness_1": "250mm",
"flat_ceiling_insulation_thickness_2": "AB"
},
"construction_age_band": "E"
},
"roof_construction": 5,
"wall_construction": 4,
"building_part_number": 1,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.7,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 72.71,
"quantity": "square metres"
},
"party_wall_length": {
"value": 0,
"quantity": "metres"
},
"floor_construction": 1,
"heat_loss_perimeter": {
"value": 22.2,
"quantity": "metres"
}
}
],
"wall_insulation_type": 2,
"construction_age_band": "E",
"party_wall_construction": "NA",
"wall_thickness_measured": "Y",
"roof_insulation_location": 4,
"roof_insulation_thickness": "ND",
"wall_insulation_thickness": "NI",
"floor_insulation_thickness": "NI"
},
{
"identifier": "Extension 1",
"wall_dry_lined": "N",
"floor_heat_loss": 7,
"roof_construction": 1,
"wall_construction": 4,
"building_part_number": 2,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.3,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 29.47,
"quantity": "square metres"
},
"party_wall_length": {
"value": 0,
"quantity": "metres"
},
"floor_construction": 1,
"heat_loss_perimeter": {
"value": 22.2,
"quantity": "metres"
}
}
],
"wall_insulation_type": 2,
"construction_age_band": "G",
"party_wall_construction": "NA",
"wall_thickness_measured": "N",
"roof_insulation_location": 6,
"wall_insulation_thickness": "NI",
"floor_insulation_thickness": "NI",
"flat_roof_insulation_thickness": "AB"
},
{
"identifier": "Extension 2",
"wall_dry_lined": "N",
"wall_thickness": 250,
"floor_heat_loss": 7,
"roof_construction": 1,
"wall_construction": 4,
"building_part_number": 3,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.35,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 15.19,
"quantity": "square metres"
},
"party_wall_length": {
"value": 0,
"quantity": "metres"
},
"floor_construction": 1,
"heat_loss_perimeter": {
"value": 11.1,
"quantity": "metres"
}
}
],
"wall_insulation_type": 4,
"construction_age_band": "H",
"party_wall_construction": "NA",
"wall_thickness_measured": "Y",
"roof_insulation_location": 6,
"wall_insulation_thickness": "NI",
"floor_insulation_thickness": "NI",
"flat_roof_insulation_thickness": "AB"
}
],
"open_chimneys_count": 1,
"solar_water_heating": "N",
"habitable_room_count": 5,
"heating_cost_current": {
"value": 1919,
"currency": "GBP"
},
"insulated_door_count": 0,
"co2_emissions_current": 5.6,
"energy_rating_average": 60,
"energy_rating_current": 67,
"lighting_cost_current": {
"value": 116,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": "Programmer, room thermostat and TRVs",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"has_hot_water_cylinder": "false",
"heating_cost_potential": {
"value": 1474,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 317,
"currency": "GBP"
},
"mechanical_ventilation": 0,
"percent_draughtproofed": 100,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": {
"value": 278,
"currency": "GBP"
},
"indicative_cost": "\u00a3900 - \u00a31,200",
"improvement_type": "A3",
"improvement_details": {
"improvement_number": 46
},
"improvement_category": 5,
"energy_performance_rating": 71,
"environmental_impact_rating": 69
},
{
"sequence": 2,
"typical_saving": {
"value": 167,
"currency": "GBP"
},
"indicative_cost": "\u00a35,000 - \u00a310,000",
"improvement_type": "W2",
"improvement_details": {
"improvement_number": 58
},
"improvement_category": 5,
"energy_performance_rating": 73,
"environmental_impact_rating": 72
},
{
"sequence": 3,
"typical_saving": {
"value": 253,
"currency": "GBP"
},
"indicative_cost": "\u00a38,000 - \u00a310,000",
"improvement_type": "U",
"improvement_details": {
"improvement_number": 34
},
"improvement_category": 5,
"energy_performance_rating": 76,
"environmental_impact_rating": 73
}
],
"co2_emissions_potential": 4.2,
"energy_rating_potential": 76,
"lighting_cost_potential": {
"value": 116,
"currency": "GBP"
},
"schema_version_original": "21.0.1",
"hot_water_cost_potential": {
"value": 317,
"currency": "GBP"
},
"renewable_heat_incentive": {
"water_heating": 2245.63,
"space_heating_existing_dwelling": 21411.19
},
"draughtproofed_door_count": 3,
"energy_consumption_current": 173,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 100,
"calculation_software_version": "5.02r0344",
"energy_consumption_potential": 127,
"environmental_impact_current": 64,
"current_energy_efficiency_band": "D",
"environmental_impact_potential": 73,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "C",
"co2_emissions_current_per_floor_area": 31,
"low_energy_fixed_lighting_bulbs_count": 18,
"incandescent_fixed_lighting_bulbs_count": 7
}

View file

@ -0,0 +1,439 @@
{
"uprn": 128017782,
"roofs": [
{
"description": "Pitched, limited insulation",
"energy_efficiency_rating": 2,
"environmental_efficiency_rating": 2
},
{
"description": "Roof room(s), limited insulation (assumed)",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
}
],
"walls": [
{
"description": "Cavity wall, as built, partial insulation (assumed)",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
}
],
"floors": [
{
"description": "Solid, no insulation (assumed)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"status": "entered",
"tenure": 1,
"window": {
"description": "Fully double glazed",
"energy_efficiency_rating": 2,
"environmental_efficiency_rating": 2
},
"addendum": {
"addendum_numbers": [
15
],
"cavity_fill_recommended": "true"
},
"lighting": {
"description": "Good lighting efficiency",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"postcode": "KT6 6JJ",
"hot_water": {
"description": "From main system",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"post_town": "SURBITON",
"built_form": 1,
"created_at": "2026-01-19 17:55:06",
"door_count": 1,
"region_code": 17,
"report_type": 2,
"sap_heating": {
"number_baths": 2,
"cylinder_size": 1,
"number_baths_wwhrs": 0,
"water_heating_code": 901,
"water_heating_fuel": 26,
"main_heating_details": [
{
"has_fghrs": "N",
"main_fuel_type": 26,
"boiler_flue_type": 2,
"fan_flue_present": "Y",
"heat_emitter_type": 1,
"emitter_temperature": 0,
"main_heating_number": 1,
"main_heating_control": 2103,
"main_heating_category": 2,
"main_heating_fraction": 1,
"central_heating_pump_age": 0,
"main_heating_data_source": 1,
"main_heating_index_number": 17974
}
],
"immersion_heating_type": "NA",
"has_fixed_air_conditioning": "false"
},
"sap_version": 10.2,
"sap_windows": [
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 8,
"window_type": 1,
"glazing_type": 3,
"window_width": 1.8,
"window_height": 0.9,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 8,
"window_type": 1,
"glazing_type": 3,
"window_width": 1.8,
"window_height": 0.9,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 8,
"window_type": 1,
"glazing_type": 3,
"window_width": 1.8,
"window_height": 0.9,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 4,
"window_type": 1,
"glazing_type": 3,
"window_width": 1.8,
"window_height": 0.9,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 4,
"window_type": 1,
"glazing_type": 3,
"window_width": 1.8,
"window_height": 0.9,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 4,
"window_type": 1,
"glazing_type": 3,
"window_width": 1.8,
"window_height": 0.9,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 3,
"window_type": 2,
"glazing_type": 3,
"window_width": 1.8,
"window_height": 0.9,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
}
],
"schema_type": "RdSAP-Schema-21.0.1",
"uprn_source": "Energy Assessor",
"country_code": "ENG",
"main_heating": [
{
"description": "Boiler and radiators, mains gas",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"air_tightness": {
"description": "(not tested)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"dwelling_type": "Detached house",
"language_code": 1,
"pressure_test": 4,
"property_type": 0,
"address_line_1": "22 St. Matthew's Avenue",
"assessment_type": "RdSAP",
"completion_date": "2026-01-19",
"inspection_date": "2026-01-08",
"extensions_count": 0,
"measurement_type": 1,
"total_floor_area": 189,
"transaction_type": 8,
"conservatory_type": 1,
"heated_room_count": 7,
"registration_date": "2026-01-19",
"sap_energy_source": {
"mains_gas": "Y",
"meter_type": 3,
"pv_connection": 0,
"photovoltaic_supply": {
"none_or_no_details": {
"percent_roof_area": 0
}
},
"wind_turbines_count": 0,
"gas_smart_meter_present": "false",
"is_dwelling_export_capable": "false",
"wind_turbines_terrain_type": 2,
"electricity_smart_meter_present": "false"
},
"secondary_heating": {
"description": "None",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"sap_building_parts": [
{
"identifier": "Main Dwelling",
"wall_dry_lined": "N",
"wall_thickness": 280,
"floor_heat_loss": 7,
"sap_room_in_roof": {
"floor_area": 48.6,
"room_in_roof_type_2": {
"gable_wall_type_1": 0,
"gable_wall_type_2": 0,
"gable_wall_height_1": 2.1,
"gable_wall_height_2": 2.1,
"gable_wall_length_1": 4.8,
"gable_wall_length_2": 4.8,
"common_wall_height_1": 1.4,
"common_wall_height_2": 1.4,
"common_wall_length_1": 10.8,
"common_wall_length_2": 10.8
},
"construction_age_band": "F"
},
"roof_construction": 8,
"wall_construction": 4,
"building_part_number": 1,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.1,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 69.95,
"quantity": "square metres"
},
"party_wall_length": {
"value": 0,
"quantity": "metres"
},
"floor_construction": 1,
"heat_loss_perimeter": {
"value": 35.8,
"quantity": "metres"
}
},
{
"floor": 1,
"room_height": {
"value": 2.1,
"quantity": "metres"
},
"total_floor_area": {
"value": 69.95,
"quantity": "square metres"
},
"party_wall_length": {
"value": 0,
"quantity": "metres"
},
"heat_loss_perimeter": {
"value": 35.8,
"quantity": "metres"
}
}
],
"wall_insulation_type": 4,
"construction_age_band": "F",
"party_wall_construction": "NA",
"wall_thickness_measured": "Y",
"roof_insulation_location": 7,
"wall_insulation_thickness": "NI",
"floor_insulation_thickness": "NI",
"sloping_ceiling_insulation_thickness": "AB"
}
],
"solar_water_heating": "N",
"habitable_room_count": 7,
"heating_cost_current": {
"value": 1589,
"currency": "GBP"
},
"insulated_door_count": 0,
"co2_emissions_current": 5.4,
"energy_rating_average": 60,
"energy_rating_current": 69,
"lighting_cost_current": {
"value": 109,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": "Room thermostat only",
"energy_efficiency_rating": 2,
"environmental_efficiency_rating": 2
}
],
"has_hot_water_cylinder": "false",
"heating_cost_potential": {
"value": 1193,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 207,
"currency": "GBP"
},
"mechanical_ventilation": 0,
"percent_draughtproofed": 88,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": {
"value": 286,
"currency": "GBP"
},
"indicative_cost": "\u00a3900 - \u00a31,500",
"improvement_type": "B",
"improvement_details": {
"improvement_number": 6
},
"improvement_category": 5,
"energy_performance_rating": 74,
"environmental_impact_rating": 70
},
{
"sequence": 2,
"typical_saving": {
"value": 109,
"currency": "GBP"
},
"indicative_cost": "\u00a35,000 - \u00a310,000",
"improvement_type": "W2",
"improvement_details": {
"improvement_number": 58
},
"improvement_category": 5,
"energy_performance_rating": 75,
"environmental_impact_rating": 72
},
{
"sequence": 3,
"typical_saving": {
"value": 261,
"currency": "GBP"
},
"indicative_cost": "\u00a38,000 - \u00a310,000",
"improvement_type": "U",
"improvement_details": {
"improvement_number": 34
},
"improvement_category": 5,
"energy_performance_rating": 78,
"environmental_impact_rating": 73
}
],
"co2_emissions_potential": 4.0,
"energy_rating_potential": 78,
"lighting_cost_potential": {
"value": 109,
"currency": "GBP"
},
"schema_version_original": "21.0.1",
"alternative_improvements": [
{
"improvement": {
"sequence": 1,
"typical_saving": {
"value": 173,
"currency": "GBP"
},
"improvement_type": "Q2",
"improvement_details": {
"improvement_number": 55
},
"improvement_category": 6,
"energy_performance_rating": 76,
"environmental_impact_rating": 73
}
}
],
"hot_water_cost_potential": {
"value": 207,
"currency": "GBP"
},
"renewable_heat_incentive": {
"water_heating": 2707.81,
"space_heating_existing_dwelling": 19564.86
},
"draughtproofed_door_count": 0,
"energy_consumption_current": 155,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 100,
"calculation_software_version": "5.02r0332",
"energy_consumption_potential": 112,
"environmental_impact_current": 64,
"current_energy_efficiency_band": "C",
"environmental_impact_potential": 73,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "C",
"co2_emissions_current_per_floor_area": 28,
"low_energy_fixed_lighting_bulbs_count": 26,
"incandescent_fixed_lighting_bulbs_count": 0
}

View file

@ -9,14 +9,27 @@ SAP 10.3 specification (13-01-2026), §1 reference at
docs/sap-spec/sap-10-3-full-specification-2026-01-13.pdf pages 11-12. docs/sap-spec/sap-10-3-full-specification-2026-01-13.pdf pages 11-12.
""" """
import json
from dataclasses import replace
from pathlib import Path
import pytest import pytest
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapRoomInRoof,
)
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.ml.tests._fixtures import ( from domain.ml.tests._fixtures import (
make_building_part, make_building_part,
make_floor_dimension, make_floor_dimension,
make_minimal_sap10_epc, make_minimal_sap10_epc,
) )
from domain.sap.worksheet.dimensions import Dimensions, dimensions_from_cert from domain.sap.worksheet.dimensions import Dimensions, dimensions_from_cert
from domain.sap.worksheet.tests._xlsx_loader import load_cells
_RIR_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "rir"
def test_single_storey_single_part_populates_every_dimension_field() -> None: def test_single_storey_single_part_populates_every_dimension_field() -> None:
@ -174,3 +187,195 @@ def test_party_wall_area_scales_with_room_height_and_storey_count() -> None:
assert result.party_wall_area_m2 == pytest.approx(54.0) # 10 × 2.7 × 2 assert result.party_wall_area_m2 == pytest.approx(54.0) # 10 × 2.7 × 2
assert result.gross_wall_area_m2 == pytest.approx(162.0) # 30 × 2.7 × 2 assert result.gross_wall_area_m2 == pytest.approx(162.0) # 30 × 2.7 × 2
assert result.volume_m3 == pytest.approx(432.0) # 160 × 2.7 assert result.volume_m3 == pytest.approx(432.0) # 160 × 2.7
def test_section_1_matches_excel_worksheet_conformance() -> None:
"""Mirror the worked example in `2026-05-19-17-18 RdSap10Worksheet.xlsx`,
sheet `NonRegionalWeather`, §1 (Overall dwelling dimensions).
Excel cells:
Q7 = (1a) Basement area = 84.44
S7 = (2a) Basement height = 2.92 m
U7 = (3a) Basement volume = 246.5648
Q9 = (1b) Ground area = 74.55
S9 = (2b) Ground height = 3.56 m
U9 = (3b) Ground volume = 265.398
Q23 = (4) Total floor area = Σ (1x) = 158.99
U25 = (5) Dwelling volume = Σ (3x) = 511.9628
`SapFloorDimension` has no basement representation (API never sets
it), so Excel's Basement+Ground are mapped to floor=0 and floor=1 —
§1 is a pure sum so storey labels don't affect the result.
"""
# Arrange
excel = load_cells(
"NonRegionalWeather", ["Q7", "S7", "U7", "Q9", "S9", "U9", "Q23", "U25"]
)
# Sanity-check the Excel arithmetic itself before testing against our code.
assert excel["Q7"] * excel["S7"] == pytest.approx(excel["U7"])
assert excel["Q9"] * excel["S9"] == pytest.approx(excel["U9"])
assert excel["Q7"] + excel["Q9"] == pytest.approx(excel["Q23"])
assert excel["U7"] + excel["U9"] == pytest.approx(excel["U25"])
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=excel["Q7"], room_height_m=excel["S7"],
party_wall_length_m=0.0, heat_loss_perimeter_m=0.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=excel["Q9"], room_height_m=excel["S9"],
party_wall_length_m=0.0, heat_loss_perimeter_m=0.0, floor=1,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=excel["Q23"], sap_building_parts=[main]
)
# Act
result = dimensions_from_cert(epc)
# Assert — line (4) and line (5) match the worksheet.
assert result.total_floor_area_m2 == pytest.approx(excel["Q23"])
assert result.volume_m3 == pytest.approx(excel["U25"])
def test_section_1_uses_per_storey_sums_even_when_cert_top_level_disagrees() -> None:
"""§1 lines (4)/(5) are Σ of per-storey (1x)/(3x), not the cert's
top-level TFA. Catches a regression where Dimensions reads
`epc.total_floor_area_m2` directly instead of summing per-storey."""
# Arrange — Two storeys totalling 100 m² + 50 m² = 150 m². Set the
# cert's top-level TFA to a deliberately wrong 999.0 so a per-storey
# sum gives 150 m² but a cert-level read gives 999.
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=0.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=50.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=0.0, floor=1,
),
],
)
epc = make_minimal_sap10_epc(total_floor_area_m2=999.0, sap_building_parts=[main])
# Act
result = dimensions_from_cert(epc)
# Assert
assert result.total_floor_area_m2 == pytest.approx(150.0) # Σ (1x), not 999
assert result.volume_m3 == pytest.approx(375.0) # Σ (3x) = 150 × 2.5
def test_room_in_roof_adds_one_storey_with_simplified_2_45m_height() -> None:
"""RdSAP §1.8 + §3.9: a room-in-roof counts as a separate storey for
§1. For Simplified type 1 (true RR, the common API shape only
gable-wall lengths populated) RdSAP §3.9.1 fixes the storey height
at 2.45 m (= 2.2 m internal + 0.25 m floor structure between RR and
storey below). Modelled after golden cert 0240: ground floor 97.72
+ room-in-roof 83.2 should sum to TFA 180.92 (matches cert TFA
202 to within the ~10 rounding the cert applies elsewhere)."""
# Arrange — single part with one ground floor + a room-in-roof block.
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=97.72, room_height_m=2.28,
party_wall_length_m=0.0, heat_loss_perimeter_m=36.45, floor=0,
),
],
sap_room_in_roof=SapRoomInRoof(
floor_area=83.2, construction_age_band="J",
),
)
epc = make_minimal_sap10_epc(total_floor_area_m2=180.92, sap_building_parts=[main])
# Act
result = dimensions_from_cert(epc)
# Assert — TFA = ground + RR; volume = ground×ht + RR×2.45.
assert result.total_floor_area_m2 == pytest.approx(97.72 + 83.2)
assert result.volume_m3 == pytest.approx(97.72 * 2.28 + 83.2 * 2.45)
assert result.storey_count == 2 # ground floor + room-in-roof storey
def _strip_room_in_roof(epc: EpcPropertyData) -> EpcPropertyData:
"""Return a copy of `epc` with every building part's `sap_room_in_roof`
set to None. Used to isolate the RR contribution to §1 outputs by
diffing against the with-RR result."""
parts_no_rr = [
replace(p, sap_room_in_roof=None) for p in (epc.sap_building_parts or [])
]
return replace(epc, sap_building_parts=parts_no_rr)
@pytest.mark.parametrize(
"cert_filename, rr_shape_label",
[
("0782-3058-6209-9186-1200.json", "room_in_roof_type_2 (Detailed type 2 — gable + common wall heights)"),
("0636-8125-6600-0416-2202.json", "room_in_roof_details with stud_walls (Detailed type 1)"),
("0636-6824-0100-0500-6222.json", "room_in_roof_details with common_walls (Detailed type 2)"),
],
)
def test_all_rir_shapes_apply_section_1_2_45m_convention_uniformly(
cert_filename: str, rr_shape_label: str,
) -> None:
"""RdSAP §3.9.2 wall-area formulas and §3.10 detailed measurements
are for §3 heat-loss U-value calculation, **not** §1 dimensions
confirmed at docs/sap-spec/rdsap-10-specification-2025-06-10.pdf
pages 22-24. The §1 storey-height convention of 2.45 m from §3.9.1
extends uniformly to every RR shape: each contributes exactly
`floor_area` to TFA, `floor_area × 2.45` to volume, and +1 storey.
Real-corpus fixtures (from /workspaces/model/data/ml_training/bulk/
certificates-2026.json.zip) exercise the three non-Simplified-type-1
shapes; the dynamic-delta assertion catches any future code path
that special-cases by shape."""
# Arrange — load a real cert with a non-Simplified-type-1 RR block
doc = json.loads((_RIR_FIXTURES_DIR / cert_filename).read_text())
epc = EpcPropertyDataMapper.from_api_response(doc)
parts_with_rr = [
p for p in (epc.sap_building_parts or []) if p.sap_room_in_roof is not None
]
rir_floor_area_total = sum(p.sap_room_in_roof.floor_area for p in parts_with_rr)
assert rir_floor_area_total > 0, f"Fixture {cert_filename} should carry RR floor_area"
# Act — compute §1 outputs with and without the RR block to isolate its delta
result_with_rr = dimensions_from_cert(epc)
result_without_rr = dimensions_from_cert(_strip_room_in_roof(epc))
# Assert — the RR contribution is exactly the spec convention. One
# storey added per part that carries a sap_room_in_roof block (a
# detached + extension can both have an attic conversion).
tfa_delta = result_with_rr.total_floor_area_m2 - result_without_rr.total_floor_area_m2
volume_delta = result_with_rr.volume_m3 - result_without_rr.volume_m3
storey_delta = result_with_rr.storey_count - result_without_rr.storey_count
assert tfa_delta == pytest.approx(rir_floor_area_total)
assert volume_delta == pytest.approx(rir_floor_area_total * 2.45)
assert storey_delta == len(parts_with_rr)
from types import ModuleType # noqa: E402 (kept near the Elmhurst tests)
from domain.sap.worksheet.tests._elmhurst_fixtures import ( # noqa: E402
ALL_FIXTURES as _ELMHURST_FIXTURES,
fixture_id as _elmhurst_fixture_id,
)
@pytest.mark.parametrize("fixture", _ELMHURST_FIXTURES, ids=_elmhurst_fixture_id)
def test_section_1_matches_elmhurst_worksheet(fixture: ModuleType) -> None:
"""Real Elmhurst SAP10.2 worksheets — asserts §1 lines (4) Total Floor
Area and (5) Dwelling Volume against the canonical Elmhurst output for
each registered fixture. Pytest id = the worksheet reference number."""
# Arrange / Act
result = dimensions_from_cert(fixture.build_epc())
# Assert
assert result.total_floor_area_m2 == pytest.approx(fixture.LINE_4_TFA_M2, abs=0.01)
assert result.volume_m3 == pytest.approx(fixture.LINE_5_VOLUME_M3, abs=0.05)

View file

@ -17,7 +17,11 @@ envelope.py test pack so cases match production cert shape.
import pytest import pytest
from datatypes.epc.domain.epc_property_data import EnergyElement from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EnergyElement,
SapAlternativeWall,
)
from domain.ml.tests._fixtures import ( from domain.ml.tests._fixtures import (
make_building_part, make_building_part,
@ -404,10 +408,12 @@ def test_windows_subtract_from_net_wall_area_so_walls_w_per_k_drops() -> None:
epc, window_total_area_m2=15.0, window_avg_u_value=2.8, epc, window_total_area_m2=15.0, window_avg_u_value=2.8,
) )
# Assert — walls fall by U_wall × window_area; windows = U_win × window_area. # Assert — walls fall by U_wall × window_area; windows = U_effective ×
# window_area where U_effective = 1/(1/2.8 + 0.04) per SAP10.2 §3.2.
assert with_windows.walls_w_per_k == pytest.approx(no_windows.walls_w_per_k - 0.60 * 15.0, abs=1.0) assert with_windows.walls_w_per_k == pytest.approx(no_windows.walls_w_per_k - 0.60 * 15.0, abs=1.0)
assert with_windows.windows_w_per_k == pytest.approx(2.8 * 15.0, abs=0.5) effective_u = 1.0 / (1.0 / 2.8 + 0.04)
# Total rises because U_window (2.8) > U_wall (0.60), so the net swap adds heat loss. assert with_windows.windows_w_per_k == pytest.approx(effective_u * 15.0, abs=0.05)
# Total rises because U_window (2.5 effective) > U_wall (0.60), so the swap adds heat loss.
assert with_windows.total_w_per_k > no_windows.total_w_per_k assert with_windows.total_w_per_k > no_windows.total_w_per_k
@ -679,3 +685,434 @@ def test_ground_floor_flat_exposure_keeps_floor_drops_roof() -> None:
# Assert # Assert
assert ground.floor_w_per_k > 0 assert ground.floor_w_per_k > 0
assert ground.roof_w_per_k == 0.0 assert ground.roof_w_per_k == 0.0
# ============================================================================
# New §3 worksheet-line-mapped tests: alternative walls, effective window U,
# and the (31)/(33) line-ref fields. Reference: SAP10.2 §3.2, RdSAP10 §1.4.2.
# ============================================================================
def test_alternative_wall_uses_own_construction_and_deducts_from_main_wall_area() -> None:
"""RdSAP §1.4.2: a building part can carry up to two alternative-wall
sub-areas of different construction. Each alt's `wall_area` is
deducted from the main wall, and U-values are applied per sub-area.
Alt walls inherit the part's age band but bring their own
construction/insulation."""
from dataclasses import replace
# Arrange — main is age-G cavity-as-built (U≈0.6 for default cavity);
# the alt sub-area is the same cavity construction but with 50 mm
# of insulation, which RdSAP Table 6 puts at U≈0.35 in age G.
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="G",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
main_with_alt = replace(
main,
sap_alternative_wall_1=SapAlternativeWall(
wall_area=20.0,
wall_dry_lined="N",
wall_construction=4, # cavity
wall_insulation_type=1, # has insulation
wall_thickness_measured="N",
wall_insulation_thickness="50",
),
)
epc_no_alt = make_minimal_sap10_epc(
total_floor_area_m2=100.0, country_code="ENG", sap_building_parts=[main],
)
epc_with_alt = make_minimal_sap10_epc(
total_floor_area_m2=100.0, country_code="ENG",
sap_building_parts=[main_with_alt],
)
# Act
no_alt = heat_transmission_from_cert(epc_no_alt)
with_alt = heat_transmission_from_cert(epc_with_alt)
# Assert — adding a better-insulated alt sub-area lowers the wall
# heat loss vs the same gross wall as all-main; the total external
# element area is unchanged because the alt is a sub-area within the
# gross wall, not an addition.
assert with_alt.walls_w_per_k < no_alt.walls_w_per_k
assert with_alt.total_external_element_area_m2 == pytest.approx(
no_alt.total_external_element_area_m2
)
def test_window_uses_effective_u_value_with_curtain_resistance_per_sap10_2_section_3_2() -> None:
"""SAP10.2 §3.2: the window U-value used for heat-transmission is the
effective form `U_eff = 1/(1/U_raw + 0.04)` the 0.04 m²K/W is the
curtain/blind resistance. Excel worked example asserts U_raw=2.0
U_eff=1.852 and (27) = 25.76 × 1.852 = 47.70 W/K."""
# Arrange
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="G",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=100.0, country_code="ENG", sap_building_parts=[main],
)
# Act — pass the Excel worked example's window inputs directly.
result = heat_transmission_from_cert(
epc, window_total_area_m2=25.76, window_avg_u_value=2.0,
)
# Assert — matches the Excel worked example cell Q130 = 47.7037...
expected_u_eff = 1.0 / (1.0 / 2.0 + 0.04) # = 1.8518...
assert expected_u_eff == pytest.approx(1.852, abs=0.001)
assert result.windows_w_per_k == pytest.approx(25.76 * expected_u_eff, rel=1e-9)
assert result.windows_w_per_k == pytest.approx(47.70, abs=0.02)
def test_heat_transmission_exposes_line_31_total_external_area_and_line_33_fabric_heat_loss() -> None:
"""Worksheet line refs (31), (33), (37):
(31) Σ A over external elements excludes party wall (own row, (32))
(33) Σ (A × U) over fabric elements WITHOUT thermal bridging
(36) thermal bridging = y × external area (with party wall included
in the bridging area sum per RdSAP §5.15)
(37) total = (33) + (36) what `total_w_per_k` carries.
Asserts the invariants between these fields rather than absolute
values; existing tests above pin the per-element values."""
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="G",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.5,
party_wall_length_m=10.0, heat_loss_perimeter_m=35.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=80.0, country_code="ENG", sap_building_parts=[main],
)
# Act
result = heat_transmission_from_cert(
epc, door_count=2, window_total_area_m2=10.0,
)
# Assert — invariants
expected_33 = (
result.walls_w_per_k + result.roof_w_per_k + result.floor_w_per_k
+ result.party_walls_w_per_k + result.windows_w_per_k + result.doors_w_per_k
)
assert result.fabric_heat_loss_w_per_k == pytest.approx(expected_33, rel=1e-9)
assert result.total_w_per_k == pytest.approx(
result.fabric_heat_loss_w_per_k + result.thermal_bridging_w_per_k, rel=1e-9
)
# (31) is the sum of external-element areas — non-zero and strictly less
# than the sum we'd get by including the party wall (party adds 10×2.5×1=25).
assert result.total_external_element_area_m2 > 0
def test_section_3_worksheet_excel_arithmetic_placeholder() -> None:
"""Placeholder for full Excel §3 conformance using cert→inputs flow.
Asserts the worksheet's worked-example arithmetic at line refs (27),
(31), (33), (36), (37) i.e. that *if* our calculator can be coaxed
into producing the Excel's per-element products, the resulting line
refs reduce as the worksheet says they do. Replaced by a real
cert-based conformance test when filled SAP worksheets land."""
# Per-element A × U products from xlsx NonRegionalWeather worked example
# (rows 126, 130, 140, 145, 149, 150, 154, 155, 161).
doors_w_per_k = 3.7 * 3.0 # (26) = 11.10
windows_w_per_k = 25.76 * (1.0 / (1.0 / 2.0 + 0.04)) # (27) = 47.70
ground_floor_w_per_k = 9.89 * 0.91 # (28a) = 9.00
exposed_floor_w_per_k = 74.55 * 1.2 # (28b) = 89.46
wall_t1_w_per_k = 160.53 * 1.7 # (29a) = 272.90
wall_t2_w_per_k = 18.87 * 0.3 # (29a) = 5.66
roof_t1_w_per_k = 74.55 * 0.14 # (30) = 10.44
roof_t2_w_per_k = 9.89 * 0.4 # (30) = 3.96
party_w_per_k = 96.63 * 0.0 # (32) = 0.00
fabric_33 = (
doors_w_per_k + windows_w_per_k
+ ground_floor_w_per_k + exposed_floor_w_per_k
+ wall_t1_w_per_k + wall_t2_w_per_k
+ roof_t1_w_per_k + roof_t2_w_per_k
+ party_w_per_k
)
external_area_31 = (
3.7 + 25.76 + 9.89 + 74.55 + 160.53 + 18.87 + 74.55 + 9.89
) # excludes the party wall (96.63) per Excel layout
bridging_36 = 0.15 * external_area_31 # y default age G per Table 21
total_37 = fabric_33 + bridging_36
# Assertions vs Excel sums (xlsx cells U176/M159/U185/U190).
assert fabric_33 == pytest.approx(450.22, abs=0.05) # (33)
assert external_area_31 == pytest.approx(377.74, abs=0.05) # (31)
assert bridging_36 == pytest.approx(56.66, abs=0.05) # (36)
assert total_37 == pytest.approx(506.88, abs=0.05) # (37)
# ============================================================================
# Basement detection + Table 23 U-value tests (RdSAP §5.17). The basement
# wall code = 6 is empirically confirmed against a 50k 2026-bulk sweep:
# 88% precision when basement-top-level signal present, sub-0.2% false
# positive when absent. Detection covers both main wall and alt sub-area.
# ============================================================================
def test_sap_alternative_wall_is_basement_property_uses_wall_construction_code_6() -> None:
"""`SapAlternativeWall.is_basement_wall` is True iff `wall_construction
== 6`. Code 6 is the gov-EPC API's basement-wall sentinel — confirmed
against the 2026 bulk dump."""
# Arrange / Act / Assert
basement_alt = SapAlternativeWall(
wall_area=14.24, wall_dry_lined="N", wall_construction=6,
wall_insulation_type=4, wall_thickness_measured="Y",
wall_insulation_thickness="NI",
)
cavity_alt = SapAlternativeWall(
wall_area=14.24, wall_dry_lined="N", wall_construction=4,
wall_insulation_type=4, wall_thickness_measured="Y",
)
assert basement_alt.is_basement_wall is True
assert cavity_alt.is_basement_wall is False
def test_sap_building_part_has_basement_detects_main_wall_and_alt_wall_codes() -> None:
"""`SapBuildingPart.has_basement` covers both detection paths:
(a) main `wall_construction == 6` whole part below grade
(b) any alt sub-area with `is_basement_wall` typical: house with
a basement room as a separately-described sub-area"""
from dataclasses import replace
# Arrange — no basement signal at all
plain = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="G",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0,
),
],
)
main_is_basement = replace(plain, wall_construction=6)
alt_is_basement = replace(
plain,
sap_alternative_wall_1=SapAlternativeWall(
wall_area=14.24, wall_dry_lined="N",
wall_construction=6, wall_insulation_type=4,
wall_thickness_measured="N",
),
)
# Act / Assert
assert plain.has_basement is False
assert main_is_basement.has_basement is True
assert main_is_basement.main_wall_is_basement is True
assert alt_is_basement.has_basement is True
assert alt_is_basement.main_wall_is_basement is False # main is still wc=4
def test_basement_alt_wall_uses_table_23_u_value_not_cascade() -> None:
"""RdSAP §5.17 / Table 23 governs basement-wall U-values: 0.7 for age
A-F, 0.6 for G-H, 0.45 for I, 0.35 for J, ..., 0.26 for M. The
basement-wall sub-area MUST bypass the regular `u_wall` cascade."""
from dataclasses import replace
# Arrange — age G dwelling with one 20 m² basement alt sub-area. The
# regular cavity-as-built cascade would give ≈ 0.6 W/m²K here, which
# happens to coincide with Table 23 age G. Use age B instead to
# produce a clear difference (Table 23 = 0.7 vs cascade much higher).
main_age_b = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="B",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0,
),
],
)
with_basement_alt = replace(
main_age_b,
sap_alternative_wall_1=SapAlternativeWall(
wall_area=20.0, wall_dry_lined="N",
wall_construction=6, wall_insulation_type=4,
wall_thickness_measured="N", wall_insulation_thickness="NI",
),
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=80.0, country_code="ENG",
sap_building_parts=[with_basement_alt],
)
# Act
result = heat_transmission_from_cert(epc)
# Assert — the basement alt sub-area contributes 0.7 (Table 23 age B
# wall) × 20 m² = 14.0 W/K, on top of the main wall. We assert the
# invariant rather than absolute value to keep the test robust to
# main-wall cascade changes.
epc_no_basement = make_minimal_sap10_epc(
total_floor_area_m2=80.0, country_code="ENG",
sap_building_parts=[main_age_b],
)
no_basement = heat_transmission_from_cert(epc_no_basement)
# The delta in walls_w_per_k between the two should equal:
# alt_area × (u_basement_wall(B) - u_main_cascade)
# Where the alt area was deducted from main wall and re-applied at
# the basement U-value. Total external area unchanged.
assert with_basement_alt.has_basement is True
assert result.total_external_element_area_m2 == pytest.approx(
no_basement.total_external_element_area_m2
)
def test_basement_floor_uses_table_23_u_value_for_whole_floor_when_basement_detected() -> None:
"""User-confirmed convention: when a part has a basement, the WHOLE
floor=0 is the basement floor. Table 23 F-column overrides the
regular floor cascade for that part."""
from dataclasses import replace
# Arrange — age M (latest band) gives Table 23 floor U = 0.18, much
# lower than the default ground-floor uninsulated cascade.
plain_part = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="M",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0,
),
],
)
basement_part = replace(plain_part, wall_construction=6)
epc_plain = make_minimal_sap10_epc(
total_floor_area_m2=80.0, country_code="ENG", sap_building_parts=[plain_part],
)
epc_basement = make_minimal_sap10_epc(
total_floor_area_m2=80.0, country_code="ENG", sap_building_parts=[basement_part],
)
# Act
plain = heat_transmission_from_cert(epc_plain)
basement = heat_transmission_from_cert(epc_basement)
# Assert — basement floor U (0.18 for M) × 80 m² = 14.4 W/K exactly.
assert basement_part.has_basement is True
assert basement.floor_w_per_k == pytest.approx(0.18 * 80.0, rel=1e-6)
# Per-element invariant still holds.
assert basement.fabric_heat_loss_w_per_k == pytest.approx(
basement.walls_w_per_k + basement.roof_w_per_k + basement.floor_w_per_k
+ basement.party_walls_w_per_k + basement.windows_w_per_k + basement.doors_w_per_k,
rel=1e-9,
)
def test_real_corpus_basement_cert_has_part_with_has_basement_true() -> None:
"""End-to-end smoke test using the saved basement fixture
(Mid-terrace, age B, TFA 84) confirms our domain mapping picks up
a basement signal from a real 2026 corpus cert (alt wall_construction=6
on the Main Dwelling)."""
# Arrange
import json as _json
from pathlib import Path as _Path
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
fixture = _Path(__file__).parent / "fixtures" / "basement" / "0712-3058-2202-3816-8204.json"
doc = _json.loads(fixture.read_text())
# Act
epc = EpcPropertyDataMapper.from_api_response(doc)
# Assert — at least one part has a basement detected.
parts_with_basement = [p for p in epc.sap_building_parts if p.has_basement]
assert len(parts_with_basement) >= 1
# The Main Dwelling carries the alt wall_construction=6 in this cert.
main = parts_with_basement[0]
assert main.has_basement is True
assert main.main_wall_is_basement is False # main wc=4 (cavity), basement is alt
assert main.sap_alternative_wall_1 is not None
assert main.sap_alternative_wall_1.is_basement_wall is True
from types import ModuleType # noqa: E402
from domain.sap.worksheet.tests._elmhurst_fixtures import ( # noqa: E402
ALL_FIXTURES as _ELMHURST_FIXTURES,
fixture_id as _elmhurst_fixture_id,
)
@pytest.mark.parametrize("fixture", _ELMHURST_FIXTURES, ids=_elmhurst_fixture_id)
def test_section_3_partial_match_against_elmhurst_worksheet(fixture: ModuleType) -> None:
"""Real Elmhurst SAP10.2 worksheets — partial §3 conformance.
§3 is NOT yet feature-complete for any cert with a room-in-roof
because our `SapRoomInRoof` only carries `floor_area`, not the
gable/slope/stud-wall/flat-ceiling sub-areas. Each worksheet's RR
contributes a meaningful chunk of fabric heat loss that our code
under-reports.
What this test DOES verify (and is robust to the RR gap):
- Internal invariants: (33) = Σ per-element; (37) = (33) + (36)
- (31) total external element area is strictly less than the
worksheet's full value (because RR sub-areas missing)
- Computation produces non-zero output
Known divergences:
1. RR walls not computed smaller (33), smaller (31) for RR fixtures
2. Per-storey-different heat-loss perimeters not handled our code
does `ground_perim × avg_height × storey_count` which over-counts
when upper storeys are smaller than the ground (surfaced by
worksheet 000474 where Main has ground perim 7.07 / first 5.27).
Right formula: Σ (perim_i × height_i). Tracked as follow-up.
3. Window U-value is per-window in Elmhurst; we pass an area-weighted
raw U so our effective transform approximates (27)
"""
# Arrange — every Elmhurst fixture has known window U=1.4 raw on
# most windows (one fixture has a 2.8 sub-area). For this partial
# test we don't require exact window match; pass an arbitrary
# window block to exercise the code path.
epc = fixture.build_epc()
# Act
result = heat_transmission_from_cert(
epc,
window_total_area_m2=8.0,
window_avg_u_value=1.5,
door_count=1,
)
# Assert — internal invariants
expected_fabric = (
result.walls_w_per_k + result.roof_w_per_k + result.floor_w_per_k
+ result.party_walls_w_per_k + result.windows_w_per_k + result.doors_w_per_k
)
assert result.fabric_heat_loss_w_per_k == pytest.approx(expected_fabric, rel=1e-9)
assert result.total_w_per_k == pytest.approx(
result.fabric_heat_loss_w_per_k + result.thermal_bridging_w_per_k, rel=1e-9
)
# External-area divergence direction depends on the fixture:
# - RR fixtures: ours < worksheet (RR walls missing — gap #1)
# - Non-RR with non-constant per-storey perim: ours > worksheet
# (gap #2 — wall-area over-count). Just check non-zero until both
# fixes land.
assert result.total_external_element_area_m2 > 0

View file

@ -1,24 +1,29 @@
"""Tests for SAP 10.3 §2 + RdSAP10 §4.1 infiltration worksheet. """Tests for SAP 10.2 §2 + RdSAP10 §4.1 ventilation rate worksheet.
Covers worksheet lines (6a)-(16): openings + structural baseline + storey Covers every line of the §2 worksheet: openings (6a)-(7c), infiltration
additional + floor + draught lobby + window draught proofing. Pressure-test (8), components (10)-(16), pressure-test override (17)-(18), shelter
override (17-21) and mechanical ventilation are separate later slices. (19)-(21), monthly wind (22)-(22b), and mechanical ventilation modes
(23a)-(24d) final monthly (25)m.
Reference: SAP 10.3 (13-01-2026) §2; RdSAP10 (June 2025) §4.1 Table 5. Reference: SAP 10.2 (14-03-2025) §2; RdSAP10 (June 2025) §4.1 Table 5.
Canonical worked example: `2026-05-19-17-18 RdSap10Worksheet.xlsx`,
`NonRegionalWeather` sheet, rows 27-121.
""" """
import pytest import pytest
from domain.sap.worksheet.tests._xlsx_loader import load_cells
from domain.sap.worksheet.ventilation import ( from domain.sap.worksheet.ventilation import (
InfiltrationBreakdown, MechanicalVentilationKind,
infiltration_ach, TABLE_U2_NON_REGIONAL_WIND_SPEED_M_S,
VentilationResult,
ventilation_from_inputs,
) )
def test_bare_masonry_detached_returns_baseline_total_of_0_65_ach() -> None: def test_bare_masonry_detached_returns_baseline_line_16_of_0_65() -> None:
# Arrange — Single-storey masonry detached bungalow with no openings, no # Arrange — Single-storey masonry detached bungalow with no openings,
# draught lobby, 0% draught-proofed windows. Worksheet baseline summed # no draught lobby, 0% draught-proofed windows. §2 baseline summed:
# per SAP 10.3 §2 / RdSAP10 §4.1 Table 5:
# (8) openings = 0 # (8) openings = 0
# (10) additional = (1-1) × 0.1 = 0 # (10) additional = (1-1) × 0.1 = 0
# (11) structural = 0.35 masonry # (11) structural = 0.35 masonry
@ -28,161 +33,137 @@ def test_bare_masonry_detached_returns_baseline_total_of_0_65_ach() -> None:
# (16) total = 0.65 ach # (16) total = 0.65 ach
# Act # Act
result = infiltration_ach( result = ventilation_from_inputs(
volume_m3=200.0, volume_m3=200.0,
storey_count=1, storey_count=1,
is_timber_or_steel_frame=False, is_timber_or_steel_frame=False,
sheltered_sides=0,
) )
# Assert # Assert
assert isinstance(result, InfiltrationBreakdown) assert isinstance(result, VentilationResult)
assert result.total_ach == pytest.approx(0.65, abs=0.01) assert result.infiltration_rate_ach == pytest.approx(0.65, abs=0.01)
def test_open_chimney_adds_80_per_volume_to_openings_ach() -> None: def test_open_chimney_adds_80_per_volume_to_line_8_openings() -> None:
# Arrange — Same masonry detached bungalow with one open chimney. Per # Arrange — Same masonry bungalow with one open chimney. Per Table
# Table 2.1 an open chimney contributes 80 m³/hour. Volume is 200 m³, so # 2.1 an open chimney contributes 80 m³/h. Volume 200 m³, so
# openings_ach = 80 / 200 = 0.40 and total = 0.65 + 0.40 = 1.05. # (8) = 80 / 200 = 0.40 and (16) = 0.65 + 0.40 = 1.05.
# Act # Act
result = infiltration_ach( result = ventilation_from_inputs(
volume_m3=200.0, volume_m3=200.0,
storey_count=1, storey_count=1,
is_timber_or_steel_frame=False, is_timber_or_steel_frame=False,
open_chimneys=1, open_chimneys=1,
sheltered_sides=0,
) )
# Assert # Assert
assert result.openings_ach == pytest.approx(0.40, abs=0.005) assert result.openings_ach == pytest.approx(0.40, abs=0.005)
assert result.total_ach == pytest.approx(1.05, abs=0.01) assert result.infiltration_rate_ach == pytest.approx(1.05, abs=0.01)
def test_two_storey_dwelling_adds_0_1_ach_via_additional_line_10() -> None: def test_two_storey_dwelling_adds_0_1_via_line_10() -> None:
# Arrange — Worksheet line (10): additional infiltration = (n 1) × 0.1. # Arrange — Line (10): additional infiltration = (n 1) × 0.1.
# A two-storey home contributes +0.1 ach on top of the baseline. Bare # A two-storey home contributes +0.1 ach on top of the baseline.
# masonry baseline 0.65 → 0.75.
# Act # Act
result = infiltration_ach( result = ventilation_from_inputs(
volume_m3=200.0, volume_m3=200.0,
storey_count=2, storey_count=2,
is_timber_or_steel_frame=False, is_timber_or_steel_frame=False,
sheltered_sides=0,
) )
# Assert # Assert
assert result.additional_ach == pytest.approx(0.1, abs=0.001) assert result.additional_ach == pytest.approx(0.1, abs=0.001)
assert result.total_ach == pytest.approx(0.75, abs=0.01) assert result.infiltration_rate_ach == pytest.approx(0.75, abs=0.01)
def test_timber_frame_uses_structural_baseline_0_25_not_0_35() -> None: def test_timber_frame_uses_line_11_structural_0_25_not_0_35() -> None:
# Arrange — Worksheet line (11) per RdSAP10 §4.1: structural infiltration # Arrange — Line (11) per RdSAP §4.1: structural = 0.25 for steel or
# = 0.25 for steel or timber frame, 0.35 for masonry. Baseline drops by # timber frame, 0.35 for masonry. Baseline drops by 0.10 ach.
# 0.10 ach for a frame dwelling.
# Act # Act
result = infiltration_ach( result = ventilation_from_inputs(
volume_m3=200.0, volume_m3=200.0,
storey_count=1, storey_count=1,
is_timber_or_steel_frame=True, is_timber_or_steel_frame=True,
sheltered_sides=0,
) )
# Assert # Assert
assert result.structural_ach == pytest.approx(0.25, abs=0.001) assert result.structural_ach == pytest.approx(0.25, abs=0.001)
assert result.total_ach == pytest.approx(0.55, abs=0.01) # 0.65 - 0.10 assert result.infiltration_rate_ach == pytest.approx(0.55, abs=0.01)
def test_suspended_timber_floor_unsealed_adds_0_2_ach_line_12() -> None: def test_suspended_timber_floor_line_12_unsealed_vs_sealed() -> None:
# Arrange — Worksheet line (12) per RdSAP10 §4.1: floor infiltration # Arrange — Line (12): 0.2 unsealed suspended timber / 0.1 sealed / 0.
# = 0.2 unsealed suspended timber / 0.1 sealed / 0 otherwise. Older
# solid-floor age bands or post-1970 dwellings don't carry this loss.
# Act # Act
unsealed = infiltration_ach( unsealed = ventilation_from_inputs(
volume_m3=200.0, volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
storey_count=1, has_suspended_timber_floor=True,
is_timber_or_steel_frame=False,
suspended_timber_floor_sealed=False, suspended_timber_floor_sealed=False,
has_suspended_timber_floor=True, sheltered_sides=0,
) )
sealed = infiltration_ach( sealed = ventilation_from_inputs(
volume_m3=200.0, volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
storey_count=1,
is_timber_or_steel_frame=False,
suspended_timber_floor_sealed=True,
has_suspended_timber_floor=True, has_suspended_timber_floor=True,
suspended_timber_floor_sealed=True,
sheltered_sides=0,
) )
# Assert # Assert
assert unsealed.floor_ach == pytest.approx(0.2, abs=0.001) assert unsealed.floor_ach == pytest.approx(0.2, abs=0.001)
assert sealed.floor_ach == pytest.approx(0.1, abs=0.001) assert sealed.floor_ach == pytest.approx(0.1, abs=0.001)
assert unsealed.total_ach == pytest.approx(0.85, abs=0.01) # 0.65 + 0.20
assert sealed.total_ach == pytest.approx(0.75, abs=0.01) # 0.65 + 0.10
def test_draught_lobby_present_zeros_line_13_infiltration() -> None: def test_draught_lobby_present_zeros_line_13() -> None:
# Arrange — Worksheet line (13): no draught lobby contributes 0.05 ach; # Arrange — Line (13): no lobby → 0.05 ach; lobby present → 0.
# a present lobby contributes 0. So baseline 0.65 drops to 0.60 when the
# lobby is present.
# Act # Act
result = infiltration_ach( result = ventilation_from_inputs(
volume_m3=200.0, volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
storey_count=1,
is_timber_or_steel_frame=False,
has_draught_lobby=True, has_draught_lobby=True,
sheltered_sides=0,
) )
# Assert # Assert
assert result.draught_lobby_ach == pytest.approx(0.0, abs=0.001) assert result.draught_lobby_ach == pytest.approx(0.0, abs=0.001)
assert result.total_ach == pytest.approx(0.60, abs=0.01)
def test_fully_draught_proofed_windows_drops_line_15_to_0_05() -> None: def test_window_draught_proofed_line_15_is_linear_in_pct() -> None:
# Arrange — Worksheet line (15): window infiltration = 0.25 - 0.2 × (pct/100). # Arrange — Line (15): 0.25 - 0.2 × (pct/100). 100% DP → 0.05;
# 100% DP -> 0.25 - 0.20 = 0.05; 50% DP -> 0.15. # 50% DP → 0.15; 0% → 0.25.
# Act # Act
full_dp = infiltration_ach( full = ventilation_from_inputs(
volume_m3=200.0, volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
storey_count=1, window_pct_draught_proofed=100.0, sheltered_sides=0,
is_timber_or_steel_frame=False,
window_pct_draught_proofed=100.0,
) )
half_dp = infiltration_ach( half = ventilation_from_inputs(
volume_m3=200.0, volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
storey_count=1, window_pct_draught_proofed=50.0, sheltered_sides=0,
is_timber_or_steel_frame=False,
window_pct_draught_proofed=50.0,
) )
# Assert # Assert
assert full_dp.window_ach == pytest.approx(0.05, abs=0.005) assert full.window_ach == pytest.approx(0.05, abs=0.005)
assert half_dp.window_ach == pytest.approx(0.15, abs=0.005) assert half.window_ach == pytest.approx(0.15, abs=0.005)
assert full_dp.total_ach == pytest.approx(0.45, abs=0.01) # 0.65 - 0.20
assert half_dp.total_ach == pytest.approx(0.55, abs=0.01) # 0.65 - 0.10
def test_openings_sum_each_table_2_1_rate_independently() -> None: def test_openings_sum_each_table_2_1_rate_independently() -> None:
# Arrange — Each opening type in Table 2.1 must contribute its own rate. # Arrange — 1 open flue (20) + 1 closed fire (10) + 1 SF boiler (20)
# 1 open flue (20) + 1 closed-fire chimney (10) + 1 solid-fuel-boiler # + 1 other heater (35) + 1 blocked (20) + 1 fan (10) + 1 PSV (10) +
# flue (20) + 1 other-heater flue (35) + 1 blocked chimney (20) + 1 # 1 flueless GF (40) = 165 m³/h. Vol 200 → openings_ach = 0.825.
# intermittent fan (10) + 1 passive vent (10) + 1 flueless gas fire (40)
# = 165 m³/h. Volume 200 m³ -> openings_ach = 0.825.
# Act # Act
result = infiltration_ach( result = ventilation_from_inputs(
volume_m3=200.0, volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
storey_count=1, open_flues=1, closed_fire_chimneys=1, solid_fuel_boiler_chimneys=1,
is_timber_or_steel_frame=False, other_heater_chimneys=1, blocked_chimneys=1, intermittent_fans=1,
open_chimneys=0, passive_vents=1, flueless_gas_fires=1,
open_flues=1,
closed_fire_chimneys=1,
solid_fuel_boiler_chimneys=1,
other_heater_chimneys=1,
blocked_chimneys=1,
intermittent_fans=1,
passive_vents=1,
flueless_gas_fires=1,
) )
# Assert # Assert
@ -190,11 +171,364 @@ def test_openings_sum_each_table_2_1_rate_independently() -> None:
def test_zero_or_negative_volume_raises_value_error() -> None: def test_zero_or_negative_volume_raises_value_error() -> None:
# Arrange — A zero-volume dwelling would divide-by-zero in line (8). # Arrange / Act / Assert — line (8) divides by volume, so guard.
# Fail fast so the caller knows the upstream Dimensions are bad. with pytest.raises(ValueError, match="volume_m3"):
ventilation_from_inputs(volume_m3=0.0, storey_count=1, is_timber_or_steel_frame=False)
with pytest.raises(ValueError, match="volume_m3"):
ventilation_from_inputs(volume_m3=-1.0, storey_count=1, is_timber_or_steel_frame=False)
def test_wrong_length_monthly_wind_array_raises_value_error() -> None:
# Arrange / Act / Assert — Table U2 always has 12 entries (Jan-Dec).
with pytest.raises(ValueError, match="12 entries"):
ventilation_from_inputs(
volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
monthly_wind_speed_m_s=(4.0, 4.0, 4.0),
)
def test_pressure_test_ap50_uses_line_18a_formula() -> None:
# Arrange — line (18) = (17) / 20 + (8). With AP50=5 and 0 openings,
# (18) = 0.25 (vs (16) which would be ~0.65). Pressure test overrides.
# Act
result = ventilation_from_inputs(
volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
air_permeability_ap50=5.0,
sheltered_sides=0,
)
# Assert
assert result.pressure_test_ach == pytest.approx(0.25, abs=0.001)
def test_pressure_test_ap4_uses_line_18b_formula() -> None:
# Arrange — line (18) = 0.263 × (17a)^0.924 + (8). With AP4=4 and 0
# openings, (18) = 0.263 × 4^0.924 ≈ 0.951.
# Act
result = ventilation_from_inputs(
volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
air_permeability_ap4=4.0,
sheltered_sides=0,
)
# Assert
assert result.pressure_test_ach == pytest.approx(0.263 * (4.0 ** 0.924), abs=0.001)
def test_shelter_factor_line_20_clamps_sides_to_0_4() -> None:
# Arrange — (20) = 1 - 0.075 × min(4, max(0, sides)).
# 0 sides → 1.0
# 2 sides → 0.85
# 4 sides → 0.7
# 5+ sides → clamped to 4 → 0.7
# Act / Assert # Act / Assert
with pytest.raises(ValueError, match="volume_m3"): assert ventilation_from_inputs(
infiltration_ach(volume_m3=0.0, storey_count=1, is_timber_or_steel_frame=False) volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, sheltered_sides=0,
with pytest.raises(ValueError, match="volume_m3"): ).shelter_factor == pytest.approx(1.0)
infiltration_ach(volume_m3=-1.0, storey_count=1, is_timber_or_steel_frame=False) assert ventilation_from_inputs(
volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, sheltered_sides=2,
).shelter_factor == pytest.approx(0.85)
assert ventilation_from_inputs(
volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, sheltered_sides=4,
).shelter_factor == pytest.approx(0.7)
assert ventilation_from_inputs(
volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, sheltered_sides=99,
).shelter_factor == pytest.approx(0.7)
def test_monthly_wind_factor_line_22a_is_wind_over_4() -> None:
# Arrange — (22a)m = (22)m / 4. Default Table U2 Jan=5.1 → 1.275.
# Act
result = ventilation_from_inputs(
volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
)
# Assert — 12 entries, first month Jan = 5.1 m/s → 1.275.
assert len(result.monthly_wind_factor) == 12
assert result.monthly_wind_factor[0] == pytest.approx(5.1 / 4.0)
assert result.monthly_wind_factor[5] == pytest.approx(3.8 / 4.0) # Jun
assert result.monthly_wind_factor[11] == pytest.approx(4.7 / 4.0) # Dec
def test_natural_ventilation_uses_24d_piecewise_formula() -> None:
# Arrange — (24d)m: if (22b)m ≥ 1 → (22b)m; else 0.5 + (22b)m² / 2.
# With a high (21) value, some months will yield (22b)m ≥ 1 and pass
# through; others will use the quadratic.
# Act — Pick (21) such that Jan (22a=1.275) gives (22b)≈1.0:
# (21) ≈ 0.785 → (22b)Jan = 0.785 × 1.275 ≈ 1.001 (>= 1, passes through)
# (22b)Jun = 0.785 × 0.95 ≈ 0.746 (< 1, uses quadratic)
result = ventilation_from_inputs(
volume_m3=200.0, storey_count=4, is_timber_or_steel_frame=False,
# storeys=4 → (10)=0.3; add components → ~1.0; ×0.85 shelter → 0.85
# Adjusting via window draught proof to dial in the value
window_pct_draught_proofed=0.0,
sheltered_sides=2,
mv_kind=MechanicalVentilationKind.NATURAL,
)
# Assert — verify the piecewise law numerically.
for i, w_22b in enumerate(result.monthly_wind_adjusted_ach):
if w_22b >= 1.0:
assert result.effective_monthly_ach[i] == pytest.approx(w_22b)
else:
assert result.effective_monthly_ach[i] == pytest.approx(
0.5 + (w_22b ** 2) * 0.5
)
def test_mvhr_24a_subtracts_efficiency_from_system_air_change() -> None:
# Arrange — (24a)m = (22b)m + (23b) × (1 - (23c)/100). With 90%
# efficiency, only 10% of system ach contributes; with 0%, all.
# Act
mvhr_90 = ventilation_from_inputs(
volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
mv_kind=MechanicalVentilationKind.MVHR,
mv_system_ach=0.5, mvhr_efficiency_pct=90.0,
sheltered_sides=0,
)
mvhr_0 = ventilation_from_inputs(
volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
mv_kind=MechanicalVentilationKind.MVHR,
mv_system_ach=0.5, mvhr_efficiency_pct=0.0,
sheltered_sides=0,
)
# Assert — 90% efficiency adds 0.5×0.1=0.05 to each month; 0% adds 0.5.
for i in range(12):
delta_90 = mvhr_90.effective_monthly_ach[i] - mvhr_90.monthly_wind_adjusted_ach[i]
delta_0 = mvhr_0.effective_monthly_ach[i] - mvhr_0.monthly_wind_adjusted_ach[i]
assert delta_90 == pytest.approx(0.05, abs=0.001)
assert delta_0 == pytest.approx(0.5, abs=0.001)
def test_balanced_mv_24b_adds_full_system_ach_each_month() -> None:
# Arrange — (24b)m = (22b)m + (23b). Balanced MV without recovery.
# Act
result = ventilation_from_inputs(
volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False,
mv_kind=MechanicalVentilationKind.MV,
mv_system_ach=0.4,
sheltered_sides=0,
)
# Assert
for i in range(12):
assert result.effective_monthly_ach[i] == pytest.approx(
result.monthly_wind_adjusted_ach[i] + 0.4
)
def test_extract_or_piv_24c_clips_at_system_ach_for_low_wind_months() -> None:
# Arrange — (24c)m: if (22b)m < 0.5 × (23b) → (23b); else (22b)m + 0.5 × (23b).
# Low natural wind (low (22b)m) → just (23b). High wind → (22b)m + half.
# Act — pick (21) tiny so (22b)m << 0.5 × (23b) in every month:
# mv_system_ach=2.0 → threshold 1.0. (21)=0.1 → (22b)m max ≈ 0.13 (well under 1).
low_wind = ventilation_from_inputs(
volume_m3=10000.0, # huge volume → openings near 0
storey_count=1, is_timber_or_steel_frame=True, # 0.25 structural
window_pct_draught_proofed=100.0, # window→0.05
has_draught_lobby=True, # lobby→0
mv_kind=MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE,
mv_system_ach=2.0,
sheltered_sides=4, # shelter factor 0.7
)
# All months should be clipped to 2.0.
# Assert
for v in low_wind.effective_monthly_ach:
assert v == pytest.approx(2.0)
def test_excel_worksheet_conformance_section_2_lines_6a_to_25m() -> None:
"""Mirror the worked example in `2026-05-19-17-18 RdSap10Worksheet.xlsx`,
sheet `NonRegionalWeather`, §2 (rows 27-121) covering every line
(6a)..(25)m.
Inputs from the worksheet:
- Volume (5) = 511.9628
- Storeys (9) = 2
- Intermittent fans (7a) = 30 /h 3 fans
- Masonry, no suspended timber floor, no draught lobby,
100% draught-proofed
- 1 sheltered side
- Whole-house extract / PIV-outside MV at (23a) = 0.5 ach
Every line is then asserted against its Excel cell."""
# Arrange — load every line ref from the canonical worksheet
cells = load_cells(
"NonRegionalWeather",
[
# Openings (6a..6f, 7a..7c)
"U30", "U32", "U34", "U36", "U38", "U40",
"U42", "U44", "U46",
# Line (8) infiltration from openings
"U48",
# Volume (5) and storey count (9)
"U25", "U52",
# Components (10..15)
"U54", "U56", "U58", "U60", "U62", "U64",
# Line (16) sum
"U66",
# Pressure test (17, 17a, 18)
"U73",
# Shelter (19, 20, 21)
"U77", "U79", "U81",
# Monthly wind speed (22)m Jan..Dec
"G86", "H86", "I86", "J86", "K86", "L86",
"M86", "N86", "O86", "P86", "Q86", "R86",
# Monthly (22a)m wind factor Jan..Dec
"G89", "H89", "I89", "J89", "K89", "L89",
"M89", "N89", "O89", "P89", "Q89", "R89",
# Monthly (22b)m wind-adjusted ach Jan..Dec
"G92", "H92", "I92", "J92", "K92", "L92",
"M92", "N92", "O92", "P92", "Q92", "R92",
# MV system (23a), (23b)
"U96", "U98",
# Monthly (24c)m / (25)m Jan..Dec — this example uses extract/PIV path
"G109", "H109", "I109", "J109", "K109", "L109",
"M109", "N109", "O109", "P109", "Q109", "R109",
"G115", "H115", "I115", "J115", "K115", "L115",
"M115", "N115", "O115", "P115", "Q115", "R115",
],
)
# Act — mirror the worksheet inputs into ventilation_from_inputs.
# Excel (7a) = 30 = 3 fans × 10; (9) = 2 storeys; volume from (5).
result = ventilation_from_inputs(
volume_m3=cells["U25"],
storey_count=int(cells["U52"]),
is_timber_or_steel_frame=False, # (11) = 0.35 masonry
intermittent_fans=3, # (7a) = 30
has_suspended_timber_floor=False, # (12) = 0
has_draught_lobby=False, # (13) = 0.05
window_pct_draught_proofed=cells["U62"], # (14) = 100
sheltered_sides=int(cells["U77"]), # (19) = 1
mv_kind=MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE,
mv_system_ach=cells["U96"], # (23a) = 0.5
)
# Assert — every populated line matches its Excel cell.
# Openings m³/h
assert result.open_chimneys_m3_h == pytest.approx(cells["U30"]) # (6a)
assert result.open_flues_m3_h == pytest.approx(cells["U32"]) # (6b)
assert result.closed_fire_chimneys_m3_h == pytest.approx(cells["U34"]) # (6c)
assert result.solid_fuel_boiler_m3_h == pytest.approx(cells["U36"]) # (6d)
assert result.other_heater_m3_h == pytest.approx(cells["U38"]) # (6e)
assert result.blocked_chimneys_m3_h == pytest.approx(cells["U40"]) # (6f)
assert result.intermittent_fans_m3_h == pytest.approx(cells["U42"]) # (7a)
assert result.passive_vents_m3_h == pytest.approx(cells["U44"]) # (7b)
assert result.flueless_gas_fires_m3_h == pytest.approx(cells["U46"]) # (7c)
# Line (8) infiltration from openings
assert result.openings_ach == pytest.approx(cells["U48"])
# Components (10..15)
assert result.additional_ach == pytest.approx(cells["U54"]) # (10)
assert result.structural_ach == pytest.approx(cells["U56"]) # (11)
assert result.floor_ach == pytest.approx(cells["U58"]) # (12)
assert result.draught_lobby_ach == pytest.approx(cells["U60"]) # (13)
assert result.window_ach == pytest.approx(cells["U64"]) # (15)
# Line (16) sum
assert result.infiltration_rate_ach == pytest.approx(cells["U66"])
# Line (18) — no pressure test, so (18) = (16)
assert result.pressure_test_ach == pytest.approx(cells["U73"])
# Shelter (19, 20, 21)
assert result.sheltered_sides == int(cells["U77"])
assert result.shelter_factor == pytest.approx(cells["U79"])
assert result.shelter_adjusted_ach == pytest.approx(cells["U81"])
# Monthly wind speed (22)m
expected_22 = tuple(cells[c] for c in ("G86","H86","I86","J86","K86","L86","M86","N86","O86","P86","Q86","R86"))
expected_22a = tuple(cells[c] for c in ("G89","H89","I89","J89","K89","L89","M89","N89","O89","P89","Q89","R89"))
expected_22b = tuple(cells[c] for c in ("G92","H92","I92","J92","K92","L92","M92","N92","O92","P92","Q92","R92"))
expected_24c = tuple(cells[c] for c in ("G109","H109","I109","J109","K109","L109","M109","N109","O109","P109","Q109","R109"))
expected_25 = tuple(cells[c] for c in ("G115","H115","I115","J115","K115","L115","M115","N115","O115","P115","Q115","R115"))
for i in range(12):
assert result.monthly_wind_speed_m_s[i] == pytest.approx(expected_22[i])
assert result.monthly_wind_factor[i] == pytest.approx(expected_22a[i])
assert result.monthly_wind_adjusted_ach[i] == pytest.approx(expected_22b[i])
# The extract/PIV-outside path makes (24c)m = (25)m here.
assert result.effective_monthly_ach[i] == pytest.approx(expected_24c[i])
assert result.effective_monthly_ach[i] == pytest.approx(expected_25[i])
# MV system (23a, 23b)
assert result.mv_system_ach == pytest.approx(cells["U96"])
assert result.mv_system_ach_after_fmv == pytest.approx(cells["U98"])
from types import ModuleType # noqa: E402
from domain.sap.worksheet.tests._elmhurst_fixtures import ( # noqa: E402
ALL_FIXTURES as _ELMHURST_FIXTURES,
fixture_id as _elmhurst_fixture_id,
)
@pytest.mark.parametrize("fixture", _ELMHURST_FIXTURES, ids=_elmhurst_fixture_id)
def test_section_2_matches_elmhurst_worksheet(fixture: ModuleType) -> None:
"""Real Elmhurst SAP10.2 worksheets — asserts every populated §2 line
ref against the worksheet output for each registered fixture.
`storey_count` and `sheltered_sides` come from the fixture (Elmhurst
quirks: ns=3 here is dwelling height not Σ parts, sheltered_sides
varies per cert). The HAS_SUSPENDED_TIMBER_FLOOR flag also varies
some Elmhurst assessors lodge "Suspended Timber" floor U-value while
ticking (12) = 0.0 (treat as effectively sealed).
"""
# Arrange
from domain.sap.worksheet.dimensions import dimensions_from_cert
dims = dimensions_from_cert(fixture.build_epc())
# Act
result = ventilation_from_inputs(
volume_m3=dims.volume_m3,
storey_count=fixture.LINE_9_STOREYS,
is_timber_or_steel_frame=False,
intermittent_fans=fixture.INTERMITTENT_FANS,
has_suspended_timber_floor=fixture.HAS_SUSPENDED_TIMBER_FLOOR,
suspended_timber_floor_sealed=fixture.SUSPENDED_TIMBER_FLOOR_SEALED,
has_draught_lobby=fixture.HAS_DRAUGHT_LOBBY,
window_pct_draught_proofed=fixture.WINDOW_PCT_DRAUGHT_PROOFED,
sheltered_sides=fixture.LINE_19_SHELTERED_SIDES,
mv_kind=fixture.MV_KIND,
)
# Assert — line-by-line vs Elmhurst output.
assert result.openings_ach == pytest.approx(fixture.LINE_8_OPENINGS_ACH, abs=0.0005)
assert result.additional_ach == pytest.approx(fixture.LINE_10_ADDITIONAL_ACH, abs=0.0001)
assert result.structural_ach == pytest.approx(fixture.LINE_11_STRUCTURAL_ACH, abs=0.0001)
assert result.floor_ach == pytest.approx(fixture.LINE_12_FLOOR_ACH, abs=0.0001)
assert result.draught_lobby_ach == pytest.approx(fixture.LINE_13_DRAUGHT_LOBBY_ACH, abs=0.0001)
assert result.window_ach == pytest.approx(fixture.LINE_15_WINDOW_ACH, abs=0.0001)
assert result.infiltration_rate_ach == pytest.approx(fixture.LINE_16_INFILTRATION_RATE_ACH, abs=0.0005)
assert result.pressure_test_ach == pytest.approx(fixture.LINE_18_PRESSURE_TEST_ACH, abs=0.0005)
assert result.shelter_factor == pytest.approx(fixture.LINE_20_SHELTER_FACTOR, abs=0.0001)
assert result.shelter_adjusted_ach == pytest.approx(fixture.LINE_21_SHELTER_ADJUSTED_ACH, abs=0.0005)
# Monthly arrays — every month.
for i in range(12):
assert result.monthly_wind_speed_m_s[i] == pytest.approx(fixture.LINE_22_WIND_SPEED_M_S[i], abs=0.001)
assert result.monthly_wind_factor[i] == pytest.approx(fixture.LINE_22A_WIND_FACTOR[i], abs=0.001)
assert result.monthly_wind_adjusted_ach[i] == pytest.approx(fixture.LINE_22B_WIND_ADJUSTED_ACH[i], abs=0.0005)
assert result.effective_monthly_ach[i] == pytest.approx(fixture.LINE_25_EFFECTIVE_ACH[i], abs=0.0005)
def test_table_u2_default_matches_worksheet_g86_to_r86() -> None:
"""The TABLE_U2_NON_REGIONAL_WIND_SPEED_M_S constant must match the
`NonRegionalWeather` sheet row 86 (G86..R86) so RdSAP runs without
regional weather lookup still produce spec-correct (22)m."""
# Arrange — pull the 12 cells from the worksheet
cells = load_cells(
"NonRegionalWeather",
["G86", "H86", "I86", "J86", "K86", "L86",
"M86", "N86", "O86", "P86", "Q86", "R86"],
)
expected = (cells["G86"], cells["H86"], cells["I86"], cells["J86"],
cells["K86"], cells["L86"], cells["M86"], cells["N86"],
cells["O86"], cells["P86"], cells["Q86"], cells["R86"])
# Act / Assert — constant matches sheet exactly.
assert TABLE_U2_NON_REGIONAL_WIND_SPEED_M_S == pytest.approx(expected)

View file

@ -1,30 +1,28 @@
"""SAP 10.3 §2 + RdSAP10 §4.1 — infiltration (air-change rate) worksheet. """SAP 10.2 §2 + RdSAP10 §4.1 — ventilation rate worksheet.
Ports worksheet lines (6a) through (16) of the SAP 10.2 / RdSAP10 air-change Ports every worksheet line of §2: openings (6a)-(7c), infiltration (8),
algorithm. When no pressure test is available, infiltration in air-changes- non-pressure-test components (10)-(16), pressure-test override (17)-(18),
per-hour is the sum of: shelter (19)-(21), monthly wind adjustment (22)-(22b), and mechanical
ventilation modes (23a)-(24d) final monthly (25)m.
(8) openings Σ(Table 2.1 rate × count) / volume Per-line accessors on `VentilationResult` let callers audit the
(10) additional (storey_count 1) × 0.1 computation against the SAP10.2 worksheet by line number. The
(11) structural 0.25 steel/timber-frame, 0.35 masonry (default) calculator consumes `effective_monthly_ach` directly so the §3-(38)
(12) floor 0.2 unsealed suspended-timber / 0.1 sealed / else 0 monthly HLC reflects wind-adjusted, MV-mode-specific ventilation
(13) draught lobby 0.05 if absent, 0.0 if present not a single annual scalar.
(15) window 0.25 0.2 × (pct_draught_proofed / 100)
(16) total (8) + (10) + (11) + (12) + (13) + (15)
Returned breakdown preserves each worksheet line so callers can audit per Reference:
SAP convention. Sheltered-sides shelter factor (19-21), pressure-test - SAP 10.2 specification (14-03-2025) §2 (pages 12-17)
override (17-18), and mechanical ventilation adjustments are out of scope - RdSAP10 specification (June 2025) §4.1 Table 5 (pages 27-30)
for this slice see ADR-0009 Session A plan. - Canonical worked example at `2026-05-19-17-18 RdSap10Worksheet.xlsx`,
`NonRegionalWeather` sheet, rows 27-121
Reference: SAP 10.3 specification §2 (pages 12-16);
RdSAP10 specification §4.1 Table 5 (pages 27-30).
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Final from enum import Enum
from typing import Final, Optional
# Table 2.1 — ventilation rates in m³/hour per opening type. # Table 2.1 — ventilation rates in m³/hour per opening type.
@ -38,21 +36,90 @@ _INTERMITTENT_FAN_M3_H: Final[float] = 10.0
_PASSIVE_VENT_M3_H: Final[float] = 10.0 _PASSIVE_VENT_M3_H: Final[float] = 10.0
_FLUELESS_GAS_FIRE_M3_H: Final[float] = 40.0 _FLUELESS_GAS_FIRE_M3_H: Final[float] = 40.0
# Table U2 (non-regional) — monthly average wind speed at 10m, m/s, Jan-Dec.
# Source: worksheet `NonRegionalWeather` row 86 (cells G86..R86).
TABLE_U2_NON_REGIONAL_WIND_SPEED_M_S: Final[tuple[float, ...]] = (
5.1, 5.0, 4.9, 4.4, 4.3, 3.8,
3.8, 3.7, 4.0, 4.3, 4.5, 4.7,
)
class MechanicalVentilationKind(Enum):
"""SAP10.2 worksheet (24a)-(24d) mechanical-ventilation categories.
- NATURAL: natural ventilation OR positive input ventilation from
the loft equation (24d)m. The default for dwellings with no MV
system installed.
- MVHR: balanced mechanical ventilation with heat recovery
equation (24a)m. Requires `mvhr_efficiency_pct` from PCDB.
- MV: balanced mechanical ventilation without heat recovery
equation (24b)m.
- EXTRACT_OR_PIV_OUTSIDE: whole-house extract ventilation OR
positive input ventilation from OUTSIDE equation (24c)m.
"""
NATURAL = "natural"
MVHR = "mvhr"
MV = "mv"
EXTRACT_OR_PIV_OUTSIDE = "extract_or_piv_outside"
@dataclass(frozen=True) @dataclass(frozen=True)
class InfiltrationBreakdown: class VentilationResult:
"""SAP worksheet lines (8), (10), (11), (12), (13), (15), (16).""" """Every SAP10.2 §2 worksheet line — `(6a)` through `(25)m`.
Fields are organised in worksheet order so a reader can locate each
one in the canonical xlsx without ambiguity."""
# Lines (6a)-(7c) — openings in m³/hour.
open_chimneys_m3_h: float # (6a)
open_flues_m3_h: float # (6b)
closed_fire_chimneys_m3_h: float # (6c)
solid_fuel_boiler_m3_h: float # (6d)
other_heater_m3_h: float # (6e)
blocked_chimneys_m3_h: float # (6f)
intermittent_fans_m3_h: float # (7a)
passive_vents_m3_h: float # (7b)
flueless_gas_fires_m3_h: float # (7c)
# Line (8) — Σ openings ÷ dwelling volume (ach).
openings_ach: float openings_ach: float
additional_ach: float
structural_ach: float # Lines (10)-(15) — infiltration components (ach).
floor_ach: float additional_ach: float # (10) = (storeys 1) × 0.1
draught_lobby_ach: float structural_ach: float # (11) 0.25 frame / 0.35 masonry
window_ach: float floor_ach: float # (12) suspended timber adjustment
total_ach: float draught_lobby_ach: float # (13) 0.05 when absent, else 0
window_pct_draught_proofed: float # (14) % windows/doors DP
window_ach: float # (15) 0.25 0.2 × (14)/100
# Line (16) — pre-pressure-test infiltration rate (ach).
infiltration_rate_ach: float
# Lines (17)-(18) — pressure test override.
air_permeability_ap50: Optional[float] # (17)
air_permeability_ap4: Optional[float] # (17a)
pressure_test_ach: float # (18)
# Lines (19)-(21) — shelter factor.
sheltered_sides: int # (19)
shelter_factor: float # (20) = 1 0.075 × (19)
shelter_adjusted_ach: float # (21) = (18) × (20)
# Lines (22)-(22b) — monthly wind adjustment (Jan..Dec).
monthly_wind_speed_m_s: tuple[float, ...] # (22)m
monthly_wind_factor: tuple[float, ...] # (22a)m = (22)m ÷ 4
monthly_wind_adjusted_ach: tuple[float, ...] # (22b)m = (21) × (22a)m
# Lines (23)-(25) — mechanical ventilation + final monthly rate.
mv_kind: MechanicalVentilationKind
mv_system_ach: float # (23a)
mv_system_ach_after_fmv: float # (23b)
mvhr_efficiency_pct: Optional[float] # (23c) — None when not MVHR
effective_monthly_ach: tuple[float, ...] # (25)m — final answer
def infiltration_ach( def ventilation_from_inputs(
*, *,
volume_m3: float, volume_m3: float,
storey_count: int, storey_count: int,
@ -70,28 +137,49 @@ def infiltration_ach(
suspended_timber_floor_sealed: bool = False, suspended_timber_floor_sealed: bool = False,
has_draught_lobby: bool = False, has_draught_lobby: bool = False,
window_pct_draught_proofed: float = 0.0, window_pct_draught_proofed: float = 0.0,
sheltered_sides: int = 0, air_permeability_ap50: Optional[float] = None,
) -> InfiltrationBreakdown: air_permeability_ap4: Optional[float] = None,
"""Air-change rate (ach) per SAP 10.3 §2 / RdSAP10 §4.1, no pressure sheltered_sides: int = 2,
test path. `sheltered_sides` defaults to 0 (no shelter; spec-pure monthly_wind_speed_m_s: tuple[float, ...] = TABLE_U2_NON_REGIONAL_WIND_SPEED_M_S,
intermediate value). Callers can pass 2 (typical UK terraced / mv_kind: MechanicalVentilationKind = MechanicalVentilationKind.NATURAL,
semi-detached) to apply the SAP §2 shelter factor mv_system_ach: float = 0.0,
(1 - 0.075 × sheltered_sides) so the returned total_ach is the mv_fmv_factor: float = 1.0,
effective rate after wind shelter.""" mvhr_efficiency_pct: Optional[float] = None,
) -> VentilationResult:
"""Build a `VentilationResult` from a single dwelling's inputs.
`sheltered_sides` defaults to 2 (typical UK terraced/semi-detached);
the cert doesn't lodge this value so callers should match the spec
convention. `monthly_wind_speed_m_s` defaults to Table U2
(non-regional) so RdSAP runs with no regional weather lookup still
produce spec-correct (22b)m / (25)m values.
"""
if volume_m3 <= 0: if volume_m3 <= 0:
raise ValueError(f"volume_m3 must be > 0, got {volume_m3}") raise ValueError(f"volume_m3 must be > 0, got {volume_m3}")
openings_m3_h = ( if len(monthly_wind_speed_m_s) != 12:
open_chimneys * _OPEN_CHIMNEY_M3_H raise ValueError(
+ open_flues * _OPEN_FLUE_M3_H f"monthly_wind_speed_m_s must have 12 entries, got {len(monthly_wind_speed_m_s)}"
+ closed_fire_chimneys * _CLOSED_FIRE_CHIMNEY_M3_H )
+ solid_fuel_boiler_chimneys * _SOLID_FUEL_BOILER_CHIMNEY_M3_H
+ other_heater_chimneys * _OTHER_HEATER_CHIMNEY_M3_H # Lines (6a)-(7c): m³/h per opening type × Table 2.1 rate.
+ blocked_chimneys * _BLOCKED_CHIMNEY_M3_H open_chim = open_chimneys * _OPEN_CHIMNEY_M3_H
+ intermittent_fans * _INTERMITTENT_FAN_M3_H open_flue = open_flues * _OPEN_FLUE_M3_H
+ passive_vents * _PASSIVE_VENT_M3_H closed_fire = closed_fire_chimneys * _CLOSED_FIRE_CHIMNEY_M3_H
+ flueless_gas_fires * _FLUELESS_GAS_FIRE_M3_H solid_fuel = solid_fuel_boiler_chimneys * _SOLID_FUEL_BOILER_CHIMNEY_M3_H
other_heater = other_heater_chimneys * _OTHER_HEATER_CHIMNEY_M3_H
blocked = blocked_chimneys * _BLOCKED_CHIMNEY_M3_H
int_fans = intermittent_fans * _INTERMITTENT_FAN_M3_H
pas_vents = passive_vents * _PASSIVE_VENT_M3_H
flueless = flueless_gas_fires * _FLUELESS_GAS_FIRE_M3_H
# Line (8): Σ (6a..6f)+(7a..7c) ÷ volume.
total_openings_m3_h = (
open_chim + open_flue + closed_fire + solid_fuel + other_heater
+ blocked + int_fans + pas_vents + flueless
) )
openings = openings_m3_h / volume_m3 openings_ach = total_openings_m3_h / volume_m3
# Lines (10)-(15).
additional = max(0, storey_count - 1) * 0.1 additional = max(0, storey_count - 1) * 0.1
structural = 0.25 if is_timber_or_steel_frame else 0.35 structural = 0.25 if is_timber_or_steel_frame else 0.35
if has_suspended_timber_floor: if has_suspended_timber_floor:
@ -100,17 +188,83 @@ def infiltration_ach(
floor = 0.0 floor = 0.0
draught_lobby = 0.0 if has_draught_lobby else 0.05 draught_lobby = 0.0 if has_draught_lobby else 0.05
window = 0.25 - 0.2 * (window_pct_draught_proofed / 100.0) window = 0.25 - 0.2 * (window_pct_draught_proofed / 100.0)
raw_total = openings + additional + structural + floor + draught_lobby + window
# SAP §2 worksheet line 22 shelter factor: 1 - 0.075 × sheltered_sides. # Line (16) — sum (8) + (10) + (11) + (12) + (13) + (15).
# 2 sheltered sides → multiply by 0.85. line_16 = openings_ach + additional + structural + floor + draught_lobby + window
shelter_factor = 1.0 - 0.075 * max(0, min(4, sheltered_sides))
total = raw_total * shelter_factor # Lines (17)-(18) — pressure-test override (AP50 preferred over AP4).
return InfiltrationBreakdown( if air_permeability_ap50 is not None:
openings_ach=openings, line_18 = air_permeability_ap50 / 20.0 + openings_ach
elif air_permeability_ap4 is not None:
line_18 = 0.263 * (air_permeability_ap4 ** 0.924) + openings_ach
else:
line_18 = line_16
# Lines (19)-(21) — shelter factor (clamped 0..4 sides per spec).
clamped_sides = max(0, min(4, sheltered_sides))
shelter_factor = 1.0 - 0.075 * clamped_sides
line_21 = line_18 * shelter_factor
# Lines (22)-(22b) — monthly wind adjustment from Table U2.
monthly_wind_factor = tuple(w / 4.0 for w in monthly_wind_speed_m_s)
monthly_22b = tuple(line_21 * f for f in monthly_wind_factor)
# Lines (23a)-(23b) — MV system air-change rate.
line_23a = mv_system_ach
line_23b = line_23a * mv_fmv_factor
# Lines (24a)-(24d) → (25)m — pick the formula matching mv_kind.
monthly_25: tuple[float, ...]
if mv_kind is MechanicalVentilationKind.MVHR:
# (24a)m = (22b)m + (23b) × [1 - (23c)/100]
eff = (mvhr_efficiency_pct or 0.0) / 100.0
monthly_25 = tuple(w + line_23b * (1.0 - eff) for w in monthly_22b)
elif mv_kind is MechanicalVentilationKind.MV:
# (24b)m = (22b)m + (23b)
monthly_25 = tuple(w + line_23b for w in monthly_22b)
elif mv_kind is MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE:
# (24c)m: if (22b)m < 0.5 × (23b) → (23b); else (22b)m + 0.5 × (23b)
monthly_25 = tuple(
line_23b if w < 0.5 * line_23b else w + 0.5 * line_23b
for w in monthly_22b
)
else: # NATURAL
# (24d)m: if (22b)m ≥ 1 → (22b)m; else 0.5 + (22b)m² / 2
monthly_25 = tuple(
w if w >= 1.0 else 0.5 + (w ** 2) * 0.5
for w in monthly_22b
)
return VentilationResult(
open_chimneys_m3_h=open_chim,
open_flues_m3_h=open_flue,
closed_fire_chimneys_m3_h=closed_fire,
solid_fuel_boiler_m3_h=solid_fuel,
other_heater_m3_h=other_heater,
blocked_chimneys_m3_h=blocked,
intermittent_fans_m3_h=int_fans,
passive_vents_m3_h=pas_vents,
flueless_gas_fires_m3_h=flueless,
openings_ach=openings_ach,
additional_ach=additional, additional_ach=additional,
structural_ach=structural, structural_ach=structural,
floor_ach=floor, floor_ach=floor,
draught_lobby_ach=draught_lobby, draught_lobby_ach=draught_lobby,
window_pct_draught_proofed=window_pct_draught_proofed,
window_ach=window, window_ach=window,
total_ach=total, infiltration_rate_ach=line_16,
air_permeability_ap50=air_permeability_ap50,
air_permeability_ap4=air_permeability_ap4,
pressure_test_ach=line_18,
sheltered_sides=clamped_sides,
shelter_factor=shelter_factor,
shelter_adjusted_ach=line_21,
monthly_wind_speed_m_s=tuple(monthly_wind_speed_m_s),
monthly_wind_factor=monthly_wind_factor,
monthly_wind_adjusted_ach=monthly_22b,
mv_kind=mv_kind,
mv_system_ach=line_23a,
mv_system_ach_after_fmv=line_23b,
mvhr_efficiency_pct=mvhr_efficiency_pct,
effective_monthly_ach=monthly_25,
) )