Merge branch 'main' into feature/pashub-map-main-heating-control-1557

This commit is contained in:
Daniel Roth 2026-07-14 17:53:04 +00:00
commit 328887c4d3
2 changed files with 104 additions and 0 deletions

View file

@ -343,6 +343,13 @@ class EpcPropertyDataMapper:
inspection_date=general.inspection_date,
tenure=general.tenure,
transaction_type=general.transaction_type,
# PAS Hub site notes don't lodge a country field, but the
# cascade reads `country_code` to pick the England (vs Welsh /
# Scottish / NI) U-value tables. Set 'ENG' explicitly, matching
# the Elmhurst path (`from_elmhurst_site_notes`) and this
# cohort's postcodes; a general fix should read the surveyed
# country once PAS Hub lodges one.
country_code="ENG",
roofs=[],
walls=[],
floors=[],
@ -5985,6 +5992,7 @@ def _map_main_building_part(
),
sap_floor_dimensions=_map_floor_dimensions(measurements.main_building.floors),
wall_thickness_mm=main.wall_thickness_mm,
roof_construction_type=roof.construction_type or None,
roof_insulation_location=roof_location,
roof_insulation_thickness=roof_thickness,
floor_type=floor.floor_type,
@ -6015,6 +6023,9 @@ def _map_extension_building_part(
),
sap_floor_dimensions=_map_floor_dimensions(ext_m.floors),
wall_thickness_mm=ext_c.wall_thickness_mm,
roof_construction_type=(
roof.construction_type or None if roof is not None else None
),
roof_insulation_location=roof_location,
roof_insulation_thickness=roof_thickness,
)
@ -6095,6 +6106,11 @@ def _map_sap_heating(
condensing=main.condensing,
weather_compensator=main.weather_compensator,
central_heating_pump_age_str=main.central_heating_pump_age,
# PCDB product id → the appliance's SEDBUK efficiency source
# (`gas_oil_boiler_record`); 0 means no product lodged (#1563).
main_heating_index_number=(
main.product_id if main.product_id > 0 else None
),
)
],
has_fixed_air_conditioning=ventilation.has_fixed_air_conditioning,

View file

@ -34,6 +34,62 @@ def load(filename: str) -> Dict[str, Any]:
return json.load(f) # type: ignore[no-any-return]
class TestRoofConstructionTypeCoding:
"""`from_site_notes` must set `roof_construction_type` from the surveyed
`roof_space.*.construction_type` label, not leave it `None`. The
calculator's `heat_transmission` reads this field for its "flat" /
"sloping ceiling" substring routing (flat-roof U path vs the pitched-
roof cos(30°) inclined-surface factor); leaving it `None` silently
routes every roof flat or pitched through the pitched default
(Guinness cohort, issue #1566).
"""
@pytest.mark.parametrize(
"label, expected",
[
("Pitched roof (Slates or tiles), Access to loft", "Pitched roof (Slates or tiles), Access to loft"),
("Flat", "Flat"), # routes to the flat-roof U path (heat_transmission "flat" substring)
("", None), # no lodging
],
)
def test_main_building_label_passes_through(
self, label: str, expected: Optional[str]
) -> None:
data = load("pashub_rdsap_site_notes_example1.json")
data["roof_space"]["main_building"]["construction_type"] = label
survey = from_dict(PasHubRdSapSiteNotes, data)
result = EpcPropertyDataMapper.from_site_notes(survey)
assert result.sap_building_parts[0].roof_construction_type == expected
def test_extension_label_passes_through(self) -> None:
data = load("pashub_rdsap_site_notes_example2.json")
data["roof_space"]["extensions"][0]["construction_type"] = "Flat"
survey = from_dict(PasHubRdSapSiteNotes, data)
result = EpcPropertyDataMapper.from_site_notes(survey)
extension_part = next(
bp
for bp in result.sap_building_parts
if bp.identifier != BuildingPartIdentifier.MAIN
)
assert extension_part.roof_construction_type == "Flat"
class TestCountryCode:
"""`from_site_notes` must set `country_code` so the cascade's England /
Wales / Scotland / NI U-value tables (`u_floor`, `u_basement_floor`,
`u_door`) resolve correctly. PAS Hub site notes don't lodge a country
field; default to 'ENG' for this all-English cohort (Guinness cohort,
issue #1566), matching the Elmhurst site-notes path.
"""
def test_country_code_is_england(self) -> None:
survey = from_dict(
PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json")
)
result = EpcPropertyDataMapper.from_site_notes(survey)
assert result.country_code == "ENG"
class TestPartyWallConstructionCoding:
"""`from_site_notes` must int-code the PAS Hub `party_wall_construction_type`
label to the SAP10 code `u_party_wall` consumes (RdSAP 10 Table 15), not copy
@ -314,6 +370,35 @@ class TestWaterHeatingCoding:
)
class TestMainHeatingEfficiencySource:
"""`from_site_notes` must carry the surveyed PCDB `product_id` onto
`main_heating_index_number`, so the calculator reads the actual appliance's
SEDBUK efficiency (`gas_oil_boiler_record`) instead of a generic Table-4b
default. The site note lodges the boiler by PCDB id (`summer_efficiency` is
always 0), so the index IS the efficiency source (issue #1563).
"""
def _survey(self, **main: object) -> PasHubRdSapSiteNotes:
data = load("pashub_rdsap_site_notes_example1.json")
data["heating_and_hot_water"]["main_heating"].update(main)
return from_dict(PasHubRdSapSiteNotes, data)
def test_product_id_maps_to_index_number(self) -> None:
result = EpcPropertyDataMapper.from_site_notes(self._survey(product_id=18400))
assert (
result.sap_heating.main_heating_details[0].main_heating_index_number
== 18400
)
def test_zero_product_id_leaves_index_none(self) -> None:
# No PCDB product lodged (e.g. selected by efficiency) → no index.
result = EpcPropertyDataMapper.from_site_notes(self._survey(product_id=0))
assert (
result.sap_heating.main_heating_details[0].main_heating_index_number
is None
)
class TestFromSiteNotesExample1:
"""
Fixture: pashub_rdsap_site_notes_example1.json
@ -650,6 +735,7 @@ class TestFromSiteNotesExample1:
status=None,
tenure="Rented Social",
transaction_type="None of the Above",
country_code="ENG",
# Elements (not available from site notes)
roofs=[],
walls=[],
@ -673,6 +759,7 @@ class TestFromSiteNotesExample1:
condensing=True,
weather_compensator=False,
central_heating_pump_age_str="Unknown",
main_heating_index_number=18400, # PCDB product id → efficiency source
)
],
has_fixed_air_conditioning=False,
@ -778,6 +865,7 @@ class TestFromSiteNotesExample1:
),
],
wall_thickness_mm=280,
roof_construction_type="Pitched roof (Slates or tiles), Access to loft",
roof_insulation_location="Joists",
roof_insulation_thickness=100,
floor_type="Ground Floor",