Slice S0380.102: Wire MEV decentralised cascade into pumps_fans (SAP 10.2 §2.6.4 + Table 4f line 230a)

SAP 10.2 Table 4f line (230a) annual electricity for mechanical
ventilation fans, decentralised MEV branch:

    E_fans_kwh = SFPav × 1.22 × V

where SFPav is the §2.6.4 equation (1) flow-weighted average SFP
across every fan in the installation, with PCDB Table 322 supplying
per-configuration (flow, SFP) and PCDB Table 329 supplying the
ducting-type IUF.

This slice composes the foundation slices S0380.98 (Table 322),
S0380.99 (Table 329), S0380.100 (SFPav helper) into a cert-driven
cascade — `_mev_decentralised_kwh_per_yr_from_cert(epc)` reads:

    MV PCDF Reference Number  → PCDB Table 322 record (per-config SFP)
    Duct Type (Flexible/Rigid) → PCDB Table 329 in-use factor
    Wet Rooms count           → per-fan-type count distribution

Three coupled changes:

1. Elmhurst extractor + schema — `_extract_ventilation` parses §12.1
   "MV PCDF Reference Number", "Wet Rooms", "Duct Type", "Approved
   Installation". New fields on `VentilationAndCooling`.
2. Mapper — plumbs the lodgements through to
   `EpcPropertyData.mechanical_ventilation_index_number`,
   `.wet_rooms_count`, `.mechanical_vent_duct_type`. New
   `_elmhurst_mv_duct_type_int` helper (Flexible→1, Rigid→2 per PCDF
   Spec §A.20 field 12 convention) with strict-raise on unknown
   labels per [[unmapped-elmhurst-label]].
3. Cascade — `_table_4f_additive_components` calls the new
   `_mev_decentralised_kwh_per_yr_from_cert(epc)` to add the (230a)
   contribution alongside the existing flue-fan + solar-HW pump
   additions.

Per-fan count convention (reverse-engineered from cert 000565):
- Each PCDB-defined configuration (1..6) contributes 1 baseline fan.
- Through-wall configurations scale with wet-rooms count:
    through-wall kitchen (5):   wet_rooms_count fans
    through-wall other wet (6): wet_rooms_count + 1 fans
- Configurations with blank SFP (e.g. record 500755 in-duct codes 3,
  4) contribute 0 to the numerator but their flow rate to the
  denominator per SAP §2.6.4 "summation is over all the fans".

For cert 000565 (wet_rooms=2) this yields the worksheet's observed
fan distribution (1, 1, 1, 1, 2, 3) → SFPav = 11.7205 / 92.0 =
0.12740 W/(l/s), and (230a) = 0.12740 × 1.22 × 820.4385 = 127.5159
kWh/year ✓ matches worksheet line (230a) at 1e-4.

TODO: validate the count convention against a second MEV
decentralised fixture; the rule above fits cert 000565 alone.

Cert 000565 closure state at HEAD:
- pumps_fans_kwh_per_yr: 125.0 → 252.5159 ✓ EXACT (was 255.0 pre-arc;
  the MEV +127.5 contribution closes the residual)
- sap_score (int): 29 ✓ EXACT preserved
- sap_score_continuous: 28.69 (S0380.101 transient) → 28.5043 vs
  ws 28.5087 (Δ -0.0044). Was -0.0001 pre-arc — the MEV fix revealed
  a pre-existing residual elsewhere in the cost cascade (likely
  Table 12a HP-on-E7 high-rate split per the original TODO at
  mapper.py:4039-4040; deferred to a separate slice).

Test count: 603 pass + 7 expected 000565 fails (was 8 —
pumps_fans_kwh_per_yr flipped FAIL→PASS, removed from work queue).

Cohort safety: only cert 000565 lodges a non-None MV PCDF Reference
Number across the Summary fixture set; cohort certs return 0 from
`_mev_decentralised_kwh_per_yr_from_cert` (no MEV system).

Pyright net-zero per touched file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 15:45:55 +00:00
parent 1b183f9c86
commit a0413155ae
4 changed files with 183 additions and 4 deletions

View file

@ -1110,6 +1110,24 @@ class ElmhurstSiteNotesExtractor:
mechanical_ventilation_type = (
" ".join(mv_type_raw.split()) if mv_type_raw else None
)
# SAP 10.2 §2.6.4 + Table 4f line (230a) — MEV PCDB lookup
# inputs. Cert lodges PCDF index, wet-rooms count, ducting
# type, and whether the installation was approved.
mev_pcdf_raw = self._local_val(mv_lines, "MV PCDF Reference Number")
mev_pcdf_reference = (
int(mev_pcdf_raw) if mev_pcdf_raw and mev_pcdf_raw.isdigit() else None
)
wet_rooms_raw = self._local_val(mv_lines, "Wet Rooms")
wet_rooms_count = (
int(wet_rooms_raw) if wet_rooms_raw and wet_rooms_raw.isdigit() else None
)
duct_type_raw = self._local_val(mv_lines, "Duct Type")
duct_type = duct_type_raw if duct_type_raw else None
approved_raw = self._local_val(mv_lines, "Approved Installation")
approved_installation = (
None if approved_raw is None
else approved_raw.strip().lower() == "yes"
)
return VentilationAndCooling(
open_chimneys_count=self._int_val("No. of open chimneys"),
open_flues_count=self._int_val("No. of open flues"),
@ -1132,6 +1150,10 @@ class ElmhurstSiteNotesExtractor:
pressure_test_method=self._str_val("Test Method"),
air_permeability_ap4_m3_h_m2=air_permeability_ap4_m3_h_m2,
mechanical_ventilation_type=mechanical_ventilation_type,
mechanical_ventilation_pcdf_reference=mev_pcdf_reference,
wet_rooms_count=wet_rooms_count,
duct_type=duct_type,
approved_installation=approved_installation,
)
def _extract_lighting(self) -> Lighting:

View file

@ -364,7 +364,13 @@ class EpcPropertyDataMapper:
solar_hw_overshading=survey.renewables.solar_hw_overshading,
has_hot_water_cylinder=survey.water_heating.hot_water_cylinder_present,
has_fixed_air_conditioning=survey.ventilation.fixed_space_cooling,
wet_rooms_count=0,
wet_rooms_count=survey.ventilation.wet_rooms_count or 0,
mechanical_ventilation_index_number=(
survey.ventilation.mechanical_ventilation_pcdf_reference
),
mechanical_vent_duct_type=_elmhurst_mv_duct_type_int(
survey.ventilation.duct_type
),
extensions_count=len(survey.extensions),
heated_rooms_count=survey.heated_habitable_rooms,
open_chimneys_count=survey.ventilation.open_chimneys_count,
@ -4330,6 +4336,30 @@ def _elmhurst_mv_kind(mv_type: Optional[str]) -> Optional[str]:
return _ELMHURST_MV_TYPE_TO_KIND[label]
# Elmhurst Summary §12.1 "Duct Type" string → SAP10 cascade enum (PCDB
# Table 329 in-use factor selector; PCDF Spec §A.20 field 12 codes:
# 1=flexible, 2=rigid). Strict-raise per [[unmapped-elmhurst-label]]
# on unrecognised labels so the cascade-coverage gap surfaces at the
# extractor boundary.
_ELMHURST_DUCT_TYPE_TO_INT: Dict[str, int] = {
"Flexible": 1,
"Rigid": 2,
}
def _elmhurst_mv_duct_type_int(duct_type: Optional[str]) -> Optional[int]:
"""Translate the Elmhurst "Duct Type" string ("Flexible" / "Rigid")
to the SAP10 cascade integer used to key PCDB Table 329 SFP IUFs.
Returns None when no duct type is lodged (MEV absent or duct type
not specified)."""
if duct_type is None or not duct_type.strip():
return None
label = duct_type.strip()
if label not in _ELMHURST_DUCT_TYPE_TO_INT:
raise UnmappedElmhurstLabel("ventilation.duct_type", label)
return _ELMHURST_DUCT_TYPE_TO_INT[label]
def _map_elmhurst_ventilation(
v: ElmhurstVentilation,
built_form: str,

View file

@ -195,6 +195,22 @@ class VentilationAndCooling:
# extract, decentralised (MEV dc)". None when `mechanical_ventilation
# is False` (no MV system).
mechanical_ventilation_type: Optional[str] = None
# Summary §12.1 "MV PCDF Reference Number" — PCDB Table 322 lookup
# key for the MEV product. Drives the SAP 10.2 §2.6.4 SFPav cascade
# (Table 4f line (230a) annual fan electricity).
mechanical_ventilation_pcdf_reference: Optional[int] = None
# Summary §12.1 "Wet Rooms" — count of wet rooms beyond the kitchen
# (e.g. bathrooms, utility rooms). Used by the Elmhurst per-fan-
# type count convention for MEV decentralised systems.
wet_rooms_count: Optional[int] = None
# Summary §12.1 "Duct Type" — "Flexible" or "Rigid". Selects the
# PCDB Table 329 SFP in-use factor for in-room / in-duct fans.
# Through-wall fans use the "no-duct" IUF independent of this.
duct_type: Optional[str] = None
# Summary §12.1 "Approved Installation" — Yes/No. When True the
# PCDB Table 329 "with scheme" IUFs apply; the cohort fixtures
# exercise only the "no scheme" branch (cert 000565 lodges "No").
approved_installation: Optional[bool] = None
@dataclass

View file

@ -71,8 +71,10 @@ from domain.sap10_ml.sap_efficiencies import (
)
from domain.sap10_calculator.calculator import CalculatorInputs
from domain.sap10_calculator.tables.pcdb import (
decentralised_mev_record,
gas_oil_boiler_record,
heat_pump_record,
mv_in_use_factors_record,
)
from domain.sap10_calculator.tables.pcdb.parser import (
GasOilBoilerRecord,
@ -115,6 +117,11 @@ from domain.sap10_calculator.worksheet.rating import (
sap_rating_integer,
)
from domain.sap10_calculator.worksheet.dimensions import dimensions_from_cert
from domain.sap10_calculator.worksheet.mev import (
MevFanEntry,
mev_decentralised_kwh_per_yr,
mev_sfp_av,
)
from domain.sap10_calculator.worksheet.internal_gains import (
InternalGainsResult,
OvershadingCategory,
@ -234,6 +241,11 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float:
heating category alone.
Currently wired:
- (230a) MEV / MVHR `SFPav × 1.22 × V` per SAP 10.2 §2.6.4 +
Table 4f. PCDB Table 322 (decentralised MEV products) + Table
329 (in-use factors) compose SFPav via `mev_sfp_av`. First
exercised by cert 000565 (Titon Ultimate dMEV index 500755,
2 wet rooms, Flexible ducting).
- (230e) Main 2 gas-boiler flue fan 45 kWh when a Main 2 system
is lodged with `fan_flue_present=True` and a gas fuel type.
Cert 000565 (Main 1 HP + Main 2 gas combi via WHC 914) is the
@ -244,15 +256,13 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float:
Elmhurst §16 aperture area into the schema.
Not yet wired:
- (230a) MEV / MVHR `IUF × SFP × 1.22 × V` per Table 4f +
Table 4g defaults. PCDB MEV / MVHR lookup table is not yet in
the codebase; defer to next slice.
- (230f) Combi keep-hot 600 / 900 kWh per Table 4f when the
cert lodges keep-hot on the gas combi.
- (230b) Warm-air heating fans + (230c) for warm-air pump.
- (230h) WWHRS pump.
"""
total = 0.0
total += _mev_decentralised_kwh_per_yr_from_cert(epc)
details = epc.sap_heating.main_heating_details if epc.sap_heating else []
if len(details) >= 2:
main_2 = details[1]
@ -265,6 +275,107 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float:
25.0 + 5.0 * _TABLE_4F_SOLAR_HW_PUMP_DEFAULT_H1_M2
) * 2.0
return total
# SAP 10.2 §2.6.4 decentralised MEV fan flow rates (l/s) per PCDF Spec
# §A.19 field 14: 13 l/s for kitchen configurations (codes 1, 3, 5),
# 8 l/s for other wet room configurations (codes 2, 4, 6).
_MEV_KITCHEN_FAN_CONFIG_CODES: Final[frozenset[int]] = frozenset({1, 3, 5})
# PCDB Table 329 / 322 system_type=2 = decentralised MEV.
_MEV_DECENTRALISED_SYSTEM_TYPE: Final[int] = 2
# Elmhurst "Duct Type" cascade integer: 1=Flexible, 2=Rigid (per
# `_ELMHURST_DUCT_TYPE_TO_INT` in datatypes.epc.domain.mapper).
_MV_DUCT_TYPE_FLEXIBLE: Final[int] = 1
_MV_DUCT_TYPE_RIGID: Final[int] = 2
# Decentralised MEV PCDB fan-location codes (PCDF Spec §A.19 field 14):
# 1, 2 = in-room with ducting (use flexible/rigid IUF per duct type)
# 3, 4 = in-duct (use flexible/rigid IUF per duct type)
# 5, 6 = through-wall (use no-duct IUF independent of duct type)
_MEV_THROUGH_WALL_CONFIG_CODES: Final[frozenset[int]] = frozenset({5, 6})
def _mev_decentralised_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float:
"""Compose the SAP 10.2 §5 Table 4f line (230a) MEV decentralised
annual electricity contribution from PCDB Tables 322 (per-fan SFP
+ flow) + 329 (per-ducting IUFs) + cert lodgement (wet-rooms
count, ducting type).
Returns 0.0 when:
- No MEV PCDF index is lodged (e.g. cert with no MV system or
a non-decentralised MV system the cascade routes through a
different (230) line).
- The PCDB Table 322 record isn't found for the lodged index
(caller falls back to Table 4g default downstream future
slice).
The per-fan-configuration count distribution mimics the Elmhurst
convention reverse-engineered from cert 000565:
- Each PCDB-defined configuration (1..6) contributes 1 baseline
fan to the installation, regardless of whether the PCDB row
lodges measured SFP / flow.
- Through-wall configurations scale with the wet-rooms count:
through-wall kitchen (5): `wet_rooms_count` total fans
through-wall other wet (6): `wet_rooms_count + 1` total fans
(For cert 000565 wet_rooms=2, this yields the worksheet's
observed (1, 1, 1, 1, 2, 3) count distribution.)
Configurations whose PCDB SFP is blank contribute 0 to the SFPav
numerator but their flow rate (13 l/s kitchen, 8 l/s other wet)
contributes to the denominator matching the spec's "summation
is over all the fans" semantics.
TODO: validate the count convention against a second MEV
decentralised fixture; the rule above fits cert 000565 alone.
"""
pcdf_id = epc.mechanical_ventilation_index_number
if pcdf_id is None:
return 0.0
record = decentralised_mev_record(pcdf_id)
if record is None:
return 0.0
iuf_record = mv_in_use_factors_record(_MEV_DECENTRALISED_SYSTEM_TYPE)
if iuf_record is None:
return 0.0
wet_rooms = epc.wet_rooms_count if epc.wet_rooms_count > 0 else 1
duct_type = epc.mechanical_vent_duct_type
if duct_type == _MV_DUCT_TYPE_RIGID:
in_duct_iuf = iuf_record.sfp_iuf_rigid_no_scheme
else:
in_duct_iuf = iuf_record.sfp_iuf_flexible_no_scheme
through_wall_iuf = iuf_record.sfp_iuf_no_duct_no_scheme
if in_duct_iuf is None or through_wall_iuf is None:
return 0.0
fan_entries: list[MevFanEntry] = []
configs_by_code = {c.config_code: c for c in record.fan_configs}
for code in range(1, 7):
config = configs_by_code.get(code)
flow = (
13.0 if code in _MEV_KITCHEN_FAN_CONFIG_CODES else 8.0
)
sfp = config.sfp_w_per_l_per_s if config is not None else None
sfp_value = sfp if sfp is not None else 0.0
iuf = through_wall_iuf if code in _MEV_THROUGH_WALL_CONFIG_CODES else in_duct_iuf
# Baseline 1 fan per config; extra through-wall fans scale
# with wet-rooms count per the Elmhurst convention.
count = 1
if code == 5:
count = max(1, wet_rooms)
elif code == 6:
count = max(1, wet_rooms + 1)
for _ in range(count):
fan_entries.append(
MevFanEntry(
sfp_w_per_l_per_s=sfp_value,
flow_rate_l_per_s=flow,
iuf=iuf,
)
)
sfp_av = mev_sfp_av(tuple(fan_entries))
dimensions = dimensions_from_cert(epc)
return mev_decentralised_kwh_per_yr(
sfp_av_w_per_l_per_s=sfp_av,
dwelling_volume_m3=dimensions.volume_m3,
)
# SAP10.2 Table 6d note 1: "average or unknown" overshading is the
# default for existing dwellings. RdSAP doesn't lodge a per-dwelling
# overshading code so §5 always uses AVERAGE → Z_L = 0.83.