Slice S0380.100: MEV SFPav + (230a) cascade helpers (SAP 10.2 §2.6.4 + Table 4f)

SAP 10.2 specification (14-03-2025) §2.6.4 (PDF p.16):

  "In the case of decentralised MEV the specific fan power is provided
   for each fan and an average value is calculated for the purposes of
   the SAP calculations. There are two types of fan, one for kitchens
   and one for other wet rooms, and three types of fan location (in
   room with ducting, in duct, or through wall with no duct). [...]
   The average SFP, including adjustments for the in-use factors, is
   given by:

       SFPav = Σ(SFP_j × FR_j × IUF_j) / Σ(FR_j)             (1)

   where the summation is over all the fans, j represents each
   individual fan, FR is the flow rate which is 13 l/s for kitchens
   and 8 l/s for all other wet rooms, and IUF is the applicable
   in-use factor."

And SAP 10.2 §5 Table 4f line (230a):

  "Annual electricity for mechanical ventilation fans (kWh/year) =
   IUF × SFP × 1.22 × V"

This slice lands the two pure-function cascade primitives:

  mev_sfp_av(fan_entries) -> float        # equation (1)
  mev_decentralised_kwh_per_yr(*, sfp_av, V) -> float   # (230a)

`MevFanEntry` carries the per-fan resolved (SFP_w_per_l_per_s, flow_l_
per_s, IUF) triple. Callers (PCDB Table 322 + Table 329 + cert
lodgement of duct type) compose the entries upstream; the cascade
helper does no PCDB resolution itself.

Cert 000565 worksheet line (230a) pinned at 1e-4:
  Σ FR = 92.0 l/s  (matches worksheet "total flow")
  Σ SFP×FR×IUF = 11.7205 W  (matches worksheet "total watage")
  SFPav = 11.7205 / 92.0 = 0.1274 W/(l/s) ✓ vs ws 0.1274
  (230a) = 0.1274 × 1.22 × 820.4385 = 127.5159 ✓ vs ws 127.5159

Pure-function helpers; no cascade integration yet. Next slice
S0380.101 wires HP category mapper; S0380.102 wires cert→inputs
to invoke the cascade. 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:28:59 +00:00 committed by Jun-te Kim
parent 518471fa80
commit 61c0276599
2 changed files with 223 additions and 0 deletions

View file

@ -0,0 +1,86 @@
"""SAP 10.2 §2.6.4 — Mechanical Extract Ventilation (MEV) electricity.
This module implements the §2.6.4 "Specific fan power" cascade for
decentralised MEV systems plus the §5 Table 4f line (230a) annual
electricity formula. Centralised MEV / MVHR / balanced systems share
the same (230a) shape but pick their per-system SFP differently
deferred until a fixture exercises them.
Spec references (SAP 10.2 14-03-2025):
- §2.6.4 page 16 equation (1) average SFP for decentralised MEV
- Table 4f page 174 line (230a) `IUF × SFP × 1.22 × V` annual kWh
- Table 4g page 176 default SFP (0.8 W/(l/s)) for unknown PCDB
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
# SAP 10.2 §5 Table 4f line (230a): annual kWh = SFP × 1.22 × V.
# The "× 1.22" coefficient absorbs continuous-run hours × 0.5 ach
# (MEV throughput per §2.6.5) × air-density-and-unit conversion.
_MEV_KWH_COEFF: Final[float] = 1.22
@dataclass(frozen=True)
class MevFanEntry:
"""One fan in a decentralised MEV installation, with the IUF
already resolved per the system's lodged ducting type.
`sfp_w_per_l_per_s` is the PCDB-lodged Specific Fan Power for the
fan's (location × wet-room-type) configuration; `flow_rate_l_per_s`
is the SAP standard flow per wet-room-type (13 l/s kitchens, 8 l/s
other wet rooms see SAP 10.2 §2.6.4); `iuf` is the in-use factor
from PCDB Table 329 for the fan's location (in-room with ducting →
flexible/rigid IUF; through-wall no-duct IUF).
"""
sfp_w_per_l_per_s: float
flow_rate_l_per_s: float
iuf: float
def mev_sfp_av(fan_entries: tuple[MevFanEntry, ...]) -> float:
"""SAP 10.2 §2.6.4 equation (1): average SFP for a decentralised
MEV system.
SFPav = Σ(SFP_j × FR_j × IUF_j) / Σ(FR_j)
The summation is over every fan in the installation (kitchen +
each wet room) each fan carries its own (SFP, FR, IUF) per the
spec's `MevFanEntry` resolution.
Returns 0.0 when no fans are lodged (empty installation caller
should not invoke `mev_decentralised_kwh_per_yr` in that case).
"""
if not fan_entries:
return 0.0
weighted_sum = sum(
entry.sfp_w_per_l_per_s * entry.flow_rate_l_per_s * entry.iuf
for entry in fan_entries
)
flow_sum = sum(entry.flow_rate_l_per_s for entry in fan_entries)
if flow_sum == 0.0:
return 0.0
return weighted_sum / flow_sum
def mev_decentralised_kwh_per_yr(
*,
sfp_av_w_per_l_per_s: float,
dwelling_volume_m3: float,
) -> float:
"""SAP 10.2 §5 Table 4f line (230a) annual electricity for the
mechanical ventilation fans of a decentralised MEV system:
E_fans_kwh = SFPav × 1.22 × V
`sfp_av_w_per_l_per_s` is the IUF-adjusted average SFP from
`mev_sfp_av` (i.e. the IUFs are already folded in via the
equation (1) numerator). `dwelling_volume_m3` is worksheet line
(5) the sum of (3a)+(3b)+...+(3n) across every storey of every
building part.
"""
return sfp_av_w_per_l_per_s * _MEV_KWH_COEFF * dwelling_volume_m3

View file

@ -0,0 +1,137 @@
"""Tests for SAP 10.2 §2.6.4 decentralised MEV cascade helpers.
Pin both `mev_sfp_av` (equation (1)) and `mev_decentralised_kwh_per_yr`
(line (230a)) against the U985-0001-000565 worksheet's documented MEV
breakdown:
"MEVDecentralised, Database: total watage = 11.7205,
total flow = 92.0000,
SFP = 0.1274"
mechanical ventilation fans (SFP = 0.1274) 127.5159 (230a)
Cert 000565 lodges PCDB index 500755 (Titon Ultimate dMEV); the
fan installation is 1 in-room kitchen + 1 in-room other-wet-room
+ 2 through-wall kitchens + 3 through-wall other-wet-rooms, with
"Duct Type: Flexible" and "Approved Installation: No".
References:
- SAP 10.2 specification (14-03-2025) §2.6.4 + §5 Table 4f
- U985-0001-000565.pdf lines 558-559 (MEV electricity)
"""
from __future__ import annotations
from domain.sap10_calculator.worksheet.mev import (
MevFanEntry,
mev_decentralised_kwh_per_yr,
mev_sfp_av,
)
# Cert 000565 lodged fan installation per the worksheet line 122-127
# breakdown:
# 1 × in-room kitchen (SFP 0.15, FR 13, IUF 1.45 — flexible)
# 1 × in-room other wet (SFP 0.15, FR 8, IUF 1.45 — flexible)
# 1 × in-duct kitchen (SFP blank — Table 322 record 500755
# doesn't lodge this configuration;
# contributes 0 to the numerator but
# FR to the denominator per SAP §2.6.4
# "summation is over all the fans")
# 1 × in-duct other wet (SFP blank, FR 8)
# 2 × through-wall kitchen (SFP 0.11, FR 13, IUF 1.15 — no-duct)
# 3 × through-wall other wet (SFP 0.14, FR 8, IUF 1.15 — no-duct)
#
# Σ FR = 13 + 8 + 13 + 8 + 26 + 24 = 92 l/s (worksheet total_flow)
# Σ SFP×FR×IUF = 2.8275 + 1.74 + 0 + 0 + 3.289 + 3.864 = 11.7205 W
# (worksheet total_watage)
_CERT_000565_FAN_ENTRIES: tuple[MevFanEntry, ...] = (
# 1 × in-room kitchen
MevFanEntry(sfp_w_per_l_per_s=0.15, flow_rate_l_per_s=13.0, iuf=1.45),
# 1 × in-room other wet room
MevFanEntry(sfp_w_per_l_per_s=0.15, flow_rate_l_per_s=8.0, iuf=1.45),
# 1 × in-duct kitchen — SFP blank (PCDB-untested for this configuration);
# contributes only flow to the SFPav denominator.
MevFanEntry(sfp_w_per_l_per_s=0.0, flow_rate_l_per_s=13.0, iuf=1.45),
# 1 × in-duct other wet room — SFP blank
MevFanEntry(sfp_w_per_l_per_s=0.0, flow_rate_l_per_s=8.0, iuf=1.45),
# 2 × through-wall kitchen
MevFanEntry(sfp_w_per_l_per_s=0.11, flow_rate_l_per_s=13.0, iuf=1.15),
MevFanEntry(sfp_w_per_l_per_s=0.11, flow_rate_l_per_s=13.0, iuf=1.15),
# 3 × through-wall other wet room
MevFanEntry(sfp_w_per_l_per_s=0.14, flow_rate_l_per_s=8.0, iuf=1.15),
MevFanEntry(sfp_w_per_l_per_s=0.14, flow_rate_l_per_s=8.0, iuf=1.15),
MevFanEntry(sfp_w_per_l_per_s=0.14, flow_rate_l_per_s=8.0, iuf=1.15),
)
# Cert 000565 dwelling volume from U985-0001-000565 line (5):
# (3a)+(3b)+...+(3n) = 820.4385 m³
_CERT_000565_DWELLING_VOLUME_M3: float = 820.4385
def test_mev_sfp_av_for_cert_000565_matches_worksheet_0p1274() -> None:
"""SAP 10.2 §2.6.4 equation (1):
SFPav = Σ(SFP × FR × IUF) / Σ(FR)
Worksheet line 558: "total watage = 11.7205, total flow = 92.0000,
SFP = 0.1274". Closed-form check:
numerator = 11.7205 W
denominator = 92.0 l/s
SFPav = 0.127397826... W/(l/s)
"""
# Arrange / Act
sfp_av = mev_sfp_av(_CERT_000565_FAN_ENTRIES)
# Assert — 1e-4 strict floor per [[feedback-zero-error-strict]].
assert abs(sfp_av - 0.1274) <= 1e-4
def test_mev_decentralised_kwh_per_yr_for_cert_000565_matches_worksheet_127p5159() -> None:
"""SAP 10.2 §5 Table 4f line (230a):
E_fans_kwh = SFPav × 1.22 × V
Worksheet line 559: 127.5159 kWh/year. Closed-form:
0.127397826 × 1.22 × 820.4385 = 127.5163 127.5159 (worksheet
rounds the printed SFP to 4 d.p. before display; the underlying
high-precision SFP from the database yields the exact figure).
"""
# Arrange
sfp_av = mev_sfp_av(_CERT_000565_FAN_ENTRIES)
# Act
kwh = mev_decentralised_kwh_per_yr(
sfp_av_w_per_l_per_s=sfp_av,
dwelling_volume_m3=_CERT_000565_DWELLING_VOLUME_M3,
)
# Assert — strict 1e-4 against worksheet line (230a).
assert abs(kwh - 127.5159) <= 1e-4
def test_mev_sfp_av_returns_zero_for_empty_installation() -> None:
"""No fans lodged → no MEV electricity contribution. Caller should
not invoke `mev_decentralised_kwh_per_yr` in that case, but the
primitive returns 0.0 defensively for callers that want a single
code path."""
# Arrange / Act
sfp_av = mev_sfp_av(())
# Assert
assert sfp_av == 0.0
def test_mev_decentralised_kwh_per_yr_scales_linearly_with_volume() -> None:
"""The line (230a) formula is linear in dwelling volume. Doubling
V doubles the annual fan electricity; this pin guards against any
future regression in the per-unit coefficient (currently 1.22)."""
# Arrange
sfp = 0.25
# Act
kwh_at_100 = mev_decentralised_kwh_per_yr(
sfp_av_w_per_l_per_s=sfp, dwelling_volume_m3=100.0,
)
kwh_at_200 = mev_decentralised_kwh_per_yr(
sfp_av_w_per_l_per_s=sfp, dwelling_volume_m3=200.0,
)
# Assert
assert abs(kwh_at_100 - 30.5) <= 1e-4 # 0.25 × 1.22 × 100
assert abs(kwh_at_200 - 2 * kwh_at_100) <= 1e-4