Slice S0380.93: floor above partially-heated space U=0.7 (RdSAP 10 §5.14)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 13:53:28 +00:00
parent a7894b1185
commit 23aaa4fa66
6 changed files with 101 additions and 3 deletions

View file

@ -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

View file

@ -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)

View file

@ -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]] = [

View file

@ -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

View file

@ -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

View file

@ -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.