SAP calculator entry point + cert→inputs adapter + strict P6.1 identifiers

Lands the production code that the just-committed Elmhurst conformance
fixtures (6455d48b) exercise: the SAP10.3 calculator orchestrator
(domain.sap.calculator.Sap10Calculator), the RdSAP-driven cert→inputs
mapper (domain.sap.rdsap.cert_to_inputs), and the EpcPropertyData
strict-type pass that P6.1 starts.

calculator.py is the entry point. Two surfaces depending on the caller's
shape:
- Sap10Calculator().calculate(epc) — full RdSAP mapper + worksheet loop
- calculate_sap_from_inputs(inputs) — pure physics over typed inputs

P6.1 introduces BuildingPartIdentifier as a strictly-typed replacement
for bare-string matching on SapBuildingPart.identifier (motivated by
the pain point at worksheet/dimensions.py:74-82). Two boundary factories
canonicalise raw inputs: from_api_string for the gov-EPC API, and
extension(n) for site-notes / construction id flows.

Also catches up two transitive deps that 6455d48b implicitly required
but I missed:
- ml/rdsap_uvalues.py — party-wall U-value rows that heat_transmission
  resolves; the U=0.0 branch the 000516 fixture exercises lands here.
- ml/tests/_fixtures.py — make_minimal_sap10_epc that every Elmhurst
  fixture imports. Without this catch-up, checking out 6455d48b in
  isolation would ImportError.

Out of scope (will commit separately): ml/transform.py legacy envelope
drift; backend/ FastAPI + documents_parser layer; etl/ scratch.

824 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 09:54:30 +00:00
parent 6455d48b9d
commit a8b443f669
11 changed files with 353 additions and 46 deletions

View file

@ -1,10 +1,67 @@
import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from typing import List, Optional, Union from enum import Enum
from typing import Final, List, Optional, Union
from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc import Epc
_API_EXTENSION = re.compile(r"^Extension\s+(\d+)$")
class BuildingPartIdentifier(Enum):
"""Canonical identifier for a SAP building part.
Replaces bare-string matching on `SapBuildingPart.identifier`. The
enum *values* match the site-notes / database shape ("main",
"extension_1" .. "extension_4"); boundary mappers (gov-EPC API,
site notes) construct these via the `from_api_string` / `extension`
classmethods so consumers can dispatch with `is` instead of fragile
string equality.
RdSAP10 §1.2 caps extensions at 4 per dwelling, so EXTENSION_1..4
are enumerated explicitly; anything else falls to OTHER so callers
can still iterate safely.
P6.1 first slice of the strict-typing P6 work documented in
HANDOVER_SYSTEMATIC_REVIEW §2.5.
"""
MAIN = "main"
EXTENSION_1 = "extension_1"
EXTENSION_2 = "extension_2"
EXTENSION_3 = "extension_3"
EXTENSION_4 = "extension_4"
OTHER = "other"
@classmethod
def from_api_string(
cls, api_identifier: Optional[str]
) -> "BuildingPartIdentifier":
"""Map a gov-EPC API `BuildingPart.identifier` to its canonical
member. "Main Dwelling" MAIN; "Extension N" EXTENSION_N
(for N in 1..4). `None` (permitted by the 21_0_1 schema) and
anything unrecognised fall to OTHER.
"""
if api_identifier == "Main Dwelling":
return cls.MAIN
if api_identifier is not None:
match = _API_EXTENSION.match(api_identifier)
if match is not None:
return cls.extension(int(match.group(1)))
return cls.OTHER
@classmethod
def extension(cls, n: int) -> "BuildingPartIdentifier":
"""Canonical identifier for the Nth extension. RdSAP10 §1.2
caps at 4; numbers outside 1..4 fall to OTHER."""
try:
return cls(f"extension_{n}")
except ValueError:
return cls.OTHER
@dataclass @dataclass
class EnergyElement: class EnergyElement:
description: str description: str
@ -201,6 +258,14 @@ class SapRoomInRoof:
construction_age_band: str construction_age_band: str
# RdSAP10 wall_construction integer encoding. The gov-EPC API doesn't publish
# the mapping; established empirically from a 50k 2026-bulk sweep — code 6
# co-occurs with `walls[].description = "Basement wall"` in 88% of cases at
# a 0.18% false-positive rate, so we treat it as the canonical basement-wall
# signal.
BASEMENT_WALL_CONSTRUCTION_CODE: Final[int] = 6
@dataclass @dataclass
class SapAlternativeWall: class SapAlternativeWall:
wall_area: float wall_area: float
@ -210,11 +275,19 @@ class SapAlternativeWall:
wall_thickness_measured: str wall_thickness_measured: str
wall_insulation_thickness: Optional[str] = None wall_insulation_thickness: Optional[str] = None
@property
def is_basement_wall(self) -> bool:
"""True iff this alt sub-area is the dwelling's basement wall —
identified by RdSAP10 wall_construction code = 6 (see module
constant `BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23
applies a special U-value lookup to basement walls."""
return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
@dataclass @dataclass
class SapBuildingPart: class SapBuildingPart:
# General # General
identifier: str # e.g. "main", "roof" identifier: BuildingPartIdentifier
construction_age_band: str construction_age_band: str
# Wall # Wall
@ -261,6 +334,29 @@ class SapBuildingPart:
) )
sap_room_in_roof: Optional[SapRoomInRoof] = None sap_room_in_roof: Optional[SapRoomInRoof] = None
@property
def main_wall_is_basement(self) -> bool:
"""True iff this part's primary wall (not an alt sub-area) is the
basement wall happens when the whole part sits below grade.
Empirically 54 of 67k parts in the 2026 sweep; rare but real."""
return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
@property
def has_basement(self) -> bool:
"""True iff this part carries a basement wall — either as its
main wall (`main_wall_is_basement`) or as an alt sub-area
(`SapAlternativeWall.is_basement_wall`). When true, RdSAP §5.17 /
Table 23 governs both the basement-wall U-value AND the entire
ground floor's U-value for this part (per user-confirmed
convention: basement-wall presence whole floor=0 is basement
floor)."""
if self.main_wall_is_basement:
return True
return any(
alt is not None and alt.is_basement_wall
for alt in (self.sap_alternative_wall_1, self.sap_alternative_wall_2)
)
@dataclass @dataclass
class WindowsTransmissionDetails: class WindowsTransmissionDetails:

View file

@ -4,6 +4,7 @@ from datatypes.epc.schema.helpers import from_dict
from datatypes.epc.domain.epc_property_data import ( from datatypes.epc.domain.epc_property_data import (
Addendum, Addendum,
BuildingPartIdentifier,
EnergyElement, EnergyElement,
EpcPropertyData, EpcPropertyData,
InstantaneousWwhrs, InstantaneousWwhrs,
@ -427,7 +428,7 @@ class EpcPropertyDataMapper:
), ),
sap_building_parts=[ sap_building_parts=[
SapBuildingPart( SapBuildingPart(
identifier=bp.identifier, identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
construction_age_band=bp.construction_age_band, construction_age_band=bp.construction_age_band,
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
@ -568,7 +569,7 @@ class EpcPropertyDataMapper:
), ),
sap_building_parts=[ sap_building_parts=[
SapBuildingPart( SapBuildingPart(
identifier=bp.identifier, identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
construction_age_band=bp.construction_age_band, construction_age_band=bp.construction_age_band,
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
@ -705,7 +706,7 @@ class EpcPropertyDataMapper:
), ),
sap_building_parts=[ sap_building_parts=[
SapBuildingPart( SapBuildingPart(
identifier=bp.identifier, identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
construction_age_band=bp.construction_age_band, construction_age_band=bp.construction_age_band,
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
@ -850,7 +851,7 @@ class EpcPropertyDataMapper:
), ),
sap_building_parts=[ sap_building_parts=[
SapBuildingPart( SapBuildingPart(
identifier=bp.identifier, identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
construction_age_band=bp.construction_age_band, construction_age_band=bp.construction_age_band,
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
@ -1012,7 +1013,7 @@ class EpcPropertyDataMapper:
), ),
sap_building_parts=[ sap_building_parts=[
SapBuildingPart( SapBuildingPart(
identifier=bp.identifier, identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
construction_age_band=bp.construction_age_band, construction_age_band=bp.construction_age_band,
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
@ -1201,7 +1202,7 @@ class EpcPropertyDataMapper:
), ),
sap_building_parts=[ sap_building_parts=[
SapBuildingPart( SapBuildingPart(
identifier=bp.identifier, identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
construction_age_band=bp.construction_age_band, construction_age_band=bp.construction_age_band,
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
@ -1446,7 +1447,7 @@ class EpcPropertyDataMapper:
# SAP building parts # SAP building parts
sap_building_parts=[ sap_building_parts=[
SapBuildingPart( SapBuildingPart(
identifier=bp.identifier, identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
construction_age_band=bp.construction_age_band, construction_age_band=bp.construction_age_band,
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
@ -1755,7 +1756,7 @@ def _map_main_building_part(
floor = construction.floor floor = construction.floor
roof_location, roof_thickness = _map_roof(roof) roof_location, roof_thickness = _map_roof(roof)
return SapBuildingPart( return SapBuildingPart(
identifier="main", identifier=BuildingPartIdentifier.MAIN,
construction_age_band=_extract_age_band(main.age_range), construction_age_band=_extract_age_band(main.age_range),
wall_construction=main.walls_construction_type, wall_construction=main.walls_construction_type,
wall_insulation_type=main.walls_insulation_type, wall_insulation_type=main.walls_insulation_type,
@ -1779,7 +1780,7 @@ def _map_extension_building_part(
) -> SapBuildingPart: ) -> SapBuildingPart:
roof_location, roof_thickness = _map_roof(roof) roof_location, roof_thickness = _map_roof(roof)
return SapBuildingPart( return SapBuildingPart(
identifier=f"extension_{ext_c.id}", identifier=BuildingPartIdentifier.extension(ext_c.id),
construction_age_band=_extract_age_band(ext_c.age_range), construction_age_band=_extract_age_band(ext_c.age_range),
wall_construction=ext_c.walls_construction_type, wall_construction=ext_c.walls_construction_type,
wall_insulation_type=ext_c.walls_insulation_type, wall_insulation_type=ext_c.walls_insulation_type,
@ -1902,7 +1903,7 @@ def _map_elmhurst_building_part(survey: ElmhurstSiteNotes) -> SapBuildingPart:
for i, f in enumerate(dims.floors) for i, f in enumerate(dims.floors)
] ]
return SapBuildingPart( return SapBuildingPart(
identifier="main", identifier=BuildingPartIdentifier.MAIN,
construction_age_band=_strip_code(survey.construction_age_band), construction_age_band=_strip_code(survey.construction_age_band),
wall_construction=_strip_code(survey.walls.wall_type), wall_construction=_strip_code(survey.walls.wall_type),
wall_insulation_type=_strip_code(survey.walls.insulation), wall_insulation_type=_strip_code(survey.walls.insulation),

View file

@ -0,0 +1,98 @@
"""Tests for `BuildingPartIdentifier` — the strictly-typed identifier
that replaces bare-string matching on `SapBuildingPart.identifier`.
Two boundary factories convert raw inputs to canonical members:
- `BuildingPartIdentifier.from_api_string` (gov-EPC API)
- `BuildingPartIdentifier.extension(n)` (site-notes / construction id)
P6.1 starts P6 (strict-type EpcPropertyData) from the documented pain
point in packages/domain/src/domain/sap/worksheet/dimensions.py:74-82.
"""
from __future__ import annotations
import pytest
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
class TestFromApiString:
"""The gov-EPC API returns "Main Dwelling" and "Extension N"; the
21_0_1 schema also permits `None`. All map to canonical members."""
def test_main_dwelling_becomes_main(self) -> None:
# Arrange / Act
identifier = BuildingPartIdentifier.from_api_string("Main Dwelling")
# Assert
assert identifier is BuildingPartIdentifier.MAIN
@pytest.mark.parametrize(
"api_string, expected",
[
("Extension 1", BuildingPartIdentifier.EXTENSION_1),
("Extension 2", BuildingPartIdentifier.EXTENSION_2),
("Extension 3", BuildingPartIdentifier.EXTENSION_3),
("Extension 4", BuildingPartIdentifier.EXTENSION_4),
],
)
def test_extension_n_becomes_extension_n(
self, api_string: str, expected: BuildingPartIdentifier
) -> None:
# Arrange / Act
identifier = BuildingPartIdentifier.from_api_string(api_string)
# Assert
assert identifier is expected
def test_none_becomes_other(self) -> None:
# Arrange — the 21_0_1 schema permits `identifier: Optional[str]`.
# Act
identifier = BuildingPartIdentifier.from_api_string(None)
# Assert
assert identifier is BuildingPartIdentifier.OTHER
@pytest.mark.parametrize(
"api_string", ["", "roof", "garage", "Extension", "Main", "Extension 5"]
)
def test_unrecognised_becomes_other(self, api_string: str) -> None:
# Arrange — "Extension 5" is intentionally OTHER per RdSAP10 §1.2
# (max 4 extensions); bare "Extension" with no digit likewise.
# Act
identifier = BuildingPartIdentifier.from_api_string(api_string)
# Assert
assert identifier is BuildingPartIdentifier.OTHER
class TestExtensionFactory:
"""`extension(n)` is the site-notes-side constructor — surveyors
record extensions by integer id; this maps idcanonical member."""
@pytest.mark.parametrize(
"n, expected",
[
(1, BuildingPartIdentifier.EXTENSION_1),
(2, BuildingPartIdentifier.EXTENSION_2),
(3, BuildingPartIdentifier.EXTENSION_3),
(4, BuildingPartIdentifier.EXTENSION_4),
],
)
def test_valid_extension_number_returns_member(
self, n: int, expected: BuildingPartIdentifier
) -> None:
# Arrange / Act
identifier = BuildingPartIdentifier.extension(n)
# Assert
assert identifier is expected
@pytest.mark.parametrize("n", [0, 5, 99, -1])
def test_out_of_range_falls_to_other(self, n: int) -> None:
# Arrange — RdSAP10 §1.2 caps at 4; out-of-range numbers should
# not crash the mapper, they should classify as OTHER.
# Act
identifier = BuildingPartIdentifier.extension(n)
# Assert
assert identifier is BuildingPartIdentifier.OTHER

View file

@ -6,6 +6,7 @@ from typing import Any, Dict
import pytest import pytest
from datatypes.epc.domain.epc_property_data import ( from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData, EpcPropertyData,
InstantaneousWwhrs, InstantaneousWwhrs,
MainHeatingDetail, MainHeatingDetail,
@ -211,7 +212,7 @@ class TestFromSiteNotesExample1:
assert len(result.sap_building_parts) == 1 assert len(result.sap_building_parts) == 1
def test_building_part_identifier(self, result: EpcPropertyData) -> None: def test_building_part_identifier(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].identifier == "main" assert result.sap_building_parts[0].identifier is BuildingPartIdentifier.MAIN
def test_construction_age_band(self, result: EpcPropertyData) -> None: def test_construction_age_band(self, result: EpcPropertyData) -> None:
# main_building.age_range: "I: 1996 - 2002" → letter "I" # main_building.age_range: "I: 1996 - 2002" → letter "I"
@ -464,7 +465,7 @@ class TestFromSiteNotesExample1:
# Building parts # Building parts
sap_building_parts=[ sap_building_parts=[
SapBuildingPart( SapBuildingPart(
identifier="main", identifier=BuildingPartIdentifier.MAIN,
construction_age_band="I", construction_age_band="I",
wall_construction="Cavity", wall_construction="Cavity",
wall_insulation_type="As built", wall_insulation_type="As built",

View file

@ -587,6 +587,59 @@ def u_door(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Basement U-values (RdSAP §5.17 / Table 23)
#
# Applied when a building part carries an alt wall with
# wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE. Per the user-
# confirmed convention, the WHOLE floor=0 of that part is the basement
# floor — so `u_basement_floor` overrides the regular floor U-value for
# the part's ground floor area, and `u_basement_wall` overrides the
# cascade for the basement alt sub-area only.
# ---------------------------------------------------------------------------
_BASEMENT_WALL_BY_BAND: Final[dict[str, float]] = {
"A": 0.7, "B": 0.7, "C": 0.7, "D": 0.7, "E": 0.7,
"F": 0.7,
"G": 0.6, "H": 0.6,
"I": 0.45,
"J": 0.35,
"K": 0.3,
"L": 0.28,
"M": 0.26,
}
_BASEMENT_FLOOR_BY_BAND: Final[dict[str, float]] = {
"A": 0.50, "B": 0.50, "C": 0.50, "D": 0.50, "E": 0.50,
"F": 0.50,
"G": 0.5, "H": 0.5, "I": 0.5,
"J": 0.25,
"K": 0.22,
"L": 0.22,
"M": 0.18,
}
def u_basement_wall(age_band: Optional[str]) -> float:
"""Basement-wall U-value (W/m²K), RdSAP10 Table 23. Defaults to the
A-E value (0.7) when age band is missing matches the worst-case
cascade behaviour elsewhere in this module."""
if age_band is None:
return 0.7
return _BASEMENT_WALL_BY_BAND.get(age_band.upper(), 0.7)
def u_basement_floor(age_band: Optional[str]) -> float:
"""Basement-floor U-value (W/m²K), RdSAP10 Table 23. Applied to the
WHOLE floor=0 of a part that has a basement (per user-confirmed
convention: basement-wall presence entire ground floor is basement
floor). Defaults to the A-E value (0.50) when band is missing."""
if age_band is None:
return 0.50
return _BASEMENT_FLOOR_BY_BAND.get(age_band.upper(), 0.50)
def u_party_wall(party_wall_construction: Optional[int]) -> float: def u_party_wall(party_wall_construction: Optional[int]) -> float:
"""RdSAP10 party-wall U-value in W/m^2K, never null. """RdSAP10 party-wall U-value in W/m^2K, never null.

View file

@ -12,6 +12,7 @@ from datetime import date
from typing import Optional, Union from typing import Optional, Union
from datatypes.epc.domain.epc_property_data import ( from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData, EpcPropertyData,
InstantaneousWwhrs, InstantaneousWwhrs,
MainHeatingDetail, MainHeatingDetail,
@ -123,7 +124,7 @@ def make_floor_dimension(
def make_building_part( def make_building_part(
*, *,
identifier: str = "Main Dwelling", identifier: BuildingPartIdentifier = BuildingPartIdentifier.MAIN,
construction_age_band: str = "B", construction_age_band: str = "B",
wall_construction: Union[int, str] = 3, wall_construction: Union[int, str] = 3,
wall_insulation_type: Union[int, str] = 2, wall_insulation_type: Union[int, str] = 2,

View file

@ -96,7 +96,11 @@ class CalculatorInputs:
dimensions: Dimensions dimensions: Dimensions
heat_transmission: HeatTransmission heat_transmission: HeatTransmission
infiltration_ach: float # SAP10.2 (25)m — effective monthly air-change rate (12-tuple Jan..Dec).
# Per-month because ventilation HLC varies with wind speed (Table U2)
# and MV mode (§2 lines 24a-d). Constant-monthly inputs work too:
# pass `(ach,) * 12` to model a single rate across all months.
monthly_infiltration_ach: tuple[float, ...]
region: int region: int
windows: tuple[WindowInput, ...] windows: tuple[WindowInput, ...]
control_type: int control_type: int
@ -278,22 +282,34 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
typed `SapResult`. Cert-shape mapping is the job of `cert_to_inputs` typed `SapResult`. Cert-shape mapping is the job of `cert_to_inputs`
(S-A7b); this entry point is pure physics.""" (S-A7b); this entry point is pure physics."""
tfa = inputs.dimensions.total_floor_area_m2 tfa = inputs.dimensions.total_floor_area_m2
hlc_v = inputs.infiltration_ach * inputs.dimensions.volume_m3 * _AIR_HEAT_CAPACITY_WH_PER_M3_K volume = inputs.dimensions.volume_m3
hlc = inputs.heat_transmission.total_w_per_k + hlc_v transmission_hlc = inputs.heat_transmission.total_w_per_k
hlp = hlc / tfa if tfa > 0 else 0.0
tau_h = _time_constant_h( # SAP10.2 §3 line (38): ventilation HLC = 0.33 × (25)m × volume —
tmp_kj_per_m2_k=inputs.thermal_mass_parameter_kj_per_m2_k, # monthly because (25)m varies with Table U2 wind. HLC, HLP, and the
tfa_m2=tfa, # time constant τ all become 12-tuples.
hlc_w_per_k=hlc, monthly_hlc_v = tuple(
ach * volume * _AIR_HEAT_CAPACITY_WH_PER_M3_K
for ach in inputs.monthly_infiltration_ach
)
monthly_hlc = tuple(transmission_hlc + hv for hv in monthly_hlc_v)
monthly_hlp = tuple(h / tfa if tfa > 0 else 0.0 for h in monthly_hlc)
monthly_tau_h = tuple(
_time_constant_h(
tmp_kj_per_m2_k=inputs.thermal_mass_parameter_kj_per_m2_k,
tfa_m2=tfa,
hlc_w_per_k=h,
)
for h in monthly_hlc
) )
monthly = tuple( monthly = tuple(
_solve_month( _solve_month(
inputs=inputs, inputs=inputs,
month=m, month=m,
hlc_w_per_k=hlc, hlc_w_per_k=monthly_hlc[m - 1],
time_constant_h=tau_h, time_constant_h=monthly_tau_h[m - 1],
heat_loss_parameter=hlp, heat_loss_parameter=monthly_hlp[m - 1],
) )
for m in range(1, 13) for m in range(1, 13)
) )
@ -374,11 +390,13 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
"windows_w_per_k": ht.windows_w_per_k, "windows_w_per_k": ht.windows_w_per_k,
"doors_w_per_k": ht.doors_w_per_k, "doors_w_per_k": ht.doors_w_per_k,
"thermal_bridging_w_per_k": ht.thermal_bridging_w_per_k, "thermal_bridging_w_per_k": ht.thermal_bridging_w_per_k,
"infiltration_ach": inputs.infiltration_ach, # Annual means for the back-compat single-float audit dict; full
"infiltration_w_per_k": hlc_v, # monthly arrays are available via the upstream VentilationResult.
"heat_transfer_coefficient_w_per_k": hlc, "infiltration_ach": sum(inputs.monthly_infiltration_ach) / 12.0,
"heat_loss_parameter_w_per_m2k": hlp, "infiltration_w_per_k": sum(monthly_hlc_v) / 12.0,
"time_constant_h": tau_h, "heat_transfer_coefficient_w_per_k": sum(monthly_hlc) / 12.0,
"heat_loss_parameter_w_per_m2k": sum(monthly_hlp) / 12.0,
"time_constant_h": sum(monthly_tau_h) / 12.0,
"internal_gains_annual_avg_w": sum(e.internal_gains_w for e in monthly) / 12.0, "internal_gains_annual_avg_w": sum(e.internal_gains_w for e in monthly) / 12.0,
"mean_internal_temp_annual_avg_c": sum(e.internal_temp_c for e in monthly) / 12.0, "mean_internal_temp_annual_avg_c": sum(e.internal_temp_c for e in monthly) / 12.0,
"useful_space_heating_kwh_per_yr": space_heating_kwh, "useful_space_heating_kwh_per_yr": space_heating_kwh,

View file

@ -64,7 +64,10 @@ from domain.sap.worksheet.heat_transmission import (
heat_transmission_from_cert, heat_transmission_from_cert,
) )
from domain.sap.worksheet.solar_gains import Orientation from domain.sap.worksheet.solar_gains import Orientation
from domain.sap.worksheet.ventilation import infiltration_ach from domain.sap.worksheet.ventilation import (
MechanicalVentilationKind,
ventilation_from_inputs,
)
# RdSAP 10 Table 27 — fraction of total floor area treated as the # RdSAP 10 Table 27 — fraction of total floor area treated as the
@ -766,7 +769,35 @@ def cert_to_inputs(
vol = dim.volume_m3 if dim.volume_m3 > 0 else 1.0 vol = dim.volume_m3 if dim.volume_m3 > 0 else 1.0
storeys = max(1, dim.storey_count) storeys = max(1, dim.storey_count)
vc = _ventilation_counts(epc.sap_ventilation) vc = _ventilation_counts(epc.sap_ventilation)
infiltration = infiltration_ach( # SAP §2 ventilation: full worksheet (6a)..(25)m via VentilationResult.
# The following cert→input ambiguities are intentionally papered over
# with spec-default values for now; each TODO is a tracked follow-up
# for when the mapping rule becomes available:
#
# TODO(cert→ventilation 1): `epc.mechanical_ventilation: int` carries
# a code (0..N) selecting Natural / MVHR / MV / Extract / PIV-outside
# / PIV-loft. The int→enum mapping isn't in the SAP10.2 or RdSAP10
# PDFs we have; Elmhurst likely uses the same code list. We pin
# NATURAL until we have a documented mapping or a golden cert that
# exercises an MV path.
# TODO(cert→ventilation 2): `has_suspended_timber_floor` / `_sealed`
# should be derived from `floor_construction` + `floor_insulation`
# on `SapFloorDimension`; the RdSAP §4.1 rule for "treat as
# suspended timber" isn't yet wired in. Defaulted to False.
# TODO(cert→ventilation 3): `has_draught_lobby` is not lodged on the
# gov-EPC API. RdSAP §4.1 may infer from dwelling form. Defaulted
# to False (worst case for line (13) = 0.05).
# TODO(cert→ventilation 4): `air_permeability_ap50` / `ap4` from a
# pressure test — cert has `pressure_test: int` (code, not a value)
# and `air_tightness: {description,...}`. Likely only present on
# SAP (new-build) certs, not RdSAP. Defaulted to None (no test).
# TODO(cert→ventilation 5): `sheltered_sides` not on the cert — we
# hardcode 2 (typical UK terraced/semi-detached). Could be derived
# from `dwelling_type` (detached → 0, end-terrace → 2, mid-terrace → 3).
# TODO(cert→ventilation 6): `monthly_wind_speed_m_s` defaults to
# Table U2 non-regional. Should select the regional row keyed by
# `epc.region_code` once regional weather is wired in.
ventilation = ventilation_from_inputs(
volume_m3=vol, volume_m3=vol,
storey_count=storeys, storey_count=storeys,
is_timber_or_steel_frame=_is_timber_or_steel_frame(epc.sap_building_parts), is_timber_or_steel_frame=_is_timber_or_steel_frame(epc.sap_building_parts),
@ -780,10 +811,8 @@ def cert_to_inputs(
passive_vents=vc.passive_vents, passive_vents=vc.passive_vents,
flueless_gas_fires=vc.flueless_gas_fires, flueless_gas_fires=vc.flueless_gas_fires,
window_pct_draught_proofed=float(epc.percent_draughtproofed or 0), window_pct_draught_proofed=float(epc.percent_draughtproofed or 0),
# SAP §2 shelter factor: 2 sheltered sides default per typical
# UK terraced/semi-detached layout. The cert doesn't lodge a
# sheltered-sides count, so we apply the spec's typical default.
sheltered_sides=2, sheltered_sides=2,
mv_kind=MechanicalVentilationKind.NATURAL,
) )
main = _first_main_heating(epc) main = _first_main_heating(epc)
@ -847,7 +876,9 @@ def cert_to_inputs(
return CalculatorInputs( return CalculatorInputs(
dimensions=dim, dimensions=dim,
heat_transmission=ht, heat_transmission=ht,
infiltration_ach=infiltration.total_ach, # SAP10.2 line (25)m — 12-month effective air-change rate from the
# full §2 worksheet (openings, shelter, wind adjustment, MV mode).
monthly_infiltration_ach=ventilation.effective_monthly_ach,
region=_region_index(epc.region_code), region=_region_index(epc.region_code),
windows=_window_inputs(epc.sap_windows), windows=_window_inputs(epc.sap_windows),
control_type=_control_type(main), control_type=_control_type(main),

View file

@ -354,8 +354,10 @@ def test_open_chimneys_raise_infiltration_ach() -> None:
inputs_base = cert_to_inputs(base) inputs_base = cert_to_inputs(base)
inputs_chim = cert_to_inputs(with_chimney) inputs_chim = cert_to_inputs(with_chimney)
# Assert # Assert — direction check on the annual mean of (25)m.
assert inputs_chim.infiltration_ach > inputs_base.infiltration_ach assert sum(inputs_chim.monthly_infiltration_ach) > sum(
inputs_base.monthly_infiltration_ach
)
def test_living_area_fraction_uses_rdsap_table_27_by_habitable_rooms() -> None: def test_living_area_fraction_uses_rdsap_table_27_by_habitable_rooms() -> None:

View file

@ -58,6 +58,8 @@ def _baseline_dwelling() -> CalculatorInputs:
windows_w_per_k=25.0, windows_w_per_k=25.0,
doors_w_per_k=5.0, doors_w_per_k=5.0,
thermal_bridging_w_per_k=20.0, thermal_bridging_w_per_k=20.0,
fabric_heat_loss_w_per_k=130.0, # 60+20+20+0+25+5
total_external_element_area_m2=200.0, # synthetic placeholder
total_w_per_k=150.0, total_w_per_k=150.0,
) )
windows = ( windows = (
@ -81,7 +83,7 @@ def _baseline_dwelling() -> CalculatorInputs:
return CalculatorInputs( return CalculatorInputs(
dimensions=dim, dimensions=dim,
heat_transmission=ht, heat_transmission=ht,
infiltration_ach=0.7, monthly_infiltration_ach=(0.7,) * 12,
region=0, region=0,
windows=windows, windows=windows,
control_type=2, control_type=2,

View file

@ -54,6 +54,8 @@ def _baseline_inputs() -> CalculatorInputs:
windows_w_per_k=25.0, windows_w_per_k=25.0,
doors_w_per_k=5.0, doors_w_per_k=5.0,
thermal_bridging_w_per_k=20.0, thermal_bridging_w_per_k=20.0,
fabric_heat_loss_w_per_k=130.0, # 60+20+20+0+25+5
total_external_element_area_m2=200.0, # synthetic placeholder
total_w_per_k=150.0, total_w_per_k=150.0,
) )
windows = ( windows = (
@ -77,7 +79,7 @@ def _baseline_inputs() -> CalculatorInputs:
return CalculatorInputs( return CalculatorInputs(
dimensions=dim, dimensions=dim,
heat_transmission=ht, heat_transmission=ht,
infiltration_ach=0.7, monthly_infiltration_ach=(0.7,) * 12,
region=0, region=0,
windows=windows, windows=windows,
control_type=2, control_type=2,
@ -170,8 +172,9 @@ def test_calculate_exposes_ventilation_intermediates() -> None:
result = calculate_sap_from_inputs(inputs) result = calculate_sap_from_inputs(inputs)
# Assert # Assert
assert result.intermediate["infiltration_ach"] == inputs.infiltration_ach annual_mean_ach = sum(inputs.monthly_infiltration_ach) / 12.0
expected_hlc_v = inputs.infiltration_ach * inputs.dimensions.volume_m3 * 0.33 assert result.intermediate["infiltration_ach"] == pytest.approx(annual_mean_ach, rel=1e-12)
expected_hlc_v = annual_mean_ach * inputs.dimensions.volume_m3 * 0.33
assert result.intermediate["infiltration_w_per_k"] == pytest.approx( assert result.intermediate["infiltration_w_per_k"] == pytest.approx(
expected_hlc_v, rel=1e-9 expected_hlc_v, rel=1e-9
) )
@ -189,9 +192,10 @@ def test_calculate_exposes_hlc_hlp_and_annual_averages() -> None:
result = calculate_sap_from_inputs(inputs) result = calculate_sap_from_inputs(inputs)
# Assert # Assert
annual_mean_ach = sum(inputs.monthly_infiltration_ach) / 12.0
expected_hlc = ( expected_hlc = (
inputs.heat_transmission.total_w_per_k inputs.heat_transmission.total_w_per_k
+ inputs.infiltration_ach * inputs.dimensions.volume_m3 * 0.33 + annual_mean_ach * inputs.dimensions.volume_m3 * 0.33
) )
expected_hlp = expected_hlc / inputs.dimensions.total_floor_area_m2 expected_hlp = expected_hlc / inputs.dimensions.total_floor_area_m2
assert result.intermediate["heat_transfer_coefficient_w_per_k"] == pytest.approx( assert result.intermediate["heat_transfer_coefficient_w_per_k"] == pytest.approx(
@ -483,8 +487,8 @@ def test_zero_heat_transmission_collapses_space_heating_to_zero() -> None:
base = _baseline_inputs() base = _baseline_inputs()
no_loss = replace( no_loss = replace(
base, base,
heat_transmission=HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), heat_transmission=HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
infiltration_ach=0.0, monthly_infiltration_ach=(0.0,) * 12,
) )
# Act # Act