diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 0ddb4baa..88a0e188 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3416,6 +3416,25 @@ def _map_elmhurst_rir_surface( if prefix is None: return None kind = _RIR_KIND_FROM_NAME_PREFIX[prefix] + # RdSAP 10 §3.9.1 (PDF p.21) Simplified assessment: the roof-going + # surfaces (slope / flat ceiling / stud wall) are NOT measured — the + # Summary lodges placeholder Length/Height cells (e.g. a 40 m ceiling + # height, a 32 m slope on a 4.65 m-wide gable). The spec instead + # derives one timber-framed "remaining area" from the floor area: + # A_RR = 12.5 × √(A_RR_floor / 1.5) §3.9.1(d) + # A_RR_final = A_RR − ΣA_RR_gable/other §3.9.1(e) + # The cascade computes A_RR_final itself (heat_transmission.py — the + # `12.5 × √(A_RR_floor / 1.5) − rr_walls_in_a_rr_area` residual), + # but ONLY when `detailed_surfaces` carries no roof-going kind + # (`has_roof_lodgement` gate). Emitting these placeholder rows flips + # that gate and bills their raw L×H as explicit roof area (a 7.5× + # heat-loss explosion). Drop them for Simplified so the cascade's + # residual formula fires — matching how the API path already handles + # the same Simplified RR (scalar gable fields, no roof-going + # detailed_surfaces; cert 6035) and the gables-only cert 000565. + # Detailed (§3.10) assessments DO measure these surfaces — keep them. + if is_simplified and kind in ("slope", "flat_ceiling", "stud_wall"): + return None u_value_override: Optional[float] = None if kind == "gable_wall" and surface.gable_type == "Sheltered": kind = "gable_wall_external" diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 40ba7aff..fbccd757 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -14,10 +14,18 @@ area fraction); SAP 10.3 specification (13-01-2026) Tables 4a/4e/12. from __future__ import annotations -from typing import Final +from typing import Final, Optional import pytest +from datatypes.epc.domain.mapper import ( + _map_elmhurst_room_in_roof, # pyright: ignore[reportPrivateUsage] +) +from datatypes.epc.surveys.elmhurst_site_notes import ( + RoomInRoof as ElmhurstRoomInRoof, + RoomInRoofSurface as ElmhurstRoomInRoofSurface, +) + from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, MainHeatingDetail, @@ -2120,6 +2128,70 @@ def test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels() -> N assert _ELMHURST_MAIN_FUEL_TO_SAP10["Bio-liquid HVO from used cooking oil"] == 71 +def _placeholder_rir_surfaces() -> "list[ElmhurstRoomInRoofSurface]": + # A §3.9.1 Simplified Room-in-Roof lodges the roof-going Length/Height + # cells as placeholders (a 40 m flat-ceiling height, a 32 m slope on a + # 4.65 m gable) — Elmhurst ignores them and derives the area from the + # floor area. Gables ARE measured (4.65 × 2.45 = 11.39). + def surf( + name: str, length: float, height: float, + gable_type: Optional[str] = None, default_u: Optional[float] = None, + ) -> "ElmhurstRoomInRoofSurface": + return ElmhurstRoomInRoofSurface( + name=name, length_m=length, height_m=height, insulation="", + insulation_type=None, gable_type=gable_type, + default_u_value=default_u, u_value_known=False, u_value=0.0, + ) + + return [ + surf("Flat Ceiling 1", 4.00, 40.00), # placeholder + surf("Slope 1", 32.00, 32.00), # placeholder + surf("Gable Wall 1", 4.65, 2.45, gable_type="Exposed", default_u=0.29), + surf("Gable Wall 2", 4.65, 2.45, gable_type="Party", default_u=0.25), + ] + + +def test_elmhurst_simplified_rir_drops_placeholder_roof_surfaces() -> None: + # Arrange — RdSAP 10 §3.9.1 (PDF p.21): a Simplified RR's slope / + # flat ceiling / stud wall are not measured; emitting their + # placeholder L×H as `detailed_surfaces` makes the cascade bill them + # as explicit roof area (7.5× heat-loss explosion) instead of firing + # the spec's `A_RR = 12.5√(A_floor/1.5) − Σwalls` residual formula. + rir = ElmhurstRoomInRoof( + floor_area_m2=29.75, construction_age_band="A", + assessment="Simplified Type 1", surfaces=_placeholder_rir_surfaces(), + ) + + # Act + mapped = _map_elmhurst_room_in_roof(rir) + + # Assert — roof-going surfaces dropped, both gables retained. + assert mapped is not None + kinds = sorted(s.kind for s in (mapped.detailed_surfaces or [])) + assert "slope" not in kinds + assert "flat_ceiling" not in kinds + assert kinds == ["gable_wall", "gable_wall_external"] + + +def test_elmhurst_detailed_rir_keeps_roof_surfaces() -> None: + # Arrange — a Detailed (§3.10) assessment DOES measure slope / flat + # ceiling, so they must be retained (regression guard so the + # Simplified drop doesn't bleed into Detailed lodgements). + rir = ElmhurstRoomInRoof( + floor_area_m2=29.75, construction_age_band="A", + assessment="Detailed", surfaces=_placeholder_rir_surfaces(), + ) + + # Act + mapped = _map_elmhurst_room_in_roof(rir) + + # Assert — slope + flat ceiling retained under the Detailed path. + assert mapped is not None + kinds = sorted(s.kind for s in (mapped.detailed_surfaces or [])) + assert "slope" in kinds + assert "flat_ceiling" in kinds + + def test_elmhurst_gas_boiler_main_fuel_derives_carrier_from_water_heating() -> None: # Arrange — SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas # boilers (including mains gas, LPG and biogas)". The code identifies