diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 41c1415d..8227764b 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4987,16 +4987,23 @@ def ventilation_from_cert( storeys = max(1, dim.storey_count) vc = _ventilation_counts(epc.sap_ventilation) sv = epc.sap_ventilation - # RdSAP 10 §4.1 Table 5 (PDF p.28) — extract-fans default when the - # lodged count is below the age-band minimum. The Elmhurst Summary - # renders "0" as the form for unknown; the worksheet applies the - # default via `max(lodged, table_5_default)`. + # RdSAP 10 §4.1 Table 5 (PDF p.28) — extract fans: "Number of extract + # fans if known; if number is unknown: [age-band default]." The default + # is an UNKNOWN-fallback, NOT a floor: a genuinely-lodged count is used + # as-is even when it is below the age-band default (e.g. a band H-M + # dwelling lodging 2 fans is NOT bumped to the 3-fan default). The + # Elmhurst Summary / RdSAP convention renders "0" as the form for + # unknown, so a lodged 0 falls back to the default; any positive count + # is taken literally. (Was `max(lodged, default)`, which over-applied + # the default as a minimum and over-counted ventilation.) age_band = _dwelling_age_band(epc) or "" is_park_home = (epc.property_type or "").strip().lower() == "park home" table_5_fan_default = _rdsap_extract_fans_default( age_band, epc.habitable_rooms_count, is_park_home=is_park_home, ) - intermittent_fans = max(vc.intermittent_fans, table_5_fan_default) + intermittent_fans = ( + vc.intermittent_fans if vc.intermittent_fans > 0 else table_5_fan_default + ) wind_kwargs: dict[str, tuple[float, ...]] = ( {"monthly_wind_speed_m_s": postcode_climate.monthly_wind_speed_m_per_s} if postcode_climate is not None else {} diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 4b4951bc..e1ca3f7e 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1609,6 +1609,40 @@ def test_ventilation_from_cert_applies_table_5_default_when_lodged_zero() -> Non ) +def test_ventilation_from_cert_uses_lodged_fans_below_age_default_not_floored() -> None: + # Arrange — RdSAP 10 §4.1 Table 5 (PDF p.28): "Number of extract fans if + # known; if unknown: [age-band default]." The default is an UNKNOWN- + # fallback, NOT a floor — a genuinely-lodged positive count is used + # as-is even when below the age default. An age-H 6-habitable-room + # dwelling has a 3-fan default, but a cert lodging 2 fans must use 2, + # not be floored up to 3. (Was `max(lodged, default)` → 3, over-counting + # ventilation; surfaced on simulated case 46 where it inflated (8) by + # one fan = 0.055 ACH and pushed SAP 30 → 29.) + age_h_part = make_building_part( + floor_dimensions=[ + make_floor_dimension(total_floor_area_m2=45.0, floor=0), + make_floor_dimension(total_floor_area_m2=45.0, floor=1), + ], + construction_age_band='H', + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=6, # age H-M, 6-8 rooms → Table 5 default 3 + region_code="1", + sap_building_parts=[age_h_part], + sap_ventilation=SapVentilation(extract_fans_count=2), # lodged 2 < 3 + ) + + # Act + v = ventilation_from_cert(epc) + + # Assert — (8) openings ACH uses the lodged 2 fans (20 m³/h), not 3. + from domain.sap10_calculator.rdsap.cert_to_inputs import dimensions_from_cert + vol = dimensions_from_cert(epc).volume_m3 + assert abs(v.openings_ach - 20.0 / vol) <= 1e-6 + assert abs(v.openings_ach - 30.0 / vol) > 1e-6 + + def test_ventilation_from_cert_passes_lodged_ap4_to_pressure_test_ach_per_sap_10_2_section_2_line_18() -> None: # Arrange — SAP 10.2 §2 line (17a)/(18) "Air permeability value, AP4 # (m³/h/m²)": when a Pulse pressure test is lodged the cascade must diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 079f4e01..df64ed59 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -183,7 +183,17 @@ _CORPUS = Path( # degenerate dwelling no longer emits a negative SAP. Removed a -12.3 outlier # (cert 422000111926, lodged at the floor of 1, was computing -11.3): within-0.5 # 70.2% -> 70.3%, MAE 0.845 -> 0.833. -_MIN_WITHIN_HALF_SAP = 0.71 +# EXTRACT-FAN DEFAULT IS UNKNOWN-FALLBACK, NOT A FLOOR (RdSAP 10 §4.1 Table 5, +# PDF p.28). Table 5 reads "Number of extract fans if known; if unknown: +# [age-band default]". The cascade applied `max(lodged, age_default)`, flooring +# a genuinely-lodged count up to the age-band minimum (e.g. an age H-M dwelling +# lodging 2 fans billed at the 3-fan default), over-counting ventilation line +# (8) and the HLC. Fixed to `lodged if lodged > 0 else default` (a lodged 0 is +# the Elmhurst/RdSAP "unknown" form → default; any positive count is literal). +# within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815. Surfaced by Khalim's Elmhurst +# stress worksheet (simulated case 46): closed its last ventilation residual +# (our Jan ACH 9.14 -> 9.0748 exact; SAP 29 -> 30 = accredited Elmhurst). +_MIN_WITHIN_HALF_SAP = 0.72 _MAX_SAP_MAE = 0.82 _MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 3.7 # kWh / m2 / yr vs energy_consumption_current