Synthesise windows, lighting, ventilation and hot water for 17.1 certs 🟥

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-11 13:13:37 +00:00
parent 0e982f902f
commit a8895b41d4
2 changed files with 191 additions and 15 deletions

View file

@ -607,12 +607,7 @@ class EpcPropertyDataMapper:
uprn=schema.uprn,
assessment_type=schema.assessment_type,
sap_version=schema.sap_version,
# ADR-0028: 17.1 lodges dwelling_type as str OR localised dict.
dwelling_type=(
schema.dwelling_type
if isinstance(schema.dwelling_type, str)
else schema.dwelling_type.value
),
dwelling_type=schema.dwelling_type.value,
property_type=str(schema.property_type),
built_form=str(schema.built_form),
address_line_1=schema.address_line_1,
@ -706,7 +701,7 @@ class EpcPropertyDataMapper:
percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area,
)
)
if getattr(es.photovoltaic_supply, "none_or_no_details", None)
if es.photovoltaic_supply
else None
),
),
@ -2135,14 +2130,6 @@ class EpcPropertyDataMapper:
from_dict(RdSapSchema18_0, data)
)
)
if schema == "RdSAP-Schema-17.1":
from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1
return _clear_basement_flag_when_system_built(
EpcPropertyDataMapper.from_rdsap_schema_17_1(
from_dict(RdSapSchema17_1, data)
)
)
raise ValueError(f"Unsupported EPC schema: {schema!r}")

View file

@ -1744,3 +1744,192 @@ class TestRdSap17_1ReducedFieldSynthesis:
# Assert
assert isinstance(result, EpcPropertyData)
def test_rich_cert_uses_lodged_window_area_for_geometry(self) -> None:
# ADR-0028: 14/1000 17.1 certs lodge a per-window sap_windows array
# (band-4); use lodged window_area as geometry, not synthesised.
corpus = _load_17_1_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
cert = next((c for c in corpus if c.get("sap_windows")), None)
if cert is None:
pytest.skip("no corpus cert lodging sap_windows")
lodged = cert["sap_windows"]
expected_total = sum(w["window_area"]["value"] for w in lodged)
result = EpcPropertyDataMapper.from_api_response(cert)
assert len(result.sap_windows) == len(lodged)
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
assert total_area == pytest.approx(expected_total)
def test_band_normal_synthesises_total_glazing_at_0_148_of_floor_area(
self,
) -> None:
# ADR-0028: band-1 (969/1000) synthesises total glazing = 0.148 x TFA,
# the inherited 20.0.0 coefficient (validated vs 17.1's band-4 rich certs).
corpus = _load_17_1_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
cert = next(
(c for c in corpus if not c.get("sap_windows") and c.get("glazed_area") == 1),
None,
)
if cert is None:
pytest.skip("no band-1 corpus cert without sap_windows")
tfa = float(cert["total_floor_area"])
result = EpcPropertyDataMapper.from_api_response(cert)
assert len(result.sap_windows) == 4
assert all(w.window_height == 1.0 for w in result.sap_windows)
assert sorted(w.orientation for w in result.sap_windows) == [1, 3, 5, 7]
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
assert total_area == pytest.approx(0.148 * tfa)
def test_band_more_than_typical_scales_glazing_by_1_25(self) -> None:
# ADR-0028: band 2 ("More than typical") = inherited 1.25 multiplier.
corpus = _load_17_1_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
cert = next(
(c for c in corpus if not c.get("sap_windows") and c.get("glazed_area") == 2),
None,
)
if cert is None:
pytest.skip("no band-2 corpus cert without sap_windows")
tfa = float(cert["total_floor_area"])
result = EpcPropertyDataMapper.from_api_response(cert)
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
assert total_area == pytest.approx(0.148 * tfa * 1.25)
def test_synthesised_glazing_type_routed_through_cascade(self) -> None:
# ADR-0028: multiple_glazing_type shares 20.0.0's code space — route
# through the cascade so code 1 ("DG pre-2002") remaps to 2, not single.
corpus = _load_17_1_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
cert = next(
(
c
for c in corpus
if not c.get("sap_windows") and c.get("multiple_glazing_type") == 1
),
None,
)
if cert is None:
pytest.skip("no corpus cert with multiple_glazing_type=1")
result = EpcPropertyDataMapper.from_api_response(cert)
assert all(w.glazing_type == 2 for w in result.sap_windows)
def test_synthesised_glazing_type_handles_not_defined_code(self) -> None:
# ADR-0028: "ND" (56/1000) maps to a valid INTEGER glazing_type (DG-modal),
# never the raw string on an int field.
corpus = _load_17_1_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
cert = next(
(
c
for c in corpus
if not c.get("sap_windows") and c.get("multiple_glazing_type") == "ND"
),
None,
)
if cert is None:
pytest.skip("no windowless corpus cert with multiple_glazing_type ND")
result = EpcPropertyDataMapper.from_api_response(cert)
assert result.sap_windows
assert all(isinstance(w.glazing_type, int) for w in result.sap_windows)
def test_lighting_counts_incandescent_remainder_and_low_energy_as_lel(
self,
) -> None:
# ADR-0028: total + low-energy OUTLET counts -> incandescent remainder +
# low-energy as LEL.
corpus = _load_17_1_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
cert = next(
(
c
for c in corpus
if (c.get("fixed_lighting_outlets_count") or 0)
> (c.get("low_energy_fixed_lighting_outlets_count") or 0)
),
None,
)
if cert is None:
pytest.skip("no corpus cert with incandescent lighting")
total = cert["fixed_lighting_outlets_count"]
low = cert["low_energy_fixed_lighting_outlets_count"]
result = EpcPropertyDataMapper.from_api_response(cert)
assert result.incandescent_fixed_lighting_bulbs_count == total - low
assert result.low_energy_fixed_lighting_bulbs_count == low
def test_ventilation_maps_chimneys_draughtproofing_and_sheltered_sides(
self,
) -> None:
# ADR-0028: open_fireplaces_count -> chimneys, percent_draughtproofed,
# sheltered_sides from built_form.
from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage]
corpus = _load_17_1_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
cert = next(
(
c
for c in corpus
if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1
),
None,
)
if cert is None:
pytest.skip("no corpus cert with an open fireplace")
result = EpcPropertyDataMapper.from_api_response(cert)
assert result.open_chimneys_count == cert["open_fireplaces_count"]
assert result.percent_draughtproofed == cert["percent_draughtproofed"]
assert result.sap_ventilation is not None
assert result.sap_ventilation.sheltered_sides == _api_sheltered_sides(
cert["built_form"]
)
def test_hot_water_derives_bath_and_mixer_counts_from_room_counts(self) -> None:
# ADR-0028: instantaneous_wwhrs ROOM counts -> number_baths/mixer_shower.
corpus = _load_17_1_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
cert = next(
(
c
for c in corpus
if not c.get("sap_windows")
and c.get("sap_heating", {}).get("instantaneous_wwhrs")
),
None,
)
if cert is None:
pytest.skip("no corpus cert with instantaneous_wwhrs")
iw = cert["sap_heating"]["instantaneous_wwhrs"]
expected_baths = iw["rooms_with_bath_and_or_shower"] + iw[
"rooms_with_bath_and_mixer_shower"
]
expected_mixers = iw["rooms_with_mixer_shower_no_bath"] + iw[
"rooms_with_bath_and_mixer_shower"
]
result = EpcPropertyDataMapper.from_api_response(cert)
assert result.sap_heating.number_baths == expected_baths
assert result.sap_heating.mixer_shower_count == expected_mixers