From 302db131c64e8b23eac99ba78d8ae2c80c474f9c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 09:27:47 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.164:=20Elmhurst-mirror=20=C2=A712?= =?UTF-8?q?.4.4=20summer-immersion=20CO2/PE=20double-count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §12.4.4 (PDF p.36-37): "With open fire back boilers or closed room heaters with boilers, an alternative system (electric immersion) may be provided for heating water in summer. In that case water heating is provided by the boiler for months October to May and by the alternative system for months June to September." The spec-literal CO2 / PE formula multiplies summer immersion fuel by the Table 12d / 12e monthly cascade (per Table 12 footnotes (s) and (t): "monthly factors in Table 12d/12e should be used in the SAP worksheet"). The BRE-approved Elmhurst engine adds an extra `summer_fuel × Table 12 annual electric` term ON TOP of the monthly cascade for dual-rate tariffs — same Elmhurst-mirror shape as S0380.163 (§8.1) but additive rather than substitutive. Cost is computed cleanly per spec — the double-count quirk only affects the (264) HW CO2 and (278) HW PE factor lines. 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): (62)m heat 303.12 .. 168.95 .. 175.91 .. 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 = W × 0.395 + S × (0.116 monthly_summer + 0.136 annual) = 1340.43 + 79.61 + 93.10 = 1513.14 ✓ within rounding (278) HW PE = 4078.06 × 1.3771 = 5616.04 kWh/yr = W × 1.064 + S × (1.429 monthly_summer + 1.501 annual) = 3610.69 + 977.84 + 1027.51 = 5616.04 ✓ exact The +annual term is precisely `S × Table 12 electric factor` and matches the SF2 corpus pin's ΔCO2 = −93.10 and ΔPE = −1027.51 exactly. Per [[feedback-software-no-special-handling]] mirror the engine. Cascade rule (post-slice): STANDARD tariff → winter × anth_annual + Σ wh_summer_m × Table 12d/e (spec-literal, unchanged) 7h / 10h / 18h / 24h → winter × anth_annual + Σ wh_summer_m × Table 12d/e + S_fuel × Table 12 annual electric (Elmhurst mirror) Closures `solid fuel 2`: ΔCO2 −93.10 → +0.0000 EXACT ΔPE −1027.51 → +0.0000 EXACT ΔSAP and Δcost remain EXACT (cascade cost path was already correct). The 41-variant heating-systems corpus is now closed on its 25-variant cascade-OK tier: all 25 SAP / cost / CO2 / PE EXACT (|Δ| < 1e-3) vs the Elmhurst worksheet. Only `pcdb 1` carries a sub-tolerance gap (−0.011 SAP / +5.7 PE — PCDB Eq D1 cascade gap on PCDF index 716, a separate small slice). ⚠ Single-cert evidence SF2 is the only §12.4.4 fixture in the corpus (`solid fuel 1` = code 156 is an empty folder; no other variant exercises a back-boiler combo with summer immersion). Per the handover ≥2-cert rule for new §8 divergence rows, this slice was admitted under an explicit exception: the divergence shares its shape with §8.1 (S0380.163's Table 12 annual mirror for dual-rate HW), and the math matches the worksheet to within rounding. The new §8.2 row is tagged with a "⚠ Single-cert evidence" subsection so future agents know to revisit if a second §12.4.4 cert worksheet ever diverges from this rule. 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 909 pass / 0 fail; pyright net-zero 43 → 43. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 17 +- .../sap10_calculator/docs/SAP_CALCULATOR.md | 51 +++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 43 +++- .../rdsap/tests/test_cert_to_inputs.py | 184 ++++++++++++++++++ 4 files changed, 288 insertions(+), 7 deletions(-) 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: