From 035303e9f8bf4387ffbb347eea9d23880cbe641c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:22:51 +0000 Subject: [PATCH] =?UTF-8?q?S0380.182:=20community-heating=20CHP+boilers=20?= =?UTF-8?q?CO2/PE=20credit=20(=C2=A712b/13b)=20=E2=80=94=20closes=20CH2/CH?= =?UTF-8?q?4=20CO2+PE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 worksheet block 12b (CO2) / 13b (PE) for community heating "CHP and boilers" (SAP code 302). Per unit of network heat fuel H = (307)+(310) the effective generation factor is: chp×100/(362)×f_fuel − chp×(361)/(362)×f_disp + (1−chp)×100/(367)×f_fuel (363)/(463) CHP fuel = chp_frac × 100/heat_eff × f_fuel (364)/(464) less credit = −chp_frac × elec_eff/heat_eff × f_disp (368)/(468) boiler fuel = (1−chp_frac) × 100/boiler_eff × f_fuel f_fuel = Table 12 heat-network fuel factor (the CHP unit and the back-up boilers burn the same community fuel — verified vs CH2 gas / CH4 oil / CH6 coal worksheets (363)/(368)); f_disp = Table 12f (PDF p.196) credit for the CHP-generated electricity. RdSAP 10 §C (p.58) defaults: heat eff 50% (362), electrical eff 25% (361), boiler eff 80% (367); CHP heat frac 0.35 per-cert via community_heating_chp_fraction. New `_heat_network_code_302_effective_factor` + Table 12f flexible constants (0.420 CO2 / 2.369 PE) + RdSAP §C efficiency constants, wired into all four factor helpers (main + HW, CO2 + PE) ahead of the existing single-fuel / 1-over-heat-source-eff path. The worksheet (368)/(468) boiler emissions DISPLAY rounded/mis-aligned in the PDF, but the (373)/(473)/(386)/(486) totals reconcile only with the boiler at the full Table 12 factor — verified EXACT. Two spec citations applied: - Table 12f flexible-operation default for RdSAP community CHP is an Elmhurst engine choice (Table 12f notes make "standard" the default); mirrored per [[feedback-software-no-special-handling]] and documented in SAP_CALCULATOR.md §8.3. - Table 12 heat-network oil/biodiesel CO2 (codes 53/56) corrected 0.298 → 0.335 per Table 12 (p.189) "assumes 'gas oil'"; the code-302 oil cascade (CH4) was the first to exercise it. PE 1.180 was already correct. No other variant uses these codes (no regression). Closures (CO2 + PE only — the CHP credit does not touch cost/SAP): CH2 (CHP/Gas) CO2 −1411.49→+0.0000, PE +1331.23→+0.0000 EXACT CH4 (CHP/Oil) CO2 −4378.24→−0.0000, PE +319.81→−0.0000 EXACT CH6 (CHP/Coal) CO2/PE re-pinned (+2411.54 / +5023.48) — its worksheet lodges a manual DLF=1.0 the Summary doesn't carry, so cascade DLF=1.45 over-scales H; same root as the CH6 SAP −7.49 / cost +£172 (separate DLF front). CH2/CH4 are now CO2+PE-exact but still carry the heat-network cost/SAP residual (+0.5277 SAP / −£12.16 cost, exposed by S0380.175 — cost-side, untouched here). CH3 unchanged (code 304 community-HP COP front). Corpus state: 37 variants EXACT on all four metrics (incl. CH1); remaining residuals are CH2/CH4 cost+SAP, CH3 CO2+PE (HP COP), CH6 all-metric (DLF quirk). 2223 pass + 1 skip + 0 fail (tolerances 1e-4 all metrics per S0380.181); pyright net-zero 43→43. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_heating_systems_corpus.py | 27 +++- .../sap10_calculator/docs/SAP_CALCULATOR.md | 31 +++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 124 ++++++++++++++++++ domain/sap10_calculator/tables/table_12.py | 10 +- .../rdsap/test_cert_to_inputs.py | 54 ++++++++ 5 files changed, 241 insertions(+), 5 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index a09e3ae7..f008d0ee 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -695,11 +695,32 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # for the next slice. Elmhurst DISPLAYS the (372) energy column as # 0.01 × (307) (space only) but computes emissions on 0.01 × # (307+310) per the §C3.2 text — verified EXACT line-by-line. + # + # Slice S0380.182 wired the SAP 10.2 §12b/13b community-heating + # "CHP and boilers" (SAP code 302) CO2/PE cascade: per unit of + # network heat fuel H = (307)+(310), the effective generation factor + # = chp_frac × 100/(362) × f_fuel − chp_frac × (361)/(362) × f_disp + # + (1−chp_frac) × 100/(367) × f_fuel, where f_fuel is the Table 12 + # heat-network fuel factor (CHP + back-up boilers burn the same + # community fuel) and f_disp is the Table 12f credit factor for the + # CHP-generated electricity (Elmhurst uses "flexible operation" + # 0.420 CO2 / 2.369 PE). RdSAP 10 §C (p.58) defaults: heat eff 50% / + # electrical eff 25% / boiler eff 80%; CHP frac 0.35 per-cert. Also + # fixed Table 12 heat-network-oil CO2 (codes 53/56 0.298→0.335 per + # Table 12 p.189 — the code-302 oil cascade was the first to use it). + # CH2 (gas) + CH4 (oil) CO2 + PE now EXACT (<1e-4). CH6 (coal) CO2/PE + # shift sign: its worksheet lodges a manual DLF=1.0 (two adjoining + # dwellings) the Summary doesn't carry, so the cascade's DLF=1.45 + # over-scales H — pin + the CH6 SAP −7.49 / cost +£172 are the same + # DLF quirk (separate front, likely pin-forever). CH2/CH4 SAP +0.5277 + # / cost −£12.16 is the heat-network cost/standing residual exposed + # by S0380.175 (cost-side, untouched by this CO2/PE slice). CH3 + # unchanged (code 304 community-HP COP front). _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), - _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-1411.4867, expected_pe_resid_kwh=+1331.2330), + _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-75.3228, expected_pe_resid_kwh=-249.3161), - _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-4378.2449, expected_pe_resid_kwh=+319.8065), - _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=-2916.0676, expected_pe_resid_kwh=+7689.7925), + _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=-0.0000), + _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=+2411.5399, expected_pe_resid_kwh=+5023.4766), ) diff --git a/domain/sap10_calculator/docs/SAP_CALCULATOR.md b/domain/sap10_calculator/docs/SAP_CALCULATOR.md index cbb1d1df..b8db1853 100644 --- a/domain/sap10_calculator/docs/SAP_CALCULATOR.md +++ b/domain/sap10_calculator/docs/SAP_CALCULATOR.md @@ -495,3 +495,34 @@ shape (Table 12 annual where spec literal says monthly), so the gate is implemented under the same `dual-rate → annual on top of monthly` discipline. If a second §12.4.4-eligible cert worksheet diverges from this rule it should be raised against this row before re-tuning. + +### 8.3 Community-heating CHP uses Table 12f "flexible operation" by default + +**Slice S0380.182.** For RdSAP-defaulted community heating with CHP +(SAP code 302) that is **not** in the PCDB, the displaced-electricity +credit (worksheet (364)/(366) CO2 and (464)/(466) PE) needs a Table 12f +(PDF p.196) "fuel factor for electricity generated by CHP". Table 12f +offers three regimes per CHP vintage: + +| Regime | CO2 kg/kWh | PE | Note | +|---|---|---|---| +| export only | 0.394 | 2.345 | | +| **flexible operation** | **0.420** | **2.369** | needs assessor evidence | +| standard | 0.348 | 2.149 | "all other operating regimes" | + +Table 12f's own notes make **standard** the default ("Standard ... should +be used for all other operating regimes of gas CHP plants") and require +submitted evidence for **flexible**. Yet the BRE-approved Elmhurst rdSAP +engine emits **0.420 / 2.369 (flexible)** for these RdSAP-defaulted +community-CHP certs — verified line-by-line against the CH2 (gas) / CH4 +(oil) / CH6 (coal) corpus worksheets (364)/(366)/(464)/(466), all of +which carry 0.4200 CO2 and 2.3690 PE regardless of the community fuel. +RdSAP 10 §C (p.58) is silent on the Table 12f regime, so this is an +engine default not derivable from the spec text. + +Per [[feedback-software-no-special-handling]] / [[feedback-worksheet-not-api-reference]] +we mirror the engine: `_TABLE_12F_CHP_FLEXIBLE_{CO2,PE}` in +`cert_to_inputs`. CH2 + CH4 close to <1e-4 on both CO2 and PE with this +factor; "standard" (0.348/2.149) would leave a residual. If a future +PCDB-listed or evidence-backed CHP cert diverges, raise it against this +row before re-tuning. diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 11877d8b..7aa5ce36 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -984,6 +984,97 @@ def _heat_network_distribution_electricity( return (energy_kwh, co2_factor, pe_factor) +# SAP 10.2 Table 12 fuel code 302's worksheet path. Community heating +# "CHP and boilers" (Table 4a code 302). +_SAP_CODE_COMMUNITY_CHP_AND_BOILERS: Final[int] = 302 + +# SAP 10.2 Table 12f (PDF p.196) — fuel factors for the electricity +# GENERATED BY CHP (the displaced-grid credit, worksheet (364)/(464)). +# The BRE-approved Elmhurst rdSAP engine applies the "flexible +# operation" row (0.420 kg CO2/kWh, 2.369 PE) to RdSAP-defaulted +# community CHP that is not in the PCDB — verified line-by-line against +# the CH2/CH4/CH6 corpus worksheets (363)..(366) / (463)..(466). Table +# 12f's own notes make "standard" (0.348 / 2.149) the default and +# require assessor evidence for "flexible"; we mirror the certified +# engine per [[feedback-software-no-special-handling]] (documented as a +# spec divergence in SAP_CALCULATOR.md §8). +_TABLE_12F_CHP_FLEXIBLE_CO2_KG_PER_KWH: Final[float] = 0.420 +_TABLE_12F_CHP_FLEXIBLE_PE_KWH_PER_KWH: Final[float] = 2.369 + +# RdSAP 10 §C (PDF p.58) heat-network CHP defaults when the network is +# not in the PCDB: CHP overall efficiency 75% with heat-to-power ratio +# 2.0 → heat efficiency 50% (worksheet (362)) + electrical efficiency +# 25% (worksheet (361)); back-up boiler efficiency 80% (worksheet +# (367)). The CHP heat fraction (0.35 default) is per-cert via +# `community_heating_chp_fraction`. +_HEAT_NETWORK_CHP_HEAT_EFFICIENCY: Final[float] = 0.50 +_HEAT_NETWORK_CHP_ELECTRICAL_EFFICIENCY: Final[float] = 0.25 +_HEAT_NETWORK_CHP_BOILER_EFFICIENCY: Final[float] = 0.80 + + +def _heat_network_code_302_effective_factor( + main: Optional[MainHeatingDetail], + *, + primary_energy: bool, +) -> Optional[float]: + """SAP 10.2 worksheet block 12b (CO2) / 13b (PE) for community + heating "CHP and boilers" (SAP code 302) — the effective per-kWh + factor to apply to the network heat fuel [(307) + (310)]. + + Per unit of network heat fuel H = (307) + (310), the worksheet sums: + + CHP fuel (363)/(463) = chp_frac × 100/(362) × f_fuel + less credit (364)/(464) = −chp_frac × (361)/(362) × f_disp + boiler fuel (368)/(468) = (1−chp_frac) × 100/(367) × f_fuel + + where f_fuel is the Table 12 heat-network fuel factor (the CHP unit + and the back-up boilers burn the same community fuel — verified vs + CH2 gas / CH4 oil / CH6 coal worksheets) and f_disp is the Table 12f + credit factor for the electricity the CHP generates. RdSAP 10 §C + defaults: (362) heat eff 50%, (361) electrical eff 25%, (367) boiler + eff 80%. + + Returns the blended factor for code-302 mains with the CHP-split + fields populated; None otherwise so callers fall through to the + existing single-fuel / heat-source-efficiency-scaling path. + + NB the worksheet PDF DISPLAYS the (368)/(468) boiler emissions + rounded/mis-aligned, but the (373)/(473)/(386)/(486) totals + reconcile only with the boiler at the FULL Table 12 fuel factor — + verified EXACT. + """ + if ( + main is None + or main.sap_main_heating_code != _SAP_CODE_COMMUNITY_CHP_AND_BOILERS + ): + return None + chp_fraction = main.community_heating_chp_fraction + boiler_fuel_code = main.community_heating_boiler_fuel_type + if chp_fraction is None or boiler_fuel_code is None: + return None + if primary_energy: + fuel_factor = primary_energy_factor(boiler_fuel_code) + displaced_factor = _TABLE_12F_CHP_FLEXIBLE_PE_KWH_PER_KWH + else: + fuel_factor = co2_factor_kg_per_kwh(boiler_fuel_code) + displaced_factor = _TABLE_12F_CHP_FLEXIBLE_CO2_KG_PER_KWH + boiler_fraction = 1.0 - chp_fraction + return ( + chp_fraction + * (1.0 / _HEAT_NETWORK_CHP_HEAT_EFFICIENCY) + * fuel_factor + - chp_fraction + * ( + _HEAT_NETWORK_CHP_ELECTRICAL_EFFICIENCY + / _HEAT_NETWORK_CHP_HEAT_EFFICIENCY + ) + * displaced_factor + + boiler_fraction + * (1.0 / _HEAT_NETWORK_CHP_BOILER_EFFICIENCY) + * fuel_factor + ) + + @dataclass(frozen=True) class PriceTable: """Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and @@ -2606,6 +2697,13 @@ def _main_heating_co2_factor_kg_per_kwh( - zero-fuel cases (sum monthly_kwh == 0 → effective factor None; annual factor is the safe degenerate value) """ + # SAP 10.2 §12b — community-heating CHP+boilers (code 302): the + # blended CHP-credit + boiler generation CO2 factor (S0380.182). + code_302_co2 = _heat_network_code_302_effective_factor( + main, primary_energy=False, + ) + if code_302_co2 is not None: + return code_302_co2 if not _is_electric_main(main): # Heat-network mains (SAP codes 301 / 304) are non-electric per # `_is_electric_main` but require a heat-source-efficiency scaling @@ -2671,6 +2769,13 @@ def _main_heating_primary_factor( Fallback to annual `primary_energy_factor` for non-electric mains and the same edge cases as the CO2 helper (no Table 12a row, unknown dual-rate codes, zero-fuel).""" + # SAP 10.2 §13b — community-heating CHP+boilers (code 302): the + # blended CHP-credit + boiler generation PE factor (S0380.182). + code_302_pe = _heat_network_code_302_effective_factor( + main, primary_energy=True, + ) + if code_302_pe is not None: + return code_302_pe fuel = _main_fuel_code(main) if not _is_electric_main(main): # PE-side mirror of `_main_heating_co2_factor_kg_per_kwh` @@ -2929,6 +3034,16 @@ def _hot_water_co2_factor_kg_per_kwh( monthly HW fuel kWh — the calculator uses an annual-flat HW efficiency so the SHAPE of fuel monthly is identical to demand monthly, and `_effective_monthly_co2_factor` is shape-only).""" + # SAP 10.2 §12b — community-heating CHP+boilers (code 302) HW from + # main: the same blended CHP-credit + boiler generation CO2 factor + # as SH (S0380.182). Gated on WHC ∈ {901, 902, 914} so immersion- + # heated DHW on a CHP network keeps the lodged electric factor. + if epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES: + code_302_co2 = _heat_network_code_302_effective_factor( + _water_heating_main(epc), primary_energy=False, + ) + if code_302_co2 is not None: + return code_302_co2 # Community heating + WHC ∈ {901, 902, 914}: HW heat is delivered # through the heat-network main, so HW CO2 must read the same # Table 12 heat-network code factor as SH, scaled by 1/heat_source_ @@ -2977,6 +3092,15 @@ def _hot_water_primary_factor( exactly to match the Elmhurst worksheet's (278) annual factor. The 41-variant heating-systems corpus closes its HW PE residual +25/+48 → 0 with this gate.""" + # SAP 10.2 §13b — community-heating CHP+boilers (code 302) HW from + # main: same blended CHP-credit + boiler generation PE factor as SH + # (S0380.182). Gated on WHC ∈ {901, 902, 914}. + if epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES: + code_302_pe = _heat_network_code_302_effective_factor( + _water_heating_main(epc), primary_energy=True, + ) + if code_302_pe is not None: + return code_302_pe # Mirror of `_hot_water_co2_factor_kg_per_kwh` community-heating # branch (S0380.173): WHC ∈ {901, 902, 914} on a heat-network main # routes HW PE through the same Table 12 heat-network code as SH, diff --git a/domain/sap10_calculator/tables/table_12.py b/domain/sap10_calculator/tables/table_12.py index b6248317..2c884128 100644 --- a/domain/sap10_calculator/tables/table_12.py +++ b/domain/sap10_calculator/tables/table_12.py @@ -192,8 +192,14 @@ CO2_KG_PER_KWH: Final[dict[int, float]] = { 30: 0.136, 31: 0.136, 32: 0.136, 33: 0.136, 34: 0.136, 35: 0.136, 38: 0.136, 40: 0.136, 39: 0.136, 60: 0.136, 36: 0.136, # Heat networks - 51: 0.210, 52: 0.241, 53: 0.298, 54: 0.375, 55: 0.269, - 56: 0.298, 57: 0.036, 58: 0.018, + # Heat-network oil (code 53 "assumes 'gas oil'") and mineral-oil/ + # biodiesel boilers (code 56) carry 0.335 kg CO2/kWh per SAP 10.2 + # Table 12 (p.189) — NOT the individual-appliance heating-oil factor + # (code 4 = 0.298). (Fixed in S0380.182 when the code-302 CHP CO2 + # cascade first exercised heat-network oil; PE 1.180 was already + # correct.) + 51: 0.210, 52: 0.241, 53: 0.335, 54: 0.375, 55: 0.269, + 56: 0.335, 57: 0.036, 58: 0.018, 41: 0.136, 42: 0.015, 43: 0.029, 44: 0.024, 45: 0.015, 46: 0.011, 47: 0.011, 48: 0.136, 49: 0.136, 50: 0.0, diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 6c6602c8..20d7abb2 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -41,6 +41,7 @@ from domain.sap10_calculator.exceptions import ( from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] + _heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage] _heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] @@ -209,6 +210,59 @@ def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> assert abs(pe_factor - expected_pe_factor) <= 1e-9 +def test_heat_network_code_302_chp_effective_factor_per_sap_10_2_block_12b_13b() -> None: + # Arrange — community heating "CHP and boilers" (SAP code 302) on + # the RdSAP 10 §C (PDF p.58) defaults: CHP heat frac 0.35, heat eff + # 50% / electrical eff 25%, boiler eff 80%. CH2-style gas network + # (community_heating_boiler_fuel_type = 51 → Table 12 gas 0.210 CO2 + # / 1.130 PE). SAP 10.2 §12b/13b effective generation factor: + # chp×100/(362)×f − chp×(361)/(362)×f_disp + (1−chp)×100/(367)×f + # with f_disp = Table 12f flexible operation (0.420 CO2 / 2.369 PE). + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=302, + community_heating_chp_fraction=0.35, + community_heating_boiler_fuel_type=51, + ) + + # Act + co2 = _heat_network_code_302_effective_factor(main, primary_energy=False) + pe = _heat_network_code_302_effective_factor(main, primary_energy=True) + + # Assert — gas: 0.35×2.0×0.210 − 0.35×0.5×0.420 + 0.65×1.25×0.210 + # = 0.147 − 0.0735 + 0.170625 = 0.244125 (matches the + # CH2 worksheet (386) generation factor); PE mirror with 1.130 / + # 2.369 = 1.29455. + assert co2 is not None + assert pe is not None + assert abs(co2 - 0.244125) <= 1e-9 + assert abs(pe - 1.29455) <= 1e-9 + + +def test_heat_network_code_302_effective_factor_none_for_non_302_main() -> None: + # Arrange — a code-301 heat-network boiler main (no CHP split). The + # §12b/13b CHP+boilers blend applies only to code 302; code 301 + # routes through the 1/heat-source-eff scaling path instead. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=301, + ) + + # Act / Assert + assert _heat_network_code_302_effective_factor(main, primary_energy=False) is None + assert _heat_network_code_302_effective_factor(main, primary_energy=True) is None + + def test_heat_network_distribution_electricity_none_for_individual_main() -> None: # Arrange — an individually-heated gas-boiler main (category 2, no # heat-network SAP code). §C3.2 pumping electricity applies only to