ecoengine 3.2.7__tar.gz → 3.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ecoengine-3.2.7 → ecoengine-3.3.0}/PKG-INFO +1 -1
- {ecoengine-3.2.7 → ecoengine-3.3.0}/pyproject.toml +1 -1
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/__init__.py +1 -0
- ecoengine-3.3.0/src/ecoengine/interfaces/EcoBuilder.py +554 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/interfaces/EcosizerEngine.py +72 -332
- ecoengine-3.3.0/src/ecoengine/interfaces/system_assembly_utils.py +380 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/DHWSystem.py +2 -2
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/recirc_systems/ParallelLoopSystem.py +2 -2
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/recirc_systems/SwingERTrdOffSystem.py +1 -1
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/recirc_systems/SwingSystem.py +3 -3
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/SinglePassRTPSystem.py +1 -1
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/simulation/SimulationRun.py +14 -1
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine.egg-info/PKG-INFO +1 -1
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine.egg-info/SOURCES.txt +2 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/README.md +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/setup.cfg +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/constants/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/constants/constants.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/climate_data/DryBulbTemperatures_ByClimateZone.csv +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/climate_data/InletWaterTemperatures_ByClimateZone.csv +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/climate_data/WeatherStation_ClimateZone_Lookup.csv +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/climate_data/ZipCode_ClimateZone_Lookup.csv +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/climate_data/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/climate_data/kGperkWh_ByClimateZone.csv +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/apartment.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/elementary_school.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/food_service_a.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/food_service_b.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/junior_high.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/mens_dorm.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/motel.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/multi_family.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/nursing_home.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/office_building.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/senior_high.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/womens_dorm.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/maps.json +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH3_SP_bounds.pkl +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH3_SP_capacity_interpolator.pkl +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH3_SP_power_in_interpolator.pkl +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH4_MP_bounds.pkl +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH4_MP_capacity_interpolator.pkl +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH4_MP_power_in_interpolator.pkl +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/interfaces/Simulator.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/interfaces/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/building/Building.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/building/ClimateZone.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/building/UtilityCostTracker.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/building/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/heating/Controls.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/heating/PerformanceMap.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/heating/WaterHeater.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/heating/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/storage/EnergyTank.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/storage/MixedStorageTank.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/storage/SlugOverlayTank.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/storage/StorageTank.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/storage/StratifiedTank.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/storage/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/InstantWHSystem.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/MPNoRecircSystem.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/recirc_systems/RecircSystem.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/recirc_systems/SwingDualFuelSystem.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/recirc_systems/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/MP_RTPInSeriesSystem.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/MultiPassRTPSystem.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/RTPSystem.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/SP_RTPInParallelSystem.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/SP_RTPInSeriesSystem.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/utils.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/simulation/__init__.py +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine.egg-info/dependency_links.txt +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine.egg-info/requires.txt +0 -0
- {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EcoBuilder — build a pre-sized DHWSystem component by component.
|
|
3
|
+
|
|
4
|
+
Where ``EcosizerEngine`` takes one flat set of parameters and sizes the
|
|
5
|
+
system, ``EcoBuilder`` lets the caller define each piece (heaters, controls,
|
|
6
|
+
tanks, recirc loop) in any order and then assemble them with
|
|
7
|
+
``build_system()``. Nothing is sized: the system is built from exactly the
|
|
8
|
+
components supplied, so ``_minimum_*`` sizing results are never set.
|
|
9
|
+
|
|
10
|
+
Example::
|
|
11
|
+
|
|
12
|
+
eb = EcoBuilder()
|
|
13
|
+
eb.define_temperatures(supply_temp_f=120.0, storage_temp_f=150.0)
|
|
14
|
+
eb.define_storage_volume(500.0)
|
|
15
|
+
schedule = eb.build_control_schedule([["normal", 0], ["shed", 16], ["normal", 20]])
|
|
16
|
+
cmap = {
|
|
17
|
+
"normal": eb.build_control_setting(0.5, 120.0, 0.1, 150.0, 150.0),
|
|
18
|
+
"shed": eb.build_control_setting(None, None, None, None, 150.0),
|
|
19
|
+
}
|
|
20
|
+
eb.build_water_heater(eb.build_performance_map(nominal_capacity_kbtuh=300.0),
|
|
21
|
+
schedule, cmap)
|
|
22
|
+
system = eb.build_system("primary_no_recirc")
|
|
23
|
+
building = eb.build_building("multi_family", 100, "95823")
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import warnings
|
|
28
|
+
|
|
29
|
+
from ecoengine.objects.building.Building import Building
|
|
30
|
+
from ecoengine.objects.components.heating.Controls import AlwaysOffControls, Controls
|
|
31
|
+
from ecoengine.objects.components.heating.PerformanceMap import (
|
|
32
|
+
NominalPerformanceMap,
|
|
33
|
+
PerformanceMap,
|
|
34
|
+
)
|
|
35
|
+
from ecoengine.objects.components.heating.WaterHeater import WaterHeater
|
|
36
|
+
from ecoengine.objects.dhwsystems.DHWSystem import DHWSystem
|
|
37
|
+
from .system_assembly_utils import (
|
|
38
|
+
IN_SERIES_SCHEMATICS,
|
|
39
|
+
RECIRC_SCHEMATICS,
|
|
40
|
+
SCHEMATICS,
|
|
41
|
+
TM_SCHEMATICS,
|
|
42
|
+
assemble_dhw_system,
|
|
43
|
+
build_building,
|
|
44
|
+
canonical_schematic,
|
|
45
|
+
required_fields,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_SYSTEM_SLOTS = ("primary", "tm", "in_series")
|
|
49
|
+
_FUEL_TYPES = ("electric", "gas")
|
|
50
|
+
_MP_SCHEMATICS = frozenset({"multi_pass_rtp", "mprtp_in_series"})
|
|
51
|
+
|
|
52
|
+
# assemble_dhw_system argument name → EcoBuilder field name (for error messages)
|
|
53
|
+
_FIELD_NAMES = {
|
|
54
|
+
"supply_temp_f": "supply_temp_f",
|
|
55
|
+
"storage_temp_f": "storage_temp_f",
|
|
56
|
+
"water_heaters": "water_heaters",
|
|
57
|
+
"storage_vol_gal": "primary_storage_vol_gal",
|
|
58
|
+
"return_temp_f": "return_temp_f",
|
|
59
|
+
"return_flow_gpm": "return_flow_gpm",
|
|
60
|
+
"tm_water_heater": "tm_water_heaters",
|
|
61
|
+
"tm_storage_vol_gal": "tm_storage_vol_gal",
|
|
62
|
+
"gas_water_heater": "in_series_water_heaters",
|
|
63
|
+
"gas_storage_vol_gal": "in_series_storage_vol_gal",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class EcoBuilder:
|
|
68
|
+
"""
|
|
69
|
+
Collects DHW system components and assembles them into a pre-sized
|
|
70
|
+
DHWSystem.
|
|
71
|
+
|
|
72
|
+
Attributes
|
|
73
|
+
----------
|
|
74
|
+
supply_temp_f, storage_temp_f : float | None
|
|
75
|
+
DHW delivery and storage setpoint temperatures [°F].
|
|
76
|
+
water_heaters : list[WaterHeater]
|
|
77
|
+
Heaters heating the primary storage tank. For ``sprtp_in_parallel``
|
|
78
|
+
this list also holds the single gas backup heater
|
|
79
|
+
(``fuel_type='gas'``).
|
|
80
|
+
tm_water_heaters : list[WaterHeater]
|
|
81
|
+
Heaters for the temperature-maintenance tank, if applicable.
|
|
82
|
+
in_series_water_heaters : list[WaterHeater]
|
|
83
|
+
Gas heaters for the in-series tank (``sprtp_in_series``,
|
|
84
|
+
``mprtp_in_series``).
|
|
85
|
+
primary_storage_vol_gal, tm_storage_vol_gal, in_series_storage_vol_gal : float | None
|
|
86
|
+
Tank volumes [gal].
|
|
87
|
+
drawdown_fract : float
|
|
88
|
+
Fraction of the primary tank above the cold-water inlet. Only used by
|
|
89
|
+
``multi_pass_rtp`` and ``mprtp_in_series``. Default 1.0.
|
|
90
|
+
return_temp_f, return_flow_gpm : float | None
|
|
91
|
+
Recirc loop return temperature [°F] and flow rate [GPM].
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, perf_map_dir: str | None = None, climate_data_dir: str | None = None) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
perf_map_dir : str, optional
|
|
99
|
+
Directory with ``maps.json`` and ``pkls/`` used by
|
|
100
|
+
``build_performance_map()``. Defaults to the bundled data.
|
|
101
|
+
climate_data_dir : str, optional
|
|
102
|
+
Climate data directory used by ``build_building()``. Defaults to
|
|
103
|
+
the bundled data.
|
|
104
|
+
"""
|
|
105
|
+
self.perf_map_dir = perf_map_dir
|
|
106
|
+
self.climate_data_dir = climate_data_dir
|
|
107
|
+
|
|
108
|
+
self.supply_temp_f: float | None = None
|
|
109
|
+
self.storage_temp_f: float | None = None
|
|
110
|
+
|
|
111
|
+
self.water_heaters: list[WaterHeater] = []
|
|
112
|
+
self.tm_water_heaters: list[WaterHeater] = []
|
|
113
|
+
self.in_series_water_heaters: list[WaterHeater] = []
|
|
114
|
+
|
|
115
|
+
self.primary_storage_vol_gal: float | None = None
|
|
116
|
+
self.tm_storage_vol_gal: float | None = None
|
|
117
|
+
self.in_series_storage_vol_gal: float | None = None
|
|
118
|
+
self.drawdown_fract: float = 1.0
|
|
119
|
+
|
|
120
|
+
self.return_temp_f: float | None = None
|
|
121
|
+
self.return_flow_gpm: float | None = None
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# System-level settings
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def define_temperatures(
|
|
128
|
+
self,
|
|
129
|
+
supply_temp_f: float | None = None,
|
|
130
|
+
storage_temp_f: float | None = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Set (or overwrite) the supply and/or storage temperature [°F]."""
|
|
133
|
+
if supply_temp_f is not None:
|
|
134
|
+
self.supply_temp_f = supply_temp_f
|
|
135
|
+
if storage_temp_f is not None:
|
|
136
|
+
self.storage_temp_f = storage_temp_f
|
|
137
|
+
|
|
138
|
+
def define_storage_volume(
|
|
139
|
+
self,
|
|
140
|
+
gal: float,
|
|
141
|
+
storage_type: str = "primary",
|
|
142
|
+
drawdown_fract: float | None = None,
|
|
143
|
+
supply_temp_f: float | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""
|
|
146
|
+
Set (or overwrite) a tank volume.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
gal : float
|
|
151
|
+
Tank volume [gal]. Must be > 0.
|
|
152
|
+
storage_type : str
|
|
153
|
+
``'primary'``, ``'tm'`` or ``'in_series'``.
|
|
154
|
+
drawdown_fract : float, optional
|
|
155
|
+
``'primary'`` only: fraction of the tank above the cold-water
|
|
156
|
+
inlet (0 < drawdown_fract <= 1). Only used by multi-pass RTP
|
|
157
|
+
schematics.
|
|
158
|
+
supply_temp_f : float, optional
|
|
159
|
+
Also sets (or overwrites) the supply temperature [°F].
|
|
160
|
+
|
|
161
|
+
Raises
|
|
162
|
+
------
|
|
163
|
+
ValueError
|
|
164
|
+
On an unknown ``storage_type``, a non-positive volume, or an
|
|
165
|
+
invalid ``drawdown_fract``.
|
|
166
|
+
"""
|
|
167
|
+
if storage_type not in _SYSTEM_SLOTS:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"storage_type must be one of {_SYSTEM_SLOTS}, got {storage_type!r}."
|
|
170
|
+
)
|
|
171
|
+
if gal is None or gal <= 0:
|
|
172
|
+
raise ValueError(f"Storage volume must be > 0 gal, got {gal!r}.")
|
|
173
|
+
if drawdown_fract is not None:
|
|
174
|
+
if storage_type != "primary":
|
|
175
|
+
raise ValueError("drawdown_fract only applies to the primary tank.")
|
|
176
|
+
if not 0.0 < drawdown_fract <= 1.0:
|
|
177
|
+
raise ValueError(f"drawdown_fract must be in (0, 1], got {drawdown_fract!r}.")
|
|
178
|
+
self.drawdown_fract = drawdown_fract
|
|
179
|
+
|
|
180
|
+
setattr(self, f"{storage_type}_storage_vol_gal", gal)
|
|
181
|
+
self.define_temperatures(supply_temp_f=supply_temp_f)
|
|
182
|
+
|
|
183
|
+
def define_recirc(
|
|
184
|
+
self,
|
|
185
|
+
return_temp_f: float,
|
|
186
|
+
return_flow_gpm: float,
|
|
187
|
+
supply_temp_f: float | None = None,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""
|
|
190
|
+
Set (or overwrite) the recirc loop return temperature [°F] and flow
|
|
191
|
+
[GPM], and optionally the supply temperature [°F].
|
|
192
|
+
"""
|
|
193
|
+
self.return_temp_f = return_temp_f
|
|
194
|
+
self.return_flow_gpm = return_flow_gpm
|
|
195
|
+
self.define_temperatures(supply_temp_f=supply_temp_f)
|
|
196
|
+
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
# Component builders
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def build_performance_map(
|
|
202
|
+
self,
|
|
203
|
+
model_name: str | None = None,
|
|
204
|
+
nominal_capacity_kbtuh: float | None = None,
|
|
205
|
+
num_units: int = 1,
|
|
206
|
+
design_inlet_temp_f: float = 50.0,
|
|
207
|
+
) -> PerformanceMap:
|
|
208
|
+
"""
|
|
209
|
+
Build a PerformanceMap for use in ``build_water_heater()``.
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
model_name : str, optional
|
|
214
|
+
Model name from ``maps.json``. When given, the map is loaded from
|
|
215
|
+
the registry and ``nominal_capacity_kbtuh`` (total, all units) is
|
|
216
|
+
only used as the electric-resistance fallback below the map's
|
|
217
|
+
minimum OAT.
|
|
218
|
+
nominal_capacity_kbtuh : float, optional
|
|
219
|
+
Without ``model_name``: the constant total capacity [kBTU/hr] of a
|
|
220
|
+
``NominalPerformanceMap``.
|
|
221
|
+
num_units : int
|
|
222
|
+
Number of identical units (model maps only). Default 1.
|
|
223
|
+
design_inlet_temp_f : float
|
|
224
|
+
Fallback inlet temperature for model maps [°F]. Default 50.
|
|
225
|
+
|
|
226
|
+
Raises
|
|
227
|
+
------
|
|
228
|
+
ValueError
|
|
229
|
+
If neither ``model_name`` nor ``nominal_capacity_kbtuh`` is given,
|
|
230
|
+
or ``num_units != 1`` without ``model_name``.
|
|
231
|
+
"""
|
|
232
|
+
if model_name is not None:
|
|
233
|
+
return PerformanceMap.from_model_name(
|
|
234
|
+
model_name,
|
|
235
|
+
num_units=num_units,
|
|
236
|
+
design_inlet_temp_f=design_inlet_temp_f,
|
|
237
|
+
nominal_capacity_kbtuh=nominal_capacity_kbtuh,
|
|
238
|
+
perf_map_dir=self.perf_map_dir,
|
|
239
|
+
)
|
|
240
|
+
if nominal_capacity_kbtuh is None:
|
|
241
|
+
raise ValueError("build_performance_map requires model_name or nominal_capacity_kbtuh.")
|
|
242
|
+
if num_units != 1:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
"num_units only applies to model maps; pass the total capacity "
|
|
245
|
+
"as nominal_capacity_kbtuh instead."
|
|
246
|
+
)
|
|
247
|
+
return NominalPerformanceMap(nominal_capacity_kbtuh)
|
|
248
|
+
|
|
249
|
+
def build_control_setting(
|
|
250
|
+
self,
|
|
251
|
+
on_sensor_fract: float | None,
|
|
252
|
+
on_trigger_t_f: float | None,
|
|
253
|
+
off_sensor_fract: float | None,
|
|
254
|
+
off_trigger_t_f: float | None,
|
|
255
|
+
outlet_temp_f: float,
|
|
256
|
+
) -> Controls:
|
|
257
|
+
"""
|
|
258
|
+
Build a Controls object for one operating mode of a control map.
|
|
259
|
+
|
|
260
|
+
If the four sensor/trigger values are all None, returns an
|
|
261
|
+
``AlwaysOffControls`` (heater never fires in that mode).
|
|
262
|
+
|
|
263
|
+
Raises
|
|
264
|
+
------
|
|
265
|
+
ValueError
|
|
266
|
+
If only some of the four sensor/trigger values are None.
|
|
267
|
+
"""
|
|
268
|
+
sensor_values = (on_sensor_fract, on_trigger_t_f, off_sensor_fract, off_trigger_t_f)
|
|
269
|
+
if all(v is None for v in sensor_values):
|
|
270
|
+
return AlwaysOffControls(outlet_temp_f=outlet_temp_f)
|
|
271
|
+
if any(v is None for v in sensor_values):
|
|
272
|
+
raise ValueError(
|
|
273
|
+
"Set all of on_sensor_fract, on_trigger_t_f, off_sensor_fract and "
|
|
274
|
+
"off_trigger_t_f, or none of them (for an always-off mode)."
|
|
275
|
+
)
|
|
276
|
+
return Controls(
|
|
277
|
+
on_sensor_fract = on_sensor_fract,
|
|
278
|
+
on_trigger_t_f = on_trigger_t_f,
|
|
279
|
+
off_sensor_fract = off_sensor_fract,
|
|
280
|
+
off_trigger_t_f = off_trigger_t_f,
|
|
281
|
+
outlet_temp_f = outlet_temp_f,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def build_control_schedule(self, start_times: list[list] | None = None) -> list[str]:
|
|
285
|
+
"""
|
|
286
|
+
Build a 24-hour control schedule from mode start hours.
|
|
287
|
+
|
|
288
|
+
Each mode runs from its start hour until the next mode's start hour
|
|
289
|
+
(or the end of the day).
|
|
290
|
+
|
|
291
|
+
Parameters
|
|
292
|
+
----------
|
|
293
|
+
start_times : list[[str, int]], optional
|
|
294
|
+
``[mode, start_hour]`` pairs, in any order. One must start at
|
|
295
|
+
hour 0. None → ``['normal'] * 24``.
|
|
296
|
+
|
|
297
|
+
``[['normal', 0], ['shed', 12], ['normal', 18]]`` → normal except
|
|
298
|
+
hours 12–17 shed.
|
|
299
|
+
|
|
300
|
+
Raises
|
|
301
|
+
------
|
|
302
|
+
ValueError
|
|
303
|
+
If no mode starts at hour 0, an hour is not an int in 0–23, two
|
|
304
|
+
modes share a start hour, or an entry is malformed.
|
|
305
|
+
"""
|
|
306
|
+
if start_times is None:
|
|
307
|
+
return ["normal"] * 24
|
|
308
|
+
|
|
309
|
+
entries = []
|
|
310
|
+
for entry in start_times:
|
|
311
|
+
if len(entry) != 2:
|
|
312
|
+
raise ValueError(f"Each start time must be [mode, hour], got {entry!r}.")
|
|
313
|
+
mode, hour = entry
|
|
314
|
+
if not isinstance(mode, str):
|
|
315
|
+
raise ValueError(f"Mode must be a string, got {mode!r}.")
|
|
316
|
+
if isinstance(hour, bool) or not isinstance(hour, int) or not 0 <= hour <= 23:
|
|
317
|
+
raise ValueError(f"Start hour must be an int in 0–23, got {hour!r}.")
|
|
318
|
+
entries.append((hour, mode))
|
|
319
|
+
|
|
320
|
+
hours = [h for h, _ in entries]
|
|
321
|
+
if len(set(hours)) != len(hours):
|
|
322
|
+
raise ValueError(f"Two modes share a start hour: {start_times!r}.")
|
|
323
|
+
if 0 not in hours:
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f"No mode starts at hour 0, so the start of the day is ambiguous: {start_times!r}."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
entries.sort()
|
|
329
|
+
schedule = []
|
|
330
|
+
for i, (start, mode) in enumerate(entries):
|
|
331
|
+
end = entries[i + 1][0] if i + 1 < len(entries) else 24
|
|
332
|
+
schedule += [mode] * (end - start)
|
|
333
|
+
return schedule
|
|
334
|
+
|
|
335
|
+
def build_water_heater(
|
|
336
|
+
self,
|
|
337
|
+
performance_map: PerformanceMap,
|
|
338
|
+
control_schedule: list[str],
|
|
339
|
+
control_map: dict[str, Controls],
|
|
340
|
+
fuel_type: str = "electric",
|
|
341
|
+
system: str | None = "primary",
|
|
342
|
+
) -> WaterHeater:
|
|
343
|
+
"""
|
|
344
|
+
Build a WaterHeater and add it to a heater list.
|
|
345
|
+
|
|
346
|
+
Parameters
|
|
347
|
+
----------
|
|
348
|
+
performance_map : PerformanceMap
|
|
349
|
+
From ``build_performance_map()``.
|
|
350
|
+
control_schedule : list[str]
|
|
351
|
+
24-element schedule, e.g. from ``build_control_schedule()``.
|
|
352
|
+
control_map : dict[str, Controls]
|
|
353
|
+
Controls for every mode in ``control_schedule``.
|
|
354
|
+
fuel_type : str
|
|
355
|
+
``'electric'`` or ``'gas'``.
|
|
356
|
+
system : str | None
|
|
357
|
+
``'primary'`` → ``water_heaters``, ``'tm'`` → ``tm_water_heaters``,
|
|
358
|
+
``'in_series'`` → ``in_series_water_heaters``, None → not added
|
|
359
|
+
to any list (just returned).
|
|
360
|
+
|
|
361
|
+
Raises
|
|
362
|
+
------
|
|
363
|
+
ValueError
|
|
364
|
+
On an unknown ``system`` or ``fuel_type``, a schedule that is not
|
|
365
|
+
24 entries long, or schedule modes missing from ``control_map``.
|
|
366
|
+
TypeError
|
|
367
|
+
If ``performance_map`` is not a PerformanceMap.
|
|
368
|
+
"""
|
|
369
|
+
if system is not None and system not in _SYSTEM_SLOTS:
|
|
370
|
+
raise ValueError(f"system must be one of {_SYSTEM_SLOTS} or None, got {system!r}.")
|
|
371
|
+
if fuel_type not in _FUEL_TYPES:
|
|
372
|
+
raise ValueError(f"fuel_type must be one of {_FUEL_TYPES}, got {fuel_type!r}.")
|
|
373
|
+
if not isinstance(performance_map, PerformanceMap):
|
|
374
|
+
raise TypeError(
|
|
375
|
+
"performance_map must be a PerformanceMap; use build_performance_map()."
|
|
376
|
+
)
|
|
377
|
+
if len(control_schedule) != 24:
|
|
378
|
+
raise ValueError(
|
|
379
|
+
f"control_schedule must have 24 entries, got {len(control_schedule)}."
|
|
380
|
+
)
|
|
381
|
+
missing_modes = sorted(set(control_schedule) - set(control_map))
|
|
382
|
+
if missing_modes:
|
|
383
|
+
raise ValueError(f"control_map has no Controls for mode(s): {', '.join(missing_modes)}.")
|
|
384
|
+
|
|
385
|
+
heater = WaterHeater(
|
|
386
|
+
performance_map = performance_map,
|
|
387
|
+
control_schedule = list(control_schedule),
|
|
388
|
+
control_map = dict(control_map),
|
|
389
|
+
fuel_type = fuel_type,
|
|
390
|
+
)
|
|
391
|
+
if system is not None:
|
|
392
|
+
getattr(self, "water_heaters" if system == "primary" else f"{system}_water_heaters").append(heater)
|
|
393
|
+
return heater
|
|
394
|
+
|
|
395
|
+
def build_building(
|
|
396
|
+
self,
|
|
397
|
+
building_type: str,
|
|
398
|
+
magnitude: int | float,
|
|
399
|
+
zip_code_or_climate_zone,
|
|
400
|
+
gpdpp: float | None = None,
|
|
401
|
+
custom_peak_load_shape: list[float] | None = None,
|
|
402
|
+
custom_avg_load_shape: list[float] | None = None,
|
|
403
|
+
) -> Building:
|
|
404
|
+
"""
|
|
405
|
+
Build and return a Building. The Building is not stored on the builder.
|
|
406
|
+
|
|
407
|
+
``zip_code_or_climate_zone`` takes the same forms as in
|
|
408
|
+
``EcosizerEngine``: a 5-digit CA zip string, a zone ID int, a weather
|
|
409
|
+
station name, or a dict with ``'design_oat_f'`` and/or
|
|
410
|
+
``'design_inlet_water_temp_f'``.
|
|
411
|
+
"""
|
|
412
|
+
return build_building(
|
|
413
|
+
building_type = building_type,
|
|
414
|
+
magnitude = magnitude,
|
|
415
|
+
zip_code_or_climate_zone = zip_code_or_climate_zone,
|
|
416
|
+
gpdpp = gpdpp,
|
|
417
|
+
custom_peak_load_shape = custom_peak_load_shape,
|
|
418
|
+
custom_avg_load_shape = custom_avg_load_shape,
|
|
419
|
+
climate_data_dir = self.climate_data_dir,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# ------------------------------------------------------------------
|
|
423
|
+
# Assembly
|
|
424
|
+
# ------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
def build_system(self, schematic: str) -> DHWSystem:
|
|
427
|
+
"""
|
|
428
|
+
Assemble the defined components into a DHWSystem.
|
|
429
|
+
|
|
430
|
+
No sizing is done and no controls are overridden: every heater runs
|
|
431
|
+
on the control map it was built with. Heater objects are shared with
|
|
432
|
+
the returned system, so building twice from the same builder gives
|
|
433
|
+
two systems that share heaters.
|
|
434
|
+
|
|
435
|
+
Schematic notes
|
|
436
|
+
---------------
|
|
437
|
+
* ``sprtp_in_parallel``: exactly one primary heater must have
|
|
438
|
+
``fuel_type='gas'``; it becomes the gas backup heater.
|
|
439
|
+
* ``parallel_loop``: the TM tank's cycling temperatures are taken
|
|
440
|
+
from the TM heater's hour-0 controls (``on_trigger_t_f`` /
|
|
441
|
+
``off_trigger_t_f``).
|
|
442
|
+
* TM and in-series slots accept one heater for now.
|
|
443
|
+
|
|
444
|
+
Raises
|
|
445
|
+
------
|
|
446
|
+
ValueError
|
|
447
|
+
If the schematic is unknown, required fields are missing (all
|
|
448
|
+
listed in one message), or heater counts are wrong.
|
|
449
|
+
"""
|
|
450
|
+
name = canonical_schematic(schematic)
|
|
451
|
+
if name not in SCHEMATICS:
|
|
452
|
+
raise ValueError(
|
|
453
|
+
f"Unknown schematic {schematic!r}. Supported values: {', '.join(SCHEMATICS)}."
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
primary_heaters = list(self.water_heaters)
|
|
457
|
+
gas_heater = None
|
|
458
|
+
if name == "sprtp_in_parallel":
|
|
459
|
+
gas_heaters = [wh for wh in primary_heaters if wh.fuel_type == "gas"]
|
|
460
|
+
if len(gas_heaters) != 1:
|
|
461
|
+
raise ValueError(
|
|
462
|
+
"sprtp_in_parallel requires exactly one primary heater with "
|
|
463
|
+
f"fuel_type='gas'; found {len(gas_heaters)}."
|
|
464
|
+
)
|
|
465
|
+
gas_heater = gas_heaters[0]
|
|
466
|
+
primary_heaters = [wh for wh in primary_heaters if wh is not gas_heater]
|
|
467
|
+
elif name in IN_SERIES_SCHEMATICS:
|
|
468
|
+
gas_heater = self._single_heater(self.in_series_water_heaters, "in_series_water_heaters", name)
|
|
469
|
+
|
|
470
|
+
tm_heater = (
|
|
471
|
+
self._single_heater(self.tm_water_heaters, "tm_water_heaters", name)
|
|
472
|
+
if name in TM_SCHEMATICS else None
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
fields = {
|
|
476
|
+
"supply_temp_f": self.supply_temp_f,
|
|
477
|
+
"storage_temp_f": self.storage_temp_f,
|
|
478
|
+
"water_heaters": primary_heaters,
|
|
479
|
+
"storage_vol_gal": self.primary_storage_vol_gal,
|
|
480
|
+
"return_temp_f": self.return_temp_f,
|
|
481
|
+
"return_flow_gpm": self.return_flow_gpm,
|
|
482
|
+
"tm_water_heater": tm_heater,
|
|
483
|
+
"tm_storage_vol_gal": self.tm_storage_vol_gal,
|
|
484
|
+
"gas_water_heater": gas_heater,
|
|
485
|
+
"gas_storage_vol_gal": self.in_series_storage_vol_gal,
|
|
486
|
+
}
|
|
487
|
+
missing = [
|
|
488
|
+
_FIELD_NAMES[f] for f in required_fields(name)
|
|
489
|
+
if fields[f] is None or fields[f] == []
|
|
490
|
+
]
|
|
491
|
+
if missing:
|
|
492
|
+
raise ValueError(f"Schematic '{name}' requires: {', '.join(missing)}.")
|
|
493
|
+
|
|
494
|
+
self._warn_unused_fields(name)
|
|
495
|
+
|
|
496
|
+
tm_on_temp_f = tm_off_temp_f = None
|
|
497
|
+
if name == "parallel_loop":
|
|
498
|
+
tm_controls = tm_heater.get_controls_for_hour(0)
|
|
499
|
+
tm_on_temp_f = tm_controls.on_trigger_t_f
|
|
500
|
+
tm_off_temp_f = tm_controls.off_trigger_t_f
|
|
501
|
+
|
|
502
|
+
return assemble_dhw_system(
|
|
503
|
+
name,
|
|
504
|
+
primary_heaters,
|
|
505
|
+
self.supply_temp_f,
|
|
506
|
+
self.storage_temp_f,
|
|
507
|
+
self.primary_storage_vol_gal,
|
|
508
|
+
return_temp_f = self.return_temp_f,
|
|
509
|
+
return_flow_gpm = self.return_flow_gpm,
|
|
510
|
+
tm_water_heater = tm_heater,
|
|
511
|
+
tm_storage_vol_gal = self.tm_storage_vol_gal,
|
|
512
|
+
tm_on_temp_f = tm_on_temp_f,
|
|
513
|
+
tm_off_temp_f = tm_off_temp_f,
|
|
514
|
+
gas_water_heater = gas_heater,
|
|
515
|
+
gas_storage_vol_gal = self.in_series_storage_vol_gal,
|
|
516
|
+
drawdown_fract = self.drawdown_fract,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
@staticmethod
|
|
520
|
+
def _single_heater(heaters: list[WaterHeater], field: str, schematic: str) -> WaterHeater | None:
|
|
521
|
+
"""Return the only heater in ``heaters`` (None if empty); error if more than one."""
|
|
522
|
+
if len(heaters) > 1:
|
|
523
|
+
raise ValueError(
|
|
524
|
+
f"Schematic '{schematic}' supports one heater in {field} for now; "
|
|
525
|
+
f"found {len(heaters)}."
|
|
526
|
+
)
|
|
527
|
+
return heaters[0] if heaters else None
|
|
528
|
+
|
|
529
|
+
def _warn_unused_fields(self, schematic: str) -> None:
|
|
530
|
+
"""Warn about defined fields that ``schematic`` does not use."""
|
|
531
|
+
unused = []
|
|
532
|
+
if schematic == "instant_wh" and self.primary_storage_vol_gal is not None:
|
|
533
|
+
unused.append("primary_storage_vol_gal")
|
|
534
|
+
if schematic not in RECIRC_SCHEMATICS and (
|
|
535
|
+
self.return_temp_f is not None or self.return_flow_gpm is not None
|
|
536
|
+
):
|
|
537
|
+
unused.append("return_temp_f / return_flow_gpm")
|
|
538
|
+
if schematic not in TM_SCHEMATICS:
|
|
539
|
+
if self.tm_water_heaters:
|
|
540
|
+
unused.append("tm_water_heaters")
|
|
541
|
+
if self.tm_storage_vol_gal is not None:
|
|
542
|
+
unused.append("tm_storage_vol_gal")
|
|
543
|
+
if schematic not in IN_SERIES_SCHEMATICS:
|
|
544
|
+
if self.in_series_water_heaters:
|
|
545
|
+
unused.append("in_series_water_heaters")
|
|
546
|
+
if self.in_series_storage_vol_gal is not None:
|
|
547
|
+
unused.append("in_series_storage_vol_gal")
|
|
548
|
+
if schematic not in _MP_SCHEMATICS and self.drawdown_fract != 1.0:
|
|
549
|
+
unused.append("drawdown_fract")
|
|
550
|
+
if unused:
|
|
551
|
+
warnings.warn(
|
|
552
|
+
f"Schematic '{schematic}' does not use: {', '.join(unused)}. These will be ignored.",
|
|
553
|
+
stacklevel=3,
|
|
554
|
+
)
|