slice S-B20: Table 11 secondary heating allocation (conditional)

SAP 10.2 Table 11 allocates a fraction (10-20%) of space heating to a
secondary system based on main heating category. Per Appendix A §A.2.2,
this is applied:
  - Always for electric storage heater main systems (codes 401-407, 409,
    421); a portable electric heater (code 693) is defaulted when no
    secondary is recorded.
  - Otherwise only when the cert lodges a secondary_heating_type.

Calculator gains secondary_heating_fraction, secondary_heating_efficiency,
secondary_heating_fuel_cost_gbp_per_kwh on CalculatorInputs and a
secondary_heating_fuel_kwh_per_yr on SapResult. Monthly loop splits
demand: q_main = q_heat × (1 - frac), q_secondary = q_heat × frac, each
converted to fuel via its own efficiency. Cost = main_kwh × main_price
+ secondary_kwh × secondary_price + ... .

Initial implementation applied 10% unconditionally and regressed 300-
cert MAE 5.45 → 6.58 (bias -2.65). Restricted to the conditional rule
above and aggregate returns to flat:

300-cert: MAE 5.45 → 5.43 (flat)
          bias  +0.22 → -0.52
          within ±5: 62.7% → 64.3%

The slice is spec-correct and architecturally enables the secondary-
heating channel; aggregate MAE moves are small because most certs
don't lodge a secondary and most non-storage mains don't force one.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 17:28:53 +00:00
parent 3a3f9cacdf
commit b73690fe6e
2 changed files with 128 additions and 2 deletions

View file

@ -110,6 +110,14 @@ class CalculatorInputs:
# collapse to a single credit at the export rate (Table 12 code 60).
pv_generation_kwh_per_yr: float = 0.0
pv_export_credit_gbp_per_kwh: float = 0.0
# Secondary heating — SAP 10.2 Table 11 routes a fraction of space
# heating demand to a secondary system (0.10 for gas/oil/solid main
# systems; 0.15-0.20 for electric room/storage heaters). Fraction
# 0.0 disables secondary handling (default for ports that don't yet
# split heating).
secondary_heating_fraction: float = 0.0
secondary_heating_efficiency: float = 1.0
secondary_heating_fuel_cost_gbp_per_kwh: float = 0.0
@dataclass(frozen=True)
@ -125,6 +133,7 @@ class MonthlyEntry:
utilisation_factor: float
space_heat_requirement_kwh: float
main_heating_fuel_kwh: float
secondary_heating_fuel_kwh: float = 0.0
@dataclass(frozen=True)
@ -140,6 +149,7 @@ class SapResult:
co2_kg_per_yr: float
space_heating_kwh_per_yr: float
main_heating_fuel_kwh_per_yr: float
secondary_heating_fuel_kwh_per_yr: float
hot_water_kwh_per_yr: float
pumps_fans_kwh_per_yr: float
lighting_kwh_per_yr: float
@ -222,7 +232,15 @@ def _solve_month(
total_gains_w=g_total,
days_in_month=_DAYS_IN_MONTH[month - 1],
)
fuel = q_heat / inputs.main_heating_efficiency if inputs.main_heating_efficiency > 0 else 0.0
sec_frac = inputs.secondary_heating_fraction
q_main = q_heat * (1.0 - sec_frac)
q_secondary = q_heat * sec_frac
fuel_main = q_main / inputs.main_heating_efficiency if inputs.main_heating_efficiency > 0 else 0.0
fuel_secondary = (
q_secondary / inputs.secondary_heating_efficiency
if inputs.secondary_heating_efficiency > 0
else 0.0
)
return MonthlyEntry(
month=month,
@ -233,7 +251,8 @@ def _solve_month(
heat_loss_rate_w=loss_rate_w,
utilisation_factor=eta,
space_heat_requirement_kwh=q_heat,
main_heating_fuel_kwh=fuel,
main_heating_fuel_kwh=fuel_main,
secondary_heating_fuel_kwh=fuel_secondary,
)
@ -264,8 +283,10 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
space_heating_kwh = sum(e.space_heat_requirement_kwh for e in monthly)
main_fuel_kwh = sum(e.main_heating_fuel_kwh for e in monthly)
secondary_fuel_kwh = sum(e.secondary_heating_fuel_kwh for e in monthly)
delivered_fuel_kwh = (
main_fuel_kwh
+ secondary_fuel_kwh
+ inputs.hot_water_kwh_per_yr
+ inputs.pumps_fans_kwh_per_yr
+ inputs.lighting_kwh_per_yr
@ -274,6 +295,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
total_cost = max(
0.0,
main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh
+ secondary_fuel_kwh * inputs.secondary_heating_fuel_cost_gbp_per_kwh
+ inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh
+ (inputs.pumps_fans_kwh_per_yr + inputs.lighting_kwh_per_yr)
* inputs.other_fuel_cost_gbp_per_kwh
@ -292,6 +314,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
co2_kg_per_yr=co2,
space_heating_kwh_per_yr=space_heating_kwh,
main_heating_fuel_kwh_per_yr=main_fuel_kwh,
secondary_heating_fuel_kwh_per_yr=secondary_fuel_kwh,
hot_water_kwh_per_yr=inputs.hot_water_kwh_per_yr,
pumps_fans_kwh_per_yr=inputs.pumps_fans_kwh_per_yr,
lighting_kwh_per_yr=inputs.lighting_kwh_per_yr,

View file

@ -141,6 +141,40 @@ _INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909})
_PV_ANNUAL_YIELD_KWH_PER_KWP: Final[float] = 850.0
# SAP 10.2 Table 11 — fraction of space heating supplied by a secondary
# system, keyed on the main system's category.
# Cat 1, 2 (gas/oil/solid boiler): 0.10
# Cat 4 (heat pump): 0.00 (HP eff includes any secondary)
# Cat 5 (warm air): 0.10
# Cat 7 (electric storage): 0.15 (not-fan-assisted average)
# Cat 10 (room heaters): 0.20
# Heat networks (cat 3, 6) → 0.10 per Table 11.
_SECONDARY_HEATING_FRACTION_BY_CATEGORY: Final[dict[int, float]] = {
1: 0.10,
2: 0.10,
3: 0.10,
5: 0.10,
6: 0.10,
7: 0.15,
10: 0.20,
}
_SECONDARY_HEATING_FRACTION_DEFAULT: Final[float] = 0.10
# SAP §A.2.2 forcing rule: "A secondary system is always included for
# the SAP calculation when the main system (or main system 1 when there
# are two systems) is electric storage heaters or off-peak electric
# underfloor heating. This applies to main heating codes 401 to 407, 409
# and 421. Portable electric heaters (693) are used in the calculation
# if no secondary system has been identified."
# For gas/oil/solid boiler main systems, the cert calculator only includes
# secondary when one has actually been lodged on the cert.
_DEFAULT_SECONDARY_HEATING_CODE: Final[int] = 693
_FORCE_SECONDARY_FOR_MAIN_CODES: Final[frozenset[int]] = frozenset(
list(range(401, 410)) + [421]
)
# SAP 10.2 Table 12 code 60 — PV export tariff. The calculator uses this
# rate as the per-kWh PV cost credit applied against total annual fuel
# cost in the ECF numerator.
@ -488,6 +522,66 @@ def _hot_water_fuel_cost_gbp_per_kwh(
return _fuel_cost_gbp_per_kwh(main, prices)
def _secondary_fraction(
main: Optional[MainHeatingDetail], secondary_heating_type: object
) -> float:
"""SAP 10.2 Table 11 lookup by main heating category, applied only
when (a) the cert has a secondary system lodged OR (b) the main
heating code is in the §A.2.2 forced-secondary set (electric storage
heaters). Returns 0.0 when neither applies the most common case
for gas/oil main systems whose cert doesn't lodge a secondary."""
if main is None:
return 0.0
code = main.sap_main_heating_code
has_lodged_secondary = secondary_heating_type is not None
force = code is not None and code in _FORCE_SECONDARY_FOR_MAIN_CODES
if not has_lodged_secondary and not force:
return 0.0
cat = main.main_heating_category
if cat is None:
return _SECONDARY_HEATING_FRACTION_DEFAULT
return _SECONDARY_HEATING_FRACTION_BY_CATEGORY.get(
cat, _SECONDARY_HEATING_FRACTION_DEFAULT
)
def _secondary_efficiency(
sap_heating, main_code: Optional[int], main_fuel: Optional[int]
) -> float:
"""Look up secondary efficiency from cert's secondary_heating_type
code, falling back to portable electric heater (code 693, eff 1.0)
per SAP §A.2.2 default."""
code = _int_or_none(sap_heating.secondary_heating_type)
if code is None:
code = _DEFAULT_SECONDARY_HEATING_CODE
return seasonal_efficiency(code, None, None)
def _secondary_fuel_cost_gbp_per_kwh(
sap_heating,
main: Optional[MainHeatingDetail],
meter_type: object,
prices: PriceTable,
) -> float:
"""Secondary fuel cost. When secondary_fuel_type is missing, default
to portable-electric (code 30 standard electricity, or off-peak
under E7-eligible meter). The cert's secondary is an electric room
heater per the §A.2.2 default."""
sec_fuel = sap_heating.secondary_fuel_type
if sec_fuel is None:
# Default to electricity since the default secondary system is
# portable electric heaters (code 693).
if _is_off_peak_meter(meter_type, fuel_is_electric=True):
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
# When secondary_fuel_type is electricity, apply off-peak if applicable.
if _is_electric_water(sec_fuel) and _is_off_peak_meter(
meter_type, fuel_is_electric=True
):
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
return prices.unit_price_p_per_kwh(sec_fuel) * _PENCE_TO_GBP
def _pv_generation_kwh_per_yr(epc: EpcPropertyData) -> float:
"""Annual PV generation (kWh/yr) summed across all photovoltaic
arrays on the cert. SAP 10.2 Appendix M: yield = peak power × annual
@ -699,4 +793,13 @@ def cert_to_inputs(
co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main),
pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc),
pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(prices),
secondary_heating_fraction=_secondary_fraction(
main, epc.sap_heating.secondary_heating_type
),
secondary_heating_efficiency=_secondary_efficiency(
epc.sap_heating, main_code, main_fuel
),
secondary_heating_fuel_cost_gbp_per_kwh=_secondary_fuel_cost_gbp_per_kwh(
epc.sap_heating, main, epc.sap_energy_source.meter_type, prices
),
)