§4 HW slice 1: PCDB Table 3b combi-loss override

Closes the dominant ~92% of the 000474 HW kWh +14.4% residual that
the post-§10a Table 32 cost-side fix exposed (pre-§10a wrong prices
had been masking it). 000474 HW fuel kWh tightens 2622 → 2320 (+1.2%
over PDF 2292); remaining +1.2% closes when slice 2 (Eq D1 monthly
cascade) lands. 000490 unaffected — PCDB 10328 lodges separate_dhw_
tests=0 (no Table 3b/3c data), falls through to existing Table 3a
default.

- tables/pcdb/parser.py: GasOilBoilerRecord gains 7 typed fields per
  BRE PCDF Spec v1.0 §7.11 — subsidiary_type (field 16), store_type
  (field 39), separate_dhw_tests (field 48), rejected_energy_
  proportion_r1 (field 51), loss_factor_f1_kwh_per_day (field 52),
  loss_factor_f2_kwh_per_day (field 56), rejected_factor_f3_per_
  litre (field 57). Field positions cross-verified against PDF Σ(61)
  = 337.27 vs 000474 worksheet pin 337.19 (Δ 0.02%).
- worksheet/water_heating.py: combi_loss_monthly_kwh_table_3b_row_1_
  instantaneous(r1, F1, energy_content (45)m, daily HW (44)m) — SAP10.2
  Appendix J Table 3b row 1 formula (61)m = (45)m × r1 × fu + F1 × n_m.
  Other Table 3b rows (storage variants) and Table 3c (two-profile)
  deferred until a fixture exercises.
- rdsap/cert_to_inputs.py: _pcdb_table_3b_combi_loss_override builds
  the (61)m override from the PCDB record when separate_dhw_tests=1
  + subsidiary=0 + store_type=0 (instantaneous non-storage path).
  _hot_water_fuel_kwh_per_yr threaded with pcdb_record kwarg; calls
  water_heating_from_cert with the override when present.
- docs/sap-spec/pcdb_table_105_gas_oil_boilers.jsonl: regenerated via
  the ETL to surface the new typed fields alongside the existing
  efficiency columns.

484 tests passing (was 479). e2e ceilings hold: 000474 SAP delta
4 → 3 (within current ceiling of 4 — will tighten further after
slice 2 Eq D1 cascade lands).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-21 22:26:41 +00:00
parent ae8c946179
commit 760e25dea9
7 changed files with 7487 additions and 7236 deletions

File diff suppressed because it is too large Load diff

View file

@ -54,6 +54,7 @@ from domain.ml.sap_efficiencies import (
)
from domain.sap.calculator import CalculatorInputs
from domain.sap.tables.pcdb import gas_oil_boiler_record
from domain.sap.tables.pcdb.parser import GasOilBoilerRecord
from domain.sap.tables.table_12 import (
co2_factor_kg_per_kwh,
primary_energy_factor,
@ -97,6 +98,7 @@ from domain.sap.worksheet.ventilation import (
)
from domain.sap.worksheet.water_heating import (
TABLE_J1_TCOLD_FROM_MAINS_C,
combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
water_heating_from_cert,
)
@ -709,12 +711,47 @@ def _has_bath_from_cert(epc: EpcPropertyData) -> bool:
return n is None or n >= 1
def _pcdb_table_3b_combi_loss_override(
pcdb_record: Optional[GasOilBoilerRecord],
*,
energy_content_monthly_kwh: tuple[float, ...],
daily_hot_water_monthly_l_per_day: tuple[float, ...],
) -> Optional[tuple[float, ...]]:
"""Build a Table 3b row-1 combi-loss override when the PCDB record
lodges single-profile (Profile M only) test data for an instantaneous
combi with non-storage FGHRS or without FGHRS. Returns None for
every other PCDB combi configuration so the worksheet falls back to
the Table 3a default. Other Table 3b/3c rows (storage variants,
integral FGHRS, two-profile Table 3c) are deferred until a fixture
exercises them defaulting to Table 3a is safe (matches the pre-
§4 behaviour) but loses spec accuracy for those configurations."""
if pcdb_record is None:
return None
if pcdb_record.separate_dhw_tests != 1:
return None
if pcdb_record.subsidiary_type not in (None, 0):
return None
if pcdb_record.store_type not in (None, 0):
return None
r1 = pcdb_record.rejected_energy_proportion_r1
f1 = pcdb_record.loss_factor_f1_kwh_per_day
if r1 is None or f1 is None:
return None
return combi_loss_monthly_kwh_table_3b_row_1_instantaneous(
rejected_energy_proportion_r1=r1,
loss_factor_f1_kwh_per_day=f1,
energy_content_monthly_kwh=energy_content_monthly_kwh,
daily_hot_water_monthly_l_per_day=daily_hot_water_monthly_l_per_day,
)
def _hot_water_fuel_kwh_per_yr(
*,
epc: EpcPropertyData,
water_efficiency_pct: float,
is_instantaneous: bool,
primary_age: Optional[str],
pcdb_record: Optional[GasOilBoilerRecord] = None,
) -> tuple[float, tuple[float, ...]]:
"""Annual hot water FUEL kWh (the slot calculator.CalculatorInputs
expects). Wires the SAP10.2 §4 worksheet orchestrator into the cert
@ -753,6 +790,25 @@ def _hot_water_fuel_kwh_per_yr(
has_solar_water_heating=epc.solar_water_heating,
)
return legacy_kwh, zero_monthly
# If the PCDB record carries Profile-M combi-test data (separate_dhw_
# tests=1, instantaneous non-storage), pre-build the (61)m override
# so `water_heating_from_cert` uses Table 3b row 1 instead of the
# Table 3a default. Requires (45)m and (44)m from a prior orchestrator
# invocation; cheapest to call the orchestrator twice (once to derive
# the inputs to the override, once to land the final result with the
# override in place).
bootstrap = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
has_bath=_has_bath_from_cert(epc),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
)
combi_loss_override = _pcdb_table_3b_combi_loss_override(
pcdb_record,
energy_content_monthly_kwh=bootstrap.energy_content_monthly_kwh,
daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly,
)
result = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
@ -762,6 +818,7 @@ def _hot_water_fuel_kwh_per_yr(
# a domain-model field + plumb-through in a future slice.
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
combi_loss_monthly_kwh_override=combi_loss_override,
)
if water_efficiency_pct <= 0:
return 0.0, result.heat_gains_monthly_kwh
@ -1035,6 +1092,7 @@ def cert_to_inputs(
water_efficiency_pct=water_eff,
is_instantaneous=is_instantaneous,
primary_age=primary_age,
pcdb_record=pcdb_main,
)
lighting_kwh = predicted_lighting_kwh(
total_floor_area_m2=epc.total_floor_area_m2,

View file

@ -61,6 +61,13 @@ def _load_table_105() -> dict[int, GasOilBoilerRecord]:
comparative_hot_water_efficiency_pct=data["comparative_hot_water_efficiency_pct"],
output_kw_max=data["output_kw_max"],
final_year_of_manufacture=data["final_year_of_manufacture"],
subsidiary_type=data.get("subsidiary_type"),
store_type=data.get("store_type"),
separate_dhw_tests=data.get("separate_dhw_tests"),
rejected_energy_proportion_r1=data.get("rejected_energy_proportion_r1"),
loss_factor_f1_kwh_per_day=data.get("loss_factor_f1_kwh_per_day"),
loss_factor_f2_kwh_per_day=data.get("loss_factor_f2_kwh_per_day"),
rejected_factor_f3_per_litre=data.get("rejected_factor_f3_per_litre"),
raw=tuple(data["raw"]),
)
records_by_id[record.pcdb_id] = record

View file

@ -64,6 +64,24 @@ class GasOilBoilerRecord:
comparative_hot_water_efficiency_pct: Optional[float]
output_kw_max: Optional[float]
final_year_of_manufacture: Optional[int]
# SAP10.2 Appendix J Table 3b/3c — combi-loss fields per BRE PCDF
# Spec v1.0 §7.11 fields 48 / 51 / 52 / 56 / 57. Populated only for
# boilers EN 13203-2 / OPS 26 tested; SAP-default boilers leave them
# all blank → `separate_dhw_tests=0` and (61)m falls back to Table 3a.
# BRE PCDF Spec v1.0 §7.11 field 16 (0-idx 15): 0=normal, 1=integral
# FGHRS, 2=combined HP+boiler, 3=combined HP+boiler+FGHRS. Gates the
# Table 3b/3c row selection — only `subsidiary_type=0` exercises the
# "Instantaneous with non-storage FGHRS or without FGHRS" row 1.
subsidiary_type: Optional[int]
# BRE PCDF Spec v1.0 §7.11 field 39 (0-idx 38): 0=not storage combi,
# 1=primary water store, 2=secondary store, 3=CPSU. Gates storage-
# combi rows in Table 3b/3c (deferred until a fixture exercises).
store_type: Optional[int]
separate_dhw_tests: Optional[int]
rejected_energy_proportion_r1: Optional[float]
loss_factor_f1_kwh_per_day: Optional[float]
loss_factor_f2_kwh_per_day: Optional[float]
rejected_factor_f3_per_litre: Optional[float]
raw: tuple[str, ...]
@ -141,5 +159,12 @@ def parse_table_105_row(row: str) -> GasOilBoilerRecord:
winter_efficiency_pct=_parse_optional_float(fields[25]),
summer_efficiency_pct=_parse_optional_float(fields[26]),
comparative_hot_water_efficiency_pct=_parse_optional_float(fields[28]),
subsidiary_type=_parse_optional_int(fields[15]),
store_type=_parse_optional_int(fields[38]),
separate_dhw_tests=_parse_optional_int(fields[47]),
rejected_energy_proportion_r1=_parse_optional_float(fields[50]),
loss_factor_f1_kwh_per_day=_parse_optional_float(fields[51]),
loss_factor_f2_kwh_per_day=_parse_optional_float(fields[55]),
rejected_factor_f3_per_litre=_parse_optional_float(fields[56]),
raw=fields,
)

View file

@ -34,6 +34,22 @@ _BAXI_98_RAW: str = (
",,0,,0,,0,,,,,0,,,,,,,,,,,,,0000,,,,,,,,,,,,,,,"
)
# Verified by ground-truth arithmetic against PDF Σ(61) = 337.19 for 000474
# Elmhurst fixture (Vaillant ecoTEC pro 28 VUW GB 286/5-3, pcdb_id 16839):
# Table 3b row 1 → Σ(61) = (45) × r1 × fu + F1 × 365
# = 1680.84 × 0.0025 × 1.0 + 0.91251 × 365 = 337.27.
# Combi-loss fields (BRE PCDF Spec v1.0 §7.11 fields 48/51/52/56/57):
# separate_dhw_tests = 1 (one test, profile M → Table 3b)
# rejected_energy_proportion_r1 = 0.0025
# loss_factor_f1_kwh_per_day = 0.91251
# loss_factor_f2 / rejected_factor_f3 = blank (Table 3c not used)
_VAILLANT_16839_RAW: str = (
"016839,000031,0,2019/Mar/04 10:28,Vaillant,Vaillant,ecoTEC pro 28,"
"VUW GB 286/5-3,GC 47-044-45,2005,2015,1,2,1,2,0,,,2,2,2,24.4,24.4,,,"
"88.7,87.0,,75.1,,2,,,104,1,2,105,2,0,,,,0,,,,,1,7.012,0.133,0.0025,"
"0.91251,,,,,,1,1,,0045,,,,,,,,,89.0,98.0,,,,,96.3"
)
def test_table_105_parser_extracts_baxi_98_known_fields() -> None:
"""Decode the user-verified Baxi 000098 Wm 20/3rs record. Field positions
@ -116,6 +132,63 @@ def test_table_105_parser_extracts_other_user_verified_records(
assert getattr(record, key) == value, f"field {key}"
def test_table_105_parser_extracts_separate_dhw_tests_profile_flag() -> None:
"""BRE PCDF Spec v1.0 §7.11 field 48 (0-indexed 47) "Separate DHW
tests" encodes the profile-flag for PCDB Table 3b/3c combi-loss
selection: 0 = none / not applicable, 1 = one test profile M
(Table 3b), 2 = two tests profiles M+L (Table 3c), 3 = two tests
profiles M+S (Table 3c). 16839 lodges flag=1 Table 3b path."""
# Arrange
raw_row = _VAILLANT_16839_RAW
# Act
record = parse_table_105_row(raw_row)
# Assert
assert record.separate_dhw_tests == 1
def test_table_105_parser_extracts_table_3b_3c_combi_loss_coefficients() -> None:
"""BRE PCDF Spec v1.0 §7.11 fields 51 / 52 / 56 / 57 (0-indexed
50 / 51 / 55 / 56) carry the Table 3b/3c combi-loss coefficients:
rejected energy r1, loss factor F1 (Table 3b), loss factor F2
(Table 3c), rejected factor F3 (Table 3c, can be negative).
16839 lodges profile M only, so F2/F3 are absent (blank). Cross-
verified by arithmetic: Σ(61) = (45) × r1 × fu + F1 × 365
= 1680.84 × 0.0025 × 1.0 + 0.91251 × 365 = 337.27 kWh/yr against
the 000474 worksheet's PDF pin Σ(61) = 337.19 (Δ 0.02%)."""
# Arrange
raw_row = _VAILLANT_16839_RAW
# Act
record = parse_table_105_row(raw_row)
# Assert
assert record.rejected_energy_proportion_r1 == 0.0025
assert record.loss_factor_f1_kwh_per_day == 0.91251
assert record.loss_factor_f2_kwh_per_day is None
assert record.rejected_factor_f3_per_litre is None
def test_table_105_parser_leaves_combi_loss_fields_none_for_sap_default_boilers() -> None:
"""Baxi 000098 is a SAP-default boiler (no EN 13203-2 / OPS 26 tests),
so the Table 3b/3c combi-loss fields are blank in pcdb10.dat. The
parser exposes them as None to signal Table 3a fallback (the
pre-§4-HW default 600 kWh/yr behaviour)."""
# Arrange
raw_row = _BAXI_98_RAW
# Act
record = parse_table_105_row(raw_row)
# Assert
assert record.separate_dhw_tests == 0
assert record.rejected_energy_proportion_r1 is None
assert record.loss_factor_f1_kwh_per_day is None
assert record.loss_factor_f2_kwh_per_day is None
assert record.rejected_factor_f3_per_litre is None
def test_parse_table_105_walks_section_skipping_headers_and_comments() -> None:
"""The .dat file demarcates each table with a `$<id>,<format>,...`
header line, intersperses `#`-prefixed comments, and ends the table

View file

@ -24,6 +24,7 @@ from domain.sap.worksheet.water_heating import (
annual_average_hot_water_other_uses_l_per_day,
assumed_occupancy,
combi_loss_monthly_kwh_table_3a_keep_hot_time_clock,
combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
distribution_loss_monthly_kwh,
energy_content_of_hot_water_monthly_kwh,
heat_gains_from_water_heating_monthly_kwh,
@ -474,6 +475,60 @@ def test_distribution_loss_zero_for_instantaneous_point_of_use_water_heating() -
assert all(v == pytest.approx(0.0, abs=1e-9) for v in loss)
def test_combi_loss_table_3b_row_1_matches_elmhurst_000474_pcdb_arithmetic() -> None:
"""SAP10.2 Appendix J Table 3b row 1 (Instantaneous with non-storage
FGHRS or without FGHRS):
(61)m = (45)m × r1 × fu + [F1 × n_m]
where r1, F1 are PCDB Table 105 fields and fu = V_d,m/100 for daily
HW < 100 L/day, else 1.0.
For 000474 (Vaillant ecoTEC pro 28, PCDB 16839): r1 = 0.0025,
F1 = 0.91251 kWh/day, V_d,m > 100 fu = 1.0 every month.
Σ(61) = 0.0025 × Σ(45) + 0.91251 × 365 = 4.20 + 333.07 = 337.27
against PDF pin Σ(61) = 337.19 (Δ 0.02%, rounding-floor)."""
# Arrange
r1 = 0.0025
f1 = 0.91251
energy_content_45 = _w000474.LINE_45_M_HW_ENERGY_CONTENT_KWH
# Daily HW > 100 L/day every month for 000474 → fu collapses to 1.0.
daily_hw_44 = _w000474.LINE_44_M_DAILY_HW_USAGE_L
# Act
monthly = combi_loss_monthly_kwh_table_3b_row_1_instantaneous(
rejected_energy_proportion_r1=r1,
loss_factor_f1_kwh_per_day=f1,
energy_content_monthly_kwh=energy_content_45,
daily_hot_water_monthly_l_per_day=daily_hw_44,
)
# Assert
assert sum(monthly) == pytest.approx(337.27, abs=0.10)
# Per-month also: Jan = (174.4 × 0.0025 × 1.0) + (0.91251 × 31) = 28.7271
assert monthly[0] == pytest.approx(_w000474.LINE_61_M_COMBI_LOSS_KWH[0], abs=0.05)
def test_000474_cert_to_inputs_hot_water_kwh_closes_within_1_5pct_via_pcdb_table_3b() -> None:
"""Cert-round-trip conformance: 000474 mid-terrace combi-gas (PDF
HW fuel = 2291.78 kWh/yr). Pre-§4 slice 1: cert_to_inputs used
Table 3a default 600 kWh/yr combi loss HW fuel 2621.65 (+14.4%).
Post-§4 slice 1: cert_to_inputs reads PCDB Table 105 r1/F1 fields
and routes through Table 3b row 1 (Σ(61) = 337.27) HW fuel 2319.7
(+1.2%). The remaining ~1.2% residual closes when slice 2 promotes
`water_efficiency_pct` from the scalar summer efficiency to the
monthly Equation D1 cascade (Appendix D §D2.1 (2))."""
# Arrange
from domain.sap.rdsap.cert_to_inputs import cert_to_inputs
epc = _w000474.build_epc()
# Act
inputs = cert_to_inputs(epc)
# Assert
assert inputs.hot_water_kwh_per_yr == pytest.approx(2291.78, rel=0.015)
def test_combi_loss_table_3a_time_clock_keep_hot_matches_elmhurst_000490() -> None:
"""SAP10.2 §4 line (61)m via Table 3a row "Instantaneous, with keep-hot
facility controlled by time clock":

View file

@ -264,6 +264,39 @@ def distribution_loss_monthly_kwh(
return tuple(0.15 * e for e in monthly_energy_content_kwh)
def combi_loss_monthly_kwh_table_3b_row_1_instantaneous(
*,
rejected_energy_proportion_r1: float,
loss_factor_f1_kwh_per_day: float,
energy_content_monthly_kwh: tuple[float, ...],
daily_hot_water_monthly_l_per_day: tuple[float, ...],
) -> tuple[float, ...]:
"""SAP 10.2 Appendix J Table 3b row 1 (Instantaneous combi with non-
storage FGHRS or without FGHRS, profile M only):
(61)m = (45)m × r1 × fu + [F1 × n_m]
where r1 = rejected energy proportion (PCDB Table 105 field 51),
F1 = loss factor in kWh/day (PCDB field 52), and fu = V_d,m / 100
when daily hot-water usage V_d,m < 100 L/day, else fu = 1.0.
Applies only to combi boilers EN 13203-2 / OPS 26 tested with one
profile (Separate DHW tests = 1). Other Table 3b rows (storage
combis, storage-FGHRS variants) and Table 3c (two-profile tests)
are deferred until a fixture exercises them. Untested combis fall
back to the existing Table 3a path.
"""
return tuple(
e * rejected_energy_proportion_r1 * (v / 100.0 if v < 100.0 else 1.0)
+ loss_factor_f1_kwh_per_day * n
for e, v, n in zip(
energy_content_monthly_kwh,
daily_hot_water_monthly_l_per_day,
_DAYS_IN_MONTH,
)
)
def combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() -> tuple[float, ...]:
"""SAP 10.2 §4 line (61)m — Table 3a row "Instantaneous, with keep-hot
facility controlled by time clock": 600 × n_m / 365 kWh/month.