Model/domain/sap10_calculator/tables/table_4b.py
Khalim Conn-Kowlessar 7dceeff24b Slice S0380.147: Appendix D Eq D1 — Table 4b non-PCDB boilers (winter/summer monthly cascade)
SAP 10.2 Appendix D §D2.1 (2) Equation (D1) (PDF p.57):

  If the boiler provides both space and water heating, and the summer
  seasonal efficiency is lower than the winter seasonal efficiency,
  the efficiency is a combination of winter and summer seasonal
  efficiencies according to the relative proportion of heat needed
  from the boiler for space and water heating in the month concerned:

              Q_space + Q_water
  η_water,m = ───────────────────────────────
              Q_space/η_winter + Q_water/η_summer

  where Q_space (kWh/month) is the quantity calculated at (98c)m
  multiplied by (204) or by (205);
        Q_water (kWh/month) is the quantity calculated at (64)m;
        η_winter and η_summer are the winter and summer seasonal
        efficiencies (from Table 4b).

Pre-slice the cascade only wired Eq D1 for PCDB-tested boilers (the
`pcdb_record` branch in `_apply_water_efficiency`). For non-PCDB
Table 4b boilers (`sap_main_heating_code` 101-141) where the cert
lodges no `main_heating_index_number`, the cascade fell through to
the scalar `water_efficiency_pct` divisor — which resolved via WHC
901 inherit to Table 4b WINTER eff (wrong direction; spec wants the
monthly Eq D1 blend).

This slice:

  - Adds `domain/sap10_calculator/tables/table_4b.py` with the full
    41-row Table 4b (winter, summer) pair dict for codes 101-141
    verbatim from SAP 10.2 PDF p.168 (Table 4b).
  - Refactors `_apply_water_efficiency` parameter from
    `pcdb_record: Optional[GasOilBoilerRecord]` to
    `eq_d1_winter_summer_pct: Optional[tuple[float, float]]` —
    decouples the Eq D1 input from the PCDB record so a Table 4b
    fallback can populate it without faking a PCDB record.
  - Resolves Eq D1 inputs at the call site with priority order:
        1. PCDB Table 105 winter/summer (existing path)
        2. SAP 10.2 Table 4b (PDF p.168) winter/summer when PCDB
           absent + WHC=901 (`_WHC_FROM_MAIN_HEATING`, the spec form
           of "boiler provides both space and water heating").
    §9.4.11 -5pp interlock applies symmetrically to both columns of
    whichever (winter, summer) tuple is resolved.

Oil 1 cert worksheet (217)m verified Jan 81.83 / Apr 81.42 / May
79.94 / Jun-Sep 72.00 / Dec 81.86 — exact back-solve to Eq D1 with
Table 4b code 127 (winter 84, summer 72). Annual HW fuel (219) =
Σ (64)m × 100 / (217)m = 3638.99 kWh/yr ≡ cascade post-slice.

Cascade impact:

  Heating-systems corpus (worksheet-pinned, oil 1 only on pin grid):
    oil 1  SAP +1.76 → +1.18  (Δ -0.59)
           cost -£40.60 → -£27.12  (Δ +£13.48)
           CO2  -129.22 → -55.36   (Δ +73.86 kg/yr)
           PE   -590.02 → -275.52  (Δ +314.50 kWh/yr)
    Remaining oil 1 residual is Table 4f auxiliary energy (cascade
    pumps_fans 130 kWh vs worksheet 265 kWh — missing the oil-boiler
    pump 100 kWh + CH pump 130 vs ws 165). Follow-up slice.

  Golden fixtures (cert-pinned, integer-rounded PE):
    cert 0240 (dual oil combi 130, no cylinder): PE +0.05 → +1.02
    cert 6035 (gas combi 104, no cylinder):      PE +46.10 → +47.29
    Both shifts reflect spec-correct Eq D1 now firing for non-PCDB
    combi-no-cylinder configs. The pre-slice near-zero pin on cert
    0240 was masking offsetting cascade gaps (likely Table 4f
    auxiliary energy and/or dual-main Q_space split per (98c)m ×
    (204) which the cascade currently treats as full demand).

Following [[reference-unmapped-sap-code]] discipline, the new Table
4b dict is the canonical spec-source — `domain.sap10_ml.sap_
efficiencies._SPACE_EFF_BY_CODE` still carries the winter column for
the ML feature cascade and is left in place per the sap10_ml
deprecation plan (separate migration).

Test:
  test_sap_appendix_d_eq_d1_water_efficiency_monthly_for_non_pcdb_
  table_4b_boiler_with_cylinder — asserts cert 1431 oil 1 HW fuel
  annual = 3638.99 ± 1.0 kWh/yr (matches worksheet (219)).

Extended handover suite: 890 pass, 0 fail. Pyright net-zero (44=44).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:22:46 +00:00

96 lines
4.7 KiB
Python

"""SAP 10.2 Table 4b (PDF p.168) — "Seasonal efficiency for gas and
liquid fuel boilers", winter / summer pair per Table 4b sub-row code
(`sap_main_heating_code` 101-141).
This table is the spec-canonical fallback when a gas / oil boiler is
NOT in the PCDB. Winter efficiency feeds (206)..(212) space heating;
summer efficiency feeds Appendix D §D2.1 (2) Equation D1 alongside
winter to derive the worksheet (217)m monthly water-heating efficiency.
Codes are grouped in Table 4b by boiler type:
101-109 Gas boilers (mains, LPG, biogas) 1998 or later
110-114 Gas pre-1998 with fan-assisted flue
115-119 Gas pre-1998 with balanced / open flue
120-123 Combined Primary Storage Units (CPSU)
124-132 Liquid fuel boilers (oil, etc.)
133-141 Range cooker boilers (gas + liquid fuel)
The winter column is duplicated in `domain.sap10_ml.sap_efficiencies.
_SPACE_EFF_BY_CODE` for backward-compat with that module's interim
ML cascade; the canonical source for new cascade work is here per
[[sap10_ml deprecation]] memory.
"""
from __future__ import annotations
from typing import Final, Optional
# Verbatim from SAP 10.2 spec PDF p.168 (the "Boiler ... Efficiency, %
# Winter / Summer" table). All values percent.
_TABLE_4B_SEASONAL_EFF_PCT_BY_CODE: Final[dict[int, tuple[float, float]]] = {
# Gas boilers (including mains gas, LPG and biogas) 1998 or later
101: (74.0, 64.0), # Regular non-condensing with automatic ignition
102: (84.0, 74.0), # Regular condensing with automatic ignition
103: (74.0, 65.0), # Non-condensing combi with automatic ignition
104: (84.0, 75.0), # Condensing combi with automatic ignition
105: (70.0, 60.0), # Regular non-condensing with permanent pilot
106: (80.0, 70.0), # Regular condensing with permanent pilot
107: (70.0, 61.0), # Non-condensing combi with permanent pilot
108: (80.0, 71.0), # Condensing combi with permanent pilot
109: (66.0, 56.0), # Back boiler to radiators
# Gas pre-1998 with fan-assisted flue
110: (73.0, 63.0), # Regular, low thermal capacity
111: (69.0, 59.0), # Regular, high or unknown thermal capacity
112: (71.0, 62.0), # Combi
113: (84.0, 75.0), # Condensing combi
114: (84.0, 74.0), # Regular, condensing
# Gas pre-1998 with balanced or open flue
115: (66.0, 56.0), # Regular, wall mounted
116: (56.0, 46.0), # Regular, floor mounted, pre 1979
117: (66.0, 56.0), # Regular, floor mounted, 1979 to 1997
118: (66.0, 57.0), # Combi
119: (66.0, 56.0), # Back boiler to radiators
# Combined Primary Storage Units (CPSU)
120: (74.0, 72.0), # With automatic ignition (non-condensing)
121: (83.0, 81.0), # With automatic ignition (condensing)
122: (70.0, 68.0), # With permanent pilot (non-condensing)
123: (79.0, 77.0), # With permanent pilot (condensing)
# Liquid fuel boilers
124: (66.0, 54.0), # Standard oil boiler pre-1985
125: (71.0, 59.0), # Standard oil boiler 1985 to 1997
126: (80.0, 68.0), # Standard oil boiler, 1998 or later
127: (84.0, 72.0), # Condensing oil boiler
128: (71.0, 62.0), # Combi oil boiler, pre-1998
129: (77.0, 68.0), # Combi oil boiler, 1998 or later
130: (82.0, 73.0), # Condensing combi oil boiler
131: (66.0, 54.0), # Oil room heater with boiler to radiators, pre 2000
132: (71.0, 59.0), # Oil room heater with boiler to radiators, 2000 or later
# Range cooker boilers (mains gas, LPG and biogas)
133: (47.0, 37.0), # Single burner with permanent pilot
134: (51.0, 41.0), # Single burner with automatic ignition
135: (61.0, 51.0), # Twin burner with permanent pilot (non-condensing) pre 1998
136: (66.0, 56.0), # Twin burner with automatic ignition (non-condensing) pre 1998
137: (66.0, 56.0), # Twin burner with permanent pilot (non-condensing) 1998 or later
138: (71.0, 61.0), # Twin burner with automatic ignition (non-condensing) 1998 or later
# Range cooker boilers (liquid fuel)
139: (61.0, 49.0), # Single burner
140: (71.0, 59.0), # Twin burner (non-condensing) pre 1998
141: (76.0, 64.0), # Twin burner (non-condensing) 1998 or later
}
def table_4b_seasonal_efficiencies_pct(
sap_main_heating_code: Optional[int],
) -> Optional[tuple[float, float]]:
"""Return the SAP 10.2 Table 4b `(winter, summer)` efficiency pair
as percentages, or `None` when the lodged code is not a Table 4b
boiler sub-row (e.g. Table 4a category code, no lodging).
Total contract — never raises; non-Table-4b codes fall through to
None so the caller can route to the scalar / category cascade.
"""
if sap_main_heating_code is None:
return None
return _TABLE_4B_SEASONAL_EFF_PCT_BY_CODE.get(sap_main_heating_code)