From fb173cdf3f078ed4e1d20ca2bce8f236cf0bd4b1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 1 Jun 2026 11:26:53 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.151:=20RdSAP=2010=20=C2=A74.1=20T?= =?UTF-8?q?able=205=20=E2=80=94=20extract-fans=20age-band=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 Specification §4.1 Table 5 "Ventilation parameters" (PDF p.28) verbatim — "Extract fans" entry: • Number of extract fans if known • If number is unknown: Not park home: Age bands A to E all cases → 0 Age bands F to G all cases → 1 Age bands H to M up to 2 hab. rooms → 1 3 to 5 hab. rooms → 2 6 to 8 hab. rooms → 3 more than 8 hab. rooms → 4 Park home: Age band F all cases → 0 Age bands G onwards all cases → 2 The Elmhurst Summary §12.0 renders "No. of intermittent extract fans: 0" as the form for *unknown*; every other §2 chimney/flue line item follows "number if known, or 0 if not present" and the cascade trusts the lodged value verbatim. Only extract fans have a non-zero age-band default. Pre-slice the cascade read the lodged 0 verbatim → cohort-wide -0.044 ACH ventilation deficit (= -2.6 W/K HLC, = -1.2% SH demand, = ~-0.3 SAP per variant). All 25 cascade-OK corpus variants are age G + 4 habitable rooms + not park home → Table 5 default = 1 fan. New helper `_rdsap_extract_fans_default(age_band, habitable_rooms, *, is_park_home)` + wiring in `ventilation_from_cert` applies `max(lodged, table_5_default)` so the spec minimum fires when lodging is below it. Heating-systems corpus impact (25 cascade-OK variants): oil 1, oil pcdb 1/2/3 +0.27..+0.29 → EXACT (<1e-4) electric 1, solid fuel 5/6/7/8 +0.28..+0.43 → EXACT pcdb 1, ashp +0.41 / +0.18 → ±0.02 electric 3/6/7/8/9, sf 4/9/10/11 +0.39..+0.60 → +0.08..+0.12 electric 5 -0.74 → -1.18 (Cluster B over-shoot) electric 2 -0.24 → -0.46 (Cluster C HW gap) gshp +1.09 → +0.94 (Cluster C HW gap) solid fuel 2/3 +3.08 / +1.76 → +2.77 / +1.31 Cluster A (cohort-wide HLC deficit) is closed. The four remaining open fronts (Clusters B + C) are now visible without offsetting bugs: - Cluster B (Table 9c step 12 R sign): electric 5, solid fuel 2/3 - Cluster C (HW kWh cascade): gshp + electric 2 (Appendix N3) solid fuel 2/3 (Table 4b HW efficiency) Golden-fixture re-pins: cert 0240 (age J, TFA 118): PE +2.18 → +5.80, CO2 +0.13 → +0.32 cert 0390-2954 (age F, TFA 360): PE -28.27 → -27.97, CO2 -2.74 → -2.71 Pyright net-zero (44 → 44). Extended handover suite: 893 → 895 pass. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 50 +++++------ .../sap10_calculator/rdsap/cert_to_inputs.py | 56 +++++++++++- .../rdsap/tests/test_cert_to_inputs.py | 88 +++++++++++++++++++ .../rdsap/tests/test_golden_fixtures.py | 21 +++-- 4 files changed, 183 insertions(+), 32 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index fe62b1a8..5eeb0748 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -219,21 +219,21 @@ class _CorpusExpectation: # the SH+Sec demand mismatch for electric 3/6/7 (Table 11 fraction # for codes 401/402) remains the open driver of those SAP residuals. _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( - _CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.1830, expected_cost_resid_gbp=-4.2166, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=-11.8017), - _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=+0.3849, expected_cost_resid_gbp=-8.8694, expected_co2_resid_kg=-4.3334, expected_pe_resid_kwh=-40.1603), - _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.2430, expected_cost_resid_gbp=+5.5979, expected_co2_resid_kg=+38.7768, expected_pe_resid_kwh=+392.8379), - _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+0.5980, expected_cost_resid_gbp=-13.7793, expected_co2_resid_kg=-13.8238, expected_pe_resid_kwh=-114.7533), - _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-0.7401, expected_cost_resid_gbp=+17.0523, expected_co2_resid_kg=+43.9325, expected_pe_resid_kwh=+338.5315), - _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+0.5156, expected_cost_resid_gbp=-11.8811, expected_co2_resid_kg=-10.1354, expected_pe_resid_kwh=-93.1997), - _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+0.4810, expected_cost_resid_gbp=-11.0832, expected_co2_resid_kg=-8.3964, expected_pe_resid_kwh=-83.9576), - _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+0.4286, expected_cost_resid_gbp=-9.8766, expected_co2_resid_kg=-6.4095, expected_pe_resid_kwh=-70.5744), - _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=+0.5673, expected_cost_resid_gbp=-13.0713, expected_co2_resid_kg=-12.3507, expected_pe_resid_kwh=-105.2495), - _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.0903, expected_cost_resid_gbp=-25.1234, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023), - _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+0.2902, expected_cost_resid_gbp=-6.6882, expected_co2_resid_kg=-36.6371, expected_pe_resid_kwh=-71.2875), - _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.2728, expected_cost_resid_gbp=-6.2850, expected_co2_resid_kg=-34.4292, expected_pe_resid_kwh=-67.1831), - _CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.2728, expected_cost_resid_gbp=-6.2850, expected_co2_resid_kg=-34.4292, expected_pe_resid_kwh=-67.1831), - _CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+0.2729, expected_cost_resid_gbp=-6.2879, expected_co2_resid_kg=-34.4447, expected_pe_resid_kwh=-67.2071), - _CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+0.4096, expected_cost_resid_gbp=-9.0664, expected_co2_resid_kg=-49.6654, expected_pe_resid_kwh=-92.8147), + _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.4584, expected_cost_resid_gbp=+10.5613, expected_co2_resid_kg=+47.8864, expected_pe_resid_kwh=+443.1346), + _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+0.1215, expected_cost_resid_gbp=-2.8003, expected_co2_resid_kg=+6.7227, expected_pe_resid_kwh=-5.9859), + _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-1.1759, expected_cost_resid_gbp=+27.0929, expected_co2_resid_kg=+62.7232, expected_pe_resid_kwh=+438.0333), + _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+0.1081, expected_cost_resid_gbp=-2.4918, expected_co2_resid_kg=+7.3225, expected_pe_resid_kwh=+0.1603), + _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+0.1017, expected_cost_resid_gbp=-2.3444, expected_co2_resid_kg=+7.6424, expected_pe_resid_kwh=+3.0976), + _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+0.0941, expected_cost_resid_gbp=-2.1679, expected_co2_resid_kg=+7.9230, expected_pe_resid_kwh=+6.5824), + _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=+0.1199, expected_cost_resid_gbp=-2.7611, expected_co2_resid_kg=+6.8225, expected_pe_resid_kwh=-4.5085), + _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+0.9373, expected_cost_resid_gbp=-21.5977, expected_co2_resid_kg=-34.9751, expected_pe_resid_kwh=-418.9168), + _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), + _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), + _CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), + _CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000), + _CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=-0.0108, expected_cost_resid_gbp=+0.2420, expected_co2_resid_kg=+1.3254, expected_pe_resid_kwh=+5.6974), # Slice S0380.133 unblocked 10 solid-fuel variants by routing the # Elmhurst §14.0 "Main Heating EES Code" through the new # `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` dict. Pre-slice the @@ -241,16 +241,16 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # cost / CO2 / PE all route via the correct Table 32 fuel code. # Remaining residuals are likely heating-system efficiency or # control-type gaps — separate slices. - _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+3.0805, expected_cost_resid_gbp=-70.9797, expected_co2_resid_kg=+41.5584, expected_pe_resid_kwh=-1346.0016), - _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.7637, expected_cost_resid_gbp=-40.6395, expected_co2_resid_kg=-441.0048, expected_pe_resid_kwh=-1069.2375), - _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+0.3935, expected_cost_resid_gbp=-9.0668, expected_co2_resid_kg=-86.4442, expected_pe_resid_kwh=-106.8858), - _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+0.2767, expected_cost_resid_gbp=-6.3746, expected_co2_resid_kg=-56.6651, expected_pe_resid_kwh=-41.8008), - _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.4703, expected_cost_resid_gbp=-10.8355, expected_co2_resid_kg=-11.6812, expected_pe_resid_kwh=-89.8541), - _CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+0.5361, expected_cost_resid_gbp=-12.5193, expected_co2_resid_kg=-87.4488, expected_pe_resid_kwh=-117.8475), - _CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=+0.3618, expected_cost_resid_gbp=-8.3371, expected_co2_resid_kg=+5.6990, expected_pe_resid_kwh=-89.4580), - _CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=+0.4898, expected_cost_resid_gbp=-11.2865, expected_co2_resid_kg=+1.6494, expected_pe_resid_kwh=-103.7659), - _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=+0.5249, expected_cost_resid_gbp=-12.0942, expected_co2_resid_kg=-0.2410, expected_pe_resid_kwh=-130.1413), - _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=+0.4221, expected_cost_resid_gbp=-9.7259, expected_co2_resid_kg=+5.5072, expected_pe_resid_kwh=-92.4917), + _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.7654, expected_cost_resid_gbp=-63.7195, expected_co2_resid_kg=+120.3433, expected_pe_resid_kwh=-1241.7357), + _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3086, expected_cost_resid_gbp=-30.1525, expected_co2_resid_kg=-327.2043, expected_pe_resid_kwh=-918.6312), + _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+0.0850, expected_cost_resid_gbp=-1.9582, expected_co2_resid_kg=-9.3050, expected_pe_resid_kwh=-5.7762), + _CorpusExpectation(variant='solid fuel 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='solid fuel 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), + _CorpusExpectation(variant='solid fuel 7', 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='solid fuel 8', 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='solid fuel 9', block='11a', expected_sap_resid=+0.1072, expected_cost_resid_gbp=-2.4702, expected_co2_resid_kg=+9.6917, expected_pe_resid_kwh=-5.0715), + _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=+0.1134, expected_cost_resid_gbp=-2.6121, expected_co2_resid_kg=+9.3131, expected_pe_resid_kwh=-13.9149), + _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=+0.0912, expected_cost_resid_gbp=-2.1006, expected_co2_resid_kg=+10.5547, expected_pe_resid_kwh=-0.7387), ) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 9eb0c0d6..97434aa3 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2651,6 +2651,47 @@ def _ventilation_counts(vent: Optional[SapVentilation]) -> _VentilationCounts: ) +def _rdsap_extract_fans_default( + age_band: str, habitable_rooms: int, *, is_park_home: bool, +) -> int: + """RdSAP 10 §4.1 Table 5 (PDF p.28) — extract-fans default when the + lodged number is unknown. Spec verbatim: + + Not park home: + Age bands A to E: all cases → 0 + Age bands F to G: all cases → 1 + Age bands H to M: up to 2 hab. rooms → 1 + 3 to 5 hab. rooms → 2 + 6 to 8 hab. rooms → 3 + more than 8 hab. rooms → 4 + Park home: + Age band F: all cases → 0 + Age bands G onwards: all cases → 2 + + The Elmhurst Summary §12.0 renders "No. of intermittent extract + fans: 0" as the form for *unknown*; every other §2 chimney/flue + item follows "number if known, or 0 if not present" and zero is + literal absence. Only extract fans have a non-zero age-band default + — this helper plus a `max(lodged, default)` wiring at the call + site applies the spec when the lodging is below the minimum. + """ + band = age_band.strip().upper() if age_band else "" + if is_park_home: + return 0 if band in {"A", "B", "C", "D", "E", "F"} else 2 + if band in {"A", "B", "C", "D", "E"}: + return 0 + if band in {"F", "G"}: + return 1 + # Age bands H to M scale by habitable rooms + if habitable_rooms <= 2: + return 1 + if habitable_rooms <= 5: + return 2 + if habitable_rooms <= 8: + return 3 + return 4 + + def water_heating_section_from_cert( epc: EpcPropertyData, ) -> Optional[WaterHeatingResult]: @@ -3416,6 +3457,19 @@ 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)`. + age_band = ( + epc.sap_building_parts[0].construction_age_band + if epc.sap_building_parts else "" + ) + 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) 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 {} @@ -3468,7 +3522,7 @@ def ventilation_from_cert( closed_fire_chimneys=vc.closed_fire_chimneys, solid_fuel_boiler_chimneys=vc.solid_fuel_boiler_chimneys, other_heater_chimneys=vc.other_heater_chimneys, - intermittent_fans=vc.intermittent_fans, + intermittent_fans=intermittent_fans, passive_vents=vc.passive_vents, flueless_gas_fires=vc.flueless_gas_fires, has_suspended_timber_floor=eff_has_susp, diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 3bf835fe..3000726f 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -46,6 +46,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _is_off_peak_meter, # pyright: ignore[reportPrivateUsage] _main_floor_u_value, # pyright: ignore[reportPrivateUsage] _other_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] + _rdsap_extract_fans_default, # pyright: ignore[reportPrivateUsage] _pv_overshading_factor, # pyright: ignore[reportPrivateUsage] _pv_pitch_deg, # pyright: ignore[reportPrivateUsage] _responsiveness, # pyright: ignore[reportPrivateUsage] @@ -611,6 +612,93 @@ def test_main_floor_u_value_routes_suspended_timber_via_floor_construction_type( assert sealed is False +def test_rdsap_extract_fans_default_per_table_5() -> None: + # Arrange — RdSAP 10 §4.1 Table 5 (PDF p.28) "Extract fans" default + # when the lodged number is unknown. The Summary §12.0 "No. of + # intermittent extract fans = 0" is the Elmhurst-rendered form of + # "unknown" (lodging form has explicit "if known" vs. "unknown" + # options); every other §2 chimney/flue line item follows "number if + # known, or 0 if not present" and the cascade trusts the lodged + # value verbatim. Only extract fans have a non-zero age-band default + # — A-E = 0, F-G = 1, H-M scales 1..4 by habitable rooms. + # + # Table 5 verbatim (Not park home): + # Age bands A to E: all cases → 0 + # Age bands F to G: all cases → 1 + # Age bands H to M: up to 2 hab. rooms → 1 + # 3 to 5 hab. rooms → 2 + # 6 to 8 hab. rooms → 3 + # more than 8 hab. rooms → 4 + # Park home: + # Age band F: all cases → 0 + # Age bands G onwards: all cases → 2 + + # Act / Assert — exhaustive Table 5 coverage + for age in 'ABCDE': + assert _rdsap_extract_fans_default(age, 4, is_park_home=False) == 0 + for age in 'FG': + for hr in (1, 2, 3, 4, 5, 6, 8, 10): + assert _rdsap_extract_fans_default(age, hr, is_park_home=False) == 1 + for age in 'HIJKLM': + assert _rdsap_extract_fans_default(age, 1, is_park_home=False) == 1 + assert _rdsap_extract_fans_default(age, 2, is_park_home=False) == 1 + assert _rdsap_extract_fans_default(age, 3, is_park_home=False) == 2 + assert _rdsap_extract_fans_default(age, 5, is_park_home=False) == 2 + assert _rdsap_extract_fans_default(age, 6, is_park_home=False) == 3 + assert _rdsap_extract_fans_default(age, 8, is_park_home=False) == 3 + assert _rdsap_extract_fans_default(age, 9, is_park_home=False) == 4 + assert _rdsap_extract_fans_default(age, 12, is_park_home=False) == 4 + # Park home rows + assert _rdsap_extract_fans_default('F', 4, is_park_home=True) == 0 + for age in 'GHIJKLM': + assert _rdsap_extract_fans_default(age, 4, is_park_home=True) == 2 + + +def test_ventilation_from_cert_applies_table_5_default_when_lodged_zero() -> None: + # Arrange — corpus property 001431 (age G, 4 habitable rooms, semi- + # detached) lodges Summary §12.0 "No. of intermittent extract fans + # = 0" but the Elmhurst worksheet (7a) uses 1 × 10 = 10 m³/h. Per + # RdSAP 10 §4.1 Table 5 (PDF p.28) age G + not-park-home defaults + # to 1 fan when the lodged count is unknown. Pre-slice the cascade + # treated lodged 0 as "explicitly zero" and skipped the default — + # under-counting (8) by 0.044 ACH cohort-wide (= ~1.2% HLC deficit + # = ~0.3 SAP per variant across 25 cascade-OK cohort variants). + base = _typical_semi_detached_epc() + # Override age band on the building part to G; 4 habitable rooms is + # already the default of `_typical_semi_detached_epc`. + age_g_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='G', + ) + epc_age_g = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + region_code="1", + sap_building_parts=[age_g_part], + sap_windows=base.sap_windows, + sap_heating=base.sap_heating, + sap_ventilation=SapVentilation(extract_fans_count=0), + ) + + # Act + v = ventilation_from_cert(epc_age_g) + + # Assert — (8) openings ACH must include 1 fan × 10 m³/h ÷ volume. + # Volume = TFA × 2.5 m storey height × 2 storeys; use the cascade's + # own dim.volume_m3 by reading it back. + from domain.sap10_calculator.rdsap.cert_to_inputs import dimensions_from_cert + vol = dimensions_from_cert(epc_age_g).volume_m3 + expected_openings_ach = 10.0 / vol # one fan at Table 5 default + assert abs(v.openings_ach - expected_openings_ach) <= 1e-6, ( + f"openings ACH {v.openings_ach:.6f} should equal " + f"10 / volume = {expected_openings_ach:.6f} per RdSAP 10 " + f"§4.1 Table 5 age-G default of 1 fan" + ) + + 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/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index 53f1636a..435df408 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -75,8 +75,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0240-0200-5706-2365-8010", actual_sap=73, expected_sap_resid=-1, - expected_pe_resid_kwh_per_m2=+2.1847, - expected_co2_resid_tonnes_per_yr=+0.1333, + expected_pe_resid_kwh_per_m2=+5.8007, + expected_co2_resid_tonnes_per_yr=+0.3173, notes=( "Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + " "RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_" @@ -108,7 +108,12 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "→ -1) and raises PE +1.0211 → +2.5225, CO2 +0.1118 → " "+0.1395. Residual remains net-positive — the 100 kWh " "spec figure may need refinement when the dual-main " - "main_heating_fraction split lands (slice candidate)." + "main_heating_fraction split lands (slice candidate). " + "Slice S0380.151 wired RdSAP 10 §4.1 Table 5 (PDF p.28) " + "extract-fans default (age J, 4 hab rooms → 2 fans). " + "Cascade ventilation HLC rises ~0.07 ACH × volume → SH " + "demand rises proportionally; PE +2.5225 → +5.8007, CO2 " + "+0.1395 → +0.3173. SAP integer unchanged at 72." ), ), _GoldenExpectation( @@ -143,8 +148,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0390-2954-3640-2196-4175", actual_sap=60, expected_sap_resid=+7, - expected_pe_resid_kwh_per_m2=-28.2719, - expected_co2_resid_tonnes_per_yr=-2.7404, + expected_pe_resid_kwh_per_m2=-27.9745, + expected_co2_resid_tonnes_per_yr=-2.7134, notes=( "Detached, TFA 360, age F, Firebird oil combi PCDF 9005 " "(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + " @@ -171,7 +176,11 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "for the oil combi Main 1 — cascade pumps_fans +100 kWh/yr, " "PE residual -28.5027 → -28.0830 (closer to zero), CO2 " "-2.7481 → -2.7342 (closer to zero). Remaining residual is " - "a fabric or different §4 driver — follow-up slice candidate." + "a fabric or different §4 driver — follow-up slice candidate. " + "Slice S0380.151 wired RdSAP 10 §4.1 Table 5 (PDF p.28) " + "extract-fans default (age F → 1 fan). Cascade ventilation " + "HLC rises ~0.03 ACH × volume; PE -28.0830 → -27.9745 " + "(closer to zero), CO2 -2.7342 → -2.7134." ), ), _GoldenExpectation(