ecoengine 3.3.1__tar.gz → 3.3.3__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.1 → ecoengine-3.3.3}/PKG-INFO +1 -1
  2. {ecoengine-3.3.1 → ecoengine-3.3.3}/pyproject.toml +1 -1
  3. ecoengine-3.3.3/src/ecoengine/constants/constants.py +18 -0
  4. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/interfaces/EcoBuilder.py +16 -13
  5. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/interfaces/EcosizerEngine.py +72 -4
  6. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/building/UtilityCostTracker.py +21 -0
  7. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/components/heating/PerformanceMap.py +37 -7
  8. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/components/heating/WaterHeater.py +44 -19
  9. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/DHWSystem.py +2 -6
  10. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/recirc_systems/ParallelLoopSystem.py +8 -13
  11. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/recirc_systems/SwingDualFuelSystem.py +9 -16
  12. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/recirc_systems/SwingERTrdOffSystem.py +2 -0
  13. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/recirc_systems/SwingSystem.py +11 -20
  14. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/rtp_systems/MP_RTPInSeriesSystem.py +6 -14
  15. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/rtp_systems/MultiPassRTPSystem.py +1 -7
  16. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/rtp_systems/SP_RTPInParallelSystem.py +8 -9
  17. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/rtp_systems/SP_RTPInSeriesSystem.py +8 -13
  18. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/rtp_systems/SinglePassRTPSystem.py +2 -6
  19. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/simulation/SimulationRun.py +94 -26
  20. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine.egg-info/PKG-INFO +1 -1
  21. ecoengine-3.3.1/src/ecoengine/constants/constants.py +0 -5
  22. {ecoengine-3.3.1 → ecoengine-3.3.3}/README.md +0 -0
  23. {ecoengine-3.3.1 → ecoengine-3.3.3}/setup.cfg +0 -0
  24. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/__init__.py +0 -0
  25. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/constants/__init__.py +0 -0
  26. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/__init__.py +0 -0
  27. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/climate_data/DryBulbTemperatures_ByClimateZone.csv +0 -0
  28. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/climate_data/InletWaterTemperatures_ByClimateZone.csv +0 -0
  29. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/climate_data/WeatherStation_ClimateZone_Lookup.csv +0 -0
  30. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/climate_data/ZipCode_ClimateZone_Lookup.csv +0 -0
  31. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/climate_data/__init__.py +0 -0
  32. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/climate_data/kGperkWh_ByClimateZone.csv +0 -0
  33. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/__init__.py +0 -0
  34. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/apartment.json +0 -0
  35. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/elementary_school.json +0 -0
  36. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/food_service_a.json +0 -0
  37. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/food_service_b.json +0 -0
  38. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/junior_high.json +0 -0
  39. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/mens_dorm.json +0 -0
  40. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/motel.json +0 -0
  41. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/multi_family.json +0 -0
  42. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/nursing_home.json +0 -0
  43. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/office_building.json +0 -0
  44. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/senior_high.json +0 -0
  45. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/load_shapes/womens_dorm.json +0 -0
  46. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/preformanceMaps/__init__.py +0 -0
  47. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/preformanceMaps/maps.json +0 -0
  48. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH3_SP_bounds.pkl +0 -0
  49. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH3_SP_capacity_interpolator.pkl +0 -0
  50. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH3_SP_power_in_interpolator.pkl +0 -0
  51. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH4_MP_bounds.pkl +0 -0
  52. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH4_MP_capacity_interpolator.pkl +0 -0
  53. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/preformanceMaps/pkls/GenericHPWH4_MP_power_in_interpolator.pkl +0 -0
  54. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/data/preformanceMaps/pkls/__init__.py +0 -0
  55. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/interfaces/Simulator.py +0 -0
  56. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/interfaces/__init__.py +0 -0
  57. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/interfaces/system_assembly_utils.py +0 -0
  58. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/__init__.py +0 -0
  59. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/building/Building.py +0 -0
  60. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/building/ClimateZone.py +0 -0
  61. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/building/__init__.py +0 -0
  62. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/components/__init__.py +0 -0
  63. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/components/heating/Controls.py +0 -0
  64. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/components/heating/__init__.py +0 -0
  65. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/components/storage/EnergyTank.py +0 -0
  66. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/components/storage/MixedStorageTank.py +0 -0
  67. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/components/storage/SlugOverlayTank.py +0 -0
  68. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/components/storage/StorageTank.py +0 -0
  69. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/components/storage/StratifiedTank.py +0 -0
  70. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/components/storage/__init__.py +0 -0
  71. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/InstantWHSystem.py +0 -0
  72. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/MPNoRecircSystem.py +0 -0
  73. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/__init__.py +0 -0
  74. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/recirc_systems/RecircSystem.py +0 -0
  75. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/recirc_systems/__init__.py +0 -0
  76. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/rtp_systems/RTPSystem.py +0 -0
  77. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/rtp_systems/__init__.py +0 -0
  78. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/dhwsystems/utils.py +0 -0
  79. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine/objects/simulation/__init__.py +0 -0
  80. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine.egg-info/SOURCES.txt +0 -0
  81. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine.egg-info/dependency_links.txt +0 -0
  82. {ecoengine-3.3.1 → ecoengine-3.3.3}/src/ecoengine.egg-info/requires.txt +0 -0
  83. {ecoengine-3.3.1 → ecoengine-3.3.3}/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.1
3
+ Version: 3.3.3
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.1"
7
+ version = "3.3.3"
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].
@@ -260,6 +260,13 @@ class UtilityCostTracker:
260
260
  )
261
261
 
262
262
  uc = UtilityCostTracker.from_csv("utility_rates.csv")
263
+
264
+ Demand interval
265
+ ---------------
266
+ ``demand_interval_min`` (default 15) is the averaging window for demand
267
+ charges: the billed demand of a period is the highest average kW over any
268
+ ``demand_interval_min``-minute window that lies entirely inside that
269
+ period (see ``SimulationRun.get_max_demand_kw_by_period``).
263
270
  """
264
271
 
265
272
  def __init__(
@@ -272,7 +279,12 @@ class UtilityCostTracker:
272
279
  monthly_base_charge: float,
273
280
  energy_charge_by_hour: list[float] | None = None,
274
281
  include_discount: bool = False,
282
+ demand_interval_min: int = 15,
275
283
  ) -> None:
284
+ if not isinstance(demand_interval_min, int) or isinstance(demand_interval_min, bool) or demand_interval_min <= 0:
285
+ raise ValueError(
286
+ f"demand_interval_min must be a positive integer number of minutes, got {demand_interval_min!r}."
287
+ )
276
288
  self.demand_period_chart = demand_period_chart
277
289
  self.demand_charge_map = demand_charge_map
278
290
  self.energy_charge_map = energy_charge_map
@@ -281,6 +293,7 @@ class UtilityCostTracker:
281
293
  self.monthly_base_charge = monthly_base_charge
282
294
  self.energy_charge_by_hour: list[float] = energy_charge_by_hour or []
283
295
  self.include_discount = include_discount
296
+ self.demand_interval_min = demand_interval_min
284
297
 
285
298
  # ------------------------------------------------------------------
286
299
  # Factory constructors
@@ -303,6 +316,7 @@ class UtilityCostTracker:
303
316
  dscnt_end_hour=None,
304
317
  discnt_demand_charge=None,
305
318
  discnt_energy_charge=None,
319
+ demand_interval_min: int = 15,
306
320
  ) -> UtilityCostTracker:
307
321
  """
308
322
  Build a UtilityCostTracker from rate parameters.
@@ -348,6 +362,8 @@ class UtilityCostTracker:
348
362
  Discount demand charge [$/kW].
349
363
  discnt_energy_charge : float or list[float] or None
350
364
  Discount energy rate [$/kWh].
365
+ demand_interval_min : int
366
+ Averaging window for demand charges [minutes]. Default 15.
351
367
 
352
368
  Returns
353
369
  -------
@@ -414,6 +430,7 @@ class UtilityCostTracker:
414
430
  is_discount_map=is_discount_map,
415
431
  monthly_base_charge=monthly_base_charge,
416
432
  include_discount=include_discount,
433
+ demand_interval_min=demand_interval_min,
417
434
  )
418
435
 
419
436
  @classmethod
@@ -421,6 +438,7 @@ class UtilityCostTracker:
421
438
  cls,
422
439
  csv_path: str | None = None,
423
440
  csv_file: TextIOWrapper | None = None,
441
+ demand_interval_min: int = 15,
424
442
  ) -> UtilityCostTracker:
425
443
  """
426
444
  Build a UtilityCostTracker from an 8760-row CSV file.
@@ -439,6 +457,8 @@ class UtilityCostTracker:
439
457
  ----------
440
458
  csv_path : str or None
441
459
  csv_file : TextIOWrapper or None
460
+ demand_interval_min : int
461
+ Averaging window for demand charges [minutes]. Default 15.
442
462
 
443
463
  Returns
444
464
  -------
@@ -554,6 +574,7 @@ class UtilityCostTracker:
554
574
  is_discount_map=is_discount_map,
555
575
  monthly_base_charge=monthly_base_charge,
556
576
  energy_charge_by_hour=energy_charge_by_hour,
577
+ demand_interval_min=demand_interval_min,
557
578
  )
558
579
 
559
580
  # ------------------------------------------------------------------
@@ -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,
@@ -1639,14 +1639,10 @@ class DHWSystem:
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)
@@ -393,22 +396,14 @@ class ParallelLoopSystem(RecircSystem):
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)