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:
Daniel Roth 2026-06-30 10:26:17 +01:00 committed by GitHub
commit fad7bd6e96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 371 additions and 31 deletions

View file

@ -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 unknownstandard 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`,

View file

@ -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

View file

@ -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

View file

@ -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,
)
)

View file

@ -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,
)
}
)

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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],

View file

@ -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"),
[

View file

@ -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,
}

View file

@ -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