Converter PLM dispatch - #773
Conversation
kbrunik
left a comment
There was a problem hiding this comment.
Looking pretty good! Remarkable how much simpler the code is when you don't have to track state of charge! I know there was already some planned work that still needs to happen, so happy to re-review at that point
A few high-level notes for the PR:
- Be sure to update the changelog
- Add some basic tests to make sure the dispatch is occurring as expected (i.e., the tech is turns off after peak is "managed")
- Write a doc page for the converter PLM
There was a problem hiding this comment.
I'm thinking it might be good to move this file to the resource_files folder at the top level of H2I so that it can be used in this example and 33_peak_load_management.
There was a problem hiding this comment.
or the library folder at the top level of H2I instead?
There was a problem hiding this comment.
I moved the demand profiles to the library
There was a problem hiding this comment.
For both this and the upstream file, could you please add comments at the start of the file about what this data means, where it came from, units, what example uses it, and anything else relevant?
There was a problem hiding this comment.
I'm thinking it might be good to move this file to the resource_files folder at the top level of H2I so that it can be used in this example and 33_peak_load_management.
There was a problem hiding this comment.
moved to library
| @@ -0,0 +1,5 @@ | |||
| name: driver_config | |||
| description: Driver configuration for multivariable streams example | |||
| control_parameters: | ||
| commodity: electricity | ||
| commodity_rate_units: kW | ||
| system_capacity_rate: 1000.0 |
There was a problem hiding this comment.
Remove from control section, example didn't run with it included.
There was a problem hiding this comment.
I removed system_capacity_rates, I think the others should stay there.
| """ | ||
| Example 33: Peak load management dispatch | ||
|
|
||
| This example demonstrates: |
There was a problem hiding this comment.
Update example description based on fuel cell/converter example.
| @@ -0,0 +1,65 @@ | |||
| name: technology_config | |||
| description: This plant charges a battery from the grid to reduce peak demand | |||
| StorageOpenLoopControlBaseConfig | ||
| ): | ||
| """ | ||
| Configuration class for the PeakLoadManagementHeuristicOpenLoopStorageController. |
|
|
||
| """ | ||
|
|
||
| system_capacity_kw: int | float = field() |
There was a problem hiding this comment.
@elenya-grant and @johnjasa, I was discussing with Jared about how to make this control strategy tech agnostic. Right now it's hardcoded to have system_capacity_kw in the config. I'm wondering if y'all have ideas of what the best path forward is for this to work with all dispatchable converters.
We have access to the standardized commodity_rate_units but there's no standardization across how we define system capacity, which makes this challenging.
There was a problem hiding this comment.
well system capacity is a standard output of performance models (rated_{commodity}_production). I get confused on the order that models are added, but if the performance model is created in the tech subgroup before the controller, then the performance model would output the rated_{commodity}_production and that could then be an input to the PLM converter controller.
This is a good question though. I think the best thing (if possible) would be to have rated_{commodity}_production as an openmdao input.
Also - I don't think the capacity input in the config or the openmdao inputs should be standardized for a few reasons:
- In the config, it seems more acceptable to include units in config parameters to make it more clear to users what units that parameter should be in. The units are not included in OpenMDAO input names.
- Some technologies do not have a single capacity-related input. Some technologies have an input that would instead be a multiplier on some number of units (like number of electrolyzer clusters and cluster capacity OR number of wind turbines and wind turbine capacity).
There was a problem hiding this comment.
@elenya-grant, thank you for the idea. I successfully implemented the switch to rated_{commodity}_production to make the model technology agnositc. It does require a non-linear solver to resolve the output of the performance model into an input of the controller, which takes 3 iterations to converge. This should be fine as long as the performance model in question is fast-running. I also like how it opens up a possible path for more realistic control interactions between the performance and tech control models. We should probably discuss this more, somewhat related to #711 in that this solution adds a feedback loop in the tech model between the performance and tech control.
* varopex * refurb schedule * update fuel cell schedule calc to allow for non-integer multiples of eol and timesteps * extend liftime for schedule test * Update h2integrate/converters/hydrogen/h2_fuel_cell.py --------- Co-authored-by: Jared Thomas <jaredthomas68@gmail.com> Co-authored-by: Jared Thomas <jaredthomas68@users.noreply.github.com>
… example to work with latest changes in H2I
… under a single example
There was a problem hiding this comment.
Pull request overview
This PR introduces a new open-loop “peak load management” dispatch controller for converter technologies (e.g., fuel cells), updates the fuel cell model to support refurbishment scheduling and variable OpEx, and reorganizes/extends peak-load-management examples and documentation to cover the new converter heuristic workflow.
Changes:
- Added
PeakLoadManagementHeuristicOpenLoopConverterControllerand registered it as a supported control strategy. - Extended the H2 fuel cell performance and cost models with a replacement schedule output and
VarOpEx, plus new/updated unit tests. - Added a new converter PLM heuristic example and updated docs/example tests accordingly.
Reviewed changes
Copilot reviewed 23 out of 27 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| h2integrate/core/supported_models.py | Registers the new converter controller for dynamic loading. |
| h2integrate/core/model_baseclasses.py | Minor whitespace-only cleanup in CostModelBaseClass. |
| h2integrate/core/h2integrate_model.py | Adds a nonlinear/linear solver when a controller reads performance outputs (to converge the intra-tech coupling). |
| h2integrate/converters/hydrogen/h2_fuel_cell.py | Adds fuel-cell replacement scheduling and variable OpEx computation. |
| h2integrate/converters/hydrogen/test/test_h2_fuel_cell.py | Adds/updates unit tests for replacement schedule and variable OpEx. |
| h2integrate/control/control_strategies/converters/plm_openloop_converter_controller.py | New open-loop peak-shaving converter controller implementation. |
| h2integrate/control/control_strategies/converters/init.py | Exports the new converter controller from the package. |
| examples/test/test_all_examples.py | Repoints PLM storage example and adds integration coverage for new converter example. |
| examples/33_peak_load_management/driver_config.yaml | Removes the old example driver config after example re-org. |
| examples/33_peak_load_management_heuristics/plm_storage/tech_config.yaml | Updates description for reorganized PLM storage heuristic example. |
| examples/33_peak_load_management_heuristics/plm_storage/run_peak_load_management.py | Updates the run script to use the new storage heuristic YAML entrypoint. |
| examples/33_peak_load_management_heuristics/plm_storage/plant_config.yaml | Updates description for the reorganized storage heuristic example. |
| examples/33_peak_load_management_heuristics/plm_storage/driver_config.yaml | Adds driver config for the reorganized storage heuristic example. |
| examples/33_peak_load_management_heuristics/plm_storage/33_plm_storage_heuristic.yaml | New top-level YAML entrypoint for the storage heuristic example. |
| examples/33_peak_load_management_heuristics/plm_converter/tech_config.yaml | Adds a new converter-based PLM heuristic example tech configuration. |
| examples/33_peak_load_management_heuristics/plm_converter/run_peak_load_management.py | Adds run/plot script for the converter heuristic example. |
| examples/33_peak_load_management_heuristics/plm_converter/plant_config.yaml | Adds plant config for the converter heuristic example. |
| examples/33_peak_load_management_heuristics/plm_converter/driver_config.yaml | Adds driver config for the converter heuristic example. |
| examples/33_peak_load_management_heuristics/plm_converter/33_plm_converter_heuristic.yaml | New top-level YAML entrypoint for the converter heuristic example. |
| docs/user_guide/model_overview.md | Documents the new converter controller in the control models list. |
| docs/control/technology_level_control/technology_control_overview.md | Adds the converter controller to the supported controllers list. |
| docs/control/technology_level_control/open-loop_controllers.md | Documents open-loop converter controllers and points to the new example. |
| docs/_static/class_hierarchy.html | Updates autogenerated class hierarchy to include the new controller class. |
| CHANGELOG.md | Adds a changelog entry for the new converter controller. |
| .pre-commit-config.yaml | Updates YAML formatting exclusions to reflect demand-profile file location changes. |
Suppressed comments (2)
h2integrate/converters/hydrogen/test/test_h2_fuel_cell.py:171
- Same issue as above: this block reuses and mutates the
tech_configfixture in-place, which can leak changes into later subtests/tests. Make a deep copy before editing nested fields.
tech_config_copy = tech_config
tech_config_copy["model_inputs"]["performance_parameters"]["uptime_hours_until_eol"] = (
8760 * 0.75
)
examples/33_peak_load_management_heuristics/plm_storage/tech_config.yaml:5
- This example's tech_config still references
demand_profiles/demand_profile.yaml/demand_profile_upstream.yamlvia!include, but there is nodemand_profile*.yamlanywhere in the repo checkout. As a result, this example config will fail to load withFileNotFoundErrorunless those profile files are added/moved or the include paths are updated.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
johnjasa
left a comment
There was a problem hiding this comment.
This is great stuff, Jared! Glad to see it slot in so cleanly. I appreciate the new examples, updated docs, and expanded fuel cell models. I pushed up some small changes and left some comments/suggestions. I think some of them are worth considering, especially the vectorization of the compute for the PLM converter. Otherwise I think this is quite close to ready! Thanks for your great work and iterating.
There was a problem hiding this comment.
For both this and the upstream file, could you please add comments at the start of the file about what this data means, where it came from, units, what example uses it, and anything else relevant?
| demand_profile_upstream_kind (str): Interpretation mode for | ||
| ``demand_profile_upstream``. One of ``"electricity"`` or ``"price"``. | ||
| Defaults to ``"electricity"``. |
There was a problem hiding this comment.
Question about your mindset for this component and its limitations: is the demand profile truly just for electricity or price, or could this be extended as-is for other commodities, e.g. hydrogen or ammonia? I understand that peak load management is most often considered with electricity, but it's more me trying to figure out what would need to change to enable others, or if that was on your mind at all, or a purposeful limitation to simplify code or logic.
There was a problem hiding this comment.
No, they could be other things. This is tech/commodity agnostic. Price could technically be anything you want to use to trigger dispatch. I changed the name from electricity to commodity to reflect the commodity agnostic code changes.
| super().__attrs_post_init__() | ||
|
|
||
|
|
||
| class PeakLoadManagementHeuristicOpenLoopConverterController(StorageOpenLoopControlBase): |
There was a problem hiding this comment.
Both this and the config class inherit the baseclasses for Storage. Should we generalize the naming of those baseclasses, move them out of the storage folder, or something else? Or maybe it's a non-issue. I'm more thinking about potential confusion around a converter controller inheriting from a storage base.
|
|
||
|
|
||
| @define(kw_only=True) | ||
| class PeakLoadManagementHeuristicOpenLoopConverterControllerConfig( |
There was a problem hiding this comment.
This name and the class name are pretty lengthy; could we reasonably shorten it without loss of clarity? Maybe PLMHeuristicOpenLoopConverterControllerConfig or even more?
There was a problem hiding this comment.
since it inherits the StorageOpenLoopControlBaseConfig - I think you could at least remove OpenLoop from the name. What if it was just PLMHeuristicConverterControllerConfig
| demand_profile_peak_cutoff = self.config.demand_profile_peak_cutoff | ||
| demand_profile_upstream = self.config.demand_profile_upstream | ||
| demand_profile_upstream_peak_cutoff = inputs["demand_profile_upstream_peak_cutoff"][0] | ||
| self.command_value = np.zeros(self.n_timesteps) |
There was a problem hiding this comment.
Does this need to be an attribute of self? I would recommend just having it locally available within compute
| for idx, val in enumerate(demand_profile): | ||
| val_upstream = demand_profile_upstream[idx] | ||
| if ( | ||
| val > demand_profile_peak_cutoff | ||
| or val_upstream > demand_profile_upstream_peak_cutoff | ||
| ): | ||
| desired_dispatch = val - demand_profile_peak_cutoff | ||
|
|
||
| if self.config.demand_profile_upstream_kind == "electricity": | ||
| desired_dispatch_upstream = val_upstream - demand_profile_upstream_peak_cutoff | ||
|
|
||
| self.command_value[idx] = min( | ||
| max( | ||
| max(desired_dispatch, 0), | ||
| max(desired_dispatch_upstream, 0), | ||
| ), | ||
| val, | ||
| rated_production, | ||
| ) | ||
| elif self.config.demand_profile_upstream_kind == "price": | ||
| if val_upstream > demand_profile_upstream_peak_cutoff: | ||
| self.command_value[idx] = min( | ||
| max(desired_dispatch, 0), | ||
| val, | ||
| rated_production, | ||
| ) | ||
| else: | ||
| raise ( | ||
| ValueError( | ||
| f"Invalid demand_profile_upstream_kind \ | ||
| '{self.config.demand_profile_upstream_kind}'" | ||
| ) | ||
| ) |
There was a problem hiding this comment.
I think this section could be rewritten to avoid the top-level Python for-loop and to rely on numpy operations for speed and clarity. I've done that here and confirmed that the example gets the exact same results. I didn't want to push this directly to the branch as this is a good bit of changes, but please consider something like this.
| for idx, val in enumerate(demand_profile): | |
| val_upstream = demand_profile_upstream[idx] | |
| if ( | |
| val > demand_profile_peak_cutoff | |
| or val_upstream > demand_profile_upstream_peak_cutoff | |
| ): | |
| desired_dispatch = val - demand_profile_peak_cutoff | |
| if self.config.demand_profile_upstream_kind == "electricity": | |
| desired_dispatch_upstream = val_upstream - demand_profile_upstream_peak_cutoff | |
| self.command_value[idx] = min( | |
| max( | |
| max(desired_dispatch, 0), | |
| max(desired_dispatch_upstream, 0), | |
| ), | |
| val, | |
| rated_production, | |
| ) | |
| elif self.config.demand_profile_upstream_kind == "price": | |
| if val_upstream > demand_profile_upstream_peak_cutoff: | |
| self.command_value[idx] = min( | |
| max(desired_dispatch, 0), | |
| val, | |
| rated_production, | |
| ) | |
| else: | |
| raise ( | |
| ValueError( | |
| f"Invalid demand_profile_upstream_kind \ | |
| '{self.config.demand_profile_upstream_kind}'" | |
| ) | |
| ) | |
| # Primary exceedance above the demand peak cutoff, clipped at zero. | |
| desired_dispatch = np.maximum(demand_profile - demand_profile_peak_cutoff, 0.0) | |
| if self.config.demand_profile_upstream_kind == "electricity": | |
| # Upstream exceedance uses the same units as the commodity set-point. | |
| desired_dispatch_upstream = np.maximum( | |
| demand_profile_upstream - demand_profile_upstream_peak_cutoff, 0.0 | |
| ) | |
| # Dispatch is considered when either primary or upstream profile exceeds cutoff. | |
| active_mask = (demand_profile > demand_profile_peak_cutoff) | ( | |
| demand_profile_upstream > demand_profile_upstream_peak_cutoff | |
| ) | |
| # Follow the larger exceedance and cap by demand and rated production. | |
| desired_command = np.maximum(desired_dispatch, desired_dispatch_upstream) | |
| capped_command = np.minimum(np.minimum(desired_command, demand_profile), rated_production) | |
| self.command_value = np.where(active_mask, capped_command, 0.0) | |
| elif self.config.demand_profile_upstream_kind == "price": | |
| # Price mode only dispatches when upstream price exceeds its threshold. | |
| active_mask = demand_profile_upstream > demand_profile_upstream_peak_cutoff | |
| # Cap dispatch by demand and rated production after clipping at zero. | |
| capped_command = np.minimum(np.minimum(desired_dispatch, demand_profile), rated_production) | |
| self.command_value = np.where(active_mask, capped_command, 0.0) | |
| else: | |
| raise ValueError( | |
| f"Invalid demand_profile_upstream_kind " | |
| f"'{self.config.demand_profile_upstream_kind}'" | |
| ) |
| # Some controllers read performance-model outputs (e.g. | ||
| # ``rated_<commodity>_production``) as inputs, which creates a | ||
| # controller<->performance data cycle within the technology group. Add a | ||
| # nonlinear solver so this coupling is resolved instead of using stale | ||
| # (default) values on a single execution pass. | ||
| if model_type == "control_strategy" and getattr( | ||
| model_object, "_reads_performance_outputs", False | ||
| ): | ||
| tech_group.nonlinear_solver = om.NonlinearBlockGS() | ||
| tech_group.linear_solver = om.DirectSolver() | ||
|
|
There was a problem hiding this comment.
Fun to see some slick logic and easily-added solvers!
elenya-grant
left a comment
There was a problem hiding this comment.
Hi Jared! Cool to see a converter controller! I agree with the comments by John and Kaitlin - but I think this looks good besides those! This is a nitpick and I'm not asking for you to change this (just noting it) - but I did get a little lost with the similarities in the variable names demand_profile_peak_cutoff and demand_profile_upstream_peak_cutoff
|
|
||
|
|
||
| @define(kw_only=True) | ||
| class PeakLoadManagementHeuristicOpenLoopConverterControllerConfig( |
There was a problem hiding this comment.
since it inherits the StorageOpenLoopControlBaseConfig - I think you could at least remove OpenLoop from the name. What if it was just PLMHeuristicConverterControllerConfig
|
|
||
| _time_step_bounds = ( | ||
| 1e-12, | ||
| np.inf, |
There was a problem hiding this comment.
I think an infinite timestep is a little unlikely. Could we just make this a big number? Like - would it ever make sense to run this controller with a time step of 1 year? Even if it can handle a 1-year timestep - would that make sense?
| demand_profile_peak_cutoff = self.config.demand_profile_peak_cutoff | ||
| demand_profile_upstream = self.config.demand_profile_upstream |
There was a problem hiding this comment.
should these be made as inputs if they aren't None in the config? Why is demand_profile_upstream_peak_cutoff an input but these aren't?
| demand_profile_upstream_peak_cutoff (int | float | None): Threshold applied to | ||
| ``demand_profile_upstream``. Units depend on | ||
| ``demand_profile_upstream_kind``. | ||
| demand_profile_upstream_kind (str): Interpretation mode for |
There was a problem hiding this comment.
For my own edification, you would have to use the demand_profile_upstream if you wanted to dispatch based on price only? and if that was the case would the demand profile basically be a dummy profile? An example of this case might be helpful to show the functionality or more explanation of this in the docs with perhaps an example tech config.
Converter PLM control
This PR adds peak load management dispatch for converters. It handles two load profiles, one representing an upstream (grid) load and the other representing either a local load that must be met or a price profile. The converter will be dispatched when either of the demand profiles exceeds their respective peak limits. If a price profile is given, then dispatching will only occur when the price exceeds the provided cutoff.
This PR also provides minor updates to the fuel cell model to determine VarOpEx.
Section 1: Type of Contribution
Section 2: Draft PR Checklist
TODO:
Type of Reviewer Feedback Requested (on Draft PR)
Ready for any and all feedback. Let's get this PR polished and merged.
Structural feedback:
Looking for high-level feedback at this point from any who wish to give it.Implementation feedback:
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 4: Related Issues
Section 5: Impacted Areas of the Software
Section 5.1: New Files
PeakLoadManagementHeuristicOpenLoopConverterControllerConfig: New config plm converter controlPeakLoadManagementHeuristicOpenLoopConverterController.setupPeakLoadManagementHeuristicOpenLoopConverterController.compute: Computes a per-timestep{commodity}_command_valuethat peak-shaves using primary/upstream cutoffs, clipped to instantaneous demand and rated production.PeakLoadManagementHeuristicOpenLoopConverterController33_plm_converter_heuristic.yaml,tech_config.yaml,plant_config.yaml,driver_config.yamlSection 5.2: Modified Files
h2integrate_model.py
_process_model: Adds aNonlinearBlockGS/DirectSolverto the tech group when a controller sets_reads_performance_outputs, so the controller/performance data cycle converges instead of using stale defaults.h2_fuel_cell.py
LinearH2FuelCellPerformanceConfig/compute: Addsuptime_hours_until_eoland computes areplacement_schedulefrom cumulative "on" hours for use by the finance model.H2FuelCellCostConfig/setup/compute: Addsvariable_opex_per_kwhand anannual_electricity_producedinput to outputVarOpEx.test_h2_fuel_cell.py
unittest.test_all_examples.py
33_peak_load_management/plm_storageand addstest_plm_converter_heuristic_examplecovering the new converter example.model_baseclasses.py
CostModelBaseClass: Removed a stray blank line (whitespace only)..pre-commit-config.yaml: adjusted excluded files for demand_profile.yaml rename
Renamed demand profile files (
.../demand_profiles/demand_profile.yaml,demand_profile_upstream.yaml)Section 6: Additional Supporting Information
Section 7: Test Results, if applicable
Section 8 (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.ymlgenerate_class_hierarchy.pyto update the class hierarchy diagram indocs/developer_guide/class_structure.md