mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.92: AP4 + MEV decentralised plumbing (SAP 10.2 §2 (17a)/(18)/(23a)/(24c))
SAP 10.2 §2 lines (17a)/(18) "Air permeability value, AP4 (m³/h/m²)" (PDF p.12-13): > "The air permeability at 4 Pa (AP4) measured with the low-pressure > pulse technique [...] is used in the following formula to estimate > of the air infiltration rate at typical pressure differences. > In this case (9) to (16) of the worksheet are not used." > > Air infiltration rate (ach) = 0.263 × AP4^0.924 > > If based on air permeability value at 4 Pa, > then (18) = [0.263 × (17a)^0.924] + (8) SAP 10.2 §2 lines (23a)/(24c)/(25) "MEV" + "Whole-house extract ventilation" (PDF p.13/133): > "The SAP calculation is based on a throughput of 0.5 air changes per > hour through the mechanical system." (23a) = 0.5 > > If whole house extract ventilation or positive input ventilation > from outside: > if (22b)m < 0.5 × (23b), then (24c) = (23b) > otherwise (24c) = (22b)m + 0.5 × (23b) Cert 000565 lodges: - Summary §12.1 "Mechanical Ventilation Type: Mechanical extract, decentralised (MEV dc)" (PCDF 500755) - Summary §12.2 "Test Method: Pulse" + "Pressure Test Result (AP4): 2.00" Pre-slice both lodgements were silently dropped by the Elmhurst extractor / mapper / `cert_to_inputs` cascade: - AP4 had no schema field on `VentilationAndCooling` or `SapVentilation` even though `ventilation.py:ventilation_from_inputs(air_permeability_ ap4=...)` already implemented the spec formula. - Mechanical Ventilation Type had no schema field; `cert_to_inputs. ventilation_from_cert` hardcoded `mv_kind=MechanicalVentilationKind. NATURAL` regardless of the lodgement, routing cert 000565 through the (24d) natural-vent formula instead of (24c). These bugs are coupled: AP4 alone would close (18) but the cascade's (25) NATURAL pass-through would then *under*-count the effective ach by 0.25 (the missing MEV contribution). MEV alone would over-count because the (18) over-count remains. Per [[feedback-bigger-slices- for-uniform-work]] + handover precedent on coupling-aware reverts, these land together. Slice span (5 layers): 1. **Schema** — `VentilationAndCooling.air_permeability_ap4_m3_h_m2` + `VentilationAndCooling.mechanical_ventilation_type` (site-notes); `SapVentilation.air_permeability_ap4_m3_h_m2` + `SapVentilation.mechanical_ventilation_kind` (domain). 2. **Extractor** — `_extract_ventilation` parses "Pressure Test Result (AP4)" scoped to §12.2 and "Mechanical Ventilation Type" scoped to §12.1. Both default to None when the cert lodges no MV / no Pulse test (cohort modal case). 3. **Mapper** — `_map_elmhurst_ventilation` plumbs AP4 through; new `_ELMHURST_MV_TYPE_TO_KIND` dispatch with strict-raise on unmapped labels (per [[reference-unmapped-elmhurst-label]] mirror pattern). 4. **cert_to_inputs** — `ventilation_from_cert` reads AP4 and resolves `mechanical_ventilation_kind` name → `MechanicalVentilationKind` enum. MEV/MV/MVHR kinds set `mv_system_ach=0.5` per spec (23a). 5. **Tests** — 4 in test_summary_pdf_mapper_chain.py (extractor + mapper for both AP4 and MEV kind), 2 in test_cert_to_inputs.py (cascade AP4 formula + MEV kind dispatch). All AAA-structured. Cert 000565 movement (HEAD `83218630` → this slice): - cascade (18) pressure_test_ach: 2.4037 → 2.0287 ✓ EXACT vs ws 2.0287 - cascade (21) shelter-adj: 2.0431 → 1.7244 ✓ EXACT vs ws 1.7244 - cascade mean (25)m: 2.2347 → 2.1360 vs ws 2.086 (+0.05) - **sap_score (integer): 28 → 29 ✓ EXACT vs ws 29** (Δ−1 → Δ 0) - sap_score_continuous: 27.99 → 28.77 (Δ−0.52 → +0.26) - ecf: 5.44 → 5.36 (Δ+0.05 → −0.03) - total_fuel_cost_gbp: 4726.75 → 4657.37 (Δ+46 → Δ−23) - co2_kg_per_yr: 6506.48 → 6415.56 (Δ+59 → Δ−32) - **space_heating_kwh: +631 → −367** (~75% closed) - main_heating_fuel: +371 → −216 (~58% closed) - hot_water_kwh: ✓ 0 EXACT unchanged - lighting / pumps_fans: sub-spec residuals unchanged The residual cascade-over-by-0.05 ach on (25)m is the cascade using the cert-agnostic Table U2 wind tuple instead of the cert's regional wind lookup; future ventilation_from_cert wires a `postcode_climate` arg through which `cert_to_demand_inputs` already does for the demand cascade, but the SAP-rating cascade keeps the Table U2 default. Cohort safety: - All 21 other Elmhurst cohort fixtures lodge `pressure_test_method= "Not available"` and `mechanical_ventilation=False` → both new fields default to None → cascade behaviour unchanged. - 9 golden + 38 cohort-2 API certs route through `_map_sap_ventilation` (the API mapper variant), which leaves both new SapVentilation fields at their None default → cascade behaviour unchanged. Test baseline: 582 pass + 8 expected `000565` fails (was 575 + 9; +6 new tests + sap_score reclassified from fail to pass). 1763 pass in broader sap10_ml + worksheet + epc.domain suites + 3 pre-existing fails unchanged. Pyright net-zero per touched file (1/0/0/32/34→32/13/ 11 → 1/0/0/32/32/13/11, cert_to_inputs.py improved −2). Per [[project-sap10_ml-deprecation]] the new fields live on the existing `SapVentilation` domain type; no new modules under sap10_ml. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b6ebcad54d
commit
66c14bb1e9
7 changed files with 243 additions and 1 deletions
|
|
@ -1061,6 +1061,29 @@ class ElmhurstSiteNotesExtractor:
|
|||
return glazing_type, building_part, orientation
|
||||
|
||||
def _extract_ventilation(self) -> VentilationAndCooling:
|
||||
# SAP 10.2 §2 (17a) "Air permeability value, AP4". Scoped to
|
||||
# §12.2..§13.0 so the per-window U-values + door U-values can't
|
||||
# shadow the float read. Absent when `pressure_test_method !=
|
||||
# "Pulse"` (the modal cohort lodgement).
|
||||
pressure_lines = self._section_lines(
|
||||
"12.2 Air Pressure Test", "13.0 Lighting"
|
||||
)
|
||||
ap4_raw = self._local_val(pressure_lines, "Pressure Test Result (AP4)")
|
||||
air_permeability_ap4_m3_h_m2: Optional[float] = None
|
||||
if ap4_raw:
|
||||
try:
|
||||
air_permeability_ap4_m3_h_m2 = float(ap4_raw.split()[0])
|
||||
except (ValueError, IndexError):
|
||||
air_permeability_ap4_m3_h_m2 = None
|
||||
# Summary §12.1 "Mechanical Ventilation Type" — scoped to §12.1
|
||||
# body so the global "Type" labels in §14 / §15 can't shadow it.
|
||||
mv_lines = self._section_lines(
|
||||
"12.1 Mechanical Ventilation", "12.2 Air Pressure Test"
|
||||
)
|
||||
mv_type_raw = self._local_val(mv_lines, "Mechanical Ventilation Type")
|
||||
mechanical_ventilation_type = (
|
||||
" ".join(mv_type_raw.split()) if mv_type_raw else None
|
||||
)
|
||||
return VentilationAndCooling(
|
||||
open_chimneys_count=self._int_val("No. of open chimneys"),
|
||||
open_flues_count=self._int_val("No. of open flues"),
|
||||
|
|
@ -1081,6 +1104,8 @@ class ElmhurstSiteNotesExtractor:
|
|||
draught_lobby=self._str_val("Draught Lobby"),
|
||||
mechanical_ventilation=self._bool_val("Mechanical Ventilation"),
|
||||
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,
|
||||
)
|
||||
|
||||
def _extract_lighting(self) -> Lighting:
|
||||
|
|
|
|||
|
|
@ -1584,6 +1584,88 @@ def test_summary_000565_ext1_party_wall_cf_routes_to_u_value_0p2() -> None:
|
|||
assert abs(u - 0.2) <= 1e-4
|
||||
|
||||
|
||||
def test_summary_000565_section_12_2_pulse_pressure_test_ap4_extracted() -> None:
|
||||
# Arrange — cert 000565 §12.2 Air Pressure Test lodges:
|
||||
# Test Method: Pulse
|
||||
# Pressure Test Result (AP4): 2.00
|
||||
# SAP 10.2 §2 line (17a) "Air permeability value, AP4, (m³/h/m²)" is
|
||||
# the measured air permeability at 4 Pa from the low-pressure pulse
|
||||
# technique. The cascade's `ventilation_from_inputs(air_permeability
|
||||
# _ap4=...)` consumes it via line (18) = 0.263 × AP4^0.924 + (8).
|
||||
# Pre-slice the extractor read only the Test Method string and
|
||||
# silently dropped the AP4 value, so the cascade fell back to the
|
||||
# components-based (16) infiltration rate (+0.375 ach over worksheet).
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
|
||||
|
||||
# Act
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Assert
|
||||
assert site_notes.ventilation.pressure_test_method == "Pulse"
|
||||
ap4 = site_notes.ventilation.air_permeability_ap4_m3_h_m2
|
||||
assert ap4 is not None
|
||||
assert abs(ap4 - 2.0) <= 1e-4
|
||||
|
||||
|
||||
def test_summary_000565_air_permeability_ap4_routes_to_sap_ventilation_field() -> None:
|
||||
# Arrange — mapper plumbing for SAP 10.2 §2 (17a). The Elmhurst
|
||||
# `VentilationAndCooling.air_permeability_ap4_m3_h_m2` field carries
|
||||
# through to `SapVentilation.air_permeability_ap4_m3_h_m2` so the
|
||||
# `cert_to_inputs` ventilation cascade can read it and pass into
|
||||
# `ventilation_from_inputs(air_permeability_ap4=...)`.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Assert
|
||||
assert epc.sap_ventilation is not None
|
||||
ap4 = epc.sap_ventilation.air_permeability_ap4_m3_h_m2
|
||||
assert ap4 is not None
|
||||
assert abs(ap4 - 2.0) <= 1e-4
|
||||
|
||||
|
||||
def test_summary_000565_section_12_1_extracts_mechanical_extract_decentralised_mev_dc_kind() -> None:
|
||||
# Arrange — cert 000565 §12.1 Mechanical Ventilation lodges:
|
||||
# Mechanical Ventilation: Yes
|
||||
# Mechanical Ventilation Type: Mechanical extract, decentralised
|
||||
# (MEV dc)
|
||||
# SAP 10.2 §2 line (23a) for MEV: "system throughput = 0.5 ach"; the
|
||||
# effective ach formula (25) routes through (24c) "whole-house
|
||||
# extract ventilation or PIV from outside" — `(22b)m + 0.5 × (23b)`
|
||||
# when (22b) ≥ 0.5×(23b). Pre-slice the extractor read only the
|
||||
# "Mechanical Ventilation" yes/no bool and dropped the Type string,
|
||||
# so the cascade defaulted to mv_kind=NATURAL → (24d) formula.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
|
||||
|
||||
# Act
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Assert
|
||||
assert site_notes.ventilation.mechanical_ventilation is True
|
||||
assert (
|
||||
site_notes.ventilation.mechanical_ventilation_type
|
||||
== "Mechanical extract, decentralised (MEV dc)"
|
||||
)
|
||||
|
||||
|
||||
def test_summary_000565_mev_decentralised_routes_to_extract_or_piv_outside_mv_kind() -> None:
|
||||
# Arrange — mapper plumbing for SAP 10.2 §2 (23a)/(24c) MEV: the
|
||||
# Elmhurst "Mechanical extract, decentralised (MEV dc)" string maps
|
||||
# to `MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE` so the
|
||||
# cascade picks the (24c) effective-ach formula.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Assert
|
||||
assert epc.sap_ventilation is not None
|
||||
assert epc.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE"
|
||||
|
||||
|
||||
def test_summary_mapper_raises_on_unmapped_wall_type_code() -> None:
|
||||
# Arrange — strict-coverage gate per [[reference-unmapped-api-
|
||||
# code]] mirror: an Elmhurst wall_type lodgement that isn't in
|
||||
|
|
|
|||
|
|
@ -173,6 +173,16 @@ class SapVentilation:
|
|||
has_suspended_timber_floor: Optional[bool] = None # (12) gate
|
||||
suspended_timber_floor_sealed: Optional[bool] = None
|
||||
has_draught_lobby: Optional[bool] = None # (13) gate (overrides .draught_lobby for §2 cascade)
|
||||
# SAP 10.2 §2 (17a) — air permeability at 4 Pa from the low-pressure
|
||||
# Pulse pressure test, m³/h per m² of envelope area. When present the
|
||||
# cascade routes (18) via the AP4 formula `0.263 × AP4^0.924 + (8)`.
|
||||
air_permeability_ap4_m3_h_m2: Optional[float] = None
|
||||
# SAP 10.2 §2 (23a)/(24a..d) — Elmhurst "Mechanical Ventilation Type"
|
||||
# string mapped to the `MechanicalVentilationKind` enum name (e.g.
|
||||
# "EXTRACT_OR_PIV_OUTSIDE" for MEV decentralised). The cascade uses
|
||||
# this to pick the (25)m effective-ach formula; None defaults to the
|
||||
# natural-ventilation (24d) branch.
|
||||
mechanical_ventilation_kind: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -4246,6 +4246,29 @@ def _elmhurst_sheltered_sides(built_form: str) -> Optional[int]:
|
|||
return _ELMHURST_SHELTERED_SIDES_BY_BUILT_FORM.get(built_form)
|
||||
|
||||
|
||||
_ELMHURST_MV_TYPE_TO_KIND: Dict[str, str] = {
|
||||
# Summary §12.1 "Mechanical Ventilation Type" → MechanicalVentilation
|
||||
# Kind enum name. Mapped per SAP 10.2 §2 (24a..d) effective-ach
|
||||
# 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",
|
||||
}
|
||||
|
||||
|
||||
def _elmhurst_mv_kind(mv_type: Optional[str]) -> Optional[str]:
|
||||
"""Map the Elmhurst "Mechanical Ventilation Type" string to a
|
||||
`MechanicalVentilationKind` enum name. Returns None when no MV is
|
||||
lodged. Raises `UnmappedElmhurstLabel` on an unrecognised non-empty
|
||||
string so the strict-coverage gate surfaces new MV variants at the
|
||||
extractor boundary."""
|
||||
if mv_type is None or not mv_type.strip():
|
||||
return None
|
||||
label = mv_type.strip()
|
||||
if label not in _ELMHURST_MV_TYPE_TO_KIND:
|
||||
raise UnmappedElmhurstLabel("ventilation.mechanical_ventilation_type", label)
|
||||
return _ELMHURST_MV_TYPE_TO_KIND[label]
|
||||
|
||||
|
||||
def _map_elmhurst_ventilation(
|
||||
v: ElmhurstVentilation,
|
||||
built_form: str,
|
||||
|
|
@ -4276,4 +4299,6 @@ def _map_elmhurst_ventilation(
|
|||
None if has_suspended_timber_floor is None
|
||||
else (False if has_suspended_timber_floor else None)
|
||||
),
|
||||
air_permeability_ap4_m3_h_m2=v.air_permeability_ap4_m3_h_m2,
|
||||
mechanical_ventilation_kind=_elmhurst_mv_kind(v.mechanical_ventilation_type),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -183,6 +183,13 @@ class VentilationAndCooling:
|
|||
draught_lobby: str # e.g. "Not present"
|
||||
mechanical_ventilation: bool
|
||||
pressure_test_method: str # e.g. "Not available"
|
||||
# SAP 10.2 §2 (17a) AP4 reading from §12.2 "Pressure Test Result
|
||||
# (AP4)" — only present when `pressure_test_method == "Pulse"`.
|
||||
air_permeability_ap4_m3_h_m2: Optional[float] = None
|
||||
# Summary §12.1 "Mechanical Ventilation Type" — e.g. "Mechanical
|
||||
# extract, decentralised (MEV dc)". None when `mechanical_ventilation
|
||||
# is False` (no MV system).
|
||||
mechanical_ventilation_type: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -2739,6 +2739,28 @@ def ventilation_from_cert(
|
|||
if sv is not None and sv.suspended_timber_floor_sealed is not None
|
||||
else spec_sealed
|
||||
)
|
||||
# SAP 10.2 §2 (17a) — AP4 pressure-test reading routes to the
|
||||
# cascade's `(18) = 0.263 × AP4^0.924 + (8)` formula; absent value
|
||||
# falls through to the components-based (16) ach.
|
||||
ap4 = sv.air_permeability_ap4_m3_h_m2 if sv is not None else None
|
||||
# SAP 10.2 §2 (23a)/(24a..d) — MV kind dispatch chooses the (25)m
|
||||
# effective-ach formula. The Elmhurst mapper translates the lodged
|
||||
# "Mechanical Ventilation Type" string to an enum *name*; resolve
|
||||
# back to the enum here. Unmapped names default to NATURAL (24d).
|
||||
mv_kind = MechanicalVentilationKind.NATURAL
|
||||
mv_system_ach = 0.0
|
||||
mv_kind_name = sv.mechanical_ventilation_kind if sv is not None else None
|
||||
if mv_kind_name is not None:
|
||||
try:
|
||||
mv_kind = MechanicalVentilationKind[mv_kind_name]
|
||||
except KeyError:
|
||||
mv_kind = MechanicalVentilationKind.NATURAL
|
||||
if mv_kind is not MechanicalVentilationKind.NATURAL:
|
||||
# SAP 10.2 §2 (23a) "If mechanical ventilation: air change
|
||||
# rate through system = 0.5" (PDF p.13). PCDB-lodged systems
|
||||
# can override via a future plumbing slice; the spec default
|
||||
# is what every MEV / MV / MVHR cohort cert lodges today.
|
||||
mv_system_ach = 0.5
|
||||
return ventilation_from_inputs(
|
||||
volume_m3=vol,
|
||||
storey_count=storeys,
|
||||
|
|
@ -2757,7 +2779,9 @@ def ventilation_from_cert(
|
|||
has_draught_lobby=bool(sv.has_draught_lobby) if sv is not None and sv.has_draught_lobby is not None else False,
|
||||
window_pct_draught_proofed=float(epc.percent_draughtproofed or 0),
|
||||
sheltered_sides=int(sv.sheltered_sides) if sv is not None and sv.sheltered_sides is not None else 2,
|
||||
mv_kind=MechanicalVentilationKind.NATURAL,
|
||||
air_permeability_ap4=ap4,
|
||||
mv_kind=mv_kind,
|
||||
mv_system_ach=mv_system_ach,
|
||||
**wind_kwargs,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
MainHeatingDetail,
|
||||
PhotovoltaicArray,
|
||||
SapFloorDimension,
|
||||
SapVentilation,
|
||||
)
|
||||
|
||||
from domain.sap10_ml.tests._fixtures import (
|
||||
|
|
@ -46,6 +47,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
|||
cert_to_demand_inputs,
|
||||
cert_to_inputs,
|
||||
pcdb_combi_loss_override,
|
||||
ventilation_from_cert,
|
||||
)
|
||||
from domain.sap10_calculator.tables.pcdb import GasOilBoilerRecord, gas_oil_boiler_record
|
||||
from domain.sap10_calculator.worksheet.tests import _elmhurst_worksheet_000477 as _w000477
|
||||
|
|
@ -599,6 +601,73 @@ def test_main_floor_u_value_routes_suspended_timber_via_floor_construction_type(
|
|||
assert sealed is False
|
||||
|
||||
|
||||
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
|
||||
# use `(18) = 0.263 × AP4^0.924 + (8)` instead of the components-
|
||||
# based (16) fallback. Pre-slice S0380.92 the `ventilation_from_cert`
|
||||
# cascade dropped the lodged AP4 value so cert 000565 fell through
|
||||
# to (16) — over-counting infiltration by +0.375 ach.
|
||||
base = _typical_semi_detached_epc()
|
||||
with_ap4 = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=_TYPICAL_TFA_M2,
|
||||
habitable_rooms_count=4,
|
||||
region_code="1",
|
||||
sap_building_parts=base.sap_building_parts,
|
||||
sap_windows=base.sap_windows,
|
||||
sap_heating=base.sap_heating,
|
||||
sap_ventilation=SapVentilation(air_permeability_ap4_m3_h_m2=2.0),
|
||||
)
|
||||
|
||||
# Act
|
||||
v_default = ventilation_from_cert(base)
|
||||
v_ap4 = ventilation_from_cert(with_ap4)
|
||||
|
||||
# Assert — `(18) = 0.263 × 2.0^0.924 + (8)` where (8) is the openings
|
||||
# ach. The default (no pressure test) routes through (16) which sums
|
||||
# in (10)..(15) — strictly larger than the AP4 path because (10)..(15)
|
||||
# are all non-negative.
|
||||
expected_line_18 = 0.263 * (2.0 ** 0.924) + v_ap4.openings_ach
|
||||
assert abs(v_ap4.pressure_test_ach - expected_line_18) <= 1e-4
|
||||
assert v_ap4.pressure_test_ach < v_default.pressure_test_ach
|
||||
|
||||
|
||||
def test_ventilation_from_cert_routes_mev_decentralised_to_extract_or_piv_outside_mv_kind() -> None:
|
||||
# Arrange — SAP 10.2 §2 line (23a)/(24c) MEV: the (25)m effective
|
||||
# ach formula for whole-house extract ventilation is `(22b)m + 0.5 ×
|
||||
# (23b)` when (22b) ≥ 0.5×(23b). Pre-slice the cascade hardcoded
|
||||
# `mv_kind=NATURAL` so MEV-decentralised certs routed through (24d)
|
||||
# natural-ventilation instead. Cert 000565's Mechanical Ventilation
|
||||
# Type "Mechanical extract, decentralised (MEV dc)" maps to the
|
||||
# `MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE` enum.
|
||||
base = _typical_semi_detached_epc()
|
||||
with_mev = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=_TYPICAL_TFA_M2,
|
||||
habitable_rooms_count=4,
|
||||
region_code="1",
|
||||
sap_building_parts=base.sap_building_parts,
|
||||
sap_windows=base.sap_windows,
|
||||
sap_heating=base.sap_heating,
|
||||
sap_ventilation=SapVentilation(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"),
|
||||
)
|
||||
|
||||
# Act
|
||||
v_mev = ventilation_from_cert(with_mev)
|
||||
|
||||
# Assert — MEV throughput is (23a)=0.5; (23b)=(23a)×Fmv=0.5×1.0; the
|
||||
# cascade's per-month (25)m = (22b)m + 0.5×(23b) when (22b) ≥
|
||||
# 0.5×(23b), or = (23b) otherwise. Verify the cascade picks the
|
||||
# `EXTRACT_OR_PIV_OUTSIDE` formula directly rather than rely on
|
||||
# (22b) magnitude to disambiguate from the NATURAL fallback.
|
||||
from domain.sap10_calculator.worksheet.ventilation import MechanicalVentilationKind
|
||||
assert v_mev.mv_kind is MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE
|
||||
assert abs(v_mev.mv_system_ach - 0.5) <= 1e-4
|
||||
assert abs(v_mev.mv_system_ach_after_fmv - 0.5) <= 1e-4
|
||||
for w22b, w25 in zip(v_mev.monthly_wind_adjusted_ach, v_mev.effective_monthly_ach):
|
||||
expected = 0.5 if w22b < 0.5 * 0.5 else w22b + 0.5 * 0.5
|
||||
assert abs(w25 - expected) <= 1e-4
|
||||
|
||||
|
||||
def test_open_chimneys_raise_infiltration_ach() -> None:
|
||||
# Arrange — Direction check: chimneys add Table 2.1 volume to the
|
||||
# infiltration calc, so an otherwise identical dwelling with 2 open
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue