mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
commit
221e310fd8
25 changed files with 1200 additions and 617 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'))}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue