"""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, PhotovoltaicArray, PvBatteries, PvBattery, SapBuildingPart, SapVentilation, ) from domain.modelling.simulation import ( BuildingPartOverlay, EpcSimulation, HeatingOverlay, LightingOverlay, SecondaryHeatingOverlay, SolarOverlay, 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_flat_roof_construction_type_folds_onto_the_part() -> None: # ADR-0033: a flat-roof landlord override sets `roof_construction_type` so the # calculator's flat path (`"flat" in roof_construction_type`) fires the # age-band default. Proves the generic field loop wires the new overlay field. # Arrange baseline: EpcPropertyData = build_epc() simulation = EpcSimulation( building_parts={ BuildingPartIdentifier.MAIN: BuildingPartOverlay( roof_construction_type="Flat" ) } ) # Act result: EpcPropertyData = apply_simulations(baseline, [simulation]) # Assert assert _part(result, BuildingPartIdentifier.MAIN).roof_construction_type == "Flat" 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_secondary_heating_overlay_clears_the_lodged_secondary() -> None: # Arrange — 000490 lodges a secondary system (SAP code 691, electric panel/ # convector/radiant heaters). Pin a fuel on it too so we prove the fold # clears BOTH the type and the fuel (ADR-0028). baseline: EpcPropertyData = build_epc() baseline.sap_heating.secondary_fuel_type = 30 assert baseline.sap_heating.secondary_heating_type == 691 # Act — fold a removal overlay. result: EpcPropertyData = apply_simulations( baseline, [EpcSimulation(secondary_heating=SecondaryHeatingOverlay())] ) # Assert — the secondary is gone from the dwelling handed to the calculator. assert result.sap_heating.secondary_heating_type is None assert result.sap_heating.secondary_fuel_type is None def test_secondary_heating_removal_does_not_mutate_the_baseline() -> None: # Arrange — 000490 lodges secondary SAP code 691. baseline: EpcPropertyData = build_epc() assert baseline.sap_heating.secondary_heating_type == 691 # Act — fold a removal overlay. _: EpcPropertyData = apply_simulations( baseline, [EpcSimulation(secondary_heating=SecondaryHeatingOverlay())] ) # Assert — the baseline's secondary is untouched (the fold copies first). assert baseline.sap_heating.secondary_heating_type == 691 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_apply_folds_a_solar_overlay_onto_the_energy_source() -> None: # Arrange — 000490 lodges no PV, not export-capable, no diverter. A Solar PV # Option installs a two-segment array, ensures export, and adds a battery # (ADR-0026). The solar overlay is the sixth surface; it writes onto # sap_energy_source. baseline: EpcPropertyData = build_epc() arrays = [ PhotovoltaicArray(peak_power=4.8, pitch=2, orientation=5, overshading=1), PhotovoltaicArray(peak_power=1.2, pitch=2, orientation=6, overshading=2), ] simulation = EpcSimulation( solar=SolarOverlay( photovoltaic_arrays=arrays, pv_diverter_present=True, pv_connection=1, is_dwelling_export_capable=True, pv_batteries=PvBatteries(pv_battery=PvBattery(battery_capacity=5.0)), ) ) # Act result: EpcPropertyData = apply_simulations(baseline, [simulation]) # Assert — every field routed onto sap_energy_source. source = result.sap_energy_source assert source.photovoltaic_arrays is not None assert [a.peak_power for a in source.photovoltaic_arrays] == [4.8, 1.2] assert [a.orientation for a in source.photovoltaic_arrays] == [5, 6] assert [a.overshading for a in source.photovoltaic_arrays] == [1, 2] assert source.pv_diverter_present is True assert source.pv_connection == 1 assert source.is_dwelling_export_capable is True assert source.pv_batteries is not None assert abs(source.pv_batteries.pv_battery.battery_capacity - 5.0) <= 1e-9 def test_solar_overlay_leaves_diverter_unset_when_omitted() -> None: # Arrange — a combi dwelling gets PV without a diverter (nothing to divert # to); the omitted field leaves the baseline False unchanged. baseline: EpcPropertyData = build_epc() simulation = EpcSimulation( solar=SolarOverlay( photovoltaic_arrays=[ PhotovoltaicArray(peak_power=3.2, pitch=2, orientation=5, overshading=1) ], is_dwelling_export_capable=True, ) ) # Act result: EpcPropertyData = apply_simulations(baseline, [simulation]) # Assert — diverter untouched (still False), export flipped True. assert result.sap_energy_source.pv_diverter_present is False assert result.sap_energy_source.is_dwelling_export_capable is True def test_baseline_energy_source_is_not_mutated_by_a_solar_overlay() -> None: # Arrange baseline: EpcPropertyData = build_epc() original_export = baseline.sap_energy_source.is_dwelling_export_capable original_arrays = baseline.sap_energy_source.photovoltaic_arrays # Act _: EpcPropertyData = apply_simulations( baseline, [ EpcSimulation( solar=SolarOverlay( photovoltaic_arrays=[ PhotovoltaicArray( peak_power=3.2, pitch=2, orientation=5, overshading=1 ) ], is_dwelling_export_capable=True, ) ) ], ) # Assert — the baseline's energy source is untouched. assert baseline.sap_energy_source.is_dwelling_export_capable == original_export assert baseline.sap_energy_source.photovoltaic_arrays == original_arrays 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