mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
2a4d67e396
commit
d501535cbc
4 changed files with 141 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue