Slice S0380.99: PCDB Table 329 (MV In-Use Factors) ETL + parser + lookup (PCDF Spec §A.20)

PCDF Spec Rev 6b §A.20 (May 2021) Format 430 — Mechanical Ventilation
In-Use Factors Table. Pcdb10.dat carries Format 432 (header
`$329,432,4,2021,11,25,2`), an extended-field version where Format
430 fields 1-4 (system_type + 3 SFP factors for the "no approved
scheme" variant) align at positions 0..3. The remainder of Format
432 carries MVHR adjustments + "with approved scheme" variants +
additional Format 432 columns, preserved verbatim in `raw` for
follow-up slices.

Per PCDF Spec §A.20 field 1 — system types:
  1  = centralised MEV
  2  = decentralised MEV
  3  = balanced whole-house MV (with or without heat recovery)
  5  = positive input ventilation (PIV)
  10 = default data (used with SAP Table 4g defaults)

Decentralised MEV (system_type=2) IUFs:
  SFP × ducting type:
    flexible:   1.45 (field 2)
    rigid:      1.30 (field 3)
    no-duct:    1.15 (field 4 — through-wall fans)

Per spec Note: "If there is no applicable approved installation
scheme the values for with and without scheme are the same." Cert
000565 lodges "Approved Installation: No" → use the "no scheme"
IUFs.

Validation for cert 000565 against worksheet line (230a):
  Σ(SFP_j × FR_j × IUF_j) for the 4 lodged fans:
    in-room kitchen:        1×0.15×13×1.45 = 2.8275
    in-room other wet:      1×0.15× 8×1.45 = 1.7400
    through-wall kitchen:   2×0.11×13×1.15 = 3.2890
    through-wall other wet: 3×0.14× 8×1.15 = 3.8640
  Σ = 11.7205 W (matches worksheet "total watage = 11.7205")
  Σ(FR_j) = 92.0 l/s (matches worksheet "total flow = 92.0000")
  SFPav = 11.7205 / 92.0 = 0.1274 W/(l/s) ✓ matches worksheet

Foundation only this slice — typed parser + ETL + runtime lookup
`mv_in_use_factors_record(system_type)`. No cascade integration; no
behavioural change on any cert. Next slice S0380.100 wires the
SFPav formula.

5 Table 329 records ingested. 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:20:02 +00:00
parent b3330821e7
commit 433f4a49ce
6 changed files with 234 additions and 0 deletions

View file

@ -32,8 +32,10 @@ from domain.sap10_calculator.tables.pcdb.parser import (
GasOilBoilerRecord,
HeatPumpRecord,
MevFanConfig,
MvInUseFactorsRecord,
parse_decentralised_mev_row,
parse_heat_pump_row_raw,
parse_mv_in_use_factors_row,
)
__all__ = [
@ -41,9 +43,11 @@ __all__ = [
"GasOilBoilerRecord",
"HeatPumpRecord",
"MevFanConfig",
"MvInUseFactorsRecord",
"decentralised_mev_record",
"gas_oil_boiler_record",
"heat_pump_record",
"mv_in_use_factors_record",
]
@ -54,6 +58,9 @@ _TABLE_105_JSONL: Final[Path] = (
_TABLE_322_JSONL: Final[Path] = (
_PCDB_DATA_DIR / "pcdb_table_322_decentralised_mev.jsonl"
)
_TABLE_329_JSONL: Final[Path] = (
_PCDB_DATA_DIR / "pcdb_table_329_mv_in_use_factors.jsonl"
)
_TABLE_362_JSONL: Final[Path] = (
_PCDB_DATA_DIR / "pcdb_table_362_heat_pumps.jsonl"
)
@ -172,3 +179,36 @@ def decentralised_mev_record(pcdb_id: int) -> Optional[DecentralisedMevRecord]:
for MEV centralised or decentralised) per the spec's first-tier
cascade rule (§2.6.3 / Table 4g note 1)."""
return _TABLE_322_BY_ID.get(pcdb_id)
def _load_table_329() -> dict[int, MvInUseFactorsRecord]:
"""Read the Table 329 NDJSON at import time and build a by-system-
type dict of typed `MvInUseFactorsRecord`s. Returns empty when the
jsonl is missing (ETL bootstrap concession; production callers
always observe the committed file)."""
records_by_type: dict[int, MvInUseFactorsRecord] = {}
if not _TABLE_329_JSONL.exists():
return records_by_type
with _TABLE_329_JSONL.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
data = json.loads(line)
raw_fields = tuple(data["raw"])
record = parse_mv_in_use_factors_row(",".join(raw_fields))
records_by_type[record.system_type] = record
return records_by_type
_TABLE_329_BY_SYSTEM_TYPE: Final[dict[int, MvInUseFactorsRecord]] = _load_table_329()
def mv_in_use_factors_record(system_type: int) -> Optional[MvInUseFactorsRecord]:
"""Table 329 lookup by SAP 10.2 ventilation system type (1, 2, 3,
5, 10 per PCDF Spec §A.20). Returns None when the system_type is
not in Table 329 caller can fall back to SAP 10.2 Table 4g
defaults (system_type=10) or skip the IUF adjustment per spec
Note: "If there is no applicable approved installation scheme the
values for with and without scheme are the same"."""
return _TABLE_329_BY_SYSTEM_TYPE.get(system_type)

View file

@ -0,0 +1,5 @@
{"system_type": 1, "raw": ["1", "1.7", "1.4", "", "", "", "", "", "1.6", "1.3", "", "", "", "", "", "", "", "", "", "", "", "", "2021/Nov/22 14:55"]}
{"system_type": 2, "raw": ["2", "1.45", "1.3", "1.15", "", "", "", "", "1.45", "1.3", "1.15", "", "", "", "", "", "", "", "", "", "", "", "2021/Nov/22 14:55"]}
{"system_type": 5, "raw": ["5", "1.7", "1.4", "", "", "", "", "", "1.6", "1.25", "", "", "", "", "", "1.1", "1.1", "", "", "", "", "", "2021/Nov/22 14:55"]}
{"system_type": 3, "raw": ["3", "1.7", "1.4", "", "0.25", "0.5", "0.9", "0.8", "1.6", "1.25", "", "0.25", "0.5", "0.9", "0.8", "1.1", "1.1", "", "0.25", "0.5", "0.9", "0.8", "2021/Nov/22 14:55"]}
{"system_type": 10, "raw": ["10", "2.5", "2.5", "2.5", "0.25", "0.25", "0.7", "0.7", "2.5", "2.5", "2.5", "0.25", "0.25", "0.7", "0.7", "2.5", "2.5", "2.5", "0.25", "0.25", "0.7", "0.7", "2021/Nov/22 14:55"]}

View file

@ -16,15 +16,18 @@ from pathlib import Path
from domain.sap10_calculator.tables.pcdb.parser import (
DecentralisedMevRecord,
GasOilBoilerRecord,
MvInUseFactorsRecord,
RawPcdbRecord,
parse_table_105,
parse_table_322,
parse_table_329,
parse_table_raw,
)
_TABLE_105_OUTPUT_FILENAME: str = "pcdb_table_105_gas_oil_boilers.jsonl"
_TABLE_322_OUTPUT_FILENAME: str = "pcdb_table_322_decentralised_mev.jsonl"
_TABLE_329_OUTPUT_FILENAME: str = "pcdb_table_329_mv_in_use_factors.jsonl"
# Tables ingested as `RawPcdbRecord` (pcdb_id + raw) — per-field typing is
# deferred to follow-up slices when the cert-side wiring for each table
# lands.
@ -81,6 +84,17 @@ def run_etl(*, source: Path, output_dir: Path) -> None:
for r in parse_table_322(dat_text)
],
)
# Table 329 (MV In-Use Factors) — typed via `parse_table_329`,
# exposing the per-ducting-type SFP IUF multipliers for "no
# approved scheme" installations (the only variant our cohort
# exercises). Stored as raw row + typed-on-load.
_write_ndjson(
output_path=output_dir / _TABLE_329_OUTPUT_FILENAME,
records=[
_mv_in_use_factors_record_to_jsonable(r)
for r in parse_table_329(dat_text)
],
)
for table_id, filename in _RAW_TABLES.items():
_write_ndjson(
output_path=output_dir / filename,
@ -98,6 +112,16 @@ def _decentralised_mev_record_to_jsonable(
return {"pcdb_id": record.pcdb_id, "raw": list(record.raw)}
def _mv_in_use_factors_record_to_jsonable(
record: MvInUseFactorsRecord,
) -> dict[str, object]:
"""Serialise a typed Table 329 record. Table 329 is keyed by
`system_type` rather than `pcdb_id`, so this dict uses `system_type`
as the primary identifier; lookup callers `mv_in_use_factors(
system_type)` resolve via the same key."""
return {"system_type": record.system_type, "raw": list(record.raw)}
if __name__ == "__main__": # pragma: no cover — manual ETL invocation
data_dir = Path(__file__).resolve().parent / "data"
run_etl(

View file

@ -519,6 +519,79 @@ def parse_table_322(dat_text: str) -> list[DecentralisedMevRecord]:
return [parse_decentralised_mev_row(row) for row in _walk_table_records(dat_text, "322")]
# Table 329 (MV In-Use Factors) — PCDF Spec Rev 6b §A.20 Format 430.
# pcdb10.dat carries Format 432 (header `$329,432,4,2021,11,25,2`), an
# extended-field version of spec Format 430. The spec's first 4 fields
# (system_type + 3 SFP factors for "no approved scheme") align with the
# Format 432 layout positions 0-3 — the only positions this slice
# decodes. Trailing fields (MVHR adjustments + "with-scheme" variants +
# additional Format 432 columns) are preserved verbatim in `raw` for
# follow-up slices.
#
# System types per PCDF Spec §A.20 field 1:
# 1 = centralised MEV
# 2 = decentralised MEV
# 3 = balanced whole-house MV (with or without heat recovery)
# 5 = positive input ventilation (PIV)
# 10 = default data (used when SFP / efficiency are taken from SAP
# Table 4g rather than the PCDB)
_MV_IUF_IDX_SYSTEM_TYPE: Final[int] = 0
_MV_IUF_IDX_SFP_FLEX_NO_SCHEME: Final[int] = 1
_MV_IUF_IDX_SFP_RIGID_NO_SCHEME: Final[int] = 2
_MV_IUF_IDX_SFP_NO_DUCT_NO_SCHEME: Final[int] = 3
@dataclass(frozen=True)
class MvInUseFactorsRecord:
"""PCDB Table 329 (MV In-Use Factors) typed record.
SAP 10.2 §2.6 + §2.6.4 in-use factors (IUF) are multiplied into
the PCDB SFP per equation (1) to allow for additional ductwork
losses encountered in practice. Per PCDF Spec §A.20 Note 1: "If
there is no applicable approved installation scheme the values
for with and without scheme are the same" — so this slice exposes
the "no scheme" SFP IUFs only; with-scheme variants are deferred
until a fixture lodges an approved installation.
Fields are Optional because each system_type populates a subset
(e.g. centralised MEV lodges the flex / rigid IUFs but no
through-wall the no-duct field is blank).
"""
system_type: int
sfp_iuf_flexible_no_scheme: Optional[float]
sfp_iuf_rigid_no_scheme: Optional[float]
sfp_iuf_no_duct_no_scheme: Optional[float]
raw: tuple[str, ...]
def parse_table_329(dat_text: str) -> list[MvInUseFactorsRecord]:
"""Walk a PCDB dat string, yielding parsed Table 329 (MV In-Use
Factors) records. One record per `system_type`; SFP IUFs decoded
for the "no scheme" variant per PCDF Spec §A.20 Note 1."""
return [parse_mv_in_use_factors_row(row) for row in _walk_table_records(dat_text, "329")]
def parse_mv_in_use_factors_row(row: str) -> MvInUseFactorsRecord:
"""Decode one Table 329 (MV In-Use Factors) Format-432 row into a
typed `MvInUseFactorsRecord`. Positions 0..3 align with PCDF Spec
Format 430 fields 1..4 the "no approved scheme" SFP IUFs."""
fields = tuple(row.rstrip("\r\n").split(","))
return MvInUseFactorsRecord(
system_type=int(fields[_MV_IUF_IDX_SYSTEM_TYPE]),
sfp_iuf_flexible_no_scheme=_parse_optional_float(
fields[_MV_IUF_IDX_SFP_FLEX_NO_SCHEME]
),
sfp_iuf_rigid_no_scheme=_parse_optional_float(
fields[_MV_IUF_IDX_SFP_RIGID_NO_SCHEME]
),
sfp_iuf_no_duct_no_scheme=_parse_optional_float(
fields[_MV_IUF_IDX_SFP_NO_DUCT_NO_SCHEME]
),
raw=fields,
)
def parse_decentralised_mev_row(row: str) -> DecentralisedMevRecord:
"""Decode one Table 322 (Decentralised MEV) Format-428 row into a
typed `DecentralisedMevRecord`.

View file

@ -310,6 +310,7 @@ def test_run_etl_writes_all_pcdb_table_jsonl_files(tmp_path: Path) -> None:
"pcdb_table_143_micro_cogen.jsonl",
"pcdb_table_313_flue_gas_heat_recovery.jsonl",
"pcdb_table_322_decentralised_mev.jsonl",
"pcdb_table_329_mv_in_use_factors.jsonl",
"pcdb_table_353_waste_water_heat_recovery.jsonl",
"pcdb_table_362_heat_pumps.jsonl",
"pcdb_table_391_high_heat_retention_storage_heaters.jsonl",

View file

@ -0,0 +1,91 @@
"""Tests for the runtime PCDB Table 329 (MV In-Use Factors) lookup.
Table 329 maps each ventilation system_type (1=centralised MEV,
2=decentralised MEV, 3=balanced w/wo HR, 5=PIV, 10=default) to a set
of multiplicative in-use factors (IUF) that adjust the PCDB-lodged
Specific Fan Power for the ducting type actually installed.
The cascade applies the IUFs via SAP 10.2 §2.6.4 equation (1) in
the SFPav numerator the PCDB SFP for each fan is multiplied by the
applicable in-use factor before the flow-weighted average is taken.
Reference: BRE PCDB pcdb10.dat (header `$329,432,4,2021,11,25,2`);
PCDF Spec Rev 6b §A.20 Format 430; SAP 10.2 specification §2.6 +
§2.6.4.
"""
from __future__ import annotations
from domain.sap10_calculator.tables.pcdb import mv_in_use_factors_record
def test_mv_in_use_factors_for_decentralised_mev_returns_per_ducting_iufs() -> None:
"""SAP 10.2 §2.6.4 — decentralised MEV (system_type=2) lodges
three per-ducting SFP in-use factors at PCDF Spec §A.20 fields
2, 3, 4 (Format 432 positions 1, 2, 3):
flexible ducting: 1.45
rigid ducting: 1.30
no ducting (TW): 1.15
The "no scheme" variants are exercised by cert 000565 (Approved
Installation: No); the with-scheme variants are deferred until a
cohort cert lodges an approved installation.
Cert 000565 validation:
ws line (230a) = IUF × SFPav × 1.22 × V 127.5159 kWh
where SFPav = Σ(SFP_j × FR_j × IUF_j) / Σ(FR_j) = 11.7205 / 92 = 0.1274
and IUF_j {1.45 (in-room flex), 1.15 (through-wall)}
"""
# Arrange / Act
record = mv_in_use_factors_record(2)
# Assert
assert record is not None
assert record.system_type == 2
assert record.sfp_iuf_flexible_no_scheme == 1.45
assert record.sfp_iuf_rigid_no_scheme == 1.30
assert record.sfp_iuf_no_duct_no_scheme == 1.15
def test_mv_in_use_factors_for_centralised_mev_has_no_through_wall_iuf() -> None:
"""Centralised MEV (system_type=1) doesn't include "through-the-
wall" / no-duct as a valid fan location — fans are centrally
located with ducting to each wet room. The PCDB record reflects
this by leaving field 4 (no-duct IUF) blank."""
# Arrange / Act
record = mv_in_use_factors_record(1)
# Assert
assert record is not None
assert record.system_type == 1
assert record.sfp_iuf_flexible_no_scheme == 1.7
assert record.sfp_iuf_rigid_no_scheme == 1.4
assert record.sfp_iuf_no_duct_no_scheme is None
def test_mv_in_use_factors_for_default_data_system_type_10() -> None:
"""System type 10 = default data per PCDF Spec §A.20: used when SFP
and efficiency are taken from SAP Table 4g rather than the PCDB.
All three SFP IUFs are 2.5 (per the pcdb10.dat record), reflecting
a conservative spread for un-lodged products."""
# Arrange / Act
record = mv_in_use_factors_record(10)
# Assert
assert record is not None
assert record.system_type == 10
assert record.sfp_iuf_flexible_no_scheme == 2.5
assert record.sfp_iuf_rigid_no_scheme == 2.5
assert record.sfp_iuf_no_duct_no_scheme == 2.5
def test_mv_in_use_factors_returns_none_for_unknown_system_type() -> None:
"""A system_type not in Table 329 returns None so callers can
fall back to default (system_type=10) or skip the IUF adjustment
entirely."""
# Arrange / Act
record = mv_in_use_factors_record(99)
# Assert
assert record is None