diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index e0758e3d..ec27d6fc 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -866,7 +866,10 @@ class EpcPropertyDataMapper: # explicit flag); tariff → meter_type; wind turbines pass through. sap_energy_source=SapEnergySource( mains_gas=_sap_dwelling_on_mains_gas(schema), - meter_type=str(schema.sap_energy_source.electricity_tariff or ""), + meter_type=_sap_17_1_meter_type( + schema.sap_energy_source.electricity_tariff + ), + photovoltaic_arrays=_sap_17_1_pv_arrays(schema), pv_battery_count=0, wind_turbines_count=schema.sap_energy_source.wind_turbines_count or 0, gas_smart_meter_present=False, @@ -2932,6 +2935,59 @@ def _sap_dwelling_on_mains_gas(schema: SapSchema17_1) -> bool: ) +def _sap_17_1_pv_arrays( + schema: SapSchema17_1, +) -> Optional[List[PhotovoltaicArray]]: + """Map a full-SAP cert's lodged PV (`sap_energy_source.pv_arrays`) to the + domain `PhotovoltaicArray` list the calculator's Appendix-M generation + credit reads. Without this the PV is dropped and an all-electric dwelling + that the array lifts to A/B is mis-modelled down a band or more. An array + with no peak power generates nothing, so it's skipped; orientation honours + the ND/NA sentinel (None ⇒ zero-generation array).""" + arrays = schema.sap_energy_source.pv_arrays + if not arrays: + return None + mapped = [ + PhotovoltaicArray( + peak_power=array.peak_power, + pitch=array.pitch if array.pitch is not None else 0, + overshading=array.overshading if array.overshading is not None else 0, + orientation=_pv_orientation(array.orientation), + ) + for array in arrays + if array.peak_power is not None + ] + return mapped or None + + +def _sap_17_1_meter_type(electricity_tariff: Optional[int]) -> str: + """Translate a full-SAP ``energy_tariff`` code into the RdSAP ``meter_type`` + value the calculator's Table 12a tariff resolver consumes. + + The two code spaces *differ* (epc_codes.csv `energy_tariff` vs + `_METER_INT_TO_TARIFF`): full-SAP 1=standard / 2=off-peak-7hr / 3=off-peak- + 10hr / 4=24-hour, whereas RdSAP meter 1=dual-7hr / 2=single / 3=unknown / + 4=24-hour. Passing the full-SAP code straight through (the prior bug) read a + standard-tariff cert as Economy 7 (over-rated) and an Economy-7 cert as + single (under-rated). Map onto the RdSAP word aliases so the resolved tariff + is correct; absent/ND → "" (the unknown→standard sentinel).""" + if electricity_tariff is None: + return "" + return _SAP_TARIFF_TO_RDSAP_METER_TYPE.get(electricity_tariff, "") + + +# full-SAP `energy_tariff` code → RdSAP `meter_type` word alias (consumed by +# `tariff_from_meter_type` / `_METER_STR_TO_INT`). 10-hour (3) has no dedicated +# 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) + 4: "dual (24 hour)", # 24-hour tariff +} + + 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 08bff90a..7a682c8d 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -137,6 +137,67 @@ class TestFromSapSchema17_1RebaselineFields: assert result.assessment_type == "SAP" +class TestFullSapPhotovoltaics: + """Full-SAP certs lodge measured PV under `sap_energy_source.pv_arrays`; + the mapper must carry it to the domain `photovoltaic_arrays` so the + Appendix-M generation credit isn't silently dropped.""" + + def test_maps_the_lodged_pv_array(self) -> None: + # sap_17_1_house.json lodges one 1.62 kWp array (pitch 3, orientation 6, + # overshading 1). + schema = from_dict(SapSchema17_1, load("sap_17_1_house.json")) + + result = EpcPropertyDataMapper.from_sap_schema_17_1(schema) + + arrays = result.sap_energy_source.photovoltaic_arrays + assert arrays is not None + assert len(arrays) == 1 + assert arrays[0].peak_power == 1.62 + + +class TestFullSapElectricityTariffTranslation: + """The full-SAP `energy_tariff` code space differs from the RdSAP + `meter_type` one the calculator reads, so the mapper must translate it.""" + + def test_economy7_tariff_is_not_read_as_single_rate(self) -> None: + # full-SAP energy_tariff 2 = "off-peak 7 hour" (Economy 7). Passing the + # code straight through read it as RdSAP meter 2 = Single → no off-peak + # split (under-rated). It must resolve to the SEVEN_HOUR off-peak tariff. + from domain.sap10_calculator.tables.table_12a import ( + Tariff, + tariff_from_meter_type, + ) + + from datatypes.epc.domain.mapper import _sap_17_1_meter_type + + meter_type = _sap_17_1_meter_type(2) + + assert tariff_from_meter_type(meter_type) is Tariff.SEVEN_HOUR + + @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) + (None, "STANDARD"), # absent/ND → unknown → standard + ], + ) + def test_energy_tariff_resolves_to_the_correct_calculator_tariff( + self, energy_tariff: Any, expected: str + ) -> None: + from domain.sap10_calculator.tables.table_12a import ( + tariff_from_meter_type, + ) + + from datatypes.epc.domain.mapper import _sap_17_1_meter_type + + resolved = tariff_from_meter_type(_sap_17_1_meter_type(energy_tariff)) + + assert resolved.name == expected + + class TestFromSapSchema17_1DisplayElements: """Display EnergyElements the WIP mapper dropped, leaving the FE property-details panel "Unknown" for full-SAP certs (ADR-0037). Brings @@ -671,8 +732,9 @@ class TestFromSapSchema16_2: epc = EpcPropertyDataMapper.from_api_response(load("sap_17_0.json")) assert isinstance(epc, EpcPropertyData) assert epc.uprn == 10023444324 - # lodged 82; engine produces 80. - assert Sap10Calculator().calculate(epc).sap_score == 80 + # lodged 82; the engine now also produces 82 — the cert's lodged PV is + # credited (previously dropped by the full-SAP mapper, under-rating to 80). + assert Sap10Calculator().calculate(epc).sap_score == 82 def test_18_0_0_dispatches_via_full_sap_path(self) -> None: # SAP-Schema-18.0.0 is the full-SAP 17.1 shape; dispatched to diff --git a/datatypes/epc/schema/sap_schema_17_1.py b/datatypes/epc/schema/sap_schema_17_1.py index 5cad983c..e4f41d9e 100644 --- a/datatypes/epc/schema/sap_schema_17_1.py +++ b/datatypes/epc/schema/sap_schema_17_1.py @@ -107,6 +107,20 @@ class SapVentilation: mechanical_vent_duct_type: Optional[int] = None +@dataclass +class SapPvArray: + """One measured photovoltaic array lodged on a full-SAP cert (under + `sap_energy_source.pv_arrays`): peak power (kWp), pitch, SAP octant + orientation (1-8), overshading code, and the connection type. Mirrors the + domain `PhotovoltaicArray` the calculator's Appendix M generation uses.""" + + peak_power: Optional[float] = None + pitch: Optional[int] = None + orientation: Optional[int] = None + overshading: Optional[int] = None + pv_connection: Optional[int] = None + + @dataclass class SapEnergySource: """Electricity tariff, on-site generation and lighting. Lighting outlet @@ -118,6 +132,7 @@ class SapEnergySource: wind_turbine_terrain_type: Optional[int] = None fixed_lighting_outlets_count: Optional[int] = None low_energy_fixed_lighting_outlets_count: Optional[int] = None + pv_arrays: Optional[List[SapPvArray]] = None @dataclass diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 94d4947c..e763fea7 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -12,10 +12,11 @@ field-wise with the main_fuel / water_heating overlays. electricity tariff (meter) and, for storage heaters, its charge control. Rather than hand-attach those per archetype (easy to forget when a new system is added), they are **derived from the SAP code**: the off-peak meter from the -calculator's single off-peak classification (`OFF_PEAK_IMPLYING_HEATING_CODES`, -SAP §12), and the conservative manual charge control for storage heaters. So -adding a heating archetype is just adding its code — coherent companions fall -out. Synthesis owns coherence; the calculator never normalises a lodged cert. +overlay's assumed-Dual classification (`_ASSUMED_DUAL_METER_CODES` — the §12 +off-peak systems plus all-electric room-heater dwellings), and the conservative +manual charge control for storage heaters. So adding a heating archetype is just +adding its code — coherent companions fall out. Synthesis owns coherence; the +calculator never normalises a lodged cert. The SEDBUK A-G efficiency band the Hyde "Heating" column carries is NOT honoured yet (no efficiency slot on the overlay/MainHeatingDetail) -- archetypes map to @@ -45,6 +46,19 @@ _OFF_PEAK_METER = "Dual" # split (the mirror of the storage→Dual drag, ADR-0035). _SINGLE_RATE_METER = "Single" +# Electric room heaters (SAP Table 4a 691). They don't *require* off-peak the way +# storage/CPSU do, so they're absent from the calculator's §12 +# `OFF_PEAK_IMPLYING_HEATING_CODES` (Rules 1-2). But a dwelling heated by them is +# all-electric and realistically billed on Economy 7 — its immersion hot water +# charges overnight and §12 Rule 3 gives the room heaters a 10-hour off-peak +# window. So when the landlord names only the system, the coherent meter to +# assume is Dual; the §12 dispatch then applies the realistic high/low split +# (not a single-rate over-penalty, nor an all-low over-credit). +_ROOM_HEATER_CODES = frozenset({691}) +# Codes for which the overlay assumes a Dual (off-peak) meter: the §12-mandated +# off-peak systems plus the all-electric room-heater dwellings above. +_ASSUMED_DUAL_METER_CODES = OFF_PEAK_IMPLYING_HEATING_CODES | _ROOM_HEATER_CODES + # SAP Table 4e Group 4 storage charge-control code. Manual charge control is the # *conservative* assumption when the landlord didn't tell us the control: its # +0.7 C mean-internal-temperature adjustment is the largest of the storage @@ -80,9 +94,11 @@ _FROM_MAIN_WATER_HEATING_CODE = 901 # Canonical system archetype → representative SAP `sap_main_heating_code`. Codes # map to the modern/condensing variant (A-G efficiency deferred): 102 regular # condensing, 104 condensing combi, 120 CPSU, 401-404 storage heaters, 191 -# direct-acting electric. Companion fields (meter / control / fuel / hot water) -# are NOT listed here — they are derived from the code below, so a new archetype -# is just a code (ADR-0035 drag-along). +# direct-acting electric, 691 panel/convector/radiant electric room heaters +# (Table 4a — direct-acting, so a single-rate meter, NOT off-peak storage). +# Companion fields (meter / control / fuel / hot water) are NOT listed here — +# they are derived from the code below, so a new archetype is just a code +# (ADR-0035 drag-along). _MAIN_HEATING_CODES: dict[str, int] = { "Gas boiler, combi": 104, "Gas boiler, regular": 102, @@ -92,14 +108,16 @@ _MAIN_HEATING_CODES: dict[str, int] = { "Electric storage heaters, convector": 403, "Electric storage heaters, fan": 404, "Direct-acting electric": 191, + "Electric room heaters": 691, } def _meter_for(code: int) -> str: """The coherent meter a heating code implies: an off-peak ("Dual") meter for - the calculator's §12 off-peak systems, an explicit single-rate ("Single") - meter for every other system. Always set — never left to bleed.""" - return _OFF_PEAK_METER if code in OFF_PEAK_IMPLYING_HEATING_CODES else _SINGLE_RATE_METER + the §12 off-peak systems and all-electric room-heater dwellings + (`_ASSUMED_DUAL_METER_CODES`), an explicit single-rate ("Single") meter for + every other system. Always set — never left to bleed.""" + return _OFF_PEAK_METER if code in _ASSUMED_DUAL_METER_CODES else _SINGLE_RATE_METER def _control_for(code: int) -> Optional[int]: @@ -146,5 +164,10 @@ def main_heating_overlay_for( sap_main_heating_code=code, meter_type=_meter_for(code), main_heating_control=_control_for(code), + # A landlord override describes the existing dwelling, so its assumed + # off-peak meter must not downgrade a more-off-peak cert meter + # (e.g. a 24-hour all-low tariff). Measures, which re-meter for real, + # build their HeatingOverlay directly and leave this False. + keep_existing_off_peak_meter=True, ) ) diff --git a/domain/epc/property_overlays/wall_type_overlay.py b/domain/epc/property_overlays/wall_type_overlay.py index 0620819c..848ced77 100644 --- a/domain/epc/property_overlays/wall_type_overlay.py +++ b/domain/epc/property_overlays/wall_type_overlay.py @@ -31,6 +31,9 @@ _MATERIAL_CONSTRUCTION: dict[str, int] = { "Curtain Wall": 9, } +# RdSAP `WALL_SYSTEM_BUILT` — shares code 6 with the gov-EPC basement sentinel. +_WALL_SYSTEM_BUILT = 6 + # RdSAP `wall_insulation_type` codes by insulation-state suffix # (domain/sap10_ml/rdsap_uvalues.py): external 1, filled-cavity 2, internal 3, # as-built/uninsulated 4, cavity+external 6, cavity+internal 7. @@ -66,11 +69,18 @@ def wall_overlay_for( if building_part == 0 else BuildingPartIdentifier.extension(building_part) ) + # System-built is RdSAP code 6, which collides with the gov-EPC code-6 + # basement sentinel that `main_wall_is_basement` falls back to. A landlord + # naming a System-built wall is asserting the material, not a basement — pin + # the flag False so the override isn't mis-read as one (ADR-0033 / the + # overlay mirror of the mapper's `_clear_basement_flag_when_system_built`). + wall_is_basement = False if construction == _WALL_SYSTEM_BUILT else None return EpcSimulation( building_parts={ identifier: BuildingPartOverlay( wall_construction=construction, wall_insulation_type=insulation, + wall_is_basement=wall_is_basement, ) } ) diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index bea14e6a..3bfff01e 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -24,4 +24,5 @@ class MainHeatingSystemType(Enum): ELECTRIC_STORAGE_CONVECTOR = "Electric storage heaters, convector" ELECTRIC_STORAGE_FAN = "Electric storage heaters, fan" DIRECT_ELECTRIC = "Direct-acting electric" + ELECTRIC_ROOM_HEATERS = "Electric room heaters" UNKNOWN = "Unknown" diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 93875bbf..36fedb83 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -152,6 +152,22 @@ _SAP_HEATING_FIELDS: tuple[str, ...] = ( _ENERGY_SOURCE_FIELDS: tuple[str, ...] = ("meter_type", "mains_gas") +def _is_off_peak_meter(meter_type: object) -> bool: + """True iff the meter resolves to an off-peak Table 12a tariff (not the + STANDARD single-rate column). Unparseable / absent meters count as not + off-peak so a coherent override meter still applies to them.""" + from domain.sap10_calculator.exceptions import UnmappedSapCode + from domain.sap10_calculator.tables.table_12a import ( + Tariff, + tariff_from_meter_type, + ) + + try: + return tariff_from_meter_type(meter_type) is not Tariff.STANDARD + except UnmappedSapCode: + return False + + def _fold_heating(epc: EpcPropertyData, overlay: HeatingOverlay) -> None: """Write a `HeatingOverlay`'s non-``None`` fields onto the (copied) dwelling, routing each to its home: the primary ``main_heating_details[0]``, the @@ -181,8 +197,25 @@ def _fold_heating(epc: EpcPropertyData, overlay: HeatingOverlay) -> None: epc.has_hot_water_cylinder = overlay.has_hot_water_cylinder for field_name in _ENERGY_SOURCE_FIELDS: value = getattr(overlay, field_name) - if value is not None: - setattr(epc.sap_energy_source, field_name, value) + if value is None: + continue + # A landlord heating override's assumed meter (Dual for off-peak + # systems) is a coherent default, not a re-metering of the cert: when it + # opts in (`keep_existing_off_peak_meter`), don't downgrade a cert that + # already lodges a MORE off-peak meter (e.g. a 24-hour all-low tariff) to + # the overlay's 7-hour E7 — keep the cert's (more specific) one. Single/ + # unknown existing meters still receive the off-peak meter, and a switch + # to single-rate still resets it (its desired value isn't off-peak). + # Heating MEASURES leave the flag False — they re-meter for real + # (Elmhurst re-lodges 18-hour → Dual on a storage install). + if ( + field_name == "meter_type" + and overlay.keep_existing_off_peak_meter + and _is_off_peak_meter(value) + and _is_off_peak_meter(epc.sap_energy_source.meter_type) + ): + continue + setattr(epc.sap_energy_source, field_name, value) # `SolarOverlay` fields all live on `sap_energy_source` (the home of the SAP diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 2f6fc94d..401aaf27 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -36,6 +36,14 @@ class BuildingPartOverlay: construction_age_band: Optional[str] = None wall_construction: Optional[int] = None wall_insulation_type: Optional[int] = None + # Disambiguates the RdSAP `wall_construction == 6` code collision: gov-EPC + # code 6 = "Basement wall" but RdSAP `WALL_SYSTEM_BUILT` is also 6, and + # `SapBuildingPart.main_wall_is_basement` falls back to the code-6 heuristic + # when the flag is `None`. A Landlord Override that sets a System-built wall + # (construction 6) must therefore set this `False` so the override isn't + # mis-read as a basement — the overlay-path mirror of the gov-API mapper's + # `_clear_basement_flag_when_system_built`. + wall_is_basement: Optional[bool] = None # Added solid-wall insulation depth (mm) — drives the calculator's Table 6 # bucket / §5.8 documentary U-value for EWI (`wall_insulation_type=1`) and # IWI (`wall_insulation_type=3`); λ defaults to 0.04 W/m·K in the calculator. @@ -185,6 +193,13 @@ class HeatingOverlay: # sap_energy_source meter_type: Optional[str] = None mains_gas: 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 + # (e.g. a 24-hour all-low tariff → the overlay's 7-hour E7). A heating + # MEASURE re-meters for real (Elmhurst re-lodges 18-hour → Dual on a storage + # install), so it leaves this False. `_fold_heating` reads it. + keep_existing_off_peak_meter: bool = False @dataclass(frozen=True) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index e4bd544c..ea73c0f7 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -11,12 +11,10 @@ import pytest from domain.epc.property_overrides.main_heating_system_type import MainHeatingSystemType from domain.epc.property_overlays.main_fuel_overlay import fuel_overlay_for from domain.epc.property_overlays.main_heating_system_overlay import ( + _ASSUMED_DUAL_METER_CODES, _MAIN_HEATING_CODES, main_heating_overlay_for, ) -from domain.sap10_calculator.tables.table_12a import ( - OFF_PEAK_IMPLYING_HEATING_CODES, -) from domain.epc.property_overlays.water_heating_overlay import ( water_heating_overlay_for, ) @@ -36,6 +34,29 @@ def test_gas_combi_overlays_the_primary_heating_code() -> None: assert simulation.heating.sap_main_heating_code == 104 +def test_electric_room_heaters_overlay_the_direct_acting_room_heater_code() -> None: + # Act — panel/convector/radiant direct-acting electric room heaters + simulation = main_heating_overlay_for("Electric room heaters", 0) + + # Assert — SAP Table 4a code 691, NOT convector storage (403). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 691 + + +def test_electric_room_heaters_assume_a_dual_economy7_meter() -> None: + # A dwelling on electric room heaters is all-electric and realistically + # billed on Economy 7 (its immersion hot water charges overnight; §12 Rule 3 + # gives the room heaters a 10-hour off-peak window). When the landlord names + # only the system, the coherent meter to assume is Dual — the §12 dispatch + # then applies the realistic high/low split, not a single-rate over-penalty. + simulation = main_heating_overlay_for("Electric room heaters", 0) + + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.meter_type == "Dual" + + @pytest.mark.parametrize( ("main_heating_value", "code"), [ @@ -218,7 +239,7 @@ def test_off_peak_archetypes_drag_dual_others_drag_single() -> None: for value, code in _MAIN_HEATING_CODES.items(): simulation = main_heating_overlay_for(value, 0) assert simulation is not None and simulation.heating is not None - expected = "Dual" if code in OFF_PEAK_IMPLYING_HEATING_CODES else "Single" + expected = "Dual" if code in _ASSUMED_DUAL_METER_CODES else "Single" assert simulation.heating.meter_type == expected, value @@ -308,6 +329,48 @@ def test_the_three_heating_overrides_compose_without_conflict() -> None: assert result.sap_heating.water_heating_fuel == 29 +def test_room_heaters_preserve_an_existing_more_off_peak_cert_meter() -> None: + # The overlay's assumed Dual (7-hour E7) meter is a coherent *default* for a + # single/unknown-meter dwelling — it must NOT downgrade a cert that already + # lodges a more-off-peak meter (here a 24-hour all-low tariff, code "4"). + # Clobbering it to E7 would bill the heating on a high/low split it doesn't + # have, under-rating the dwelling. + baseline = build_epc() + baseline.sap_energy_source.meter_type = "4" # 24-hour tariff + overlay = main_heating_overlay_for("Electric room heaters", 0) + assert overlay is not None + + result = apply_simulations(baseline, [overlay]) + + assert result.sap_energy_source.meter_type == "4" + + +def test_room_heaters_set_dual_when_the_cert_meter_is_single() -> None: + # The flip side: a single-rate dwelling DOES get the assumed Dual meter — + # off-peak heating can't be billed on a single-rate meter (ADR-0035 drag). + baseline = build_epc() + baseline.sap_energy_source.meter_type = "Single" + overlay = main_heating_overlay_for("Electric room heaters", 0) + assert overlay is not None + + result = apply_simulations(baseline, [overlay]) + + assert result.sap_energy_source.meter_type == "Dual" + + +def test_electric_room_heaters_member_decodes_to_the_room_heater_code() -> None: + # Arrange — the canonical landlord archetype for direct-acting room heaters + member = MainHeatingSystemType.ELECTRIC_ROOM_HEATERS + + # Act + simulation = main_heating_overlay_for(member.value, 0) + + # Assert — member value stays in lock-step with the overlay (code 691) + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 691 + + @pytest.mark.parametrize( "member", [m for m in MainHeatingSystemType if m is not MainHeatingSystemType.UNKNOWN], diff --git a/tests/domain/epc/test_wall_type_overlay.py b/tests/domain/epc/test_wall_type_overlay.py index 5b1cc9a8..36015d6f 100644 --- a/tests/domain/epc/test_wall_type_overlay.py +++ b/tests/domain/epc/test_wall_type_overlay.py @@ -25,6 +25,30 @@ def test_solid_brick_with_internal_insulation_overlays_main_wall() -> None: assert overlay.wall_insulation_type == 3 +def test_system_built_override_is_not_mis_read_as_a_basement() -> None: + # A System-built wall is RdSAP code 6, which collides with the gov-EPC + # code-6 basement sentinel. The overlay must set wall_is_basement=False so + # main_wall_is_basement doesn't fire the code-6 heuristic (phantom basement). + simulation = wall_overlay_for( + "System built, as built, no insulation (assumed)", 0 + ) + + assert simulation is not None + overlay = simulation.building_parts[BuildingPartIdentifier.MAIN] + assert overlay.wall_construction == 6 + assert overlay.wall_is_basement is False + + +def test_non_system_built_override_leaves_basement_flag_untouched() -> None: + # Cavity (code 4) can't collide with the basement sentinel, so the overlay + # must not assert a basement verdict either way — leave the flag None. + simulation = wall_overlay_for("Cavity wall, with internal insulation", 0) + + assert simulation is not None + overlay = simulation.building_parts[BuildingPartIdentifier.MAIN] + assert overlay.wall_is_basement is None + + @pytest.mark.parametrize( ("wall_type_value", "construction", "insulation"), [ diff --git a/tests/domain/epc_prediction/test_component_accuracy_gate.py b/tests/domain/epc_prediction/test_component_accuracy_gate.py index cc262c17..ce7105ef 100644 --- a/tests/domain/epc_prediction/test_component_accuracy_gate.py +++ b/tests/domain/epc_prediction/test_component_accuracy_gate.py @@ -56,6 +56,15 @@ _FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "epc_prediction" # new-build-vs-old-stock service mismatch on 1-2 targets each (heating_main_fuel # 0.9722->0.9394, water_heating_fuel ->0.9495, cylinder_insulation_type 0.6667-> # 0.3333) plus floor_area (+0.31 MAE). Tighten-only resumes from these values. +# +# has_pv re-baselined 0.9798->0.9697 when full-SAP lodged PV mapping landed +# (datatypes/epc/domain/mapper.py `_sap_17_1_pv_arrays`): full-SAP certs lodge +# their measured array under `sap_energy_source.pv_arrays`, which the schema +# dropped at parse, so the leave-one-out scorer's *actual* has_pv read False for +# every full-SAP PV dwelling. Carrying the array now reads the true has_pv=True, +# and one full-SAP target the similarity-weighted donors don't predict as PV +# tips the agreement 32/33 (the held-out actual is now correct — a ground-truth- +# method change, not a prediction-logic loosening). Tighten-only resumes here. _RATE_FLOORS: dict[str, float] = { "wall_construction": 0.9091, "wall_insulation_type": 0.8687, @@ -76,7 +85,7 @@ _RATE_FLOORS: dict[str, float] = { "floor_insulation": 0.9375, "has_room_in_roof": 0.9495, "modal_glazing_type": 0.8384, - "has_pv": 0.9798, + "has_pv": 0.9697, "solar_water_heating": 1.0000, } 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 5575384f..6988818d 100644 --- a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py +++ b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py @@ -122,12 +122,18 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( # (engine uses the cert's measured 0.19/0.11/0.11 U-values; Elmhurst uses # age-band L proxies + party-wall default) plus FGHRS (cert idx 60031) omitted # on BOTH sides (the engine can't yet model full-SAP FGHRS). PINNED TO THE - # OBSERVED 82, not lodged 84 — mapping deliberately untuned. + # OBSERVED 83 (was 82), not lodged 84 — mapping deliberately untuned. + # WAS 82 until the full-SAP electricity-tariff → RdSAP meter_type fix: this + # cert lodges energy_tariff=1 (standard), which the mapper previously passed + # through untranslated as RdSAP meter_type "1" — wrongly read as dual/Economy 7 + # and priced on the off-peak high/low split. Translating it to "single" (the + # correct standard tariff) re-prices its electricity at the flat rate, lifting + # this gas semi 82→83. No PV (sap_energy_source.pv_arrays absent). RealCertExpectation( schema="SAP-Schema-17.1", sample="uprn_10093116528", cert_num="8000-8495-2839-2607-9683", - sap_score=82, + sap_score=83, ), # UPRN 10093116543 → cert 8358-7436-5620-6889-0906. SAP-Schema-17.1 — a # FULL-SAP cert (2017 mains-gas COMBI semi, Emsworth), forced through the @@ -290,13 +296,18 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( # control 2106 (CBE); water from primary (combi); MEV on; AP50 Blower Door 3.5. # The −3 vs lodged 85 is the documented full-SAP→RdSAP gap: the engine uses the # cert's MEASURED U (wall 0.24 / floor 0.13, WORSE than RdSAP band-M defaults) - # + MEV priced as extract loss not heat recovery. PINNED to the observed 82 — - # mapping untuned; engine == Elmhurst. + # + MEV priced as extract loss not heat recovery. PINNED to the observed 84 + # (was 82), still −1 vs lodged 85 — mapping untuned. + # WAS 82 until full-SAP lodged PV mapping landed: this cert lodges a 0.38 kWp + # array under sap_energy_source.pv_arrays (SE-facing, pitch 30°, unshaded) that + # the schema dropped at parse, so the Appendix-M generation credit was lost. + # Carrying it (mapper `_sap_17_1_pv_arrays`) credits the generation and lifts + # this flat 82→84, closing most of the gap to the lodged 85 the array explains. RealCertExpectation( schema="SAP-Schema-19.1.0", sample="uprn_10096028301", cert_num="0390-3321-6060-2405-7985", - sap_score=82, + sap_score=84, ), # UPRN 44012843 → cert 0775-2898-6628-9594-8005. SAP-Schema-16.3 — a # reduced-field (RdSAP-shaped) ground-floor FLAT, band K (2007-2011), cavity @@ -326,14 +337,20 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( # worksheet SAP 80 — engine EXACTLY matches (80.13 vs 80); engine-on-Elmhurst's- # own-parsed-inputs 81.03 ≈ 80 → calculator faithful. Boiler set to the cert's # exact PCDB 16211 via the search dialog; control 2106 (CBE); water from primary - # (combi); MEV on; AP50 Blower Door 3.2; party wall 6.43 m entered. The −2 vs - # lodged 82 is the documented full-SAP→RdSAP gap (measured U 0.2/0.1 + MEV - # extract loss). PINNED to the observed 80 — mapping untuned; engine == Elmhurst. + # (combi); MEV on; AP50 Blower Door 3.2; party wall 6.43 m entered. + # WAS 80 (engine == Elmhurst, both built WITHOUT PV) until full-SAP lodged PV + # mapping landed: this cert lodges a 0.48 kWp array under + # sap_energy_source.pv_arrays (SE-facing, pitch 30°, unshaded) the schema + # dropped at parse. Crediting it (mapper `_sap_17_1_pv_arrays`) closes the + # −2 gap exactly — the engine now reproduces the accredited lodged 82. The + # Elmhurst worksheet (80) omitted the PV (not entered in the RdSAP build), so + # the +2 over Elmhurst is the now-credited array, not a calculator drift. + # PINNED to the observed 82 == lodged 82 — mapping untuned. RealCertExpectation( schema="SAP-Schema-17.0", sample="uprn_10023444324", cert_num="8501-5064-6739-1407-0163", - sap_score=80, + sap_score=82, ), # UPRN 10023444320 → cert 0868-6045-7331-4376-0914. SAP-Schema-17.0 — FULL-SAP # MID-FLOOR FLAT (sibling of 10023444324, same block / combi PCDB 16211 / MEV), @@ -342,12 +359,24 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( # worksheet 82 — engine within ~1 (81.38 vs 82); engine-on-Elmhurst-inputs 82.46 # ≈ 82 → calculator faithful. Boiler PCDB 16211 via search; control 2106 (CBE); # water from primary (combi); MEV on; AP50 Blower Door 3.09; mid-floor (floor = - # another dwelling below). PINNED to the observed 81 — mapping untuned. + # another dwelling below). + # WAS 81 until full-SAP lodged PV mapping landed: this cert lodges the SAME + # 0.48 kWp array as its ground-floor sibling 10023444324 under + # sap_energy_source.pv_arrays (the block's roof PV apportioned to the flat on + # the lodged cert). Crediting it faithfully (mapper `_sap_17_1_pv_arrays`) + # lifts this flat 81→83. NOTE this lands +2 OVER the lodged 81 (and +1 over the + # Elmhurst worksheet 82) — unlike the ground-floor sibling whose pre-PV engine + # was 2 UNDER lodged so the same array closed the gap exactly. The mid-floor's + # pre-PV engine already matched lodged, so the credited array now overshoots: + # the lodged 81 does not appear to carry the array's full generation credit + # that SAP Appendix-M awards it. This is the documented full-SAP→RdSAP residual + # (faithful to the cert's lodged PV, not tuned to the lodged integer). PINNED + # to the observed 83 — mapping untuned. RealCertExpectation( schema="SAP-Schema-17.0", sample="uprn_10023444320", cert_num="0868-6045-7331-4376-0914", - sap_score=81, + sap_score=83, ), # UPRN 10090844932 → cert 0646-3008-6208-0619-6204. RdSAP-Schema-20.0.0 — # END-TERRACE HOUSE, 2-storey, band L (2012-2022), cavity insulated, pitched