From 23aaa4fa66954abc1e3f0ebaf8aba4b264433c80 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 13:53:28 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.93:=20floor=20above=20partially-h?= =?UTF-8?q?eated=20space=20U=3D0.7=20(RdSAP=2010=20=C2=A75.14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 §5.14 (PDF p.47) "U-value of floor above a partially heated space": > "The U-value of a floor above partially heated premises is taken as > 0.7 W/m²K. This applies typically for a flat above non-domestic > premises that are not heated to the same extent or duration as the > flat." Cert 000565 Ext1 lodges Summary §9 "Location: P Above partially heated space" + "Default U-value: 0.70". Worksheet line (28b) confirms "Exposed floor Ext1 ... 34.0000 0.7000 23.8000". Pre-slice the cascade routed BP[1] floor through the BS EN ISO 13370 ground-floor formula (the "else" branch of the floor U-value dispatch in `heat_transmission.py`) — producing cascade U=0.76 vs spec 0.70. Over-counted floor heat loss by (0.76 − 0.70) × 34 m² = +2.04 W/K on the part subtotal and on the total HTC. Slice span (4 layers): 1. **Helper** — `u_floor_above_partially_heated_space()` in `domain/sap10_ml/rdsap_uvalues.py`, verbatim spec constant 0.7 (no age-band / insulation-thickness inputs). Lives in `sap10_ml` per [[project-sap10_ml-deprecation]] (edit existing file fine). 2. **Schema** — `SapFloorDimension.is_above_partially_heated_space: bool = False` (parallel to existing `is_exposed_floor`). Mutually exclusive with the exposed-floor / basement-floor branches. 3. **Mapper** — new `_is_floor_above_partially_heated_space(location)` helper detecting "above partially heated" in the Elmhurst §9 floor location string. Plumbed into `_map_elmhurst_building_part` floor- dim construction; only applies to the ground floor (i==0). 4. **Cascade** — `heat_transmission.py` adds a new branch between the exposed-floor and ground-floor branches: `is_above_partial → u_floor_above_partially_heated_space()`. Cert 000565 movement (HEAD `a7894b11` → this slice): - cascade floor_w_per_k: 72.41 → 70.37 (Δ +10.74 → Δ +8.70) - cascade BP[1] floor U: 0.76 → 0.70 (✓ EXACT vs ws 0.70) - sap_score (integer): 29 ✓ EXACT (unchanged — at goal) - sap_score_continuous: 28.7663 → 28.8131 (+0.0468 drift) - space_heating_kwh: −367 → −427 (small drift further under) - main_heating_fuel: −216 → −251 (downstream of SH) - co2_kg_per_yr: −32 → −37 - total_fuel_cost_gbp: −23 → −27 - hot_water_kwh: ✓ 0 EXACT unchanged The small continuous-SAP drift is the expected arithmetic of closing a single component when adjacent components remain unclosed (floor +10.74 was cancelling thermal_bridging −11.76 + roof −7.94 at the net-HTC level). Per [[feedback-zero-error-strict]] + [[feedback- spec-citation-in-commits]] the spec-correct slice ships regardless of transient continuous-SAP drift; remaining residual components (floor +8.70 from BP[2] Ext2 lodged 200 mm insulation thickness; roof −7.94; thermal_bridging −11.76; walls −1.67) each get their own spec-cited slice. Cohort safety: only cert 000565 Ext1 in the cohort lodges "Above partially heated space". All other Elmhurst cohort fixtures + 9 golden + 38 cohort-2 API certs default to `is_above_partially_ heated_space=False` so cascade behaviour is unchanged. Test baseline: 583 pass + 8 expected `000565` fails (was 582 + 8; +1 new mapper-chain test). Pyright net-zero per touched file (1/65/1/32/13/13 preserved). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 26 +++++++++++++++++++ datatypes/epc/domain/epc_property_data.py | 6 +++++ datatypes/epc/domain/mapper.py | 23 ++++++++++++++-- .../worksheet/heat_transmission.py | 11 +++++++- domain/sap10_ml/rdsap_uvalues.py | 19 ++++++++++++++ domain/sap10_ml/tests/test_rdsap_uvalues.py | 19 ++++++++++++++ 6 files changed, 101 insertions(+), 3 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 36816402..a2422e01 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1650,6 +1650,32 @@ def test_summary_000565_section_12_1_extracts_mechanical_extract_decentralised_m ) +def test_summary_000565_ext1_floor_above_partially_heated_routes_to_u_value_0p7_per_rdsap_10_section_5_14() -> None: + # Arrange — RdSAP 10 §5.14 (PDF p.47) "U-value of floor above a + # partially heated space": + # "The U-value of a floor above partially heated premises is taken + # as 0.7 W/m²K. This applies typically for a flat above non- + # domestic premises that are not heated to the same extent or + # duration as the flat." + # Cert 000565 Summary §9 1st Extension lodges "Location: P Above + # partially heated space" + "Default U-value: 0.70". Pre-slice the + # cascade routed BP[1] floor through the BS EN ISO 13370 ground- + # floor formula → cascade U=0.76 (vs spec 0.70, over by +2.04 W/K + # × 34 m²). The mapper now flags `is_above_partially_heated_space= + # True` on the ground SapFloorDimension so `heat_transmission` + # dispatches to the §5.14 constant. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + ext1_ground = epc.sap_building_parts[1].sap_floor_dimensions[0] + assert ext1_ground.floor == 0 + assert ext1_ground.is_above_partially_heated_space is True + + def test_summary_000565_mev_decentralised_routes_to_extract_or_piv_outside_mv_kind() -> None: # Arrange — mapper plumbing for SAP 10.2 §2 (23a)/(24c) MEV: the # Elmhurst "Mechanical extract, decentralised (MEV dc)" string maps diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 08935fad..eb7c228e 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -306,6 +306,12 @@ class SapFloorDimension: # first storey upward. False means a ground floor (on soil), the # default path through the BS EN ISO 13370 / Table 19 cascade. is_exposed_floor: bool = False + # RdSAP 10 §5.14 (PDF p.47): True when this floor sits above non- + # domestic premises heated to a lesser extent / duration. Routes to + # the constant U=0.7 W/m²K instead of Table 19/20 or §5.13. First + # surfaced on cert 000565 Ext1 (Summary §9 "P Above partially + # heated space" + Default U-value 0.70). + is_above_partially_heated_space: bool = False @dataclass(frozen=True) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 1641362d..d00a2f83 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2864,6 +2864,19 @@ def _is_floor_exposed_to_unheated_space(location: Optional[str]) -> bool: return "above unheated" in lower or "external air" in lower +def _is_floor_above_partially_heated_space(location: Optional[str]) -> bool: + """True when the lodged Elmhurst §9 floor location is "P Above + partially heated space". Routes the cascade to the RdSAP 10 §5.14 + (PDF p.47) constant U=0.7 W/m²K — distinct from `_is_floor_ + exposed_to_unheated_space` (Table 20 fully-unheated below) and from + the ground-floor default (BS EN ISO 13370). First surfaced on cert + 000565 Ext1 (Summary §9 "P Above partially heated space"; worksheet + line (28b) "Exposed floor Ext1 ... 0.7000").""" + if location is None: + return False + return "above partially heated" in location.lower() + + def _extract_age_band(age_range: str) -> str: """Return the letter code from a site-notes age range, e.g. 'I: 1996 - 2002' → 'I'.""" return age_range.split(":")[0].strip() @@ -3065,15 +3078,20 @@ def _map_elmhurst_building_part( key=lambda f: (0 if _is_lowest(f.name) else 1, f.name), ) floor_is_exposed = _is_floor_exposed_to_unheated_space(floor.location) + floor_is_above_partial = _is_floor_above_partially_heated_space(floor.location) floor_dims: List[SapFloorDimension] = [] for i, f in enumerate(ordered): # SAP convention adds 0.25 m to non-ground room heights for the # joist/floor-void contribution; the ground floor uses the # lodged value directly. height = f.room_height_m if i == 0 else f.room_height_m + _UPPER_FLOOR_HEIGHT_ADD_M - # `is_exposed_floor` only applies to the ground floor of a bp - # sitting above unheated space (e.g. an extension over a porch). + # `is_exposed_floor` / `is_above_partially_heated_space` only + # apply to the ground floor of a bp sitting above unheated / + # partially-heated space (e.g. an extension over a non-domestic + # premises). Mutually exclusive with each other and with the + # ground-floor BS EN ISO 13370 cascade. is_exposed = floor_is_exposed and i == 0 + is_above_partial = floor_is_above_partial and i == 0 floor_dims.append( SapFloorDimension( room_height_m=height, @@ -3082,6 +3100,7 @@ def _map_elmhurst_building_part( heat_loss_perimeter_m=f.heat_loss_perimeter_m, floor=i, is_exposed_floor=is_exposed, + is_above_partially_heated_space=is_above_partial, ) ) alt_walls: List[Optional[SapAlternativeWall]] = [ diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index ced751dc..fc3f391e 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -61,6 +61,7 @@ from domain.sap10_ml.rdsap_uvalues import ( u_door, u_exposed_floor, u_floor, + u_floor_above_partially_heated_space, u_party_wall, u_roof, u_rr_default_all_elements, @@ -680,14 +681,22 @@ def heat_transmission_from_cert( # geometry input. Set on the ground SapFloorDimension when # the part hangs off the main from the first storey upward # (e.g. 000490 Extension 1). - # 3. Ground floor — BS EN ISO 13370 / Table 19 cascade. + # 3. Above partially heated space — RdSAP 10 §5.14 constant + # U=0.7 W/m²K (e.g. cert 000565 Ext1 above non-domestic + # premises). + # 4. Ground floor — BS EN ISO 13370 / Table 19 cascade. is_exposed_floor = bool(ground_fd is not None and ground_fd.is_exposed_floor) + is_above_partial = bool( + ground_fd is not None and ground_fd.is_above_partially_heated_space + ) if part.has_basement: uf = u_basement_floor(age_band) elif is_exposed_floor: uf = u_exposed_floor( age_band=age_band, insulation_thickness_mm=floor_ins_thickness ) + elif is_above_partial: + uf = u_floor_above_partially_heated_space() else: # The per-bp `floor_construction_type` lodgement ("Suspended # timber" / "Solid") takes precedence over the global diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 38973a0b..14262675 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -1074,6 +1074,25 @@ _BASEMENT_FLOOR_BY_BAND: Final[dict[str, float]] = { } +# RdSAP 10 §5.14 (PDF p.47) — "U-value of floor above a partially +# heated space": +# "The U-value of a floor above partially heated premises is taken +# as 0.7 W/m²K. This applies typically for a flat above non- +# domestic premises that are not heated to the same extent or +# duration as the flat." +# Verbatim constant — no age band / insulation thickness inputs. +# Distinct from `u_exposed_floor` (Table 20 for unheated below) and +# from `u_floor` (BS EN ISO 13370 ground-floor formula). +_PARTIALLY_HEATED_FLOOR_U_W_PER_M2K: Final[float] = 0.7 + + +def u_floor_above_partially_heated_space() -> float: + """RdSAP 10 §5.14 (PDF p.47) — U-value (W/m²K) of a floor above a + partially heated premises. Verbatim 0.7 W/m²K from the spec; no + geometry / age / insulation inputs.""" + return _PARTIALLY_HEATED_FLOOR_U_W_PER_M2K + + 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 diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 1d36ca15..7619ae77 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -35,6 +35,7 @@ from domain.sap10_ml.rdsap_uvalues import ( u_door, u_exposed_floor, u_floor, + u_floor_above_partially_heated_space, u_party_wall, u_roof, u_rr_default_all_elements, @@ -1127,6 +1128,24 @@ def test_u_exposed_floor_age_b_unknown_insulation_uses_table_20_row_a_to_g() -> assert result == pytest.approx(1.20, abs=0.001) +def test_u_floor_above_partially_heated_space_returns_0p7_per_rdsap_10_section_5_14() -> None: + # Arrange — RdSAP 10 §5.14 (PDF p.47) "U-value of floor above a + # partially heated space": + # "The U-value of a floor above partially heated premises is + # taken as 0.7 W/m²K. This applies typically for a flat above + # non-domestic premises that are not heated to the same extent + # or duration as the flat." + # Verbatim constant — no age-band or insulation-thickness inputs. + # Cert 000565 Ext1 (Summary §9: "P Above partially heated space", + # Default U-value 0.70) exercises this branch. + + # Act + result = u_floor_above_partially_heated_space() + + # Assert + assert abs(result - 0.7) <= 1e-4 + + def test_u_floor_falls_back_to_mid_range_when_geometry_unknown() -> None: # Arrange — geometry missing.