fix(ventilation): use lodged extract-fan count when known, not max(lodged, age default) (RdSAP 10 §4.1 Table 5, PDF p.28)

Table 5 reads "Number of extract fans if known; if number is unknown:
[age-band default]" — the default is an UNKNOWN-fallback, NOT a floor. The
cascade applied `max(lodged, table_5_default)`, flooring a genuinely-lodged
count up to the age-band minimum: e.g. an age H-M dwelling lodging 2 extract
fans was billed at the 6-8-room default of 3, over-counting ventilation line
(8) and the heat-loss coefficient. Fixed to `lodged if lodged > 0 else
default` (a lodged 0 is the Elmhurst/RdSAP "unknown" form → default; any
positive count is taken literally).

Surfaced by Khalim's Elmhurst stress worksheet (simulated case 46): this was
its last ventilation residual — our Jan effective ACH 9.14 -> 9.0748 (exact
match to the accredited worksheet), SAP 29 -> 30 = Elmhurst, cost £1496 vs
£1493. Corpus IMPROVED: within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815 (the
max-flooring over-counted ventilation on every cert lodging fans below its
age default). Floor ratcheted 0.71 -> 0.72. pyright not installed locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-21 06:22:26 +00:00
parent 34e52a893c
commit a9632937d5
3 changed files with 57 additions and 6 deletions

View file

@ -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 {}

View file

@ -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

View file

@ -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