mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Merge pull request #1363 from Hestia-Homes/fix/hyde-portfolio-audit
fix(full-sap): seven mapper/overlay fixes from the portfolio-796 modelling audit
This commit is contained in:
commit
fad7bd6e96
12 changed files with 371 additions and 31 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue