Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
b17aef3
framework changes to allow for non-annual simulation
jaredthomas68 Aug 14, 2026
6f5af1e
include method to handle capacity factor and degradation and replacem…
jaredthomas68 Sep 16, 2026
b150e80
adjust resource api to handle any simulation length and step size
jaredthomas68 Sep 16, 2026
dba1b81
allow pysam solar to function non-annual
jaredthomas68 Sep 17, 2026
7e31cbd
example showing non-annual runs
jaredthomas68 Sep 17, 2026
e5e7391
adjust grid and storage base to work with sub-annual and multi-year s…
jaredthomas68 Sep 17, 2026
1658932
fix example 24 to run with variable time step and simulation length
jaredthomas68 Sep 18, 2026
66a8dbc
merge from develop
jaredthomas68 Sep 18, 2026
7956d7c
fix finance tests after merging from develop
jaredthomas68 Sep 21, 2026
35d00d0
update pysam solar tests for back merge of develop
jaredthomas68 Sep 21, 2026
15d71a1
Merge branch 'develop' into nonannual
jaredthomas68 Sep 21, 2026
3c118fb
correct docstrings in PerformanceModelClass so docs build
jaredthomas68 Sep 21, 2026
d2d8d86
make test_time_tools.py work with pandas 2.0 and 3.0+
jaredthomas68 Sep 21, 2026
708d268
don't overwrite resource_year in config
jaredthomas68 Sep 21, 2026
97f304a
backmerge from develop
jaredthomas68 Sep 24, 2026
54c5798
resolve conflicts
jaredthomas68 Oct 2, 2026
9485b75
Merge branch 'nonannual-resource' into nonannual
jaredthomas68 Oct 5, 2026
4ad1c4d
update example 24 with new non-annual resource work
jaredthomas68 Oct 5, 2026
fe3cdc8
add resource resampling warning and make resampling non-automatic
jaredthomas68 Oct 5, 2026
441b4c1
adjust comment wording
jaredthomas68 Oct 5, 2026
b8ee6b5
complete merge
jaredthomas68 Oct 8, 2026
6ca4100
Merge branch 'nonannual-framework' into nonannual
jaredthomas68 Oct 8, 2026
1978b58
Merge branch 'nonannual-framework' into nonannual
jaredthomas68 Oct 9, 2026
492e4d7
update sim duration bounds for example models and add non-annual inte…
jaredthomas68 Oct 9, 2026
98a08ae
Merge branch 'nonannual-framework' into nonannual
jaredthomas68 Oct 9, 2026
39eda1c
Merge branch 'nonannual-framework' into nonannual
jaredthomas68 Oct 12, 2026
9b69409
update changelog
jaredthomas68 Oct 12, 2026
89125e3
resolve conflict
jaredthomas68 Oct 12, 2026
996a094
Merge branch 'nonannual-framework' into nonannual
jaredthomas68 Oct 13, 2026
1672b12
merge
jaredthomas68 Oct 13, 2026
672418c
Merge branch 'nonannual-framework' into nonannual
jaredthomas68 Oct 14, 2026
ec5c8f3
merge
jaredthomas68 Oct 14, 2026
3be72ac
fix typo
jaredthomas68 Oct 14, 2026
2bd9969
fix typo
jaredthomas68 Oct 14, 2026
8348cd5
Merge branch 'nonannual-framework' into nonannual
jaredthomas68 Oct 14, 2026
37fada0
Merge branch 'nonannual-framework' into nonannual
jaredthomas68 Oct 14, 2026
9d89bc1
Merge branch 'nonannual-framework' into nonannual
jaredthomas68 Oct 15, 2026
ee6c40d
merge
jaredthomas68 Oct 16, 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 @@ -6,6 +6,7 @@
- Updated `make_price_breakdown()` and `run_profast()` in `profast_tools.py` so that fixed cost items and feedstock cost items are properly labelled. [PR 930](https://github.com/NatLabRockies/H2Integrate/pull/930)
- Add data resampling capabilities to resource models via pandas methods. [PR 933](https://github.com/NatLabRockies/H2Integrate/pull/933)
- Add core framework support for non-annual simulation durations, including annual capacity-factor and replacement-count projections across the plant life. Leap-day-inclusive resources are rejected in full H2I runs until framework-wide calendar alignment is supported. [PR 934](https://github.com/NatLabRockies/H2Integrate/pull/934)
- Add example for running with simulation lengths less than or greater than a year and with timesteps shorter than and longer than an hour. [PR 936](https://github.com/NatLabRockies/H2Integrate/pull/936)

## 0.10.0 [October 12, 2026]

Expand Down
9 changes: 6 additions & 3 deletions examples/24_solar_battery_grid/plant_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ name: plant_config
description: This plant is located in Texas
sites:
site:
latitude: 32.34
longitude: -98.27
latitude: 34.22
longitude: -102.75
resources:
solar_resource:
resource_model: GOESAggregatedSolarAPI
resource_parameters:
# Defaults for a single-year run; the runner overrides these per variant to
# demonstrate sub-annual, annual, and multi-year resource handling.
resource_year: 2013
resource_filename: 30.6617_-101.7096_psmv3_60_2013.csv
resource_filename: 34.22_-102.75_2013_goes_aggregated_v4_60min_utc_tz.csv
# array of arrays containing left-to-right technology
# interconnections; can support bidirectional connections
# with the reverse definition.
Expand Down Expand Up @@ -38,6 +40,7 @@ site_to_tech_connections:
plant:
plant_life: 30
simulation:
dt: 3600
n_timesteps: 8760
finance_parameters:
finance_groups:
Expand Down
153 changes: 147 additions & 6 deletions examples/24_solar_battery_grid/run_solar_battery_grid.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,152 @@
import copy

from h2integrate import H2IntegrateModel
from h2integrate.core.file_utils import load_yaml


top_level_config = load_yaml("solar_battery_grid.yaml")
base_driver_config = load_yaml(top_level_config["driver_config"])
base_tech_config = load_yaml(top_level_config["technology_config"])
base_plant_config = load_yaml(top_level_config["plant_config"])

variants = {
"half_year": {
"dt": 900, # 15 minutes
"n_timesteps": 17520,
# One native year, upsampled from 60-min to 15-min and clipped to half a year.
"resource": {
"resource_year": 2013,
"resource_filename": "34.22_-102.75_2013_goes_aggregated_v4_60min_utc_tz.csv",
"upsample_method": "time",
},
},
"annual": {
"dt": 3600, # 1 hour
"n_timesteps": 8760,
"resource": {
"resource_year": 2013,
"resource_filename": "34.22_-102.75_2013_goes_aggregated_v4_60min_utc_tz.csv",
},
},
"two_year": {
"dt": 10800, # 3 hours
"n_timesteps": 5840,
# Two native years, downsampled from 60-min to 3-hour.
"resource": {
"resource_year": 2012,
"resource_year_order": [2012, 2013],
"downsample_method": "mean",
},
},
}


def _fmt(value, digits=2):
return f"{float(value):,.{digits}f}"


results = []

for variant_name, variant in variants.items():
driver_config = copy.deepcopy(base_driver_config)
plant_config = copy.deepcopy(base_plant_config)
tech_config = copy.deepcopy(base_tech_config)

driver_config.setdefault("general", {})["create_om_reports"] = False
plant_config["plant"]["simulation"]["dt"] = variant["dt"]
plant_config["plant"]["simulation"]["n_timesteps"] = variant["n_timesteps"]

# Replace the solar resource settings so each variant pulls the right number of years.
resource_params = plant_config["sites"]["site"]["resources"]["solar_resource"][
"resource_parameters"
]
resource_params.clear()
resource_params.update(variant["resource"])

h2i = H2IntegrateModel(
{
"name": f"{top_level_config['name']}_{variant_name}",
"system_summary": top_level_config["system_summary"],
"driver_config": driver_config,
"technology_config": tech_config,
"plant_config": plant_config,
}
)

print(f"Running variant: {variant_name}")
h2i.run()

total_load_served = h2i.prob.get_val(
"electrical_load_demand.total_electricity_produced", units="kW*h"
)[0]
total_solar = h2i.prob.get_val("solar.total_electricity_produced", units="kW*h")[0]
total_grid_buy = h2i.prob.get_val("grid_buy.total_electricity_produced", units="kW*h")[0]
battery_cf = h2i.prob.get_val("battery.standard_capacity_factor", units="percent")[0]
solar_cf = h2i.prob.get_val("solar.capacity_factor", units="percent")[0]
lcoe = h2i.prob.get_val("finance_subgroup_renewables.LCOE")[0]
percent_load_missed = h2i.prob.get_val("electrical_load_demand.percent_load_missed")[0]
curtailment_percent = h2i.prob.get_val("electrical_load_demand.curtailment_percent")[0]

results.append(
{
"variant": variant_name,
"years": variant["n_timesteps"] * variant["dt"] / 31_536_000,
"dt_hours": variant["dt"] / 3600,
"n_timesteps": variant["n_timesteps"],
"lcoe": lcoe,
"load_served_mwh": total_load_served / 1000,
"solar_mwh": total_solar / 1000,
"grid_buy_mwh": total_grid_buy / 1000,
"solar_cf_pct": solar_cf,
"battery_cf_pct": battery_cf,
"load_missed_pct": percent_load_missed,
"curtailment_pct": curtailment_percent,
}
)


header = (
"variant",
"sim years",
"dt (h)",
"n_steps",
"LCOE ($/kWh)",
"load served (MWh)",
"solar gen (MWh)",
"grid buy (MWh)",
"solar CF (%)",
"battery std CF (%)",
"load missed (%)",
"curtailment (%)",
)
rows = [header]
rows.extend(
(
result["variant"],
_fmt(result["years"], 2),
_fmt(result["dt_hours"], 2),
str(result["n_timesteps"]),
_fmt(result["lcoe"], 4),
_fmt(result["load_served_mwh"], 2),
_fmt(result["solar_mwh"], 2),
_fmt(result["grid_buy_mwh"], 2),
_fmt(result["solar_cf_pct"], 2),
_fmt(result["battery_cf_pct"], 2),
_fmt(result["load_missed_pct"], 2),
_fmt(result["curtailment_pct"], 2),
)
for result in results
)

col_widths = [max(len(row[i]) for row in rows) for i in range(len(header))]


# Create an H2I model
h2i = H2IntegrateModel("solar_battery_grid.yaml")
def _print_row(row):
print(" ".join(value.ljust(col_widths[i]) for i, value in enumerate(row)))

# Run the model
h2i.run()

# Post-process the results
h2i.post_process()
print("\nSummary of example 24 variants")
_print_row(header)
_print_row(tuple("-" * width for width in col_widths))
for row in rows[1:]:
_print_row(row)
65 changes: 62 additions & 3 deletions examples/test/test_all_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -2286,6 +2286,7 @@ def test_24_solar_battery_grid_example(subtests, temp_copy_of_example):
electricity_sold = sum(model.prob.get_val("grid_sell.electricity_in", units="kW"))

solar_aep = sum(model.prob.get_val("solar.electricity_out", units="kW"))
battery_net_energy = sum(model.prob.get_val("battery.electricity_out", units="kW"))

with subtests.test("Behavior check battery missed load is electricity bought"):
assert pytest.approx(battery_missed_load, rel=1e-6) == electricity_bought
Expand All @@ -2294,13 +2295,71 @@ def test_24_solar_battery_grid_example(subtests, temp_copy_of_example):
assert pytest.approx(battery_curtailed, rel=1e-6) == electricity_sold

with subtests.test(
"Behavior check energy for financials; include solar aep and electricity bought"
"Behavior check energy for financials includes solar, grid, and battery net output"
):
assert pytest.approx(energy_for_financials, rel=1e-6) == (solar_aep + electricity_bought)
assert pytest.approx(energy_for_financials, rel=1e-6) == (
solar_aep + electricity_bought + battery_net_energy
)

with subtests.test("Value check on LCOE"):
lcoe = model.prob.get_val("finance_subgroup_renewables.LCOE", units="USD/(MW*h)")[0]
assert pytest.approx(lcoe, rel=1e-4) == 91.7057887
assert pytest.approx(lcoe, rel=1e-4) == 89.28084320265795


@pytest.mark.integration
@pytest.mark.parametrize(
"example_folder,resource_example_folder", [("24_solar_battery_grid", "11_hybrid_energy_plant/")]
)
def test_24_solar_battery_grid_non_annual(subtests, temp_copy_of_example):
# Regression guard: the models used by example 24 must declare non-annual
# `_simulation_duration_bounds`, so a sub-annual horizon runs without being rejected by
# `check_model_simulation_duration`.
import copy

example_folder = temp_copy_of_example
top_level_config = load_yaml(example_folder / "solar_battery_grid.yaml")
driver_config = load_yaml(example_folder / top_level_config["driver_config"])
tech_config = load_yaml(example_folder / top_level_config["technology_config"])
plant_config = load_yaml(example_folder / top_level_config["plant_config"])

driver_config.setdefault("general", {})["create_om_reports"] = False
# Half-year horizon at 15-minute steps (one native year upsampled to 15-min, clipped).
plant_config["plant"]["simulation"]["dt"] = 900
plant_config["plant"]["simulation"]["n_timesteps"] = 17520
resource_params = plant_config["sites"]["site"]["resources"]["solar_resource"][
"resource_parameters"
]
resource_params.clear()
resource_params.update(
{
"resource_year": 2013,
"resource_filename": "34.22_-102.75_2013_goes_aggregated_v4_60min_utc_tz.csv",
"upsample_method": "time",
}
)

model = H2IntegrateModel(
{
"name": "solar_battery_grid_half_year",
"system_summary": top_level_config["system_summary"],
"driver_config": driver_config,
"technology_config": copy.deepcopy(tech_config),
"plant_config": plant_config,
}
)
model.run()

plant_life = int(plant_config["plant"]["plant_life"])

with subtests.test("Capacity factor is projected across the full plant life"):
solar_cf = model.prob.get_val("solar.capacity_factor", units="unitless")
assert solar_cf.shape == (plant_life,)

with subtests.test("Sub-annual load served is less than a comparable annual run"):
load_served = model.prob.get_val(
"electrical_load_demand.total_electricity_produced", units="kW*h"
)[0]
assert load_served > 0.0


@pytest.mark.integration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import numpy as np
from attrs import define
from openmdao.utils import units as om_units

from h2integrate.core.utilities import merge_shared_inputs
from h2integrate.core.supported_models import register
Expand Down Expand Up @@ -47,9 +48,11 @@ class DemandOpenLoopStorageController(OpenLoopControlBase):
"""

_time_step_bounds = (
3600,
3600,
1, # 1 second
86400, # 24 hours
) # (min, max) time step lengths (in seconds) compatible with this model
# (min, max) permitted simulation duration in years
_simulation_duration_bounds = (0.0, float("inf"))

def setup(self):
self.config = DemandOpenLoopStorageControllerConfig.from_dict(
Expand Down Expand Up @@ -84,6 +87,13 @@ def setup(self):
desc="Storage discharge rate",
)

self.dt = int(self.options["plant_config"]["plant"]["simulation"]["dt"])
self.dt_amount = om_units.convert_units(
self.dt,
"s",
f"({self.config.commodity_amount_units})/({self.config.commodity_rate_units})",
)

def compute(self, inputs, outputs):
"""
Compute storage state of charge (SOC), delivered output, curtailment, and unmet
Expand Down Expand Up @@ -153,9 +163,12 @@ def compute(self, inputs, outputs):
# Get the input flow at the current time step
input_flow = inputs[f"{commodity}_in"][t]

# Calculate the available charge/discharge capacity
available_charge = float((soc_max - soc) * max_capacity)
available_discharge = float((soc - soc_min) * max_capacity)
# Available headroom is naturally an amount. Convert it to a rate using the
# timestep width so it can be compared against the charge/discharge limits.
available_charge_amount = float((soc_max - soc) * max_capacity)
available_discharge_amount = float((soc - soc_min) * max_capacity)
available_charge_rate = available_charge_amount / self.dt_amount
available_discharge_rate = available_discharge_amount / self.dt_amount

# Determine the output flow based on demand_t and SOC
if demand_t > input_flow:
Expand All @@ -165,10 +178,12 @@ def compute(self, inputs, outputs):
# `discharge` is as seen by the storage, but `max_discharge_rate` is as observed
# outside the storage
discharge = min(
discharge_needed, available_discharge, max_discharge_rate / discharge_eff
discharge_needed,
available_discharge_rate,
max_discharge_rate / discharge_eff,
)

soc -= discharge / max_capacity # soc is a ratio with value between 0 and 1
soc -= (discharge * self.dt_amount) / max_capacity
# output is as observed outside the storage, so we need to adjust `discharge` by
# applying `discharge_efficiency`.
combined_output_array[t] = input_flow + discharge * discharge_eff
Expand All @@ -182,9 +197,10 @@ def compute(self, inputs, outputs):
# seen outside the storage so we need to adjust `available_charge` outside the
# storage view and the final result back into the storage view.
charge = (
min(unused_input, available_charge / charge_eff, max_charge_rate) * charge_eff
min(unused_input, available_charge_rate / charge_eff, max_charge_rate)
* charge_eff
)
soc += charge / max_capacity # soc is a ratio with value between 0 and 1
soc += (charge * self.dt_amount) / max_capacity
combined_output_array[t] = demand_t
set_point_array[t] = -1 * charge / charge_eff

Expand Down
Loading