mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.65: SAP 10.2 Table 12d + Table 12a Grid 1 dual-rate CO2 factor for electric mains on off-peak
Pre-S0380.65 the cascade applied the annual-flat SAP 10.2 Table 12
code-30 CO2 factor (0.136 kg/kWh) to all electric main heating fuel,
ignoring both the Table 12d monthly variation AND the Table 12a
Grid 1 (SH) high-rate fraction. For winter-peaked heat-pump loads on
an off-peak tariff this hid ~600 kg/yr of CO2 — cert 000565 worksheet
line 261:
Main 1 (high-rate cost) 20826.4764 kWh × 0.1581 = 3293.6388 kg
Main 1 (low-rate cost) 13884.3176 kWh × 0.1460 = 2027.2261 kg
───────────── ────────────
blended 34710.7941 × 0.1533 = 5320.87 kg
Cascade impact on cert 000565:
- co2_kg_per_yr 5823.16 → 6427.86 (Δ −624 → Δ −20)
- main_heating_co2_factor 0.1360 → 0.1533 (matches spec line 261)
- sap_score 29 EXACT (unchanged — CO2 doesn't enter
the energy-rating cost factor)
Spec basis:
- SAP 10.2 Table 12a Grid 1 (p.191): SH high-rate fraction by
`Table12aSystem` × tariff. ASHP_OTHER + TEN_HOUR = 0.6 high
(Slice S0380.61 already exercises this on the cost side).
- SAP 10.2 Table 12d (p.194): monthly CO2 factors by electricity
fuel code. 7-hour split = codes 32 (high) / 31 (low); 10-hour
split = codes 34 (high) / 33 (low). 18-hour (38/40) and 24-hour
(35) fall through to code-30 monthly factors in the table —
no dual-rate split applies for those tariffs.
- RdSAP 10 §12 page 62 (Slice S0380.60): meter_type=Dual + HP
without PCDB record → Rule 1 → TEN_HOUR tariff.
Implementation:
- New `_TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12` dict — mirror of the
existing `_TARIFF_HIGH_LOW_RATES_P_PER_KWH` cost-rates dict.
Only SEVEN_HOUR + TEN_HOUR have a Table 12d split entry.
- New `_main_heating_co2_factor_kg_per_kwh(main, tariff, monthly_kwh)`
helper — mirror of `_space_heating_fuel_cost_gbp_per_kwh` on the
CO2 side. Falls back to the annual `_co2_factor_kg_per_kwh` for
non-electric mains, STANDARD tariff, mains without a Table 12a
Grid 1 row yet (storage / direct-acting electric — TODO matches
cost-helper coverage gap), tariffs without a Table 12d split,
and zero-fuel degenerate cases.
- Wire helper into `CalculatorInputs.main_heating_co2_factor_kg_per_kwh`
using the `energy_requirements_result.main_1_fuel_monthly_kwh`
profile already precomputed in `cert_to_inputs`.
Tests:
- `test_dual_meter_ashp_main_heating_co2_factor_applies_table_12a
_grid_1_split` — minimal ASHP code 224 + meter_type=1 cert
asserts the effective factor exceeds the pre-S0380.65 annual
flat (0.136 + 0.005) per spec.
- `test_standard_meter_ashp_main_heating_co2_factor_falls_back_
to_annual_table_12` — meter_type=2 (Standard) pass-through pin
at 0.136. Locks in non-regression for non-dual-meter certs.
Test suite: 480 pass + 9 expected 000565 cascade-gap fails (was
478/9 pre-S0380.65). Pyright net-zero on both touched files
(cert_to_inputs.py 34/34; test_cert_to_inputs.py 11/11).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2725ff505b
commit
99c8c148f1
2 changed files with 164 additions and 1 deletions
|
|
@ -849,6 +849,18 @@ _TARIFF_HIGH_LOW_RATES_P_PER_KWH: Final[dict[Tariff, tuple[float, float]]] = {
|
|||
}
|
||||
|
||||
|
||||
# Tariff → (high_rate_fuel_code, low_rate_fuel_code) for the SAP 10.2
|
||||
# Table 12d (CO2) / Table 12e (PE) monthly factors. Mirror of the
|
||||
# Table 32 cost-rates dict above: 7-hour and 10-hour tariffs split into
|
||||
# distinct Table 12d profiles; 18-hour (38/40) and 24-hour (35) fall
|
||||
# through to standard code 30 monthly factors in Table 12d itself, so
|
||||
# no dual-rate split applies for them.
|
||||
_TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12: Final[dict[Tariff, tuple[int, int]]] = {
|
||||
Tariff.SEVEN_HOUR: (32, 31), # 7-hour high, 7-hour low
|
||||
Tariff.TEN_HOUR: (34, 33), # 10-hour high, 10-hour low
|
||||
}
|
||||
|
||||
|
||||
def _table_12a_system_for_main(
|
||||
main: Optional[MainHeatingDetail],
|
||||
) -> Optional[Table12aSystem]:
|
||||
|
|
@ -1401,6 +1413,58 @@ def _co2_factor_kg_per_kwh(main: Optional[MainHeatingDetail]) -> float:
|
|||
return co2_factor_kg_per_kwh(_main_fuel_code(main))
|
||||
|
||||
|
||||
def _main_heating_co2_factor_kg_per_kwh(
|
||||
main: Optional[MainHeatingDetail],
|
||||
tariff: Tariff,
|
||||
main_fuel_monthly_kwh: tuple[float, ...],
|
||||
) -> float:
|
||||
"""SAP 10.2 Table 12a Grid 1 (SH) + Table 12d (p.194) dual-rate
|
||||
monthly CO2 factor for electric mains on off-peak tariffs.
|
||||
|
||||
Mirrors `_space_heating_fuel_cost_gbp_per_kwh` on the CO2 side —
|
||||
cert 000565 worksheet line 261 shows the spec calculation:
|
||||
|
||||
Main heating CO2 = high_frac × Σ(F_m × CO2^high_m) / Σ F_m × F_total
|
||||
+ (1 - high_frac) × Σ(F_m × CO2^low_m) / Σ F_m × F_total
|
||||
|
||||
blended as a single effective factor × annual fuel for the
|
||||
calculator's energy-rating output. For TEN_HOUR + ASHP_OTHER (Grid 1
|
||||
high_frac=0.6) the worksheet blends Table 12d code 34 (10h high)
|
||||
and code 33 (10h low) over the cert's main_1_fuel_monthly_kwh
|
||||
profile → 0.6 × 0.1581 + 0.4 × 0.1460 = 0.1533 kg/kWh, vs the pre-
|
||||
S0380.65 flat Table 12 code-30 annual factor 0.136 that hid ~579
|
||||
kg/yr of HP CO2 on a winter-peaked load.
|
||||
|
||||
Fallback to `_co2_factor_kg_per_kwh` (annual Table 12) for:
|
||||
- non-electric mains (gas, oil, LPG — pass-through)
|
||||
- STANDARD tariff (no dual-rate routing per RdSAP 10 §12)
|
||||
- mains without a Table 12a Grid 1 row yet (storage heaters, direct-
|
||||
acting electric — TODO mirrors the cost-helper coverage gap)
|
||||
- tariffs without a Table 12d split (EIGHTEEN_HOUR, TWENTY_FOUR_HOUR
|
||||
— both fall through to code 30 monthly factors in the table)
|
||||
- zero-fuel cases (sum monthly_kwh == 0 → effective factor None;
|
||||
annual factor is the safe degenerate value)
|
||||
"""
|
||||
if not _is_electric_main(main) or tariff is Tariff.STANDARD:
|
||||
return _co2_factor_kg_per_kwh(main)
|
||||
system = _table_12a_system_for_main(main)
|
||||
if system is None:
|
||||
return _co2_factor_kg_per_kwh(main)
|
||||
try:
|
||||
high_frac = space_heating_high_rate_fraction(system, tariff)
|
||||
except NotImplementedError:
|
||||
return _co2_factor_kg_per_kwh(main)
|
||||
codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff)
|
||||
if codes is None:
|
||||
return _co2_factor_kg_per_kwh(main)
|
||||
high_code, low_code = codes
|
||||
high_factor = _effective_monthly_co2_factor(main_fuel_monthly_kwh, high_code)
|
||||
low_factor = _effective_monthly_co2_factor(main_fuel_monthly_kwh, low_code)
|
||||
if high_factor is None or low_factor is None:
|
||||
return _co2_factor_kg_per_kwh(main)
|
||||
return high_frac * high_factor + (1.0 - high_frac) * low_factor
|
||||
|
||||
|
||||
def _int_or_none(value: object) -> Optional[int]:
|
||||
return value if isinstance(value, int) else None
|
||||
|
||||
|
|
@ -3476,7 +3540,15 @@ def cert_to_inputs(
|
|||
# annual Table 12 value. None → calculator falls back to the global
|
||||
# `co2_factor_kg_per_kwh`. Secondary heating defaults to standard
|
||||
# electricity per RdSAP §A.2.2 (portable electric heater).
|
||||
main_heating_co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main),
|
||||
# Main heating routes through `_main_heating_co2_factor_kg_per_kwh`
|
||||
# so electric mains on off-peak tariffs blend Table 12a Grid 1 SH
|
||||
# high-rate fraction × Table 12d high-rate monthly factors with
|
||||
# the matching low-rate pair (mirror of the cost-side dual-rate
|
||||
# split landed in Slice S0380.61).
|
||||
main_heating_co2_factor_kg_per_kwh=_main_heating_co2_factor_kg_per_kwh(
|
||||
main, _rdsap_tariff(epc),
|
||||
energy_requirements_result.main_1_fuel_monthly_kwh,
|
||||
),
|
||||
secondary_heating_co2_factor_kg_per_kwh=_effective_monthly_co2_factor(
|
||||
energy_requirements_result.secondary_fuel_monthly_kwh,
|
||||
_STANDARD_ELECTRICITY_FUEL_CODE,
|
||||
|
|
|
|||
|
|
@ -851,6 +851,97 @@ def test_off_peak_meter_routes_electric_costs_to_low_rate() -> None:
|
|||
assert abs(inputs.other_fuel_cost_gbp_per_kwh - 0.14311) < 1e-5
|
||||
|
||||
|
||||
def test_dual_meter_ashp_main_heating_co2_factor_applies_table_12a_grid_1_split() -> None:
|
||||
# Arrange — RdSAP 10 §12 page 62 Rule 1: HP without PCDB record →
|
||||
# TEN_HOUR tariff. Table 12a Grid 1 (SH) ASHP_OTHER + TEN_HOUR =
|
||||
# 0.6 high-rate fraction. Table 12d (CO2) high-rate code 34 (10h
|
||||
# high) + low-rate code 33 (10h low) monthly factors blend by
|
||||
# main_1_fuel_monthly_kwh profile per Slice S0380.65. Pre-S0380.65
|
||||
# the cascade applied annual-flat code 30 factor 0.136 to all
|
||||
# electric main heating, masking ~579 kg/yr of CO2 on cert 000565.
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=_TYPICAL_TFA_M2,
|
||||
habitable_rooms_count=3,
|
||||
region_code="1",
|
||||
dwelling_type="Detached bungalow",
|
||||
sap_building_parts=[
|
||||
make_building_part(
|
||||
floor_dimensions=[
|
||||
make_floor_dimension(
|
||||
total_floor_area_m2=_TYPICAL_TFA_M2, floor=0,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
sap_heating=make_sap_heating(
|
||||
water_heating_fuel=29,
|
||||
main_heating_details=[
|
||||
MainHeatingDetail(
|
||||
has_fghrs=False, main_fuel_type=29, heat_emitter_type=1,
|
||||
emitter_temperature=1, main_heating_control=2106,
|
||||
main_heating_category=4, sap_main_heating_code=224,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
epc.sap_energy_source.meter_type = 1 # type: ignore[assignment] # Dual → §12 Rule 1 → TEN_HOUR
|
||||
|
||||
# Act
|
||||
inputs = cert_to_inputs(epc)
|
||||
|
||||
# Assert — winter-peaked HP fuel profile weights higher-CO2 winter
|
||||
# months; the dual-rate Table 12a + Table 12d blend must land
|
||||
# materially above the pre-S0380.65 annual-flat 0.136. (The exact
|
||||
# value depends on the cascade's main_1_fuel_monthly_kwh profile;
|
||||
# cert 000565 worksheet line 261 lands at 0.1533 for its real
|
||||
# geometry.)
|
||||
annual_flat = 0.136
|
||||
factor = inputs.main_heating_co2_factor_kg_per_kwh
|
||||
assert factor is not None and factor > annual_flat + 0.005, (
|
||||
f"expected dual-rate blend > {annual_flat + 0.005:.4f}; "
|
||||
f"got {factor}"
|
||||
)
|
||||
|
||||
|
||||
def test_standard_meter_ashp_main_heating_co2_factor_falls_back_to_annual_table_12() -> None:
|
||||
# Arrange — same ASHP, but meter_type=2 (Standard) → no §12
|
||||
# routing → no Table 12a Grid 1 split → annual Table 12 code-30
|
||||
# factor 0.136 (pass-through). Pre- and post-S0380.65 behave
|
||||
# identically for STANDARD tariff.
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=_TYPICAL_TFA_M2,
|
||||
habitable_rooms_count=3,
|
||||
region_code="1",
|
||||
dwelling_type="Detached bungalow",
|
||||
sap_building_parts=[
|
||||
make_building_part(
|
||||
floor_dimensions=[
|
||||
make_floor_dimension(
|
||||
total_floor_area_m2=_TYPICAL_TFA_M2, floor=0,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
sap_heating=make_sap_heating(
|
||||
water_heating_fuel=29,
|
||||
main_heating_details=[
|
||||
MainHeatingDetail(
|
||||
has_fghrs=False, main_fuel_type=29, heat_emitter_type=1,
|
||||
emitter_temperature=1, main_heating_control=2106,
|
||||
main_heating_category=4, sap_main_heating_code=224,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
epc.sap_energy_source.meter_type = 2 # type: ignore[assignment] # Standard
|
||||
|
||||
# Act
|
||||
inputs = cert_to_inputs(epc)
|
||||
|
||||
# Assert — annual flat 0.136 (Table 12 code 30).
|
||||
assert inputs.main_heating_co2_factor_kg_per_kwh == 0.136
|
||||
|
||||
|
||||
def test_standard_meter_keeps_electric_costs_on_standard_rate() -> None:
|
||||
# Arrange — same all-electric dwelling but meter_type=1 (Standard);
|
||||
# space heating + HW should now bill at the standard rate, not E7.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue