mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
9458a03021
commit
8c1f895b2d
6 changed files with 344 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
96
domain/sap10_calculator/tests/test_pcdb_table_322_lookup.py
Normal file
96
domain/sap10_calculator/tests/test_pcdb_table_322_lookup.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue