mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
3a3f9cacdf
commit
b73690fe6e
2 changed files with 128 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue