From a1743011365a6b4ff57f5be74e348a99528cf5a4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 1 Jul 2026 10:50:58 +0000 Subject: [PATCH] rename mains_gas to gas_connection_available --- .../tests/test_elmhurst_end_to_end.py | 30 +- .../documents_parser/tests/test_end_to_end.py | 2 +- datatypes/epc/domain/epc_property_data.py | 2 +- datatypes/epc/domain/mapper.py | 396 +++++++----- .../domain/tests/test_from_rdsap_schema.py | 349 +++++++---- .../epc/domain/tests/test_from_sap_schema.py | 46 +- .../epc/domain/tests/test_from_site_notes.py | 18 +- .../property_overlays/main_fuel_overlay.py | 2 +- .../main_heating_system_overlay.py | 2 +- .../generators/heating_recommendation.py | 16 +- domain/modelling/simulation.py | 2 +- domain/sap10_ml/tests/_fixtures.py | 52 +- domain/sap10_ml/transform.py | 585 +++++++++++++----- harness/report.py | 20 +- infrastructure/postgres/epc_property_table.py | 46 +- repositories/epc/epc_postgres_repository.py | 91 ++- scripts/elmhurst_input_sheet.py | 20 +- tests/domain/epc/test_main_fuel_overlay.py | 5 +- .../epc/test_main_heating_system_overlay.py | 8 +- .../domain/modelling/test_ashp_cost_inputs.py | 4 +- .../modelling/test_heating_recommendation.py | 8 +- .../modelling/test_overlay_applicator.py | 28 +- 22 files changed, 1139 insertions(+), 593 deletions(-) diff --git a/backend/documents_parser/tests/test_elmhurst_end_to_end.py b/backend/documents_parser/tests/test_elmhurst_end_to_end.py index 1ccd28c9e..e7917d58f 100644 --- a/backend/documents_parser/tests/test_elmhurst_end_to_end.py +++ b/backend/documents_parser/tests/test_elmhurst_end_to_end.py @@ -5,7 +5,10 @@ from datetime import date import pytest from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor -from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier, EpcPropertyData +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, +) from datatypes.epc.domain.mapper import EpcPropertyDataMapper FIXTURE_PATH = os.path.join( @@ -179,10 +182,15 @@ class TestBuildingPart: assert len(result.sap_building_parts[0].sap_floor_dimensions) == 1 def test_floor_dimension_area(self, result: EpcPropertyData) -> None: - assert result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 == 44.89 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 + == 44.89 + ) def test_floor_dimension_room_height(self, result: EpcPropertyData) -> None: - assert result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.24 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.24 + ) def test_floor_dimension_heat_loss_perimeter(self, result: EpcPropertyData) -> None: assert ( @@ -245,11 +253,17 @@ class TestWindows: def test_transmission_solar_transmittance(self, result: EpcPropertyData) -> None: assert result.sap_windows[0].window_transmission_details is not None - assert result.sap_windows[0].window_transmission_details.solar_transmittance == 0.72 + assert ( + result.sap_windows[0].window_transmission_details.solar_transmittance + == 0.72 + ) def test_transmission_data_source(self, result: EpcPropertyData) -> None: assert result.sap_windows[0].window_transmission_details is not None - assert result.sap_windows[0].window_transmission_details.data_source == "Manufacturer" + assert ( + result.sap_windows[0].window_transmission_details.data_source + == "Manufacturer" + ) class TestHeating: @@ -308,7 +322,7 @@ class TestHeating: class TestEnergySource: def test_mains_gas(self, result: EpcPropertyData) -> None: - assert result.sap_energy_source.mains_gas is True + assert result.sap_energy_source.gas_connection_available is True def test_meter_type(self, result: EpcPropertyData) -> None: assert result.sap_energy_source.meter_type == "Single" @@ -384,5 +398,7 @@ class TestWindowFrameMaterial: class TestLowEnergyLighting: - def test_low_energy_fixed_lighting_bulbs_count(self, result2: EpcPropertyData) -> None: + def test_low_energy_fixed_lighting_bulbs_count( + self, result2: EpcPropertyData + ) -> None: assert result2.low_energy_fixed_lighting_bulbs_count == 5 diff --git a/backend/documents_parser/tests/test_end_to_end.py b/backend/documents_parser/tests/test_end_to_end.py index d1a027173..6fc20ad09 100644 --- a/backend/documents_parser/tests/test_end_to_end.py +++ b/backend/documents_parser/tests/test_end_to_end.py @@ -177,7 +177,7 @@ class TestPdfToEpcPropertyData: ), ], sap_energy_source=SapEnergySource( - mains_gas=True, + gas_connection_available=True, meter_type="Single", pv_battery_count=0, wind_turbines_count=0, diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 895e4ab30..328397e82 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -361,7 +361,7 @@ class PhotovoltaicArray: @dataclass class SapEnergySource: - mains_gas: bool + gas_connection_available: bool meter_type: str # int in API, str (e.g. "Single") in site notes pv_battery_count: int wind_turbines_count: int diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index e89062610..1f39331ea 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4,7 +4,18 @@ import re from dataclasses import replace from datetime import date from decimal import ROUND_HALF_UP, Decimal -from typing import Any, Dict, Final, List, Optional, Sequence, Tuple, TypeVar, Union, cast +from typing import ( + Any, + Dict, + Final, + List, + Optional, + Sequence, + Tuple, + TypeVar, + Union, + cast, +) from datatypes.epc.schema.helpers import from_dict from datatypes.epc.domain.epc import Epc @@ -77,16 +88,16 @@ _SAP_DEFAULT_AGE_BAND: Final[str] = "M" # (66% × 0.70 in-use factor) as the RdSAP-21 API path when no PCDF index is # lodged — the cascade handles a null index gracefully. _SAP_VENTILATION_TYPE_TO_MV_KIND: Final[Dict[int, Optional[str]]] = { - 1: None, # natural (with intermittent extract fans) - 2: None, # passive stack — treated as natural - 3: None, # positive input from loft → natural - 4: "EXTRACT_OR_PIV_OUTSIDE", # positive input from outside - 5: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, centralised (MEV c) - 6: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, decentralised (MEV dc) - 7: "MV", # balanced without heat recovery (MV) - 8: "MVHR", # balanced with heat recovery (MVHR) - 9: None, # natural with extract fans and/or passive vents (legacy) - 10: None, # natural with extract fans and passive vents + 1: None, # natural (with intermittent extract fans) + 2: None, # passive stack — treated as natural + 3: None, # positive input from loft → natural + 4: "EXTRACT_OR_PIV_OUTSIDE", # positive input from outside + 5: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, centralised (MEV c) + 6: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, decentralised (MEV dc) + 7: "MV", # balanced without heat recovery (MV) + 8: "MVHR", # balanced with heat recovery (MVHR) + 9: None, # natural with extract fans and/or passive vents (legacy) + 10: None, # natural with extract fans and passive vents } # rdsap_uvalues WALL_CAVITY = 4 (D7 fallback; U comes from the description). _SAP_DEFAULT_WALL_CONSTRUCTION: Final[int] = 4 @@ -336,7 +347,7 @@ class EpcPropertyDataMapper: sap_heating=_map_sap_heating(heating, ventilation, survey.water_use), sap_windows=[_map_sap_window(w) for w in survey.windows], sap_energy_source=SapEnergySource( - mains_gas=general.mains_gas_available, + gas_connection_available=general.mains_gas_available, meter_type=general.electric_meter_type, pv_battery_count=renewables.number_of_pv_batteries, wind_turbines_count=0 if not renewables.wind_turbines else 1, @@ -442,7 +453,7 @@ class EpcPropertyDataMapper: ] or None, sap_energy_source=SapEnergySource( - mains_gas=survey.meters.main_gas, + gas_connection_available=survey.meters.main_gas, meter_type=survey.meters.electricity_meter_type, pv_battery_count=0, wind_turbines_count=1 if survey.renewables.wind_turbine_present else 0, @@ -569,9 +580,11 @@ class EpcPropertyDataMapper: dwelling_type=( schema.dwelling_type if isinstance(schema.dwelling_type, str) - else schema.dwelling_type.value - if hasattr(schema.dwelling_type, "value") - else str(schema.dwelling_type) + else ( + schema.dwelling_type.value + if hasattr(schema.dwelling_type, "value") + else str(schema.dwelling_type) + ) ), property_type=str(schema.property_type), built_form=str(schema.built_form), @@ -651,26 +664,28 @@ class EpcPropertyDataMapper: # glazed_area band + TFA via the shared reduced-field core. The 10 # rich certs keep their lodged per-window geometry (used directly: # window_width = area, height = 1.0). - sap_windows=[ - SapWindow( - frame_material=None, - glazing_gap=0, - orientation=w.orientation, - window_type=w.window_type, - glazing_type=w.glazing_type, - window_width=_measurement_value(w.window_area), - window_height=1.0, - draught_proofed=False, - window_location=w.window_location, - window_wall_type=0, - permanent_shutters_present=False, - ) - for w in schema.sap_windows - ] - if schema.sap_windows - else _synthesise_17_0_sap_windows(schema), + sap_windows=( + [ + SapWindow( + frame_material=None, + glazing_gap=0, + orientation=w.orientation, + window_type=w.window_type, + glazing_type=w.glazing_type, + window_width=_measurement_value(w.window_area), + window_height=1.0, + draught_proofed=False, + window_location=w.window_location, + window_wall_type=0, + permanent_shutters_present=False, + ) + for w in schema.sap_windows + ] + if schema.sap_windows + else _synthesise_17_0_sap_windows(schema) + ), sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=0, wind_turbines_count=es.wind_turbines_count, @@ -742,9 +757,7 @@ class EpcPropertyDataMapper: # gov property_type code → str, mirroring from_rdsap_schema_17_1. # Feeds the calculator's house/flat heat-transmission split. property_type=( - str(schema.property_type) - if schema.property_type is not None - else None + str(schema.property_type) if schema.property_type is not None else None ), built_form=( str(schema.built_form) if schema.built_form is not None else None @@ -798,7 +811,10 @@ class EpcPropertyDataMapper: led_fixed_lighting_bulbs_count=0, incandescent_fixed_lighting_bulbs_count=( (schema.sap_energy_source.fixed_lighting_outlets_count or 0) - - (schema.sap_energy_source.low_energy_fixed_lighting_outlets_count or 0) + - ( + schema.sap_energy_source.low_energy_fixed_lighting_outlets_count + or 0 + ) ), low_energy_fixed_lighting_bulbs_count=( schema.sap_energy_source.low_energy_fixed_lighting_outlets_count or 0 @@ -865,7 +881,7 @@ class EpcPropertyDataMapper: # D5: mains_gas derived from the heating fuel (full SAP has no # explicit flag); tariff → meter_type; wind turbines pass through. sap_energy_source=SapEnergySource( - mains_gas=_sap_dwelling_on_mains_gas(schema), + gas_connection_available=_sap_dwelling_on_mains_gas(schema), meter_type=_sap_17_1_meter_type( schema.sap_energy_source.electricity_tariff ), @@ -1201,7 +1217,7 @@ class EpcPropertyDataMapper: else _synthesise_17_1_sap_windows(schema) ), sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=0, wind_turbines_count=es.wind_turbines_count, @@ -1386,30 +1402,32 @@ class EpcPropertyDataMapper: # ADR-0028: 990/1000 omit sap_windows -> synthesised from the # glazed_area band + TFA (added in a later slice). The 10 rich # certs keep their lodged per-window geometry. - sap_windows=[ - SapWindow( - frame_material=None, - glazing_gap=0, - orientation=w.orientation, - window_type=w.window_type, - glazing_type=w.glazing_type, - # ADR-0028: the 10 rich certs lodge a real per-window - # window_area (Measurement) -- use it directly as geometry - # (width = area, height = 1.0) rather than the placeholder - # windowless 0x0. - window_width=_measurement_value(w.window_area), - window_height=1.0, - draught_proofed=False, - window_location=w.window_location, - window_wall_type=0, - permanent_shutters_present=False, - ) - for w in schema.sap_windows - ] - if schema.sap_windows - else _synthesise_18_0_sap_windows(schema), + sap_windows=( + [ + SapWindow( + frame_material=None, + glazing_gap=0, + orientation=w.orientation, + window_type=w.window_type, + glazing_type=w.glazing_type, + # ADR-0028: the 10 rich certs lodge a real per-window + # window_area (Measurement) -- use it directly as geometry + # (width = area, height = 1.0) rather than the placeholder + # windowless 0x0. + window_width=_measurement_value(w.window_area), + window_height=1.0, + draught_proofed=False, + window_location=w.window_location, + window_wall_type=0, + permanent_shutters_present=False, + ) + for w in schema.sap_windows + ] + if schema.sap_windows + else _synthesise_18_0_sap_windows(schema) + ), sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=0, wind_turbines_count=es.wind_turbines_count, @@ -1475,7 +1493,9 @@ class EpcPropertyDataMapper: ), sap_room_in_roof=( SapRoomInRoof( - floor_area=_measurement_value(bp.sap_room_in_roof.floor_area), + floor_area=_measurement_value( + bp.sap_room_in_roof.floor_area + ), construction_age_band=bp.sap_room_in_roof.construction_age_band, ) if bp.sap_room_in_roof @@ -1504,9 +1524,11 @@ class EpcPropertyDataMapper: dwelling_type=( schema.dwelling_type if isinstance(schema.dwelling_type, str) - else schema.dwelling_type.value - if hasattr(schema.dwelling_type, "value") - else str(schema.dwelling_type) + else ( + schema.dwelling_type.value + if hasattr(schema.dwelling_type, "value") + else str(schema.dwelling_type) + ) ), property_type=str(schema.property_type), built_form=str(schema.built_form), @@ -1597,26 +1619,28 @@ class EpcPropertyDataMapper: # glazed_area band + TFA via the shared reduced-field core. The 6 # rich certs keep their lodged per-window geometry (used directly: # window_width = area, height = 1.0). - sap_windows=[ - SapWindow( - frame_material=None, - glazing_gap=0, - orientation=w.orientation, - window_type=w.window_type, - glazing_type=w.glazing_type, - window_width=_measurement_value(w.window_area), - window_height=1.0, - draught_proofed=False, - window_location=w.window_location, - window_wall_type=0, - permanent_shutters_present=False, - ) - for w in schema.sap_windows - ] - if schema.sap_windows - else _synthesise_19_0_sap_windows(schema), + sap_windows=( + [ + SapWindow( + frame_material=None, + glazing_gap=0, + orientation=w.orientation, + window_type=w.window_type, + glazing_type=w.glazing_type, + window_width=_measurement_value(w.window_area), + window_height=1.0, + draught_proofed=False, + window_location=w.window_location, + window_wall_type=0, + permanent_shutters_present=False, + ) + for w in schema.sap_windows + ] + if schema.sap_windows + else _synthesise_19_0_sap_windows(schema) + ), sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=0, wind_turbines_count=es.wind_turbines_count, @@ -1884,7 +1908,7 @@ class EpcPropertyDataMapper: else _synthesise_20_0_0_sap_windows(schema) ), sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=0, wind_turbines_count=es.wind_turbines_count, @@ -2134,7 +2158,7 @@ class EpcPropertyDataMapper: for w in schema.sap_windows ], sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=es.pv_battery_count or 0, wind_turbines_count=es.wind_turbines_count, @@ -2239,11 +2263,14 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_1.wall_area, wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined, - wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_1.wall_construction), + wall_construction=_api_wall_construction_code( + bp.sap_alternative_wall_1.wall_construction + ), wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, - is_sheltered=bp.sap_alternative_wall_1.sheltered_wall == "Y", + is_sheltered=bp.sap_alternative_wall_1.sheltered_wall + == "Y", ) if bp.sap_alternative_wall_1 else None @@ -2252,11 +2279,14 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_2.wall_area, wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined, - wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_2.wall_construction), + wall_construction=_api_wall_construction_code( + bp.sap_alternative_wall_2.wall_construction + ), wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, - is_sheltered=bp.sap_alternative_wall_2.sheltered_wall == "Y", + is_sheltered=bp.sap_alternative_wall_2.sheltered_wall + == "Y", ) if bp.sap_alternative_wall_2 else None @@ -2463,7 +2493,7 @@ class EpcPropertyDataMapper: or None, # SAP energy source sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=es.pv_battery_count or 0, wind_turbines_count=es.wind_turbines_count, @@ -2569,11 +2599,14 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_1.wall_area, wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined, - wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_1.wall_construction), + wall_construction=_api_wall_construction_code( + bp.sap_alternative_wall_1.wall_construction + ), wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, - is_sheltered=bp.sap_alternative_wall_1.sheltered_wall == "Y", + is_sheltered=bp.sap_alternative_wall_1.sheltered_wall + == "Y", ) if bp.sap_alternative_wall_1 else None @@ -2582,11 +2615,14 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_2.wall_area, wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined, - wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_2.wall_construction), + wall_construction=_api_wall_construction_code( + bp.sap_alternative_wall_2.wall_construction + ), wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, - is_sheltered=bp.sap_alternative_wall_2.sheltered_wall == "Y", + is_sheltered=bp.sap_alternative_wall_2.sheltered_wall + == "Y", ) if bp.sap_alternative_wall_2 else None @@ -2895,7 +2931,14 @@ class EpcPropertyDataMapper: # Mirrored here read-only to back-solve a room count from full SAP's measured # living_area (single home is the calculator; this is the inverse lookup). _SAP_LIVING_AREA_FRACTION_BY_ROOMS: Final[Dict[int, float]] = { - 1: 0.75, 2: 0.50, 3: 0.30, 4: 0.25, 5: 0.21, 6: 0.18, 7: 0.16, 8: 0.14, + 1: 0.75, + 2: 0.50, + 3: 0.30, + 4: 0.25, + 5: 0.21, + 6: 0.18, + 7: 0.16, + 8: 0.14, } @@ -2982,9 +3025,9 @@ def _sap_17_1_meter_type(electricity_tariff: Optional[int]) -> str: # RdSAP meter code — it maps to "dual", and the §12 dispatch resolves 7-/10-hour # from the heating system. _SAP_TARIFF_TO_RDSAP_METER_TYPE: dict[int, str] = { - 1: "single", # standard tariff - 2: "dual", # off-peak 7 hour (Economy 7) - 3: "dual", # off-peak 10 hour (§12 dispatch resolves 7/10hr) + 1: "single", # standard tariff + 2: "dual", # off-peak 7 hour (Economy 7) + 3: "dual", # off-peak 10 hour (§12 dispatch resolves 7/10hr) 4: "dual (24 hour)", # 24-hour tariff 5: "off-peak 18 hour", # off-peak 18 hour (Table 12a EIGHTEEN_HOUR) } @@ -3740,7 +3783,9 @@ def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]: window: Any = d.get("window") description = "" if isinstance(window, dict): - description = str(cast(Dict[str, Any], window).get("description") or "").lower() + description = str( + cast(Dict[str, Any], window).get("description") or "" + ).lower() if "single" in description: d["multiple_glazing_type"] = 5 @@ -4694,20 +4739,20 @@ def _synthesise_17_1_sap_windows(schema: RdSapSchema17_1) -> List[SapWindow]: # is lodged, falling back to the type-only default for missing gaps. _API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = { # (u_value, solar_transmittance/g_⊥, frame_factor) - 1: (2.8, 0.76, 0.70), # Double glazed, pre-2002 / unknown install - # date — Table 24 row 2 (PVC/wooden), 12mm - # gap default. Schema sibling of code 3. - 2: (2.0, 0.72, 0.70), # Double glazed, England/Wales 2002+ (pre-2022) - 3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 (12mm gap default) - 13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022 - 14: (1.4, 0.72, 0.70), # Double or triple glazed, post-2022 - # (Table 24 last row: 2022+ E/W / 2023+ Sc - # / 2022+ NI — same U/g as code 13 per - # spec; the integer codes 13/14 are - # schema siblings within the post-2022 - # product family. Cert 0380 lodges code - # 14 on all windows; worksheet uses U=1.4 - # = post-curtain 1.3258.) + 1: (2.8, 0.76, 0.70), # Double glazed, pre-2002 / unknown install + # date — Table 24 row 2 (PVC/wooden), 12mm + # gap default. Schema sibling of code 3. + 2: (2.0, 0.72, 0.70), # Double glazed, England/Wales 2002+ (pre-2022) + 3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 (12mm gap default) + 13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022 + 14: (1.4, 0.72, 0.70), # Double or triple glazed, post-2022 + # (Table 24 last row: 2022+ E/W / 2023+ Sc + # / 2022+ NI — same U/g as code 13 per + # spec; the integer codes 13/14 are + # schema siblings within the post-2022 + # product family. Cert 0380 lodges code + # 14 on all windows; worksheet uses U=1.4 + # = post-curtain 1.3258.) # # SINGLE / SECONDARY / TRIPLE glazing — RdSAP 10 Table 24 (spec p.50). # Previously unmapped (`_api_glazing_transmission` returned None) so the @@ -4715,27 +4760,27 @@ _API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = { # all-None default 2.5 instead of its true 4.8 — over-rating single- # glazed dwellings (cert 0370-2933, 7 single windows, +17 SAP). Codes # per datatypes/epc/domain/epc_codes.csv `glazed_type` (RdSAP-Schema-21). - 4: (2.9, 0.85, 0.70), # Secondary glazing, unknown data → Table 24 - # Secondary "Normal emissivity" default (2.9). - 5: (4.8, 0.85, 0.70), # Single glazing — Table 24 "Single / Any - # period" (PVC/wooden 4.8, g 0.85). - 6: (2.1, 0.68, 0.70), # Triple glazed, unknown install date — Table - # 24 Triple pre-2002 12mm-gap default (2.1). - 7: (2.8, 0.76, 0.70), # Double glazed, known data — no measured U on - # the reduced-data path → double pre-2002 / - # unknown-date family default (2.8), as code 3. - 8: (2.1, 0.68, 0.70), # Triple glazed, known data → triple unknown- - # date family default (2.1), as code 6. - 9: (2.0, 0.72, 0.70), # Triple glazed, 2002-2022 — Table 24 "Double - # or triple, 2002+ (pre-2022), any gap" (2.0). - 10: (2.1, 0.68, 0.70), # Triple glazed, pre-2002 — Table 24 Triple - # pre-2002 12mm-gap default (2.1). - 11: (2.9, 0.85, 0.70), # Secondary glazing, normal emissivity — - # Table 24 Secondary "Normal emissivity" (2.9). - 12: (2.2, 0.85, 0.70), # Secondary glazing, low emissivity — Table 24 - # Secondary "Low emissivity" (2.2). - 15: (4.8, 0.85, 0.70), # Single glazing, known data → Single row - # (4.8) when no measured U is lodged, as code 5. + 4: (2.9, 0.85, 0.70), # Secondary glazing, unknown data → Table 24 + # Secondary "Normal emissivity" default (2.9). + 5: (4.8, 0.85, 0.70), # Single glazing — Table 24 "Single / Any + # period" (PVC/wooden 4.8, g 0.85). + 6: (2.1, 0.68, 0.70), # Triple glazed, unknown install date — Table + # 24 Triple pre-2002 12mm-gap default (2.1). + 7: (2.8, 0.76, 0.70), # Double glazed, known data — no measured U on + # the reduced-data path → double pre-2002 / + # unknown-date family default (2.8), as code 3. + 8: (2.1, 0.68, 0.70), # Triple glazed, known data → triple unknown- + # date family default (2.1), as code 6. + 9: (2.0, 0.72, 0.70), # Triple glazed, 2002-2022 — Table 24 "Double + # or triple, 2002+ (pre-2022), any gap" (2.0). + 10: (2.1, 0.68, 0.70), # Triple glazed, pre-2002 — Table 24 Triple + # pre-2002 12mm-gap default (2.1). + 11: (2.9, 0.85, 0.70), # Secondary glazing, normal emissivity — + # Table 24 Secondary "Normal emissivity" (2.9). + 12: (2.2, 0.85, 0.70), # Secondary glazing, low emissivity — Table 24 + # Secondary "Low emissivity" (2.2). + 15: (4.8, 0.85, 0.70), # Single glazing, known data → Single row + # (4.8) when no measured U is lodged, as code 5. } @@ -4757,19 +4802,19 @@ _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[ (3, "16+"): (2.7, 0.76, 0.70), # Double glazed, known data (code 7) — aliases the double pre-2002 / # unknown-date Table 24 row (same as codes 1/3) when no measured U. - (7, 6): (3.1, 0.76, 0.70), - (7, 12): (2.8, 0.76, 0.70), + (7, 6): (3.1, 0.76, 0.70), + (7, 12): (2.8, 0.76, 0.70), (7, "16+"): (2.7, 0.76, 0.70), # Triple glazed pre-2002 / unknown / known-data (codes 6/8/10) — # Table 24 Triple pre-2002 row varies by gap (6mm=2.4, 12mm=2.1, 16+=2.0). - (6, 6): (2.4, 0.68, 0.70), - (6, 12): (2.1, 0.68, 0.70), - (6, "16+"): (2.0, 0.68, 0.70), - (8, 6): (2.4, 0.68, 0.70), - (8, 12): (2.1, 0.68, 0.70), - (8, "16+"): (2.0, 0.68, 0.70), - (10, 6): (2.4, 0.68, 0.70), - (10, 12): (2.1, 0.68, 0.70), + (6, 6): (2.4, 0.68, 0.70), + (6, 12): (2.1, 0.68, 0.70), + (6, "16+"): (2.0, 0.68, 0.70), + (8, 6): (2.4, 0.68, 0.70), + (8, 12): (2.1, 0.68, 0.70), + (8, "16+"): (2.0, 0.68, 0.70), + (10, 6): (2.4, 0.68, 0.70), + (10, 12): (2.1, 0.68, 0.70), (10, "16+"): (2.0, 0.68, 0.70), } @@ -5167,10 +5212,16 @@ def _api_type_2_surfaces( ] surfaces: List[SapRoomInRoofSurface] = [] gable_specs = ( - (type_2.gable_wall_type_1, type_2.gable_wall_length_1, - type_2.gable_wall_height_1), - (type_2.gable_wall_type_2, type_2.gable_wall_length_2, - type_2.gable_wall_height_2), + ( + type_2.gable_wall_type_1, + type_2.gable_wall_length_1, + type_2.gable_wall_height_1, + ), + ( + type_2.gable_wall_type_2, + type_2.gable_wall_length_2, + type_2.gable_wall_height_2, + ), ) for gable_type, length, height in gable_specs: # Length is mandatory; H may be 0 for the §3.9.2 absent-gable @@ -5237,23 +5288,32 @@ def _api_rir_detailed_surfaces( # insulation_type is left None so the cascade defers to the Table 17 # column (a) mineral-wool default, mirroring the flat_ceiling branch. slope_specs = ( - (details.slope_length_1, details.slope_height_1, - details.slope_insulation_thickness_1), - (details.slope_length_2, details.slope_height_2, - details.slope_insulation_thickness_2), + ( + details.slope_length_1, + details.slope_height_1, + details.slope_insulation_thickness_1, + ), + ( + details.slope_length_2, + details.slope_height_2, + details.slope_insulation_thickness_2, + ), ) stud_specs = ( - (details.stud_wall_length_1, details.stud_wall_height_1, - details.stud_wall_insulation_thickness_1), - (details.stud_wall_length_2, details.stud_wall_height_2, - details.stud_wall_insulation_thickness_2), + ( + details.stud_wall_length_1, + details.stud_wall_height_1, + details.stud_wall_insulation_thickness_1, + ), + ( + details.stud_wall_length_2, + details.stud_wall_height_2, + details.stud_wall_insulation_thickness_2, + ), ) for kind, specs in (("slope", slope_specs), ("stud_wall", stud_specs)): for length, height, thickness_str in specs: - if ( - length is not None and height is not None - and length > 0 and height > 0 - ): + if length is not None and height is not None and length > 0 and height > 0: area = _round_half_up_2dp(float(length), float(height)) surfaces.append( SapRoomInRoofSurface( @@ -5274,10 +5334,7 @@ def _api_rir_detailed_surfaces( (details.common_wall_length_2, details.common_wall_height_2), ) for length, height in common_wall_specs: - if ( - length is not None and height is not None - and length > 0 and height > 0 - ): + if length is not None and height is not None and length > 0 and height > 0: surfaces.append( SapRoomInRoofSurface( kind="common_wall", @@ -7388,7 +7445,8 @@ def _elmhurst_cylinder_insulation_code( def _elmhurst_immersion_type_code( - immersion_type_label: Optional[str], cylinder_present: bool, + immersion_type_label: Optional[str], + cylinder_present: bool, ) -> Optional[int]: """Map an Elmhurst §15.1 "Immersion Heater" label ("Dual" / "Single") to the SAP10 `immersion_heating_type` cascade code (1 = dual, 2 = diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 07fc7ddb6..40d179e9e 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -201,8 +201,14 @@ class TestFromRdSapSchema20_0_0: # dict-tolerant array reader captures the arrays. data = load("20_0_0.json") data["sap_energy_source"]["photovoltaic_supply"] = [ - [{"pitch": 2, "peak_power": {"value": 1.14, "quantity": "kW"}, - "orientation": 5, "overshading": 1}], + [ + { + "pitch": 2, + "peak_power": {"value": 1.14, "quantity": "kW"}, + "orientation": 5, + "overshading": 1, + } + ], [{"pitch": 2, "peak_power": 1.14, "orientation": 5, "overshading": 1}], ] schema = from_dict(RdSapSchema20_0_0, data) @@ -263,18 +269,30 @@ class TestFromRdSapSchema21_0_0: # the only one those certs omit is roof_insulation_thickness.) data = load("21_0_0.json") for window in data.get("sap_windows", []): - for k in ("frame_factor", "glazing_gap", "pvc_frame", - "window_transmission_details"): + for k in ( + "frame_factor", + "glazing_gap", + "pvc_frame", + "window_transmission_details", + ): window.pop(k, None) - for k in ("wet_rooms_count", "open_chimneys_count", - "pressure_test_certificate_number", "windows_transmission_details", - "mechanical_ventilation_index_number", "mechanical_vent_duct_type", - "mechanical_vent_duct_placement", "mechanical_vent_duct_insulation", - "mechanical_vent_duct_insulation_level", - "mechanical_vent_measured_installation", "insulated_door_u_value", - "low_energy_fixed_lighting_bulbs_count", - "cfl_fixed_lighting_bulbs_count", "led_fixed_lighting_bulbs_count", - "suggested_improvements"): + for k in ( + "wet_rooms_count", + "open_chimneys_count", + "pressure_test_certificate_number", + "windows_transmission_details", + "mechanical_ventilation_index_number", + "mechanical_vent_duct_type", + "mechanical_vent_duct_placement", + "mechanical_vent_duct_insulation", + "mechanical_vent_duct_insulation_level", + "mechanical_vent_measured_installation", + "insulated_door_u_value", + "low_energy_fixed_lighting_bulbs_count", + "cfl_fixed_lighting_bulbs_count", + "led_fixed_lighting_bulbs_count", + "suggested_improvements", + ): data.pop(k, None) data["sap_energy_source"].pop("wind_turbine_details", None) data["sap_energy_source"].pop("pv_battery_count", None) @@ -440,7 +458,9 @@ class TestFromRdSapSchema21_0_1: assert result.open_chimneys_count == 0 - def test_omitted_cfl_fixed_lighting_bulbs_count_defaults_to_zero_not_none(self) -> None: + def test_omitted_cfl_fixed_lighting_bulbs_count_defaults_to_zero_not_none( + self, + ) -> None: data = load("21_0_1.json") data.pop("cfl_fixed_lighting_bulbs_count", None) schema = from_dict(RdSapSchema21_0_1, data) @@ -449,7 +469,9 @@ class TestFromRdSapSchema21_0_1: assert result.cfl_fixed_lighting_bulbs_count == 0 - def test_omitted_led_fixed_lighting_bulbs_count_defaults_to_zero_not_none(self) -> None: + def test_omitted_led_fixed_lighting_bulbs_count_defaults_to_zero_not_none( + self, + ) -> None: data = load("21_0_1.json") data.pop("led_fixed_lighting_bulbs_count", None) schema = from_dict(RdSapSchema21_0_1, data) @@ -542,7 +564,8 @@ class TestFromRdSapSchema21_0_1: schema, sap_building_parts=[ dataclasses.replace( - bps[0], roof_insulation_location=1, + bps[0], + roof_insulation_location=1, rafter_insulation_thickness="225mm", ), *bps[1:], @@ -555,9 +578,7 @@ class TestFromRdSapSchema21_0_1: # Assert assert result.sap_building_parts[0].rafter_insulation_thickness == "225mm" - def test_cylinder_heat_loss_threaded( - self, schema: RdSapSchema21_0_1 - ) -> None: + def test_cylinder_heat_loss_threaded(self, schema: RdSapSchema21_0_1) -> None: # Arrange — the gov API lodges the manufacturer's declared cylinder # loss factor (kWh/day) in `sap_heating.cylinder_heat_loss` (SAP # 10.2 §4 branch a). Previously undeclared → `from_dict` dropped it @@ -656,7 +677,9 @@ class TestFromRdSapSchema21_0_1: def test_lighting_element_description(self, result: EpcPropertyData) -> None: assert result.lighting is not None - assert result.lighting.description == "Low energy lighting in 50% of fixed outlets" + assert ( + result.lighting.description == "Low energy lighting in 50% of fixed outlets" + ) def test_hot_water_element_description(self, result: EpcPropertyData) -> None: assert result.hot_water is not None @@ -676,7 +699,7 @@ class TestFromRdSapSchema21_0_1: def test_mains_gas(self, result: EpcPropertyData) -> None: # mains_gas: "Y" - assert result.sap_energy_source.mains_gas is True + assert result.sap_energy_source.gas_connection_available is True def test_electricity_smart_meter(self, result: EpcPropertyData) -> None: # electricity_smart_meter_present: "true" @@ -784,16 +807,27 @@ class TestFromRdSapSchema21_0_1: assert len(result.sap_building_parts[0].sap_floor_dimensions) == 1 def test_floor_area(self, result: EpcPropertyData) -> None: - assert result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 == 45.82 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 + == 45.82 + ) def test_floor_height(self, result: EpcPropertyData) -> None: - assert result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.45 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.45 + ) def test_heat_loss_perimeter(self, result: EpcPropertyData) -> None: - assert result.sap_building_parts[0].sap_floor_dimensions[0].heat_loss_perimeter_m == 19.5 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].heat_loss_perimeter_m + == 19.5 + ) def test_party_wall_length(self, result: EpcPropertyData) -> None: - assert result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m == 7.9 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m + == 7.9 + ) # --- room-in-roof (sap_room_in_roof.room_in_roof_type_1) --- @@ -899,7 +933,6 @@ class TestFromRdSapSchema21_0_1: assert rhi.impact_of_solid_wall_insulation_kwh == -3560.0 - # --------------------------------------------------------------------------- # Measured wall insulation thickness (`wall_insulation_thickness == "measured"`) # --------------------------------------------------------------------------- @@ -915,7 +948,9 @@ class TestApiWallConstructionCode: def test_gov_api_cob_code_9_remaps_to_wall_cob_7(self) -> None: # Arrange - from datatypes.epc.domain.mapper import _api_wall_construction_code # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_wall_construction_code, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_wall_construction_code(9) @@ -927,7 +962,9 @@ class TestApiWallConstructionCode: # Arrange — codes 1-5 already align; gov 8 (system built) is left # for u_wall to resolve (calc WALL_PARK_HOME=8 is never dispatched); # gov 6 (basement) is left to the basement machinery. - from datatypes.epc.domain.mapper import _api_wall_construction_code # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_wall_construction_code, + ) # pyright: ignore[reportPrivateUsage] # Act / Assert for code in (1, 2, 3, 4, 5, 6, 8): @@ -1015,9 +1052,7 @@ class TestApiResolveSlopingCeilingThickness: # Act — code 8, no loft-joist thickness, age B (pre-1950), but the # sloping ceiling carries a lodged 100 mm. - resolved: object = _api_resolve_sloping_ceiling_thickness( - 8, None, "B", "100mm" - ) + resolved: object = _api_resolve_sloping_ceiling_thickness(8, None, "B", "100mm") # Assert — the lodged sloping-ceiling thickness wins over the # pre-1950 None → 0 mm fallback. @@ -1032,9 +1067,7 @@ class TestApiResolveSlopingCeilingThickness: ) # Act — code 8, no thickness anywhere, pre-1950 age. - resolved: object = _api_resolve_sloping_ceiling_thickness( - 8, None, "C", None - ) + resolved: object = _api_resolve_sloping_ceiling_thickness(8, None, "C", None) # Assert — existing Slice 57 behaviour preserved: 0 mm (U=2.30). assert resolved == 0 @@ -1047,9 +1080,7 @@ class TestApiResolveSlopingCeilingThickness: # Act — code 8, age B (pre-1950), sloping lodged "AB" (As Built — # categorical, NOT a measured thickness). - resolved: object = _api_resolve_sloping_ceiling_thickness( - 8, None, "B", "AB" - ) + resolved: object = _api_resolve_sloping_ceiling_thickness(8, None, "B", "AB") # Assert — "AB" is not a numeric thickness, so it must NOT win; the # Slice 57 pre-1950 None → 0 mm (U=2.30) rule still applies. @@ -1088,9 +1119,7 @@ class TestElmhurstGlazingTypeWrappedGap: from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code # Act - code = _elmhurst_glazing_type_code( - "Double between 2002 and 2021 16 mm or" - ) + code = _elmhurst_glazing_type_code("Double between 2002 and 2021 16 mm or") # Assert — clean "Double between 2002 and 2021" → SAP10 code 3 assert code == 3 @@ -1100,9 +1129,7 @@ class TestElmhurstGlazingTypeWrappedGap: from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code # Act - code = _elmhurst_glazing_type_code( - "Double between 2002 and 2021 16 mm or 1st" - ) + code = _elmhurst_glazing_type_code("Double between 2002 and 2021 16 mm or 1st") # Assert assert code == 3 @@ -1129,7 +1156,9 @@ class TestApiFloorTypeCode: def test_code_6_maps_to_another_dwelling_below(self) -> None: # Arrange - from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_type_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_floor_type_str(6) @@ -1140,7 +1169,9 @@ class TestApiFloorTypeCode: def test_code_7_still_maps_to_ground_floor(self) -> None: # Arrange — regression guard: the ground-floor signal the §5 (12) # suspended-timber rule keys on is unchanged. - from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_type_str, + ) # pyright: ignore[reportPrivateUsage] # Act / Assert assert _api_floor_type_str(7) == "Ground floor" @@ -1158,7 +1189,9 @@ class TestApiFloorTypeCode: # (consumed by heat_transmission's party-floor override); it is # != "Ground floor", so the §5 (12) suspended-timber rule stays # inert. Pre-this, code 8 raised UnmappedApiCode, blocking the cert. - from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_type_str, + ) # pyright: ignore[reportPrivateUsage] # Act / Assert — no-heat-loss signal (not None, not "Ground floor"). assert _api_floor_type_str(8) == "(another dwelling below)" @@ -1171,7 +1204,9 @@ class TestApiFloorTypeCode: # constant U=0.7. The string is != "Ground floor" / "(another # dwelling below)", so it is inert metadata; the U-routing is driven # by the `is_above_partially_heated_space` floor-dimension flag. - from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_type_str, + ) # pyright: ignore[reportPrivateUsage] # Act / Assert assert _api_floor_type_str(3) == "(other premises below)" @@ -1182,7 +1217,9 @@ class TestApiFloorTypeCode: # only, so the cascade routes that floor to U=0.7 (§5.14) and the # heat-transmission step keeps its area even on a flat whose # dwelling-level exposure defaults has_exposed_floor=False. - from datatypes.epc.domain.mapper import _api_build_sap_floor_dimensions # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_build_sap_floor_dimensions, + ) # pyright: ignore[reportPrivateUsage] from datatypes.epc.schema.rdsap_schema_21_0_1 import ( SapFloorDimension as ApiSapFloorDimension, ) @@ -1217,7 +1254,9 @@ class TestApiFloorConstructionCode: def test_code_3_maps_to_suspended_not_timber(self) -> None: # Arrange - from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_construction_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_floor_construction_str(3) @@ -1230,7 +1269,9 @@ class TestApiFloorConstructionCode: def test_code_2_still_maps_to_suspended_timber(self) -> None: # Arrange — regression guard: the timber code is unchanged. - from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_construction_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_floor_construction_str(2) @@ -1249,7 +1290,9 @@ class TestApiFloorConstructionCode: # floor_construction. Empirically inert: floor W/K is identical to # any explicit construction string across all observed code-0 # certs (the heat loss is governed by floor_heat_loss, not this). - from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_construction_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_floor_construction_str(0) @@ -1267,7 +1310,10 @@ class TestDefaultMissingPostTown: def test_absent_post_town_is_defaulted_to_empty_string(self) -> None: # Arrange - from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _default_missing_post_town, + ) # pyright: ignore[reportPrivateUsage] + doc: dict[str, object] = {"postcode": "EX31 2LE"} # Act @@ -1278,7 +1324,10 @@ class TestDefaultMissingPostTown: def test_present_post_town_is_untouched(self) -> None: # Arrange — regression guard: a lodged town passes through. - from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _default_missing_post_town, + ) # pyright: ignore[reportPrivateUsage] + doc: dict[str, object] = {"post_town": "BARNSTAPLE"} # Act @@ -1303,7 +1352,9 @@ class TestApiRoofConstructionCode: def test_code_7_same_dwelling_above_maps_to_none(self) -> None: # Arrange - from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_roof_construction_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_roof_construction_str(7) @@ -1314,7 +1365,9 @@ class TestApiRoofConstructionCode: def test_code_6_thatched_maps_to_none(self) -> None: # Arrange - from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_roof_construction_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_roof_construction_str(6) @@ -1325,7 +1378,9 @@ class TestApiRoofConstructionCode: def test_code_8_still_maps_to_sloping_ceiling(self) -> None: # Arrange — regression guard: the sloping-ceiling code is unchanged. - from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_roof_construction_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_roof_construction_str(8) @@ -1345,7 +1400,9 @@ class TestApiGlazingTransmissionTable24: def test_single_glazing_code_5_is_table_24_u_4p8(self) -> None: # Arrange — RdSAP 21 glazing_type 5 = "single glazing"; Table 24 # row "Single / Any period" → U 4.8 (PVC/wooden), g 0.85. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(5, None) @@ -1356,7 +1413,9 @@ class TestApiGlazingTransmissionTable24: def test_single_glazing_known_data_code_15_is_table_24_u_4p8(self) -> None: # Arrange — code 15 = "single glazing, known data"; same Table 24 # Single row when no measured U is lodged on the reduced-data path. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(15, None) @@ -1367,7 +1426,9 @@ class TestApiGlazingTransmissionTable24: def test_secondary_glazing_normal_emissivity_code_11_is_u_2p9(self) -> None: # Arrange — code 11 = "secondary glazing, normal emissivity"; # Table 24 Secondary "Normal emissivity" row → U 2.9, g 0.85. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(11, None) @@ -1378,7 +1439,9 @@ class TestApiGlazingTransmissionTable24: def test_secondary_glazing_low_emissivity_code_12_is_u_2p2(self) -> None: # Arrange — code 12 = "secondary glazing, low emissivity"; Table 24 # Secondary "Low emissivity" row → U 2.2, g 0.85. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(12, None) @@ -1389,7 +1452,9 @@ class TestApiGlazingTransmissionTable24: def test_triple_glazing_2002_to_2022_code_9_is_u_2p0(self) -> None: # Arrange — code 9 = "triple glazing, installed 2002-2022"; Table 24 # "Double or triple, 2002+ (pre-2022), any gap" → U 2.0, g 0.72. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(9, None) @@ -1400,7 +1465,9 @@ class TestApiGlazingTransmissionTable24: def test_triple_glazing_pre_2002_code_10_default_gap_is_u_2p1(self) -> None: # Arrange — code 10 = "triple glazing, pre-2002"; Table 24 Triple # pre-2002 12mm-gap default → U 2.1, g 0.68. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(10, None) @@ -1412,7 +1479,9 @@ class TestApiGlazingTransmissionTable24: # Arrange — regression guard: the already-mapped double-glazing # 2002+ entry (U 2.0, g 0.72) is untouched by the single/secondary/ # triple extension. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(2, None) @@ -1433,7 +1502,9 @@ class TestApiCascadeGlazingCodeDivergentRemap: def test_single_glazing_code_5_remaps_to_cascade_single_slot_1(self) -> None: # Arrange — RdSAP-21 code 5 = single glazing; cascade single slot # is 1 (g⊥ 0.85, g_L 0.90), not cascade 5 (secondary, 0.76/0.80). - from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_cascade_glazing_type, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_cascade_glazing_type(5) @@ -1444,7 +1515,9 @@ class TestApiCascadeGlazingCodeDivergentRemap: def test_secondary_glazing_code_4_remaps_to_cascade_secondary_slot_5(self) -> None: # Arrange — RdSAP-21 code 4 = secondary glazing; cascade secondary # slot is 5 (g⊥ 0.76, g_L 0.80), not cascade 4 (double low-E 0.63). - from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_cascade_glazing_type, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_cascade_glazing_type(4) @@ -1454,7 +1527,9 @@ class TestApiCascadeGlazingCodeDivergentRemap: def test_double_pre_2002_code_1_remap_unchanged(self) -> None: # Arrange — regression guard: the existing code-1 remap (→2) stands. - from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_cascade_glazing_type, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_cascade_glazing_type(1) @@ -1465,7 +1540,9 @@ class TestApiCascadeGlazingCodeDivergentRemap: def test_rdsap21_native_codes_pass_through(self) -> None: # Arrange — codes 9-15 already coincide with the g-table's RdSAP-21 # extension slots, so they must pass through untranslated. - from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_cascade_glazing_type, + ) # pyright: ignore[reportPrivateUsage] # Act / Assert assert _api_cascade_glazing_type(14) == 14 @@ -1644,7 +1721,9 @@ class TestRdSap20_0_0ReducedFieldSynthesis: # 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] + from datatypes.epc.domain.mapper import ( + _api_sheltered_sides, + ) # pyright: ignore[reportPrivateUsage] corpus = _load_20_0_0_corpus() if not corpus: @@ -1653,7 +1732,8 @@ class TestRdSap20_0_0ReducedFieldSynthesis: ( c for c in corpus - if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1 + if not c.get("sap_windows") + and (c.get("open_fireplaces_count") or 0) >= 1 ), None, ) @@ -1691,12 +1771,13 @@ class TestRdSap20_0_0ReducedFieldSynthesis: 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" - ] + 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) @@ -1724,8 +1805,7 @@ class TestRdSap20_0_0ReducedFieldSynthesis: c for c in corpus if any( - "identifier" not in part - for part in c.get("sap_building_parts", []) + "identifier" not in part for part in c.get("sap_building_parts", []) ) ), None, @@ -1984,7 +2064,9 @@ class TestRdSap18_0ReducedFieldSynthesis: # 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] + from datatypes.epc.domain.mapper import ( + _api_sheltered_sides, + ) # pyright: ignore[reportPrivateUsage] corpus = _load_18_0_corpus() if not corpus: @@ -1993,7 +2075,8 @@ class TestRdSap18_0ReducedFieldSynthesis: ( c for c in corpus - if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1 + if not c.get("sap_windows") + and (c.get("open_fireplaces_count") or 0) >= 1 ), None, ) @@ -2031,12 +2114,13 @@ class TestRdSap18_0ReducedFieldSynthesis: 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" - ] + 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) @@ -2111,7 +2195,11 @@ class TestRdSap17_1ReducedFieldSynthesis: 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), + ( + c + for c in corpus + if not c.get("sap_windows") and c.get("glazed_area") == 1 + ), None, ) if cert is None: @@ -2132,7 +2220,11 @@ class TestRdSap17_1ReducedFieldSynthesis: 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), + ( + c + for c in corpus + if not c.get("sap_windows") and c.get("glazed_area") == 2 + ), None, ) if cert is None: @@ -2219,7 +2311,9 @@ class TestRdSap17_1ReducedFieldSynthesis: ) -> 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] + from datatypes.epc.domain.mapper import ( + _api_sheltered_sides, + ) # pyright: ignore[reportPrivateUsage] corpus = _load_17_1_corpus() if not corpus: @@ -2228,7 +2322,8 @@ class TestRdSap17_1ReducedFieldSynthesis: ( c for c in corpus - if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1 + if not c.get("sap_windows") + and (c.get("open_fireplaces_count") or 0) >= 1 ), None, ) @@ -2261,12 +2356,13 @@ class TestRdSap17_1ReducedFieldSynthesis: 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" - ] + 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) @@ -2314,16 +2410,22 @@ class TestRoomInRoofDetailedSlopeAndStudWall: rir = cert["sap_building_parts"][0]["sap_room_in_roof"] rir.pop("room_in_roof_type_1", None) rir["room_in_roof_details"] = { - "slope_length_1": 7.0, "slope_height_1": 1.4, - "slope_length_2": 7.0, "slope_height_2": 1.4, + "slope_length_1": 7.0, + "slope_height_1": 1.4, + "slope_length_2": 7.0, + "slope_height_2": 1.4, "slope_insulation_thickness_1": "100mm", "slope_insulation_thickness_2": "100mm", - "stud_wall_length_1": 7.0, "stud_wall_height_1": 1.03, - "stud_wall_length_2": 7.0, "stud_wall_height_2": 1.03, + "stud_wall_length_1": 7.0, + "stud_wall_height_1": 1.03, + "stud_wall_length_2": 7.0, + "stud_wall_height_2": 1.03, "stud_wall_insulation_thickness_1": "75mm", "stud_wall_insulation_thickness_2": "75mm", - "common_wall_length_1": 8.6, "common_wall_height_1": 1.2, - "common_wall_length_2": 8.6, "common_wall_height_2": 1.2, + "common_wall_length_1": 8.6, + "common_wall_height_1": 1.2, + "common_wall_length_2": 8.6, + "common_wall_height_2": 1.2, } # Act @@ -2369,12 +2471,16 @@ class TestRoomInRoofType2SimplifiedQuadratic: rir = cert["sap_building_parts"][0]["sap_room_in_roof"] rir.pop("room_in_roof_type_1", None) rir["room_in_roof_type_2"] = { - "gable_wall_type_1": 1, "gable_wall_length_1": 10.0, + "gable_wall_type_1": 1, + "gable_wall_length_1": 10.0, "gable_wall_height_1": 2.0, - "gable_wall_type_2": 0, "gable_wall_length_2": 6.0, + "gable_wall_type_2": 0, + "gable_wall_length_2": 6.0, "gable_wall_height_2": 2.0, - "common_wall_length_1": 8.0, "common_wall_height_1": 1.0, - "common_wall_length_2": 8.0, "common_wall_height_2": 1.0, + "common_wall_length_1": 8.0, + "common_wall_height_1": 1.0, + "common_wall_length_2": 8.0, + "common_wall_height_2": 1.0, } # Act @@ -2505,7 +2611,9 @@ class TestNonSeparatedConservatoryApiMirror19_0: ) dwelling_part_count = len( - EpcPropertyDataMapper.from_api_response(load("19_0.json")).sap_building_parts + EpcPropertyDataMapper.from_api_response( + load("19_0.json") + ).sap_building_parts ) cert = load("19_0.json") @@ -2568,7 +2676,9 @@ class TestNonSeparatedConservatoryApiMirror20_0_0: ) dwelling_part_count = len( - EpcPropertyDataMapper.from_api_response(load("20_0_0.json")).sap_building_parts + EpcPropertyDataMapper.from_api_response( + load("20_0_0.json") + ).sap_building_parts ) cert = load("20_0_0.json") @@ -2637,7 +2747,12 @@ def test_gov_mappers_split_non_separated_conservatory(fixture: str) -> None: cert = load(fixture) cert["conservatory_type"] = 4 cert["sap_building_parts"].append( - {"floor_area": 12.0, "room_height": 1, "double_glazed": "Y", "glazed_perimeter": 9.0} + { + "floor_area": 12.0, + "room_height": 1, + "double_glazed": "Y", + "glazed_perimeter": 9.0, + } ) # Act @@ -2709,9 +2824,17 @@ def test_rdsap_mappers_carry_main_heating_controls_display( [ (RdSapSchema17_1, EpcPropertyDataMapper.from_rdsap_schema_17_1, "17_1.json"), (RdSapSchema18_0, EpcPropertyDataMapper.from_rdsap_schema_18_0, "18_0.json"), - (RdSapSchema20_0_0, EpcPropertyDataMapper.from_rdsap_schema_20_0_0, "20_0_0.json"), + ( + RdSapSchema20_0_0, + EpcPropertyDataMapper.from_rdsap_schema_20_0_0, + "20_0_0.json", + ), (RdSapSchema19_0, EpcPropertyDataMapper.from_rdsap_schema_19_0, "19_0.json"), - (RdSapSchema21_0_0, EpcPropertyDataMapper.from_rdsap_schema_21_0_0, "21_0_0.json"), + ( + RdSapSchema21_0_0, + EpcPropertyDataMapper.from_rdsap_schema_21_0_0, + "21_0_0.json", + ), ], ) def test_rdsap_mappers_map_mechanical_ventilation_kind( @@ -2738,7 +2861,11 @@ def test_rdsap_mappers_map_mechanical_ventilation_kind( "schema_cls, mapper, fixture", [ (RdSapSchema19_0, EpcPropertyDataMapper.from_rdsap_schema_19_0, "19_0.json"), - (RdSapSchema21_0_0, EpcPropertyDataMapper.from_rdsap_schema_21_0_0, "21_0_0.json"), + ( + RdSapSchema21_0_0, + EpcPropertyDataMapper.from_rdsap_schema_21_0_0, + "21_0_0.json", + ), ], ) def test_rdsap_19_0_21_0_0_construct_sap_ventilation_block( diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index 957cc4a44..b561393cf 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -177,12 +177,15 @@ class TestFullSapElectricityTariffTranslation: @pytest.mark.parametrize( ("energy_tariff", "expected"), [ - (1, "STANDARD"), # standard tariff → single-rate (was over-rated as E7) - (2, "SEVEN_HOUR"), # off-peak 7-hour → Economy 7 - (3, "SEVEN_HOUR"), # off-peak 10-hour → dual (§12 resolves 7/10hr) - (4, "TWENTY_FOUR_HOUR"), # 24-hour tariff (e.g. property 709874 — unchanged) - (5, "EIGHTEEN_HOUR"), # off-peak 18-hour — was dropped to "" → STANDARD - (None, "STANDARD"), # absent/ND → unknown → standard + (1, "STANDARD"), # standard tariff → single-rate (was over-rated as E7) + (2, "SEVEN_HOUR"), # off-peak 7-hour → Economy 7 + (3, "SEVEN_HOUR"), # off-peak 10-hour → dual (§12 resolves 7/10hr) + ( + 4, + "TWENTY_FOUR_HOUR", + ), # 24-hour tariff (e.g. property 709874 — unchanged) + (5, "EIGHTEEN_HOUR"), # off-peak 18-hour — was dropped to "" → STANDARD + (None, "STANDARD"), # absent/ND → unknown → standard ], ) def test_energy_tariff_resolves_to_the_correct_calculator_tariff( @@ -284,8 +287,12 @@ class TestFromSapSchema17_1FabricDescriptions: def test_wall_description_carries_measured_u(self, sample: EpcPropertyData) -> None: assert sample.walls[0].description == "Average thermal transmittance 0.17 W/m²K" - def test_floor_description_carries_measured_u(self, sample: EpcPropertyData) -> None: - assert sample.floors[0].description == "Average thermal transmittance 0.13 W/m²K" + def test_floor_description_carries_measured_u( + self, sample: EpcPropertyData + ) -> None: + assert ( + sample.floors[0].description == "Average thermal transmittance 0.13 W/m²K" + ) def test_roof_description_carries_other_premises( self, sample: EpcPropertyData @@ -505,7 +512,7 @@ class TestFromSapSchema17_1EnergySource: def test_mains_gas_derived_from_heating_fuel(self, sample: EpcPropertyData) -> None: # Main heating fuel is mains gas (code 1) → mains_gas True. - assert sample.sap_energy_source.mains_gas is True + assert sample.sap_energy_source.gas_connection_available is True def test_lighting_outlet_counts_to_bulbs(self, sample: EpcPropertyData) -> None: # 1 fixed outlet, 1 low-energy → 1 low-energy bulb, 0 incandescent. @@ -543,7 +550,9 @@ class TestFromSapSchema17_1Ventilation: schema = from_dict(SapSchema17_1, load("sap_17_1.json")) return EpcPropertyDataMapper.from_sap_schema_17_1(schema) - def test_measured_air_permeability_fed_as_ap50(self, sample: EpcPropertyData) -> None: + def test_measured_air_permeability_fed_as_ap50( + self, sample: EpcPropertyData + ) -> None: # The lodged `air_permeability` is a q50 Blower-Door result, so it feeds the # engine's AP50 path `(18) = AP50/20 + (8)` — NOT the AP4/Pulse formula # `0.263 × AP4^0.924` (the air-permeability AP50 fix, uprn_10093116528). @@ -554,7 +563,10 @@ class TestFromSapSchema17_1Ventilation: def test_ventilation_type_6_is_extract(self, sample: EpcPropertyData) -> None: # ventilation_type 6 = MEV decentralised → EXTRACT_OR_PIV_OUTSIDE. assert sample.sap_ventilation is not None - assert sample.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE" + assert ( + sample.sap_ventilation.mechanical_ventilation_kind + == "EXTRACT_OR_PIV_OUTSIDE" + ) def test_sheltered_sides_and_wet_rooms(self, sample: EpcPropertyData) -> None: assert sample.sap_ventilation is not None @@ -649,14 +661,18 @@ class TestFromSapSchema17_1Perimeter: # measured exposed-wall area 87.85 m² (uniform per-storey split). result = self._map("sap_17_1_house.json") fds = result.sap_building_parts[0].sap_floor_dimensions - gross = sum((fd.heat_loss_perimeter_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds) + gross = sum( + (fd.heat_loss_perimeter_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds + ) assert gross == pytest.approx(87.85, abs=1e-2) def test_house_routes_party_walls_to_party_length(self) -> None: # Σ(party_length_i × height_i) must equal the measured party-wall area. result = self._map("sap_17_1_house.json") fds = result.sap_building_parts[0].sap_floor_dimensions - party = sum((fd.party_wall_length_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds) + party = sum( + (fd.party_wall_length_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds + ) assert party == pytest.approx(105.44, abs=1e-2) def test_unknown_wall_type_fails_loud(self) -> None: @@ -1009,5 +1025,7 @@ class TestFullSapSchema16xNoFloorDimensions: data.pop("sap_flat_details", None) # Act / Assert - with pytest.raises(ValueError, match="Wardalls Grove.*SE14 5FB.*sap_floor_dimensions"): + with pytest.raises( + ValueError, match="Wardalls Grove.*SE14 5FB.*sap_floor_dimensions" + ): EpcPropertyDataMapper.from_api_response(data) diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 289d415a2..5cb29b698 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -103,7 +103,7 @@ class TestFromSiteNotesExample1: def test_mains_gas_available(self, result: EpcPropertyData) -> None: # general.mains_gas_available: true - assert result.sap_energy_source.mains_gas is True + assert result.sap_energy_source.gas_connection_available is True def test_electricity_smart_meter(self, result: EpcPropertyData) -> None: # general.electricity_smart_meter: true @@ -393,7 +393,9 @@ class TestFromSiteNotesExample1: cylinder_insulation_type="Factory fitted", cylinder_insulation_thickness_mm=12, shower_outlets=ShowerOutlets( - shower_outlet=ShowerOutlet(shower_outlet_type="Non-Electric Shower"), + shower_outlet=ShowerOutlet( + shower_outlet_type="Non-Electric Shower" + ), ), ), # Windows @@ -453,7 +455,7 @@ class TestFromSiteNotesExample1: ], # Energy source sap_energy_source=SapEnergySource( - mains_gas=True, + gas_connection_available=True, meter_type="Single", pv_battery_count=0, wind_turbines_count=0, @@ -620,7 +622,10 @@ class TestFromSiteNotesFloorConstruction: def test_floor_construction_type(self, result: EpcPropertyData) -> None: # building_construction.floor.floor_construction: "Suspended, not timber" - assert result.sap_building_parts[0].floor_construction_type == "Suspended, not timber" + assert ( + result.sap_building_parts[0].floor_construction_type + == "Suspended, not timber" + ) def test_floor_insulation_type_str(self, result: EpcPropertyData) -> None: # building_construction.floor.floor_insulation_type: "As Built" @@ -657,7 +662,10 @@ class TestFromSiteNotesHeatingBoiler: def test_central_heating_pump_age_str(self, result: EpcPropertyData) -> None: # heating_and_hot_water.main_heating.central_heating_pump_age: "Unknown" - assert result.sap_heating.main_heating_details[0].central_heating_pump_age_str == "Unknown" + assert ( + result.sap_heating.main_heating_details[0].central_heating_pump_age_str + == "Unknown" + ) class TestFromSiteNotesMiscTopLevel: diff --git a/domain/epc/property_overlays/main_fuel_overlay.py b/domain/epc/property_overlays/main_fuel_overlay.py index 25e28247f..770ebb06f 100644 --- a/domain/epc/property_overlays/main_fuel_overlay.py +++ b/domain/epc/property_overlays/main_fuel_overlay.py @@ -53,5 +53,5 @@ def fuel_overlay_for( return None mains_gas = True if main_fuel_value in _MAINS_GAS_FUEL_VALUES else None return EpcSimulation( - heating=HeatingOverlay(main_fuel_type=code, mains_gas=mains_gas) + heating=HeatingOverlay(main_fuel_type=code, gas_connection_available=mains_gas) ) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 6069b7a49..d14f9e79c 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -251,7 +251,7 @@ def _gas_boiler_overlay(code: int) -> HeatingOverlay: sap_main_heating_code=code, main_heating_category=_GAS_BOILER_CATEGORY, main_fuel_type=_MAINS_GAS_FUEL, - mains_gas=True, + gas_connection_available=True, main_heating_control=_FULL_BOILER_CONTROL, fan_flue_present=True, meter_type=_SINGLE_RATE_METER, diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index d9564f069..4848a0f74 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -124,7 +124,7 @@ _ASHP_OVERLAY = HeatingOverlay( cylinder_thermostat="Y", has_hot_water_cylinder=True, meter_type="Single", - mains_gas=False, + gas_connection_available=False, ) @@ -462,7 +462,7 @@ def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool: return False if code in _ELECTRIC_BOILER_SAP_CODE_RANGE: return False - if not epc.sap_energy_source.mains_gas: + if not epc.sap_energy_source.gas_connection_available: return False if main.main_fuel_type in _GAS_FUEL_CODES and _already_condensing(code): return False @@ -514,9 +514,7 @@ def _boiler_cylinder_overlay(epc: EpcPropertyData) -> HeatingOverlay: if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm): jacket_type = _CYLINDER_JACKET_INSULATION_TYPE jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM - thermostat: Optional[str] = ( - "Y" if sap_heating.cylinder_thermostat != "Y" else None - ) + thermostat: Optional[str] = "Y" if sap_heating.cylinder_thermostat != "Y" else None return HeatingOverlay( main_fuel_type=_MAINS_GAS_FUEL, heat_emitter_type=_RADIATOR_EMITTER, @@ -584,9 +582,11 @@ def _control_features(main: MainHeatingDetail) -> tuple[bool, bool, bool]: the control is improvable anyway.""" control = main.main_heating_control code: Optional[int] = control if isinstance(control, int) else None - return _CONTROL_FEATURES_BY_CODE.get(code, (False, False, False)) if ( - code is not None - ) else (False, False, False) + return ( + _CONTROL_FEATURES_BY_CODE.get(code, (False, False, False)) + if (code is not None) + else (False, False, False) + ) def _cylinder_fix_needs(epc: EpcPropertyData) -> tuple[bool, bool]: diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 401aaf27b..761fa31a3 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -192,7 +192,7 @@ class HeatingOverlay: has_hot_water_cylinder: Optional[bool] = None # sap_energy_source meter_type: Optional[str] = None - mains_gas: Optional[bool] = None + gas_connection_available: Optional[bool] = None # A landlord heating-system override DESCRIBES the existing dwelling, so its # assumed off-peak (`meter_type`) is a coherent default, not a re-metering: # it must not downgrade a cert that already lodges a MORE off-peak meter diff --git a/domain/sap10_ml/tests/_fixtures.py b/domain/sap10_ml/tests/_fixtures.py index d3fae7e21..0e0e83c95 100644 --- a/domain/sap10_ml/tests/_fixtures.py +++ b/domain/sap10_ml/tests/_fixtures.py @@ -111,9 +111,11 @@ def make_sap_heating( """Build a SapHeating with SAP10 API defaults.""" return SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), - main_heating_details=main_heating_details - if main_heating_details is not None - else [make_main_heating_detail()], + main_heating_details=( + main_heating_details + if main_heating_details is not None + else [make_main_heating_detail()] + ), has_fixed_air_conditioning=has_fixed_air_conditioning, water_heating_code=water_heating_code, water_heating_fuel=water_heating_fuel, @@ -173,9 +175,11 @@ def make_building_part( party_wall_construction=party_wall_construction, roof_construction=roof_construction, roof_insulation_location=roof_insulation_location, - sap_floor_dimensions=floor_dimensions - if floor_dimensions is not None - else [make_floor_dimension()], + sap_floor_dimensions=( + floor_dimensions + if floor_dimensions is not None + else [make_floor_dimension()] + ), sap_room_in_roof=sap_room_in_roof, floor_type=floor_type, ) @@ -183,7 +187,9 @@ def make_building_part( def make_window( *, - orientation: Union[int, str] = 5, # SAP10: 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW + orientation: Union[ + int, str + ] = 5, # SAP10: 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW width: float = 1.0, height: float = 1.0, draught_proofed: bool = True, @@ -195,8 +201,8 @@ def make_window( permanent_shutters_present: Union[bool, str] = False, frame_material: Optional[str] = "PVC", frame_factor: Optional[float] = 0.7, # SAP10.2 Table 6c PVC default; - # mirrors the Elmhurst mapper's - # surfaced value from Summary §11. + # mirrors the Elmhurst mapper's + # surfaced value from Summary §11. window_transmission_details: Optional[WindowTransmissionDetails] = None, solar_transmittance: Optional[float] = None, u_value: float = 2.8, @@ -210,7 +216,9 @@ def make_window( """ if window_transmission_details is None and solar_transmittance is not None: window_transmission_details = WindowTransmissionDetails( - u_value=u_value, data_source=1, solar_transmittance=solar_transmittance, + u_value=u_value, + data_source=1, + solar_transmittance=solar_transmittance, ) return SapWindow( frame_material=frame_material, @@ -297,17 +305,21 @@ def make_minimal_sap10_epc( floors=[], main_heating=[], door_count=door_count, - sap_heating=sap_heating if sap_heating is not None else SapHeating( - instantaneous_wwhrs=InstantaneousWwhrs(), - main_heating_details=[], - has_fixed_air_conditioning=False, + sap_heating=( + sap_heating + if sap_heating is not None + else SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[], + has_fixed_air_conditioning=False, + ) ), sap_windows=list(sap_windows) if sap_windows is not None else [], sap_roof_windows=( list(sap_roof_windows) if sap_roof_windows is not None else None ), sap_energy_source=SapEnergySource( - mains_gas=mains_gas, + gas_connection_available=mains_gas, meter_type="Single", pv_connection=pv_connection, pv_battery_count=pv_battery_count, @@ -316,9 +328,9 @@ def make_minimal_sap10_epc( is_dwelling_export_capable=is_dwelling_export_capable, wind_turbines_terrain_type="Suburban", electricity_smart_meter_present=electricity_smart_meter_present, - photovoltaic_arrays=list(photovoltaic_arrays) - if photovoltaic_arrays is not None - else None, + photovoltaic_arrays=( + list(photovoltaic_arrays) if photovoltaic_arrays is not None else None + ), photovoltaic_supply=( PhotovoltaicSupply( none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( @@ -343,7 +355,9 @@ def make_minimal_sap10_epc( else None ), ), - sap_building_parts=list(sap_building_parts) if sap_building_parts is not None else [], + sap_building_parts=( + list(sap_building_parts) if sap_building_parts is not None else [] + ), solar_water_heating=solar_water_heating, has_hot_water_cylinder=has_hot_water_cylinder, has_fixed_air_conditioning=has_fixed_air_conditioning, diff --git a/domain/sap10_ml/transform.py b/domain/sap10_ml/transform.py index 964ea1beb..ee34fcd64 100644 --- a/domain/sap10_ml/transform.py +++ b/domain/sap10_ml/transform.py @@ -37,11 +37,13 @@ from domain.sap10_ml.ecf import ( ) from domain.sap10_ml.envelope import envelope_heat_loss_w_per_k from domain.sap10_ml.ventilation import ventilation_heat_loss_w_per_k -from domain.sap10_ml.sap_efficiencies import seasonal_efficiency, water_heating_efficiency +from domain.sap10_ml.sap_efficiencies import ( + seasonal_efficiency, + water_heating_efficiency, +) from domain.sap10_ml.schema import ColumnSpec, TransformSchema from domain.sap10_ml.ucl import apply_ucl_correction - # SAP10 orientation codes: 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW. # Anything else (0, "NR", etc.) is treated as unrecorded — it contributes to # `window_count` and `window_total_area_m2` but to no octant. @@ -60,7 +62,23 @@ _OCTANT_NAMES: dict[int, str] = { # datatypes/epc/domain/epc_codes.csv, schema RdSAP-21.0.x). Anything outside this set # (the documentation "ND" sentinel, future codes, or unexpected strings) falls into # the `_other` bucket so share columns always sum to 1.0 of total window area. -_GLAZED_TYPE_CODES: tuple[int, ...] = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15) +_GLAZED_TYPE_CODES: tuple[int, ...] = ( + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, +) _FEATURE_COLUMNS: dict[str, ColumnSpec] = { @@ -242,169 +260,227 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { # Building parts — Main Dwelling carve-out (none of these are populated if the # property has no part identified as 'Main Dwelling') "main_dwelling_heat_loss_perimeter_m": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Heat-loss perimeter (m) for the Main Dwelling only.", ), "main_dwelling_party_wall_length_m": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Party-wall length (m) for the Main Dwelling only.", ), "main_dwelling_total_floor_area_m2": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Total floor area (m²) for the Main Dwelling only.", ), "main_dwelling_avg_room_height_m": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Floor-area-weighted mean room height (m) for the Main Dwelling.", ), "main_dwelling_has_room_in_roof": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="True if the Main Dwelling carries a sap_room_in_roof block.", ), "main_dwelling_construction_age_band": ColumnSpec( - dtype=str, nullable=True, categorical=True, + dtype=str, + nullable=True, + categorical=True, description="Main Dwelling construction age band (A-M, '0', or 'NR').", ), "main_dwelling_wall_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling wall construction SAP10 code.", ), "main_dwelling_roof_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling roof construction SAP10 code.", ), # Main Dwelling fabric inputs — wall, roof, floor (model retrofit simulation surface). "main_dwelling_wall_insulation_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling wall insulation type SAP10 code.", ), "main_dwelling_wall_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Main Dwelling wall insulation thickness in mm. 'NI' (no insulation) maps to 0.", ), "main_dwelling_wall_dry_lined": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Main Dwelling wall_dry_lined flag.", ), "main_dwelling_wall_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Main Dwelling external wall thickness in mm.", ), "main_dwelling_party_wall_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling party wall construction SAP10 code (str sentinels NA/NI -> None).", ), "main_dwelling_roof_insulation_location": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling roof insulation location SAP10 code (str sentinels -> None).", ), "main_dwelling_roof_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Main Dwelling roof insulation thickness in mm. 'NI' -> 0; non-numeric sentinels -> None.", ), "main_dwelling_floor_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling ground-floor construction SAP10 code (from sap_floor_dimensions[floor==0]).", ), "main_dwelling_floor_insulation": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling ground-floor insulation SAP10 code (from sap_floor_dimensions[floor==0]).", ), "main_dwelling_floor_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Main Dwelling floor insulation thickness in mm. 'NI' -> 0; non-numeric sentinels -> None.", ), "main_dwelling_floor_heat_loss": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling floor heat-loss SAP10 code.", ), # Heating — count of main heating systems (usually 1) "main_heating_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of main heating systems declared on sap_heating.main_heating_details.", ), # Heating — primary (Top-1) slot from main_heating_details[0] "primary_main_fuel_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating main_fuel SAP10 code (per epc_codes.csv main_fuel enum).", ), "primary_heat_emitter_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating heat_emitter_type SAP10 code.", ), "primary_main_heating_control": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating main_heating_control SAP10 code.", ), "primary_main_heating_category": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating main_heating_category SAP10 code.", ), "primary_has_fghrs": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Primary heating has flue gas heat recovery system.", ), "primary_fan_flue_present": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Primary heating boiler has a fan flue.", ), "primary_boiler_flue_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating boiler flue type SAP10 code.", ), "primary_central_heating_pump_age": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating central-heating pump age band (SAP10 enum).", ), # Water heating — on sap_heating directly "water_heating_code": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Water heating SAP10 code.", ), "water_heating_fuel": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Water heating fuel SAP10 code (per epc_codes.csv water_heating_fuel enum).", ), "cylinder_size": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Hot water cylinder size SAP10 code (1=small, 2=normal, 3=large).", ), "cylinder_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Hot water cylinder insulation thickness (mm).", ), # Secondary heating — present when secondary_fuel_type is set "has_secondary_heating": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="True if sap_heating.secondary_fuel_type is populated.", ), "secondary_fuel_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Secondary heating fuel SAP10 code (shares main_fuel enum).", ), # PV — has-pv + measured-vs-estimated capacity + array aggregates "has_pv": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="True if the property has any photovoltaic system (measured or estimated).", ), "pv_capacity_source": ColumnSpec( - dtype=str, nullable=False, categorical=True, + dtype=str, + nullable=False, + categorical=True, description=( "How PV capacity is known: 'measured' (per-array peak_power available), " "'estimated_from_roof_area' (only percent_roof_area), or 'none'." ), ), "pv_array_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of measured PV arrays (0 unless capacity_source is 'measured').", ), "pv_total_peak_power_kw": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description="Sum of peak_power (kW) across measured PV arrays.", ), **{ f"pv_peak_power_kw_{name}": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( f"Sum of peak_power (kW) for measured PV arrays facing {name} " "(SAP orientation code)." @@ -413,359 +489,463 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { for name in _OCTANT_NAMES.values() }, "pv_avg_pitch": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Peak-power-weighted mean array pitch (SAP code); null when no measured arrays.", ), "pv_avg_overshading": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Peak-power-weighted mean overshading (SAP code); null when no measured arrays.", ), "pv_percent_roof_area": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Percent of roof covered by PV — populated only when capacity_source = 'estimated_from_roof_area'.", ), # PV battery, wind turbine, energy source flags "has_pv_battery": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="True if the property has at least one PV battery.", ), "pv_battery_count": ColumnSpec( dtype=int, nullable=False, description="Number of PV batteries." ), "pv_battery_capacity_kwh": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description=( "Total PV battery capacity (kWh) — pv_battery_count × per-unit capacity " "from sap_energy_source.pv_batteries. Null when count=0." ), ), "has_wind_turbine": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="True if the property has at least one wind turbine.", ), "wind_turbine_count": ColumnSpec( dtype=int, nullable=False, description="Number of wind turbines." ), "mains_gas": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="Property is connected to mains gas (strong fuel-deduction signal).", ), "electricity_smart_meter_present": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="Electricity smart meter installed.", ), "gas_smart_meter_present": ColumnSpec( dtype=bool, nullable=False, description="Gas smart meter installed." ), "is_dwelling_export_capable": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="Dwelling has an export-capable connection (eligible for SEG).", ), # Ventilation — flat fields direct off EpcPropertyData "mechanical_ventilation": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Mechanical ventilation SAP10 code (0=natural, 1-6 per epc_codes.csv enum).", ), "mechanical_vent_duct_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Mechanical ventilation duct type SAP10 code.", ), "blocked_chimneys_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of blocked / capped-off chimneys.", ), "pressure_test": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Air-tightness pressure-test SAP10 code.", ), # Dwelling-level fabric + demand inputs. "multiple_glazed_proportion": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Percent of glazed area that is multiple-glazed.", ), "number_baths": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of baths declared on sap_heating (hot-water demand proxy).", ), "number_baths_wwhrs": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of baths served by a WWHRS unit.", ), "extract_fans_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of extract fans (ventilation/heat-loss proxy).", ), # Heating — heating-system identity + flow temp + multi-system fraction. "primary_sap_main_heating_code": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="SAP10 main heating type code (canonical heating-system enum).", ), "primary_emitter_temperature": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating emitter temperature class (0=standard, 1=low-temp).", ), "primary_main_heating_fraction": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Fraction of space heating delivered by the primary main heating system.", ), # Hot water — immersion type + presence of shower outlet block. "immersion_heating_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Electric immersion heater type SAP10 code.", ), "shower_outlet_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="1 if any shower_outlet block is declared on sap_heating, else 0.", ), # Windows — per-window-type share aggregates. "window_pct_living": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Area share of windows with window_type == 1 (living room).", ), "window_pct_external": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Area share of windows with window_location == 0 (external).", ), "window_pct_permanent_shutters": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Area share of windows with permanent_shutters_present truthy.", ), # Dwelling — conservatory + flat-only block. "conservatory_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Conservatory SAP10 code (1=none, 2=heated, 3=unheated, ...).", ), "has_heated_separate_conservatory": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Whether the dwelling has a heated separate conservatory.", ), "flat_level": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Flat-only: floor number on which the flat sits.", ), "flat_top_storey": ColumnSpec( - dtype=str, nullable=True, categorical=True, + dtype=str, + nullable=True, + categorical=True, description="Flat-only: Y/N flag indicating whether this is the top storey.", ), "flat_storey_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Flat-only: storey count of the building containing the flat.", ), "flat_location": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Flat-only: location SAP10 code (corner/middle/...).", ), "flat_heat_loss_corridor": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Flat-only: heat-loss-corridor SAP10 code.", ), # Energy supply categoricals. "meter_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Electricity meter type SAP10 code (1=Standard, 2=Off-peak, ...).", ), "pv_connection": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="PV connection topology SAP10 code.", ), "wind_turbines_terrain_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Wind-turbine terrain type SAP10 code.", ), # Doors. "draughtproofed_door_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of draught-proofed doors.", ), "insulated_door_u_value": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="U-value of insulated doors (W/m^2K).", ), # Hot water extras. "cylinder_insulation_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Cylinder insulation type SAP10 code (string sentinels -> None).", ), "cylinder_thermostat": ColumnSpec( - dtype=str, nullable=True, categorical=True, + dtype=str, + nullable=True, + categorical=True, description="Cylinder-thermostat flag (Y/N/missing).", ), "secondary_heating_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Secondary heating type SAP10 code (distinct from secondary_fuel_type).", ), # Mechanical ventilation extras. "mechanical_vent_duct_placement": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Mechanical-vent duct placement SAP10 code.", ), "mechanical_vent_duct_insulation": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Mechanical-vent duct insulation SAP10 code.", ), "mechanical_vent_duct_insulation_level": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Mechanical-vent duct insulation level SAP10 code.", ), "mechanical_vent_measured_installation": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Whether mechanical ventilation was measured at installation.", ), # Lighting extras. "low_energy_fixed_lighting_bulbs_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of low-energy fixed-lighting bulbs (separate from CFL/LED).", ), "fixed_lighting_outlets_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Total number of fixed-lighting outlets.", ), "low_energy_fixed_lighting_outlets_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of low-energy fixed-lighting outlets.", ), # Window extras (per-window scalars area-weighted across windows). "window_avg_glazing_gap_mm": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Area-weighted average glazing gap in mm (non-numeric sentinels excluded).", ), "window_avg_frame_factor": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Area-weighted average frame factor across windows.", ), "window_pct_permanent_shutters_insulated": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Area share of windows with permanent_shutters_insulated == 'Y'.", ), # Main-dwelling extras: room-in-roof + alternative walls + flat-roof + measured flag. "main_dwelling_room_in_roof_floor_area_m2": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Floor area of main dwelling room-in-roof block (when present).", ), "main_dwelling_alternative_wall_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of sap_alternative_wall_* blocks on the main dwelling (0-2).", ), "main_dwelling_alternative_wall_area_m2": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description="Sum of sap_alternative_wall_*.wall_area for the main dwelling.", ), "main_dwelling_flat_roof_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Main dwelling flat-roof insulation thickness in mm (rare).", ), "main_dwelling_wall_thickness_measured": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Main dwelling wall_thickness_measured flag.", ), # Element list counts (split-fabric discriminator). "wall_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of entries in the top-level walls EnergyElement list.", ), "roof_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of entries in the top-level roofs EnergyElement list.", ), "floor_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of entries in the top-level floors EnergyElement list.", ), "main_heating_count_elements": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of entries in the top-level main_heating EnergyElement list.", ), "main_heating_controls_present": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="Whether the cert carries a main_heating_controls EnergyElement.", ), # Wind turbine geometry. "wind_turbine_hub_height_m": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Hub height of the (first) wind turbine, metres.", ), "wind_turbine_rotor_diameter_m": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Rotor diameter of the (first) wind turbine, metres.", ), # Flat extras. "flat_unheated_corridor_length_m": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Flat-only: length (m) of any unheated corridor adjacent to the dwelling.", ), # Addendum (~43% present). "addendum_stone_walls": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Addendum: stone-wall construction flagged by assessor.", ), "addendum_system_build": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Addendum: system-build construction flagged by assessor.", ), "addendum_numbers_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of addendum codes flagged.", ), # Low-carbon energy sources. "lzc_energy_sources_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of LZC energy-source codes declared (0 if none).", ), # Extension 1 (first non-main building part; ~36% of certs). "extension_1_present": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="True if there is a building part beyond the Main Dwelling.", ), "extension_1_wall_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Extension 1 wall construction SAP10 code.", ), "extension_1_wall_insulation_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Extension 1 wall insulation type SAP10 code.", ), "extension_1_wall_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Extension 1 wall insulation thickness in mm.", ), "extension_1_wall_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Extension 1 external wall thickness in mm.", ), "extension_1_roof_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Extension 1 roof construction SAP10 code.", ), "extension_1_roof_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Extension 1 roof insulation thickness in mm.", ), "extension_1_floor_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Extension 1 ground-floor construction SAP10 code.", ), "extension_1_floor_insulation": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Extension 1 ground-floor insulation SAP10 code.", ), "extension_1_floor_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Extension 1 floor insulation thickness in mm.", ), "extension_1_total_floor_area_m2": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Extension 1 total floor area (sum of its sap_floor_dimensions).", ), "extension_1_heat_loss_perimeter_m": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Extension 1 heat-loss perimeter (sum of its sap_floor_dimensions).", ), "other_building_parts_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of building parts beyond Main Dwelling and the secondary part.", ), "envelope_heat_loss_w_per_k": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Sum of U*A over walls / roof / floor / party walls / windows / doors " "plus thermal-bridging factor y times total exposed area, summed across " @@ -775,7 +955,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "ventilation_heat_loss_w_per_k": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "SAP10.2 §C ventilation heat-loss in W/K from structural infiltration " "(0.35 ACH masonry / 0.25 ACH timber) plus open chimneys (40 m³/h each) " @@ -785,7 +966,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "seasonal_efficiency_main_heating": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Space-heating seasonal efficiency as a decimal (e.g. 0.84 = 84%), " "from SAP10.2 Table 4a/4b keyed on primary_sap_main_heating_code. " @@ -793,7 +975,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "seasonal_efficiency_water_heating": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Water-heating seasonal efficiency as a decimal. Code 901 ('from main') " "inherits the main code's efficiency; unknown -> 0.78 (gas-combi). " @@ -801,7 +984,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "predicted_space_heating_kwh": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Crude annual delivered space-heating kWh: envelope_heat_loss_w_per_k * " "HDH_region * 1e-3 / seasonal_efficiency_main_heating. HDH from a 22-row " @@ -809,7 +993,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "predicted_hot_water_kwh": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Crude annual delivered hot-water kWh from SAP10.2 Appendix J simplified: " "occupancy from TFA, daily volume 25*N+36 L, delta-T 43 K, +10% losses, " @@ -817,14 +1002,16 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "predicted_lighting_kwh": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Crude annual lighting kWh from SAP10.2 Section L simplified: " "9.3 * TFA reduced by 50% LED share + 40% CFL share. ADR-0008." ), ), "predicted_pv_generation_kwh": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Annual PV generation kWh: pv_total_peak_power_kw * yield_factor " "(SAP10.2 Table 6e region-keyed; UK avg 850 kWh/kWp/yr). " @@ -833,7 +1020,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "predicted_total_fuel_cost_gbp": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Annual regulated fuel cost (gbp/yr): space + DHW + lighting kWh " "multiplied by Table 32 unit prices. Standing charges omitted " @@ -842,7 +1030,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "predicted_ecf": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "SAP10 §20.1 Energy Cost Factor: 0.42 * predicted_total_fuel_cost / " "(TFA + 45). SAP score is a piecewise log/linear function of ECF. " @@ -850,7 +1039,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "predicted_log10_ecf": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "log10 of predicted_ecf. Monotone with sap_score so a tree-based " "model can use this as a near-target feature; the SAP rating's " @@ -961,7 +1151,9 @@ class EpcMlTransform: envelope_w_per_k = envelope_heat_loss_w_per_k( sap_building_parts=epc.sap_building_parts, country_code=epc.country_code, - window_total_area_m2=float(window_aggregates.get("window_total_area_m2") or 0.0), + window_total_area_m2=float( + window_aggregates.get("window_total_area_m2") or 0.0 + ), window_avg_u_value=window_aggregates.get("window_avg_u_value"), door_count=epc.door_count, insulated_door_count=epc.insulated_door_count, @@ -975,10 +1167,16 @@ class EpcMlTransform: window_dp_pct = window_aggregates.get("window_pct_draught_proofed") ventilation_w_per_k = ventilation_heat_loss_w_per_k( total_floor_area_m2=epc.total_floor_area_m2, - avg_room_height_m=float(avg_room_h) if isinstance(avg_room_h, (int, float)) else 2.5, + avg_room_height_m=( + float(avg_room_h) if isinstance(avg_room_h, (int, float)) else 2.5 + ), is_timber_frame=is_timber_frame, open_chimneys_count=epc.open_chimneys_count, - window_pct_draught_proofed=float(window_dp_pct) if isinstance(window_dp_pct, (int, float)) else None, + window_pct_draught_proofed=( + float(window_dp_pct) + if isinstance(window_dp_pct, (int, float)) + else None + ), ) main_heating_code = heating_aggregates.get("primary_sap_main_heating_code") water_code = heating_aggregates.get("water_heating_code") @@ -986,12 +1184,16 @@ class EpcMlTransform: main_fuel = heating_aggregates.get("primary_main_fuel_type") space_eff = seasonal_efficiency( main_heating_code if isinstance(main_heating_code, int) else None, - main_heating_category=main_category if isinstance(main_category, int) else None, + main_heating_category=( + main_category if isinstance(main_category, int) else None + ), main_fuel_type=main_fuel if isinstance(main_fuel, int) else None, ) water_eff = water_heating_efficiency( water_heating_code=water_code if isinstance(water_code, int) else None, - main_heating_code=main_heating_code if isinstance(main_heating_code, int) else None, + main_heating_code=( + main_heating_code if isinstance(main_heating_code, int) else None + ), ) pred_space_kwh = predicted_space_heating_kwh( envelope_heat_loss_w_per_k=envelope_w_per_k, @@ -1006,11 +1208,20 @@ class EpcMlTransform: pred_hw_kwh = predicted_hot_water_kwh( total_floor_area_m2=epc.total_floor_area_m2, seasonal_efficiency_water=water_eff, - cylinder_size=cylinder_size_val if isinstance(cylinder_size_val, int) else None, - cylinder_insulation_thickness_mm=cylinder_ins_thk if isinstance(cylinder_ins_thk, int) else None, - cylinder_insulation_type=cylinder_ins_type if isinstance(cylinder_ins_type, int) else None, + cylinder_size=( + cylinder_size_val if isinstance(cylinder_size_val, int) else None + ), + cylinder_insulation_thickness_mm=( + cylinder_ins_thk if isinstance(cylinder_ins_thk, int) else None + ), + cylinder_insulation_type=( + cylinder_ins_type if isinstance(cylinder_ins_type, int) else None + ), age_band=main_age if isinstance(main_age, str) else None, - has_wwhrs=bool(epc.sap_heating.number_baths_wwhrs and epc.sap_heating.number_baths_wwhrs > 0), + has_wwhrs=bool( + epc.sap_heating.number_baths_wwhrs + and epc.sap_heating.number_baths_wwhrs > 0 + ), has_solar_water_heating=epc.solar_water_heating, ) pred_light_kwh = predicted_lighting_kwh( @@ -1031,7 +1242,9 @@ class EpcMlTransform: predicted_hot_water_kwh=pred_hw_kwh, predicted_lighting_kwh=pred_light_kwh, main_fuel_code=main_fuel_code if isinstance(main_fuel_code, int) else None, - water_heating_fuel_code=water_fuel_code if isinstance(water_fuel_code, int) else None, + water_heating_fuel_code=( + water_fuel_code if isinstance(water_fuel_code, int) else None + ), predicted_pv_kwh=pred_pv_kwh, ) pred_ecf_v = predicted_ecf( @@ -1102,36 +1315,52 @@ class EpcMlTransform: "conservatory_type": epc.conservatory_type, "has_heated_separate_conservatory": epc.has_heated_separate_conservatory, "flat_level": ( - _int_or_none(epc.sap_flat_details.level) if epc.sap_flat_details else None + _int_or_none(epc.sap_flat_details.level) + if epc.sap_flat_details + else None ), "flat_top_storey": ( epc.sap_flat_details.top_storey if epc.sap_flat_details else None ), "flat_storey_count": ( - _int_or_none(epc.sap_flat_details.storey_count) if epc.sap_flat_details else None + _int_or_none(epc.sap_flat_details.storey_count) + if epc.sap_flat_details + else None ), "flat_location": ( - _int_or_none(epc.sap_flat_details.flat_location) if epc.sap_flat_details else None + _int_or_none(epc.sap_flat_details.flat_location) + if epc.sap_flat_details + else None ), "flat_heat_loss_corridor": ( - _int_or_none(epc.sap_flat_details.heat_loss_corridor) if epc.sap_flat_details else None + _int_or_none(epc.sap_flat_details.heat_loss_corridor) + if epc.sap_flat_details + else None ), # Features — energy supply categoricals "meter_type": _meter_type_int(epc.sap_energy_source.meter_type), "pv_connection": epc.sap_energy_source.pv_connection, - "wind_turbines_terrain_type": _wind_terrain_int(epc.sap_energy_source.wind_turbines_terrain_type), + "wind_turbines_terrain_type": _wind_terrain_int( + epc.sap_energy_source.wind_turbines_terrain_type + ), # Features — doors "draughtproofed_door_count": epc.draughtproofed_door_count, "insulated_door_u_value": epc.insulated_door_u_value, # Features — hot water extras - "cylinder_insulation_type": _int_or_none(epc.sap_heating.cylinder_insulation_type), + "cylinder_insulation_type": _int_or_none( + epc.sap_heating.cylinder_insulation_type + ), "cylinder_thermostat": epc.sap_heating.cylinder_thermostat, - "secondary_heating_type": _int_or_none(epc.sap_heating.secondary_heating_type), + "secondary_heating_type": _int_or_none( + epc.sap_heating.secondary_heating_type + ), # Features — mechanical ventilation extras "mechanical_vent_duct_placement": epc.mechanical_vent_duct_placement, "mechanical_vent_duct_insulation": epc.mechanical_vent_duct_insulation, "mechanical_vent_duct_insulation_level": epc.mechanical_vent_duct_insulation_level, - "mechanical_vent_measured_installation": _truthy_yn(epc.mechanical_vent_measured_installation), + "mechanical_vent_measured_installation": _truthy_yn( + epc.mechanical_vent_measured_installation + ), # Features — lighting extras "low_energy_fixed_lighting_bulbs_count": epc.low_energy_fixed_lighting_bulbs_count, "fixed_lighting_outlets_count": epc.fixed_lighting_outlets_count, @@ -1145,16 +1374,19 @@ class EpcMlTransform: # Features — wind turbine geometry "wind_turbine_hub_height_m": ( epc.sap_energy_source.wind_turbine_details.hub_height - if epc.sap_energy_source.wind_turbine_details is not None else None + if epc.sap_energy_source.wind_turbine_details is not None + else None ), "wind_turbine_rotor_diameter_m": ( epc.sap_energy_source.wind_turbine_details.rotor_diameter - if epc.sap_energy_source.wind_turbine_details is not None else None + if epc.sap_energy_source.wind_turbine_details is not None + else None ), # Features — flat unheated corridor length "flat_unheated_corridor_length_m": ( epc.sap_flat_details.unheated_corridor_length_m - if epc.sap_flat_details is not None else None + if epc.sap_flat_details is not None + else None ), # Features — addendum + LZC "addendum_stone_walls": ( @@ -1165,7 +1397,8 @@ class EpcMlTransform: ), "addendum_numbers_count": ( len(epc.addendum.addendum_numbers) - if epc.addendum is not None and epc.addendum.addendum_numbers is not None + if epc.addendum is not None + and epc.addendum.addendum_numbers is not None else 0 ), "lzc_energy_sources_count": ( @@ -1268,7 +1501,7 @@ def _energy_source_other_aggregates(es: SapEnergySource) -> dict[str, Any]: "pv_battery_capacity_kwh": battery_capacity_kwh, "has_wind_turbine": es.wind_turbines_count > 0, "wind_turbine_count": es.wind_turbines_count, - "mains_gas": es.mains_gas, + "mains_gas": es.gas_connection_available, "electricity_smart_meter_present": es.electricity_smart_meter_present, "gas_smart_meter_present": es.gas_smart_meter_present, "is_dwelling_export_capable": es.is_dwelling_export_capable, @@ -1336,7 +1569,9 @@ def _heating_aggregates(sap_heating: SapHeating) -> dict[str, Any]: primary.central_heating_pump_age ) aggregates["primary_sap_main_heating_code"] = primary.sap_main_heating_code - aggregates["primary_emitter_temperature"] = _int_or_none(primary.emitter_temperature) + aggregates["primary_emitter_temperature"] = _int_or_none( + primary.emitter_temperature + ) aggregates["primary_main_heating_fraction"] = primary.main_heating_fraction return aggregates @@ -1474,9 +1709,7 @@ def _building_part_aggregates(parts: list[SapBuildingPart]) -> dict[str, Any]: columns populate only when a part with `identifier == "Main Dwelling"` is present — otherwise None (we don't silently fall back to the first part). """ - main = next( - (p for p in parts if p.identifier is BuildingPartIdentifier.MAIN), None - ) + main = next((p for p in parts if p.identifier is BuildingPartIdentifier.MAIN), None) aggregates: dict[str, Any] = { "building_parts_count": len(parts), "total_heat_loss_perimeter_m": 0.0, @@ -1535,13 +1768,13 @@ def _building_part_aggregates(parts: list[SapBuildingPart]) -> dict[str, Any]: aggregates["main_dwelling_has_room_in_roof"] = main.sap_room_in_roof is not None aggregates["main_dwelling_construction_age_band"] = main.construction_age_band aggregates["main_dwelling_wall_construction"] = ( - main.wall_construction - if isinstance(main.wall_construction, int) - else None + main.wall_construction if isinstance(main.wall_construction, int) else None ) aggregates["main_dwelling_roof_construction"] = main.roof_construction # New fabric inputs: walls - aggregates["main_dwelling_wall_insulation_type"] = _int_or_none(main.wall_insulation_type) + aggregates["main_dwelling_wall_insulation_type"] = _int_or_none( + main.wall_insulation_type + ) aggregates["main_dwelling_wall_insulation_thickness_mm"] = _parse_thickness_mm( main.wall_insulation_thickness ) @@ -1564,7 +1797,9 @@ def _building_part_aggregates(parts: list[SapBuildingPart]) -> dict[str, Any]: ) ground_floor = _ground_floor(main) if ground_floor is not None: - aggregates["main_dwelling_floor_construction"] = ground_floor.floor_construction + aggregates["main_dwelling_floor_construction"] = ( + ground_floor.floor_construction + ) aggregates["main_dwelling_floor_insulation"] = ground_floor.floor_insulation # Main dwelling extras: room-in-roof, alternative walls, flat-roof, measured flag. if main.sap_room_in_roof is not None: @@ -1579,10 +1814,12 @@ def _building_part_aggregates(parts: list[SapBuildingPart]) -> dict[str, Any]: alt_area += float(alt.wall_area) aggregates["main_dwelling_alternative_wall_count"] = alt_count aggregates["main_dwelling_alternative_wall_area_m2"] = alt_area - aggregates["main_dwelling_flat_roof_insulation_thickness_mm"] = _parse_thickness_mm( - main.flat_roof_insulation_thickness + aggregates["main_dwelling_flat_roof_insulation_thickness_mm"] = ( + _parse_thickness_mm(main.flat_roof_insulation_thickness) + ) + aggregates["main_dwelling_wall_thickness_measured"] = ( + main.wall_thickness_measured ) - aggregates["main_dwelling_wall_thickness_measured"] = main.wall_thickness_measured # Extension 1 — first non-main entry in the list. secondary = next( @@ -1621,7 +1858,9 @@ def _building_part_aggregates(parts: list[SapBuildingPart]) -> dict[str, Any]: aggregates["extension_1_heat_loss_perimeter_m"] = sec_hlp # Anything beyond main + secondary just gets counted (extension chains, etc.). - aggregates["other_building_parts_count"] = max(0, len(parts) - (1 if main else 0) - (1 if secondary else 0)) + aggregates["other_building_parts_count"] = max( + 0, len(parts) - (1 if main else 0) - (1 if secondary else 0) + ) return aggregates @@ -1728,7 +1967,11 @@ def _window_aggregates(windows: list[SapWindow]) -> dict[str, Any]: weighted_solar_transmittance / transmission_area ) if glazing_gap_area > 0: - aggregates["window_avg_glazing_gap_mm"] = weighted_glazing_gap / glazing_gap_area + aggregates["window_avg_glazing_gap_mm"] = ( + weighted_glazing_gap / glazing_gap_area + ) if frame_factor_area > 0: - aggregates["window_avg_frame_factor"] = weighted_frame_factor / frame_factor_area + aggregates["window_avg_frame_factor"] = ( + weighted_frame_factor / frame_factor_area + ) return aggregates diff --git a/harness/report.py b/harness/report.py index c9bd13a29..f138154cd 100644 --- a/harness/report.py +++ b/harness/report.py @@ -142,7 +142,7 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]: "sap_main_heating_code": ( epc.sap_heating.main_heating_details[0].sap_main_heating_code ), - "mains_gas": epc.sap_energy_source.mains_gas, + "mains_gas": epc.sap_energy_source.gas_connection_available, } if measure_type == "air_source_heat_pump": # heating_recommendation.py offers ASHP to any non-flat house/bungalow @@ -161,7 +161,7 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]: "sap_main_heating_code": ( epc.sap_heating.main_heating_details[0].sap_main_heating_code ), - "mains_gas": epc.sap_energy_source.mains_gas, + "mains_gas": epc.sap_energy_source.gas_connection_available, "has_hot_water_cylinder": epc.has_hot_water_cylinder, } if measure_type in ("system_tune_up", "system_tune_up_zoned"): @@ -223,7 +223,9 @@ def build_property_report( ) for measure in plan.measures ) - except Exception as error: # noqa: BLE001 — modelling raise must not abort the report + except ( + Exception + ) as error: # noqa: BLE001 — modelling raise must not abort the report plan_error = f"{type(error).__name__}: {error}" return PropertyReport( @@ -312,9 +314,7 @@ def _calculator_error_section(reports: list[PropertyReport]) -> list[str]: ) delta: str = "—" if report.sap_error is None else f"{report.sap_error:+.2f}" flag: str = "⚠ FLAG" if report.sap_error_exceeds_threshold else "" - lines.append( - f"| {report.name} | {lodged} | {calculated} | {delta} | {flag} |" - ) + lines.append(f"| {report.name} | {lodged} | {calculated} | {delta} | {flag} |") return lines @@ -436,9 +436,11 @@ def format_report_csv(reports: list[PropertyReport]) -> str: None if plan is None else plan.baseline_epc_rating.value, None if plan is None else plan.post_epc_rating.value, None if plan is None else len(plan.measures), - None - if plan is None - else ";".join(measure.measure_type for measure in plan.measures), + ( + None + if plan is None + else ";".join(measure.measure_type for measure in plan.measures) + ), None if plan is None else plan.cost_of_works, None if plan is None else plan.contingency_cost, None if plan is None else plan.energy_bill_savings, diff --git a/infrastructure/postgres/epc_property_table.py b/infrastructure/postgres/epc_property_table.py index 74443e7aa..9b85e2c90 100644 --- a/infrastructure/postgres/epc_property_table.py +++ b/infrastructure/postgres/epc_property_table.py @@ -20,7 +20,9 @@ from datatypes.epc.domain.epc_property_data import ( class EpcPropertyModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_property" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_property" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) property_id: Optional[int] = Field(default=None) @@ -264,9 +266,7 @@ class EpcPropertyModel(SQLModel, table=True): has_heated_separate_conservatory=data.has_heated_separate_conservatory, conservatory_type=data.conservatory_type, conservatory_floor_area_m2=( - data.sap_conservatory.floor_area_m2 - if data.sap_conservatory - else None + data.sap_conservatory.floor_area_m2 if data.sap_conservatory else None ), conservatory_glazed_perimeter_m=( data.sap_conservatory.glazed_perimeter_m @@ -333,7 +333,7 @@ class EpcPropertyModel(SQLModel, table=True): if data.windows_transmission_details else None ), - energy_mains_gas=es.mains_gas, + energy_mains_gas=es.gas_connection_available, energy_meter_type=str(es.meter_type), energy_pv_battery_count=es.pv_battery_count, energy_wind_turbines_count=es.wind_turbines_count, @@ -408,7 +408,9 @@ class EpcPropertyModel(SQLModel, table=True): class EpcPropertyEnergyPerformanceModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_property_energy_performance" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_property_energy_performance" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field( @@ -468,7 +470,9 @@ class EpcPropertyEnergyPerformanceModel(SQLModel, table=True): class EpcRenewableHeatIncentiveModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_renewable_heat_incentive" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_renewable_heat_incentive" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field( @@ -496,7 +500,9 @@ class EpcRenewableHeatIncentiveModel(SQLModel, table=True): class EpcFlatDetailsModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_flat_details" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_flat_details" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field( @@ -526,7 +532,9 @@ class EpcFlatDetailsModel(SQLModel, table=True): class EpcMainHeatingDetailModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_main_heating_detail" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_main_heating_detail" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False) @@ -589,7 +597,9 @@ class EpcMainHeatingDetailModel(SQLModel, table=True): class EpcBuildingPartModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_building_part" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_building_part" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False) @@ -715,7 +725,9 @@ class EpcBuildingPartModel(SQLModel, table=True): class EpcFloorDimensionModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_floor_dimension" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_floor_dimension" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_building_part_id: int = Field( @@ -754,7 +766,9 @@ class EpcFloorDimensionModel(SQLModel, table=True): class EpcWindowModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_window" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_window" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False) @@ -806,7 +820,9 @@ class EpcWindowModel(SQLModel, table=True): class EpcPhotovoltaicArrayModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_photovoltaic_array" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_photovoltaic_array" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False) @@ -837,7 +853,9 @@ class EpcPhotovoltaicArrayModel(SQLModel, table=True): class EpcEnergyElementModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_energy_element" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_energy_element" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False) diff --git a/repositories/epc/epc_postgres_repository.py b/repositories/epc/epc_postgres_repository.py index 1c92e1bff..2b8beb61a 100644 --- a/repositories/epc/epc_postgres_repository.py +++ b/repositories/epc/epc_postgres_repository.py @@ -64,7 +64,9 @@ class EpcSaveRequest: source: EpcSource = field(default="lodged") -def _col_values(model: SQLModel, exclude: frozenset[str] = frozenset()) -> dict[str, Any]: +def _col_values( + model: SQLModel, exclude: frozenset[str] = frozenset() +) -> dict[str, Any]: """Extract column-keyed values from a SQLModel instance for Core INSERT.""" return { c.name: getattr(model, c.name) # type: ignore[union-attr] @@ -130,7 +132,9 @@ class EpcPostgresRepository(EpcRepository): portfolio_id: Optional[int] = None, source: EpcSource = "lodged", ) -> int: - return self.save_batch([EpcSaveRequest(data, property_id, portfolio_id, source)])[0] + return self.save_batch( + [EpcSaveRequest(data, property_id, portfolio_id, source)] + )[0] def save_batch(self, requests: list[EpcSaveRequest]) -> list[int]: """Insert all EPCs in `requests` in one pass per table, returning one @@ -158,7 +162,10 @@ class EpcPostgresRepository(EpcRepository): parent_rows = [ _col_values( EpcPropertyModel.from_epc_property_data( - r.data, property_id=r.property_id, portfolio_id=r.portfolio_id, source=r.source + r.data, + property_id=r.property_id, + portfolio_id=r.portfolio_id, + source=r.source, ), exclude=frozenset({"id"}), ) @@ -185,23 +192,33 @@ class EpcPostgresRepository(EpcRepository): d = r.data perf_rows.append( _col_values( - EpcPropertyEnergyPerformanceModel.from_epc_property_data(d, epc_pid), + EpcPropertyEnergyPerformanceModel.from_epc_property_data( + d, epc_pid + ), exclude=frozenset({"id"}), ) ) for detail in d.sap_heating.main_heating_details: heating_rows.append( - _col_values(EpcMainHeatingDetailModel.from_domain(detail, epc_pid), frozenset({"id"})) + _col_values( + EpcMainHeatingDetailModel.from_domain(detail, epc_pid), + frozenset({"id"}), + ) ) for part in d.sap_building_parts: parts_ordered.append((part, epc_pid)) for window in d.sap_windows: window_rows.append( - _col_values(EpcWindowModel.from_domain(window, epc_pid), frozenset({"id"})) + _col_values( + EpcWindowModel.from_domain(window, epc_pid), frozenset({"id"}) + ) ) for idx, array in enumerate(d.sap_energy_source.photovoltaic_arrays or []): pv_rows.append( - _col_values(EpcPhotovoltaicArrayModel.from_domain(array, idx, epc_pid), frozenset({"id"})) + _col_values( + EpcPhotovoltaicArrayModel.from_domain(array, idx, epc_pid), + frozenset({"id"}), + ) ) for etype, els in ( ("roof", d.roofs), @@ -211,7 +228,10 @@ class EpcPostgresRepository(EpcRepository): ): for el in els: element_rows.append( - _col_values(EpcEnergyElementModel.from_domain(el, etype, epc_pid), frozenset({"id"})) + _col_values( + EpcEnergyElementModel.from_domain(el, etype, epc_pid), + frozenset({"id"}), + ) ) for el, etype in ( (d.window, "window"), @@ -222,15 +242,26 @@ class EpcPostgresRepository(EpcRepository): ): if el is not None: element_rows.append( - _col_values(EpcEnergyElementModel.from_domain(el, etype, epc_pid), frozenset({"id"})) + _col_values( + EpcEnergyElementModel.from_domain(el, etype, epc_pid), + frozenset({"id"}), + ) ) if d.sap_flat_details is not None: flat_rows.append( - _col_values(EpcFlatDetailsModel.from_domain(d.sap_flat_details, epc_pid), frozenset({"id"})) + _col_values( + EpcFlatDetailsModel.from_domain(d.sap_flat_details, epc_pid), + frozenset({"id"}), + ) ) if d.renewable_heat_incentive is not None: rhi_rows.append( - _col_values(EpcRenewableHeatIncentiveModel.from_domain(d.renewable_heat_incentive, epc_pid), frozenset({"id"})) + _col_values( + EpcRenewableHeatIncentiveModel.from_domain( + d.renewable_heat_incentive, epc_pid + ), + frozenset({"id"}), + ) ) # Bulk-insert all simple child tables (no downstream FK dependency). @@ -256,7 +287,9 @@ class EpcPostgresRepository(EpcRepository): # Positional zip is safe because PostgreSQL preserves VALUES order in RETURNING. if parts_ordered: bp_rows = [ - _col_values(EpcBuildingPartModel.from_domain(part, epc_pid), frozenset({"id"})) + _col_values( + EpcBuildingPartModel.from_domain(part, epc_pid), frozenset({"id"}) + ) for part, epc_pid in parts_ordered ] returned_bps = self._session.execute( # type: ignore[deprecated] @@ -264,7 +297,10 @@ class EpcPostgresRepository(EpcRepository): bp_rows, ).all() floor_rows: list[dict[str, Any]] = [ - _col_values(EpcFloorDimensionModel.from_domain(dim, bp_row[0]), frozenset({"id"})) + _col_values( + EpcFloorDimensionModel.from_domain(dim, bp_row[0]), + frozenset({"id"}), + ) for (part, _), bp_row in zip(parts_ordered, returned_bps) for dim in part.sap_floor_dimensions ] @@ -273,7 +309,9 @@ class EpcPostgresRepository(EpcRepository): return epc_property_ids - def _delete_for_properties(self, property_ids: list[int], source: EpcSource) -> None: + def _delete_for_properties( + self, property_ids: list[int], source: EpcSource + ) -> None: """Batch-delete every EPC graph for the given property_ids and source in one pass per child table (IN queries), replacing the per-property flush loop that drove RDS CPU to saturation during bulk modelling runs.""" @@ -370,9 +408,7 @@ class EpcPostgresRepository(EpcRepository): def get_for_property(self, property_id: int) -> Optional[EpcPropertyData]: return self._get_for_property(property_id, source="lodged") - def get_predicted_for_property( - self, property_id: int - ) -> Optional[EpcPropertyData]: + def get_predicted_for_property(self, property_id: int) -> Optional[EpcPropertyData]: return self._get_for_property(property_id, source="predicted") def _get_for_property( @@ -388,9 +424,7 @@ class EpcPostgresRepository(EpcRepository): return None return self.get(row.id) - def get_for_properties( - self, property_ids: list[int] - ) -> dict[int, EpcPropertyData]: + def get_for_properties(self, property_ids: list[int]) -> dict[int, EpcPropertyData]: """Bulk-hydrate a batch's LODGED EPCs, keyed by property_id.""" return self._for_properties(property_ids, source="lodged") @@ -481,10 +515,7 @@ class EpcPostgresRepository(EpcRepository): ).all() ) part_ids = [ - bp.id - for parts in parts_by.values() - for bp in parts - if bp.id is not None + bp.id for parts in parts_by.values() for bp in parts if bp.id is not None ] floor_dims_by_part = self._floor_dims_by_part(part_ids) @@ -593,7 +624,11 @@ class EpcPostgresRepository(EpcRepository): rhi_row: Optional[EpcRenewableHeatIncentiveModel], ) -> EpcPropertyData: def _elements(element_type: str) -> list[EnergyElement]: - return [self._to_energy_element(e) for e in elements if e.element_type == element_type] + return [ + self._to_energy_element(e) + for e in elements + if e.element_type == element_type + ] def _single(element_type: str) -> Optional[EnergyElement]: found = _elements(element_type) @@ -786,9 +821,7 @@ class EpcPostgresRepository(EpcRepository): ) @private - def _pv_arrays( - self, epc_property_id: int - ) -> list[EpcPhotovoltaicArrayModel]: + def _pv_arrays(self, epc_property_id: int) -> list[EpcPhotovoltaicArrayModel]: return list( self._session.exec( select(EpcPhotovoltaicArrayModel) @@ -1018,7 +1051,7 @@ class EpcPostgresRepository(EpcRepository): else None ), pv_diverter_present=p.energy_pv_diverter_present, - mains_gas=p.energy_mains_gas, + gas_connection_available=p.energy_mains_gas, meter_type=p.energy_meter_type, pv_battery_count=p.energy_pv_battery_count, wind_turbines_count=p.energy_wind_turbines_count, diff --git a/scripts/elmhurst_input_sheet.py b/scripts/elmhurst_input_sheet.py index 5626c5be3..e2c5022d9 100644 --- a/scripts/elmhurst_input_sheet.py +++ b/scripts/elmhurst_input_sheet.py @@ -22,6 +22,7 @@ Certs are read from the cache built by `fetch_2026_epc_sample.py` (default `/tmp/epc_2026_sample`, overridable via `EPC_SAMPLE_CACHE`). A bare cert number resolves to `/.json`; an explicit path is also accepted. """ + from __future__ import annotations import json @@ -33,7 +34,10 @@ from typing import Any, Optional from datatypes.epc.domain.mapper import EpcPropertyDataMapper from domain.sap10_calculator.calculator import calculate_sap_from_inputs -from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + SAP_10_2_SPEC_PRICES, + cert_to_inputs, +) CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample")) @@ -59,7 +63,9 @@ def _resolve(cert_arg: str) -> Path: def _our_sap(epc: Any) -> str: """Our continuous SAP, or the exception that blocks it.""" try: - result = calculate_sap_from_inputs(cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)) + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) cont: float = result.sap_score_continuous return f"{cont:.4f}" if math.isfinite(cont) else f"non-finite ({cont})" except Exception as e: # debugging tool — surface, don't swallow @@ -113,7 +119,9 @@ def render(cert: str, doc: dict[str, Any]) -> str: # --- element descriptions (lodged) --------------------------------- w("\n## Element descriptions (lodged)") for label, elems in ( - ("WALL", epc.walls), ("ROOF", epc.roofs), ("FLOOR", epc.floors), + ("WALL", epc.walls), + ("ROOF", epc.roofs), + ("FLOOR", epc.floors), ): for el in elems or []: w(f" {label}: {el.description}") @@ -135,7 +143,9 @@ def render(cert: str, doc: dict[str, Any]) -> str: # types the mapper emits (NOT the `schema` dataclasses). w("\n## Building parts / dimensions") for bp in epc.sap_building_parts or []: - w(f"### {bp.identifier} (part {bp.building_part_number}, age {bp.construction_age_band})") + w( + f"### {bp.identifier} (part {bp.building_part_number}, age {bp.construction_age_band})" + ) w( f" wall_construction={bp.wall_construction} " f"insulation_type={bp.wall_insulation_type} " @@ -226,7 +236,7 @@ def render(cert: str, doc: dict[str, Any]) -> str: ) es = epc.sap_energy_source w( - f" ENERGY SOURCE: mains_gas={es.mains_gas} " + f" ENERGY SOURCE: mains_gas={es.gas_connection_available} " f"meter_type={es.meter_type} " f"wind_turbines={es.wind_turbines_count} " f"pv_raw={json.dumps(doc.get('sap_energy_source', {}).get('photovoltaic_supply'))}" diff --git a/tests/domain/epc/test_main_fuel_overlay.py b/tests/domain/epc/test_main_fuel_overlay.py index 7de2a5e50..c664563ac 100644 --- a/tests/domain/epc/test_main_fuel_overlay.py +++ b/tests/domain/epc/test_main_fuel_overlay.py @@ -87,7 +87,7 @@ def test_mains_gas_fuel_sets_the_mains_gas_connection_flag() -> None: simulation = fuel_overlay_for("mains gas", 0) assert simulation is not None assert simulation.heating is not None - assert simulation.heating.mains_gas is True + assert simulation.heating.gas_connection_available is True @pytest.mark.parametrize( @@ -104,7 +104,7 @@ def test_non_mains_gas_fuel_leaves_the_mains_gas_flag_unchanged( simulation = fuel_overlay_for(main_fuel_value, 0) assert simulation is not None assert simulation.heating is not None - assert simulation.heating.mains_gas is None + assert simulation.heating.gas_connection_available is None @pytest.mark.parametrize("main_fuel_value", ["Unknown", "", "no heating or hot water"]) @@ -141,4 +141,3 @@ def test_every_resolvable_fuel_value_decodes_to_a_code(member: MainFuelType) -> # Assert assert simulation is not None - diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index d98d01458..6e68cce03 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -196,7 +196,7 @@ def test_gas_boiler_drags_a_coherent_mains_gas_system(main_heating_value: str) - assert simulation is not None assert simulation.heating is not None heating = simulation.heating - assert heating.mains_gas is True + assert heating.gas_connection_available is True assert heating.main_fuel_type == 26 assert heating.main_heating_category == 2 assert heating.fan_flue_present is True @@ -300,7 +300,7 @@ def test_gas_boiler_override_onto_a_storage_baseline_leaves_no_stale_fields() -> assert main.main_fuel_type == 26 assert main.main_heating_category == 2 assert main.main_heating_control == 2106 - assert result.sap_energy_source.mains_gas is True + assert result.sap_energy_source.gas_connection_available is True assert result.sap_energy_source.meter_type == "Single" assert result.has_hot_water_cylinder is False assert result.sap_heating.water_heating_code == 901 @@ -506,7 +506,9 @@ def test_community_boilers_decode_to_the_heat_network_boiler_code() -> None: assert simulation.heating.sap_main_heating_code == 301 -def test_community_boilers_drag_heat_network_category_and_a_community_gas_fuel() -> None: +def test_community_boilers_drag_heat_network_category_and_a_community_gas_fuel() -> ( + None +): # A community boiler scheme is SAP main_heating_category 6 (heat network), so # the calculator treats it as a heat network (cert_to_inputs `_is_heat_network` # checks code OR category 6). Its natural fuel is mains gas (community) — RdSAP diff --git a/tests/domain/modelling/test_ashp_cost_inputs.py b/tests/domain/modelling/test_ashp_cost_inputs.py index c598f9ede..c75cf52fc 100644 --- a/tests/domain/modelling/test_ashp_cost_inputs.py +++ b/tests/domain/modelling/test_ashp_cost_inputs.py @@ -58,7 +58,7 @@ def test_classification_keys_on_fuel_not_the_mains_gas_flag() -> None: ) # Act / Assert - assert epc.sap_energy_source.mains_gas is True + assert epc.sap_energy_source.gas_connection_available is True inputs: AshpCostInputs = ashp_cost_inputs(epc) assert inputs.existing_system is AshpExistingSystem.ELECTRIC_STORAGE assert inputs.has_reusable_wet_system is False @@ -73,7 +73,7 @@ def test_oil_and_lpg_dwellings_are_reusable_wet_systems() -> None: def _with_fuel(code: int) -> EpcPropertyData: clone: EpcPropertyData = copy.deepcopy(base) - clone.sap_energy_source.mains_gas = False + clone.sap_energy_source.gas_connection_available = False clone.sap_heating.main_heating_details[0].main_fuel_type = code clone.sap_heating.main_heating_details[0].sap_main_heating_code = 199 return clone diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 4a4687580..51b0b8f05 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -63,7 +63,9 @@ def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None: assert recommendation.surface == "Heating & Hot Water" options = {o.measure_type.value: o for o in recommendation.options} assert "high_heat_retention_storage_heaters" in options - assert options["high_heat_retention_storage_heaters"].overlay.heating == HeatingOverlay( + assert options[ + "high_heat_retention_storage_heaters" + ].overlay.heating == HeatingOverlay( main_fuel_type=30, sap_main_heating_code=409, main_heating_control=2404, @@ -188,7 +190,7 @@ def test_gas_boiler_house_yields_an_ashp_bundle() -> None: cylinder_insulation_type=1, cylinder_insulation_thickness_mm=50, cylinder_thermostat="Y", - has_hot_water_cylinder=True, + gas_connection_availableater_cylinder=True, meter_type="Single", mains_gas=False, ) @@ -455,7 +457,7 @@ def test_off_gas_boiler_yields_no_gas_boiler_upgrade() -> None: # state is gated on a mains-gas connection). baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 130 - baseline.sap_energy_source.mains_gas = False + baseline.sap_energy_source.gas_connection_available = False # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index 8f6c960ff..2458e96a5 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -173,8 +173,7 @@ def test_apply_writes_dwelling_ventilation_onto_sap_ventilation() -> None: # Assert assert result.sap_ventilation is not None assert ( - result.sap_ventilation.mechanical_ventilation_kind - == "EXTRACT_OR_PIV_OUTSIDE" + result.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE" ) @@ -194,8 +193,7 @@ def test_ventilation_overlay_creates_sap_ventilation_when_baseline_has_none() -> # Assert assert isinstance(result.sap_ventilation, SapVentilation) assert ( - result.sap_ventilation.mechanical_ventilation_kind - == "EXTRACT_OR_PIV_OUTSIDE" + result.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE" ) @@ -216,7 +214,9 @@ def test_ventilation_overlay_leaves_building_parts_and_baseline_untouched() -> N # Assert — ventilation overlay touches only sap_ventilation; the baseline # is never mutated. - assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == main_before + assert ( + _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == main_before + ) assert baseline.sap_ventilation is not None assert baseline.sap_ventilation.mechanical_ventilation_kind is None @@ -243,9 +243,7 @@ def test_baseline_is_not_mutated() -> None: ) # Assert - assert ( - _part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type == original - ) + assert _part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type == original def test_apply_folds_a_window_overlay_by_index_into_transmission_details() -> None: @@ -293,9 +291,7 @@ def test_baseline_windows_are_not_mutated_by_a_window_overlay() -> None: # Assert assert baseline.sap_windows[0].window_transmission_details is not None - assert ( - baseline.sap_windows[0].window_transmission_details.u_value == original_u - ) + assert baseline.sap_windows[0].window_transmission_details.u_value == original_u def test_apply_writes_dwelling_lighting_onto_top_level_bulb_counts() -> None: @@ -344,7 +340,7 @@ def test_apply_folds_a_heating_overlay_across_all_five_locations() -> None: ) ) - # Act + # Actgas_connection_available result: EpcPropertyData = apply_simulations(baseline, [simulation]) # Assert — every targeted field routed to its home object. @@ -360,7 +356,7 @@ def test_apply_folds_a_heating_overlay_across_all_five_locations() -> None: assert result.has_hot_water_cylinder is True assert result.sap_energy_source is not None assert result.sap_energy_source.meter_type == "18 Hour" - assert result.sap_energy_source.mains_gas is False + assert result.sap_energy_source.gas_connection_available is False def test_secondary_heating_overlay_clears_the_lodged_secondary() -> None: @@ -404,7 +400,7 @@ def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None: original_wh_code: int | None = baseline.sap_heating.water_heating_code original_cylinder = baseline.has_hot_water_cylinder assert baseline.sap_energy_source is not None - original_mains_gas = baseline.sap_energy_source.mains_gas + original_mains_gas = baseline.sap_energy_source.gas_connection_available # Act — fold an HHR storage bundle. _: EpcPropertyData = apply_simulations( @@ -421,7 +417,7 @@ def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None: ) ) ], - ) + )gas_connection_available # Assert — the baseline's heating is untouched. assert baseline.sap_heating.main_heating_details[0].main_fuel_type == original_fuel @@ -431,7 +427,7 @@ def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None: ) assert baseline.sap_heating.water_heating_code == original_wh_code assert baseline.has_hot_water_cylinder == original_cylinder - assert baseline.sap_energy_source.mains_gas == original_mains_gas + assert baseline.sap_energy_source.gas_connection_available == original_mains_gas def test_heating_index_overlay_clears_a_stale_sap_main_heating_code() -> None: