mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
§5 slice 11: wire calculator.py to internal_gains_from_cert + drop legacy
Removes the legacy SAP-10.3-flavoured scalar internal_gains_w API (plus
its InternalGainsBreakdown dataclass, _default_occupancy_sap_j, and the
L5b/L8c fallback constants used only by the legacy path). Calculator
now indexes a CalculatorInputs.internal_gains_monthly_w 12-tuple per
month instead of recomputing inline.
cert_to_inputs:
- _hot_water_fuel_kwh_per_yr now also returns the §4 (65)m
heat_gains_monthly_kwh tuple (was discarded). Plumbed forward into
internal_gains_from_cert via water_heating_gains bridge.
- Calls §5 orchestrator with EpcPropertyData + dwelling_volume_m3 +
(65)m + AVERAGE overshading (Table 6d default per note 1).
- Falls back to (0.0,) * 12 internal gains when TFA missing.
CalculatorInputs gains a new required field `internal_gains_monthly_w`.
Synthetic-input tests (test_calculator, test_bre_worked_examples)
updated to pass a 450 W constant tuple.
All 283 §1-§7 tests pass. E2e SAP-score regression unaffected for
000490 (still within 1 point) and 000474 (still within 7) because the
legacy fixture build_epc()s don't carry §5-specific sap_windows /
bulbs / heating-details, so the orchestrator returns the L5b lighting
fallback + zero (65)m — matches the legacy scalar's behaviour.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
99e5c2cd44
commit
bf6a7e04b3
5 changed files with 56 additions and 86 deletions
|
|
@ -41,7 +41,6 @@ if TYPE_CHECKING:
|
|||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from domain.sap.worksheet.dimensions import Dimensions
|
||||
from domain.sap.worksheet.heat_transmission import HeatTransmission
|
||||
from domain.sap.worksheet.internal_gains import internal_gains_w
|
||||
from domain.sap.worksheet.mean_internal_temperature import mean_internal_temperature_c
|
||||
from domain.sap.worksheet.rating import (
|
||||
ECF_LOG_THRESHOLD,
|
||||
|
|
@ -101,6 +100,11 @@ class CalculatorInputs:
|
|||
# and MV mode (§2 lines 24a-d). Constant-monthly inputs work too:
|
||||
# pass `(ach,) * 12` to model a single rate across all months.
|
||||
monthly_infiltration_ach: tuple[float, ...]
|
||||
# SAP10.2 (73)m — total internal gains W per month (Jan..Dec).
|
||||
# Per-month because lighting/appliances cosine-modulate and pumps/fans
|
||||
# zero out in summer per Table 5a. Produced by §5 orchestrator
|
||||
# `internal_gains_from_cert` (called from cert_to_inputs).
|
||||
internal_gains_monthly_w: tuple[float, ...]
|
||||
region: int
|
||||
windows: tuple[WindowInput, ...]
|
||||
control_type: int
|
||||
|
|
@ -213,10 +217,7 @@ def _solve_month(
|
|||
heat_loss_parameter: float,
|
||||
) -> MonthlyEntry:
|
||||
t_ext = external_temperature_c(inputs.region, month)
|
||||
g_int = internal_gains_w(
|
||||
total_floor_area_m2=inputs.dimensions.total_floor_area_m2,
|
||||
month=month,
|
||||
).total_w
|
||||
g_int = inputs.internal_gains_monthly_w[month - 1]
|
||||
g_sol = _solar_gains_w(windows=inputs.windows, region=inputs.region, month=month)
|
||||
g_total = g_int + g_sol
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@ from domain.sap.tables.table_12 import (
|
|||
unit_price_p_per_kwh,
|
||||
)
|
||||
from domain.sap.worksheet.dimensions import dimensions_from_cert
|
||||
from domain.sap.worksheet.internal_gains import (
|
||||
OvershadingCategory,
|
||||
internal_gains_from_cert,
|
||||
)
|
||||
from domain.sap.worksheet.heat_transmission import (
|
||||
DwellingExposure,
|
||||
heat_transmission_from_cert,
|
||||
|
|
@ -137,6 +141,12 @@ _FRAME_FACTOR_DEFAULT: Final[float] = 0.70
|
|||
_PENCE_TO_GBP: Final[float] = 0.01
|
||||
_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0
|
||||
_DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0
|
||||
# SAP10.2 Table 6d note 1: "average or unknown" overshading is the
|
||||
# default for existing dwellings. RdSAP doesn't lodge a per-dwelling
|
||||
# overshading code so §5 always uses AVERAGE → Z_L = 0.83.
|
||||
_INTERNAL_GAINS_DEFAULT_OVERSHADING: Final[OvershadingCategory] = (
|
||||
OvershadingCategory.AVERAGE
|
||||
)
|
||||
|
||||
|
||||
# Water-heating codes for instantaneous (no-cylinder) systems — SAP §4
|
||||
|
|
@ -785,7 +795,7 @@ def _hot_water_fuel_kwh_per_yr(
|
|||
water_efficiency_pct: float,
|
||||
is_instantaneous: bool,
|
||||
primary_age: Optional[str],
|
||||
) -> float:
|
||||
) -> tuple[float, tuple[float, ...]]:
|
||||
"""Annual hot water FUEL kWh (the slot calculator.CalculatorInputs
|
||||
expects). Wires the SAP10.2 §4 worksheet orchestrator into the cert→
|
||||
inputs adapter.
|
||||
|
|
@ -799,9 +809,15 @@ def _hot_water_fuel_kwh_per_yr(
|
|||
to convert delivered heat to fuel kWh, mirroring the worksheet's
|
||||
(219) line. Falls back to legacy `predicted_hot_water_kwh` if the
|
||||
TFA is missing (the orchestrator requires it for occupancy).
|
||||
|
||||
Returns a 2-tuple `(fuel_kwh_per_yr, heat_gains_monthly_kwh)`. The
|
||||
heat-gains tuple is the §4 (65)m output, plumbed onward into the
|
||||
§5 internal-gains orchestrator's `water_heating_gains_monthly_w`
|
||||
bridge. Falls back to a 12-zero tuple when the legacy HW path is used.
|
||||
"""
|
||||
zero_monthly = (0.0,) * 12
|
||||
if epc.total_floor_area_m2 is None:
|
||||
return predicted_hot_water_kwh(
|
||||
legacy_kwh = predicted_hot_water_kwh(
|
||||
total_floor_area_m2=epc.total_floor_area_m2,
|
||||
seasonal_efficiency_water=water_efficiency_pct,
|
||||
cylinder_size=None if is_instantaneous else _int_or_none(epc.sap_heating.cylinder_size),
|
||||
|
|
@ -816,6 +832,7 @@ def _hot_water_fuel_kwh_per_yr(
|
|||
has_wwhrs=False,
|
||||
has_solar_water_heating=epc.solar_water_heating,
|
||||
)
|
||||
return legacy_kwh, zero_monthly
|
||||
result = water_heating_from_cert(
|
||||
epc=epc,
|
||||
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
|
||||
|
|
@ -827,11 +844,11 @@ def _hot_water_fuel_kwh_per_yr(
|
|||
low_water_use=False,
|
||||
)
|
||||
if water_efficiency_pct <= 0:
|
||||
return 0.0
|
||||
return 0.0, result.heat_gains_monthly_kwh
|
||||
# `water_efficiency_pct` is misnamed in the calling code — the value
|
||||
# is a decimal (0.0–1.0), not a percent. Divide the orchestrator's
|
||||
# delivered-heat output by the decimal efficiency to land fuel kWh.
|
||||
return result.output_kwh_per_yr / water_efficiency_pct
|
||||
return result.output_kwh_per_yr / water_efficiency_pct, result.heat_gains_monthly_kwh
|
||||
|
||||
|
||||
def cert_to_inputs(
|
||||
|
|
@ -941,7 +958,7 @@ def cert_to_inputs(
|
|||
# = q_generated, matching the per-kWh-generated unit price.
|
||||
water_eff = 1.0 / _heat_network_dlf(primary_age)
|
||||
is_instantaneous = epc.sap_heating.water_heating_code in _INSTANTANEOUS_WATER_CODES
|
||||
hw_kwh = _hot_water_fuel_kwh_per_yr(
|
||||
hw_kwh, hw_heat_gains_monthly_kwh = _hot_water_fuel_kwh_per_yr(
|
||||
epc=epc,
|
||||
water_efficiency_pct=water_eff,
|
||||
is_instantaneous=is_instantaneous,
|
||||
|
|
@ -953,6 +970,24 @@ def cert_to_inputs(
|
|||
led_count=epc.led_fixed_lighting_bulbs_count,
|
||||
incandescent_count=epc.incandescent_fixed_lighting_bulbs_count,
|
||||
)
|
||||
# SAP10.2 §5: chain (66)..(73) internal-gain components via the §5
|
||||
# orchestrator. The orchestrator needs the §4 (65)m heat-gains tuple,
|
||||
# which we just plumbed out of `water_heating_from_cert` above.
|
||||
# Falls back to a 12-zero tuple when TFA is missing — matches the
|
||||
# legacy `internal_gains_w` zero-floor behaviour. Overshading default
|
||||
# is AVERAGE per Table 6d note 1 (existing dwellings).
|
||||
if epc.total_floor_area_m2 is None:
|
||||
internal_gains_monthly_w = (0.0,) * 12
|
||||
else:
|
||||
internal_gains_result = internal_gains_from_cert(
|
||||
epc=epc,
|
||||
dwelling_volume_m3=dim.volume_m3,
|
||||
heat_gains_from_water_heating_monthly_kwh=hw_heat_gains_monthly_kwh,
|
||||
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
|
||||
)
|
||||
internal_gains_monthly_w = (
|
||||
internal_gains_result.total_internal_gains_monthly_w
|
||||
)
|
||||
|
||||
return CalculatorInputs(
|
||||
dimensions=dim,
|
||||
|
|
@ -960,6 +995,9 @@ def cert_to_inputs(
|
|||
# SAP10.2 line (25)m — 12-month effective air-change rate from the
|
||||
# full §2 worksheet (openings, shelter, wind adjustment, MV mode).
|
||||
monthly_infiltration_ach=ventilation.effective_monthly_ach,
|
||||
# SAP10.2 line (73)m — total internal gains W/month from §5
|
||||
# orchestrator (composed above).
|
||||
internal_gains_monthly_w=internal_gains_monthly_w,
|
||||
region=_region_index(epc.region_code),
|
||||
windows=_window_inputs(epc.sap_windows),
|
||||
control_type=_control_type(main),
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ def _baseline_dwelling() -> CalculatorInputs:
|
|||
dimensions=dim,
|
||||
heat_transmission=ht,
|
||||
monthly_infiltration_ach=(0.7,) * 12,
|
||||
internal_gains_monthly_w=(450.0,) * 12,
|
||||
region=0,
|
||||
windows=windows,
|
||||
control_type=2,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,10 @@ def _baseline_inputs() -> CalculatorInputs:
|
|||
dimensions=dim,
|
||||
heat_transmission=ht,
|
||||
monthly_infiltration_ach=(0.7,) * 12,
|
||||
# Synthetic baseline internal gains: 450 W constant. Real
|
||||
# per-month variation lives in §5 orchestrator output; tracer
|
||||
# tests don't need the modulation to verify the SAP loop.
|
||||
internal_gains_monthly_w=(450.0,) * 12,
|
||||
region=0,
|
||||
windows=windows,
|
||||
control_type=2,
|
||||
|
|
|
|||
|
|
@ -15,13 +15,10 @@ Column A (typical gains) is used for the SAP rating + cooling calc per
|
|||
Table 5 footnote 3; Column B (reduced gains) is only for new-build
|
||||
DPER/TPER/DER/TER. We rate existing dwellings → always Column A.
|
||||
|
||||
The `internal_gains_w` scalar API below is the legacy SAP-10.3 stub; it
|
||||
remains so `calculator.py` keeps building until §5 wiring (slice 11)
|
||||
lands. Tests + worksheet conformance target the new 12-tuple functions.
|
||||
|
||||
Reference: SAP 10.2 specification (14-03-2025), §5 (page 25), Table 5 +
|
||||
Table 5a (page 177), Appendix L (lighting/appliances/cooking),
|
||||
Appendix J Table 1b (occupancy from TFA).
|
||||
Appendix J Table 1b (occupancy from TFA), Table 6d (Z_L light access
|
||||
factor for L2a daylighting calc).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -428,29 +425,6 @@ def water_heating_gains_monthly_w(
|
|||
)
|
||||
|
||||
|
||||
_METABOLIC_W_PER_OCCUPANT: Final[float] = 60.0
|
||||
|
||||
|
||||
_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||
# Appendix L existing-dwelling lighting fallback constants.
|
||||
_FIXED_LIGHTING_EFFICACY_LM_PER_W: Final[float] = 21.3
|
||||
_FIXED_LIGHTING_LUMENS_PER_M2: Final[float] = 185.0
|
||||
_REFERENCE_LIGHTING_LUMENS_PER_M2: Final[float] = 330.0
|
||||
_DAYLIGHT_FACTOR_NO_BONUS: Final[float] = 1.433
|
||||
_LIGHTING_INTERNAL_FRACTION: Final[float] = 0.85
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InternalGainsBreakdown:
|
||||
"""SAP 10.3 §5 internal-gain components in watts for a given month."""
|
||||
|
||||
metabolic_w: float
|
||||
cooking_w: float
|
||||
appliances_w: float
|
||||
lighting_w: float
|
||||
total_w: float
|
||||
|
||||
|
||||
def _assumed_occupancy(total_floor_area_m2: float) -> float:
|
||||
"""Appendix J Table 1b occupancy default from TFA.
|
||||
|
||||
|
|
@ -637,51 +611,3 @@ def internal_gains_from_cert(
|
|||
)
|
||||
|
||||
|
||||
def _default_occupancy_sap_j(total_floor_area_m2: float) -> float:
|
||||
"""SAP 10.3 Appendix J Table 1b occupancy default from TFA."""
|
||||
if total_floor_area_m2 <= 13.9:
|
||||
return 1.0
|
||||
tfa_offset = total_floor_area_m2 - 13.9
|
||||
return 1.0 + 1.76 * (1 - exp(-0.000349 * tfa_offset * tfa_offset)) + 0.0013 * tfa_offset
|
||||
|
||||
|
||||
def internal_gains_w(
|
||||
*,
|
||||
total_floor_area_m2: float,
|
||||
month: int,
|
||||
occupancy: Optional[float] = None,
|
||||
) -> InternalGainsBreakdown:
|
||||
"""SAP 10.3 §5 internal gains in watts for a given month."""
|
||||
n = occupancy if occupancy is not None else _default_occupancy_sap_j(total_floor_area_m2)
|
||||
if not 1 <= month <= 12:
|
||||
raise ValueError(f"month must be 1..12, got {month}")
|
||||
n_m = _DAYS_IN_MONTH[month - 1]
|
||||
metabolic = _METABOLIC_W_PER_OCCUPANT * n
|
||||
cooking = 35.0 + 7.0 * n
|
||||
# Appendix L (L13) + (L14) + (L16a): appliances energy by month,
|
||||
# converted to a watt heat-gain (100% of appliance energy stays internal).
|
||||
e_a_annual = 207.8 * (total_floor_area_m2 * n) ** 0.4714
|
||||
appliances_month_factor = 1.0 + 0.157 * cos(2.0 * pi * (month - 1.78) / 12.0)
|
||||
e_a_m_kwh = e_a_annual * appliances_month_factor * n_m / 365.0
|
||||
appliances = e_a_m_kwh * 1000.0 / (24.0 * n_m)
|
||||
# Appendix L lighting — existing-dwelling fallback path (L5b, L8c, L9c-d, L10, L12).
|
||||
lambda_b = 11.2 * 59.73 * (total_floor_area_m2 * n) ** 0.4714
|
||||
c_daylight = _DAYLIGHT_FACTOR_NO_BONUS
|
||||
lambda_req = (2.0 / 3.0) * lambda_b * c_daylight
|
||||
c_l_fixed = _FIXED_LIGHTING_LUMENS_PER_M2 * total_floor_area_m2
|
||||
c_l_ref = _REFERENCE_LIGHTING_LUMENS_PER_M2 * total_floor_area_m2
|
||||
lambda_prov = lambda_req * c_l_fixed / c_l_ref if c_l_ref > 0 else 0.0
|
||||
e_l_fixed = (lambda_prov if lambda_req >= lambda_prov else lambda_req) / _FIXED_LIGHTING_EFFICACY_LM_PER_W
|
||||
e_l_topup = max(0.0, lambda_req / 3.0 - lambda_prov) / _FIXED_LIGHTING_EFFICACY_LM_PER_W
|
||||
e_l_portable = (1.0 / 3.0) * lambda_b * c_daylight / _FIXED_LIGHTING_EFFICACY_LM_PER_W
|
||||
e_l_annual = e_l_fixed + e_l_topup + e_l_portable
|
||||
lighting_month_factor = 1.0 + 0.5 * cos(2.0 * pi * (month - 0.2) / 12.0)
|
||||
e_l_m_kwh = e_l_annual * lighting_month_factor * n_m / 365.0
|
||||
lighting = e_l_m_kwh * _LIGHTING_INTERNAL_FRACTION * 1000.0 / (24.0 * n_m)
|
||||
return InternalGainsBreakdown(
|
||||
metabolic_w=metabolic,
|
||||
cooking_w=cooking,
|
||||
appliances_w=appliances,
|
||||
lighting_w=lighting,
|
||||
total_w=metabolic + cooking + appliances + lighting,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue