Slice 92: API mapper floor dimensions (SAP +0.25m + exposed-floor + NI→None)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-26 08:09:28 +00:00
parent 2cebba28dc
commit 8e752e5720
2 changed files with 151 additions and 93 deletions

View file

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

View file

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