diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 82b22f6c..e40666b2 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -319,10 +319,25 @@ class _CorpusExpectation: # variants; only the lighting-PE +48.66 / +11.95 CO2 deferred quirk # remains (same offset as electric 1 + solid fuel 5/6/7/8). Cluster # Σ|ΔSAP_c| 1.06 → 0.00 in one slice. +# +# Slice S0380.161 closed electric 2 (Cat 5 warm-air HP, code 524) by +# wiring the SAP 10.2 Table 5a row "Warm air heating system fans +# a) c)" (PDF p.177) GAIN side. Pre-slice S0380.158 wired the kWh +# side (136.35 kWh/yr via Table 4f) but the parallel GAIN row was +# never wired, so cascade (70) m = 0 every month vs worksheet 13.6350 +# W in heating months (= 1.5 × 0.04 × 227.25 with SFP default 1.5 +# W/(l/s) per footnote c). The -13.6 W winter gain shortfall over- +# stated cascade SH demand by ~57 kWh/yr (cascade 9483 vs worksheet +# 9426), under-charging cost by ~£2.50 with opposite sign. New +# `_any_main_system_has_warm_air_distribution` + `_has_balanced_ +# mechanical_ventilation` predicates + leaf wiring in the orchestrator. +# Electric 2 SAP -0.1087 → -0.0000 EXACT; joins the lighting-PE +# deferred cohort (CO2 +11.95 / PE +48.66). Cohort Σ|ΔSAP_c| +# 0.18 → 0.07 in one slice. _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=-0.0240, expected_cost_resid_gbp=+0.5536, expected_co2_resid_kg=+7.3267, expected_pe_resid_kwh=+36.3435), _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6605), - _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.1087, expected_cost_resid_gbp=+2.5037, expected_co2_resid_kg=+16.5405, expected_pe_resid_kwh=+97.6875), + _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604), diff --git a/domain/sap10_calculator/worksheet/internal_gains.py b/domain/sap10_calculator/worksheet/internal_gains.py index 989a0c43..c2a6d65a 100644 --- a/domain/sap10_calculator/worksheet/internal_gains.py +++ b/domain/sap10_calculator/worksheet/internal_gains.py @@ -59,6 +59,11 @@ _LIQUID_FUEL_WARM_AIR_PUMP_W: Final[float] = 10.0 _WARM_AIR_HEATING_VOLUME_COEFF: Final[float] = 0.04 _PIV_VOLUME_COEFF: Final[float] = 0.12 _BALANCED_MV_NO_HR_VOLUME_COEFF: Final[float] = 0.06 +# Table 5a footnote c) default SFP when no PCDB warm-air-unit SFP is +# lodged: "otherwise 1.5 W/(l/s). These values of SFP include an +# in-use factor." Same default as Table 4f footnote e) for the kWh +# side (see `cert_to_inputs._TABLE_4F_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S`). +_TABLE_5A_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S: Final[float] = 1.5 _HIU_HOURS_PER_DAY: Final[float] = 24.0 _SUMMER_MONTHS: Final[frozenset[int]] = frozenset({6, 7, 8, 9}) @@ -730,6 +735,59 @@ def _any_main_system_has_central_heating_pump(epc: EpcPropertyData) -> bool: return False +# SAP 10.2 Table 4a (PDF p.165-166) warm-air heating SAP codes. The +# Table 5a "Warm air heating system fans" gain (and Table 4f +# electricity row) fire for these mains: +# - Cat 5 (heat pumps with warm-air distribution): 521, 523-527 +# - Cat 9 (warm air NOT heat pump): 501-515, 520 +# Mirrors `cert_to_inputs._TABLE_4A_WARM_AIR_SAP_CODES` — kept here as +# a sibling so the worksheet layer does not depend on rdsap. Keep in +# sync manually with the cert_to_inputs constant. +_TABLE_4A_WARM_AIR_SAP_CODES: Final[frozenset[int]] = frozenset({ + 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 520, + 512, 513, 514, 515, + 521, 523, 524, 525, 526, 527, +}) + + +# SAP 10.2 Table 5a footnote c) (PDF p.177) for the "Warm air heating +# system fans" row: "If the heating system is a warm air unit and +# there is balanced whole house mechanical ventilation, the gains for +# the warm air system should not be included." +# Mirrors `cert_to_inputs._BALANCED_MV_KIND_NAMES`. Balanced MV kinds +# = MVHR (balanced with HR) + MV (balanced without HR). MEV, PIV from +# outside, and natural ventilation do NOT trigger the omission. +_BALANCED_MV_KIND_NAMES: Final[frozenset[str]] = frozenset({"MVHR", "MV"}) + + +def _any_main_system_has_warm_air_distribution(epc: EpcPropertyData) -> bool: + """True iff any lodged main heating system distributes heat as warm + air (Table 4a Cat 5 HPs with warm-air dist. + Cat 9 warm-air not + HP) — qualifying for the SAP 10.2 Table 5a "Warm air heating + system fans" gain row. + """ + details = epc.sap_heating.main_heating_details + if not details: + return False + for d in details: + code = d.sap_main_heating_code + if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES: + return True + return False + + +def _has_balanced_mechanical_ventilation(epc: EpcPropertyData) -> bool: + """SAP 10.2 Table 5a footnote c) / Table 4f footnote e) balanced-MV + gate: True when the cert lodges either MVHR or MV (both balanced). + Mirrors `cert_to_inputs._has_balanced_mechanical_ventilation`. + """ + sv = getattr(epc, "sap_ventilation", None) + if sv is None: + return False + name = getattr(sv, "mechanical_ventilation_kind", None) + return name in _BALANCED_MV_KIND_NAMES + + def internal_gains_from_cert( *, epc: EpcPropertyData, @@ -803,11 +861,29 @@ def internal_gains_from_cert( ) else: pump_w = 0.0 - # Liquid-fuel + warm-air + PIV + MV + HIU branches default to zero for - # the combi-gas-natural-vent population; future slices will detect them + # SAP 10.2 Table 5a row "Warm air heating system fans a) c)" (PDF + # p.177): SFP × 0.04 × V W, heating-season only per footnote a), + # omitted when balanced whole-house MV is present per footnote c). + # Default SFP 1.5 W/(l/s) per footnote c) — no PCDB warm-air-unit + # SFP lookup yet. Sister to the Table 4f kWh-side wiring in + # `_table_4f_warm_air_heating_fans_kwh` (S0380.158). Cohort + # entry point: heating-systems corpus electric 2 (code 524 ASHP + # warm-air, V=227.25 m³, no MV → 13.6350 W matches worksheet (70)). + if ( + _any_main_system_has_warm_air_distribution(epc) + and not _has_balanced_mechanical_ventilation(epc) + ): + warm_air_fan_w = warm_air_heating_fan_w( + sfp_w_per_l_per_s=_TABLE_5A_WARM_AIR_FAN_DEFAULT_SFP_W_PER_L_PER_S, + dwelling_volume_m3=dwelling_volume_m3, + ) + else: + warm_air_fan_w = 0.0 + # Liquid-fuel + PIV + MV + HIU branches default to zero for the + # combi-gas-natural-vent population; future slices will detect them # from epc.main_heating_details + epc.mechanical_ventilation. pumps_fans = pumps_fans_monthly_w( - heating_season_w=pump_w, + heating_season_w=pump_w + warm_air_fan_w, year_round_w=0.0, ) diff --git a/domain/sap10_calculator/worksheet/tests/test_internal_gains.py b/domain/sap10_calculator/worksheet/tests/test_internal_gains.py index 93f0bb19..bd6695d7 100644 --- a/domain/sap10_calculator/worksheet/tests/test_internal_gains.py +++ b/domain/sap10_calculator/worksheet/tests/test_internal_gains.py @@ -575,6 +575,71 @@ def test_internal_gains_from_cert_reproduces_000490_worksheet_end_to_end() -> No assert result.total_internal_gains_monthly_w[m] == pytest.approx(expected_73[m], abs=1e-1), f"(73) month {m+1}" +def test_internal_gains_pumps_fans_adds_warm_air_fan_gain_for_cat5_hp_main() -> None: + """SAP 10.2 Table 5a (PDF p.177) row "Warm air heating system fans a) c)" + — gain = SFP × 0.04 × V (W). Default SFP = 1.5 W/(l/s) per footnote c + when no PCDB warm-air-unit record is lodged. Heating-season mask per + footnote a). Worksheet evidence: heating-systems corpus electric 2 + (SAP code 524 ASHP with warm-air distribution, V = 227.25 m³, + no balanced MV) lodges (70) = 13.6350 W in heating months + (Jan-May, Oct-Dec), 0 W in summer (Jun-Sep) — exactly + 1.5 × 0.04 × 227.25 = 13.635. + + Sister to S0380.158 which wired the Table 4f KWH side of the same + row (136.35 kWh/yr). This slice wires the gain side. Footnote c) + omission "If the heating system is a warm air unit and there is + balanced whole house mechanical ventilation, the gains for the + warm air system should not be included" parallels Table 4f + footnote e) for the kWh. + """ + # Arrange — Cat 5 warm-air HP (code 524), no balanced MV. + sap_heating = SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[ + MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, + heat_emitter_type="", + emitter_temperature="", + sap_main_heating_code=524, + main_heating_category=4, # HP per Table 4a Cat 5 + main_heating_control=2401, + central_heating_pump_age_str="Unknown", + ), + ], + has_fixed_air_conditioning=False, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=90.0, + low_energy_fixed_lighting_bulbs_count=6, + sap_windows=[], + sap_heating=sap_heating, + ) + + # Act + result = internal_gains_from_cert( + epc=epc, + dwelling_volume_m3=227.25, + heat_gains_from_water_heating_monthly_kwh=(0.0,) * 12, + overshading=OvershadingCategory.AVERAGE, + ) + + # Assert — 13.635 W (= 1.5 × 0.04 × 227.25) in heating months, + # zero in summer (Jun-Sep) per footnote a) heating-season mask. + expected_warm_air_fan_w = 1.5 * 0.04 * 227.25 # 13.635 + expected = ( + expected_warm_air_fan_w, expected_warm_air_fan_w, expected_warm_air_fan_w, + expected_warm_air_fan_w, expected_warm_air_fan_w, + 0.0, 0.0, 0.0, 0.0, + expected_warm_air_fan_w, expected_warm_air_fan_w, expected_warm_air_fan_w, + ) + for m in range(12): + assert abs(result.pumps_fans_monthly_w[m] - expected[m]) <= 1e-6, ( + f"(70) month {m+1} = {result.pumps_fans_monthly_w[m]:.4f}, " + f"expected {expected[m]:.4f}" + ) + + def test_internal_gains_pumps_fans_is_zero_for_electric_storage_heater_main() -> None: """SAP 10.2 Table 5a (PDF p.177) row "Central heating pump in heated space" — the gain applies only to mains with a water-loop circulation