Skip to content

Demand Response Optimization for Battery Energy Storage (Stage 2) - #808

Open
abhineet-gupta wants to merge 11 commits into
NatLabRockies:developfrom
abhineet-gupta:PLM_DR
Open

Demand Response Optimization for Battery Energy Storage (Stage 2)#808
abhineet-gupta wants to merge 11 commits into
NatLabRockies:developfrom
abhineet-gupta:PLM_DR

Conversation

@abhineet-gupta

@abhineet-gupta abhineet-gupta commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Demand Response Optimization for Battery Energy Storage (Stage 2)

This PR is a continuation of PR 679.
It adds a Pyomo optimization based controller for the BESS system.
The controller optimizes the battery dispatch to minimize the Co-Op's expenditure while allowing demand response capabilities based on G&T requirements during peak window.

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:

Type of Reviewer Feedback Requested (on Draft PR)

Structural feedback:
Do you agree with the terminology used in describing this approach in documentation.

Implementation feedback:
Is this the correct way to add highs solver for pyomo to github workflow. It has already been added to environment.yml.
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 4: Related Issues

Section 5: Impacted Areas of the Software

Section 5.1: New Files

Section 5.2: Modified Files

  • h2integrate/control/control_strategies/storage/plm_optimized_storage_controller.py
    • Modified implementation under PeakLoadManagementOptimizedStorageController

Section 6: Additional Supporting Information

Section 7: Test Results, if applicable

Section 8 (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
    • Run generate_class_hierarchy.py to update the class hierarchy diagram in docs/developer_guide/class_structure.md

@abhineet-gupta
abhineet-gupta changed the base branch from main to develop July 21, 2026 13:56
@abhineet-gupta
abhineet-gupta requested a review from vijay092 July 21, 2026 13:58
@abhineet-gupta
abhineet-gupta force-pushed the PLM_DR branch 3 times, most recently from 96a279f to bb33d74 Compare July 21, 2026 14:20

@johnjasa johnjasa left a comment

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 is great, thanks for the follow-on PR, @abhineet-gupta! I love how you updated the code, examples, tests, and docs. Really slick stuff with some good solver generalizations as well.

I've left some questions and suggestions, nothing huge but I do think some of the clarifications will be useful for users approaching this code.

Comment thread environment.yml
Comment thread docs/control/technology_level_control/pyomo_controllers.md

@vijay092 vijay092 left a comment

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.

Thank you for working on this! I had some minor comments.


**Given:**
- $\lambda_t$ := `supervisory_signal`: price, demand, or price $\times$ demand time series at timestep $t$
- $\lambda_t$ := `lmp_signal`: electricity price time series at timestep $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'd call this a supervisory signal, since it could be either LMP or demand- essentially any external signal that the battery needs to respond to.

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.

This controller needs both LMP and demand signals as inputs at the same time, hence the distinction.

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 just one demand signal? If the LMP could also be an upstream demand signal (as in the heuristic control work), then it may make sense to adjust the name as @vijay092 suggested. I see a supervisory signal file, but I only see one demand and lmp in the docs formulation.


$$
\max_{u_t, v_t,p_{d,t}, p_{c,t}} \quad \gamma \cdot \Delta t \sum_{t \in \mathcal{T}} p_{d,t}
\min_{u_{gt,t},u_{coop,t},v_t,p^d_{gt,t},p^d_{coop,t},pc_t,\text{SOC}_t,p_{gt2coop,t}} \quad

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.

Could you confirm if the units are uniform in the first and second term? Incentive is $ / kwh and LMP is $/MWh?

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.

The optimization setup expects $/kWh for both. I will double check the input data in example to ensure that they follow the convention.


# Incentive revenue is earned for every kWh discharged.
# Power transmitted to CoOp
m.p_tocoop = pyomo.Var(

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.

p_tocoop is unbounded above which might cause issues later on. Good to add an upper bound.

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.

That's a good point. There would be a practical upper bound (based on interconnections etc). However, there is no theoretical upper bound (we can potentially model as large a G&T and CoOp under this framework as we like). Not sure how to pick a reasonable upper bound here.

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 the upper bound could be exposed as a user input to the control in the tech_config. I think it would appropriate to have a default value of None but allow the user to set it as well. It may also work to pull it from the performance model rating information, perhaps with a buffer.

Comment thread examples/34_plm_optimized_dispatch/run_plm_optimized_dispatch.py Outdated
with subtests.test("Discharge never above max_charge_rate"):
assert np.all(discharge <= 1.0 + 1e-4)

with subtests.test("SOC at t=0 equal to init_soc_fraction"):

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.

Why is this removed?

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.

SOC at t = 0 is calculated after the first iteration is complete. So this test is only valid if battery doesn't discharge at the first step. Previously, that wasn't possible because the battery could only discharge during peak window but now it can also discharge at the first step.

@abhineet-gupta

Copy link
Copy Markdown
Collaborator Author

@johnjasa,
Thanks for the review, I have addressed your comments with and tagged them with specific commit hashes.

@abhineet-gupta
abhineet-gupta marked this pull request as ready for review July 30, 2026 17:38

@jaredthomas68 jaredthomas68 left a comment

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.

Nice work, this is looking really good! Most of my comments are related to generality and needs of other projects hoping to use this work. If you have any questions or concerns I'm happy to clarify and discuss potential solutions.


**Given:**
- $\lambda_t$ := `supervisory_signal`: price, demand, or price $\times$ demand time series at timestep $t$
- $\lambda_t$ := `lmp_signal`: electricity price time series at timestep $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 just one demand signal? If the LMP could also be an upstream demand signal (as in the heuristic control work), then it may make sense to adjust the name as @vijay092 suggested. I see a supervisory signal file, but I only see one demand and lmp in the docs formulation.


$$
\sum_{t \in \mathcal{M}_m \cap \mathcal{T}} u_t \leq B_m \cdot \tau \qquad \forall\, m
\sum_{t \in \mathcal{M}_m \cap \mathcal{T}} u_{gt,t} \leq B_m \cdot \tau \qquad \forall\, m

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 appears that only the number of hours (or timesteps?) are constrained per month, rather than the number of events, which I think is intended to be a full battery cycle per event. From the plot, it looks like multiple discharge events happen per day, while we need to be able limit the number of events per day as well as the number of events per month. I may be misunderstanding, but I think it would be worth revisiting this formulation against the requirements. My understanding of what is needed:

  • constrain the number of events in a month
  • constrain the number of events in a day
  • specify whether an event should be a full battery cycle or use some other definition of what an "event" is.

To be clear, I like what you have here and I think we should keep it. I also think it is not sufficient for the current project needs.

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 may be worth moving these demand and lmp profiles to the library/demand_profiles as I did in #773, but probably less important here since they are only used in one example so far.

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.

Can you add descriptions at the top of each lmp/demand/other signal with information about the data, where it came from, units, etc?

storage_out = np.zeros(self.n_timesteps)
soc_out = np.zeros(self.n_timesteps)

# Per-timestep history of the MILP decision variables, accumulated

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 this is a fine way of storing if you just need them for use from the object immediately after a run. If you want them saved in the openmdao recorder database for later analysis from a saved file then we should consider making them outputs of the component.

domain=pyomo.NonNegativeReals,
bounds=(0, P_max),
doc="Discharge power (kW) at timestep t",
doc="Discharge power to Co-Op (kW) at timestep 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.

as determined by the local/coop

m.soc_evolution = pyomo.Constraint(m.T, rule=soc_evolution_rule)

# Can't charge and discharge at the same time.
# Can't charge to both G&T and CoOp or discharge at the same time.

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 discharge only ever impacts the coop directly, the impact on the G&T is secondary.

Suggested change
# Can't charge to both G&T and CoOp or discharge at the same time.
# Can't simultaneously discharge according to both G&T and COOP, or charge and discharge at the same time.

),
)

# Can't discharge to CoOp in the dispatch 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.

Suggested change
# Can't discharge to CoOp in the dispatch window.
# Can't follow discharge commands from the COOP in the dispatch window.


@staticmethod
def glpk_solve_call(
def pyomosolver_solve_call(

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 the move away from having a method call in the control to call a specific solver by name, but I think we should use that general method (pyomosolver_solve_call) to run the solver of choice (GLPK, HIGHs, etc). could we either add some logic or methods for each solver that can be called by pyomosolver_solve_call so the user can choose the solver to use?

float: Cost charged by the G$T in the same units as `lmp`.
"""
return (
self.config.GnT_pricingfunction_coeffs[0] * lmp

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 the COOP price needs to be independent of LMP in some cases. Maybe that just means setting the first coeff to 0? I'm also wondering how we may be able to build in other electricity pricing approaches than what is represented here. We should probably discuss offline about other project needs and make sure we make the pricing and objective functions here work for what is needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants