From cb4d080da26077d1c5c56a0d27c18cb7543e0bba Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 15 Jun 2026 14:18:01 +0000 Subject: [PATCH] =?UTF-8?q?Map=20full-SAP=20heating=20systems=20onto=20the?= =?UTF-8?q?=20domain=20SapHeating=20model=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) --- CONTEXT.md | 24 ++++++++++ datatypes/epc/domain/mapper.py | 45 ++++++++++++++++--- .../epc/domain/tests/test_from_sap_schema.py | 25 +++++++++++ datatypes/epc/schema/sap_schema_17_1.py | 36 +++++++++++++++ .../test_real_cert_sap_accuracy.py | 43 +++++++++++++++--- 5 files changed, 162 insertions(+), 11 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 87f4e1eb..0a2ffbcc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -350,6 +350,30 @@ _Avoid_: new API, current API The auth credential required by the New EPC API; stored in the `EPC_AUTH_TOKEN` environment variable. _Avoid_: API key, auth token, secret +## Team + +Who's who on the project, so commit authorship and review history read +correctly. The git author names below map to people as follows. + +**Khalim Conn-Kowlessar**: +CTO and the technical decision-maker — the boss. Treat his calls as +authoritative when guidance conflicts. Git author: `Khalim +Conn-Kowlessar ` (also commits as `KhalimCK`, +same email). + +**Daniel Roth ("Dan")**: +Software engineer. Git author: `Daniel Roth ` +(also `Daniel Roth <36244509+dancafc@users.noreply.github.com>`). + +**Jun-te Kim ("Junte")**: +Software engineer. Git author: `Jun-te Kim ` +(also `` and `<39764191+kimjunte@users.noreply.github.com>`). + +**Michael Duong**: +Contractor (software). Git author: `Michael Duong ` +(also commits as `` and from local machine +addresses ``, ``). + ## Relationships - A **Property** represents a single physical dwelling for modelling; identified by `(portfolio_id, UPRN)` or `(portfolio_id, landlord_property_id)`. diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 60827bc1..19be97fa 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -710,11 +710,10 @@ class EpcPropertyDataMapper: _sap_17_1_building_part(bp, i) for i, bp in enumerate(schema.sap_building_parts) ], - sap_heating=SapHeating( - instantaneous_wwhrs=InstantaneousWwhrs(), - main_heating_details=[], - has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true", - ), + # D6: full-SAP heating — translate the differing field names onto + # 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), sap_energy_source=SapEnergySource( mains_gas=False, meter_type="", @@ -2505,6 +2504,42 @@ _SAP_LIVING_AREA_FRACTION_BY_ROOMS: Final[Dict[int, float]] = { } +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`, + `main_heating_flue_type`→`boiler_flue_type`, `water_fuel_type`→ + `water_heating_fuel`; has_fghrs isn't lodged (default False).""" + sh = schema.sap_heating + return SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + has_fixed_air_conditioning=sh.has_fixed_air_conditioning == "true", + water_heating_code=sh.water_heating_code, + water_heating_fuel=sh.water_fuel_type, + main_heating_details=[ + MainHeatingDetail( + has_fghrs=False, + main_fuel_type=d.main_fuel_type, + heat_emitter_type=d.heat_emitter_type, + emitter_temperature=d.emitter_temperature, + main_heating_control=d.main_heating_control, + fan_flue_present=d.is_flue_fan_present == "true", + boiler_flue_type=d.main_heating_flue_type, + central_heating_pump_age=d.central_heating_pump_age, + main_heating_index_number=d.main_heating_index_number, + main_heating_number=d.main_heating_number, + main_heating_category=d.main_heating_category, + main_heating_fraction=( + int(d.main_heating_fraction) + if d.main_heating_fraction is not None + else None + ), + main_heating_data_source=d.main_heating_data_source, + ) + for d in sh.main_heating_details + ], + ) + + def _sap_back_solved_habitable_rooms(schema: SapSchema17_1) -> int: """D3: pick the habitable-room count whose Table 27 fraction is closest to the measured living_area/total_floor_area, so the engine's Table-27 path diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index 4ccfd756..fa194326 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -232,6 +232,31 @@ class TestFromSapSchema17_1LivingArea: assert self._map("sap_17_1_flat.json").habitable_rooms_count == 3 +class TestFromSapSchema17_1Heating: + """Slice D6: full-SAP sap_heating (differing field names) maps onto the + domain SapHeating + MainHeatingDetail the calculator consumes.""" + + @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_main_heating_detail_mapped(self, sample: EpcPropertyData) -> None: + details = sample.sap_heating.main_heating_details + assert len(details) == 1 + assert details[0].main_fuel_type == 1 + # PCDB boiler index drives the efficiency lookup — must survive. + assert details[0].main_heating_index_number == 17929 + + def test_flue_fan_field_renamed(self, sample: EpcPropertyData) -> None: + # full SAP lodges is_flue_fan_present; domain field is fan_flue_present. + assert sample.sap_heating.main_heating_details[0].fan_flue_present is True + + def test_water_heating_carried(self, sample: EpcPropertyData) -> None: + assert sample.sap_heating.water_heating_code == 901 + assert sample.sap_heating.water_heating_fuel == 1 + + class TestFromSapSchema17_1Perimeter: """Slice 5 (D1): full SAP lodges no heat-loss perimeter; derive it from the measured exposed-wall areas (wall_type 1/2/3) ÷ Σ storey-heights, with party diff --git a/datatypes/epc/schema/sap_schema_17_1.py b/datatypes/epc/schema/sap_schema_17_1.py index d80ee557..19c4a2f2 100644 --- a/datatypes/epc/schema/sap_schema_17_1.py +++ b/datatypes/epc/schema/sap_schema_17_1.py @@ -88,6 +88,41 @@ class SapBuildingPart: building_part_number: Optional[int] = None +@dataclass +class SapMainHeatingDetail: + """One main-heating system. Field names differ from RdSAP (e.g. + `is_flue_fan_present` vs `fan_flue_present`, `main_heating_flue_type` vs + `boiler_flue_type`); the mapper translates them.""" + + main_fuel_type: int + heat_emitter_type: int + emitter_temperature: Union[int, str] + main_heating_control: int + main_heating_number: Optional[int] = None + main_heating_category: Optional[int] = None + main_heating_fraction: Optional[Union[int, float]] = None + main_heating_data_source: Optional[int] = None + main_heating_index_number: Optional[int] = None + main_heating_flue_type: Optional[int] = None + is_flue_fan_present: Optional[str] = None + central_heating_pump_age: Optional[int] = None + load_or_weather_compensation: Optional[int] = None + + +@dataclass +class SapHeating: + """Heating + hot-water systems. `water_fuel_type`/`water_heating_code` drive + the hot-water cascade; `main_heating_index_number` keys the PCDB efficiency + lookup.""" + + main_heating_details: List[SapMainHeatingDetail] = field(default_factory=list) + water_fuel_type: Optional[int] = None + water_heating_code: Optional[int] = None + has_hot_water_cylinder: Optional[str] = None + has_fixed_air_conditioning: Optional[str] = None + secondary_heating_category: Optional[int] = None + + @dataclass class EnergyElement: """A fabric/system element with its lodged description. On full SAP the @@ -121,6 +156,7 @@ class SapSchema17_1: floors: List[EnergyElement] sap_opening_types: List[SapOpeningType] sap_building_parts: List[SapBuildingPart] + sap_heating: SapHeating # 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 diff --git a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py index 82b2b2ea..051a2831 100644 --- a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py +++ b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py @@ -52,6 +52,12 @@ class RealCertExpectation: `unsupported_schema=True` marks a cert whose schema the mapper can't yet consume (full SAP vs RdSAP). Those cases are expected to FAIL at the mapper until support lands — see `test_real_cert_sap_score`. + + `known_bug_xfail` marks a cert whose `sap_score` is the verified + ground truth (e.g. reproduced in Elmhurst on identical inputs) but + which the engine doesn't yet hit because of a localised, documented + calculator bug. Strict xfail: when the bug is fixed the test flips to + a failure, prompting removal of the marker. """ schema: str @@ -63,6 +69,7 @@ class RealCertExpectation: hot_water_kwh_per_yr: Optional[float] = None co2_kg_per_yr: Optional[float] = None unsupported_schema: bool = False + known_bug_xfail: Optional[str] = None # Absolute tolerance for float pins — matches the Elmhurst cohort. @@ -93,6 +100,28 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( sap_score=83, unsupported_schema=True, ), + # UPRN 10002468137 → cert 0215-2818-7357-9703-2145. RdSAP-Schema-17.1, + # all-electric high-heat-retention storage heaters on Economy 7, solid- + # brick uninsulated end-terrace. Ground truth is Elmhurst RdSAP10 = 60, + # reproduced on identical inputs (summary + full SAP 10.2 worksheet saved + # alongside: elmhurst_summary.pdf / elmhurst_worksheet.pdf). The engine + # produces 62 — a +2 over-rating localised to OFF-PEAK WATER HEATING: + # the worksheet (lines 243-246) prices the 7-hour off-peak immersion at a + # Table 13 split (19.36% @ 15.29p high + 80.64% @ 5.5p low), but the engine + # prices 100% at the 5.5p low rate, under-costing the bill (£595.68 vs + # £629.67) → lower ECF (2.69 vs 2.84) → SAP 62 not 60. (Space heating 100% + # off-peak IS correct for storage heaters — the worksheet agrees.) Strict + # xfail until the off-peak water-heating rate split is implemented. + RealCertExpectation( + schema="RdSAP-Schema-17.1", + sample="uprn_10002468137", + cert_num="0215-2818-7357-9703-2145", + sap_score=60, + known_bug_xfail=( + "off-peak (7-hour) water-heating high/low rate split not applied — " + "engine prices 100% at the low rate; see elmhurst_worksheet.pdf (243-246)" + ), + ), ) @@ -100,17 +129,19 @@ def _as_param(exp: RealCertExpectation) -> object: """Wrap a case as a pytest param, marking unsupported-schema certs as strict xfails (they raise `ValueError` at the mapper until full-SAP support exists; strict so the marker can't silently outlive the gap).""" - marks = ( - [ + marks = [] + if exp.unsupported_schema: + marks.append( pytest.mark.xfail( reason="full-SAP (non-RdSAP) schema not yet supported by the mapper", raises=ValueError, strict=True, ) - ] - if exp.unsupported_schema - else [] - ) + ) + elif exp.known_bug_xfail is not None: + marks.append( + pytest.mark.xfail(reason=exp.known_bug_xfail, strict=True) + ) return pytest.param(exp, id=exp.sample, marks=marks)