Skip to content

Demand Response Optimization for Battery Energy Storage#679

Merged
johnjasa merged 61 commits intoNatLabRockies:developfrom
vijay092:peakload-optimized
May 8, 2026
Merged

Demand Response Optimization for Battery Energy Storage#679
johnjasa merged 61 commits intoNatLabRockies:developfrom
vijay092:peakload-optimized

Conversation

@vijay092
Copy link
Copy Markdown
Collaborator

@vijay092 vijay092 commented Apr 15, 2026

Demand Response Optimization for Battery Energy Storage (Stage 1)

This PR introduces a Pyomo-based formulation for demand response, which will be implemented in two stages.

As the first stage, this work implements a rolling horizon optimization for battery operations. The battery dispatch logic is based on a pre-defined signal, such as LMP, load, or a combination of both. This is the G&T level dispatch signal for demand response. The next stage will implement the peak load management logic.

Section 1: Type of Contribution

  • Feature Enhancement
    • Framework
    • New Model
    • Updated Model
    • Tools/Utilities
    • Other (please describe):
  • Bug Fix
  • Documentation Update
  • CI Changes
  • Other (please describe):

Section 2: Draft PR Checklist

  • Open draft PR
  • Describe the feature that will be added
  • Fill out TODO list steps
  • Describe requested feedback from reviewers on draft PR
  • Complete Section 7: New Model Checklist (if applicable)

TODO:

  • Add unit tests
  • Add documentation

Type of Reviewer Feedback Requested (on Draft PR)

Structural feedback: Is this in the right place?

Implementation feedback: I used the same style as Gen's pyomo implementation. Appreciate feedback here.

Other feedback:

Section 3: General PR Checklist

  • PR description thoroughly describes the new feature, bug fix, etc.
  • Added tests for new functionality or bug fixes
  • Tests pass (If not, and this is expected, please elaborate in the Section 6: Test Results)
  • Documentation
    • Docstrings are up-to-date
    • Related docs/ files are up-to-date, or added when necessary
    • Documentation has been rebuilt successfully
    • Examples have been updated (if applicable)
  • CHANGELOG.md
    • At least one complete sentence has been provided to describe the changes made in this PR
    • After the above, a hyperlink has been provided to the PR using the following format:
      "A complete thought. [PR XYZ]((https://github.com/NatLabRockies/H2Integrate/pull/XYZ)", where
      XYZ should be replaced with the actual number.

Section 3: Related Issues

Section 4: Impacted Areas of the Software

Section 4.1: New Files

  • Main Implementation: h2integrate/control/control_strategies/storage/plm_optimized_storage_controller.py

  • Usage Example: examples/34_plm_optimized_dispatch

Section 4.2: Modified Files

  • h2integrate/core/supported_models.py
    • Added the new model

Section 5: Additional Supporting Information

Section 6: Test Results, if applicable

Section 7 (Optional): New Model Checklist

  • Model Structure:
    • Follows established naming conventions outlined in docs/developer_guide/coding_guidelines.md
    • Used attrs class to define the Config to load in attributes for the model
      • If applicable: inherit from BaseConfig or CostModelBaseConfig
    • Added: initialize() method, setup() method, compute() method
      • If applicable: inherit from CostModelBaseClass
  • Integration: Model has been properly integrated into H2Integrate
    • Added to supported_models.py
    • If a new commodity_type is added, update create_financial_model in h2integrate_model.py
  • Tests: Unit tests have been added for the new model
    • Pytest-style unit tests
    • Unit tests are in a "test" folder within the folder a new model was added to
    • If applicable add integration tests
  • Example: If applicable, a working example demonstrating the new model has been created
    • Input file comments
    • Run file comments
    • Example has been tested and runs successfully in test_all_examples.py
  • Documentation:
    • Write docstrings using the Google style
    • Model added to the main models list in docs/user_guide/model_overview.md
      • Model documentation page added to the appropriate docs/ section
      • <model_name>.md is added to the _toc.yml

Copy link
Copy Markdown
Collaborator

@elenya-grant elenya-grant left a comment

Choose a reason for hiding this comment

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

Hi Sanjana! Just putting a few high-level comments now - but I will do a deeper dive shortly! I love the thorough testing of PLMOptimizedControllerConfig and PLMOptimizedStorageController! The docstrings also look awesome! Besides the comments and questions that I left, other high-level things I'd like to see included before this is merged in are:

  1. integration tests of the controller with the StoragePerformanceModel - similar to the tests in h2integrate/control/control_strategies/storage/test/test_optimal_controllers.py
  2. a test for the example you added in examples/test/test_all_examples.py (this can be a pretty basic test, but it'll be good to ensure that the example you added is updated and runs without error as H2I is further developed)

Truly awesome work! Please message or call me if you want to discuss any of the comments I left! I will do another review that focuses on the logic within the controller - but wanted to get these initial questions and comments to you early on!

performance_incentive (float)
Incentive revenue ($/kW per dispatch hour).
n_max_events (int)
Maximum discharge events per control window. Default 10.
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.

great doc-strings! These are so helpful!

peak_window (dict)
Hours eligible for dispatch. 'start' and 'end' as HH:MM:SS strings.
performance_incentive (float)
Incentive revenue ($/kW per dispatch hour).
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.

Suggested change
Incentive revenue ($/kW per dispatch hour).
Incentive revenue ($/commodity_rate_units per dispatch hour).

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've only thought about this PR as a battery use case. We should chat about what this would mean.

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 agree that it makes sense to make this general for storage since there is not anything that is really battery specific.

m = pyomo.ConcreteModel(name="plm_dr")

# Parameters
P_max = self.config.max_charge_rate
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.

In order for the controller to be compatible with a case where we want to optimize the storage charge rate or capacity, then any method called in compute() should use inputs["max_charge_rate"] instead of self.config.max_charge_rate and inputs["storage_capacity"] instead of self.config.max_capacity.

An example of how to test this is somewhat shown in h2integrate/storage/test/test_storage_performance_model.py::test_generic_storage_with_simple_control_charge_rate_lessthan_demand, using the prob.set_val function (happy to chat about it if you want)

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 changed the code to use inputs. Could you check if that's what you had in mind?

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.

yes! It looks great to me now!

@vijay092 vijay092 force-pushed the peakload-optimized branch from b82857b to 410e488 Compare April 22, 2026 21:44
@johnjasa johnjasa self-requested a review April 23, 2026 19:45
Copy link
Copy Markdown
Collaborator

@jaredthomas68 jaredthomas68 left a comment

Choose a reason for hiding this comment

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

This is great Sanjana! I'm excited about where this is going. I gave a lot of comments, but some of them may be irrelevant due to my experience with pyomo. The doc page details were very helpful, thank you.

Comment thread docs/control/pyomo_controllers.md Outdated
Comment thread docs/control/pyomo_controllers.md Outdated
- $\overline{\text{SoC}}$ := `max_soc_fraction`, $\quad \underline{\text{SoC}}$ := `min_soc_fraction`
- `n_control_window` := Horizon length for optimization
- $\mathcal{T} := \{0, 1, \ldots, T\}$: hourly time steps over `n_control_window`
- $\mathcal{M}_m$ := set of hours in month $m$, for $m = 1, \ldots, 12$
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.

Same as above, does this have to be hours?

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.

It can handle any timestep now.

Comment thread docs/control/pyomo_controllers.md Outdated
Comment thread docs/control/pyomo_controllers.md
Comment thread docs/control/pyomo_controllers.md Outdated
Maximize total annual incentive revenue:

$$
\max_{u_t,\, v_t} \quad \gamma \cdot \bar{P} \sum_{t \in \mathcal{T}} u_t
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.

should the objective be based on max charge/discharge rate, or on actual discharge rate at each time step?

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 was assuming that the battery always charges and discharges at max discharge rate. If we want to solve for the actual discharge rate as well as when (the binary var) we want to discharge, the math becomes a little harder. I can work on that tomorrow.

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.

For now, I've assumed the battery charges and discharges at its maximum rate. I'll submit another PR to implement your version. When the state of charge approaches its limits, the optimizer handles edge cases as follows:

-If the SOC is too close to the minimum, discharging is disabled.
-If the SOC is too close to the maximum, charging is disabled.

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.

Does this mean that a one hour battery with a 0.1 min soc and 0.9 max soc will never charge or discharge because it will be too close to the limits? If so, then I think we should adjust this PR. If the battery can still reach the max and min allowable SOC then I am happy with where this is at.

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.

Hi @jaredthomas68, I'm now solving for the optimal power (not assuming it charges/discharges at rated) . Check out the new formulation.

)

m.objective = pyomo.Objective(
expr=-incentive * P_max * sum(m.discharge[t] for t in m.T),
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.

is there a good way to use possible discharge instead of rating (see my similar comment in docs)

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.

Totally fair point, I am working on it.

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.

See my previous comments.

Comment on lines +418 to +419
+ eta_c * mdl.charge[t] * P_max / E_max
- mdl.discharge[t] * P_max / (eta_d * E_max)
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.

see my comments in docs

list[float]: ``(u_t - v_t) * P_max`` for each timestep in
the solved window. Positive = discharge, negative = charge.
"""
P_max = self.config.max_charge_rate
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 P_max should be adjusted to not overcharge the battery, but still allow it to go to max charge. Maybe you have the handled somehow and I missed it.

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 will submit another PR to solve for the optimal charge rate, and this comment will be addressed then.
SOC will never go out of bounds because we have constraints on it.

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.

Made changes to solve for the optimal charge rate.

@vijay092 vijay092 force-pushed the peakload-optimized branch from 3d4735f to 2f0a325 Compare April 26, 2026 02:43
@brookeslawski
Copy link
Copy Markdown
Collaborator

I've reviewed this PR from a high level (without running the code), mostly focusing on the documentation and overall functionality. The documentation explains the added features compared to existing models clearly, which is very helpful. Nice job!

n_max_events: 10 # maximum discharge events per month
signal_threshold_percentile: 95.0 # only dispatch when LMP >= this percentile
n_control_window_hours: 24 # hours; converted to timesteps using simulation dt
event_duration: # omit or set to null to use static peak_window
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.

Do we need to add another constraint (event_duration==null) or (min_peak_window == null) or (event_duration <= min_peak_separation)?

Copy link
Copy Markdown
Collaborator

@genevievestarke genevievestarke left a comment

Choose a reason for hiding this comment

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

Looks good to me! Thank you @vijay092 for adding in this capability!
I had a few suggestions for making the solver references more general.

vijay092 and others added 5 commits May 5, 2026 09:34
…torage_controller.py

Co-authored-by: genevievestarke <103534902+genevievestarke@users.noreply.github.com>
…torage_controller.py

Co-authored-by: genevievestarke <103534902+genevievestarke@users.noreply.github.com>
…torage_controller.py

Co-authored-by: genevievestarke <103534902+genevievestarke@users.noreply.github.com>
Copy link
Copy Markdown
Collaborator

@jaredthomas68 jaredthomas68 left a comment

Choose a reason for hiding this comment

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

This is looking really good! Thank you for addressing the feedback. I think there are a few more changes that need to be made before merging, mostly some minor adjustments to the tests.

Comment thread docs/control/pyomo_controllers.md Outdated
Comment thread docs/control/pyomo_controllers.md Outdated
- SoC bounds:

$$
\underline{\text{SoC}} \leq \text{SoC}_t \leq \overline{\text{SoC}} \qquad \forall\, t \in \mathcal{T}
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'm not sure why, but the under and over bars are not rendering in the docs, at least in the tests.

Comment thread docs/control/pyomo_controllers.md Outdated

$$
u_t \in \{0, 1\}, \quad v_t \in \{0, 1\}, \quad \text{SoC}_t \in [0, 1] \qquad \forall\, t, m
u_t \in \{0, 1\}, \quad v_t \in \{0, 1\}, \quad \text{SoC}_t \in [\underline{\text{SoC}},\, \overline{\text{SoC}}] \qquad \forall\, t
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.

the underline and overline are not rendering in the test docs build

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.

How are you seeing the preview? It looks fine to me. Is the overbar problem only on SOC, or on P too?
I went ahead and changed the notation to SOC_min and SOC_max.

]
event_dur_cfg = control_params.get("event_duration")

half_td = None
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.

did you mean to name this "half_dt"?

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.

td is short for timedelta. Half the event duration as pd.Timedelta.

self.steps_per_event: int = max(
1,
int(
round(
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.

would it make sense to enforce rounding up here to make sure the full event duration is included?

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.

Currently, a 3.5-timestep event rounds to 4 steps and a 3.4-timestep event rounds to 3 steps. For our use case, ceil makes more sense- I'd rather dispatch one extra step than miss revenue. Good catch, changed it to ceil.

@pytest.mark.parametrize(
"example_folder,resource_example_folder", [("34_plm_optimized_dispatch", None)]
)
def test_plm_optimized_dispatch_example(subtests, temp_copy_of_example):
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.

Thanks for the added tests

p_discharge = pyomo.value(model.p_discharge[t]) # type: ignore[index]
if t > 0:
soc += eta_c * p_charge * dt_hours / E_max - p_discharge * dt_hours / (eta_d * E_max)
assert soc >= base_config.min_soc_fraction - 1e-6
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.

It is best to use subtests if you have multiple assert statements in a single test. There are many examples of subtests in our test suite. Can you make sure that all your tests with multiple asserts are protected with subtests?

controller.dt_seconds = 3600
signal = np.array([1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 1.0, 8.0, 1.0, 1.0])
mask = controller._compute_eligible_mask(signal)
print(mask)
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.

Please remove print statements


plt.tight_layout()
plt.savefig(
"examples/34_plm_optimized_dispatch/plm_optimized_dispatch.png", dpi=150, bbox_inches="tight"
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.

This line prevents the running the example from inside the example 34 directory. Using the EXAMPLE_DIR variable here should solve this.

Copy link
Copy Markdown
Collaborator

@abhineet-gupta abhineet-gupta left a comment

Choose a reason for hiding this comment

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

The rest of the PR looks good to me.

Copy link
Copy Markdown
Collaborator

@jaredthomas68 jaredthomas68 left a comment

Choose a reason for hiding this comment

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

I think is looking good. I left a few suggestions, but no blocking comments.

self.solve_dispatch_model(
start_time=window_start,
n_days=self.n_timesteps // 24,
n_days=int(round(self.n_timesteps * self.dt_seconds / 86400)),
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 ceiling would be more appropriate here so we don't have 0 days. I'm not positive though, so this is a non-blocking comment.

Comment on lines +74 to +81
with subtests.test("start time"):
assert start.hour == 8
assert start.minute == 0
assert start.second == 0
with subtests.test("end time"):
assert end.hour == 18
assert end.minute == 40
assert end.second == 20
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.

Technically I think these asserts should separated, but they are close enough I could let it go. Non-blocking.

Comment on lines 264 to 267
with subtests.test(f"SOC in bounds at t={t}"):
assert soc >= base_config.min_soc_fraction - 1e-6
assert soc <= base_config.max_soc_fraction + 1e-6

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.

Two asserts in one subtest should be separated. Non-blocking.


## Optimization Problem

This optimization is executed for each rolling window. At each window boundary the terminal SoC is carried forward as the initial condition for the next window.
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.

It might be good to highlight the variable used for rolling window. I believe you are incorporating MPC here?

vijay092 and others added 2 commits May 7, 2026 14:28
…torage_controller.py

Co-authored-by: Jared Thomas <jaredthomas68@users.noreply.github.com>
@johnjasa johnjasa enabled auto-merge May 7, 2026 21:12
@johnjasa johnjasa merged commit f626984 into NatLabRockies:develop May 8, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dispatch related to dispatch and control ready for review This PR is ready for input from folks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants