fix(mapper): map dropped §6.1 non-separated conservatory (API)

The gov API lodges a NON-SEPARATED conservatory (conservatory_type=4) as a
glazed "building part" carrying only {floor_area, room_height,
double_glazed, glazed_perimeter} — no fabric, no floor dimensions. The
four fields were undeclared on the 21.0.1 SapBuildingPart, so `from_dict`
dropped them and the conservatory was silently lost: it billed no §6.1
window/rooflight/floor and added nothing to TFA (5 corpus certs over-rated
— too little heat loss → SAP too high).

Fix (21.0.1 schema + mapper):
- declare the four glazed fields on `SapBuildingPart`;
- `_api_sap_conservatory` builds `EpcPropertyData.sap_conservatory` from
  the glazed BP (identified by a lodged `glazed_perimeter`; only type-4
  conservatories lodge it — separated ones, §6.2, lodge nothing);
- exclude the glazed BP from the fabric building-part loop (it is billed
  by the §6.1 cascade, not as a dwelling part);
- `_total_floor_area_from_building_parts` adds the conservatory floor area
  to TFA (drives occupancy → §4/§5 demand).

Validation is cross-mapper parity, NOT a corpus back-solve: the API mapper
feeds the SAME worksheet-validated §6.1 cascade (`conservatory_geometry`,
pinned to 1e-4 against the case-44 Summary) as the Elmhurst path — so the
API conservatory fabric is correct by construction. `from_api_response`
on an injected type-4 cert reproduces the glazed wall (perimeter × ground-
floor room height = 22.05), glazed roof (floor/cos20 = 12.77) and Table 25
double U_eff (2.758 wall / 2.993 roof); a separated (type 2/3) cert lodges
no glazed BP → disregarded per §6.2.

Gauges: corpus within-0.5 67.9% → 68.6% (MAE 0.959 → 0.942; floor 0.67→0.68,
ceiling 0.97→0.95); /tmp eval mean|err| 0.822 → 0.817. Harness 47/47
0-raised; regression = the 3 pre-existing fails; pyright net-zero (65=65).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-16 23:37:25 +00:00
parent 2a4d67e396
commit d501535cbc
4 changed files with 141 additions and 2 deletions

View file

@ -2091,8 +2091,14 @@ class EpcPropertyDataMapper:
else None
),
)
# RdSAP 10 §6.1 — exclude the glazed conservatory BP from the
# fabric loop; it is carried as `sap_conservatory` below and
# billed by the §6.1 cascade (window/rooflight/floor), not as
# a dwelling building part.
for bp in schema.sap_building_parts
if getattr(bp, "glazed_perimeter", None) is None
],
sap_conservatory=_api_sap_conservatory(schema.sap_building_parts),
renewable_heat_incentive=RenewableHeatIncentive(
space_heating_kwh=float(
schema.renewable_heat_incentive.space_heating_existing_dwelling
@ -2426,6 +2432,33 @@ def _measurement_value(field: Any) -> float:
return float(field)
def _api_sap_conservatory(building_parts: Any) -> Optional[SapConservatory]:
"""Build the domain `SapConservatory` from the gov-API glazed
conservatory building part the part the API uses for a NON-SEPARATED
conservatory (RdSAP 10 §6.1, PDF p.49), identified by a lodged
`glazed_perimeter` (real dwelling parts carry fabric + floor dimensions
instead, never `glazed_perimeter`). Only type-4 (non-separated)
conservatories lodge this BP; separated ones (§6.2) lodge nothing, so
its presence is the §6.1 signal. Mirror of `_map_elmhurst_conservatory`
for the API path proven equivalent by cross-mapper parity (the cascade
reads `epc.sap_conservatory` identically). Returns None when absent."""
if not building_parts:
return None
for bp in building_parts:
if getattr(bp, "glazed_perimeter", None) is None:
continue
return SapConservatory(
floor_area_m2=_measurement_value(bp.floor_area),
glazed_perimeter_m=_measurement_value(bp.glazed_perimeter),
double_glazed=bp.double_glazed == "Y",
# The gov API only lodges this glazed BP for NON-separated
# (type-4) conservatories; separated ones (§6.2) lodge no BP.
thermally_separated=False,
room_height_storeys=float(_measurement_value(bp.room_height)),
)
return None
def _total_floor_area_from_building_parts(building_parts: Any) -> Optional[float]:
"""Sum per-bp `sap_floor_dimensions[*].total_floor_area` (plus each bp's
`sap_room_in_roof.floor_area` when present) to recover the precise
@ -2450,6 +2483,13 @@ def _total_floor_area_from_building_parts(building_parts: Any) -> Optional[float
total = 0.0
found = False
for bp in building_parts:
# RdSAP 10 §6.1 — a non-separated conservatory's glazed BP (no floor
# dimensions) adds its floor area to the dwelling TFA. TFA drives
# occupancy → §4/§5 demand, so the conservatory must be in the sum.
if getattr(bp, "glazed_perimeter", None) is not None:
total += _measurement_value(bp.floor_area)
found = True
continue
floor_dims: Any = bp.sap_floor_dimensions or []
for fd in floor_dims:
total += _measurement_value(fd.total_floor_area)

View file

@ -2272,3 +2272,88 @@ class TestRoomInRoofType2SimplifiedQuadratic:
assert len(party) == 1 and abs(party[0].area_m2 - 12.50) <= 1e-9
assert len(commons) == 2
assert abs(commons[0].area_m2 - 10.00) <= 1e-9
class TestNonSeparatedConservatoryApiMirror:
"""RdSAP 10 §6.1 (PDF p.49) — the gov API lodges a NON-SEPARATED
conservatory (conservatory_type=4) as a glazed "building part" carrying
only {floor_area, room_height, double_glazed, glazed_perimeter}. The
block was undeclared `from_dict` dropped it the conservatory was
silently lost (5 corpus certs over-rating). The mapper now splits it
into `EpcPropertyData.sap_conservatory`, excludes it from the fabric
building-part loop, and adds its floor area to TFA.
Validation is cross-mapper parity, NOT a corpus back-solve: the API
mapper feeds the SAME worksheet-validated §6.1 cascade
(`conservatory_geometry`, pinned to 1e-4 against the case-44 Summary)
as the Elmhurst path so the API conservatory fabric is correct by
construction."""
def test_from_api_response_splits_out_conservatory_building_part(
self,
) -> None:
# Arrange — a 1-BP dwelling (ground-floor room height 2.45 m) plus a
# non-separated double-glazed conservatory glazed BP.
from datatypes.epc.domain.epc_property_data import SapConservatory
from domain.sap10_calculator.worksheet.conservatory import (
conservatory_geometry,
)
baseline_tfa = EpcPropertyDataMapper.from_api_response(
load("21_0_1.json")
).total_floor_area_m2
cert = load("21_0_1.json")
cert["conservatory_type"] = 4
cert["sap_building_parts"].append(
{
"floor_area": 12.0,
"room_height": 1,
"double_glazed": "Y",
"glazed_perimeter": 9.0,
}
)
# Act
epc = EpcPropertyDataMapper.from_api_response(cert)
# Assert — conservatory split out; the glazed BP is NOT a fabric part.
assert epc.sap_conservatory == SapConservatory(
floor_area_m2=12.0,
glazed_perimeter_m=9.0,
double_glazed=True,
thermally_separated=False,
room_height_storeys=1.0,
)
assert len(epc.sap_building_parts) == 1
# §6.1: the conservatory floor area joins TFA (drives occupancy).
assert abs(epc.total_floor_area_m2 - (baseline_tfa + 12.0)) <= 1e-9
# Cross-mapper parity: the shared §6.1 cascade derives the same
# surfaces it does for the case-44 Summary — glazed wall = exposed
# perimeter × ground-floor room height (9.0 × 2.45 = 22.05); glazed
# roof = floor / cos(20°) (12.0 / 0.9397 = 12.77); Table 25 double
# U_eff = 1/(1/3.1 + 0.04) = 2.758 (wall) / 1/(1/3.4 + 0.04) = 2.993.
geom = conservatory_geometry(epc)
assert geom is not None
assert abs(geom.glazed_wall_area_m2 - 22.05) <= 1e-4
assert abs(geom.glazed_roof_area_m2 - 12.77) <= 1e-4
assert abs(geom.wall_u_eff - 2.7580) <= 1e-4
assert abs(geom.roof_u_eff - 2.9930) <= 1e-4
def test_separated_conservatory_lodges_no_glazed_building_part(self) -> None:
# Arrange — a separated conservatory (type 2/3) lodges NO glazed BP
# (verified across the gov corpus); the dwelling is unchanged.
from domain.sap10_calculator.worksheet.conservatory import (
conservatory_geometry,
)
cert = load("21_0_1.json")
cert["conservatory_type"] = 2
# Act
epc = EpcPropertyDataMapper.from_api_response(cert)
# Assert — §6.2: disregarded; no conservatory geometry.
assert epc.sap_conservatory is None
assert conservatory_geometry(epc) is None

View file

@ -382,6 +382,16 @@ class SapBuildingPart:
# redacts the backing insulation. Previously undeclared → dropped.
wall_u_value: Optional[float] = None
floor_u_value: Optional[float] = None
# RdSAP 10 §6.1 (PDF p.49) — a NON-SEPARATED conservatory is lodged by
# the gov API as a glazed "building part" carrying ONLY these four
# fields (no fabric, no floor dimensions); `conservatory_type == 4` at
# the property level. Previously undeclared → dropped by `from_dict`,
# so the conservatory was silently lost on the API path. The mapper
# splits this BP out into `EpcPropertyData.sap_conservatory`.
floor_area: Optional[Union[Measurement, int, float]] = None
room_height: Optional[Union[Measurement, int, float]] = None
double_glazed: Optional[str] = None
glazed_perimeter: Optional[Union[Measurement, int, float]] = None
@dataclass

View file

@ -98,8 +98,12 @@ _CORPUS = Path(
# The §3.9.2 Simplified Type-2 RR mapper (room_in_roof_type_2: gable quadratic +
# common-wall L×(0.25+H), MIRRORING the worksheet-validated Summary path,
# cross-mapper-parity-exact on cert 000565) -> 67.9% (MAE 0.959).
_MIN_WITHIN_HALF_SAP = 0.67
_MAX_SAP_MAE = 0.97
# The §6.1 non-separated conservatory mapper (the gov API's glazed building
# part → SapConservatory → §6.1 window/rooflight/floor cascade + TFA, MIRRORING
# the case-44 Summary path pinned to 1e-4) -> 68.6% (MAE 0.942). 5 type-4
# certs were over-rating (conservatory dropped → too little heat loss).
_MIN_WITHIN_HALF_SAP = 0.68
_MAX_SAP_MAE = 0.95
_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current