Demand Response Optimization for Battery Energy Storage#679
Demand Response Optimization for Battery Energy Storage#679johnjasa merged 61 commits intoNatLabRockies:developfrom
Conversation
elenya-grant
left a comment
There was a problem hiding this comment.
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:
- integration tests of the controller with the
StoragePerformanceModel- similar to the tests inh2integrate/control/control_strategies/storage/test/test_optimal_controllers.py - 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. |
There was a problem hiding this comment.
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). |
There was a problem hiding this comment.
| Incentive revenue ($/kW per dispatch hour). | |
| Incentive revenue ($/commodity_rate_units per dispatch hour). |
There was a problem hiding this comment.
I've only thought about this PR as a battery use case. We should chat about what this would mean.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
I changed the code to use inputs. Could you check if that's what you had in mind?
There was a problem hiding this comment.
yes! It looks great to me now!
b82857b to
410e488
Compare
jaredthomas68
left a comment
There was a problem hiding this comment.
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.
| - $\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$ |
There was a problem hiding this comment.
Same as above, does this have to be hours?
There was a problem hiding this comment.
It can handle any timestep now.
| Maximize total annual incentive revenue: | ||
|
|
||
| $$ | ||
| \max_{u_t,\, v_t} \quad \gamma \cdot \bar{P} \sum_{t \in \mathcal{T}} u_t |
There was a problem hiding this comment.
should the objective be based on max charge/discharge rate, or on actual discharge rate at each time step?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
is there a good way to use possible discharge instead of rating (see my similar comment in docs)
There was a problem hiding this comment.
Totally fair point, I am working on it.
There was a problem hiding this comment.
See my previous comments.
| + eta_c * mdl.charge[t] * P_max / E_max | ||
| - mdl.discharge[t] * P_max / (eta_d * E_max) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Made changes to solve for the optimal charge rate.
3d4735f to
2f0a325
Compare
|
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 |
There was a problem hiding this comment.
Do we need to add another constraint (event_duration==null) or (min_peak_window == null) or (event_duration <= min_peak_separation)?
genevievestarke
left a comment
There was a problem hiding this comment.
Looks good to me! Thank you @vijay092 for adding in this capability!
I had a few suggestions for making the solver references more general.
…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>
jaredthomas68
left a comment
There was a problem hiding this comment.
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.
| - SoC bounds: | ||
|
|
||
| $$ | ||
| \underline{\text{SoC}} \leq \text{SoC}_t \leq \overline{\text{SoC}} \qquad \forall\, t \in \mathcal{T} |
There was a problem hiding this comment.
I'm not sure why, but the under and over bars are not rendering in the docs, at least in the tests.
|
|
||
| $$ | ||
| 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 |
There was a problem hiding this comment.
the underline and overline are not rendering in the test docs build
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
did you mean to name this "half_dt"?
There was a problem hiding this comment.
td is short for timedelta. Half the event duration as pd.Timedelta.
| self.steps_per_event: int = max( | ||
| 1, | ||
| int( | ||
| round( |
There was a problem hiding this comment.
would it make sense to enforce rounding up here to make sure the full event duration is included?
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Please remove print statements
Co-authored-by: Jared Thomas <jaredthomas68@users.noreply.github.com>
…tegrate into peakload-optimized
|
|
||
| plt.tight_layout() | ||
| plt.savefig( | ||
| "examples/34_plm_optimized_dispatch/plm_optimized_dispatch.png", dpi=150, bbox_inches="tight" |
There was a problem hiding this comment.
This line prevents the running the example from inside the example 34 directory. Using the EXAMPLE_DIR variable here should solve this.
abhineet-gupta
left a comment
There was a problem hiding this comment.
The rest of the PR looks good to me.
jaredthomas68
left a comment
There was a problem hiding this comment.
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)), |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
Technically I think these asserts should separated, but they are close enough I could let it go. Non-blocking.
| 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 | ||
|
|
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
It might be good to highlight the variable used for rolling window. I believe you are incorporating MPC here?
…torage_controller.py Co-authored-by: Jared Thomas <jaredthomas68@users.noreply.github.com>
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
Section 2: Draft PR Checklist
TODO:
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
docs/files are up-to-date, or added when necessaryCHANGELOG.md"A complete thought. [PR XYZ]((https://github.com/NatLabRockies/H2Integrate/pull/XYZ)", where
XYZshould 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.pyUsage Example:
examples/34_plm_optimized_dispatchSection 4.2: Modified Files
h2integrate/core/supported_models.pySection 5: Additional Supporting Information
Section 6: Test Results, if applicable
Section 7 (Optional): New Model Checklist
docs/developer_guide/coding_guidelines.mdattrsclass to define theConfigto load in attributes for the modelBaseConfigorCostModelBaseConfiginitialize()method,setup()method,compute()methodCostModelBaseClasssupported_models.pycreate_financial_modelinh2integrate_model.pytest_all_examples.pydocs/user_guide/model_overview.mddocs/section<model_name>.mdis added to the_toc.yml