diff --git a/README.md b/README.md index e3cecb0d6..4f4c3cab7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# Hybrid Optimization and Performance Platform +# Packages + +## HOPP: Hybrid Optimization and Performance Platform ![CI Tests](https://github.com/NREL/HOPP/actions/workflows/ci.yml/badge.svg) @@ -6,6 +8,11 @@ As part of NREL's [Hybrid Energy Systems Research](https://www.nrel.gov/wind/hyb software assesses optimal designs for the deployment of utility-scale hybrid energy plants, particularly considering wind, solar and storage. +## GreenHEART: Grean Hydrogen Energy and Renewable Technologies +Hybrid project power-to-x component-level system performance and financial modeling for control and design optimization. Currently includes renewable energy, hydrogen, ammonia, and steel. Other elements such as desalination systems, pipelines, compressors, and storage systems can also be included as needed. + +`greenheart` will install alongside `hopp` by following the instructions for installing HOPP from source. + ## Software requirements - Python version 3.8, 3.9, 3.10 64-bit - Other versions may still work, but have not been extensively tested at this time @@ -17,7 +24,7 @@ solar and storage. pip install HOPP ``` -## Installing from Source +## Installing from Source 1. Using Git, navigate to a local target directory and clone repository: ``` git clone https://github.com/NREL/HOPP.git diff --git a/docs/hopp/simulation/technologies/dispatch.rst b/docs/hopp/simulation/technologies/dispatch.rst new file mode 100644 index 000000000..8f3849acb --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch.rst @@ -0,0 +1,50 @@ +.. _Dispatch: + +Dispatch Strategies +=================== + +These are the dispatch strategies that may be used for a standard HOPP simulation. Dispatch +settings can be defined through :class:`.HybridDispatchOptions`. + +Storage Dispatch +---------------- + +.. toctree:: + :maxdepth: 1 + + dispatch/power_storage/simple_battery_dispatch.rst + dispatch/power_storage/simple_battery_dispatch_heuristic.rst + dispatch/power_storage/heuristic_load_following_dispatch.rst + dispatch/power_storage/linear_voltage_convex_battery_dispatch.rst + dispatch/power_storage/linear_voltage_nonconvex_battery_dispatch.rst + dispatch/power_storage/one_cycle_battery_dispatch_heuristic.rst + +The above dispatch classes inherit from the :py:class:`.PowerStorageDispatch` class. + +.. toctree:: + :maxdepth: 1 + + dispatch/power_storage/power_storage_dispatch.rst + +Technology Dispatch +------------------- + +Dispatch classes are made for each technology where their specific components of the objectives, +their parameters, and other technology specific dispatch properties are defined. + +.. toctree:: + :maxdepth: 1 + + dispatch/power_sources/pv_dispatch.rst + dispatch/power_sources/wind_dispatch.rst + dispatch/power_sources/wave_dispatch.rst + dispatch/power_sources/trough_dispatch.rst + dispatch/power_sources/tower_dispatch.rst + dispatch/power_sources/csp_dispatch.rst + +The above technology classes inherit from the :py:class:`.PowerSourceDispatch` class. + +.. toctree:: + :maxdepth: 1 + + dispatch/power_sources/power_source_dispatch.rst diff --git a/docs/hopp/simulation/technologies/dispatch/power_sources/csp_dispatch.rst b/docs/hopp/simulation/technologies/dispatch/power_sources/csp_dispatch.rst new file mode 100644 index 000000000..7907cb175 --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_sources/csp_dispatch.rst @@ -0,0 +1,10 @@ +.. _CSPDispatch: + + +CSP Dispatch +============ + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_sources.csp_dispatch.CspDispatch + :members: diff --git a/docs/hopp/simulation/technologies/dispatch/power_source_dispatch.rst b/docs/hopp/simulation/technologies/dispatch/power_sources/power_source_dispatch.rst similarity index 51% rename from docs/hopp/simulation/technologies/dispatch/power_source_dispatch.rst rename to docs/hopp/simulation/technologies/dispatch/power_sources/power_source_dispatch.rst index c13f3be80..d5b6c623c 100644 --- a/docs/hopp/simulation/technologies/dispatch/power_source_dispatch.rst +++ b/docs/hopp/simulation/technologies/dispatch/power_sources/power_source_dispatch.rst @@ -1,15 +1,10 @@ -:orphan: - .. _PowerSourceDispatch: -PowerSourceDispatch: Abstract Class -=================================== - -Base dispatch class for power source models +Power Source Dispatch +===================== .. toctree:: - .. autoclass:: hopp.simulation.technologies.dispatch.power_sources.power_source_dispatch.PowerSourceDispatch - :members: + :members: \ No newline at end of file diff --git a/docs/hopp/simulation/technologies/dispatch/power_sources/pv_dispatch.rst b/docs/hopp/simulation/technologies/dispatch/power_sources/pv_dispatch.rst new file mode 100644 index 000000000..d95496a16 --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_sources/pv_dispatch.rst @@ -0,0 +1,10 @@ +.. _PVDispatch: + + +PV Dispatch +=========== + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_sources.pv_dispatch.PvDispatch + :members: diff --git a/docs/hopp/simulation/technologies/dispatch/power_sources/tower_dispatch.rst b/docs/hopp/simulation/technologies/dispatch/power_sources/tower_dispatch.rst new file mode 100644 index 000000000..fd51886ac --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_sources/tower_dispatch.rst @@ -0,0 +1,10 @@ +.. _TowerDispatch: + + +Tower Dispatch +============== + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_sources.tower_dispatch.TowerDispatch + :members: diff --git a/docs/hopp/simulation/technologies/dispatch/power_sources/trough_dispatch.rst b/docs/hopp/simulation/technologies/dispatch/power_sources/trough_dispatch.rst new file mode 100644 index 000000000..1047d8245 --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_sources/trough_dispatch.rst @@ -0,0 +1,10 @@ +.. _TroughDispatch: + + +Trough Dispatch +=============== + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_sources.trough_dispatch.TroughDispatch + :members: diff --git a/docs/hopp/simulation/technologies/dispatch/power_sources/wave_dispatch.rst b/docs/hopp/simulation/technologies/dispatch/power_sources/wave_dispatch.rst new file mode 100644 index 000000000..02edf00d9 --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_sources/wave_dispatch.rst @@ -0,0 +1,10 @@ +.. _WaveDispatch: + + +Wave Dispatch +============= + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_sources.wave_dispatch.WaveDispatch + :members: diff --git a/docs/hopp/simulation/technologies/dispatch/power_sources/wind_dispatch.rst b/docs/hopp/simulation/technologies/dispatch/power_sources/wind_dispatch.rst new file mode 100644 index 000000000..9d5d97bcd --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_sources/wind_dispatch.rst @@ -0,0 +1,10 @@ +.. _WindDispatch: + + +Wind Dispatch +============= + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_sources.wind_dispatch.WindDispatch + :members: diff --git a/docs/hopp/simulation/technologies/dispatch/power_storage/heuristic_load_following_dispatch.rst b/docs/hopp/simulation/technologies/dispatch/power_storage/heuristic_load_following_dispatch.rst new file mode 100644 index 000000000..6adec3df9 --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_storage/heuristic_load_following_dispatch.rst @@ -0,0 +1,10 @@ +.. _HeuristicLoadFollowingDispatch: + + +Heuristic Load Following Dispatch +================================= + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_storage.heuristic_load_following_dispatch.HeuristicLoadFollowingDispatch + :members: diff --git a/docs/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_convex_battery_dispatch.rst b/docs/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_convex_battery_dispatch.rst new file mode 100644 index 000000000..63fc049b8 --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_convex_battery_dispatch.rst @@ -0,0 +1,10 @@ +.. _ConvexLinearVoltageBatteryDispatch: + + +Convex Linear Voltage Battery Dispatch +====================================== + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_storage.linear_voltage_convex_battery_dispatch.ConvexLinearVoltageBatteryDispatch + :members: diff --git a/docs/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_nonconvex_battery_dispatch.rst b/docs/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_nonconvex_battery_dispatch.rst new file mode 100644 index 000000000..8c9fb82c1 --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_nonconvex_battery_dispatch.rst @@ -0,0 +1,10 @@ +.. _NonConvexLinearVoltageBatteryDispatch: + + +Non-Convex Linear Voltage Battery Dispatch +========================================== + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_storage.linear_voltage_nonconvex_battery_dispatch.NonConvexLinearVoltageBatteryDispatch + :members: diff --git a/docs/hopp/simulation/technologies/dispatch/power_storage/one_cycle_battery_dispatch_heuristic.rst b/docs/hopp/simulation/technologies/dispatch/power_storage/one_cycle_battery_dispatch_heuristic.rst new file mode 100644 index 000000000..c9112d682 --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_storage/one_cycle_battery_dispatch_heuristic.rst @@ -0,0 +1,10 @@ +.. _OneCycleBatteryDispatchHeuristic: + + +One Cycle Battery Dispatch Heuristic +==================================== + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_storage.one_cycle_battery_dispatch_heuristic.OneCycleBatteryDispatchHeuristic + :members: diff --git a/docs/hopp/simulation/technologies/dispatch/power_storage/power_storage_dispatch.rst b/docs/hopp/simulation/technologies/dispatch/power_storage/power_storage_dispatch.rst new file mode 100644 index 000000000..c34dab5d3 --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_storage/power_storage_dispatch.rst @@ -0,0 +1,10 @@ +.. _PowerStorageDispatch: + + +Power Storage Dispatch +====================== + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_storage.power_storage_dispatch.PowerStorageDispatch + :members: diff --git a/docs/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch.rst b/docs/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch.rst new file mode 100644 index 000000000..a0e1be9bb --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch.rst @@ -0,0 +1,10 @@ +.. _SimpleBatteryDispatch: + + +Simple Battery Dispatch +======================= + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch.SimpleBatteryDispatch + :members: diff --git a/docs/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch_heuristic.rst b/docs/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch_heuristic.rst new file mode 100644 index 000000000..32cce175b --- /dev/null +++ b/docs/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch_heuristic.rst @@ -0,0 +1,10 @@ +.. _SimpleBatteryDispatchHeuristic: + + +Simple Battery Dispatch Heuristic +================================= + +.. toctree:: + +.. autoclass:: hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch_heuristic.SimpleBatteryDispatchHeuristic + :members: diff --git a/docs/index.rst b/docs/index.rst index bddf1a085..3562f52d1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -20,7 +20,7 @@ Welcome to HOPP's documentation! hopp/simulation/hopp_interface.rst hopp/simulation/technologies/sites/site_info.rst hopp/simulation/technologies/technologies.rst - + hopp/simulation/technologies/dispatch.rst .. toctree:: :maxdepth: 1 diff --git a/greenheart/simulation/greenheart_simulation.py b/greenheart/simulation/greenheart_simulation.py index ad99e986f..cccd7b02e 100644 --- a/greenheart/simulation/greenheart_simulation.py +++ b/greenheart/simulation/greenheart_simulation.py @@ -271,25 +271,18 @@ def setup_greenheart_simulation(config: GreenHeartSimulationConfig): "is being overwritten with the value from the greenheart_config", UserWarning, ) + + if ( + config.orbit_config["plant"]["turbine_spacing"] + != config.greenheart_config["site"]["wind_layout"]["turbine_spacing"] + ): + config.orbit_config["plant"].update( + {"turbine_spacing": config.greenheart_config["site"]["wind_layout"]["turbine_spacing"]} + ) warnings.warn(f"'turbine_spacing' in the orbit_config was {config.orbit_config['plant']['turbine_spacing']}, but 'turbine_spacing' in" f"greenheart_config was {config.greenheart_config['site']['wind_layout']['turbine_spacing']}. The 'turbine_spacing' value in the orbit_config" "is being overwritten with the value from the greenheart_config", UserWarning) - if config.orbit_config["plant"]["row_spacing"] != config.greenheart_config["site"]["wind_layout"]["row_spacing"]: - config.orbit_config["plant"].update( - { - "turbine_spacing": config.greenheart_config["site"]["wind_layout"][ - "turbine_spacing" - ] - } - ) - warnings.warn( - f"site depth in the orbit_config was {config.orbit_config['plant']['turbine_spacing']}, but 'turbine_spacing' in" - f"greenheart_config was {config.greenheart_config['site']['wind_layout']['turbine_spacing']}. The 'turbine_spacing' value in the orbit_config" - "is being overwritten with the value from the greenheart_config", - UserWarning, - ) - if ( config.orbit_config["plant"]["row_spacing"] != config.greenheart_config["site"]["wind_layout"]["row_spacing"] @@ -302,7 +295,7 @@ def setup_greenheart_simulation(config: GreenHeartSimulationConfig): } ) warnings.warn( - f"site depth in the orbit_config was {config.orbit_config['plant']['row_spacing']}, but 'row_spacing' in" + f"'row_spacing' in the orbit_config was {config.orbit_config['plant']['row_spacing']}, but 'row_spacing' in" f"greenheart_config was {config.greenheart_config['site']['wind_layout']['row_spacing']}. The 'row_spacing' value in the orbit_config" "is being overwritten with the value from the greenheart_config", UserWarning, diff --git a/greenheart/tools/optimization/gc_PoseOptimization.py b/greenheart/tools/optimization/gc_PoseOptimization.py index bd0cbee3d..16a9716a5 100644 --- a/greenheart/tools/optimization/gc_PoseOptimization.py +++ b/greenheart/tools/optimization/gc_PoseOptimization.py @@ -77,8 +77,9 @@ def get_number_design_variables(self): n_DV += self.config.hopp_config["technologies"]["wind"]["num_turbines"] # Wrap-up at end with multiplier for finite differencing - if self.config.greenheart_config["opt_options"]["driver"]["optimization"]["form"] == "central": # TODO this should probably be handled at the MPI point to avoid confusion with n_DV being double what would be expected - n_DV *= 2 + if "form" in self.config.greenheart_config["opt_options"]["driver"]["optimization"].keys(): + if self.config.greenheart_config["opt_options"]["driver"]["optimization"]["form"] == "central": # TODO this should probably be handled at the MPI point to avoid confusion with n_DV being double what would be expected + n_DV *= 2 return n_DV diff --git a/hopp/simulation/technologies/dispatch/__init__.py b/hopp/simulation/technologies/dispatch/__init__.py index 4bd6aace3..1a2b6c417 100644 --- a/hopp/simulation/technologies/dispatch/__init__.py +++ b/hopp/simulation/technologies/dispatch/__init__.py @@ -1,12 +1,26 @@ from hopp.simulation.technologies.dispatch.power_sources.pv_dispatch import PvDispatch -from hopp.simulation.technologies.dispatch.power_sources.wind_dispatch import WindDispatch +from hopp.simulation.technologies.dispatch.power_sources.wind_dispatch import ( + WindDispatch, +) from hopp.simulation.technologies.dispatch.power_sources.csp_dispatch import CspDispatch -from hopp.simulation.technologies.dispatch.power_sources.trough_dispatch import TroughDispatch -from hopp.simulation.technologies.dispatch.power_sources.tower_dispatch import TowerDispatch -from hopp.simulation.technologies.dispatch.power_sources.wave_dispatch import WaveDispatch +from hopp.simulation.technologies.dispatch.power_sources.trough_dispatch import ( + TroughDispatch, +) +from hopp.simulation.technologies.dispatch.power_sources.tower_dispatch import ( + TowerDispatch, +) +from hopp.simulation.technologies.dispatch.power_sources.wave_dispatch import ( + WaveDispatch, +) from hopp.simulation.technologies.dispatch.grid_dispatch import GridDispatch -from hopp.simulation.technologies.dispatch.hybrid_dispatch_options import HybridDispatchOptions +from hopp.simulation.technologies.dispatch.hybrid_dispatch_options import ( + HybridDispatchOptions, +) from hopp.simulation.technologies.dispatch.hybrid_dispatch import HybridDispatch -from hopp.simulation.technologies.dispatch.dispatch_problem_state import DispatchProblemState -from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch import SimpleBatteryDispatch +from hopp.simulation.technologies.dispatch.dispatch_problem_state import ( + DispatchProblemState, +) +from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch import ( + SimpleBatteryDispatch, +) diff --git a/hopp/simulation/technologies/dispatch/dispatch.py b/hopp/simulation/technologies/dispatch/dispatch.py index 48791afc6..c2f6e9175 100644 --- a/hopp/simulation/technologies/dispatch/dispatch.py +++ b/hopp/simulation/technologies/dispatch/dispatch.py @@ -4,18 +4,22 @@ try: u.USD except AttributeError: - u.load_definitions_from_strings(['USD = [currency]', 'lifecycle = [energy] / [energy]']) + u.load_definitions_from_strings( + ["USD = [currency]", "lifecycle = [energy] / [energy]"] + ) + class Dispatch: - """ + """ """ - """ - def __init__(self, - pyomo_model: pyomo.ConcreteModel, - index_set: pyomo.Set, - system_model, - financial_model, - block_set_name: str = 'dispatch'): + def __init__( + self, + pyomo_model: pyomo.ConcreteModel, + index_set: pyomo.Set, + system_model, + financial_model, + block_set_name: str = "dispatch", + ): self.block_set_name = block_set_name self.round_digits = int(4) @@ -29,13 +33,19 @@ def __init__(self, @staticmethod def dispatch_block_rule(block, t): - raise NotImplemented("This function must be overridden for specific dispatch model") + raise NotImplemented( + "This function must be overridden for specific dispatch model" + ) def initialize_parameters(self): - raise NotImplemented("This function must be overridden for specific dispatch model") + raise NotImplemented( + "This function must be overridden for specific dispatch model" + ) def update_time_series_parameters(self, start_time: int): - raise NotImplemented("This function must be overridden for specific dispatch model") + raise NotImplemented( + "This function must be overridden for specific dispatch model" + ) @staticmethod def _check_efficiency_value(efficiency): diff --git a/hopp/simulation/technologies/dispatch/dispatch_problem_state.py b/hopp/simulation/technologies/dispatch/dispatch_problem_state.py index 2d30493f9..c57db9a7c 100644 --- a/hopp/simulation/technologies/dispatch/dispatch_problem_state.py +++ b/hopp/simulation/technologies/dispatch/dispatch_problem_state.py @@ -18,7 +18,9 @@ def __init__(self): self._gap = () self._n_non_optimal_solves = 0 - def store_problem_metrics(self, solver_results, start_time, n_days, objective_value): + def store_problem_metrics( + self, solver_results, start_time, n_days, objective_value + ): self.start_time = start_time self.n_days = n_days self.termination_condition = str(solver_results.solver.termination_condition) @@ -35,20 +37,24 @@ def store_problem_metrics(self, solver_results, start_time, n_days, objective_va # solver_results.solution.Gap not define if solver_results.problem.upper_bound != 0.0: - self.gap = (abs(solver_results.problem.upper_bound - solver_results.problem.lower_bound) - / abs(solver_results.problem.upper_bound)) + self.gap = abs( + solver_results.problem.upper_bound - solver_results.problem.lower_bound + ) / abs(solver_results.problem.upper_bound) elif solver_results.problem.lower_bound == 0.0: self.gap = 0.0 else: - self.gap = float('inf') + self.gap = float("inf") - if not solver_results.solver.termination_condition == TerminationCondition.optimal: + if ( + not solver_results.solver.termination_condition + == TerminationCondition.optimal + ): self._n_non_optimal_solves += 1 def _update_metric(self, metric_name, value): data = list(getattr(self, metric_name)) data.append(value) - setattr(self, '_' + metric_name, tuple(data)) + setattr(self, "_" + metric_name, tuple(data)) @property def start_time(self) -> tuple: @@ -56,7 +62,7 @@ def start_time(self) -> tuple: @start_time.setter def start_time(self, start_hour: int): - self._update_metric('start_time', start_hour) + self._update_metric("start_time", start_hour) @property def n_days(self) -> tuple: @@ -64,7 +70,7 @@ def n_days(self) -> tuple: @n_days.setter def n_days(self, solve_days: int): - self._update_metric('n_days', solve_days) + self._update_metric("n_days", solve_days) @property def termination_condition(self) -> tuple: @@ -72,7 +78,7 @@ def termination_condition(self) -> tuple: @termination_condition.setter def termination_condition(self, condition: str): - self._update_metric('termination_condition', condition) + self._update_metric("termination_condition", condition) @property def solve_time(self) -> tuple: @@ -80,7 +86,7 @@ def solve_time(self) -> tuple: @solve_time.setter def solve_time(self, time: float): - self._update_metric('solve_time', time) + self._update_metric("solve_time", time) @property def objective(self) -> tuple: @@ -88,7 +94,7 @@ def objective(self) -> tuple: @objective.setter def objective(self, objective_value: float): - self._update_metric('objective', objective_value) + self._update_metric("objective", objective_value) @property def upper_bound(self) -> tuple: @@ -96,7 +102,7 @@ def upper_bound(self) -> tuple: @upper_bound.setter def upper_bound(self, bound: float): - self._update_metric('upper_bound', bound) + self._update_metric("upper_bound", bound) @property def lower_bound(self) -> tuple: @@ -104,7 +110,7 @@ def lower_bound(self) -> tuple: @lower_bound.setter def lower_bound(self, bound: float): - self._update_metric('lower_bound', bound) + self._update_metric("lower_bound", bound) @property def constraints(self) -> tuple: @@ -112,7 +118,7 @@ def constraints(self) -> tuple: @constraints.setter def constraints(self, constraint_count: int): - self._update_metric('constraints', constraint_count) + self._update_metric("constraints", constraint_count) @property def variables(self) -> tuple: @@ -120,7 +126,7 @@ def variables(self) -> tuple: @variables.setter def variables(self, variable_count: int): - self._update_metric('variables', variable_count) + self._update_metric("variables", variable_count) @property def non_zeros(self) -> tuple: @@ -128,7 +134,7 @@ def non_zeros(self) -> tuple: @non_zeros.setter def non_zeros(self, non_zeros_count: int): - self._update_metric('non_zeros', non_zeros_count) + self._update_metric("non_zeros", non_zeros_count) @property def gap(self) -> tuple: @@ -136,7 +142,7 @@ def gap(self) -> tuple: @gap.setter def gap(self, mip_gap: int): - self._update_metric('gap', mip_gap) + self._update_metric("gap", mip_gap) @property def n_non_optimal_solves(self) -> int: diff --git a/hopp/simulation/technologies/dispatch/grid_dispatch.py b/hopp/simulation/technologies/dispatch/grid_dispatch.py index c3c9b270a..21f79f581 100644 --- a/hopp/simulation/technologies/dispatch/grid_dispatch.py +++ b/hopp/simulation/technologies/dispatch/grid_dispatch.py @@ -1,3 +1,5 @@ +from typing import Union + import pyomo.environ as pyomo from pyomo.network import Port, Arc from pyomo.environ import units as u @@ -6,24 +8,29 @@ class GridDispatch(Dispatch): + grid_obj: Union[pyomo.Expression, float] _model: pyomo.ConcreteModel _blocks: pyomo.Block """ """ - def __init__(self, - pyomo_model: pyomo.ConcreteModel, - index_set: pyomo.Set, - system_model, - financial_model, - block_set_name: str = 'grid'): + def __init__( + self, + pyomo_model: pyomo.ConcreteModel, + index_set: pyomo.Set, + system_model, + financial_model, + block_set_name: str = "grid", + ): - super().__init__(pyomo_model, - index_set, - system_model, - financial_model, - block_set_name=block_set_name) + super().__init__( + pyomo_model, + index_set, + system_model, + financial_model, + block_set_name=block_set_name, + ) def dispatch_block_rule(self, grid): # Parameters @@ -35,6 +42,82 @@ def dispatch_block_rule(self, grid): # Ports self._create_grid_ports(grid) + def max_gross_profit_objective(self, hybrid_blocks): + self.obj = pyomo.Expression( + expr=sum( + hybrid_blocks[t].time_weighting_factor + * self.blocks[t].time_duration + * self.blocks[t].electricity_sell_price + * hybrid_blocks[t].electricity_sold + - (1 / hybrid_blocks[t].time_weighting_factor) + * self.blocks[t].time_duration + * self.blocks[t].electricity_purchase_price + * hybrid_blocks[t].electricity_purchased + - self.blocks[t].epsilon * self.blocks[t].is_generating + for t in hybrid_blocks.index_set() + ) + ) + + def min_operating_cost_objective(self, hybrid_blocks): + self.obj = sum( + hybrid_blocks[t].time_weighting_factor + * self.blocks[t].time_duration + * self.blocks[t].electricity_sell_price + * ( + self.blocks[t].generation_transmission_limit + - hybrid_blocks[t].electricity_sold + ) + + ( + hybrid_blocks[t].time_weighting_factor + * self.blocks[t].time_duration + * self.blocks[t].electricity_purchase_price + * hybrid_blocks[t].electricity_purchased + ) + + (self.blocks[t].epsilon * self.blocks[t].is_generating) + for t in hybrid_blocks.index_set() + ) + + def _create_variables(self, hybrid): + hybrid.system_generation = pyomo.Var( + doc="System generation [MW]", + domain=pyomo.NonNegativeReals, + units=u.MW, + ) + hybrid.system_load = pyomo.Var( + doc="System load [MW]", + domain=pyomo.NonNegativeReals, + units=u.MW, + ) + hybrid.electricity_sold = pyomo.Var( + doc="Electricity sold [MW]", + domain=pyomo.NonNegativeReals, + units=u.MW, + ) + hybrid.electricity_purchased = pyomo.Var( + doc="Electricity purchased [MW]", + domain=pyomo.NonNegativeReals, + units=u.MW, + ) + + return 0, 0 + + def _create_port(self, hybrid): + hybrid.grid_port = Port( + initialize={ + "system_generation": hybrid.system_generation, + "system_load": hybrid.system_load, + "electricity_sold": hybrid.electricity_sold, + "electricity_purchased": hybrid.electricity_purchased, + } + ) + return hybrid.grid_port + + def _create_constraints(self, hybrid, t): + hybrid.generation_total = pyomo.Constraint( + doc="hybrid system generation total", + rule=hybrid.system_generation == sum(self.power_source_gen_vars[t]), + ) + @staticmethod def _create_grid_parameters(grid): ################################## @@ -45,37 +128,43 @@ def _create_grid_parameters(grid): default=1e-3, within=pyomo.NonNegativeReals, mutable=True, - units=u.USD) + units=u.USD, + ) grid.time_duration = pyomo.Param( doc="Time step [hour]", default=1.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.hr) + units=u.hr, + ) grid.electricity_sell_price = pyomo.Param( doc="Electricity sell price [$/MWh]", default=0.0, within=pyomo.Reals, mutable=True, - units=u.USD / u.MWh) + units=u.USD / u.MWh, + ) grid.electricity_purchase_price = pyomo.Param( doc="Electricity purchase price [$/MWh]", default=0.0, within=pyomo.Reals, mutable=True, - units=u.USD / u.MWh) + units=u.USD / u.MWh, + ) grid.generation_transmission_limit = pyomo.Param( doc="Grid transmission limit for generation [MW]", default=1000.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MW) + units=u.MW, + ) grid.load_transmission_limit = pyomo.Param( doc="Grid transmission limit for load [MW]", default=1000.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MW) + units=u.MW, + ) @staticmethod def _create_grid_variables(grid): @@ -83,27 +172,26 @@ def _create_grid_variables(grid): # Variables # ################################## grid.system_generation = pyomo.Var( - doc="System generation [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW) + doc="System generation [MW]", domain=pyomo.NonNegativeReals, units=u.MW + ) grid.system_load = pyomo.Var( - doc="System load [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW) + doc="System load [MW]", domain=pyomo.NonNegativeReals, units=u.MW + ) grid.electricity_sold = pyomo.Var( doc="Electricity sold to the grid [MW]", domain=pyomo.NonNegativeReals, bounds=(0, grid.generation_transmission_limit), - units=u.MW) + units=u.MW, + ) grid.electricity_purchased = pyomo.Var( doc="Electricity purchased from the grid [MW]", domain=pyomo.NonNegativeReals, bounds=(0, grid.load_transmission_limit), - units=u.MW) + units=u.MW, + ) grid.is_generating = pyomo.Var( - doc="System is generating power", - domain=pyomo.Binary, - units=u.dimensionless) + doc="System is generating power", domain=pyomo.Binary, units=u.dimensionless + ) @staticmethod def _create_grid_constraints(grid): @@ -112,15 +200,22 @@ def _create_grid_constraints(grid): ################################## grid.balance = pyomo.Constraint( doc="Transmission energy balance", - expr=grid.electricity_sold - grid.electricity_purchased == grid.system_generation - grid.system_load + expr=( + grid.electricity_sold - grid.electricity_purchased + == grid.system_generation - grid.system_load + ), ) grid.sales_transmission_limit = pyomo.Constraint( doc="Transmission limit on electricity sales", - expr=grid.electricity_sold <= grid.generation_transmission_limit * grid.is_generating + expr=grid.electricity_sold + <= grid.generation_transmission_limit * grid.is_generating, ) grid.purchases_transmission_limit = pyomo.Constraint( doc="Transmission limit on electricity purchases", - expr=grid.electricity_purchased <= grid.load_transmission_limit * (1 - grid.is_generating) + expr=( + grid.electricity_purchased + <= grid.load_transmission_limit * (1 - grid.is_generating) + ), ) @staticmethod @@ -135,9 +230,13 @@ def _create_grid_ports(grid): grid.port.add(grid.electricity_purchased) def initialize_parameters(self): - grid_limit_kw = self._system_model.value('grid_interconnection_limit_kwac') - self.generation_transmission_limit = [grid_limit_kw / 1e3] * len(self.blocks.index_set()) - self.load_transmission_limit = [grid_limit_kw / 1e3] * len(self.blocks.index_set()) + grid_limit_kw = self._system_model.value("grid_interconnection_limit_kwac") + self.generation_transmission_limit = [grid_limit_kw / 1e3] * len( + self.blocks.index_set() + ) + self.load_transmission_limit = [grid_limit_kw / 1e3] * len( + self.blocks.index_set() + ) def update_time_series_parameters(self, start_time: int): n_horizon = len(self.blocks.index_set()) @@ -145,58 +244,85 @@ def update_time_series_parameters(self, start_time: int): ppa_price = self._financial_model.value("ppa_price_input")[0] if start_time + n_horizon > len(dispatch_factors): prices = list(dispatch_factors[start_time:]) - prices.extend(list(dispatch_factors[0:n_horizon - len(prices)])) + prices.extend(list(dispatch_factors[0 : n_horizon - len(prices)])) else: - prices = dispatch_factors[start_time:start_time + n_horizon] + prices = dispatch_factors[start_time : start_time + n_horizon] # NOTE: Assuming the same prices - self.electricity_sell_price = [norm_price * ppa_price * 1e3 for norm_price in prices] - self.electricity_purchase_price = [norm_price * ppa_price * 1e3 for norm_price in prices] + self.electricity_sell_price = [ + norm_price * ppa_price * 1e3 for norm_price in prices + ] + self.electricity_purchase_price = [ + norm_price * ppa_price * 1e3 for norm_price in prices + ] @property def electricity_sell_price(self) -> list: - return [self.blocks[t].electricity_sell_price.value for t in self.blocks.index_set()] + return [ + self.blocks[t].electricity_sell_price.value for t in self.blocks.index_set() + ] @electricity_sell_price.setter def electricity_sell_price(self, price_per_mwh: list): if len(price_per_mwh) == len(self.blocks): for t, price in zip(self.blocks, price_per_mwh): - self.blocks[t].electricity_sell_price.set_value(round(price, self.round_digits)) + self.blocks[t].electricity_sell_price.set_value( + round(price, self.round_digits) + ) else: - raise ValueError("'price_per_mwh' list must be the same length as time horizon") + raise ValueError( + "'price_per_mwh' list must be the same length as time horizon" + ) @property def electricity_purchase_price(self) -> list: - return [self.blocks[t].electricity_purchase_price.value for t in self.blocks.index_set()] + return [ + self.blocks[t].electricity_purchase_price.value + for t in self.blocks.index_set() + ] @electricity_purchase_price.setter def electricity_purchase_price(self, price_per_mwh: list): if len(price_per_mwh) == len(self.blocks): for t, price in zip(self.blocks, price_per_mwh): - self.blocks[t].electricity_purchase_price.set_value(round(price, self.round_digits)) + self.blocks[t].electricity_purchase_price.set_value( + round(price, self.round_digits) + ) else: - raise ValueError("'price_per_mwh' list must be the same length as time horizon") + raise ValueError( + "'price_per_mwh' list must be the same length as time horizon" + ) @property def generation_transmission_limit(self) -> list: - return [self.blocks[t].generation_transmission_limit.value for t in self.blocks.index_set()] + return [ + self.blocks[t].generation_transmission_limit.value + for t in self.blocks.index_set() + ] @generation_transmission_limit.setter def generation_transmission_limit(self, limit_mw: list): if len(limit_mw) == len(self.blocks): for t, limit in zip(self.blocks, limit_mw): - self.blocks[t].generation_transmission_limit.set_value(round(limit, self.round_digits)) + self.blocks[t].generation_transmission_limit.set_value( + round(limit, self.round_digits) + ) else: raise ValueError("'limit_mw' list must be the same length as time horizon") @property def load_transmission_limit(self) -> list: - return [self.blocks[t].load_transmission_limit.value for t in self.blocks.index_set()] + return [ + self.blocks[t].load_transmission_limit.value + for t in self.blocks.index_set() + ] @load_transmission_limit.setter def load_transmission_limit(self, limit_mw: list): if len(limit_mw) == len(self.blocks): for t, limit in zip(self.blocks, limit_mw): - self.blocks[t].load_transmission_limit.set_value(round(limit, self.round_digits)) + self.blocks[t].load_transmission_limit.set_value( + round(limit, self.round_digits) + ) else: raise ValueError("'limit_mw' list must be the same length as time horizon") @@ -230,7 +356,9 @@ def electricity_sold(self) -> list: @property def electricity_purchased(self) -> list: - return [self.blocks[t].electricity_purchased.value for t in self.blocks.index_set()] + return [ + self.blocks[t].electricity_purchased.value for t in self.blocks.index_set() + ] @property def is_generating(self) -> list: diff --git a/hopp/simulation/technologies/dispatch/hybrid_dispatch.py b/hopp/simulation/technologies/dispatch/hybrid_dispatch.py index b0412d266..ae9727f01 100644 --- a/hopp/simulation/technologies/dispatch/hybrid_dispatch.py +++ b/hopp/simulation/technologies/dispatch/hybrid_dispatch.py @@ -3,19 +3,22 @@ from pyomo.environ import units as u from hopp.simulation.technologies.dispatch.dispatch import Dispatch -from hopp.simulation.technologies.dispatch.hybrid_dispatch_options import HybridDispatchOptions +from hopp.simulation.technologies.dispatch.hybrid_dispatch_options import ( + HybridDispatchOptions, +) class HybridDispatch(Dispatch): - """ - - """ - def __init__(self, - pyomo_model: pyomo.ConcreteModel, - index_set: pyomo.Set, - power_sources: dict, - dispatch_options: HybridDispatchOptions = None, - block_set_name: str = 'hybrid'): + """ """ + + def __init__( + self, + pyomo_model: pyomo.ConcreteModel, + index_set: pyomo.Set, + power_sources: dict, + dispatch_options: HybridDispatchOptions = None, + block_set_name: str = "hybrid", + ): """ Parameters @@ -32,11 +35,13 @@ def __init__(self, self.ports = {key: [] for key in index_set} self.arcs = [] - super().__init__(pyomo_model, - index_set, - None, - None, - block_set_name=block_set_name) + super().__init__( + pyomo_model, + index_set, + None, + None, + block_set_name=block_set_name, + ) def dispatch_block_rule(self, hybrid, t): ################################## @@ -46,23 +51,11 @@ def dispatch_block_rule(self, hybrid, t): ################################## # Variables / Ports # ################################## - for tech in self.power_sources.keys(): - try: - getattr(self, "_create_" + tech + "_variables")(hybrid, t) - getattr(self, "_create_" + tech + "_port")(hybrid, t) - except AttributeError: - raise ValueError("'{}' is not supported in the hybrid dispatch model.".format(tech)) - except Exception as e: - raise RuntimeError("Error in setting up dispatch for {}: {}".format(tech, e)) + self._create_variables_and_ports(hybrid, t) ################################## # Constraints # ################################## - self._create_grid_constraints(hybrid, t) - if 'battery' in self.power_sources.keys(): - if self.options.pv_charging_only: - self._create_pv_battery_limitation(hybrid) - elif not self.options.grid_charging: - self._create_grid_battery_limitation(hybrid) + self._create_hybrid_constraints(hybrid, t) @staticmethod def _create_parameters(hybrid): @@ -71,165 +64,84 @@ def _create_parameters(hybrid): initialize=1.0, within=pyomo.PercentFraction, mutable=True, - units=u.dimensionless) - - def _create_pv_variables(self, hybrid, t): - hybrid.pv_generation = pyomo.Var( - doc="Power generation of photovoltaics [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW, - initialize=0.0) - self.power_source_gen_vars[t].append(hybrid.pv_generation) - - def _create_pv_port(self, hybrid, t): - hybrid.pv_port = Port(initialize={'generation': hybrid.pv_generation}) - self.ports[t].append(hybrid.pv_port) - - def _create_wind_variables(self, hybrid, t): - hybrid.wind_generation = pyomo.Var( - doc="Power generation of wind turbines [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW, - initialize=0.0) - self.power_source_gen_vars[t].append(hybrid.wind_generation) - - def _create_wind_port(self, hybrid, t): - hybrid.wind_port = Port(initialize={'generation': hybrid.wind_generation}) - self.ports[t].append(hybrid.wind_port) - - def _create_wave_variables(self, hybrid, t): - hybrid.wave_generation = pyomo.Var( - doc="Power generation of wave devices [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW, - initialize=0.0) - self.power_source_gen_vars[t].append(hybrid.wave_generation) - - def _create_wave_port(self, hybrid, t): - hybrid.wave_port = Port(initialize={'generation': hybrid.wave_generation}) - self.ports[t].append(hybrid.wave_port) - - def _create_tower_variables(self, hybrid, t): - hybrid.tower_generation = pyomo.Var( - doc="Power generation of CSP tower [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW, - initialize=0.0) - hybrid.tower_load = pyomo.Var( - doc="Load of CSP tower [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW, - initialize=0.0) - self.power_source_gen_vars[t].append(hybrid.tower_generation) - self.load_vars[t].append(hybrid.tower_load) - - def _create_tower_port(self, hybrid, t): - hybrid.tower_port = Port(initialize={'cycle_generation': hybrid.tower_generation, - 'system_load': hybrid.tower_load}) - self.ports[t].append(hybrid.tower_port) - - def _create_trough_variables(self, hybrid, t): - hybrid.trough_generation = pyomo.Var( - doc="Power generation of CSP trough [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW, - initialize=0.0) - hybrid.trough_load = pyomo.Var( - doc="Load of CSP trough [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW, - initialize=0.0) - self.power_source_gen_vars[t].append(hybrid.trough_generation) - self.load_vars[t].append(hybrid.trough_load) - - def _create_trough_port(self, hybrid, t): - hybrid.trough_port = Port(initialize={'cycle_generation': hybrid.trough_generation, - 'system_load': hybrid.trough_load}) - self.ports[t].append(hybrid.trough_port) - - def _create_battery_variables(self, hybrid, t): - hybrid.battery_charge = pyomo.Var( - doc="Power charging the electric battery [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW, - initialize=0.0) - hybrid.battery_discharge = pyomo.Var( - doc="Power discharging the electric battery [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW, - initialize=0.0) - self.power_source_gen_vars[t].append(hybrid.battery_discharge) - self.load_vars[t].append(hybrid.battery_charge) - - def _create_battery_port(self, hybrid, t): - hybrid.battery_port = Port(initialize={'charge_power': hybrid.battery_charge, - 'discharge_power': hybrid.battery_discharge}) - self.ports[t].append(hybrid.battery_port) + units=u.dimensionless, + ) - @staticmethod - def _create_grid_variables(hybrid, _): - hybrid.system_generation = pyomo.Var( - doc="System generation [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW) - hybrid.system_load = pyomo.Var( - doc="System load [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW) - hybrid.electricity_sold = pyomo.Var( - doc="Electricity sold [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW) - hybrid.electricity_purchased = pyomo.Var( - doc="Electricity purchased [MW]", - domain=pyomo.NonNegativeReals, - units=u.MW) - - def _create_grid_port(self, hybrid, t): - hybrid.grid_port = Port(initialize={'system_generation': hybrid.system_generation, - 'system_load': hybrid.system_load, - 'electricity_sold': hybrid.electricity_sold, - 'electricity_purchased': hybrid.electricity_purchased}) - self.ports[t].append(hybrid.grid_port) - - def _create_grid_constraints(self, hybrid, t): + def _create_variables_and_ports(self, hybrid, t): + for tech in self.power_sources.keys(): + try: + gen_var, load_var = self.power_sources[ + tech + ]._dispatch._create_variables(hybrid) + self.power_source_gen_vars[t].append(gen_var) + self.load_vars[t].append(load_var) + self.ports[t].append( + self.power_sources[tech]._dispatch._create_port(hybrid) + ) + except AttributeError: + raise ValueError( + "'{}' is not supported in the hybrid dispatch model.".format(tech) + ) + except Exception as e: + raise RuntimeError( + "Error in setting up dispatch for {}: {}".format(tech, e) + ) + + def _create_hybrid_constraints(self, hybrid, t): hybrid.generation_total = pyomo.Constraint( doc="hybrid system generation total", - rule=hybrid.system_generation == sum(self.power_source_gen_vars[t])) + rule=hybrid.system_generation == sum(self.power_source_gen_vars[t]), + ) hybrid.load_total = pyomo.Constraint( doc="hybrid system load total", - rule=hybrid.system_load == sum(self.load_vars[t])) + rule=hybrid.system_load == sum(self.load_vars[t]), + ) + + if "battery" in self.power_sources.keys(): + if self.options.pv_charging_only: + self._create_pv_battery_limitation(hybrid) + elif not self.options.grid_charging: + self._create_grid_battery_limitation(hybrid) @staticmethod def _create_grid_battery_limitation(hybrid): hybrid.no_grid_battery_charge = pyomo.Constraint( doc="Battery storage cannot charge via the grid", - expr=hybrid.system_generation >= hybrid.battery_charge) + expr=hybrid.system_generation >= hybrid.battery_charge, + ) @staticmethod def _create_pv_battery_limitation(hybrid): hybrid.only_pv_battery_charge = pyomo.Constraint( doc="Battery storage can only charge from pv", - expr=hybrid.pv_generation >= hybrid.battery_charge) + expr=hybrid.pv_generation >= hybrid.battery_charge, + ) def create_arcs(self): ################################## # Arcs # ################################## for tech in self.power_sources.keys(): + def arc_rule(m, t): source_port = self.power_sources[tech].dispatch.blocks[t].port destination_port = getattr(self.blocks[t], tech + "_port") - return {'source': source_port, 'destination': destination_port} + return {"source": source_port, "destination": destination_port} - setattr(self.model, tech + "_hybrid_arc", Arc(self.blocks.index_set(), rule=arc_rule)) + setattr( + self.model, + tech + "_hybrid_arc", + Arc(self.blocks.index_set(), rule=arc_rule), + ) self.arcs.append(getattr(self.model, tech + "_hybrid_arc")) pyomo.TransformationFactory("network.expand_arcs").apply_to(self.model) def initialize_parameters(self): - self.time_weighting_factor = self.options.time_weighting_factor # Discount factor + self.time_weighting_factor = ( + self.options.time_weighting_factor + ) # Discount factor for tech in self.power_sources.values(): tech.dispatch.initialize_parameters() @@ -244,150 +156,68 @@ def _delete_objective(self): def create_max_gross_profit_objective(self): self._delete_objective() - if 'grid' in self.power_sources.keys(): - tb = self.power_sources['grid'].dispatch.blocks - self.model.grid_obj = pyomo.Expression(expr= - sum(self.blocks[t].time_weighting_factor * tb[t].time_duration - * tb[t].electricity_sell_price * self.blocks[t].electricity_sold - - (1/self.blocks[t].time_weighting_factor) * tb[t].time_duration - * tb[t].electricity_purchase_price * self.blocks[t].electricity_purchased - - tb[t].epsilon * tb[t].is_generating - for t in self.blocks.index_set())) - - if 'pv' in self.power_sources.keys(): - tb = self.power_sources['pv'].dispatch.blocks - self.model.pv_obj = pyomo.Expression(expr= - sum(- (1/self.blocks[t].time_weighting_factor) - * tb[t].time_duration * tb[t].cost_per_generation * self.blocks[t].pv_generation - for t in self.blocks.index_set())) - - if 'wind' in self.power_sources.keys(): - tb = self.power_sources['wind'].dispatch.blocks - self.model.wind_obj = pyomo.Expression(expr= - sum(- (1/self.blocks[t].time_weighting_factor) - * tb[t].time_duration * tb[t].cost_per_generation * self.blocks[t].wind_generation - for t in self.blocks.index_set())) - - if 'wave' in self.power_sources.keys(): - tb = self.power_sources['wave'].dispatch.blocks - self.model.wave_obj = pyomo.Expression(expr= - sum(- (1/self.blocks[t].time_weighting_factor) - * tb[t].time_duration * tb[t].cost_per_generation * self.blocks[t].wave_generation - for t in self.blocks.index_set())) - - csp_techs = [i for i in ['tower', 'trough'] if i in self.power_sources.keys()] - for tech in csp_techs: - tb = self.power_sources[tech].dispatch.blocks - objective = pyomo.Expression(expr= - sum(- (1/self.blocks[t].time_weighting_factor) - * ((tb[t].cost_per_field_generation - * tb[t].receiver_thermal_power - * tb[t].time_duration) - + tb[t].cost_per_field_start * tb[t].incur_field_start - + (tb[t].cost_per_cycle_generation - * tb[t].cycle_generation - * tb[t].time_duration) - + tb[t].cost_per_cycle_start * tb[t].incur_cycle_start - + tb[t].cost_per_change_thermal_input * tb[t].cycle_thermal_ramp) - for t in self.blocks.index_set())) - setattr(self.model, tech + "_obj", objective) - - if 'battery' in self.power_sources.keys(): - def battery_profit_objective_rule(m): - objective = 0 - tb = self.power_sources['battery'].dispatch.blocks - objective += sum(- (1/self.blocks[t].time_weighting_factor) * tb[t].time_duration - * (tb[t].cost_per_charge * self.blocks[t].battery_charge - + tb[t].cost_per_discharge * self.blocks[t].battery_discharge) - for t in self.blocks.index_set()) - tb = self.power_sources['battery'].dispatch - if tb.options.include_lifecycle_count: - objective -= tb.model.lifecycle_cost * sum(tb.model.lifecycles) - return objective - self.model.battery_obj = pyomo.Expression(rule=battery_profit_objective_rule) - - def gross_profit_objective_rule(m): - obj = 0 + def gross_profit_objective_rule(m) -> float: + obj = 0.0 for tech in self.power_sources.keys(): + # Create the max_gross_profit_objective within each of the technology + # dispatch classes. + self.power_sources[tech]._dispatch.max_gross_profit_objective( + self.blocks + ) + # Copy the technology objective to the pyomo model. + setattr(m, tech + "_obj", self.power_sources[tech]._dispatch.obj) + # TODO: Does the objective really need to be stored on the self.model object? + # Trying to grab the attribute 'obj' from the dispatch classes + # themselves doesn't seem to work within pyomo, e.g.: + # `getattr(self.power_sources[tech]._dispatch, "obj")`. If we could avoid + # this, then the above `setattr` would not be needed. + + # Assemble the objective as a linear summation. obj += getattr(m, tech + "_obj") return obj self.model.objective = pyomo.Objective( - expr=gross_profit_objective_rule, - sense=pyomo.maximize) + expr=gross_profit_objective_rule, sense=pyomo.maximize + ) def create_min_operating_cost_objective(self): self._delete_objective() - def operating_cost_objective_rule(m): - objective = 0.0 + def operating_cost_objective_rule(m) -> float: + obj = 0.0 for tech in self.power_sources.keys(): - if tech == 'grid': - tb = self.power_sources[tech].dispatch.blocks - objective += sum(self.blocks[t].time_weighting_factor * tb[t].time_duration - * tb[t].electricity_sell_price * (tb[t].generation_transmission_limit - - self.blocks[t].electricity_sold) - + self.blocks[t].time_weighting_factor * tb[t].time_duration - * tb[t].electricity_purchase_price * self.blocks[t].electricity_purchased - + tb[t].epsilon * tb[t].is_generating - for t in self.blocks.index_set()) - elif tech == 'pv': - tb = self.power_sources[tech].dispatch.blocks - objective += sum(self.blocks[t].time_weighting_factor * tb[t].time_duration - * tb[t].cost_per_generation * self.blocks[t].pv_generation - for t in self.blocks.index_set()) - elif tech == 'wind': - tb = self.power_sources[tech].dispatch.blocks - objective += sum(self.blocks[t].time_weighting_factor * tb[t].time_duration - * tb[t].cost_per_generation * self.blocks[t].wind_generation - for t in self.blocks.index_set()) - elif tech == 'wave': - tb = self.power_sources[tech].dispatch.blocks - objective += sum(self.blocks[t].time_weighting_factor * tb[t].time_duration - * tb[t].cost_per_generation * self.blocks[t].wave_generation - for t in self.blocks.index_set()) - elif tech == 'tower' or tech == 'trough': - tb = self.power_sources[tech].dispatch.blocks - objective += sum(self.blocks[t].time_weighting_factor - * (tb[t].cost_per_field_start * tb[t].incur_field_start - - (tb[t].cost_per_field_generation - * tb[t].receiver_thermal_power - * tb[t].time_duration) # Trying to incentivize TES generation - + (tb[t].cost_per_cycle_generation - * tb[t].cycle_generation - * tb[t].time_duration) - + tb[t].cost_per_cycle_start * tb[t].incur_cycle_start - + tb[t].cost_per_change_thermal_input * tb[t].cycle_thermal_ramp) - for t in self.blocks.index_set()) - elif tech == 'battery': - tb = self.power_sources[tech].dispatch.blocks - objective += sum(self.blocks[t].time_weighting_factor * tb[t].time_duration - * (tb[t].cost_per_discharge * self.blocks[t].battery_discharge - - tb[t].cost_per_charge * self.blocks[t].battery_charge) - # Try to incentivize battery charging - for t in self.blocks.index_set()) - tb = self.power_sources['battery'].dispatch - if tb.options.include_lifecycle_count: - objective += tb.model.lifecycle_cost * tb.model.lifecycles - return objective + # Create the min_operating_cost_objective within each of the technology + # dispatch classes. + self.power_sources[tech]._dispatch.min_operating_cost_objective( + self.blocks + ) + + # Assemble the objective as a linear summation. + obj += self.power_sources[tech]._dispatch.obj + + return obj self.model.objective = pyomo.Objective( - rule=operating_cost_objective_rule, - sense=pyomo.minimize) + rule=operating_cost_objective_rule, sense=pyomo.minimize + ) @property def time_weighting_factor(self) -> float: for t in self.blocks.index_set(): - return self.blocks[t+1].time_weighting_factor.value + return self.blocks[t + 1].time_weighting_factor.value @time_weighting_factor.setter def time_weighting_factor(self, weighting: float): for t in self.blocks.index_set(): - self.blocks[t].time_weighting_factor = round(weighting ** t, self.round_digits) + self.blocks[t].time_weighting_factor = round( + weighting**t, self.round_digits + ) @property def time_weighting_factor_list(self) -> list: - return [self.blocks[t].time_weighting_factor.value for t in self.blocks.index_set()] + return [ + self.blocks[t].time_weighting_factor.value for t in self.blocks.index_set() + ] # Outputs @property @@ -405,7 +235,7 @@ def wind_generation(self) -> list: @property def wave_generation(self) -> list: return [self.blocks[t].wave_generation.value for t in self.blocks.index_set()] - + @property def tower_generation(self) -> list: return [self.blocks[t].tower_generation.value for t in self.blocks.index_set()] @@ -440,14 +270,22 @@ def system_load(self) -> list: @property def electricity_sales(self) -> list: - if 'grid' in self.power_sources: - tb = self.power_sources['grid'].dispatch.blocks - return [tb[t].time_duration.value * tb[t].electricity_sell_price.value - * self.blocks[t].electricity_sold.value for t in self.blocks.index_set()] + if "grid" in self.power_sources: + tb = self.power_sources["grid"].dispatch.blocks + return [ + tb[t].time_duration.value + * tb[t].electricity_sell_price.value + * self.blocks[t].electricity_sold.value + for t in self.blocks.index_set() + ] @property def electricity_purchases(self) -> list: - if 'grid' in self.power_sources: - tb = self.power_sources['grid'].dispatch.blocks - return [tb[t].time_duration.value * tb[t].electricity_purchase_price.value - * self.blocks[t].electricity_purchased.value for t in self.blocks.index_set()] \ No newline at end of file + if "grid" in self.power_sources: + tb = self.power_sources["grid"].dispatch.blocks + return [ + tb[t].time_duration.value + * tb[t].electricity_purchase_price.value + * self.blocks[t].electricity_purchased.value + for t in self.blocks.index_set() + ] diff --git a/hopp/simulation/technologies/dispatch/hybrid_dispatch_builder_solver.py b/hopp/simulation/technologies/dispatch/hybrid_dispatch_builder_solver.py index cbc5d694f..9367e17b0 100644 --- a/hopp/simulation/technologies/dispatch/hybrid_dispatch_builder_solver.py +++ b/hopp/simulation/technologies/dispatch/hybrid_dispatch_builder_solver.py @@ -7,7 +7,11 @@ from pyomo.util.check_units import assert_units_consistent from hopp.simulation.technologies.sites.site_info import SiteInfo -from hopp.simulation.technologies.dispatch import HybridDispatch, HybridDispatchOptions, DispatchProblemState +from hopp.simulation.technologies.dispatch import ( + HybridDispatch, + HybridDispatchOptions, + DispatchProblemState, +) from hopp.simulation.technologies.clustering import Clustering from hopp.utilities.log import hybrid_logger as logger @@ -15,10 +19,10 @@ class HybridDispatchBuilderSolver: """Helper class for building hybrid system dispatch problem, solving dispatch problem, and simulating system with dispatch solution.""" - def __init__(self, - site: SiteInfo, - power_sources: dict, - dispatch_options: dict = None): + + def __init__( + self, site: SiteInfo, power_sources: dict, dispatch_options: dict = None + ): """ Parameters @@ -37,7 +41,9 @@ def __init__(self, if os.path.isfile(self.options.log_name): os.remove(self.options.log_name) - self.needs_dispatch = any(item in ['battery', 'tower', 'trough'] for item in self.power_sources.keys()) + self.needs_dispatch = any( + item in ["battery", "tower", "trough"] for item in self.power_sources.keys() + ) if self.needs_dispatch: self._pyomo_model = self._create_dispatch_optimization_model() @@ -48,17 +54,27 @@ def __init__(self, self.dispatch.create_arcs() assert_units_consistent(self.pyomo_model) self.problem_state = DispatchProblemState() - + # Clustering (optional) self.clustering = None if self.options.use_clustering: - #TODO: Add resource data for wind - self.clustering = Clustering(power_sources.keys(), self.site.solar_resource.filename, wind_resource_data = None, price_data = self.site.elec_prices.data) + # TODO: Add resource data for wind + self.clustering = Clustering( + power_sources.keys(), + self.site.solar_resource.filename, + wind_resource_data=None, + price_data=self.site.elec_prices.data, + ) self.clustering.n_cluster = self.options.n_clusters if len(self.options.clustering_weights.keys()) == 0: self.clustering.use_default_weights = True - elif self.options.clustering_divisions.keys() != self.options.clustering_weights.keys(): - print ('Warning: Keys in user-specified dictionaries for clustering weights and divisions do not match. Reverting to default weights/divisions') + elif ( + self.options.clustering_divisions.keys() + != self.options.clustering_weights.keys() + ): + print( + "Warning: Keys in user-specified dictionaries for clustering weights and divisions do not match. Reverting to default weights/divisions" + ) self.clustering.use_default_weights = True else: self.clustering.weights = self.options.clustering_weights @@ -70,252 +86,331 @@ def _create_dispatch_optimization_model(self): """ Creates monolith dispatch model """ - model = pyomo.ConcreteModel(name='hybrid_dispatch') + model = pyomo.ConcreteModel(name="hybrid_dispatch") ################################# # Sets # ################################# - model.forecast_horizon = pyomo.Set(doc="Set of time periods in time horizon", - initialize=range(self.options.n_look_ahead_periods)) + model.forecast_horizon = pyomo.Set( + doc="Set of time periods in time horizon", + initialize=range(self.options.n_look_ahead_periods), + ) ################################# # Blocks (technologies) # ################################# module = getattr(__import__("hopp").simulation.technologies, "dispatch") for source, tech in self.power_sources.items(): - if source == 'battery': + if source == "battery": tech._dispatch = self.options.battery_dispatch_class( model, model.forecast_horizon, tech._system_model, tech._financial_model, block_set_name=source, - dispatch_options=self.options) + dispatch_options=self.options, + ) else: try: - dispatch_class_name = getattr(module, source.capitalize() + "Dispatch") + dispatch_class_name = getattr( + module, source.capitalize() + "Dispatch" + ) except AttributeError: - raise ValueError("Could not find {} in dispatch module. Is {} supported in the hybrid " - "dispatch model?".format(source.capitalize() + "Dispatch", source)) + raise ValueError( + "Could not find {} in dispatch module. Is {} supported in the hybrid " + "dispatch model?".format( + source.capitalize() + "Dispatch", source + ) + ) tech._dispatch = dispatch_class_name( model, model.forecast_horizon, tech._system_model, - tech._financial_model) + tech._financial_model, + ) self._dispatch = HybridDispatch( - model, - model.forecast_horizon, - self.power_sources, - self.options) + model, model.forecast_horizon, self.power_sources, self.options + ) return model def solve_dispatch_model(self, start_time: int, n_days: int): # Solve dispatch model - if self.options.solver == 'glpk': + if self.options.solver == "glpk": solver_results = self.glpk_solve() - elif self.options.solver == 'cbc': + elif self.options.solver == "cbc": solver_results = self.cbc_solve() - elif self.options.solver == 'xpress': + elif self.options.solver == "xpress": solver_results = self.xpress_solve() - elif self.options.solver == 'xpress_persistent': + elif self.options.solver == "xpress_persistent": solver_results = self.xpress_persistent_solve() - elif self.options.solver == 'gurobi_ampl': + elif self.options.solver == "gurobi_ampl": solver_results = self.gurobi_ampl_solve() - elif self.options.solver == 'gurobi': + elif self.options.solver == "gurobi": solver_results = self.gurobi_solve() else: raise ValueError("{} is not a supported solver".format(self.options.solver)) - self.problem_state.store_problem_metrics(solver_results, start_time, n_days, - self.dispatch.objective_value) + self.problem_state.store_problem_metrics( + solver_results, start_time, n_days, self.dispatch.objective_value + ) @staticmethod - def glpk_solve_call(pyomo_model: pyomo.ConcreteModel, - log_name: str = "", - user_solver_options: dict = None): + def glpk_solve_call( + pyomo_model: pyomo.ConcreteModel, + log_name: str = "", + user_solver_options: dict = None, + ): # log_name = "annual_solve_GLPK.log" # For debugging MILP solver # Ref. on solver options: https://en.wikibooks.org/wiki/GLPK/Using_GLPSOL - glpk_solver_options = {'cuts': None, - 'presol': None, - # 'mostf': None, - # 'mipgap': 0.001, - 'tmlim': 30 - } - solver_options = SolverOptions(glpk_solver_options, log_name, user_solver_options,'log') - with pyomo.SolverFactory('glpk') as solver: + glpk_solver_options = { + "cuts": None, + "presol": None, + # 'mostf': None, + # 'mipgap': 0.001, + "tmlim": 30, + } + solver_options = SolverOptions( + glpk_solver_options, log_name, user_solver_options, "log" + ) + with pyomo.SolverFactory("glpk") as solver: results = solver.solve(pyomo_model, options=solver_options.constructed) - HybridDispatchBuilderSolver.log_and_solution_check(log_name, solver_options.instance_log, results.solver.termination_condition, pyomo_model) + HybridDispatchBuilderSolver.log_and_solution_check( + log_name, + solver_options.instance_log, + results.solver.termination_condition, + pyomo_model, + ) return results - + def glpk_solve(self): - return HybridDispatchBuilderSolver.glpk_solve_call(self.pyomo_model, - self.options.log_name, - self.options.solver_options) - + return HybridDispatchBuilderSolver.glpk_solve_call( + self.pyomo_model, self.options.log_name, self.options.solver_options + ) + @staticmethod - def gurobi_ampl_solve_call(pyomo_model: pyomo.ConcreteModel, - log_name: str = "", - user_solver_options: dict = None): + def gurobi_ampl_solve_call( + pyomo_model: pyomo.ConcreteModel, + log_name: str = "", + user_solver_options: dict = None, + ): # Ref. on solver options: https://www.gurobi.com/documentation/9.1/ampl-gurobi/parameters.html - gurobi_solver_options = {'timelim': 60, - 'threads': 1} - solver_options = SolverOptions(gurobi_solver_options, log_name, user_solver_options,'logfile') - - with pyomo.SolverFactory('gurobi', executable='/opt/solvers/gurobi', solver_io='nl') as solver: + gurobi_solver_options = {"timelim": 60, "threads": 1} + solver_options = SolverOptions( + gurobi_solver_options, log_name, user_solver_options, "logfile" + ) + + with pyomo.SolverFactory( + "gurobi", executable="/opt/solvers/gurobi", solver_io="nl" + ) as solver: results = solver.solve(pyomo_model, options=solver_options.constructed) - HybridDispatchBuilderSolver.log_and_solution_check(log_name, solver_options.instance_log, results.solver.termination_condition, pyomo_model) + HybridDispatchBuilderSolver.log_and_solution_check( + log_name, + solver_options.instance_log, + results.solver.termination_condition, + pyomo_model, + ) return results def gurobi_ampl_solve(self): - return HybridDispatchBuilderSolver.gurobi_ampl_solve_call(self.pyomo_model, - self.options.log_name, - self.options.solver_options) - + return HybridDispatchBuilderSolver.gurobi_ampl_solve_call( + self.pyomo_model, self.options.log_name, self.options.solver_options + ) + @staticmethod - def gurobi_solve_call(opt: pyomo.SolverFactory, - pyomo_model: pyomo.ConcreteModel, - log_name: str = "", - user_solver_options: dict = None): + def gurobi_solve_call( + opt: pyomo.SolverFactory, + pyomo_model: pyomo.ConcreteModel, + log_name: str = "", + user_solver_options: dict = None, + ): # Ref. on solver options: https://www.gurobi.com/documentation/9.1/ampl-gurobi/parameters.html - gurobi_solver_options = {'timelim': 60, - 'threads': 1} - solver_options = SolverOptions(gurobi_solver_options, log_name, user_solver_options,'logfile') - + gurobi_solver_options = {"timelim": 60, "threads": 1} + solver_options = SolverOptions( + gurobi_solver_options, log_name, user_solver_options, "logfile" + ) + opt.options.update(solver_options.constructed) opt.set_instance(pyomo_model) results = opt.solve(save_results=False) - HybridDispatchBuilderSolver.log_and_solution_check(log_name, solver_options.instance_log, results.solver.termination_condition, pyomo_model) + HybridDispatchBuilderSolver.log_and_solution_check( + log_name, + solver_options.instance_log, + results.solver.termination_condition, + pyomo_model, + ) return results def gurobi_solve(self): if self.opt is None: - self.opt = pyomo.SolverFactory('gurobi', solver_io='persistent') - - return HybridDispatchBuilderSolver.gurobi_solve_call(self.opt, - self.pyomo_model, - self.options.log_name, - self.options.solver_options) + self.opt = pyomo.SolverFactory("gurobi", solver_io="persistent") + + return HybridDispatchBuilderSolver.gurobi_solve_call( + self.opt, + self.pyomo_model, + self.options.log_name, + self.options.solver_options, + ) @staticmethod - def cbc_solve_call(pyomo_model: pyomo.ConcreteModel, - log_name: str = "", - user_solver_options: dict = None): + def cbc_solve_call( + pyomo_model: pyomo.ConcreteModel, + log_name: str = "", + user_solver_options: dict = None, + ): # log_name = "annual_solve_CBC.log" # Solver options can be found by launching executable 'start cbc.exe', verbose 15, ? # https://coin-or.github.io/Cbc/faq.html (a bit outdated) - cbc_solver_options = { # 'ratioGap': 0.001, - 'seconds': 60} - solver_options = SolverOptions(cbc_solver_options, log_name, user_solver_options,'log') + cbc_solver_options = {"seconds": 60} # 'ratioGap': 0.001, + solver_options = SolverOptions( + cbc_solver_options, log_name, user_solver_options, "log" + ) - if sys.platform == 'win32' or sys.platform == 'cygwin': + if sys.platform == "win32" or sys.platform == "cygwin": cbc_path = Path(__file__).parent / "cbc_solver" / "cbc-win64" / "cbc" if log_name != "": - logger.warning("Warning: CBC solver logging is active... This will significantly increase simulation time.") - solver_options.constructed['log'] = 2 - solver = pyomo.SolverFactory('asl:cbc', executable=cbc_path) - results = solver.solve(pyomo_model, logfile=solver_options.instance_log, options=solver_options.constructed) + logger.warning( + "Warning: CBC solver logging is active... This will significantly increase simulation time." + ) + solver_options.constructed["log"] = 2 + solver = pyomo.SolverFactory("asl:cbc", executable=cbc_path) + results = solver.solve( + pyomo_model, + logfile=solver_options.instance_log, + options=solver_options.constructed, + ) else: - solver = pyomo.SolverFactory('cbc', executable=cbc_path, solver_io='nl') + solver = pyomo.SolverFactory("cbc", executable=cbc_path, solver_io="nl") results = solver.solve(pyomo_model, options=solver_options.constructed) - elif sys.platform == 'darwin' or sys.platform == 'linux': - solver = pyomo.SolverFactory('cbc') + elif sys.platform == "darwin" or sys.platform == "linux": + solver = pyomo.SolverFactory("cbc") results = solver.solve(pyomo_model, options=solver_options.constructed) else: - raise SystemError('Platform not supported ', sys.platform) - - HybridDispatchBuilderSolver.log_and_solution_check(log_name, solver_options.instance_log, results.solver.termination_condition, pyomo_model) + raise SystemError("Platform not supported ", sys.platform) + + HybridDispatchBuilderSolver.log_and_solution_check( + log_name, + solver_options.instance_log, + results.solver.termination_condition, + pyomo_model, + ) return results def cbc_solve(self): - return HybridDispatchBuilderSolver.cbc_solve_call(self.pyomo_model, - self.options.log_name, - self.options.solver_options) + return HybridDispatchBuilderSolver.cbc_solve_call( + self.pyomo_model, self.options.log_name, self.options.solver_options + ) @staticmethod - def xpress_solve_call(pyomo_model: pyomo.ConcreteModel, - log_name: str = "", - user_solver_options: dict = None): + def xpress_solve_call( + pyomo_model: pyomo.ConcreteModel, + log_name: str = "", + user_solver_options: dict = None, + ): # FIXME: Logging does not work # log_name = "annual_solve_Xpress.log" # For debugging MILP solver # Ref. on solver options: https://ampl.com/products/solvers/solvers-we-sell/xpress/options/ - xpress_solver_options = {'mipgap': 0.001, - 'maxtime': 30} - solver_options = SolverOptions(xpress_solver_options, log_name, user_solver_options,'LOGFILE') + xpress_solver_options = {"mipgap": 0.001, "maxtime": 30} + solver_options = SolverOptions( + xpress_solver_options, log_name, user_solver_options, "LOGFILE" + ) - with pyomo.SolverFactory('xpress_direct') as solver: + with pyomo.SolverFactory("xpress_direct") as solver: results = solver.solve(pyomo_model, options=solver_options.constructed) - HybridDispatchBuilderSolver.log_and_solution_check(log_name, solver_options.instance_log, results.solver.termination_condition, pyomo_model) + HybridDispatchBuilderSolver.log_and_solution_check( + log_name, + solver_options.instance_log, + results.solver.termination_condition, + pyomo_model, + ) return results def xpress_solve(self): - return HybridDispatchBuilderSolver.xpress_solve_call(self.pyomo_model, - self.options.log_name, - self.options.solver_options) + return HybridDispatchBuilderSolver.xpress_solve_call( + self.pyomo_model, self.options.log_name, self.options.solver_options + ) @staticmethod - def xpress_persistent_solve_call(opt: pyomo.SolverFactory, - pyomo_model: pyomo.ConcreteModel, - log_name: str = "", - user_solver_options: dict = None): + def xpress_persistent_solve_call( + opt: pyomo.SolverFactory, + pyomo_model: pyomo.ConcreteModel, + log_name: str = "", + user_solver_options: dict = None, + ): # log_name = "annual_solve_Xpress.log" # For debugging MILP solver # Ref. on solver options: https://ampl.com/products/solvers/solvers-we-sell/xpress/options/ - xpress_solver_options = {'mipgap': 0.001, - 'MAXTIME': 30} - solver_options = SolverOptions(xpress_solver_options, log_name, user_solver_options,'LOGFILE') + xpress_solver_options = {"mipgap": 0.001, "MAXTIME": 30} + solver_options = SolverOptions( + xpress_solver_options, log_name, user_solver_options, "LOGFILE" + ) opt.options.update(solver_options.constructed) opt.set_instance(pyomo_model) results = opt.solve(save_results=False) - HybridDispatchBuilderSolver.log_and_solution_check(log_name, solver_options.instance_log, results.solver.termination_condition, pyomo_model) + HybridDispatchBuilderSolver.log_and_solution_check( + log_name, + solver_options.instance_log, + results.solver.termination_condition, + pyomo_model, + ) return results def xpress_persistent_solve(self): if self.opt is None: - self.opt = pyomo.SolverFactory('xpress', solver_io='persistent') + self.opt = pyomo.SolverFactory("xpress", solver_io="persistent") + + return HybridDispatchBuilderSolver.xpress_persistent_solve_call( + self.opt, + self.pyomo_model, + self.options.log_name, + self.options.solver_options, + ) - return HybridDispatchBuilderSolver.xpress_persistent_solve_call(self.opt, - self.pyomo_model, - self.options.log_name, - self.options.solver_options) @staticmethod - def mindtpy_solve_call(pyomo_model: pyomo.ConcreteModel, - log_name: str = ""): + def mindtpy_solve_call(pyomo_model: pyomo.ConcreteModel, log_name: str = ""): raise NotImplementedError - solver = pyomo.SolverFactory('mindtpy') - results = solver.solve(pyomo_model, - mip_solver='glpk', - nlp_solver='ipopt', - tee=True) - - HybridDispatchBuilderSolver.log_and_solution_check("", "", results.solver.termination_condition, pyomo_model) + solver = pyomo.SolverFactory("mindtpy") + results = solver.solve( + pyomo_model, mip_solver="glpk", nlp_solver="ipopt", tee=True + ) + + HybridDispatchBuilderSolver.log_and_solution_check( + "", "", results.solver.termination_condition, pyomo_model + ) return results @staticmethod - def log_and_solution_check(log_name:str, solve_log: str, solver_termination_condition, pyomo_model): + def log_and_solution_check( + log_name: str, solve_log: str, solver_termination_condition, pyomo_model + ): if log_name != "": HybridDispatchBuilderSolver.append_solve_to_log(log_name, solve_log) - HybridDispatchBuilderSolver.check_solve_condition(solver_termination_condition, pyomo_model) + HybridDispatchBuilderSolver.check_solve_condition( + solver_termination_condition, pyomo_model + ) @staticmethod def check_solve_condition(solver_termination_condition, pyomo_model): if solver_termination_condition == TerminationCondition.infeasible: HybridDispatchBuilderSolver.print_infeasible_problem(pyomo_model) elif not solver_termination_condition == TerminationCondition.optimal: - logger.warning("Warning: Dispatch problem termination condition was '" - + str(solver_termination_condition) + "'") + logger.warning( + "Warning: Dispatch problem termination condition was '" + + str(solver_termination_condition) + + "'" + ) @staticmethod def append_solve_to_log(log_name: str, solve_log: str): # Appends single problem instance log to annual log file - fin = open(solve_log, 'r') + fin = open(solve_log, "r") data = fin.read() fin.close() - ann_log = open(log_name, 'a+') + ann_log = open(log_name, "a+") ann_log.write("=" * 50 + "\n") ann_log.write(data) ann_log.close() @@ -323,15 +418,17 @@ def append_solve_to_log(log_name: str, solve_log: str): @staticmethod def print_infeasible_problem(model: pyomo.ConcreteModel): original_stdout = sys.stdout - with open('infeasible_instance.txt', 'w') as f: + with open("infeasible_instance.txt", "w") as f: sys.stdout = f - print('\n' + '#' * 20 + ' Model Parameter Values ' + '#' * 20 + '\n') + print("\n" + "#" * 20 + " Model Parameter Values " + "#" * 20 + "\n") HybridDispatchBuilderSolver.print_all_parameters(model) - print('\n' + '#' * 20 + ' Model Blocks Display ' + '#' * 20 + '\n') + print("\n" + "#" * 20 + " Model Blocks Display " + "#" * 20 + "\n") HybridDispatchBuilderSolver.display_all_blocks(model) sys.stdout = original_stdout - raise ValueError("Dispatch optimization model is infeasible.\n" - "See 'infeasible_instance.txt' for parameter values.") + raise ValueError( + "Dispatch optimization model is infeasible.\n" + "See 'infeasible_instance.txt' for parameter values." + ) @staticmethod def print_all_parameters(model: pyomo.ConcreteModel): @@ -347,7 +444,9 @@ def print_all_parameters(model: pyomo.ConcreteModel): print("\nParent Block Name: ", block_name) print("Parameter: ", name_to_print) for index in parent_block.index_set(): - val_to_print = pyomo.value(getattr(parent_block[index], param_object.getname())) + val_to_print = pyomo.value( + getattr(parent_block[index], param_object.getname()) + ) print("\t", index, "\t", val_to_print) @staticmethod @@ -370,78 +469,138 @@ def simulate_power(self): # Solving the year in series for i, t in enumerate(ti): if self.options.is_test_start_year or self.options.is_test_end_year: - if (self.options.is_test_start_year and i < 5) or (self.options.is_test_end_year and i > 359): + if (self.options.is_test_start_year and i < 5) or ( + self.options.is_test_end_year and i > 359 + ): start_time = time.time() self.simulate_with_dispatch(t) sim_w_dispath_time = time.time() - logger.info('Day {} dispatch optimized.'.format(i)) - logger.info(" %6.2f seconds required to simulate with dispatch" % (sim_w_dispath_time - start_time)) + logger.info("Day {} dispatch optimized.".format(i)) + logger.info( + " %6.2f seconds required to simulate with dispatch" + % (sim_w_dispath_time - start_time) + ) else: continue # TODO: can we make the csp and battery model run with heuristic dispatch here? # Maybe calling a simulate_with_heuristic() method else: if (i % 73) == 0: - logger.info("\t {:.0f} % complete".format(i*20/73)) + logger.info("\t {:.0f} % complete".format(i * 20 / 73)) self.simulate_with_dispatch(t) else: - initial_states = {tech:{'day':[], 'soc':[], 'load':[]} for tech in ['trough', 'tower', 'battery'] if tech in self.power_sources.keys()} # List of known charge states at 12 am from completed simulations - npercluster = self.clustering.clusters['count'] - inds = sorted(range(len(npercluster)), key=npercluster.__getitem__) # Indicies to sort clusters by low-to-high number of days represented - for i in range(self.clustering.clusters['n_cluster']): + initial_states = { + tech: {"day": [], "soc": [], "load": []} + for tech in ["trough", "tower", "battery"] + if tech in self.power_sources.keys() + } # List of known charge states at 12 am from completed simulations + npercluster = self.clustering.clusters["count"] + inds = sorted( + range(len(npercluster)), key=npercluster.__getitem__ + ) # Indicies to sort clusters by low-to-high number of days represented + for i in range(self.clustering.clusters["n_cluster"]): j = inds[i] # cluster index time_start, time_stop = self.clustering.get_sim_start_end_times(j) - battery_soc = self.clustering.battery_soc_heuristic(j, initial_states['battery']) if 'battery' in self.power_sources.keys() else None + battery_soc = ( + self.clustering.battery_soc_heuristic(j, initial_states["battery"]) + if "battery" in self.power_sources.keys() + else None + ) # Set CSP initial states (need to do this prior to update_time_series_parameters() or update_initial_conditions(), both pull from the stored plant state) - for tech in ['trough', 'tower']: + for tech in ["trough", "tower"]: if tech in self.power_sources.keys(): - self.power_sources[tech].plant_state = self.power_sources[tech].set_initial_plant_state() # Reset to default initial state - csp_soc, is_cycle_on, initial_cycle_load = self.clustering.csp_initial_state_heuristic(j, self.power_sources[tech].solar_multiple, initial_states[tech]) - self.power_sources[tech].set_tes_soc(csp_soc) - self.power_sources[tech].set_cycle_state(is_cycle_on) + self.power_sources[tech].plant_state = self.power_sources[ + tech + ].set_initial_plant_state() # Reset to default initial state + csp_soc, is_cycle_on, initial_cycle_load = ( + self.clustering.csp_initial_state_heuristic( + j, + self.power_sources[tech].solar_multiple, + initial_states[tech], + ) + ) + self.power_sources[tech].set_tes_soc(csp_soc) + self.power_sources[tech].set_cycle_state(is_cycle_on) self.power_sources[tech].set_cycle_load(initial_cycle_load) - self.simulate_with_dispatch(time_start, self.clustering.ndays+1, battery_soc, n_initial_sims = 1) + self.simulate_with_dispatch( + time_start, self.clustering.ndays + 1, battery_soc, n_initial_sims=1 + ) # Update lists of known states at 12am - for tech in ['trough', 'tower', 'battery']: + for tech in ["trough", "tower", "battery"]: if tech in self.power_sources.keys(): for d in range(self.clustering.ndays): - day = self.clustering.sim_start_days[j]+d - initial_states[tech]['day'].append(day) - if tech in ['trough', 'tower']: - initial_states[tech]['soc'].append(self.power_sources[tech].get_tes_soc(day*24)) - initial_states[tech]['load'].append(self.power_sources[tech].get_cycle_load(day*24)) - elif tech in ['battery']: - step = day*24 * int(self.site.n_timesteps/8760) - initial_states[tech]['soc'].append(self.power_sources[tech].Outputs.SOC[step]) + day = self.clustering.sim_start_days[j] + d + initial_states[tech]["day"].append(day) + if tech in ["trough", "tower"]: + initial_states[tech]["soc"].append( + self.power_sources[tech].get_tes_soc(day * 24) + ) + initial_states[tech]["load"].append( + self.power_sources[tech].get_cycle_load(day * 24) + ) + elif tech in ["battery"]: + step = day * 24 * int(self.site.n_timesteps / 8760) + initial_states[tech]["soc"].append( + self.power_sources[tech].Outputs.SOC[step] + ) # After exemplar simulations, update to full annual generation array for dispatchable technologies for tech in self.power_sources.keys(): - if tech in ['battery']: - for key in ['gen', 'P', 'SOC']: + if tech in ["battery"]: + for key in ["gen", "P", "SOC"]: val = getattr(self.power_sources[tech].Outputs, key) - setattr(self.power_sources[tech].Outputs, key, list(self.clustering.compute_annual_array_from_cluster_exemplar_data(val))) - elif tech in ['trough', 'tower']: - for key in ['gen', 'P_out_net', 'P_cycle', 'q_dot_pc_startup', 'q_pc_startup', 'e_ch_tes', 'eta', 'q_pb']: # Data quantities used in capacity value calculations - self.power_sources[tech].outputs.ssc_time_series[key] = list(self.clustering.compute_annual_array_from_cluster_exemplar_data(self.power_sources[tech].outputs.ssc_time_series[key])) - - def simulate_with_dispatch(self, - start_time: int, - n_days: int = 1, - initial_soc: float = None, - n_initial_sims: int = 0): + setattr( + self.power_sources[tech].Outputs, + key, + list( + self.clustering.compute_annual_array_from_cluster_exemplar_data( + val + ) + ), + ) + elif tech in ["trough", "tower"]: + for key in [ + "gen", + "P_out_net", + "P_cycle", + "q_dot_pc_startup", + "q_pc_startup", + "e_ch_tes", + "eta", + "q_pb", + ]: # Data quantities used in capacity value calculations + self.power_sources[tech].outputs.ssc_time_series[key] = list( + self.clustering.compute_annual_array_from_cluster_exemplar_data( + self.power_sources[tech].outputs.ssc_time_series[key] + ) + ) + + def simulate_with_dispatch( + self, + start_time: int, + n_days: int = 1, + initial_soc: float = None, + n_initial_sims: int = 0, + ): # this is needed for clustering effort - update_dispatch_times = list(range(start_time, - start_time + n_days * self.site.n_periods_per_day, - self.options.n_roll_periods)) + update_dispatch_times = list( + range( + start_time, + start_time + n_days * self.site.n_periods_per_day, + self.options.n_roll_periods, + ) + ) for i, sim_start_time in enumerate(update_dispatch_times): # Update battery initial state of charge - if 'battery' in self.power_sources.keys(): - self.power_sources['battery'].dispatch.update_dispatch_initial_soc(initial_soc=initial_soc) + if "battery" in self.power_sources.keys(): + self.power_sources["battery"].dispatch.update_dispatch_initial_soc( + initial_soc=initial_soc + ) initial_soc = None for model in self.power_sources.values(): @@ -450,23 +609,38 @@ def simulate_with_dispatch(self, model.dispatch.update_time_series_parameters(sim_start_time) if self.site.follow_desired_schedule: - n_horizon = len(self.power_sources['grid'].dispatch.blocks.index_set()) + n_horizon = len(self.power_sources["grid"].dispatch.blocks.index_set()) if start_time + n_horizon > len(self.site.desired_schedule): system_limit = list(self.site.desired_schedule[start_time:]) - system_limit.extend(list(self.site.desired_schedule[0:n_horizon - len(system_limit)])) + system_limit.extend( + list( + self.site.desired_schedule[ + 0 : n_horizon - len(system_limit) + ] + ) + ) else: - system_limit = self.site.desired_schedule[start_time:start_time + n_horizon] - - transmission_limit = self.power_sources['grid'].value('grid_interconnection_limit_kwac') / 1e3 + system_limit = self.site.desired_schedule[ + start_time : start_time + n_horizon + ] + + transmission_limit = ( + self.power_sources["grid"].value("grid_interconnection_limit_kwac") + / 1e3 + ) for count, value in enumerate(system_limit): if value > transmission_limit: - logger.warning('Warning: Desired schedule is greater than transmission limit. ' - 'Overwriting schedule to transmission limit') + logger.warning( + "Warning: Desired schedule is greater than transmission limit. " + "Overwriting schedule to transmission limit" + ) system_limit[count] = transmission_limit - self.power_sources['grid'].dispatch.generation_transmission_limit = system_limit + self.power_sources["grid"].dispatch.generation_transmission_limit = ( + system_limit + ) - if 'heuristic' in self.options.battery_dispatch: + if "heuristic" in self.options.battery_dispatch: # TODO: this is not a good way to do this... This won't work with CSP addition... self.battery_heuristic() # TODO: we could just run the csp model without dispatch here @@ -480,51 +654,64 @@ def simulate_with_dispatch(self, battery_sim_start_time = None # simulate using dispatch solution - if 'battery' in self.power_sources.keys(): - self.power_sources['battery'].simulate_with_dispatch(self.options.n_roll_periods, - sim_start_time=battery_sim_start_time) - - if 'trough' in self.power_sources.keys(): - self.power_sources['trough'].simulate_with_dispatch(self.options.n_roll_periods, - sim_start_time=sim_start_time, - store_outputs=store_outputs) - if 'tower' in self.power_sources.keys(): - self.power_sources['tower'].simulate_with_dispatch(self.options.n_roll_periods, - sim_start_time=sim_start_time, - store_outputs=store_outputs) + if "battery" in self.power_sources.keys(): + self.power_sources["battery"].simulate_with_dispatch( + self.options.n_roll_periods, sim_start_time=battery_sim_start_time + ) + + if "trough" in self.power_sources.keys(): + self.power_sources["trough"].simulate_with_dispatch( + self.options.n_roll_periods, + sim_start_time=sim_start_time, + store_outputs=store_outputs, + ) + if "tower" in self.power_sources.keys(): + self.power_sources["tower"].simulate_with_dispatch( + self.options.n_roll_periods, + sim_start_time=sim_start_time, + store_outputs=store_outputs, + ) def battery_heuristic(self): - tot_gen = [0.0]*self.options.n_look_ahead_periods - if 'pv' in self.power_sources.keys(): - pv_gen = self.power_sources['pv'].dispatch.available_generation + tot_gen = [0.0] * self.options.n_look_ahead_periods + if "pv" in self.power_sources.keys(): + pv_gen = self.power_sources["pv"].dispatch.available_generation tot_gen = [pv + gen for pv, gen in zip(pv_gen, tot_gen)] - if 'wind' in self.power_sources.keys(): - wind_gen = self.power_sources['wind'].dispatch.available_generation + if "wind" in self.power_sources.keys(): + wind_gen = self.power_sources["wind"].dispatch.available_generation tot_gen = [wind + gen for wind, gen in zip(wind_gen, tot_gen)] - grid_limit = self.power_sources['grid'].dispatch.generation_transmission_limit + grid_limit = self.power_sources["grid"].dispatch.generation_transmission_limit - if 'one_cycle' in self.options.battery_dispatch: + if "one_cycle" in self.options.battery_dispatch: # Get prices for one cycle heuristic - prices = self.power_sources['grid'].dispatch.electricity_sell_price - self.power_sources['battery'].dispatch.prices = prices + prices = self.power_sources["grid"].dispatch.electricity_sell_price + self.power_sources["battery"].dispatch.prices = prices - if 'load_following' in self.options.battery_dispatch: + if "load_following" in self.options.battery_dispatch: # TODO: Look into how to define a system as load following or not in the config file - required_keys = ['desired_load'] + required_keys = ["desired_load"] if self.site.follow_desired_schedule: # Get difference between baseload demand and power generation and control scenario variables load_value = self.site.desired_schedule - load_difference = [(load_value[x] - tot_gen[x]) for x in range(len(tot_gen))] - self.power_sources['battery'].dispatch.load_difference = load_difference + load_difference = [ + (load_value[x] - tot_gen[x]) for x in range(len(tot_gen)) + ] + self.power_sources["battery"].dispatch.load_difference = load_difference else: - raise ValueError(type(self).__name__ + " requires the following : desired_schedule") - # Adding goal_power for the simple battery heuristic method for power setpoint tracking - goal_power = [load_value]*self.options.n_look_ahead_periods + raise ValueError( + type(self).__name__ + " requires the following : desired_schedule" + ) + # Adding goal_power for the simple battery heuristic method for power setpoint tracking + goal_power = [load_value] * self.options.n_look_ahead_periods ### Note: the inputs grid_limit and goal_power are in MW ### - self.power_sources['battery'].dispatch.set_fixed_dispatch(tot_gen, grid_limit, load_value) + self.power_sources["battery"].dispatch.set_fixed_dispatch( + tot_gen, grid_limit, load_value + ) else: - self.power_sources['battery'].dispatch.set_fixed_dispatch(tot_gen, grid_limit) + self.power_sources["battery"].dispatch.set_fixed_dispatch( + tot_gen, grid_limit + ) @property def pyomo_model(self) -> pyomo.ConcreteModel: @@ -534,15 +721,23 @@ def pyomo_model(self) -> pyomo.ConcreteModel: def dispatch(self) -> HybridDispatch: return self._dispatch + class SolverOptions: """Class for housing solver options""" - def __init__(self, solver_spec_options: dict, log_name: str="", user_solver_options: dict = None, solver_spec_log_key: str="logfile"): + + def __init__( + self, + solver_spec_options: dict, + log_name: str = "", + user_solver_options: dict = None, + solver_spec_log_key: str = "logfile", + ): self.instance_log = "dispatch_solver.log" self.solver_spec_options = solver_spec_options self.user_solver_options = user_solver_options - + self.constructed = solver_spec_options if log_name != "": self.constructed[solver_spec_log_key] = self.instance_log if user_solver_options is not None: - self.constructed.update(user_solver_options) \ No newline at end of file + self.constructed.update(user_solver_options) diff --git a/hopp/simulation/technologies/dispatch/hybrid_dispatch_options.py b/hopp/simulation/technologies/dispatch/hybrid_dispatch_options.py index 69c26f1da..d19a78e72 100644 --- a/hopp/simulation/technologies/dispatch/hybrid_dispatch_options.py +++ b/hopp/simulation/technologies/dispatch/hybrid_dispatch_options.py @@ -15,7 +15,7 @@ class HybridDispatchOptions: Class for setting dispatch options through HybridSimulation class. Args: - dispatch_options (dict): Contains attribute key-value pairs to change default options. + dispatch_options (dict): Contains attribute key-value pairs to change default options. - **solver** (str, default='cbc'): MILP solver used for dispatch optimization problem. Options are `('glpk', 'cbc', 'xpress', 'xpress_persistent', 'gurobi_ampl', 'gurobi')`. @@ -55,22 +55,27 @@ class HybridDispatchOptions: - **use_higher_hours** bool (default = False): if True, the simulation will run extra hours analysis (must be used with load following) - - **higher_hours** (dict, default = {}): Higher hour count parameters: the value of power that must be available above the schedule and the number of hours in a row + - **higher_hours** (dict, default = {}): Higher hour count parameters: the value of power that must be available above the schedule and the number of hours in a row """ + def __init__(self, dispatch_options: dict = None): - self.solver: str = 'cbc' - self.solver_options: dict = {} # used to update solver options, look at specific solver for option names - self.battery_dispatch: str = 'simple' + self.solver: str = "cbc" + self.solver_options: dict = ( + {} + ) # used to update solver options, look at specific solver for option names + self.battery_dispatch: str = "simple" self.include_lifecycle_count: bool = True - self.lifecycle_cost_per_kWh_cycle: float = 0.0265 # Estimated using SAM output (lithium-ion battery) + self.lifecycle_cost_per_kWh_cycle: float = ( + 0.0265 # Estimated using SAM output (lithium-ion battery) + ) self.max_lifecycle_per_day: int = np.inf self.grid_charging: bool = True self.pv_charging_only: bool = False self.n_look_ahead_periods: int = 48 self.time_weighting_factor: float = 0.995 self.n_roll_periods: int = 24 - self.log_name: str = '' # NOTE: Logging is not thread safe + self.log_name: str = "" # NOTE: Logging is not thread safe self.is_test_start_year: bool = False self.is_test_end_year: bool = False @@ -92,30 +97,45 @@ def __init__(self, dispatch_options: dict = None): value = type(getattr(self, key))(value) setattr(self, key, value) except: - raise ValueError("'{}' is the wrong data type. Should be {}".format(key, type(getattr(self, key)))) + raise ValueError( + "'{}' is the wrong data type. Should be {}".format( + key, type(getattr(self, key)) + ) + ) else: - raise NameError("'{}' is not an attribute in {}".format(key, type(self).__name__)) + raise NameError( + "'{}' is not an attribute in {}".format( + key, type(self).__name__ + ) + ) if self.is_test_start_year and self.is_test_end_year: - print('WARNING: Dispatch optimization START and END of year testing is enabled!') + print( + "WARNING: Dispatch optimization START and END of year testing is enabled!" + ) elif self.is_test_start_year: - print('WARNING: Dispatch optimization START of year testing is enabled!') + print("WARNING: Dispatch optimization START of year testing is enabled!") elif self.is_test_end_year: - print('WARNING: Dispatch optimization END of year testing is enabled!') + print("WARNING: Dispatch optimization END of year testing is enabled!") if self.pv_charging_only and self.grid_charging: - raise ValueError("Battery cannot be restricted to charge from PV only if grid_charging is enabled") + raise ValueError( + "Battery cannot be restricted to charge from PV only if grid_charging is enabled" + ) self._battery_dispatch_model_options = { - 'one_cycle_heuristic': OneCycleBatteryDispatchHeuristic, - 'heuristic': SimpleBatteryDispatchHeuristic, - 'simple': SimpleBatteryDispatch, - 'non_convex_LV': NonConvexLinearVoltageBatteryDispatch, - 'convex_LV': ConvexLinearVoltageBatteryDispatch, - 'load_following_heuristic': HeuristicLoadFollowingDispatch} + "one_cycle_heuristic": OneCycleBatteryDispatchHeuristic, + "heuristic": SimpleBatteryDispatchHeuristic, + "simple": SimpleBatteryDispatch, + "non_convex_LV": NonConvexLinearVoltageBatteryDispatch, + "convex_LV": ConvexLinearVoltageBatteryDispatch, + "load_following_heuristic": HeuristicLoadFollowingDispatch, + } if self.battery_dispatch in self._battery_dispatch_model_options: - self.battery_dispatch_class = self._battery_dispatch_model_options[self.battery_dispatch] - if 'heuristic' in self.battery_dispatch: + self.battery_dispatch_class = self._battery_dispatch_model_options[ + self.battery_dispatch + ] + if "heuristic" in self.battery_dispatch: # FIXME: This should be set to the number of time steps within a day. # Dispatch time duration is not set as of now... self.n_roll_periods = 24 @@ -123,4 +143,8 @@ def __init__(self, dispatch_options: dict = None): # dispatch cycle counting is not available in heuristics self.include_lifecycle_count = False else: - raise ValueError("'{}' is not currently a battery dispatch class.".format(self.battery_dispatch)) + raise ValueError( + "'{}' is not currently a battery dispatch class.".format( + self.battery_dispatch + ) + ) diff --git a/hopp/simulation/technologies/dispatch/power_sources/__init__.py b/hopp/simulation/technologies/dispatch/power_sources/__init__.py index 308b46c51..ea7bd3306 100644 --- a/hopp/simulation/technologies/dispatch/power_sources/__init__.py +++ b/hopp/simulation/technologies/dispatch/power_sources/__init__.py @@ -1,4 +1,10 @@ -from hopp.simulation.technologies.dispatch.power_sources.power_source_dispatch import PowerSourceDispatch +from hopp.simulation.technologies.dispatch.power_sources.power_source_dispatch import ( + PowerSourceDispatch, +) from hopp.simulation.technologies.dispatch.power_sources.pv_dispatch import PvDispatch -from hopp.simulation.technologies.dispatch.power_sources.wind_dispatch import WindDispatch -from hopp.simulation.technologies.dispatch.power_sources.wave_dispatch import WaveDispatch +from hopp.simulation.technologies.dispatch.power_sources.wind_dispatch import ( + WindDispatch, +) +from hopp.simulation.technologies.dispatch.power_sources.wave_dispatch import ( + WaveDispatch, +) diff --git a/hopp/simulation/technologies/dispatch/power_sources/csp_dispatch.py b/hopp/simulation/technologies/dispatch/power_sources/csp_dispatch.py index 26e8a1000..4977227cb 100644 --- a/hopp/simulation/technologies/dispatch/power_sources/csp_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_sources/csp_dispatch.py @@ -9,29 +9,49 @@ class CspDispatch(Dispatch): - """ - Dispatch model for Concentrating Solar Power (CSP) with thermal energy storage. - """ - - def __init__(self, - pyomo_model: pyomo.ConcreteModel, - index_set: pyomo.Set, - system_model, - financial_model, - block_set_name: str = 'csp'): - - super().__init__(pyomo_model, index_set, system_model, financial_model, block_set_name=block_set_name) + """Dispatch model for Concentrating Solar Power (CSP) with thermal energy storage.""" + + def __init__( + self, + pyomo_model: pyomo.ConcreteModel, + index_set: pyomo.Set, + system_model, + financial_model, + block_set_name: str = "csp", + ): + """Initialize a CSP dispatch model. + + Args: + pyomo_model (pyomo.ConcreteModel): Pyomo model instance. + index_set (pyomo.Set): Index set for the model. + system_model: System model. + financial_model: Financial model. + block_set_name (str, optional): Name of the block. Defaults to 'csp'. + + """ + super().__init__( + pyomo_model, + index_set, + system_model, + financial_model, + block_set_name=block_set_name, + ) self._create_linking_constraints() - self.objective_cost_terms = {'cost_per_field_generation': 0.5, - 'cost_per_field_start_rel': 1.5, - 'cost_per_cycle_generation': 2.0, - 'cost_per_cycle_start_rel': 40.0, - 'cost_per_change_thermal_input': 0.5} + self.objective_cost_terms = { + "cost_per_field_generation": 0.5, + "cost_per_field_start_rel": 1.5, + "cost_per_cycle_generation": 2.0, + "cost_per_cycle_start_rel": 40.0, + "cost_per_change_thermal_input": 0.5, + } def dispatch_block_rule(self, csp): - """ - Called during Dispatch's __init__ + """Called during Dispatch's __init__. Define dispatch block rules. + + Args: + csp: CSP instance. + """ # Parameters self._create_storage_parameters(csp) @@ -55,89 +75,115 @@ def dispatch_block_rule(self, csp): @staticmethod def _create_storage_parameters(csp): + """Create parameters related to thermal energy storage. + + Args: + csp: CSP instance. + + """ + csp.time_duration = pyomo.Param( doc="Time step [hour]", default=1.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.hr) + units=u.hr, + ) csp.storage_capacity = pyomo.Param( doc="Thermal energy storage capacity [MWht]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MWh) + units=u.MWh, + ) @staticmethod def _create_receiver_parameters(csp): + """Create parameters related to CSP receiver. + + Args: + csp: CSP instance. + + """ # Cost Parameters csp.cost_per_field_generation = pyomo.Param( doc="Generation cost for the CSP field and receiver [$/MWht]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.USD / u.MWh) + units=u.USD / u.MWh, + ) csp.cost_per_field_start = pyomo.Param( doc="Fixed cost for receiver start-up [$/start]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.USD) # $/start + units=u.USD, + ) # $/start # Performance Parameters csp.available_thermal_generation = pyomo.Param( doc="Available thermal power generated by the CSP heliostat field [MWt]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MW) + units=u.MW, + ) csp.receiver_startup_fraction = pyomo.Param( doc="Estimated fraction of time period required for receiver start-up [-]", default=1.0, within=pyomo.PercentFraction, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) csp.min_receiver_start_time = pyomo.Param( doc="Minimum time to start the receiver [hr]", default=0.5, within=pyomo.NonNegativeReals, mutable=True, - units=u.hr) + units=u.hr, + ) csp.field_startup_losses = pyomo.Param( doc="Heliostat field startup or shut down parasitic loss [MWhe]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MWh) + units=u.MWh, + ) csp.receiver_required_startup_energy = pyomo.Param( doc="Required energy expended to start receiver [MWht]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MWh) + units=u.MWh, + ) csp.receiver_pumping_losses = pyomo.Param( doc="Solar field and/or receiver pumping power per unit power produced [MWe/MWt]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) csp.minimum_receiver_power = pyomo.Param( doc="Minimum operational thermal power delivered by receiver [MWt]", default=1.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MW) + units=u.MW, + ) csp.allowable_receiver_startup_power = pyomo.Param( doc="Allowable power per period for receiver start-up [MWt]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MW) + units=u.MW, + ) csp.field_track_losses = pyomo.Param( doc="Solar field tracking parasitic loss [MWe]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MW) + units=u.MW, + ) # csp.heat_trace_losses = pyomo.Param( # doc="Piping heat trace parasitic loss [MWe]", # default=0.0, @@ -147,79 +193,97 @@ def _create_receiver_parameters(csp): @staticmethod def _create_cycle_parameters(csp): + """Create parameters related to the power cycle. + + Args: + csp: CSP instance. + + """ # Cost parameters csp.cost_per_cycle_generation = pyomo.Param( doc="Generation cost for power cycle [$/MWh]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.USD / u.MWh) # Electric + units=u.USD / u.MWh, + ) # Electric csp.cost_per_cycle_start = pyomo.Param( doc="Fixed cost for power cycle start [$/start]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.USD) # $/start + units=u.USD, + ) # $/start csp.cost_per_change_thermal_input = pyomo.Param( doc="Penalty for change in power cycle thermal input [$/MWt]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.USD / u.MW) # $/(Delta)MW (thermal) + units=u.USD / u.MW, + ) # $/(Delta)MW (thermal) # Performance parameters csp.cycle_ambient_efficiency_correction = pyomo.Param( doc="Cycle efficiency ambient temperature adjustment [-]", within=pyomo.NonNegativeReals, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) csp.condenser_losses = pyomo.Param( doc="Normalized condenser parasitic losses [-]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) csp.cycle_required_startup_energy = pyomo.Param( doc="Required energy expended to start cycle [MWht]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MWh) + units=u.MWh, + ) csp.cycle_nominal_efficiency = pyomo.Param( doc="Power cycle nominal efficiency [-]", default=0.0, within=pyomo.PercentFraction, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) csp.cycle_performance_slope = pyomo.Param( doc="Slope of linear approximation of power cycle performance curve [MWe/MWt]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) csp.cycle_pumping_losses = pyomo.Param( doc="Cycle heat transfer fluid pumping power per unit energy expended [MWe/MWt]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) csp.allowable_cycle_startup_power = pyomo.Param( doc="Allowable power per period for cycle start-up [MWt]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MW) + units=u.MW, + ) csp.minimum_cycle_thermal_power = pyomo.Param( doc="Minimum operational thermal power delivered to the power cycle [MWt]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MW) + units=u.MW, + ) csp.maximum_cycle_thermal_power = pyomo.Param( doc="Maximum operational thermal power delivered to the power cycle [MWt]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MW) + units=u.MW, + ) # csp.minimum_cycle_power = pyomo.Param( # doc="Minimum cycle electric power output [MWe]", # default=0.0, @@ -231,7 +295,8 @@ def _create_cycle_parameters(csp): default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MW) + units=u.MW, + ) ################################## # Variables # @@ -239,112 +304,151 @@ def _create_cycle_parameters(csp): @staticmethod def _create_storage_variables(csp): + """Create variables related to thermal energy storage. + + Args: + csp: CSP instance. + + """ csp.thermal_energy_storage = pyomo.Var( doc="Thermal energy storage reserve quantity [MWht]", domain=pyomo.NonNegativeReals, bounds=(0, csp.storage_capacity), - units=u.MWh) + units=u.MWh, + ) # initial variables csp.previous_thermal_energy_storage = pyomo.Var( doc="Thermal energy storage reserve quantity at the beginning of the period [MWht]", domain=pyomo.NonNegativeReals, bounds=(0, csp.storage_capacity), - units=u.MWh) + units=u.MWh, + ) @staticmethod def _create_receiver_variables(csp): + """Create variables related to the receiver. + + Args: + csp: CSP instance. + + """ csp.receiver_startup_inventory = pyomo.Var( doc="Receiver start-up energy inventory [MWht]", domain=pyomo.NonNegativeReals, - units=u.MWh) + units=u.MWh, + ) csp.receiver_thermal_power = pyomo.Var( doc="Thermal power delivered by the receiver [MWt]", domain=pyomo.NonNegativeReals, - units=u.MW) + units=u.MW, + ) csp.receiver_startup_consumption = pyomo.Var( doc="Receiver start-up power consumption [MWt]", domain=pyomo.NonNegativeReals, - units=u.MW) + units=u.MW, + ) csp.is_field_generating = pyomo.Var( doc="1 if solar field is generating 'usable' thermal power; 0 Otherwise [-]", domain=pyomo.Binary, - units=u.dimensionless) + units=u.dimensionless, + ) csp.is_field_starting = pyomo.Var( doc="1 if solar field is starting up; 0 Otherwise [-]", domain=pyomo.Binary, - units=u.dimensionless) + units=u.dimensionless, + ) csp.incur_field_start = pyomo.Var( doc="1 if solar field start-up penalty is incurred; 0 Otherwise [-]", domain=pyomo.Binary, - units=u.dimensionless) + units=u.dimensionless, + ) # initial variables csp.previous_receiver_startup_inventory = pyomo.Var( doc="Previous receiver start-up energy inventory [MWht]", domain=pyomo.NonNegativeReals, - units=u.MWh) + units=u.MWh, + ) csp.was_field_generating = pyomo.Var( doc="1 if solar field was generating 'usable' thermal power in the previous time period; 0 Otherwise [-]", domain=pyomo.Binary, - units=u.dimensionless) + units=u.dimensionless, + ) csp.was_field_starting = pyomo.Var( doc="1 if solar field was starting up in the previous time period; 0 Otherwise [-]", domain=pyomo.Binary, - units=u.dimensionless) + units=u.dimensionless, + ) @staticmethod def _create_cycle_variables(csp): + """Create variables related to the power cycle. + + Args: + csp: CSP instance. + + """ csp.system_load = pyomo.Var( - doc="Load of csp system [MWe]", - domain=pyomo.NonNegativeReals, - units=u.MW) + doc="Load of csp system [MWe]", domain=pyomo.NonNegativeReals, units=u.MW + ) csp.cycle_startup_inventory = pyomo.Var( doc="Cycle start-up energy inventory [MWht]", domain=pyomo.NonNegativeReals, - units=u.MWh) + units=u.MWh, + ) csp.cycle_generation = pyomo.Var( doc="Power cycle electricity generation [MWe]", domain=pyomo.NonNegativeReals, - units=u.MW) + units=u.MW, + ) csp.cycle_thermal_ramp = pyomo.Var( doc="Power cycle positive change in thermal energy input [MWt]", domain=pyomo.NonNegativeReals, bounds=(0, csp.maximum_cycle_thermal_power), - units=u.MW) + units=u.MW, + ) csp.cycle_thermal_power = pyomo.Var( doc="Cycle thermal power utilization [MWt]", domain=pyomo.NonNegativeReals, bounds=(0, csp.maximum_cycle_thermal_power), - units=u.MW) + units=u.MW, + ) csp.is_cycle_generating = pyomo.Var( doc="1 if cycle is generating electric power; 0 Otherwise [-]", domain=pyomo.Binary, - units=u.dimensionless) + units=u.dimensionless, + ) csp.is_cycle_starting = pyomo.Var( doc="1 if cycle is starting up; 0 Otherwise [-]", domain=pyomo.Binary, - units=u.dimensionless) + units=u.dimensionless, + ) csp.incur_cycle_start = pyomo.Var( doc="1 if cycle start-up penalty is incurred; 0 Otherwise [-]", domain=pyomo.Binary, - units=u.dimensionless) + units=u.dimensionless, + ) # Initial variables csp.previous_cycle_startup_inventory = pyomo.Var( doc="Previous cycle start-up energy inventory [MWht]", domain=pyomo.NonNegativeReals, - units=u.MWh) + units=u.MWh, + ) csp.previous_cycle_thermal_power = pyomo.Var( doc="Cycle thermal power in the previous period [MWt]", domain=pyomo.NonNegativeReals, bounds=(0, csp.maximum_cycle_thermal_power), - units=u.MW) + units=u.MW, + ) csp.was_cycle_generating = pyomo.Var( doc="1 if cycle was generating electric power in previous time period; 0 Otherwise [-]", domain=pyomo.Binary, - units=u.dimensionless) + units=u.dimensionless, + ) csp.was_cycle_starting = pyomo.Var( doc="1 if cycle was starting up in previous time period; 0 Otherwise [-]", domain=pyomo.Binary, - units=u.dimensionless) + units=u.dimensionless, + ) ################################## # Constraints # @@ -352,124 +456,221 @@ def _create_cycle_variables(csp): @staticmethod def _create_storage_constraints(csp): + """Create constraints related to thermal energy storage. + + Args: + csp: CSP instance. + + """ csp.storage_inventory = pyomo.Constraint( doc="Thermal energy storage energy balance", - expr=(csp.thermal_energy_storage - csp.previous_thermal_energy_storage == - csp.time_duration * (csp.receiver_thermal_power - - (csp.allowable_cycle_startup_power * csp.is_cycle_starting - + csp.cycle_thermal_power) - ) - )) + expr=( + csp.thermal_energy_storage - csp.previous_thermal_energy_storage + == csp.time_duration + * ( + csp.receiver_thermal_power + - ( + csp.allowable_cycle_startup_power * csp.is_cycle_starting + + csp.cycle_thermal_power + ) + ) + ), + ) csp.receiver_startup = pyomo.Constraint( doc="If receiver is starting up, then there must be a sufficient charge level " - "in the TES in the previous time period", - expr=(csp.previous_thermal_energy_storage >= csp.time_duration * csp.receiver_startup_fraction - * (csp.maximum_cycle_thermal_power * (-3 + csp.is_field_starting - + csp.was_cycle_generating + csp.is_cycle_generating) - + csp.cycle_thermal_power)) + "in the TES in the previous time period", + expr=( + csp.previous_thermal_energy_storage + >= csp.time_duration + * csp.receiver_startup_fraction + * ( + csp.maximum_cycle_thermal_power + * ( + -3 + + csp.is_field_starting + + csp.was_cycle_generating + + csp.is_cycle_generating + ) + + csp.cycle_thermal_power + ) + ), ) @staticmethod def _create_receiver_constraints(csp): + """Create constraints related to the receiver. + + Args: + csp: CSP instance. + + """ # Start-up csp.receiver_startup_inventory_balance = pyomo.Constraint( doc="Receiver startup energy inventory balance", - expr=csp.receiver_startup_inventory <= (csp.previous_receiver_startup_inventory - + csp.time_duration * csp.receiver_startup_consumption)) + expr=csp.receiver_startup_inventory + <= ( + csp.previous_receiver_startup_inventory + + csp.time_duration * csp.receiver_startup_consumption + ), + ) csp.receiver_startup_inventory_reset = pyomo.Constraint( doc="Resets receiver and/or field startup inventory when startup is completed", - expr=csp.receiver_startup_inventory <= csp.receiver_required_startup_energy * csp.is_field_starting) + expr=csp.receiver_startup_inventory + <= csp.receiver_required_startup_energy * csp.is_field_starting, + ) csp.receiver_operation_startup = pyomo.Constraint( doc="Thermal production is allowed only upon completion of start-up or operating in previous time period", - expr=csp.is_field_generating <= (csp.receiver_startup_inventory - / csp.receiver_required_startup_energy) + csp.was_field_generating) + expr=csp.is_field_generating + <= (csp.receiver_startup_inventory / csp.receiver_required_startup_energy) + + csp.was_field_generating, + ) csp.receiver_startup_delay = pyomo.Constraint( doc="If field previously was producing, it cannot startup this period", - expr=csp.is_field_starting + csp.was_field_generating <= 1) + expr=csp.is_field_starting + csp.was_field_generating <= 1, + ) csp.receiver_startup_limit = pyomo.Constraint( doc="Receiver and/or field startup energy consumption limit", - expr=csp.receiver_startup_consumption <= (csp.allowable_receiver_startup_power - * csp.is_field_starting)) + expr=csp.receiver_startup_consumption + <= (csp.allowable_receiver_startup_power * csp.is_field_starting), + ) csp.receiver_startup_cut = pyomo.Constraint( doc="Receiver and/or field trivial resource startup cut", - expr=csp.is_field_starting <= csp.available_thermal_generation / csp.minimum_receiver_power) + expr=csp.is_field_starting + <= csp.available_thermal_generation / csp.minimum_receiver_power, + ) # Supply and demand csp.receiver_energy_balance = pyomo.Constraint( doc="Receiver generation and startup usage must be below available", - expr=csp.available_thermal_generation >= csp.receiver_thermal_power + csp.receiver_startup_consumption) + expr=csp.available_thermal_generation + >= csp.receiver_thermal_power + csp.receiver_startup_consumption, + ) csp.maximum_field_generation = pyomo.Constraint( doc="Receiver maximum generation limit", - expr=csp.receiver_thermal_power <= csp.available_thermal_generation * csp.is_field_generating) + expr=csp.receiver_thermal_power + <= csp.available_thermal_generation * csp.is_field_generating, + ) csp.minimum_field_generation = pyomo.Constraint( doc="Receiver minimum generation limit", - expr=csp.receiver_thermal_power >= csp.minimum_receiver_power * csp.is_field_generating) + expr=csp.receiver_thermal_power + >= csp.minimum_receiver_power * csp.is_field_generating, + ) csp.receiver_generation_cut = pyomo.Constraint( doc="Receiver and/or field trivial resource generation cut", - expr=csp.is_field_generating <= csp.available_thermal_generation / csp.minimum_receiver_power) + expr=csp.is_field_generating + <= csp.available_thermal_generation / csp.minimum_receiver_power, + ) # Logic associated with receiver modes csp.field_startup = pyomo.Constraint( doc="Ensures that field start is accounted", - expr=csp.incur_field_start >= csp.is_field_starting - csp.was_field_starting) + expr=csp.incur_field_start + >= csp.is_field_starting - csp.was_field_starting, + ) @staticmethod def _create_cycle_constraints(csp): + """Create constraints related to the power cycle. + + Args: + csp: CSP instance. + + """ # Start-up csp.cycle_startup_inventory_balance = pyomo.Constraint( doc="Cycle startup energy inventory balance", - expr=csp.cycle_startup_inventory <= (csp.previous_cycle_startup_inventory - + (csp.time_duration - * csp.allowable_cycle_startup_power - * csp.is_cycle_starting))) + expr=csp.cycle_startup_inventory + <= ( + csp.previous_cycle_startup_inventory + + ( + csp.time_duration + * csp.allowable_cycle_startup_power + * csp.is_cycle_starting + ) + ), + ) csp.cycle_startup_inventory_reset = pyomo.Constraint( doc="Resets power cycle startup inventory when startup is completed", - expr=csp.cycle_startup_inventory <= csp.cycle_required_startup_energy * csp.is_cycle_starting) + expr=csp.cycle_startup_inventory + <= csp.cycle_required_startup_energy * csp.is_cycle_starting, + ) csp.cycle_operation_startup = pyomo.Constraint( doc="Electric production is allowed only upon completion of start-up or operating in previous time period", - expr=csp.is_cycle_generating <= (csp.cycle_startup_inventory - / csp.cycle_required_startup_energy) + csp.was_cycle_generating) + expr=csp.is_cycle_generating + <= (csp.cycle_startup_inventory / csp.cycle_required_startup_energy) + + csp.was_cycle_generating, + ) csp.cycle_startup_delay = pyomo.Constraint( doc="If cycle previously was generating, it cannot startup this period", - expr=csp.is_cycle_starting + csp.was_cycle_generating <= 1) + expr=csp.is_cycle_starting + csp.was_cycle_generating <= 1, + ) # Supply and demand # TODO: do we penalize start-up on valuable hours? I don't think I need this... csp.maximum_cycle_thermal_consumption_startup = pyomo.Constraint( doc="Power cycle maximum thermal energy consumption maximum limit including startup", - expr=(csp.cycle_thermal_power - + (csp.cycle_required_startup_energy / csp.time_duration) * csp.is_cycle_starting - <= csp.maximum_cycle_thermal_power)) + expr=( + csp.cycle_thermal_power + + (csp.cycle_required_startup_energy / csp.time_duration) + * csp.is_cycle_starting + <= csp.maximum_cycle_thermal_power + ), + ) csp.maximum_cycle_thermal_consumption = pyomo.Constraint( doc="Power cycle maximum thermal energy consumption maximum limit", - expr=csp.cycle_thermal_power <= csp.maximum_cycle_thermal_power * csp.is_cycle_generating) + expr=csp.cycle_thermal_power + <= csp.maximum_cycle_thermal_power * csp.is_cycle_generating, + ) csp.minimum_cycle_thermal_consumption = pyomo.Constraint( doc="Power cycle minimum thermal energy consumption minimum limit", - expr=csp.cycle_thermal_power >= csp.minimum_cycle_thermal_power * csp.is_cycle_generating) + expr=csp.cycle_thermal_power + >= csp.minimum_cycle_thermal_power * csp.is_cycle_generating, + ) csp.cycle_performance_curve = pyomo.Constraint( doc="Power cycle relationship between electrical power and thermal input with corrections " - "for ambient temperature", - expr=(csp.cycle_generation == - (csp.cycle_ambient_efficiency_correction / csp.cycle_nominal_efficiency) - * (csp.cycle_performance_slope * csp.cycle_thermal_power - + (csp.maximum_cycle_power - csp.cycle_performance_slope - * csp.maximum_cycle_thermal_power) * csp.is_cycle_generating))) + "for ambient temperature", + expr=( + csp.cycle_generation + == ( + csp.cycle_ambient_efficiency_correction + / csp.cycle_nominal_efficiency + ) + * ( + csp.cycle_performance_slope * csp.cycle_thermal_power + + ( + csp.maximum_cycle_power + - csp.cycle_performance_slope * csp.maximum_cycle_thermal_power + ) + * csp.is_cycle_generating + ) + ), + ) csp.cycle_thermal_ramp_constraint = pyomo.Constraint( doc="Positive ramping of power cycle thermal power", - expr=csp.cycle_thermal_ramp >= csp.cycle_thermal_power - csp.previous_cycle_thermal_power) + expr=csp.cycle_thermal_ramp + >= csp.cycle_thermal_power - csp.previous_cycle_thermal_power, + ) # System load csp.generation_balance = pyomo.Constraint( doc="Calculates csp system load for grid model", - expr=csp.system_load == (csp.cycle_generation * csp.condenser_losses - + csp.receiver_pumping_losses * (csp.receiver_thermal_power - + csp.receiver_startup_consumption) - + csp.cycle_pumping_losses * (csp.cycle_thermal_power - + (csp.allowable_cycle_startup_power - * csp.is_cycle_starting)) - + csp.field_track_losses * csp.is_field_generating - # + csp.heat_trace_losses * csp.is_field_starting - + (csp.field_startup_losses/csp.time_duration) * csp.is_field_starting)) + expr=csp.system_load + == ( + csp.cycle_generation * csp.condenser_losses + + csp.receiver_pumping_losses + * (csp.receiver_thermal_power + csp.receiver_startup_consumption) + + csp.cycle_pumping_losses + * ( + csp.cycle_thermal_power + + (csp.allowable_cycle_startup_power * csp.is_cycle_starting) + ) + + csp.field_track_losses * csp.is_field_generating + # + csp.heat_trace_losses * csp.is_field_starting + + (csp.field_startup_losses / csp.time_duration) * csp.is_field_starting + ), + ) # Logic governing cycle modes csp.cycle_startup = pyomo.Constraint( doc="Ensures that cycle start is accounted", - expr=csp.incur_cycle_start >= csp.is_cycle_starting - csp.was_cycle_starting) + expr=csp.incur_cycle_start + >= csp.is_cycle_starting - csp.was_cycle_starting, + ) ################################## # Ports # @@ -477,6 +678,12 @@ def _create_cycle_constraints(csp): @staticmethod def _create_csp_port(csp): + """Create pyomo ports related to CSP instance. + + Args: + csp: CSP instance. + + """ csp.port = Port() csp.port.add(csp.cycle_generation) csp.port.add(csp.system_load) @@ -486,6 +693,7 @@ def _create_csp_port(csp): ################################## def _create_linking_constraints(self): + """Create linking constraints for storage, receiver and cycle.""" self._create_storage_linking_constraints() self._create_receiver_linking_constraints() self._create_cycle_linking_constraints() @@ -495,171 +703,277 @@ def _create_linking_constraints(self): ################################## def _create_storage_linking_constraints(self): + """Create constraints for linking storage.""" self.model.initial_thermal_energy_storage = pyomo.Param( doc="Initial thermal energy storage reserve quantity at beginning of the horizon [MWht]", default=0.0, within=pyomo.NonNegativeReals, # validate= # TODO: Might be worth looking into mutable=True, - units=u.MWh) + units=u.MWh, + ) def tes_linking_rule(m, t): if t == self.blocks.index_set().first(): - return self.blocks[t].previous_thermal_energy_storage == self.model.initial_thermal_energy_storage - return self.blocks[t].previous_thermal_energy_storage == self.blocks[t - 1].thermal_energy_storage + return ( + self.blocks[t].previous_thermal_energy_storage + == self.model.initial_thermal_energy_storage + ) + return ( + self.blocks[t].previous_thermal_energy_storage + == self.blocks[t - 1].thermal_energy_storage + ) + self.model.tes_linking = pyomo.Constraint( self.blocks.index_set(), doc="Thermal energy storage block linking constraint", - rule=tes_linking_rule) + rule=tes_linking_rule, + ) def _create_receiver_linking_constraints(self): + """Create constraints for linking receiver.""" self.model.initial_receiver_startup_inventory = pyomo.Param( doc="Initial receiver start-up energy inventory at beginning of the horizon [MWht]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MWh) + units=u.MWh, + ) self.model.is_field_generating_initial = pyomo.Param( doc="1 if solar field is generating 'usable' thermal power at beginning of the horizon; 0 Otherwise [-]", default=0.0, within=pyomo.Binary, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) self.model.is_field_starting_initial = pyomo.Param( doc="1 if solar field is starting up at beginning of the horizon; 0 Otherwise [-]", default=0.0, within=pyomo.Binary, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) def receiver_startup_inventory_linking_rule(m, t): if t == self.blocks.index_set().first(): - return self.blocks[t].previous_receiver_startup_inventory == self.model.initial_receiver_startup_inventory - return self.blocks[t].previous_receiver_startup_inventory == self.blocks[t - 1].receiver_startup_inventory + return ( + self.blocks[t].previous_receiver_startup_inventory + == self.model.initial_receiver_startup_inventory + ) + return ( + self.blocks[t].previous_receiver_startup_inventory + == self.blocks[t - 1].receiver_startup_inventory + ) + self.model.receiver_startup_inventory_linking = pyomo.Constraint( self.blocks.index_set(), doc="Receiver startup inventory block linking constraint", - rule=receiver_startup_inventory_linking_rule) + rule=receiver_startup_inventory_linking_rule, + ) def field_generating_linking_rule(m, t): if t == self.blocks.index_set().first(): - return self.blocks[t].was_field_generating == self.model.is_field_generating_initial - return self.blocks[t].was_field_generating == self.blocks[t - 1].is_field_generating + return ( + self.blocks[t].was_field_generating + == self.model.is_field_generating_initial + ) + return ( + self.blocks[t].was_field_generating + == self.blocks[t - 1].is_field_generating + ) + self.model.field_generating_linking = pyomo.Constraint( self.blocks.index_set(), doc="Is field generating binary block linking constraint", - rule=field_generating_linking_rule) + rule=field_generating_linking_rule, + ) def field_starting_linking_rule(m, t): if t == self.blocks.index_set().first(): - return self.blocks[t].was_field_starting == self.model.is_field_starting_initial - return self.blocks[t].was_field_starting == self.blocks[t - 1].is_field_starting + return ( + self.blocks[t].was_field_starting + == self.model.is_field_starting_initial + ) + return ( + self.blocks[t].was_field_starting + == self.blocks[t - 1].is_field_starting + ) + self.model.field_starting_linking = pyomo.Constraint( self.blocks.index_set(), doc="Is field starting up binary block linking constraint", - rule=field_starting_linking_rule) + rule=field_starting_linking_rule, + ) def _create_cycle_linking_constraints(self): + """Create constraints for linking cycle.""" self.model.initial_cycle_startup_inventory = pyomo.Param( doc="Initial cycle start-up energy inventory at beginning of the horizon [MWht]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MWh) + units=u.MWh, + ) self.model.initial_cycle_thermal_power = pyomo.Param( doc="Initial cycle thermal power at beginning of the horizon [MWt]", default=0.0, within=pyomo.NonNegativeReals, # validate= # TODO: bounds->(0, csp.maximum_cycle_thermal_power), Sec. 4.7.1 mutable=True, - units=u.MW) + units=u.MW, + ) self.model.is_cycle_generating_initial = pyomo.Param( doc="1 if cycle is generating electric power at beginning of the horizon; 0 Otherwise [-]", default=0.0, within=pyomo.Binary, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) self.model.is_cycle_starting_initial = pyomo.Param( doc="1 if cycle is starting up at beginning of the horizon; 0 Otherwise [-]", default=0.0, within=pyomo.Binary, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) def cycle_startup_inventory_linking_rule(m, t): if t == self.blocks.index_set().first(): - return self.blocks[t].previous_cycle_startup_inventory == self.model.initial_cycle_startup_inventory - return self.blocks[t].previous_cycle_startup_inventory == self.blocks[t - 1].cycle_startup_inventory + return ( + self.blocks[t].previous_cycle_startup_inventory + == self.model.initial_cycle_startup_inventory + ) + return ( + self.blocks[t].previous_cycle_startup_inventory + == self.blocks[t - 1].cycle_startup_inventory + ) + self.model.cycle_startup_inventory_linking = pyomo.Constraint( self.blocks.index_set(), doc="Cycle startup inventory block linking constraint", - rule=cycle_startup_inventory_linking_rule) + rule=cycle_startup_inventory_linking_rule, + ) def cycle_thermal_power_linking_rule(m, t): if t == self.blocks.index_set().first(): - return self.blocks[t].previous_cycle_thermal_power == self.model.initial_cycle_thermal_power - return self.blocks[t].previous_cycle_thermal_power == self.blocks[t - 1].cycle_thermal_power + return ( + self.blocks[t].previous_cycle_thermal_power + == self.model.initial_cycle_thermal_power + ) + return ( + self.blocks[t].previous_cycle_thermal_power + == self.blocks[t - 1].cycle_thermal_power + ) + self.model.cycle_thermal_power_linking = pyomo.Constraint( self.blocks.index_set(), doc="Cycle thermal power block linking constraint", - rule=cycle_thermal_power_linking_rule) + rule=cycle_thermal_power_linking_rule, + ) def cycle_generating_linking_rule(m, t): if t == self.blocks.index_set().first(): - return self.blocks[t].was_cycle_generating == self.model.is_cycle_generating_initial - return self.blocks[t].was_cycle_generating == self.blocks[t - 1].is_cycle_generating + return ( + self.blocks[t].was_cycle_generating + == self.model.is_cycle_generating_initial + ) + return ( + self.blocks[t].was_cycle_generating + == self.blocks[t - 1].is_cycle_generating + ) + self.model.cycle_generating_linking = pyomo.Constraint( self.blocks.index_set(), doc="Is cycle generating binary block linking constraint", - rule=cycle_generating_linking_rule) + rule=cycle_generating_linking_rule, + ) def cycle_starting_linking_rule(m, t): if t == self.blocks.index_set().first(): - return self.blocks[t].was_cycle_starting == self.model.is_cycle_starting_initial - return self.blocks[t].was_cycle_starting == self.blocks[t - 1].is_cycle_starting + return ( + self.blocks[t].was_cycle_starting + == self.model.is_cycle_starting_initial + ) + return ( + self.blocks[t].was_cycle_starting + == self.blocks[t - 1].is_cycle_starting + ) + self.model.cycle_starting_linking = pyomo.Constraint( self.blocks.index_set(), doc="Is cycle starting up binary block linking constraint", - rule=cycle_starting_linking_rule) + rule=cycle_starting_linking_rule, + ) def initialize_parameters(self): + """Initialize parameters for the CSP model.""" csp = self._system_model cycle_rated_thermal = csp.cycle_thermal_rating field_rated_thermal = csp.field_thermal_rating # Cost Parameters - self.cost_per_field_generation = self.objective_cost_terms['cost_per_field_generation'] - self.cost_per_field_start = self.objective_cost_terms['cost_per_field_start_rel'] * field_rated_thermal - self.cost_per_cycle_generation = self.objective_cost_terms['cost_per_cycle_generation'] - self.cost_per_cycle_start = self.objective_cost_terms['cost_per_cycle_start_rel'] * csp.value('P_ref') - self.cost_per_change_thermal_input = self.objective_cost_terms['cost_per_change_thermal_input'] + self.cost_per_field_generation = self.objective_cost_terms[ + "cost_per_field_generation" + ] + self.cost_per_field_start = ( + self.objective_cost_terms["cost_per_field_start_rel"] * field_rated_thermal + ) + self.cost_per_cycle_generation = self.objective_cost_terms[ + "cost_per_cycle_generation" + ] + self.cost_per_cycle_start = self.objective_cost_terms[ + "cost_per_cycle_start_rel" + ] * csp.value("P_ref") + self.cost_per_change_thermal_input = self.objective_cost_terms[ + "cost_per_change_thermal_input" + ] # Solar field and thermal energy storage performance parameters - self.field_startup_losses = csp.value('p_start') * csp.number_of_reflector_units / 1e3 - self.receiver_required_startup_energy = csp.value('rec_qf_delay') * field_rated_thermal + self.field_startup_losses = ( + csp.value("p_start") * csp.number_of_reflector_units / 1e3 + ) + self.receiver_required_startup_energy = ( + csp.value("rec_qf_delay") * field_rated_thermal + ) self.storage_capacity = csp.tes_hours * cycle_rated_thermal - self.minimum_receiver_power = csp.minimum_receiver_power_fraction * field_rated_thermal - self.allowable_receiver_startup_power = self.receiver_required_startup_energy / csp.value('rec_su_delay') + self.minimum_receiver_power = ( + csp.minimum_receiver_power_fraction * field_rated_thermal + ) + self.allowable_receiver_startup_power = ( + self.receiver_required_startup_energy / csp.value("rec_su_delay") + ) self.receiver_pumping_losses = csp.estimate_receiver_pumping_parasitic() self.field_track_losses = csp.field_tracking_power - #self.heat_trace_losses = 0.00163 * field_rated_thermal # TODO: need to update for troughs + # self.heat_trace_losses = 0.00163 * field_rated_thermal # TODO: need to update for troughs # Power cycle performance - self.cycle_required_startup_energy = csp.value('startup_frac') * cycle_rated_thermal + self.cycle_required_startup_energy = ( + csp.value("startup_frac") * cycle_rated_thermal + ) self.cycle_nominal_efficiency = csp.cycle_nominal_efficiency design_mass_flow = csp.get_cycle_design_mass_flow() - self.cycle_pumping_losses = csp.value('pb_pump_coef') * design_mass_flow / (cycle_rated_thermal * 1e3) - self.allowable_cycle_startup_power = self.cycle_required_startup_energy / csp.value('startup_time') - self.minimum_cycle_thermal_power = csp.value('cycle_cutoff_frac') * cycle_rated_thermal - self.maximum_cycle_thermal_power = csp.value('cycle_max_frac') * cycle_rated_thermal + self.cycle_pumping_losses = ( + csp.value("pb_pump_coef") * design_mass_flow / (cycle_rated_thermal * 1e3) + ) + self.allowable_cycle_startup_power = ( + self.cycle_required_startup_energy / csp.value("startup_time") + ) + self.minimum_cycle_thermal_power = ( + csp.value("cycle_cutoff_frac") * cycle_rated_thermal + ) + self.maximum_cycle_thermal_power = ( + csp.value("cycle_max_frac") * cycle_rated_thermal + ) self.set_part_load_cycle_parameters() def update_time_series_parameters(self, start_time: int): - """ - Sets up SSC simulation to get time series performance parameters after simulation. - : param start_time: hour of the year starting dispatch horizon + """Sets up SSC simulation to get time series performance parameters after simulation. + + Args: + start_time (int): Hour of the year starting dispatch horizon. + """ n_horizon = len(self.blocks.index_set()) self.time_duration = [1.0] * n_horizon # assume hourly for now @@ -669,13 +983,15 @@ def update_time_series_parameters(self, start_time: int): temperature = list(self._system_model.year_weather_df.Temperature.values) if start_time + n_horizon > len(thermal_resource): field_gen = list(thermal_resource[start_time:]) - field_gen.extend(list(thermal_resource[0:n_horizon - len(field_gen)])) + field_gen.extend(list(thermal_resource[0 : n_horizon - len(field_gen)])) dry_bulb_temperature = list(temperature[start_time:]) - dry_bulb_temperature.extend(list(temperature[0:n_horizon - len(dry_bulb_temperature)])) + dry_bulb_temperature.extend( + list(temperature[0 : n_horizon - len(dry_bulb_temperature)]) + ) else: - field_gen = thermal_resource[start_time:start_time + n_horizon] - dry_bulb_temperature = temperature[start_time:start_time + n_horizon] + field_gen = thermal_resource[start_time : start_time + n_horizon] + dry_bulb_temperature = temperature[start_time : start_time + n_horizon] self.available_thermal_generation = field_gen # Set cycle performance parameters that depend on ambient temperature @@ -688,82 +1004,168 @@ def set_part_load_cycle_parameters(self): """Set parameters in dispatch model for off-design cycle performance.""" # --- Cycle part-load efficiency tables = self._system_model.cycle_efficiency_tables - if 'cycle_eff_load_table' in tables: + if "cycle_eff_load_table" in tables: q_pb_design = self._system_model.cycle_thermal_rating - num_pts = len(tables['cycle_eff_load_table']) - norm_heat_pts = [tables['cycle_eff_load_table'][i][0] / q_pb_design for i in range(num_pts)] # Load fraction - efficiency_pts = [tables['cycle_eff_load_table'][i][1] for i in range(num_pts)] # Efficiency + num_pts = len(tables["cycle_eff_load_table"]) + norm_heat_pts = [ + tables["cycle_eff_load_table"][i][0] / q_pb_design + for i in range(num_pts) + ] # Load fraction + efficiency_pts = [ + tables["cycle_eff_load_table"][i][1] for i in range(num_pts) + ] # Efficiency self.set_linearized_cycle_part_load_params(norm_heat_pts, efficiency_pts) - elif 'ud_ind_od' in tables: + elif "ud_ind_od" in tables: # Tables not returned from ssc, but can be taken from user-defined cycle inputs - D = self.interpret_user_defined_cycle_data(tables['ud_ind_od']) - k = 3 * D['nT'] + D['nm'] - norm_heat_pts = D['mpts'] # Load fraction - efficiency_pts = [self._system_model.cycle_nominal_efficiency * (tables['ud_ind_od'][k + p][3] / tables['ud_ind_od'][k + p][4]) - for p in range(len(norm_heat_pts))] # Efficiency + D = self.interpret_user_defined_cycle_data(tables["ud_ind_od"]) + k = 3 * D["nT"] + D["nm"] + norm_heat_pts = D["mpts"] # Load fraction + efficiency_pts = [ + self._system_model.cycle_nominal_efficiency + * (tables["ud_ind_od"][k + p][3] / tables["ud_ind_od"][k + p][4]) + for p in range(len(norm_heat_pts)) + ] # Efficiency self.set_linearized_cycle_part_load_params(norm_heat_pts, efficiency_pts) else: - print('WARNING: Dispatch optimization cycle part-load efficiency is not set. ' - 'Defaulting to constant efficiency vs load.') + print( + "WARNING: Dispatch optimization cycle part-load efficiency is not set. " + "Defaulting to constant efficiency vs load." + ) self.cycle_performance_slope = self._system_model.cycle_nominal_efficiency # self.minimum_cycle_power = self.minimum_cycle_thermal_power * self._system_model.cycle_nominal_efficiency - self.maximum_cycle_power = self.maximum_cycle_thermal_power * self._system_model.cycle_nominal_efficiency + self.maximum_cycle_power = ( + self.maximum_cycle_thermal_power + * self._system_model.cycle_nominal_efficiency + ) def set_linearized_cycle_part_load_params(self, norm_heat_pts, efficiency_pts): + """Set linearized part-load parameters for the power cycle. + + Args: + norm_heat_pts (list): Normalized heat points for the power cycle. + efficiency_pts (list): Efficiency points for the power cycle. + + """ q_pb_design = self._system_model.cycle_thermal_rating - fpts = [self._system_model.value('cycle_cutoff_frac'), self._system_model.value('cycle_max_frac')] + fpts = [ + self._system_model.value("cycle_cutoff_frac"), + self._system_model.value("cycle_max_frac"), + ] step = norm_heat_pts[1] - norm_heat_pts[0] - q, eta = [ [] for v in range(2)] + q, eta = [[] for v in range(2)] for j in range(2): # Find first point in user-defined array of load fractions - p = max(0, min(int((fpts[j] - norm_heat_pts[0]) / step), len(norm_heat_pts) - 2)) - eta.append(efficiency_pts[p] + (efficiency_pts[p + 1] - efficiency_pts[p]) / step * (fpts[j] - norm_heat_pts[p])) - q.append(fpts[j]*q_pb_design) - etap = (q[1]*eta[1]-q[0]*eta[0])/(q[1]-q[0]) - b = q[1]*(eta[1] - etap) + p = max( + 0, min(int((fpts[j] - norm_heat_pts[0]) / step), len(norm_heat_pts) - 2) + ) + eta.append( + efficiency_pts[p] + + (efficiency_pts[p + 1] - efficiency_pts[p]) + / step + * (fpts[j] - norm_heat_pts[p]) + ) + q.append(fpts[j] * q_pb_design) + etap = (q[1] * eta[1] - q[0] * eta[0]) / (q[1] - q[0]) + b = q[1] * (eta[1] - etap) self.cycle_performance_slope = etap # self.minimum_cycle_power = b + self.minimum_cycle_thermal_power * self.cycle_performance_slope - self.maximum_cycle_power = b + self.maximum_cycle_thermal_power * self.cycle_performance_slope + self.maximum_cycle_power = ( + b + self.maximum_cycle_thermal_power * self.cycle_performance_slope + ) return def set_ambient_temperature_cycle_parameters(self, dry_bulb_temperature): - """Set ambient temperature dependent cycle performance parameters.""" + """Set ambient temperature dependent cycle performance parameters. + + Args: + dry_bulb_temperature (float or list): Ambient dry bulb temperature(s) [°C]. + + Returns: + None + + Notes: + This method sets up ambient temperature dependent cycle performance parameters + such as cycle efficiency corrections and condenser losses based on the provided + dry bulb temperature(s). + + """ # --- Cycle ambient-temperature efficiency corrections tables = self._system_model.cycle_efficiency_tables - if 'cycle_eff_Tdb_table' in tables: - nT = len(tables['cycle_eff_Tdb_table']) - Tpts = [tables['cycle_eff_Tdb_table'][i][0] for i in range(nT)] - efficiency_pts = [tables['cycle_eff_Tdb_table'][i][1] * self._system_model.cycle_nominal_efficiency for i in range(nT)] # Efficiency - wcondfpts = [tables['cycle_wcond_Tdb_table'][i][1] for i in range(nT)] # Fraction of cycle design gross output consumed by cooling - self.set_cycle_ambient_corrections(dry_bulb_temperature, Tpts, efficiency_pts, wcondfpts) - elif 'ud_ind_od' in tables: + if "cycle_eff_Tdb_table" in tables: + nT = len(tables["cycle_eff_Tdb_table"]) + Tpts = [tables["cycle_eff_Tdb_table"][i][0] for i in range(nT)] + efficiency_pts = [ + tables["cycle_eff_Tdb_table"][i][1] + * self._system_model.cycle_nominal_efficiency + for i in range(nT) + ] # Efficiency + wcondfpts = [ + tables["cycle_wcond_Tdb_table"][i][1] for i in range(nT) + ] # Fraction of cycle design gross output consumed by cooling + self.set_cycle_ambient_corrections( + dry_bulb_temperature, Tpts, efficiency_pts, wcondfpts + ) + elif "ud_ind_od" in tables: # Tables not returned from ssc, but can be taken from user-defined cycle inputs - D = self.interpret_user_defined_cycle_data(tables['ud_ind_od']) - k = 3 * D['nT'] + 3 * D['nm'] + D[ - 'nTamb'] # first index in udpc data corresponding to performance at design point HTF T, and design point mass flow - npts = D['nTamb'] - efficiency_pts = [self._system_model.cycle_nominal_efficiency * (tables['ud_ind_od'][j][3] / tables['ud_ind_od'][j][4]) - for j in range(k, k + npts)] # Efficiency - wcondfpts = [(self._system_model.value('ud_f_W_dot_cool_des') / 100.) * tables['ud_ind_od'][j][5] for j in - range(k, k + npts)] # Fraction of cycle design gross output consumed by cooling - self.set_cycle_ambient_corrections(dry_bulb_temperature, D['Tambpts'], efficiency_pts, wcondfpts) + D = self.interpret_user_defined_cycle_data(tables["ud_ind_od"]) + k = ( + 3 * D["nT"] + 3 * D["nm"] + D["nTamb"] + ) # first index in udpc data corresponding to performance at design point HTF T, and design point mass flow + npts = D["nTamb"] + efficiency_pts = [ + self._system_model.cycle_nominal_efficiency + * (tables["ud_ind_od"][j][3] / tables["ud_ind_od"][j][4]) + for j in range(k, k + npts) + ] # Efficiency + wcondfpts = [ + (self._system_model.value("ud_f_W_dot_cool_des") / 100.0) + * tables["ud_ind_od"][j][5] + for j in range(k, k + npts) + ] # Fraction of cycle design gross output consumed by cooling + self.set_cycle_ambient_corrections( + dry_bulb_temperature, D["Tambpts"], efficiency_pts, wcondfpts + ) else: - print('WARNING: Dispatch optimization cycle ambient temperature corrections are not set up.') + print( + "WARNING: Dispatch optimization cycle ambient temperature corrections are not set up." + ) n = len(dry_bulb_temperature) - self.cycle_ambient_efficiency_correction = [self._system_model.cycle_nominal_efficiency] * n + self.cycle_ambient_efficiency_correction = [ + self._system_model.cycle_nominal_efficiency + ] * n self.condenser_losses = [0.0] * n return def set_cycle_ambient_corrections(self, Tdb, Tpts, etapts, wcondfpts): - n = len(Tdb) # Tdb = set of ambient temperature points for each dispatch time step - npts = len(Tpts) # Tpts = ambient temperature points with tabulated values - cycle_ambient_efficiency_correction = [1.0]*n - condenser_losses = [0.0]*n + """Set cycle ambient corrections based on ambient temperature. + + Args: + Tdb (float or list): Ambient temperature(s) for each dispatch time step [°C]. + Tpts (list): Ambient temperature points with tabulated values [°C]. + etapts (list): Efficiency values corresponding to each Tpts. + wcondfpts (list): Fraction of cycle design gross output consumed by cooling corresponding to each Tpts. + + Returns: + None + + Notes: + This method calculates cycle ambient efficiency correction and condenser losses based on the provided + ambient temperature(s) and tabulated values. The corrections are set for each dispatch time step. + + """ + n = len( + Tdb + ) # Tdb = set of ambient temperature points for each dispatch time step + npts = len(Tpts) # Tpts = ambient temperature points with tabulated values + cycle_ambient_efficiency_correction = [1.0] * n + condenser_losses = [0.0] * n Tstep = Tpts[1] - Tpts[0] for j in range(n): - i = max(0, min( int((Tdb[j] - Tpts[0]) / Tstep), npts-2) ) + i = max(0, min(int((Tdb[j] - Tpts[0]) / Tstep), npts - 2)) r = (Tdb[j] - Tpts[i]) / Tstep - cycle_ambient_efficiency_correction[j] = etapts[i] + (etapts[i + 1] - etapts[i]) * r + cycle_ambient_efficiency_correction[j] = ( + etapts[i] + (etapts[i + 1] - etapts[i]) * r + ) condenser_losses[j] = wcondfpts[i] + (wcondfpts[i + 1] - wcondfpts[i]) * r self.cycle_ambient_efficiency_correction = cycle_ambient_efficiency_correction self.condenser_losses = condenser_losses @@ -771,79 +1173,164 @@ def set_cycle_ambient_corrections(self, Tdb, Tpts, etapts, wcondfpts): @staticmethod def interpret_user_defined_cycle_data(ud_ind_od): + """Interpret user-defined cycle data. + + Args: + ud_ind_od (list): User-defined cycle data. + + Returns: + dict: Dictionary containing interpreted data with keys: + - 'nT': Number of temperature points + - 'Tpts': Ambient temperature points + - 'Tlevels': Levels of temperature + - 'nm': Number of mass flow rate points + - 'mpts': Mass flow rate points + - 'mlevels': Levels of mass flow rate + - 'nTamb': Number of ambient temperature points + - 'Tambpts': Ambient temperature points + - 'Tamblevels': Levels of ambient temperature + + Notes: + This method interprets user-defined cycle data and organizes it into a dictionary + containing relevant information about temperature points, mass flow rate points, and + ambient temperature points. + + """ + data = np.array(ud_ind_od) i0 = 0 nT = np.where(np.diff(data[i0::, 0]) < 0)[0][0] + 1 - Tpts = data[i0:i0 + nT, 0] + Tpts = data[i0 : i0 + nT, 0] mlevels = [data[j, 1] for j in [i0, i0 + nT, i0 + 2 * nT]] i0 = 3 * nT nm = np.where(np.diff(data[i0::, 1]) < 0)[0][0] + 1 - mpts = data[i0:i0 + nm, 1] + mpts = data[i0 : i0 + nm, 1] Tamblevels = [data[j, 2] for j in [i0, i0 + nm, i0 + 2 * nm]] i0 = 3 * nT + 3 * nm nTamb = np.where(np.diff(data[i0::, 2]) < 0)[0][0] + 1 - Tambpts = data[i0:i0 + nTamb, 2] + Tambpts = data[i0 : i0 + nTamb, 2] Tlevels = [data[j, 0] for j in [i0, i0 + nm, i0 + 2 * nm]] - return {'nT': nT, 'Tpts': Tpts, 'Tlevels': Tlevels, 'nm': nm, 'mpts': mpts, 'mlevels': mlevels, 'nTamb': nTamb, - 'Tambpts': Tambpts, 'Tamblevels': Tamblevels} + return { + "nT": nT, + "Tpts": Tpts, + "Tlevels": Tlevels, + "nm": nm, + "mpts": mpts, + "mlevels": mlevels, + "nTamb": nTamb, + "Tambpts": Tambpts, + "Tamblevels": Tamblevels, + } def set_receiver_require_startup_time_fraction(self, field_gen: list): - """Estimates the fraction of time period required for receiver start-up.""" - self.min_receiver_start_time = self._system_model.value('rec_su_delay') + """Estimates the fraction of time period required for receiver start-up. + + Args: + field_gen (list): Field generation profile. + + Notes: + This method estimates the fraction of time period required for the receiver start-up + based on the field generation profile. + + """ + self.min_receiver_start_time = self._system_model.value("rec_su_delay") su_fraction = [1.0] * len(field_gen) epsilon = 1e-6 time_duration = self.time_duration for i in range(len(field_gen)): - su_fraction[i] = min(1.0, - max(self.min_receiver_start_time / time_duration[i], - self.receiver_required_startup_energy / max(1e-6, - field_gen[i] * time_duration[i]) - ) - ) + su_fraction[i] = min( + 1.0, + max( + self.min_receiver_start_time / time_duration[i], + self.receiver_required_startup_energy + / max(1e-6, field_gen[i] * time_duration[i]), + ), + ) self.receiver_startup_fraction = su_fraction def update_initial_conditions(self): + """This method updates the initial conditions for the dispatch optimization, + including the initial thermal energy storage, initial cycle startup inventory, + and initial cycle thermal power. + + """ csp = self._system_model m_des = csp.get_design_storage_mass() - m_hot = csp.initial_tes_hot_mass_fraction * m_des # Available active mass in hot tank - cp = csp.get_cp_htf(0.5 * (csp.plant_state['T_tank_hot_init'] + csp.htf_cold_design_temperature)) # J/kg/K - self.initial_thermal_energy_storage = min(self.storage_capacity, - m_hot * cp * (csp.plant_state['T_tank_hot_init'] - - csp.htf_cold_design_temperature) * 1.e-6 / 3600) + m_hot = ( + csp.initial_tes_hot_mass_fraction * m_des + ) # Available active mass in hot tank + cp = csp.get_cp_htf( + 0.5 * (csp.plant_state["T_tank_hot_init"] + csp.htf_cold_design_temperature) + ) # J/kg/K + self.initial_thermal_energy_storage = min( + self.storage_capacity, + m_hot + * cp + * (csp.plant_state["T_tank_hot_init"] - csp.htf_cold_design_temperature) + * 1.0e-6 + / 3600, + ) - self.is_field_generating_initial = (csp.plant_state['rec_op_mode_initial'] == 2) - self.is_field_starting_initial = (csp.plant_state['rec_op_mode_initial'] == 1) + self.is_field_generating_initial = csp.plant_state["rec_op_mode_initial"] == 2 + self.is_field_starting_initial = csp.plant_state["rec_op_mode_initial"] == 1 # Initial startup energy accumulated # ssc seems to report nan when startup is completed - if csp.plant_state['pc_startup_energy_remain_initial'] != csp.plant_state['pc_startup_energy_remain_initial']: + if ( + csp.plant_state["pc_startup_energy_remain_initial"] + != csp.plant_state["pc_startup_energy_remain_initial"] + ): self.initial_cycle_startup_inventory = self.cycle_required_startup_energy else: - self.initial_cycle_startup_inventory = max(0.0, self.cycle_required_startup_energy - - csp.plant_state['pc_startup_energy_remain_initial'] / 1e3) - if self.initial_cycle_startup_inventory > (1.0 - 1.e-6) * self.cycle_required_startup_energy: - self.initial_cycle_startup_inventory = self.cycle_required_startup_energy - - self.is_cycle_generating_initial = (csp.plant_state['pc_op_mode_initial'] == 1) - self.is_cycle_starting_initial = (csp.plant_state['pc_op_mode_initial'] == 0 - or csp.plant_state['pc_op_mode_initial'] == 4) + self.initial_cycle_startup_inventory = max( + 0.0, + self.cycle_required_startup_energy + - csp.plant_state["pc_startup_energy_remain_initial"] / 1e3, + ) + if ( + self.initial_cycle_startup_inventory + > (1.0 - 1.0e-6) * self.cycle_required_startup_energy + ): + self.initial_cycle_startup_inventory = ( + self.cycle_required_startup_energy + ) + + self.is_cycle_generating_initial = csp.plant_state["pc_op_mode_initial"] == 1 + self.is_cycle_starting_initial = ( + csp.plant_state["pc_op_mode_initial"] == 0 + or csp.plant_state["pc_op_mode_initial"] == 4 + ) # self.ycsb0 = (plant.state['pc_op_mode_initial'] == 2) if self.is_cycle_generating_initial: - self.initial_cycle_thermal_power = csp.plant_state['heat_into_cycle'] + self.initial_cycle_thermal_power = csp.plant_state["heat_into_cycle"] else: self.initial_cycle_thermal_power = 0.0 @staticmethod def get_start_end_datetime(start_time: int, n_horizon: int): + """Get start and end datetimes based on simulation start time and horizon length. + + Args: + start_time (int): Start time of the simulation in hours. + n_horizon (int): Length of the simulation horizon in hours. + + Returns: + tuple: A tuple containing the start and end datetime objects. + + Notes: + This method calculates the start and end datetimes based on the provided start time + and horizon length, assuming hourly data. + + """ # Setting simulation times start_datetime = CspDispatch.get_start_datetime_by_hour(start_time) # Handling end of simulation horizon -> assumes hourly data @@ -855,10 +1342,18 @@ def get_start_end_datetime(start_time: int, n_horizon: int): @staticmethod def get_start_datetime_by_hour(start_time: int): - """ - Get datetime for start_time hour of the year - : param start_time: hour of year - : return: datetime object + """Get the datetime object corresponding to the start time of year in hours. + + Args: + start_time (int): Start time of the simulation in hours. + + Returns: + datetime.datetime: Datetime object corresponding to the start time. + + Notes: + This method calculates the datetime object corresponding to the start time + in hours relative to the beginning of the year. + """ # TODO: bring in the correct year from site data - or replace outside of function? beginning_of_year = datetime.datetime(2009, 1, 1, 0) @@ -866,12 +1361,24 @@ def get_start_datetime_by_hour(start_time: int): @staticmethod def seconds_since_newyear(dt): + """Get the number of seconds elapsed since the beginning of the year. + + Args: + dt (datetime.datetime): Datetime object. + + Returns: + int: Number of seconds elapsed since the beginning of the year. + + Notes: + This method calculates the number of seconds elapsed since the beginning of the year, + using a non-leap year (2009) for consistency with a multiple of 8760 hours assumption. + + """ # Substitute a non-leap year (2009) to keep multiple of 8760 assumption: newyear = datetime.datetime(2009, 1, 1, 0, 0, 0, 0) time_diff = dt - newyear return int(time_diff.total_seconds()) - ################################# # INPUTS # ################################# @@ -883,40 +1390,58 @@ def time_duration(self) -> list: @time_duration.setter def time_duration(self, time_duration: list): - """Dispatch horizon time steps [hour]""" if len(time_duration) == len(self.blocks): for t, delta in zip(self.blocks, time_duration): self.blocks[t].time_duration = round(delta, self.round_digits) else: - raise ValueError(self.time_duration.__name__ + " list must be the same length as time horizon") + raise ValueError( + self.time_duration.__name__ + + " list must be the same length as time horizon" + ) @property def available_thermal_generation(self) -> list: """Available solar thermal generation from the csp field [MWt]""" - return [self.blocks[t].available_thermal_generation.value for t in self.blocks.index_set()] + return [ + self.blocks[t].available_thermal_generation.value + for t in self.blocks.index_set() + ] @available_thermal_generation.setter def available_thermal_generation(self, available_thermal_generation: list): - """Available solar thermal generation from the csp field [MWt]""" if len(available_thermal_generation) == len(self.blocks): for t, value in zip(self.blocks, available_thermal_generation): - self.blocks[t].available_thermal_generation = round(value, self.round_digits) + self.blocks[t].available_thermal_generation = round( + value, self.round_digits + ) else: - raise ValueError(self.available_thermal_generation.__name__ + " list must be the same length as time horizon") + raise ValueError( + self.available_thermal_generation.__name__ + + " list must be the same length as time horizon" + ) @property def cycle_ambient_efficiency_correction(self) -> list: """Cycle efficiency ambient temperature adjustment factor [-]""" - return [self.blocks[t].cycle_ambient_efficiency_correction.value for t in self.blocks.index_set()] + return [ + self.blocks[t].cycle_ambient_efficiency_correction.value + for t in self.blocks.index_set() + ] @cycle_ambient_efficiency_correction.setter - def cycle_ambient_efficiency_correction(self, cycle_ambient_efficiency_correction: list): - """Cycle efficiency ambient temperature adjustment factor [-]""" + def cycle_ambient_efficiency_correction( + self, cycle_ambient_efficiency_correction: list + ): if len(cycle_ambient_efficiency_correction) == len(self.blocks): for t, value in zip(self.blocks, cycle_ambient_efficiency_correction): - self.blocks[t].cycle_ambient_efficiency_correction = round(value, self.round_digits) + self.blocks[t].cycle_ambient_efficiency_correction = round( + value, self.round_digits + ) else: - raise ValueError(self.cycle_ambient_efficiency_correction.__name__ + " list must be the same length as time horizon") + raise ValueError( + self.cycle_ambient_efficiency_correction.__name__ + + " list must be the same length as time horizon" + ) @property def condenser_losses(self) -> list: @@ -925,26 +1450,35 @@ def condenser_losses(self) -> list: @condenser_losses.setter def condenser_losses(self, condenser_losses: list): - """Normalized condenser parasitic losses [-]""" if len(condenser_losses) == len(self.blocks): for t, value in zip(self.blocks, condenser_losses): self.blocks[t].condenser_losses = round(value, self.round_digits) else: - raise ValueError(self.condenser_losses.__name__ + " list must be the same length as time horizon") + raise ValueError( + self.condenser_losses.__name__ + + " list must be the same length as time horizon" + ) @property def receiver_startup_fraction(self) -> list: """Estimated fraction of time period required for receiver start-up [-]""" - return [self.blocks[t].receiver_startup_fraction.value for t in self.blocks.index_set()] + return [ + self.blocks[t].receiver_startup_fraction.value + for t in self.blocks.index_set() + ] @receiver_startup_fraction.setter def receiver_startup_fraction(self, receiver_startup_fraction: list): - """Estimated fraction of time period required for receiver start-up [-]""" if len(receiver_startup_fraction) == len(self.blocks): for t, value in zip(self.blocks, receiver_startup_fraction): - self.blocks[t].receiver_startup_fraction = round(value, self.round_digits) + self.blocks[t].receiver_startup_fraction = round( + value, self.round_digits + ) else: - raise ValueError(self.receiver_startup_fraction.__name__ + " list must be the same length as time horizon") + raise ValueError( + self.receiver_startup_fraction.__name__ + + " list must be the same length as time horizon" + ) @property def min_receiver_start_time(self) -> float: @@ -954,9 +1488,10 @@ def min_receiver_start_time(self) -> float: @min_receiver_start_time.setter def min_receiver_start_time(self, min_receiver_start_time_hr: float): - """Minimum time to start the receiver [hr]""" for t in self.blocks.index_set(): - self.blocks[t].min_receiver_start_time.set_value(round(min_receiver_start_time_hr, self.round_digits)) + self.blocks[t].min_receiver_start_time.set_value( + round(min_receiver_start_time_hr, self.round_digits) + ) @property def cost_per_field_generation(self) -> float: @@ -966,9 +1501,10 @@ def cost_per_field_generation(self) -> float: @cost_per_field_generation.setter def cost_per_field_generation(self, om_dollar_per_mwh_thermal: float): - """Generation cost for the csp field [$/MWht]""" for t in self.blocks.index_set(): - self.blocks[t].cost_per_field_generation.set_value(round(om_dollar_per_mwh_thermal, self.round_digits)) + self.blocks[t].cost_per_field_generation.set_value( + round(om_dollar_per_mwh_thermal, self.round_digits) + ) @property def cost_per_field_start(self) -> float: @@ -978,9 +1514,10 @@ def cost_per_field_start(self) -> float: @cost_per_field_start.setter def cost_per_field_start(self, dollars_per_start: float): - """Penalty for field start-up [$/start]""" for t in self.blocks.index_set(): - self.blocks[t].cost_per_field_start.set_value(round(dollars_per_start, self.round_digits)) + self.blocks[t].cost_per_field_start.set_value( + round(dollars_per_start, self.round_digits) + ) @property def cost_per_cycle_generation(self) -> float: @@ -990,9 +1527,10 @@ def cost_per_cycle_generation(self) -> float: @cost_per_cycle_generation.setter def cost_per_cycle_generation(self, om_dollar_per_mwh_electric: float): - """Generation cost for power cycle [$/MWhe]""" for t in self.blocks.index_set(): - self.blocks[t].cost_per_cycle_generation.set_value(round(om_dollar_per_mwh_electric, self.round_digits)) + self.blocks[t].cost_per_cycle_generation.set_value( + round(om_dollar_per_mwh_electric, self.round_digits) + ) @property def cost_per_cycle_start(self) -> float: @@ -1002,9 +1540,10 @@ def cost_per_cycle_start(self) -> float: @cost_per_cycle_start.setter def cost_per_cycle_start(self, dollars_per_start: float): - """Penalty for power cycle start [$/start]""" for t in self.blocks.index_set(): - self.blocks[t].cost_per_cycle_start.set_value(round(dollars_per_start, self.round_digits)) + self.blocks[t].cost_per_cycle_start.set_value( + round(dollars_per_start, self.round_digits) + ) @property def cost_per_change_thermal_input(self) -> float: @@ -1014,9 +1553,10 @@ def cost_per_change_thermal_input(self) -> float: @cost_per_change_thermal_input.setter def cost_per_change_thermal_input(self, dollars_per_thermal_power: float): - """Penalty for change in power cycle thermal input [$/MWt]""" for t in self.blocks.index_set(): - self.blocks[t].cost_per_change_thermal_input.set_value(round(dollars_per_thermal_power, self.round_digits)) + self.blocks[t].cost_per_change_thermal_input.set_value( + round(dollars_per_thermal_power, self.round_digits) + ) @property def field_startup_losses(self) -> float: @@ -1026,9 +1566,10 @@ def field_startup_losses(self) -> float: @field_startup_losses.setter def field_startup_losses(self, field_startup_losses: float): - """Solar field startup or shutdown parasitic loss [MWhe]""" for t in self.blocks.index_set(): - self.blocks[t].field_startup_losses.set_value(round(field_startup_losses, self.round_digits)) + self.blocks[t].field_startup_losses.set_value( + round(field_startup_losses, self.round_digits) + ) @property def receiver_required_startup_energy(self) -> float: @@ -1038,9 +1579,10 @@ def receiver_required_startup_energy(self) -> float: @receiver_required_startup_energy.setter def receiver_required_startup_energy(self, energy: float): - """Required energy expended to start receiver [MWht]""" for t in self.blocks.index_set(): - self.blocks[t].receiver_required_startup_energy.set_value(round(energy, self.round_digits)) + self.blocks[t].receiver_required_startup_energy.set_value( + round(energy, self.round_digits) + ) @property def storage_capacity(self) -> float: @@ -1050,7 +1592,6 @@ def storage_capacity(self) -> float: @storage_capacity.setter def storage_capacity(self, energy: float): - """Thermal energy storage capacity [MWht]""" for t in self.blocks.index_set(): self.blocks[t].storage_capacity.set_value(round(energy, self.round_digits)) @@ -1062,9 +1603,10 @@ def receiver_pumping_losses(self) -> float: @receiver_pumping_losses.setter def receiver_pumping_losses(self, electric_per_thermal: float): - """Solar field and/or receiver pumping power per unit power produced [MWe/MWt]""" for t in self.blocks.index_set(): - self.blocks[t].receiver_pumping_losses.set_value(round(electric_per_thermal, self.round_digits)) + self.blocks[t].receiver_pumping_losses.set_value( + round(electric_per_thermal, self.round_digits) + ) @property def minimum_receiver_power(self) -> float: @@ -1074,9 +1616,10 @@ def minimum_receiver_power(self) -> float: @minimum_receiver_power.setter def minimum_receiver_power(self, thermal_power: float): - """Minimum operational thermal power delivered by receiver [MWt]""" for t in self.blocks.index_set(): - self.blocks[t].minimum_receiver_power.set_value(round(thermal_power, self.round_digits)) + self.blocks[t].minimum_receiver_power.set_value( + round(thermal_power, self.round_digits) + ) @property def allowable_receiver_startup_power(self) -> float: @@ -1086,9 +1629,10 @@ def allowable_receiver_startup_power(self) -> float: @allowable_receiver_startup_power.setter def allowable_receiver_startup_power(self, thermal_power: float): - """Allowable power per period for receiver start-up [MWt]""" for t in self.blocks.index_set(): - self.blocks[t].allowable_receiver_startup_power.set_value(round(thermal_power, self.round_digits)) + self.blocks[t].allowable_receiver_startup_power.set_value( + round(thermal_power, self.round_digits) + ) @property def field_track_losses(self) -> float: @@ -1098,9 +1642,10 @@ def field_track_losses(self) -> float: @field_track_losses.setter def field_track_losses(self, electric_power: float): - """Solar field tracking parasitic loss [MWe]""" for t in self.blocks.index_set(): - self.blocks[t].field_track_losses.set_value(round(electric_power, self.round_digits)) + self.blocks[t].field_track_losses.set_value( + round(electric_power, self.round_digits) + ) # @property # def heat_trace_losses(self) -> float: @@ -1122,9 +1667,10 @@ def cycle_required_startup_energy(self) -> float: @cycle_required_startup_energy.setter def cycle_required_startup_energy(self, thermal_energy: float): - """Required energy expended to start cycle [MWht]""" for t in self.blocks.index_set(): - self.blocks[t].cycle_required_startup_energy.set_value(round(thermal_energy, self.round_digits)) + self.blocks[t].cycle_required_startup_energy.set_value( + round(thermal_energy, self.round_digits) + ) @property def cycle_nominal_efficiency(self) -> float: @@ -1134,10 +1680,11 @@ def cycle_nominal_efficiency(self) -> float: @cycle_nominal_efficiency.setter def cycle_nominal_efficiency(self, efficiency: float): - """Power cycle nominal efficiency [-]""" efficiency = self._check_efficiency_value(efficiency) for t in self.blocks.index_set(): - self.blocks[t].cycle_nominal_efficiency.set_value(round(efficiency, self.round_digits)) + self.blocks[t].cycle_nominal_efficiency.set_value( + round(efficiency, self.round_digits) + ) @property def cycle_performance_slope(self) -> float: @@ -1147,9 +1694,10 @@ def cycle_performance_slope(self) -> float: @cycle_performance_slope.setter def cycle_performance_slope(self, slope: float): - """Slope of linear approximation of power cycle performance curve [MWe/MWt]""" for t in self.blocks.index_set(): - self.blocks[t].cycle_performance_slope.set_value(round(slope, self.round_digits)) + self.blocks[t].cycle_performance_slope.set_value( + round(slope, self.round_digits) + ) @property def cycle_pumping_losses(self) -> float: @@ -1159,9 +1707,10 @@ def cycle_pumping_losses(self) -> float: @cycle_pumping_losses.setter def cycle_pumping_losses(self, electric_per_thermal: float): - """Cycle heat transfer fluid pumping power per unit energy expended [MWe/MWt]""" for t in self.blocks.index_set(): - self.blocks[t].cycle_pumping_losses.set_value(round(electric_per_thermal, self.round_digits)) + self.blocks[t].cycle_pumping_losses.set_value( + round(electric_per_thermal, self.round_digits) + ) @property def allowable_cycle_startup_power(self) -> float: @@ -1171,9 +1720,10 @@ def allowable_cycle_startup_power(self) -> float: @allowable_cycle_startup_power.setter def allowable_cycle_startup_power(self, thermal_power: float): - """Allowable power per period for cycle start-up [MWt]""" for t in self.blocks.index_set(): - self.blocks[t].allowable_cycle_startup_power.set_value(round(thermal_power, self.round_digits)) + self.blocks[t].allowable_cycle_startup_power.set_value( + round(thermal_power, self.round_digits) + ) @property def minimum_cycle_thermal_power(self) -> float: @@ -1183,9 +1733,10 @@ def minimum_cycle_thermal_power(self) -> float: @minimum_cycle_thermal_power.setter def minimum_cycle_thermal_power(self, thermal_power: float): - """Minimum operational thermal power delivered to the power cycle [MWt]""" for t in self.blocks.index_set(): - self.blocks[t].minimum_cycle_thermal_power.set_value(round(thermal_power, self.round_digits)) + self.blocks[t].minimum_cycle_thermal_power.set_value( + round(thermal_power, self.round_digits) + ) @property def maximum_cycle_thermal_power(self) -> float: @@ -1195,9 +1746,10 @@ def maximum_cycle_thermal_power(self) -> float: @maximum_cycle_thermal_power.setter def maximum_cycle_thermal_power(self, thermal_power: float): - """Maximum operational thermal power delivered to the power cycle [MWt]""" for t in self.blocks.index_set(): - self.blocks[t].maximum_cycle_thermal_power.set_value(round(thermal_power, self.round_digits)) + self.blocks[t].maximum_cycle_thermal_power.set_value( + round(thermal_power, self.round_digits) + ) # @property # def minimum_cycle_power(self) -> float: @@ -1219,9 +1771,10 @@ def maximum_cycle_power(self) -> float: @maximum_cycle_power.setter def maximum_cycle_power(self, electric_power: float): - """Maximum cycle electric power output [MWe]""" for t in self.blocks.index_set(): - self.blocks[t].maximum_cycle_power.set_value(round(electric_power, self.round_digits)) + self.blocks[t].maximum_cycle_power.set_value( + round(electric_power, self.round_digits) + ) # INITIAL CONDITIONS @property @@ -1231,8 +1784,9 @@ def initial_thermal_energy_storage(self) -> float: @initial_thermal_energy_storage.setter def initial_thermal_energy_storage(self, initial_energy: float): - """Initial thermal energy storage reserve quantity at beginning of the horizon [MWht]""" - self.model.initial_thermal_energy_storage = round(initial_energy, self.round_digits) + self.model.initial_thermal_energy_storage = round( + initial_energy, self.round_digits + ) @property def initial_receiver_startup_inventory(self) -> float: @@ -1241,19 +1795,18 @@ def initial_receiver_startup_inventory(self) -> float: @initial_receiver_startup_inventory.setter def initial_receiver_startup_inventory(self, initial_energy: float): - """Initial receiver start-up energy inventory at beginning of the horizon [MWht]""" - self.model.initial_receiver_startup_inventory = round(initial_energy, self.round_digits) + self.model.initial_receiver_startup_inventory = round( + initial_energy, self.round_digits + ) @property def is_field_generating_initial(self) -> bool: """True (1) if solar field is generating 'usable' thermal power at beginning of the horizon; - False (0) Otherwise [-]""" + False (0) Otherwise [-]""" return bool(self.model.is_field_generating_initial.value) @is_field_generating_initial.setter def is_field_generating_initial(self, is_field_generating: Union[bool, int]): - """True (1) if solar field is generating 'usable' thermal power at beginning of the horizon; - False (0) Otherwise [-]""" self.model.is_field_generating_initial = int(is_field_generating) @property @@ -1263,7 +1816,6 @@ def is_field_starting_initial(self) -> bool: @is_field_starting_initial.setter def is_field_starting_initial(self, is_field_starting: Union[bool, int]): - """True (1) if solar field is starting up at beginning of the horizon; False (0) Otherwise [-]""" self.model.is_field_starting_initial = int(is_field_starting) @property @@ -1273,8 +1825,9 @@ def initial_cycle_startup_inventory(self) -> float: @initial_cycle_startup_inventory.setter def initial_cycle_startup_inventory(self, initial_energy: float): - """Initial cycle start-up energy inventory at beginning of the horizon [MWht]""" - self.model.initial_cycle_startup_inventory = round(initial_energy, self.round_digits) + self.model.initial_cycle_startup_inventory = round( + initial_energy, self.round_digits + ) @property def initial_cycle_thermal_power(self) -> float: @@ -1283,7 +1836,6 @@ def initial_cycle_thermal_power(self) -> float: @initial_cycle_thermal_power.setter def initial_cycle_thermal_power(self, initial_power: float): - """Initial cycle thermal power at beginning of the horizon [MWt]""" self.model.initial_cycle_thermal_power = round(initial_power, self.round_digits) @property @@ -1293,7 +1845,6 @@ def is_cycle_generating_initial(self) -> bool: @is_cycle_generating_initial.setter def is_cycle_generating_initial(self, is_cycle_generating: Union[bool, int]): - """True (1) if cycle is generating electric power at beginning of the horizon; False (0) Otherwise [-]""" self.model.is_cycle_generating_initial = int(is_cycle_generating) @property @@ -1303,82 +1854,125 @@ def is_cycle_starting_initial(self) -> bool: @is_cycle_starting_initial.setter def is_cycle_starting_initial(self, is_cycle_starting: Union[bool, int]): - """True (1) if cycle is starting up at beginning of the horizon; False (0) Otherwise [-]""" self.model.is_cycle_starting_initial = int(is_cycle_starting) # OUTPUTS @property def thermal_energy_storage(self) -> list: """Thermal energy storage reserve quantity [MWht]""" - return [round(self.blocks[t].thermal_energy_storage.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].thermal_energy_storage.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def receiver_startup_inventory(self) -> list: """Receiver start-up energy inventory [MWht]""" - return [round(self.blocks[t].receiver_startup_inventory.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].receiver_startup_inventory.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def receiver_thermal_power(self) -> list: """Thermal power delivered by the receiver [MWt]""" - return [round(self.blocks[t].receiver_thermal_power.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].receiver_thermal_power.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def receiver_startup_consumption(self) -> list: """Receiver start-up power consumption [MWt]""" - return [round(self.blocks[t].receiver_startup_consumption.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].receiver_startup_consumption.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def is_field_generating(self) -> list: """1 if solar field is generating 'usable' thermal power; 0 Otherwise [-]""" - return [round(self.blocks[t].is_field_generating.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].is_field_generating.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def is_field_starting(self) -> list: """1 if solar field is starting up; 0 Otherwise [-]""" - return [round(self.blocks[t].is_field_starting.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].is_field_starting.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def incur_field_start(self) -> list: """1 if solar field start-up penalty is incurred; 0 Otherwise [-]""" - return [round(self.blocks[t].incur_field_start.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].incur_field_start.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def cycle_startup_inventory(self) -> list: """Cycle start-up energy inventory [MWht]""" - return [round(self.blocks[t].cycle_startup_inventory.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].cycle_startup_inventory.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def system_load(self) -> list: """Net generation of csp system [MWe]""" - return [round(self.blocks[t].system_load.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].system_load.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def cycle_generation(self) -> list: """Power cycle electricity generation [MWe]""" - return [round(self.blocks[t].cycle_generation.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].cycle_generation.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def cycle_thermal_ramp(self) -> list: """Power cycle positive change in thermal energy input [MWt]""" - return [round(self.blocks[t].cycle_thermal_ramp.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].cycle_thermal_ramp.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def cycle_thermal_power(self) -> list: """Cycle thermal power utilization [MWt]""" - return [round(self.blocks[t].cycle_thermal_power.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].cycle_thermal_power.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def is_cycle_generating(self) -> list: """1 if cycle is generating electric power; 0 Otherwise [-]""" - return [round(self.blocks[t].is_cycle_generating.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].is_cycle_generating.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def is_cycle_starting(self) -> list: """1 if cycle is starting up; 0 Otherwise [-]""" - return [round(self.blocks[t].is_cycle_starting.value, self.round_digits) for t in self.blocks.index_set()] + return [ + round(self.blocks[t].is_cycle_starting.value, self.round_digits) + for t in self.blocks.index_set() + ] @property def incur_cycle_start(self) -> list: """1 if cycle start-up penalty is incurred; 0 Otherwise [-]""" - return [round(self.blocks[t].incur_cycle_start.value, self.round_digits) for t in self.blocks.index_set()] - + return [ + round(self.blocks[t].incur_cycle_start.value, self.round_digits) + for t in self.blocks.index_set() + ] diff --git a/hopp/simulation/technologies/dispatch/power_sources/power_source_dispatch.py b/hopp/simulation/technologies/dispatch/power_sources/power_source_dispatch.py index baf1edfc8..c1dd1d3fc 100644 --- a/hopp/simulation/technologies/dispatch/power_sources/power_source_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_sources/power_source_dispatch.py @@ -6,23 +6,45 @@ class PowerSourceDispatch(Dispatch): - """ - - """ - def __init__(self, - pyomo_model: pyomo.ConcreteModel, - index_set: pyomo.Set, - system_model, - financial_model, - block_set_name: str = 'generator'): - super().__init__(pyomo_model, - index_set, - system_model, - financial_model, - block_set_name=block_set_name) + """Dispatch optimization model for power sources.""" + + def __init__( + self, + pyomo_model: pyomo.ConcreteModel, + index_set: pyomo.Set, + system_model, + financial_model, + block_set_name: str = "generator", + ): + """Initialize PowerSourceDispatch. + + Args: + pyomo_model (pyomo.ConcreteModel): Pyomo concrete model. + index_set (pyomo.Set): Index set. + system_model: System model. + financial_model: Financial model. + block_set_name (str): Name of the block set. + + """ + super().__init__( + pyomo_model, + index_set, + system_model, + financial_model, + block_set_name=block_set_name, + ) @staticmethod def dispatch_block_rule(gen): + """Dispatch block rule method. + + Args: + gen: Generator. + + Returns: + None + + """ ################################## # Parameters # ################################## @@ -31,19 +53,22 @@ def dispatch_block_rule(gen): default=1.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.hr) + units=u.hr, + ) gen.cost_per_generation = pyomo.Param( doc="Generation cost for generator [$/MWh]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.USD / u.MWh) + units=u.USD / u.MWh, + ) gen.available_generation = pyomo.Param( doc="Available generation for the generator [MW]", default=0.0, within=pyomo.Reals, mutable=True, - units=u.MW) + units=u.MW, + ) ################################## # Variables # ################################## @@ -51,7 +76,8 @@ def dispatch_block_rule(gen): doc="Power generation of generator [MW]", domain=pyomo.NonNegativeReals, bounds=(0, gen.available_generation), - units=u.MW) + units=u.MW, + ) ################################## # Constraints # ################################## @@ -62,48 +88,117 @@ def dispatch_block_rule(gen): gen.port.add(gen.generation) def initialize_parameters(self): - self.cost_per_generation = self._financial_model.value("om_capacity")[0]*1e3/8760 + """Initialize parameters method.""" + self.cost_per_generation = ( + self._financial_model.value("om_capacity")[0] * 1e3 / 8760 + ) def update_time_series_parameters(self, start_time: int): + """Update time series parameters method. + + Args: + start_time (int): Start time. + + Returns: + None + + """ n_horizon = len(self.blocks.index_set()) generation = self._system_model.value("gen") if start_time + n_horizon > len(generation): horizon_gen = list(generation[start_time:]) - horizon_gen.extend(list(generation[0:n_horizon - len(horizon_gen)])) + horizon_gen.extend(list(generation[0 : n_horizon - len(horizon_gen)])) else: - horizon_gen = generation[start_time:start_time + n_horizon] + horizon_gen = generation[start_time : start_time + n_horizon] if len(horizon_gen) < len(self.blocks): - raise RuntimeError(f"Dispatch parameter update error at start_time {start_time}: System model " - f"{type(self._system_model)} generation profile should have at least {len(self.blocks)} " - f"length but has only {len(generation)}") + raise RuntimeError( + f"Dispatch parameter update error at start_time {start_time}: System model " + f"{type(self._system_model)} generation profile should have at least {len(self.blocks)} " + f"length but has only {len(generation)}" + ) self.available_generation = [gen_kw / 1e3 for gen_kw in horizon_gen] + def _create_variables(self, hybrid): + """Create variables method (abstract). + + Args: + hybrid: hybrid plant instance to which individual technology is added. + + Returns: + None + + Raises: + NotImplemented: Must be overridden in specific technology models. + + """ + raise NotImplemented( + "This function must be overridden for specific dispatch model" + ) + + def _create_port(self, hybrid): + """Create port method (abstract). + + Args: + hybrid: Hybrid. + + Returns: + None + + Raises: + NotImplemented: Must be overridden in specific technology models. + + """ + raise NotImplemented( + "This function must be overridden for specific dispatch model" + ) + @property def cost_per_generation(self) -> float: + """Cost per generation [$/MWh]""" for t in self.blocks.index_set(): return self.blocks[t].cost_per_generation.value @cost_per_generation.setter def cost_per_generation(self, om_dollar_per_mwh: float): for t in self.blocks.index_set(): - self.blocks[t].cost_per_generation.set_value(round(om_dollar_per_mwh, self.round_digits)) + self.blocks[t].cost_per_generation.set_value( + round(om_dollar_per_mwh, self.round_digits) + ) @property def available_generation(self) -> list: - return [self.blocks[t].available_generation.value for t in self.blocks.index_set()] + """Available generation. + + Returns: + list: List of available generation. + + """ + return [ + self.blocks[t].available_generation.value for t in self.blocks.index_set() + ] @available_generation.setter def available_generation(self, resource: list): if len(resource) == len(self.blocks): for t, gen in zip(self.blocks, resource): - self.blocks[t].available_generation.set_value(round(gen, self.round_digits)) + self.blocks[t].available_generation.set_value( + round(gen, self.round_digits) + ) else: - raise ValueError(f"'resource' list ({len(resource)}) must be the same length as time horizon ({len(self.blocks)})") + raise ValueError( + f"'resource' list ({len(resource)}) must be the same length as time horizon ({len(self.blocks)})" + ) @property def generation(self) -> list: - return [round(self.blocks[t].generation.value, self.round_digits) for t in self.blocks.index_set()] - + """Generation. + Returns: + list: List of generation. + """ + return [ + round(self.blocks[t].generation.value, self.round_digits) + for t in self.blocks.index_set() + ] diff --git a/hopp/simulation/technologies/dispatch/power_sources/pv_dispatch.py b/hopp/simulation/technologies/dispatch/power_sources/pv_dispatch.py index 5e3b737ed..f368fedcc 100644 --- a/hopp/simulation/technologies/dispatch/power_sources/pv_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_sources/pv_dispatch.py @@ -1,5 +1,6 @@ from typing import Union -from pyomo.environ import ConcreteModel, Set +from pyomo.environ import ConcreteModel, Expression, NonNegativeReals, Set, units, Var +from pyomo.network import Port import PySAM.Pvsamv1 as Pvsam import PySAM.Pvwattsv8 as Pvwatts @@ -9,19 +10,113 @@ class PvDispatch(PowerSourceDispatch): + pv_obj: Union[Expression, float] _system_model: Union[Pvsam.Pvsamv1, Pvwatts.Pvwattsv8] _financial_model: FinancialModelType - """ + """Dispatch optimization model for photovoltaic (PV) systems.""" - """ - def __init__(self, - pyomo_model: ConcreteModel, - indexed_set: Set, - system_model: Union[Pvsam.Pvsamv1, Pvwatts.Pvwattsv8], - financial_model: FinancialModelType, - block_set_name: str = 'pv'): - super().__init__(pyomo_model, indexed_set, system_model, financial_model, block_set_name=block_set_name) + def __init__( + self, + pyomo_model: ConcreteModel, + indexed_set: Set, + system_model: Union[Pvsam.Pvsamv1, Pvwatts.Pvwattsv8], + financial_model: FinancialModelType, + block_set_name: str = "pv", + ): + """Initialize PvDispatch. + + Args: + pyomo_model (ConcreteModel): Pyomo concrete model. + indexed_set (Set): Indexed set. + system_model (Union[Pvsam.Pvsamv1, Pvwatts.Pvwattsv8]): System model. + financial_model (FinancialModelType): Financial model. + block_set_name (str): Name of the block set. + + """ + + super().__init__( + pyomo_model, + indexed_set, + system_model, + financial_model, + block_set_name=block_set_name, + ) def update_time_series_parameters(self, start_time: int): + """Update time series parameters method. + + Args: + start_time (int): Start time. + + """ super().update_time_series_parameters(start_time) - self.available_generation = [max(0, i) for i in self.available_generation] # zero out any negative load + + # zero out any negative load + self.available_generation = [max(0, i) for i in self.available_generation] + + def max_gross_profit_objective(self, hybrid_blocks): + """PV instance of maximum gross profit objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = Expression( + expr=sum( + -(1 / hybrid_blocks[t].time_weighting_factor) + * self.blocks[t].time_duration + * self.blocks[t].cost_per_generation + * hybrid_blocks[t].pv_generation + for t in hybrid_blocks.index_set() + ) + ) + + def min_operating_cost_objective(self, hybrid_blocks): + """PV instance of minimum operating cost objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = sum( + hybrid_blocks[t].time_weighting_factor + * self.blocks[t].time_duration + * self.blocks[t].cost_per_generation + * hybrid_blocks[t].pv_generation + for t in hybrid_blocks.index_set() + ) + + def _create_variables(self, hybrid): + """Create PV variables to add to hybrid plant instance. + + Args: + hybrid: Hybrid plant instance. + + Returns: + tuple: Tuple containing created variables. + - generation: Generation from given technology. + - load: Load from given technology. + + """ + hybrid.pv_generation = Var( + doc="Power generation of photovoltaics [MW]", + domain=NonNegativeReals, + units=units.MW, + initialize=0.0, + ) + return hybrid.pv_generation, 0 + + def _create_port(self, hybrid): + """Create pv port to add to hybrid plant instance. + + Args: + hybrid: Hybrid plant instance. + + Returns: + Port: PV Port object. + + """ + hybrid.pv_port = Port(initialize={"generation": hybrid.pv_generation}) + return hybrid.pv_port diff --git a/hopp/simulation/technologies/dispatch/power_sources/tower_dispatch.py b/hopp/simulation/technologies/dispatch/power_sources/tower_dispatch.py index fd596a2fb..4edf02ab3 100644 --- a/hopp/simulation/technologies/dispatch/power_sources/tower_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_sources/tower_dispatch.py @@ -1,33 +1,180 @@ -from pyomo.environ import ConcreteModel, Set +from typing import Union +from pyomo.environ import ConcreteModel, Expression, NonNegativeReals, Set, units, Var +from pyomo.network import Port from hopp.simulation.technologies.financial import FinancialModelType from hopp.simulation.technologies.dispatch.power_sources.csp_dispatch import CspDispatch class TowerDispatch(CspDispatch): + tower_obj: Union[Expression, float] _system_model: None _financial_model: FinancialModelType - """ + """Dispatch optimization model for CSP tower systems.""" - """ - def __init__(self, - pyomo_model: ConcreteModel, - indexed_set: Set, - system_model: None, - financial_model: FinancialModelType, - block_set_name: str = 'tower'): - super().__init__(pyomo_model, indexed_set, system_model, financial_model, block_set_name=block_set_name) + def __init__( + self, + pyomo_model: ConcreteModel, + indexed_set: Set, + system_model: None, + financial_model: FinancialModelType, + block_set_name: str = "tower", + ): + """Initialize TowerDispatch. + + Args: + pyomo_model (ConcreteModel): Pyomo concrete model. + indexed_set (Set): Indexed set. + system_model (None): System model. + financial_model (FinancialModelType): Financial model. + block_set_name (str): Name of the block set. + + """ + super().__init__( + pyomo_model, + indexed_set, + system_model, + financial_model, + block_set_name=block_set_name, + ) def update_initial_conditions(self): + """Update initial conditions.""" super().update_initial_conditions() csp = self._system_model # Note, SS receiver model in ssc assumes full available power is used for startup # (even if, time requirement is binding) - rec_accumulate_time = max(0.0, csp.value('rec_su_delay') - csp.plant_state['rec_startup_time_remain_init']) - rec_accumulate_energy = max(0.0, self.receiver_required_startup_energy - - csp.plant_state['rec_startup_energy_remain_init'] / 1e6) - self.initial_receiver_startup_inventory = min(rec_accumulate_energy, - rec_accumulate_time * self.allowable_receiver_startup_power) - if self.initial_receiver_startup_inventory > (1.0 - 1.e-6) * self.receiver_required_startup_energy: - self.initial_receiver_startup_inventory = self.receiver_required_startup_energy + rec_accumulate_time = max( + 0.0, + csp.value("rec_su_delay") - csp.plant_state["rec_startup_time_remain_init"], + ) + rec_accumulate_energy = max( + 0.0, + ( + self.receiver_required_startup_energy + - csp.plant_state["rec_startup_energy_remain_init"] / 1e6 + ), + ) + self.initial_receiver_startup_inventory = min( + rec_accumulate_energy, + rec_accumulate_time * self.allowable_receiver_startup_power, + ) + if ( + self.initial_receiver_startup_inventory + > (1.0 - 1.0e-6) * self.receiver_required_startup_energy + ): + self.initial_receiver_startup_inventory = ( + self.receiver_required_startup_energy + ) + + def max_gross_profit_objective(self, hybrid_blocks): + """Tower CSP instance of maximum gross profit objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = Expression( + expr=sum( + -(1 / hybrid_blocks[t].time_weighting_factor) + * ( + ( + self.blocks[t].cost_per_field_generation + * self.blocks[t].receiver_thermal_power + * self.blocks[t].time_duration + ) + + ( + self.blocks[t].cost_per_field_start + * self.blocks[t].incur_field_start + ) + + ( + self.blocks[t].cost_per_cycle_generation + * self.blocks[t].cycle_generation + * self.blocks[t].time_duration + ) + + ( + self.blocks[t].cost_per_cycle_start + * self.blocks[t].incur_cycle_start + ) + + ( + self.blocks[t].cost_per_change_thermal_input + * self.blocks[t].cycle_thermal_ramp + ) + ) + for t in hybrid_blocks.index_set() + ) + ) + + def min_operating_cost_objective(self, hybrid_blocks): + """Tower CSP instance of minimum operating cost objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = sum( + hybrid_blocks[t].time_weighting_factor + * ( + self.blocks[t].cost_per_field_start * self.blocks[t].incur_field_start + - ( + self.blocks[t].cost_per_field_generation + * self.blocks[t].receiver_thermal_power + * self.blocks[t].time_duration + ) # Trying to incentivize TES generation + + ( + self.blocks[t].cost_per_cycle_generation + * self.blocks[t].cycle_generation + * self.blocks[t].time_duration + ) + + self.blocks[t].cost_per_cycle_start * self.blocks[t].incur_cycle_start + + self.blocks[t].cost_per_change_thermal_input + * self.blocks[t].cycle_thermal_ramp + ) + for t in hybrid_blocks.index_set() + ) + + def _create_variables(self, hybrid): + """Create Tower CSP variables to add to hybrid plant instance. + + Args: + hybrid: Hybrid plant instance. + + Returns: + tuple: Tuple containing created variables. + - generation: Generation from given technology. + - load: Load from given technology. + + """ + hybrid.tower_generation = Var( + doc="Power generation of CSP tower [MW]", + domain=NonNegativeReals, + units=units.MW, + initialize=0.0, + ) + hybrid.tower_load = Var( + doc="Load of CSP tower [MW]", + domain=NonNegativeReals, + units=units.MW, + initialize=0.0, + ) + return hybrid.tower_generation, hybrid.tower_load + + def _create_port(self, hybrid): + """Create CSP tower port to add to hybrid plant instance. + + Args: + hybrid: Hybrid plant instance. + + Returns: + Port: CSP Tower Port object. + """ + hybrid.tower_port = Port( + initialize={ + "cycle_generation": hybrid.tower_generation, + "system_load": hybrid.tower_load, + } + ) + return hybrid.tower_port diff --git a/hopp/simulation/technologies/dispatch/power_sources/trough_dispatch.py b/hopp/simulation/technologies/dispatch/power_sources/trough_dispatch.py index 6694cc0a7..a018da01c 100644 --- a/hopp/simulation/technologies/dispatch/power_sources/trough_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_sources/trough_dispatch.py @@ -1,27 +1,162 @@ -from pyomo.environ import ConcreteModel, Set +from typing import Union +from pyomo.environ import ConcreteModel, Expression, NonNegativeReals, Set, units, Var +from pyomo.network import Port from hopp.simulation.technologies.financial import FinancialModelType from hopp.simulation.technologies.dispatch.power_sources.csp_dispatch import CspDispatch class TroughDispatch(CspDispatch): + trough_obj: Union[Expression, float] _system_model: None _financial_model: FinancialModelType - """ + """Dispatch optimization model for CSP trough systems.""" - """ - def __init__(self, - pyomo_model: ConcreteModel, - indexed_set: Set, - system_model: None, - financial_model: FinancialModelType, - block_set_name: str = 'trough'): - super().__init__(pyomo_model, indexed_set, system_model, financial_model, block_set_name=block_set_name) + def __init__( + self, + pyomo_model: ConcreteModel, + indexed_set: Set, + system_model: None, + financial_model: FinancialModelType, + block_set_name: str = "trough", + ): + """Initialize TroughDispatch. + + Args: + pyomo_model (ConcreteModel): Pyomo concrete model. + indexed_set (Set): Indexed set. + system_model (None): System model. + financial_model (FinancialModelType): Financial model. + block_set_name (str): Name of the block set. + + """ + super().__init__( + pyomo_model, + indexed_set, + system_model, + financial_model, + block_set_name=block_set_name, + ) def update_initial_conditions(self): + """Update initial conditions method.""" super().update_initial_conditions() self.initial_receiver_startup_inventory = 0.0 # FIXME: if self.is_field_starting_initial: - print('Warning: Solar field is starting at the initial time step of the dispatch horizon, but initial ' - 'startup energy inventory is assumed to be zero. This may result in persistent receiver start-up') + print( + "Warning: Solar field is starting at the initial time step of the dispatch " + "horizon, but initial startup energy inventory is assumed to be zero. This may " + "result in persistent receiver start-up." + ) + + def max_gross_profit_objective(self, hybrid_blocks): + """Trough CSP instance of maximum gross profit objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = Expression( + expr=sum( + -(1 / hybrid_blocks[t].time_weighting_factor) + * ( + ( + self.blocks[t].cost_per_field_generation + * self.blocks[t].receiver_thermal_power + * self.blocks[t].time_duration + ) + + ( + self.blocks[t].cost_per_field_start + * self.blocks[t].incur_field_start + ) + + ( + self.blocks[t].cost_per_cycle_generation + * self.blocks[t].cycle_generation + * self.blocks[t].time_duration + ) + + ( + self.blocks[t].cost_per_cycle_start + * self.blocks[t].incur_cycle_start + ) + + ( + self.blocks[t].cost_per_change_thermal_input + * self.blocks[t].cycle_thermal_ramp + ) + ) + for t in hybrid_blocks.index_set() + ) + ) + + def min_operating_cost_objective(self, hybrid_blocks): + """Trough CSP instance of minimum operating cost objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = sum( + hybrid_blocks[t].time_weighting_factor + * ( + self.blocks[t].cost_per_field_start * self.blocks[t].incur_field_start + - ( + self.blocks[t].cost_per_field_generation + * self.blocks[t].receiver_thermal_power + * self.blocks[t].time_duration + ) # Trying to incentivize TES generation + + ( + self.blocks[t].cost_per_cycle_generation + * self.blocks[t].cycle_generation + * self.blocks[t].time_duration + ) + + self.blocks[t].cost_per_cycle_start * self.blocks[t].incur_cycle_start + + self.blocks[t].cost_per_change_thermal_input + * self.blocks[t].cycle_thermal_ramp + ) + for t in hybrid_blocks.index_set() + ) + + def _create_variables(self, hybrid): + """Create Trough CSP variables to add to hybrid plant instance. + + Args: + hybrid: Hybrid plant instance. + + Returns: + tuple: Tuple containing created variables. + - generation: Generation from given technology. + - load: Load from given technology. + + """ + hybrid.trough_generation = Var( + doc="Power generation of CSP trough [MW]", + domain=NonNegativeReals, + units=units.MW, + initialize=0.0, + ) + hybrid.trough_load = Var( + doc="Load of CSP trough [MW]", + domain=NonNegativeReals, + units=units.MW, + initialize=0.0, + ) + return hybrid.trough_generation, hybrid.trough_load + + def _create_port(self, hybrid): + """Create CSP trough port to add to hybrid plant instance. + + Args: + hybrid: Hybrid plant instance. + + Returns: + Port: CSP Trough Port object. + """ + hybrid.trough_port = Port( + initialize={ + "cycle_generation": hybrid.trough_generation, + "system_load": hybrid.trough_load, + } + ) + return hybrid.trough_port diff --git a/hopp/simulation/technologies/dispatch/power_sources/wave_dispatch.py b/hopp/simulation/technologies/dispatch/power_sources/wave_dispatch.py index 55f1d617a..298105983 100644 --- a/hopp/simulation/technologies/dispatch/power_sources/wave_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_sources/wave_dispatch.py @@ -1,23 +1,110 @@ -from pyomo.environ import ConcreteModel, Set +from typing import Union +from pyomo.environ import ConcreteModel, Expression, NonNegativeReals, Set, units, Var +from pyomo.network import Port import PySAM.MhkWave as MhkWave from hopp.simulation.technologies.financial import FinancialModelType -from hopp.simulation.technologies.dispatch.power_sources.power_source_dispatch import PowerSourceDispatch - +from hopp.simulation.technologies.dispatch.power_sources.power_source_dispatch import ( + PowerSourceDispatch, +) class WaveDispatch(PowerSourceDispatch): + wave_obj: Union[Expression, float] _system_model: MhkWave.MhkWave _financial_model: FinancialModelType - """ + """Dispatch optimization model for mhk wave power source.""" + + def __init__( + self, + pyomo_model: ConcreteModel, + indexed_set: Set, + system_model: MhkWave.MhkWave, + financial_model: FinancialModelType, + block_set_name: str = "wave", + ): + """Initialize WaveDispatch. + + Args: + pyomo_model (ConcreteModel): Pyomo concrete model. + indexed_set (Set): Indexed set. + system_model (MhkWave.MhkWave): System model. + financial_model (FinancialModelType): Financial model. + block_set_name (str): Name of the block set. + + """ + super().__init__( + pyomo_model, + indexed_set, + system_model, + financial_model, + block_set_name=block_set_name, + ) + + def max_gross_profit_objective(self, hybrid_blocks): + """MHK wave instance of maximum gross profit objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = Expression( + expr=sum( + -(1 / hybrid_blocks[t].time_weighting_factor) + * self.blocks[t].time_duration + * self.blocks[t].cost_per_generation + * hybrid_blocks[t].wave_generation + for t in hybrid_blocks.index_set() + ) + ) + + def min_operating_cost_objective(self, hybrid_blocks): + """MHK wave instance of minimum operating cost objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = sum( + hybrid_blocks[t].time_weighting_factor + * self.blocks[t].time_duration + * self.blocks[t].cost_per_generation + * hybrid_blocks[t].wave_generation + for t in hybrid_blocks.index_set() + ) + + def _create_variables(self, hybrid): + """Create MHK wave variables to add to hybrid plant instance. + + Args: + hybrid: Hybrid plant instance. + + Returns: + tuple: Tuple containing created variables. + - generation: Generation from given technology. + - load: Load from given technology. + + """ + hybrid.wave_generation = Var( + doc="Power generation of wave devices [MW]", + domain=NonNegativeReals, + units=units.MW, + initialize=0.0, + ) + return hybrid.wave_generation, 0 + + def _create_port(self, hybrid): + """Create mhk wave port to add to hybrid plant instance. + + Args: + hybrid: Hybrid plant instance. - """ - def __init__(self, - pyomo_model: ConcreteModel, - indexed_set: Set, - system_model: MhkWave.MhkWave, - financial_model: FinancialModelType, - block_set_name: str = 'wave'): - super().__init__(pyomo_model, indexed_set, system_model, financial_model, block_set_name=block_set_name) + Returns: + Port: MHK wave Port object. + """ + hybrid.wave_port = Port(initialize={"generation": hybrid.wave_generation}) + return hybrid.wave_port diff --git a/hopp/simulation/technologies/dispatch/power_sources/wind_dispatch.py b/hopp/simulation/technologies/dispatch/power_sources/wind_dispatch.py index 1fe99861a..661be0fe6 100644 --- a/hopp/simulation/technologies/dispatch/power_sources/wind_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_sources/wind_dispatch.py @@ -1,25 +1,114 @@ from typing import Union, TYPE_CHECKING -from pyomo.environ import ConcreteModel, Set +from pyomo.environ import ConcreteModel, Expression, NonNegativeReals, Set, units, Var +from pyomo.network import Port import PySAM.Windpower as Windpower from hopp.simulation.technologies.financial import FinancialModelType -from hopp.simulation.technologies.dispatch.power_sources.power_source_dispatch import PowerSourceDispatch +from hopp.simulation.technologies.dispatch.power_sources.power_source_dispatch import ( + PowerSourceDispatch, +) if TYPE_CHECKING: from hopp.simulation.technologies.wind.floris import Floris + class WindDispatch(PowerSourceDispatch): - _system_model: Union[Windpower.Windpower,"Floris"] + wind_obj: Union[Expression, float] + _system_model: Union[Windpower.Windpower, "Floris"] _financial_model: FinancialModelType - """ + """Dispatch optimization model for wind power source.""" + + def __init__( + self, + pyomo_model: ConcreteModel, + indexed_set: Set, + system_model: Union[Windpower.Windpower, "Floris"], + financial_model: FinancialModelType, + block_set_name: str = "wind", + ): + """Initialize WindDispatch. + + Args: + pyomo_model (ConcreteModel): Pyomo concrete model. + indexed_set (Set): Indexed set. + system_model (Union[Windpower.Windpower,"Floris"]): System model. + financial_model (FinancialModelType): Financial model. + block_set_name (str): Name of the block set. + + """ + + super().__init__( + pyomo_model, + indexed_set, + system_model, + financial_model, + block_set_name=block_set_name, + ) + + def max_gross_profit_objective(self, hybrid_blocks): + """Wind instance of maximum gross profit objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = Expression( + expr=sum( + -(1 / hybrid_blocks[t].time_weighting_factor) + * self.blocks[t].time_duration + * self.blocks[t].cost_per_generation + * hybrid_blocks[t].wind_generation + for t in hybrid_blocks.index_set() + ) + ) + + def min_operating_cost_objective(self, hybrid_blocks): + """Wind instance of minimum operating cost objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = sum( + hybrid_blocks[t].time_weighting_factor + * self.blocks[t].time_duration + * self.blocks[t].cost_per_generation + * hybrid_blocks[t].wind_generation + for t in hybrid_blocks.index_set() + ) + + def _create_variables(self, hybrid): + """Create wind variables to add to hybrid plant instance. + + Args: + hybrid: Hybrid plant instance. + + Returns: + tuple: Tuple containing created variables. + - generation: Generation from given technology. + - load: Load from given technology. + + """ + hybrid.wind_generation = Var( + doc="Power generation of wind turbines [MW]", + domain=NonNegativeReals, + units=units.MW, + initialize=0.0, + ) + return hybrid.wind_generation, 0 + + def _create_port(self, hybrid): + """Create wind port to add to hybrid plant instance. - """ - def __init__(self, - pyomo_model: ConcreteModel, - indexed_set: Set, - system_model: Union[Windpower.Windpower,"Floris"], - financial_model: FinancialModelType, - block_set_name: str = 'wind'): - super().__init__(pyomo_model, indexed_set, system_model, financial_model, block_set_name=block_set_name) + Args: + hybrid: Hybrid plant instance. + Returns: + Port: Wind Port object. + + """ + hybrid.wind_port = Port(initialize={"generation": hybrid.wind_generation}) + return hybrid.wind_port diff --git a/hopp/simulation/technologies/dispatch/power_storage/__init__.py b/hopp/simulation/technologies/dispatch/power_storage/__init__.py index 73592b774..3bd3d34b4 100644 --- a/hopp/simulation/technologies/dispatch/power_storage/__init__.py +++ b/hopp/simulation/technologies/dispatch/power_storage/__init__.py @@ -1,7 +1,21 @@ -from hopp.simulation.technologies.dispatch.power_storage.linear_voltage_convex_battery_dispatch import ConvexLinearVoltageBatteryDispatch -from hopp.simulation.technologies.dispatch.power_storage.linear_voltage_nonconvex_battery_dispatch import NonConvexLinearVoltageBatteryDispatch -from hopp.simulation.technologies.dispatch.power_storage.one_cycle_battery_dispatch_heuristic import OneCycleBatteryDispatchHeuristic -from hopp.simulation.technologies.dispatch.power_storage.power_storage_dispatch import PowerStorageDispatch -from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch import SimpleBatteryDispatch -from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch_heuristic import SimpleBatteryDispatchHeuristic -from hopp.simulation.technologies.dispatch.power_storage.heuristic_load_following_dispatch import HeuristicLoadFollowingDispatch +from hopp.simulation.technologies.dispatch.power_storage.linear_voltage_convex_battery_dispatch import ( + ConvexLinearVoltageBatteryDispatch, +) +from hopp.simulation.technologies.dispatch.power_storage.linear_voltage_nonconvex_battery_dispatch import ( + NonConvexLinearVoltageBatteryDispatch, +) +from hopp.simulation.technologies.dispatch.power_storage.one_cycle_battery_dispatch_heuristic import ( + OneCycleBatteryDispatchHeuristic, +) +from hopp.simulation.technologies.dispatch.power_storage.power_storage_dispatch import ( + PowerStorageDispatch, +) +from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch import ( + SimpleBatteryDispatch, +) +from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch_heuristic import ( + SimpleBatteryDispatchHeuristic, +) +from hopp.simulation.technologies.dispatch.power_storage.heuristic_load_following_dispatch import ( + HeuristicLoadFollowingDispatch, +) diff --git a/hopp/simulation/technologies/dispatch/power_storage/heuristic_load_following_dispatch.py b/hopp/simulation/technologies/dispatch/power_storage/heuristic_load_following_dispatch.py index 2d6ae049a..7f9ead6be 100644 --- a/hopp/simulation/technologies/dispatch/power_storage/heuristic_load_following_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_storage/heuristic_load_following_dispatch.py @@ -5,27 +5,40 @@ import PySAM.BatteryStateful as BatteryModel import PySAM.Singleowner as Singleowner -from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch_heuristic import SimpleBatteryDispatchHeuristic +from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch_heuristic import ( + SimpleBatteryDispatchHeuristic, +) class HeuristicLoadFollowingDispatch(SimpleBatteryDispatchHeuristic): - """Operates the battery based on heuristic rules to meet the demand profile based power available from power generation profiles and + """Operates the battery based on heuristic rules to meet the demand profile based power available from power generation profiles and power demand profile. Currently, enforces available generation and grid limit assuming no battery charging from grid + """ - def __init__(self, - pyomo_model: pyomo.ConcreteModel, - index_set: pyomo.Set, - system_model: BatteryModel.BatteryStateful, - financial_model: Singleowner.Singleowner, - fixed_dispatch: Optional[List] = None, - block_set_name: str = 'heuristic_load_following_battery', - dispatch_options: Optional[dict] = None): - """ + + def __init__( + self, + pyomo_model: pyomo.ConcreteModel, + index_set: pyomo.Set, + system_model: BatteryModel.BatteryStateful, + financial_model: Singleowner.Singleowner, + fixed_dispatch: Optional[List] = None, + block_set_name: str = "heuristic_load_following_battery", + dispatch_options: Optional[dict] = None, + ): + """Initialize HeuristicLoadFollowingDispatch. Args: - fixed_dispatch: list of normalized values [-1, 1] (Charging (-), Discharging (+)) + pyomo_model (pyomo.ConcreteModel): Pyomo concrete model. + index_set (pyomo.Set): Indexed set. + system_model (BatteryModel.BatteryStateful): System model. + financial_model (Singleowner.Singleowner): Financial model. + fixed_dispatch (Optional[List], optional): List of normalized values [-1, 1] (Charging (-), Discharging (+)). Defaults to None. + block_set_name (str, optional): Name of the block set. Defaults to 'heuristic_load_following_battery'. + dispatch_options (Optional[dict], optional): Dispatch options. Defaults to None. + """ super().__init__( pyomo_model, @@ -34,29 +47,40 @@ def __init__(self, financial_model, fixed_dispatch, block_set_name, - dispatch_options + dispatch_options, ) def set_fixed_dispatch(self, gen: list, grid_limit: list, goal_power: list): - """Sets charge and discharge power of battery dispatch using fixed_dispatch attribute and enforces available - generation and grid limits. + """Sets charge and discharge power of battery dispatch using fixed_dispatch attribute + and enforces available generation and grid limits. + + Args: + gen (list): List of power generation. + grid_limit (list): List of grid limits. + goal_power (list): List of goal power. """ + self.check_gen_grid_limit(gen, grid_limit) self._set_power_fraction_limits(gen, grid_limit) self._heuristic_method(gen, goal_power) self._fix_dispatch_model_variables() def _heuristic_method(self, gen, goal_power): - """ Enforces battery power fraction limits and sets _fixed_dispatch attribute - Sets the _fixed_dispatch based on goal_power and gen (power genration profile) + """Enforces battery power fraction limits and sets _fixed_dispatch attribute. + Sets the _fixed_dispatch based on goal_power and gen (power generation profile). + + Args: + gen: Power generation profile. + goal_power: Goal power. + """ for t in self.blocks.index_set(): fd = (goal_power[t] - gen[t]) / self.maximum_power - if fd > 0.0: # Discharging + if fd > 0.0: # Discharging if fd > self.max_discharge_fraction[t]: fd = self.max_discharge_fraction[t] elif fd < 0.0: # Charging if -fd > self.max_charge_fraction[t]: fd = -self.max_charge_fraction[t] - self._fixed_dispatch[t] = fd \ No newline at end of file + self._fixed_dispatch[t] = fd diff --git a/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_convex_battery_dispatch.py b/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_convex_battery_dispatch.py index 3632d53a0..4c8e9ddbe 100644 --- a/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_convex_battery_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_convex_battery_dispatch.py @@ -4,34 +4,61 @@ import PySAM.BatteryStateful as BatteryModel import PySAM.Singleowner as Singleowner -from hopp.simulation.technologies.dispatch.power_storage.linear_voltage_nonconvex_battery_dispatch import NonConvexLinearVoltageBatteryDispatch +from hopp.simulation.technologies.dispatch.power_storage.linear_voltage_nonconvex_battery_dispatch import ( + NonConvexLinearVoltageBatteryDispatch, +) class ConvexLinearVoltageBatteryDispatch(NonConvexLinearVoltageBatteryDispatch): + """This class represents a convex linear voltage battery dispatch model. + + It extends the NonConvexLinearVoltageBatteryDispatch model and adds additional formulation to enforce convexity. + """ - - """ + # TODO: add a reference to original paper - def __init__(self, - pyomo_model: pyomo.ConcreteModel, - index_set: pyomo.Set, - system_model: BatteryModel.BatteryStateful, - financial_model: Singleowner.Singleowner, - block_set_name: str = 'convex_LV_battery', - dispatch_options: dict = None, - use_exp_voltage_point: bool = False): + def __init__( + self, + pyomo_model: pyomo.ConcreteModel, + index_set: pyomo.Set, + system_model: BatteryModel.BatteryStateful, + financial_model: Singleowner.Singleowner, + block_set_name: str = "convex_LV_battery", + dispatch_options: dict = None, + use_exp_voltage_point: bool = False, + ): + """Initialize ConvexLinearVoltageBatteryDispatch. + + Args: + pyomo_model (pyomo.ConcreteModel): Pyomo concrete model. + index_set (pyomo.Set): Indexed set. + system_model (BatteryModel.BatteryStateful): Battery system model. + financial_model (Singleowner.Singleowner): Financial model. + block_set_name (str, optional): Name of the block set. Defaults to 'convex_LV_battery'. + dispatch_options (dict, optional): Dispatch options. Defaults to None. + use_exp_voltage_point (bool, optional): Boolean indicating whether to use the exponential voltage point. Defaults to False. + + """ if dispatch_options is None: dispatch_options = {} - super().__init__(pyomo_model, - index_set, - system_model, - financial_model, - block_set_name=block_set_name, - dispatch_options=dispatch_options, - use_exp_voltage_point=use_exp_voltage_point) + super().__init__( + pyomo_model, + index_set, + system_model, + financial_model, + block_set_name=block_set_name, + dispatch_options=dispatch_options, + use_exp_voltage_point=use_exp_voltage_point, + ) def dispatch_block_rule(self, battery): + """Additional formulation for dispatch block rule. + + Args: + battery: Battery instance. + + """ # Additional formulation # Variables self._create_lv_battery_auxiliary_variables(battery) @@ -39,148 +66,264 @@ def dispatch_block_rule(self, battery): @staticmethod def _create_lv_battery_auxiliary_variables(battery): + """Create auxiliary variables for the battery model. + + Args: + battery: Battery instance. + + """ # Auxiliary Variables battery.aux_charge_current_soc = pyomo.Var( doc="Auxiliary bi-linear term equal to the product of charge current and previous state-of-charge [MA]", domain=pyomo.NonNegativeReals, - units=u.MA) # = charge_current[t] * soc[t-1] + units=u.MA, + ) # = charge_current[t] * soc[t-1] battery.aux_charge_current_is_charging = pyomo.Var( doc="Auxiliary bi-linear term equal to the product of charge current and charging binary [MA]", domain=pyomo.NonNegativeReals, - units=u.MA) # = charge_current[t] * is_charging[t] + units=u.MA, + ) # = charge_current[t] * is_charging[t] battery.aux_discharge_current_soc = pyomo.Var( doc="Auxiliary bi-linear equal to the product of discharge current and previous state-of-charge [MA]", domain=pyomo.NonNegativeReals, - units=u.MA) # = discharge_current[t] * soc[t-1] + units=u.MA, + ) # = discharge_current[t] * soc[t-1] battery.aux_discharge_current_is_discharging = pyomo.Var( doc="Auxiliary bi-linear term equal to the product of discharge current and discharging binary [MA]", domain=pyomo.NonNegativeReals, - units=u.MA) # = discharge_current[t] * is_discharging[t] + units=u.MA, + ) # = discharge_current[t] * is_discharging[t] @staticmethod def _create_lv_battery_power_equation_constraints(battery): + """Create power equation constraints for the battery model. + + Args: + battery: Battery instance. + + """ battery.charge_power_equation = pyomo.Constraint( doc="Battery charge power equation equal to the product of current and voltage", - expr=battery.charge_power == (battery.voltage_slope * battery.aux_charge_current_soc - + (battery.voltage_intercept - + battery.average_current * battery.internal_resistance - ) * battery.aux_charge_current_is_charging)) + expr=battery.charge_power + == ( + battery.voltage_slope * battery.aux_charge_current_soc + + ( + battery.voltage_intercept + + battery.average_current * battery.internal_resistance + ) + * battery.aux_charge_current_is_charging + ), + ) battery.discharge_power_equation = pyomo.Constraint( doc="Battery discharge power equation equal to the product of current and voltage", - expr=battery.discharge_power == (battery.voltage_slope * battery.aux_discharge_current_soc - + (battery.voltage_intercept - - battery.average_current * battery.internal_resistance - ) * battery.aux_discharge_current_is_discharging)) + expr=battery.discharge_power + == ( + battery.voltage_slope * battery.aux_discharge_current_soc + + ( + battery.voltage_intercept + - battery.average_current * battery.internal_resistance + ) + * battery.aux_discharge_current_is_discharging + ), + ) # Auxiliary Variable bounds (binary*continuous exact linearization) # Charge current * charging binary battery.aux_charge_lb = pyomo.Constraint( doc="Charge current * charge binary lower bound", - expr=battery.aux_charge_current_is_charging >= battery.minimum_charge_current * battery.is_charging) + expr=battery.aux_charge_current_is_charging + >= battery.minimum_charge_current * battery.is_charging, + ) battery.aux_charge_ub = pyomo.Constraint( doc="Charge current * charge binary upper bound", - expr=battery.aux_charge_current_is_charging <= battery.maximum_charge_current * battery.is_charging) + expr=battery.aux_charge_current_is_charging + <= battery.maximum_charge_current * battery.is_charging, + ) battery.aux_charge_diff_lb = pyomo.Constraint( doc="Charge current and auxiliary difference lower bound", - expr=(battery.charge_current - battery.aux_charge_current_is_charging - >= - battery.maximum_charge_current * (1 - battery.is_charging))) + expr=( + battery.charge_current - battery.aux_charge_current_is_charging + >= -battery.maximum_charge_current * (1 - battery.is_charging) + ), + ) battery.aux_charge_diff_ub = pyomo.Constraint( doc="Charge current and auxiliary difference upper bound", - expr=(battery.charge_current - battery.aux_charge_current_is_charging - <= battery.maximum_charge_current * (1 - battery.is_charging))) + expr=( + battery.charge_current - battery.aux_charge_current_is_charging + <= battery.maximum_charge_current * (1 - battery.is_charging) + ), + ) # Discharge current * discharging binary battery.aux_discharge_lb = pyomo.Constraint( doc="discharge current * discharge binary lower bound", - expr=(battery.aux_discharge_current_is_discharging - >= battery.minimum_discharge_current * battery.is_discharging)) + expr=( + battery.aux_discharge_current_is_discharging + >= battery.minimum_discharge_current * battery.is_discharging + ), + ) battery.aux_discharge_ub = pyomo.Constraint( doc="discharge current * discharge binary upper bound", - expr=(battery.aux_discharge_current_is_discharging - <= battery.maximum_discharge_current * battery.is_discharging)) + expr=( + battery.aux_discharge_current_is_discharging + <= battery.maximum_discharge_current * battery.is_discharging + ), + ) battery.aux_discharge_diff_lb = pyomo.Constraint( doc="discharge current and auxiliary difference lower bound", - expr=(battery.discharge_current - battery.aux_discharge_current_is_discharging - >= - battery.maximum_discharge_current * (1 - battery.is_discharging))) + expr=( + battery.discharge_current - battery.aux_discharge_current_is_discharging + >= -battery.maximum_discharge_current * (1 - battery.is_discharging) + ), + ) battery.aux_discharge_diff_ub = pyomo.Constraint( doc="discharge current and auxiliary difference upper bound", - expr=(battery.discharge_current - battery.aux_discharge_current_is_discharging - <= battery.maximum_discharge_current * (1 - battery.is_discharging))) + expr=( + battery.discharge_current - battery.aux_discharge_current_is_discharging + <= battery.maximum_discharge_current * (1 - battery.is_discharging) + ), + ) # Auxiliary Variable bounds (continuous*continuous approx. linearization) # TODO: The error in these constraints should be quantified # TODO: scaling the problem to between [0,1] might help battery.aux_charge_soc_lower1 = pyomo.Constraint( doc="McCormick envelope underestimate 1", - expr=battery.aux_charge_current_soc >= (battery.maximum_charge_current * battery.soc0 - + battery.maximum_soc * battery.charge_current - - battery.maximum_soc * battery.maximum_charge_current)) + expr=battery.aux_charge_current_soc + >= ( + battery.maximum_charge_current * battery.soc0 + + battery.maximum_soc * battery.charge_current + - battery.maximum_soc * battery.maximum_charge_current + ), + ) battery.aux_charge_soc_lower2 = pyomo.Constraint( doc="McCormick envelope underestimate 2", - expr=battery.aux_charge_current_soc >= (battery.minimum_charge_current * battery.soc0 - + battery.minimum_soc * battery.charge_current - - battery.minimum_soc * battery.minimum_charge_current)) + expr=battery.aux_charge_current_soc + >= ( + battery.minimum_charge_current * battery.soc0 + + battery.minimum_soc * battery.charge_current + - battery.minimum_soc * battery.minimum_charge_current + ), + ) battery.aux_charge_soc_upper1 = pyomo.Constraint( doc="McCormick envelope overestimate 1", - expr=battery.aux_charge_current_soc <= (battery.maximum_charge_current * battery.soc0 - + battery.minimum_soc * battery.charge_current - - battery.minimum_soc * battery.maximum_charge_current)) + expr=battery.aux_charge_current_soc + <= ( + battery.maximum_charge_current * battery.soc0 + + battery.minimum_soc * battery.charge_current + - battery.minimum_soc * battery.maximum_charge_current + ), + ) battery.aux_charge_soc_upper2 = pyomo.Constraint( doc="McCormick envelope overestimate 2", - expr=battery.aux_charge_current_soc <= (battery.minimum_charge_current * battery.soc0 - + battery.maximum_soc * battery.charge_current - - battery.maximum_soc * battery.minimum_charge_current)) + expr=battery.aux_charge_current_soc + <= ( + battery.minimum_charge_current * battery.soc0 + + battery.maximum_soc * battery.charge_current + - battery.maximum_soc * battery.minimum_charge_current + ), + ) battery.aux_discharge_soc_lower1 = pyomo.Constraint( doc="McCormick envelope underestimate 1", - expr=battery.aux_discharge_current_soc >= (battery.maximum_discharge_current * battery.soc0 - + battery.maximum_soc * battery.discharge_current - - battery.maximum_soc * battery.maximum_discharge_current)) + expr=battery.aux_discharge_current_soc + >= ( + battery.maximum_discharge_current * battery.soc0 + + battery.maximum_soc * battery.discharge_current + - battery.maximum_soc * battery.maximum_discharge_current + ), + ) battery.aux_discharge_soc_lower2 = pyomo.Constraint( doc="McCormick envelope underestimate 2", - expr=battery.aux_discharge_current_soc >= (battery.minimum_discharge_current * battery.soc0 - + battery.minimum_soc * battery.discharge_current - - battery.minimum_soc * battery.minimum_discharge_current)) + expr=battery.aux_discharge_current_soc + >= ( + battery.minimum_discharge_current * battery.soc0 + + battery.minimum_soc * battery.discharge_current + - battery.minimum_soc * battery.minimum_discharge_current + ), + ) battery.aux_discharge_soc_upper1 = pyomo.Constraint( doc="McCormick envelope overestimate 1", - expr=battery.aux_discharge_current_soc <= (battery.maximum_discharge_current * battery.soc0 - + battery.minimum_soc * battery.discharge_current - - battery.minimum_soc * battery.maximum_discharge_current)) + expr=battery.aux_discharge_current_soc + <= ( + battery.maximum_discharge_current * battery.soc0 + + battery.minimum_soc * battery.discharge_current + - battery.minimum_soc * battery.maximum_discharge_current + ), + ) battery.aux_discharge_soc_upper2 = pyomo.Constraint( doc="McCormick envelope overestimate 2", - expr=battery.aux_discharge_current_soc <= (battery.minimum_discharge_current * battery.soc0 - + battery.maximum_soc * battery.discharge_current - - battery.maximum_soc * battery.minimum_discharge_current)) + expr=battery.aux_discharge_current_soc + <= ( + battery.minimum_discharge_current * battery.soc0 + + battery.maximum_soc * battery.discharge_current + - battery.maximum_soc * battery.minimum_discharge_current + ), + ) def _lifecycle_count_rule(self, m, i): + """Lifecycle count rule. + + Args: + m: Model instance. + i: Index. + + """ # current accounting # TODO: Check for cheating -> there seems to be a lot of error start = int(i * self.timesteps_per_day) end = int((i + 1) * self.timesteps_per_day) - return self.model.lifecycles[i] == sum(self.blocks[t].time_duration - * (0.8 * self.blocks[t].discharge_current - - 0.8 * self.blocks[t].aux_discharge_current_soc) - / self.blocks[t].capacity for t in range(start, end)) + return self.model.lifecycles[i] == sum( + self.blocks[t].time_duration + * ( + 0.8 * self.blocks[t].discharge_current + - 0.8 * self.blocks[t].aux_discharge_current_soc + ) + / self.blocks[t].capacity + for t in range(start, end) + ) # Auxiliary Variables @property def aux_charge_current_soc(self) -> list: - return [self.blocks[t].aux_charge_current_soc.value for t in self.blocks.index_set()] + """List of auxiliary charge current state of charge.""" + return [ + self.blocks[t].aux_charge_current_soc.value for t in self.blocks.index_set() + ] @property def real_charge_current_soc(self) -> list: - return [self.blocks[t].charge_current.value * self.blocks[t].soc0.value for t in self.blocks.index_set()] + """List of real charge current state of charge.""" + return [ + self.blocks[t].charge_current.value * self.blocks[t].soc0.value + for t in self.blocks.index_set() + ] @property def aux_charge_current_is_charging(self) -> list: - return [self.blocks[t].aux_charge_current_is_charging.value for t in self.blocks.index_set()] + """List of auxiliary charge current charging status.""" + return [ + self.blocks[t].aux_charge_current_is_charging.value + for t in self.blocks.index_set() + ] @property def aux_discharge_current_soc(self) -> list: - return [self.blocks[t].aux_discharge_current_soc.value for t in self.blocks.index_set()] + """List of auxiliary discharge current state of charge.""" + return [ + self.blocks[t].aux_discharge_current_soc.value + for t in self.blocks.index_set() + ] @property def real_discharge_current_soc(self) -> list: - return [self.blocks[t].discharge_current.value * self.blocks[t].soc0.value for t in self.blocks.index_set()] + """List of real discharge current state of charge.""" + return [ + self.blocks[t].discharge_current.value * self.blocks[t].soc0.value + for t in self.blocks.index_set() + ] @property def aux_discharge_current_is_discharging(self) -> list: - return [self.blocks[t].aux_discharge_current_is_discharging.value for t in self.blocks.index_set()] - + """List of auxiliary discharge current discharging status.""" + return [ + self.blocks[t].aux_discharge_current_is_discharging.value + for t in self.blocks.index_set() + ] diff --git a/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_nonconvex_battery_dispatch.py b/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_nonconvex_battery_dispatch.py index 26b5e7c1f..fccbe7904 100644 --- a/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_nonconvex_battery_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_storage/linear_voltage_nonconvex_battery_dispatch.py @@ -4,35 +4,62 @@ import PySAM.BatteryStateful as BatteryModel import PySAM.Singleowner as Singleowner -from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch import SimpleBatteryDispatch +from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch import ( + SimpleBatteryDispatch, +) class NonConvexLinearVoltageBatteryDispatch(SimpleBatteryDispatch): - """ + """This class represents a non-convex linear voltage battery dispatch model. + It extends the SimpleBatteryDispatch model and adds additional formulation to handle non-convex behavior. + """ + # TODO: add a reference to original paper - def __init__(self, - pyomo_model: pyomo.ConcreteModel, - index_set: pyomo.Set, - system_model: BatteryModel.BatteryStateful, - financial_model: Singleowner.Singleowner, - block_set_name: str = 'LV_battery', - dispatch_options: dict = None, - use_exp_voltage_point: bool = False): - u.load_definitions_from_strings(['amp_hour = amp * hour = Ah = amphour']) + def __init__( + self, + pyomo_model: pyomo.ConcreteModel, + index_set: pyomo.Set, + system_model: BatteryModel.BatteryStateful, + financial_model: Singleowner.Singleowner, + block_set_name: str = "LV_battery", + dispatch_options: dict = None, + use_exp_voltage_point: bool = False, + ): + """Initialize NonConvexLinearVoltageBatteryDispatch. + + Args: + pyomo_model (pyomo.ConcreteModel): Pyomo concrete model. + index_set (pyomo.Set): Indexed set. + system_model (BatteryModel.BatteryStateful): Battery system model. + financial_model (Singleowner.Singleowner): Financial model. + block_set_name (str, optional): Name of the block set. Defaults to 'LV_battery'. + dispatch_options (dict, optional): Dispatch options. Defaults to None. + use_exp_voltage_point (bool, optional): Boolean indicating whether to use the exponential voltage point. Defaults to False. + + """ + u.load_definitions_from_strings(["amp_hour = amp * hour = Ah = amphour"]) if dispatch_options is None: dispatch_options = {} - super().__init__(pyomo_model, - index_set, - system_model, - financial_model, - block_set_name=block_set_name, - dispatch_options=dispatch_options) + super().__init__( + pyomo_model, + index_set, + system_model, + financial_model, + block_set_name=block_set_name, + dispatch_options=dispatch_options, + ) self.use_exp_voltage_point = use_exp_voltage_point def dispatch_block_rule(self, battery): + """Additional formulation for dispatch block rule. + + Args: + battery: Battery instance. + + """ # Parameters self._create_lv_battery_parameters(battery) # Variables @@ -44,27 +71,43 @@ def dispatch_block_rule(self, battery): self._create_lv_battery_power_equation_constraints(battery) def _create_efficiency_parameters(self, battery): + """Not defined in this formulation.""" # Not defined in this formulation pass def _create_capacity_parameter(self, battery): + """Create capacity parameter for the battery model. + + Args: + battery: Battery instance. + + """ battery.capacity = pyomo.Param( doc=self.block_set_name + " capacity [MAh]", within=pyomo.NonNegativeReals, mutable=True, - units=u.MAh) + units=u.MAh, + ) def _create_lv_battery_parameters(self, battery): + """Create parameters for the battery model. + + Args: + battery: Battery instance. + + """ battery.voltage_slope = pyomo.Param( doc=self.block_set_name + " linear voltage model slope coefficient [V]", within=pyomo.NonNegativeReals, mutable=True, - units=u.V) + units=u.V, + ) battery.voltage_intercept = pyomo.Param( doc=self.block_set_name + " linear voltage model intercept coefficient [V]", within=pyomo.NonNegativeReals, mutable=True, - units=u.V) + units=u.V, + ) # TODO: Add this if wanted # self.alphaP = Param(None) # [kW_DC] Bi-directional intercept for charge # self.betaP = Param(None) # [-] Bi-directional slope for charge @@ -74,123 +117,203 @@ def _create_lv_battery_parameters(self, battery): doc="Typical cell current for both charge and discharge [A]", within=pyomo.NonNegativeReals, mutable=True, - units=u.A) + units=u.A, + ) battery.internal_resistance = pyomo.Param( doc=self.block_set_name + " internal resistance [Ohm]", within=pyomo.NonNegativeReals, mutable=True, - units=u.ohm) + units=u.ohm, + ) battery.minimum_charge_current = pyomo.Param( doc=self.block_set_name + " minimum charge current [MA]", within=pyomo.NonNegativeReals, mutable=True, - units=u.MA) + units=u.MA, + ) battery.maximum_charge_current = pyomo.Param( doc=self.block_set_name + " maximum charge current [MA]", within=pyomo.NonNegativeReals, mutable=True, - units=u.MA) + units=u.MA, + ) battery.minimum_discharge_current = pyomo.Param( doc=self.block_set_name + " minimum discharge current [MA]", within=pyomo.NonNegativeReals, mutable=True, - units=u.MA) + units=u.MA, + ) battery.maximum_discharge_current = pyomo.Param( doc=self.block_set_name + " maximum discharge current [MA]", within=pyomo.NonNegativeReals, mutable=True, - units=u.MA) + units=u.MA, + ) @staticmethod def _create_lv_battery_variables(battery): + """Create variables for the battery model. + + Args: + battery: Battery instance. + + """ battery.charge_current = pyomo.Var( doc="Current into the battery [MA]", domain=pyomo.NonNegativeReals, - units=u.MA) + units=u.MA, + ) battery.discharge_current = pyomo.Var( doc="Current out of the battery [MA]", domain=pyomo.NonNegativeReals, - units=u.MA) + units=u.MA, + ) def _create_soc_inventory_constraint(self, storage): + """Create state-of-charge inventory balance constraint. + + Args: + battery: Battery instance. + + """ + def soc_inventory_rule(m): # TODO: add alpha and beta terms - return m.soc == (m.soc0 + m.time_duration * (m.charge_current - m.discharge_current) / m.capacity) + return m.soc == ( + m.soc0 + + m.time_duration + * (m.charge_current - m.discharge_current) + / m.capacity + ) + # Storage State-of-charge balance storage.soc_inventory = pyomo.Constraint( doc=self.block_set_name + " state-of-charge inventory balance", - rule=soc_inventory_rule) + rule=soc_inventory_rule, + ) @staticmethod def _create_lv_battery_constraints(battery): + """Create constraints for the battery model. + + Args: + battery: Battery instance. + + """ # Charge current bounds battery.charge_current_lb = pyomo.Constraint( doc="Battery Charging current lower bound", - expr=battery.charge_current >= battery.minimum_charge_current * battery.is_charging) + expr=battery.charge_current + >= battery.minimum_charge_current * battery.is_charging, + ) battery.charge_current_ub = pyomo.Constraint( doc="Battery Charging current upper bound", - expr=battery.charge_current <= battery.maximum_charge_current * battery.is_charging) + expr=battery.charge_current + <= battery.maximum_charge_current * battery.is_charging, + ) battery.charge_current_ub_soc = pyomo.Constraint( doc="Battery Charging current upper bound state-of-charge dependence", - expr=battery.charge_current <= battery.capacity * (1.0 - battery.soc0) / battery.time_duration) + expr=battery.charge_current + <= battery.capacity * (1.0 - battery.soc0) / battery.time_duration, + ) # Discharge current bounds battery.discharge_current_lb = pyomo.Constraint( doc="Battery Discharging current lower bound", - expr=battery.discharge_current >= battery.minimum_discharge_current * battery.is_discharging) + expr=battery.discharge_current + >= battery.minimum_discharge_current * battery.is_discharging, + ) battery.discharge_current_ub = pyomo.Constraint( doc="Battery Discharging current upper bound", - expr=battery.discharge_current <= battery.maximum_discharge_current * battery.is_discharging) + expr=battery.discharge_current + <= battery.maximum_discharge_current * battery.is_discharging, + ) battery.discharge_current_ub_soc = pyomo.Constraint( doc="Battery Discharging current upper bound state-of-charge dependence", - expr=battery.discharge_current <= battery.maximum_discharge_current * battery.soc0) + expr=battery.discharge_current + <= battery.maximum_discharge_current * battery.soc0, + ) @staticmethod def _create_lv_battery_power_equation_constraints(battery): + """Create power equation constraints for the battery model. + + Args: + battery: Battery instance. + + """ battery.charge_power_equation = pyomo.Constraint( doc="Battery charge power equation equal to the product of current and voltage", - expr=battery.charge_power == battery.charge_current * (battery.voltage_slope * battery.soc0 - + (battery.voltage_intercept - + battery.average_current - * battery.internal_resistance))) + expr=battery.charge_power + == battery.charge_current + * ( + battery.voltage_slope * battery.soc0 + + ( + battery.voltage_intercept + + battery.average_current * battery.internal_resistance + ) + ), + ) battery.discharge_power_equation = pyomo.Constraint( doc="Battery discharge power equation equal to the product of current and voltage", - expr=battery.discharge_power == battery.discharge_current * (battery.voltage_slope * battery.soc0 - + (battery.voltage_intercept - - battery.average_current - * battery.internal_resistance))) + expr=battery.discharge_power + == battery.discharge_current + * ( + battery.voltage_slope * battery.soc0 + + ( + battery.voltage_intercept + - battery.average_current * battery.internal_resistance + ) + ), + ) def _lifecycle_count_rule(self, m, i): + """Lifecycle count rule. + + Args: + m: Model instance. + i: Index. + + """ # current accounting start = int(i * self.timesteps_per_day) end = int((i + 1) * self.timesteps_per_day) - return self.model.lifecycles[i] == sum(self.blocks[t].time_duration - * (0.8 * self.blocks[t].discharge_current - - 0.8 * self.blocks[t].discharge_current * self.blocks[t].soc0) - / self.blocks[t].capacity for t in range(start, end)) + return self.model.lifecycles[i] == sum( + self.blocks[t].time_duration + * ( + 0.8 * self.blocks[t].discharge_current + - 0.8 * self.blocks[t].discharge_current * self.blocks[t].soc0 + ) + / self.blocks[t].capacity + for t in range(start, end) + ) def _set_control_mode(self): + """Set control mode.""" self._system_model.value("control_mode", 0.0) # Current control self.control_variable = "input_current" def _set_model_specific_parameters(self): + """Set model-specific parameters.""" # Getting information from system_model - nominal_voltage = self._system_model.value('nominal_voltage') - nominal_energy = self._system_model.value('nominal_energy') - Vnom_default = self._system_model.value('Vnom_default') - C_rate = self._system_model.value('C_rate') - resistance = self._system_model.value('resistance') + nominal_voltage = self._system_model.value("nominal_voltage") + nominal_energy = self._system_model.value("nominal_energy") + Vnom_default = self._system_model.value("Vnom_default") + C_rate = self._system_model.value("C_rate") + resistance = self._system_model.value("resistance") - Qfull = self._system_model.value('Qfull') - Qnom = self._system_model.value('Qnom') - Qexp = self._system_model.value('Qexp') + Qfull = self._system_model.value("Qfull") + Qnom = self._system_model.value("Qnom") + Qexp = self._system_model.value("Qexp") - Vfull = self._system_model.value('Vfull') - Vnom = self._system_model.value('Vnom') - Vexp = self._system_model.value('Vexp') + Vfull = self._system_model.value("Vfull") + Vnom = self._system_model.value("Vnom") + Vexp = self._system_model.value("Vexp") # Using the Ceiling for both these -> Ceil(a/b) = -(-a//b) - cells_in_series = - (- nominal_voltage // Vnom_default) - strings_in_parallel = - (- nominal_energy * 1e3 // (Qfull * cells_in_series * Vnom_default)) + cells_in_series = -(-nominal_voltage // Vnom_default) + strings_in_parallel = -( + -nominal_energy * 1e3 // (Qfull * cells_in_series * Vnom_default) + ) self.capacity = Qfull * strings_in_parallel / 1e6 # [MAh] @@ -211,7 +334,7 @@ def _set_model_specific_parameters(self): self.voltage_slope = cells_in_series * a self.voltage_intercept = cells_in_series * b - self.average_current = (Qfull * strings_in_parallel * C_rate / 2.) + self.average_current = Qfull * strings_in_parallel * C_rate / 2.0 self.internal_resistance = resistance * cells_in_series / strings_in_parallel # TODO: These parameters might need updating self.minimum_charge_current = 0.0 @@ -222,6 +345,7 @@ def _set_model_specific_parameters(self): # Inputs @property def voltage_slope(self) -> float: + """Voltage slope.""" for t in self.blocks.index_set(): return self.blocks[t].voltage_slope.value @@ -232,13 +356,16 @@ def voltage_slope(self, voltage_slope: float): @property def voltage_intercept(self) -> float: + """Voltage intercept.""" for t in self.blocks.index_set(): return self.blocks[t].voltage_intercept.value @voltage_intercept.setter def voltage_intercept(self, voltage_intercept: float): for t in self.blocks.index_set(): - self.blocks[t].voltage_intercept = round(voltage_intercept, self.round_digits) + self.blocks[t].voltage_intercept = round( + voltage_intercept, self.round_digits + ) # # TODO: Add this if wanted # # self.alphaP = Param(None) # [kW_DC] Bi-directional intercept for charge @@ -248,6 +375,7 @@ def voltage_intercept(self, voltage_intercept: float): @property def average_current(self) -> float: + """Average current.""" for t in self.blocks.index_set(): return self.blocks[t].average_current.value @@ -258,64 +386,84 @@ def average_current(self, average_current: float): @property def internal_resistance(self) -> float: + """Internal resistance.""" for t in self.blocks.index_set(): return self.blocks[t].internal_resistance.value @internal_resistance.setter def internal_resistance(self, internal_resistance: float): for t in self.blocks.index_set(): - self.blocks[t].internal_resistance = round(internal_resistance, self.round_digits) + self.blocks[t].internal_resistance = round( + internal_resistance, self.round_digits + ) @property def minimum_charge_current(self) -> float: + """Minimum charge current.""" for t in self.blocks.index_set(): return self.blocks[t].minimum_charge_current.value @minimum_charge_current.setter def minimum_charge_current(self, minimum_charge_current: float): for t in self.blocks.index_set(): - self.blocks[t].minimum_charge_current = round(minimum_charge_current, self.round_digits) + self.blocks[t].minimum_charge_current = round( + minimum_charge_current, self.round_digits + ) @property def maximum_charge_current(self) -> float: + """Maximum charge current.""" for t in self.blocks.index_set(): return self.blocks[t].maximum_charge_current.value @maximum_charge_current.setter def maximum_charge_current(self, maximum_charge_current: float): for t in self.blocks.index_set(): - self.blocks[t].maximum_charge_current = round(maximum_charge_current, self.round_digits) + self.blocks[t].maximum_charge_current = round( + maximum_charge_current, self.round_digits + ) @property def minimum_discharge_current(self) -> float: + """Minimum discharge current.""" for t in self.blocks.index_set(): return self.blocks[t].minimum_discharge_current.value @minimum_discharge_current.setter def minimum_discharge_current(self, minimum_discharge_current: float): for t in self.blocks.index_set(): - self.blocks[t].minimum_discharge_current = round(minimum_discharge_current, self.round_digits) + self.blocks[t].minimum_discharge_current = round( + minimum_discharge_current, self.round_digits + ) @property def maximum_discharge_current(self) -> float: + """Maximum discharge current.""" for t in self.blocks.index_set(): return self.blocks[t].maximum_discharge_current.value @maximum_discharge_current.setter def maximum_discharge_current(self, maximum_discharge_current: float): for t in self.blocks.index_set(): - self.blocks[t].maximum_discharge_current = round(maximum_discharge_current, self.round_digits) + self.blocks[t].maximum_discharge_current = round( + maximum_discharge_current, self.round_digits + ) # Outputs @property def charge_current(self) -> list: + """Charge current.""" return [self.blocks[t].charge_current.value for t in self.blocks.index_set()] @property def discharge_current(self) -> list: + """Discharge current.""" return [self.blocks[t].discharge_current.value for t in self.blocks.index_set()] @property def current(self) -> list: - return [self.blocks[t].discharge_current.value - self.blocks[t].charge_current.value - for t in self.blocks.index_set()] + """Current.""" + return [ + self.blocks[t].discharge_current.value - self.blocks[t].charge_current.value + for t in self.blocks.index_set() + ] diff --git a/hopp/simulation/technologies/dispatch/power_storage/one_cycle_battery_dispatch_heuristic.py b/hopp/simulation/technologies/dispatch/power_storage/one_cycle_battery_dispatch_heuristic.py index 8bc097b46..78a7fee52 100644 --- a/hopp/simulation/technologies/dispatch/power_storage/one_cycle_battery_dispatch_heuristic.py +++ b/hopp/simulation/technologies/dispatch/power_storage/one_cycle_battery_dispatch_heuristic.py @@ -5,42 +5,59 @@ import PySAM.BatteryStateful as BatteryModel import PySAM.Singleowner as Singleowner -from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch_heuristic import SimpleBatteryDispatchHeuristic +from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch_heuristic import ( + SimpleBatteryDispatchHeuristic, +) class OneCycleBatteryDispatchHeuristic(SimpleBatteryDispatchHeuristic): - """ - - fixed_dispatch: list of normalized values [-1, 1] (Charging (-), Discharging (+)) - """ - def __init__(self, - pyomo_model: pyomo.ConcreteModel, - index_set: pyomo.Set, - system_model: BatteryModel.BatteryStateful, - financial_model: Singleowner.Singleowner, - block_set_name: str = 'one_cycle_heuristic_battery', - dispatch_options: dict = None): + """One cycle per day heuristic battery dispatch.""" + + def __init__( + self, + pyomo_model: pyomo.ConcreteModel, + index_set: pyomo.Set, + system_model: BatteryModel.BatteryStateful, + financial_model: Singleowner.Singleowner, + block_set_name: str = "one_cycle_heuristic_battery", + dispatch_options: dict = None, + ): + """Initialize OneCycleBatteryDispatchHeuristic. + + Args: + pyomo_model (pyomo.ConcreteModel): Pyomo concrete model. + index_set (pyomo.Set): Indexed set. + system_model (BatteryModel.BatteryStateful): Battery system model. + financial_model (Singleowner.Singleowner): Financial model. + block_set_name (str, optional):Name of the block set. Defaults to 'one_cycle_heuristic_battery'. + dispatch_options (dict, optional): Dispatch options. Defaults to None. + + """ if dispatch_options is None: dispatch_options = {} - super().__init__(pyomo_model, - index_set, - system_model, - financial_model, - block_set_name=block_set_name, - dispatch_options=dispatch_options) + super().__init__( + pyomo_model, + index_set, + system_model, + financial_model, + block_set_name=block_set_name, + dispatch_options=dispatch_options, + ) self.prices = list([0.0] * len(self.blocks.index_set())) def _heuristic_method(self, gen): - """This sets battery dispatch using a 1 cycle per day assumption. + """Sets battery dispatch using a one cycle per day assumption. Method: - 1. Sort input prices - 2. Determine the duration required to fully discharge and charge the battery - 3. Set discharge and charge operations based on sorted prices - 3. Check SOC feasibility - 4. If infeasible, find infeasibility, shift operation to the next sorted price periods - 5. Repeat step 4 until SOC feasible - NOTE: If operation is tried on half of time periods, then operation defaults to 'do nothing' + - Sort input prices + - Determine the duration required to fully discharge and charge the battery + - Set discharge and charge operations based on sorted prices + - Check SOC feasibility + - If infeasible, find infeasibility, shift operation to the next sorted price periods + - Repeat step 4 until SOC feasible + + NOTE: If operation is tried on half of time periods, then operation defaults to 'do nothing' + """ if sum(self.prices) == 0.0 and max(self.prices) == 0.0: raise ValueError("prices must be set before calling heuristic method.") @@ -52,13 +69,13 @@ def _heuristic_method(self, gen): sorted_prices = sorted(sorted_prices, key=lambda i: i[1]) # Set initial fixed dispatch - fixed_dispatch, next_charge_idx = self._charge_battery(charge_time, 0, - sorted_prices, - fixed_dispatch) + fixed_dispatch, next_charge_idx = self._charge_battery( + charge_time, 0, sorted_prices, fixed_dispatch + ) - fixed_dispatch, next_discharge_idx = self._discharge_battery(discharge_time, 0, - sorted_prices, - fixed_dispatch) + fixed_dispatch, next_discharge_idx = self._discharge_battery( + discharge_time, 0, sorted_prices, fixed_dispatch + ) # test feasibility and find infeasibility feasible = self.test_soc_feasibility(fixed_dispatch) @@ -67,29 +84,41 @@ def _heuristic_method(self, gen): idx_infeasible = feasible[1] infeasible_value = fixed_dispatch[idx_infeasible] if infeasible_value > 0: # Discharging - discharge_remaining = fixed_dispatch[idx_infeasible] * self.time_duration[idx_infeasible] + discharge_remaining = ( + fixed_dispatch[idx_infeasible] * self.time_duration[idx_infeasible] + ) fixed_dispatch[idx_infeasible] = 0 - if next_discharge_idx < len(sorted_prices)/2: - fixed_dispatch, next_discharge_idx = self._discharge_battery(discharge_remaining, - next_discharge_idx, - sorted_prices, - fixed_dispatch) - elif infeasible_value < 0: # Charging - charge_remaining = -fixed_dispatch[idx_infeasible] * self.time_duration[idx_infeasible] + if next_discharge_idx < len(sorted_prices) / 2: + fixed_dispatch, next_discharge_idx = self._discharge_battery( + discharge_remaining, + next_discharge_idx, + sorted_prices, + fixed_dispatch, + ) + elif infeasible_value < 0: # Charging + charge_remaining = ( + -fixed_dispatch[idx_infeasible] * self.time_duration[idx_infeasible] + ) fixed_dispatch[idx_infeasible] = 0 - if next_charge_idx < len(sorted_prices)/2: # TODO: maybe too restrictive - fixed_dispatch, next_charge_idx = self._charge_battery(charge_remaining, - next_charge_idx, - sorted_prices, - fixed_dispatch) + if ( + next_charge_idx < len(sorted_prices) / 2 + ): # TODO: maybe too restrictive + fixed_dispatch, next_charge_idx = self._charge_battery( + charge_remaining, next_charge_idx, sorted_prices, fixed_dispatch + ) feasible = self.test_soc_feasibility(fixed_dispatch) self._fixed_dispatch = fixed_dispatch - def _discharge_battery(self, discharge_remaining, next_discharge_idx, sorted_prices, fixed_dispatch): - """Discharge battery using the remaining discharge and the next best discharge period. + def _discharge_battery( + self, discharge_remaining, next_discharge_idx, sorted_prices, fixed_dispatch + ): + """Discharges battery using the remaining discharge and the next best discharge period. + + Returns: + Tuple[list, int]: Adjusted fixed dispatch and next discharge index to be tried. - Returns adjusted fixed_dispatch and next discharge index to be tried.""" + """ period_count = next_discharge_idx while discharge_remaining > 0: if period_count < len(sorted_prices): @@ -106,18 +135,23 @@ def _discharge_battery(self, discharge_remaining, next_discharge_idx, sorted_pri next_discharge_idx = period_count return fixed_dispatch, next_discharge_idx - def _charge_battery(self, charge_remaining, next_charge_idx, sorted_prices, fixed_dispatch): - """Charge battery using the remaining charge and the next best charge period. + def _charge_battery( + self, charge_remaining, next_charge_idx, sorted_prices, fixed_dispatch + ): + """Charges battery using the remaining charge and the next best charge period. + + Returns: + Tuple[list, int]: Adjusted fixed dispatch and next charge index to be tried. - Returns adjusted fixed_dispatch and next charge index to be tried.""" + """ period_count = next_charge_idx while charge_remaining > 0: if period_count < len(sorted_prices): idx = sorted_prices[period_count][0] if self.max_charge_fraction[idx] < charge_remaining: - fixed_dispatch[idx] = - self.max_charge_fraction[idx] + fixed_dispatch[idx] = -self.max_charge_fraction[idx] else: - fixed_dispatch[idx] = - charge_remaining + fixed_dispatch[idx] = -charge_remaining # update count and remaining discharge charge_remaining += fixed_dispatch[idx] * self.time_duration[idx] period_count += 1 @@ -127,28 +161,46 @@ def _charge_battery(self, charge_remaining, next_charge_idx, sorted_prices, fixe return fixed_dispatch, next_charge_idx def _get_duration_battery_full_cycle(self) -> Tuple[float, float]: - """ Calculates discharge and charge hours required to fully cycle the battery.""" + """Calculates discharge and charge hours required to fully cycle the battery. + + Returns: + Tuple[float, float]: Discharge and charge hours. + + """ true_capacity = (self.maximum_soc - self.minimum_soc) * self.capacity / 100.0 - n_discharge = true_capacity / (1/(self.discharge_efficiency/100.) * self.maximum_power) - n_charge = true_capacity / (self.charge_efficiency / 100. * self.maximum_power) + n_discharge = true_capacity / ( + 1 / (self.discharge_efficiency / 100.0) * self.maximum_power + ) + n_charge = true_capacity / (self.charge_efficiency / 100.0 * self.maximum_power) return n_discharge, n_charge def test_soc_feasibility(self, fixed_dispatch) -> Tuple[bool, int]: - """Steps through fixed_dispatch and test SOC feasibility. + """Steps through fixed_dispatch and tests SOC feasibility. + + Returns: + Tuple[bool, int]: Tuple indicating SOC feasibility and index of first infeasible operation. - If fixed_dispatch is infeasible, return index of first infeasibility operation. """ soc0 = self.model.initial_soc.value for idx, fd in enumerate(fixed_dispatch): soc = self.update_soc(fd, soc0) - if round(soc, 6)*100. < self.minimum_soc or round(soc, 6)*100. > self.maximum_soc: + if ( + round(soc, 6) * 100.0 < self.minimum_soc + or round(soc, 6) * 100.0 > self.maximum_soc + ): return False, idx soc0 = soc return True, None @property def prices(self) -> list: + """List of normalized prices [-1, 1] (Charging (-), Discharging (+)). + + Returns: + list: Prices. + + """ return self._prices @prices.setter diff --git a/hopp/simulation/technologies/dispatch/power_storage/power_storage_dispatch.py b/hopp/simulation/technologies/dispatch/power_storage/power_storage_dispatch.py index f88986482..cb72b49ed 100644 --- a/hopp/simulation/technologies/dispatch/power_storage/power_storage_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_storage/power_storage_dispatch.py @@ -7,19 +7,36 @@ class PowerStorageDispatch(Dispatch): - """ + """Dispatch algorithm for power storage.""" + + def __init__( + self, + pyomo_model: pyomo.ConcreteModel, + index_set: pyomo.Set, + system_model, + financial_model, + block_set_name: str, + dispatch_options, + ): + """Intialize PowerStorageDispatch. + + Args: + pyomo_model (pyomo.ConcreteModel): Pyomo concrete model. + index_set (pyomo.Set): Indexed set. + system_model: System model. + financial_model: Financial model. + block_set_name (str, optional): Name of the block set. + dispatch_options (dict, optional): Dispatch options. - """ - - def __init__(self, - pyomo_model: pyomo.ConcreteModel, - index_set: pyomo.Set, - system_model, - financial_model, - block_set_name: str, - dispatch_options): + """ - super().__init__(pyomo_model, index_set, system_model, financial_model, block_set_name=block_set_name) + super().__init__( + pyomo_model, + index_set, + system_model, + financial_model, + block_set_name=block_set_name, + ) self._create_soc_linking_constraint() # TODO: we could remove this option and just have lifecycle count default @@ -30,8 +47,12 @@ def __init__(self, self._create_lifecycle_count_constraint() def dispatch_block_rule(self, storage): - """ - Called during Dispatch's __init__ + """Initializes storage parameters, variables, and constraints. + Called during Dispatch's __init__. + + Args: + storage: Storage instance. + """ # Parameters self._create_storage_parameters(storage) @@ -45,7 +66,104 @@ def dispatch_block_rule(self, storage): # Ports self._create_storage_port(storage) + def max_gross_profit_objective(self, hybrid_blocks): + """Sets the max gross profit objective for the dispatch. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + + def battery_profit_objective_rule(m): + objective = 0 + objective += sum( + -(1 / hybrid_blocks[t].time_weighting_factor) + * self.blocks[t].time_duration + * ( + self.blocks[t].cost_per_charge * hybrid_blocks[t].battery_charge + + self.blocks[t].cost_per_discharge + * hybrid_blocks[t].battery_discharge + ) + for t in hybrid_blocks.index_set() + ) + if self.options.include_lifecycle_count: + objective -= self.model.lifecycle_cost * sum(self.model.lifecycles) + return objective + + self.obj = pyomo.Expression(rule=battery_profit_objective_rule) + + def min_operating_cost_objective(self, hybrid_blocks): + """Sets the min operating cost objective for the dispatch. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + objective = sum( + hybrid_blocks[t].time_weighting_factor + * self.blocks[t].time_duration + * ( + self.blocks[t].cost_per_discharge * hybrid_blocks[t].battery_discharge + - self.blocks[t].cost_per_charge * hybrid_blocks[t].battery_charge + ) # Try to incentivize battery charging + for t in self.blocks.index_set() + ) + if self.options.include_lifecycle_count: + objective += self.model.lifecycle_cost * self.model.lifecycles + + self.obj = objective + + def _create_variables(self, hybrid): + """Creates storage variables. + + Args: + hybrid: Hybrid instance. + + Returns: + Tuple: Tuple containing battery discharge and charge variables. + + """ + hybrid.battery_charge = pyomo.Var( + doc="Power charging the electric battery [MW]", + domain=pyomo.NonNegativeReals, + units=u.MW, + initialize=0.0, + ) + hybrid.battery_discharge = pyomo.Var( + doc="Power discharging the electric battery [MW]", + domain=pyomo.NonNegativeReals, + units=u.MW, + initialize=0.0, + ) + return hybrid.battery_discharge, hybrid.battery_charge + + def _create_port(self, hybrid): + """Creates storage port. + + Args: + hybrid: Hybrid instance. + + Returns: + Port: Storage port. + + """ + hybrid.battery_port = Port( + initialize={ + "charge_power": hybrid.battery_charge, + "discharge_power": hybrid.battery_discharge, + } + ) + return hybrid.battery_port + def _create_storage_parameters(self, storage): + """Creates storage parameters. + + Args: + storage: Storage instance. + + """ ################################## # Parameters # ################################## @@ -54,94 +172,129 @@ def _create_storage_parameters(self, storage): default=1.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.hr) + units=u.hr, + ) storage.cost_per_charge = pyomo.Param( doc="Operating cost of " + self.block_set_name + " charging [$/MWh]", - default=0., + default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.USD / u.MWh) + units=u.USD / u.MWh, + ) storage.cost_per_discharge = pyomo.Param( doc="Operating cost of " + self.block_set_name + " discharging [$/MWh]", - default=0., + default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.USD / u.MWh) + units=u.USD / u.MWh, + ) storage.minimum_power = pyomo.Param( doc=self.block_set_name + " minimum power rating [MW]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.MW) + units=u.MW, + ) storage.maximum_power = pyomo.Param( doc=self.block_set_name + " maximum power rating [MW]", within=pyomo.NonNegativeReals, mutable=True, - units=u.MW) + units=u.MW, + ) storage.minimum_soc = pyomo.Param( doc=self.block_set_name + " minimum state-of-charge [-]", default=0.1, within=pyomo.PercentFraction, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) storage.maximum_soc = pyomo.Param( doc=self.block_set_name + " maximum state-of-charge [-]", default=0.9, within=pyomo.PercentFraction, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) def _create_efficiency_parameters(self, storage): + """Creates storage efficiency parameters. + + Args: + storage: Storage instance. + + """ storage.charge_efficiency = pyomo.Param( doc=self.block_set_name + " Charging efficiency [-]", default=0.938, within=pyomo.PercentFraction, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) storage.discharge_efficiency = pyomo.Param( doc=self.block_set_name + " discharging efficiency [-]", default=0.938, within=pyomo.PercentFraction, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) def _create_capacity_parameter(self, storage): + """Creates storage capacity parameter. + + Args: + storage: Storage instance. + + """ storage.capacity = pyomo.Param( doc=self.block_set_name + " capacity [MWh]", within=pyomo.NonNegativeReals, mutable=True, - units=u.MWh) + units=u.MWh, + ) def _create_storage_variables(self, storage): + """Creates storage variables. + + Args: + storage: Storage instance. + + """ ################################## # Variables # ################################## storage.is_charging = pyomo.Var( doc="1 if " + self.block_set_name + " is charging; 0 Otherwise [-]", domain=pyomo.Binary, - units=u.dimensionless) + units=u.dimensionless, + ) storage.is_discharging = pyomo.Var( doc="1 if " + self.block_set_name + " is discharging; 0 Otherwise [-]", domain=pyomo.Binary, - units=u.dimensionless) + units=u.dimensionless, + ) storage.soc0 = pyomo.Var( - doc=self.block_set_name + " initial state-of-charge at beginning of period[-]", + doc=self.block_set_name + + " initial state-of-charge at beginning of period[-]", domain=pyomo.PercentFraction, bounds=(storage.minimum_soc, storage.maximum_soc), - units=u.dimensionless) + units=u.dimensionless, + ) storage.soc = pyomo.Var( doc=self.block_set_name + " state-of-charge at end of period [-]", domain=pyomo.PercentFraction, bounds=(storage.minimum_soc, storage.maximum_soc), - units=u.dimensionless) + units=u.dimensionless, + ) storage.charge_power = pyomo.Var( doc="Power into " + self.block_set_name + " [MW]", domain=pyomo.NonNegativeReals, - units=u.MW) + units=u.MW, + ) storage.discharge_power = pyomo.Var( doc="Power out of " + self.block_set_name + " [MW]", domain=pyomo.NonNegativeReals, - units=u.MW) + units=u.MW, + ) def _create_storage_constraints(self, storage): ################################## @@ -150,33 +303,63 @@ def _create_storage_constraints(self, storage): # Charge power bounds storage.charge_power_ub = pyomo.Constraint( doc=self.block_set_name + " charging power upper bound", - expr=storage.charge_power <= storage.maximum_power * storage.is_charging) + expr=storage.charge_power <= storage.maximum_power * storage.is_charging, + ) storage.charge_power_lb = pyomo.Constraint( doc=self.block_set_name + " charging power lower bound", - expr=storage.charge_power >= storage.minimum_power * storage.is_charging) + expr=storage.charge_power >= storage.minimum_power * storage.is_charging, + ) # Discharge power bounds storage.discharge_power_lb = pyomo.Constraint( doc=self.block_set_name + " Discharging power lower bound", - expr=storage.discharge_power >= storage.minimum_power * storage.is_discharging) + expr=storage.discharge_power + >= storage.minimum_power * storage.is_discharging, + ) storage.discharge_power_ub = pyomo.Constraint( doc=self.block_set_name + " Discharging power upper bound", - expr=storage.discharge_power <= storage.maximum_power * storage.is_discharging) + expr=storage.discharge_power + <= storage.maximum_power * storage.is_discharging, + ) # Storage packing constraint storage.charge_discharge_packing = pyomo.Constraint( - doc=self.block_set_name + " packing constraint for charging and discharging binaries", - expr=storage.is_charging + storage.is_discharging <= 1) + doc=self.block_set_name + + " packing constraint for charging and discharging binaries", + expr=storage.is_charging + storage.is_discharging <= 1, + ) def _create_soc_inventory_constraint(self, storage): + """Creates state-of-charge inventory constraint for storage. + + Args: + storage: Storage instance. + + """ + def soc_inventory_rule(m): - return m.soc == (m.soc0 + m.time_duration * (m.charge_efficiency * m.charge_power - - (1 / m.discharge_efficiency) * m.discharge_power) / m.capacity) + return m.soc == ( + m.soc0 + + m.time_duration + * ( + m.charge_efficiency * m.charge_power + - (1 / m.discharge_efficiency) * m.discharge_power + ) + / m.capacity + ) + # Storage State-of-charge balance storage.soc_inventory = pyomo.Constraint( doc=self.block_set_name + " state-of-charge inventory balance", - rule=soc_inventory_rule) + rule=soc_inventory_rule, + ) @staticmethod def _create_storage_port(storage): + """Creates storage port. + + Args: + storage: Storage instance. + + """ ################################## # Ports # ################################## @@ -185,15 +368,18 @@ def _create_storage_port(storage): storage.port.add(storage.discharge_power) def _create_soc_linking_constraint(self): + """Creates state-of-charge linking constraint.""" ################################## # Parameters # ################################## self.model.initial_soc = pyomo.Param( - doc=self.block_set_name + " initial state-of-charge at beginning of the horizon[-]", + doc=self.block_set_name + + " initial state-of-charge at beginning of the horizon[-]", within=pyomo.PercentFraction, default=0.5, mutable=True, - units=u.dimensionless) + units=u.dimensionless, + ) ################################## # Constraints # ################################## @@ -203,31 +389,50 @@ def storage_soc_linking_rule(m, t): if t == self.blocks.index_set().first(): return self.blocks[t].soc0 == self.model.initial_soc return self.blocks[t].soc0 == self.blocks[t - 1].soc + self.model.soc_linking = pyomo.Constraint( self.blocks.index_set(), doc=self.block_set_name + " state-of-charge block linking constraint", - rule=storage_soc_linking_rule) + rule=storage_soc_linking_rule, + ) def _lifecycle_count_rule(self, m, i): + """Calculates lifecycle count rule. + + Args: + m: Model instance. + i: Index. + + Returns: + float: Lifecycle count. + + """ # Use full-energy cycles start = int(i * self.timesteps_per_day) end = int((i + 1) * self.timesteps_per_day) - return m.lifecycles[i] == sum(self.blocks[t].time_duration - * self.blocks[t].discharge_power - / self.blocks[t].capacity for t in range(start, end)) + return m.lifecycles[i] == sum( + self.blocks[t].time_duration + * self.blocks[t].discharge_power + / self.blocks[t].capacity + for t in range(start, end) + ) def _create_lifecycle_model(self): + """Creates lifecycle model.""" ################################## # Parameters # ################################## self.timesteps_per_day = 24 / pyomo.value(self.blocks[0].time_duration) - self.model.days = pyomo.RangeSet(0, int(len(self.blocks)) / self.timesteps_per_day - 1) + self.model.days = pyomo.RangeSet( + 0, int(len(self.blocks)) / self.timesteps_per_day - 1 + ) self.model.lifecycle_cost = pyomo.Param( doc="Lifecycle cost of " + self.block_set_name + " [$/lifecycle]", default=0.0, within=pyomo.NonNegativeReals, mutable=True, - units=u.USD / u.lifecycle) + units=u.USD / u.lifecycle, + ) ################################## # Variables # ################################## @@ -235,14 +440,15 @@ def _create_lifecycle_model(self): self.model.days, doc=self.block_set_name + " lifecycle count", domain=pyomo.NonNegativeReals, - units=u.lifecycle) + units=u.lifecycle, + ) ################################## # Constraints # ################################## self.model.lifecycle_count = pyomo.Constraint( self.model.days, doc=self.block_set_name + " lifecycle counting", - rule=self._lifecycle_count_rule + rule=self._lifecycle_count_rule, ) ################################## # Ports # @@ -251,42 +457,60 @@ def _create_lifecycle_model(self): self.model.lifecycles_port.add(self.model.lifecycles) self.model.lifecycles_port.add(self.model.lifecycle_cost) - def _create_lifecycle_count_constraint(self): + """Creates lifecycle count constraint.""" self.model.max_cycles_per_day = pyomo.Param( doc="Max number of full energy cycles per day for " + self.block_set_name, default=self.options.max_lifecycle_per_day, within=pyomo.NonNegativeReals, mutable=True, - units=u.lifecycle) - + units=u.lifecycle, + ) + self.model.lifecycle_count_constraint = pyomo.Constraint( - self.model.days, - rule=lambda m, i: m.lifecycles[i] <= m.max_cycles_per_day + self.model.days, rule=lambda m, i: m.lifecycles[i] <= m.max_cycles_per_day ) def _check_initial_soc(self, initial_soc): + """Checks initial state-of-charge. + + Args: + initial_soc: Initial state-of-charge value. + + Returns: + float: Checked initial state-of-charge. + + """ if initial_soc > 1: - initial_soc /= 100. + initial_soc /= 100.0 initial_soc = round(initial_soc, self.round_digits) - if initial_soc > self.maximum_soc/100: - print("Warning: Storage dispatch was initialized with a state-of-charge greater than maximum value!") + if initial_soc > self.maximum_soc / 100: + print( + "Warning: Storage dispatch was initialized with a state-of-charge greater than " + "maximum value!" + ) print("Initial SOC = {}".format(initial_soc)) print("Initial SOC was set to maximum value.") initial_soc = self.maximum_soc / 100 - elif initial_soc < self.minimum_soc/100: - print("Warning: Storage dispatch was initialized with a state-of-charge less than minimum value!") + elif initial_soc < self.minimum_soc / 100: + print( + "Warning: Storage dispatch was initialized with a state-of-charge less than " + "minimum value!" + ) print("Initial SOC = {}".format(initial_soc)) print("Initial SOC was set to minimum value.") initial_soc = self.minimum_soc / 100 return initial_soc def update_dispatch_initial_soc(self, initial_soc: float = None): - raise NotImplemented("This function must be overridden for specific storage dispatch model") + raise NotImplemented( + "This function must be overridden for specific storage dispatch model" + ) # INPUTS @property def time_duration(self) -> list: + """Time duration.""" return [self.blocks[t].time_duration.value for t in self.blocks.index_set()] @time_duration.setter @@ -295,10 +519,14 @@ def time_duration(self, time_duration: list): for t, delta in zip(self.blocks, time_duration): self.blocks[t].time_duration = round(delta, self.round_digits) else: - raise ValueError(self.time_duration.__name__ + " list must be the same length as time horizon") + raise ValueError( + self.time_duration.__name__ + + " list must be the same length as time horizon" + ) @property def cost_per_charge(self) -> float: + """Cost per charge.""" for t in self.blocks.index_set(): return self.blocks[t].cost_per_charge.value @@ -309,16 +537,20 @@ def cost_per_charge(self, om_dollar_per_mwh: float): @property def cost_per_discharge(self) -> float: + """Cost per discharge.""" for t in self.blocks.index_set(): return self.blocks[t].cost_per_discharge.value @cost_per_discharge.setter def cost_per_discharge(self, om_dollar_per_mwh: float): for t in self.blocks.index_set(): - self.blocks[t].cost_per_discharge = round(om_dollar_per_mwh, self.round_digits) + self.blocks[t].cost_per_discharge = round( + om_dollar_per_mwh, self.round_digits + ) @property def minimum_power(self) -> float: + """Minimum power.""" for t in self.blocks.index_set(): return self.blocks[t].minimum_power.value @@ -329,6 +561,7 @@ def minimum_power(self, minimum_power_mw: float): @property def maximum_power(self) -> float: + """Maximum power.""" for t in self.blocks.index_set(): return self.blocks[t].maximum_power.value @@ -339,32 +572,35 @@ def maximum_power(self, maximum_power_mw: float): @property def minimum_soc(self) -> float: + """Minimum state-of-charge.""" for t in self.blocks.index_set(): - return self.blocks[t].minimum_soc.value * 100. + return self.blocks[t].minimum_soc.value * 100.0 @minimum_soc.setter def minimum_soc(self, minimum_soc: float): if minimum_soc > 1: - minimum_soc /= 100. + minimum_soc /= 100.0 for t in self.blocks.index_set(): self.blocks[t].minimum_soc = round(minimum_soc, self.round_digits) @property def maximum_soc(self) -> float: + """Maximum state-of-charge.""" for t in self.blocks.index_set(): - return self.blocks[t].maximum_soc.value * 100. + return self.blocks[t].maximum_soc.value * 100.0 @maximum_soc.setter def maximum_soc(self, maximum_soc: float): if maximum_soc > 1: - maximum_soc /= 100. + maximum_soc /= 100.0 for t in self.blocks.index_set(): self.blocks[t].maximum_soc = round(maximum_soc, self.round_digits) @property def charge_efficiency(self) -> float: + """Charge efficiency.""" for t in self.blocks.index_set(): - return self.blocks[t].charge_efficiency.value * 100. + return self.blocks[t].charge_efficiency.value * 100.0 @charge_efficiency.setter def charge_efficiency(self, efficiency: float): @@ -374,8 +610,9 @@ def charge_efficiency(self, efficiency: float): @property def discharge_efficiency(self) -> float: + """Discharge efficiency.""" for t in self.blocks.index_set(): - return self.blocks[t].discharge_efficiency.value * 100. + return self.blocks[t].discharge_efficiency.value * 100.0 @discharge_efficiency.setter def discharge_efficiency(self, efficiency: float): @@ -385,17 +622,20 @@ def discharge_efficiency(self, efficiency: float): @property def round_trip_efficiency(self) -> float: - return self.charge_efficiency * self.discharge_efficiency / 100. + """Round trip efficiency.""" + return self.charge_efficiency * self.discharge_efficiency / 100.0 @round_trip_efficiency.setter def round_trip_efficiency(self, round_trip_efficiency: float): round_trip_efficiency = self._check_efficiency_value(round_trip_efficiency) - efficiency = round_trip_efficiency ** (1 / 2) # Assumes equal charge and discharge efficiencies + # Assumes equal charge and discharge efficiencies + efficiency = round_trip_efficiency ** (1 / 2) self.charge_efficiency = efficiency self.discharge_efficiency = efficiency @property def capacity(self) -> float: + """Capacity.""" for t in self.blocks.index_set(): return self.blocks[t].capacity.value @@ -406,7 +646,8 @@ def capacity(self, capacity_mwh: float): @property def initial_soc(self) -> float: - return self.model.initial_soc.value * 100. + """Initial state-of-charge.""" + return self.model.initial_soc.value * 100.0 @initial_soc.setter def initial_soc(self, initial_soc: float): @@ -415,6 +656,7 @@ def initial_soc(self, initial_soc: float): @property def lifecycle_cost(self) -> float: + """Lifecycle cost.""" return self.model.lifecycle_cost.value @lifecycle_cost.setter @@ -423,36 +665,45 @@ def lifecycle_cost(self, lifecycle_cost: float): @property def lifecycle_cost_per_kWh_cycle(self) -> float: + """Lifecycle cost per kWh cycle.""" return self.options.lifecycle_cost_per_kWh_cycle @lifecycle_cost_per_kWh_cycle.setter def lifecycle_cost_per_kWh_cycle(self, lifecycle_cost_per_kWh_cycle: float): self.options.lifecycle_cost_per_kWh_cycle = lifecycle_cost_per_kWh_cycle - self.model.lifecycle_cost = lifecycle_cost_per_kWh_cycle * self._system_model.value('nominal_energy') + self.model.lifecycle_cost = ( + lifecycle_cost_per_kWh_cycle * self._system_model.value("nominal_energy") + ) # Outputs @property def is_charging(self) -> list: + """Storage is charging.""" return [self.blocks[t].is_charging.value for t in self.blocks.index_set()] @property def is_discharging(self) -> list: + """Storage is discharging.""" return [self.blocks[t].is_discharging.value for t in self.blocks.index_set()] @property def soc(self) -> list: + """State-of-charge.""" return [self.blocks[t].soc.value * 100.0 for t in self.blocks.index_set()] @property def charge_power(self) -> list: + """Charge power.""" return [self.blocks[t].charge_power.value for t in self.blocks.index_set()] @property def discharge_power(self) -> list: + """Discharge power.""" return [self.blocks[t].discharge_power.value for t in self.blocks.index_set()] @property def lifecycles(self) -> float: + """Lifecycles.""" if self.options.include_lifecycle_count: return [pyomo.value(i) for _, i in self.model.lifecycles.items()] else: @@ -460,13 +711,18 @@ def lifecycles(self) -> float: @property def power(self) -> list: - return [self.blocks[t].discharge_power.value - self.blocks[t].charge_power.value - for t in self.blocks.index_set()] + """Power.""" + return [ + self.blocks[t].discharge_power.value - self.blocks[t].charge_power.value + for t in self.blocks.index_set() + ] @property def current(self) -> list: + """Current.""" return [0.0 for t in self.blocks.index_set()] @property def generation(self) -> list: + """Generation.""" return self.power diff --git a/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch.py b/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch.py index 23f96b12c..6587ebf85 100644 --- a/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch.py @@ -2,66 +2,108 @@ from pyomo.environ import units as u import PySAM.BatteryStateful as BatteryModel -import PySAM.Singleowner as Singleowner -from hopp.simulation.technologies.dispatch.power_storage.power_storage_dispatch import PowerStorageDispatch +from hopp.simulation.technologies.dispatch.power_storage.power_storage_dispatch import ( + PowerStorageDispatch, +) from hopp.simulation.technologies.financial import FinancialModelType class SimpleBatteryDispatch(PowerStorageDispatch): - _system_model: BatteryModel.BatteryStateful - _financial_model: Singleowner.Singleowner - """ - - """ - - def __init__(self, - pyomo_model: pyomo.ConcreteModel, - index_set: pyomo.Set, - system_model: BatteryModel.BatteryStateful, - financial_model: FinancialModelType, - block_set_name: str, - dispatch_options): - super().__init__(pyomo_model, - index_set, - system_model, - financial_model, - block_set_name=block_set_name, - dispatch_options=dispatch_options) + """A dispatch class for simple battery operations.""" + + def __init__( + self, + pyomo_model: pyomo.ConcreteModel, + index_set: pyomo.Set, + system_model: BatteryModel.BatteryStateful, + financial_model: FinancialModelType, + block_set_name: str, + dispatch_options, + ): + """Initializes SimpleBatteryDispatch. + + Args: + pyomo_model (pyomo.ConcreteModel): The Pyomo model instance. + index_set (pyomo.Set): The Pyomo index set. + system_model (BatteryModel.BatteryStateful): The battery stateful model. + financial_model (FinancialModelType): The financial model type. + block_set_name (str): Name of the block set. + dispatch_options: Dispatch options. + + """ + super().__init__( + pyomo_model, + index_set, + system_model, + financial_model, + block_set_name=block_set_name, + dispatch_options=dispatch_options, + ) def initialize_parameters(self): + """Initializes parameters.""" if self.options.include_lifecycle_count: - self.lifecycle_cost = self.options.lifecycle_cost_per_kWh_cycle * self._system_model.value('nominal_energy') + self.lifecycle_cost = ( + self.options.lifecycle_cost_per_kWh_cycle + * self._system_model.value("nominal_energy") + ) - self.cost_per_charge = 0.75 # [$/MWh] - self.cost_per_discharge = 0.75 # [$/MWh] + self.cost_per_charge = self._financial_model.value("om_batt_variable_cost")[ + 0 + ] # [$/MWh] + self.cost_per_discharge = self._financial_model.value("om_batt_variable_cost")[ + 0 + ] # [$/MWh] self.minimum_power = 0.0 # FIXME: Change C_rate call to user set system_capacity_kw # self.maximum_power = self._system_model.value('nominal_energy') * self._system_model.value('C_rate') / 1e3 self.maximum_power = self._financial_model.value("system_capacity") / 1e3 - self.minimum_soc = self._system_model.value('minimum_SOC') - self.maximum_soc = self._system_model.value('maximum_SOC') - self.initial_soc = self._system_model.value('initial_SOC') + self.minimum_soc = self._system_model.value("minimum_SOC") + self.maximum_soc = self._system_model.value("maximum_SOC") + self.initial_soc = self._system_model.value("initial_SOC") self._set_control_mode() self._set_model_specific_parameters() def _set_control_mode(self): + """Sets control mode.""" if isinstance(self._system_model, BatteryModel.BatteryStateful): self._system_model.value("control_mode", 1.0) # Power control - self._system_model.value("input_power", 0.) + self._system_model.value("input_power", 0.0) self.control_variable = "input_power" - def _set_model_specific_parameters(self): - self.round_trip_efficiency = 88.0 # Including converter efficiency - self.capacity = self._system_model.value('nominal_energy') / 1e3 # [MWh] + def _set_model_specific_parameters(self, round_trip_efficiency=88.0): + """Sets model-specific parameters. + + Args: + round_trip_efficiency (float, optional): The round-trip efficiency including converter efficiency. + Defaults to 88.0, which includes converter efficiency. + + """ + self.round_trip_efficiency = ( + round_trip_efficiency # Including converter efficiency + ) + self.capacity = self._system_model.value("nominal_energy") / 1e3 # [MWh] def update_time_series_parameters(self, start_time: int): + """Updates time series parameters. + + Args: + start_time (int): The start time. + + """ # TODO: provide more control self.time_duration = [1.0] * len(self.blocks.index_set()) def update_dispatch_initial_soc(self, initial_soc: float = None): + """Updates dispatch initial state of charge (SOC). + + Args: + initial_soc (float, optional): Initial state of charge. Defaults to None. + + """ if initial_soc is not None: self._system_model.value("initial_SOC", initial_soc) self._system_model.setup() # TODO: Do I need to re-setup stateful battery? - self.initial_soc = self._system_model.value('SOC') + self.initial_soc = self._system_model.value("SOC") diff --git a/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch_heuristic.py b/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch_heuristic.py index 8c5760693..7b2c2ba19 100644 --- a/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch_heuristic.py +++ b/hopp/simulation/technologies/dispatch/power_storage/simple_battery_dispatch_heuristic.py @@ -6,38 +6,54 @@ import PySAM.BatteryStateful as BatteryModel import PySAM.Singleowner as Singleowner -from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch import SimpleBatteryDispatch +from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch import ( + SimpleBatteryDispatch, +) class SimpleBatteryDispatchHeuristic(SimpleBatteryDispatch): """Fixes battery dispatch operations based on user input. - Currently, enforces available generation and grid limit assuming no battery charging from grid + Currently, enforces available generation and grid limit assuming no battery charging from grid. + """ - def __init__(self, - pyomo_model: pyomo.ConcreteModel, - index_set: pyomo.Set, - system_model: BatteryModel.BatteryStateful, - financial_model: Singleowner.Singleowner, - fixed_dispatch: Optional[List] = None, - block_set_name: str = 'heuristic_battery', - dispatch_options: Optional[Dict] = None): - """ - :param fixed_dispatch: list of normalized values [-1, 1] (Charging (-), Discharging (+)) + def __init__( + self, + pyomo_model: pyomo.ConcreteModel, + index_set: pyomo.Set, + system_model: BatteryModel.BatteryStateful, + financial_model: Singleowner.Singleowner, + fixed_dispatch: Optional[List] = None, + block_set_name: str = "heuristic_battery", + dispatch_options: Optional[Dict] = None, + ): + """Initialize SimpleBatteryDispatchHeuristic. + + Args: + pyomo_model (pyomo.ConcreteModel): Pyomo concrete model. + index_set (pyomo.Set): Indexed set. + system_model (BatteryModel.BatteryStateful): Battery system model. + financial_model (Singleowner.Singleowner): Financial model. + fixed_dispatch (Optional[List], optional): List of normalized values [-1, 1] (Charging (-), Discharging (+)). Defaults to None. + block_set_name (str, optional): Name of block set. Defaults to 'heuristic_battery'. + dispatch_options (dict, optional): Dispatch options. Defaults to None. + """ if dispatch_options is None: dispatch_options = {} - super().__init__(pyomo_model, - index_set, - system_model, - financial_model, - block_set_name=block_set_name, - dispatch_options=dispatch_options) - - self.max_charge_fraction = list([0.0]*len(self.blocks.index_set())) - self.max_discharge_fraction = list([0.0]*len(self.blocks.index_set())) - self.user_fixed_dispatch = list([0.0]*len(self.blocks.index_set())) + super().__init__( + pyomo_model, + index_set, + system_model, + financial_model, + block_set_name=block_set_name, + dispatch_options=dispatch_options, + ) + + self.max_charge_fraction = list([0.0] * len(self.blocks.index_set())) + self.max_discharge_fraction = list([0.0] * len(self.blocks.index_set())) + self.user_fixed_dispatch = list([0.0] * len(self.blocks.index_set())) # TODO: should I enforce either a day schedule or a year schedule year and save it as user input. # Additionally, Should I drop it as input in the init function? if fixed_dispatch is not None: @@ -49,6 +65,13 @@ def set_fixed_dispatch(self, gen: list, grid_limit: list): """Sets charge and discharge power of battery dispatch using fixed_dispatch attribute and enforces available generation and grid limits. + Args: + gen (list): Generation blocks. + grid_limit (list): Grid capacity. + + Raises: + ValueError: If gen or grid_limit length does not match fixed_dispatch length. + """ self.check_gen_grid_limit(gen, grid_limit) self._set_power_fraction_limits(gen, grid_limit) @@ -56,37 +79,50 @@ def set_fixed_dispatch(self, gen: list, grid_limit: list): self._fix_dispatch_model_variables() def check_gen_grid_limit(self, gen: list, grid_limit: list): + """Checks if generation and grid limit lengths match fixed_dispatch length. + + Args: + gen (list): Generation blocks. + grid_limit (list): Grid capacity. + + Raises: + ValueError: If gen or grid_limit length does not match fixed_dispatch length. + + """ if len(gen) != len(self.fixed_dispatch): raise ValueError("gen must be the same length as fixed_dispatch.") elif len(grid_limit) != len(self.fixed_dispatch): raise ValueError("grid_limit must be the same length as fixed_dispatch.") def _set_power_fraction_limits(self, gen: list, grid_limit: list): - """ - Set battery charge and discharge power fraction limits based on + """Set battery charge and discharge power fraction limits based on available generation and grid capacity, respectively. Args: - gen: generation Blocks - grid_limit: grid capacity + gen (list): Generation blocks. + grid_limit (list): Grid capacity. NOTE: This method assumes that battery cannot be charged by the grid. + """ for t in self.blocks.index_set(): - self.max_charge_fraction[t] = self.enforce_power_fraction_simple_bounds(gen[t] / self.maximum_power) - self.max_discharge_fraction[t] = self.enforce_power_fraction_simple_bounds((grid_limit[t] - gen[t]) - / self.maximum_power) + self.max_charge_fraction[t] = self.enforce_power_fraction_simple_bounds( + gen[t] / self.maximum_power + ) + self.max_discharge_fraction[t] = self.enforce_power_fraction_simple_bounds( + (grid_limit[t] - gen[t]) / self.maximum_power + ) @staticmethod def enforce_power_fraction_simple_bounds(power_fraction: float) -> float: - """ - Enforces simple bounds (0, .9) for battery power fractions. - + """Enforces simple bounds (0, .9) for battery power fractions. + Args: - power_fraction: power fraction from heuristic method + power_fraction (float): Power fraction from heuristic method. Returns: - bounded power fraction + power_fraction (float): Bounded power fraction. + """ if power_fraction > 0.9: power_fraction = 0.9 @@ -95,23 +131,33 @@ def enforce_power_fraction_simple_bounds(power_fraction: float) -> float: return power_fraction def update_soc(self, power_fraction: float, soc0: float) -> float: - """ - Updates SOC based on power fraction threshold (0.1). - + """Updates SOC based on power fraction threshold (0.1). + Args: - power_fraction: power fraction from heuristic method. Below threshold - is charging, above is discharging - soc0: initial SOC - + power_fraction (float): Power fraction from heuristic method. Below threshold + is charging, above is discharging. + soc0 (float): Initial SOC. + Returns: - Updated SOC. + soc (float): Updated SOC. + """ if power_fraction > 0.0: discharge_power = power_fraction * self.maximum_power - soc = soc0 - self.time_duration[0] * (1/(self.discharge_efficiency/100.) * discharge_power) / self.capacity + soc = ( + soc0 + - self.time_duration[0] + * (1 / (self.discharge_efficiency / 100.0) * discharge_power) + / self.capacity + ) elif power_fraction < 0.0: charge_power = -power_fraction * self.maximum_power - soc = soc0 + self.time_duration[0] * (self.charge_efficiency / 100. * charge_power) / self.capacity + soc = ( + soc0 + + self.time_duration[0] + * (self.charge_efficiency / 100.0 * charge_power) + / self.capacity + ) else: soc = soc0 @@ -123,22 +169,23 @@ def update_soc(self, power_fraction: float, soc0: float) -> float: return soc def _heuristic_method(self, _): - """Does specific heuristic method to fix battery dispatch.""" + """Executes specific heuristic method to fix battery dispatch.""" self._enforce_power_fraction_limits() def _enforce_power_fraction_limits(self): - """ Enforces battery power fraction limits and sets _fixed_dispatch attribute""" + """Enforces battery power fraction limits and sets _fixed_dispatch attribute.""" for t in self.blocks.index_set(): fd = self.user_fixed_dispatch[t] - if fd > 0.0: # Discharging + if fd > 0.0: # Discharging if fd > self.max_discharge_fraction[t]: fd = self.max_discharge_fraction[t] elif fd < 0.0: # Charging - if - fd > self.max_charge_fraction[t]: - fd = - self.max_charge_fraction[t] + if -fd > self.max_charge_fraction[t]: + fd = -self.max_charge_fraction[t] self._fixed_dispatch[t] = fd def _fix_dispatch_model_variables(self): + """Fixes dispatch model variables based on the fixed dispatch values.""" soc0 = self.model.initial_soc.value for t in self.blocks.index_set(): dispatch_factor = self._fixed_dispatch[t] @@ -156,23 +203,28 @@ def _fix_dispatch_model_variables(self): elif dispatch_factor < 0.0: # Charging self.blocks[t].discharge_power.fix(0.0) - self.blocks[t].charge_power.fix(- dispatch_factor * self.maximum_power) + self.blocks[t].charge_power.fix(-dispatch_factor * self.maximum_power) @property def fixed_dispatch(self) -> list: + """list: List of fixed dispatch.""" return self._fixed_dispatch @property def user_fixed_dispatch(self) -> list: + """list: List of user fixed dispatch.""" return self._user_fixed_dispatch @user_fixed_dispatch.setter def user_fixed_dispatch(self, fixed_dispatch: list): # TODO: Annual dispatch array... if len(fixed_dispatch) != len(self.blocks.index_set()): - raise ValueError("fixed_dispatch must be the same length as dispatch index set.") + raise ValueError( + "fixed_dispatch must be the same length as dispatch index set." + ) elif max(fixed_dispatch) > 1.0 or min(fixed_dispatch) < -1.0: - raise ValueError("fixed_dispatch must be normalized values between -1 and 1.") + raise ValueError( + "fixed_dispatch must be normalized values between -1 and 1." + ) else: self._user_fixed_dispatch = fixed_dispatch - diff --git a/hopp/version.py b/hopp/version.py index 7ec1d6db4..ccbccc3dc 100644 --- a/hopp/version.py +++ b/hopp/version.py @@ -1 +1 @@ -2.1.0 +2.2.0 diff --git a/tests/hopp/test_battery_dispatch.py b/tests/hopp/test_battery_dispatch.py index f21251f64..72edc3317 100644 --- a/tests/hopp/test_battery_dispatch.py +++ b/tests/hopp/test_battery_dispatch.py @@ -28,7 +28,7 @@ 'om_production': [2], 'om_capacity': (0,), 'om_batt_fixed_cost': 0, - 'om_batt_variable_cost': [0], + 'om_batt_variable_cost': [0.75], 'om_batt_capacity_cost': 0, 'om_batt_replacement_cost': [0], 'om_replacement_cost_escal': 0, diff --git a/tests/hopp/test_custom_financial.py b/tests/hopp/test_custom_financial.py index 36266f93a..cae1743ac 100644 --- a/tests/hopp/test_custom_financial.py +++ b/tests/hopp/test_custom_financial.py @@ -311,6 +311,7 @@ def test_hybrid_simple_pv_with_wind_storage_dispatch(site, subtests): hybrid_plant = hi.system hybrid_plant.layout.plot() hybrid_plant.battery.dispatch.lifecycle_cost_per_kWh_cycle = 0.01 + hybrid_plant.battery._financial_model.om_batt_variable_cost = [0.75] hybrid_plant.simulate() @@ -401,6 +402,7 @@ def test_hybrid_detailed_pv_with_wind_storage_dispatch(site, subtests): hybrid_plant = hi.system hybrid_plant.layout.plot() hybrid_plant.battery.dispatch.lifecycle_cost_per_kWh_cycle = 0.01 + hybrid_plant.battery._financial_model.om_batt_variable_cost = [0.75] hybrid_plant.simulate() diff --git a/tests/hopp/test_dispatch.py b/tests/hopp/test_dispatch.py index 52af45254..52b8d1510 100644 --- a/tests/hopp/test_dispatch.py +++ b/tests/hopp/test_dispatch.py @@ -482,7 +482,7 @@ def create_test_objective_rule(m): def test_simple_battery_dispatch(site): - expected_objective = 28957.15 + expected_objective = 29678.62 dispatch_n_look_ahead = 48 config = BatteryConfig.from_dict(technologies['battery']) @@ -546,7 +546,7 @@ def create_test_objective_rule(m): def test_simple_battery_dispatch_lifecycle_count(site): - expected_objective = 23657 + expected_objective = 24378.6 expected_lifecycles = [0.75048, 1.50096] dispatch_n_look_ahead = 48 @@ -611,7 +611,7 @@ def create_test_objective_rule(m): def test_detailed_battery_dispatch(site): - expected_objective = 33508 + expected_objective = 34505.9 expected_lifecycles = [0.14300, 0.22169] # TODO: McCormick error is large enough to make objective 50% higher than # the value of simple battery dispatch objective @@ -681,7 +681,7 @@ def create_test_objective_rule(m): def test_pv_wind_battery_hybrid_dispatch(site): - expected_objective = 38777.757 + expected_objective = 39005 wind_solar_battery = {key: technologies[key] for key in ('pv', 'wind', 'battery', 'grid')} hopp_config = { @@ -783,7 +783,7 @@ def test_hybrid_dispatch_one_cycle_heuristic(site): def test_hybrid_solar_battery_dispatch(site): - expected_objective = 23474 + expected_objective = 24029 solar_battery_technologies = {k: technologies[k] for k in ('pv', 'battery', 'grid')} hopp_config = { @@ -928,7 +928,7 @@ def test_desired_schedule_dispatch(site): def test_simple_battery_dispatch_lifecycle_limit(site): - expected_objective = 7561 + expected_objective = 7882 max_lifecycle_per_day = 0.5 dispatch_n_look_ahead = 48 diff --git a/tests/hopp/test_hybrid.py b/tests/hopp/test_hybrid.py index 3b7c6b488..89d7db188 100644 --- a/tests/hopp/test_hybrid.py +++ b/tests/hopp/test_hybrid.py @@ -9,8 +9,13 @@ from hopp.simulation import HoppInterface from hopp.simulation.technologies.sites import SiteInfo -from hopp.simulation.technologies.pv.detailed_pv_plant import DetailedPVPlant, DetailedPVConfig -from hopp.simulation.technologies.layout.pv_design_utils import size_electrical_parameters +from hopp.simulation.technologies.pv.detailed_pv_plant import ( + DetailedPVPlant, + DetailedPVConfig, +) +from hopp.simulation.technologies.layout.pv_design_utils import ( + size_electrical_parameters, +) from hopp.simulation.technologies.financial.mhk_cost_model import MHKCostModelInputs from tests.hopp.utils import create_default_site_info, DEFAULT_FIN_CONFIG from hopp import ROOT_DIR @@ -20,7 +25,9 @@ @fixture def hybrid_config(): """Loads the config YAML and updates site info to use resource files.""" - hybrid_config_path = ROOT_DIR.parent / "tests" / "hopp" / "inputs" / "hybrid_run.yaml" + hybrid_config_path = ( + ROOT_DIR.parent / "tests" / "hopp" / "inputs" / "hybrid_run.yaml" + ) hybrid_config = load_yaml(hybrid_config_path) return hybrid_config @@ -29,25 +36,23 @@ def hybrid_config(): def site(): return create_default_site_info() -wave_resource_file = ROOT_DIR.parent / "resource_files" / "wave" / "Wave_resource_timeseries.csv" + +wave_resource_file = ( + ROOT_DIR.parent / "resource_files" / "wave" / "Wave_resource_timeseries.csv" +) + @fixture def wavesite(): - data = { - "lat": 44.6899, - "lon": 124.1346, - "year": 2010, - "tz": -7 - } + data = {"lat": 44.6899, "lon": 124.1346, "year": 2010, "tz": -7} return SiteInfo( - data, - wave_resource_file=wave_resource_file, - solar=False, - wind=False, - wave=True + data, wave_resource_file=wave_resource_file, solar=False, wind=False, wave=True ) -mhk_yaml_path = ROOT_DIR.parent / "tests" / "hopp" / "inputs" / "wave" / "wave_device.yaml" + +mhk_yaml_path = ( + ROOT_DIR.parent / "tests" / "hopp" / "inputs" / "wave" / "wave_device.yaml" +) mhk_config = load_yaml(mhk_yaml_path) interconnection_size_kw = 15000 @@ -56,63 +61,158 @@ def wavesite(): batt_kw = 5000 detailed_pv = { - 'tech_config': { - 'system_capacity_kw': pv_kw - }, - 'layout_params': { + "tech_config": {"system_capacity_kw": pv_kw}, + "layout_params": { "x_position": 0.5, "y_position": 0.5, "aspect_power": 0, "gcr": 0.5, "s_buffer": 2, - "x_buffer": 2 - } + "x_buffer": 2, + }, } # From a Cambium midcase BA10 2030 analysis (Jan 1 = 1): -capacity_credit_hours_of_year = [4604,4605,4606,4628,4629,4630,4652,4821,5157,5253, - 5254,5277,5278,5299,5300,5301,5302,5321,5323,5324, - 5325,5326,5327,5347,5348,5349,5350,5369,5370,5371, - 5372,5374,5395,5396,5397,5398,5419,5420,5421,5422, - 5443,5444,5445,5446,5467,5468,5469,5493,5494,5517, - 5539,5587,5589,5590,5661,5757,5781,5803,5804,5805, - 5806,5826,5827,5830,5947,5948,5949,5995,5996,5997, - 6019,6090,6091,6092,6093,6139,6140,6141,6163,6164, - 6165,6166,6187,6188,6211,6212,6331,6354,6355,6356, - 6572,6594,6595,6596,6597,6598,6618,6619,6620,6621] +capacity_credit_hours_of_year = [ + 4604, + 4605, + 4606, + 4628, + 4629, + 4630, + 4652, + 4821, + 5157, + 5253, + 5254, + 5277, + 5278, + 5299, + 5300, + 5301, + 5302, + 5321, + 5323, + 5324, + 5325, + 5326, + 5327, + 5347, + 5348, + 5349, + 5350, + 5369, + 5370, + 5371, + 5372, + 5374, + 5395, + 5396, + 5397, + 5398, + 5419, + 5420, + 5421, + 5422, + 5443, + 5444, + 5445, + 5446, + 5467, + 5468, + 5469, + 5493, + 5494, + 5517, + 5539, + 5587, + 5589, + 5590, + 5661, + 5757, + 5781, + 5803, + 5804, + 5805, + 5806, + 5826, + 5827, + 5830, + 5947, + 5948, + 5949, + 5995, + 5996, + 5997, + 6019, + 6090, + 6091, + 6092, + 6093, + 6139, + 6140, + 6141, + 6163, + 6164, + 6165, + 6166, + 6187, + 6188, + 6211, + 6212, + 6331, + 6354, + 6355, + 6356, + 6572, + 6594, + 6595, + 6596, + 6597, + 6598, + 6618, + 6619, + 6620, + 6621, +] # List length 8760, True if the hour counts for capacity payments, False otherwise -capacity_credit_hours = [hour in capacity_credit_hours_of_year for hour in range(1,8760+1)] +capacity_credit_hours = [ + hour in capacity_credit_hours_of_year for hour in range(1, 8760 + 1) +] + def test_hybrid_wave_only(hybrid_config, wavesite, subtests): hybrid_config["site"]["wave"] = True hybrid_config["site"]["wave_resource_file"] = wave_resource_file wave_only_technologies = { - 'wave': { - 'device_rating_kw': mhk_config['device_rating_kw'], - 'num_devices': 10, - 'wave_power_matrix': mhk_config['wave_power_matrix'], - 'fin_model': DEFAULT_FIN_CONFIG + "wave": { + "device_rating_kw": mhk_config["device_rating_kw"], + "num_devices": 10, + "wave_power_matrix": mhk_config["wave_power_matrix"], + "fin_model": DEFAULT_FIN_CONFIG, + }, + "grid": { + "interconnect_kw": interconnection_size_kw, + "fin_model": DEFAULT_FIN_CONFIG, }, - 'grid': { - 'interconnect_kw': interconnection_size_kw, - 'fin_model': DEFAULT_FIN_CONFIG, - } } hybrid_config["technologies"] = wave_only_technologies - - # TODO once the financial model is implemented, romove the line immediately following this comment and un-indent the rest of the test + + # TODO once the financial model is implemented, romove the line immediately following this comment and un-indent the rest of the test hi = HoppInterface(hybrid_config) hybrid_plant = hi.system # hybrid_plant = HybridSimulation(wave_only_technologies, wavesite) - cost_model_inputs = MHKCostModelInputs.from_dict({ - 'reference_model_num':3, - 'water_depth': 100, - 'distance_to_shore': 80, - 'number_rows': 10, - 'device_spacing':600, - 'row_spacing': 600, - 'cable_system_overbuild': 20 - }) + cost_model_inputs = MHKCostModelInputs.from_dict( + { + "reference_model_num": 3, + "water_depth": 100, + "distance_to_shore": 80, + "number_rows": 10, + "device_spacing": 600, + "row_spacing": 600, + "cable_system_overbuild": 20, + } + ) assert hybrid_plant.wave is not None hybrid_plant.wave.create_mhk_cost_calculator(cost_model_inputs) @@ -123,99 +223,142 @@ def test_hybrid_wave_only(hybrid_config, wavesite, subtests): # check that wave and grid match when only wave is in the hybrid system with subtests.test("financial parameters"): - assert hybrid_plant.wave._financial_model.FinancialParameters == approx(hybrid_plant.grid._financial_model.FinancialParameters) + assert hybrid_plant.wave._financial_model.FinancialParameters == approx( + hybrid_plant.grid._financial_model.FinancialParameters + ) with subtests.test("Revenue"): - assert hybrid_plant.wave._financial_model.Revenue == approx(hybrid_plant.grid._financial_model.Revenue) + assert hybrid_plant.wave._financial_model.Revenue == approx( + hybrid_plant.grid._financial_model.Revenue + ) with subtests.test("SystemCosts"): - assert hybrid_plant.wave._financial_model.SystemCosts == approx(hybrid_plant.grid._financial_model.SystemCosts) + assert hybrid_plant.wave._financial_model.SystemCosts == approx( + hybrid_plant.grid._financial_model.SystemCosts + ) # with subtests.test("SystemOutput.__dict__"): # skip(reason="this test will not be consistent until the code is more type stable. Outputs may be tuple or list") # assert hybrid_plant.wave._financial_model.SystemOutput.__dict__ == hybrid_plant.grid._financial_model.SystemOutput.__dict__ with subtests.test("SystemOutput.gen"): - assert hybrid_plant.wave._financial_model.SystemOutput.gen == approx(hybrid_plant.grid._financial_model.SystemOutput.gen) + assert hybrid_plant.wave._financial_model.SystemOutput.gen == approx( + hybrid_plant.grid._financial_model.SystemOutput.gen + ) with subtests.test("SystemOutput.system_capacity"): - assert hybrid_plant.wave._financial_model.SystemOutput.system_capacity == approx(hybrid_plant.grid._financial_model.SystemOutput.system_capacity) + assert ( + hybrid_plant.wave._financial_model.SystemOutput.system_capacity + == approx(hybrid_plant.grid._financial_model.SystemOutput.system_capacity) + ) with subtests.test("SystemOutput.degradation"): - assert hybrid_plant.wave._financial_model.SystemOutput.degradation == approx(hybrid_plant.grid._financial_model.SystemOutput.degradation) + assert hybrid_plant.wave._financial_model.SystemOutput.degradation == approx( + hybrid_plant.grid._financial_model.SystemOutput.degradation + ) with subtests.test("SystemOutput.system_pre_curtailment_kwac"): - assert hybrid_plant.wave._financial_model.SystemOutput.system_pre_curtailment_kwac == approx(hybrid_plant.grid._financial_model.SystemOutput.system_pre_curtailment_kwac) + assert ( + hybrid_plant.wave._financial_model.SystemOutput.system_pre_curtailment_kwac + == approx( + hybrid_plant.grid._financial_model.SystemOutput.system_pre_curtailment_kwac + ) + ) with subtests.test("SystemOutput.annual_energy_pre_curtailment_ac"): - assert hybrid_plant.wave._financial_model.SystemOutput.annual_energy_pre_curtailment_ac == approx(hybrid_plant.grid._financial_model.SystemOutput.annual_energy_pre_curtailment_ac) + assert ( + hybrid_plant.wave._financial_model.SystemOutput.annual_energy_pre_curtailment_ac + == approx( + hybrid_plant.grid._financial_model.SystemOutput.annual_energy_pre_curtailment_ac + ) + ) with subtests.test("Outputs"): - assert hybrid_plant.wave._financial_model.Outputs == approx(hybrid_plant.grid._financial_model.Outputs) + assert hybrid_plant.wave._financial_model.Outputs == approx( + hybrid_plant.grid._financial_model.Outputs + ) with subtests.test("net cash flow"): - wave_period = hybrid_plant.wave._financial_model.value('analysis_period') - grid_period = hybrid_plant.grid._financial_model.value('analysis_period') - assert hybrid_plant.wave._financial_model.net_cash_flow(wave_period) == approx(hybrid_plant.grid._financial_model.net_cash_flow(grid_period)) - + wave_period = hybrid_plant.wave._financial_model.value("analysis_period") + grid_period = hybrid_plant.grid._financial_model.value("analysis_period") + assert hybrid_plant.wave._financial_model.net_cash_flow(wave_period) == approx( + hybrid_plant.grid._financial_model.net_cash_flow(grid_period) + ) + with subtests.test("degradation"): - assert hybrid_plant.wave._financial_model.value("degradation") == approx(hybrid_plant.grid._financial_model.value("degradation")) + assert hybrid_plant.wave._financial_model.value("degradation") == approx( + hybrid_plant.grid._financial_model.value("degradation") + ) with subtests.test("total_installed_cost"): - assert hybrid_plant.wave._financial_model.value("total_installed_cost") == approx(hybrid_plant.grid._financial_model.value("total_installed_cost")) + assert hybrid_plant.wave._financial_model.value( + "total_installed_cost" + ) == approx(hybrid_plant.grid._financial_model.value("total_installed_cost")) with subtests.test("inflation_rate"): - assert hybrid_plant.wave._financial_model.value("inflation_rate") == approx(hybrid_plant.grid._financial_model.value("inflation_rate")) + assert hybrid_plant.wave._financial_model.value("inflation_rate") == approx( + hybrid_plant.grid._financial_model.value("inflation_rate") + ) with subtests.test("annual_energy"): - assert hybrid_plant.wave._financial_model.value("annual_energy") == approx(hybrid_plant.grid._financial_model.value("annual_energy")) + assert hybrid_plant.wave._financial_model.value("annual_energy") == approx( + hybrid_plant.grid._financial_model.value("annual_energy") + ) with subtests.test("ppa_price_input"): - assert hybrid_plant.wave._financial_model.value("ppa_price_input") == approx(hybrid_plant.grid._financial_model.value("ppa_price_input")) + assert hybrid_plant.wave._financial_model.value("ppa_price_input") == approx( + hybrid_plant.grid._financial_model.value("ppa_price_input") + ) with subtests.test("ppa_escalation"): - assert hybrid_plant.wave._financial_model.value("ppa_escalation") == approx(hybrid_plant.grid._financial_model.value("ppa_escalation")) + assert hybrid_plant.wave._financial_model.value("ppa_escalation") == approx( + hybrid_plant.grid._financial_model.value("ppa_escalation") + ) # test hybrid outputs with subtests.test("wave aep"): - assert aeps.wave == approx(12132526.0,1e-2) + assert aeps.wave == approx(12132526.0, 1e-2) with subtests.test("hybrid wave only aep"): assert aeps.hybrid == approx(aeps.wave) with subtests.test("wave cf"): - assert cf.wave == approx(48.42,1e-2) + assert cf.wave == approx(48.42, 1e-2) with subtests.test("hybrid wave only cf"): assert cf.hybrid == approx(cf.wave) with subtests.test("wave npv"): - #TODO check/verify this test value somehow, not sure how to do it right now + # TODO check/verify this test value somehow, not sure how to do it right now assert npvs.wave == approx(-53731805.52113224) with subtests.test("hybrid wave only npv"): assert npvs.hybrid == approx(npvs.wave) + def test_hybrid_wave_battery(hybrid_config, wavesite, subtests): hybrid_config["site"]["wave"] = True hybrid_config["site"]["wave_resource_file"] = wave_resource_file wave_only_technologies = { - 'wave': { - 'device_rating_kw': mhk_config['device_rating_kw'], - 'num_devices': 10, - 'wave_power_matrix': mhk_config['wave_power_matrix'], - 'fin_model': DEFAULT_FIN_CONFIG + "wave": { + "device_rating_kw": mhk_config["device_rating_kw"], + "num_devices": 10, + "wave_power_matrix": mhk_config["wave_power_matrix"], + "fin_model": DEFAULT_FIN_CONFIG, }, - 'battery': { - 'system_capacity_kwh': 20000, - 'system_capacity_kw': 80000, - 'fin_model': DEFAULT_FIN_CONFIG + "battery": { + "system_capacity_kwh": 20000, + "system_capacity_kw": 80000, + "fin_model": DEFAULT_FIN_CONFIG, + }, + "grid": { + "interconnect_kw": interconnection_size_kw, + "fin_model": DEFAULT_FIN_CONFIG, }, - 'grid': { - 'interconnect_kw': interconnection_size_kw, - 'fin_model': DEFAULT_FIN_CONFIG, - } } hybrid_config["technologies"] = wave_only_technologies - - # TODO once the financial model is implemented, romove the line immediately following this comment and un-indent the rest of the test + + # TODO once the financial model is implemented, romove the line immediately following this comment and un-indent the rest of the test hi = HoppInterface(hybrid_config) hybrid_plant = hi.system # hybrid_plant = HybridSimulation(wave_only_technologies, wavesite) - cost_model_inputs = MHKCostModelInputs.from_dict({ - 'reference_model_num':3, - 'water_depth': 100, - 'distance_to_shore': 80, - 'number_rows': 10, - 'device_spacing':600, - 'row_spacing': 600, - 'cable_system_overbuild': 20 - }) + cost_model_inputs = MHKCostModelInputs.from_dict( + { + "reference_model_num": 3, + "water_depth": 100, + "distance_to_shore": 80, + "number_rows": 10, + "device_spacing": 600, + "row_spacing": 600, + "cable_system_overbuild": 20, + } + ) assert hybrid_plant.wave is not None hybrid_plant.wave.create_mhk_cost_calculator(cost_model_inputs) + hybrid_plant.battery._financial_model.om_batt_variable_cost = [0.75] hi.simulate() aeps = hybrid_plant.annual_energies @@ -225,9 +368,10 @@ def test_hybrid_wave_battery(hybrid_config, wavesite, subtests): with subtests.test("battery aep"): assert aeps.battery == approx(87.84, 1e3) + def test_hybrid_wind_only(hybrid_config): technologies = hybrid_config["technologies"] - wind_only = {key: technologies[key] for key in ('wind', 'grid')} + wind_only = {key: technologies[key] for key in ("wind", "grid")} hybrid_config["technologies"] = wind_only hi = HoppInterface(hybrid_config) hybrid_plant = hi.system @@ -247,7 +391,7 @@ def test_hybrid_wind_only(hybrid_config): def test_hybrid_pv_only(hybrid_config): technologies = hybrid_config["technologies"] - solar_only = {key: technologies[key] for key in ('pv', 'grid')} + solar_only = {key: technologies[key] for key in ("pv", "grid")} hybrid_config["technologies"] = solar_only hi = HoppInterface(hybrid_config) @@ -376,17 +520,23 @@ def test_hybrid_pv_battery_custom_fin(hybrid_config, subtests): assert hybrid_plant.battery.om_capacity == (30,) def test_detailed_pv_system_capacity(hybrid_config, subtests): - with subtests.test("Detailed PV model (pvsamv1) using defaults except the top level system_capacity_kw parameter"): + with subtests.test( + "Detailed PV model (pvsamv1) using defaults except the top level system_capacity_kw parameter" + ): annual_energy_expected = 11128604 npv_expected = -2436229 technologies = hybrid_config["technologies"] - solar_only = deepcopy({key: technologies[key] for key in ('pv', 'grid')}) # includes system_capacity_kw parameter - solar_only['pv']['use_pvwatts'] = False # specify detailed PV model but don't change any defaults - solar_only['grid']['interconnect_kw'] = 150e3 + solar_only = deepcopy( + {key: technologies[key] for key in ("pv", "grid")} + ) # includes system_capacity_kw parameter + solar_only["pv"][ + "use_pvwatts" + ] = False # specify detailed PV model but don't change any defaults + solar_only["grid"]["interconnect_kw"] = 150e3 hybrid_config["technologies"] = solar_only hi = HoppInterface(hybrid_config) hybrid_plant = hi.system - assert hybrid_plant.pv.value('subarray1_nstrings') == 1343 + assert hybrid_plant.pv.value("subarray1_nstrings") == 1343 hybrid_plant.layout.plot() hi.simulate() @@ -398,35 +548,47 @@ def test_detailed_pv_system_capacity(hybrid_config, subtests): assert npvs.pv == approx(npv_expected, 1e-3) assert npvs.hybrid == approx(npv_expected, 1e-3) - - with subtests.test("Detailed PV model (pvsamv1) using parameters from file except the top level system_capacity_kw parameter"): - pvsamv1_defaults_file = Path(__file__).absolute().parent / "pvsamv1_basic_params.json" - with open(pvsamv1_defaults_file, 'r') as f: + with subtests.test( + "Detailed PV model (pvsamv1) using parameters from file except the top level system_capacity_kw parameter" + ): + pvsamv1_defaults_file = ( + Path(__file__).absolute().parent / "pvsamv1_basic_params.json" + ) + with open(pvsamv1_defaults_file, "r") as f: tech_config = json.load(f) - solar_only = deepcopy({key: technologies[key] for key in ('pv', 'grid')}) # includes system_capacity_kw parameter - solar_only['pv']['use_pvwatts'] = False # specify detailed PV model - solar_only['pv']['tech_config'] = tech_config # specify parameters - solar_only['grid']['interconnect_kw'] = 150e3 + solar_only = deepcopy( + {key: technologies[key] for key in ("pv", "grid")} + ) # includes system_capacity_kw parameter + solar_only["pv"]["use_pvwatts"] = False # specify detailed PV model + solar_only["pv"]["tech_config"] = tech_config # specify parameters + solar_only["grid"]["interconnect_kw"] = 150e3 hybrid_config["technologies"] = solar_only with raises(Exception) as context: hi = HoppInterface(hybrid_config) - assert "The specified system capacity of 5000 kW is more than 5% from the value calculated" in str(context.value) + assert ( + "The specified system capacity of 5000 kW is more than 5% from the value calculated" + in str(context.value) + ) # Run detailed PV model (pvsamv1) using file parameters, minus the number of strings, and the top level system_capacity_kw parameter annual_energy_expected = 8955045 npv_expected = -2622684 - pvsamv1_defaults_file = Path(__file__).absolute().parent / "pvsamv1_basic_params.json" - with open(pvsamv1_defaults_file, 'r') as f: + pvsamv1_defaults_file = ( + Path(__file__).absolute().parent / "pvsamv1_basic_params.json" + ) + with open(pvsamv1_defaults_file, "r") as f: tech_config = json.load(f) - tech_config.pop('subarray1_nstrings') - solar_only = deepcopy({key: technologies[key] for key in ('pv', 'grid')}) # includes system_capacity_kw parameter - solar_only['pv']['use_pvwatts'] = False # specify detailed PV model - solar_only['pv']['tech_config'] = tech_config # specify parameters - solar_only['grid']['interconnect_kw'] = 150e3 + tech_config.pop("subarray1_nstrings") + solar_only = deepcopy( + {key: technologies[key] for key in ("pv", "grid")} + ) # includes system_capacity_kw parameter + solar_only["pv"]["use_pvwatts"] = False # specify detailed PV model + solar_only["pv"]["tech_config"] = tech_config # specify parameters + solar_only["grid"]["interconnect_kw"] = 150e3 hybrid_config["technologies"] = solar_only hi = HoppInterface(hybrid_config) hybrid_plant = hi.system - assert hybrid_plant.pv.value('subarray1_nstrings') == 1343 + assert hybrid_plant.pv.value("subarray1_nstrings") == 1343 hybrid_plant.layout.plot() hi.simulate() @@ -446,18 +608,19 @@ def test_hybrid_detailed_pv_only(site, hybrid_config, subtests): assert pv_plant.system_capacity_kw == approx(pv_kw, 1e-2) pv_plant.simulate_power(1, False) assert pv_plant.system_capacity_kw == approx(pv_kw, 1e-2) - assert pv_plant._system_model.Outputs.annual_energy == approx(annual_energy_expected, 1e-2) + assert pv_plant._system_model.Outputs.annual_energy == approx( + annual_energy_expected, 1e-2 + ) assert pv_plant._system_model.Outputs.capacity_factor == approx(25.66, 1e-2) with subtests.test("detailed PV model (pvsamv1) using defaults"): technologies = hybrid_config["technologies"] npv_expected = -2436229 - solar_only = { - 'pv': detailed_pv, - 'grid': technologies['grid'] - } - solar_only['pv']['use_pvwatts'] = False # specify detailed PV model but don't change any defaults - solar_only['grid']['interconnect_kw'] = 150e3 + solar_only = {"pv": detailed_pv, "grid": technologies["grid"]} + solar_only["pv"][ + "use_pvwatts" + ] = False # specify detailed PV model but don't change any defaults + solar_only["grid"]["interconnect_kw"] = 150e3 hybrid_config["technologies"] = solar_only hi = HoppInterface(hybrid_config) hybrid_plant = hi.system @@ -475,14 +638,16 @@ def test_hybrid_detailed_pv_only(site, hybrid_config, subtests): with subtests.test("Detailed PV model (pvsamv1) using parameters from file"): annual_energy_expected = 102997528 npv_expected = -25049424 - pvsamv1_defaults_file = Path(__file__).absolute().parent / "pvsamv1_basic_params.json" - with open(pvsamv1_defaults_file, 'r') as f: + pvsamv1_defaults_file = ( + Path(__file__).absolute().parent / "pvsamv1_basic_params.json" + ) + with open(pvsamv1_defaults_file, "r") as f: tech_config = json.load(f) - solar_only = deepcopy({key: technologies[key] for key in ('pv', 'grid')}) - solar_only['pv']['use_pvwatts'] = False # specify detailed PV model - solar_only['pv']['tech_config'] = tech_config # specify parameters - solar_only['grid']['interconnect_kw'] = 150e3 - solar_only['pv']['system_capacity_kw'] = 50000 # use another system capacity + solar_only = deepcopy({key: technologies[key] for key in ("pv", "grid")}) + solar_only["pv"]["use_pvwatts"] = False # specify detailed PV model + solar_only["pv"]["tech_config"] = tech_config # specify parameters + solar_only["grid"]["interconnect_kw"] = 150e3 + solar_only["pv"]["system_capacity_kw"] = 50000 # use another system capacity hybrid_config["technologies"] = solar_only hi = HoppInterface(hybrid_config) hybrid_plant = hi.system @@ -517,38 +682,49 @@ def test_hybrid_detailed_pv_only(site, hybrid_config, subtests): # assert npvs.pv == approx(npv_expected, 1e-3) # assert npvs.hybrid == approx(npv_expected, 1e-3) - with subtests.test("Detailed PV model using parameters from file and autosizing electrical parameters"): + with subtests.test( + "Detailed PV model using parameters from file and autosizing electrical parameters" + ): annual_energy_expected = 102319358 npv_expected = -25110524 - pvsamv1_defaults_file = Path(__file__).absolute().parent / "pvsamv1_basic_params.json" - with open(pvsamv1_defaults_file, 'r') as f: + pvsamv1_defaults_file = ( + Path(__file__).absolute().parent / "pvsamv1_basic_params.json" + ) + with open(pvsamv1_defaults_file, "r") as f: tech_config = json.load(f) - solar_only = deepcopy({key: technologies[key] for key in ('pv', 'grid')}) - solar_only['pv']['use_pvwatts'] = False # specify detailed PV model - solar_only['pv']['tech_config'] = tech_config # specify parameters - solar_only['grid']['interconnect_kw'] = 150e3 - solar_only['pv'].pop('system_capacity_kw') # use default system capacity instead + solar_only = deepcopy({key: technologies[key] for key in ("pv", "grid")}) + solar_only["pv"]["use_pvwatts"] = False # specify detailed PV model + solar_only["pv"]["tech_config"] = tech_config # specify parameters + solar_only["grid"]["interconnect_kw"] = 150e3 + solar_only["pv"].pop( + "system_capacity_kw" + ) # use default system capacity instead # autosize number of strings, number of inverters and adjust system capacity - n_strings, n_combiners, n_inverters, calculated_system_capacity = size_electrical_parameters( - target_system_capacity=solar_only['pv']['tech_config']['system_capacity'], - target_dc_ac_ratio=1.34, - modules_per_string=solar_only['pv']['tech_config']['subarray1_modules_per_string'], - module_power= \ - solar_only['pv']['tech_config']['cec_i_mp_ref'] \ - * solar_only['pv']['tech_config']['cec_v_mp_ref'] \ + n_strings, n_combiners, n_inverters, calculated_system_capacity = ( + size_electrical_parameters( + target_system_capacity=solar_only["pv"]["tech_config"][ + "system_capacity" + ], + target_dc_ac_ratio=1.34, + modules_per_string=solar_only["pv"]["tech_config"][ + "subarray1_modules_per_string" + ], + module_power=solar_only["pv"]["tech_config"]["cec_i_mp_ref"] + * solar_only["pv"]["tech_config"]["cec_v_mp_ref"] * 1e-3, - inverter_power=solar_only['pv']['tech_config']['inv_snl_paco'] * 1e-3, - n_inputs_inverter=50, - n_inputs_combiner=32 + inverter_power=solar_only["pv"]["tech_config"]["inv_snl_paco"] * 1e-3, + n_inputs_inverter=50, + n_inputs_combiner=32, + ) ) assert n_strings == 13435 assert n_combiners == 420 assert n_inverters == 50 assert calculated_system_capacity == approx(50002.2, 1e-3) - solar_only['pv']['tech_config']['subarray1_nstrings'] = n_strings - solar_only['pv']['tech_config']['inverter_count'] = n_inverters - solar_only['pv']['tech_config']['system_capacity'] = calculated_system_capacity + solar_only["pv"]["tech_config"]["subarray1_nstrings"] = n_strings + solar_only["pv"]["tech_config"]["inverter_count"] = n_inverters + solar_only["pv"]["tech_config"]["system_capacity"] = calculated_system_capacity hybrid_config["technologies"] = solar_only hi = HoppInterface(hybrid_config) @@ -575,32 +751,26 @@ def test_hybrid_user_instantiated(site, subtests): interconnect_kw = 150e3 layout_params = { - "x_position": 0.5, - "y_position": 0.5, - "aspect_power": 0, - "gcr": 0.5, - "s_buffer": 2, - "x_buffer": 2 + "x_position": 0.5, + "y_position": 0.5, + "aspect_power": 0, + "gcr": 0.5, + "s_buffer": 2, + "x_buffer": 2, } # Run non-user-instantiated to compare against with subtests.test("baseline comparison"): solar_only = { - 'pv': { - 'use_pvwatts': False, - 'tech_config': {'system_capacity_kw': system_capacity_kw}, + "pv": { + "use_pvwatts": False, + "tech_config": {"system_capacity_kw": system_capacity_kw}, "layout_params": layout_params, - 'dc_degradation': [0] * 25 + "dc_degradation": [0] * 25, }, - 'grid': { - 'interconnect_kw': interconnect_kw, - 'ppa_price': 0.01 - } - } - hopp_config = { - "site": site, - "technologies": solar_only + "grid": {"interconnect_kw": interconnect_kw, "ppa_price": 0.01}, } + hopp_config = {"site": site, "technologies": solar_only} hi = HoppInterface(hopp_config) hybrid_plant = hi.system hybrid_plant.layout.plot() @@ -613,27 +783,23 @@ def test_hybrid_user_instantiated(site, subtests): assert npvs.pv == approx(npv_expected, 1e-2) assert npvs.hybrid == approx(npv_expected, 1e-2) - with subtests.test("detailed PV plant, grid and respective financial models"): - # Run + # Run power_sources = { - 'pv': { - 'use_pvwatts': False, - 'system_capacity_kw': system_capacity_kw, - 'layout_params': layout_params, - 'fin_model': 'FlatPlatePVSingleOwner', - 'dc_degradation': [0] * 25 + "pv": { + "use_pvwatts": False, + "system_capacity_kw": system_capacity_kw, + "layout_params": layout_params, + "fin_model": "FlatPlatePVSingleOwner", + "dc_degradation": [0] * 25, + }, + "grid": { + "interconnect_kw": interconnect_kw, + "fin_model": "GenericSystemSingleOwner", + "ppa_price": 0.01, }, - 'grid': { - 'interconnect_kw': interconnect_kw, - 'fin_model': 'GenericSystemSingleOwner', - 'ppa_price': 0.01 - } - } - hopp_config = { - "site": site, - "technologies": power_sources } + hopp_config = {"site": site, "technologies": power_sources} hi = HoppInterface(hopp_config) hybrid_plant = hi.system assert hybrid_plant.pv is not None @@ -643,8 +809,12 @@ def test_hybrid_user_instantiated(site, subtests): aeps = hybrid_plant.annual_energies npvs = hybrid_plant.net_present_values - assert hybrid_plant.pv._system_model.value("system_capacity") == approx(system_capacity_kw_expected, 1e-3) - assert hybrid_plant.pv._financial_model.value("system_capacity") == approx(system_capacity_kw_expected, 1e-3) + assert hybrid_plant.pv._system_model.value("system_capacity") == approx( + system_capacity_kw_expected, 1e-3 + ) + assert hybrid_plant.pv._financial_model.value("system_capacity") == approx( + system_capacity_kw_expected, 1e-3 + ) assert aeps.pv == approx(annual_energy_expected, 1e-3) assert aeps.hybrid == approx(annual_energy_expected, 1e-3) assert npvs.pv == approx(npv_expected, 1e-3) @@ -656,7 +826,7 @@ def test_hybrid(hybrid_config): Performance from Wind is slightly different from wind-only case because the solar presence modified the wind layout """ technologies = hybrid_config["technologies"] - solar_wind_hybrid = {key: technologies[key] for key in ('pv', 'wind', 'grid')} + solar_wind_hybrid = {key: technologies[key] for key in ("pv", "wind", "grid")} hybrid_config["technologies"] = solar_wind_hybrid hi = HoppInterface(hybrid_config) hybrid_plant = hi.system @@ -677,11 +847,16 @@ def test_hybrid(hybrid_config): def test_wind_pv_with_storage_dispatch(hybrid_config): technologies = hybrid_config["technologies"] - wind_pv_battery = {key: technologies[key] for key in ('pv', 'wind', 'battery', 'grid')} + wind_pv_battery = { + key: technologies[key] for key in ("pv", "wind", "battery", "grid") + } hybrid_config["technologies"] = wind_pv_battery hybrid_config["technologies"]["grid"]["ppa_price"] = 0.03 hi = HoppInterface(hybrid_config) hybrid_plant = hi.system + hybrid_plant.battery._financial_model.SystemCosts.assign( + {"om_batt_variable_cost": [0.75]} + ) hi.simulate() @@ -756,26 +931,23 @@ def test_wind_pv_with_storage_dispatch(hybrid_config): def test_tower_pv_hybrid(hybrid_config): interconnection_size_kw_test = 50000 technologies_test = { - 'tower': { - 'cycle_capacity_kw': 50 * 1000, - 'solar_multiple': 2.0, - 'tes_hours': 12.0 + "tower": { + "cycle_capacity_kw": 50 * 1000, + "solar_multiple": 2.0, + "tes_hours": 12.0, }, - 'pv': {'system_capacity_kw': 50 * 1000}, - 'grid': { - 'interconnect_kw': interconnection_size_kw_test, - 'ppa_price': 0.12 - } + "pv": {"system_capacity_kw": 50 * 1000}, + "grid": {"interconnect_kw": interconnection_size_kw_test, "ppa_price": 0.12}, } - solar_hybrid = {key: technologies_test[key] for key in ('tower', 'pv', 'grid')} + solar_hybrid = {key: technologies_test[key] for key in ("tower", "pv", "grid")} hybrid_config["technologies"] = solar_hybrid - dispatch_options={'is_test_start_year': True, 'is_test_end_year': True} + dispatch_options = {"is_test_start_year": True, "is_test_end_year": True} hybrid_config["config"]["dispatch_options"] = dispatch_options hi = HoppInterface(hybrid_config) hybrid_plant = hi.system - hybrid_plant.tower.value('helio_width', 8.0) - hybrid_plant.tower.value('helio_height', 8.0) + hybrid_plant.tower.value("helio_width", 8.0) + hybrid_plant.tower.value("helio_height", 8.0) hi.simulate() @@ -788,28 +960,25 @@ def test_tower_pv_hybrid(hybrid_config): # TODO: check npv for csp would require a full simulation assert npvs.pv == approx(45233832.23, 1e3) - #assert npvs.tower == approx(-13909363, 1e3) - #assert npvs.hybrid == approx(-19216589, 1e3) + # assert npvs.tower == approx(-13909363, 1e3) + # assert npvs.hybrid == approx(-19216589, 1e3) def test_trough_pv_hybrid(hybrid_config): interconnection_size_kw_test = 50000 technologies_test = { - 'trough': { - 'cycle_capacity_kw': 50 * 1000, - 'solar_multiple': 2.0, - 'tes_hours': 12.0 - }, - 'pv': {'system_capacity_kw': 50 * 1000}, - 'grid': { - 'interconnect_kw': interconnection_size_kw_test, - 'ppa_price': 0.12 + "trough": { + "cycle_capacity_kw": 50 * 1000, + "solar_multiple": 2.0, + "tes_hours": 12.0, }, + "pv": {"system_capacity_kw": 50 * 1000}, + "grid": {"interconnect_kw": interconnection_size_kw_test, "ppa_price": 0.12}, } - solar_hybrid = {key: technologies_test[key] for key in ('trough', 'pv', 'grid')} + solar_hybrid = {key: technologies_test[key] for key in ("trough", "pv", "grid")} hybrid_config["technologies"] = solar_hybrid - dispatch_options={'is_test_start_year': True, 'is_test_end_year': True} + dispatch_options = {"is_test_start_year": True, "is_test_end_year": True} hybrid_config["config"]["dispatch_options"] = dispatch_options hi = HoppInterface(hybrid_config) hybrid_plant = hi.system @@ -824,37 +993,33 @@ def test_trough_pv_hybrid(hybrid_config): assert aeps.hybrid == approx(106111732.52, 1e-3) assert npvs.pv == approx(80738107, 1e3) - #assert npvs.tower == approx(-13909363, 1e3) - #assert npvs.hybrid == approx(-19216589, 1e3) + # assert npvs.tower == approx(-13909363, 1e3) + # assert npvs.hybrid == approx(-19216589, 1e3) def test_tower_pv_battery_hybrid(hybrid_config): interconnection_size_kw_test = 50000 technologies_test = { - 'tower': { - 'cycle_capacity_kw': 50 * 1000, - 'solar_multiple': 2.0, - 'tes_hours': 12.0 - }, - 'pv': {'system_capacity_kw': 50 * 1000}, - 'battery': { - 'system_capacity_kwh': 40 * 1000, - 'system_capacity_kw': 20 * 1000 + "tower": { + "cycle_capacity_kw": 50 * 1000, + "solar_multiple": 2.0, + "tes_hours": 12.0, }, - 'grid': { - 'interconnect_kw': interconnection_size_kw_test, - 'ppa_price': 0.12 - } + "pv": {"system_capacity_kw": 50 * 1000}, + "battery": {"system_capacity_kwh": 40 * 1000, "system_capacity_kw": 20 * 1000}, + "grid": {"interconnect_kw": interconnection_size_kw_test, "ppa_price": 0.12}, } - solar_hybrid = {key: technologies_test[key] for key in ('tower', 'pv', 'battery', 'grid')} - dispatch_options={'is_test_start_year': True, 'is_test_end_year': True} + solar_hybrid = { + key: technologies_test[key] for key in ("tower", "pv", "battery", "grid") + } + dispatch_options = {"is_test_start_year": True, "is_test_end_year": True} hybrid_config["technologies"] = solar_hybrid hybrid_config["config"]["dispatch_options"] = dispatch_options hi = HoppInterface(hybrid_config) hybrid_plant = hi.system - hybrid_plant.tower.value('helio_width', 10.0) - hybrid_plant.tower.value('helio_height', 10.0) + hybrid_plant.tower.value("helio_width", 10.0) + hybrid_plant.tower.value("helio_height", 10.0) hi.simulate() @@ -867,29 +1032,35 @@ def test_tower_pv_battery_hybrid(hybrid_config): assert aeps.hybrid == approx(107903653, 1e-2) assert npvs.pv == approx(80738107, 1e3) - #assert npvs.tower == approx(-13909363, 1e3) - #assert npvs.hybrid == approx(-19216589, 1e3) + # assert npvs.tower == approx(-13909363, 1e3) + # assert npvs.hybrid == approx(-19216589, 1e3) + def test_hybrid_om_costs_error(hybrid_config): technologies = hybrid_config["technologies"] - wind_pv_battery = {key: technologies[key] for key in ('pv', 'wind', 'battery', 'grid')} - dispatch_options={'battery_dispatch': 'one_cycle_heuristic'} + wind_pv_battery = { + key: technologies[key] for key in ("pv", "wind", "battery", "grid") + } + dispatch_options = {"battery_dispatch": "one_cycle_heuristic"} hybrid_config["technologies"] = wind_pv_battery hybrid_config["technologies"]["grid"]["ppa_price"] = 0.03 hybrid_config["config"]["dispatch_options"] = dispatch_options hi = HoppInterface(hybrid_config) hybrid_plant = hi.system - hybrid_plant.battery._financial_model.value('om_production', (1,)) + hybrid_plant.battery._financial_model.value("om_production", (1,)) try: hi.simulate() except ValueError as e: assert e + def test_hybrid_om_costs(hybrid_config): technologies = hybrid_config["technologies"] - wind_pv_battery = {key: technologies[key] for key in ('pv', 'wind', 'battery', 'grid')} - dispatch_options={'battery_dispatch': 'one_cycle_heuristic'} + wind_pv_battery = { + key: technologies[key] for key in ("pv", "wind", "battery", "grid") + } + dispatch_options = {"battery_dispatch": "one_cycle_heuristic"} hybrid_config["technologies"] = wind_pv_battery hybrid_config["technologies"]["grid"]["ppa_price"] = 0.03 hybrid_config["config"]["dispatch_options"] = dispatch_options @@ -917,7 +1088,9 @@ def test_hybrid_om_costs(hybrid_config): var_om_costs = hybrid_plant.om_variable_expenses total_om_costs = hybrid_plant.om_total_expenses for i in range(len(var_om_costs.hybrid)): - assert var_om_costs.pv[i] + var_om_costs.wind[i] + var_om_costs.battery[i] == approx(var_om_costs.hybrid[i], rel=1e-1) + assert var_om_costs.pv[i] + var_om_costs.wind[i] + var_om_costs.battery[ + i + ] == approx(var_om_costs.hybrid[i], rel=1e-1) assert total_om_costs.pv[i] == approx(var_om_costs.pv[i]) assert total_om_costs.wind[i] == approx(var_om_costs.wind[i]) assert total_om_costs.battery[i] == approx(var_om_costs.battery[i]) @@ -934,8 +1107,9 @@ def test_hybrid_om_costs(hybrid_config): fixed_om_costs = hybrid_plant.om_fixed_expenses total_om_costs = hybrid_plant.om_total_expenses for i in range(len(fixed_om_costs.hybrid)): - assert fixed_om_costs.pv[i] + fixed_om_costs.wind[i] + fixed_om_costs.battery[i] \ - == approx(fixed_om_costs.hybrid[i]) + assert fixed_om_costs.pv[i] + fixed_om_costs.wind[i] + fixed_om_costs.battery[ + i + ] == approx(fixed_om_costs.hybrid[i]) assert total_om_costs.pv[i] == approx(fixed_om_costs.pv[i]) assert total_om_costs.wind[i] == approx(fixed_om_costs.wind[i]) assert total_om_costs.battery[i] == approx(fixed_om_costs.battery[i]) @@ -952,8 +1126,9 @@ def test_hybrid_om_costs(hybrid_config): cap_om_costs = hybrid_plant.om_capacity_expenses total_om_costs = hybrid_plant.om_total_expenses for i in range(len(cap_om_costs.hybrid)): - assert cap_om_costs.pv[i] + cap_om_costs.wind[i] + cap_om_costs.battery[i] \ - == approx(cap_om_costs.hybrid[i]) + assert cap_om_costs.pv[i] + cap_om_costs.wind[i] + cap_om_costs.battery[ + i + ] == approx(cap_om_costs.hybrid[i]) assert total_om_costs.pv[i] == approx(cap_om_costs.pv[i]) assert total_om_costs.wind[i] == approx(cap_om_costs.wind[i]) assert total_om_costs.battery[i] == approx(cap_om_costs.battery[i]) @@ -962,54 +1137,76 @@ def test_hybrid_om_costs(hybrid_config): hybrid_plant.pv.om_capacity = 0 hybrid_plant.battery.om_capacity = 0 + def test_hybrid_tax_incentives(hybrid_config): technologies = hybrid_config["technologies"] - wind_pv_battery = {key: technologies[key] for key in ('pv', 'wind', 'battery', 'grid')} - dispatch_options={'battery_dispatch': 'one_cycle_heuristic'} + wind_pv_battery = { + key: technologies[key] for key in ("pv", "wind", "battery", "grid") + } + dispatch_options = {"battery_dispatch": "one_cycle_heuristic"} hybrid_config["technologies"] = wind_pv_battery hybrid_config["technologies"]["grid"]["ppa_price"] = 0.03 hybrid_config["config"]["dispatch_options"] = dispatch_options hi = HoppInterface(hybrid_config) hybrid_plant = hi.system - hybrid_plant.pv._financial_model.value('itc_fed_percent', [0.0]) - hybrid_plant.wind._financial_model.value('ptc_fed_amount', (1,)) - hybrid_plant.pv._financial_model.value('ptc_fed_amount', (2,)) - hybrid_plant.battery._financial_model.value('ptc_fed_amount', (3,)) - hybrid_plant.wind._financial_model.value('ptc_fed_escal', 0) - hybrid_plant.pv._financial_model.value('ptc_fed_escal', 0) - hybrid_plant.battery._financial_model.value('ptc_fed_escal', 0) + hybrid_plant.pv._financial_model.value("itc_fed_percent", [0.0]) + hybrid_plant.wind._financial_model.value("ptc_fed_amount", (1,)) + hybrid_plant.pv._financial_model.value("ptc_fed_amount", (2,)) + hybrid_plant.battery._financial_model.value("ptc_fed_amount", (3,)) + hybrid_plant.wind._financial_model.value("ptc_fed_escal", 0) + hybrid_plant.pv._financial_model.value("ptc_fed_escal", 0) + hybrid_plant.battery._financial_model.value("ptc_fed_escal", 0) hi.simulate() ptc_wind = hybrid_plant.wind._financial_model.value("cf_ptc_fed")[1] - assert ptc_wind == approx(hybrid_plant.wind._financial_model.value("ptc_fed_amount")[0]*hybrid_plant.wind.annual_energy_kwh, rel=1e-3) + assert ptc_wind == approx( + hybrid_plant.wind._financial_model.value("ptc_fed_amount")[0] + * hybrid_plant.wind.annual_energy_kwh, + rel=1e-3, + ) ptc_pv = hybrid_plant.pv._financial_model.value("cf_ptc_fed")[1] - assert ptc_pv == approx(hybrid_plant.pv._financial_model.value("ptc_fed_amount")[0]*hybrid_plant.pv.annual_energy_kwh, rel=1e-3) + assert ptc_pv == approx( + hybrid_plant.pv._financial_model.value("ptc_fed_amount")[0] + * hybrid_plant.pv.annual_energy_kwh, + rel=1e-3, + ) ptc_batt = hybrid_plant.battery._financial_model.value("cf_ptc_fed")[1] - assert ptc_batt == approx(hybrid_plant.battery._financial_model.value("ptc_fed_amount")[0] - * hybrid_plant.battery._financial_model.value('batt_annual_discharge_energy')[1], rel=1e-3) + assert ptc_batt == approx( + hybrid_plant.battery._financial_model.value("ptc_fed_amount")[0] + * hybrid_plant.battery._financial_model.value("batt_annual_discharge_energy")[ + 1 + ], + rel=1e-3, + ) ptc_hybrid = hybrid_plant.grid._financial_model.value("cf_ptc_fed")[1] ptc_fed_amount = hybrid_plant.grid._financial_model.value("ptc_fed_amount")[0] assert ptc_fed_amount == approx(1.229, rel=1e-2) - assert ptc_hybrid == approx(ptc_fed_amount * hybrid_plant.grid._financial_model.value('cf_energy_net')[1], rel=1e-3) + assert ptc_hybrid == approx( + ptc_fed_amount * hybrid_plant.grid._financial_model.value("cf_energy_net")[1], + rel=1e-3, + ) def test_capacity_credit(hybrid_config): technologies = hybrid_config["technologies"] site = create_default_site_info(capacity_hours=capacity_credit_hours) - wind_pv_battery = {key: technologies[key] for key in ('pv', 'wind', 'battery')} - wind_pv_battery['grid'] = { - 'interconnect_kw': interconnection_size_kw, - 'ppa_price': 0.03 + wind_pv_battery = {key: technologies[key] for key in ("pv", "wind", "battery")} + wind_pv_battery["grid"] = { + "interconnect_kw": interconnection_size_kw, + "ppa_price": 0.03, } hybrid_config["technologies"] = wind_pv_battery hybrid_config["site"] = site hi = HoppInterface(hybrid_config) hybrid_plant = hi.system + hybrid_plant.battery._financial_model.SystemCosts.assign( + {"om_batt_variable_cost": [0.75]} + ) assert hybrid_plant.interconnect_kw == 15e3 @@ -1017,6 +1214,7 @@ def test_capacity_credit(hybrid_config): gen_max_feasible_orig = hybrid_plant.battery.gen_max_feasible capacity_hours_orig = hybrid_plant.site.capacity_hours interconnect_kw_orig = hybrid_plant.interconnect_kw + def reinstate_orig_values(): hybrid_plant.battery.gen_max_feasible = gen_max_feasible_orig hybrid_plant.site.capacity_hours = capacity_hours_orig @@ -1025,24 +1223,32 @@ def reinstate_orig_values(): # Test when 0 gen_max_feasible reinstate_orig_values() hybrid_plant.battery.gen_max_feasible = [0] * 8760 - capacity_credit_battery = hybrid_plant.battery.calc_capacity_credit_percent(hybrid_plant.interconnect_kw) + capacity_credit_battery = hybrid_plant.battery.calc_capacity_credit_percent( + hybrid_plant.interconnect_kw + ) assert capacity_credit_battery == approx(0, rel=0.05) # Test when representative gen_max_feasible reinstate_orig_values() hybrid_plant.battery.gen_max_feasible = [2500] * 8760 - capacity_credit_battery = hybrid_plant.battery.calc_capacity_credit_percent(hybrid_plant.interconnect_kw) + capacity_credit_battery = hybrid_plant.battery.calc_capacity_credit_percent( + hybrid_plant.interconnect_kw + ) assert capacity_credit_battery == approx(50, rel=0.05) # Test when no capacity hours reinstate_orig_values() hybrid_plant.battery.gen_max_feasible = [2500] * 8760 hybrid_plant.site.capacity_hours = [False] * 8760 - capacity_credit_battery = hybrid_plant.battery.calc_capacity_credit_percent(hybrid_plant.interconnect_kw) + capacity_credit_battery = hybrid_plant.battery.calc_capacity_credit_percent( + hybrid_plant.interconnect_kw + ) assert capacity_credit_battery == approx(0, rel=0.05) # Test when no interconnect capacity reinstate_orig_values() hybrid_plant.battery.gen_max_feasible = [2500] * 8760 hybrid_plant.interconnect_kw = 0 - capacity_credit_battery = hybrid_plant.battery.calc_capacity_credit_percent(hybrid_plant.interconnect_kw) + capacity_credit_battery = hybrid_plant.battery.calc_capacity_credit_percent( + hybrid_plant.interconnect_kw + ) assert capacity_credit_battery == approx(0, rel=0.05) # Test integration with system simulation @@ -1054,30 +1260,53 @@ def reinstate_orig_values(): hi.simulate() - total_gen_max_feasible = np.array(hybrid_plant.pv.gen_max_feasible) \ - + np.array(hybrid_plant.wind.gen_max_feasible) \ - + np.array(hybrid_plant.battery.gen_max_feasible) - assert sum(hybrid_plant.grid.gen_max_feasible) == approx(sum(np.minimum(hybrid_plant.grid.interconnect_kw * hybrid_plant.site.interval / 60, \ - total_gen_max_feasible)), rel=0.01) + total_gen_max_feasible = ( + np.array(hybrid_plant.pv.gen_max_feasible) + + np.array(hybrid_plant.wind.gen_max_feasible) + + np.array(hybrid_plant.battery.gen_max_feasible) + ) + assert sum(hybrid_plant.grid.gen_max_feasible) == approx( + sum( + np.minimum( + hybrid_plant.grid.interconnect_kw * hybrid_plant.site.interval / 60, + total_gen_max_feasible, + ) + ), + rel=0.01, + ) - total_nominal_capacity = hybrid_plant.pv.calc_nominal_capacity(hybrid_plant.interconnect_kw) \ - + hybrid_plant.wind.calc_nominal_capacity(hybrid_plant.interconnect_kw) \ - + hybrid_plant.battery.calc_nominal_capacity(hybrid_plant.interconnect_kw) + total_nominal_capacity = ( + hybrid_plant.pv.calc_nominal_capacity(hybrid_plant.interconnect_kw) + + hybrid_plant.wind.calc_nominal_capacity(hybrid_plant.interconnect_kw) + + hybrid_plant.battery.calc_nominal_capacity(hybrid_plant.interconnect_kw) + ) assert total_nominal_capacity == approx(18845.8, rel=0.01) - assert total_nominal_capacity == approx(hybrid_plant.grid.hybrid_nominal_capacity, rel=0.01) - + assert total_nominal_capacity == approx( + hybrid_plant.grid.hybrid_nominal_capacity, rel=0.01 + ) + capcred = hybrid_plant.capacity_credit_percent - assert capcred['pv'][0] == approx(8.03, rel=0.05) - assert capcred['wind'][0] == approx(33.25, rel=0.10) - assert capcred['battery'][0] == approx(58.95, rel=0.05) - assert capcred['hybrid'][0] == approx(43.88, rel=0.05) + assert capcred["pv"][0] == approx(8.03, rel=0.05) + assert capcred["wind"][0] == approx(33.25, rel=0.10) + assert capcred["battery"][0] == approx(58.95, rel=0.05) + assert capcred["hybrid"][0] == approx(43.88, rel=0.05) cp_pay = hybrid_plant.capacity_payments - np_cap = hybrid_plant.system_nameplate_mw # This is not the same as nominal capacity... - assert cp_pay['pv'][1]/(np_cap['pv'])/(capcred['pv'][0]/100) == approx(cap_payment_mw, 0.05) - assert cp_pay['wind'][1]/(np_cap['wind'])/(capcred['wind'][0]/100) == approx(cap_payment_mw, 0.05) - assert cp_pay['battery'][1]/(np_cap['battery'])/(capcred['battery'][0]/100) == approx(cap_payment_mw, 0.05) - assert cp_pay['hybrid'][1]/(np_cap['hybrid'])/(capcred['hybrid'][0]/100) == approx(cap_payment_mw, 0.05) + np_cap = ( + hybrid_plant.system_nameplate_mw + ) # This is not the same as nominal capacity... + assert cp_pay["pv"][1] / (np_cap["pv"]) / (capcred["pv"][0] / 100) == approx( + cap_payment_mw, 0.05 + ) + assert cp_pay["wind"][1] / (np_cap["wind"]) / (capcred["wind"][0] / 100) == approx( + cap_payment_mw, 0.05 + ) + assert cp_pay["battery"][1] / (np_cap["battery"]) / ( + capcred["battery"][0] / 100 + ) == approx(cap_payment_mw, 0.05) + assert cp_pay["hybrid"][1] / (np_cap["hybrid"]) / ( + capcred["hybrid"][0] / 100 + ) == approx(cap_payment_mw, 0.05) aeps = hybrid_plant.annual_energies npvs = hybrid_plant.net_present_values