Merge pull request #1382 from Hestia-Homes/incorrect-overrides

Fix HHRSH eligibility for on-gas-street electric dwellings; rename mains_gas → gas_connection_available
This commit is contained in:
Daniel Roth 2026-07-01 14:53:14 +01:00 committed by GitHub
commit 221e310fd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1200 additions and 617 deletions

View file

@ -5,7 +5,10 @@ from datetime import date
import pytest
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier, EpcPropertyData
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
)
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
FIXTURE_PATH = os.path.join(
@ -179,10 +182,15 @@ class TestBuildingPart:
assert len(result.sap_building_parts[0].sap_floor_dimensions) == 1
def test_floor_dimension_area(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 == 44.89
assert (
result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2
== 44.89
)
def test_floor_dimension_room_height(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.24
assert (
result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.24
)
def test_floor_dimension_heat_loss_perimeter(self, result: EpcPropertyData) -> None:
assert (
@ -245,11 +253,17 @@ class TestWindows:
def test_transmission_solar_transmittance(self, result: EpcPropertyData) -> None:
assert result.sap_windows[0].window_transmission_details is not None
assert result.sap_windows[0].window_transmission_details.solar_transmittance == 0.72
assert (
result.sap_windows[0].window_transmission_details.solar_transmittance
== 0.72
)
def test_transmission_data_source(self, result: EpcPropertyData) -> None:
assert result.sap_windows[0].window_transmission_details is not None
assert result.sap_windows[0].window_transmission_details.data_source == "Manufacturer"
assert (
result.sap_windows[0].window_transmission_details.data_source
== "Manufacturer"
)
class TestHeating:
@ -308,7 +322,7 @@ class TestHeating:
class TestEnergySource:
def test_mains_gas(self, result: EpcPropertyData) -> None:
assert result.sap_energy_source.mains_gas is True
assert result.sap_energy_source.gas_connection_available is True
def test_meter_type(self, result: EpcPropertyData) -> None:
assert result.sap_energy_source.meter_type == "Single"
@ -384,5 +398,7 @@ class TestWindowFrameMaterial:
class TestLowEnergyLighting:
def test_low_energy_fixed_lighting_bulbs_count(self, result2: EpcPropertyData) -> None:
def test_low_energy_fixed_lighting_bulbs_count(
self, result2: EpcPropertyData
) -> None:
assert result2.low_energy_fixed_lighting_bulbs_count == 5

View file

@ -177,7 +177,7 @@ class TestPdfToEpcPropertyData:
),
],
sap_energy_source=SapEnergySource(
mains_gas=True,
gas_connection_available=True,
meter_type="Single",
pv_battery_count=0,
wind_turbines_count=0,

View file

@ -361,7 +361,7 @@ class PhotovoltaicArray:
@dataclass
class SapEnergySource:
mains_gas: bool
gas_connection_available: bool
meter_type: str # int in API, str (e.g. "Single") in site notes
pv_battery_count: int
wind_turbines_count: int

View file

@ -4,7 +4,18 @@ import re
from dataclasses import replace
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Final, List, Optional, Sequence, Tuple, TypeVar, Union, cast
from typing import (
Any,
Dict,
Final,
List,
Optional,
Sequence,
Tuple,
TypeVar,
Union,
cast,
)
from datatypes.epc.schema.helpers import from_dict
from datatypes.epc.domain.epc import Epc
@ -77,16 +88,16 @@ _SAP_DEFAULT_AGE_BAND: Final[str] = "M"
# (66% × 0.70 in-use factor) as the RdSAP-21 API path when no PCDF index is
# lodged — the cascade handles a null index gracefully.
_SAP_VENTILATION_TYPE_TO_MV_KIND: Final[Dict[int, Optional[str]]] = {
1: None, # natural (with intermittent extract fans)
2: None, # passive stack — treated as natural
3: None, # positive input from loft → natural
4: "EXTRACT_OR_PIV_OUTSIDE", # positive input from outside
5: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, centralised (MEV c)
6: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, decentralised (MEV dc)
7: "MV", # balanced without heat recovery (MV)
8: "MVHR", # balanced with heat recovery (MVHR)
9: None, # natural with extract fans and/or passive vents (legacy)
10: None, # natural with extract fans and passive vents
1: None, # natural (with intermittent extract fans)
2: None, # passive stack — treated as natural
3: None, # positive input from loft → natural
4: "EXTRACT_OR_PIV_OUTSIDE", # positive input from outside
5: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, centralised (MEV c)
6: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, decentralised (MEV dc)
7: "MV", # balanced without heat recovery (MV)
8: "MVHR", # balanced with heat recovery (MVHR)
9: None, # natural with extract fans and/or passive vents (legacy)
10: None, # natural with extract fans and passive vents
}
# rdsap_uvalues WALL_CAVITY = 4 (D7 fallback; U comes from the description).
_SAP_DEFAULT_WALL_CONSTRUCTION: Final[int] = 4
@ -336,7 +347,7 @@ class EpcPropertyDataMapper:
sap_heating=_map_sap_heating(heating, ventilation, survey.water_use),
sap_windows=[_map_sap_window(w) for w in survey.windows],
sap_energy_source=SapEnergySource(
mains_gas=general.mains_gas_available,
gas_connection_available=general.mains_gas_available,
meter_type=general.electric_meter_type,
pv_battery_count=renewables.number_of_pv_batteries,
wind_turbines_count=0 if not renewables.wind_turbines else 1,
@ -442,7 +453,7 @@ class EpcPropertyDataMapper:
]
or None,
sap_energy_source=SapEnergySource(
mains_gas=survey.meters.main_gas,
gas_connection_available=survey.meters.main_gas,
meter_type=survey.meters.electricity_meter_type,
pv_battery_count=0,
wind_turbines_count=1 if survey.renewables.wind_turbine_present else 0,
@ -569,9 +580,11 @@ class EpcPropertyDataMapper:
dwelling_type=(
schema.dwelling_type
if isinstance(schema.dwelling_type, str)
else schema.dwelling_type.value
if hasattr(schema.dwelling_type, "value")
else str(schema.dwelling_type)
else (
schema.dwelling_type.value
if hasattr(schema.dwelling_type, "value")
else str(schema.dwelling_type)
)
),
property_type=str(schema.property_type),
built_form=str(schema.built_form),
@ -686,26 +699,28 @@ class EpcPropertyDataMapper:
# glazed_area band + TFA via the shared reduced-field core. The 10
# rich certs keep their lodged per-window geometry (used directly:
# window_width = area, height = 1.0).
sap_windows=[
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=w.glazing_type,
window_width=_measurement_value(w.window_area),
window_height=1.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
]
if schema.sap_windows
else _synthesise_17_0_sap_windows(schema),
sap_windows=(
[
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=w.glazing_type,
window_width=_measurement_value(w.window_area),
window_height=1.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
]
if schema.sap_windows
else _synthesise_17_0_sap_windows(schema)
),
sap_energy_source=SapEnergySource(
mains_gas=es.mains_gas == "Y",
gas_connection_available=es.mains_gas == "Y",
meter_type=str(es.meter_type),
pv_battery_count=0,
wind_turbines_count=es.wind_turbines_count,
@ -790,9 +805,7 @@ class EpcPropertyDataMapper:
# gov property_type code → str, mirroring from_rdsap_schema_17_1.
# Feeds the calculator's house/flat heat-transmission split.
property_type=(
str(schema.property_type)
if schema.property_type is not None
else None
str(schema.property_type) if schema.property_type is not None else None
),
built_form=(
str(schema.built_form) if schema.built_form is not None else None
@ -846,7 +859,10 @@ class EpcPropertyDataMapper:
led_fixed_lighting_bulbs_count=0,
incandescent_fixed_lighting_bulbs_count=(
(schema.sap_energy_source.fixed_lighting_outlets_count or 0)
- (schema.sap_energy_source.low_energy_fixed_lighting_outlets_count or 0)
- (
schema.sap_energy_source.low_energy_fixed_lighting_outlets_count
or 0
)
),
low_energy_fixed_lighting_bulbs_count=(
schema.sap_energy_source.low_energy_fixed_lighting_outlets_count or 0
@ -913,7 +929,7 @@ class EpcPropertyDataMapper:
# D5: mains_gas derived from the heating fuel (full SAP has no
# explicit flag); tariff → meter_type; wind turbines pass through.
sap_energy_source=SapEnergySource(
mains_gas=_sap_dwelling_on_mains_gas(schema),
gas_connection_available=_sap_dwelling_on_mains_gas(schema),
meter_type=_sap_17_1_meter_type(
schema.sap_energy_source.electricity_tariff
),
@ -1249,7 +1265,7 @@ class EpcPropertyDataMapper:
else _synthesise_17_1_sap_windows(schema)
),
sap_energy_source=SapEnergySource(
mains_gas=es.mains_gas == "Y",
gas_connection_available=es.mains_gas == "Y",
meter_type=str(es.meter_type),
pv_battery_count=0,
wind_turbines_count=es.wind_turbines_count,
@ -1434,30 +1450,32 @@ class EpcPropertyDataMapper:
# ADR-0028: 990/1000 omit sap_windows -> synthesised from the
# glazed_area band + TFA (added in a later slice). The 10 rich
# certs keep their lodged per-window geometry.
sap_windows=[
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=w.glazing_type,
# ADR-0028: the 10 rich certs lodge a real per-window
# window_area (Measurement) -- use it directly as geometry
# (width = area, height = 1.0) rather than the placeholder
# windowless 0x0.
window_width=_measurement_value(w.window_area),
window_height=1.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
]
if schema.sap_windows
else _synthesise_18_0_sap_windows(schema),
sap_windows=(
[
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=w.glazing_type,
# ADR-0028: the 10 rich certs lodge a real per-window
# window_area (Measurement) -- use it directly as geometry
# (width = area, height = 1.0) rather than the placeholder
# windowless 0x0.
window_width=_measurement_value(w.window_area),
window_height=1.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
]
if schema.sap_windows
else _synthesise_18_0_sap_windows(schema)
),
sap_energy_source=SapEnergySource(
mains_gas=es.mains_gas == "Y",
gas_connection_available=es.mains_gas == "Y",
meter_type=str(es.meter_type),
pv_battery_count=0,
wind_turbines_count=es.wind_turbines_count,
@ -1523,7 +1541,9 @@ class EpcPropertyDataMapper:
),
sap_room_in_roof=(
SapRoomInRoof(
floor_area=_measurement_value(bp.sap_room_in_roof.floor_area),
floor_area=_measurement_value(
bp.sap_room_in_roof.floor_area
),
construction_age_band=bp.sap_room_in_roof.construction_age_band,
)
if bp.sap_room_in_roof
@ -1552,9 +1572,11 @@ class EpcPropertyDataMapper:
dwelling_type=(
schema.dwelling_type
if isinstance(schema.dwelling_type, str)
else schema.dwelling_type.value
if hasattr(schema.dwelling_type, "value")
else str(schema.dwelling_type)
else (
schema.dwelling_type.value
if hasattr(schema.dwelling_type, "value")
else str(schema.dwelling_type)
)
),
property_type=str(schema.property_type),
built_form=str(schema.built_form),
@ -1658,26 +1680,28 @@ class EpcPropertyDataMapper:
# glazed_area band + TFA via the shared reduced-field core. The 6
# rich certs keep their lodged per-window geometry (used directly:
# window_width = area, height = 1.0).
sap_windows=[
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=w.glazing_type,
window_width=_measurement_value(w.window_area),
window_height=1.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
]
if schema.sap_windows
else _synthesise_19_0_sap_windows(schema),
sap_windows=(
[
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=w.glazing_type,
window_width=_measurement_value(w.window_area),
window_height=1.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
]
if schema.sap_windows
else _synthesise_19_0_sap_windows(schema)
),
sap_energy_source=SapEnergySource(
mains_gas=es.mains_gas == "Y",
gas_connection_available=es.mains_gas == "Y",
meter_type=str(es.meter_type),
pv_battery_count=0,
wind_turbines_count=es.wind_turbines_count,
@ -1945,7 +1969,7 @@ class EpcPropertyDataMapper:
else _synthesise_20_0_0_sap_windows(schema)
),
sap_energy_source=SapEnergySource(
mains_gas=es.mains_gas == "Y",
gas_connection_available=es.mains_gas == "Y",
meter_type=str(es.meter_type),
pv_battery_count=0,
wind_turbines_count=es.wind_turbines_count,
@ -2195,7 +2219,7 @@ class EpcPropertyDataMapper:
for w in schema.sap_windows
],
sap_energy_source=SapEnergySource(
mains_gas=es.mains_gas == "Y",
gas_connection_available=es.mains_gas == "Y",
meter_type=str(es.meter_type),
pv_battery_count=es.pv_battery_count or 0,
wind_turbines_count=es.wind_turbines_count,
@ -2300,11 +2324,14 @@ class EpcPropertyDataMapper:
SapAlternativeWall(
wall_area=bp.sap_alternative_wall_1.wall_area,
wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined,
wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_1.wall_construction),
wall_construction=_api_wall_construction_code(
bp.sap_alternative_wall_1.wall_construction
),
wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type,
wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured,
wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness,
is_sheltered=bp.sap_alternative_wall_1.sheltered_wall == "Y",
is_sheltered=bp.sap_alternative_wall_1.sheltered_wall
== "Y",
)
if bp.sap_alternative_wall_1
else None
@ -2313,11 +2340,14 @@ class EpcPropertyDataMapper:
SapAlternativeWall(
wall_area=bp.sap_alternative_wall_2.wall_area,
wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined,
wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_2.wall_construction),
wall_construction=_api_wall_construction_code(
bp.sap_alternative_wall_2.wall_construction
),
wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type,
wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured,
wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness,
is_sheltered=bp.sap_alternative_wall_2.sheltered_wall == "Y",
is_sheltered=bp.sap_alternative_wall_2.sheltered_wall
== "Y",
)
if bp.sap_alternative_wall_2
else None
@ -2524,7 +2554,7 @@ class EpcPropertyDataMapper:
or None,
# SAP energy source
sap_energy_source=SapEnergySource(
mains_gas=es.mains_gas == "Y",
gas_connection_available=es.mains_gas == "Y",
meter_type=str(es.meter_type),
pv_battery_count=es.pv_battery_count or 0,
wind_turbines_count=es.wind_turbines_count,
@ -2630,11 +2660,14 @@ class EpcPropertyDataMapper:
SapAlternativeWall(
wall_area=bp.sap_alternative_wall_1.wall_area,
wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined,
wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_1.wall_construction),
wall_construction=_api_wall_construction_code(
bp.sap_alternative_wall_1.wall_construction
),
wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type,
wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured,
wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness,
is_sheltered=bp.sap_alternative_wall_1.sheltered_wall == "Y",
is_sheltered=bp.sap_alternative_wall_1.sheltered_wall
== "Y",
)
if bp.sap_alternative_wall_1
else None
@ -2643,11 +2676,14 @@ class EpcPropertyDataMapper:
SapAlternativeWall(
wall_area=bp.sap_alternative_wall_2.wall_area,
wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined,
wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_2.wall_construction),
wall_construction=_api_wall_construction_code(
bp.sap_alternative_wall_2.wall_construction
),
wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type,
wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured,
wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness,
is_sheltered=bp.sap_alternative_wall_2.sheltered_wall == "Y",
is_sheltered=bp.sap_alternative_wall_2.sheltered_wall
== "Y",
)
if bp.sap_alternative_wall_2
else None
@ -2956,7 +2992,14 @@ class EpcPropertyDataMapper:
# Mirrored here read-only to back-solve a room count from full SAP's measured
# living_area (single home is the calculator; this is the inverse lookup).
_SAP_LIVING_AREA_FRACTION_BY_ROOMS: Final[Dict[int, float]] = {
1: 0.75, 2: 0.50, 3: 0.30, 4: 0.25, 5: 0.21, 6: 0.18, 7: 0.16, 8: 0.14,
1: 0.75,
2: 0.50,
3: 0.30,
4: 0.25,
5: 0.21,
6: 0.18,
7: 0.16,
8: 0.14,
}
@ -3043,9 +3086,9 @@ def _sap_17_1_meter_type(electricity_tariff: Optional[int]) -> str:
# 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)
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
5: "off-peak 18 hour", # off-peak 18 hour (Table 12a EIGHTEEN_HOUR)
}
@ -3801,7 +3844,9 @@ def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]:
window: Any = d.get("window")
description = ""
if isinstance(window, dict):
description = str(cast(Dict[str, Any], window).get("description") or "").lower()
description = str(
cast(Dict[str, Any], window).get("description") or ""
).lower()
if "single" in description:
d["multiple_glazing_type"] = 5
@ -4755,20 +4800,20 @@ def _synthesise_17_1_sap_windows(schema: RdSapSchema17_1) -> List[SapWindow]:
# is lodged, falling back to the type-only default for missing gaps.
_API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = {
# (u_value, solar_transmittance/g_⊥, frame_factor)
1: (2.8, 0.76, 0.70), # Double glazed, pre-2002 / unknown install
# date — Table 24 row 2 (PVC/wooden), 12mm
# gap default. Schema sibling of code 3.
2: (2.0, 0.72, 0.70), # Double glazed, England/Wales 2002+ (pre-2022)
3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 (12mm gap default)
13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022
14: (1.4, 0.72, 0.70), # Double or triple glazed, post-2022
# (Table 24 last row: 2022+ E/W / 2023+ Sc
# / 2022+ NI — same U/g as code 13 per
# spec; the integer codes 13/14 are
# schema siblings within the post-2022
# product family. Cert 0380 lodges code
# 14 on all windows; worksheet uses U=1.4
# = post-curtain 1.3258.)
1: (2.8, 0.76, 0.70), # Double glazed, pre-2002 / unknown install
# date — Table 24 row 2 (PVC/wooden), 12mm
# gap default. Schema sibling of code 3.
2: (2.0, 0.72, 0.70), # Double glazed, England/Wales 2002+ (pre-2022)
3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 (12mm gap default)
13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022
14: (1.4, 0.72, 0.70), # Double or triple glazed, post-2022
# (Table 24 last row: 2022+ E/W / 2023+ Sc
# / 2022+ NI — same U/g as code 13 per
# spec; the integer codes 13/14 are
# schema siblings within the post-2022
# product family. Cert 0380 lodges code
# 14 on all windows; worksheet uses U=1.4
# = post-curtain 1.3258.)
#
# SINGLE / SECONDARY / TRIPLE glazing — RdSAP 10 Table 24 (spec p.50).
# Previously unmapped (`_api_glazing_transmission` returned None) so the
@ -4776,27 +4821,27 @@ _API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = {
# all-None default 2.5 instead of its true 4.8 — over-rating single-
# glazed dwellings (cert 0370-2933, 7 single windows, +17 SAP). Codes
# per datatypes/epc/domain/epc_codes.csv `glazed_type` (RdSAP-Schema-21).
4: (2.9, 0.85, 0.70), # Secondary glazing, unknown data → Table 24
# Secondary "Normal emissivity" default (2.9).
5: (4.8, 0.85, 0.70), # Single glazing — Table 24 "Single / Any
# period" (PVC/wooden 4.8, g 0.85).
6: (2.1, 0.68, 0.70), # Triple glazed, unknown install date — Table
# 24 Triple pre-2002 12mm-gap default (2.1).
7: (2.8, 0.76, 0.70), # Double glazed, known data — no measured U on
# the reduced-data path → double pre-2002 /
# unknown-date family default (2.8), as code 3.
8: (2.1, 0.68, 0.70), # Triple glazed, known data → triple unknown-
# date family default (2.1), as code 6.
9: (2.0, 0.72, 0.70), # Triple glazed, 2002-2022 — Table 24 "Double
# or triple, 2002+ (pre-2022), any gap" (2.0).
10: (2.1, 0.68, 0.70), # Triple glazed, pre-2002 — Table 24 Triple
# pre-2002 12mm-gap default (2.1).
11: (2.9, 0.85, 0.70), # Secondary glazing, normal emissivity —
# Table 24 Secondary "Normal emissivity" (2.9).
12: (2.2, 0.85, 0.70), # Secondary glazing, low emissivity — Table 24
# Secondary "Low emissivity" (2.2).
15: (4.8, 0.85, 0.70), # Single glazing, known data → Single row
# (4.8) when no measured U is lodged, as code 5.
4: (2.9, 0.85, 0.70), # Secondary glazing, unknown data → Table 24
# Secondary "Normal emissivity" default (2.9).
5: (4.8, 0.85, 0.70), # Single glazing — Table 24 "Single / Any
# period" (PVC/wooden 4.8, g 0.85).
6: (2.1, 0.68, 0.70), # Triple glazed, unknown install date — Table
# 24 Triple pre-2002 12mm-gap default (2.1).
7: (2.8, 0.76, 0.70), # Double glazed, known data — no measured U on
# the reduced-data path → double pre-2002 /
# unknown-date family default (2.8), as code 3.
8: (2.1, 0.68, 0.70), # Triple glazed, known data → triple unknown-
# date family default (2.1), as code 6.
9: (2.0, 0.72, 0.70), # Triple glazed, 2002-2022 — Table 24 "Double
# or triple, 2002+ (pre-2022), any gap" (2.0).
10: (2.1, 0.68, 0.70), # Triple glazed, pre-2002 — Table 24 Triple
# pre-2002 12mm-gap default (2.1).
11: (2.9, 0.85, 0.70), # Secondary glazing, normal emissivity —
# Table 24 Secondary "Normal emissivity" (2.9).
12: (2.2, 0.85, 0.70), # Secondary glazing, low emissivity — Table 24
# Secondary "Low emissivity" (2.2).
15: (4.8, 0.85, 0.70), # Single glazing, known data → Single row
# (4.8) when no measured U is lodged, as code 5.
}
@ -4818,19 +4863,19 @@ _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[
(3, "16+"): (2.7, 0.76, 0.70),
# Double glazed, known data (code 7) — aliases the double pre-2002 /
# unknown-date Table 24 row (same as codes 1/3) when no measured U.
(7, 6): (3.1, 0.76, 0.70),
(7, 12): (2.8, 0.76, 0.70),
(7, 6): (3.1, 0.76, 0.70),
(7, 12): (2.8, 0.76, 0.70),
(7, "16+"): (2.7, 0.76, 0.70),
# Triple glazed pre-2002 / unknown / known-data (codes 6/8/10) —
# Table 24 Triple pre-2002 row varies by gap (6mm=2.4, 12mm=2.1, 16+=2.0).
(6, 6): (2.4, 0.68, 0.70),
(6, 12): (2.1, 0.68, 0.70),
(6, "16+"): (2.0, 0.68, 0.70),
(8, 6): (2.4, 0.68, 0.70),
(8, 12): (2.1, 0.68, 0.70),
(8, "16+"): (2.0, 0.68, 0.70),
(10, 6): (2.4, 0.68, 0.70),
(10, 12): (2.1, 0.68, 0.70),
(6, 6): (2.4, 0.68, 0.70),
(6, 12): (2.1, 0.68, 0.70),
(6, "16+"): (2.0, 0.68, 0.70),
(8, 6): (2.4, 0.68, 0.70),
(8, 12): (2.1, 0.68, 0.70),
(8, "16+"): (2.0, 0.68, 0.70),
(10, 6): (2.4, 0.68, 0.70),
(10, 12): (2.1, 0.68, 0.70),
(10, "16+"): (2.0, 0.68, 0.70),
}
@ -5228,10 +5273,16 @@ def _api_type_2_surfaces(
]
surfaces: List[SapRoomInRoofSurface] = []
gable_specs = (
(type_2.gable_wall_type_1, type_2.gable_wall_length_1,
type_2.gable_wall_height_1),
(type_2.gable_wall_type_2, type_2.gable_wall_length_2,
type_2.gable_wall_height_2),
(
type_2.gable_wall_type_1,
type_2.gable_wall_length_1,
type_2.gable_wall_height_1,
),
(
type_2.gable_wall_type_2,
type_2.gable_wall_length_2,
type_2.gable_wall_height_2,
),
)
for gable_type, length, height in gable_specs:
# Length is mandatory; H may be 0 for the §3.9.2 absent-gable
@ -5298,23 +5349,32 @@ def _api_rir_detailed_surfaces(
# insulation_type is left None so the cascade defers to the Table 17
# column (a) mineral-wool default, mirroring the flat_ceiling branch.
slope_specs = (
(details.slope_length_1, details.slope_height_1,
details.slope_insulation_thickness_1),
(details.slope_length_2, details.slope_height_2,
details.slope_insulation_thickness_2),
(
details.slope_length_1,
details.slope_height_1,
details.slope_insulation_thickness_1,
),
(
details.slope_length_2,
details.slope_height_2,
details.slope_insulation_thickness_2,
),
)
stud_specs = (
(details.stud_wall_length_1, details.stud_wall_height_1,
details.stud_wall_insulation_thickness_1),
(details.stud_wall_length_2, details.stud_wall_height_2,
details.stud_wall_insulation_thickness_2),
(
details.stud_wall_length_1,
details.stud_wall_height_1,
details.stud_wall_insulation_thickness_1,
),
(
details.stud_wall_length_2,
details.stud_wall_height_2,
details.stud_wall_insulation_thickness_2,
),
)
for kind, specs in (("slope", slope_specs), ("stud_wall", stud_specs)):
for length, height, thickness_str in specs:
if (
length is not None and height is not None
and length > 0 and height > 0
):
if length is not None and height is not None and length > 0 and height > 0:
area = _round_half_up_2dp(float(length), float(height))
surfaces.append(
SapRoomInRoofSurface(
@ -5335,10 +5395,7 @@ def _api_rir_detailed_surfaces(
(details.common_wall_length_2, details.common_wall_height_2),
)
for length, height in common_wall_specs:
if (
length is not None and height is not None
and length > 0 and height > 0
):
if length is not None and height is not None and length > 0 and height > 0:
surfaces.append(
SapRoomInRoofSurface(
kind="common_wall",
@ -7449,7 +7506,8 @@ def _elmhurst_cylinder_insulation_code(
def _elmhurst_immersion_type_code(
immersion_type_label: Optional[str], cylinder_present: bool,
immersion_type_label: Optional[str],
cylinder_present: bool,
) -> Optional[int]:
"""Map an Elmhurst §15.1 "Immersion Heater" label ("Dual" / "Single")
to the SAP10 `immersion_heating_type` cascade code (1 = dual, 2 =

View file

@ -201,8 +201,14 @@ class TestFromRdSapSchema20_0_0:
# dict-tolerant array reader captures the arrays.
data = load("20_0_0.json")
data["sap_energy_source"]["photovoltaic_supply"] = [
[{"pitch": 2, "peak_power": {"value": 1.14, "quantity": "kW"},
"orientation": 5, "overshading": 1}],
[
{
"pitch": 2,
"peak_power": {"value": 1.14, "quantity": "kW"},
"orientation": 5,
"overshading": 1,
}
],
[{"pitch": 2, "peak_power": 1.14, "orientation": 5, "overshading": 1}],
]
schema = from_dict(RdSapSchema20_0_0, data)
@ -263,18 +269,30 @@ class TestFromRdSapSchema21_0_0:
# the only one those certs omit is roof_insulation_thickness.)
data = load("21_0_0.json")
for window in data.get("sap_windows", []):
for k in ("frame_factor", "glazing_gap", "pvc_frame",
"window_transmission_details"):
for k in (
"frame_factor",
"glazing_gap",
"pvc_frame",
"window_transmission_details",
):
window.pop(k, None)
for k in ("wet_rooms_count", "open_chimneys_count",
"pressure_test_certificate_number", "windows_transmission_details",
"mechanical_ventilation_index_number", "mechanical_vent_duct_type",
"mechanical_vent_duct_placement", "mechanical_vent_duct_insulation",
"mechanical_vent_duct_insulation_level",
"mechanical_vent_measured_installation", "insulated_door_u_value",
"low_energy_fixed_lighting_bulbs_count",
"cfl_fixed_lighting_bulbs_count", "led_fixed_lighting_bulbs_count",
"suggested_improvements"):
for k in (
"wet_rooms_count",
"open_chimneys_count",
"pressure_test_certificate_number",
"windows_transmission_details",
"mechanical_ventilation_index_number",
"mechanical_vent_duct_type",
"mechanical_vent_duct_placement",
"mechanical_vent_duct_insulation",
"mechanical_vent_duct_insulation_level",
"mechanical_vent_measured_installation",
"insulated_door_u_value",
"low_energy_fixed_lighting_bulbs_count",
"cfl_fixed_lighting_bulbs_count",
"led_fixed_lighting_bulbs_count",
"suggested_improvements",
):
data.pop(k, None)
data["sap_energy_source"].pop("wind_turbine_details", None)
data["sap_energy_source"].pop("pv_battery_count", None)
@ -440,7 +458,9 @@ class TestFromRdSapSchema21_0_1:
assert result.open_chimneys_count == 0
def test_omitted_cfl_fixed_lighting_bulbs_count_defaults_to_zero_not_none(self) -> None:
def test_omitted_cfl_fixed_lighting_bulbs_count_defaults_to_zero_not_none(
self,
) -> None:
data = load("21_0_1.json")
data.pop("cfl_fixed_lighting_bulbs_count", None)
schema = from_dict(RdSapSchema21_0_1, data)
@ -449,7 +469,9 @@ class TestFromRdSapSchema21_0_1:
assert result.cfl_fixed_lighting_bulbs_count == 0
def test_omitted_led_fixed_lighting_bulbs_count_defaults_to_zero_not_none(self) -> None:
def test_omitted_led_fixed_lighting_bulbs_count_defaults_to_zero_not_none(
self,
) -> None:
data = load("21_0_1.json")
data.pop("led_fixed_lighting_bulbs_count", None)
schema = from_dict(RdSapSchema21_0_1, data)
@ -542,7 +564,8 @@ class TestFromRdSapSchema21_0_1:
schema,
sap_building_parts=[
dataclasses.replace(
bps[0], roof_insulation_location=1,
bps[0],
roof_insulation_location=1,
rafter_insulation_thickness="225mm",
),
*bps[1:],
@ -555,9 +578,7 @@ class TestFromRdSapSchema21_0_1:
# Assert
assert result.sap_building_parts[0].rafter_insulation_thickness == "225mm"
def test_cylinder_heat_loss_threaded(
self, schema: RdSapSchema21_0_1
) -> None:
def test_cylinder_heat_loss_threaded(self, schema: RdSapSchema21_0_1) -> None:
# Arrange — the gov API lodges the manufacturer's declared cylinder
# loss factor (kWh/day) in `sap_heating.cylinder_heat_loss` (SAP
# 10.2 §4 branch a). Previously undeclared → `from_dict` dropped it
@ -656,7 +677,9 @@ class TestFromRdSapSchema21_0_1:
def test_lighting_element_description(self, result: EpcPropertyData) -> None:
assert result.lighting is not None
assert result.lighting.description == "Low energy lighting in 50% of fixed outlets"
assert (
result.lighting.description == "Low energy lighting in 50% of fixed outlets"
)
def test_hot_water_element_description(self, result: EpcPropertyData) -> None:
assert result.hot_water is not None
@ -676,7 +699,7 @@ class TestFromRdSapSchema21_0_1:
def test_mains_gas(self, result: EpcPropertyData) -> None:
# mains_gas: "Y"
assert result.sap_energy_source.mains_gas is True
assert result.sap_energy_source.gas_connection_available is True
def test_electricity_smart_meter(self, result: EpcPropertyData) -> None:
# electricity_smart_meter_present: "true"
@ -784,16 +807,27 @@ class TestFromRdSapSchema21_0_1:
assert len(result.sap_building_parts[0].sap_floor_dimensions) == 1
def test_floor_area(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 == 45.82
assert (
result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2
== 45.82
)
def test_floor_height(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.45
assert (
result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.45
)
def test_heat_loss_perimeter(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].sap_floor_dimensions[0].heat_loss_perimeter_m == 19.5
assert (
result.sap_building_parts[0].sap_floor_dimensions[0].heat_loss_perimeter_m
== 19.5
)
def test_party_wall_length(self, result: EpcPropertyData) -> None:
assert result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m == 7.9
assert (
result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m
== 7.9
)
# --- room-in-roof (sap_room_in_roof.room_in_roof_type_1) ---
@ -899,7 +933,6 @@ class TestFromRdSapSchema21_0_1:
assert rhi.impact_of_solid_wall_insulation_kwh == -3560.0
# ---------------------------------------------------------------------------
# Measured wall insulation thickness (`wall_insulation_thickness == "measured"`)
# ---------------------------------------------------------------------------
@ -915,7 +948,9 @@ class TestApiWallConstructionCode:
def test_gov_api_cob_code_9_remaps_to_wall_cob_7(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _api_wall_construction_code # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_wall_construction_code,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_wall_construction_code(9)
@ -927,7 +962,9 @@ class TestApiWallConstructionCode:
# Arrange — codes 1-5 already align; gov 8 (system built) is left
# for u_wall to resolve (calc WALL_PARK_HOME=8 is never dispatched);
# gov 6 (basement) is left to the basement machinery.
from datatypes.epc.domain.mapper import _api_wall_construction_code # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_wall_construction_code,
) # pyright: ignore[reportPrivateUsage]
# Act / Assert
for code in (1, 2, 3, 4, 5, 6, 8):
@ -1015,9 +1052,7 @@ class TestApiResolveSlopingCeilingThickness:
# Act — code 8, no loft-joist thickness, age B (pre-1950), but the
# sloping ceiling carries a lodged 100 mm.
resolved: object = _api_resolve_sloping_ceiling_thickness(
8, None, "B", "100mm"
)
resolved: object = _api_resolve_sloping_ceiling_thickness(8, None, "B", "100mm")
# Assert — the lodged sloping-ceiling thickness wins over the
# pre-1950 None → 0 mm fallback.
@ -1032,9 +1067,7 @@ class TestApiResolveSlopingCeilingThickness:
)
# Act — code 8, no thickness anywhere, pre-1950 age.
resolved: object = _api_resolve_sloping_ceiling_thickness(
8, None, "C", None
)
resolved: object = _api_resolve_sloping_ceiling_thickness(8, None, "C", None)
# Assert — existing Slice 57 behaviour preserved: 0 mm (U=2.30).
assert resolved == 0
@ -1047,9 +1080,7 @@ class TestApiResolveSlopingCeilingThickness:
# Act — code 8, age B (pre-1950), sloping lodged "AB" (As Built —
# categorical, NOT a measured thickness).
resolved: object = _api_resolve_sloping_ceiling_thickness(
8, None, "B", "AB"
)
resolved: object = _api_resolve_sloping_ceiling_thickness(8, None, "B", "AB")
# Assert — "AB" is not a numeric thickness, so it must NOT win; the
# Slice 57 pre-1950 None → 0 mm (U=2.30) rule still applies.
@ -1088,9 +1119,7 @@ class TestElmhurstGlazingTypeWrappedGap:
from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code
# Act
code = _elmhurst_glazing_type_code(
"Double between 2002 and 2021 16 mm or"
)
code = _elmhurst_glazing_type_code("Double between 2002 and 2021 16 mm or")
# Assert — clean "Double between 2002 and 2021" → SAP10 code 3
assert code == 3
@ -1100,9 +1129,7 @@ class TestElmhurstGlazingTypeWrappedGap:
from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code
# Act
code = _elmhurst_glazing_type_code(
"Double between 2002 and 2021 16 mm or 1st"
)
code = _elmhurst_glazing_type_code("Double between 2002 and 2021 16 mm or 1st")
# Assert
assert code == 3
@ -1129,7 +1156,9 @@ class TestApiFloorTypeCode:
def test_code_6_maps_to_another_dwelling_below(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_floor_type_str,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_floor_type_str(6)
@ -1140,7 +1169,9 @@ class TestApiFloorTypeCode:
def test_code_7_still_maps_to_ground_floor(self) -> None:
# Arrange — regression guard: the ground-floor signal the §5 (12)
# suspended-timber rule keys on is unchanged.
from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_floor_type_str,
) # pyright: ignore[reportPrivateUsage]
# Act / Assert
assert _api_floor_type_str(7) == "Ground floor"
@ -1158,7 +1189,9 @@ class TestApiFloorTypeCode:
# (consumed by heat_transmission's party-floor override); it is
# != "Ground floor", so the §5 (12) suspended-timber rule stays
# inert. Pre-this, code 8 raised UnmappedApiCode, blocking the cert.
from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_floor_type_str,
) # pyright: ignore[reportPrivateUsage]
# Act / Assert — no-heat-loss signal (not None, not "Ground floor").
assert _api_floor_type_str(8) == "(another dwelling below)"
@ -1171,7 +1204,9 @@ class TestApiFloorTypeCode:
# constant U=0.7. The string is != "Ground floor" / "(another
# dwelling below)", so it is inert metadata; the U-routing is driven
# by the `is_above_partially_heated_space` floor-dimension flag.
from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_floor_type_str,
) # pyright: ignore[reportPrivateUsage]
# Act / Assert
assert _api_floor_type_str(3) == "(other premises below)"
@ -1182,7 +1217,9 @@ class TestApiFloorTypeCode:
# only, so the cascade routes that floor to U=0.7 (§5.14) and the
# heat-transmission step keeps its area even on a flat whose
# dwelling-level exposure defaults has_exposed_floor=False.
from datatypes.epc.domain.mapper import _api_build_sap_floor_dimensions # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_build_sap_floor_dimensions,
) # pyright: ignore[reportPrivateUsage]
from datatypes.epc.schema.rdsap_schema_21_0_1 import (
SapFloorDimension as ApiSapFloorDimension,
)
@ -1217,7 +1254,9 @@ class TestApiFloorConstructionCode:
def test_code_3_maps_to_suspended_not_timber(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_floor_construction_str,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_floor_construction_str(3)
@ -1230,7 +1269,9 @@ class TestApiFloorConstructionCode:
def test_code_2_still_maps_to_suspended_timber(self) -> None:
# Arrange — regression guard: the timber code is unchanged.
from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_floor_construction_str,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_floor_construction_str(2)
@ -1249,7 +1290,9 @@ class TestApiFloorConstructionCode:
# floor_construction. Empirically inert: floor W/K is identical to
# any explicit construction string across all observed code-0
# certs (the heat loss is governed by floor_heat_loss, not this).
from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_floor_construction_str,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_floor_construction_str(0)
@ -1267,7 +1310,10 @@ class TestDefaultMissingPostTown:
def test_absent_post_town_is_defaulted_to_empty_string(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_default_missing_post_town,
) # pyright: ignore[reportPrivateUsage]
doc: dict[str, object] = {"postcode": "EX31 2LE"}
# Act
@ -1278,7 +1324,10 @@ class TestDefaultMissingPostTown:
def test_present_post_town_is_untouched(self) -> None:
# Arrange — regression guard: a lodged town passes through.
from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_default_missing_post_town,
) # pyright: ignore[reportPrivateUsage]
doc: dict[str, object] = {"post_town": "BARNSTAPLE"}
# Act
@ -1303,7 +1352,9 @@ class TestApiRoofConstructionCode:
def test_code_7_same_dwelling_above_maps_to_none(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_roof_construction_str,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_roof_construction_str(7)
@ -1314,7 +1365,9 @@ class TestApiRoofConstructionCode:
def test_code_6_thatched_maps_to_none(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_roof_construction_str,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_roof_construction_str(6)
@ -1325,7 +1378,9 @@ class TestApiRoofConstructionCode:
def test_code_8_still_maps_to_sloping_ceiling(self) -> None:
# Arrange — regression guard: the sloping-ceiling code is unchanged.
from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_roof_construction_str,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_roof_construction_str(8)
@ -1345,7 +1400,9 @@ class TestApiGlazingTransmissionTable24:
def test_single_glazing_code_5_is_table_24_u_4p8(self) -> None:
# Arrange — RdSAP 21 glazing_type 5 = "single glazing"; Table 24
# row "Single / Any period" → U 4.8 (PVC/wooden), g 0.85.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_glazing_transmission,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(5, None)
@ -1356,7 +1413,9 @@ class TestApiGlazingTransmissionTable24:
def test_single_glazing_known_data_code_15_is_table_24_u_4p8(self) -> None:
# Arrange — code 15 = "single glazing, known data"; same Table 24
# Single row when no measured U is lodged on the reduced-data path.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_glazing_transmission,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(15, None)
@ -1367,7 +1426,9 @@ class TestApiGlazingTransmissionTable24:
def test_secondary_glazing_normal_emissivity_code_11_is_u_2p9(self) -> None:
# Arrange — code 11 = "secondary glazing, normal emissivity";
# Table 24 Secondary "Normal emissivity" row → U 2.9, g 0.85.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_glazing_transmission,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(11, None)
@ -1378,7 +1439,9 @@ class TestApiGlazingTransmissionTable24:
def test_secondary_glazing_low_emissivity_code_12_is_u_2p2(self) -> None:
# Arrange — code 12 = "secondary glazing, low emissivity"; Table 24
# Secondary "Low emissivity" row → U 2.2, g 0.85.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_glazing_transmission,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(12, None)
@ -1389,7 +1452,9 @@ class TestApiGlazingTransmissionTable24:
def test_triple_glazing_2002_to_2022_code_9_is_u_2p0(self) -> None:
# Arrange — code 9 = "triple glazing, installed 2002-2022"; Table 24
# "Double or triple, 2002+ (pre-2022), any gap" → U 2.0, g 0.72.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_glazing_transmission,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(9, None)
@ -1400,7 +1465,9 @@ class TestApiGlazingTransmissionTable24:
def test_triple_glazing_pre_2002_code_10_default_gap_is_u_2p1(self) -> None:
# Arrange — code 10 = "triple glazing, pre-2002"; Table 24 Triple
# pre-2002 12mm-gap default → U 2.1, g 0.68.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_glazing_transmission,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(10, None)
@ -1412,7 +1479,9 @@ class TestApiGlazingTransmissionTable24:
# Arrange — regression guard: the already-mapped double-glazing
# 2002+ entry (U 2.0, g 0.72) is untouched by the single/secondary/
# triple extension.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_glazing_transmission,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(2, None)
@ -1433,7 +1502,9 @@ class TestApiCascadeGlazingCodeDivergentRemap:
def test_single_glazing_code_5_remaps_to_cascade_single_slot_1(self) -> None:
# Arrange — RdSAP-21 code 5 = single glazing; cascade single slot
# is 1 (g⊥ 0.85, g_L 0.90), not cascade 5 (secondary, 0.76/0.80).
from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_cascade_glazing_type,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_cascade_glazing_type(5)
@ -1444,7 +1515,9 @@ class TestApiCascadeGlazingCodeDivergentRemap:
def test_secondary_glazing_code_4_remaps_to_cascade_secondary_slot_5(self) -> None:
# Arrange — RdSAP-21 code 4 = secondary glazing; cascade secondary
# slot is 5 (g⊥ 0.76, g_L 0.80), not cascade 4 (double low-E 0.63).
from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_cascade_glazing_type,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_cascade_glazing_type(4)
@ -1454,7 +1527,9 @@ class TestApiCascadeGlazingCodeDivergentRemap:
def test_double_pre_2002_code_1_remap_unchanged(self) -> None:
# Arrange — regression guard: the existing code-1 remap (→2) stands.
from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_cascade_glazing_type,
) # pyright: ignore[reportPrivateUsage]
# Act
result = _api_cascade_glazing_type(1)
@ -1465,7 +1540,9 @@ class TestApiCascadeGlazingCodeDivergentRemap:
def test_rdsap21_native_codes_pass_through(self) -> None:
# Arrange — codes 9-15 already coincide with the g-table's RdSAP-21
# extension slots, so they must pass through untranslated.
from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_cascade_glazing_type,
) # pyright: ignore[reportPrivateUsage]
# Act / Assert
assert _api_cascade_glazing_type(14) == 14
@ -1644,7 +1721,9 @@ class TestRdSap20_0_0ReducedFieldSynthesis:
# built_form. Build sap_ventilation with sheltered_sides from built_form
# (else the calculator defaults every dwelling to mid-terrace=2). A cert
# with an open fireplace.
from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_sheltered_sides,
) # pyright: ignore[reportPrivateUsage]
corpus = _load_20_0_0_corpus()
if not corpus:
@ -1653,7 +1732,8 @@ class TestRdSap20_0_0ReducedFieldSynthesis:
(
c
for c in corpus
if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1
if not c.get("sap_windows")
and (c.get("open_fireplaces_count") or 0) >= 1
),
None,
)
@ -1691,12 +1771,13 @@ class TestRdSap20_0_0ReducedFieldSynthesis:
if cert is None:
pytest.skip("no corpus cert with instantaneous_wwhrs")
iw = cert["sap_heating"]["instantaneous_wwhrs"]
expected_baths = iw["rooms_with_bath_and_or_shower"] + iw[
"rooms_with_bath_and_mixer_shower"
]
expected_mixers = iw["rooms_with_mixer_shower_no_bath"] + iw[
"rooms_with_bath_and_mixer_shower"
]
expected_baths = (
iw["rooms_with_bath_and_or_shower"] + iw["rooms_with_bath_and_mixer_shower"]
)
expected_mixers = (
iw["rooms_with_mixer_shower_no_bath"]
+ iw["rooms_with_bath_and_mixer_shower"]
)
# Act
result = EpcPropertyDataMapper.from_api_response(cert)
@ -1724,8 +1805,7 @@ class TestRdSap20_0_0ReducedFieldSynthesis:
c
for c in corpus
if any(
"identifier" not in part
for part in c.get("sap_building_parts", [])
"identifier" not in part for part in c.get("sap_building_parts", [])
)
),
None,
@ -1984,7 +2064,9 @@ class TestRdSap18_0ReducedFieldSynthesis:
# percent_draughtproofed, and built_form. Build sap_ventilation with
# sheltered_sides from built_form (else the calculator defaults every
# dwelling to mid-terrace=2). A cert with an open fireplace.
from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_sheltered_sides,
) # pyright: ignore[reportPrivateUsage]
corpus = _load_18_0_corpus()
if not corpus:
@ -1993,7 +2075,8 @@ class TestRdSap18_0ReducedFieldSynthesis:
(
c
for c in corpus
if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1
if not c.get("sap_windows")
and (c.get("open_fireplaces_count") or 0) >= 1
),
None,
)
@ -2031,12 +2114,13 @@ class TestRdSap18_0ReducedFieldSynthesis:
if cert is None:
pytest.skip("no corpus cert with instantaneous_wwhrs")
iw = cert["sap_heating"]["instantaneous_wwhrs"]
expected_baths = iw["rooms_with_bath_and_or_shower"] + iw[
"rooms_with_bath_and_mixer_shower"
]
expected_mixers = iw["rooms_with_mixer_shower_no_bath"] + iw[
"rooms_with_bath_and_mixer_shower"
]
expected_baths = (
iw["rooms_with_bath_and_or_shower"] + iw["rooms_with_bath_and_mixer_shower"]
)
expected_mixers = (
iw["rooms_with_mixer_shower_no_bath"]
+ iw["rooms_with_bath_and_mixer_shower"]
)
# Act
result = EpcPropertyDataMapper.from_api_response(cert)
@ -2111,7 +2195,11 @@ class TestRdSap17_1ReducedFieldSynthesis:
if not corpus:
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
cert = next(
(c for c in corpus if not c.get("sap_windows") and c.get("glazed_area") == 1),
(
c
for c in corpus
if not c.get("sap_windows") and c.get("glazed_area") == 1
),
None,
)
if cert is None:
@ -2132,7 +2220,11 @@ class TestRdSap17_1ReducedFieldSynthesis:
if not corpus:
pytest.skip("no RdSAP-Schema-17.1 corpus harvested")
cert = next(
(c for c in corpus if not c.get("sap_windows") and c.get("glazed_area") == 2),
(
c
for c in corpus
if not c.get("sap_windows") and c.get("glazed_area") == 2
),
None,
)
if cert is None:
@ -2219,7 +2311,9 @@ class TestRdSap17_1ReducedFieldSynthesis:
) -> None:
# ADR-0028: open_fireplaces_count -> chimneys, percent_draughtproofed,
# sheltered_sides from built_form.
from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import (
_api_sheltered_sides,
) # pyright: ignore[reportPrivateUsage]
corpus = _load_17_1_corpus()
if not corpus:
@ -2228,7 +2322,8 @@ class TestRdSap17_1ReducedFieldSynthesis:
(
c
for c in corpus
if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1
if not c.get("sap_windows")
and (c.get("open_fireplaces_count") or 0) >= 1
),
None,
)
@ -2261,12 +2356,13 @@ class TestRdSap17_1ReducedFieldSynthesis:
if cert is None:
pytest.skip("no corpus cert with instantaneous_wwhrs")
iw = cert["sap_heating"]["instantaneous_wwhrs"]
expected_baths = iw["rooms_with_bath_and_or_shower"] + iw[
"rooms_with_bath_and_mixer_shower"
]
expected_mixers = iw["rooms_with_mixer_shower_no_bath"] + iw[
"rooms_with_bath_and_mixer_shower"
]
expected_baths = (
iw["rooms_with_bath_and_or_shower"] + iw["rooms_with_bath_and_mixer_shower"]
)
expected_mixers = (
iw["rooms_with_mixer_shower_no_bath"]
+ iw["rooms_with_bath_and_mixer_shower"]
)
result = EpcPropertyDataMapper.from_api_response(cert)
@ -2314,16 +2410,22 @@ class TestRoomInRoofDetailedSlopeAndStudWall:
rir = cert["sap_building_parts"][0]["sap_room_in_roof"]
rir.pop("room_in_roof_type_1", None)
rir["room_in_roof_details"] = {
"slope_length_1": 7.0, "slope_height_1": 1.4,
"slope_length_2": 7.0, "slope_height_2": 1.4,
"slope_length_1": 7.0,
"slope_height_1": 1.4,
"slope_length_2": 7.0,
"slope_height_2": 1.4,
"slope_insulation_thickness_1": "100mm",
"slope_insulation_thickness_2": "100mm",
"stud_wall_length_1": 7.0, "stud_wall_height_1": 1.03,
"stud_wall_length_2": 7.0, "stud_wall_height_2": 1.03,
"stud_wall_length_1": 7.0,
"stud_wall_height_1": 1.03,
"stud_wall_length_2": 7.0,
"stud_wall_height_2": 1.03,
"stud_wall_insulation_thickness_1": "75mm",
"stud_wall_insulation_thickness_2": "75mm",
"common_wall_length_1": 8.6, "common_wall_height_1": 1.2,
"common_wall_length_2": 8.6, "common_wall_height_2": 1.2,
"common_wall_length_1": 8.6,
"common_wall_height_1": 1.2,
"common_wall_length_2": 8.6,
"common_wall_height_2": 1.2,
}
# Act
@ -2369,12 +2471,16 @@ class TestRoomInRoofType2SimplifiedQuadratic:
rir = cert["sap_building_parts"][0]["sap_room_in_roof"]
rir.pop("room_in_roof_type_1", None)
rir["room_in_roof_type_2"] = {
"gable_wall_type_1": 1, "gable_wall_length_1": 10.0,
"gable_wall_type_1": 1,
"gable_wall_length_1": 10.0,
"gable_wall_height_1": 2.0,
"gable_wall_type_2": 0, "gable_wall_length_2": 6.0,
"gable_wall_type_2": 0,
"gable_wall_length_2": 6.0,
"gable_wall_height_2": 2.0,
"common_wall_length_1": 8.0, "common_wall_height_1": 1.0,
"common_wall_length_2": 8.0, "common_wall_height_2": 1.0,
"common_wall_length_1": 8.0,
"common_wall_height_1": 1.0,
"common_wall_length_2": 8.0,
"common_wall_height_2": 1.0,
}
# Act
@ -2505,7 +2611,9 @@ class TestNonSeparatedConservatoryApiMirror19_0:
)
dwelling_part_count = len(
EpcPropertyDataMapper.from_api_response(load("19_0.json")).sap_building_parts
EpcPropertyDataMapper.from_api_response(
load("19_0.json")
).sap_building_parts
)
cert = load("19_0.json")
@ -2568,7 +2676,9 @@ class TestNonSeparatedConservatoryApiMirror20_0_0:
)
dwelling_part_count = len(
EpcPropertyDataMapper.from_api_response(load("20_0_0.json")).sap_building_parts
EpcPropertyDataMapper.from_api_response(
load("20_0_0.json")
).sap_building_parts
)
cert = load("20_0_0.json")
@ -2637,7 +2747,12 @@ def test_gov_mappers_split_non_separated_conservatory(fixture: str) -> None:
cert = load(fixture)
cert["conservatory_type"] = 4
cert["sap_building_parts"].append(
{"floor_area": 12.0, "room_height": 1, "double_glazed": "Y", "glazed_perimeter": 9.0}
{
"floor_area": 12.0,
"room_height": 1,
"double_glazed": "Y",
"glazed_perimeter": 9.0,
}
)
# Act
@ -2709,9 +2824,17 @@ def test_rdsap_mappers_carry_main_heating_controls_display(
[
(RdSapSchema17_1, EpcPropertyDataMapper.from_rdsap_schema_17_1, "17_1.json"),
(RdSapSchema18_0, EpcPropertyDataMapper.from_rdsap_schema_18_0, "18_0.json"),
(RdSapSchema20_0_0, EpcPropertyDataMapper.from_rdsap_schema_20_0_0, "20_0_0.json"),
(
RdSapSchema20_0_0,
EpcPropertyDataMapper.from_rdsap_schema_20_0_0,
"20_0_0.json",
),
(RdSapSchema19_0, EpcPropertyDataMapper.from_rdsap_schema_19_0, "19_0.json"),
(RdSapSchema21_0_0, EpcPropertyDataMapper.from_rdsap_schema_21_0_0, "21_0_0.json"),
(
RdSapSchema21_0_0,
EpcPropertyDataMapper.from_rdsap_schema_21_0_0,
"21_0_0.json",
),
],
)
def test_rdsap_mappers_map_mechanical_ventilation_kind(
@ -2738,7 +2861,11 @@ def test_rdsap_mappers_map_mechanical_ventilation_kind(
"schema_cls, mapper, fixture",
[
(RdSapSchema19_0, EpcPropertyDataMapper.from_rdsap_schema_19_0, "19_0.json"),
(RdSapSchema21_0_0, EpcPropertyDataMapper.from_rdsap_schema_21_0_0, "21_0_0.json"),
(
RdSapSchema21_0_0,
EpcPropertyDataMapper.from_rdsap_schema_21_0_0,
"21_0_0.json",
),
],
)
def test_rdsap_19_0_21_0_0_construct_sap_ventilation_block(

View file

@ -177,12 +177,15 @@ class TestFullSapElectricityTariffTranslation:
@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)
(5, "EIGHTEEN_HOUR"), # off-peak 18-hour — was dropped to "" → STANDARD
(None, "STANDARD"), # absent/ND → unknown → standard
(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)
(5, "EIGHTEEN_HOUR"), # off-peak 18-hour — was dropped to "" → STANDARD
(None, "STANDARD"), # absent/ND → unknown → standard
],
)
def test_energy_tariff_resolves_to_the_correct_calculator_tariff(
@ -284,8 +287,12 @@ class TestFromSapSchema17_1FabricDescriptions:
def test_wall_description_carries_measured_u(self, sample: EpcPropertyData) -> None:
assert sample.walls[0].description == "Average thermal transmittance 0.17 W/m²K"
def test_floor_description_carries_measured_u(self, sample: EpcPropertyData) -> None:
assert sample.floors[0].description == "Average thermal transmittance 0.13 W/m²K"
def test_floor_description_carries_measured_u(
self, sample: EpcPropertyData
) -> None:
assert (
sample.floors[0].description == "Average thermal transmittance 0.13 W/m²K"
)
def test_roof_description_carries_other_premises(
self, sample: EpcPropertyData
@ -505,7 +512,7 @@ class TestFromSapSchema17_1EnergySource:
def test_mains_gas_derived_from_heating_fuel(self, sample: EpcPropertyData) -> None:
# Main heating fuel is mains gas (code 1) → mains_gas True.
assert sample.sap_energy_source.mains_gas is True
assert sample.sap_energy_source.gas_connection_available is True
def test_lighting_outlet_counts_to_bulbs(self, sample: EpcPropertyData) -> None:
# 1 fixed outlet, 1 low-energy → 1 low-energy bulb, 0 incandescent.
@ -543,7 +550,9 @@ class TestFromSapSchema17_1Ventilation:
schema = from_dict(SapSchema17_1, load("sap_17_1.json"))
return EpcPropertyDataMapper.from_sap_schema_17_1(schema)
def test_measured_air_permeability_fed_as_ap50(self, sample: EpcPropertyData) -> None:
def test_measured_air_permeability_fed_as_ap50(
self, sample: EpcPropertyData
) -> None:
# The lodged `air_permeability` is a q50 Blower-Door result, so it feeds the
# engine's AP50 path `(18) = AP50/20 + (8)` — NOT the AP4/Pulse formula
# `0.263 × AP4^0.924` (the air-permeability AP50 fix, uprn_10093116528).
@ -554,7 +563,10 @@ class TestFromSapSchema17_1Ventilation:
def test_ventilation_type_6_is_extract(self, sample: EpcPropertyData) -> None:
# ventilation_type 6 = MEV decentralised → EXTRACT_OR_PIV_OUTSIDE.
assert sample.sap_ventilation is not None
assert sample.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE"
assert (
sample.sap_ventilation.mechanical_ventilation_kind
== "EXTRACT_OR_PIV_OUTSIDE"
)
def test_sheltered_sides_and_wet_rooms(self, sample: EpcPropertyData) -> None:
assert sample.sap_ventilation is not None
@ -649,14 +661,18 @@ class TestFromSapSchema17_1Perimeter:
# measured exposed-wall area 87.85 m² (uniform per-storey split).
result = self._map("sap_17_1_house.json")
fds = result.sap_building_parts[0].sap_floor_dimensions
gross = sum((fd.heat_loss_perimeter_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds)
gross = sum(
(fd.heat_loss_perimeter_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds
)
assert gross == pytest.approx(87.85, abs=1e-2)
def test_house_routes_party_walls_to_party_length(self) -> None:
# Σ(party_length_i × height_i) must equal the measured party-wall area.
result = self._map("sap_17_1_house.json")
fds = result.sap_building_parts[0].sap_floor_dimensions
party = sum((fd.party_wall_length_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds)
party = sum(
(fd.party_wall_length_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds
)
assert party == pytest.approx(105.44, abs=1e-2)
def test_unknown_wall_type_fails_loud(self) -> None:
@ -1009,5 +1025,7 @@ class TestFullSapSchema16xNoFloorDimensions:
data.pop("sap_flat_details", None)
# Act / Assert
with pytest.raises(ValueError, match="Wardalls Grove.*SE14 5FB.*sap_floor_dimensions"):
with pytest.raises(
ValueError, match="Wardalls Grove.*SE14 5FB.*sap_floor_dimensions"
):
EpcPropertyDataMapper.from_api_response(data)

View file

@ -103,7 +103,7 @@ class TestFromSiteNotesExample1:
def test_mains_gas_available(self, result: EpcPropertyData) -> None:
# general.mains_gas_available: true
assert result.sap_energy_source.mains_gas is True
assert result.sap_energy_source.gas_connection_available is True
def test_electricity_smart_meter(self, result: EpcPropertyData) -> None:
# general.electricity_smart_meter: true
@ -393,7 +393,9 @@ class TestFromSiteNotesExample1:
cylinder_insulation_type="Factory fitted",
cylinder_insulation_thickness_mm=12,
shower_outlets=ShowerOutlets(
shower_outlet=ShowerOutlet(shower_outlet_type="Non-Electric Shower"),
shower_outlet=ShowerOutlet(
shower_outlet_type="Non-Electric Shower"
),
),
),
# Windows
@ -453,7 +455,7 @@ class TestFromSiteNotesExample1:
],
# Energy source
sap_energy_source=SapEnergySource(
mains_gas=True,
gas_connection_available=True,
meter_type="Single",
pv_battery_count=0,
wind_turbines_count=0,
@ -620,7 +622,10 @@ class TestFromSiteNotesFloorConstruction:
def test_floor_construction_type(self, result: EpcPropertyData) -> None:
# building_construction.floor.floor_construction: "Suspended, not timber"
assert result.sap_building_parts[0].floor_construction_type == "Suspended, not timber"
assert (
result.sap_building_parts[0].floor_construction_type
== "Suspended, not timber"
)
def test_floor_insulation_type_str(self, result: EpcPropertyData) -> None:
# building_construction.floor.floor_insulation_type: "As Built"
@ -657,7 +662,10 @@ class TestFromSiteNotesHeatingBoiler:
def test_central_heating_pump_age_str(self, result: EpcPropertyData) -> None:
# heating_and_hot_water.main_heating.central_heating_pump_age: "Unknown"
assert result.sap_heating.main_heating_details[0].central_heating_pump_age_str == "Unknown"
assert (
result.sap_heating.main_heating_details[0].central_heating_pump_age_str
== "Unknown"
)
class TestFromSiteNotesMiscTopLevel:

View file

@ -32,8 +32,8 @@ _FUEL_CODES: dict[str, int] = {
}
# A "mains gas" main fuel asserts the dwelling has a mains-gas connection, so the
# overlay must also flip `sap_energy_source.mains_gas` — not just the fuel code.
# Without it the effective EPC says "fuel = mains gas" while `mains_gas` stays
# overlay must also flip `sap_energy_source.gas_connection_available` — not just the fuel code.
# Without it the effective EPC says "fuel = mains gas" while `gas_connection_available` stays
# False, which (a) gates out the gas-boiler-upgrade Measure and (b) makes the
# heating Generator read the dwelling as off-gas and wrongly offer HHRSH storage
# (property 728513). Only the **private** mains-gas connection (code 26) sets it;
@ -51,7 +51,7 @@ def fuel_overlay_for(
code = _FUEL_CODES.get(main_fuel_value)
if code is None:
return None
mains_gas = True if main_fuel_value in _MAINS_GAS_FUEL_VALUES else None
gas_connection_available = True if main_fuel_value in _MAINS_GAS_FUEL_VALUES else None
return EpcSimulation(
heating=HeatingOverlay(main_fuel_type=code, mains_gas=mains_gas)
heating=HeatingOverlay(main_fuel_type=code, gas_connection_available=gas_connection_available)
)

View file

@ -251,7 +251,7 @@ def _gas_boiler_overlay(code: int) -> HeatingOverlay:
sap_main_heating_code=code,
main_heating_category=_GAS_BOILER_CATEGORY,
main_fuel_type=_MAINS_GAS_FUEL,
mains_gas=True,
gas_connection_available=True,
main_heating_control=_FULL_BOILER_CONTROL,
fan_flue_present=True,
meter_type=_SINGLE_RATE_METER,

View file

@ -124,7 +124,7 @@ _ASHP_OVERLAY = HeatingOverlay(
cylinder_thermostat="Y",
has_hot_water_cylinder=True,
meter_type="Single",
mains_gas=False,
gas_connection_available=False,
)
@ -462,7 +462,7 @@ def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool:
return False
if code in _ELECTRIC_BOILER_SAP_CODE_RANGE:
return False
if not epc.sap_energy_source.mains_gas:
if not epc.sap_energy_source.gas_connection_available:
return False
if main.main_fuel_type in _GAS_FUEL_CODES and _already_condensing(code):
return False
@ -514,9 +514,7 @@ def _boiler_cylinder_overlay(epc: EpcPropertyData) -> HeatingOverlay:
if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm):
jacket_type = _CYLINDER_JACKET_INSULATION_TYPE
jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM
thermostat: Optional[str] = (
"Y" if sap_heating.cylinder_thermostat != "Y" else None
)
thermostat: Optional[str] = "Y" if sap_heating.cylinder_thermostat != "Y" else None
return HeatingOverlay(
main_fuel_type=_MAINS_GAS_FUEL,
heat_emitter_type=_RADIATOR_EMITTER,
@ -584,9 +582,11 @@ def _control_features(main: MainHeatingDetail) -> tuple[bool, bool, bool]:
the control is improvable anyway."""
control = main.main_heating_control
code: Optional[int] = control if isinstance(control, int) else None
return _CONTROL_FEATURES_BY_CODE.get(code, (False, False, False)) if (
code is not None
) else (False, False, False)
return (
_CONTROL_FEATURES_BY_CODE.get(code, (False, False, False))
if (code is not None)
else (False, False, False)
)
def _cylinder_fix_needs(epc: EpcPropertyData) -> tuple[bool, bool]:
@ -714,14 +714,15 @@ def _hhr_storage_option(
def _hhr_storage_eligible(epc: EpcPropertyData) -> bool:
"""HHR storage suits an electrically-heated or off-gas dwelling, unless it is
already HHR or a heat pump (translated from legacy `HeatingRecommender.
is_high_heat_retention_valid`, which keyed on description strings)."""
"""HHR storage suits a non-gas-fuel dwelling, unless it is already HHR or a
heat pump. Eligibility keys on the heating *fuel* (main_fuel_type not in
_GAS_FUEL_CODES), not the gas-connection flag a dwelling on a gas street
with electric or oil heating has gas_connection_available=True but still
qualifies."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
if main.sap_main_heating_code == _HHR_STORAGE_SAP_CODE:
return False
if main.main_heating_category == _HEAT_PUMP_CATEGORY:
return False
off_gas: bool = not epc.sap_energy_source.mains_gas
electric_main: bool = main.main_fuel_type == _ELECTRICITY_FUEL
return electric_main or off_gas
non_gas_fuel: bool = main.main_fuel_type not in _GAS_FUEL_CODES
return non_gas_fuel

View file

@ -149,7 +149,7 @@ _SAP_HEATING_FIELDS: tuple[str, ...] = (
"cylinder_thermostat",
"immersion_heating_type",
)
_ENERGY_SOURCE_FIELDS: tuple[str, ...] = ("meter_type", "mains_gas")
_ENERGY_SOURCE_FIELDS: tuple[str, ...] = ("meter_type", "gas_connection_available")
def _is_off_peak_meter(meter_type: object) -> bool:

View file

@ -154,7 +154,7 @@ class HeatingOverlay:
(`water_heating_*`, cylinder size + insulation);
- the top-level `EpcPropertyData` `has_hot_water_cylinder`;
- ``sap_energy_source`` `meter_type` (an off-peak tariff for storage) and
`mains_gas` (cleared when the dwelling goes all-electric).
`gas_connection_available` (cleared when the dwelling goes all-electric).
The values are **absolute target states**, not deltas (the bundle replaces
the system regardless of the before). A `None` field means "leave the
@ -192,7 +192,7 @@ class HeatingOverlay:
has_hot_water_cylinder: Optional[bool] = None
# sap_energy_source
meter_type: Optional[str] = None
mains_gas: Optional[bool] = None
gas_connection_available: 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

View file

@ -111,9 +111,11 @@ def make_sap_heating(
"""Build a SapHeating with SAP10 API defaults."""
return SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=main_heating_details
if main_heating_details is not None
else [make_main_heating_detail()],
main_heating_details=(
main_heating_details
if main_heating_details is not None
else [make_main_heating_detail()]
),
has_fixed_air_conditioning=has_fixed_air_conditioning,
water_heating_code=water_heating_code,
water_heating_fuel=water_heating_fuel,
@ -173,9 +175,11 @@ def make_building_part(
party_wall_construction=party_wall_construction,
roof_construction=roof_construction,
roof_insulation_location=roof_insulation_location,
sap_floor_dimensions=floor_dimensions
if floor_dimensions is not None
else [make_floor_dimension()],
sap_floor_dimensions=(
floor_dimensions
if floor_dimensions is not None
else [make_floor_dimension()]
),
sap_room_in_roof=sap_room_in_roof,
floor_type=floor_type,
)
@ -183,7 +187,9 @@ def make_building_part(
def make_window(
*,
orientation: Union[int, str] = 5, # SAP10: 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW
orientation: Union[
int, str
] = 5, # SAP10: 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW
width: float = 1.0,
height: float = 1.0,
draught_proofed: bool = True,
@ -195,8 +201,8 @@ def make_window(
permanent_shutters_present: Union[bool, str] = False,
frame_material: Optional[str] = "PVC",
frame_factor: Optional[float] = 0.7, # SAP10.2 Table 6c PVC default;
# mirrors the Elmhurst mapper's
# surfaced value from Summary §11.
# mirrors the Elmhurst mapper's
# surfaced value from Summary §11.
window_transmission_details: Optional[WindowTransmissionDetails] = None,
solar_transmittance: Optional[float] = None,
u_value: float = 2.8,
@ -210,7 +216,9 @@ def make_window(
"""
if window_transmission_details is None and solar_transmittance is not None:
window_transmission_details = WindowTransmissionDetails(
u_value=u_value, data_source=1, solar_transmittance=solar_transmittance,
u_value=u_value,
data_source=1,
solar_transmittance=solar_transmittance,
)
return SapWindow(
frame_material=frame_material,
@ -268,7 +276,7 @@ def make_minimal_sap10_epc(
photovoltaic_arrays: Optional[list[PhotovoltaicArray]] = None,
photovoltaic_supply_percent_roof_area: Optional[int] = None,
pv_connection: Optional[int] = None,
mains_gas: bool = True,
gas_connection_available: bool = True,
electricity_smart_meter_present: bool = False,
gas_smart_meter_present: bool = False,
is_dwelling_export_capable: bool = False,
@ -297,17 +305,21 @@ def make_minimal_sap10_epc(
floors=[],
main_heating=[],
door_count=door_count,
sap_heating=sap_heating if sap_heating is not None else SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=[],
has_fixed_air_conditioning=False,
sap_heating=(
sap_heating
if sap_heating is not None
else SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=[],
has_fixed_air_conditioning=False,
)
),
sap_windows=list(sap_windows) if sap_windows is not None else [],
sap_roof_windows=(
list(sap_roof_windows) if sap_roof_windows is not None else None
),
sap_energy_source=SapEnergySource(
mains_gas=mains_gas,
gas_connection_available=gas_connection_available,
meter_type="Single",
pv_connection=pv_connection,
pv_battery_count=pv_battery_count,
@ -316,9 +328,9 @@ def make_minimal_sap10_epc(
is_dwelling_export_capable=is_dwelling_export_capable,
wind_turbines_terrain_type="Suburban",
electricity_smart_meter_present=electricity_smart_meter_present,
photovoltaic_arrays=list(photovoltaic_arrays)
if photovoltaic_arrays is not None
else None,
photovoltaic_arrays=(
list(photovoltaic_arrays) if photovoltaic_arrays is not None else None
),
photovoltaic_supply=(
PhotovoltaicSupply(
none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails(
@ -343,7 +355,9 @@ def make_minimal_sap10_epc(
else None
),
),
sap_building_parts=list(sap_building_parts) if sap_building_parts is not None else [],
sap_building_parts=(
list(sap_building_parts) if sap_building_parts is not None else []
),
solar_water_heating=solar_water_heating,
has_hot_water_cylinder=has_hot_water_cylinder,
has_fixed_air_conditioning=has_fixed_air_conditioning,

View file

@ -1020,7 +1020,7 @@ def test_to_row_extracts_energy_source_booleans() -> None:
# Arrange — gas + electricity smart meters, export capable
epc = make_minimal_sap10_epc(
energy_rating_current=82,
mains_gas=True,
gas_connection_available=True,
electricity_smart_meter_present=True,
gas_smart_meter_present=True,
is_dwelling_export_capable=True,

File diff suppressed because it is too large Load diff

View file

@ -142,7 +142,7 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]:
"sap_main_heating_code": (
epc.sap_heating.main_heating_details[0].sap_main_heating_code
),
"mains_gas": epc.sap_energy_source.mains_gas,
"mains_gas": epc.sap_energy_source.gas_connection_available,
}
if measure_type == "air_source_heat_pump":
# heating_recommendation.py offers ASHP to any non-flat house/bungalow
@ -161,7 +161,7 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]:
"sap_main_heating_code": (
epc.sap_heating.main_heating_details[0].sap_main_heating_code
),
"mains_gas": epc.sap_energy_source.mains_gas,
"mains_gas": epc.sap_energy_source.gas_connection_available,
"has_hot_water_cylinder": epc.has_hot_water_cylinder,
}
if measure_type in ("system_tune_up", "system_tune_up_zoned"):
@ -223,7 +223,9 @@ def build_property_report(
)
for measure in plan.measures
)
except Exception as error: # noqa: BLE001 — modelling raise must not abort the report
except (
Exception
) as error: # noqa: BLE001 — modelling raise must not abort the report
plan_error = f"{type(error).__name__}: {error}"
return PropertyReport(
@ -312,9 +314,7 @@ def _calculator_error_section(reports: list[PropertyReport]) -> list[str]:
)
delta: str = "" if report.sap_error is None else f"{report.sap_error:+.2f}"
flag: str = "⚠ FLAG" if report.sap_error_exceeds_threshold else ""
lines.append(
f"| {report.name} | {lodged} | {calculated} | {delta} | {flag} |"
)
lines.append(f"| {report.name} | {lodged} | {calculated} | {delta} | {flag} |")
return lines
@ -436,9 +436,11 @@ def format_report_csv(reports: list[PropertyReport]) -> str:
None if plan is None else plan.baseline_epc_rating.value,
None if plan is None else plan.post_epc_rating.value,
None if plan is None else len(plan.measures),
None
if plan is None
else ";".join(measure.measure_type for measure in plan.measures),
(
None
if plan is None
else ";".join(measure.measure_type for measure in plan.measures)
),
None if plan is None else plan.cost_of_works,
None if plan is None else plan.contingency_cost,
None if plan is None else plan.energy_bill_savings,

View file

@ -20,7 +20,9 @@ from datatypes.epc.domain.epc_property_data import (
class EpcPropertyModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "epc_property" # pyright: ignore[reportIncompatibleVariableOverride]
__tablename__: ClassVar[str] = (
"epc_property" # pyright: ignore[reportIncompatibleVariableOverride]
)
id: Optional[int] = Field(default=None, primary_key=True)
property_id: Optional[int] = Field(default=None)
@ -126,7 +128,7 @@ class EpcPropertyModel(SQLModel, table=True):
windows_transmission_solar_transmittance: Optional[float] = Field(default=None)
# Energy source
energy_mains_gas: bool
energy_gas_connection_available: bool
energy_meter_type: str
energy_pv_battery_count: int
energy_wind_turbines_count: int
@ -264,9 +266,7 @@ class EpcPropertyModel(SQLModel, table=True):
has_heated_separate_conservatory=data.has_heated_separate_conservatory,
conservatory_type=data.conservatory_type,
conservatory_floor_area_m2=(
data.sap_conservatory.floor_area_m2
if data.sap_conservatory
else None
data.sap_conservatory.floor_area_m2 if data.sap_conservatory else None
),
conservatory_glazed_perimeter_m=(
data.sap_conservatory.glazed_perimeter_m
@ -333,7 +333,7 @@ class EpcPropertyModel(SQLModel, table=True):
if data.windows_transmission_details
else None
),
energy_mains_gas=es.mains_gas,
energy_gas_connection_available=es.gas_connection_available,
energy_meter_type=str(es.meter_type),
energy_pv_battery_count=es.pv_battery_count,
energy_wind_turbines_count=es.wind_turbines_count,
@ -408,7 +408,9 @@ class EpcPropertyModel(SQLModel, table=True):
class EpcPropertyEnergyPerformanceModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "epc_property_energy_performance" # pyright: ignore[reportIncompatibleVariableOverride]
__tablename__: ClassVar[str] = (
"epc_property_energy_performance" # pyright: ignore[reportIncompatibleVariableOverride]
)
id: Optional[int] = Field(default=None, primary_key=True)
epc_property_id: int = Field(
@ -468,7 +470,9 @@ class EpcPropertyEnergyPerformanceModel(SQLModel, table=True):
class EpcRenewableHeatIncentiveModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "epc_renewable_heat_incentive" # pyright: ignore[reportIncompatibleVariableOverride]
__tablename__: ClassVar[str] = (
"epc_renewable_heat_incentive" # pyright: ignore[reportIncompatibleVariableOverride]
)
id: Optional[int] = Field(default=None, primary_key=True)
epc_property_id: int = Field(
@ -496,7 +500,9 @@ class EpcRenewableHeatIncentiveModel(SQLModel, table=True):
class EpcFlatDetailsModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "epc_flat_details" # pyright: ignore[reportIncompatibleVariableOverride]
__tablename__: ClassVar[str] = (
"epc_flat_details" # pyright: ignore[reportIncompatibleVariableOverride]
)
id: Optional[int] = Field(default=None, primary_key=True)
epc_property_id: int = Field(
@ -526,7 +532,9 @@ class EpcFlatDetailsModel(SQLModel, table=True):
class EpcMainHeatingDetailModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "epc_main_heating_detail" # pyright: ignore[reportIncompatibleVariableOverride]
__tablename__: ClassVar[str] = (
"epc_main_heating_detail" # pyright: ignore[reportIncompatibleVariableOverride]
)
id: Optional[int] = Field(default=None, primary_key=True)
epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False)
@ -589,7 +597,9 @@ class EpcMainHeatingDetailModel(SQLModel, table=True):
class EpcBuildingPartModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "epc_building_part" # pyright: ignore[reportIncompatibleVariableOverride]
__tablename__: ClassVar[str] = (
"epc_building_part" # pyright: ignore[reportIncompatibleVariableOverride]
)
id: Optional[int] = Field(default=None, primary_key=True)
epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False)
@ -715,7 +725,9 @@ class EpcBuildingPartModel(SQLModel, table=True):
class EpcFloorDimensionModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "epc_floor_dimension" # pyright: ignore[reportIncompatibleVariableOverride]
__tablename__: ClassVar[str] = (
"epc_floor_dimension" # pyright: ignore[reportIncompatibleVariableOverride]
)
id: Optional[int] = Field(default=None, primary_key=True)
epc_building_part_id: int = Field(
@ -754,7 +766,9 @@ class EpcFloorDimensionModel(SQLModel, table=True):
class EpcWindowModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "epc_window" # pyright: ignore[reportIncompatibleVariableOverride]
__tablename__: ClassVar[str] = (
"epc_window" # pyright: ignore[reportIncompatibleVariableOverride]
)
id: Optional[int] = Field(default=None, primary_key=True)
epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False)
@ -806,7 +820,9 @@ class EpcWindowModel(SQLModel, table=True):
class EpcPhotovoltaicArrayModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "epc_photovoltaic_array" # pyright: ignore[reportIncompatibleVariableOverride]
__tablename__: ClassVar[str] = (
"epc_photovoltaic_array" # pyright: ignore[reportIncompatibleVariableOverride]
)
id: Optional[int] = Field(default=None, primary_key=True)
epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False)
@ -837,7 +853,9 @@ class EpcPhotovoltaicArrayModel(SQLModel, table=True):
class EpcEnergyElementModel(SQLModel, table=True):
__tablename__: ClassVar[str] = "epc_energy_element" # pyright: ignore[reportIncompatibleVariableOverride]
__tablename__: ClassVar[str] = (
"epc_energy_element" # pyright: ignore[reportIncompatibleVariableOverride]
)
id: Optional[int] = Field(default=None, primary_key=True)
epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False)

View file

@ -64,7 +64,9 @@ class EpcSaveRequest:
source: EpcSource = field(default="lodged")
def _col_values(model: SQLModel, exclude: frozenset[str] = frozenset()) -> dict[str, Any]:
def _col_values(
model: SQLModel, exclude: frozenset[str] = frozenset()
) -> dict[str, Any]:
"""Extract column-keyed values from a SQLModel instance for Core INSERT."""
return {
c.name: getattr(model, c.name) # type: ignore[union-attr]
@ -130,7 +132,9 @@ class EpcPostgresRepository(EpcRepository):
portfolio_id: Optional[int] = None,
source: EpcSource = "lodged",
) -> int:
return self.save_batch([EpcSaveRequest(data, property_id, portfolio_id, source)])[0]
return self.save_batch(
[EpcSaveRequest(data, property_id, portfolio_id, source)]
)[0]
def save_batch(self, requests: list[EpcSaveRequest]) -> list[int]:
"""Insert all EPCs in `requests` in one pass per table, returning one
@ -158,7 +162,10 @@ class EpcPostgresRepository(EpcRepository):
parent_rows = [
_col_values(
EpcPropertyModel.from_epc_property_data(
r.data, property_id=r.property_id, portfolio_id=r.portfolio_id, source=r.source
r.data,
property_id=r.property_id,
portfolio_id=r.portfolio_id,
source=r.source,
),
exclude=frozenset({"id"}),
)
@ -185,23 +192,33 @@ class EpcPostgresRepository(EpcRepository):
d = r.data
perf_rows.append(
_col_values(
EpcPropertyEnergyPerformanceModel.from_epc_property_data(d, epc_pid),
EpcPropertyEnergyPerformanceModel.from_epc_property_data(
d, epc_pid
),
exclude=frozenset({"id"}),
)
)
for detail in d.sap_heating.main_heating_details:
heating_rows.append(
_col_values(EpcMainHeatingDetailModel.from_domain(detail, epc_pid), frozenset({"id"}))
_col_values(
EpcMainHeatingDetailModel.from_domain(detail, epc_pid),
frozenset({"id"}),
)
)
for part in d.sap_building_parts:
parts_ordered.append((part, epc_pid))
for window in d.sap_windows:
window_rows.append(
_col_values(EpcWindowModel.from_domain(window, epc_pid), frozenset({"id"}))
_col_values(
EpcWindowModel.from_domain(window, epc_pid), frozenset({"id"})
)
)
for idx, array in enumerate(d.sap_energy_source.photovoltaic_arrays or []):
pv_rows.append(
_col_values(EpcPhotovoltaicArrayModel.from_domain(array, idx, epc_pid), frozenset({"id"}))
_col_values(
EpcPhotovoltaicArrayModel.from_domain(array, idx, epc_pid),
frozenset({"id"}),
)
)
for etype, els in (
("roof", d.roofs),
@ -211,7 +228,10 @@ class EpcPostgresRepository(EpcRepository):
):
for el in els:
element_rows.append(
_col_values(EpcEnergyElementModel.from_domain(el, etype, epc_pid), frozenset({"id"}))
_col_values(
EpcEnergyElementModel.from_domain(el, etype, epc_pid),
frozenset({"id"}),
)
)
for el, etype in (
(d.window, "window"),
@ -222,15 +242,26 @@ class EpcPostgresRepository(EpcRepository):
):
if el is not None:
element_rows.append(
_col_values(EpcEnergyElementModel.from_domain(el, etype, epc_pid), frozenset({"id"}))
_col_values(
EpcEnergyElementModel.from_domain(el, etype, epc_pid),
frozenset({"id"}),
)
)
if d.sap_flat_details is not None:
flat_rows.append(
_col_values(EpcFlatDetailsModel.from_domain(d.sap_flat_details, epc_pid), frozenset({"id"}))
_col_values(
EpcFlatDetailsModel.from_domain(d.sap_flat_details, epc_pid),
frozenset({"id"}),
)
)
if d.renewable_heat_incentive is not None:
rhi_rows.append(
_col_values(EpcRenewableHeatIncentiveModel.from_domain(d.renewable_heat_incentive, epc_pid), frozenset({"id"}))
_col_values(
EpcRenewableHeatIncentiveModel.from_domain(
d.renewable_heat_incentive, epc_pid
),
frozenset({"id"}),
)
)
# Bulk-insert all simple child tables (no downstream FK dependency).
@ -256,7 +287,9 @@ class EpcPostgresRepository(EpcRepository):
# Positional zip is safe because PostgreSQL preserves VALUES order in RETURNING.
if parts_ordered:
bp_rows = [
_col_values(EpcBuildingPartModel.from_domain(part, epc_pid), frozenset({"id"}))
_col_values(
EpcBuildingPartModel.from_domain(part, epc_pid), frozenset({"id"})
)
for part, epc_pid in parts_ordered
]
returned_bps = self._session.execute( # type: ignore[deprecated]
@ -264,7 +297,10 @@ class EpcPostgresRepository(EpcRepository):
bp_rows,
).all()
floor_rows: list[dict[str, Any]] = [
_col_values(EpcFloorDimensionModel.from_domain(dim, bp_row[0]), frozenset({"id"}))
_col_values(
EpcFloorDimensionModel.from_domain(dim, bp_row[0]),
frozenset({"id"}),
)
for (part, _), bp_row in zip(parts_ordered, returned_bps)
for dim in part.sap_floor_dimensions
]
@ -273,7 +309,9 @@ class EpcPostgresRepository(EpcRepository):
return epc_property_ids
def _delete_for_properties(self, property_ids: list[int], source: EpcSource) -> None:
def _delete_for_properties(
self, property_ids: list[int], source: EpcSource
) -> None:
"""Batch-delete every EPC graph for the given property_ids and source in
one pass per child table (IN queries), replacing the per-property flush
loop that drove RDS CPU to saturation during bulk modelling runs."""
@ -370,9 +408,7 @@ class EpcPostgresRepository(EpcRepository):
def get_for_property(self, property_id: int) -> Optional[EpcPropertyData]:
return self._get_for_property(property_id, source="lodged")
def get_predicted_for_property(
self, property_id: int
) -> Optional[EpcPropertyData]:
def get_predicted_for_property(self, property_id: int) -> Optional[EpcPropertyData]:
return self._get_for_property(property_id, source="predicted")
def _get_for_property(
@ -388,9 +424,7 @@ class EpcPostgresRepository(EpcRepository):
return None
return self.get(row.id)
def get_for_properties(
self, property_ids: list[int]
) -> dict[int, EpcPropertyData]:
def get_for_properties(self, property_ids: list[int]) -> dict[int, EpcPropertyData]:
"""Bulk-hydrate a batch's LODGED EPCs, keyed by property_id."""
return self._for_properties(property_ids, source="lodged")
@ -481,10 +515,7 @@ class EpcPostgresRepository(EpcRepository):
).all()
)
part_ids = [
bp.id
for parts in parts_by.values()
for bp in parts
if bp.id is not None
bp.id for parts in parts_by.values() for bp in parts if bp.id is not None
]
floor_dims_by_part = self._floor_dims_by_part(part_ids)
@ -593,7 +624,11 @@ class EpcPostgresRepository(EpcRepository):
rhi_row: Optional[EpcRenewableHeatIncentiveModel],
) -> EpcPropertyData:
def _elements(element_type: str) -> list[EnergyElement]:
return [self._to_energy_element(e) for e in elements if e.element_type == element_type]
return [
self._to_energy_element(e)
for e in elements
if e.element_type == element_type
]
def _single(element_type: str) -> Optional[EnergyElement]:
found = _elements(element_type)
@ -786,9 +821,7 @@ class EpcPostgresRepository(EpcRepository):
)
@private
def _pv_arrays(
self, epc_property_id: int
) -> list[EpcPhotovoltaicArrayModel]:
def _pv_arrays(self, epc_property_id: int) -> list[EpcPhotovoltaicArrayModel]:
return list(
self._session.exec(
select(EpcPhotovoltaicArrayModel)
@ -1018,7 +1051,7 @@ class EpcPostgresRepository(EpcRepository):
else None
),
pv_diverter_present=p.energy_pv_diverter_present,
mains_gas=p.energy_mains_gas,
gas_connection_available=p.energy_gas_connection_available,
meter_type=p.energy_meter_type,
pv_battery_count=p.energy_pv_battery_count,
wind_turbines_count=p.energy_wind_turbines_count,

View file

@ -22,6 +22,7 @@ Certs are read from the cache built by `fetch_2026_epc_sample.py` (default
`/tmp/epc_2026_sample`, overridable via `EPC_SAMPLE_CACHE`). A bare cert
number resolves to `<cache>/<cert>.json`; an explicit path is also accepted.
"""
from __future__ import annotations
import json
@ -33,7 +34,10 @@ from typing import Any, Optional
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
cert_to_inputs,
)
CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample"))
@ -59,7 +63,9 @@ def _resolve(cert_arg: str) -> Path:
def _our_sap(epc: Any) -> str:
"""Our continuous SAP, or the exception that blocks it."""
try:
result = calculate_sap_from_inputs(cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES))
result = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
cont: float = result.sap_score_continuous
return f"{cont:.4f}" if math.isfinite(cont) else f"non-finite ({cont})"
except Exception as e: # debugging tool — surface, don't swallow
@ -113,7 +119,9 @@ def render(cert: str, doc: dict[str, Any]) -> str:
# --- element descriptions (lodged) ---------------------------------
w("\n## Element descriptions (lodged)")
for label, elems in (
("WALL", epc.walls), ("ROOF", epc.roofs), ("FLOOR", epc.floors),
("WALL", epc.walls),
("ROOF", epc.roofs),
("FLOOR", epc.floors),
):
for el in elems or []:
w(f" {label}: {el.description}")
@ -135,7 +143,9 @@ def render(cert: str, doc: dict[str, Any]) -> str:
# types the mapper emits (NOT the `schema` dataclasses).
w("\n## Building parts / dimensions")
for bp in epc.sap_building_parts or []:
w(f"### {bp.identifier} (part {bp.building_part_number}, age {bp.construction_age_band})")
w(
f"### {bp.identifier} (part {bp.building_part_number}, age {bp.construction_age_band})"
)
w(
f" wall_construction={bp.wall_construction} "
f"insulation_type={bp.wall_insulation_type} "
@ -226,7 +236,7 @@ def render(cert: str, doc: dict[str, Any]) -> str:
)
es = epc.sap_energy_source
w(
f" ENERGY SOURCE: mains_gas={es.mains_gas} "
f" ENERGY SOURCE: mains_gas={es.gas_connection_available} "
f"meter_type={es.meter_type} "
f"wind_turbines={es.wind_turbines_count} "
f"pv_raw={json.dumps(doc.get('sap_energy_source', {}).get('photovoltaic_supply'))}"

View file

@ -80,14 +80,14 @@ def test_community_mains_gas_is_a_distinct_fuel_code() -> None:
def test_mains_gas_fuel_sets_the_mains_gas_connection_flag() -> None:
# A "mains gas" fuel means the dwelling has a mains-gas connection, so the
# overlay must set sap_energy_source.mains_gas too — not only the fuel code.
# Without it the effective EPC says "fuel = mains gas" yet mains_gas=False,
# overlay must set sap_energy_source.gas_connection_available too — not only the fuel code.
# Without it the effective EPC says "fuel = mains gas" yet gas_connection_available=False,
# which suppresses the gas-boiler-upgrade path and wrongly offers HHRSH
# storage (the off-gas path). (Property 728513.)
simulation = fuel_overlay_for("mains gas", 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.mains_gas is True
assert simulation.heating.gas_connection_available is True
@pytest.mark.parametrize(
@ -104,7 +104,7 @@ def test_non_mains_gas_fuel_leaves_the_mains_gas_flag_unchanged(
simulation = fuel_overlay_for(main_fuel_value, 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.mains_gas is None
assert simulation.heating.gas_connection_available is None
@pytest.mark.parametrize("main_fuel_value", ["Unknown", "", "no heating or hot water"])
@ -141,4 +141,3 @@ def test_every_resolvable_fuel_value_decodes_to_a_code(member: MainFuelType) ->
# Assert
assert simulation is not None

View file

@ -196,7 +196,7 @@ def test_gas_boiler_drags_a_coherent_mains_gas_system(main_heating_value: str) -
assert simulation is not None
assert simulation.heating is not None
heating = simulation.heating
assert heating.mains_gas is True
assert heating.gas_connection_available is True
assert heating.main_fuel_type == 26
assert heating.main_heating_category == 2
assert heating.fan_flue_present is True
@ -300,7 +300,7 @@ def test_gas_boiler_override_onto_a_storage_baseline_leaves_no_stale_fields() ->
assert main.main_fuel_type == 26
assert main.main_heating_category == 2
assert main.main_heating_control == 2106
assert result.sap_energy_source.mains_gas is True
assert result.sap_energy_source.gas_connection_available is True
assert result.sap_energy_source.meter_type == "Single"
assert result.has_hot_water_cylinder is False
assert result.sap_heating.water_heating_code == 901
@ -506,7 +506,9 @@ def test_community_boilers_decode_to_the_heat_network_boiler_code() -> None:
assert simulation.heating.sap_main_heating_code == 301
def test_community_boilers_drag_heat_network_category_and_a_community_gas_fuel() -> None:
def test_community_boilers_drag_heat_network_category_and_a_community_gas_fuel() -> (
None
):
# A community boiler scheme is SAP main_heating_category 6 (heat network), so
# the calculator treats it as a heat network (cert_to_inputs `_is_heat_network`
# checks code OR category 6). Its natural fuel is mains gas (community) — RdSAP

View file

@ -58,7 +58,7 @@ def test_classification_keys_on_fuel_not_the_mains_gas_flag() -> None:
)
# Act / Assert
assert epc.sap_energy_source.mains_gas is True
assert epc.sap_energy_source.gas_connection_available is True
inputs: AshpCostInputs = ashp_cost_inputs(epc)
assert inputs.existing_system is AshpExistingSystem.ELECTRIC_STORAGE
assert inputs.has_reusable_wet_system is False
@ -73,7 +73,7 @@ def test_oil_and_lpg_dwellings_are_reusable_wet_systems() -> None:
def _with_fuel(code: int) -> EpcPropertyData:
clone: EpcPropertyData = copy.deepcopy(base)
clone.sap_energy_source.mains_gas = False
clone.sap_energy_source.gas_connection_available = False
clone.sap_heating.main_heating_details[0].main_fuel_type = code
clone.sap_heating.main_heating_details[0].sap_main_heating_code = 199
return clone

View file

@ -23,9 +23,11 @@ from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
)
# Electricity main-fuel code (Elmhurst → SAP10) and the Table 4a SAP code an
# existing (non-HHR) electric storage system lodges.
# Electricity fuel codes and the Table 4a SAP code an existing (non-HHR)
# electric storage system lodges. 30 is the SAP Table 12 code (used by
# Elmhurst / cascade); 29 is the raw gov-API enum that some certs carry.
_ELECTRICITY = 30
_API_ELECTRICITY = 29
_OLD_STORAGE_SAP_CODE = 402
@ -61,7 +63,9 @@ def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None:
assert recommendation.surface == "Heating & Hot Water"
options = {o.measure_type.value: o for o in recommendation.options}
assert "high_heat_retention_storage_heaters" in options
assert options["high_heat_retention_storage_heaters"].overlay.heating == HeatingOverlay(
assert options[
"high_heat_retention_storage_heaters"
].overlay.heating == HeatingOverlay(
main_fuel_type=30,
sap_main_heating_code=409,
main_heating_control=2404,
@ -138,6 +142,24 @@ def test_hhr_storage_bundle_carries_the_product_cost_and_contingency() -> None:
assert abs(option.cost.contingency_rate - 0.26) <= 1e-9
def test_on_gas_street_electric_dwelling_yields_hhr_storage_bundle() -> None:
# Arrange — electrically heated dwelling whose cert carries the raw API
# fuel code (29) instead of the SAP Table 12 code (30), AND has a mains-
# gas connection at the street (mains_gas=True, the default for 000490).
# Bug: off_gas = not True = False; electric_main = 29 == 30 = False →
# HHRSH incorrectly blocked. Fix: key on main_fuel_type not in _GAS_FUEL_CODES.
baseline: EpcPropertyData = _electric_storage_baseline()
baseline.sap_heating.main_heating_details[0].main_fuel_type = _API_ELECTRICITY
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
# Assert — dwelling heats electrically (not with gas) → HHRSH must be offered.
assert recommendation is not None
options = {o.measure_type.value: o for o in recommendation.options}
assert "high_heat_retention_storage_heaters" in options
def _gas_boiler_house() -> EpcPropertyData:
"""A 000490 mains-gas combi dwelling, explicitly a House — ASHP-eligible."""
epc: EpcPropertyData = build_epc()
@ -170,7 +192,7 @@ def test_gas_boiler_house_yields_an_ashp_bundle() -> None:
cylinder_thermostat="Y",
has_hot_water_cylinder=True,
meter_type="Single",
mains_gas=False,
gas_connection_available=False,
)
@ -435,7 +457,7 @@ def test_off_gas_boiler_yields_no_gas_boiler_upgrade() -> None:
# state is gated on a mains-gas connection).
baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline()
baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 130
baseline.sap_energy_source.mains_gas = False
baseline.sap_energy_source.gas_connection_available = False
# Act
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())

View file

@ -173,8 +173,7 @@ def test_apply_writes_dwelling_ventilation_onto_sap_ventilation() -> None:
# Assert
assert result.sap_ventilation is not None
assert (
result.sap_ventilation.mechanical_ventilation_kind
== "EXTRACT_OR_PIV_OUTSIDE"
result.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE"
)
@ -194,8 +193,7 @@ def test_ventilation_overlay_creates_sap_ventilation_when_baseline_has_none() ->
# Assert
assert isinstance(result.sap_ventilation, SapVentilation)
assert (
result.sap_ventilation.mechanical_ventilation_kind
== "EXTRACT_OR_PIV_OUTSIDE"
result.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE"
)
@ -216,7 +214,9 @@ def test_ventilation_overlay_leaves_building_parts_and_baseline_untouched() -> N
# Assert — ventilation overlay touches only sap_ventilation; the baseline
# is never mutated.
assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == main_before
assert (
_part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == main_before
)
assert baseline.sap_ventilation is not None
assert baseline.sap_ventilation.mechanical_ventilation_kind is None
@ -243,9 +243,7 @@ def test_baseline_is_not_mutated() -> None:
)
# Assert
assert (
_part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type == original
)
assert _part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type == original
def test_apply_folds_a_window_overlay_by_index_into_transmission_details() -> None:
@ -293,9 +291,7 @@ def test_baseline_windows_are_not_mutated_by_a_window_overlay() -> None:
# Assert
assert baseline.sap_windows[0].window_transmission_details is not None
assert (
baseline.sap_windows[0].window_transmission_details.u_value == original_u
)
assert baseline.sap_windows[0].window_transmission_details.u_value == original_u
def test_apply_writes_dwelling_lighting_onto_top_level_bulb_counts() -> None:
@ -340,7 +336,7 @@ def test_apply_folds_a_heating_overlay_across_all_five_locations() -> None:
cylinder_insulation_thickness_mm=120,
has_hot_water_cylinder=True,
meter_type="18 Hour",
mains_gas=False,
gas_connection_available=False,
)
)
@ -360,7 +356,7 @@ def test_apply_folds_a_heating_overlay_across_all_five_locations() -> None:
assert result.has_hot_water_cylinder is True
assert result.sap_energy_source is not None
assert result.sap_energy_source.meter_type == "18 Hour"
assert result.sap_energy_source.mains_gas is False
assert result.sap_energy_source.gas_connection_available is False
def test_secondary_heating_overlay_clears_the_lodged_secondary() -> None:
@ -404,7 +400,7 @@ def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None:
original_wh_code: int | None = baseline.sap_heating.water_heating_code
original_cylinder = baseline.has_hot_water_cylinder
assert baseline.sap_energy_source is not None
original_mains_gas = baseline.sap_energy_source.mains_gas
original_mains_gas = baseline.sap_energy_source.gas_connection_available
# Act — fold an HHR storage bundle.
_: EpcPropertyData = apply_simulations(
@ -417,7 +413,7 @@ def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None:
main_heating_control=2404,
water_heating_code=903,
has_hot_water_cylinder=True,
mains_gas=False,
gas_connection_available=False,
)
)
],
@ -431,7 +427,7 @@ def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None:
)
assert baseline.sap_heating.water_heating_code == original_wh_code
assert baseline.has_hot_water_cylinder == original_cylinder
assert baseline.sap_energy_source.mains_gas == original_mains_gas
assert baseline.sap_energy_source.gas_connection_available == original_mains_gas
def test_heating_index_overlay_clears_a_stale_sap_main_heating_code() -> None:

View file

@ -649,6 +649,16 @@ def test_listed_uprn_ingested_blocks_solid_wall_insulation_in_modelling(
is_active=True,
description="Mechanical extract ventilation unit",
),
# LPG solid-brick dwelling (fuel 27) is now HHRSH-eligible after
# the fix keying on main_fuel_type not in _GAS_FUEL_CODES (#1378).
MaterialRow(
id=6,
type="high_heat_retention_storage_heaters",
total_cost=3500.0,
cost_unit="gbp_per_unit",
is_active=True,
description="High heat retention storage heaters",
),
]
)
session.commit()
@ -680,22 +690,28 @@ def test_listed_uprn_ingested_blocks_solid_wall_insulation_in_modelling(
# Assert — a listed building blocks the fabric-protected measures: both
# solid-wall Options AND the ASHP bundle (all gated on `blocks_internal`,
# ADR-0024). So the listed dwelling gets neither, while the unrestricted one
# gets the ASHP bundle (which the efficient Vaillant now makes the Optimiser
# select — ADR-0025, so walls are no longer needed to reach the band). The
# only difference between them is the planning status Ingestion cached,
# proving the gate end to end (ADR-0019/0020/0024).
# ADR-0024). The listed dwelling gets none of those. The unrestricted one
# gets a heating upgrade — HHRSH, since the solid-brick LPG fixture (fuel
# 27) is now correctly non-gas-fuel eligible after the #1378 fix, and the
# Optimiser selects it over ASHP for this fixture. The only difference
# between them is the planning status Ingestion cached, proving the gate
# end to end (ADR-0019/0020/0024).
_PROTECTED_TYPES = {
"external_wall_insulation",
"internal_wall_insulation",
"air_source_heat_pump",
}
_HEATING_UPGRADE_TYPES = {
"air_source_heat_pump",
"high_heat_retention_storage_heaters",
"gas_boiler_upgrade",
}
with Session(db_engine) as session:
listed_types = _plan_measure_types(session, property_id=40)
unrestricted_types = _plan_measure_types(session, property_id=41)
assert _PROTECTED_TYPES.isdisjoint(listed_types)
assert "air_source_heat_pump" in unrestricted_types
assert unrestricted_types & _HEATING_UPGRADE_TYPES
def _plan_measure_types(session: Session, *, property_id: int) -> set[str]: