feat(ventilation): credit MVHR (24a) heat recovery via PCDB Table 323 + 329

MVHR (24a) heat-recovery support, part 2: the mapper + cascade wiring.

Both source paths now resolve balanced whole-house MV with heat recovery
to the MVHR kind:
- gov-API: `_API_MECHANICAL_VENTILATION_TO_KIND` 4 → "MVHR" (was None /
  treated as natural — under-stated ventilation heat loss, over-rating).
- Elmhurst Summary: `_ELMHURST_MV_TYPE_TO_KIND` "Mechanical ventilation
  with heat recovery (MVHR)" → "MVHR" (was UnmappedElmhurstLabel, which
  blocked the whole Summary for MVHR dwellings).

cert_to_inputs resolves the in-use heat-recovery efficiency + SFP for an
MVHR cert (`_mvhr_system_values`): pick the PCDB Table 323 data point by
the lodged wet-room count (SAP 10.2 §2.6.4), multiply the raw efficiency
by the Table 329 ducts-inside-envelope in-use factor (0.90) and the raw
SFP by the per-duct-type factor (rigid 1.4), and feed:
- the §2.6.6 eq (2) effective-air-change credit (23c) → (24a)/(25)m;
- the (230a) fan electricity (in-use SFP × 1.22 × V), costed but NOT
  added to the Table 5a gains (its effect is in the efficiency).
An MVHR lodged with no PCDF index falls back to the SAP 10.2 Table 4g
default (raw efficiency 66% × 0.70, raw SFP 2.0 × 2.5).

Worksheet-proven on simulated case 49 (000565 semi + Vent Axia Sentinel
Kinetic B 500140 + gas combi → Elmhurst Current SAP 72): every MVHR line
matches Elmhurst exactly — (33) fabric heat loss 100.5923, (23c) in-use
efficiency 81.9% = 91 × 0.90, (25)m Jan 0.8571, (230a) fan electricity
415.9325, (231) total pumps/fans 501.9325. The residual SAP 71 vs 72 is
the known 000565-family space-heating-demand artifact (same -1/-2 seen on
cases 47/48), not the MVHR logic.

Corpus: within-0.5 72.6% -> 72.7%, MAE 0.788 -> 0.782, PE 3.6 -> 3.5.
The 3 gov-API MVHR certs: Flat 1 +6 -> 0 (Table 4g default path) and
12a Princes Gate +3 -> +1 (heat-recovery credit); Apartment 707 -4 -> -6
is a separate baseline under-rate (it under-rated as natural too — the
MVHR credit correctly adds ventilation loss per Elmhurst's method).
Ratcheted _MAX_SAP_MAE 0.79 -> 0.785, _MAX_PE_PER_M2_MAE 3.7 -> 3.6.

Note: pyright strict type gate not run locally (pyright not installed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 19:45:37 +00:00
parent 34cbd7d66c
commit 7b30b464e5
4 changed files with 220 additions and 15 deletions

View file

@ -3928,20 +3928,19 @@ def _api_sheltered_sides(built_form: object) -> Optional[int]:
# loft-sourced PIV as natural
# (no added system air change)
# 6 positive input from outside → EXTRACT_OR_PIV_OUTSIDE (24c)
# Code 4 (MVHR, 24a) is DEFERRED: its (24a)m formula needs the lodged
# heat-recovery efficiency (`mvhr_efficiency_pct`, PCDB Table 326) which the
# API→cascade path does not yet plumb; mapping it to MVHR with a null
# efficiency would mis-model it as MV (no recovery), so it stays NATURAL
# until the efficiency is wired. The extract systems (2/3/6) carry no
# efficiency, so this slice closes the clean +1.90 SAP over-rate on the
# MEV/PIV-outside cohort (n=20, 5% within 0.5) — they were silently
# defaulting to NATURAL, under-stating ventilation heat loss.
# Code 4 (MVHR, 24a) resolves to MVHR: `cert_to_inputs` reads the lodged
# heat-recovery efficiency from PCDB Table 323 (per wet-room count) ×
# Table 329 in-use factor and feeds the §2.6.6 equation (2) credit. The
# 3 corpus MVHR certs carry a PCDF index (`mechanical_ventilation_index_
# number`) + wet-room + duct lodgements; an MVHR lodged with no PCDF index
# falls back to the SAP 10.2 Table 4g default (efficiency 66% × 0.70).
# The extract systems (2/3/6) carry no efficiency.
_API_MECHANICAL_VENTILATION_TO_KIND: Final[dict[int, Optional[str]]] = {
0: None,
1: "MV",
2: "EXTRACT_OR_PIV_OUTSIDE",
3: "EXTRACT_OR_PIV_OUTSIDE",
4: None, # MVHR — efficiency plumbing deferred; treat as natural
4: "MVHR", # balanced whole-house MV with heat recovery (24a)
5: None,
6: "EXTRACT_OR_PIV_OUTSIDE",
}
@ -7564,6 +7563,7 @@ _ELMHURST_MV_TYPE_TO_KIND: Dict[str, str] = {
# formula choice; the cascade resolves enum name → enum value when
# picking which (25)m formula to apply.
"Mechanical extract, decentralised (MEV dc)": "EXTRACT_OR_PIV_OUTSIDE",
"Mechanical ventilation with heat recovery (MVHR)": "MVHR",
}

View file

@ -75,10 +75,13 @@ from domain.sap10_calculator.tables.pcdb import (
gas_oil_boiler_record,
heat_pump_record,
mv_in_use_factors_record,
mvhr_record,
)
from domain.sap10_calculator.tables.pcdb.parser import (
GasOilBoilerRecord,
HeatPumpRecord,
MvhrDataPoint,
MvhrRecord,
)
from domain.sap10_calculator.tables.pcdb.postcode_weather import (
PostcodeClimate,
@ -525,6 +528,9 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float:
"""
total = 0.0
total += _mev_decentralised_kwh_per_yr_from_cert(epc)
# (230a) balanced MVHR fan electricity — SFP × 1.22 × V (SAP 10.2
# §2.6.6). Costed here but kept out of the Table 5a gains.
total += _mvhr_fan_kwh_per_yr_from_cert(epc)
details = epc.sap_heating.main_heating_details if epc.sap_heating else []
if details:
main_1 = details[0]
@ -709,6 +715,106 @@ def _mev_decentralised_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float:
sfp_av_w_per_l_per_s=sfp_av,
dwelling_volume_m3=dimensions.volume_m3,
)
# PCDB Table 329 / 323 system_type 3 = balanced whole-house MV (with or
# without heat recovery).
_MV_BALANCED_SYSTEM_TYPE: Final[int] = 3
# SAP 10.2 Table 4g (PDF p.176) defaults for an MVHR system NOT in the
# PCDB: raw heat-recovery efficiency 66% + raw SFP 2.0 W/(l/s). Each is
# then multiplied by the default-data in-use factor (Table 329
# system_type 10 — efficiency 0.70 inside the envelope, SFP 2.5).
_TABLE_4G_DEFAULT_MVHR_EFFICIENCY_PCT: Final[float] = 66.0
_TABLE_4G_DEFAULT_MVHR_SFP_W_PER_L_PER_S: Final[float] = 2.0
@dataclass(frozen=True)
class _MvhrSystemValues:
"""In-use SFP + heat-recovery efficiency for an MVHR dwelling, with
the PCDB Table 329 in-use factors already folded in."""
in_use_sfp_w_per_l_per_s: float
in_use_efficiency_pct: float
def _select_mvhr_data_point(record: MvhrRecord, wet_rooms_count: int) -> MvhrDataPoint:
"""SAP 10.2 §2.6.4: select the MVHR data point by the dwelling's
wet-room count (each Table 323 group is keyed by wet rooms). Exact
match where lodged; otherwise clamp to the tested range. Falls back
to the smallest-wet-room group when the cert lodges 0 (unlodged)."""
points = sorted(record.data_points, key=lambda p: p.num_wet_rooms)
target = wet_rooms_count if wet_rooms_count > 0 else points[0].num_wet_rooms
for point in points:
if point.num_wet_rooms == target:
return point
if target < points[0].num_wet_rooms:
return points[0]
return points[-1]
def _mvhr_system_values(epc: EpcPropertyData) -> Optional[_MvhrSystemValues]:
"""Resolve the in-use SFP + heat-recovery efficiency for an MVHR
dwelling, or None when the cert is not MVHR.
PCDB path (index lodged + Table 323 hit): select the data point whose
wet-room count matches the lodgement, then apply the Table 329
system_type-3 in-use factors SFP per the lodged duct type (rigid
1.4 / flexible 1.7); heat-recovery efficiency for ducts inside the
heated envelope (0.90). Worksheet-proven on simulated case 49 (000565,
2 wet rooms, Vent Axia 500140 raw SFP 0.88 × 1.4 = 1.232; raw
efficiency 91 × 0.90 = 81.9% = worksheet (23c)).
Default path (no PCDB record, e.g. an MVHR lodged with no PCDF index):
SAP 10.2 Table 4g raw SFP 2.0 / efficiency 66%, × the default-data
in-use factors (Table 329 system_type 10 SFP 2.5, efficiency 0.70).
Duct type defaults to rigid when unlodged (the Elmhurst Summary path
captures it but not all sources do); semi-rigid maps to rigid per
SAP 10.2 §2.6.8. Only the ducts-inside-envelope efficiency factor is
worksheet-validated (all corpus + worksheet MVHR certs lodge it).
"""
sv = epc.sap_ventilation
if sv is None or sv.mechanical_ventilation_kind != MechanicalVentilationKind.MVHR.name:
return None
pcdf_id = epc.mechanical_ventilation_index_number
record = mvhr_record(pcdf_id) if pcdf_id is not None else None
if record is not None and record.data_points:
point = _select_mvhr_data_point(record, epc.wet_rooms_count)
raw_sfp = point.sfp_w_per_l_per_s
raw_efficiency_pct = point.efficiency_pct
iuf_record = mv_in_use_factors_record(_MV_BALANCED_SYSTEM_TYPE)
else:
raw_sfp = _TABLE_4G_DEFAULT_MVHR_SFP_W_PER_L_PER_S
raw_efficiency_pct = _TABLE_4G_DEFAULT_MVHR_EFFICIENCY_PCT
iuf_record = mv_in_use_factors_record(_MEV_DEFAULT_DATA_SYSTEM_TYPE)
if iuf_record is None or raw_sfp is None or raw_efficiency_pct is None:
return None
if epc.mechanical_vent_duct_type == _MV_DUCT_TYPE_FLEXIBLE:
sfp_iuf = iuf_record.sfp_iuf_flexible_no_scheme
else:
sfp_iuf = iuf_record.sfp_iuf_rigid_no_scheme
efficiency_iuf = iuf_record.mvhr_efficiency_iuf_inside_no_scheme
if sfp_iuf is None or efficiency_iuf is None:
return None
return _MvhrSystemValues(
in_use_sfp_w_per_l_per_s=raw_sfp * sfp_iuf,
in_use_efficiency_pct=raw_efficiency_pct * efficiency_iuf,
)
def _mvhr_fan_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float:
"""SAP 10.2 §5 Table 4f line (230a) annual fan electricity for an
MVHR dwelling: in-use SFP × 1.22 × V (the same (230a) shape as
decentralised MEV). Returns 0.0 when the cert is not MVHR. Per SAP
10.2 §2.6.6 this electricity is costed but NOT added to the Table 5a
gains (its effect is already in the heat-recovery efficiency)."""
values = _mvhr_system_values(epc)
if values is None:
return 0.0
return mev_decentralised_kwh_per_yr(
sfp_av_w_per_l_per_s=values.in_use_sfp_w_per_l_per_s,
dwelling_volume_m3=dimensions_from_cert(epc).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.
@ -5101,6 +5207,16 @@ def ventilation_from_cert(
# MVHR/MV kinds are left untouched pending their own worksheet.
if mv_kind is MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE:
intermittent_fans = vc.intermittent_fans
# SAP 10.2 §2.6.6 equation (2): the (24a) MVHR effective-air-change
# credit needs the in-use heat-recovery efficiency (23c) = raw PCDB
# efficiency × Table 329 in-use factor (or the Table 4g default when
# no PCDB record). None for non-MVHR kinds → ventilation_from_inputs
# leaves the heat-recovery term at zero.
mvhr_efficiency_pct: Optional[float] = None
if mv_kind is MechanicalVentilationKind.MVHR:
mvhr_values = _mvhr_system_values(epc)
if mvhr_values is not None:
mvhr_efficiency_pct = mvhr_values.in_use_efficiency_pct
return ventilation_from_inputs(
volume_m3=vol,
storey_count=storeys,
@ -5123,6 +5239,7 @@ def ventilation_from_cert(
air_permeability_ap4=ap4,
mv_kind=mv_kind,
mv_system_ach=mv_system_ach,
mvhr_efficiency_pct=mvhr_efficiency_pct,
**wind_kwargs,
)

View file

@ -3773,19 +3773,98 @@ def test_api_mechanical_ventilation_maps_extract_systems_to_cascade_kind() -> No
)
# Act / Assert — extract / positive-input-from-outside systems carry
# the (24c) kind; MV-no-HR carries (24b); natural and PIV-from-loft
# stay NATURAL; MVHR (4) is deferred (efficiency not yet plumbed) and
# stays NATURAL rather than mis-modelling as MV.
# the (24c) kind; MV-no-HR carries (24b); MVHR (4) carries (24a) (its
# heat-recovery efficiency is read from PCDB Table 323 in cert_to_
# inputs); natural and PIV-from-loft stay NATURAL.
assert _api_mechanical_ventilation_kind(0) is None # natural
assert _api_mechanical_ventilation_kind(1) == "MV" # MV, no HR
assert _api_mechanical_ventilation_kind(2) == "EXTRACT_OR_PIV_OUTSIDE" # MEV dc
assert _api_mechanical_ventilation_kind(3) == "EXTRACT_OR_PIV_OUTSIDE" # MEV c
assert _api_mechanical_ventilation_kind(4) is None # MVHR (deferred)
assert _api_mechanical_ventilation_kind(4) == "MVHR" # balanced + HR (24a)
assert _api_mechanical_ventilation_kind(5) is None # PIV from loft
assert _api_mechanical_ventilation_kind(6) == "EXTRACT_OR_PIV_OUTSIDE" # PIV outside
assert _api_mechanical_ventilation_kind(None) is None
def test_mvhr_system_values_apply_pcdb_wet_room_point_and_table_329_iufs() -> None:
# Arrange — simulated case 49 (000565 semi + MVHR + gas combi →
# Elmhurst Current SAP 72). The dwelling lodges Vent Axia Sentinel
# Kinetic B (PCDB Table 323 index 500140), 2 wet rooms, rigid ducting.
# SAP 10.2 §2.6.4 selects the 2-wet-room data point (raw SFP 0.88,
# efficiency 91%); the Table 329 system_type-3 in-use factors are SFP
# rigid 1.4 + heat-recovery-efficiency-inside-envelope 0.90.
import dataclasses
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_mvhr_fan_kwh_per_yr_from_cert, # pyright: ignore[reportPrivateUsage]
_mvhr_system_values, # pyright: ignore[reportPrivateUsage]
dimensions_from_cert,
)
from tests.domain.sap10_calculator.worksheet import (
_elmhurst_worksheet_000565 as _w000565,
)
base = _w000565.build_epc()
assert base.sap_ventilation is not None
epc = dataclasses.replace(
base,
mechanical_ventilation_index_number=500140,
wet_rooms_count=2,
mechanical_vent_duct_type=2, # rigid
sap_ventilation=dataclasses.replace(
base.sap_ventilation, mechanical_ventilation_kind="MVHR"
),
)
# Act
values = _mvhr_system_values(epc)
fan_kwh = _mvhr_fan_kwh_per_yr_from_cert(epc)
# Assert — worksheet (23c) = 91 × 0.90 = 81.9%; in-use SFP = 0.88 ×
# 1.4 = 1.232 W/(l/s). The (230a) fan electricity is in-use SFP ×
# 1.22 × V; on the case-49 Summary (V = 276.7275 m³) this is exactly
# 415.9325 kWh (verified via run_elmhurst_summary). Here we pin the
# formula against this fixture's own volume (its geometry differs).
assert values is not None
assert abs(values.in_use_efficiency_pct - 81.9) <= 1e-6
assert abs(values.in_use_sfp_w_per_l_per_s - 1.232) <= 1e-9
expected_fan_kwh = 1.232 * 1.22 * dimensions_from_cert(epc).volume_m3
assert abs(fan_kwh - expected_fan_kwh) <= 1e-6
def test_mvhr_system_values_fall_back_to_table_4g_defaults_without_pcdb_index() -> None:
# Arrange — an MVHR cert lodged with NO PCDF index (corpus cert
# "Flat 1"). SAP 10.2 Table 4g (PDF p.176): default raw efficiency
# 66% + raw SFP 2.0, × the default-data in-use factors (Table 329
# system_type 10 — efficiency-inside 0.70, SFP 2.5).
import dataclasses
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_mvhr_system_values, # pyright: ignore[reportPrivateUsage]
)
from tests.domain.sap10_calculator.worksheet import (
_elmhurst_worksheet_000565 as _w000565,
)
base = _w000565.build_epc()
assert base.sap_ventilation is not None
epc = dataclasses.replace(
base,
mechanical_ventilation_index_number=None,
sap_ventilation=dataclasses.replace(
base.sap_ventilation, mechanical_ventilation_kind="MVHR"
),
)
# Act
values = _mvhr_system_values(epc)
# Assert — efficiency 66 × 0.70 = 46.2%; SFP 2.0 × 2.5 = 5.0.
assert values is not None
assert abs(values.in_use_efficiency_pct - 46.2) <= 1e-6
assert abs(values.in_use_sfp_w_per_l_per_s - 5.0) <= 1e-9
def test_api_mechanical_ventilation_unmapped_code_raises() -> None:
# Arrange — an out-of-range `mechanical_ventilation` integer is a
# spec-coverage gap: raise rather than silently default to NATURAL

View file

@ -201,9 +201,18 @@ _MIN_WITHIN_HALF_SAP = 0.72
# dMEV intermittent-fan fix: an EXTRACT_OR_PIV_OUTSIDE cert lodging 0 fans now
# takes 0 (not the Table 5 age-band default) — same "case 48" worksheet closes
# its space-heating demand to land SAP 57 exact.
_MAX_SAP_MAE = 0.79
# 0.788 -> 0.782 (within-0.5 72.6% -> 72.7%, PE 3.6 -> 3.5) via MVHR (24a) heat-
# recovery support: the gov-API `mechanical_ventilation=4` cohort (balanced
# whole-house MV with heat recovery) was treated as natural; now credited via
# PCDB Table 323 efficiency × Table 329 in-use factor and the §2.6.6 eq (2)
# effective-air-change formula + (230a) fan electricity. Worksheet-proven on
# simulated case 49 (Vent Axia 500140, every MVHR line — (33) 100.5923, (23c)
# 81.9%, (230a) 415.9325, (231) 501.9325 — matching Elmhurst exactly). Corpus
# MVHR certs: Flat 1 +6 -> 0, 12a Princes Gate +3 -> +1; Apartment 707's -4 ->
# -6 is a separate baseline under-rate (it under-rated as natural too).
_MAX_SAP_MAE = 0.785
_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
_MAX_PE_PER_M2_MAE = 3.6 # kWh / m2 / yr vs energy_consumption_current
def _load_corpus() -> list[dict[str, Any]]: