From 7d460183862a44cfe83535e554c12e75c10a80cd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 26 May 2026 22:01:41 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20100a:=20API=20path=20=E2=80=94=20surfac?= =?UTF-8?q?e=20Detailed-RR=20per-surface=20areas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two RR shapes coexist in real-API JSON: cohort certs (6035, 0240, schema test 21_0_1.json) lodge `room_in_roof_type_1` (RdSAP §3.9.1 Simplified Type 1 — gable lengths only, cascade applies the 2.45 m default storey height); cert 9501 lodges `room_in_roof_details` (RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat- ceiling detail). The schema only modelled the Simplified-Type-1 wrapper, so `from_dict` parsed cert 9501's Detailed-RR block as None and the API mapper built `SapRoomInRoof` with `detailed_ surfaces=None`. The cascade then defaulted to Simplified Type 2 "all elements" (RR floor area × Table 18 col(4) age-B U=2.30) for the whole RR → roof HLC 149.43 W/K vs worksheet 18.10 (Δ +131.32). Changes: - Add `RoomInRoofDetails` dataclass to both schema 21.0.0 and 21.0.1 with the 10 fields the JSON lodges: gable_wall_type_{1,2} + gable_wall_length_{1,2} + gable_wall_height_{1,2} + flat_ceiling_ length_1 + flat_ceiling_height_1 + flat_ceiling_insulation_ type_1 + flat_ceiling_insulation_thickness_1. `SapRoomInRoof` gains a sibling `room_in_roof_details` field next to the legacy `room_in_roof_type_1`; both shapes are now lossless. - Extract `_api_build_room_in_roof` mapper helper that reads from whichever block is present and populates `SapRoomInRoof.detailed_surfaces` from the Detailed-RR block. Gables route to `gable_wall_external` for flats (top-floor flats with RR sit at the end of the building, no neighbour above) and to `gable_wall` (party at U=0.25) otherwise — mirrors the Summary mapper's `_map_elmhurst_rir_surface` heuristic. - Replace both inline `SapRoomInRoof(...)` builds in `from_rdsap_schema_21_0_0` and `from_rdsap_schema_21_0_1` with the helper. Effect on cert 9501 API path: - roof HLC 149.43 → 18.10 (= worksheet 18.10 exact) - walls HLC 168.74 → 218.81 (= worksheet 218.81 exact) - (37) total HLC 382.19 → 297.54 (worksheet 296.68; Δ +0.86) - sap_continuous still -9.27 vs worksheet because TFA on the API path is still 81.28 (missing the 31.8 m² RR floor area) — next slice closes that. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 37 +++++ datatypes/epc/domain/mapper.py | 134 ++++++++++++------ datatypes/epc/schema/rdsap_schema_21_0_0.py | 17 +++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 24 ++++ 4 files changed, 172 insertions(+), 40 deletions(-) diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 9e60c163..7ff2b676 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -497,6 +497,43 @@ _API_0330_JSON = ( / "0330-2249-8150-2326-4121.json" ) +_API_9501_JSON = ( + Path(__file__).parents[3] + / "domain/sap10_calculator/rdsap/tests/fixtures/golden" + / "9501-3059-8202-7356-0204.json" +) + + +def test_api_9501_room_in_roof_surfaces_populated() -> None: + # Arrange — cert 9501's API JSON lodges measured RR detail under + # `sap_room_in_roof.room_in_roof_details`: two gable walls + # (5.51 m × 2.45 m + 6.51 m × 2.45 m) and a flat ceiling (5.5 m × + # 1.0 m, 300 mm insulation). The schema's `SapRoomInRoof` dataclass + # exposed the inner block under the wrong field name + # `room_in_roof_type_1` (the legacy Simplified Type 1 wrapper), + # so `from_dict` parsed the inner block as None — the API mapper + # then built `SapRoomInRoof` with no per-surface area data, and + # the cascade defaulted to the Simplified Type 2 "all elements" + # branch (RR floor_area × Table 18 col(4) age-B U=2.30) for the + # whole RR → roof HLC 149.43 vs worksheet 18.10 (Δ +131). + doc = json.loads(_API_9501_JSON.read_text()) + + # Act + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Assert — RR surfaces present and match worksheet element table: + # Gable Wall 1 = 13.50 m², Gable Wall 2 = 15.95 m², Flat Ceiling 1 + # = 5.50 m² (per worksheet §3 element table). + rir = epc.sap_building_parts[0].sap_room_in_roof + assert rir is not None + assert rir.detailed_surfaces is not None + kinds_by_area = sorted((s.kind, s.area_m2) for s in rir.detailed_surfaces) + assert kinds_by_area == [ + ("flat_ceiling", 5.5), + ("gable_wall_external", 13.50), + ("gable_wall_external", 15.95), + ] + def test_api_0330_full_chain_sap_matches_worksheet_pdf_exactly() -> None: # Arrange — cert 0330-2249-8150-2326-4121 (second boiler validation diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 9482d41c..c6ba82a1 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1354,26 +1354,9 @@ class EpcPropertyDataMapper: bp.roof_insulation_thickness, bp.construction_age_band, ), - sap_room_in_roof=( - SapRoomInRoof( - floor_area=_measurement_value(bp.sap_room_in_roof.floor_area), - construction_age_band=bp.sap_room_in_roof.construction_age_band, - # RdSAP §3.9.1 Simplified Type 1: gable lengths - # only (no heights — the cascade applies the - # 2.45 m default storey height per §3.9.1). - gable_1_length_m=( - bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_1 - if bp.sap_room_in_roof.room_in_roof_type_1 is not None - else None - ), - gable_2_length_m=( - bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_2 - if bp.sap_room_in_roof.room_in_roof_type_1 is not None - else None - ), - ) - if bp.sap_room_in_roof - else None + sap_room_in_roof=_api_build_room_in_roof( + bp.sap_room_in_roof, + is_flat=schema.property_type == 2, ), sap_alternative_wall_1=( SapAlternativeWall( @@ -1668,26 +1651,9 @@ class EpcPropertyDataMapper: bp.roof_insulation_thickness, bp.construction_age_band, ), - sap_room_in_roof=( - SapRoomInRoof( - floor_area=_measurement_value(bp.sap_room_in_roof.floor_area), - construction_age_band=bp.sap_room_in_roof.construction_age_band, - # RdSAP §3.9.1 Simplified Type 1: gable lengths - # only (no heights — the cascade applies the - # 2.45 m default storey height per §3.9.1). - gable_1_length_m=( - bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_1 - if bp.sap_room_in_roof.room_in_roof_type_1 is not None - else None - ), - gable_2_length_m=( - bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_2 - if bp.sap_room_in_roof.room_in_roof_type_1 is not None - else None - ), - ) - if bp.sap_room_in_roof - else None + sap_room_in_roof=_api_build_room_in_roof( + bp.sap_room_in_roof, + is_flat=schema.property_type == 2, ), sap_alternative_wall_1=( SapAlternativeWall( @@ -2376,6 +2342,94 @@ def _api_build_sap_floor_dimensions( return out +def _api_build_room_in_roof( + bp_rir: Any, *, is_flat: bool = False, +) -> Optional[SapRoomInRoof]: + """Build `SapRoomInRoof` from the API schema's per-bp RR block. Two + real-API shapes coexist: + - `room_in_roof_type_1` (cohort certs 6035, 0240): RdSAP §3.9.1 + Simplified Type 1 — gable lengths only, cascade applies the + 2.45 m default storey height. + - `room_in_roof_details` (cert 9501): RdSAP §3.9 Detailed RR — + per-surface lengths + heights + flat-ceiling detail. + When the Detailed block is present, build `detailed_surfaces` so the + cascade's per-surface RR branch (heat_transmission.py:629) picks + up exact gable + flat-ceiling areas instead of falling through to + the Table 18 col(4) "all elements" default U. + """ + if bp_rir is None: + return None + rir = SapRoomInRoof( + floor_area=_measurement_value(bp_rir.floor_area), + construction_age_band=bp_rir.construction_age_band, + ) + type_1 = getattr(bp_rir, "room_in_roof_type_1", None) + if type_1 is not None: + # RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights — + # the cascade applies the 2.45 m default storey height). + rir.gable_1_length_m = type_1.gable_wall_length_1 + rir.gable_2_length_m = type_1.gable_wall_length_2 + details = getattr(bp_rir, "room_in_roof_details", None) + if details is not None: + rir.detailed_surfaces = _api_rir_detailed_surfaces(details, is_flat=is_flat) + return rir + + +def _api_rir_detailed_surfaces( + details: Any, *, is_flat: bool, +) -> Optional[List[SapRoomInRoofSurface]]: + """Translate the API `room_in_roof_details` block into the per-surface + list the cascade's Detailed-RR branch consumes. + + Gable walls route to `gable_wall_external` when `is_flat=True` (top- + floor flats with RR sit at the end of their building, no neighbour + above) and to `gable_wall` (party at U=0.25) otherwise. Mirrors the + Summary mapper's `_map_elmhurst_rir_surface` heuristic. + """ + surfaces: List[SapRoomInRoofSurface] = [] + gable_specs = ( + (details.gable_wall_length_1, details.gable_wall_height_1), + (details.gable_wall_length_2, details.gable_wall_height_2), + ) + gable_kind = "gable_wall_external" if is_flat else "gable_wall" + for length, height in gable_specs: + if length is not None and height is not None and length > 0 and height > 0: + area = _round_half_up_2dp(float(length), float(height)) + surfaces.append(SapRoomInRoofSurface(kind=gable_kind, area_m2=area)) + if ( + details.flat_ceiling_length_1 is not None + and details.flat_ceiling_height_1 is not None + and details.flat_ceiling_length_1 > 0 + and details.flat_ceiling_height_1 > 0 + ): + area = _round_half_up_2dp( + float(details.flat_ceiling_length_1), + float(details.flat_ceiling_height_1), + ) + thickness = _parse_rir_insulation_thickness_mm( + details.flat_ceiling_insulation_thickness_1 + ) + surfaces.append( + SapRoomInRoofSurface( + kind="flat_ceiling", + area_m2=area, + insulation_thickness_mm=thickness, + ) + ) + return surfaces or None + + +def _parse_rir_insulation_thickness_mm(value: Any) -> Optional[int]: + """Parse the API's `flat_ceiling_insulation_thickness_*` string + (e.g. "300mm") to an integer mm. Returns None when missing or + unparseable so the cascade defers to the spec default.""" + if value is None: + return None + s = str(value).strip() + m = re.match(r"^(\d+)\s*mm$", s) + return int(m.group(1)) if m else None + + def _api_resolve_sloping_ceiling_thickness( roof_construction: Optional[int], roof_insulation_thickness: Union[str, int, None], diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 16360256..383a4a6e 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -185,12 +185,29 @@ class RoomInRoofType1: gable_wall_length_2: Optional[float] = None +@dataclass +class RoomInRoofDetails: + """RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-ceiling + detail. See `rdsap_schema_21_0_1.RoomInRoofDetails`.""" + gable_wall_type_1: Optional[int] = None + gable_wall_type_2: Optional[int] = None + gable_wall_length_1: Optional[float] = None + gable_wall_length_2: Optional[float] = None + gable_wall_height_1: Optional[float] = None + gable_wall_height_2: Optional[float] = None + flat_ceiling_length_1: Optional[float] = None + flat_ceiling_height_1: Optional[float] = None + flat_ceiling_insulation_type_1: Optional[int] = None + flat_ceiling_insulation_thickness_1: Optional[str] = None + + @dataclass class SapRoomInRoof: """Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0.""" floor_area: Union[int, float] construction_age_band: str room_in_roof_type_1: Optional[RoomInRoofType1] = None + room_in_roof_details: Optional[RoomInRoofDetails] = None @dataclass diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 06ed6bdc..55c59c86 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -194,11 +194,35 @@ class RoomInRoofType1: gable_wall_length_2: Optional[float] = None +@dataclass +class RoomInRoofDetails: + """RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-ceiling + detail. Newer cert vintages lodge full per-surface measured detail under + `room_in_roof_details` instead of the Simplified Type 1 wrapper. Used + by `EpcPropertyDataMapper.from_api_response` to populate + `SapRoomInRoof.detailed_surfaces` with `gable_wall_external` / + `flat_ceiling` entries the cascade's Detailed-RR branch consumes.""" + gable_wall_type_1: Optional[int] = None + gable_wall_type_2: Optional[int] = None + gable_wall_length_1: Optional[float] = None + gable_wall_length_2: Optional[float] = None + gable_wall_height_1: Optional[float] = None + gable_wall_height_2: Optional[float] = None + flat_ceiling_length_1: Optional[float] = None + flat_ceiling_height_1: Optional[float] = None + flat_ceiling_insulation_type_1: Optional[int] = None + flat_ceiling_insulation_thickness_1: Optional[str] = None + + @dataclass class SapRoomInRoof: floor_area: Union[int, float] construction_age_band: str + # Two real-API shapes coexist: older certs (cohort 6035, 0240, test + # fixture 21_0_1.json) lodge the Simplified Type 1 wrapper; newer + # certs (9501) lodge the Detailed-RR block. Accept both. room_in_roof_type_1: Optional[RoomInRoofType1] = None + room_in_roof_details: Optional[RoomInRoofDetails] = None @dataclass