Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7d61a1c
add draft for variable timestep framework
jaredthomas68 Apr 3, 2026
3d44da6
Merge branch 'develop' into variabledt
jaredthomas68 Apr 7, 2026
c36dfb8
Changed error wording
johnjasa Apr 9, 2026
852bc7f
Merge branch 'develop' into variabledt
johnjasa Apr 9, 2026
f4b1fc8
Merge branch 'develop' into variabledt
jaredthomas68 Apr 10, 2026
352ed79
Merge remote-tracking branch 'myfork/variabledt' into variabledt
jaredthomas68 Apr 10, 2026
0774f31
switch bounds to tuple
jaredthomas68 Apr 10, 2026
1adab8c
minor test correction
jaredthomas68 Apr 10, 2026
c74968e
include time-series generation functions
jaredthomas68 Apr 10, 2026
935ef26
add tests for variable dt and update simulation length check for non-…
jaredthomas68 Apr 10, 2026
58ebc70
Merge branch 'develop' into variabledt
jaredthomas68 Apr 10, 2026
82e8bff
update docs
jaredthomas68 Apr 10, 2026
ee2a46f
update changelog
jaredthomas68 Apr 10, 2026
b9dc167
update docs and doc strings
jaredthomas68 Apr 10, 2026
e866dd6
restore develop version of utilities.py
jaredthomas68 Apr 10, 2026
9eeec64
update dt bounds error
jaredthomas68 Apr 11, 2026
f4e8701
Merge branch 'develop' into variabledt
johnjasa Apr 11, 2026
d358b92
Merge branch 'develop' into variabledt
kbrunik Apr 11, 2026
edd178b
Apply suggestions from code review
johnjasa Apr 11, 2026
ac3ddcc
Fixing tests
johnjasa Apr 11, 2026
7334507
Adding time step bounds to all models
johnjasa Apr 11, 2026
22b6cf7
Adding time step bounds to the combiner/splitter/transporters
johnjasa Apr 11, 2026
4b50db0
Updates for tests
johnjasa Apr 11, 2026
467caea
Adding time step bounds to the custom paper mill performance model
johnjasa Apr 11, 2026
5bc9b5e
Added time step bounds to paper mill cost
johnjasa Apr 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
- The `FlexibleDemandOpenLoopConverterController` has been renamed to `FlexibleDemandComponent`
- The `DemandOpenLoopConverterController` has been renamed to `GenericDemandComponent`
- Modified CI setup so Windows is temporarily disabled and also so unit, regression, and integration tests are run in separate jobs to speed up testing and provide more information on test failures. [PR 668](https://github.com/NatLabRockies/H2Integrate/pull/668)
- Added infrastructure for running models with non-hourly time steps via a class attribute `_time_step_bounds` and sets new time step bounds of 5-minutes to 1-hour for the grid components. [PR 653](https://github.com/NatLabRockies/H2Integrate/pull/653)

## 0.7.2 [April 9, 2026]

Expand Down
13 changes: 13 additions & 0 deletions docs/developer_guide/adding_a_new_technology.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,19 @@ In the middle-ground case where the models might use a shared object that is com
This would require additional logic to first check if the cached object exists and is valid before attempting to load it, otherwise it would create the object from scratch.
There is an example of this in the `hopp_wrapper.py` file.

### Specifying allowable time step for your model
If you want your model to run with time steps other than 1 hour (3600 s), then you must specify the `_time_step_bounds` as a class attribute in each of your model classes. To run a simulation with a given time step, all models in the plant must be compatible with the desired time step.

```python
class ECOElectrolyzerPerformanceModel(ElectrolyzerPerformanceBaseClass):
"""
An OpenMDAO component that wraps the PEM electrolyzer model.
Takes electricity input and outputs hydrogen and oxygen generation rates.
"""

# (min, max) time step lengths (in seconds) compatible with this model
_time_step_bounds = (300, 3600) # (5-min, 1-hour)
```

### Other cases

Expand Down
8 changes: 7 additions & 1 deletion docs/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,17 @@ H2Integrate was previously known as GreenHEART. The name was updated to H2Integr

## How does H2Integrate work?

H2Integrate models energy systems on a yearly basis using hourly timesteps (i.e., 8760 operational data points across a year).
H2Integrate typically models energy systems on a yearly basis using hourly timesteps (i.e., 8760 operational data points across a year).
Results from these simulations are then processed across the project's lifecycle to provide insights into the system's performance, costs, and financial viability.
Depending on the models used and the size of the system, H2Integrate can simulate systems ranging from the kW to GW scale in seconds on a personal computer.
Additionally, H2Integrate tracks the flow of electricity, molecules (e.g., hydrogen, ammonia, methanol), and other products (e.g., steel) between different technologies in the energy system.

```{note}
Some models are now able to operate with non-hourly time steps.
Appropriate time step bounds are included as class attributes when non-hourly time steps are permitted.
Check individual model docs and definitions for time step bounds for individual models. All models in a given simulation must be compatible with the specified time step.
```

For each technology there are 4 different types of models: control, performance, cost, and finance. These model categories allow for modular pieces to be brought in or re-used throughout H2Integrate, as well as ease of development and organization. Note that the only required models for a technology are performance and cost, while control and finance are optional. The figure below shows these four categories and some of the technologies included in H2Integrate. For a full list of models available, please see [Model Overview](user_guide/model_overview.md).
![A representation of a single technology model in H2Integrate](tech-model.png)

Expand Down
4 changes: 4 additions & 0 deletions docs/technology_models/grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,7 @@ The **revenue** of selling electricity to the grid is represented as a variable
```{note}
If you're using a price-maker financial model (e.g., calculating the LCOE) and selling all of the electricity to the grid, then the `electricity_sell_price` should most likely be set to 0. since you want to know the breakeven price of selling that electricity.
```

```{note}
The grid components are currently compatible with 5-minute (300-second) to 1-hour (3600-second) time steps.
```
4 changes: 4 additions & 0 deletions examples/06_custom_tech/user_defined_model/paper_mill.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class PaperMillConfig(BaseConfig):


class PaperMillPerformance(om.ExplicitComponent):
_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def initialize(self):
self.options.declare("driver_config", types=dict)
self.options.declare("plant_config", types=dict)
Expand Down Expand Up @@ -53,6 +55,8 @@ class PaperMillCostConfig(CostModelBaseConfig):


class PaperMillCost(CostModelBaseClass):
_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def setup(self):
self.config = PaperMillCostConfig.from_dict(
merge_shared_inputs(self.options["tech_config"]["model_inputs"], "cost")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@


class PyomoDispatchGenericConverter(PyomoRuleBaseClass):
_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def setup(self):
self.config = PyomoRuleBaseConfig.from_dict(
merge_shared_inputs(self.options["tech_config"]["model_inputs"], "dispatch_rule"),
Expand Down
2 changes: 2 additions & 0 deletions h2integrate/control/control_rules/pyomo_rule_baseclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class PyomoRuleBaseConfig(BaseConfig):


class PyomoRuleBaseClass(om.ExplicitComponent):
_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def initialize(self):
self.options.declare("driver_config", types=dict)
self.options.declare("plant_config", types=dict)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class PyomoStorageRuleBaseConfig(PyomoRuleBaseConfig):
class PyomoRuleStorageBaseclass(PyomoRuleBaseClass):
"""Base class defining Pyomo rules for generic commodity storage components."""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def setup(self):
self.config = PyomoStorageRuleBaseConfig.from_dict(
merge_shared_inputs(self.options["tech_config"]["model_inputs"], "dispatch_rule"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ class HeuristicLoadFollowingController(PyomoControllerBaseClass):

"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def setup(self):
"""Initialize the heuristic load-following controller."""
self.config = HeuristicLoadFollowingControllerConfig.from_dict(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ class OptimizedDispatchController(PyomoControllerBaseClass):

"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def setup(self):
"""Initialize the optimized dispatch controller."""
self.config = OptimizedDispatchControllerConfig.from_dict(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ def __attrs_post_init__(self):


class PyomoControllerBaseClass(om.ExplicitComponent):
_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def initialize(self):
"""
Declare options for the component. See "Attributes" section in class doc strings for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ class DemandOpenLoopStorageController(StorageOpenLoopControlBase):
commodity to charge, discharge, or curtail at each time step.
"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def setup(self):
self.config = DemandOpenLoopStorageControllerConfig.from_dict(
merge_shared_inputs(self.options["tech_config"]["model_inputs"], "control"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class StorageOpenLoopControlBase(om.ExplicitComponent):
dispatch command profile.
"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def initialize(self):
self.options.declare("driver_config", types=dict)
self.options.declare("plant_config", types=dict)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ class SimpleStorageOpenLoopController(StorageOpenLoopControlBase):
uncontrolled frameworks.
"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def setup(self):
self.config = SimpleStorageOpenLoopControllerConfig.from_dict(
merge_shared_inputs(self.options["tech_config"]["model_inputs"], "control"),
Expand Down
4 changes: 4 additions & 0 deletions h2integrate/converters/ammonia/ammonia_synloop.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ class AmmoniaSynLoopPerformanceModel(ResizeablePerformanceModelBaseClass):
conversion efficiency up to the limiting reagent or energy input.
"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def initialize(self):
super().initialize()
self.commodity = "ammonia"
Expand Down Expand Up @@ -423,6 +425,8 @@ class AmmoniaSynLoopCostModel(CostModelBaseClass):
Annual maintenance cost
"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def setup(self):
target_cost_year = self.options["plant_config"]["finance_parameters"][
"cost_adjustment_parameters"
Expand Down
4 changes: 4 additions & 0 deletions h2integrate/converters/ammonia/simple_ammonia_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class SimpleAmmoniaPerformanceModel(PerformanceModelBaseClass):
Computes annual ammonia production based on plant capacity and capacity factor.
"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def initialize(self):
super().initialize()
self.commodity = "ammonia"
Expand Down Expand Up @@ -108,6 +110,8 @@ class SimpleAmmoniaCostModel(CostModelBaseClass):
Includes CapEx, OpEx, and byproduct credits, and exposes all detailed cost outputs.
"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def setup(self):
self.config = AmmoniaCostModelConfig.from_dict(
merge_shared_inputs(self.options["tech_config"]["model_inputs"], "cost"),
Expand Down
4 changes: 4 additions & 0 deletions h2integrate/converters/co2/marine/direct_ocean_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ class DOCPerformanceModel(PerformanceModelBaseClass):
An OpenMDAO component for modeling the performance of a Direct Ocean Capture (DOC) plant.
"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def initialize(self):
super().initialize()
self.commodity = "co2"
Expand Down Expand Up @@ -153,6 +155,8 @@ class DOCCostModel(CostModelBaseClass):
direct ocean capture (DOC) system.
"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def initialize(self):
super().initialize()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ class OAEPerformanceConfig(BaseConfig):
class OAEPerformanceModel(PerformanceModelBaseClass):
"""OpenMDAO component for modeling Ocean Alkalinity Enhancement (OAE) performance."""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def initialize(self):
super().initialize()
self.commodity = "co2"
Expand Down Expand Up @@ -255,6 +257,8 @@ class OAECostModel(CostModelBaseClass):
ocean alkalinity enhancement (OAE) system.
"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def initialize(self):
super().initialize()

Expand Down Expand Up @@ -347,6 +351,8 @@ class OAECostAndFinancialModel(CostModelBaseClass):
- Carbon Credit Value
"""

_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def initialize(self):
super().initialize()

Expand Down
2 changes: 2 additions & 0 deletions h2integrate/converters/generic_converter_cost.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ def __attrs_post_init__(self):


class GenericConverterCostModel(CostModelBaseClass):
_time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model

def setup(self):
self.config = GenericConverterCostConfig.from_dict(
merge_shared_inputs(self.options["tech_config"]["model_inputs"], "cost"),
Expand Down
14 changes: 10 additions & 4 deletions h2integrate/converters/grid/grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class GridPerformanceModel(PerformanceModelBaseClass):
different grid connection points (for example, one for buying upstream and
another for selling downstream).

This model is compatible with time steps ranging from 5-minutes to 1-hour.

Inputs
interconnection_size (float): Maximum power capacity for grid connection (kW).
electricity_in (array): Power flowing into the grid (selling) (kW).
Expand All @@ -45,6 +47,8 @@ class GridPerformanceModel(PerformanceModelBaseClass):
electricity_out (array): Power flowing out of the grid (buying) (kW).
"""

_time_step_bounds = (300, 3600) # (min, max) time step lengths compatible with this model

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like how you've added these attributes. I could see a similar thing perhaps being implemented in the future for simulation length. I'd be curious what your (and other people's thoughts) would be on whether these should be two separate variables (like you have) vs one tuple like this instead:

_time_step_bounds = (300, 3600)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another question - why can't this have a min timestep of 1 sec? Why 300?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think making the min 300 seconds for now makes a lot of sense, since that's the smallest resource step that is available and also the smallest grid price data resolution. I think that letting the time step be 1 second is not realistic for the steady state models we have in H2I. This is something I feel pretty strongly about, but what are others' opinions about this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @elenya-grant. Also, I like the idea of using a tuple.

I agree with @genevievestarke on the minimum time step, at least for now. The 5-minute resource data floor, and the fact we are not modeling any transient behavior, is why I choose to limit the grid to 5-minute minimum time steps.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the only reason to not use timestep bounds would be so one or the other could be left out. Realistically, I think there will always be upper and lower limits, so I think it will be fine to use a tuple.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I switched to using a tuple

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if in the more general implementation for the framework--like in h2integrate_model.py and the utilities files that house dt methods (not the already include converter/storage models)--we can allow dt to be less than 300 because I don't see a reason we couldn't theoretically allow users to put a signal of their choice in at a 1 second time resolution and use the other h2i capabilities such as the financial modeling with finer resolution performance data.

def initialize(self):
super().initialize()
self.commodity = "electricity"
Expand Down Expand Up @@ -174,10 +178,12 @@ class GridCostModel(CostModelBaseClass):
- Revenue from electricity sales (sell mode)
- Support for time-varying electricity prices

Note: Although the electricity units are in kW and the prices are in USD/kWh,
this model assumes that each timestep represents 1 hour.
This model is compatible with time steps ranging from 5-minutes to 1-hour.

"""

_time_step_bounds = (300, 3600) # (min, max) time step lengths compatible with this model

def setup(self):
self.config = GridCostModelConfig.from_dict(
merge_shared_inputs(self.options["tech_config"]["model_inputs"], "cost"),
Expand Down Expand Up @@ -277,13 +283,13 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs):
electricity_out = inputs["electricity_out"]
buy_price = inputs["electricity_buy_price"]
# Buying costs money (positive VarOpEx)
varopex += np.sum(electricity_out * buy_price)
varopex += np.sum((self.dt / 3600) * electricity_out * buy_price)

# Add selling revenue if sell price is configured
# electricity_sold represents power flowing INTO grid (selling)
if self.config.electricity_sell_price is not None:
sell_price = inputs["electricity_sell_price"]
# Selling generates revenue (negative VarOpEx)
varopex -= np.sum(inputs["electricity_sold"] * sell_price)
varopex -= np.sum((self.dt / 3600) * inputs["electricity_sold"] * sell_price)

outputs["VarOpEx"] = varopex
Loading
Loading