From acd0ed485d4fced4d59f749c2ad66bc1514a3ffa Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 15 Jun 2026 14:23:31 +0000 Subject: [PATCH] =?UTF-8?q?Map=20full-SAP=20energy=20source,=20mains-gas?= =?UTF-8?q?=20inference=20and=20lighting=20bulbs=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 35 ++++++++++++++++--- .../epc/domain/tests/test_from_sap_schema.py | 22 ++++++++++++ datatypes/epc/schema/sap_schema_17_1.py | 14 ++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 19be97fa..93df2b5f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -688,9 +688,17 @@ class EpcPropertyDataMapper: door_count=door_count, insulated_door_count=door_count, insulated_door_u_value=insulated_door_u, + # D5: lighting outlet counts → bulb counts (full SAP lodges total + + # low-energy outlet counts, like the RdSAP 17.1 path). cfl_fixed_lighting_bulbs_count=0, led_fixed_lighting_bulbs_count=0, - incandescent_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) + ), + low_energy_fixed_lighting_bulbs_count=( + schema.sap_energy_source.low_energy_fixed_lighting_outlets_count or 0 + ), # D4: full SAP lodges the measured U as text in the element # description ("Average thermal transmittance X W/m²K"); carry it # through so u_wall/u_floor/u_roof parse it instead of re-deriving @@ -714,14 +722,18 @@ class EpcPropertyDataMapper: # the domain SapHeating the calculator consumes (PCDB efficiency via # main_heating_index_number; water cascade via water_heating_code). sap_heating=_sap_17_1_heating(schema), + # 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=False, - meter_type="", + mains_gas=_sap_dwelling_on_mains_gas(schema), + meter_type=str(schema.sap_energy_source.electricity_tariff or ""), pv_battery_count=0, - wind_turbines_count=0, + wind_turbines_count=schema.sap_energy_source.wind_turbines_count or 0, gas_smart_meter_present=False, is_dwelling_export_capable=False, - wind_turbines_terrain_type="", + wind_turbines_terrain_type=str( + schema.sap_energy_source.wind_turbine_terrain_type or "" + ), electricity_smart_meter_present=False, ), ) @@ -2504,6 +2516,19 @@ _SAP_LIVING_AREA_FRACTION_BY_ROOMS: Final[Dict[int, float]] = { } +# SAP main_fuel_type code for mains gas. +_SAP_MAINS_GAS_FUEL_CODE: Final[int] = 1 + + +def _sap_dwelling_on_mains_gas(schema: SapSchema17_1) -> bool: + """D5: full SAP has no explicit mains-gas flag — infer it from whether any + main-heating system burns mains gas (fuel code 1).""" + return any( + d.main_fuel_type == _SAP_MAINS_GAS_FUEL_CODE + for d in schema.sap_heating.main_heating_details + ) + + def _sap_17_1_heating(schema: SapSchema17_1) -> SapHeating: """D6: map full-SAP `sap_heating` onto the domain `SapHeating`. Field names differ from RdSAP — `is_flue_fan_present`→`fan_flue_present`, diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index fa194326..e0c21b11 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -232,6 +232,28 @@ class TestFromSapSchema17_1LivingArea: assert self._map("sap_17_1_flat.json").habitable_rooms_count == 3 +class TestFromSapSchema17_1EnergySource: + """Slice D5: full-SAP sap_energy_source → mains_gas (derived from heating + fuel), lighting outlet counts → bulb counts, and the tariff/meter.""" + + @pytest.fixture + def sample(self) -> EpcPropertyData: + schema = from_dict(SapSchema17_1, load("sap_17_1.json")) + return EpcPropertyDataMapper.from_sap_schema_17_1(schema) + + 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 + + def test_lighting_outlet_counts_to_bulbs(self, sample: EpcPropertyData) -> None: + # 1 fixed outlet, 1 low-energy → 1 low-energy bulb, 0 incandescent. + assert sample.low_energy_fixed_lighting_bulbs_count == 1 + assert sample.incandescent_fixed_lighting_bulbs_count == 0 + + def test_wind_turbine_fields_pass_through(self, sample: EpcPropertyData) -> None: + assert sample.sap_energy_source.wind_turbines_count == 0 + + class TestFromSapSchema17_1Heating: """Slice D6: full-SAP sap_heating (differing field names) maps onto the domain SapHeating + MainHeatingDetail the calculator consumes.""" diff --git a/datatypes/epc/schema/sap_schema_17_1.py b/datatypes/epc/schema/sap_schema_17_1.py index 19c4a2f2..44986c22 100644 --- a/datatypes/epc/schema/sap_schema_17_1.py +++ b/datatypes/epc/schema/sap_schema_17_1.py @@ -88,6 +88,19 @@ class SapBuildingPart: building_part_number: Optional[int] = None +@dataclass +class SapEnergySource: + """Electricity tariff, on-site generation and lighting. Lighting outlet + counts map to the engine's bulb counts; `mains_gas` is derived from the + heating fuel (full SAP has no explicit flag).""" + + electricity_tariff: Optional[int] = None + wind_turbines_count: Optional[int] = None + wind_turbine_terrain_type: Optional[int] = None + fixed_lighting_outlets_count: Optional[int] = None + low_energy_fixed_lighting_outlets_count: Optional[int] = None + + @dataclass class SapMainHeatingDetail: """One main-heating system. Field names differ from RdSAP (e.g. @@ -157,6 +170,7 @@ class SapSchema17_1: sap_opening_types: List[SapOpeningType] sap_building_parts: List[SapBuildingPart] sap_heating: SapHeating + sap_energy_source: SapEnergySource = field(default_factory=SapEnergySource) # measured living-room area (m²); the engine consumes it via a back-solved # habitable_rooms_count (Table 27). Optional — 100% present in the corpus. living_area: Optional[Union[int, float]] = None