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 fa8dbe6e..e01fba9d 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -691,6 +691,40 @@ _API_2225_JSON = ( / "2225-3062-8205-2856-7204.json" ) +_API_2636_JSON = ( + Path(__file__).parents[3] + / "domain/sap10_calculator/rdsap/tests/fixtures/golden" + / "2636-0525-2600-0401-2296.json" +) + + +def test_api_2636_cantilever_floor_surfaces_as_exposed_floor() -> None: + # Arrange — cert 2636 (Mitsubishi ASHP, semi-detached, 2 storeys, + # property_type=0) has BP0 floor 0 area 39.18 m² and floor 1 area + # 42.92 m². The 3.74 m² difference is an upper-floor cantilever — + # worksheet (28b) "Exposed floor Main: 3.74 × 1.20 = 4.4880" treats + # it per RdSAP Table 20 U_exposed_floor at age-D + no insulation + # = 1.20 W/m²K. + # + # Without the cantilever surfaced, cert 2636 cascade SAP = + # 86.7514 vs worksheet 86.2641 (Δ +0.49 — by far the largest + # outlier in the 7-cert ASHP cohort, where the other 6 cluster + # at ±0.06). Pre-fix HLC drift was -4.51 W/K = 3.74 × 1.20 + + # 0.15 × 3.74 thermal-bridging contribution on the extra exposed + # area. After cantilever wiring, SAP closes to within 1e-2. + doc = json.loads(_API_2636_JSON.read_text()) + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Act — full cert→inputs→calculator cascade + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + + # Assert — SAP within 1e-2 of worksheet 86.2641. + assert abs(result.sap_score_continuous - 86.2641) < 1e-2, ( + f"cascade SAP={result.sap_score_continuous:.4f} vs worksheet 86.2641" + ) + def test_api_2225_no_mixer_lodged_uses_zero_showers_per_worksheet() -> None: # Arrange — cert 2225 lodges `mixer_shower_count = None` (the field diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 07a330d5..717c5d7b 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -100,6 +100,18 @@ _AREA_ROUND_DP: Final[int] = 2 # inclined surface area (floor area divided by cos(30°)) rather than # the horizontal projection. _COS_30_DEG: Final[float] = cos(radians(30.0)) +# RdSAP "first floor over passageway" cantilever filters. Adjacent-storey +# area diff is treated as Exposed floor (Table 20 U) when both filters +# pass — cohort ground-truth: cert 2636 (3.74 m² / 9.5% of ground floor) +# lands worksheet (28b); larger ratios indicate flat-stairwell access +# (cert 9501 BP0: 987%) or sub-ground multi-storey shapes (cert 7536 +# BP0: 195%), neither of which the worksheet treats as cantilever. +_CANTILEVER_MIN_AREA_M2: Final[float] = 1.0 +_CANTILEVER_MAX_RATIO: Final[float] = 0.25 +# EPC API `property_type` strings that flag a dwelling as a house (not +# flat). Cantilever detection only fires for houses — flats with very +# small floor=0 areas (stairwell access) would otherwise over-count. +_PROPERTY_TYPE_HOUSE: Final[str] = "0" @dataclass(frozen=True) @@ -283,6 +295,33 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]: correction = 2.0 * ((gable_height - h_common) ** 2) / 2.0 area = max(0.0, area - correction) rr_gable_area += area + # RdSAP cantilever / "first floor over passageway" detection: when an + # upper storey has a larger area than the storey immediately below, + # the excess overhangs an unheated space (or external air) and routes + # through Table 20's U_exposed_floor (1.20 W/m²K for age-D + no + # insulation, the modal cohort lodging). Cohort ground-truth: cert + # 2636 BP0 floor 1 (42.92 m²) − floor 0 (39.18 m²) = 3.74 m² lands + # on worksheet (28b) "Exposed floor Main: 3.74 × 1.20 = 4.4880". + # + # Gated to avoid false positives: + # - Modest cantilever ratio (< 25% of ground-floor area) — excludes + # flat-stairwell shapes (cert 9501: 987%) and sub-ground-floor + # multi-storey shapes (cert 7536: 195%). + # - Minimum 1 m² to skip 2-dp rounding artefacts (cert 0535: 0.32). + cantilever_area = 0.0 + fds_by_floor = sorted(fds, key=lambda fd: fd.floor or 0) + for prev_idx in range(len(fds_by_floor) - 1): + prev_fd = fds_by_floor[prev_idx] + curr_fd = fds_by_floor[prev_idx + 1] + prev_area: float = prev_fd.total_floor_area_m2 or 0.0 + curr_area: float = curr_fd.total_floor_area_m2 or 0.0 + excess = max(0.0, curr_area - prev_area) + if ( + excess >= _CANTILEVER_MIN_AREA_M2 + and prev_area > 0.0 + and (excess / prev_area) < _CANTILEVER_MAX_RATIO + ): + cantilever_area += excess return { "ground_floor_area_m2": ground.total_floor_area_m2 or 0.0, "top_floor_area_m2": max(0.0, max_floor_area - rr_floor_area), @@ -292,6 +331,7 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]: "rr_simplified_a_rr_m2": rr_a_rr, "rr_common_wall_area_m2": rr_common_wall_area, "rr_gable_area_m2": rr_gable_area, + "cantilever_floor_area_m2": cantilever_area, } @@ -675,6 +715,21 @@ def heat_transmission_from_cert( rr_detailed_area += area walls += u_gable * area floor += uf * floor_area_total + # RdSAP "first floor over passageway" cantilever — only fires + # for houses (property_type=0); see `_part_geometry` filters. + # The cantilever floor uses the same Table 20 U as an explicit + # exposed floor (age × insulation thickness), and feeds (36) + # thermal bridges via its area on (31). + cantilever_area = ( + _round_half_up(geom.get("cantilever_floor_area_m2", 0.0), _AREA_ROUND_DP) + if epc.property_type == _PROPERTY_TYPE_HOUSE + else 0.0 + ) + if cantilever_area > 0: + u_cantilever = u_exposed_floor( + age_band=age_band, insulation_thickness_mm=floor_ins_thickness, + ) + floor += u_cantilever * cantilever_area party += upw * party_area # windows: total computed pre-loop (`windows_w_per_k_total`). # w_area still drives the net-wall opening subtraction below. @@ -684,10 +739,13 @@ def heat_transmission_from_cert( # party wall (party walls have their own line (32)) per RdSAP # §5.15: bridging applies to *exposed* area only. RR area joins # the external surfaces per the spec — A_RR contributes to (31) - # alongside walls + roof + floor + openings. + # alongside walls + roof + floor + openings. Cantilever contributes + # its area to (31) too (worksheet cert 2636 line 31 = 160.33 + # includes the 3.74 m² (28b) cantilever). part_external_area = ( main_wall_area + alt_walls_total_area + roof_area + floor_area_total + w_area + d_area + rw_area_part + rr_a_rr + rr_detailed_area + + cantilever_area ) total_external_area += part_external_area bridging += y * part_external_area