Slice 94: API mapper sheltered_sides + floor_type — cert 001479 to 1e-3

Two API mapper gaps surfacing the cert 001479 +1.18 SAP gap post
Slice 93:

(1) `SapVentilation.sheltered_sides` from API `built_form`

The API schema doesn't lodge sheltered_sides as a discrete field —
it's derived per RdSAP §S5 from the dwelling's built_form. The
cascade defaults to 2 when missing (right for Mid-Terrace) but wrong
for detached/semi/end-terrace. Cert 001479 (built_form=2 Semi-
Detached) needs 1 sheltered side; default 2 over-counted shelter
factor → line (21) under by 0.185 → ventilation under by ~2 ACH/yr.

New `_api_sheltered_sides` translator + `_API_BUILT_FORM_TO_
SHELTERED_SIDES` table (1=Detached/0, 2=Semi/1, 3=End-T/1, 4=Mid-T/2,
5=Encl-End/2, 6=Encl-Mid/3) — mirrors the cohort Elmhurst
`_ELMHURST_SHELTERED_SIDES_BY_BUILT_FORM` keyed by the API integer
enum.

(2) `SapBuildingPart.floor_type` from API `floor_heat_loss`

The Slice 87 spec rule for §2(12) suspended-timber-floor infiltration
(`_has_suspended_timber_floor_per_spec` in cert_to_inputs) requires
the Main bp's lowest floor to have `floor_type == "Ground floor"` to
apply the (12)=0.2/0.1 rule. The API mapper wasn't surfacing this
string (only floor_construction_type), so the spec rule short-
circuited to False even for genuine ground floors and the cascade's
line (12) was 0.0 instead of 0.2.

New `_api_floor_type_str` translator + `_API_FLOOR_HEAT_LOSS_TO_
FLOOR_TYPE` table (1="To external air" for cantilevered exposed
floors, 7="Ground floor"). Routes correctly for cert 001479: Main +
Ext1 carry floor_heat_loss=7 → both Ground floor; Ext2 carries
floor_heat_loss=1 → exposed (its is_exposed_floor=True already lifts
the floor U cascade to Table 20).

**Result on cert 001479 API path:**
  SAP delta: +1.18 → +0.0006 (essentially exact match at integer SAP)
  Cascade SAP=69.0100 vs worksheet 69.0094 — within 1e-3 of target.

The remaining ~0.001 SAP gap is dominated by:
  - hot_water_kwh_per_yr: +6.7 (API 2365.0 vs target 2358.3)
  - internal_gains Σ: +25.7 W·months (subtle gain-cascade differences)
  - solar_gains Σ: +1.5 W·months
Sub-1e-3 SAP impact each; would need slice-by-slice diagnosis to
close to the strict 1e-4 bar.

Layer 3 API-mapper-vs-Summary-mapper EpcPropertyData equivalence:
the API path now produces SAP within 0.001 of the Summary path
(Summary Layer 2 = 69.0094 EXACT). API integer SAP = 69 = worksheet
integer SAP = 69 ✓ — matches the API's published energy_rating_
current=69 (zero residual on the production goal metric).

Golden cert residuals: 8 of 10 expectations shifted by Slices 90-94
cascade improvements. Spec-compliance shifts; new residuals pinned.

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:27:10 +00:00
parent 7281b7b300
commit 0320341837
2 changed files with 86 additions and 23 deletions

View file

@ -1115,6 +1115,7 @@ class EpcPropertyDataMapper:
# "Suspended"/"Solid" branch selection) and Slice
# 89 (`roof_construction_type` containing "sloping
# ceiling" → cos(30°) inclined-surface area).
floor_type=_api_floor_type_str(bp.floor_heat_loss),
floor_construction_type=_api_floor_construction_str(
bp.sap_floor_dimensions[0].floor_construction
if bp.sap_floor_dimensions else None
@ -1328,6 +1329,7 @@ class EpcPropertyDataMapper:
# "Suspended"/"Solid" branch selection) and Slice
# 89 (`roof_construction_type` containing "sloping
# ceiling" → cos(30°) inclined-surface area).
floor_type=_api_floor_type_str(bp.floor_heat_loss),
floor_construction_type=_api_floor_construction_str(
bp.sap_floor_dimensions[0].floor_construction
if bp.sap_floor_dimensions else None
@ -1632,6 +1634,7 @@ class EpcPropertyDataMapper:
# "Suspended"/"Solid" branch selection) and Slice
# 89 (`roof_construction_type` containing "sloping
# ceiling" → cos(30°) inclined-surface area).
floor_type=_api_floor_type_str(bp.floor_heat_loss),
floor_construction_type=_api_floor_construction_str(
bp.sap_floor_dimensions[0].floor_construction
if bp.sap_floor_dimensions else None
@ -1793,6 +1796,15 @@ class EpcPropertyDataMapper:
if schema.has_draught_lobby is not None
else None
),
# `sheltered_sides` is derived per RdSAP §S5 from the
# dwelling's built_form rather than lodged explicitly
# in the API schema — the cascade defaults to 2 when
# missing, which is right for mid-terrace but wrong
# for detached/semi/end-terrace (cert 001479: built_
# form=2 Semi-Detached → 1 sheltered side, cascade
# default 2 → over-counted shelter factor → -2.42
# ACH/month infiltration shortfall).
sheltered_sides=_api_sheltered_sides(schema.built_form),
),
)
@ -2075,6 +2087,30 @@ def _api_floor_construction_str(value: Optional[int]) -> Optional[str]:
return _API_FLOOR_CONSTRUCTION_TO_STR.get(value) if value is not None else None
# GOV.UK API `floor_heat_loss` integer → `floor_type` string the
# RdSAP 10 §5 (12) spec rule reads in `_has_suspended_timber_floor_per
# _spec` (cert_to_inputs.py). The spec applies (12)=0.2/0.1 only when
# the Main bp's lowest floor is a "Ground floor" with "Suspended
# timber" construction, so the API mapper has to surface "Ground
# floor" as the floor_type — otherwise the spec rule short-circuits
# to False even when the cert is genuinely G+T (e.g. cert 001479
# Main, floor_heat_loss=7).
_API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE: Dict[int, str] = {
1: "To external air", # exposed (cantilevered / over passageway)
7: "Ground floor",
}
def _api_floor_type_str(floor_heat_loss: Optional[int]) -> Optional[str]:
"""Translate the API integer floor_heat_loss code to the
human-readable floor_type the RdSAP 10 §5 (12) spec rule consumes
(see `_has_suspended_timber_floor_per_spec`)."""
return (
_API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE.get(floor_heat_loss)
if floor_heat_loss is not None else None
)
def _api_roof_construction_str(value: Optional[int]) -> Optional[str]:
"""Translate the API integer roof_construction code to the
human-readable string the cascade reads via Slice 89's
@ -2092,6 +2128,33 @@ def _api_roof_construction_str(value: Optional[int]) -> Optional[str]:
_API_FLOOR_HEAT_LOSS_EXPOSED: Final[int] = 1
# GOV.UK API `built_form` integer → SAP10.2 sheltered_sides count per
# RdSAP §S5. Detached has no neighbours shielding wind; terraced
# variants pick up 1-3 sheltered sides via adjacent dwellings. Cross-
# checked against the cohort built_form enum at line 3003 (Elmhurst's
# string lookup) which itself matches U985 worksheet line (19) for the
# 6 cohort fixtures.
_API_BUILT_FORM_TO_SHELTERED_SIDES: Dict[int, int] = {
1: 0, # Detached
2: 1, # Semi-Detached
3: 1, # End-Terrace
4: 2, # Mid-Terrace
5: 2, # Enclosed End-Terrace
6: 3, # Enclosed Mid-Terrace
}
def _api_sheltered_sides(built_form: object) -> Optional[int]:
"""Translate the API `built_form` integer code to the SAP10.2 §S5
sheltered-sides count. Returns None when the form isn't recognised
so the cascade applies its own default (currently 2)."""
if isinstance(built_form, str) and built_form.isdigit():
built_form = int(built_form)
if not isinstance(built_form, int):
return None
return _API_BUILT_FORM_TO_SHELTERED_SIDES.get(built_form)
# GOV.UK API `glazing_type` integer → (u_value W/m²K, g_perpendicular,
# frame_factor) lookup the cascade reads via `window_transmission_
# details` for per-window cascade fidelity. The cascade defaults to a

View file

@ -74,9 +74,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="0240-0200-5706-2365-8010",
actual_sap=73,
expected_sap_resid=-13,
expected_pe_resid_kwh_per_m2=+10.4527,
expected_co2_resid_tonnes_per_yr=+0.5916,
expected_sap_resid=-14,
expected_pe_resid_kwh_per_m2=+14.6650,
expected_co2_resid_tonnes_per_yr=+0.8060,
notes=(
"Detached house, TFA 202, age J, oil boiler, Table 4b code 130. "
"API response lodges sap_room_in_roof.room_in_roof_type_1 with "
@ -94,9 +94,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="0300-2747-7640-2526-2135",
actual_sap=78,
expected_sap_resid=+2,
expected_pe_resid_kwh_per_m2=-0.2955,
expected_co2_resid_tonnes_per_yr=-0.9443,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=+1.0093,
expected_co2_resid_tonnes_per_yr=-0.8321,
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=-5,
expected_pe_resid_kwh_per_m2=-28.4884,
expected_co2_resid_tonnes_per_yr=-2.7466,
expected_sap_resid=-6,
expected_pe_resid_kwh_per_m2=-26.4584,
expected_co2_resid_tonnes_per_yr=-2.5618,
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=+39.1452,
expected_co2_resid_tonnes_per_yr=+0.8845,
expected_sap_resid=-6,
expected_pe_resid_kwh_per_m2=+48.2971,
expected_co2_resid_tonnes_per_yr=+1.1016,
notes=(
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
"Slice 59 per-bp window apportionment tightens all 3 "
@ -132,9 +132,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="7536-3827-0600-0600-0276",
actual_sap=68,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-8.8124,
expected_co2_resid_tonnes_per_yr=-0.2337,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-3.4482,
expected_co2_resid_tonnes_per_yr=-0.0907,
notes=(
"Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, "
"Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and "
@ -146,9 +146,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="8135-1728-8500-0511-3296",
actual_sap=72,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-10.0737,
expected_co2_resid_tonnes_per_yr=-0.1645,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-2.4194,
expected_co2_resid_tonnes_per_yr=-0.0198,
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=+2,
expected_pe_resid_kwh_per_m2=-43.5103,
expected_co2_resid_tonnes_per_yr=+0.2414,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-38.1521,
expected_co2_resid_tonnes_per_yr=+0.3047,
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=+0,
expected_pe_resid_kwh_per_m2=-1.9117,
expected_co2_resid_tonnes_per_yr=+0.0102,
expected_pe_resid_kwh_per_m2=+1.6837,
expected_co2_resid_tonnes_per_yr=+0.0637,
notes=(
"End-terrace + 1 extension, TFA 80, gas combi PCDB index 18119, "
"no PV, no secondary, postcode LN12 (PCDB Table 172 match). "