diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 75b4c97d..392fff2d 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -100,7 +100,10 @@ from domain.sap.worksheet.energy_requirements import ( from domain.sap.worksheet.fabric_energy_efficiency import ( fabric_energy_efficiency_kwh_per_m2_yr, ) -from domain.sap.worksheet.space_cooling import space_cooling_monthly_kwh +from domain.sap.worksheet.space_cooling import ( + SpaceCoolingResult, + space_cooling_monthly_kwh, +) from domain.sap.worksheet.space_heating import ( SpaceHeatingResult, space_heating_monthly_kwh, @@ -953,6 +956,63 @@ def space_heating_section_from_cert( ) +def space_cooling_section_from_cert( + epc: EpcPropertyData, +) -> Optional[SpaceCoolingResult]: + """SAP 10.2 §8c cert→inputs cascade for `space_cooling_monthly_kwh`. + + Composes §1 (dim) + §2 (ventilation) + §3 (HLC) + climate; cooling + gains and cooled-area fraction default to 0 (RdSAP convention — the + cert never lodges cooled-area data, and for `has_fixed_air_conditioning + =False` certs the f_C=0 zeros (107) regardless of gains). Returns the + full `SpaceCoolingResult` (every (100)..(108) line ref) so cascade pin + tests can assert each §8c line ref against the U985 PDF. + + Returns `None` when TFA is missing (matches other section helpers). + """ + if epc.total_floor_area_m2 is None: + return None + dim = dimensions_from_cert(epc) + ventilation = ventilation_from_cert(epc) + ht = heat_transmission_section_from_cert(epc) + monthly_htc_w_per_k = tuple( + ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m] + for m in range(12) + ) + region = _region_index(epc.region_code) + monthly_external_temp_c = tuple( + external_temperature_c(region, m) for m in range(1, 13) + ) + return space_cooling_monthly_kwh( + monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, + monthly_external_temperature_c=monthly_external_temp_c, + monthly_total_gains_w=(0.0,) * 12, + total_floor_area_m2=dim.total_floor_area_m2, + thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + cooled_area_fraction=0.0, + intermittency_factor=0.25, + ) + + +def fabric_energy_efficiency_from_cert(epc: EpcPropertyData) -> Optional[float]: + """SAP 10.2 §8f cert→inputs cascade for `fabric_energy_efficiency_kwh_ + per_m2_yr` — line (109) = (98a)/(4) + (108). Composes §8 (space heating) + + §8c (space cooling) + §1 (TFA). Returns None when TFA missing. + """ + if epc.total_floor_area_m2 is None: + return None + dim = dimensions_from_cert(epc) + sh = space_heating_section_from_cert(epc) + sc = space_cooling_section_from_cert(epc) + assert sh is not None, "space_heating None despite TFA present" + assert sc is not None, "space_cooling None despite TFA present" + return fabric_energy_efficiency_kwh_per_m2_yr( + space_heating_kwh_per_yr=sh.space_heating_requirement_kwh_per_yr, + total_floor_area_m2=dim.total_floor_area_m2, + space_cooling_per_m2_kwh=sc.space_cooling_per_m2_kwh, + ) + + def solar_gains_section_from_cert(epc: EpcPropertyData) -> SolarGainsResult: """SAP 10.2 §6 cert→inputs cascade for `solar_gains_from_cert`. diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py index 135a8714..2a6d1f07 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py @@ -18,10 +18,12 @@ import pytest from domain.sap.rdsap.cert_to_inputs import ( cert_to_inputs, + fabric_energy_efficiency_from_cert, heat_transmission_section_from_cert, internal_gains_section_from_cert, mean_internal_temperature_section_from_cert, solar_gains_section_from_cert, + space_cooling_section_from_cert, space_heating_section_from_cert, ventilation_from_cert, water_heating_section_from_cert, @@ -593,3 +595,107 @@ def test_section_8_scalar_line_refs_match_pdf( # Assert _pin(actual, expected, f"§8 {fixture_attr} {fixture_name}") + + +# ============================================================================ +# §8c Space cooling — LINE_100..LINE_108 +# ============================================================================ +# All 6 Elmhurst fixtures have `has_fixed_air_conditioning=False` so f_C=0 +# and (107)/(108) collapse to zero. (100), (102), (104) depend on H × (24 − +# T_e) per fixture, so we don't assert those here (covered by +# `test_space_cooling.py` synthetic-positive case); cascade pins target the +# spec-collapsed lines: (101) η_loss=1, (103) gains=0, (106) intermittency +# mask, (107) cooling kWh=0, (107) annual=0, (108) per-m²=0. + +_SECTION_8C_MONTHLY_PINS: Final[tuple[tuple[str, str], ...]] = ( + ("LINE_101_M_UTILISATION_FACTOR_LOSS", "utilisation_factor_loss_monthly"), + ("LINE_103_M_COOLING_GAINS_W", "cooling_gains_monthly_w"), + ("LINE_106_M_INTERMITTENCY_FACTOR", "intermittency_factor_monthly"), + ("LINE_107_M_SPACE_COOLING_KWH", "space_cooling_monthly_kwh"), +) + +_SECTION_8C_SCALAR_PINS: Final[tuple[tuple[str, str], ...]] = ( + ("SECTION_8C_COOLED_AREA_FRACTION", "cooled_area_fraction"), + ("LINE_107_ANNUAL_KWH", "space_cooling_kwh_per_yr"), + ("LINE_108_PER_M2_KWH", "space_cooling_per_m2_kwh"), +) + + +@pytest.mark.parametrize( + "fixture_name,fixture_attr,result_attr", + [ + (fix, line, attr) + for fix in _FIXTURES + for line, attr in _SECTION_8C_MONTHLY_PINS + ], + ids=lambda x: x if isinstance(x, str) else None, +) +def test_section_8c_monthly_line_refs_match_pdf( + fixture_name: str, fixture_attr: str, result_attr: str +) -> None: + """§8c monthly pins — every Jan..Dec value of (101)/(103)/(106)/(107) + space-cooling lines matches the U985 PDF to abs=1e-4.""" + # Arrange + mod = _FIXTURES[fixture_name] + epc = mod.build_epc() # type: ignore[attr-defined] + expected = getattr(mod, fixture_attr) + + # Act + sc = space_cooling_section_from_cert(epc) + assert sc is not None, f"{fixture_name}: space_cooling_from_cert returned None" + actual = getattr(sc, result_attr) + + # Assert + for m in range(12): + _pin(actual[m], expected[m], f"§8c {fixture_attr}[{m+1}] {fixture_name}") + + +@pytest.mark.parametrize( + "fixture_name,fixture_attr,result_attr", + [ + (fix, line, attr) + for fix in _FIXTURES + for line, attr in _SECTION_8C_SCALAR_PINS + ], + ids=lambda x: x if isinstance(x, str) else None, +) +def test_section_8c_scalar_line_refs_match_pdf( + fixture_name: str, fixture_attr: str, result_attr: str +) -> None: + """§8c scalar pins — (105) cooled-area fraction + (107) annual Σ + + (108) per-m² total match the U985 PDF to abs=1e-4.""" + # Arrange + mod = _FIXTURES[fixture_name] + epc = mod.build_epc() # type: ignore[attr-defined] + expected = getattr(mod, fixture_attr) + + # Act + sc = space_cooling_section_from_cert(epc) + assert sc is not None, f"{fixture_name}: space_cooling_from_cert returned None" + actual = getattr(sc, result_attr) + + # Assert + _pin(actual, expected, f"§8c {fixture_attr} {fixture_name}") + + +# ============================================================================ +# §8f Fabric Energy Efficiency — LINE_109 +# ============================================================================ + + +@pytest.mark.parametrize("fixture_name", list(_FIXTURES), ids=lambda x: x) +def test_section_8f_line_109_fee_matches_pdf(fixture_name: str) -> None: + """§8f scalar pin — (109) FEE = (98a)/(4) + (108) matches the U985 PDF + to abs=1e-4. For all 6 fixtures (98b) solar space heating = 0 and (108) + = 0, so (109) = (99).""" + # Arrange + mod = _FIXTURES[fixture_name] + epc = mod.build_epc() # type: ignore[attr-defined] + expected = mod.LINE_109_FEE_KWH_PER_M2 # type: ignore[attr-defined] + + # Act + actual = fabric_energy_efficiency_from_cert(epc) + + # Assert + assert actual is not None, f"{fixture_name}: fee_from_cert returned None" + _pin(actual, expected, f"§8f LINE_109_FEE_KWH_PER_M2 {fixture_name}")