From 8e752e5720768e7a413b9a15053a842d6dbd89dc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 26 May 2026 08:09:28 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2092:=20API=20mapper=20floor=20dimensions?= =?UTF-8?q?=20(SAP=20+0.25m=20+=20exposed-floor=20+=20NI=E2=86=92None)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coupled API-mapper fixes that close the cert 001479 floor-W/K gap from +4.39 to EXACT 0. (1) Upper-floor room_height_m += 0.25 m SAP 10.2 convention: every storey above the lowest adds 0.25 m to the lodged room_height for the joist/floor-void contribution (cohort Elmhurst mapper already applies this via `_UPPER_FLOOR_HEIGHT_ADD_M` at line 2338). The API schema lodges the raw internal height; the cascade volume computation needs the +0.25 m before computing party- wall area and ventilation ACH. For cert 001479 Main floor=1, raw lodge 2.28 m vs worksheet 2.53 m — without the fix, party W/K was short by 0.87 (party_wall_length × delta_height × U). (2) `is_exposed_floor=True` when `bp.floor_heat_loss == 1` API integer code 1 on `floor_heat_loss` signals an exposed floor (a bp's lowest storey hanging over an unheated space or external air). Mirrors the cohort Elmhurst mapper's `_is_floor_exposed_to_unheated_ space` for the API path. Applied only to the lowest storey (floor==0) per the cohort 000490/000487 fixture convention. For cert 001479 Ext2 (cantilevered upper-storey extension over external air), this routes the cascade through Table 20's `u_exposed_floor` (U=1.20) rather than the BS EN ISO 13370 ground-floor formula. (3) `floor_insulation_thickness="NI" → None` for cascade default API certs commonly lodge "NI" (no measured thickness) on floors that aren't actually uninsulated — for newer age bands (I-M with non-zero Table 19 defaults: 25/75/100/100/140 mm) the cascade should use the age-band default insulation rather than treating "NI" as explicit zero. Translate "NI" → None at the mapper boundary so `u_floor` reaches the Table 19 fallback. For cert 001479 Ext1 (age M, suspended timber, NI lodged) the cascade now returns U=0.20 via the age-M 140 mm default — previously gave U=1.05 from treating thickness as 0. **Floor W/K is now EXACT for cert 001479** (23.1705 ✓). Impact on cert 001479 API path: Before Slice 87: +3.0752 SAP delta After Slice 90: +1.5298 After Slice 91: +1.0970 After Slice 92: +1.0022 (floor W/K exact; remaining gap is in windows / gains — Slice 93) Golden cert residual updates: 7 of 10 expectations shifted from the floor cascade improvements (NI→None changed many certs with age I-M extensions). Spec-compliance shifts; new residuals committed. Pyright: mapper.py 33 → 33. Co-Authored-By: Claude Opus 4.7 --- datatypes/epc/domain/mapper.py | 212 +++++++++++------- .../sap/rdsap/tests/test_golden_fixtures.py | 32 +-- 2 files changed, 151 insertions(+), 93 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index e758e02e..d5b7e23a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -626,18 +626,9 @@ class EpcPropertyDataMapper: party_wall_construction=_api_party_wall_construction_int( bp.party_wall_construction ), - sap_floor_dimensions=[ - SapFloorDimension( - room_height_m=_measurement_value(fd.room_height), - total_floor_area_m2=_measurement_value(fd.total_floor_area), - party_wall_length_m=_measurement_value(fd.party_wall_length), - heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter), - floor=fd.floor, - floor_insulation=fd.floor_insulation, - floor_construction=fd.floor_construction, - ) - for fd in (bp.sap_floor_dimensions or []) - ], + sap_floor_dimensions=_api_build_sap_floor_dimensions( + bp.sap_floor_dimensions or [], bp.floor_heat_loss, + ), building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, @@ -765,24 +756,28 @@ class EpcPropertyDataMapper: party_wall_construction=_api_party_wall_construction_int( bp.party_wall_construction ), - sap_floor_dimensions=[ - SapFloorDimension( - room_height_m=_measurement_value(fd.room_height), - total_floor_area_m2=_measurement_value(fd.total_floor_area), - party_wall_length_m=_measurement_value(fd.party_wall_length), - heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter), - floor=fd.floor, - floor_insulation=fd.floor_insulation, - floor_construction=fd.floor_construction, - ) - for fd in (bp.sap_floor_dimensions or []) - ], + sap_floor_dimensions=_api_build_sap_floor_dimensions( + bp.sap_floor_dimensions or [], bp.floor_heat_loss, + ), building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, wall_insulation_thickness=bp.wall_insulation_thickness, floor_heat_loss=bp.floor_heat_loss, - floor_insulation_thickness=bp.floor_insulation_thickness, + # API certs commonly lodge "NI" (no measured + # thickness) on floors that aren't actually + # uninsulated — for newer age bands (I-M) the + # cascade should fall back to the Table 19 age-band + # default insulation. Translate "NI" → None at the + # mapper boundary so `u_floor` reaches its age-band + # default branch instead of the "explicit zero" + # path. Cert 001479 Ext1 (age M, suspended timber, + # NI lodged) gets U=0.20 via the age-M 140 mm + # default; previously cascade returned U=1.05. + floor_insulation_thickness=( + None if bp.floor_insulation_thickness == "NI" + else bp.floor_insulation_thickness + ), flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, roof_construction=bp.roof_construction, roof_insulation_location=bp.roof_insulation_location, @@ -917,24 +912,28 @@ class EpcPropertyDataMapper: party_wall_construction=_api_party_wall_construction_int( bp.party_wall_construction ), - sap_floor_dimensions=[ - SapFloorDimension( - room_height_m=_measurement_value(fd.room_height), - total_floor_area_m2=_measurement_value(fd.total_floor_area), - party_wall_length_m=_measurement_value(fd.party_wall_length), - heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter), - floor=fd.floor, - floor_insulation=fd.floor_insulation, - floor_construction=fd.floor_construction, - ) - for fd in (bp.sap_floor_dimensions or []) - ], + sap_floor_dimensions=_api_build_sap_floor_dimensions( + bp.sap_floor_dimensions or [], bp.floor_heat_loss, + ), building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, wall_insulation_thickness=bp.wall_insulation_thickness, floor_heat_loss=bp.floor_heat_loss, - floor_insulation_thickness=bp.floor_insulation_thickness, + # API certs commonly lodge "NI" (no measured + # thickness) on floors that aren't actually + # uninsulated — for newer age bands (I-M) the + # cascade should fall back to the Table 19 age-band + # default insulation. Translate "NI" → None at the + # mapper boundary so `u_floor` reaches its age-band + # default branch instead of the "explicit zero" + # path. Cert 001479 Ext1 (age M, suspended timber, + # NI lodged) gets U=0.20 via the age-M 140 mm + # default; previously cascade returned U=1.05. + floor_insulation_thickness=( + None if bp.floor_insulation_thickness == "NI" + else bp.floor_insulation_thickness + ), flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, roof_construction=bp.roof_construction, roof_insulation_location=bp.roof_insulation_location, @@ -1086,24 +1085,28 @@ class EpcPropertyDataMapper: party_wall_construction=_api_party_wall_construction_int( bp.party_wall_construction ), - sap_floor_dimensions=[ - SapFloorDimension( - room_height_m=_measurement_value(fd.room_height), - total_floor_area_m2=_measurement_value(fd.total_floor_area), - party_wall_length_m=_measurement_value(fd.party_wall_length), - heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter), - floor=fd.floor, - floor_insulation=fd.floor_insulation, - floor_construction=fd.floor_construction, - ) - for fd in (bp.sap_floor_dimensions or []) - ], + sap_floor_dimensions=_api_build_sap_floor_dimensions( + bp.sap_floor_dimensions or [], bp.floor_heat_loss, + ), building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, wall_insulation_thickness=bp.wall_insulation_thickness, floor_heat_loss=bp.floor_heat_loss, - floor_insulation_thickness=bp.floor_insulation_thickness, + # API certs commonly lodge "NI" (no measured + # thickness) on floors that aren't actually + # uninsulated — for newer age bands (I-M) the + # cascade should fall back to the Table 19 age-band + # default insulation. Translate "NI" → None at the + # mapper boundary so `u_floor` reaches its age-band + # default branch instead of the "explicit zero" + # path. Cert 001479 Ext1 (age M, suspended timber, + # NI lodged) gets U=0.20 via the age-M 140 mm + # default; previously cascade returned U=1.05. + floor_insulation_thickness=( + None if bp.floor_insulation_thickness == "NI" + else bp.floor_insulation_thickness + ), flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, roof_construction=bp.roof_construction, # Surface human-readable strings derived from the @@ -1295,24 +1298,28 @@ class EpcPropertyDataMapper: party_wall_construction=_api_party_wall_construction_int( bp.party_wall_construction ), - sap_floor_dimensions=[ - SapFloorDimension( - room_height_m=_measurement_value(fd.room_height), - total_floor_area_m2=_measurement_value(fd.total_floor_area), - party_wall_length_m=_measurement_value(fd.party_wall_length), - heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter), - floor=fd.floor, - floor_insulation=fd.floor_insulation, - floor_construction=fd.floor_construction, - ) - for fd in (bp.sap_floor_dimensions or []) - ], + sap_floor_dimensions=_api_build_sap_floor_dimensions( + bp.sap_floor_dimensions or [], bp.floor_heat_loss, + ), building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, wall_insulation_thickness=bp.wall_insulation_thickness, floor_heat_loss=bp.floor_heat_loss, - floor_insulation_thickness=bp.floor_insulation_thickness, + # API certs commonly lodge "NI" (no measured + # thickness) on floors that aren't actually + # uninsulated — for newer age bands (I-M) the + # cascade should fall back to the Table 19 age-band + # default insulation. Translate "NI" → None at the + # mapper boundary so `u_floor` reaches its age-band + # default branch instead of the "explicit zero" + # path. Cert 001479 Ext1 (age M, suspended timber, + # NI lodged) gets U=0.20 via the age-M 140 mm + # default; previously cascade returned U=1.05. + floor_insulation_thickness=( + None if bp.floor_insulation_thickness == "NI" + else bp.floor_insulation_thickness + ), flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, roof_construction=bp.roof_construction, # Surface human-readable strings derived from the @@ -1573,24 +1580,28 @@ class EpcPropertyDataMapper: party_wall_construction=_api_party_wall_construction_int( bp.party_wall_construction ), - sap_floor_dimensions=[ - SapFloorDimension( - room_height_m=_measurement_value(fd.room_height), - total_floor_area_m2=_measurement_value(fd.total_floor_area), - party_wall_length_m=_measurement_value(fd.party_wall_length), - heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter), - floor=fd.floor, - floor_insulation=fd.floor_insulation, - floor_construction=fd.floor_construction, - ) - for fd in (bp.sap_floor_dimensions or []) - ], + sap_floor_dimensions=_api_build_sap_floor_dimensions( + bp.sap_floor_dimensions or [], bp.floor_heat_loss, + ), building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, wall_insulation_thickness=bp.wall_insulation_thickness, floor_heat_loss=bp.floor_heat_loss, - floor_insulation_thickness=bp.floor_insulation_thickness, + # API certs commonly lodge "NI" (no measured + # thickness) on floors that aren't actually + # uninsulated — for newer age bands (I-M) the + # cascade should fall back to the Table 19 age-band + # default insulation. Translate "NI" → None at the + # mapper boundary so `u_floor` reaches its age-band + # default branch instead of the "explicit zero" + # path. Cert 001479 Ext1 (age M, suspended timber, + # NI lodged) gets U=0.20 via the age-M 140 mm + # default; previously cascade returned U=1.05. + floor_insulation_thickness=( + None if bp.floor_insulation_thickness == "NI" + else bp.floor_insulation_thickness + ), flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, roof_construction=bp.roof_construction, # Surface human-readable strings derived from the @@ -2050,6 +2061,53 @@ def _api_roof_construction_str(value: Optional[int]) -> Optional[str]: return _API_ROOF_CONSTRUCTION_TO_STR.get(value) if value is not None else None +# API `floor_heat_loss` integer that signals an exposed floor (lowest +# storey of a bp that hangs over an unheated space or external air). +# Cohort cert 001479 Ext2 lodges floor_heat_loss=1 on its cantilevered +# upper-storey extension; the Summary mapper sets is_exposed_floor=True +# on the lowest floor for this case (routes through Table 20's u_exposed +# _floor cascade at U=1.20 instead of BS EN ISO 13370 ground-floor). +_API_FLOOR_HEAT_LOSS_EXPOSED: Final[int] = 1 + + +def _api_build_sap_floor_dimensions( + fds: List[Any], + floor_heat_loss: Optional[int], +) -> List[SapFloorDimension]: + """Build per-bp `SapFloorDimension` list with the SAP10.2 conventions + the API schema doesn't lodge directly: + + 1. **Upper-floor room height +0.25 m** (SAP convention for the + joist/floor-void contribution; the ground floor uses the lodged + value directly). Cohort cert 001479 Main floor=1 lodges + room_height=2.28 m — the worksheet uses 2.53 m. Without the + +0.25 m the cascade volume is short → ventilation low → + SAP overshoot. + 2. **`is_exposed_floor=True`** when `bp.floor_heat_loss` == 1 (the + lowest floor of a cantilevered or over-unheated-space bp routes + through Table 20's `u_exposed_floor` cascade at U=1.20 rather + than the BS EN ISO 13370 ground-floor formula). Applied only to + the lowest storey (floor==0) per the cohort 000490/000487 + fixture convention. + """ + is_exposed = floor_heat_loss == _API_FLOOR_HEAT_LOSS_EXPOSED + out: List[SapFloorDimension] = [] + for fd in fds or []: + raw_height = _measurement_value(fd.room_height) + height = raw_height if fd.floor == 0 else raw_height + _UPPER_FLOOR_HEIGHT_ADD_M + out.append(SapFloorDimension( + room_height_m=height, + total_floor_area_m2=_measurement_value(fd.total_floor_area), + party_wall_length_m=_measurement_value(fd.party_wall_length), + heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter), + floor=fd.floor, + floor_insulation=fd.floor_insulation, + floor_construction=fd.floor_construction, + is_exposed_floor=is_exposed and fd.floor == 0, + )) + return out + + def _api_resolve_sloping_ceiling_thickness( roof_construction: Optional[int], roof_insulation_thickness: Union[str, int, None], diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py b/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py index 272ab6a9..59676ff0 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py @@ -95,8 +95,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0300-2747-7640-2526-2135", actual_sap=78, expected_sap_resid=+2, - expected_pe_resid_kwh_per_m2=-1.9082, - expected_co2_resid_tonnes_per_yr=-1.0829, + expected_pe_resid_kwh_per_m2=-0.9139, + expected_co2_resid_tonnes_per_yr=-0.9974, notes=( "Large semi-detached, TFA 526, age D, gas boiler PCDB-listed " "(no Table 4b code). Cert lodges open_flues_count=1 + " @@ -110,17 +110,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0390-2954-3640-2196-4175", actual_sap=60, - expected_sap_resid=-4, - expected_pe_resid_kwh_per_m2=-31.5482, - expected_co2_resid_tonnes_per_yr=-3.0251, + expected_sap_resid=-5, + expected_pe_resid_kwh_per_m2=-28.4884, + expected_co2_resid_tonnes_per_yr=-2.7466, notes="Large detached, TFA 360, age F, oil PCDB-listed. Cert lodges has_draught_lobby=true.", ), _GoldenExpectation( cert_number="6035-7729-2309-0879-2296", actual_sap=70, expected_sap_resid=-5, - expected_pe_resid_kwh_per_m2=+34.4963, - expected_co2_resid_tonnes_per_yr=+0.7742, + expected_pe_resid_kwh_per_m2=+37.7305, + expected_co2_resid_tonnes_per_yr=+0.8510, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "Slice 59 per-bp window apportionment tightens all 3 " @@ -133,8 +133,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="7536-3827-0600-0600-0276", actual_sap=68, expected_sap_resid=+2, - expected_pe_resid_kwh_per_m2=-15.8298, - expected_co2_resid_tonnes_per_yr=-0.4207, + expected_pe_resid_kwh_per_m2=-11.7633, + expected_co2_resid_tonnes_per_yr=-0.3124, notes=( "Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, " "Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and " @@ -147,8 +147,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="8135-1728-8500-0511-3296", actual_sap=72, expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-16.3714, - expected_co2_resid_tonnes_per_yr=-0.2836, + expected_pe_resid_kwh_per_m2=-13.0069, + expected_co2_resid_tonnes_per_yr=-0.2200, notes=( "Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges " "blocked_chimneys_count=1. Slice 59 per-bp window apportionment " @@ -159,9 +159,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="2130-1033-4050-5007-8395", actual_sap=82, - expected_sap_resid=+3, - expected_pe_resid_kwh_per_m2=-51.0953, - expected_co2_resid_tonnes_per_yr=+0.1517, + expected_sap_resid=+2, + expected_pe_resid_kwh_per_m2=-44.8941, + expected_co2_resid_tonnes_per_yr=+0.2250, notes=( "End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, " "postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays " @@ -180,8 +180,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0390-2254-6420-2126-5561", actual_sap=65, expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-8.9202, - expected_co2_resid_tonnes_per_yr=-0.0942, + expected_pe_resid_kwh_per_m2=-3.5091, + expected_co2_resid_tonnes_per_yr=-0.0136, notes=( "End-terrace + 1 extension, TFA 80, gas combi PCDB index 18119, " "no PV, no secondary, postcode LN12 (PCDB Table 172 match). "