"""Tests for envelope_heat_loss_w_per_k. Uses the existing `make_building_part` / `make_floor_dimension` fixtures so test cases stay close to the shape transform.py sees on a real cert. """ import pytest from domain.sap10_ml.envelope import envelope_heat_loss_w_per_k from domain.sap10_ml.tests._fixtures import make_building_part, make_floor_dimension def test_envelope_single_storey_no_windows_no_doors_age_g_cavity_returns_expected_w_per_k() -> None: # Arrange — Mid-terrace, age G cavity-as-built, 100 m^2, 40 m perimeter, 5 m party wall, # 2.5 m room height, single storey, no windows, no doors. # Expected (RdSAP10 Tables 6,15,18,21): # U_wall = 0.60, U_roof = 0.40, U_floor ~= 0.61 (ISO 13370 with A=100, P=40), # U_party = 0.0 (solid masonry default), y = 0.15. # Wall area = 40 * 2.5 - 0 - 0 = 100 m^2; party = 5 * 2.5 = 12.5 m^2. # Heat loss ~= 0.60*100 + 0.40*100 + 0.61*100 + 0.0*12.5 + 0.15*(100+100+100+12.5) # = 60 + 40 + 61 + 0 + 46.875 ~= 208 W/K. main = make_building_part( identifier="Main Dwelling", construction_age_band="G", wall_construction=4, # cavity wall_insulation_type=4, # none party_wall_construction=1, # solid masonry roof_construction=4, floor_dimensions=[ make_floor_dimension( total_floor_area_m2=100.0, room_height_m=2.5, party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0, ) ], ) # Act result = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, ) # Assert assert result == pytest.approx(208.0, abs=8.0) def test_envelope_doubles_for_two_storey_dwelling() -> None: # Arrange — same floor plan but 2 storeys (wall area + party area double; roof+floor stay). main = make_building_part( identifier="Main Dwelling", construction_age_band="G", wall_construction=4, wall_insulation_type=4, party_wall_construction=1, roof_construction=4, floor_dimensions=[ make_floor_dimension( total_floor_area_m2=100.0, room_height_m=2.5, party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0, ), make_floor_dimension( total_floor_area_m2=100.0, room_height_m=2.5, party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=1, ), ], ) # Act result = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, ) # Assert — 0.60*200 + 0.40*100 + 0.61*100 + 0 + 0.15*(200+100+100+25) ~= 285 W/K. assert result == pytest.approx(285.0, abs=12.0) def test_envelope_drops_with_better_insulation() -> None: # Arrange — same geometry, age band M (post-2012, well insulated). main = make_building_part( identifier="Main Dwelling", construction_age_band="M", wall_construction=4, wall_insulation_type=2, # filled cavity party_wall_construction=1, roof_construction=4, floor_dimensions=[ make_floor_dimension( total_floor_area_m2=100.0, room_height_m=2.5, party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0, ) ], ) age_g_main = make_building_part( identifier="Main Dwelling", construction_age_band="G", wall_construction=4, wall_insulation_type=4, party_wall_construction=1, roof_construction=4, floor_dimensions=[ make_floor_dimension( total_floor_area_m2=100.0, room_height_m=2.5, party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0, ) ], ) # Act age_m = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, ) age_g = envelope_heat_loss_w_per_k( sap_building_parts=[age_g_main], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, ) # Assert assert age_m < age_g def test_envelope_returns_zero_when_no_building_parts() -> None: # Arrange / Act / Assert assert envelope_heat_loss_w_per_k( sap_building_parts=[], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, ) == 0.0 def test_envelope_sums_main_and_extension_contributions() -> None: # Arrange — main + one extension; combined > main alone. main = make_building_part( identifier="Main Dwelling", construction_age_band="G", wall_construction=4, wall_insulation_type=4, party_wall_construction=1, roof_construction=4, floor_dimensions=[ make_floor_dimension( total_floor_area_m2=100.0, room_height_m=2.5, party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0, ) ], ) extension = make_building_part( identifier="Extension 1", construction_age_band="L", wall_construction=4, wall_insulation_type=2, party_wall_construction=1, roof_construction=4, floor_dimensions=[ make_floor_dimension( total_floor_area_m2=15.0, room_height_m=2.4, party_wall_length_m=0.0, heat_loss_perimeter_m=16.0, floor=0, ) ], ) # Act main_only = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, ) with_extension = envelope_heat_loss_w_per_k( sap_building_parts=[main, extension], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, ) # Assert assert with_extension > main_only def test_envelope_increases_with_windows_and_doors() -> None: # Arrange — same base, but with 15 m^2 of windows + 1 door. main = make_building_part( identifier="Main Dwelling", construction_age_band="G", wall_construction=4, wall_insulation_type=4, party_wall_construction=1, roof_construction=4, floor_dimensions=[ make_floor_dimension( total_floor_area_m2=100.0, room_height_m=2.5, party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0, ) ], ) # Act no_openings = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, ) with_openings = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code="ENG", window_total_area_m2=15.0, window_avg_u_value=2.8, door_count=1, insulated_door_count=0, insulated_door_u_value=None, ) # Assert — openings add net heat loss because U_window (2.8) > U_wall (0.60). assert with_openings > no_openings def test_envelope_uninsulated_roof_description_raises_heat_loss() -> None: # Arrange — Catastrophic heritage roof: top-level roofs[i].description says # "no insulation". Without the flag the Table 18 age-G default of 0.40 W/m^2K # under-states heat loss; with it u_roof returns 2.30 W/m^2K so the envelope # rises by roughly (2.30-0.40)*100 = 190 W/K for a 100 m^2 roof. main = make_building_part( identifier="Main Dwelling", construction_age_band="G", wall_construction=4, wall_insulation_type=4, party_wall_construction=1, roof_construction=4, floor_dimensions=[ make_floor_dimension( total_floor_area_m2=100.0, room_height_m=2.5, party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0, ) ], ) # Act default_roof = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, ) uninsulated_roof = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, roof_description="Pitched, no insulation (assumed)", ) # Assert — heat loss jumps by ~190 W/K (1.90 W/m^2K * 100 m^2 roof area). assert uninsulated_roof - default_roof == pytest.approx(190.0, abs=15.0) def test_envelope_limited_roof_insulation_description_raises_heat_loss() -> None: # Arrange — "limited insulation" maps to u_roof=1.50; delta vs Table 18 # age-G default of 0.40 is 1.10 W/m^2K * 100 m^2 = 110 W/K. main = make_building_part( identifier="Main Dwelling", construction_age_band="G", wall_construction=4, wall_insulation_type=4, party_wall_construction=1, roof_construction=4, floor_dimensions=[ make_floor_dimension( total_floor_area_m2=100.0, room_height_m=2.5, party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0, ) ], ) # Act default_roof = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, ) limited_roof = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, roof_description="Roof room(s), limited insulation", ) # Assert assert limited_roof - default_roof == pytest.approx(110.0, abs=15.0) def test_envelope_stone_wall_description_raises_heat_loss_vs_cavity_default() -> None: # Arrange — construction integer missing on a Victorian (age D) cert. # _DEFAULT_WALL_BY_AGE picks CAVITY (1.5 W/m^2K uninsulated for D) by # default; a walls[i].description naming "Sandstone" should resolve to # stone instead (1.7 W/m^2K). Delta on net wall area ~100 m^2 -> +20 W/K. main = make_building_part( identifier="Main Dwelling", construction_age_band="D", wall_construction=10, # WALL_UNKNOWN -> envelope passes None to u_wall wall_insulation_type=4, party_wall_construction=1, roof_construction=4, floor_dimensions=[ make_floor_dimension( total_floor_area_m2=100.0, room_height_m=2.5, party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0, ) ], ) # Act default_wall = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, ) stone_wall = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, wall_description="Sandstone or limestone, as built, no insulation (assumed)", ) # Assert — heat loss rises by ~20 W/K (0.2 W/m^2K * 100 m^2 net wall area). assert stone_wall - default_wall == pytest.approx(20.0, abs=5.0) def test_envelope_never_null_even_with_missing_fields() -> None: # Arrange — minimal building part with most fields unspecified. main = make_building_part( identifier="Main Dwelling", construction_age_band="NR", # unrecorded floor_dimensions=[ make_floor_dimension( total_floor_area_m2=80.0, room_height_m=2.5, party_wall_length_m=0.0, heat_loss_perimeter_m=36.0, floor=0, ) ], ) # Act result = envelope_heat_loss_w_per_k( sap_building_parts=[main], country_code=None, window_total_area_m2=0.0, window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, ) # Assert — finite, positive. assert result > 0.0