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