From 518471fa80c6bb90c240b2ff0d0f29538ee45a9b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 15:20:02 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.99:=20PCDB=20Table=20329=20(MV=20?= =?UTF-8?q?In-Use=20Factors)=20ETL=20+=20parser=20+=20lookup=20(PCDF=20Spe?= =?UTF-8?q?c=20=C2=A7A.20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sap10_calculator/tables/pcdb/__init__.py | 40 ++++++++ .../pcdb_table_329_mv_in_use_factors.jsonl | 5 + domain/sap10_calculator/tables/pcdb/etl.py | 24 +++++ domain/sap10_calculator/tables/pcdb/parser.py | 73 +++++++++++++++ .../sap10_calculator/tests/test_pcdb_etl.py | 1 + .../tests/test_pcdb_table_329_lookup.py | 91 +++++++++++++++++++ 6 files changed, 234 insertions(+) create mode 100644 domain/sap10_calculator/tables/pcdb/data/pcdb_table_329_mv_in_use_factors.jsonl create mode 100644 domain/sap10_calculator/tests/test_pcdb_table_329_lookup.py diff --git a/domain/sap10_calculator/tables/pcdb/__init__.py b/domain/sap10_calculator/tables/pcdb/__init__.py index d3459cbc..017d98e1 100644 --- a/domain/sap10_calculator/tables/pcdb/__init__.py +++ b/domain/sap10_calculator/tables/pcdb/__init__.py @@ -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) diff --git a/domain/sap10_calculator/tables/pcdb/data/pcdb_table_329_mv_in_use_factors.jsonl b/domain/sap10_calculator/tables/pcdb/data/pcdb_table_329_mv_in_use_factors.jsonl new file mode 100644 index 00000000..c857ad79 --- /dev/null +++ b/domain/sap10_calculator/tables/pcdb/data/pcdb_table_329_mv_in_use_factors.jsonl @@ -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"]} diff --git a/domain/sap10_calculator/tables/pcdb/etl.py b/domain/sap10_calculator/tables/pcdb/etl.py index 7eb4fa1b..c34bc513 100644 --- a/domain/sap10_calculator/tables/pcdb/etl.py +++ b/domain/sap10_calculator/tables/pcdb/etl.py @@ -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( diff --git a/domain/sap10_calculator/tables/pcdb/parser.py b/domain/sap10_calculator/tables/pcdb/parser.py index 2cc1f553..1b4dc80e 100644 --- a/domain/sap10_calculator/tables/pcdb/parser.py +++ b/domain/sap10_calculator/tables/pcdb/parser.py @@ -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`. diff --git a/domain/sap10_calculator/tests/test_pcdb_etl.py b/domain/sap10_calculator/tests/test_pcdb_etl.py index da2ad935..13770f83 100644 --- a/domain/sap10_calculator/tests/test_pcdb_etl.py +++ b/domain/sap10_calculator/tests/test_pcdb_etl.py @@ -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", diff --git a/domain/sap10_calculator/tests/test_pcdb_table_329_lookup.py b/domain/sap10_calculator/tests/test_pcdb_table_329_lookup.py new file mode 100644 index 00000000..21bf191d --- /dev/null +++ b/domain/sap10_calculator/tests/test_pcdb_table_329_lookup.py @@ -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