ecoengine 3.3.0__tar.gz → 3.3.2__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 (83) hide show
  1. {ecoengine-3.3.0 → ecoengine-3.3.2}/PKG-INFO +1 -1
  2. {ecoengine-3.3.0 → ecoengine-3.3.2}/pyproject.toml +1 -1
  3. ecoengine-3.3.2/src/ecoengine/constants/constants.py +18 -0
  4. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/interfaces/EcoBuilder.py +16 -13
  5. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/interfaces/EcosizerEngine.py +72 -4
  6. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/components/heating/PerformanceMap.py +37 -7
  7. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/components/heating/WaterHeater.py +64 -28
  8. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/DHWSystem.py +3 -7
  9. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/recirc_systems/ParallelLoopSystem.py +9 -14
  10. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/recirc_systems/SwingDualFuelSystem.py +12 -19
  11. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/recirc_systems/SwingERTrdOffSystem.py +2 -0
  12. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/recirc_systems/SwingSystem.py +13 -22
  13. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/rtp_systems/MP_RTPInSeriesSystem.py +8 -16
  14. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/rtp_systems/MultiPassRTPSystem.py +2 -8
  15. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/rtp_systems/SP_RTPInParallelSystem.py +10 -11
  16. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/rtp_systems/SP_RTPInSeriesSystem.py +10 -15
  17. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/rtp_systems/SinglePassRTPSystem.py +3 -7
  18. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/simulation/SimulationRun.py +27 -13
  19. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine.egg-info/PKG-INFO +1 -1
  20. ecoengine-3.3.0/src/ecoengine/constants/constants.py +0 -5
  21. {ecoengine-3.3.0 → ecoengine-3.3.2}/README.md +0 -0
  22. {ecoengine-3.3.0 → ecoengine-3.3.2}/setup.cfg +0 -0
  23. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/__init__.py +0 -0
  24. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/constants/__init__.py +0 -0
  25. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/__init__.py +0 -0
  26. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/climate_data/DryBulbTemperatures_ByClimateZone.csv +0 -0
  27. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/climate_data/InletWaterTemperatures_ByClimateZone.csv +0 -0
  28. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/climate_data/WeatherStation_ClimateZone_Lookup.csv +0 -0
  29. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/climate_data/ZipCode_ClimateZone_Lookup.csv +0 -0
  30. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/climate_data/__init__.py +0 -0
  31. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/climate_data/kGperkWh_ByClimateZone.csv +0 -0
  32. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/__init__.py +0 -0
  33. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/apartment.json +0 -0
  34. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/elementary_school.json +0 -0
  35. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/food_service_a.json +0 -0
  36. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/food_service_b.json +0 -0
  37. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/junior_high.json +0 -0
  38. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/mens_dorm.json +0 -0
  39. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/motel.json +0 -0
  40. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/multi_family.json +0 -0
  41. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/nursing_home.json +0 -0
  42. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/office_building.json +0 -0
  43. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/senior_high.json +0 -0
  44. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/load_shapes/womens_dorm.json +0 -0
  45. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/preformanceMaps/__init__.py +0 -0
  46. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/preformanceMaps/maps.json +0 -0
  47. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH3_SP_bounds.pkl +0 -0
  48. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH3_SP_capacity_interpolator.pkl +0 -0
  49. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH3_SP_power_in_interpolator.pkl +0 -0
  50. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH4_MP_bounds.pkl +0 -0
  51. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH4_MP_capacity_interpolator.pkl +0 -0
  52. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH4_MP_power_in_interpolator.pkl +0 -0
  53. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/data/preformanceMaps/pkls/__init__.py +0 -0
  54. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/interfaces/Simulator.py +0 -0
  55. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/interfaces/__init__.py +0 -0
  56. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/interfaces/system_assembly_utils.py +0 -0
  57. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/__init__.py +0 -0
  58. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/building/Building.py +0 -0
  59. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/building/ClimateZone.py +0 -0
  60. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/building/UtilityCostTracker.py +0 -0
  61. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/building/__init__.py +0 -0
  62. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/components/__init__.py +0 -0
  63. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/components/heating/Controls.py +0 -0
  64. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/components/heating/__init__.py +0 -0
  65. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/components/storage/EnergyTank.py +0 -0
  66. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/components/storage/MixedStorageTank.py +0 -0
  67. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/components/storage/SlugOverlayTank.py +0 -0
  68. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/components/storage/StorageTank.py +0 -0
  69. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/components/storage/StratifiedTank.py +0 -0
  70. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/components/storage/__init__.py +0 -0
  71. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/InstantWHSystem.py +0 -0
  72. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/MPNoRecircSystem.py +0 -0
  73. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/__init__.py +0 -0
  74. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/recirc_systems/RecircSystem.py +0 -0
  75. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/recirc_systems/__init__.py +0 -0
  76. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/rtp_systems/RTPSystem.py +0 -0
  77. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/rtp_systems/__init__.py +0 -0
  78. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/dhwsystems/utils.py +0 -0
  79. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine/objects/simulation/__init__.py +0 -0
  80. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine.egg-info/SOURCES.txt +0 -0
  81. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine.egg-info/dependency_links.txt +0 -0
  82. {ecoengine-3.3.0 → ecoengine-3.3.2}/src/ecoengine.egg-info/requires.txt +0 -0
  83. {ecoengine-3.3.0 → ecoengine-3.3.2}/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.3.0
3
+ Version: 3.3.2
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.3.0"
7
+ version = "3.3.2"
8
8
  requires-python = ">=3.11"
9
9
 
10
10
  # Ranges are bounded on purpose, and the shared ones are kept in sync with
@@ -0,0 +1,18 @@
1
+ # Volumetric heat capacity of water [BTU / (gallon · °F)]
2
+ _RHO_CP: float = 8.353535
3
+
4
+ # Unit conversion: 1 W = 3.412142 BTU/hr (equivalently, 1 kW = 3.412142 kBTU/hr)
5
+ _BTUH_PER_W: float = 3.412142
6
+
7
+ # Default Nominal COPs
8
+ _ELECTRIC_RESISTANCE_COP = 0.95
9
+ _GAS_COP = 0.95 # ER equivellant
10
+ _HEAT_PUMP_COP = 2.5
11
+
12
+ # Nominal COP by water heater heating type (used by NominalPerformanceMap)
13
+ _NOMINAL_COP_BY_HEATING_TYPE: dict[str, float] = {
14
+ "electric_resistance": _ELECTRIC_RESISTANCE_COP,
15
+ "gas": _GAS_COP,
16
+ "heat_pump": _HEAT_PUMP_COP,
17
+ }
18
+ _HEATING_TYPES: tuple[str, ...] = tuple(_NOMINAL_COP_BY_HEATING_TYPE)
@@ -26,6 +26,7 @@ from __future__ import annotations
26
26
 
27
27
  import warnings
28
28
 
29
+ from ecoengine.constants.constants import _HEATING_TYPES
29
30
  from ecoengine.objects.building.Building import Building
30
31
  from ecoengine.objects.components.heating.Controls import AlwaysOffControls, Controls
31
32
  from ecoengine.objects.components.heating.PerformanceMap import (
@@ -46,7 +47,6 @@ from .system_assembly_utils import (
46
47
  )
47
48
 
48
49
  _SYSTEM_SLOTS = ("primary", "tm", "in_series")
49
- _FUEL_TYPES = ("electric", "gas")
50
50
  _MP_SCHEMATICS = frozenset({"multi_pass_rtp", "mprtp_in_series"})
51
51
 
52
52
  # assemble_dhw_system argument name → EcoBuilder field name (for error messages)
@@ -76,7 +76,7 @@ class EcoBuilder:
76
76
  water_heaters : list[WaterHeater]
77
77
  Heaters heating the primary storage tank. For ``sprtp_in_parallel``
78
78
  this list also holds the single gas backup heater
79
- (``fuel_type='gas'``).
79
+ (``heating_type='gas'``).
80
80
  tm_water_heaters : list[WaterHeater]
81
81
  Heaters for the temperature-maintenance tank, if applicable.
82
82
  in_series_water_heaters : list[WaterHeater]
@@ -217,7 +217,8 @@ class EcoBuilder:
217
217
  minimum OAT.
218
218
  nominal_capacity_kbtuh : float, optional
219
219
  Without ``model_name``: the constant total capacity [kBTU/hr] of a
220
- ``NominalPerformanceMap``.
220
+ ``NominalPerformanceMap``. Its power input uses the nominal COP
221
+ of the heater's ``heating_type``, set in ``build_water_heater()``.
221
222
  num_units : int
222
223
  Number of identical units (model maps only). Default 1.
223
224
  design_inlet_temp_f : float
@@ -337,7 +338,7 @@ class EcoBuilder:
337
338
  performance_map: PerformanceMap,
338
339
  control_schedule: list[str],
339
340
  control_map: dict[str, Controls],
340
- fuel_type: str = "electric",
341
+ heating_type: str = "heat_pump",
341
342
  system: str | None = "primary",
342
343
  ) -> WaterHeater:
343
344
  """
@@ -351,8 +352,10 @@ class EcoBuilder:
351
352
  24-element schedule, e.g. from ``build_control_schedule()``.
352
353
  control_map : dict[str, Controls]
353
354
  Controls for every mode in ``control_schedule``.
354
- fuel_type : str
355
- ``'electric'`` or ``'gas'``.
355
+ heating_type : str
356
+ ``'heat_pump'`` (default), ``'electric_resistance'`` or ``'gas'``.
357
+ With a nominal performance map this sets the nominal COP used
358
+ for power input.
356
359
  system : str | None
357
360
  ``'primary'`` → ``water_heaters``, ``'tm'`` → ``tm_water_heaters``,
358
361
  ``'in_series'`` → ``in_series_water_heaters``, None → not added
@@ -361,15 +364,15 @@ class EcoBuilder:
361
364
  Raises
362
365
  ------
363
366
  ValueError
364
- On an unknown ``system`` or ``fuel_type``, a schedule that is not
367
+ On an unknown ``system`` or ``heating_type``, a schedule that is not
365
368
  24 entries long, or schedule modes missing from ``control_map``.
366
369
  TypeError
367
370
  If ``performance_map`` is not a PerformanceMap.
368
371
  """
369
372
  if system is not None and system not in _SYSTEM_SLOTS:
370
373
  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}.")
374
+ if heating_type not in _HEATING_TYPES:
375
+ raise ValueError(f"heating_type must be one of {_HEATING_TYPES}, got {heating_type!r}.")
373
376
  if not isinstance(performance_map, PerformanceMap):
374
377
  raise TypeError(
375
378
  "performance_map must be a PerformanceMap; use build_performance_map()."
@@ -386,7 +389,7 @@ class EcoBuilder:
386
389
  performance_map = performance_map,
387
390
  control_schedule = list(control_schedule),
388
391
  control_map = dict(control_map),
389
- fuel_type = fuel_type,
392
+ heating_type = heating_type,
390
393
  )
391
394
  if system is not None:
392
395
  getattr(self, "water_heaters" if system == "primary" else f"{system}_water_heaters").append(heater)
@@ -435,7 +438,7 @@ class EcoBuilder:
435
438
  Schematic notes
436
439
  ---------------
437
440
  * ``sprtp_in_parallel``: exactly one primary heater must have
438
- ``fuel_type='gas'``; it becomes the gas backup heater.
441
+ ``heating_type='gas'``; it becomes the gas backup heater.
439
442
  * ``parallel_loop``: the TM tank's cycling temperatures are taken
440
443
  from the TM heater's hour-0 controls (``on_trigger_t_f`` /
441
444
  ``off_trigger_t_f``).
@@ -456,11 +459,11 @@ class EcoBuilder:
456
459
  primary_heaters = list(self.water_heaters)
457
460
  gas_heater = None
458
461
  if name == "sprtp_in_parallel":
459
- gas_heaters = [wh for wh in primary_heaters if wh.fuel_type == "gas"]
462
+ gas_heaters = [wh for wh in primary_heaters if wh.heating_type == "gas"]
460
463
  if len(gas_heaters) != 1:
461
464
  raise ValueError(
462
465
  "sprtp_in_parallel requires exactly one primary heater with "
463
- f"fuel_type='gas'; found {len(gas_heaters)}."
466
+ f"heating_type='gas'; found {len(gas_heaters)}."
464
467
  )
465
468
  gas_heater = gas_heaters[0]
466
469
  primary_heaters = [wh for wh in primary_heaters if wh is not gas_heater]
@@ -1265,8 +1265,19 @@ class EcosizerEngine:
1265
1265
  f"Schematic '{self.schematic}' requires gas_storage_vol_gal in pre-sized mode."
1266
1266
  )
1267
1267
 
1268
- def _build_tm_water_heater(self, tm_controls, force_electric_resistance : bool = False):
1269
- """Build the TM WaterHeater (single unit) from tm_model or tm_capacity_kbtuh."""
1268
+ def _build_tm_water_heater(
1269
+ self,
1270
+ tm_controls,
1271
+ force_electric_resistance: bool = False,
1272
+ nominal_heating_type: str = "electric_resistance",
1273
+ ):
1274
+ """
1275
+ Build the TM WaterHeater (single unit) from tm_model or tm_capacity_kbtuh.
1276
+
1277
+ ``nominal_heating_type`` sets the heating type of a nominal-capacity TM
1278
+ heater: ``'electric_resistance'`` for TM elements, ``'gas'`` for the
1279
+ ``swing_dual_fuel`` gas swing tank.
1280
+ """
1270
1281
  from ecoengine.objects.components.heating.WaterHeater import WaterHeater
1271
1282
  if force_electric_resistance:
1272
1283
  self.num_tm_heaters = 1
@@ -1288,6 +1299,7 @@ class EcosizerEngine:
1288
1299
  nominal_capacity_kbtuh=per_unit_kbtuh,
1289
1300
  control_schedule=["normal"] * 24,
1290
1301
  control_map={"normal": tm_controls},
1302
+ heating_type=nominal_heating_type,
1291
1303
  )
1292
1304
 
1293
1305
  def _build_presized_dhw_system(self):
@@ -1369,9 +1381,12 @@ class EcosizerEngine:
1369
1381
  tm_controls = default_tm_controls(
1370
1382
  schematic, self.supply_temp_f, self.tm_on_temp_f, self.tm_off_temp_f
1371
1383
  )
1372
- # Swing variants always use an electric-resistance TM element
1384
+ # Swing variants always use a nominal TM element: electric
1385
+ # resistance for swing_tank, gas for the swing_dual_fuel swing tank.
1373
1386
  tm_water_heater = self._build_tm_water_heater(
1374
- tm_controls, force_electric_resistance=(schematic != "parallel_loop")
1387
+ tm_controls,
1388
+ force_electric_resistance=(schematic != "parallel_loop"),
1389
+ nominal_heating_type="gas" if schematic == "swing_dual_fuel" else "electric_resistance",
1375
1390
  )
1376
1391
 
1377
1392
  gas_water_heater = None
@@ -1385,6 +1400,7 @@ class EcosizerEngine:
1385
1400
  nominal_capacity_kbtuh=self.gas_capacity_kbtuh,
1386
1401
  control_schedule=["normal"] * 24,
1387
1402
  control_map={"normal": gas_controls},
1403
+ heating_type="gas",
1388
1404
  )
1389
1405
 
1390
1406
  system = assemble_dhw_system(
@@ -1536,6 +1552,58 @@ class EcosizerEngine:
1536
1552
  """
1537
1553
  return {"monthly_energy_kwh": simulation_run.get_monthly_energy_kwh()}
1538
1554
 
1555
+ def get_parameters(self) -> dict:
1556
+ """
1557
+ Return every public engine field and its current value.
1558
+
1559
+ Private fields (``_building``, ``_dhw_system``) are left out; use
1560
+ ``print_parameters()`` to also see the built system's heaters and tanks.
1561
+
1562
+ Returns
1563
+ -------
1564
+ dict
1565
+ ``{field_name: value}`` in the order the fields were set.
1566
+ """
1567
+ return {k: v for k, v in vars(self).items() if not k.startswith("_")}
1568
+
1569
+ def print_parameters(self, include_system: bool = True) -> None:
1570
+ """
1571
+ Print every public engine field and its current value, for debugging.
1572
+
1573
+ Parameters
1574
+ ----------
1575
+ include_system : bool
1576
+ Also print the built DHWSystem: class, storage tanks, and for each
1577
+ heater its role, heating type, performance map class, model name
1578
+ and nominal capacity. Default True.
1579
+ """
1580
+ params = self.get_parameters()
1581
+ width = max(len(k) for k in params)
1582
+ print("EcosizerEngine parameters")
1583
+ for k, v in params.items():
1584
+ print(f" {k:<{width}} = {v!r}")
1585
+
1586
+ if not include_system or self._dhw_system is None:
1587
+ return
1588
+ system = self._dhw_system
1589
+ print(f"Built system: {type(system).__name__}")
1590
+ for attr in ("storage_tank", "tm_storage_tank", "gas_storage_tank"):
1591
+ tank = getattr(system, attr, None)
1592
+ if tank is not None:
1593
+ print(f" {attr}: {type(tank).__name__}, "
1594
+ f"{getattr(tank, 'total_volume_gal', None)} gal")
1595
+ heaters = [("primary", wh) for wh in getattr(system, "water_heaters", None) or []]
1596
+ for role, attr in (("tm", "tm_water_heater"), ("gas", "gas_water_heater")):
1597
+ wh = getattr(system, attr, None)
1598
+ if wh is not None:
1599
+ heaters.append((role, wh))
1600
+ for role, wh in heaters:
1601
+ pm = wh.performance_map
1602
+ print(f" {role} heater: heating_type={wh.heating_type!r}, "
1603
+ f"map={type(pm).__name__}, model={getattr(pm, 'model_name', '') or None!r}, "
1604
+ f"nominal_capacity_kbtuh={getattr(pm, 'nominal_capacity_kbtuh', None)!r}, "
1605
+ f"num_units={getattr(pm, 'num_units', None)!r}")
1606
+
1539
1607
  def get_hw_magnitude(self) -> float:
1540
1608
  """
1541
1609
  Return the total daily DHW usage for the building [gallons at supply temperature].
@@ -4,8 +4,9 @@ import json
4
4
  import math
5
5
  import os
6
6
  import pickle
7
+ from typing import Literal
7
8
 
8
- from ecoengine.constants.constants import _BTUH_PER_W
9
+ from ecoengine.constants.constants import _BTUH_PER_W, _NOMINAL_COP_BY_HEATING_TYPE
9
10
 
10
11
  # Bundled data directory — used as a fallback when perf_map_dir is not supplied.
11
12
  # Will be removed once the data is fully externalised.
@@ -60,9 +61,9 @@ class PerformanceMap:
60
61
  - ``PklPerformanceMap`` — pickle-based LinearND interpolator (lab data)
61
62
  - ``HPWHsimPerformanceMap``— coefficient-based polynomial (HPWHsim model)
62
63
 
63
- * ``NominalPerformanceMap(nominal_capacity_kbtuh)``
64
- Constant-output placeholder for use during preliminary sizing.
65
- **Do not change this subclass.**
64
+ * ``NominalPerformanceMap(nominal_capacity_kbtuh, heating_type)``
65
+ Constant-output placeholder for use during preliminary sizing. Power
66
+ input uses a fixed nominal COP for the heating type.
66
67
 
67
68
  API notes
68
69
  ---------
@@ -236,7 +237,7 @@ class PerformanceMap:
236
237
 
237
238
 
238
239
  # ---------------------------------------------------------------------------
239
- # NominalPerformanceMap — constant-output placeholder (do not change)
240
+ # NominalPerformanceMap — constant-output placeholder
240
241
  # ---------------------------------------------------------------------------
241
242
 
242
243
  class NominalPerformanceMap(PerformanceMap):
@@ -247,10 +248,16 @@ class NominalPerformanceMap(PerformanceMap):
247
248
  Every capacity query returns the fixed nominal_capacity_kbtuh regardless of
248
249
  outdoor air temperature or water temperature — analogous to how
249
250
  ClimateZone.from_design_conditions() returns a constant OAT for all timesteps.
250
- Power and COP queries remain stubs until a real map is assigned.
251
+ Power input is the nominal capacity divided by a fixed COP chosen by
252
+ ``heating_type`` (see ``_NOMINAL_COP_BY_HEATING_TYPE`` in constants.py).
251
253
  """
252
254
 
253
- def __init__(self, nominal_capacity_kbtuh: float, model_name: str = "") -> None:
255
+ def __init__(
256
+ self,
257
+ nominal_capacity_kbtuh: float,
258
+ model_name: str = "",
259
+ heating_type: Literal["electric_resistance", "gas", "heat_pump"] = "heat_pump",
260
+ ) -> None:
254
261
  """
255
262
  Parameters
256
263
  ----------
@@ -258,14 +265,37 @@ class NominalPerformanceMap(PerformanceMap):
258
265
  Fixed heating output capacity [kBTU/hr] returned for all conditions.
259
266
  model_name : str
260
267
  Optional human-readable identifier.
268
+ heating_type : "electric_resistance" | "gas" | "heat_pump"
269
+ Selects the nominal COP used by ``get_power_in_kw()``.
270
+ Default "heat_pump".
271
+
272
+ Raises
273
+ ------
274
+ ValueError
275
+ If ``heating_type`` is not a known heating type.
261
276
  """
277
+ if heating_type not in _NOMINAL_COP_BY_HEATING_TYPE:
278
+ raise ValueError(
279
+ f"heating_type must be one of {tuple(_NOMINAL_COP_BY_HEATING_TYPE)}, "
280
+ f"got {heating_type!r}."
281
+ )
262
282
  super().__init__(map_data=None, model_name=model_name)
263
283
  self.nominal_capacity_kbtuh = nominal_capacity_kbtuh
284
+ self.heating_type = heating_type
285
+ self.cop = _NOMINAL_COP_BY_HEATING_TYPE[heating_type]
264
286
 
265
287
  def get_capacity_kbtuh(self, oat_f: float, outlet_temp_f: float, inlet_temp_f: float | None = None) -> float:
266
288
  """Return the fixed nominal capacity [kBTU/hr] regardless of conditions."""
267
289
  return self.nominal_capacity_kbtuh
268
290
 
291
+ def get_power_in_kw(self, oat_f: float, outlet_temp_f: float, inlet_temp_f: float | None = None) -> float:
292
+ """Return power input [kW]: nominal capacity / nominal COP for the heating type."""
293
+ return self.nominal_capacity_kbtuh / _BTUH_PER_W / self.cop
294
+
295
+ def is_within_operating_bounds(self, oat_f: float) -> bool:
296
+ """A nominal map has no operating limits."""
297
+ return True
298
+
269
299
 
270
300
  # ---------------------------------------------------------------------------
271
301
  # PklPerformanceMap — pickle-based LinearND interpolator
@@ -2,8 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING, Literal
4
4
 
5
+ from ecoengine.constants.constants import _HEATING_TYPES
5
6
  from ecoengine.objects.components.heating.PerformanceMap import NominalPerformanceMap, PerformanceMap
6
7
 
8
+ HeatingType = Literal["electric_resistance", "gas", "heat_pump"]
9
+
7
10
  if TYPE_CHECKING:
8
11
  from ecoengine.objects.components.heating.Controls import Controls
9
12
  from ecoengine.objects.components.storage.StorageTank import StorageTank
@@ -49,7 +52,7 @@ class WaterHeater:
49
52
  performance_map: PerformanceMap | None,
50
53
  control_schedule: list[str] | None,
51
54
  control_map: dict[str, Controls] | None,
52
- fuel_type: Literal["electric", "gas"] = "electric",
55
+ heating_type: HeatingType = "heat_pump",
53
56
  ) -> None:
54
57
  """
55
58
  Parameters
@@ -63,13 +66,29 @@ class WaterHeater:
63
66
  control_map : dict[str, Controls] | None
64
67
  Maps schedule strings to Controls objects.
65
68
  None when no control logic is configured.
66
- fuel_type : "electric" | "gas"
67
- Fuel source for this heater. Default is "electric".
69
+ heating_type : "electric_resistance" | "gas" | "heat_pump"
70
+ Heating technology. Default is "heat_pump". When
71
+ ``performance_map`` is a ``NominalPerformanceMap``, the heater
72
+ stores a copy of it with this heating type, so its power uses the
73
+ matching nominal COP. The passed map is not modified.
74
+
75
+ Raises
76
+ ------
77
+ ValueError
78
+ If ``heating_type`` is not a known heating type.
68
79
  """
80
+ if heating_type not in _HEATING_TYPES:
81
+ raise ValueError(f"heating_type must be one of {_HEATING_TYPES}, got {heating_type!r}.")
82
+ if isinstance(performance_map, NominalPerformanceMap) and performance_map.heating_type != heating_type:
83
+ performance_map = NominalPerformanceMap(
84
+ performance_map.nominal_capacity_kbtuh,
85
+ model_name=performance_map.model_name,
86
+ heating_type=heating_type,
87
+ )
69
88
  self.performance_map = performance_map
70
89
  self.control_schedule = control_schedule
71
90
  self.control_map = control_map
72
- self.fuel_type = fuel_type
91
+ self.heating_type = heating_type
73
92
  self._active = False
74
93
 
75
94
  # ------------------------------------------------------------------
@@ -82,7 +101,7 @@ class WaterHeater:
82
101
  nominal_capacity_kbtuh: float,
83
102
  control_schedule: list[str] | None,
84
103
  control_map: dict[str, Controls] | None,
85
- fuel_type: Literal["electric", "gas"] = "electric",
104
+ heating_type: HeatingType = "heat_pump",
86
105
  ) -> WaterHeater:
87
106
  """
88
107
  Create a WaterHeater with a constant-capacity placeholder performance map.
@@ -99,18 +118,19 @@ class WaterHeater:
99
118
  24-element list mapping each hour to a key in control_map.
100
119
  control_map : dict[str, Controls] | None
101
120
  Maps schedule strings to Controls objects.
102
- fuel_type : "electric" | "gas"
103
- Fuel source for this heater. Default is "electric".
121
+ heating_type : "electric_resistance" | "gas" | "heat_pump"
122
+ Heating technology; selects the nominal COP used for power input.
123
+ Default is "heat_pump".
104
124
 
105
125
  Returns
106
126
  -------
107
127
  WaterHeater
108
128
  """
109
129
  return cls(
110
- performance_map=NominalPerformanceMap(nominal_capacity_kbtuh),
130
+ performance_map=NominalPerformanceMap(nominal_capacity_kbtuh, heating_type=heating_type),
111
131
  control_schedule=control_schedule,
112
132
  control_map=control_map,
113
- fuel_type=fuel_type,
133
+ heating_type=heating_type,
114
134
  )
115
135
 
116
136
  @classmethod
@@ -122,7 +142,7 @@ class WaterHeater:
122
142
  num_units: int = 1,
123
143
  design_inlet_temp_f: float = 50.0,
124
144
  nominal_capacity_kbtuh: float | None = None,
125
- fuel_type: Literal["electric", "gas"] = "electric",
145
+ heating_type: HeatingType = "heat_pump",
126
146
  perf_map_dir: str | None = None,
127
147
  ) -> WaterHeater:
128
148
  """
@@ -144,8 +164,9 @@ class WaterHeater:
144
164
  Cold-water inlet temperature used when not supplied per-call. Default 50 °F.
145
165
  nominal_capacity_kbtuh : float | None
146
166
  Total system capacity for ER fallback sizing. Default None.
147
- fuel_type : "electric" | "gas"
148
- Fuel source for this heater. Default is "electric".
167
+ heating_type : "electric_resistance" | "gas" | "heat_pump"
168
+ Heating technology label. Default is "heat_pump". Does not change
169
+ the model map, which has its own power data.
149
170
  perf_map_dir : str | None
150
171
  Path to directory containing ``maps.json`` and ``pkls/``.
151
172
  Passed through to ``PerformanceMap.from_model_name()``.
@@ -164,7 +185,7 @@ class WaterHeater:
164
185
  ),
165
186
  control_schedule=control_schedule,
166
187
  control_map=control_map,
167
- fuel_type=fuel_type,
188
+ heating_type=heating_type,
168
189
  )
169
190
 
170
191
  # ------------------------------------------------------------------
@@ -242,9 +263,10 @@ class WaterHeater:
242
263
  oat_f: float,
243
264
  outlet_temp_f: float,
244
265
  inlet_temp_f: float | None = None,
245
- ) -> float | None:
266
+ ) -> float:
246
267
  """
247
- Return electrical power input [kW] at current conditions.
268
+ Return actual power input this timestep [kW]: the performance map's
269
+ power if active, 0 if not.
248
270
 
249
271
  Parameters
250
272
  ----------
@@ -256,11 +278,14 @@ class WaterHeater:
256
278
 
257
279
  Returns
258
280
  -------
259
- float | None
281
+ float
282
+ 0.0 when the heater is inactive, has no performance map, or the
283
+ map has no power data.
260
284
  """
261
- if self.performance_map is not None:
262
- return self.performance_map.get_power_in_kw(oat_f, outlet_temp_f, inlet_temp_f)
263
- return None
285
+ if not self._active or self.performance_map is None:
286
+ return 0.0
287
+ kw = self.performance_map.get_power_in_kw(oat_f, outlet_temp_f, inlet_temp_f)
288
+ return kw if kw is not None else 0.0
264
289
 
265
290
  def get_output_kbtuh(
266
291
  self,
@@ -307,33 +332,44 @@ class WaterHeater:
307
332
  raise ValueError(f"No Controls configured for hour {hour_of_day}.")
308
333
  return controls.outlet_temp_f
309
334
 
310
- def update_state(self, storage_tank: StorageTank, hour_of_day: int) -> None:
335
+ def update_state(
336
+ self,
337
+ storage_tank: StorageTank,
338
+ timestep_interval: int,
339
+ interval_min: int = 1,
340
+ ) -> None:
311
341
  """
312
- Look up the active Controls for the given hour, then update
342
+ Look up the active Controls for this timestep's hour, then update
313
343
  active/inactive state based on current tank condition.
314
344
 
345
+ On the first interval after a mode change in the control schedule,
346
+ the heater is turned off before the control check.
315
347
  If the heater is currently ON, check should_turn_off().
316
348
  If the heater is currently OFF, check should_turn_on().
317
349
 
318
350
  Parameters
319
351
  ----------
320
352
  storage_tank : StorageTank
321
- hour_of_day : int
322
- Hour of the day (0-23), used to select the active Controls from
323
- the control schedule.
353
+ timestep_interval : int
354
+ Number of elapsed intervals since the start of the simulation.
355
+ interval_min : int
356
+ Length of each interval [minutes]. Default 1.
324
357
 
325
358
  Raises
326
359
  ------
327
360
  ValueError
328
- If no Controls are configured for the given hour.
361
+ If no Controls are configured for this timestep's hour.
329
362
  """
363
+ hour_of_day = (timestep_interval * interval_min // 60) % 24
330
364
  controls = self.get_controls_for_hour(hour_of_day)
331
365
  if controls is None:
332
366
  raise ValueError(f"No Controls configured for hour {hour_of_day}.")
333
-
367
+
334
368
  # QPL Alignment block : QPL states Upon change of load shift operation, disable all heaters if “on sensor” is warmer than “on temperature”.
335
- # To do this in EcosizerEngine, we simply turn off water heaters at the beginning of a mode change
336
- previous_hour = 23 if hour_of_day == 0 else hour_of_day - 1
369
+ # To do this in EcosizerEngine, we turn off water heaters on the first interval of a mode change.
370
+ # previous_hour is the hour of the previous timestep, so it differs from hour_of_day
371
+ # only on the first interval of an hour (timestep 0 → hour 23).
372
+ previous_hour = ((timestep_interval - 1) * interval_min // 60) % 24
337
373
  if self.control_schedule[hour_of_day] != self.control_schedule[previous_hour]:
338
374
  self.turn_off()
339
375
 
@@ -1632,21 +1632,17 @@ class DHWSystem:
1632
1632
  if self.storage_tank is not None:
1633
1633
  # Update on/off state for each heater based on current tank condition
1634
1634
  for wh in self.water_heaters:
1635
- wh.update_state(self.storage_tank, hour_of_day)
1635
+ wh.update_state(self.storage_tank, timestep_interval, interval_min)
1636
1636
 
1637
1637
  # Apply heating from all active heaters to the tank
1638
1638
  total_kbtuh = sum(
1639
1639
  wh.get_output_kbtuh(oat_f, wh.get_outlet_temp_f(hour_of_day), inlet_temp_f)
1640
1640
  for wh in self.water_heaters
1641
1641
  )
1642
- total_kw: float | None = None
1643
- active_kws = [
1642
+ total_kw = sum(
1644
1643
  wh.get_power_in_kw(oat_f, wh.get_outlet_temp_f(hour_of_day), inlet_temp_f)
1645
1644
  for wh in self.water_heaters
1646
- if wh.is_active()
1647
- ]
1648
- if any(kw is not None for kw in active_kws):
1649
- total_kw = sum(kw or 0.0 for kw in active_kws)
1645
+ )
1650
1646
 
1651
1647
  self.storage_tank.heat(total_kbtuh, interval_min, outlet_temp_f)
1652
1648
  top_temp_f = self.storage_tank.get_temperature_at_fraction(1.0)
@@ -228,6 +228,7 @@ class ParallelLoopSystem(RecircSystem):
228
228
  nominal_capacity_kbtuh=system._minimum_tm_capacity_kbtuh / system.num_tm_heaters,
229
229
  control_schedule=["normal"] * 24,
230
230
  control_map={"normal": tm_controls},
231
+ heating_type="electric_resistance",
231
232
  )
232
233
 
233
234
  return system
@@ -373,7 +374,9 @@ class ParallelLoopSystem(RecircSystem):
373
374
  -------
374
375
  dict
375
376
  Same keys as DHWSystem.simulate_step(). heater_output_kbtuh and
376
- heater_power_in_kw include both primary and TM heater contributions.
377
+ heater_power_in_kw are primary-only; TM heater output and power are
378
+ in tm_heater_output_kbtuh / tm_heater_input_kw. SimulationRun
379
+ combines the power series.
377
380
  """
378
381
  # Run primary system: super() → RecircSystem → DHWSystem
379
382
  step = super().simulate_step(building, timestep_interval, interval_min, mode)
@@ -388,27 +391,19 @@ class ParallelLoopSystem(RecircSystem):
388
391
  self.return_flow_gpm, self.return_temp_f, interval_min
389
392
  )
390
393
 
391
- self.tm_water_heater.update_state(self.tm_storage_tank, hour_of_day)
394
+ self.tm_water_heater.update_state(self.tm_storage_tank, timestep_interval, interval_min)
392
395
 
393
396
  tm_top_temp_f = self.tm_storage_tank.get_temperature_at_fraction(1.0)
394
397
  tm_inlet_temp_f = (self.tm_off_temp_f + self.tm_on_temp_f) / 2.0
395
398
  tm_kbtuh = self.tm_water_heater.get_output_kbtuh(oat_f, tm_top_temp_f, tm_inlet_temp_f) * self.num_tm_heaters
396
- tm_kw_per_unit = (
397
- self.tm_water_heater.get_power_in_kw(oat_f, tm_top_temp_f, tm_inlet_temp_f)
398
- if self.tm_water_heater.is_active()
399
- else None
400
- )
401
- tm_kw = tm_kw_per_unit * self.num_tm_heaters if tm_kw_per_unit is not None else None
399
+ tm_kw = self.tm_water_heater.get_power_in_kw(oat_f, tm_top_temp_f, tm_inlet_temp_f) * self.num_tm_heaters
402
400
  self.tm_storage_tank.heat(tm_kbtuh, interval_min, self.tm_off_temp_f)
403
401
 
404
402
  # ------------------------------------------------------------------
405
- # Merge TM outputs into primary step dict
403
+ # Add TM outputs to the step dict
406
404
  # ------------------------------------------------------------------
407
- # heater_output_kbtuh stays PRIMARY-ONLY (used for gal/hr plot in top chart).
408
- # TM thermal output is tracked separately in tm_heater_output_kbtuh.
409
- # heater_power_in_kw merges both so get_total_energy_kwh() is accurate.
410
- # if tm_kw is not None:
411
- # step["heater_power_in_kw"] = (step["heater_power_in_kw"] or 0.0) + tm_kw
405
+ # heater_output_kbtuh and heater_power_in_kw stay PRIMARY-ONLY. TM output
406
+ # and power are recorded separately; SimulationRun combines the power.
412
407
 
413
408
  # TM panel data (consumed by SimulationRun for the TM subplot)
414
409
  step["tm_tank_temp_f"] = self.tm_storage_tank.get_temperature_at_fraction(0.5)