Map 20.0.0 lighting, ventilation and hot-water demand fields 🟩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-10 14:46:54 +00:00
parent 3352f11be3
commit eb5bb89612
2 changed files with 137 additions and 2 deletions

View file

@ -1077,6 +1077,8 @@ class EpcPropertyDataMapper:
@staticmethod
def from_rdsap_schema_20_0_0(schema: RdSapSchema20_0_0) -> EpcPropertyData:
es = schema.sap_energy_source
# ADR-0027: instantaneous_wwhrs holds bath/shower ROOM counts.
iw = schema.sap_heating.instantaneous_wwhrs
return EpcPropertyData(
uprn=schema.uprn,
assessment_type=schema.assessment_type,
@ -1110,12 +1112,27 @@ class EpcPropertyDataMapper:
heated_rooms_count=schema.heated_room_count,
wet_rooms_count=0,
extensions_count=schema.extensions_count,
open_chimneys_count=0,
open_chimneys_count=schema.open_fireplaces_count,
insulated_door_count=schema.insulated_door_count,
draughtproofed_door_count=None,
percent_draughtproofed=schema.percent_draughtproofed,
# ADR-0027: 20.0.0 has no flue/fan/vent counts (calculator defaults
# them via RdSAP Table 5), but sheltered_sides must come from
# built_form — else the calculator assumes mid-terrace (2) for all.
sap_ventilation=SapVentilation(
sheltered_sides=_api_sheltered_sides(schema.built_form),
),
# ADR-0027: 20.0.0 gives total + low-energy OUTLET counts, not an
# LED/CFL/incandescent split. Low-energy → the calculator's LEL
# path (unknown LED/CFL split); the remainder is incandescent (1
# outlet ≈ 1 bulb). Fixes a 439-cert lighting understatement.
led_fixed_lighting_bulbs_count=0,
cfl_fixed_lighting_bulbs_count=0,
incandescent_fixed_lighting_bulbs_count=0,
incandescent_fixed_lighting_bulbs_count=(
schema.fixed_lighting_outlets_count
- schema.low_energy_fixed_lighting_outlets_count
),
low_energy_fixed_lighting_bulbs_count=schema.low_energy_fixed_lighting_outlets_count,
roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs),
walls=EpcPropertyDataMapper._map_energy_elements(schema.walls),
floors=EpcPropertyDataMapper._map_energy_elements(schema.floors),
@ -1131,6 +1148,20 @@ class EpcPropertyDataMapper:
sap_heating=SapHeating(
# 20.0.0 uses room counts not product index numbers; domain fields default to None
instantaneous_wwhrs=InstantaneousWwhrs(),
# ADR-0027: derive HW demand counts from the bath/shower ROOM
# counts (instantaneous_wwhrs is the false-friend container).
number_baths=(
iw.rooms_with_bath_and_or_shower
+ iw.rooms_with_bath_and_mixer_shower
if iw is not None
else None
),
mixer_shower_count=(
iw.rooms_with_mixer_shower_no_bath
+ iw.rooms_with_bath_and_mixer_shower
if iw is not None
else None
),
main_heating_details=[
MainHeatingDetail(
has_fghrs=d.has_fghrs == "Y",

View file

@ -1261,3 +1261,107 @@ class TestRdSap20_0_0ReducedFieldSynthesis:
# Assert — cascade remaps 1 ("DG pre-2002") -> 2 (double), not raw 1.
assert all(w.glazing_type == 2 for w in result.sap_windows)
def test_lighting_counts_incandescent_remainder_and_low_energy_as_lel(
self,
) -> None:
# Arrange — ADR-0027: 20.0.0 gives total + low-energy OUTLET counts, not
# an LED/CFL/incandescent split. The non-low-energy remainder is
# incandescent (else lighting energy is understated for the 439/1000
# certs that have any); low-energy → the calculator's LEL path (unknown
# LED/CFL split). A cert with some incandescent outlets.
corpus = _load_20_0_0_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
cert = next(
(
c
for c in corpus
if not c.get("sap_windows")
and (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"]
# Act
result = EpcPropertyDataMapper.from_api_response(cert)
# Assert
assert result.incandescent_fixed_lighting_bulbs_count == total - low
assert result.low_energy_fixed_lighting_bulbs_count == low
assert result.led_fixed_lighting_bulbs_count == 0
assert result.cfl_fixed_lighting_bulbs_count == 0
def test_ventilation_maps_chimneys_draughtproofing_and_sheltered_sides(
self,
) -> None:
# Arrange — ADR-0027: 20.0.0 lodges open_fireplaces_count (currently
# dropped → -80 m³/h/chimney for 53 certs), percent_draughtproofed, and
# built_form. Build sap_ventilation with sheltered_sides from built_form
# (else the calculator defaults every dwelling to mid-terrace=2). A cert
# with an open fireplace.
from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage]
corpus = _load_20_0_0_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-20.0.0 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")
# Act
result = EpcPropertyDataMapper.from_api_response(cert)
# Assert
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:
# Arrange — ADR-0027: 20.0.0's instantaneous_wwhrs carries bath/shower
# ROOM counts (a false-friend for the WWHR device index). Derive
# number_baths and mixer_shower_count from them so HW demand isn't pinned
# to the calculator's modal 1-bath default (496/1000 have ≠1 bath).
corpus = _load_20_0_0_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-20.0.0 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"
]
# Act
result = EpcPropertyDataMapper.from_api_response(cert)
# Assert
assert result.sap_heating.number_baths == expected_baths
assert result.sap_heating.mixer_shower_count == expected_mixers