Slice S0380.172: Heat-network heat-source-eff CO2/PE factor scaling

Closes the CO2 / PE residuals for CH1 (boiler community heating, SAP
code 301) and CH3 (HP community heating, SAP code 304) via SAP 10.2
Table 4a (PDF p.164) heat-network heat-source efficiency:

  "Boilers (RdSAP)"     → 80%   → code 301
  "Heat pump (RdSAP)"   → 300%  → code 304

Spec block 13a (PDF p.153) (467) "PE associated with heat source 2"
= [(307b)+(310b)] × 100 / (467b) — i.e. fuel input = network_input ×
100 / heat_source_eff before applying Table 12 PE factor. Block 12b
(367) mirrors for CO2. The cascade meters network_input directly
(eff = 1/DLF for the cost path via Table 12 heat-network rate), so
PE / CO2 factors are scaled by 1/heat_source_eff at lookup time —
mathematically equivalent to spec's (network_input / eff) × factor.

Three changes:

1. New `_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY: Final[dict[int, float]]`
   keyed on SAP code: 301 → 0.80, 304 → 3.00. SAP 302 (CHP+boilers)
   is omitted — the 35%/65% split + displaced-electricity credit per
   spec block 13b (464)/(466)/(364)/(366) needs the .171 follow-up.

2. New `_heat_network_heat_source_efficiency_scaling(main)` helper
   returning 1.0 for non-heat-network mains + SAP 302, and
   1/heat_source_eff for SAP 301 / 304.

3. Wired into `_main_heating_co2_factor_kg_per_kwh` and
   `_main_heating_primary_factor` non-electric branches (heat
   networks are non-electric per `_is_electric_main`). Both functions
   return `Table_12_factor × scaling` so the cascade's
   `network_input × scaled_factor` lands on the spec
   `(network_input / eff) × Table_12_factor`.

Closures vs pre-S0380.172 residuals (heating-systems corpus block 11b):

  variant            ΔCO2          ΔPE        notes
  CH1 (Boilers/Gas)  -787→-126    -3827→-967  ~75-84% closure
  CH2 (CHP/Gas)      unchanged     unchanged   excluded — SAP 302
  CH3 (HP/Elec)      +1614→+473  +11879→+1749 ~71-85% closure
  CH4 (CHP/Oil)      unchanged     unchanged   excluded — SAP 302
  CH6 (CHP/Coal)     unchanged     unchanged   excluded — SAP 302

Cost + SAP unchanged on all 5 (heat-network rate × network_input via
Table 12 is correct regardless of heat-source efficiency).

Residual CH1 / CH3 gap drivers (follow-up scope):
- WHC=901 HW path: cascade reads cert-lodged "Mains gas" as HW fuel
  on community-heating certs; should fall through to main fuel for
  the heat-network so the scaling applies on HW side too.
- Elmhurst 0.8523 multiplier on heat-network energy column (worksheet
  (467) energy = spec_formula × 0.8523 uniformly across non-CHP
  heat-network rows; mechanism not yet identified — spec divergence
  candidate for SAP_CALCULATOR.md §8).

Cohort no-regression verified: 9 ASHP + 38 cohort-2 golden fixtures
pass unchanged; the 41-variant heating-systems corpus has identical
residuals for non-heat-network certs. The 2 closed CH variants are
re-pinned at their new sub-1000 magnitudes.

Test baseline at HEAD: 926 pass + 1 skipped (was 926 + 1 at
predecessor a4b5f4e7; pin updates net to 0). Pyright net-zero on
affected files (cert_to_inputs.py, test_heating_systems_corpus.py):
32 → 32.

Per [[feedback-spec-citation-in-commits]] the dispatch table cites
SAP 10.2 Table 4a (PDF p.164) verbatim row labels.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 11:44:06 +00:00 committed by Jun-te Kim
parent 5c2158e6c4
commit a5eda92a90
2 changed files with 96 additions and 4 deletions

View file

@ -546,9 +546,25 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# (worksheet (464)/(466)/(364)/(366) per SAP 10.2 §13b spec) +
# (2) community-HP COP cascade for CH3 + (3) heat-network overall
# factor (486)/(386) calc — separate follow-up slices).
_CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.5915, expected_cost_resid_gbp=-13.6289, expected_co2_resid_kg=-787.2531, expected_pe_resid_kwh=-3827.1887),
#
# Slice S0380.172 closed the CH1 (boiler) + CH3 (HP) CO2 / PE
# residuals via SAP 10.2 Table 4a (PDF p.164) heat-network heat-
# source efficiency scaling: code 301 (boilers) eff = 80%, code
# 304 (HP) eff = 300%. Spec block 13a (467) = (307+310) × 100 /
# heat_source_eff × Table 12 PE factor; cascade meters network_
# input directly so PE/CO2 factors are scaled by 1/heat_source_eff
# at lookup time. CH1 ΔCO2 787 → 126 (~84% closed) and ΔPE
# 3827 → 967 (~75% closed); CH3 ΔCO2 +1614 → +473 (~71%
# closed) and ΔPE +11879 → +1749 (~85% closed). Code 302 (CHP+
# boilers) is omitted from the scaling table — the 35%/65% split
# requires the displaced-electricity credit line per spec block
# 13b (464)/(466); follow-up slice scope. Residual CH1/CH3 gap is
# the WHC=901 HW path (cascade reads cert-lodged "Mains gas" as
# HW fuel; should fall through to main fuel for community heating)
# + the Elmhurst 0.8523 multiplier on heat-network energy column.
_CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.5915, expected_cost_resid_gbp=-13.6289, expected_co2_resid_kg=-126.4571, expected_pe_resid_kwh=-967.3648),
_CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=-0.0076, expected_cost_resid_gbp=+0.1744, expected_co2_resid_kg=-1430.3212, expected_pe_resid_kwh=+1506.0355),
_CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.5915, expected_cost_resid_gbp=-13.6289, expected_co2_resid_kg=+1613.7837, expected_pe_resid_kwh=+11878.7588),
_CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.5915, expected_cost_resid_gbp=-13.6289, expected_co2_resid_kg=+472.5996, expected_pe_resid_kwh=+1748.7395),
_CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=-0.0076, expected_cost_resid_gbp=+0.1744, expected_co2_resid_kg=-4397.0794, expected_pe_resid_kwh=+494.6090),
_CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-8.0295, expected_cost_resid_gbp=+185.0120, expected_co2_resid_kg=-2934.9021, expected_pe_resid_kwh=+7864.5950),
)
@ -881,3 +897,15 @@ def test_community_heating_mapper_populates_chp_split_fields(
main_1 = main_heating_details[0]
assert main_1.community_heating_chp_fraction == expected_chp_fraction
assert main_1.community_heating_boiler_fuel_type == expected_boiler_fuel_code
# S0380.172 — Heat-network heat-source-eff scaling residual coverage.
#
# Per SAP 10.2 Table 4a (PDF p.164): "Boilers (RdSAP)" eff=80%, "Heat
# pump (RdSAP)" eff=300%. The cascade's CO2/PE factor functions scale
# Table 12 factors by 1/heat_source_eff so that network_input × scaled
# factor lands on the spec block 13a (467) / 12b (367) "(307+310) ×
# 100 / eff × Table 12 factor" formula. SAP code 302 (CHP+boilers) is
# excluded — 35%/65% split + displaced-electricity credit is follow-up.
# Coverage is asserted via the residual-pin test above (CH1 / CH3
# closure; CH2 / CH4 / CH6 unchanged).

View file

@ -825,6 +825,31 @@ _HEAT_NETWORK_MAIN_CODES: Final[frozenset[int]] = frozenset({301, 302, 303, 304}
_HEAT_NETWORK_CATEGORY: Final[int] = 6
# SAP 10.2 Table 4a (PDF p.164) heat-network heat-source efficiency by
# SAP code. Verbatim:
# 301 "Boilers (RdSAP)" → 80%
# 302 "CHP and boilers (RdSAP)" → 75% (overall — per RdSAP 10 §C)
# 304 "Heat pump (RdSAP)" → 300% (= COP 3.0)
# Used by the block 13a/12b PE/CO2 cascade to convert delivered network
# input (post-DLF) into FUEL input by dividing by the heat-source
# efficiency: spec (467) = (307+310) × 100 / (467a). The cascade meters
# heat-network input directly (eff = 1/DLF for cost via Table 12
# heat-network rate), so PE/CO2 factors are scaled by 1/heat_source_eff
# at lookup time to land at the spec's fuel-input × Table-12-factor.
#
# Code 302 (CHP+boilers) is omitted here because the 35%/65% heat-
# fraction split applies different efficiencies to the two heat sources
# (CHP 75% overall + boilers 80%) and a single composite efficiency
# can't model the displaced-electricity credit line per spec block
# 13b (464)/(466). The cascade for code 302 keeps the current
# 1/DLF override (giving large CO2/PE residuals on CH2/CH4/CH6 —
# follow-up slice scope).
_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY: Final[dict[int, float]] = {
301: 0.80,
304: 3.00,
}
def _is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool:
"""True when the cert's main heating is a heat network — either by
SAP code (Table 4a 301-304) or by `main_heating_category` (6)."""
@ -836,6 +861,28 @@ def _is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool:
return main.main_heating_category == _HEAT_NETWORK_CATEGORY
def _heat_network_heat_source_efficiency_scaling(
main: Optional[MainHeatingDetail],
) -> float:
"""Return the multiplicative scaling factor to apply to Table 12
CO2 / PE factors when the main is a heat-network boiler (SAP 301) or
heat pump (SAP 304). Cascade computes CO2/PE = network_input ×
Table_12_factor; spec block 13a/12b computes (network_input /
heat_source_eff) × Table_12_factor. Equivalent transform: scale the
factor by 1/heat_source_eff. Returns 1.0 for code 302 (CHP+boilers
separate split-formula path) and non-heat-network mains.
"""
if not _is_heat_network_main(main):
return 1.0
code = main.sap_main_heating_code if main is not None else None
if not isinstance(code, int):
return 1.0
eff = _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY.get(code)
if eff is None:
return 1.0
return 1.0 / eff
def _heat_network_dlf(age_band: Optional[str]) -> float:
"""RdSAP 10 §10.11 + SAP 10.2 Table 12c distribution loss factor by
age band. Defaults to the K-or-newer value (1.50) when band missing.
@ -2413,7 +2460,16 @@ def _main_heating_co2_factor_kg_per_kwh(
annual factor is the safe degenerate value)
"""
if not _is_electric_main(main):
return _co2_factor_kg_per_kwh(main)
# Heat-network mains (SAP codes 301 / 304) are non-electric per
# `_is_electric_main` but require a heat-source-efficiency scaling
# per spec block 12b (363)/(367) = network_input × 100 /
# heat_source_eff × Table 12 CO2 factor. The cascade meters
# network_input directly so scale the factor by 1/eff to land at
# the spec's fuel-input × factor.
return (
_co2_factor_kg_per_kwh(main)
* _heat_network_heat_source_efficiency_scaling(main)
)
if tariff is Tariff.STANDARD:
monthly = _effective_monthly_co2_factor(
main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
@ -2470,7 +2526,15 @@ def _main_heating_primary_factor(
unknown dual-rate codes, zero-fuel)."""
fuel = _main_fuel_code(main)
if not _is_electric_main(main):
return primary_energy_factor(fuel)
# PE-side mirror of `_main_heating_co2_factor_kg_per_kwh`
# heat-network heat-source-eff scaling. Spec block 13a (463)/
# (467) = network_input × 100 / heat_source_eff × Table 12 PE
# factor; cascade meters network_input directly so scale by
# 1/eff at lookup time.
return (
primary_energy_factor(fuel)
* _heat_network_heat_source_efficiency_scaling(main)
)
if tariff is Tariff.STANDARD:
monthly = _effective_monthly_pe_factor(
main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,