Model/tests/domain/modelling/test_overlay_applicator.py
Khalim Conn-Kowlessar a1fc697d93 test(modelling): ASHP before/after cascade pin (001431) at 1e-4
A typical mains-gas combi house re-lodged as an air-source heat pump closes at
1e-4 (gas-boiler 1 example from the technical specialist). Closes one named gap
the pin surfaced: a whole-system replacement to a PCDB-indexed system left the
old Table 4a sap_main_heating_code (104) beside the new heat-pump index, and the
stale code won the calculator's efficiency dispatch (hot water billed at boiler
not HP efficiency, ΔSAP 3.98). _fold_heating now enforces the mutual exclusion
of the two efficiency anchors (setting an index clears the SAP code and vice
versa). Also fixed a pre-existing pyright annotation in the lighting applicator
test. ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:01:40 +00:00

394 lines
14 KiB
Python

"""Behaviour of the Overlay Applicator: folding Simulation Overlays
(EpcSimulation) onto a baseline EpcPropertyData to produce a new one for
the calculator. See ADR-0016 and the Modelling glossary in CONTEXT.md.
"""
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapBuildingPart,
SapVentilation,
)
from domain.modelling.simulation import (
BuildingPartOverlay,
EpcSimulation,
HeatingOverlay,
LightingOverlay,
VentilationOverlay,
WindowOverlay,
)
from domain.modelling.scoring.overlay_applicator import apply_simulations
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
)
def _part(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> SapBuildingPart:
return next(p for p in epc.sap_building_parts if p.identifier is identifier)
def test_apply_writes_targeted_building_part_and_leaves_others_untouched() -> None:
# Arrange
baseline: EpcPropertyData = build_epc()
extension_before: int | str = _part(
baseline, BuildingPartIdentifier.EXTENSION_1
).wall_insulation_type
simulation = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=1)
}
)
# Act
result: EpcPropertyData = apply_simulations(baseline, [simulation])
# Assert
assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == 1
assert (
_part(result, BuildingPartIdentifier.EXTENSION_1).wall_insulation_type
== extension_before
)
def test_empty_simulation_is_a_no_op() -> None:
# Arrange
baseline: EpcPropertyData = build_epc()
# Act
result: EpcPropertyData = apply_simulations(baseline, [EpcSimulation()])
# Assert
assert result == baseline
def test_later_simulation_wins_on_a_shared_field() -> None:
# Arrange
baseline: EpcPropertyData = build_epc()
first = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=1)
}
)
second = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2)
}
)
# Act
result: EpcPropertyData = apply_simulations(baseline, [first, second])
# Assert
assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == 2
def test_apply_writes_dwelling_ventilation_onto_sap_ventilation() -> None:
# Arrange — a Measure Dependency overlay targets the whole-dwelling
# ventilation system (no building part), e.g. retrofit MEV.
baseline: EpcPropertyData = build_epc()
simulation = EpcSimulation(
ventilation=VentilationOverlay(
mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"
)
)
# Act
result: EpcPropertyData = apply_simulations(baseline, [simulation])
# Assert
assert result.sap_ventilation is not None
assert (
result.sap_ventilation.mechanical_ventilation_kind
== "EXTRACT_OR_PIV_OUTSIDE"
)
def test_ventilation_overlay_creates_sap_ventilation_when_baseline_has_none() -> None:
# Arrange — a naturally-ventilated baseline that lodged no SapVentilation.
baseline: EpcPropertyData = build_epc()
baseline.sap_ventilation = None
simulation = EpcSimulation(
ventilation=VentilationOverlay(
mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"
)
)
# Act
result: EpcPropertyData = apply_simulations(baseline, [simulation])
# Assert
assert isinstance(result.sap_ventilation, SapVentilation)
assert (
result.sap_ventilation.mechanical_ventilation_kind
== "EXTRACT_OR_PIV_OUTSIDE"
)
def test_ventilation_overlay_leaves_building_parts_and_baseline_untouched() -> None:
# Arrange
baseline: EpcPropertyData = build_epc()
main_before: int | str = _part(
baseline, BuildingPartIdentifier.MAIN
).wall_insulation_type
simulation = EpcSimulation(
ventilation=VentilationOverlay(
mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"
)
)
# Act
result: EpcPropertyData = apply_simulations(baseline, [simulation])
# Assert — ventilation overlay touches only sap_ventilation; the baseline
# is never mutated.
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
def test_baseline_is_not_mutated() -> None:
# Arrange
baseline: EpcPropertyData = build_epc()
original: int | str = _part(
baseline, BuildingPartIdentifier.MAIN
).wall_insulation_type
# Act
_: EpcPropertyData = apply_simulations(
baseline,
[
EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
wall_insulation_type=1
)
}
)
],
)
# Assert
assert (
_part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type == original
)
def test_apply_folds_a_window_overlay_by_index_into_transmission_details() -> None:
# Arrange — window 0 starts double (glazing_type 2, U 2.8, g 0.76); the
# overlay upgrades it to a modern double spec, writing the U-value and
# solar-g into the nested WindowTransmissionDetails (ADR-0022).
baseline: EpcPropertyData = build_epc()
# Act — target window 0 by its sap_windows index.
result: EpcPropertyData = apply_simulations(
baseline,
[
EpcSimulation(
windows={
0: WindowOverlay(
glazing_type=5, u_value=1.40, solar_transmittance=0.72
)
}
)
],
)
# Assert — glazing_type set on the window; U/g routed into the transmission
# details (where the cascade reads them); other windows untouched.
upgraded = result.sap_windows[0]
assert upgraded.glazing_type == 5
assert upgraded.window_transmission_details is not None
assert abs(upgraded.window_transmission_details.u_value - 1.40) <= 1e-9
assert abs(upgraded.window_transmission_details.solar_transmittance - 0.72) <= 1e-9
assert result.sap_windows[1].window_transmission_details is not None
assert abs(result.sap_windows[1].window_transmission_details.u_value - 2.8) <= 1e-9
def test_baseline_windows_are_not_mutated_by_a_window_overlay() -> None:
# Arrange
baseline: EpcPropertyData = build_epc()
assert baseline.sap_windows[0].window_transmission_details is not None
original_u: float = baseline.sap_windows[0].window_transmission_details.u_value
# Act
_: EpcPropertyData = apply_simulations(
baseline,
[EpcSimulation(windows={0: WindowOverlay(u_value=1.40)})],
)
# Assert
assert baseline.sap_windows[0].window_transmission_details is not None
assert (
baseline.sap_windows[0].window_transmission_details.u_value == original_u
)
def test_apply_writes_dwelling_lighting_onto_top_level_bulb_counts() -> None:
# Arrange — a whole-dwelling lighting change (no building part), e.g. an
# all-LED upgrade folded onto the top-level bulb counts (ADR-0023).
baseline: EpcPropertyData = build_epc()
simulation = EpcSimulation(
lighting=LightingOverlay(
led_fixed_lighting_bulbs_count=8,
cfl_fixed_lighting_bulbs_count=0,
incandescent_fixed_lighting_bulbs_count=0,
low_energy_fixed_lighting_bulbs_count=0,
)
)
# Act
result: EpcPropertyData = apply_simulations(baseline, [simulation])
# Assert
assert result.led_fixed_lighting_bulbs_count == 8
assert result.cfl_fixed_lighting_bulbs_count == 0
assert result.incandescent_fixed_lighting_bulbs_count == 0
assert result.low_energy_fixed_lighting_bulbs_count == 0
def test_apply_folds_a_heating_overlay_across_all_five_locations() -> None:
# Arrange — a whole-system HHR storage bundle replacing 000490's gas combi
# (fuel 26, control 2106, no cylinder, mains_gas True). The heating overlay
# is the deepest surface: it writes across main_heating_details[0],
# sap_heating, the top-level EpcPropertyData, and sap_energy_source at once
# (ADR-0024).
baseline: EpcPropertyData = build_epc()
simulation = EpcSimulation(
heating=HeatingOverlay(
main_fuel_type=30,
sap_main_heating_code=409,
main_heating_control=2404,
water_heating_code=903,
water_heating_fuel=30,
cylinder_size=2,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=120,
has_hot_water_cylinder=True,
meter_type="18 Hour",
mains_gas=False,
)
)
# Act
result: EpcPropertyData = apply_simulations(baseline, [simulation])
# Assert — every targeted field routed to its home object.
main = result.sap_heating.main_heating_details[0]
assert main.main_fuel_type == 30
assert main.sap_main_heating_code == 409
assert main.main_heating_control == 2404
assert result.sap_heating.water_heating_code == 903
assert result.sap_heating.water_heating_fuel == 30
assert result.sap_heating.cylinder_size == 2
assert result.sap_heating.cylinder_insulation_type == 1
assert result.sap_heating.cylinder_insulation_thickness_mm == 120
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
def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None:
# Arrange — 000490 lodges a mains-gas combi (fuel 26, control 2106, no
# cylinder, mains_gas True).
baseline: EpcPropertyData = build_epc()
original_fuel = baseline.sap_heating.main_heating_details[0].main_fuel_type
original_control = baseline.sap_heating.main_heating_details[0].main_heating_control
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
# Act — fold an HHR storage bundle.
_: EpcPropertyData = apply_simulations(
baseline,
[
EpcSimulation(
heating=HeatingOverlay(
main_fuel_type=30,
sap_main_heating_code=409,
main_heating_control=2404,
water_heating_code=903,
has_hot_water_cylinder=True,
mains_gas=False,
)
)
],
)
# Assert — the baseline's heating is untouched.
assert baseline.sap_heating.main_heating_details[0].main_fuel_type == original_fuel
assert (
baseline.sap_heating.main_heating_details[0].main_heating_control
== original_control
)
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
def test_heating_index_overlay_clears_a_stale_sap_main_heating_code() -> None:
# Arrange — 000490's gas combi lodges a Table 4a code; an ASHP bundle sets a
# PCDB index instead. The two are mutually-exclusive efficiency anchors, so
# the stale code must be cleared or it wins the calculator's dispatch.
baseline: EpcPropertyData = build_epc()
baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 104
# Act
result: EpcPropertyData = apply_simulations(
baseline,
[
EpcSimulation(
heating=HeatingOverlay(
main_heating_index_number=101413, main_heating_category=4
)
)
],
)
# Assert — the index is set and the old SAP code is gone.
main = result.sap_heating.main_heating_details[0]
assert main.main_heating_index_number == 101413
assert main.sap_main_heating_code is None
def test_heating_sap_code_overlay_clears_a_stale_index() -> None:
# Arrange — a dwelling with a PCDB-indexed system; an HHR storage bundle sets
# a Table 4a code instead, so the stale index must be cleared.
baseline: EpcPropertyData = build_epc()
baseline.sap_heating.main_heating_details[0].main_heating_index_number = 8262
# Act
result: EpcPropertyData = apply_simulations(
baseline,
[EpcSimulation(heating=HeatingOverlay(sap_main_heating_code=409))],
)
# Assert
main = result.sap_heating.main_heating_details[0]
assert main.sap_main_heating_code == 409
assert main.main_heating_index_number is None
def test_baseline_lighting_is_not_mutated_by_a_lighting_overlay() -> None:
# Arrange — 000490 lodges 8 low-energy-unknown bulbs, 0 LED.
baseline: EpcPropertyData = build_epc()
original_led: int = baseline.led_fixed_lighting_bulbs_count
original_lel: int | None = baseline.low_energy_fixed_lighting_bulbs_count
# Act — fold an all-LED overlay (led = the 8 total).
_: EpcPropertyData = apply_simulations(
baseline,
[
EpcSimulation(
lighting=LightingOverlay(
led_fixed_lighting_bulbs_count=8,
low_energy_fixed_lighting_bulbs_count=0,
)
)
],
)
# Assert — the baseline's counts are untouched.
assert baseline.led_fixed_lighting_bulbs_count == original_led
assert baseline.low_energy_fixed_lighting_bulbs_count == original_lel