mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +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,
|
output_from_water_heater_monthly_kwh,
|
||||||
total_hot_water_monthly_l_per_day,
|
total_hot_water_monthly_l_per_day,
|
||||||
total_water_heating_demand_monthly_kwh,
|
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}"
|
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:
|
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
|
"""Appendix J piecewise definition: TFA ≤ 13.9 m² → N=1 exactly. A
|
||||||
tiny studio flat at the boundary is the most common trigger."""
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from math import exp
|
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
|
_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
|
# Table J2 — monthly factors for hot water use (also used by Appendix J
|
||||||
# equation J11 for "other uses"). Symmetric about the year midpoint.
|
# equation J11 for "other uses"). Symmetric about the year midpoint.
|
||||||
_TABLE_J2_MONTHLY_FACTORS: Final[tuple[float, ...]] = (
|
_TABLE_J2_MONTHLY_FACTORS: Final[tuple[float, ...]] = (
|
||||||
|
|
@ -399,3 +434,113 @@ def hot_water_other_uses_monthly_l_per_day(
|
||||||
if low_water_use:
|
if low_water_use:
|
||||||
annual_average *= 1.0 - _LOW_WATER_USE_REDUCTION
|
annual_average *= 1.0 - _LOW_WATER_USE_REDUCTION
|
||||||
return tuple(annual_average * f for f in _TABLE_J2_MONTHLY_FACTORS)
|
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