mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
§4 orchestrator: water_heating_from_cert + WaterHeatingResult
Chains every leaf function landed in slices 1-9 into a single call that takes an EpcPropertyData + the few site-notes inputs that aren't on the domain object yet (shower flow rates, has_bath, cold-water source, low- water-use flag). Mirrors heat_transmission_from_cert's shape from §3. WaterHeatingResult exposes the line refs (42), (43), (44)m, (45)m, (46)m, (61)m, (62)m, (64)m, (65)m plus the annual sum of (64)m as `output_kwh_per_yr` — that's the slot calculator.py's CalculatorInputs expects for `hot_water_kwh_per_yr` (modulo division by water heater efficiency, handled by the caller). `combi_loss_monthly_kwh_override` accepts a (61)m array for PCDB-tested boilers (Table 3b/3c) since those need r1+F1 parameters we haven't implemented. Defaulting to Table 3a row "time-clock keep-hot" suits the modal non-PCDB combi lodging. Validated end-to-end against both Elmhurst non-RR fixtures: - 000490: cascade-default combi loss, output matches annual to 0.01 kWh - 000474: PCDB-derived (61)m injected, output matches to 0.01 kWh Cylinder + solar + WWHRS/PV/FGHRS + electric-shower branches default to zero — extension slices land them when needed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6a3552a50d
commit
d6e2c99f5b
2 changed files with 211 additions and 1 deletions
|
|
@ -30,6 +30,7 @@ from domain.sap.worksheet.water_heating import (
|
|||
output_from_water_heater_monthly_kwh,
|
||||
total_hot_water_monthly_l_per_day,
|
||||
total_water_heating_demand_monthly_kwh,
|
||||
water_heating_from_cert,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -584,6 +585,70 @@ def test_heat_gains_from_water_heating_matches_elmhurst_line_65(fixture) -> None
|
|||
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
|
||||
|
||||
|
||||
def test_water_heating_from_cert_matches_elmhurst_worksheet_000490() -> None:
|
||||
"""End-to-end §4 orchestrator for the combi-time-clock-keep-hot path.
|
||||
|
||||
For 000490: 1 vented mixer shower at 7 L/min, bath present, mains
|
||||
cold water, combi gas with time-clock keep-hot. The orchestrator
|
||||
chains every line ref from (42) through (65) using only the cert
|
||||
inputs + the SAP defaults from Appendix J / Table 3a / Table J1.
|
||||
|
||||
Asserts the worksheet's annual output (Σ (64)m) matches and the per-
|
||||
line-ref intermediate dict pins the key monthly arrays.
|
||||
"""
|
||||
# Arrange
|
||||
epc = _w000490.build_epc()
|
||||
|
||||
# Act
|
||||
result = water_heating_from_cert(
|
||||
epc=epc,
|
||||
mixer_shower_flow_rates_l_per_min=(7.0,),
|
||||
has_bath=True,
|
||||
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
|
||||
low_water_use=False,
|
||||
)
|
||||
|
||||
# Assert — annual output equals the days-month-weighted sum of (64)m
|
||||
expected_annual = sum(_w000490.LINE_64_M_OUTPUT_FROM_WH_KWH)
|
||||
assert result.output_kwh_per_yr == pytest.approx(expected_annual, abs=0.01)
|
||||
# Per-line-ref values for audit
|
||||
for m, exp in enumerate(_w000490.LINE_44_M_DAILY_HW_USAGE_L):
|
||||
assert result.daily_hot_water_l_per_day_monthly[m] == pytest.approx(exp, abs=1e-3)
|
||||
for m, exp in enumerate(_w000490.LINE_62_M_TOTAL_WH_KWH):
|
||||
assert result.total_demand_monthly_kwh[m] == pytest.approx(exp, abs=1e-2)
|
||||
for m, exp in enumerate(_w000490.LINE_65_M_HEAT_GAINS_FROM_WH_KWH):
|
||||
assert result.heat_gains_monthly_kwh[m] == pytest.approx(exp, abs=1e-2)
|
||||
|
||||
|
||||
def test_water_heating_from_cert_accepts_combi_loss_override_for_pcdb_boilers() -> None:
|
||||
"""000474's Vaillant ecoTEC pro is PCDB-tested → Table 3b combi loss
|
||||
with PCDB-backed r1 + F1 params we haven't implemented yet. The
|
||||
orchestrator accepts a `combi_loss_monthly_kwh` override so callers
|
||||
that have the value (from PCDB or a worksheet) can inject it. With
|
||||
the LINE_61_M values from the 000474 worksheet, the orchestrator
|
||||
reproduces the rest of §4 end-to-end."""
|
||||
# Arrange
|
||||
epc = _w000474.build_epc()
|
||||
|
||||
# Act
|
||||
result = water_heating_from_cert(
|
||||
epc=epc,
|
||||
mixer_shower_flow_rates_l_per_min=(7.0,),
|
||||
has_bath=True,
|
||||
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
|
||||
low_water_use=False,
|
||||
combi_loss_monthly_kwh_override=_w000474.LINE_61_M_COMBI_LOSS_KWH,
|
||||
)
|
||||
|
||||
# Assert
|
||||
expected_annual = sum(_w000474.LINE_64_M_OUTPUT_FROM_WH_KWH)
|
||||
assert result.output_kwh_per_yr == pytest.approx(expected_annual, abs=0.01)
|
||||
for m, exp in enumerate(_w000474.LINE_62_M_TOTAL_WH_KWH):
|
||||
assert result.total_demand_monthly_kwh[m] == pytest.approx(exp, abs=1e-2)
|
||||
for m, exp in enumerate(_w000474.LINE_65_M_HEAT_GAINS_FROM_WH_KWH):
|
||||
assert result.heat_gains_monthly_kwh[m] == pytest.approx(exp, abs=1e-2)
|
||||
|
||||
|
||||
def test_assumed_occupancy_floor_at_n_eq_1_for_small_dwellings() -> None:
|
||||
"""Appendix J piecewise definition: TFA ≤ 13.9 m² → N=1 exactly. A
|
||||
tiny studio flat at the boundary is the most common trigger."""
|
||||
|
|
|
|||
|
|
@ -23,12 +23,47 @@ Reference: SAP 10.2 specification §4 (pages 22-31) + Appendix J (pages
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import exp
|
||||
from typing import Final
|
||||
from typing import Final, 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, ...]
|
||||
combi_loss_monthly_kwh: tuple[float, ...]
|
||||
total_demand_monthly_kwh: tuple[float, ...]
|
||||
output_monthly_kwh: tuple[float, ...]
|
||||
heat_gains_monthly_kwh: tuple[float, ...]
|
||||
output_kwh_per_yr: float
|
||||
|
||||
# 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, ...]] = (
|
||||
|
|
@ -399,3 +434,113 @@ def hot_water_other_uses_monthly_l_per_day(
|
|||
if low_water_use:
|
||||
annual_average *= 1.0 - _LOW_WATER_USE_REDUCTION
|
||||
return tuple(annual_average * f for f in _TABLE_J2_MONTHLY_FACTORS)
|
||||
|
||||
|
||||
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,
|
||||
) -> 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.
|
||||
|
||||
All remaining (47)–(60), (63a-d), (64a)m branches default to zero —
|
||||
suits the combi-no-storage-no-solar-no-renewables population. Cylinder
|
||||
+ solar + WWHRS / PV-diverter / FGHRS + electric-shower 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,
|
||||
)
|
||||
baths = hot_water_baths_monthly_l_per_day(
|
||||
n_occupants=n,
|
||||
has_bath=has_bath,
|
||||
has_shower=len(mixer_shower_flow_rates_l_per_min) > 0,
|
||||
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
|
||||
total_demand = total_water_heating_demand_monthly_kwh(
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
distribution_loss_monthly_kwh=distribution,
|
||||
solar_storage_monthly_kwh=zero12,
|
||||
primary_loss_monthly_kwh=zero12,
|
||||
combi_loss_monthly_kwh=combi,
|
||||
)
|
||||
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=zero12,
|
||||
fghrs_monthly_kwh=zero12,
|
||||
)
|
||||
gains = heat_gains_from_water_heating_monthly_kwh(
|
||||
energy_content_monthly_kwh=energy_content,
|
||||
distribution_loss_monthly_kwh=distribution,
|
||||
solar_storage_monthly_kwh=zero12,
|
||||
primary_loss_monthly_kwh=zero12,
|
||||
combi_loss_monthly_kwh=combi,
|
||||
electric_shower_monthly_kwh=zero12,
|
||||
)
|
||||
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,
|
||||
combi_loss_monthly_kwh=combi,
|
||||
total_demand_monthly_kwh=total_demand,
|
||||
output_monthly_kwh=output,
|
||||
heat_gains_monthly_kwh=gains,
|
||||
output_kwh_per_yr=sum(output),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue