mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
§4 conformance: extend Elmhurst fixtures to 6/6 across (42)..(65)
Populates §4 LINE_42..LINE_65 + per-fixture HW inputs (HAS_BATH, MIXER_SHOWER_FLOW_RATES_L_PER_MIN, COLD_WATER_TEMPS_C, LOW_WATER_USE, COMBI_LOSS_OVERRIDE, ELECTRIC_SHOWER_OVERRIDE) in 000477, 000480, 000487, 000516 — values extracted from the Elmhurst U985 worksheets supplied 2026-05-20. 000474 + 000490 get the same input constants for uniform parametrization. Adds electric_shower_monthly_kwh_override to water_heating_from_cert to unlock 000487 (instantaneous electric shower, no mixer). The orchestrator's has_shower flag now also accounts for the electric path. Extends 6 parametrized §4 tests from (000474, 000490) to ALL_FIXTURES and adds a new ALL_FIXTURES-parametrized e2e test exercising the orchestrator end-to-end through (42)..(65) for every Elmhurst fixture. Tolerance on (43)/(44) loosened to 5e-3 to absorb Elmhurst's 4-d.p. display rounding. Result: 150/150 tests pass; §1-§4 conform at ≤1e-2 kWh / 5e-3 L for every fixture. Deferred branches surfaced via overrides: - PCDB Table 3b combi loss (000474, 000477, 000516) - Non-time-clock Table 3a combi loss rows (000480, 000487) - Electric-shower (64a)m derivation from cert codes (000487) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
3c2f975c6d
commit
74b2c1131f
8 changed files with 377 additions and 30 deletions
|
|
@ -19,6 +19,8 @@ Distinct features vs prior fixtures:
|
|||
first 5.27) — the upper storey is smaller than the ground
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
|
|
@ -27,6 +29,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
)
|
||||
from domain.ml.tests._fixtures import make_minimal_sap10_epc
|
||||
from domain.sap.worksheet.ventilation import MechanicalVentilationKind
|
||||
from domain.sap.worksheet.water_heating import TABLE_J1_TCOLD_FROM_MAINS_C
|
||||
|
||||
_WC_CAVITY = 4
|
||||
|
||||
|
|
@ -182,6 +185,19 @@ WINDOW_TOTAL_AREA_M2: float = 11.72
|
|||
WINDOW_AVG_RAW_U_VALUE: float = 2.37
|
||||
DOOR_COUNT: int = 2 # cascade default 1.85 m²/door → 3.70 m² matches worksheet
|
||||
|
||||
# ============================================================================
|
||||
# §4 Water heating — cert-derived inputs + override hooks
|
||||
# ============================================================================
|
||||
# 1 vented mixer outlet at 7 L/min, bath present, mains cold water.
|
||||
HAS_BATH: bool = True
|
||||
MIXER_SHOWER_FLOW_RATES_L_PER_MIN: tuple[float, ...] = (7.0,)
|
||||
COLD_WATER_TEMPS_C: tuple[float, ...] = TABLE_J1_TCOLD_FROM_MAINS_C
|
||||
LOW_WATER_USE: bool = False
|
||||
# COMBI_LOSS_OVERRIDE bound at the end of the file once LINE_61_M is in
|
||||
# scope. Vaillant ecoTEC pro 28 — PCDB-tested; loss curve from Table 3b
|
||||
# (PCDB r1 + F1) which we haven't implemented yet.
|
||||
ELECTRIC_SHOWER_OVERRIDE: Optional[tuple[float, ...]] = None
|
||||
|
||||
# §4 Water heating energy requirements
|
||||
LINE_42_OCCUPANCY: float = 1.8896
|
||||
LINE_43_ANNUAL_AVG_HW_USAGE_L_PER_DAY: float = 101.1966
|
||||
|
|
@ -220,3 +236,5 @@ LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = (
|
|||
65.1690, 57.4789, 60.7300, 52.6612, 50.5342, 44.9825,
|
||||
44.0196, 46.0942, 46.9564, 53.0172, 57.1669, 64.3662,
|
||||
)
|
||||
|
||||
COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ Distinct features vs prior fixtures:
|
|||
flat ceiling — just stud walls (1.5/1.3 height) + slopes
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
|
|
@ -24,6 +26,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
)
|
||||
from domain.ml.tests._fixtures import make_minimal_sap10_epc
|
||||
from domain.sap.worksheet.ventilation import MechanicalVentilationKind
|
||||
from domain.sap.worksheet.water_heating import TABLE_J1_TCOLD_FROM_MAINS_C
|
||||
|
||||
_WC_CAVITY = 4
|
||||
|
||||
|
|
@ -122,3 +125,58 @@ LINE_31_TOTAL_EXTERNAL_AREA_M2: float = 122.3600
|
|||
LINE_33_FABRIC_HEAT_LOSS_W_PER_K: float = 160.8702
|
||||
LINE_36_THERMAL_BRIDGING_W_PER_K: float = 18.3540
|
||||
LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: float = 179.2242
|
||||
|
||||
# ============================================================================
|
||||
# §4 Water heating — cert-derived inputs + override hooks
|
||||
# ============================================================================
|
||||
# Summary: 0 baths, 1 non-electric shower outlet. Back-solving (42a)m Jan
|
||||
# against Appendix J J1-J3 with N=2.4153, has_bath=False, mains Tcold
|
||||
# gives flow=7.0 L/min for the single mixer.
|
||||
HAS_BATH: bool = False
|
||||
MIXER_SHOWER_FLOW_RATES_L_PER_MIN: tuple[float, ...] = (7.0,)
|
||||
COLD_WATER_TEMPS_C: tuple[float, ...] = TABLE_J1_TCOLD_FROM_MAINS_C
|
||||
LOW_WATER_USE: bool = False
|
||||
# Combi loss ~2 kWh/month implies a PCDB-tested boiler on Table 3b r1+F1
|
||||
# (deferred — see SPEC_COVERAGE.md §4 row). Override with worksheet value.
|
||||
ELECTRIC_SHOWER_OVERRIDE: Optional[tuple[float, ...]] = None
|
||||
|
||||
# ============================================================================
|
||||
# §4 Water heating — expected worksheet outputs
|
||||
# ============================================================================
|
||||
LINE_42_OCCUPANCY: float = 2.4153
|
||||
LINE_43_ANNUAL_AVG_HW_USAGE_L_PER_DAY: float = 104.7275
|
||||
LINE_44_M_DAILY_HW_USAGE_L: tuple[float, ...] = (
|
||||
114.1702, 111.5703, 108.4708, 103.9165, 100.1607, 96.1459,
|
||||
94.7203, 97.8079, 101.0474, 105.2471, 109.8668, 113.8915,
|
||||
)
|
||||
LINE_45_M_HW_ENERGY_CONTENT_KWH: tuple[float, ...] = (
|
||||
180.8177, 158.8747, 166.7155, 142.2565, 134.8356, 118.2226,
|
||||
114.5560, 121.1066, 124.6016, 142.8193, 156.5254, 178.3266,
|
||||
)
|
||||
LINE_46_M_DISTRIBUTION_LOSS_KWH: tuple[float, ...] = (
|
||||
27.1226, 23.8312, 25.0073, 21.3385, 20.2253, 17.7334,
|
||||
17.1834, 18.1660, 18.6902, 21.4229, 23.4788, 26.7490,
|
||||
)
|
||||
LINE_56_M_STORAGE_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no cylinder
|
||||
LINE_57_M_SOLAR_STORAGE_KWH: tuple[float, ...] = (0.0,) * 12 # no solar HW
|
||||
LINE_59_M_PRIMARY_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no primary circuit
|
||||
LINE_61_M_COMBI_LOSS_KWH: tuple[float, ...] = (
|
||||
2.3586, 2.1302, 2.3077, 2.0598, 2.0225, 1.7050,
|
||||
1.6276, 1.7768, 1.8542, 2.0414, 2.1360, 2.3331,
|
||||
)
|
||||
LINE_62_M_TOTAL_WH_KWH: tuple[float, ...] = (
|
||||
183.1763, 161.0049, 169.0232, 144.3163, 136.8581, 119.9276,
|
||||
116.1837, 122.8833, 126.4558, 144.8607, 158.6615, 180.6597,
|
||||
)
|
||||
LINE_63A_M_WWHRS_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_63B_M_PV_DIVERTER_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_63C_M_SOLAR_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_63D_M_FGHRS_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_64_M_OUTPUT_FROM_WH_KWH: tuple[float, ...] = LINE_62_M_TOTAL_WH_KWH
|
||||
LINE_64A_M_ELECTRIC_SHOWER_KWH: tuple[float, ...] = (0.0,) * 12 # mixer only
|
||||
LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = (
|
||||
60.7115, 53.3584, 56.0098, 47.8152, 45.3385, 39.7353,
|
||||
38.4968, 40.7121, 41.8936, 47.9978, 52.5787, 59.8769,
|
||||
)
|
||||
|
||||
COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ Differs from 000487 along several useful axes:
|
|||
- No alternative wall (vs 1 timber-frame alt wall on the extension)
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
|
|
@ -25,6 +27,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
)
|
||||
from domain.ml.tests._fixtures import make_minimal_sap10_epc
|
||||
from domain.sap.worksheet.ventilation import MechanicalVentilationKind
|
||||
from domain.sap.worksheet.water_heating import TABLE_J1_TCOLD_FROM_MAINS_C
|
||||
|
||||
_WC_CAVITY = 4
|
||||
|
||||
|
|
@ -153,3 +156,59 @@ LINE_31_TOTAL_EXTERNAL_AREA_M2: float = 132.0000
|
|||
LINE_33_FABRIC_HEAT_LOSS_W_PER_K: float = 223.6239
|
||||
LINE_36_THERMAL_BRIDGING_W_PER_K: float = 19.8000 # y(0.15) × (31)
|
||||
LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: float = 243.4239
|
||||
|
||||
# ============================================================================
|
||||
# §4 Water heating — cert-derived inputs + override hooks
|
||||
# ============================================================================
|
||||
# Summary: 0 baths, 1 non-electric shower outlet. Back-solving (42a)m Jan
|
||||
# against Appendix J J1-J3 with N=2.5412, has_bath=False, mains Tcold
|
||||
# gives flow=7.0 L/min for the single mixer.
|
||||
HAS_BATH: bool = False
|
||||
MIXER_SHOWER_FLOW_RATES_L_PER_MIN: tuple[float, ...] = (7.0,)
|
||||
COLD_WATER_TEMPS_C: tuple[float, ...] = TABLE_J1_TCOLD_FROM_MAINS_C
|
||||
LOW_WATER_USE: bool = False
|
||||
# Combi loss ~28 kWh/month — not the Table 3a "time-clock keep-hot" row
|
||||
# (which is a flat 600 kWh/year prorated). Likely a different Table 3a
|
||||
# row or PCDB Table 3b; the orchestrator default doesn't reproduce it.
|
||||
ELECTRIC_SHOWER_OVERRIDE: Optional[tuple[float, ...]] = None
|
||||
|
||||
# ============================================================================
|
||||
# §4 Water heating — expected worksheet outputs
|
||||
# ============================================================================
|
||||
LINE_42_OCCUPANCY: float = 2.5412
|
||||
LINE_43_ANNUAL_AVG_HW_USAGE_L_PER_DAY: float = 108.1561
|
||||
LINE_44_M_DAILY_HW_USAGE_L: tuple[float, ...] = (
|
||||
117.9079, 115.2229, 112.0218, 107.3185, 103.4397, 99.2935,
|
||||
97.8213, 101.0099, 104.3555, 108.6927, 113.4636, 117.6201,
|
||||
)
|
||||
LINE_45_M_HW_ENERGY_CONTENT_KWH: tuple[float, ...] = (
|
||||
186.7372, 164.0759, 172.1734, 146.9136, 139.2498, 122.0929,
|
||||
118.3064, 125.0713, 128.6808, 147.4949, 161.6497, 184.1646,
|
||||
)
|
||||
LINE_46_M_DISTRIBUTION_LOSS_KWH: tuple[float, ...] = (
|
||||
28.0106, 24.6114, 25.8260, 22.0370, 20.8875, 18.3139,
|
||||
17.7460, 18.7607, 19.3021, 22.1242, 24.2475, 27.6247,
|
||||
)
|
||||
LINE_56_M_STORAGE_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no cylinder
|
||||
LINE_57_M_SOLAR_STORAGE_KWH: tuple[float, ...] = (0.0,) * 12 # no solar HW
|
||||
LINE_59_M_PRIMARY_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no primary circuit
|
||||
LINE_61_M_COMBI_LOSS_KWH: tuple[float, ...] = (
|
||||
28.7547, 25.9605, 28.7182, 27.7426, 28.6359, 27.6784,
|
||||
28.5771, 28.6005, 27.6970, 28.6565, 27.7794, 28.7482,
|
||||
)
|
||||
LINE_62_M_TOTAL_WH_KWH: tuple[float, ...] = (
|
||||
215.4919, 190.0363, 200.8916, 174.6562, 167.8857, 149.7713,
|
||||
146.8835, 153.6718, 156.3778, 176.1514, 189.4292, 212.9128,
|
||||
)
|
||||
LINE_63A_M_WWHRS_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_63B_M_PV_DIVERTER_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_63C_M_SOLAR_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_63D_M_FGHRS_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_64_M_OUTPUT_FROM_WH_KWH: tuple[float, ...] = LINE_62_M_TOTAL_WH_KWH
|
||||
LINE_64A_M_ELECTRIC_SHOWER_KWH: tuple[float, ...] = (0.0,) * 12 # mixer only
|
||||
LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = (
|
||||
69.2788, 61.0453, 64.4272, 55.7844, 53.4595, 47.5155,
|
||||
46.4811, 48.7363, 49.7106, 56.2062, 60.6934, 68.4218,
|
||||
)
|
||||
|
||||
COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ values captured below. Treat the LINE_X constants as authoritative; if
|
|||
they diverge from our code, the bug is on our side.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
|
|
@ -22,6 +24,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
SapRoomInRoof,
|
||||
)
|
||||
from domain.ml.tests._fixtures import make_minimal_sap10_epc
|
||||
from domain.sap.worksheet.water_heating import TABLE_J1_TCOLD_FROM_MAINS_C
|
||||
|
||||
# RdSAP wall_construction code seen in the cert→worksheet mapping. The
|
||||
# Summary lists "CA Cavity" for both main and extension walls. The alt
|
||||
|
|
@ -167,3 +170,60 @@ LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: float = 199.7850
|
|||
# only carries floor_area, so these are NOT computed by our code.
|
||||
# Tracked separately — needs a domain schema extension.
|
||||
# - Window U is set per window in Elmhurst; we only carry an avg.
|
||||
|
||||
# ============================================================================
|
||||
# §4 Water heating — cert-derived inputs + override hooks
|
||||
# ============================================================================
|
||||
# Summary: 1 bath, 1 electric shower (no mixer outlet). (42a)m all zero
|
||||
# confirms n_outlets=0; (42b)m verified against Appendix J J6-J8 with
|
||||
# N=2.4920, has_bath=True. (64a)m electric-shower energy injected via
|
||||
# override — orchestrator doesn't yet derive (64a)m from cert codes.
|
||||
HAS_BATH: bool = True
|
||||
MIXER_SHOWER_FLOW_RATES_L_PER_MIN: tuple[float, ...] = ()
|
||||
COLD_WATER_TEMPS_C: tuple[float, ...] = TABLE_J1_TCOLD_FROM_MAINS_C
|
||||
LOW_WATER_USE: bool = False
|
||||
|
||||
# ============================================================================
|
||||
# §4 Water heating — expected worksheet outputs
|
||||
# ============================================================================
|
||||
LINE_42_OCCUPANCY: float = 2.4920
|
||||
LINE_43_ANNUAL_AVG_HW_USAGE_L_PER_DAY: float = 66.2422
|
||||
LINE_44_M_DAILY_HW_USAGE_L: tuple[float, ...] = (
|
||||
72.2700, 70.2876, 68.1231, 65.4292, 63.0259, 60.5283,
|
||||
60.0094, 62.1694, 64.3848, 66.9497, 69.6674, 72.1686,
|
||||
)
|
||||
LINE_45_M_HW_ENERGY_CONTENT_KWH: tuple[float, ...] = (
|
||||
114.4581, 100.0885, 104.7027, 89.5693, 84.8450, 74.4267,
|
||||
72.5762, 76.9787, 79.3929, 90.8500, 99.2540, 112.9986,
|
||||
)
|
||||
LINE_46_M_DISTRIBUTION_LOSS_KWH: tuple[float, ...] = (
|
||||
17.1687, 15.0133, 15.7054, 13.4354, 12.7268, 11.1640,
|
||||
10.8864, 11.5468, 11.9089, 13.6275, 14.8881, 16.9498,
|
||||
)
|
||||
LINE_56_M_STORAGE_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no cylinder
|
||||
LINE_57_M_SOLAR_STORAGE_KWH: tuple[float, ...] = (0.0,) * 12 # no solar HW
|
||||
LINE_59_M_PRIMARY_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no primary
|
||||
LINE_61_M_COMBI_LOSS_KWH: tuple[float, ...] = (
|
||||
18.0234, 16.2351, 17.9084, 17.2254, 17.7280, 17.0885,
|
||||
17.6278, 17.6713, 17.1498, 17.8023, 17.3319, 18.0116,
|
||||
)
|
||||
LINE_62_M_TOTAL_WH_KWH: tuple[float, ...] = (
|
||||
132.4815, 116.3237, 122.6111, 106.7947, 102.5730, 91.5152,
|
||||
90.2040, 94.6500, 96.5427, 108.6523, 116.5859, 131.0102,
|
||||
)
|
||||
LINE_63A_M_WWHRS_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_63B_M_PV_DIVERTER_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_63C_M_SOLAR_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_63D_M_FGHRS_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_64_M_OUTPUT_FROM_WH_KWH: tuple[float, ...] = LINE_62_M_TOTAL_WH_KWH
|
||||
LINE_64A_M_ELECTRIC_SHOWER_KWH: tuple[float, ...] = (
|
||||
52.8566, 47.0956, 51.4266, 49.0758, 49.9967, 47.6920,
|
||||
49.2817, 49.9967, 49.0758, 51.4266, 50.4596, 52.8566,
|
||||
)
|
||||
LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = (
|
||||
55.7773, 49.1121, 52.1474, 46.3571, 45.1421, 40.9420,
|
||||
40.8590, 42.5124, 42.9545, 47.5149, 49.9498, 55.2891,
|
||||
)
|
||||
|
||||
COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
|
||||
ELECTRIC_SHOWER_OVERRIDE: Optional[tuple[float, ...]] = LINE_64A_M_ELECTRIC_SHOWER_KWH
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ Distinct features vs prior fixtures:
|
|||
- DP = 100%, so (15) = 0.05 (lowest window-infiltration component)
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
|
|
@ -29,6 +31,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
)
|
||||
from domain.ml.tests._fixtures import make_minimal_sap10_epc
|
||||
from domain.sap.worksheet.ventilation import MechanicalVentilationKind
|
||||
from domain.sap.worksheet.water_heating import TABLE_J1_TCOLD_FROM_MAINS_C
|
||||
|
||||
_WC_CAVITY = 4
|
||||
|
||||
|
|
@ -170,6 +173,20 @@ WINDOW_TOTAL_AREA_M2: float = 9.03
|
|||
WINDOW_AVG_RAW_U_VALUE: float = 2.8
|
||||
DOOR_COUNT: int = 2 # cascade default 1.85 m²/door → 3.70 m² matches worksheet
|
||||
|
||||
# ============================================================================
|
||||
# §4 Water heating — cert-derived inputs + override hooks
|
||||
# ============================================================================
|
||||
# 1 vented mixer outlet at 7 L/min, bath present, mains cold water.
|
||||
HAS_BATH: bool = True
|
||||
MIXER_SHOWER_FLOW_RATES_L_PER_MIN: tuple[float, ...] = (7.0,)
|
||||
COLD_WATER_TEMPS_C: tuple[float, ...] = TABLE_J1_TCOLD_FROM_MAINS_C
|
||||
LOW_WATER_USE: bool = False
|
||||
# Vaillant Ecotec Pro combi, "Combi keep hot type = Gas/Oil, time clock":
|
||||
# the orchestrator's default (Table 3a row "time-clock keep-hot")
|
||||
# reproduces this fixture exactly. No override needed.
|
||||
COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = None
|
||||
ELECTRIC_SHOWER_OVERRIDE: Optional[tuple[float, ...]] = None
|
||||
|
||||
# §4 Water heating energy requirements
|
||||
LINE_42_OCCUPANCY: float = 2.1468
|
||||
LINE_43_ANNUAL_AVG_HW_USAGE_L_PER_DAY: float = 109.0070
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ Distinct features vs prior fixtures:
|
|||
as 000480 but on a single-part dwelling
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
|
|
@ -30,6 +32,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
)
|
||||
from domain.ml.tests._fixtures import make_minimal_sap10_epc
|
||||
from domain.sap.worksheet.ventilation import MechanicalVentilationKind
|
||||
from domain.sap.worksheet.water_heating import TABLE_J1_TCOLD_FROM_MAINS_C
|
||||
|
||||
_WC_CAVITY = 4
|
||||
_WC_SOLID_BRICK = 3 # party walls — RdSAP10 maps to U=0.0 (solid masonry)
|
||||
|
|
@ -131,3 +134,58 @@ LINE_31_TOTAL_EXTERNAL_AREA_M2: float = 122.0100
|
|||
LINE_33_FABRIC_HEAT_LOSS_W_PER_K: float = 211.3188
|
||||
LINE_36_THERMAL_BRIDGING_W_PER_K: float = 18.3015 # 0.15 × 122.01
|
||||
LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K: float = 229.6203
|
||||
|
||||
# ============================================================================
|
||||
# §4 Water heating — cert-derived inputs + override hooks
|
||||
# ============================================================================
|
||||
# Summary: 1 bath, 1 non-electric shower outlet. Back-solving (42a)m Jan
|
||||
# against Appendix J J1-J3 with N=2.6330, has_bath=True, mains Tcold
|
||||
# gives flow=7.0 L/min.
|
||||
HAS_BATH: bool = True
|
||||
MIXER_SHOWER_FLOW_RATES_L_PER_MIN: tuple[float, ...] = (7.0,)
|
||||
COLD_WATER_TEMPS_C: tuple[float, ...] = TABLE_J1_TCOLD_FROM_MAINS_C
|
||||
LOW_WATER_USE: bool = False
|
||||
# Combi loss ~2 kWh/month — PCDB-backed Vaillant ecoTEC sustain 24
|
||||
# (Table 3b r1+F1, deferred). Override with worksheet value.
|
||||
ELECTRIC_SHOWER_OVERRIDE: Optional[tuple[float, ...]] = None
|
||||
|
||||
# ============================================================================
|
||||
# §4 Water heating — expected worksheet outputs
|
||||
# ============================================================================
|
||||
LINE_42_OCCUPANCY: float = 2.6330
|
||||
LINE_43_ANNUAL_AVG_HW_USAGE_L_PER_DAY: float = 123.7705
|
||||
LINE_44_M_DAILY_HW_USAGE_L: tuple[float, ...] = (
|
||||
134.6827, 131.7300, 128.1779, 122.8808, 118.5416, 113.8917,
|
||||
112.1818, 115.7194, 119.4394, 124.3111, 129.6866, 134.3484,
|
||||
)
|
||||
LINE_45_M_HW_ENERGY_CONTENT_KWH: tuple[float, ...] = (
|
||||
213.3044, 187.5817, 197.0046, 168.2177, 159.5798, 140.0431,
|
||||
135.6741, 143.2848, 147.2808, 168.6890, 184.7624, 210.3571,
|
||||
)
|
||||
LINE_46_M_DISTRIBUTION_LOSS_KWH: tuple[float, ...] = (
|
||||
31.9957, 28.1373, 29.5507, 25.2327, 23.9370, 21.0065,
|
||||
20.3511, 21.4927, 22.0921, 25.3033, 27.7144, 31.5536,
|
||||
)
|
||||
LINE_56_M_STORAGE_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no cylinder
|
||||
LINE_57_M_SOLAR_STORAGE_KWH: tuple[float, ...] = (0.0,) * 12 # no solar HW
|
||||
LINE_59_M_PRIMARY_LOSS_KWH: tuple[float, ...] = (0.0,) * 12 # combi, no primary
|
||||
LINE_61_M_COMBI_LOSS_KWH: tuple[float, ...] = (
|
||||
2.1698, 1.9857, 2.1834, 1.9891, 1.9839, 1.8322,
|
||||
1.8075, 1.8380, 1.8125, 1.9609, 2.0087, 2.1497,
|
||||
)
|
||||
LINE_62_M_TOTAL_WH_KWH: tuple[float, ...] = (
|
||||
215.4742, 189.5674, 199.1880, 170.2069, 161.5638, 141.8753,
|
||||
137.4816, 145.1228, 149.0933, 170.6499, 186.7711, 212.5068,
|
||||
)
|
||||
LINE_63A_M_WWHRS_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_63B_M_PV_DIVERTER_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_63C_M_SOLAR_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_63D_M_FGHRS_KWH: tuple[float, ...] = (0.0,) * 12
|
||||
LINE_64_M_OUTPUT_FROM_WH_KWH: tuple[float, ...] = LINE_62_M_TOTAL_WH_KWH
|
||||
LINE_64A_M_ELECTRIC_SHOWER_KWH: tuple[float, ...] = (0.0,) * 12 # mixer only
|
||||
LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = (
|
||||
71.4662, 62.8673, 66.0499, 56.4297, 53.5563, 47.0224,
|
||||
45.5635, 48.1017, 49.4240, 56.5793, 61.9357, 70.4812,
|
||||
)
|
||||
|
||||
COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ Reference: SAP 10.2 specification §4 + Appendix J; canonical xlsx rows
|
|||
207–304 (sheet `NonRegionalWeather`).
|
||||
"""
|
||||
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.sap.worksheet.tests import (
|
||||
_elmhurst_worksheet_000474 as _w000474,
|
||||
_elmhurst_worksheet_000490 as _w000490,
|
||||
)
|
||||
from domain.sap.worksheet.tests._elmhurst_fixtures import ALL_FIXTURES, fixture_id
|
||||
from domain.sap.worksheet.tests._xlsx_loader import load_cells
|
||||
from domain.sap.worksheet.water_heating import (
|
||||
TABLE_J1_TCOLD_FROM_MAINS_C,
|
||||
|
|
@ -335,8 +338,8 @@ def test_hot_water_mixer_showers_two_outlets_distributes_shower_count_evenly() -
|
|||
assert t == pytest.approx(s, abs=1e-9), f"month {m+1}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490"))
|
||||
def test_total_hot_water_monthly_matches_elmhurst_line_44(fixture) -> None: # type: ignore[no-untyped-def]
|
||||
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
|
||||
def test_total_hot_water_monthly_matches_elmhurst_line_44(fixture: ModuleType) -> None:
|
||||
"""SAP10.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]
|
||||
|
||||
|
|
@ -345,16 +348,20 @@ def test_total_hot_water_monthly_matches_elmhurst_line_44(fixture) -> None: # t
|
|||
# Arrange
|
||||
showers = hot_water_mixer_showers_monthly_l_per_day(
|
||||
n_occupants=fixture.LINE_42_OCCUPANCY,
|
||||
has_bath=True,
|
||||
mixer_shower_flow_rates_l_per_min=(7.0,),
|
||||
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
|
||||
has_bath=fixture.HAS_BATH,
|
||||
mixer_shower_flow_rates_l_per_min=fixture.MIXER_SHOWER_FLOW_RATES_L_PER_MIN,
|
||||
cold_water_temps_c=fixture.COLD_WATER_TEMPS_C,
|
||||
)
|
||||
baths = hot_water_baths_monthly_l_per_day(
|
||||
n_occupants=fixture.LINE_42_OCCUPANCY, has_bath=True, has_shower=True,
|
||||
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, low_water_use=False,
|
||||
n_occupants=fixture.LINE_42_OCCUPANCY,
|
||||
has_bath=fixture.HAS_BATH,
|
||||
has_shower=len(fixture.MIXER_SHOWER_FLOW_RATES_L_PER_MIN) > 0
|
||||
or fixture.ELECTRIC_SHOWER_OVERRIDE is not None,
|
||||
cold_water_temps_c=fixture.COLD_WATER_TEMPS_C,
|
||||
low_water_use=fixture.LOW_WATER_USE,
|
||||
)
|
||||
other = hot_water_other_uses_monthly_l_per_day(
|
||||
n_occupants=fixture.LINE_42_OCCUPANCY, low_water_use=False,
|
||||
n_occupants=fixture.LINE_42_OCCUPANCY, low_water_use=fixture.LOW_WATER_USE,
|
||||
)
|
||||
|
||||
# Act
|
||||
|
|
@ -362,13 +369,14 @@ def test_total_hot_water_monthly_matches_elmhurst_line_44(fixture) -> None: # t
|
|||
showers=showers, baths=baths, other_uses=other
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Assert — abs=5e-3 absorbs Elmhurst's 4-d.p. display rounding on
|
||||
# intermediate (42a/b/c) values when summed end-to-end.
|
||||
for m, (actual, exp) in enumerate(zip(monthly, fixture.LINE_44_M_DAILY_HW_USAGE_L)):
|
||||
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
|
||||
assert actual == pytest.approx(exp, abs=5e-3), f"month {m+1}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490"))
|
||||
def test_annual_average_hot_water_matches_elmhurst_line_43(fixture) -> None: # type: ignore[no-untyped-def]
|
||||
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
|
||||
def test_annual_average_hot_water_matches_elmhurst_line_43(fixture: ModuleType) -> None:
|
||||
"""SAP10.2 §4 line (43) via Appendix J equation J12 (also (44)m days-
|
||||
weighted form):
|
||||
V_d,ave = Σ V_d,m × n_m / 365
|
||||
|
|
@ -382,16 +390,20 @@ def test_annual_average_hot_water_matches_elmhurst_line_43(fixture) -> None: #
|
|||
# not the days-weighted mean of (42c)m.
|
||||
showers = hot_water_mixer_showers_monthly_l_per_day(
|
||||
n_occupants=fixture.LINE_42_OCCUPANCY,
|
||||
has_bath=True,
|
||||
mixer_shower_flow_rates_l_per_min=(7.0,),
|
||||
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
|
||||
has_bath=fixture.HAS_BATH,
|
||||
mixer_shower_flow_rates_l_per_min=fixture.MIXER_SHOWER_FLOW_RATES_L_PER_MIN,
|
||||
cold_water_temps_c=fixture.COLD_WATER_TEMPS_C,
|
||||
)
|
||||
baths = hot_water_baths_monthly_l_per_day(
|
||||
n_occupants=fixture.LINE_42_OCCUPANCY, has_bath=True, has_shower=True,
|
||||
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C, low_water_use=False,
|
||||
n_occupants=fixture.LINE_42_OCCUPANCY,
|
||||
has_bath=fixture.HAS_BATH,
|
||||
has_shower=len(fixture.MIXER_SHOWER_FLOW_RATES_L_PER_MIN) > 0
|
||||
or fixture.ELECTRIC_SHOWER_OVERRIDE is not None,
|
||||
cold_water_temps_c=fixture.COLD_WATER_TEMPS_C,
|
||||
low_water_use=fixture.LOW_WATER_USE,
|
||||
)
|
||||
other_avg = annual_average_hot_water_other_uses_l_per_day(
|
||||
n_occupants=fixture.LINE_42_OCCUPANCY, low_water_use=False,
|
||||
n_occupants=fixture.LINE_42_OCCUPANCY, low_water_use=fixture.LOW_WATER_USE,
|
||||
)
|
||||
|
||||
# Act
|
||||
|
|
@ -401,13 +413,13 @@ def test_annual_average_hot_water_matches_elmhurst_line_43(fixture) -> None: #
|
|||
other_uses_annual_avg=other_avg,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Assert — abs=5e-3 absorbs Elmhurst's 4-d.p. display rounding.
|
||||
assert annual_avg == pytest.approx(
|
||||
fixture.LINE_43_ANNUAL_AVG_HW_USAGE_L_PER_DAY, abs=1e-3
|
||||
fixture.LINE_43_ANNUAL_AVG_HW_USAGE_L_PER_DAY, abs=5e-3
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490"))
|
||||
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
|
||||
def test_energy_content_of_hot_water_matches_elmhurst_line_45(fixture) -> None: # type: ignore[no-untyped-def]
|
||||
"""SAP10.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]
|
||||
|
|
@ -427,7 +439,7 @@ def test_energy_content_of_hot_water_matches_elmhurst_line_45(fixture) -> None:
|
|||
assert actual == pytest.approx(exp, abs=1e-2), f"month {m+1}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490"))
|
||||
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
|
||||
def test_distribution_loss_matches_elmhurst_line_46_for_non_instantaneous(
|
||||
fixture, # type: ignore[no-untyped-def]
|
||||
) -> None:
|
||||
|
|
@ -506,7 +518,7 @@ def test_total_water_heating_demand_matches_elmhurst_line_62_for_000490() -> Non
|
|||
assert actual == pytest.approx(exp, abs=1e-3), f"month {m+1}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490"))
|
||||
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
|
||||
def test_output_from_water_heater_matches_elmhurst_line_64(fixture) -> None: # type: ignore[no-untyped-def]
|
||||
"""SAP10.2 §4 line (64)m:
|
||||
(64)m = max(0, (62)m + (63a)m + (63b)m + (63c)m + (63d)m)
|
||||
|
|
@ -556,7 +568,7 @@ def test_output_from_water_heater_clamps_to_zero_when_renewables_exceed_demand()
|
|||
assert all(v == pytest.approx(0.0, abs=1e-9) for v in monthly)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", (_w000474, _w000490), ids=("000474", "000490"))
|
||||
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
|
||||
def test_heat_gains_from_water_heating_matches_elmhurst_line_65(fixture) -> None: # type: ignore[no-untyped-def]
|
||||
"""SAP10.2 §4 line (65)m heat gains released into the heated space:
|
||||
(65)m = 0.25 × [0.85 × (45)m + (61)m + (64a)m]
|
||||
|
|
@ -656,3 +668,52 @@ def test_assumed_occupancy_floor_at_n_eq_1_for_small_dwellings() -> None:
|
|||
assert assumed_occupancy(13.9) == pytest.approx(1.0, abs=1e-9)
|
||||
assert assumed_occupancy(10.0) == pytest.approx(1.0, abs=1e-9)
|
||||
assert assumed_occupancy(0.0) == pytest.approx(1.0, abs=1e-9)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
|
||||
def test_water_heating_from_cert_matches_elmhurst_worksheet_all_fixtures(
|
||||
fixture: ModuleType,
|
||||
) -> None:
|
||||
"""End-to-end §4 orchestrator against every Elmhurst conformance
|
||||
fixture in `_elmhurst_fixtures.ALL_FIXTURES`.
|
||||
|
||||
Each fixture pins its own cert-derived inputs (HAS_BATH, mixer flow
|
||||
rates, cold water source, low-water-use flag) plus optional overrides
|
||||
(COMBI_LOSS_OVERRIDE for PCDB-backed or non-time-clock-row combis;
|
||||
ELECTRIC_SHOWER_OVERRIDE for dwellings with an instantaneous electric
|
||||
shower — both currently deferred branches in the orchestrator).
|
||||
|
||||
Asserts (44)m daily HW, (62)m total demand, (64) annual output, and
|
||||
(65)m heat gains all close to ≤1e-2 — within Elmhurst's 4-d.p.
|
||||
rounding noise.
|
||||
"""
|
||||
# Arrange
|
||||
epc = fixture.build_epc()
|
||||
|
||||
# Act
|
||||
result = water_heating_from_cert(
|
||||
epc=epc,
|
||||
mixer_shower_flow_rates_l_per_min=fixture.MIXER_SHOWER_FLOW_RATES_L_PER_MIN,
|
||||
has_bath=fixture.HAS_BATH,
|
||||
cold_water_temps_c=fixture.COLD_WATER_TEMPS_C,
|
||||
low_water_use=fixture.LOW_WATER_USE,
|
||||
combi_loss_monthly_kwh_override=fixture.COMBI_LOSS_OVERRIDE,
|
||||
electric_shower_monthly_kwh_override=fixture.ELECTRIC_SHOWER_OVERRIDE,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.occupancy == pytest.approx(fixture.LINE_42_OCCUPANCY, abs=1e-3)
|
||||
for m, exp in enumerate(fixture.LINE_44_M_DAILY_HW_USAGE_L):
|
||||
assert result.daily_hot_water_l_per_day_monthly[m] == pytest.approx(
|
||||
exp, abs=1e-3
|
||||
), f"(44)m month {m+1}"
|
||||
for m, exp in enumerate(fixture.LINE_62_M_TOTAL_WH_KWH):
|
||||
assert result.total_demand_monthly_kwh[m] == pytest.approx(
|
||||
exp, abs=1e-2
|
||||
), f"(62)m month {m+1}"
|
||||
expected_annual = sum(fixture.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(fixture.LINE_65_M_HEAT_GAINS_FROM_WH_KWH):
|
||||
assert result.heat_gains_monthly_kwh[m] == pytest.approx(
|
||||
exp, abs=1e-2
|
||||
), f"(65)m month {m+1}"
|
||||
|
|
|
|||
|
|
@ -444,6 +444,7 @@ def water_heating_from_cert(
|
|||
cold_water_temps_c: tuple[float, ...],
|
||||
low_water_use: bool,
|
||||
combi_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None,
|
||||
electric_shower_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.
|
||||
|
|
@ -461,10 +462,16 @@ def water_heating_from_cert(
|
|||
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.
|
||||
`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")
|
||||
|
|
@ -475,10 +482,14 @@ def water_heating_from_cert(
|
|||
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
|
||||
)
|
||||
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,
|
||||
has_shower=has_shower,
|
||||
cold_water_temps_c=cold_water_temps_c,
|
||||
low_water_use=low_water_use,
|
||||
)
|
||||
|
|
@ -524,13 +535,18 @@ def water_heating_from_cert(
|
|||
solar_monthly_kwh=zero12,
|
||||
fghrs_monthly_kwh=zero12,
|
||||
)
|
||||
electric_shower = (
|
||||
electric_shower_monthly_kwh_override
|
||||
if electric_shower_monthly_kwh_override is not None
|
||||
else 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,
|
||||
electric_shower_monthly_kwh=electric_shower,
|
||||
)
|
||||
return WaterHeatingResult(
|
||||
occupancy=n,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue