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.
Files changed (82) hide show
  1. {ecoengine-3.2.7 → ecoengine-3.3.0}/PKG-INFO +1 -1
  2. {ecoengine-3.2.7 → ecoengine-3.3.0}/pyproject.toml +1 -1
  3. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/__init__.py +1 -0
  4. ecoengine-3.3.0/src/ecoengine/interfaces/EcoBuilder.py +554 -0
  5. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/interfaces/EcosizerEngine.py +72 -332
  6. ecoengine-3.3.0/src/ecoengine/interfaces/system_assembly_utils.py +380 -0
  7. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/DHWSystem.py +2 -2
  8. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/recirc_systems/ParallelLoopSystem.py +2 -2
  9. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/recirc_systems/SwingERTrdOffSystem.py +1 -1
  10. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/recirc_systems/SwingSystem.py +3 -3
  11. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/SinglePassRTPSystem.py +1 -1
  12. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/simulation/SimulationRun.py +14 -1
  13. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine.egg-info/PKG-INFO +1 -1
  14. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine.egg-info/SOURCES.txt +2 -0
  15. {ecoengine-3.2.7 → ecoengine-3.3.0}/README.md +0 -0
  16. {ecoengine-3.2.7 → ecoengine-3.3.0}/setup.cfg +0 -0
  17. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/constants/__init__.py +0 -0
  18. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/constants/constants.py +0 -0
  19. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/__init__.py +0 -0
  20. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/climate_data/DryBulbTemperatures_ByClimateZone.csv +0 -0
  21. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/climate_data/InletWaterTemperatures_ByClimateZone.csv +0 -0
  22. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/climate_data/WeatherStation_ClimateZone_Lookup.csv +0 -0
  23. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/climate_data/ZipCode_ClimateZone_Lookup.csv +0 -0
  24. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/climate_data/__init__.py +0 -0
  25. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/climate_data/kGperkWh_ByClimateZone.csv +0 -0
  26. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/__init__.py +0 -0
  27. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/apartment.json +0 -0
  28. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/elementary_school.json +0 -0
  29. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/food_service_a.json +0 -0
  30. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/food_service_b.json +0 -0
  31. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/junior_high.json +0 -0
  32. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/mens_dorm.json +0 -0
  33. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/motel.json +0 -0
  34. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/multi_family.json +0 -0
  35. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/nursing_home.json +0 -0
  36. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/office_building.json +0 -0
  37. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/senior_high.json +0 -0
  38. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/load_shapes/womens_dorm.json +0 -0
  39. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/__init__.py +0 -0
  40. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/maps.json +0 -0
  41. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH3_SP_bounds.pkl +0 -0
  42. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH3_SP_capacity_interpolator.pkl +0 -0
  43. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH3_SP_power_in_interpolator.pkl +0 -0
  44. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH4_MP_bounds.pkl +0 -0
  45. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH4_MP_capacity_interpolator.pkl +0 -0
  46. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH4_MP_power_in_interpolator.pkl +0 -0
  47. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/data/preformanceMaps/pkls/__init__.py +0 -0
  48. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/interfaces/Simulator.py +0 -0
  49. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/interfaces/__init__.py +0 -0
  50. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/__init__.py +0 -0
  51. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/building/Building.py +0 -0
  52. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/building/ClimateZone.py +0 -0
  53. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/building/UtilityCostTracker.py +0 -0
  54. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/building/__init__.py +0 -0
  55. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/__init__.py +0 -0
  56. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/heating/Controls.py +0 -0
  57. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/heating/PerformanceMap.py +0 -0
  58. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/heating/WaterHeater.py +0 -0
  59. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/heating/__init__.py +0 -0
  60. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/storage/EnergyTank.py +0 -0
  61. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/storage/MixedStorageTank.py +0 -0
  62. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/storage/SlugOverlayTank.py +0 -0
  63. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/storage/StorageTank.py +0 -0
  64. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/storage/StratifiedTank.py +0 -0
  65. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/components/storage/__init__.py +0 -0
  66. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/InstantWHSystem.py +0 -0
  67. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/MPNoRecircSystem.py +0 -0
  68. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/__init__.py +0 -0
  69. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/recirc_systems/RecircSystem.py +0 -0
  70. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/recirc_systems/SwingDualFuelSystem.py +0 -0
  71. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/recirc_systems/__init__.py +0 -0
  72. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/MP_RTPInSeriesSystem.py +0 -0
  73. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/MultiPassRTPSystem.py +0 -0
  74. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/RTPSystem.py +0 -0
  75. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/SP_RTPInParallelSystem.py +0 -0
  76. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/SP_RTPInSeriesSystem.py +0 -0
  77. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/rtp_systems/__init__.py +0 -0
  78. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/dhwsystems/utils.py +0 -0
  79. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine/objects/simulation/__init__.py +0 -0
  80. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine.egg-info/dependency_links.txt +0 -0
  81. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine.egg-info/requires.txt +0 -0
  82. {ecoengine-3.2.7 → ecoengine-3.3.0}/src/ecoengine.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ecoengine
3
- Version: 3.2.7
3
+ Version: 3.3.0
4
4
  Requires-Python: >=3.11
5
5
  Requires-Dist: numpy<3.0,>=2.4
6
6
  Requires-Dist: scipy<2.0,>=1.17
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ecoengine"
7
- version = "3.2.7"
7
+ version = "3.3.0"
8
8
  requires-python = ">=3.11"
9
9
 
10
10
  # Ranges are bounded on purpose, and the shared ones are kept in sync with
@@ -1 +1,2 @@
1
1
  from .interfaces.EcosizerEngine import EcosizerEngine, get_oat_buckets, get_list_of_models, get_sizing_curve_plot, get_sizing_curve_title, get_weather_stations, get_hpwh_output_capacity, get_annual_utility_comparison_graph
2
+ from .interfaces.EcoBuilder import EcoBuilder
@@ -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
+ )