Slice S0380.105: MEV fans CO2 split via Table 12a Grid 2 + Table 12d (SAP 10.2 §10a / §10b)

Mirror of S0380.103 for the CO2 cascade. Cert 000565 worksheet line
(267):

  Pumps, fans and electric keep-hot  252.5159  0.1412  35.3349 (267)

The displayed factor (0.1412) is the ALL_OTHER_USES Table 12d Σ
days-weighted blend; the displayed product (35.3349) is the kWh-
weighted blend across the two Grid 2 categories:

  F_FANS  = 0.58 × F_code34 + 0.42 × F_code33 = 0.13872 kg/kWh
  F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 0.14116 kg/kWh
  F_eff   = (127.5159 × 0.13872 + 125.0 × 0.14116) / 252.5159
          = 0.13993 kg/kWh
  CO2     = 252.5159 × 0.13993 = 35.3349 kg/yr ✓

Pre-slice the cascade applied 0.14116 to ALL 252.5159 kWh →
35.6457 → +0.31 over ws.

SAP 10.2 Table 12a Grid 2 (PDF p.191) verbatim header:

  "Fractions of electricity used at the higher rate, for use in
   off-peak tariff calculations
   ...
   Fans for mechanical ventilation systems   10-hour: 0.58
   All other uses, and locally generated     10-hour: 0.80
     electricity"

SAP 10.2 Table 12d (PDF p.194) verbatim header:

  "Where electricity is the fuel used, the relevant set of factors
   in the table below should be used to calculate the monthly CO2
   emissions INSTEAD of the annual average factor given in Table
   12."

The Grid 2 high-rate fraction blends Table 12d high-rate × low-
rate codes per `F_blended = high_frac × F_high + (1 − high_frac)
× F_low`. MEV fans bill at the lower 0.58 high_frac → lower CO2
factor on the higher-carbon high-rate code 34. Cost-side S0380.103
landed the same split for tariff prices; this slice mirrors it
for the CO2 factor.

3-layer fix:
1. New helper `_pumps_fans_co2_factor_kg_per_kwh` returns the
   kWh-weighted blend across `FANS_FOR_MECH_VENT` + `ALL_OTHER_USES`
   factors. Falls back to the existing `ALL_OTHER_USES` rate on
   STANDARD tariff and no-MEV certs (cohort-safe).
2. cert_to_inputs.py wires `mev_kwh_for_cost_split` +
   `pumps_fans_kwh` through to the new helper.
3. Field `CalculatorInputs.pumps_fans_co2_factor_kg_per_kwh`
   already exists from S0380.65; calculator legacy path unchanged.

Movement at HEAD `7df3fef8` → post-slice (cert 000565):

| Pin                          | Pre        | Post       | Δ vs ws  |
|------------------------------|-----------:|-----------:|---------:|
| pumps_fans_co2_kg_per_yr     |    35.6457 |    35.3349 |   ✓ 0    |
| co2_kg_per_yr (TOTAL)        |  6445.8198 |  6445.5090 |  −2.1173 |

The total CO2 residual moves -1.81 → -2.12 (sign-flip pattern of
S0380.103): the previously-cancelling pumps_fans CO2 over-count
masked the main-heating-fuel CO2 under-count (downstream of the
§3-§8 SH cascade -16 kWh fuel residual). Per user direction
(NEXT_AGENT_PROMPT) transient continuous-SAP / TOTAL drift is OK
while closing a true spec-correct intermediate-value bug; the SH
cascade closure is a separate slice.

Cohort safety: STANDARD-tariff certs return the existing
ALL_OTHER_USES rate (helper falls through). No-MEV certs return
the same rate (mev_kwh_per_yr=0 short-circuit).

Test count: 604 pass + 7 expected 000565 fails → **605 pass + 7
expected 000565 fails** (new
test_summary_000565_mev_fans_co2_factor_uses_table_12a_grid_2_
fans_for_mech_vent_split GREEN). Pyright net-zero per touched
file (45 baseline → 45 post-change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 16:48:53 +00:00
parent 7df3fef8bb
commit 8a3aaf7ae6
2 changed files with 117 additions and 3 deletions

View file

@ -1892,6 +1892,57 @@ def test_summary_000565_mev_fans_cost_uses_table_12a_grid_2_fans_for_mech_vent_r
)
def test_summary_000565_mev_fans_co2_factor_uses_table_12a_grid_2_fans_for_mech_vent_split() -> None:
# Arrange — SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12d
# (PDF p.194) — CO2-side mirror of the cost split landed in
# S0380.103. The Table 12a Grid 2 high-rate fractions on TEN_HOUR
# are:
#
# Fans for mechanical ventilation systems high_frac = 0.58
# All other uses, and locally generated high_frac = 0.80
# electricity
#
# Table 12d codes for TEN_HOUR are 34 (high) + 33 (low). Days-
# weighted Σ(F_m × N_m) / Σ N_m over the 12 months of code 30
# uniform-per-day proxy yields:
#
# F_FANS = 0.58 × F_code34 + 0.42 × F_code33 = 0.13872 kg/kWh
# F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 0.14116 kg/kWh
#
# Cert 000565 splits pumps_fans into 127.5159 kWh MEV + 125 kWh
# non-MEV (45 flue fan + 80 solar HW pump). kWh-weighted blend:
#
# F_eff = (127.5159 × 0.13872 + 125 × 0.14116) / 252.5159
# = 0.13993 kg/kWh
#
# Worksheet line (267) verifies the split:
# Pumps, fans and electric keep-hot 252.5159 × 0.1412 = 35.3349
# (display rounds factor to 0.1412 but the product is the
# kWh-weighted MEV-split total of 35.3349)
#
# Pre-slice the cascade applied 0.14116 to ALL 252.5159 kWh →
# 35.6457 kg/yr → +0.31 over ws. With the MEV-aware split the
# cascade lands on 35.3349 kg/yr.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs
# Act
inputs = cert_to_inputs(epc)
# Assert — pumps_fans CO2 factor equals the worksheet line (267)
# effective rate (within 1e-4 / kWh).
expected_factor = 35.3349 / 252.5159
actual = inputs.pumps_fans_co2_factor_kg_per_kwh
assert actual is not None
assert abs(actual - expected_factor) <= 1e-4, (
f"cascade pumps_fans_co2_factor={actual:.6f}; "
f"ws (267) effective={expected_factor:.6f}; Δ={actual - expected_factor:+.6f} "
f"(expected MEV-split kWh-weighted blend post-S0380.105)"
)
def test_summary_000565_main_1_ashp_sap_code_224_routes_to_main_heating_category_4_per_sap_table_4a() -> None:
# Arrange — SAP 10.2 Table 4a (PDF p.165) "Main heating systems":
# the category column lists "Heat pumps" as category 4. Codes in

View file

@ -1872,6 +1872,61 @@ def _main_heating_primary_factor(
return high_frac * high_factor + (1.0 - high_frac) * low_factor
def _pumps_fans_co2_factor_kg_per_kwh(
*,
tariff: Tariff,
mev_kwh_per_yr: float,
total_pumps_fans_kwh_per_yr: float,
monthly_kwh: tuple[float, ...],
) -> Optional[float]:
"""SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12d (PDF p.194) —
CO2-side mirror of `_pumps_fans_fuel_cost_gbp_per_kwh` (Slice
S0380.103).
MEV/MVHR-fan electricity bills at the `FANS_FOR_MECH_VENT` high-rate
fraction (10-hour: 0.58; 7-hour: 0.71) on dual-rate tariffs, while
the remaining pumps_fans portion (central-heating circulation
pumps, flue fans, solar HW pumps, electric keep-hot) bills at
`ALL_OTHER_USES` (10-hour: 0.80; 7-hour: 0.90). The two Grid 2
categories blend Table 12d high/low-rate codes at different ratios
two distinct effective CO2 factors. Returns the kWh-weighted
blend across the two streams.
Returns the existing `_other_use_co2_factor_kg_per_kwh(
ALL_OTHER_USES, ...)` rate on STANDARD tariff (no Grid 2 split
applies Table 12d code 30 monthly cascade only), and when no MEV
is lodged (no split needed).
Worksheet pin for cert 000565 (TEN_HOUR + MEV 127.5159 kWh + 125
kWh other pumps/fans):
F_FANS = 0.58 × F_code34 + 0.42 × F_code33 = 0.13872 kg/kWh
F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 0.14116 kg/kWh
F_eff = (127.5159 × 0.13872 + 125.0 × 0.14116) / 252.5159
= 0.13993 kg/kWh
Worksheet line (267): 252.5159 × 0.13993 = 35.3349 kg/yr; pre-slice
the cascade applied 0.14116 to all pumps_fans 35.6457 +0.31
over ws.
"""
other_factor = _other_use_co2_factor_kg_per_kwh(
OtherUse.ALL_OTHER_USES, tariff, monthly_kwh,
)
if (
tariff is Tariff.STANDARD
or mev_kwh_per_yr <= 0.0
or total_pumps_fans_kwh_per_yr <= 0.0
):
return other_factor
fans_factor = _other_use_co2_factor_kg_per_kwh(
OtherUse.FANS_FOR_MECH_VENT, tariff, monthly_kwh,
)
if fans_factor is None or other_factor is None:
return other_factor
non_mev_kwh = max(0.0, total_pumps_fans_kwh_per_yr - mev_kwh_per_yr)
return (
mev_kwh_per_yr * fans_factor + non_mev_kwh * other_factor
) / total_pumps_fans_kwh_per_yr
def _other_use_co2_factor_kg_per_kwh(
other_use: OtherUse,
tariff: Tariff,
@ -4499,9 +4554,17 @@ def cert_to_inputs(
# low Table 12d codes per the Grid 2 fraction. STANDARD tariff
# passes through to single-code-30 monthly. Mirrors the main-
# heating Grid 1 split landed in S0380.65.
pumps_fans_co2_factor_kg_per_kwh=_other_use_co2_factor_kg_per_kwh(
OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc),
_days_in_month_proportioned(pumps_fans_kwh, _DAYS_IN_MONTH),
#
# MEV/MVHR-fan kWh route through `FANS_FOR_MECH_VENT` (lower
# high-rate fraction → lower CO2 factor on a high-carbon high-
# rate code) instead of `ALL_OTHER_USES`. Slice S0380.105
# weights the two streams by their lodged kWh portions —
# mirror of the cost-side S0380.103 split.
pumps_fans_co2_factor_kg_per_kwh=_pumps_fans_co2_factor_kg_per_kwh(
tariff=_rdsap_tariff(epc),
mev_kwh_per_yr=mev_kwh_for_cost_split,
total_pumps_fans_kwh_per_yr=pumps_fans_kwh,
monthly_kwh=_days_in_month_proportioned(pumps_fans_kwh, _DAYS_IN_MONTH),
),
lighting_co2_factor_kg_per_kwh=_other_use_co2_factor_kg_per_kwh(
OtherUse.ALL_OTHER_USES, _rdsap_tariff(epc), lighting_monthly_kwh,