Slice S0380.98: PCDB Table 322 (Decentralised MEV) ETL + parser + lookup (PCDF Spec §A.19)

PCDF Spec Rev 6b §A.19 (May 2021) Format 427 — Decentralised MEV
Systems Table. Pcdb10.dat carries the per-fan-configuration block in
Format 428 (header `$322,428,72,...`), which drops the spec's per-
group "Fan speed setting" string. Each group is a 3-field triplet:
(config_code, flow_l_per_s, sfp_w_per_l_per_s).

Per the spec § field 14, the 6 fan configurations are:
  1 = In-room fan, kitchen
  2 = In-room fan, other wet room
  3 = In-duct fan, kitchen
  4 = In-duct fan, other wet room
  5 = Through-wall fan, kitchen
  6 = Through-wall fan, other wet room

Some configurations may be blank per spec Note 1 — these are not
valid SAP selections and are excluded from the SFPav summation
downstream.

This slice lands the foundation only — typed parser, ETL promotion
to typed write, and a runtime lookup `decentralised_mev_record(pcdb_
id)`. No cascade integration yet → no behavioural change on any
cert; full test suite + cert 000565 expected fails unchanged.

Subsequent slices in the arc:
- S0380.99: PCDB Table 329 (In-Use Factors) ETL + lookup
- S0380.100: SAP 10.2 §2.6.4 SFPav cascade helper
- S0380.101: HP SAP code 211-227 / 521-527 → main_heating_category=4
- S0380.102: wire MEV cascade into pumps_fans

Cert 000565 lodges `MV PCDF Reference Number = 500755` (Titon
Ultimate dMEV), resolving via this lookup to:
  config 1 (in-room kitchen):     flow=13.0, SFP=0.15 W/(l/s)
  config 2 (in-room other wet):   flow=8.0,  SFP=0.15
  config 3 (in-duct kitchen):     not tested
  config 4 (in-duct other wet):   not tested
  config 5 (thru-wall kitchen):   flow=13.0, SFP=0.11
  config 6 (thru-wall other wet): flow=8.0,  SFP=0.14

48 Table 322 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:12:55 +00:00 committed by Jun-te Kim
parent 9458a03021
commit 8c1f895b2d
6 changed files with 344 additions and 5 deletions

View file

@ -28,14 +28,20 @@ from pathlib import Path
from typing import Final, Optional
from domain.sap10_calculator.tables.pcdb.parser import (
DecentralisedMevRecord,
GasOilBoilerRecord,
HeatPumpRecord,
MevFanConfig,
parse_decentralised_mev_row,
parse_heat_pump_row_raw,
)
__all__ = [
"DecentralisedMevRecord",
"GasOilBoilerRecord",
"HeatPumpRecord",
"MevFanConfig",
"decentralised_mev_record",
"gas_oil_boiler_record",
"heat_pump_record",
]
@ -45,6 +51,9 @@ _PCDB_DATA_DIR: Final[Path] = Path(__file__).resolve().parent / "data"
_TABLE_105_JSONL: Final[Path] = (
_PCDB_DATA_DIR / "pcdb_table_105_gas_oil_boilers.jsonl"
)
_TABLE_322_JSONL: Final[Path] = (
_PCDB_DATA_DIR / "pcdb_table_322_decentralised_mev.jsonl"
)
_TABLE_362_JSONL: Final[Path] = (
_PCDB_DATA_DIR / "pcdb_table_362_heat_pumps.jsonl"
)
@ -124,3 +133,42 @@ def heat_pump_record(pcdb_id: int) -> Optional[HeatPumpRecord]:
Table 4a heat-pump category default (which in turn requires gateway
work elsewhere in the cascade)."""
return _TABLE_362_BY_ID.get(pcdb_id)
def _load_table_322() -> dict[int, DecentralisedMevRecord]:
"""Read the Table 322 NDJSON at import time and build a by-pcdb-id
dict of typed `DecentralisedMevRecord`s. Each NDJSON row carries the
raw field tuple parsed once at PCDB ETL time; we re-decode via
`parse_decentralised_mev_row` here for consistency with the Table
362 pattern (typed-on-load from raw tuple).
Returns an empty dict when the jsonl file is missing this lets
the ETL bootstrap from scratch (the ETL re-imports this module
before the jsonl exists on first ingest). The file is committed
in-repo so production callers always observe a populated dict.
"""
records_by_id: dict[int, DecentralisedMevRecord] = {}
if not _TABLE_322_JSONL.exists():
return records_by_id
with _TABLE_322_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_decentralised_mev_row(",".join(raw_fields))
records_by_id[record.pcdb_id] = record
return records_by_id
_TABLE_322_BY_ID: Final[dict[int, DecentralisedMevRecord]] = _load_table_322()
def decentralised_mev_record(pcdb_id: int) -> Optional[DecentralisedMevRecord]:
"""Table 322 lookup by `MV PCDF Reference Number` (cert lodgement
field). Returns None when the index is not in Table 322 caller
falls back to the SAP 10.2 Table 4g default SFP (0.8 W/(litre/sec)
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)

View file

@ -0,0 +1,48 @@
{"pcdb_id": 500755, "raw": ["500755", "020007", "0", "2022/Mar/29 15:32", "Titon Hardware Ltd", "Titon", "Ultimate dMEV", "", "2021", "current", "", "2", "6", "1", "13.0", "0.15", "2", "8.0", "0.15", "3", "", "", "4", "", "", "5", "13.0", "0.11", "6", "8.0", "0.14"]}
{"pcdb_id": 500756, "raw": ["500756", "020007", "0", "2022/Mar/29 15:32", "Titon Hardware Ltd", "Titon", "Ultimate dMEV H", "", "2021", "current", "", "2", "6", "1", "13.0", "0.15", "2", "8.0", "0.15", "3", "", "", "4", "", "", "5", "13.0", "0.11", "6", "8.0", "0.14"]}
{"pcdb_id": 500757, "raw": ["500757", "020007", "0", "2022/Mar/29 15:31", "Titon Hardware Ltd", "Titon", "Ultimate dMEV HD", "", "2021", "current", "", "2", "6", "1", "13.0", "0.15", "2", "8.0", "0.15", "3", "", "", "4", "", "", "5", "13.0", "0.11", "6", "8.0", "0.14"]}
{"pcdb_id": 500769, "raw": ["500769", "020004", "0", "2022/Apr/08 14:10", "Zehnder Group UK Ltd", "Greenwood", "Unity CV3", "", "2017", "current", "", "2", "6", "1", "13.0", "0.15", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.11", "6", "8.0", "0.09"]}
{"pcdb_id": 500776, "raw": ["500776", "020002", "0", "2023/Apr/18 11:10", "Vent Axia Ltd", "Vent Axia", "Lo-Carbon NBR dMEV C 100", "498095", "2022", "current", "", "2", "6", "1", "13.0", "0.14", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.08"]}
{"pcdb_id": 500777, "raw": ["500777", "020002", "0", "2023/Apr/18 11:12", "Vent Axia Ltd", "Vent Axia", "Lo-Carbon NBR dMEV C 100 HT", "498096", "2022", "current", "", "2", "6", "1", "13.0", "0.14", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.08"]}
{"pcdb_id": 500778, "raw": ["500778", "020002", "0", "2023/Apr/18 11:18", "Vent Axia Ltd", "Vent Axia", "Lo-Carbon NBR dMEV C 125", "498097", "2022", "current", "", "2", "6", "1", "13.0", "0.14", "2", "8.0", "0.12", "3", "", "", "4", "", "", "5", "13.0", "0.09", "6", "8.0", "0.10"]}
{"pcdb_id": 500779, "raw": ["500779", "020002", "0", "2023/Apr/18 11:26", "Vent Axia Ltd", "Vent Axia", "Lo-Carbon NBR dMEV C 125 HT", "498098", "2022", "current", "", "2", "6", "1", "13.0", "0.14", "2", "8.0", "0.12", "3", "", "", "4", "", "", "5", "13.0", "0.09", "6", "8.0", "0.10"]}
{"pcdb_id": 500787, "raw": ["500787", "020004", "0", "2022/Aug/22 09:53", "Zehnder Group UK Ltd", "Greenwood", "Unity CV2.1", "", "2022", "current", "", "2", "6", "1", "13.0", "0.13", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.10", "6", "8.0", "0.10"]}
{"pcdb_id": 500788, "raw": ["500788", "020004", "0", "2022/Aug/22 09:53", "Zehnder Group UK Ltd", "Greenwood", "Unity CV2.1CTA110", "Semi-Rigid", "2022", "current", "", "2", "6", "1", "13.0", "0.13", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.10", "6", "8.0", "0.10"]}
{"pcdb_id": 500805, "raw": ["500805", "020003", "0", "2023/Mar/09 17:46", "The Nuaire Group", "Nuaire", "FAITH-PLUS", "", "2022", "current", "", "2", "6", "1", "13.0", "0.23", "2", "8.0", "0.28", "3", "", "", "4", "", "", "5", "13.0", "0.16", "6", "8.0", "0.23"]}
{"pcdb_id": 500806, "raw": ["500806", "020003", "0", "2023/Mar/09 16:32", "The Nuaire Group", "Nuaire", "ISENSE-PLUS", "", "2022", "current", "", "2", "6", "1", "13.0", "0.23", "2", "8.0", "0.28", "3", "", "", "4", "", "", "5", "13.0", "0.16", "6", "8.0", "0.23"]}
{"pcdb_id": 500814, "raw": ["500814", "020002", "0", "2023/Apr/13 11:02", "Vent Axia Ltd", "Vent Axia", "Lo-Carbon Response 7 100 PRO (SAP 10)", "494144A", "2022", "current", "", "2", "6", "1", "11.0", "0.19", "2", "7.0", "0.18", "3", "", "", "4", "", "", "5", "13.0", "0.11", "6", "8.0", "0.14"]}
{"pcdb_id": 500815, "raw": ["500815", "020002", "0", "2023/Apr/13 11:03", "Vent Axia Ltd", "Vent Axia", "Lo-Carbon NBR dMEV 100 (SAP 10)", "475142B", "2022", "current", "", "2", "6", "1", "11.0", "0.19", "2", "7.0", "0.18", "3", "", "", "4", "", "", "5", "13.0", "0.11", "6", "8.0", "0.14"]}
{"pcdb_id": 500816, "raw": ["500816", "020002", "0", "2023/Apr/13 11:04", "Vent Axia Ltd", "Vent Axia", "Lo-Carbon NBR dMEV 100 HT (SAP 10)", "473809B", "2022", "current", "", "2", "6", "1", "11.0", "0.19", "2", "7.0", "0.18", "3", "", "", "4", "", "", "5", "13.0", "0.11", "6", "8.0", "0.14"]}
{"pcdb_id": 500817, "raw": ["500817", "020027", "0", "2023/Apr/13 11:31", "EnviroVent Ltd", "EnviroVent", "Eco dMEV+", "", "2023", "current", "", "2", "6", "1", "13.0", "0.33", "2", "8.0", "0.23", "3", "", "", "4", "", "", "5", "13.0", "0.32", "6", "8.0", "0.23"]}
{"pcdb_id": 500818, "raw": ["500818", "020027", "0", "2023/Apr/13 11:49", "EnviroVent Ltd", "EnviroVent", "Eco dMEV+ LC", "", "2023", "current", "", "2", "6", "1", "13.0", "0.35", "2", "8.0", "0.24", "3", "", "", "4", "", "", "5", "13.0", "0.31", "6", "8.0", "0.22"]}
{"pcdb_id": 500820, "raw": ["500820", "020205", "0", "2023/Apr/18 14:06", "Aerauliqa srl", "Aerauliqa", "QDMEV100", "", "2023", "current", "", "2", "6", "1", "13.0", "0.19", "2", "8.0", "0.21", "3", "", "", "4", "", "", "5", "13.0", "0.16", "6", "8.0", "0.18"]}
{"pcdb_id": 500825, "raw": ["500825", "020018", "0", "2023/May/30 16:48", "National Ventilation", "National Ventilation", "dMEV 100 STD Fan (SAP 10)", "MON-DMEV100A", "2023", "current", "", "2", "6", "1", "11.0", "0.19", "2", "7.0", "0.18", "3", "", "", "4", "", "", "5", "13.0", "0.11", "6", "8.0", "0.14"]}
{"pcdb_id": 500826, "raw": ["500826", "020018", "0", "2023/May/30 16:47", "National Ventilation", "National Ventilation", "dMEV 100 HST Fan (SAP 10)", "MON-DMEV100HTA", "2023", "current", "", "2", "6", "1", "11.0", "0.19", "2", "7.0", "0.18", "3", "", "", "4", "", "", "5", "13.0", "0.11", "6", "8.0", "0.14"]}
{"pcdb_id": 500828, "raw": ["500828", "020018", "0", "2023/May/30 16:45", "National Ventilation", "National Ventilation", "Monsoon Round dMEV 125 HST", "MON-DMEVR125HT", "2023", "current", "", "2", "6", "1", "13.0", "0.14", "2", "8.0", "0.12", "3", "", "", "4", "", "", "5", "13.0", "0.09", "6", "8.0", "0.10"]}
{"pcdb_id": 500830, "raw": ["500830", "020018", "0", "2023/May/30 16:44", "National Ventilation", "National Ventilation", "Monsoon Round dMEV 125 STD", "MON-DMEVR125", "2023", "current", "", "2", "6", "1", "13.0", "0.14", "2", "8.0", "0.12", "3", "", "", "4", "", "", "5", "13.0", "0.09", "6", "8.0", "0.10"]}
{"pcdb_id": 500832, "raw": ["500832", "020018", "0", "2023/May/30 16:43", "National Ventilation", "National Ventilation", "Monsoon Round dMEV 100 STD", "MON-DMEVR100", "2023", "current", "", "2", "6", "1", "13.0", "0.14", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.08"]}
{"pcdb_id": 500834, "raw": ["500834", "020018", "0", "2023/May/30 16:44", "National Ventilation", "National Ventilation", "Monsoon Round dMEV 100 HST", "MON-DMEVR100HT", "2023", "current", "", "2", "6", "1", "13.0", "0.14", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.08"]}
{"pcdb_id": 500868, "raw": ["500868", "020017", "0", "2023/Dec/15 12:13", "Airflow Developments Ltd", "Airflow", "iCONstant FLEX T", "72687119", "2023", "current", "", "2", "6", "1", "13.0", "0.19", "2", "8.0", "0.22", "3", "", "", "4", "", "", "5", "13.0", "0.15", "6", "8.0", "0.19"]}
{"pcdb_id": 500870, "raw": ["500870", "020017", "0", "2023/Dec/15 12:12", "Airflow Developments Ltd", "Airflow", "iCONstant FLEX HT", "72687120", "2023", "current", "", "2", "6", "1", "13.0", "0.19", "2", "8.0", "0.22", "3", "", "", "4", "", "", "5", "13.0", "0.15", "6", "8.0", "0.19"]}
{"pcdb_id": 500875, "raw": ["500875", "020009", "0", "2024/Feb/12 15:14", "Vortice Ltd", "Vortice", "AER DMEV 100", "", "2023", "current", "500820", "2", "6", "1", "13.0", "0.19", "2", "8.0", "0.21", "3", "", "", "4", "", "", "5", "13.0", "0.16", "6", "8.0", "0.18"]}
{"pcdb_id": 500897, "raw": ["500897", "020011", "0", "2024/Feb/20 07:45", "Vectaire Ltd", "Vectaire", "EL1003 CV", "", "2023", "current", "", "2", "6", "1", "13.0", "0.12", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.09"]}
{"pcdb_id": 500898, "raw": ["500898", "020041", "0", "2024/Mar/22 14:12", "Polypipe Ltd", "Domus Ventilation", "DMEV-NICO", "", "2023", "current", "", "2", "6", "1", "13.0", "0.23", "2", "8.0", "0.28", "3", "", "", "4", "", "", "5", "13.0", "0.16", "6", "8.0", "0.23"]}
{"pcdb_id": 500899, "raw": ["500899", "020205", "0", "2024/Apr/10 16:23", "Aerauliqa srl", "Aerauliqa", "QDMEV150", "", "2023", "current", "", "2", "6", "1", "13.0", "0.09", "2", "8.0", "0.12", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.11"]}
{"pcdb_id": 500912, "raw": ["500912", "020027", "0", "2024/May/29 12:29", "EnviroVent Ltd", "EnviroVent", "Eco dMEV+ with 90mm semi-rigid layout", "", "2023", "current", "", "2", "6", "1", "13.0", "0.31", "2", "8.0", "0.22", "3", "", "", "4", "", "", "5", "13.0", "0.29", "6", "8.0", "0.21"]}
{"pcdb_id": 500921, "raw": ["500921", "020017", "0", "2024/Sep/22 21:23", "Airflow Developments Ltd", "Airflow", "ICONstant T", "72687117", "2014", "current", "", "2", "6", "1", "13.0", "0.18", "2", "8.0", "0.19", "3", "", "", "4", "", "", "5", "13.0", "0.15", "6", "8.0", "0.16"]}
{"pcdb_id": 500922, "raw": ["500922", "020017", "0", "2024/Sep/22 21:23", "Airflow Developments Ltd", "Airflow", "ICONstant HT", "72687118", "2014", "current", "", "2", "6", "1", "13.0", "0.18", "2", "8.0", "0.19", "3", "", "", "4", "", "", "5", "13.0", "0.15", "6", "8.0", "0.16"]}
{"pcdb_id": 500923, "raw": ["500923", "020017", "0", "2024/Sep/22 21:22", "Airflow Developments Ltd", "Airflow", "LOOVENT eco dMEV", "72684308", "2012", "current", "", "2", "6", "1", "13.3", "0.18", "2", "9.3", "0.31", "3", "", "", "4", "", "", "5", "16.9", "0.19", "6", "10.0", "0.33"]}
{"pcdb_id": 500924, "raw": ["500924", "020017", "0", "2024/Sep/22 21:22", "Airflow Developments Ltd", "Airflow", "LOOVENT eco dMEV HT", "72684311", "2012", "current", "", "2", "6", "1", "13.3", "0.18", "2", "9.3", "0.31", "3", "", "", "4", "", "", "5", "16.9", "0.19", "6", "10.0", "0.33"]}
{"pcdb_id": 500926, "raw": ["500926", "020002", "0", "2024/Oct/17 12:40", "Vent-Axia", "Vent Axia", "Lo-Carbon NBR dMEVC 100HT Semi-Rigid", "498096", "2022", "current", "", "2", "6", "1", "13.0", "0.13", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.08"]}
{"pcdb_id": 500927, "raw": ["500927", "020002", "0", "2024/Oct/17 12:43", "Vent-Axia", "Vent Axia", "Lo-Carbon NBR dMEVC 100 Semi-Rigid", "498095", "2022", "current", "", "2", "6", "1", "13.0", "0.13", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.08"]}
{"pcdb_id": 500934, "raw": ["500934", "020027", "0", "2024/Nov/29 08:11", "EnviroVent Ltd", "EnviroVent", "Quro", "", "2024", "current", "", "2", "6", "1", "13.0", "0.11", "2", "8.0", "0.10", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.08"]}
{"pcdb_id": 500965, "raw": ["500965", "020265", "0", "2025/May/23 12:52", "Vent Axia", "Pas Safe Solutions Ltd", "Pas Safe DMEV D100", "PAS-SAFE-ECO-D100", "2024", "current", "", "2", "6", "1", "13.0", "0.19", "2", "8.0", "0.18", "3", "", "", "4", "", "", "5", "13.0", "0.11", "6", "8.0", "0.14"]}
{"pcdb_id": 500978, "raw": ["500978", "020090", "0", "2025/Mar/19 11:36", "Aerauliqa SRL", "Elta", "DEXA dMEV 100", "", "2023", "current", "", "2", "6", "1", "13.0", "0.19", "2", "8.0", "0.21", "3", "", "", "4", "", "", "5", "13.0", "0.16", "6", "8.0", "0.18"]}
{"pcdb_id": 500979, "raw": ["500979", "020009", "0", "2025/Aug/12 09:46", "Vortice Ltd", "Vortice", "AER DMEV 150", "", "2023", "current", "", "2", "6", "1", "13.0", "0.09", "2", "8.0", "0.12", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.11"]}
{"pcdb_id": 500980, "raw": ["500980", "020027", "0", "2025/Aug/12 10:51", "EnviroVent Ltd", "EnviroVent", "QURO with 90mm semi-rigid layout", "", "2025", "current", "", "2", "6", "1", "13.0", "0.14", "2", "8.0", "0.12", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.08"]}
{"pcdb_id": 500992, "raw": ["500992", "020090", "0", "2025/Sep/29 15:29", "Aerauliqa SRL", "Elta", "DEXA dmev 150", "", "2023", "current", "", "2", "6", "1", "13.0", "0.09", "2", "8.0", "0.12", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.11"]}
{"pcdb_id": 500997, "raw": ["500997", "020002", "0", "2025/Nov/06 16:58", "Vent Axia Ltd", "Vent Axia", "PureAir Cleanse", "414942", "2025", "current", "", "2", "6", "1", "13.0", "0.14", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.08"]}
{"pcdb_id": 500998, "raw": ["500998", "020002", "0", "2025/Nov/06 16:59", "Vent Axia Ltd", "Vent Axia", "PureAir Cleanse HT & IAQ", "414943", "2025", "current", "", "2", "6", "1", "13.0", "0.14", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.08"]}
{"pcdb_id": 500999, "raw": ["500999", "020011", "0", "2025/Nov/06 09:32", "Vectaire Ltd", "Vectaire", "MFCF100", "", "2025", "current", "", "2", "6", "1", "13.0", "0.24", "2", "8.0", "0.20", "3", "", "", "4", "", "", "5", "13.0", "0.18", "6", "8.0", "0.17"]}
{"pcdb_id": 501000, "raw": ["501000", "020002", "0", "2025/Nov/06 16:59", "Vent Axia Ltd", "Vent Axia", "PureAir Cleanse HT", "416841", "2025", "current", "", "2", "6", "1", "13.0", "0.14", "2", "8.0", "0.11", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.08"]}
{"pcdb_id": 501002, "raw": ["501002", "020027", "0", "2025/Nov/26 09:41", "EnviroVent Ltd", "EnviroVent", "QURO LC", "", "2025", "current", "", "2", "6", "1", "13.0", "0.11", "2", "8.0", "0.09", "3", "", "", "4", "", "", "5", "13.0", "0.08", "6", "8.0", "0.08"]}

View file

@ -14,14 +14,17 @@ from dataclasses import asdict
from pathlib import Path
from domain.sap10_calculator.tables.pcdb.parser import (
DecentralisedMevRecord,
GasOilBoilerRecord,
RawPcdbRecord,
parse_table_105,
parse_table_322,
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"
# 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.
@ -67,6 +70,17 @@ def run_etl(*, source: Path, output_dir: Path) -> None:
output_path=output_dir / _TABLE_105_OUTPUT_FILENAME,
records=[_gas_oil_record_to_jsonable(r) for r in parse_table_105(dat_text)],
)
# Table 322 (Decentralised MEV) — typed via `parse_table_322` so the
# per-fan-configuration block (config_code, flow, SFP triplets) is
# exposed for the SAP 10.2 §2.6.4 SFPav cascade. Stored as raw row +
# typed-on-load (consistent with Table 362 pattern at `__init__.py`).
_write_ndjson(
output_path=output_dir / _TABLE_322_OUTPUT_FILENAME,
records=[
_decentralised_mev_record_to_jsonable(r)
for r in parse_table_322(dat_text)
],
)
for table_id, filename in _RAW_TABLES.items():
_write_ndjson(
output_path=output_dir / filename,
@ -74,6 +88,16 @@ def run_etl(*, source: Path, output_dir: Path) -> None:
)
def _decentralised_mev_record_to_jsonable(
record: DecentralisedMevRecord,
) -> dict[str, object]:
"""Serialise a typed Table 322 record as `{pcdb_id, raw}` — same
shape as `_raw_record_to_jsonable` so the on-disk format is
identical between raw and typed tables. The lookup re-decodes via
`parse_decentralised_mev_row` at import time."""
return {"pcdb_id": record.pcdb_id, "raw": list(record.raw)}
if __name__ == "__main__": # pragma: no cover — manual ETL invocation
data_dir = Path(__file__).resolve().parent / "data"
run_etl(

View file

@ -435,3 +435,124 @@ def parse_table_105_row(row: str) -> GasOilBoilerRecord:
keep_hot_timer=_parse_optional_int(fields[58]) if len(fields) > 58 else None,
raw=fields,
)
# Table 322 (Decentralised MEV) — PCDF Spec Rev 6b §A.19. Format 428
# stored in pcdb10.dat (header `$322,428,72,...`) extends spec format
# 427 by dropping the per-group "Fan speed setting" string field, so
# each group is a 3-field triplet (config_code, flow_l_per_s, sfp_w_per_l_per_s).
#
# SAP 10.2 fan configuration codes (PCDF Spec §A.19 field 14):
# 1 = In-room fan, kitchen
# 2 = In-room fan, other wet room
# 3 = In-duct fan, kitchen
# 4 = In-duct fan, other wet room
# 5 = Through-wall fan, kitchen
# 6 = Through-wall fan, other wet room
#
# Each PCDB record carries the 6-tuple of (flow_l_per_s, sfp_w_per_l_per_s)
# per configuration; some configurations may be blank (PCDF Spec Note 1:
# "For some products data may not be provided for certain configurations.
# Such configurations are not a valid selection for SAP calculations.").
_TABLE_322_NUM_FAN_CONFIGS: Final[int] = 6
_TABLE_322_GROUP_STRIDE: Final[int] = 3 # (config_idx, flow, sfp)
# Format 428 header offsets (0-indexed); cross-checked against record 500755
# (Titon Ultimate dMEV) whose worksheet line (230a) lookup pins flow 13.0
# / SFP 0.15 on config 1 and flow 8.0 / SFP 0.14 on config 6.
_MEV_IDX_BRAND_NAME: Final[int] = 5
_MEV_IDX_MODEL_NAME: Final[int] = 6
_MEV_IDX_MODEL_QUALIFIER: Final[int] = 7
# Per spec field 11 is "Main type" (=2 for decentralised MEV); record
# layout in pcdb10.dat slots an extra "replacement_id" field between
# `final_year` and `main_type`, so main_type sits at position 11 and the
# fan-config block begins at position 13 (1+1 for main_type + 1 for the
# config count).
_MEV_IDX_MAIN_TYPE: Final[int] = 11
_MEV_IDX_NUM_CONFIGS: Final[int] = 12
_MEV_FAN_GROUP_START: Final[int] = 13
@dataclass(frozen=True)
class MevFanConfig:
"""One fan-configuration row from a Table 322 PCDB record.
`config_code` keys the SAP 10.2 §2.6.4 fan-type matrix (1-6 per
PCDF Spec §A.19 field 14). `flow_rate_l_per_s` is the test flow
rate for the configuration; `sfp_w_per_l_per_s` is the measured
Specific Fan Power in watts per litre-per-second (used in the
SFPav numerator with FR=13 for kitchens, FR=8 for other wet rooms
per SAP 10.2 §2.6.4 equation 1).
"""
config_code: int
flow_rate_l_per_s: Optional[float]
sfp_w_per_l_per_s: Optional[float]
@dataclass(frozen=True)
class DecentralisedMevRecord:
"""PCDB Table 322 (Decentralised MEV) typed record.
SAP 10.2 §2.6.4 decentralised MEV systems lodge a per-fan-type
SFP in the PCDB; the average SFP for SAP calculation is computed as
SFPav = Σ(SFP_j × FR_j × IUF_j) / Σ(FR_j), where FR = 13 l/s for
kitchens and 8 l/s for other wet rooms, and IUF is the in-use
factor from PCDB Table 329 (per ducting type flexible / rigid).
Reference: PCDF Spec Rev 6b §A.19 (Format 427 in spec, Format 428
in pcdb10.dat the spec's "Fan speed setting" string was removed).
"""
pcdb_id: int
brand_name: str
model_name: str
model_qualifier: str
main_type: Optional[int] # =2 for decentralised MEV
fan_configs: tuple[MevFanConfig, ...]
raw: tuple[str, ...]
def parse_table_322(dat_text: str) -> list[DecentralisedMevRecord]:
"""Walk a PCDB dat string, yielding parsed Table 322 (Decentralised
MEV) records via `parse_decentralised_mev_row`. Mirror of
`parse_table_105` for Table 105 (Gas and Oil Boilers)."""
return [parse_decentralised_mev_row(row) for row in _walk_table_records(dat_text, "322")]
def parse_decentralised_mev_row(row: str) -> DecentralisedMevRecord:
"""Decode one Table 322 (Decentralised MEV) Format-428 row into a
typed `DecentralisedMevRecord`.
The header block holds the pcdb_id + manufacturer / brand / model
identifiers; the variable-length fan-configuration block carries
one 3-field triplet per fan-type-and-location permutation. Blank
flow / SFP values mean the configuration was not tested (spec
Note 1) they're stored as None and excluded from the SFPav
summation downstream.
"""
fields = tuple(row.rstrip("\r\n").split(","))
num_configs = _parse_optional_int(fields[_MEV_IDX_NUM_CONFIGS]) or 0
configs: list[MevFanConfig] = []
for j in range(num_configs):
start = _MEV_FAN_GROUP_START + j * _TABLE_322_GROUP_STRIDE
if start + _TABLE_322_GROUP_STRIDE > len(fields):
break
code_str = fields[start].strip()
if not code_str:
continue
configs.append(
MevFanConfig(
config_code=int(code_str),
flow_rate_l_per_s=_parse_optional_float(fields[start + 1]),
sfp_w_per_l_per_s=_parse_optional_float(fields[start + 2]),
)
)
return DecentralisedMevRecord(
pcdb_id=int(fields[0]),
brand_name=fields[_MEV_IDX_BRAND_NAME],
model_name=fields[_MEV_IDX_MODEL_NAME],
model_qualifier=fields[_MEV_IDX_MODEL_QUALIFIER],
main_type=_parse_optional_int(fields[_MEV_IDX_MAIN_TYPE]),
fan_configs=tuple(configs),
raw=fields,
)

View file

@ -297,17 +297,19 @@ def test_parse_table_raw_extracts_heat_pump_records_from_real_pcdb_dat() -> None
assert len(first.raw) > 1 # multi-field row
def test_run_etl_writes_all_eight_pcdb_table_jsonl_files(tmp_path: Path) -> None:
"""Per the user-chosen scope-D ingestion: slice 1 produces JSONL for
all 8 PCDB tables of interest (105 typed; 122/143/313/353/362/391/506
as untyped pcdb_id + raw). Per-table typed refinement is the job of
follow-up slices when their cert-side wiring lands."""
def test_run_etl_writes_all_pcdb_table_jsonl_files(tmp_path: Path) -> None:
"""Per the user-chosen scope-D ingestion: ETL produces JSONL for
every PCDB table of interest (105 typed; 322 typed via
`parse_table_322`; 122/143/313/353/362/391/506 as untyped pcdb_id
+ raw). Per-table typed refinement is the job of follow-up slices
when their cert-side wiring lands."""
# Arrange
expected_filenames = {
"pcdb_table_105_gas_oil_boilers.jsonl",
"pcdb_table_122_solid_fuel_boilers.jsonl",
"pcdb_table_143_micro_cogen.jsonl",
"pcdb_table_313_flue_gas_heat_recovery.jsonl",
"pcdb_table_322_decentralised_mev.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,96 @@
"""Tests for the runtime PCDB Table 322 (Decentralised MEV) lookup.
The lookup loads pcdb_table_322_decentralised_mev.jsonl at import time
and caches a typed `DecentralisedMevRecord` per pcdb_id. Callers
(`cert_to_inputs`) will invoke `decentralised_mev_record(pcdb_id)` to
obtain the record's typed header + per-fan-configuration block, and
feed those into the SAP 10.2 §2.6.4 SFPav formula.
Field positions are documented in PCDF Spec Rev 6b §A.19 (Format 427);
pcdb10.dat (header `$322,428,...`) carries Format 428, which drops the
spec's per-group "Fan speed setting" string field — leaving each
configuration group as a (config_code, flow_l_per_s, sfp_w_per_l_per_s)
triplet.
Reference: BRE PCDB pcdb10.dat (April 2026); PCDF Spec Rev 6b §A.19;
SAP 10.2 specification (14-03-2025) §2.6.4.
"""
from __future__ import annotations
from domain.sap10_calculator.tables.pcdb import decentralised_mev_record
def test_decentralised_mev_record_returns_verified_titon_ultimate_dmev_500755_header() -> None:
"""Titon Ultimate dMEV, PCDB index 500755. The cert 000565
`MV PCDF Reference Number = 500755` resolves to this record, which
drives the worksheet (230a) MEV electricity cascade.
Header fields cross-referenced against pcdb10.dat record:
brand_name: "Titon"
model_name: "Ultimate dMEV"
model_qualifier: ""
main_type: 2 (decentralised MEV per PCDF Spec §A.19 field 11)
"""
# Arrange / Act
record = decentralised_mev_record(500755)
# Assert
assert record is not None
assert record.pcdb_id == 500755
assert record.brand_name == "Titon"
assert record.model_name == "Ultimate dMEV"
assert record.model_qualifier == ""
assert record.main_type == 2
def test_decentralised_mev_record_returns_six_fan_configurations_for_500755() -> None:
"""PCDF Spec §A.19 field 14 enumerates 6 fan configurations
(kitchen × wet-room × in-room/in-duct/through-wall). Titon Ultimate
dMEV record 500755 lodges measured (flow, SFP) for 4 of them; the
in-duct configurations (codes 3 and 4) are blank per spec Note 1.
Configurations per the worksheet's PCDB lookup section:
1 (In-room kitchen): flow=13.0 l/s, SFP=0.15 W/(l/s)
2 (In-room other wet): flow=8.0 l/s, SFP=0.15 W/(l/s)
3 (In-duct kitchen): (blank not tested)
4 (In-duct other wet): (blank not tested)
5 (Through-wall kitchen): flow=13.0 l/s, SFP=0.11 W/(l/s)
6 (Through-wall other wet): flow=8.0 l/s, SFP=0.14 W/(l/s)
"""
# Arrange / Act
record = decentralised_mev_record(500755)
# Assert
assert record is not None
by_code = {c.config_code: c for c in record.fan_configs}
assert set(by_code.keys()) == {1, 2, 3, 4, 5, 6}
# In-room fans (codes 1, 2) — both tested
assert by_code[1].flow_rate_l_per_s == 13.0
assert by_code[1].sfp_w_per_l_per_s == 0.15
assert by_code[2].flow_rate_l_per_s == 8.0
assert by_code[2].sfp_w_per_l_per_s == 0.15
# In-duct fans (codes 3, 4) — blank per PCDF spec Note 1
assert by_code[3].flow_rate_l_per_s is None
assert by_code[3].sfp_w_per_l_per_s is None
assert by_code[4].flow_rate_l_per_s is None
assert by_code[4].sfp_w_per_l_per_s is None
# Through-wall fans (codes 5, 6) — both tested
assert by_code[5].flow_rate_l_per_s == 13.0
assert by_code[5].sfp_w_per_l_per_s == 0.11
assert by_code[6].flow_rate_l_per_s == 8.0
assert by_code[6].sfp_w_per_l_per_s == 0.14
def test_decentralised_mev_record_returns_none_for_unknown_pcdb_id() -> None:
"""An index number not in Table 322 returns None so callers can fall
back to the SAP 10.2 Table 4g default SFP (0.8 W/(litre/sec) for MEV
centralised or decentralised)."""
# Arrange / Act
record = decentralised_mev_record(99999999)
# Assert
assert record is None