diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 777f409f..507962cb 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -371,6 +371,21 @@ class _CorpusExpectation: # one slice. All 25 cascade-OK variants now SAP / cost / CO2 / PE # EXACT vs worksheet (except solid fuel 2 which carries a separate # S0380.154 summer-immersion-blend CO2/PE artifact). +# +# Slice S0380.164 closed the last open variant in the cascade-OK tier: +# `solid fuel 2`. The §12.4.4 back-boiler HW blend (S0380.154) had +# computed summer immersion CO2/PE at the spec-literal Table 12d/12e +# monthly cascade only. Per-line worksheet walk back-solved the (264) +# and (278) factors as `W × anth_annual + S × (monthly_summer_avg + +# Table 12 annual electric)` — i.e. the same Elmhurst-mirror that +# S0380.163 introduced for full-electric HW, but ADDITIVE rather than +# substitutive, applied on top of the monthly cascade for the summer- +# immersion portion of the §12.4.4 blend. The new gate fires on dual- +# rate tariffs (7-hour / 10-hour / 18-hour / 24-hour). Closure: SF2 +# ΔCO2 −93.10 → ±0.0000 EXACT, ΔPE −1027.51 → ±0.0000 EXACT. All 25 +# cascade-OK variants now SAP / cost / CO2 / PE EXACT on every metric. +# Documented at `SAP_CALCULATOR.md §8.2` with the explicit single-cert +# caveat (heating-systems corpus has only one §12.4.4 fixture). _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='ashp', block='11a', 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='electric 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), @@ -394,7 +409,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # cost / CO2 / PE all route via the correct Table 32 fuel code. # Remaining residuals are likely heating-system efficiency or # control-type gaps — separate slices. - _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-93.0988, expected_pe_resid_kwh=-1027.5099), + _CorpusExpectation(variant='solid fuel 2', block='11a', 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='solid fuel 3', block='11a', 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='solid fuel 4', block='11a', 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='solid fuel 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=-0.0000), diff --git a/domain/sap10_calculator/docs/SAP_CALCULATOR.md b/domain/sap10_calculator/docs/SAP_CALCULATOR.md index 61c6e6da..cbb1d1df 100644 --- a/domain/sap10_calculator/docs/SAP_CALCULATOR.md +++ b/domain/sap10_calculator/docs/SAP_CALCULATOR.md @@ -444,3 +444,54 @@ fuel 4/5/6/7/8/9/10/11, ashp, gshp). Each variant moved from PE +25.51 or +48.66 → ±0.0000, CO2 +6.31 or +11.95 → ±0.0000. Cohort-1 ASHP certs (STANDARD tariff) and the 6 Elmhurst U985 fixtures (gas combi, STANDARD tariff) are unaffected — they continue to use the monthly cascade. + +### 8.2 §12.4.4 back-boiler summer-immersion CO2/PE doubles the summer term + +**Slice:** S0380.164. +**Code:** +[`_section_12_4_4_hw_blend`](../rdsap/cert_to_inputs.py). +**Tests:** +`test_section_12_4_4_hw_blend_mirrors_elmhurst_summer_annual_pe_co2_double_count`, +`test_section_12_4_4_hw_blend_standard_tariff_keeps_spec_literal_monthly_cascade`. + +SAP 10.2 §12.4.4 (PDF p.36-37) routes DHW through the boiler Oct-May and +an electric immersion Jun-Sep for back-boiler combos (Table 4a codes +156 + 158). The spec-literal CO2/PE formula multiplies summer-immersion +fuel by the Table 12d / 12e monthly cascade (per Table 12 footnotes +(s)/(t)). The BRE-approved Elmhurst engine adds a SECOND term — +`summer_fuel × Table 12 ANNUAL electric factor` — on top of the +monthly cascade for the (264) HW CO2 and (278) HW PE worksheet lines on +dual-rate tariffs. Same shape as §8.1 / S0380.163 but additive rather +than substitutive. + +**Cascade rule (post-S0380.164):** + +| Tariff | §12.4.4 winter CO2 / PE | §12.4.4 summer immersion CO2 / PE | +|---|---|---| +| STANDARD | `W_fuel × boiler_annual_factor` | `Σ wh_summer_m × Table 12d/e monthly` (spec literal) | +| 7-hour / 10-hour / 18-hour / 24-hour | `W_fuel × boiler_annual_factor` | `Σ wh_summer_m × Table 12d/e monthly` **+ `S_fuel × Table 12 annual electric`** (Elmhurst mirror) | + +Cost is computed cleanly per spec (`W_fuel × boiler_price + S_fuel × +off_peak_low_price`) — the double-count quirk only affects the CO2 and +PE factor lines. + +### Cohort impact + +The heating-systems corpus has exactly one §12.4.4 fixture: `solid fuel 2` +(Table 4a code 158, anthracite, 18-hour tariff, 110 L cylinder + cyl +thermostat). Pre-slice the cascade carried ΔCO2 = −93.10 kg/yr / ΔPE += −1027.51 kWh/yr — matching `684.55 kWh × 0.136 CO2` and +`684.55 kWh × 1.501 PE` to within rounding. Post-slice closes to +±0.0000 on all four metrics, completing the cohort closure at 25/25 +cascade-OK variants EXACT vs the Elmhurst worksheet. + +### ⚠ Single-cert evidence + +The §12.4.4 divergence is documented here on **one** worksheet (SF2) +because the corpus has no second §12.4.4 fixture (`solid fuel 1` = +code 156 is an empty folder). The math nonetheless matches the +worksheet to within rounding and aligns with §8.1's S0380.163 mirror +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. diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5816b3de..540cabfd 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4932,7 +4932,15 @@ def _section_12_4_4_hw_blend( # CO2: boiler fuel at its Table 12 annual factor (winter) + electric # at the summer-month-weighted Table 12d cascade (per Table 12d - # header — "monthly factors instead the annual average"). + # header — "monthly factors instead the annual average"). On dual- + # rate tariffs the BRE-approved Elmhurst engine applies an + # *additional* `summer_fuel × Table 12 annual electric CO2` term on + # top of the Table 12d monthly cascade — same shape as the S0380.163 + # Elmhurst-mirror for the (264) HW CO2 line, here added rather than + # substituted. See SAP_CALCULATOR.md §8.2 for the single-cert + # worksheet evidence (SF2 (264) factor 0.371 = W×0.395 + S×(0.116 + # monthly_summer + 0.136 annual) / total). STANDARD tariff keeps the + # spec-literal monthly-only cascade. boiler_co2 = ( co2_factor_kg_per_kwh(boiler_fuel_code) if boiler_fuel_code is not None else 0.0 @@ -4940,16 +4948,29 @@ def _section_12_4_4_hw_blend( elec_co2_monthly = co2_monthly_factors_kg_per_kwh( _SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12 ) - summer_co2_kg = ( + summer_co2_kg_monthly = ( sum( wh_output_monthly_kwh[i] * elec_co2_monthly[i] for i in _SECTION_12_4_4_SUMMER_MONTH_INDICES ) if elec_co2_monthly is not None else 0.0 ) - blended_co2 = (winter_fuel * boiler_co2 + summer_co2_kg) / total_fuel + elec_co2_annual = co2_factor_kg_per_kwh( + _SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12 + ) + summer_co2_kg_annual_mirror = ( + summer_fuel * elec_co2_annual if tariff is not Tariff.STANDARD else 0.0 + ) + blended_co2 = ( + winter_fuel * boiler_co2 + + summer_co2_kg_monthly + + summer_co2_kg_annual_mirror + ) / total_fuel - # PE: same shape (Table 12e monthly cascade for summer electric). + # PE: same shape (Table 12e monthly cascade for summer electric) + # with the same Elmhurst-mirror `summer_fuel × Table 12 annual` term + # on dual-rate tariffs. SF2 (278) factor 1.3771 = W×1.064 + S×(1.429 + # monthly_summer + 1.501 annual) / total. boiler_pe = ( primary_energy_factor(boiler_fuel_code) if boiler_fuel_code is not None else 0.0 @@ -4957,14 +4978,24 @@ def _section_12_4_4_hw_blend( elec_pe_monthly = pe_monthly_factors_kwh_per_kwh( _SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12 ) - summer_pe_kwh = ( + summer_pe_kwh_monthly = ( sum( wh_output_monthly_kwh[i] * elec_pe_monthly[i] for i in _SECTION_12_4_4_SUMMER_MONTH_INDICES ) if elec_pe_monthly is not None else 0.0 ) - blended_pe = (winter_fuel * boiler_pe + summer_pe_kwh) / total_fuel + elec_pe_annual = primary_energy_factor( + _SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12 + ) + summer_pe_kwh_annual_mirror = ( + summer_fuel * elec_pe_annual if tariff is not Tariff.STANDARD else 0.0 + ) + blended_pe = ( + winter_fuel * boiler_pe + + summer_pe_kwh_monthly + + summer_pe_kwh_annual_mirror + ) / total_fuel # Standing charges: Table 12 note (a) adds the off-peak electric # standing when HW uses off-peak electricity. The §12.4.4 summer diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 7d29d20f..856e79c1 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -1845,6 +1845,190 @@ def test_section_12_4_4_summer_immersion_applies_to_back_boiler_combos() -> None ) is False +def test_section_12_4_4_hw_blend_mirrors_elmhurst_summer_annual_pe_co2_double_count() -> None: + # Arrange — SAP 10.2 §12.4.4 back-boiler combos with the boiler heating + # the cylinder Oct-May + an electric immersion Jun-Sep. The spec-literal + # CO2/PE formula multiplies summer immersion fuel by Table 12d / 12e + # monthly factors (footnotes (s)/(t)). The BRE-approved Elmhurst engine + # adds a SECOND term: `summer_fuel × Table 12 ANNUAL electric factor` + # for dual-rate tariffs — same Elmhurst-mirror shape as S0380.163 for + # the (278) "Water heating (low-rate cost)" line, here applied on TOP + # of the spec-literal monthly cascade (not as a replacement). + # + # Worksheet evidence (heating-systems corpus property 001431, + # `solid fuel 2` — Table 4a code 158 closed-room-heater + back boiler, + # 65 % winter η + 100 % summer η, anthracite, 18-hour off-peak tariff, + # 110 L cylinder + cylinder thermostat lodged): + # + # (62)m heat 303.12, 268.95, 287.76, 257.67, 252.99, + # 168.95, 166.27, 173.42, 175.91, + # 261.69, 273.22, 300.40 kWh + # winter fuel (W) = 2205.80 / 0.65 = 3393.51 kWh anthracite + # summer fuel (S) = 684.55 / 1.00 = 684.55 kWh immersion + # total fuel = (219) = 4078.06 kWh + # + # (264) HW CO2 = 4078.06 × 0.3710 = 1513.15 kg/yr + # decomposes as W × 0.395 (anth annual) + S × 0.116 (Jun-Sep Table + # 12d cascade) + S × 0.136 (Table 12 annual electricity) + # = 1340.43 + 79.61 + 93.10 = 1513.14 ✓ within rounding + # + # (278) HW PE = 4078.06 × 1.3771 = 5616.04 kWh/yr + # decomposes as W × 1.064 (anth annual) + S × 1.429 (Jun-Sep Table + # 12e cascade) + S × 1.501 (Table 12 annual electricity) + # = 3610.69 + 977.84 + 1027.51 = 5616.04 ✓ exact + # + # Pre-slice the blend helper returned the spec-literal sum (W × anth + + # S × monthly) and the SF2 corpus pin carried ΔCO2 = −93.10 / ΔPE = + # −1027.51, matching the missing summer × annual term exactly. Per + # [[feedback-software-no-special-handling]] mirror the Elmhurst engine. + # SAP_CALCULATOR.md §8.2 documents the divergence (single-cert + # evidence — pending re-verification when a second §12.4.4 fixture + # lands; the math is the same shape as §8.1 / S0380.163's Table 12 + # annual mirror for dual-rate HW). + + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _section_12_4_4_hw_blend, # pyright: ignore[reportPrivateUsage] + ) + from domain.sap10_calculator.tables.table_12 import ( + co2_factor_kg_per_kwh, + co2_monthly_factors_kg_per_kwh, + pe_monthly_factors_kwh_per_kwh, + primary_energy_factor, + ) + from domain.sap10_calculator.tables.table_12a import Tariff + + sf2_monthly_heat = ( + 303.1182, 268.9461, 287.7571, 257.6651, 252.9865, + 168.9471, 166.2739, 173.4236, 175.9056, + 261.6901, 273.2184, 300.4020, + ) + boiler_eff_pct = 65.0 + anthracite_code = 15 # Table 12 anthracite + + # Act — invoke the §12.4.4 blend on SF2-shape inputs with the 18-hour + # off-peak tariff Elmhurst lodges for this fixture. + total_fuel, _cost, blended_co2, blended_pe, _standing = ( + _section_12_4_4_hw_blend( + wh_output_monthly_kwh=sf2_monthly_heat, + boiler_efficiency_pct=boiler_eff_pct, + boiler_fuel_code=anthracite_code, + tariff=Tariff.EIGHTEEN_HOUR, + prices=SAP_10_2_SPEC_PRICES, + ) + ) + + # Assert — total_fuel sums the two-fuel split; CO2 and PE reproduce the + # Elmhurst-mirror "annual on top of monthly" double-count. + summer_indices = frozenset({5, 6, 7, 8}) # Jun-Sep + winter_heat = sum( + h for i, h in enumerate(sf2_monthly_heat) if i not in summer_indices + ) + summer_heat = sum( + h for i, h in enumerate(sf2_monthly_heat) if i in summer_indices + ) + expected_winter_fuel = winter_heat / (boiler_eff_pct / 100.0) + expected_summer_fuel = summer_heat / 1.0 + expected_total_fuel = expected_winter_fuel + expected_summer_fuel + assert abs(total_fuel - expected_total_fuel) <= 1e-9 + + anth_co2 = co2_factor_kg_per_kwh(anthracite_code) + anth_pe = primary_energy_factor(anthracite_code) + elec_co2_monthly = co2_monthly_factors_kg_per_kwh(30) + elec_pe_monthly = pe_monthly_factors_kwh_per_kwh(30) + assert elec_co2_monthly is not None and elec_pe_monthly is not None + elec_co2_annual = co2_factor_kg_per_kwh(30) + elec_pe_annual = primary_energy_factor(30) + + expected_co2 = ( + expected_winter_fuel * anth_co2 + + sum(sf2_monthly_heat[i] * elec_co2_monthly[i] for i in summer_indices) + + expected_summer_fuel * elec_co2_annual + ) / expected_total_fuel + expected_pe = ( + expected_winter_fuel * anth_pe + + sum(sf2_monthly_heat[i] * elec_pe_monthly[i] for i in summer_indices) + + expected_summer_fuel * elec_pe_annual + ) / expected_total_fuel + + assert abs(blended_co2 - expected_co2) <= 1e-9 + assert abs(blended_pe - expected_pe) <= 1e-9 + + +def test_section_12_4_4_hw_blend_standard_tariff_keeps_spec_literal_monthly_cascade() -> None: + # Arrange — the Elmhurst-mirror "summer × annual" addition is gated on + # dual-rate tariffs (mirroring S0380.163's STANDARD ↔ off-peak split + # for the (278) "Water heating (low-rate cost)" line). On STANDARD + # tariff the spec-literal monthly cascade is the canonical form and + # the blend stays at `W × anth_annual + S × monthly_summer_avg`. + # + # No corpus fixture exercises STANDARD-tariff §12.4.4 — all 41 + # heating-systems corpus variants lodge `meter_type='18 Hour'`. This + # test guards the gate so a future STANDARD-tariff §12.4.4 fixture + # cascades through the spec-literal path. + + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _section_12_4_4_hw_blend, # pyright: ignore[reportPrivateUsage] + ) + from domain.sap10_calculator.tables.table_12 import ( + co2_factor_kg_per_kwh, + co2_monthly_factors_kg_per_kwh, + pe_monthly_factors_kwh_per_kwh, + primary_energy_factor, + ) + from domain.sap10_calculator.tables.table_12a import Tariff + + sf2_monthly_heat = ( + 303.1182, 268.9461, 287.7571, 257.6651, 252.9865, + 168.9471, 166.2739, 173.4236, 175.9056, + 261.6901, 273.2184, 300.4020, + ) + + # Act + _total, _cost, blended_co2, blended_pe, _standing = ( + _section_12_4_4_hw_blend( + wh_output_monthly_kwh=sf2_monthly_heat, + boiler_efficiency_pct=65.0, + boiler_fuel_code=15, + tariff=Tariff.STANDARD, + prices=SAP_10_2_SPEC_PRICES, + ) + ) + + # Assert — STANDARD tariff matches spec-literal (no Elmhurst-mirror + # summer×annual term). + summer_indices = frozenset({5, 6, 7, 8}) + winter_heat = sum( + h for i, h in enumerate(sf2_monthly_heat) if i not in summer_indices + ) + summer_heat = sum( + h for i, h in enumerate(sf2_monthly_heat) if i in summer_indices + ) + winter_fuel = winter_heat / 0.65 + summer_fuel = summer_heat + total_fuel = winter_fuel + summer_fuel + anth_co2 = co2_factor_kg_per_kwh(15) + anth_pe = primary_energy_factor(15) + elec_co2_monthly = co2_monthly_factors_kg_per_kwh(30) + elec_pe_monthly = pe_monthly_factors_kwh_per_kwh(30) + assert elec_co2_monthly is not None and elec_pe_monthly is not None + expected_co2_literal = ( + winter_fuel * anth_co2 + + sum(sf2_monthly_heat[i] * elec_co2_monthly[i] for i in summer_indices) + ) / total_fuel + expected_pe_literal = ( + winter_fuel * anth_pe + + sum(sf2_monthly_heat[i] * elec_pe_monthly[i] for i in summer_indices) + ) / total_fuel + assert abs(blended_co2 - expected_co2_literal) <= 1e-9 + assert abs(blended_pe - expected_pe_literal) <= 1e-9 + # Spec-literal stays strictly below the Elmhurst-mirror dual-rate + # value (the +annual term is additive). + elmhurst_mirror_co2 = expected_co2_literal + (summer_fuel * 0.136) / total_fuel + elmhurst_mirror_pe = expected_pe_literal + (summer_fuel * 1.501) / total_fuel + assert blended_co2 < elmhurst_mirror_co2 + assert blended_pe < elmhurst_mirror_pe + + def test_separately_timed_dhw_solid_fuel_boiler_codes_per_sap_10_2_table_3() -> None: # Arrange — SAP 10.2 Table 3 (PDF p.160) gives three primary-loss # rows keyed off the DHW timing arrangement: