diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 3cd7473e..94bc539e 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -1,10 +1,67 @@ +import re from dataclasses import dataclass 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 +_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 class EnergyElement: description: str @@ -201,6 +258,14 @@ class SapRoomInRoof: 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 class SapAlternativeWall: wall_area: float @@ -210,11 +275,19 @@ class SapAlternativeWall: wall_thickness_measured: str 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 class SapBuildingPart: # General - identifier: str # e.g. "main", "roof" + identifier: BuildingPartIdentifier construction_age_band: str # Wall @@ -261,6 +334,29 @@ class SapBuildingPart: ) 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 class WindowsTransmissionDetails: diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 8a3d6454..daeb8265 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4,6 +4,7 @@ from datatypes.epc.schema.helpers import from_dict from datatypes.epc.domain.epc_property_data import ( Addendum, + BuildingPartIdentifier, EnergyElement, EpcPropertyData, InstantaneousWwhrs, @@ -427,7 +428,7 @@ class EpcPropertyDataMapper: ), sap_building_parts=[ SapBuildingPart( - identifier=bp.identifier, + identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, @@ -568,7 +569,7 @@ class EpcPropertyDataMapper: ), sap_building_parts=[ SapBuildingPart( - identifier=bp.identifier, + identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, @@ -705,7 +706,7 @@ class EpcPropertyDataMapper: ), sap_building_parts=[ SapBuildingPart( - identifier=bp.identifier, + identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, @@ -850,7 +851,7 @@ class EpcPropertyDataMapper: ), sap_building_parts=[ SapBuildingPart( - identifier=bp.identifier, + identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, @@ -1012,7 +1013,7 @@ class EpcPropertyDataMapper: ), sap_building_parts=[ SapBuildingPart( - identifier=bp.identifier, + identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, @@ -1201,7 +1202,7 @@ class EpcPropertyDataMapper: ), sap_building_parts=[ SapBuildingPart( - identifier=bp.identifier, + identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, @@ -1446,7 +1447,7 @@ class EpcPropertyDataMapper: # SAP building parts sap_building_parts=[ SapBuildingPart( - identifier=bp.identifier, + identifier=BuildingPartIdentifier.from_api_string(bp.identifier), construction_age_band=bp.construction_age_band, wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, @@ -1755,7 +1756,7 @@ def _map_main_building_part( floor = construction.floor roof_location, roof_thickness = _map_roof(roof) return SapBuildingPart( - identifier="main", + identifier=BuildingPartIdentifier.MAIN, construction_age_band=_extract_age_band(main.age_range), wall_construction=main.walls_construction_type, wall_insulation_type=main.walls_insulation_type, @@ -1779,7 +1780,7 @@ def _map_extension_building_part( ) -> SapBuildingPart: roof_location, roof_thickness = _map_roof(roof) return SapBuildingPart( - identifier=f"extension_{ext_c.id}", + identifier=BuildingPartIdentifier.extension(ext_c.id), construction_age_band=_extract_age_band(ext_c.age_range), wall_construction=ext_c.walls_construction_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) ] return SapBuildingPart( - identifier="main", + identifier=BuildingPartIdentifier.MAIN, construction_age_band=_strip_code(survey.construction_age_band), wall_construction=_strip_code(survey.walls.wall_type), wall_insulation_type=_strip_code(survey.walls.insulation), diff --git a/datatypes/epc/domain/tests/test_building_part_identifier.py b/datatypes/epc/domain/tests/test_building_part_identifier.py new file mode 100644 index 00000000..3c2f024d --- /dev/null +++ b/datatypes/epc/domain/tests/test_building_part_identifier.py @@ -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 id→canonical 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 diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index ae1dbb3b..e1d0e2cf 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -6,6 +6,7 @@ from typing import Any, Dict import pytest from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, EpcPropertyData, InstantaneousWwhrs, MainHeatingDetail, @@ -211,7 +212,7 @@ class TestFromSiteNotesExample1: assert len(result.sap_building_parts) == 1 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: # main_building.age_range: "I: 1996 - 2002" → letter "I" @@ -464,7 +465,7 @@ class TestFromSiteNotesExample1: # Building parts sap_building_parts=[ SapBuildingPart( - identifier="main", + identifier=BuildingPartIdentifier.MAIN, construction_age_band="I", wall_construction="Cavity", wall_insulation_type="As built", diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index 169c6cdb..9bffc329 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -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: """RdSAP10 party-wall U-value in W/m^2K, never null. diff --git a/packages/domain/src/domain/ml/tests/_fixtures.py b/packages/domain/src/domain/ml/tests/_fixtures.py index 37fccde4..08ed9c29 100644 --- a/packages/domain/src/domain/ml/tests/_fixtures.py +++ b/packages/domain/src/domain/ml/tests/_fixtures.py @@ -12,6 +12,7 @@ from datetime import date from typing import Optional, Union from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, EpcPropertyData, InstantaneousWwhrs, MainHeatingDetail, @@ -123,7 +124,7 @@ def make_floor_dimension( def make_building_part( *, - identifier: str = "Main Dwelling", + identifier: BuildingPartIdentifier = BuildingPartIdentifier.MAIN, construction_age_band: str = "B", wall_construction: Union[int, str] = 3, wall_insulation_type: Union[int, str] = 2, diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index 57a1b14b..e32579b4 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -96,7 +96,11 @@ class CalculatorInputs: dimensions: Dimensions 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 windows: tuple[WindowInput, ...] 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` (S-A7b); this entry point is pure physics.""" tfa = inputs.dimensions.total_floor_area_m2 - hlc_v = inputs.infiltration_ach * inputs.dimensions.volume_m3 * _AIR_HEAT_CAPACITY_WH_PER_M3_K - hlc = inputs.heat_transmission.total_w_per_k + hlc_v - hlp = hlc / tfa if tfa > 0 else 0.0 - tau_h = _time_constant_h( - tmp_kj_per_m2_k=inputs.thermal_mass_parameter_kj_per_m2_k, - tfa_m2=tfa, - hlc_w_per_k=hlc, + volume = inputs.dimensions.volume_m3 + transmission_hlc = inputs.heat_transmission.total_w_per_k + + # SAP10.2 §3 line (38): ventilation HLC = 0.33 × (25)m × volume — + # monthly because (25)m varies with Table U2 wind. HLC, HLP, and the + # time constant τ all become 12-tuples. + 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( _solve_month( inputs=inputs, month=m, - hlc_w_per_k=hlc, - time_constant_h=tau_h, - heat_loss_parameter=hlp, + hlc_w_per_k=monthly_hlc[m - 1], + time_constant_h=monthly_tau_h[m - 1], + heat_loss_parameter=monthly_hlp[m - 1], ) 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, "doors_w_per_k": ht.doors_w_per_k, "thermal_bridging_w_per_k": ht.thermal_bridging_w_per_k, - "infiltration_ach": inputs.infiltration_ach, - "infiltration_w_per_k": hlc_v, - "heat_transfer_coefficient_w_per_k": hlc, - "heat_loss_parameter_w_per_m2k": hlp, - "time_constant_h": tau_h, + # Annual means for the back-compat single-float audit dict; full + # monthly arrays are available via the upstream VentilationResult. + "infiltration_ach": sum(inputs.monthly_infiltration_ach) / 12.0, + "infiltration_w_per_k": sum(monthly_hlc_v) / 12.0, + "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, "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, diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index a0180b8e..04f6d555 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -64,7 +64,10 @@ from domain.sap.worksheet.heat_transmission import ( heat_transmission_from_cert, ) 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 @@ -766,7 +769,35 @@ def cert_to_inputs( vol = dim.volume_m3 if dim.volume_m3 > 0 else 1.0 storeys = max(1, dim.storey_count) 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, storey_count=storeys, 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, flueless_gas_fires=vc.flueless_gas_fires, 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, + mv_kind=MechanicalVentilationKind.NATURAL, ) main = _first_main_heating(epc) @@ -847,7 +876,9 @@ def cert_to_inputs( return CalculatorInputs( dimensions=dim, 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), windows=_window_inputs(epc.sap_windows), control_type=_control_type(main), diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index 5798be33..e01c5f4a 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -354,8 +354,10 @@ def test_open_chimneys_raise_infiltration_ach() -> None: inputs_base = cert_to_inputs(base) inputs_chim = cert_to_inputs(with_chimney) - # Assert - assert inputs_chim.infiltration_ach > inputs_base.infiltration_ach + # Assert — direction check on the annual mean of (25)m. + 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: diff --git a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py index a836baf9..e9717033 100644 --- a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py +++ b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py @@ -58,6 +58,8 @@ def _baseline_dwelling() -> CalculatorInputs: windows_w_per_k=25.0, doors_w_per_k=5.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, ) windows = ( @@ -81,7 +83,7 @@ def _baseline_dwelling() -> CalculatorInputs: return CalculatorInputs( dimensions=dim, heat_transmission=ht, - infiltration_ach=0.7, + monthly_infiltration_ach=(0.7,) * 12, region=0, windows=windows, control_type=2, diff --git a/packages/domain/src/domain/sap/tests/test_calculator.py b/packages/domain/src/domain/sap/tests/test_calculator.py index 89c1e483..371937b7 100644 --- a/packages/domain/src/domain/sap/tests/test_calculator.py +++ b/packages/domain/src/domain/sap/tests/test_calculator.py @@ -54,6 +54,8 @@ def _baseline_inputs() -> CalculatorInputs: windows_w_per_k=25.0, doors_w_per_k=5.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, ) windows = ( @@ -77,7 +79,7 @@ def _baseline_inputs() -> CalculatorInputs: return CalculatorInputs( dimensions=dim, heat_transmission=ht, - infiltration_ach=0.7, + monthly_infiltration_ach=(0.7,) * 12, region=0, windows=windows, control_type=2, @@ -170,8 +172,9 @@ def test_calculate_exposes_ventilation_intermediates() -> None: result = calculate_sap_from_inputs(inputs) # Assert - assert result.intermediate["infiltration_ach"] == inputs.infiltration_ach - expected_hlc_v = inputs.infiltration_ach * inputs.dimensions.volume_m3 * 0.33 + annual_mean_ach = sum(inputs.monthly_infiltration_ach) / 12.0 + 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( 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) # Assert + annual_mean_ach = sum(inputs.monthly_infiltration_ach) / 12.0 expected_hlc = ( 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 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() no_loss = replace( base, - heat_transmission=HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), - infiltration_ach=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), + monthly_infiltration_ach=(0.0,) * 12, ) # Act