mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Per SAP 10.2 §4 line (64)m: `(64)m = max(0, (62)m + (63a)m + (63b)m
+ (63c)m + (63d)m)` where (63c)m is the solar HW credit lodged as a
negative quantity. The cascade hardcoded (63c)m = 0 since S0380.66
when the Appendix H orchestrator landed without integration, pending
the 1.81× over-count resolution (closed in S0380.74).
This slice plumbs the orchestrator into `water_heating_from_cert`
via a new `solar_water_heating_monthly_kwh_override` parameter, and
adds `_solar_hw_monthly_override` in cert_to_inputs.py that drives
the orchestrator from RdSAP 10 §10.11 Table 29 defaults +
cert-lodged collector geometry on Elmhurst Summary §16.0.
RdSAP 10 §10.11 Table 29 row "Solar panel" (p.58, verbatim):
"If solar panel present, the parameters for the calculation not
provided in the RdSAP data set are:
- panel aperture area 3 m²
- flat panel, η₀ = 0.80, a₁ = 4.0, a₂ = 0.01
- facing South, pitch 30°, modest overshading
- …
- pump for solar-heated water is electric (75 kWh/year)
- showers are both electric and non-electric"
Lodged collector orientation / pitch / overshading on the Summary
§16.0 ("Are details known? Yes" branch) override South / 30° /
Modest. Aperture, η₀, a₁, a₂, IAM stay at Table 29 defaults — the
deeper thermal parameter lodgement (P960 worksheet) isn't yet in
the Summary extractor surface.
For (H17)m to include storage + primary + combi losses, the cascade
runs a `demand_pass` call without solar (gets (62)m) before sizing
the solar credit. The final call then uses all overrides.
Files:
- datatypes/epc/surveys/elmhurst_site_notes.py: Renewables gains
`solar_hw_collector_orientation` / `_pitch_deg` / `_overshading`
optional fields.
- datatypes/epc/domain/epc_property_data.py: same three fields
added at the end of the dataclass.
- datatypes/epc/domain/mapper.py: from_elmhurst_site_notes
propagates the three new fields.
- backend/documents_parser/elmhurst_extractor.py: §16.0 section
parsing reads "Collector orientation" / "Collector elevation" /
"Overshading" rows; `_parse_solar_pitch_deg` strips the degree
glyph.
- domain/sap10_calculator/worksheet/water_heating.py: new
`solar_water_heating_monthly_kwh_override` param on
`water_heating_from_cert`; threaded into `output_from_water_
heater_monthly_kwh(solar_monthly_kwh=...)`.
- domain/sap10_calculator/rdsap/cert_to_inputs.py: Table 29
constants + `_solar_hw_monthly_override` helper +
`_orientation_from_summary_string` mapper. Added the demand_pass
intermediate call so (H17)m sees the full (62)m. Negates the
orchestrator output at the boundary (spec convention: heat
displaced from boiler is negative on line (63c)m).
Cert 000565 cascade pin shifts:
- hot_water_kwh_per_yr: +271.84 → −68.96 (4× closer)
- sap_score_continuous: +0.6334 → +0.7732 (drift downstream of HW)
- ecf: −0.0643 → −0.0784 (drift)
- total_fuel_cost: −56.08 → −68.36 (drift)
- co2: −19.77 → −22.66 (drift)
- sap_score (int): 29 EXACT (unchanged)
- space_heating / main_heating_fuel / lighting / pumps_fans:
unchanged
The remaining −69 kWh HW residual is the gap between Table 29
defaults (H12 = 75 L separate tank) and cert 000565's lodged H12 =
53 L + combined cylinder 160 L. Closing this requires extracting
solar storage volume + combined-cylinder routing from the cert (P960
worksheet block lodges these explicitly; Summary doesn't). That's
the follow-on slice.
Test baseline: 547 pass + 9 expected `test_sap_result_pin[000565-*]`
fails preserved. Cohort-2 + ASHP cohort + all golden fixtures
untouched (no certs other than 000565 lodge `solar_water_heating =
True`).
Pyright net-zero on touched files (68 errors at baseline = 68 errors
post-change).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
989 lines
39 KiB
Python
989 lines
39 KiB
Python
"""SAP 10.2 §4 — water heating energy requirements.
|
||
|
||
Worksheet line refs (xlsx rows 207-304, sheet `NonRegionalWeather`):
|
||
(42) assumed occupancy N from Appendix J Table 1b
|
||
(42a)m monthly hot water usage for mixer showers
|
||
(42b)m monthly hot water usage for baths
|
||
(42c)m monthly hot water usage for other uses
|
||
(43) annual average hot water usage (litres/day)
|
||
(44)m daily hot water usage by month
|
||
(45)m energy content of monthly hot water demand
|
||
(46)m distribution loss = 0.15 × (45)m
|
||
(47)–(56) storage volume + water storage / HIU loss
|
||
(57) dedicated solar storage adjustment
|
||
(58)–(61) primary loss + combi loss
|
||
(62)m total monthly water heat requirement
|
||
(63a)–(63d) WWHRS / PV-diverter / Solar / FGHRS reductions
|
||
(64)m output from water heater
|
||
(65)m heat gains from water heating
|
||
|
||
Reference: SAP 10.2 specification §4 (pages 22-31) + Appendix J (pages
|
||
84-90); canonical xlsx worked example at the repo root.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from math import exp
|
||
from typing import Final, Literal, Optional
|
||
|
||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||
|
||
|
||
_OCCUPANCY_TFA_FLOOR_M2: Final[float] = 13.9
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class WaterHeatingResult:
|
||
"""SAP 10.2 §4 worksheet outputs broken down per line ref so callers
|
||
can audit the cascade against the canonical xlsx or an Elmhurst
|
||
worksheet. Annual totals are days-weighted where appropriate.
|
||
|
||
Field-to-line-ref mapping:
|
||
(42) occupancy
|
||
(43) annual_avg_hot_water_l_per_day
|
||
(44)m daily_hot_water_l_per_day_monthly
|
||
(45)m energy_content_monthly_kwh
|
||
(46)m distribution_loss_monthly_kwh
|
||
(61)m combi_loss_monthly_kwh
|
||
(62)m total_demand_monthly_kwh
|
||
(64)m output_monthly_kwh
|
||
(65)m heat_gains_monthly_kwh
|
||
Annual sum of (64)m is exposed as `output_kwh_per_yr` for the
|
||
calculator's `hot_water_kwh_per_yr` slot.
|
||
"""
|
||
|
||
occupancy: float
|
||
annual_avg_hot_water_l_per_day: float
|
||
daily_hot_water_l_per_day_monthly: tuple[float, ...]
|
||
energy_content_monthly_kwh: tuple[float, ...]
|
||
distribution_loss_monthly_kwh: tuple[float, ...]
|
||
solar_storage_monthly_kwh: tuple[float, ...] # (57)m — Tables 2/2a/2b
|
||
primary_loss_monthly_kwh: tuple[float, ...] # (59)m — Table 3
|
||
combi_loss_monthly_kwh: tuple[float, ...]
|
||
total_demand_monthly_kwh: tuple[float, ...]
|
||
output_monthly_kwh: tuple[float, ...]
|
||
heat_gains_monthly_kwh: tuple[float, ...]
|
||
electric_shower_monthly_kwh: tuple[float, ...] # (64a)m — App J step 8
|
||
output_kwh_per_yr: float
|
||
electric_shower_kwh_per_yr: float # Σ (64a)m — feeds §10a (247a)
|
||
|
||
# Table J2 — monthly factors for hot water use (also used by Appendix J
|
||
# equation J11 for "other uses"). Symmetric about the year midpoint.
|
||
_TABLE_J2_MONTHLY_FACTORS: Final[tuple[float, ...]] = (
|
||
1.10, 1.06, 1.02, 0.98, 0.94, 0.90, 0.90, 0.94, 0.98, 1.02, 1.06, 1.10,
|
||
)
|
||
|
||
# Appendix J J11 footnote: -5% reduction in V_d,other,ave for dwellings
|
||
# designed to ≤125 L/person/day total water use.
|
||
_LOW_WATER_USE_REDUCTION: Final[float] = 0.05
|
||
|
||
# Table J1 — cold-water inlet temperatures (°C). Two columns per the spec:
|
||
# from a header tank (cooler) vs from the mains. The cert lodges which
|
||
# applies; both populations need spec-exact monthly arrays.
|
||
TABLE_J1_TCOLD_FROM_HEADER_TANK_C: Final[tuple[float, ...]] = (
|
||
11.1, 11.3, 12.3, 14.5, 16.2, 18.8, 21.3, 19.3, 18.7, 16.2, 13.2, 11.2,
|
||
)
|
||
TABLE_J1_TCOLD_FROM_MAINS_C: Final[tuple[float, ...]] = (
|
||
8.0, 8.2, 9.3, 12.7, 14.6, 16.7, 18.4, 17.6, 16.6, 14.3, 11.1, 8.5,
|
||
)
|
||
|
||
# Table J5 — behavioural variation factor for showers AND baths. Used by
|
||
# (42a)m showers (Appendix J step 1d) and (42b)m baths (step 2b) alike.
|
||
TABLE_J5_BEHAVIOURAL_FACTOR: Final[tuple[float, ...]] = (
|
||
1.035, 1.021, 1.007, 0.993, 0.979, 0.965,
|
||
0.965, 0.979, 0.993, 1.007, 1.021, 1.035,
|
||
)
|
||
|
||
# Appendix J equation J7 constants.
|
||
_BATH_VOLUME_L: Final[float] = 73.0
|
||
# Appendix J equation J8 / J3 mixing temperatures.
|
||
_HOT_DELIVERY_TEMPERATURE_C: Final[float] = 52.0
|
||
_WARM_BATH_TEMPERATURE_C: Final[float] = 42.0
|
||
_WARM_SHOWER_TEMPERATURE_C: Final[float] = 41.0
|
||
# Appendix J step 1d: assumed shower duration in minutes per event.
|
||
_SHOWER_DURATION_MIN: Final[float] = 6.0
|
||
|
||
# Days per month (non-leap year) — used by Appendix J J4 / J9 / J12 to
|
||
# turn monthly daily-rate arrays into days-weighted annual averages.
|
||
_DAYS_IN_MONTH: Final[tuple[int, ...]] = (
|
||
31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
|
||
)
|
||
_DAYS_IN_YEAR: Final[int] = sum(_DAYS_IN_MONTH)
|
||
|
||
|
||
def assumed_occupancy(total_floor_area_m2: float) -> float:
|
||
"""SAP 10.2 §4 line (42) / Appendix J Table 1b.
|
||
|
||
Piecewise occupancy by total floor area:
|
||
TFA ≤ 13.9 m² : N = 1
|
||
TFA > 13.9 m² : N = 1 + 1.76 × (1 − exp(−0.000349 × (TFA − 13.9)²))
|
||
+ 0.0013 × (TFA − 13.9)
|
||
"""
|
||
if total_floor_area_m2 <= _OCCUPANCY_TFA_FLOOR_M2:
|
||
return 1.0
|
||
x = total_floor_area_m2 - _OCCUPANCY_TFA_FLOOR_M2
|
||
return 1.0 + 1.76 * (1.0 - exp(-0.000349 * x * x)) + 0.0013 * x
|
||
|
||
|
||
def hot_water_mixer_showers_monthly_l_per_day(
|
||
*,
|
||
n_occupants: float,
|
||
has_bath: bool,
|
||
mixer_shower_flow_rates_l_per_min: tuple[float, ...],
|
||
cold_water_temps_c: tuple[float, ...],
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (42a)m via Appendix J equations J1, J2, J3.
|
||
|
||
Mixer showers draw from the main hot water system (cylinder or combi)
|
||
and mix with cold to a 41 °C delivery. The per-day shower count is
|
||
|
||
N_shower = 0.45 × N + 0.65 (bath present)
|
||
= 0.58 × N + 0.83 (no bath)
|
||
= 0 (no shower outlets)
|
||
|
||
Each outlet's warm-water draw for a month is the flow rate × 6 min ×
|
||
Table J5 fbeh. The hot fraction is (41 − Tcold[m])/(52 − Tcold[m]).
|
||
Per-outlet daily warm water is scaled by N_shower / N_outlets, then
|
||
summed across outlets to give (42a)m.
|
||
|
||
Instantaneous electric showers belong to worksheet (64a)m, not (42a)m
|
||
— those should be excluded from `mixer_shower_flow_rates_l_per_min`.
|
||
"""
|
||
n_outlets = len(mixer_shower_flow_rates_l_per_min)
|
||
if n_outlets == 0:
|
||
return tuple(0.0 for _ in range(12))
|
||
if has_bath:
|
||
n_shower = 0.45 * n_occupants + 0.65
|
||
else:
|
||
n_shower = 0.58 * n_occupants + 0.83
|
||
monthly: list[float] = []
|
||
for fbeh, tcold in zip(TABLE_J5_BEHAVIOURAL_FACTOR, cold_water_temps_c):
|
||
f_hot = (_WARM_SHOWER_TEMPERATURE_C - tcold) / (
|
||
_HOT_DELIVERY_TEMPERATURE_C - tcold
|
||
)
|
||
v_hot_total = 0.0
|
||
for flow in mixer_shower_flow_rates_l_per_min:
|
||
v_warm_per_outlet = flow * _SHOWER_DURATION_MIN * fbeh
|
||
v_d_warm = v_warm_per_outlet * n_shower / n_outlets
|
||
v_hot_total += v_d_warm * f_hot
|
||
monthly.append(v_hot_total)
|
||
return tuple(monthly)
|
||
|
||
|
||
def hot_water_baths_monthly_l_per_day(
|
||
*,
|
||
n_occupants: float,
|
||
has_bath: bool,
|
||
has_shower: bool,
|
||
cold_water_temps_c: tuple[float, ...],
|
||
low_water_use: bool,
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (42b)m via Appendix J equations J6, J7, J8.
|
||
|
||
Per-day hot water for bath fills across the 12 months. Per J6:
|
||
|
||
N_bath = 0 if no bath (but a shower is present)
|
||
= 0.13 × N + 0.19 if shower is also present
|
||
= 0.35 × N + 0.50 if no shower present, or no bath
|
||
and no shower
|
||
|
||
Each bath fills 73 L of warm water at 42 °C; the hot fraction depends
|
||
on monthly cold-water temperature (Table J1, either header tank or
|
||
mains depending on the cert). `low_water_use` knocks 5% off the
|
||
warm-water term per the J7 footnote.
|
||
|
||
`cold_water_temps_c` must be a 12-tuple of monthly Tcold values —
|
||
pass `TABLE_J1_TCOLD_FROM_MAINS_C` for the common case.
|
||
"""
|
||
if not has_bath and has_shower:
|
||
return tuple(0.0 for _ in range(12))
|
||
if has_bath and has_shower:
|
||
n_bath = 0.13 * n_occupants + 0.19
|
||
else:
|
||
n_bath = 0.35 * n_occupants + 0.50
|
||
lwu_factor = 1.0 - _LOW_WATER_USE_REDUCTION if low_water_use else 1.0
|
||
monthly: list[float] = []
|
||
for fbeh, tcold in zip(TABLE_J5_BEHAVIOURAL_FACTOR, cold_water_temps_c):
|
||
v_warm = n_bath * _BATH_VOLUME_L * fbeh * lwu_factor
|
||
f_hot = (_WARM_BATH_TEMPERATURE_C - tcold) / (
|
||
_HOT_DELIVERY_TEMPERATURE_C - tcold
|
||
)
|
||
monthly.append(v_warm * f_hot)
|
||
return tuple(monthly)
|
||
|
||
|
||
def total_hot_water_monthly_l_per_day(
|
||
*,
|
||
showers: tuple[float, ...],
|
||
baths: tuple[float, ...],
|
||
other_uses: tuple[float, ...],
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (44)m via Appendix J equation J13:
|
||
V_d,m = V_d,shower[m] + V_d,bath[m] + V_d,other[m]
|
||
|
||
A pure element-wise sum of the three monthly demand streams. All
|
||
three inputs must be 12-tuples — caller is responsible for ensuring
|
||
they were computed against the same Tcold table.
|
||
"""
|
||
return tuple(s + b + o for s, b, o in zip(showers, baths, other_uses))
|
||
|
||
|
||
def energy_content_of_hot_water_monthly_kwh(
|
||
*,
|
||
monthly_hot_water_l_per_day: tuple[float, ...],
|
||
cold_water_temps_c: tuple[float, ...],
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (45)m via Appendix J equation J14:
|
||
(45)m = 4.18 × V_d,m × n_m × (52 − Tcold[m]) / 3600 [kWh/month]
|
||
|
||
Sensible heat to raise the monthly hot water volume from Tcold[m] to
|
||
the 52 °C delivery temperature. 4.18 J/(g·K) is the specific heat of
|
||
water; dividing by 3600 converts J/g to Wh/g (= kWh/kg, since 1 L of
|
||
water ≈ 1 kg).
|
||
"""
|
||
return tuple(
|
||
4.18 * vd * n * (_HOT_DELIVERY_TEMPERATURE_C - tcold) / 3600.0
|
||
for vd, n, tcold in zip(
|
||
monthly_hot_water_l_per_day, _DAYS_IN_MONTH, cold_water_temps_c
|
||
)
|
||
)
|
||
|
||
|
||
def distribution_loss_monthly_kwh(
|
||
*,
|
||
monthly_energy_content_kwh: tuple[float, ...],
|
||
is_instantaneous_at_point_of_use: bool,
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (46)m.
|
||
|
||
Distribution loss is a flat 15% of (45)m unless the water heating is
|
||
instantaneous at the point of use (Table 4a hot water codes 907 and
|
||
909) — those have no distribution pipework so the loss is zero. Heat
|
||
networks still incur the 15% loss whether or not a hot water cylinder
|
||
is present (spec §4 step 7).
|
||
"""
|
||
if is_instantaneous_at_point_of_use:
|
||
return tuple(0.0 for _ in range(12))
|
||
return tuple(0.15 * e for e in monthly_energy_content_kwh)
|
||
|
||
|
||
def water_efficiency_monthly_via_equation_d1(
|
||
*,
|
||
winter_efficiency_pct: float,
|
||
summer_efficiency_pct: float,
|
||
space_heating_monthly_useful_kwh: tuple[float, ...],
|
||
water_heating_output_monthly_kwh: tuple[float, ...],
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 Appendix D §D2.1 (2) Equation D1 — monthly water-heating
|
||
efficiency cascade for combi boilers and CPSUs that provide both
|
||
space and water heating:
|
||
|
||
η_water,monthly = (Q_space + Q_water) / (Q_space/η_winter + Q_water/η_summer)
|
||
|
||
where Q_space (kWh/month) = (98c)m × (204) and Q_water (kWh/month)
|
||
= (64)m. η_winter is the raw PCDB winter seasonal efficiency
|
||
(Appendix D §D2.1 (2) note: "η_winter does not include any
|
||
efficiency adjustment due to design flow temperature or controls").
|
||
|
||
Two early-out rules per spec:
|
||
- If summer_efficiency ≥ winter_efficiency (or the boiler is water-
|
||
heating-only): η_water,monthly = η_summer for every month.
|
||
- If both Q_space[m] and Q_water[m] = 0 in any month: η_water,
|
||
monthly[m] = η_summer.
|
||
"""
|
||
if summer_efficiency_pct >= winter_efficiency_pct:
|
||
return (summer_efficiency_pct / 100.0,) * 12
|
||
eff_winter = winter_efficiency_pct / 100.0
|
||
eff_summer = summer_efficiency_pct / 100.0
|
||
monthly: list[float] = []
|
||
for q_space, q_water in zip(
|
||
space_heating_monthly_useful_kwh, water_heating_output_monthly_kwh
|
||
):
|
||
if q_space == 0.0 and q_water == 0.0:
|
||
monthly.append(eff_summer)
|
||
continue
|
||
numerator = q_space + q_water
|
||
denominator = q_space / eff_winter + q_water / eff_summer
|
||
monthly.append(numerator / denominator)
|
||
return tuple(monthly)
|
||
|
||
|
||
def combi_loss_monthly_kwh_table_3b_row_1_instantaneous(
|
||
*,
|
||
rejected_energy_proportion_r1: float,
|
||
loss_factor_f1_kwh_per_day: float,
|
||
energy_content_monthly_kwh: tuple[float, ...],
|
||
daily_hot_water_monthly_l_per_day: tuple[float, ...],
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 Appendix J Table 3b row 1 (Instantaneous combi with non-
|
||
storage FGHRS or without FGHRS, profile M only):
|
||
|
||
(61)m = (45)m × r1 × fu + [F1 × n_m]
|
||
|
||
where r1 = rejected energy proportion (PCDB Table 105 field 51),
|
||
F1 = loss factor in kWh/day (PCDB field 52), and fu = V_d,m / 100
|
||
when daily hot-water usage V_d,m < 100 L/day, else fu = 1.0.
|
||
|
||
Applies only to combi boilers EN 13203-2 / OPS 26 tested with one
|
||
profile (Separate DHW tests = 1). Other Table 3b rows (storage
|
||
combis, storage-FGHRS variants) and Table 3c (two-profile tests)
|
||
are deferred until a fixture exercises them. Untested combis fall
|
||
back to the existing Table 3a path.
|
||
"""
|
||
return tuple(
|
||
e * rejected_energy_proportion_r1 * (v / 100.0 if v < 100.0 else 1.0)
|
||
+ loss_factor_f1_kwh_per_day * n
|
||
for e, v, n in zip(
|
||
energy_content_monthly_kwh,
|
||
daily_hot_water_monthly_l_per_day,
|
||
_DAYS_IN_MONTH,
|
||
)
|
||
)
|
||
|
||
|
||
_DVF_M_AND_L_LOWER_V_L_PER_DAY: Final[float] = 100.2
|
||
_DVF_M_AND_L_UPPER_V_L_PER_DAY: Final[float] = 199.8
|
||
_DVF_M_AND_L_UPPER_CLAMP: Final[float] = -99.6
|
||
_DVF_M_AND_S_LOWER_V_L_PER_DAY: Final[float] = 36.0
|
||
_DVF_M_AND_S_UPPER_V_L_PER_DAY: Final[float] = 100.2
|
||
_DVF_M_AND_S_LOWER_CLAMP: Final[float] = 64.2
|
||
_DVF_LINEAR_INTERCEPT: Final[float] = 100.2
|
||
|
||
|
||
def _table_3c_dvf(
|
||
daily_hot_water_l_per_day: float,
|
||
profile_pair: Literal["M+L", "M+S"],
|
||
) -> float:
|
||
"""SAP 10.2 Appendix J Table 3c — Daily Volume Factor.
|
||
|
||
Piecewise function of V_d,m gated on which two EN 13203-2 / OPS 26
|
||
profiles the combi was tested with (encoded in PCDF Table 105 field
|
||
48 = separate_dhw_tests: 2 → schedules 2 and 3 = M+L; 3 → schedules
|
||
2 and 1 = M+S).
|
||
"""
|
||
v = daily_hot_water_l_per_day
|
||
if profile_pair == "M+L":
|
||
if v < _DVF_M_AND_L_LOWER_V_L_PER_DAY:
|
||
return 0.0
|
||
if v > _DVF_M_AND_L_UPPER_V_L_PER_DAY:
|
||
return _DVF_M_AND_L_UPPER_CLAMP
|
||
return _DVF_LINEAR_INTERCEPT - v
|
||
if v < _DVF_M_AND_S_LOWER_V_L_PER_DAY:
|
||
return _DVF_M_AND_S_LOWER_CLAMP
|
||
if v > _DVF_M_AND_S_UPPER_V_L_PER_DAY:
|
||
return 0.0
|
||
return _DVF_LINEAR_INTERCEPT - v
|
||
|
||
|
||
def combi_loss_monthly_kwh_table_3c_two_profile_instantaneous(
|
||
*,
|
||
rejected_energy_proportion_r1: float,
|
||
loss_factor_f2_kwh_per_day: float,
|
||
rejected_factor_f3_per_litre: float,
|
||
profile_pair: Literal["M+L", "M+S"],
|
||
energy_content_monthly_kwh: tuple[float, ...],
|
||
daily_hot_water_monthly_l_per_day: tuple[float, ...],
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 Appendix J Table 3c row 1 (Instantaneous combi with non-
|
||
storage FGHRS or without FGHRS), two-profile tests:
|
||
|
||
(61)m = (45)m × [r1 + DVF × F3] × fu + [F2 × n_m]
|
||
|
||
where r1, F2 (kWh/day), F3 (litres⁻¹, can be negative) are PCDF
|
||
Table 105 fields 51 / 56 / 57, DVF is the per-month Daily Volume
|
||
Factor from `_table_3c_dvf`, and fu = V_d,m / 100 when V_d,m < 100
|
||
else 1.0.
|
||
|
||
Applies to PCDB combis with separate_dhw_tests ∈ {2, 3}: =2 means
|
||
schedules 2 and 3 (profiles M and L); =3 means schedules 2 and 1
|
||
(profiles M and S). The profile pair selects the DVF piecewise
|
||
branch — see `_table_3c_dvf`. r2 (PCDF field 55) is lodged but the
|
||
spec explicitly excludes it from SAP assessments ("only r1").
|
||
|
||
Storage-FGHRS / storage-combi variants (Table 3c rows 2-5) are
|
||
deferred until a fixture exercises them — mirrors the row-1-only
|
||
coverage of Table 3b (see `pcdb_combi_loss_override`).
|
||
"""
|
||
return tuple(
|
||
e
|
||
* (rejected_energy_proportion_r1 + _table_3c_dvf(v, profile_pair) * rejected_factor_f3_per_litre)
|
||
* (v / 100.0 if v < 100.0 else 1.0)
|
||
+ loss_factor_f2_kwh_per_day * n
|
||
for e, v, n in zip(
|
||
energy_content_monthly_kwh,
|
||
daily_hot_water_monthly_l_per_day,
|
||
_DAYS_IN_MONTH,
|
||
)
|
||
)
|
||
|
||
|
||
def combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (61)m — Table 3a row "Instantaneous, with keep-hot
|
||
facility controlled by time clock": 600 × n_m / 365 kWh/month.
|
||
|
||
A flat 600 kWh/year combi loss, prorated by month length. Unlike the
|
||
"without keep-hot" rows there is no `fu` adjustment for low-volume
|
||
draw — the loss is constant. Suitable for any standard non-PCDB-
|
||
tested combi that the cert lodges as having a time-clock keep-hot
|
||
facility (Appendix D section D1.16).
|
||
"""
|
||
return tuple(600.0 * n / _DAYS_IN_YEAR for n in _DAYS_IN_MONTH)
|
||
|
||
|
||
def combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot(
|
||
*,
|
||
daily_hot_water_monthly_l_per_day: tuple[float, ...],
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (61)m — Table 3a row 1 "Instantaneous, without
|
||
keep-hot facility": 600 × fu × n_m / 365 kWh/month, where fu = V_d,m
|
||
/ 100 when V_d,m < 100 L/day, else fu = 1.0 (SAP 10.2 spec p.160).
|
||
|
||
Differs from the keep-hot time-clock row by the fu volume-scaling
|
||
factor — for low-volume dwellings (V_d < 100 L/day on average ≈ N <
|
||
2.5 occupants with no electric showers) the loss is proportionally
|
||
less than 600 kWh/yr. For V_d ≥ 100 every month, fu collapses to 1.0
|
||
and this row coincides with `..._keep_hot_time_clock()` (600 kWh/yr
|
||
flat).
|
||
|
||
Origin: BRE STP09-B04 §5.3 derived the 600 kWh/yr keep-hot baseline
|
||
from observed cycling losses; the no-keep-hot variant scales by fu
|
||
because instantaneous combis only cycle when actually drawing hot
|
||
water, and low-draw dwellings stand idle.
|
||
"""
|
||
return tuple(
|
||
600.0 * min(1.0, v / 100.0) * n / _DAYS_IN_YEAR
|
||
for v, n in zip(daily_hot_water_monthly_l_per_day, _DAYS_IN_MONTH)
|
||
)
|
||
|
||
|
||
def combi_loss_monthly_kwh_table_3a_row_4_keep_hot_no_time_clock() -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (61)m — Table 3a row "Instantaneous, with keep-hot
|
||
facility not controlled by time clock": 900 × n_m / 365 kWh/month
|
||
(SAP 10.2 spec p.160).
|
||
|
||
A flat 900 kWh/year — 50% larger than the time-clocked variant
|
||
because the keep-hot heater cycles around the clock rather than only
|
||
during scheduled windows. No fu adjustment per spec: the keep-hot
|
||
facility maintains store temperature regardless of draw.
|
||
"""
|
||
return tuple(900.0 * n / _DAYS_IN_YEAR for n in _DAYS_IN_MONTH)
|
||
|
||
|
||
# SAP 10.2 Table 2 (PDF p.158) hot water storage loss factor L kWh/litre/day.
|
||
# Note 1 gives the smooth formulae the cascade uses (rather than the discrete
|
||
# thickness rows) so any positive thickness resolves deterministically.
|
||
_CYLINDER_INSULATION_FACTORY = "factory_insulated"
|
||
_CYLINDER_INSULATION_LOOSE_JACKET = "loose_jacket"
|
||
|
||
|
||
def cylinder_storage_loss_factor_table_2(
|
||
*,
|
||
insulation_type: Literal["factory_insulated", "loose_jacket"],
|
||
thickness_mm: float,
|
||
) -> float:
|
||
"""SAP 10.2 Table 2 (PDF p.158) — hot water storage loss factor L
|
||
in kWh/litre/day. Note 1 supplies the smooth formula:
|
||
Cylinder, factory insulated: L = 0.005 + 0.55 / (t + 4.0)
|
||
Cylinder, loose jacket: L = 0.005 + 1.76 / (t + 12.8)
|
||
where t is the insulation thickness in mm. Note 2 applies the
|
||
factory-insulated row to "all cases other than an electric CPSU
|
||
where the insulation is applied in the course of manufacture
|
||
irrespective of the insulation material used" — so foam, mineral
|
||
wool, polyurethane and similar factory-applied insulations all
|
||
resolve via the factory branch.
|
||
"""
|
||
if insulation_type == _CYLINDER_INSULATION_FACTORY:
|
||
return 0.005 + 0.55 / (thickness_mm + 4.0)
|
||
return 0.005 + 1.76 / (thickness_mm + 12.8)
|
||
|
||
|
||
def cylinder_volume_factor_table_2a(volume_l: float) -> float:
|
||
"""SAP 10.2 Table 2a (PDF p.158) — volume factor VF using Note 2's
|
||
closed form `VF = (120 / Vc)^(1/3)`. The closed form matches the
|
||
tabulated rows to 4 d.p. (V=160 → VF=0.9086 in the worksheet vs the
|
||
table's 0.908 — Elmhurst computes via formula).
|
||
"""
|
||
return (120.0 / volume_l) ** (1.0 / 3.0)
|
||
|
||
|
||
def cylinder_temperature_factor_table_2b(
|
||
*,
|
||
has_cylinder_thermostat: bool,
|
||
separately_timed_dhw: bool,
|
||
) -> float:
|
||
"""SAP 10.2 Table 2b (PDF p.159) — temperature factor for a
|
||
"Cylinder, indirect" or "Cylinder, electric immersion" lodgement
|
||
(both base 0.60 in the "loss from Table 2" column). Multipliers per
|
||
Notes a) / b):
|
||
× 1.3 if cylinder thermostat is absent
|
||
× 0.9 if domestic hot water is separately timed
|
||
"""
|
||
factor = 0.60
|
||
if not has_cylinder_thermostat:
|
||
factor *= 1.3
|
||
if separately_timed_dhw:
|
||
factor *= 0.9
|
||
return factor
|
||
|
||
|
||
# SAP 10.2 Table 3 (PDF p.159) — primary circuit loss for boilers and
|
||
# heat pumps connected to a hot water cylinder via insulated or
|
||
# uninsulated primary pipework. The spec lists the zero-loss
|
||
# configurations explicitly (combi boilers, integral-vessel heat pumps,
|
||
# CPSUs, thermal stores within 1.5 m insulated pipe, etc.); callers
|
||
# must gate this helper on those exemptions.
|
||
PIPEWORK_INSULATED_UNINSULATED: Final[float] = 0.0
|
||
PIPEWORK_INSULATED_FIRST_METRE: Final[float] = 0.1
|
||
PIPEWORK_INSULATED_ALL_ACCESSIBLE: Final[float] = 0.3
|
||
PIPEWORK_INSULATED_FULLY: Final[float] = 1.0
|
||
|
||
# Per Table 3 hours-per-day table: 5 winter / 3 summer if cylinder
|
||
# thermostat present and water heating not separately timed; 3 / 3 if
|
||
# cylinder thermostat present AND separately timed; 11 / 3 if no
|
||
# cylinder thermostat. "Use summer value for June, July, August and
|
||
# September and winter value for other months."
|
||
_SUMMER_MONTH_INDICES: Final[tuple[int, ...]] = (5, 6, 7, 8) # Jun..Sep
|
||
|
||
|
||
def primary_circuit_hours_per_day_table_3(
|
||
*,
|
||
has_cylinder_thermostat: bool,
|
||
separately_timed_dhw: bool,
|
||
) -> tuple[float, float]:
|
||
"""SAP 10.2 Table 3 (PDF p.159) — hours of primary circulation per
|
||
day, returned as `(winter_hours, summer_hours)`:
|
||
no thermostat → (11, 3)
|
||
thermostat, not separately timed → ( 5, 3)
|
||
thermostat, separately timed → ( 3, 3)
|
||
"""
|
||
if not has_cylinder_thermostat:
|
||
return (11.0, 3.0)
|
||
if separately_timed_dhw:
|
||
return (3.0, 3.0)
|
||
return (5.0, 3.0)
|
||
|
||
|
||
def primary_loss_monthly_kwh(
|
||
*,
|
||
pipework_insulation_fraction: float,
|
||
has_cylinder_thermostat: bool,
|
||
separately_timed_dhw: bool,
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (59)m via Table 3 (PDF p.159):
|
||
(59)m = n_m × 14 × [{0.0091 × p + 0.0245 × (1 − p)} × h + 0.0263]
|
||
where p is the fraction of primary pipework insulated and h is the
|
||
hours of primary circulation per day (winter / summer split per
|
||
`primary_circuit_hours_per_day_table_3`).
|
||
|
||
Returns 12 monthly values in calendar order Jan..Dec. Callers must
|
||
gate this helper on the spec's zero-loss configurations
|
||
(combi boilers, integral-vessel HPs, CPSUs, thermal stores ≤ 1.5 m
|
||
insulated pipe, etc.) — the formula assumes the configuration
|
||
incurs the loss.
|
||
"""
|
||
p = pipework_insulation_fraction
|
||
pipework_term = 0.0091 * p + 0.0245 * (1.0 - p)
|
||
winter_h, summer_h = primary_circuit_hours_per_day_table_3(
|
||
has_cylinder_thermostat=has_cylinder_thermostat,
|
||
separately_timed_dhw=separately_timed_dhw,
|
||
)
|
||
return tuple(
|
||
n * 14.0 * (
|
||
pipework_term * (summer_h if m in _SUMMER_MONTH_INDICES else winter_h)
|
||
+ 0.0263
|
||
)
|
||
for m, n in enumerate(_DAYS_IN_MONTH)
|
||
)
|
||
|
||
|
||
def cylinder_storage_loss_monthly_kwh(
|
||
*,
|
||
volume_l: float,
|
||
insulation_type: Literal["factory_insulated", "loose_jacket"],
|
||
thickness_mm: float,
|
||
has_cylinder_thermostat: bool,
|
||
separately_timed_dhw: bool,
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (56)m water storage loss per spec (PDF p.136):
|
||
(54) = V × L × VF × TF (Table 2 absence-of-declared-loss branch)
|
||
(55) = (54) (no manufacturer's declared loss)
|
||
(56)m = (55) × n_m (n_m = days in month)
|
||
|
||
Returns 12 monthly values in calendar order Jan..Dec. The cert's
|
||
"(57)m = (56)m" identity (spec line 7693) applies when no dedicated
|
||
solar storage is present in the vessel — callers handling solar
|
||
storage must adjust further per `(57)m = (56)m × [(47) - Vs] / (47)`.
|
||
"""
|
||
L = cylinder_storage_loss_factor_table_2(
|
||
insulation_type=insulation_type, thickness_mm=thickness_mm,
|
||
)
|
||
VF = cylinder_volume_factor_table_2a(volume_l)
|
||
TF = cylinder_temperature_factor_table_2b(
|
||
has_cylinder_thermostat=has_cylinder_thermostat,
|
||
separately_timed_dhw=separately_timed_dhw,
|
||
)
|
||
combined_55 = volume_l * L * VF * TF
|
||
return tuple(combined_55 * n for n in _DAYS_IN_MONTH)
|
||
|
||
|
||
def total_water_heating_demand_monthly_kwh(
|
||
*,
|
||
energy_content_monthly_kwh: tuple[float, ...],
|
||
distribution_loss_monthly_kwh: tuple[float, ...],
|
||
solar_storage_monthly_kwh: tuple[float, ...],
|
||
primary_loss_monthly_kwh: tuple[float, ...],
|
||
combi_loss_monthly_kwh: tuple[float, ...],
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (62)m via the spec's formula:
|
||
(62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m
|
||
|
||
Note (56)m water-storage loss does NOT appear here — it's accounted
|
||
for through the storage system's efficiency in the (63a-d) inputs and
|
||
the (64)m output line. (46)m distribution loss appears in both (62)m
|
||
and (65)m heat gains (with weight 0.8); that's intentional per spec.
|
||
|
||
All five monthly arrays must be 12-tuples in calendar order Jan..Dec.
|
||
"""
|
||
return tuple(
|
||
0.85 * e + d + s + p + c
|
||
for e, d, s, p, c in zip(
|
||
energy_content_monthly_kwh,
|
||
distribution_loss_monthly_kwh,
|
||
solar_storage_monthly_kwh,
|
||
primary_loss_monthly_kwh,
|
||
combi_loss_monthly_kwh,
|
||
)
|
||
)
|
||
|
||
|
||
def output_from_water_heater_monthly_kwh(
|
||
*,
|
||
total_demand_monthly_kwh: tuple[float, ...],
|
||
wwhrs_monthly_kwh: tuple[float, ...],
|
||
pv_diverter_monthly_kwh: tuple[float, ...],
|
||
solar_monthly_kwh: tuple[float, ...],
|
||
fghrs_monthly_kwh: tuple[float, ...],
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (64)m:
|
||
(64)m = max(0, (62)m + (63a)m + (63b)m + (63c)m + (63d)m)
|
||
|
||
Output from the water heater after subtracting renewable / heat-
|
||
recovery contributions. The four reduction inputs are entered as
|
||
negative quantities (heat displaced FROM the boiler/cylinder), so
|
||
the formula uses + not -. Spec note "if (64)m < 0 then set to 0"
|
||
floors the result month-by-month — a renewable-heavy system can't
|
||
show negative delivered heat for the warmest months.
|
||
"""
|
||
return tuple(
|
||
max(0.0, t + w + pv + s + f)
|
||
for t, w, pv, s, f in zip(
|
||
total_demand_monthly_kwh,
|
||
wwhrs_monthly_kwh,
|
||
pv_diverter_monthly_kwh,
|
||
solar_monthly_kwh,
|
||
fghrs_monthly_kwh,
|
||
)
|
||
)
|
||
|
||
|
||
def heat_gains_from_water_heating_monthly_kwh(
|
||
*,
|
||
energy_content_monthly_kwh: tuple[float, ...],
|
||
distribution_loss_monthly_kwh: tuple[float, ...],
|
||
solar_storage_monthly_kwh: tuple[float, ...],
|
||
primary_loss_monthly_kwh: tuple[float, ...],
|
||
combi_loss_monthly_kwh: tuple[float, ...],
|
||
electric_shower_monthly_kwh: tuple[float, ...],
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (65)m heat gains released into the heated space:
|
||
(65)m = 0.25 × [0.85 × (45)m + (61)m + (64a)m]
|
||
+ 0.80 × [(46)m + (57)m + (59)m]
|
||
|
||
First bracket: delivered-heat losses (hot water at the tap + combi
|
||
cycling losses + electric-shower waste heat) at 25 % recovery into
|
||
the dwelling. Second bracket: pipe-side losses (distribution +
|
||
solar storage + primary circuit) at 80 % recovery — these run
|
||
through the heated envelope so most of the loss heats it.
|
||
|
||
Per spec footnote at xlsx row 302, include (57)m only if the hot
|
||
water store is in the dwelling. Callers should pass zero for
|
||
out-of-dwelling stores (e.g. communal heat networks).
|
||
"""
|
||
return tuple(
|
||
0.25 * (0.85 * e + c + es) + 0.80 * (d + s + p)
|
||
for e, d, s, p, c, es in zip(
|
||
energy_content_monthly_kwh,
|
||
distribution_loss_monthly_kwh,
|
||
solar_storage_monthly_kwh,
|
||
primary_loss_monthly_kwh,
|
||
combi_loss_monthly_kwh,
|
||
electric_shower_monthly_kwh,
|
||
)
|
||
)
|
||
|
||
|
||
def _days_weighted_average(monthly: tuple[float, ...]) -> float:
|
||
"""Σ value[m] × n_m / 365 — used by Appendix J equations J4 and J9."""
|
||
return sum(v * d for v, d in zip(monthly, _DAYS_IN_MONTH)) / _DAYS_IN_YEAR
|
||
|
||
|
||
def annual_average_hot_water_other_uses_l_per_day(
|
||
*, n_occupants: float, low_water_use: bool
|
||
) -> float:
|
||
"""SAP 10.2 §4 — V_d,other,ave per Appendix J step 3a: 9.8 × N + 14,
|
||
less 5% for the low-water-use target. The annual average is computed
|
||
BEFORE Table J2 monthly modulation, so it's the unmodulated baseline
|
||
value (the days-weighted mean of (42c)m would drift slightly off
|
||
because Table J2 doesn't days-average to exactly 1)."""
|
||
annual = 9.8 * n_occupants + 14.0
|
||
if low_water_use:
|
||
annual *= 1.0 - _LOW_WATER_USE_REDUCTION
|
||
return annual
|
||
|
||
|
||
def annual_average_hot_water_l_per_day(
|
||
*,
|
||
showers_monthly: tuple[float, ...],
|
||
baths_monthly: tuple[float, ...],
|
||
other_uses_annual_avg: float,
|
||
) -> float:
|
||
"""SAP 10.2 §4 line (43) via Appendix J equation J12:
|
||
V_d,ave = V_d,shower,ave + V_d,bath,ave + V_d,other,ave
|
||
|
||
Per the spec text after J12, (43) is the sum of the three component
|
||
annual averages — NOT the days-weighted average of (44)m. The
|
||
distinction only matters for "other uses": its monthly array (42c)m
|
||
is the unmodulated annual baseline times Table J2 factors, and the
|
||
days-weighted average of those factors is 0.9997 (not exactly 1.0),
|
||
so taking the days-weighted mean of (42c)m would drift slightly low.
|
||
Showers and baths only have annual averages via J4 / J9.
|
||
"""
|
||
return (
|
||
_days_weighted_average(showers_monthly)
|
||
+ _days_weighted_average(baths_monthly)
|
||
+ other_uses_annual_avg
|
||
)
|
||
|
||
|
||
def hot_water_other_uses_monthly_l_per_day(
|
||
*, n_occupants: float, low_water_use: bool
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (42c)m via Appendix J equation J11.
|
||
|
||
Annual-average daily hot water use for "other purposes" (i.e. not
|
||
showers, not baths — sinks, dishwashers, etc.):
|
||
|
||
V_d,other,ave = 9.8 × N + 14
|
||
|
||
reduced by 5% if `low_water_use` is True (dwelling designed for ≤125
|
||
L/person/day total water use). The monthly array applies Table J2's
|
||
factor sequence so each entry is daily L for that month.
|
||
"""
|
||
annual_average = 9.8 * n_occupants + 14.0
|
||
if low_water_use:
|
||
annual_average *= 1.0 - _LOW_WATER_USE_REDUCTION
|
||
return tuple(annual_average * f for f in _TABLE_J2_MONTHLY_FACTORS)
|
||
|
||
|
||
_ELECTRIC_SHOWER_DEFAULT_KW: Final[float] = 9.3
|
||
_ELECTRIC_SHOWER_DURATION_HOURS: Final[float] = 0.1
|
||
|
||
|
||
def electric_shower_monthly_kwh(
|
||
*,
|
||
n_occupants: float,
|
||
has_bath: bool,
|
||
n_outlets: int,
|
||
n_electric_showers: int,
|
||
rated_power_kw: float = _ELECTRIC_SHOWER_DEFAULT_KW,
|
||
) -> tuple[float, ...]:
|
||
"""SAP 10.2 §4 line (64a)m via Appendix J (p.82) step 8.
|
||
|
||
Per outlet for each month:
|
||
N_ES = N_shower / N_outlets (eq J16)
|
||
E_ES,j,m = N_ES × f_beh × P_ES,j × 0.1 × n_m (eq J17)
|
||
Summed across electric-shower outlets j (eq J18).
|
||
|
||
N_shower from step 1c (same branch as `hot_water_mixer_showers_monthly_
|
||
l_per_day`); N_outlets is the cert-lodged total of mixer + electric
|
||
outlets. P_ES,j defaults to Table J4 row "Instantaneous electric
|
||
shower" = 9.3 kW for assessments of existing dwellings.
|
||
|
||
Returns 12-tuple of zeros when there are no electric showers."""
|
||
if n_electric_showers <= 0 or n_outlets <= 0:
|
||
return tuple(0.0 for _ in range(12))
|
||
if has_bath:
|
||
n_shower = 0.45 * n_occupants + 0.65
|
||
else:
|
||
n_shower = 0.58 * n_occupants + 0.83
|
||
n_es_per_outlet = n_shower / n_outlets
|
||
return tuple(
|
||
n_electric_showers
|
||
* n_es_per_outlet
|
||
* fbeh
|
||
* rated_power_kw
|
||
* _ELECTRIC_SHOWER_DURATION_HOURS
|
||
* n_m
|
||
for fbeh, n_m in zip(TABLE_J5_BEHAVIOURAL_FACTOR, _DAYS_IN_MONTH)
|
||
)
|
||
|
||
|
||
def water_heating_from_cert(
|
||
*,
|
||
epc: EpcPropertyData,
|
||
mixer_shower_flow_rates_l_per_min: tuple[float, ...],
|
||
has_bath: bool,
|
||
cold_water_temps_c: tuple[float, ...],
|
||
low_water_use: bool,
|
||
combi_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None,
|
||
solar_storage_monthly_kwh_override: Optional[tuple[float, ...]] = None,
|
||
primary_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None,
|
||
solar_water_heating_monthly_kwh_override: Optional[tuple[float, ...]] = None,
|
||
electric_shower_monthly_kwh_override: Optional[tuple[float, ...]] = None,
|
||
has_electric_shower: bool = False,
|
||
electric_shower_count: int = 0,
|
||
) -> WaterHeatingResult:
|
||
"""SAP 10.2 §4 orchestrator — chain every line ref from (42) through
|
||
(65) for a combi-gas dwelling with optional PCDB-backed combi loss.
|
||
|
||
Inputs the cert / site notes contribute:
|
||
- TFA → occupancy (line 42)
|
||
- bath presence → bath formula branch (J6)
|
||
- shower flow rates per mixer outlet → (42a)m
|
||
- cold water source (mains / header tank) → Tcold table
|
||
- low-water-use target flag → J7/J11 -5% reduction
|
||
|
||
`combi_loss_monthly_kwh_override` lets callers inject a (61)m array
|
||
derived from PCDB Table 3b/3c (tested boilers). When omitted the
|
||
cascade defaults to Table 3a row "Instantaneous, with keep-hot
|
||
facility controlled by time clock" — the modal lodging for non-PCDB
|
||
combis.
|
||
|
||
`electric_shower_monthly_kwh_override` lets callers inject (64a)m for
|
||
dwellings with an instantaneous electric shower. The orchestrator
|
||
doesn't yet derive (64a)m from the cert (cert shower-type code →
|
||
Appendix J electric-shower equation), so callers with a worksheet to
|
||
hand can pass the value through. Affects (65)m heat gains via the
|
||
25%-recovery first bracket.
|
||
|
||
All remaining (47)–(60), (63a-d) branches default to zero — suits
|
||
the combi-no-storage-no-solar-no-renewables population. Cylinder +
|
||
solar + WWHRS / PV-diverter / FGHRS paths land in future slices.
|
||
"""
|
||
if epc.total_floor_area_m2 is None:
|
||
raise ValueError("EpcPropertyData.total_floor_area_m2 is required for §4")
|
||
n = assumed_occupancy(epc.total_floor_area_m2)
|
||
showers = hot_water_mixer_showers_monthly_l_per_day(
|
||
n_occupants=n,
|
||
has_bath=has_bath,
|
||
mixer_shower_flow_rates_l_per_min=mixer_shower_flow_rates_l_per_min,
|
||
cold_water_temps_c=cold_water_temps_c,
|
||
)
|
||
has_shower = (
|
||
len(mixer_shower_flow_rates_l_per_min) > 0
|
||
or electric_shower_monthly_kwh_override is not None
|
||
or has_electric_shower
|
||
)
|
||
baths = hot_water_baths_monthly_l_per_day(
|
||
n_occupants=n,
|
||
has_bath=has_bath,
|
||
has_shower=has_shower,
|
||
cold_water_temps_c=cold_water_temps_c,
|
||
low_water_use=low_water_use,
|
||
)
|
||
other = hot_water_other_uses_monthly_l_per_day(
|
||
n_occupants=n, low_water_use=low_water_use,
|
||
)
|
||
daily_total = total_hot_water_monthly_l_per_day(
|
||
showers=showers, baths=baths, other_uses=other,
|
||
)
|
||
other_avg = annual_average_hot_water_other_uses_l_per_day(
|
||
n_occupants=n, low_water_use=low_water_use,
|
||
)
|
||
annual_avg = annual_average_hot_water_l_per_day(
|
||
showers_monthly=showers,
|
||
baths_monthly=baths,
|
||
other_uses_annual_avg=other_avg,
|
||
)
|
||
energy_content = energy_content_of_hot_water_monthly_kwh(
|
||
monthly_hot_water_l_per_day=daily_total,
|
||
cold_water_temps_c=cold_water_temps_c,
|
||
)
|
||
distribution = distribution_loss_monthly_kwh(
|
||
monthly_energy_content_kwh=energy_content,
|
||
is_instantaneous_at_point_of_use=False,
|
||
)
|
||
combi = (
|
||
combi_loss_monthly_kwh_override
|
||
if combi_loss_monthly_kwh_override is not None
|
||
else combi_loss_monthly_kwh_table_3a_keep_hot_time_clock()
|
||
)
|
||
zero12 = (0.0,) * 12
|
||
solar_storage = (
|
||
solar_storage_monthly_kwh_override
|
||
if solar_storage_monthly_kwh_override is not None
|
||
else zero12
|
||
)
|
||
primary_loss = (
|
||
primary_loss_monthly_kwh_override
|
||
if primary_loss_monthly_kwh_override is not None
|
||
else zero12
|
||
)
|
||
total_demand = total_water_heating_demand_monthly_kwh(
|
||
energy_content_monthly_kwh=energy_content,
|
||
distribution_loss_monthly_kwh=distribution,
|
||
solar_storage_monthly_kwh=solar_storage,
|
||
primary_loss_monthly_kwh=primary_loss,
|
||
combi_loss_monthly_kwh=combi,
|
||
)
|
||
solar_hw = (
|
||
solar_water_heating_monthly_kwh_override
|
||
if solar_water_heating_monthly_kwh_override is not None
|
||
else zero12
|
||
)
|
||
output = output_from_water_heater_monthly_kwh(
|
||
total_demand_monthly_kwh=total_demand,
|
||
wwhrs_monthly_kwh=zero12,
|
||
pv_diverter_monthly_kwh=zero12,
|
||
solar_monthly_kwh=solar_hw,
|
||
fghrs_monthly_kwh=zero12,
|
||
)
|
||
if electric_shower_monthly_kwh_override is not None:
|
||
electric_shower = electric_shower_monthly_kwh_override
|
||
elif electric_shower_count > 0:
|
||
# Appendix J step 8 — N_outlets counts mixer + electric outlets
|
||
# together (eq J16).
|
||
n_outlets_total = len(mixer_shower_flow_rates_l_per_min) + electric_shower_count
|
||
electric_shower = electric_shower_monthly_kwh(
|
||
n_occupants=n,
|
||
has_bath=has_bath,
|
||
n_outlets=n_outlets_total,
|
||
n_electric_showers=electric_shower_count,
|
||
)
|
||
else:
|
||
electric_shower = zero12
|
||
gains = heat_gains_from_water_heating_monthly_kwh(
|
||
energy_content_monthly_kwh=energy_content,
|
||
distribution_loss_monthly_kwh=distribution,
|
||
solar_storage_monthly_kwh=solar_storage,
|
||
primary_loss_monthly_kwh=primary_loss,
|
||
combi_loss_monthly_kwh=combi,
|
||
electric_shower_monthly_kwh=electric_shower,
|
||
)
|
||
return WaterHeatingResult(
|
||
occupancy=n,
|
||
annual_avg_hot_water_l_per_day=annual_avg,
|
||
daily_hot_water_l_per_day_monthly=daily_total,
|
||
energy_content_monthly_kwh=energy_content,
|
||
distribution_loss_monthly_kwh=distribution,
|
||
solar_storage_monthly_kwh=solar_storage,
|
||
primary_loss_monthly_kwh=primary_loss,
|
||
combi_loss_monthly_kwh=combi,
|
||
total_demand_monthly_kwh=total_demand,
|
||
output_monthly_kwh=output,
|
||
heat_gains_monthly_kwh=gains,
|
||
electric_shower_monthly_kwh=electric_shower,
|
||
output_kwh_per_yr=sum(output),
|
||
electric_shower_kwh_per_yr=sum(electric_shower),
|
||
)
|