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 c79cccff..e89b4fe3 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -691,6 +691,48 @@ def _ventilation_counts(vent: Optional[SapVentilation]) -> _VentilationCounts: ) +def water_heating_section_from_cert( + epc: EpcPropertyData, +) -> Optional[WaterHeatingResult]: + """SAP 10.2 §4 cert→inputs cascade. Returns the final + `WaterHeatingResult` (every (42)..(65) line ref breakdown) after + PCDB Table 3b/3c combi-loss override, exactly as cert_to_inputs + computes internally. + + Returns `None` when TFA is missing — the legacy fallback path + bypasses §4 entirely; tests using this helper should skip those + fixtures. + """ + if epc.total_floor_area_m2 is None: + return None + main = _first_main_heating(epc) + pcdb_main = ( + gas_oil_boiler_record(main.main_heating_index_number) + if main is not None and main.main_heating_index_number is not None + else None + ) + bootstrap = water_heating_from_cert( + epc=epc, + mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc), + has_bath=_has_bath_from_cert(epc), + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + low_water_use=False, + ) + combi_loss_override = pcdb_combi_loss_override( + pcdb_main, + energy_content_monthly_kwh=bootstrap.energy_content_monthly_kwh, + daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly, + ) + return water_heating_from_cert( + epc=epc, + mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc), + has_bath=_has_bath_from_cert(epc), + cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, + low_water_use=False, + combi_loss_monthly_kwh_override=combi_loss_override, + ) + + def heat_transmission_section_from_cert(epc: EpcPropertyData) -> HeatTransmission: """SAP 10.2 §3 cert→inputs cascade for `heat_transmission_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 800c48de..8bd1eaf9 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 @@ -20,6 +20,7 @@ from domain.sap.rdsap.cert_to_inputs import ( cert_to_inputs, heat_transmission_section_from_cert, ventilation_from_cert, + water_heating_section_from_cert, ) from domain.sap.worksheet.dimensions import dimensions_from_cert from domain.sap.worksheet.tests import ( @@ -233,3 +234,79 @@ def test_section_3_line_refs_match_pdf( # Assert _pin(actual, expected, f"§3 {fixture_attr} {fixture_name}") + + +# ============================================================================ +# §4 Water heating — LINE_42..LINE_65 scalar + monthly tuples +# ============================================================================ + +_SECTION_4_SCALAR_PINS: Final[tuple[tuple[str, str], ...]] = ( + ("LINE_42_OCCUPANCY", "occupancy"), + ("LINE_43_ANNUAL_AVG_HW_USAGE_L_PER_DAY", "annual_avg_hot_water_l_per_day"), +) + +_SECTION_4_MONTHLY_PINS: Final[tuple[tuple[str, str], ...]] = ( + ("LINE_44_M_DAILY_HW_USAGE_L", "daily_hot_water_l_per_day_monthly"), + ("LINE_45_M_HW_ENERGY_CONTENT_KWH", "energy_content_monthly_kwh"), + ("LINE_46_M_DISTRIBUTION_LOSS_KWH", "distribution_loss_monthly_kwh"), + ("LINE_61_M_COMBI_LOSS_KWH", "combi_loss_monthly_kwh"), + ("LINE_62_M_TOTAL_WH_KWH", "total_demand_monthly_kwh"), + ("LINE_64_M_OUTPUT_FROM_WH_KWH", "output_monthly_kwh"), + ("LINE_65_M_HEAT_GAINS_FROM_WH_KWH", "heat_gains_monthly_kwh"), +) + + +@pytest.mark.parametrize( + "fixture_name,fixture_attr,result_attr", + [ + (fix, line, attr) + for fix in _FIXTURES + for line, attr in _SECTION_4_SCALAR_PINS + ], + ids=lambda x: x if isinstance(x, str) else None, +) +def test_section_4_scalar_line_refs_match_pdf( + fixture_name: str, fixture_attr: str, result_attr: str +) -> None: + """§4 scalar pins — (42) occupancy + (43) annual avg HW usage.""" + # Arrange + mod = _FIXTURES[fixture_name] + epc = mod.build_epc() # type: ignore[attr-defined] + expected = getattr(mod, fixture_attr) + + # Act + wh = water_heating_section_from_cert(epc) + assert wh is not None, f"{fixture_name}: water_heating_from_cert returned None" + actual = getattr(wh, result_attr) + + # Assert + _pin(actual, expected, f"§4 {fixture_attr} {fixture_name}") + + +@pytest.mark.parametrize( + "fixture_name,fixture_attr,result_attr", + [ + (fix, line, attr) + for fix in _FIXTURES + for line, attr in _SECTION_4_MONTHLY_PINS + ], + ids=lambda x: x if isinstance(x, str) else None, +) +def test_section_4_monthly_line_refs_match_pdf( + fixture_name: str, fixture_attr: str, result_attr: str +) -> None: + """§4 monthly pins — daily HW use, energy content, distribution + loss, combi loss, total demand, output, heat gains.""" + # Arrange + mod = _FIXTURES[fixture_name] + epc = mod.build_epc() # type: ignore[attr-defined] + expected = getattr(mod, fixture_attr) + + # Act + wh = water_heating_section_from_cert(epc) + assert wh is not None, f"{fixture_name}: water_heating_from_cert returned None" + actual = getattr(wh, result_attr) + + # Assert + for m in range(12): + _pin(actual[m], expected[m], f"§4 {fixture_attr}[{m+1}] {fixture_name}")