diff --git a/docs/controllers.md b/docs/controllers.md index 6ef095b4..fa9e6eda 100644 --- a/docs/controllers.md +++ b/docs/controllers.md @@ -10,19 +10,33 @@ signals and return a second dictionary (nominally called `controls_dict`) that returns the control actions. In the basic set up, `measurement_dict` is provided to `compute_controls()` by the `step()` method defined on `ControllerBase`, and the returned `controls_dict` is then passed via the -interface at the conclusion of the `step()` method. +interface at the conclusion of the `step()` method. In addition, controllers must +also implement a method `set_controller_parameters()` that accepts a dictionary of controller parameters. If no parameters are needed, this may simple be an empty +method (but it is still required). + +## Controller structure and inputs/outputs +Each controller is structured as a class that inherits from `ControllerBase`. The +constructor for each controller must accept the following arguments: +- `interface`: the interface object that the controller will use to read in measurements and pass out control actions. See [Interfaces](interfaces) for more details on the interface. +- `cname`: the name of the controller, which is used to read in the relevant controller parameters from the plant parameters dictionary as well as access appropriate portions of the `measurement_dict`. This is a string that should match a key in the plant parameters dictionary (often, the name of the Hercules hybrid plant component). +- `controller_parameters`: a dictionary of controller parameters. The keys in this dictionary should match the keys expected by the `set_controller_parameters()` method for the controller. This dictionary is passed to the `set_controller_parameters()` method on instantiation. Details on the expected parameters for each controller are provided in the documentation for each controller below. +- `verbose`: a boolean that sets whether the controller should print out information about its operation. Defaults to `False`. Verbosity is not yet fully built out in Hycon. ## Available controllers (controllers_luwakesteer)= ### LookupBasedWakeSteeringController -Yaw controller that implements wake steering based on a lookup table. -Requires a `df_opt` object produced by a FLORIS yaw optimization routine. See example +Yaw controller that implements wake steering based on a lookup table. +`controller_parameters` may include keys: +- `df_yaw`: dataframe as produced by a FLORIS yaw optimization routine that contains the lookup table for wake steering. The keys of this dictionary are tuples of the form `(wind_direction, wind_speed)`, and the values are lists of yaw angles for each turbine in the farm. The lookup table is sampled at 10 degree increments of wind direction and 1 m/s increments of wind speed, but this may be updated in the future to allow for more flexible sampling. See example lookup-based_wake_steering_florisstandin for example usage. +- `hysteresis_dict`: dictionary of hysteresis zones for wake steering. +- `yaw_IC`: initial yaw angles for the turbines. Currently, yaw angles are set based purely on the (local turbine) wind direction. The lookup table is sampled at a hardcoded wind speed of 8 m/s. This will be updated in future when an interface is developed for a simulator that provides wind turbine wind speeds also. +See [Wake Steering Design](wake_steering_design) for more details on how to produce the lookup table and hysteresis zones. ### WakeSteeringROSCOStandin Not yet developed. May be combined into a universal simple LookupBasedWakeSteeringController. @@ -35,7 +49,9 @@ reference between wind turbines evenly, without checking whether turbines are able to produce power at the requested level. Not expected to perform well when wind turbines are waked or cannot produce the desired power for other reasons. However, is a useful comparison case for the WindFarmPowerTrackingController -(described below). +(described below). +`controller_parameters` may include keys: +- `ramp_rate_limit`: a limit on the ramp rate for the entire plant, in units of kW/s. (controllers_wfpowertracking)= ### WindFarmPowerTrackingController @@ -48,23 +64,21 @@ Further details provided in Integral action, as well as gain scheduling based on turbine saturation, has been disabled as simple proportional control appears sufficient currently. However, these may be enabled at a -later date if needed. The `proportional_gain` for the controller may be provided on instantiation, -and defaults to `proportional_gain = 1`. +later date if needed. -(controllers_simplehybrid)= -### HybridSupervisoryControllerBaseline +`controller_parameters` may include keys: +- `proportional_gain`: the proportional gain for the controller. +- `ramp_rate_limit`: a limit on the ramp rate for the entire plant, in units of kW/s. -Simple closed-loop supervisory controller for a hybrid wind/solar/battery plant. -Reads in current power production from wind, solar, and battery, as well as a plant power reference. Contains logic to determine technology set points for wind, solar and battery technologies to follow the plant power reference. The control is based on a proportional gain based on the error between the wind and solar production and the plant power reference. The controller increases the power references sent to wind, solar, and battery if the power reference is not met. If there is a power surplus from wind and solar, the controller adjusts the power reference values to charge the battery up to the battery capacity. +(controllers_generichybrid)= +### HybridSupervisoryControllerGeneric -The power reference values for wind, solar and battery technologies are then handled by the operational controllers for wind, solar, and battery, which are assigned to the `HybridSupervisoryControllerBaseline` on instantiation to distribute the bulk references to each asset amongst the individual generators. Currently, only wind actually distributes the power. -Intended as a baseline for comparison to more advanced supervisory controllers. +Closed-loop supervisory controller for a hybrid plants. +Reads in current power production from various components, as well as a possible plant power reference, and manages individual component controllers. Depending on the mode of operation of component controllers, enables plant-wide power tracking or independent control up to the interconnection limit. When power tracking, simply passes the plant-wide power reference to the component controllers in the reverse curtailment order until the reference is met. -This controller can also be run for a hybrid plant comprising wind or solar -and/or a battery. At least one of the wind or solar components must be present, -with the battery component optional. Upon instantiation, the user may set -`wind_controller`, `solar_controller`, and/or `battery_controller` to `None` if -no wind, solar, and/or battery component is available, respectively. +`controller_parameters` may include keys: +- `component_controllers`: list of (Hycon) controllers for the various components in the hybrid plant. +- `curtailment_order`: list of integers referencing the `component_controllers` list. If `curtailment_order` is not provided, the default is to curtail components in the reverse order they are provided in the `component_controllers` list (that is, the final component in the list is curtailed first, and the first component in the list is curtailed last). (controllers_battery)= ### BatteryController @@ -94,6 +108,10 @@ The default is to apply the full reference across the full range of SOCs, i.e. graphics/clipping-schedules.png ) +`controller_parameters` may include keys: +- `k_batt`: the controller gain for the battery controller. +- `clipping_thresholds`: a list of four fractional SOC thresholds for clipping the battery reference as described above. + (controllers_hydrogen)= ### HydrogenPlantController Simple closed-loop controller for an off-grid power generation/hydrogen plant. The controller uses an external hydrogen reference signal to control the hydrogen production of the plant through setting the power reference signal. @@ -101,7 +119,14 @@ Simple closed-loop controller for an off-grid power generation/hydrogen plant. T Reads in current power production from the generator(s), the current hydrogen production rate, and the hydrogen rate reference. Contains logic to set the generator power reference using a proportional gain applied to the error between the current hydrogen production rate and the hydrogen production reference. The proportional gain is scaled by the current power production to handle the difference of several magnitudes between the power and the hydrogen production rate. The power reference computed is then passed to a secondary power generation plant controller, which is assigned to the `HydrogenPlantController` on instantiation. -This secondary power generation controller could be {ref}`controllers_wfpowertracking` for a wind-only plant, {ref}`controllers_simplehybrid` for a hybrid generation plant, etc. +This secondary power generation controller could be {ref}`controllers_wfpowertracking` for a wind-only plant, {ref}`controllers_generichybrid` for a hybrid generation plant, etc. + +`controller_parameters` may include keys: +- `nominal_plant_power_kW`: the nominal power of the electrolysis plant, used to scale the proportional gain for computing the power reference. +- `nominal_hydrogen_rate_kgps`: the nominal hydrogen production rate of the plant, used to scale the proportional gain for computing the power reference (units kg/s). +- `generator_controller`: a Hycon controller for the power generation component(s) of the plant, which is assigned to the `HydrogenPlantController` on instantiation and to which the computed power reference is passed. +- `hydrogen_controller_gain`: the proportional gain for computing the power reference from the hydrogen production error. + (controllers_batterymarket)= ### BatteryPriceSOCController @@ -114,3 +139,7 @@ prices from the day-ahead market, the battery is instructed to charge (if possible). Otherwise, the battery remains idle. When the battery is close to fully depleted or fully charge, the threshold for charging/discharging changes to the lowest and highest day-ahead price, respectively. + +`controller_parameters` may include keys: +- `high_soc`: the SOC above which the battery will only charge if the real-time price is above the 1 highest day-ahead price. +- `low_soc`: the SOC below which the battery will only discharge if the real-time price is below the 1 lowest day-ahead price. diff --git a/docs/examples.md b/docs/examples.md index 6ecf5ac1..2678f632 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -79,10 +79,10 @@ Ramp rate limits are also applied in this example, but can be modified by changi (examples_simplehybrid)= ## simple_hybrid_plant Example of a wind + solar + battery hybrid power plant using the -{ref}`controllers_simplehybrid` to +{ref}`controllers_generichybrid` to track a steady power reference. The plant comprises 10 NREL 5MW reference wind turbines (50 MW total wind capacity); a 100MW solar PV array; and a 4-hour, 20MW battery (80MWh energy -storage capacity). +storage capacity) that can only charge from the local wind and solar generation. To run this example, navigate to the examples/simple_hybrid_plant folder and execute the python script runscript.py. @@ -162,6 +162,6 @@ Running the simulation produces the following plot: ) as well printing ``` -Real-time revenue over simulation: $6636.5 +Real-time revenue over simulation: $6775.44 ``` to the console. \ No newline at end of file diff --git a/docs/graphics/battery-market.png b/docs/graphics/battery-market.png index bbe87cbf..a08c9434 100644 Binary files a/docs/graphics/battery-market.png and b/docs/graphics/battery-market.png differ diff --git a/docs/graphics/flexible-interconnect.png b/docs/graphics/flexible-interconnect.png index afa18168..46a2f24c 100644 Binary files a/docs/graphics/flexible-interconnect.png and b/docs/graphics/flexible-interconnect.png differ diff --git a/docs/graphics/simple-hybrid-example-plot.png b/docs/graphics/simple-hybrid-example-plot.png index 1d96ebf6..22c797e2 100644 Binary files a/docs/graphics/simple-hybrid-example-plot.png and b/docs/graphics/simple-hybrid-example-plot.png differ diff --git a/docs/interfaces.md b/docs/interfaces.md index 0672a8b2..f06cf6d0 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -21,21 +21,31 @@ These methods will all be called in the `step()` method of `ControllerBase`. ## Available interfaces +### HerculesInterface + +For direct python communication with the latest version of Hercules. This should be instantiated +in a runscript that is running Hercules; used to generate a `controller` from the Hycon controllers submodule; and that `controller` should be passed to the +`HerculesModel` after instantiation via `HerculesModel.assign_controller()`. The main purpose for this interface is for sending power reference signals to various types of hybrid plant components modeled in Hercules (and receiving state measurements from the components as well as external signals like electricity price). + +## Interfaces in development + +### ROSCO_ZMQInterface +For sending and receiving communications from one or more ROSCO instances +(which are likely connected to OpenFAST and FAST.Farm). Uses ZeroMQ to pass +messages between workers. + +## Deprecated interfaces + ### HerculesADInterface -For direct python communication with Hercules. This should be instantiated +For direct python communication with Hercules v1. This should be instantiated in a runscript that is running Hercules; used to generate a `controller` from the Hycon controllers submodule; and that `controller` should be passed to the Hercules `Emulator` upon its instantiation. Support transmitting yaw angles and power setpoints to wind turbines. ### HerculesHybridADInterface -For direct python communication with Hercules, when simulating a hybrid +For direct python communication with Hercules v1, when simulating a hybrid wind/solar/battery plant. Also handles Hercules' hydrogen modules. Supports sending power reference signals to each wind turbine in a wind farm, as well as a bulk power signal to the solar farm and a bulk power signal to the battery. - -### ROSCO_ZMQInterface -For sending and receiving communications from one or more ROSCO instances -(which are likely connected to OpenFAST and FAST.Farm). Uses ZeroMQ to pass -messages between workers. diff --git a/examples/battery_control_comparison/hercules_input.yaml b/examples/battery_control_comparison/hercules_input.yaml index 1b258b11..9f0733e0 100644 --- a/examples/battery_control_comparison/hercules_input.yaml +++ b/examples/battery_control_comparison/hercules_input.yaml @@ -35,4 +35,4 @@ battery: external_data: external_data_file: power_reference.csv log_channels: - - battery_power_reference \ No newline at end of file + - plant_power_reference \ No newline at end of file diff --git a/examples/battery_control_comparison/runscript.py b/examples/battery_control_comparison/runscript.py index e98d4a16..d207bc56 100644 --- a/examples/battery_control_comparison/runscript.py +++ b/examples/battery_control_comparison/runscript.py @@ -1,3 +1,5 @@ +import argparse + import matplotlib.pyplot as plt import numpy as np import pandas as pd @@ -5,12 +7,20 @@ from hercules.hercules_model import HerculesModel from hercules.utilities import load_hercules_input from hercules.utilities_examples import prepare_output_directory -from hycon.controllers import BatteryController, HybridSupervisoryControllerMultiRef +from hycon.controllers import BatteryController, HybridSupervisoryControllerGeneric from hycon.interfaces import HerculesInterface prepare_output_directory() -save_figs = False +parser = argparse.ArgumentParser(description="Plot outputs of battery market example") + +parser.add_argument( + "--save_plots", type=bool, default=False, help="Whether to save the generated plots" +) + +args = parser.parse_args() + +save_figs = args.save_plots # Generate the reference signal to track. We will simplify things by using an # existing input file. @@ -18,7 +28,7 @@ df = df.rename(columns={"interval_start_utc": "time_utc"}).drop(columns=["market", "lmp"]) # Create reference that steps up and down each five minutes reference_input_sequence = np.tile(np.array([20000, 0]), int(len(df) / 2)) -df["battery_power_reference"] = reference_input_sequence +df["plant_power_reference"] = reference_input_sequence # Add end of step info df["time_utc"] = pd.to_datetime(df["time_utc"]) df_2 = df.copy(deep=True) @@ -38,11 +48,11 @@ def simulate(soc_0, clipping_thresholds, gain): interface = HerculesInterface(hmodel.h_dict) battery_controller = BatteryController( interface=interface, - input_dict=hmodel.h_dict, + cname="battery", controller_parameters={"k_batt": gain, "clipping_thresholds": clipping_thresholds}, ) - controller = HybridSupervisoryControllerMultiRef( - battery_controller=battery_controller, interface=interface, input_dict=hmodel.h_dict + controller = HybridSupervisoryControllerGeneric( + interface=interface, controller_parameters={"component_controllers": [battery_controller]} ) hmodel.assign_controller(controller) @@ -55,7 +65,7 @@ def simulate(soc_0, clipping_thresholds, gain): power_sequence = df_out["battery.power"].to_numpy() soc_sequence = df_out["battery.soc"].to_numpy() time = df_out["time"].to_numpy() - reference_sequence = df_out["external_signals.battery_power_reference"].to_numpy() + reference_sequence = df_out["external_signals.plant_power_reference"].to_numpy() return time, power_sequence, soc_sequence, reference_sequence diff --git a/examples/battery_market_revenue_control/plot_outputs.py b/examples/battery_market_revenue_control/plot_outputs.py index baf2da5b..66b8641e 100644 --- a/examples/battery_market_revenue_control/plot_outputs.py +++ b/examples/battery_market_revenue_control/plot_outputs.py @@ -1,4 +1,5 @@ # Plot the outputs of the simulation for the wind and storage example +import argparse import matplotlib.pyplot as plt import numpy as np @@ -138,12 +139,23 @@ def plot_outputs(): # Compute total revenue on real-time market df["revenue_rt"] = df["battery.power"] / 1e3 * df["external_signals.lmp_rt"] / 3600 - print("Real-time revenue over simulation: ${:.1f}".format(df["revenue_rt"].sum())) + print("Real-time revenue over simulation: ${:.2f}".format(df["revenue_rt"].sum())) return fig if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Plot outputs of battery market example") + + parser.add_argument( + "--save_plots", type=bool, default=False, help="Whether to save the generated plots" + ) + + args = parser.parse_args() + fig = plot_outputs() - # fig.savefig("../../docs/graphics/battery-market.png", dpi=300, format="png") + + if args.save_plots: + fig.savefig("../../docs/graphics/battery-market.png", dpi=300, format="png") + plt.show() diff --git a/examples/battery_market_revenue_control/runscript.py b/examples/battery_market_revenue_control/runscript.py index fd7d356e..45ef55c9 100644 --- a/examples/battery_market_revenue_control/runscript.py +++ b/examples/battery_market_revenue_control/runscript.py @@ -7,7 +7,7 @@ ) from hercules.hercules_model import HerculesModel from hercules.utilities_examples import prepare_output_directory -from hycon.controllers import BatteryPriceSOCController, HybridSupervisoryControllerMultiRef +from hycon.controllers import BatteryPriceSOCController, HybridSupervisoryControllerGeneric from hycon.interfaces import HerculesInterface from plot_outputs import plot_outputs @@ -26,10 +26,11 @@ # Establish the interface and controller, assign to the Hercules model interface = HerculesInterface(hmodel.h_dict) -controller = HybridSupervisoryControllerMultiRef( - battery_controller=BatteryPriceSOCController(interface=interface, input_dict=hmodel.h_dict), +controller = HybridSupervisoryControllerGeneric( interface=HerculesInterface(hmodel.h_dict), - input_dict=hmodel.h_dict, + controller_parameters={ + "component_controllers": [BatteryPriceSOCController(interface=interface, cname="battery")] + }, ) hmodel.assign_controller(controller) diff --git a/examples/lookup-based_wake_steering_florisstandin/plot_output_data.py b/examples/lookup-based_wake_steering_florisstandin/plot_output_data.py index e73ca3f8..9a21e7a2 100644 --- a/examples/lookup-based_wake_steering_florisstandin/plot_output_data.py +++ b/examples/lookup-based_wake_steering_florisstandin/plot_output_data.py @@ -61,6 +61,6 @@ # wind direction propagates instantaneously into the power signal (as steady-state FLORIS is used # in place of the dynamic AMR-wind simulation. -# Note that in the upper plot, T000 dir., T001 dir., and T001 yaw are identical througout. +# Note that in the upper plot, T000 dir., T001 dir., and T001 yaw are identical throughout. plt.show() diff --git a/examples/simple_hybrid_plant/hercules_input.yaml b/examples/simple_hybrid_plant/hercules_input.yaml index 56a07e0e..dd412d22 100644 --- a/examples/simple_hybrid_plant/hercules_input.yaml +++ b/examples/simple_hybrid_plant/hercules_input.yaml @@ -53,7 +53,7 @@ battery: discharge_rate: 20000 # discharge rate of the battery in kW max_SOC: 0.9 # upper boundary on battery SOC min_SOC: 0.1 # lower boundary on battery SOC - allow_grid_power_consumption: True + allow_grid_power_consumption: False initial_conditions: SOC: 0.88 # initial state of charge of the battery in percentage of total size log_channels: diff --git a/examples/simple_hybrid_plant/plot_outputs.py b/examples/simple_hybrid_plant/plot_outputs.py index 2670dbe5..3a4840f2 100644 --- a/examples/simple_hybrid_plant/plot_outputs.py +++ b/examples/simple_hybrid_plant/plot_outputs.py @@ -1,3 +1,5 @@ +import argparse + import matplotlib.pyplot as plt import numpy as np from hercules import HerculesOutput @@ -7,13 +9,10 @@ def plot_outputs(): # Read the Hercules output file using HerculesOutput ho = HerculesOutput("outputs/hercules_output.h5") - # Print metadata information - print("Simulation Metadata:") - ho.print_metadata() - print() - df = ho.df - print(df.columns) + print("Available columns in the output DataFrame:") + for c in df.columns.tolist(): + print(c) # Get high-level signals power_output = df["plant.power"] @@ -106,6 +105,17 @@ def plot_outputs(): if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Plot outputs of battery market example") + + parser.add_argument( + "--save_plots", type=bool, default=False, help="Whether to save the generated plots" + ) + + args = parser.parse_args() + fig = plot_outputs() - # fig.savefig("../../docs/graphics/simple-hybrid-example-plot.png", dpi=300, format="png") + + if args.save_plots: + fig.savefig("../../docs/graphics/simple-hybrid-example-plot.png", dpi=300, format="png") + plt.show() diff --git a/examples/simple_hybrid_plant/runscript.py b/examples/simple_hybrid_plant/runscript.py index 5d606ccc..427fd54e 100644 --- a/examples/simple_hybrid_plant/runscript.py +++ b/examples/simple_hybrid_plant/runscript.py @@ -3,8 +3,8 @@ from hercules.utilities import load_hercules_input from hercules.utilities_examples import prepare_output_directory from hycon.controllers import ( - BatteryPassthroughController, - HybridSupervisoryControllerBaseline, + BatteryController, + HybridSupervisoryControllerGeneric, SolarPassthroughController, WindFarmPowerTrackingController, ) @@ -33,17 +33,21 @@ # Establish controllers based on options interface = HerculesInterface(hmodel.h_dict) print("Setting up controller.") -wind_controller = WindFarmPowerTrackingController(interface, hmodel.h_dict) -solar_controller = SolarPassthroughController(interface, hmodel.h_dict) if include_solar else None +wind_controller = WindFarmPowerTrackingController(interface, "wind_farm") +solar_controller = SolarPassthroughController(interface, "solar_farm") if include_solar else None battery_controller = ( - BatteryPassthroughController(interface, hmodel.h_dict) if include_battery else None + BatteryController(interface, "battery", {"k_batt": 0.1}) if include_battery else None ) -controller = HybridSupervisoryControllerBaseline( +component_controllers = [wind_controller] +if include_solar: + component_controllers.append(solar_controller) +if include_battery: + component_controllers.append(battery_controller) + +# Set up main supervisory controller +controller = HybridSupervisoryControllerGeneric( interface, - hmodel.h_dict, - wind_controller=wind_controller, - solar_controller=solar_controller, - battery_controller=battery_controller, + controller_parameters={"component_controllers": component_controllers}, ) hmodel.assign_controller(controller) diff --git a/examples/single_turbine_flexible_interconnect/hercules_input.yaml b/examples/single_turbine_flexible_interconnect/hercules_input.yaml index 04142390..f32fc7f3 100644 --- a/examples/single_turbine_flexible_interconnect/hercules_input.yaml +++ b/examples/single_turbine_flexible_interconnect/hercules_input.yaml @@ -13,7 +13,7 @@ verbose: False plant: interconnect_limit: 15000 -wind_farm: +distributed_wind: component_type: WindFarm wake_method: precomputed floris_input_file: floris_input.yaml @@ -34,7 +34,7 @@ battery: discharge_rate: 500 # discharge rate of the battery in kW max_SOC: 0.9 # upper boundary on battery SOC min_SOC: 0.1 # lower boundary on battery SOC - allow_grid_power_consumption: True + allow_grid_power_consumption: False initial_conditions: SOC: 0.88 # initial state of charge of the battery in percentage of total size log_channels: diff --git a/examples/single_turbine_flexible_interconnect/plot_outputs.py b/examples/single_turbine_flexible_interconnect/plot_outputs.py index 7697b2a4..12024969 100644 --- a/examples/single_turbine_flexible_interconnect/plot_outputs.py +++ b/examples/single_turbine_flexible_interconnect/plot_outputs.py @@ -1,3 +1,5 @@ +import argparse + import matplotlib.pyplot as plt import numpy as np from hercules import HerculesOutput @@ -20,10 +22,10 @@ def plot_outputs(): print(df_batt["battery.power"].head()) - pow_col = "wind_farm.turbine_powers.000" + pow_col = "distributed_wind.turbine_powers.000" ref_col = "external_signals.plant_power_reference" batt_col = "battery.power" - ws_col = "wind_farm.wind_speeds_withwakes.000" + ws_col = "distributed_wind.wind_speeds_withwakes.000" # Create plots fig, ax = plt.subplots(3, 1, sharex=True) @@ -139,6 +141,17 @@ def plot_outputs(): if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Plot outputs of battery market example") + + parser.add_argument( + "--save_plots", type=bool, default=False, help="Whether to save the generated plots" + ) + + args = parser.parse_args() + fig = plot_outputs() - # fig.savefig("../../docs/graphics/flexible-interconnect.png", dpi=300, format="png") + + if args.save_plots: + fig.savefig("../../docs/graphics/flexible-interconnect.png", dpi=300, format="png") + plt.show() diff --git a/examples/single_turbine_flexible_interconnect/runscript.py b/examples/single_turbine_flexible_interconnect/runscript.py index d725978a..4a2db326 100644 --- a/examples/single_turbine_flexible_interconnect/runscript.py +++ b/examples/single_turbine_flexible_interconnect/runscript.py @@ -5,8 +5,7 @@ from hercules.utilities_examples import prepare_output_directory from hycon.controllers import ( BatteryController, - HybridSupervisoryControllerBaseline, - HybridSupervisoryControllerMultiRef, + HybridSupervisoryControllerGeneric, WindFarmPowerTrackingController, ) from hycon.interfaces import HerculesInterface @@ -63,10 +62,11 @@ hmodel = HerculesModel(h_dict) interface = HerculesInterface(hmodel.h_dict) -controller = HybridSupervisoryControllerMultiRef( - wind_controller=WindFarmPowerTrackingController(interface, hmodel.h_dict), +wind_controller = WindFarmPowerTrackingController(interface, "distributed_wind") +controller = HybridSupervisoryControllerGeneric( interface=interface, - input_dict=hmodel.h_dict, + cname="supervisory_controller", + controller_parameters={"component_controllers": [wind_controller]}, ) hmodel.assign_controller(controller) @@ -81,10 +81,10 @@ hmodel = HerculesModel(h_dict) interface = HerculesInterface(hmodel.h_dict) -controller = HybridSupervisoryControllerBaseline( - wind_controller=WindFarmPowerTrackingController(interface, hmodel.h_dict), +controller = HybridSupervisoryControllerGeneric( interface=interface, - input_dict=hmodel.h_dict, + cname="supervisory_controller", + controller_parameters={"component_controllers": [wind_controller]}, ) hmodel.assign_controller(controller) @@ -97,11 +97,15 @@ h_dict["output_file"] = "outputs/hercules_output_with_battery.h5" hmodel = HerculesModel(h_dict) interface = HerculesInterface(hmodel.h_dict) -controller = HybridSupervisoryControllerBaseline( - wind_controller=WindFarmPowerTrackingController(interface, hmodel.h_dict), - battery_controller=BatteryController(interface, hmodel.h_dict), +controller = HybridSupervisoryControllerGeneric( interface=interface, - input_dict=hmodel.h_dict, + cname="supervisory_controller", + controller_parameters={ + "component_controllers": [ + wind_controller, + BatteryController(interface, "battery", {"k_batt": 0.1}), + ] + }, ) hmodel.assign_controller(controller) diff --git a/examples/wind_farm_power_tracking/plot_outputs.py b/examples/wind_farm_power_tracking/plot_outputs.py index 8b5314be..c3dd9e4e 100644 --- a/examples/wind_farm_power_tracking/plot_outputs.py +++ b/examples/wind_farm_power_tracking/plot_outputs.py @@ -1,3 +1,5 @@ +import argparse + import matplotlib.pyplot as plt from hercules import HerculesOutput @@ -12,7 +14,7 @@ def plot_outputs(): n_turbines = 2 pow_cols = ["wind_farm.turbine_powers.{0:03d}".format(t) for t in range(n_turbines)] - ref_col = "external_signals.wind_power_reference" + ref_col = "external_signals.plant_power_reference" mod_ref_cols = [ "wind_farm.turbine_power_setpoints.{0:03d}".format(t) for t in range(n_turbines) ] @@ -66,6 +68,17 @@ def plot_outputs(): # both controllers meet the setpoint. if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Plot outputs of battery market example") + + parser.add_argument( + "--save_plots", type=bool, default=False, help="Whether to save the generated plots" + ) + + args = parser.parse_args() + fig = plot_outputs() - # fig.savefig("../../docs/graphics/wf-power-tracking-plot.png", dpi=300, format="png") + + if args.save_plots: + fig.savefig("../../docs/graphics/wf-power-tracking-plot.png", dpi=300, format="png") + plt.show() diff --git a/examples/wind_farm_power_tracking/runscript.py b/examples/wind_farm_power_tracking/runscript.py index 1e21a0ea..f428cba1 100644 --- a/examples/wind_farm_power_tracking/runscript.py +++ b/examples/wind_farm_power_tracking/runscript.py @@ -3,7 +3,7 @@ from hercules.utilities import load_hercules_input from hercules.utilities_examples import prepare_output_directory from hycon.controllers import ( - HybridSupervisoryControllerMultiRef, + HybridSupervisoryControllerGeneric, WindFarmPowerDistributingController, WindFarmPowerTrackingController, ) @@ -24,12 +24,12 @@ interface = HerculesInterface(hmodel.h_dict) print("Running open-loop controller...") -controller = HybridSupervisoryControllerMultiRef( - wind_controller=WindFarmPowerDistributingController( - interface, hmodel.h_dict, ramp_rate_limit=ramp_rate_limit - ), +wind_controller = WindFarmPowerDistributingController( + interface, "wind_farm", controller_parameters={"ramp_rate_limit": ramp_rate_limit} +) +controller = HybridSupervisoryControllerGeneric( interface=interface, - input_dict=hmodel.h_dict, + controller_parameters={"component_controllers": [wind_controller]}, ) hmodel.assign_controller(controller) @@ -45,12 +45,12 @@ interface = HerculesInterface(hmodel.h_dict) print("Running closed-loop controller...") -controller = HybridSupervisoryControllerMultiRef( - wind_controller=WindFarmPowerTrackingController( - interface, hmodel.h_dict, ramp_rate_limit=ramp_rate_limit - ), +wind_controller = WindFarmPowerTrackingController( + interface, "wind_farm", controller_parameters={"ramp_rate_limit": ramp_rate_limit} +) +controller = HybridSupervisoryControllerGeneric( interface=interface, - input_dict=hmodel.h_dict, + controller_parameters={"component_controllers": [wind_controller]}, ) hmodel.assign_controller(controller) diff --git a/examples/wind_farm_power_tracking/wind_power_reference.csv b/examples/wind_farm_power_tracking/wind_power_reference.csv index e017e3f6..d409d42f 100644 --- a/examples/wind_farm_power_tracking/wind_power_reference.csv +++ b/examples/wind_farm_power_tracking/wind_power_reference.csv @@ -1,4 +1,4 @@ -time_utc,wind_power_reference +time_utc,plant_power_reference 2018-05-10T12:31:00.00Z,4000.0 2018-05-10T12:31:10.00Z,4000.0 2018-05-10T12:31:20.00Z,3000.0 diff --git a/hycon/controllers/__init__.py b/hycon/controllers/__init__.py index a3b31386..a10ac27c 100644 --- a/hycon/controllers/__init__.py +++ b/hycon/controllers/__init__.py @@ -4,13 +4,13 @@ BatteryPriceSOCController, ) from hycon.controllers.hybrid_supervisory_controller import ( - HybridSupervisoryControllerBaseline, - HybridSupervisoryControllerMultiRef, + HybridSupervisoryControllerGeneric, ) from hycon.controllers.hydrogen_plant_controller import HydrogenPlantController from hycon.controllers.lookup_based_wake_steering_controller import ( LookupBasedWakeSteeringController, ) +from hycon.controllers.price_curtailing_controller import PriceCurtailingController from hycon.controllers.solar_passthrough_controller import SolarPassthroughController from hycon.controllers.wake_steering_rosco_standin import WakeSteeringROSCOStandin from hycon.controllers.wind_farm_power_tracking_controller import ( diff --git a/hycon/controllers/battery_controller.py b/hycon/controllers/battery_controller.py index 7dec25ae..02541414 100644 --- a/hycon/controllers/battery_controller.py +++ b/hycon/controllers/battery_controller.py @@ -11,34 +11,23 @@ class BatteryController(ControllerBase): changes in power reference, which can lead to degradation. """ - def __init__(self, interface, input_dict, controller_parameters={}, verbose=True): + def __init__(self, interface, cname, controller_parameters={}, verbose=True): """ Instantiate BatteryController. Args: interface (object): Interface object for communicating with simulator. - input_dict (dict): Dictionary of input parameters (e.g. from Hercules). + cname (str): Name of controller, which should match the name of the corresponding + plant component. controller_parameters (dict): Dictionary of controller parameters k_batt and clipping_thresholds. See set_controller_parameters for more details. If controller parameters are provided both in input_dict and controller_parameters, the latter will take precedence. verbose (bool): If True, print debug information. """ - super().__init__(interface, verbose) - - # Extract global parameters - self.dt = input_dict["dt"] - - # Check that parameters are not specified both in input file - # and in controller_parameters - if "controller" in input_dict: - for cp in controller_parameters.keys(): - if cp in input_dict["controller"]: - raise KeyError( - 'Found key "' + cp + '" in both input_dict["controller"] and' - " in controller_parameters." - ) - controller_parameters = {**controller_parameters, **input_dict["controller"]} + super().__init__(interface, cname, verbose) + + self.check_controller_parameters(controller_parameters) self.set_controller_parameters(**controller_parameters) # Initialize controller internal state @@ -48,7 +37,6 @@ def set_controller_parameters( self, k_batt=0.1, clipping_thresholds=[0, 0, 1, 1], - **_, # <- Allows arbitrary additional parameters to be passed, which are ignored ): """ Set gains and threshold limits for BatteryController. @@ -95,8 +83,8 @@ def soc_clipping(self, soc, reference_power): """ clip_fraction = np.interp(soc, self.clipping_thresholds, [0, 1, 1, 0], left=0, right=0) - r_charge = clip_fraction * self.plant_parameters["battery"]["charge_rate"] - r_discharge = clip_fraction * self.plant_parameters["battery"]["discharge_rate"] + r_charge = clip_fraction * self.plant_parameters[self.cname]["charge_rate"] + r_discharge = clip_fraction * self.plant_parameters[self.cname]["discharge_rate"] return np.clip(reference_power, -r_discharge, r_charge) @@ -104,9 +92,14 @@ def compute_controls(self, measurements_dict): """ Main compute_controls method for BatteryController. """ - reference_power = measurements_dict["battery"]["power_reference"] - current_power = measurements_dict["battery"]["power"] - soc = measurements_dict["battery"]["state_of_charge"] + reference_power = measurements_dict[self.cname]["power_reference"] + current_power = measurements_dict[self.cname]["power"] + soc = measurements_dict[self.cname]["state_of_charge"] + power_limit_lower = measurements_dict[self.cname].get("power_limit_lower", -np.inf) + power_limit_upper = measurements_dict[self.cname].get("power_limit_upper", np.inf) + + # Clip according to upper and lower limits + reference_power = np.clip(reference_power, power_limit_lower, power_limit_upper) # Apply reference clipping reference_power = self.soc_clipping(soc, reference_power) @@ -119,7 +112,7 @@ def compute_controls(self, measurements_dict): # Update controller internal state self.x = self.a * self.x + self.b * e - controls_dict = {"power_setpoint": current_power + u} + controls_dict = {self.cname: {"power_setpoint": current_power + u}} return controls_dict @@ -129,17 +122,40 @@ class BatteryPassthroughController(ControllerBase): Simply passes power reference down to (single) battery. """ - def __init__(self, interface, input_dict, verbose=True): + def __init__(self, interface, cname, controller_parameters={}, verbose=True): + """ + Instantiate BatteryPassthroughController. + + Args: + interface (object): Interface object for communicating with simulator. + cname (str): Name of controller, which should match the name of the corresponding + plant component. + controller_parameters (dict): Dictionary of controller parameters. Not used for + BatteryPassthroughController, but included for consistency with ControllerBase. + verbose (bool): If True, print debug information. + """ + super().__init__(interface, cname, verbose) + + self.check_controller_parameters(controller_parameters) + self.set_controller_parameters(**controller_parameters) + + def set_controller_parameters(self): """ - Instantiate BatteryPassthroughController." + No parameters for BatteryPassthroughController, but method is needed to be consistent with + ControllerBase. """ - super().__init__(interface, verbose) + return None def compute_controls(self, measurements_dict): """ Main compute_controls method for BatteryPassthroughController. """ - return {"power_setpoint": measurements_dict["battery"]["power_reference"]} + power_setpoint = np.clip( + measurements_dict[self.cname]["power_reference"], + measurements_dict[self.cname].get("power_limit_lower", -np.inf), + measurements_dict[self.cname].get("power_limit_upper", np.inf), + ) + return {self.cname: {"power_setpoint": power_setpoint}} class BatteryPriceSOCController(ControllerBase): @@ -172,28 +188,30 @@ class BatteryPriceSOCController(ControllerBase): used at the Hercules/hybrid_plant level. """ - def __init__(self, interface, input_dict, controller_parameters={}, verbose=True): - super().__init__(interface, verbose) - - # Check that parameters are not specified both in input file - # and in controller_parameters - if "controller" in input_dict: - for cp in controller_parameters.keys(): - if cp in input_dict["controller"]: - raise KeyError( - 'Found key "' + cp + '" in both input_dict["controller"] and' - " in controller_parameters." - ) - controller_parameters = {**controller_parameters, **input_dict["controller"]} + def __init__(self, interface, cname, controller_parameters={}, verbose=True): + """ + Instantiate BatteryPriceSOCController. + + Args: + interface (object): Interface object for communicating with simulator. + cname (str): Name of controller, which should match the name of the corresponding + plant component. + controller_parameters (dict): Dictionary of controller parameters high_soc and low_soc. + See set_controller_parameters method for more details. + verbose (bool): If True, print debug information. + """ + super().__init__(interface, cname, verbose) + + self.check_controller_parameters(controller_parameters) self.set_controller_parameters(**controller_parameters) - self.rated_power_charging = input_dict["battery"]["charge_rate"] - self.rated_power_discharging = input_dict["battery"]["discharge_rate"] + self.rated_power_charging = self.plant_parameters[self.cname]["charge_rate"] + self.rated_power_discharging = self.plant_parameters[self.cname]["discharge_rate"] # Save the duration rounded to nearest hour self.duration = round( - interface.plant_parameters["battery"]["energy_capacity"] - / interface.plant_parameters["battery"]["power_capacity"] + self.plant_parameters[self.cname]["energy_capacity"] + / self.plant_parameters[self.cname]["power_capacity"] ) # Raise if duration makes this controller implausible @@ -214,8 +232,7 @@ def __init__(self, interface, input_dict, controller_parameters={}, verbose=True def set_controller_parameters( self, high_soc=1.0, - low_soc=0.2, - **_, # <- Allows arbitrary additional parameters to be passed, which are ignored + low_soc=0.0, ): """ Set parameters for BatteryPriceSOCController. @@ -249,7 +266,7 @@ def compute_controls(self, measurements_dict): top_1 = sorted_day_ahead_lmps[-1] # Access the state of charge and LMP in real-time - soc = measurements_dict["battery"]["state_of_charge"] + soc = measurements_dict[self.cname]["state_of_charge"] # Note that the convention is followed where charging is negative power # This matches what is in place in the hercules/hybrid_plant level and @@ -265,4 +282,19 @@ def compute_controls(self, measurements_dict): else: power_setpoint = 0.0 - return {"power_setpoint": power_setpoint} + # Limit the power_setpoint by the SOC + if power_setpoint > 0: # Trying to discharge + if soc <= self.plant_parameters[self.cname]["state_of_charge_min"]: # Fully depleted + power_setpoint = 0.0 + + # Other way + if power_setpoint < 0: # Trying to charge + if soc >= self.plant_parameters[self.cname]["state_of_charge_max"]: # Fully charged + power_setpoint = 0.0 + + # Apply limitations based on super controller + power_limit_lower = measurements_dict[self.cname].get("power_limit_lower", -np.inf) + power_limit_upper = measurements_dict[self.cname].get("power_limit_upper", np.inf) + power_setpoint = np.clip(power_setpoint, power_limit_lower, power_limit_upper) + + return {self.cname: {"power_setpoint": power_setpoint}} diff --git a/hycon/controllers/controller_base.py b/hycon/controllers/controller_base.py index a2cd6af9..8e18e024 100644 --- a/hycon/controllers/controller_base.py +++ b/hycon/controllers/controller_base.py @@ -1,11 +1,15 @@ +import copy +import inspect from abc import ABCMeta, abstractmethod class ControllerBase(metaclass=ABCMeta): - def __init__(self, interface, verbose=True): + def __init__(self, interface, cname=None, verbose=True): self._s = interface self.verbose = verbose + self.cname = cname + # Initialize measurements and controls to send self._measurements_dict = {} self._controls_dict = {} @@ -18,7 +22,7 @@ def _receive_measurements(self, input_dict=None): def _send_controls(self, input_dict=None): self._s.check_controls(self._controls_dict) - output_dict = self._s.send_controls(input_dict, **self._controls_dict) + output_dict = self._s.send_controls(input_dict, self._controls_dict) return output_dict @@ -33,6 +37,54 @@ def step(self, input_dict=None): return output_dict + def check_controller_parameters(self, controller_parameters): + # Check valid controller parameters + valid_controller_parameters = inspect.getfullargspec(self.set_controller_parameters).args + valid_controller_parameters.remove("self") + invalid_cps = [ + cp for cp in controller_parameters.keys() if cp not in valid_controller_parameters + ] + if len(invalid_cps) > 0: + raise KeyError( + "Found keys " + + str(invalid_cps) + + " in controller_parameters, but they are not valid controller parameters for " + + self.__class__.__name__ + + ". Valid controller parameters are: " + + str(valid_controller_parameters) + + "." + ) + + # Check that required parameters are specified + default_values = inspect.getfullargspec(self.set_controller_parameters).defaults + num_defaults = len(default_values) if default_values is not None else 0 + + required_args = valid_controller_parameters[:-num_defaults] + + missing_required_cps = set(required_args) - set(controller_parameters.keys()) + if len(missing_required_cps) > 0: + raise KeyError("Missing required controller parameters: " + str(missing_required_cps)) + + return None + + # TODO: Consider an "update controller parameters" method. Not urgent. + + def compute_controls_without_updating_state(self, measurements_dict): + """ + Compute controls without updating internal state. This is used when the control output + needs to be queried without actually updating the controller's internal state, such as + in the hybrid supervisory controller when querying component controllers for their desired + power references without actually updating their states. + """ + self._initial_state = copy.deepcopy(self.__dict__) + controls_dict = self.compute_controls(measurements_dict) + self.__dict__.update(copy.deepcopy(self._initial_state)) + return controls_dict + + @abstractmethod + def set_controller_parameters(self, **kwargs): + raise NotImplementedError("set_controller_parameters must be implemented in child class.") + @property def controller_parameters(self): return self._s.controller_parameters @@ -54,12 +106,17 @@ def dt(self, _): @property def cname(self): if hasattr(self, "_cname"): - return self._cname + if self._cname is None: + raise ValueError("cname has been set to None for this controller.") + else: + return self._cname else: - return ValueError("cname has not been set for this controller.") + raise ValueError("cname has not been set for this controller.") @cname.setter def cname(self, value): + if not isinstance(value, (str, type(None))): + raise ValueError("cname must be a string.") self._cname = value @abstractmethod diff --git a/hycon/controllers/hybrid_supervisory_controller.py b/hycon/controllers/hybrid_supervisory_controller.py index 242c3d06..db51009d 100644 --- a/hycon/controllers/hybrid_supervisory_controller.py +++ b/hycon/controllers/hybrid_supervisory_controller.py @@ -1,354 +1,181 @@ +import copy + import numpy as np from hycon.controllers.controller_base import ControllerBase -class HybridSupervisoryControllerBase(ControllerBase): +class HybridSupervisoryControllerGeneric(ControllerBase): """ - Base class for hybrid supervisory controllers, implementing shared functionality. + HybridSupervisoryControllerGeneric is a supervisory controller for a hybrid + plant with an arbitrary set of components. These components may be heterogeneous + or homogeneous (e.g. multiple solar farms), or a mixture (e.g. two solar farms combined with + one natural gas plant). """ def __init__( self, interface, - input_dict, - wind_controller=None, - solar_controller=None, - battery_controller=None, + cname="supervisor", + controller_parameters={}, verbose=False, ): - super().__init__(interface=interface, verbose=verbose) - - self.dt = input_dict["dt"] # Won't be needed here, but generally good to have - - # Assign the individual asset controllers - self.wind_controller = wind_controller - self.solar_controller = solar_controller - self.battery_controller = battery_controller - - self._has_solar_controller = solar_controller is not None - self._has_wind_controller = wind_controller is not None - self._has_battery_controller = battery_controller is not None - - # Initialize power references - self.wind_reference = 0 - self.solar_reference = 0 - self.battery_reference = 0 - self.prev_battery_power = 0 - self.prev_wind_power = 0 - self.prev_solar_power = 0 - - def compute_controls(self, measurements_dict): - # Run supervisory control logic - wind_reference, solar_reference, battery_reference = self.supervisory_control( - measurements_dict - ) - - # Package the controls for the individual controllers, step, and return - controls_dict = {} - if self._has_wind_controller: - measurements_dict["wind_farm"]["power_reference"] = wind_reference - wind_controls_dict = self.wind_controller.compute_controls(measurements_dict) - controls_dict["wind_power_setpoints"] = wind_controls_dict["power_setpoints"] - if self._has_solar_controller: - measurements_dict["solar_farm"]["power_reference"] = solar_reference - solar_controls_dict = self.solar_controller.compute_controls(measurements_dict) - controls_dict["solar_power_setpoint"] = solar_controls_dict["power_setpoint"] - if self._has_battery_controller: - measurements_dict["battery"]["power_reference"] = battery_reference - battery_controls_dict = self.battery_controller.compute_controls(measurements_dict) - controls_dict["battery_power_setpoint"] = battery_controls_dict["power_setpoint"] - - return controls_dict - + """ + Instantiate HybridSupervisoryControllerGeneric. + + Args: + interface: The controller's interface to the plant. + cname: The name of the controller, which should correspond to a key in the plant + parameters dictionary. Defaults to "supervisor". + controller_parameters: Dictionary of controller parameters. Should include keys + "component_controllers" and "curtailment_order". See set_controller_parameters for + details. Defaults to empty dictionary. + verbose: Whether to print additional information during controller operation. + """ + super().__init__(interface=interface, cname=cname, verbose=verbose) -class HybridSupervisoryControllerBaseline(HybridSupervisoryControllerBase): - def __init__( - self, - interface, - input_dict, - wind_controller=None, - solar_controller=None, - battery_controller=None, - verbose=False, - ): - super().__init__( - interface=interface, - input_dict=input_dict, - wind_controller=wind_controller, - solar_controller=solar_controller, - battery_controller=battery_controller, - verbose=verbose, - ) + self.check_controller_parameters(controller_parameters) + self.set_controller_parameters(**controller_parameters) - if not self._has_wind_controller and not self._has_solar_controller: + # Extract interconnection limit, if specified + self._interconnect_limit = self.plant_parameters.get("interconnect_limit", np.inf) + if self._interconnect_limit == -1 or self._interconnect_limit is None: + self._interconnect_limit = np.inf + if not isinstance(self._interconnect_limit, (float, int)) or self._interconnect_limit < -1: raise ValueError( - "The HybridSupervisoryControllerBaseline requires that either a solar_controller" - " or a wind_controller be provided." + "interconnect_limit must be a positive value (or -1, indicating no limit)." ) - def supervisory_control(self, measurements_dict): - # Extract measurements sent - if self._has_wind_controller: - wind_power = np.array(measurements_dict["wind_farm"]["turbine_powers"]).sum() - else: - wind_power = 0 - - if self._has_solar_controller: - solar_power = measurements_dict["solar_farm"]["power"] - else: - solar_power = 0 + def set_controller_parameters(self, component_controllers=[], curtailment_order=None): + """ + Set controller parameters for HybridSupervisoryControllerGeneric. + + Args: + component_controllers: List of component controllers to coordinate. Should be + instantiated Hycon-compatible controllers with cnames corresponding to the plant + components in the simulation. + curtailment_order: List of integers corresponding to the order in which to curtail + components when the overall power reference exceeds the interconnection limit. + """ - if self._has_battery_controller: - battery_power = measurements_dict["battery"]["power"] - battery_soc = measurements_dict["battery"]["state_of_charge"] + # Check valid component_controllers + if len(component_controllers) == 0: + raise ValueError( + "component_controllers cannot be empty. " + "At least one component controller must be provided." + ) else: - battery_power = 0 - battery_soc = 0 - - # Handle power_reference or plant_power_reference keys - if ( - "power_reference" in measurements_dict - and "plant_power_reference" not in measurements_dict - ): - measurements_dict["plant_power_reference"] = measurements_dict["power_reference"] - del measurements_dict["power_reference"] - elif ( - "power_reference" not in measurements_dict - and "plant_power_reference" not in measurements_dict - ): - raise KeyError( - "Either 'power_reference' or 'plant_power_reference' must be provided" - " in measurements_dict." + self.component_controllers = component_controllers + + # Check valid curtailment_order + if curtailment_order is None: + # Default is reverse order of component_controllers + self.curtailment_order = list(range(len(component_controllers) - 1, -1, -1)) + elif len(curtailment_order) != len(component_controllers): + raise ValueError("curtailment_order must be the same length as component_controllers.") + elif not all([type(c) is int and c >= 0 for c in curtailment_order]): + raise ValueError( + "All entries in curtailment_order must be non-negative integers corresponding to " + "indices of component_controllers." ) elif ( - "power_reference" in measurements_dict and "plant_power_reference" in measurements_dict - ): - raise KeyError( - "Found both 'power_reference' and 'plant_power_reference' in measurements_dict." - ) - plant_power_reference = measurements_dict["plant_power_reference"] - - # Filter the wind and solar power measurements to reduce noise and improve closed-loop - # controller damping - a = 0.1 - wind_power = (1 - a) * self.prev_wind_power + a * wind_power - solar_power = (1 - a) * self.prev_solar_power + a * solar_power - - # Calculate battery reference value - if self._has_battery_controller: - battery_reference = plant_power_reference - (wind_power + solar_power) - battery_charge_rate = self.plant_parameters["battery"]["charge_rate"] - else: - battery_reference = 0 - battery_charge_rate = 0 - - # Decide control gain: - if (wind_power + solar_power) < ( - plant_power_reference + battery_charge_rate - ) and battery_power <= 0: - if battery_soc > 0.89: - K = ((wind_power + solar_power) - plant_power_reference) / 2 - else: - K = ((wind_power + solar_power) - (plant_power_reference + battery_charge_rate)) / 2 - else: - K = ((wind_power + solar_power) - plant_power_reference) / 2 - - if not (self._has_wind_controller & self._has_solar_controller): - # Only one type of generation available, double the control gain - K = 2 * K - - if (wind_power + solar_power) > (plant_power_reference + battery_charge_rate) or ( - (wind_power + solar_power) > (plant_power_reference) and battery_soc > 0.89 + max(curtailment_order) != len(component_controllers) - 1 or min(curtailment_order) != 0 ): - # go down - wind_reference = wind_power - K - solar_reference = solar_power - K - else: - # go up - # Is the resource saturated? - if self.solar_reference > (self.prev_solar_power + 0.05 * self.solar_reference): - solar_reference = self.solar_reference - else: - # If not, ask for more power - solar_reference = solar_power - K - - if self.wind_reference > (self.prev_wind_power + 0.05 * self.wind_reference): - wind_reference = self.wind_reference - else: - wind_reference = wind_power - K - - # Reset references for invalid controllers - if not self._has_wind_controller: - wind_reference = 0 - if not self._has_solar_controller: - solar_reference = 0 - - self.prev_solar_power = solar_power - self.prev_wind_power = wind_power - self.prev_battery_power = battery_power - self.wind_reference = wind_reference - self.solar_reference = solar_reference - self.battery_reference = battery_reference - - return wind_reference, solar_reference, battery_reference - - -class HybridSupervisoryControllerMultiRef(HybridSupervisoryControllerBase): - """ - Modified version of HybridSupervisoryControllerBaseline that accepts - individual references for wind and solar generation and respects an - interconnection limit. - """ - - def __init__( - self, - interface, - input_dict, - wind_controller=None, - solar_controller=None, - battery_controller=None, - verbose=False, - ): - super().__init__( - interface=interface, - input_dict=input_dict, - wind_controller=wind_controller, - solar_controller=solar_controller, - battery_controller=battery_controller, - verbose=verbose, - ) - - # Extract interconnection limit - if "interconnect_limit" in self.plant_parameters: - if ( - not isinstance(self.plant_parameters["interconnect_limit"], (float, int)) - or self.plant_parameters["interconnect_limit"] <= 0 - ): - raise ValueError("interconnect_limit must be a positive value.") - else: - raise KeyError("interconnect_limit must be specified to use this controller.") - - # Establish curtailment protocols - default_curtailment_order = ["battery", "solar", "wind"] - default_curtailment_order = [ - c - for c, a in zip( - default_curtailment_order, - [ - self._has_battery_controller, - self._has_solar_controller, - self._has_wind_controller, - ], + raise ValueError( + "curtailment_order must contain integers corresponding to indices of " + "component_controllers." ) - if a - ] - if "curtailment_order" in self.controller_parameters: - # Check that curtailment order does not contain any invalid components - for component in self.controller_parameters["curtailment_order"]: - if component not in default_curtailment_order: - raise ValueError( - f"Invalid component {component} in curtailment_order. " - "Valid components based on configuration provided are: " - ", ".join(default_curtailment_order) - ) - self.curtailment_order = self.controller_parameters["curtailment_order"] + elif len(curtailment_order) != len(set(curtailment_order)): + raise ValueError("curtailment_order must not contain duplicate entries.") else: - self.curtailment_order = default_curtailment_order + self.curtailment_order = curtailment_order - def supervisory_control(self, measurements_dict): + def compute_controls(self, measurements_dict): """ - Overwrite HybridSupervisoryControllerBaseline.supervisory_control() - with controller that follows separate setpoints and curtails in order. + Pass necessary information to each component controller, and apply power + capping/curtailment. """ - # Extract measurements sent - if self._has_wind_controller: - wind_power = np.array(measurements_dict["wind_farm"]["turbine_powers"]).sum() - wind_reference = measurements_dict["wind_farm"].get( - "power_reference", self.plant_parameters["wind_farm"]["capacity"] - ) - wind_reference = np.minimum( - wind_reference, self.plant_parameters["wind_farm"]["capacity"] - ) - else: - wind_power = 0 - wind_reference = 0 + # Compute available storage for charging + total_available_storage_for_charging = 0.0 + for cc in self.component_controllers: + if cc.plant_parameters[cc.cname]["component_category"] == "storage" and not np.isclose( + measurements_dict[cc.cname]["state_of_charge"], + cc.plant_parameters[cc.cname].get("state_of_charge_max", 1.0), + atol=1e-2, # Within 1% of max SOC, assume storage is fully charged + ): + # Ask to charge at the full charge rate---this will then indicate whether the + # storage would like to charge, for more complex battery controllers. + standin_measurements_dict = copy.deepcopy(measurements_dict) + standin_measurements_dict[cc.cname]["power_reference"] = -cc.plant_parameters[ + cc.cname + ]["charge_rate"] + total_available_storage_for_charging += np.maximum( + 0, + -cc.compute_controls_without_updating_state(standin_measurements_dict)[ + cc.cname + ]["power_setpoint"], + ) - if self._has_solar_controller: - solar_power = measurements_dict["solar_farm"]["power"] - solar_reference = measurements_dict["solar_farm"].get( - "power_reference", self.plant_parameters["solar_farm"]["capacity"] - ) - solar_reference = np.minimum( - solar_reference, self.plant_parameters["solar_farm"]["capacity"] - ) + # Get overall reference, and remove from measurements_dict to avoid confusion for + # component controllers. + if "plant_power_reference" in measurements_dict: + provided_power_reference = measurements_dict.pop("plant_power_reference") + elif "power_reference" in measurements_dict: + provided_power_reference = measurements_dict.pop("power_reference") else: - solar_power = 0 - solar_reference = 0 + provided_power_reference = None - if self._has_battery_controller: - battery_power = measurements_dict["battery"]["power"] - if "power_reference" in measurements_dict["battery"]: - battery_reference = measurements_dict["battery"].get("power_reference", 0) - else: - battery_reference = 0 - battery_reference = np.minimum( - battery_reference, self.plant_parameters["battery"]["discharge_rate"] - ) - battery_reference = np.maximum( - battery_reference, -1 * self.plant_parameters["battery"]["charge_rate"] - ) - else: - battery_power = 0 - battery_reference = 0 + power_reference_total = min( + self._interconnect_limit, + measurements_dict.get("dynamic_interconnect_limit", np.inf), + provided_power_reference if provided_power_reference is not None else np.inf, + ) + power_reference_with_storage = power_reference_total + total_available_storage_for_charging - # Filter the wind and solar power measurements to reduce noise and improve closed-loop - # controller damping - # TODO RECONSIDER THIS MAYBE MAKE MORE DEPENDENT ON THE TIME STEP - a = 1.0 # 0.1 # FORCE THE FILTER TO BE 100% DEPENDENT ON THE CURRENT TIME STEP - wind_power = (1 - a) * self.prev_wind_power + a * wind_power - solar_power = (1 - a) * self.prev_solar_power + a * solar_power - battery_power = (1 - a) * self.prev_battery_power + a * battery_power + # Initialize overall quantities + power_export_total = 0.0 + locally_generated_power_total = 0.0 + controls_dict = {} - # Loop over the curtailment order in reverse order to progressively reduce the reference - # of the first component in the order - unconstrained_power = 0.0 + # Compute total locally generated power from generators + locally_generated_power_total = sum( + [ + measurements_dict[cc.cname]["power"] + for cc in self.component_controllers + if cc.plant_parameters[cc.cname]["component_category"] == "generator" + ] + ) - # If battery power is negative (charging), immediately include it in the unconstrained power - if battery_power < 0: - unconstrained_power += battery_power + # Loop over curtailment order in reverse to bring in power for each component until we hit + # the interconnection limit, then curtail as needed according to the order. + for cidx in self.curtailment_order[::-1]: + cc = self.component_controllers[cidx] + + if cc.plant_parameters[cc.cname]["component_category"] == "generator": + power_reference_component = power_reference_with_storage - power_export_total + elif cc.plant_parameters[cc.cname]["component_category"] == "storage": + if cc.plant_parameters[cc.cname].get("allow_grid_charging", True): + power_reference_component = power_reference_total - power_export_total + measurements_dict[cc.cname]["power_limit_lower"] = -np.inf + measurements_dict[cc.cname]["power_limit_upper"] = power_reference_component + else: + power_reference_component = max( + power_reference_total - power_export_total, + -locally_generated_power_total, + ) + measurements_dict[cc.cname][ + "power_limit_lower" + ] = -locally_generated_power_total + measurements_dict[cc.cname]["power_limit_upper"] = power_reference_component + # Reduce or increase the available power to store + locally_generated_power_total += measurements_dict[cc.cname]["power"] - for component in reversed(self.curtailment_order): - if component == "wind": - wind_reference = np.minimum( - wind_reference, - self.plant_parameters["interconnect_limit"] - unconstrained_power, - ) - unconstrained_power += wind_power - elif component == "solar": - solar_reference = np.minimum( - solar_reference, - self.plant_parameters["interconnect_limit"] - unconstrained_power, - ) - unconstrained_power += solar_reference - elif component == "battery": - battery_reference = np.minimum( - battery_reference, - self.plant_parameters["interconnect_limit"] - unconstrained_power, - ) - if battery_power < 0: # Make sure not to double count battery power when charging - unconstrained_power += battery_power - else: - raise ValueError(f"Invalid generation type {component} in curtailment_order.") + # Assign power_reference_component for use by lower level controller + measurements_dict[cc.cname]["power_reference"] = power_reference_component - self.prev_solar_power = solar_power - self.prev_wind_power = wind_power - self.prev_battery_power = battery_power - self.wind_reference = wind_reference - self.solar_reference = solar_reference - self.battery_reference = battery_reference + controls_dict.update(cc.compute_controls(measurements_dict)) - return wind_reference, solar_reference, battery_reference + power_export_total += measurements_dict[cc.cname]["power"] - # TODO: Need to add it's own compute_controls method that ensures interconnect is satisfied + return controls_dict diff --git a/hycon/controllers/hydrogen_plant_controller.py b/hycon/controllers/hydrogen_plant_controller.py index 34f78ad3..d5006499 100644 --- a/hycon/controllers/hydrogen_plant_controller.py +++ b/hycon/controllers/hydrogen_plant_controller.py @@ -7,27 +7,25 @@ class HydrogenPlantController(ControllerBase): def __init__( self, interface, - input_dict, - generator_controller=None, + cname="hydrogen", controller_parameters={}, verbose=False, ): - super().__init__(interface, verbose=verbose) - - self.dt = input_dict["dt"] # Won't be needed here, but generally good to have + """ + Constructor for HydrogenPlantController. - # Assign the individual asset controllers - self.generator_controller = generator_controller + Args: + interface (InterfaceBase): Interface object for communicating with the plant. + cname (str): Name of the controller. Defaults to "hydrogen". + controller_parameters (dict): Dictionary of controller parameters. See + set_controller_parameters for details. + verbose (bool): Verbosity flag. Defaults to False. + """ + super().__init__(interface, cname=cname, verbose=verbose) # Check that parameters are not specified both in input file # and in controller_parameters - for cp in controller_parameters.keys(): - if cp in input_dict["controller"]: - raise KeyError( - 'Found key "' + cp + '" in both input_dict["controller"] and' - " in controller_parameters." - ) - controller_parameters = {**controller_parameters, **input_dict["controller"]} + self.check_controller_parameters(controller_parameters) self.set_controller_parameters(**controller_parameters) # Initialize filter @@ -37,8 +35,8 @@ def set_controller_parameters( self, nominal_plant_power_kW, nominal_hydrogen_rate_kgps, + generator_controller, hydrogen_controller_gain=1.0, - **_, # <- Allows arbitrary additional parameters to be passed, which are ignored ): """ Set gains and threshold limits for HydrogenPlantController. @@ -53,9 +51,15 @@ def set_controller_parameters( Args: nominal_plant_power_kW (float): Nominal power of the plant in kW. nominal_hydrogen_rate_kgps (float): Nominal hydrogen production rate in kg/s. + generator_controller (ControllerBase): Controller for the generator. This controller + should accept a power reference as an input and output appropriate generator + controls. hydrogen_controller_gain (float): Gain for the hydrogen controller. Defaults to 1.0. """ + # Assign the power component controller + self.generator_controller = generator_controller + # Set K from plant inputs self.K = nominal_plant_power_kW / nominal_hydrogen_rate_kgps * hydrogen_controller_gain @@ -91,9 +95,9 @@ def compute_controls(self, measurements_dict): def supervisory_control(self, measurements_dict): # Extract measurements sent - current_power = measurements_dict["total_power"] - hydrogen_output = measurements_dict["hydrogen"]["production_rate"] - hydrogen_reference = measurements_dict["hydrogen"]["power_reference"] + current_power = measurements_dict["local_power"] + hydrogen_output = measurements_dict[self.cname]["production_rate"] + hydrogen_reference = measurements_dict[self.cname]["hydrogen_production_reference"] # Input filtering a = 0.05 diff --git a/hycon/controllers/lookup_based_wake_steering_controller.py b/hycon/controllers/lookup_based_wake_steering_controller.py index 16663f9c..c6888677 100644 --- a/hycon/controllers/lookup_based_wake_steering_controller.py +++ b/hycon/controllers/lookup_based_wake_steering_controller.py @@ -1,7 +1,6 @@ from __future__ import annotations import numpy as np -import pandas as pd from floris.utilities import wrap_180 from hycon.controllers.controller_base import ControllerBase @@ -13,9 +12,8 @@ class LookupBasedWakeSteeringController(ControllerBase): def __init__( self, interface: InterfaceBase, - input_dict: dict, - df_yaw: pd.DataFrame | None = None, - hysteresis_dict: dict | None = None, + cname: str, + controller_parameters: dict = {}, verbose: bool = False, ): """ @@ -23,21 +21,36 @@ def __init__( Args: interface (InterfaceBase): Interface object for communicating with the plant. - input_dict (dict): Dictionary of input parameters. - df_yaw (pd.DataFrame): DataFrame of yaw offsets. May be produced using tools in - hycon.design_tools.wake_steering_design. Defaults to None. - hysteresis_dict (dict): Dictionary of hysteresis zones. May be produced using - compute_hysteresis_zones function in hycon.design_tools.wake_steering_design. - Defaults to None. + cname (str): Name of the controller, used for indexing into measurements and controls + dictionaries. Should match the component name in the plant model. + controller_parameters (dict): Dictionary of controller parameters. See + set_controller_parameters for details on expected controller parameters. verbose (bool): Verbosity flag. """ - super().__init__(interface, verbose=verbose) + super().__init__(interface, cname, verbose=verbose) # Pull plant parameters for ease of use self.n_turbines = self.plant_parameters["n_turbines"] self.turbines = range(self.n_turbines) # Handle yaw optimizer object + self.check_controller_parameters(controller_parameters) + self.set_controller_parameters(**controller_parameters) + + def set_controller_parameters(self, df_yaw=None, hysteresis_dict=None, yaw_IC=270.0): + """ + Set controller parameters for LookupBasedWakeSteeringController. + + Args: + df_yaw (pd.DataFrame): DataFrame of yaw offsets. May be produced using tools in + hycon.design_tools.wake_steering_design. Defaults to None. + hysteresis_dict (dict): Dictionary of hysteresis zones. May be produced using + compute_hysteresis_zones function in hycon.design_tools.wake_steering_design. + Defaults to None. + yaw_IC (float or list of floats): Initial condition for yaw angles. If a single + float is provided, it is applied to all turbines. If a list is provided, it should + be of length num_turbines. Defaults to 270.0 (aligned with incoming wind direction). + """ if df_yaw is None: if hysteresis_dict is not None: raise ValueError( @@ -61,7 +74,6 @@ def __init__( self.hysteresis_dict = hysteresis_dict # Set initial conditions - yaw_IC = input_dict["controller"]["initial_conditions"]["yaw"] if hasattr(yaw_IC, "__len__"): if len(yaw_IC) == self.n_turbines: self.controls_dict = {"yaw_angles": yaw_IC} @@ -112,4 +124,4 @@ def wake_steering_angles(self, wind_directions): self.controls_dict = {"yaw_angles": yaw_setpoint} - return {"yaw_angles": yaw_setpoint} + return {self.cname: {"yaw_angles": yaw_setpoint}} diff --git a/hycon/controllers/price_curtailing_controller.py b/hycon/controllers/price_curtailing_controller.py new file mode 100644 index 00000000..8c6e869f --- /dev/null +++ b/hycon/controllers/price_curtailing_controller.py @@ -0,0 +1,76 @@ +import copy + +import numpy as np + +from hycon.controllers.controller_base import ControllerBase + + +class PriceCurtailingController(ControllerBase): + """ + Curtails the component if the real-time price drops below a user-defined threshold. + Otherwise, simply passes through the power reference. + """ + + def __init__(self, interface, cname, controller_parameters={}, verbose=True): + """ + Constructor for PriceCurtailingController. + + Args: + interface (InterfaceBase): Interface object for communicating with the plant. + cname (str): Name of the controller, used for indexing into measurements and controls + dictionaries. Should match the component name in the plant model. + controller_parameters (dict): Dictionary of controller parameters. See + set_controller_parameters for details on expected controller parameters. + """ + super().__init__(interface, cname, verbose) + self.check_controller_parameters(controller_parameters) + self.set_controller_parameters(**controller_parameters) + + def set_controller_parameters(self, curtailment_price=0.0, power_tracking_controller=None): + """ + Set controller parameters for PriceCurtailingController. + + Args: + curtailment_price: Real-time price threshold for curtailment. If the real-time price + drops below this threshold, the controller will curtail the component (i.e., set + power reference to 0). Defaults to 0.0. + """ + if not isinstance(curtailment_price, (int, float, np.integer, np.floating)): + raise ValueError("`curtailment_price` must be a single numeric value.") + if power_tracking_controller is None: + raise ValueError("`power_tracking_controller` must be provided.") + elif not isinstance(power_tracking_controller, ControllerBase): + raise ValueError("`power_tracking_controller` must be an instance of ControllerBase.") + + self.curtailment_price = curtailment_price + self.power_tracking_controller = power_tracking_controller + + def compute_controls(self, measurements_dict): + if "RT_LMP" not in measurements_dict or not isinstance( + measurements_dict["RT_LMP"], (int, float, np.integer, np.floating) + ): + raise KeyError( + "measurements_dict must contain key scalar 'RT_LMP' to use " + + self.__class__.__name__ + + "." + ) + elif "power_reference" not in measurements_dict[self.cname]: + raise KeyError( + "measurements_dict['" + + self.cname + + "'] must contain key 'power_reference' to use " + + self.__class__.__name__ + + "." + ) + + # Threshold based on curtailment price + measurements_dict_local = copy.deepcopy(measurements_dict) + if measurements_dict_local["RT_LMP"] <= self.curtailment_price: + measurements_dict_local[self.cname]["power_reference"] = 0.0 + else: + pass + + # Compute controls using the underlying power_tracking_controller + controls_dict = self.power_tracking_controller.compute_controls(measurements_dict_local) + + return {self.cname: {"power_setpoint": controls_dict[self.cname]["power_setpoint"]}} diff --git a/hycon/controllers/solar_passthrough_controller.py b/hycon/controllers/solar_passthrough_controller.py index ba053b79..8ed8fe49 100644 --- a/hycon/controllers/solar_passthrough_controller.py +++ b/hycon/controllers/solar_passthrough_controller.py @@ -6,8 +6,23 @@ class SolarPassthroughController(ControllerBase): Simply passes power reference down to (scalar) solar simulator. """ - def __init__(self, interface, input_dict, verbose=True): - super().__init__(interface, verbose) + def __init__(self, interface, cname, controller_parameters={}, verbose=True): + """ + Constructor for SolarPassthroughController. + + Args: + interface (InterfaceBase): Interface object for communicating with the plant. + cname (str): Name of the controller, used for indexing into measurements and controls + dictionaries. Should match the component name in the plant model. + controller_parameters (dict): Dictionary of controller parameters. Empty for this + passthrough controller. + """ + super().__init__(interface, cname, verbose) + self.check_controller_parameters(controller_parameters) + self.set_controller_parameters(**controller_parameters) + + def set_controller_parameters(self): + pass def compute_controls(self, measurements_dict): - return {"power_setpoint": measurements_dict["solar_farm"]["power_reference"]} + return {self.cname: {"power_setpoint": measurements_dict[self.cname]["power_reference"]}} diff --git a/hycon/controllers/wind_farm_power_tracking_controller.py b/hycon/controllers/wind_farm_power_tracking_controller.py index d9043bb4..3903399a 100644 --- a/hycon/controllers/wind_farm_power_tracking_controller.py +++ b/hycon/controllers/wind_farm_power_tracking_controller.py @@ -12,11 +12,20 @@ class WindFarmPowerDistributingController(ControllerBase): feedback on current power generation. """ - def __init__(self, interface, input_dict, ramp_rate_limit=None, verbose=False): - super().__init__(interface, verbose=verbose) + def __init__(self, interface, cname, controller_parameters={}, verbose=False): + """ + Constructor for WindFarmPowerDistributingController. - # Pull plant parameters for ease of use - self.cname = "wind_farm" + Args: + interface: Hycon Interface object for communication with the simulation environment. + cname: Name of the controller, used for indexing into measurements and controls + dictionaries. Should match the component name in the plant model. + controller_parameters: Dictionary of controller parameters. See + set_controller_parameters for details on expected controller parameters. + verbose: Boolean flag for verbosity. + """ + + super().__init__(interface, cname, verbose=verbose) if self.cname in self.plant_parameters: self.n_turbines = self.plant_parameters[self.cname]["n_turbines"] @@ -25,13 +34,26 @@ def __init__(self, interface, input_dict, ramp_rate_limit=None, verbose=False): self.turbines = range(self.n_turbines) # Ramp rate limit - if ramp_rate_limit is None: - ramp_rate_limit = np.inf - self.turbine_ramp_rate_limit = ramp_rate_limit / self.n_turbines + self.check_controller_parameters(controller_parameters) + self.set_controller_parameters(**controller_parameters) # Used for initialization purposes self._first_call = True + def set_controller_parameters(self, ramp_rate_limit=None): + """ + Set controller parameters for WindFarmPowerDistributingController. + + Args: + ramp_rate_limit: Ramp rate limit for the controller (kW/s). Defaults to None, which + corresponds to no ramp rate limit. + """ + if ramp_rate_limit is None: + ramp_rate_limit = np.inf + elif ramp_rate_limit < 0: + raise ValueError("ramp_rate_limit must be non-negative.") + self.turbine_ramp_rate_limit = ramp_rate_limit / self.n_turbines + def compute_controls(self, measurements_dict): ref_in_lower_dict = ( "power_reference" in measurements_dict[self.cname] @@ -54,14 +76,14 @@ def compute_controls(self, measurements_dict): else: farm_power_reference = POWER_SETPOINT_DEFAULT - turbine_power_setpoints = self.turbine_power_references( + controls_dict = self.turbine_power_references( farm_power_reference=farm_power_reference, turbine_powers=measurements_dict[self.cname]["turbine_powers"], ) self._first_call = False - return turbine_power_setpoints + return controls_dict def turbine_power_references( self, farm_power_reference=POWER_SETPOINT_DEFAULT, turbine_powers=None @@ -83,11 +105,7 @@ def turbine_power_references( # Apply ramp rate limit turbine_power_setpoints = self.apply_ramp_rate_limit(turbine_power_setpoints) - controls_dict = { - "power_setpoints": turbine_power_setpoints.tolist(), - } - - return controls_dict + return {self.cname: {"power_setpoint": turbine_power_setpoints.tolist()}} def apply_ramp_rate_limit(self, unclipped_setpoints): if self._first_call: @@ -113,22 +131,39 @@ class WindFarmPowerTrackingController(WindFarmPowerDistributingController): Inherits from WindFarmPowerDistributingController. """ - def __init__( - self, interface, input_dict, proportional_gain=1, ramp_rate_limit=None, verbose=False - ): + def __init__(self, interface, cname, controller_parameters={}, verbose=False): """ Constructor for WindFarmPowerTrackingController. Args: interface: Hycon Interface object for communication with the simulation environment. - input_dict: Dictionary containing input parameters for the controller. - proportional_gain: Proportional gain for the controller. - ramp_rate_limit: Ramp rate limit for the controller (kW/s). Defaults to None. + cname: Name of the controller, used for indexing into measurements and controls + dictionaries. Should match the component name in the plant model. + controller_parameters: Dictionary of controller parameters. See + set_controller_parameters for details on expected controller parameters. verbose: Boolean flag for verbosity. """ - super().__init__(interface, input_dict, ramp_rate_limit=ramp_rate_limit, verbose=verbose) + super().__init__(interface, cname, verbose=verbose) + + # Using bad inheritance here, so will have to recheck ramp rate limit parameters + self.check_controller_parameters(controller_parameters) + self.set_controller_parameters(**controller_parameters) + + def set_controller_parameters(self, proportional_gain=1.0, ramp_rate_limit=None): + """ + Set controller parameters for WindFarmPowerTrackingController. + + Args: + proportional_gain: Proportional gain for the controller. Defaults to 1.0. + ramp_rate_limit: Ramp rate limit for the controller (kW/s). Defaults to None, which + corresponds to no ramp rate limit. + """ + if ramp_rate_limit is None: + ramp_rate_limit = np.inf + elif ramp_rate_limit < 0: + raise ValueError("ramp_rate_limit must be non-negative.") + self.turbine_ramp_rate_limit = ramp_rate_limit / self.n_turbines - # Proportional gain self.K_p = proportional_gain * 1 / self.n_turbines def turbine_power_references( @@ -164,8 +199,4 @@ def turbine_power_references( # Apply ramp rate limit turbine_power_setpoints = self.apply_ramp_rate_limit(unclipped_setpoints) - controls_dict = { - "power_setpoints": list(turbine_power_setpoints), - } - - return controls_dict + return {self.cname: {"power_setpoint": turbine_power_setpoints.tolist()}} diff --git a/hycon/interfaces/hercules_interface.py b/hycon/interfaces/hercules_interface.py index 75b3a589..f50997b6 100644 --- a/hycon/interfaces/hercules_interface.py +++ b/hycon/interfaces/hercules_interface.py @@ -1,8 +1,28 @@ import copy -from hycon.controllers.wind_farm_power_tracking_controller import POWER_SETPOINT_DEFAULT from hycon.interfaces.interface_base import InterfaceBase +# List of channels that may be present in the hercules component data that the controller needs. +# Key: Hercules name. Value: Name to use in controller measurements dictionary +hercules_data_channel_map = { + "power": "power", + "power_reference": "power_reference", + "soc": "state_of_charge", + "turbine_powers": "turbine_powers", + "turbine_speeds": "turbine_speeds", + "wind_direction_mean": "wind_direction_mean", + "dni": "direct_normal_irradiance", + "aoi": "angle_of_incidence", + "H2_mfr": "production_rate", +} + +# List of valid Hercules component types recognized by Hycon +hercules_wind_types = ["WindFarm"] +hercules_solar_types = ["SolarPySAMPVWatts"] +hercules_battery_types = ["BatteryLithiumIon", "BatterySimple"] +hercules_hydrogen_types = ["ElectrolyzerPlant"] +hercules_thermal_types = ["HardCoalSteamTurbine", "OpenCycleGasTurbine"] + class HerculesInterface(InterfaceBase): """ @@ -13,12 +33,6 @@ def __init__(self, h_dict): super().__init__() self.dt = h_dict["dt"] - # Controller parameters - if "controller" in h_dict and h_dict["controller"] is not None: - self.controller_parameters = copy.deepcopy(h_dict["controller"]) - else: - self.controller_parameters = {} - # Plant parameters if "plant" in h_dict and h_dict["plant"] is not None: self.plant_parameters = copy.deepcopy(h_dict["plant"]) @@ -26,61 +40,58 @@ def __init__(self, h_dict): self.plant_parameters = {} # Determine which components are present in the simulation - self._has_wind_component = "wind_farm" in h_dict - self._has_solar_component = "solar_farm" in h_dict - self._has_battery_component = "battery" in h_dict - self._has_hydrogen_component = "electrolyzer" in h_dict - - # Wind farm parameters - if self._has_wind_component: - self.plant_parameters["wind_farm"] = { - "capacity": h_dict["wind_farm"]["capacity"], - "n_turbines": h_dict["wind_farm"]["n_turbines"], - "turbines": range(h_dict["wind_farm"]["n_turbines"]), - } - self._n_turbines = self.plant_parameters["wind_farm"]["n_turbines"] - else: - self._n_turbines = 0 - - # Solar farm parameters - if self._has_solar_component: - self.plant_parameters["solar_farm"] = {"capacity": h_dict["solar_farm"]["capacity"]} - - # Battery parameters - if self._has_battery_component: - self.plant_parameters["battery"] = { - "power_capacity": h_dict["battery"]["size"], - "energy_capacity": h_dict["battery"]["energy_capacity"], - "charge_rate": h_dict["battery"]["charge_rate"], - "discharge_rate": h_dict["battery"]["discharge_rate"], - "allow_grid_power_consumption": h_dict["battery"].get( - "allow_grid_power_consumption", False - ), - } - - # Electrolyzer parameters (placeholder for future electrolyzer parameters) - if self._has_hydrogen_component: - self.plant_parameters["hydrogen"] = {} + self.component_names = h_dict["component_names"] + self.component_types = {c: h_dict[c]["component_type"] for c in self.component_names} + + # Extract parameters for various component types + for c in self.component_names: + c_type = self.component_types[c] + if c_type in hercules_wind_types: + self.plant_parameters[c] = { + "type": "wind", # needed? + "component_category": "generator", + "capacity": h_dict[c]["capacity"], + "n_turbines": h_dict[c]["n_turbines"], + "turbines": range(h_dict[c]["n_turbines"]), + } + elif c_type in hercules_solar_types: + self.plant_parameters[c] = { + "type": "solar", + "component_category": "generator", + "capacity": h_dict[c]["capacity"], + } + elif c_type in hercules_battery_types: + self.plant_parameters[c] = { + "type": "battery", + "component_category": "storage", + "power_capacity": h_dict[c]["size"], + "energy_capacity": h_dict[c]["energy_capacity"], + "charge_rate": h_dict[c]["charge_rate"], + "discharge_rate": h_dict[c]["discharge_rate"], + "allow_grid_charging": h_dict[c].get("allow_grid_power_consumption", True), + "state_of_charge_max": h_dict[c].get("max_SOC", 1.0), + "state_of_charge_min": h_dict[c].get("min_SOC", 0.0), + } + elif c_type in hercules_hydrogen_types: + self.plant_parameters[c] = {"type": "hydrogen", "component_category": "load"} + elif c_type in hercules_thermal_types: + self.plant_parameters[c] = {"type": "thermal", "component_category": "generator"} + else: + raise ValueError(f"Component '{c}' has unrecognized type '{c_type}' for Hycon.") # Pre-compute LMP keys to avoid string formatting in get_measurements self._lmp_da_keys = tuple(f"lmp_da_{h:02d}" for h in range(24)) def check_controls(self, controls_dict): available_controls = [ - "wind_power_setpoints", - "solar_power_setpoint", - "battery_power_setpoint", + "power_setpoint", ] - for k in controls_dict.keys(): - if k not in available_controls: - raise ValueError("Setpoint " + k + " is not available in this configuration.") - if k == "wind_power_setpoints": - if len(controls_dict[k]) != self._n_turbines: - raise ValueError( - "Number of wind power setpoints ({0})".format(len(controls_dict[k])) - + " must match number of turbines ({0}).".format(self._n_turbines) - ) + # Check valid control keys _for each component_ on the hybrid plant + for c in controls_dict.keys(): + for k in controls_dict[c].keys(): + if k not in available_controls: + raise ValueError("Setpoint " + k + " is not available in this configuration.") def get_measurements(self, h_dict): time = h_dict["time"] @@ -92,110 +103,84 @@ def get_measurements(self, h_dict): } total_power = 0.0 - - # Basic wind quantities - if self._has_wind_component: - measurements["wind_farm"] = { - "turbine_powers": h_dict["wind_farm"]["turbine_powers"], - "wind_directions": [h_dict["wind_farm"]["wind_direction_mean"]] * self._n_turbines, - # TODO: wind_speeds? - } - total_power += sum(measurements["wind_farm"]["turbine_powers"]) - - # Basic solar quantities - if self._has_solar_component: - measurements["solar_farm"] = { - "power": h_dict["solar_farm"]["power"], - "direct_normal_irradiance": h_dict["solar_farm"]["dni"], - "angle_of_incidence": h_dict["solar_farm"]["aoi"], - } - total_power += measurements["solar_farm"]["power"] - - # Basic battery quantities - if self._has_battery_component: - measurements["battery"] = { - "power": h_dict["battery"]["power"], - "state_of_charge": h_dict["battery"]["soc"], - } - total_power += measurements["battery"]["power"] - - # Basic hydrogen quantities - if self._has_hydrogen_component: - measurements["hydrogen"] = { - "production_rate": h_dict["electrolyzer"]["H2_mfr"], - } - - # Handle external signals (parse and pass to individual components) - if "external_signals" in h_dict: - if "plant_power_reference" in h_dict["external_signals"]: - measurements["plant_power_reference"] = h_dict["external_signals"][ - "plant_power_reference" - ] - - if "wind_power_reference" in h_dict["external_signals"] and self._has_wind_component: - measurements["wind_farm"]["power_reference"] = h_dict["external_signals"][ - "wind_power_reference" - ] - - if "solar_power_reference" in h_dict["external_signals"] and self._has_solar_component: - measurements["solar_farm"]["power_reference"] = h_dict["external_signals"][ - "solar_power_reference" - ] - - if self._has_battery_component: - if "battery_power_reference" in h_dict["external_signals"]: - measurements["battery"]["power_reference"] = h_dict["external_signals"][ - "battery_power_reference" + local_power = 0.0 + + # Loop over components in simulation + for c in h_dict["component_names"]: + component_power = h_dict[c]["power"] + total_power += component_power + if self.plant_parameters[c]["component_category"] in ["generator", "storage"]: + # TODO: Do we need another that excludes storage? + local_power += component_power + component_measurements = {"power": component_power} + for k, v in hercules_data_channel_map.items(): + if k in h_dict[c]: + component_measurements[v] = h_dict[c][k] + + # Assign to main measurements dictionary + measurements[c] = component_measurements + + # Record total power + measurements["total_power"] = total_power + measurements["local_power"] = local_power + + ## Handle external signals (somewhat hardcoded; can add more as needed) + measurements["plant_power_reference"] = h_dict["external_signals"].get( + "plant_power_reference", None + ) + + # Special handling for wind directions + for c in h_dict["component_names"]: + if self.component_types[c] in hercules_wind_types: + measurements[c]["wind_directions"] = [ + h_dict[c]["wind_direction_mean"] + ] * self.plant_parameters[c]["n_turbines"] + + # Handle a variety of external_signals + if "hydrogen_reference" in h_dict["external_signals"]: + for c in h_dict["component_names"]: + if self.component_types[c] in hercules_hydrogen_types: + measurements[c]["hydrogen_production_reference"] = h_dict["external_signals"][ + "hydrogen_reference" ] - if "hydrogen_reference" in h_dict["external_signals"] and self._has_hydrogen_component: - measurements["hydrogen"]["power_reference"] = h_dict["external_signals"][ - "hydrogen_reference" - ] - - # Grid price information (using pre-computed keys for performance) - if "lmp_da_00" in h_dict["external_signals"]: - measurements["DA_LMP_24hours"] = [ - h_dict["external_signals"][k] for k in self._lmp_da_keys - ] - if "lmp_da" in h_dict["external_signals"]: - measurements["DA_LMP"] = h_dict["external_signals"]["lmp_da"] - if "lmp_rt" in h_dict["external_signals"]: - measurements["RT_LMP"] = h_dict["external_signals"]["lmp_rt"] - - # Special handling for forecast elements - for k in h_dict["external_signals"].keys(): - if "forecast" in k: - measurements["forecast"][k] = h_dict["external_signals"][k] + # Grid price information (using pre-computed keys for performance) + if "lmp_da_00" in h_dict["external_signals"]: + measurements["DA_LMP_24hours"] = [ + h_dict["external_signals"][k] for k in self._lmp_da_keys + ] + measurements["DA_LMP"] = h_dict["external_signals"].get("lmp_da", None) # TODO: used? + measurements["RT_LMP"] = h_dict["external_signals"].get("lmp_rt", None) - measurements["total_power"] = total_power + # Special handling for forecast elements + for k in h_dict["external_signals"].keys(): + if "forecast" in k: + measurements["forecast"][k] = h_dict["external_signals"][k] + + # TODO: How to prescribe an override signal for one or more components? return measurements def send_controls( self, h_dict, - wind_power_setpoints=None, - solar_power_setpoint=None, - battery_power_setpoint=None, + controls_dict, ): - if wind_power_setpoints is None: - wind_power_setpoints = [POWER_SETPOINT_DEFAULT] * self._n_turbines - if solar_power_setpoint is None: - solar_power_setpoint = POWER_SETPOINT_DEFAULT - if battery_power_setpoint is None: - battery_power_setpoint = 0.0 - - if self._has_wind_component: - # Set wind power setpoints - h_dict["wind_farm"]["turbine_power_setpoints"] = wind_power_setpoints - - if self._has_solar_component: - # Set solar power setpoint - h_dict["solar_farm"]["power_setpoint"] = solar_power_setpoint - - if self._has_battery_component: - # Set battery power setpoint (positive for discharge) - h_dict["battery"]["power_setpoint"] = battery_power_setpoint + controls_dict = copy.deepcopy(controls_dict) + # Translate controls_dict as needed + for c in self.component_names: + if c in controls_dict: + c_type = self.component_types[c] + if c_type in hercules_wind_types: + if "power_setpoint" not in controls_dict[c]: + raise ValueError( + "Missing required control 'power_setpoint' for wind component " + + c + + "." + ) + controls_dict[c]["turbine_power_setpoints"] = controls_dict[c].pop( + "power_setpoint" + ) + h_dict[c] = h_dict[c] | controls_dict[c] return h_dict diff --git a/hycon/interfaces/hercules_v1_interface.py b/hycon/interfaces/hercules_v1_interface.py index 17ab6924..c3ac9b76 100644 --- a/hycon/interfaces/hercules_v1_interface.py +++ b/hycon/interfaces/hercules_v1_interface.py @@ -6,13 +6,16 @@ class HerculesV1ADInterface(InterfaceBase): def __init__(self, hercules_dict): super().__init__() - self.dt = hercules_dict["dt"] - self.n_turbines = hercules_dict["controller"]["num_turbines"] - self.turbines = range(self.n_turbines) - # Grab name of wind farm (assumes there is only one!) self.wf_name = list(hercules_dict["hercules_comms"]["amr_wind"].keys())[0] + self.dt = hercules_dict["dt"] + # Bit of a hack here, since num_turbines no longer in controller parameters + self.n_turbines = len( + hercules_dict["hercules_comms"]["amr_wind"][self.wf_name]["turbine_powers"] + ) + self.turbines = range(self.n_turbines) + # Assign plant parameters for controller use self.plant_parameters = {"n_turbines": self.n_turbines} @@ -54,21 +57,18 @@ def get_measurements(self, hercules_dict): return measurements def check_controls(self, controls_dict): - available_controls = ["yaw_angles", "power_setpoints"] - - for k in controls_dict.keys(): - if k not in available_controls: - raise ValueError("Setpoint " + k + " is not available in this configuration.") - if len(controls_dict[k]) != self.n_turbines: - raise ValueError( - "Length of setpoint " + k + " does not match the number of turbines." - ) + available_controls = ["yaw_angles", "power_setpoint"] - def send_controls(self, hercules_dict, yaw_angles=None, power_setpoints=None): - if yaw_angles is None: - yaw_angles = [-1000] * self.n_turbines - if power_setpoints is None: - power_setpoints = [POWER_SETPOINT_DEFAULT] * self.n_turbines + for c in controls_dict.keys(): + for k in controls_dict[c].keys(): + if k not in available_controls: + raise ValueError("Setpoint " + k + " is not available in this configuration.") + + def send_controls(self, hercules_dict, controls_dict): + yaw_angles = controls_dict["wind_farm"].get("yaw_angles", [-1000] * self.n_turbines) + power_setpoints = controls_dict["wind_farm"].get( + "power_setpoint", [POWER_SETPOINT_DEFAULT] * self.n_turbines + ) hercules_dict["hercules_comms"]["amr_wind"][self.wf_name]["turbine_yaw_angles"] = yaw_angles hercules_dict["hercules_comms"]["amr_wind"][self.wf_name]["turbine_power_setpoints"] = ( @@ -113,7 +113,9 @@ def __init__(self, hercules_dict): for i in hercules_comms: if tech_keys[2] in i.split("_"): self.wind_name = list(hercules_dict["hercules_comms"]["amr_wind"].keys())[0] - self.n_turbines = hercules_dict["controller"]["num_turbines"] + self.n_turbines = len( + hercules_dict["hercules_comms"]["amr_wind"][self.wind_name]["turbine_powers"] + ) self.turbines = range(self.n_turbines) self._has_wind_component = True self.plant_parameters["wind_farm"] = {"n_turbines": self.n_turbines} @@ -201,43 +203,41 @@ def get_measurements(self, hercules_dict): def check_controls(self, controls_dict): available_controls = [ - "wind_power_setpoints", - "solar_power_setpoint", - "battery_power_setpoint", + "power_setpoint", + "yaw_angles", ] - for k in controls_dict.keys(): - if k not in available_controls: - raise ValueError("Setpoint " + k + " is not available in this configuration.") - if k == "wind_power_setpoints": - if len(controls_dict[k]) != self.n_turbines: - raise ValueError( - "Number of wind power setpoints must match number of turbines." - ) + for c in controls_dict.keys(): + for k in controls_dict[c].keys(): + if k not in available_controls: + raise ValueError("Setpoint " + k + " is not available in this configuration.") def send_controls( self, hercules_dict, - wind_power_setpoints=None, - solar_power_setpoint=None, - battery_power_setpoint=None, + controls_dict, ): - if wind_power_setpoints is None: - wind_power_setpoints = [POWER_SETPOINT_DEFAULT] * self.n_turbines - if solar_power_setpoint is None: - solar_power_setpoint = POWER_SETPOINT_DEFAULT - if battery_power_setpoint is None: - battery_power_setpoint = 0.0 - - hercules_dict["hercules_comms"]["amr_wind"][self.wind_name]["turbine_power_setpoints"] = ( - wind_power_setpoints - ) - hercules_dict["py_sims"]["inputs"].update( - { - "battery_signal": -battery_power_setpoint, - "solar_setpoint_mw": solar_power_setpoint / 1000, - } # Convert to MW - ) + if self._has_wind_component: + wind_power_setpoints = controls_dict["wind_farm"].get( + "power_setpoint", [POWER_SETPOINT_DEFAULT] * self.n_turbines + ) + hercules_dict["hercules_comms"]["amr_wind"][self.wind_name][ + "turbine_power_setpoints" + ] = wind_power_setpoints + + if self._has_solar_component: + solar_power_setpoint = controls_dict["solar_farm"].get( + "power_setpoint", POWER_SETPOINT_DEFAULT + ) + hercules_dict["py_sims"]["inputs"].update( + {"solar_setpoint_mw": solar_power_setpoint / 1000} + ) # Convert to MW + + if self._has_battery_component: + battery_power_setpoint = controls_dict["battery"].get("battery_power_setpoint", 0.0) + hercules_dict["py_sims"]["inputs"].update( + {"battery_signal": -battery_power_setpoint} + ) # Negative because of convention in battery sim return hercules_dict @@ -289,12 +289,15 @@ def get_measurements(self, hercules_dict): def check_controls(self, controls_dict): available_controls = ["power_setpoint"] - for k in controls_dict.keys(): - if k not in available_controls: - raise ValueError("Setpoint " + k + " is not available in this configuration.") + for c in controls_dict.keys(): + for k in controls_dict[c].keys(): + if k not in available_controls: + raise ValueError("Setpoint " + k + " is not available in this configuration.") - def send_controls(self, hercules_dict, power_setpoint=0): - hercules_dict["py_sims"]["inputs"].update({"battery_signal": -power_setpoint}) + def send_controls(self, hercules_dict, controls_dict): + hercules_dict["py_sims"]["inputs"].update( + {"battery_signal": -controls_dict["battery"].get("power_setpoint", 0.0)} + ) return hercules_dict diff --git a/hycon/interfaces/interface_base.py b/hycon/interfaces/interface_base.py index c2e6e490..d2b3b9b8 100644 --- a/hycon/interfaces/interface_base.py +++ b/hycon/interfaces/interface_base.py @@ -53,4 +53,4 @@ def controller_parameters(self): @controller_parameters.setter def controller_parameters(self, value): - self._controller_parameters = value + raise AttributeError("Shouldn't be called! Deprecated!") diff --git a/pyproject.toml b/pyproject.toml index 2b25e448..fb85ae27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "hycon" -version = "0.7.1" +version = "0.8" description = "Hybrid power plant controller." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/battery_controllers_test.py b/tests/battery_controllers_test.py new file mode 100644 index 00000000..875d14ec --- /dev/null +++ b/tests/battery_controllers_test.py @@ -0,0 +1,244 @@ +import numpy as np +from hycon.controllers import ( + BatteryController, + BatteryPriceSOCController, +) +from hycon.interfaces import ( + HerculesInterface, +) + + +def test_BatteryController(test_hercules_dict): + test_hercules_dict["component_names"] = ["battery"] + + test_interface = HerculesInterface(test_hercules_dict) + test_controller = BatteryController(test_interface, "battery", {"k_batt": 0.1}) + + # Test when starting with 0 power output + power_ref = 1000 + test_hercules_dict["battery"]["power"] = 0 + test_hercules_dict["battery"]["soc"] = 0.3 + test_hercules_dict["battery"]["power_reference"] = power_ref + test_controller.step(test_hercules_dict) + out_0 = test_controller._controls_dict["battery"]["power_setpoint"] + assert 0 < out_0 < power_ref + + # Test that increasing the gain increases the control response + test_controller = BatteryController(test_interface, "battery", {"k_batt": 0.5}) + test_controller.step(test_hercules_dict) + out_1 = test_controller._controls_dict["battery"]["power_setpoint"] + assert out_0 < out_1 < power_ref + + # Decreasing the gain slows the response + test_controller = BatteryController(test_interface, "battery", {"k_batt": 0.01}) + test_controller.step(test_hercules_dict) + out_2 = test_controller._controls_dict["battery"]["power_setpoint"] + assert 0 < out_2 < out_0 + + # More complex test for smoothing capabilities (mid-low gain) + power_refs_in = np.tile(np.array([1000.0, -1000.0]), 5) + power_refs_out = np.zeros_like(power_refs_in) + test_controller = BatteryController(test_interface, "battery", {"k_batt": 0.1}) + + battery_power = 0 + for i, pr_in in enumerate(power_refs_in): + test_hercules_dict["external_signals"]["plant_power_reference"] = pr_in + test_hercules_dict["battery"]["power"] = -battery_power + test_hercules_dict["time"] += 1 + out = test_controller.step(test_hercules_dict) + battery_power = out["battery"]["power_setpoint"] + power_refs_out[i] = battery_power + + assert (power_refs_out > -1000.0).all() + assert (power_refs_out < 1000.0).all() + + # Test SOC-based clipping + clipping_threshold_0 = [0.0, 0.0, 1.0, 1.0] # No clipping + clipping_threshold_1 = [0.1, 0.2, 0.8, 0.9] # Clipping at 10%--20% and 80%--90% + clipping_threshold_2 = [0.0, 0.5, 0.5, 1.0] # Clipping throughout + + # at 30% SOC, all should match if power reference is small + test_hercules_dict["battery"]["power"] = 0.0 + test_hercules_dict["battery"]["soc"] = 0.3 + test_hercules_dict["external_signals"]["plant_power_reference"] = power_ref + test_controller_0 = BatteryController( + test_interface, + "battery", + {"clipping_thresholds": clipping_threshold_0}, + ) + test_controller_0.step(test_hercules_dict) + out_0 = test_controller_0._controls_dict["battery"]["power_setpoint"] + + test_controller_1 = BatteryController( + test_interface, + "battery", + {"clipping_thresholds": clipping_threshold_1}, + ) + test_controller_1.step(test_hercules_dict) + out_1 = test_controller_1._controls_dict["battery"]["power_setpoint"] + + test_controller_2 = BatteryController( + test_interface, + "battery", + {"clipping_thresholds": clipping_threshold_2}, + ) + test_controller_2.step(test_hercules_dict) + out_2 = test_controller_2._controls_dict["battery"]["power_setpoint"] + + assert out_0 == out_1 + assert out_0 == out_0 + + # Clipping comes into play in 2 when the reference is large + test_controller_0.x = 0 + test_controller_1.x = 0 + test_controller_2.x = 0 + test_hercules_dict["battery"]["power_reference"] = 20000 + test_controller_0.step(test_hercules_dict) + out_0 = test_controller_0._controls_dict["battery"]["power_setpoint"] + test_controller_1.step(test_hercules_dict) + out_1 = test_controller_1._controls_dict["battery"]["power_setpoint"] + test_controller_2.step(test_hercules_dict) + out_2 = test_controller_2._controls_dict["battery"]["power_setpoint"] + + assert out_0 == out_1 + assert out_0 > out_2 + + # at 85% SOC and large reference, 1 should be clipped + test_hercules_dict["battery"]["power"] = 0.0 + test_hercules_dict["battery"]["soc"] = 0.85 + test_controller_0.x = 0 + test_controller_1.x = 0 + test_controller_0.step(test_hercules_dict) + out_0 = test_controller_0._controls_dict["battery"]["power_setpoint"] + test_controller_1.step(test_hercules_dict) + out_1 = test_controller_1._controls_dict["battery"]["power_setpoint"] + + assert out_0 > out_1 + + # Check upper and lower limits work (using the no-clipping controller for clarity) + measurements_dict = { + "battery": { + "state_of_charge": 0.5, + "power": 0.0, + "power_reference": 100, + "power_limit_lower": -50, + "power_limit_upper": 50, + } + } + test_setpoint = test_controller_0.compute_controls(measurements_dict)["battery"][ + "power_setpoint" + ] + np.isclose(test_setpoint, 50) + + measurements_dict["battery"]["power_reference"] = -100 + test_setpoint = test_controller_0.compute_controls(measurements_dict)["battery"][ + "power_setpoint" + ] + np.isclose(test_setpoint, -50) + + +def test_BatteryPriceSOCController_init(test_hercules_dict): + test_interface = HerculesInterface(test_hercules_dict) + + # Initialize controller + test_controller = BatteryPriceSOCController(test_interface, "battery") + + # Check that the controller is initialized correctly + assert test_controller.rated_power_charging == test_hercules_dict["battery"]["charge_rate"] + assert ( + test_controller.rated_power_discharging == test_hercules_dict["battery"]["discharge_rate"] + ) + + +def test_BatteryPriceSOCController_compute_controls(test_hercules_dict): + # This test originally written assuming 4-hour battery + + test_interface = HerculesInterface(test_hercules_dict) + + # Initialize controller + test_controller = BatteryPriceSOCController(test_interface, "battery") + + # For testing, overwrite the high_soc and low_soc + test_controller.high_soc = 0.8 + test_controller.low_soc = 0.2 + + DA_LMP_test = [i for i in range(24)] # Price is from 0 to 23 + + # Test the high soc condition when RT_LMP is below the charge price + # but above the low_soc_price. SOC is too high to justify charging. + measurement_dict = { + "battery": {"state_of_charge": 0.9}, + "RT_LMP": 2.5, + "DA_LMP_24hours": DA_LMP_test, + } + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["battery"]["power_setpoint"] == 0.0 + + # Now, change RT_LMP to be below the 1 hour low price + measurement_dict["RT_LMP"] = -0.5 + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["battery"]["power_setpoint"] == -test_controller.rated_power_charging + + # Test the high price / low soc condition + measurement_dict = { + "battery": {"state_of_charge": 0.1}, + "RT_LMP": 22, + "DA_LMP_24hours": DA_LMP_test, + } + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["battery"]["power_setpoint"] == 0.0 + + measurement_dict["RT_LMP"] = 25 + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["battery"]["power_setpoint"] == test_controller.rated_power_discharging + + # Middle SOC tests + measurement_dict = { + "battery": {"state_of_charge": 0.5}, + "RT_LMP": 2, + "DA_LMP_24hours": DA_LMP_test, + } + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["battery"]["power_setpoint"] == -test_controller.rated_power_charging + + measurement_dict["RT_LMP"] = 22 + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["battery"]["power_setpoint"] == test_controller.rated_power_discharging + + measurement_dict["RT_LMP"] = 10 + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["battery"]["power_setpoint"] == 0.0 + + +def test_BatteryPriceSOCController_compute_controls_2_hour_duration(test_hercules_dict): + # Set the duration to 2 hours + test_hercules_dict["battery"]["energy_capacity"] = 20.0e3 + test_interface = HerculesInterface(test_hercules_dict) + + # Initialize controller + test_controller = BatteryPriceSOCController(test_interface, "battery") + + # For testing, overwrite the high_soc and low_soc + test_controller.high_soc = 0.8 + test_controller.low_soc = 0.2 + + DA_LMP_test = [i for i in range(24)] # Price is from 0 to 23 + + # Test the in-between bottom 1 and bottom d prices + measurement_dict = { + "battery": {"state_of_charge": 0.5}, + "RT_LMP": 0.5, + "DA_LMP_24hours": DA_LMP_test, + } + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["battery"]["power_setpoint"] == -test_controller.rated_power_charging + + # Now raise the state of charge to 0.85 + measurement_dict["battery"]["state_of_charge"] = 0.85 + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["battery"]["power_setpoint"] == 0.0 + + # Now drop the RT_LMP to -.5 (Going below bottom 1 price) + measurement_dict["RT_LMP"] = -0.5 + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["battery"]["power_setpoint"] == -test_controller.rated_power_charging diff --git a/tests/battery_test.py b/tests/battery_test.py deleted file mode 100644 index 511cf385..00000000 --- a/tests/battery_test.py +++ /dev/null @@ -1,111 +0,0 @@ -from hycon.controllers.battery_controller import ( - BatteryPriceSOCController, -) -from hycon.interfaces import HerculesInterface - - -def test_BatteryPriceSOCController_init(test_hercules_dict): - test_interface = HerculesInterface(test_hercules_dict) - - # Initialize controller - test_controller = BatteryPriceSOCController(test_interface, test_hercules_dict) - - # Check that the controller is initialized correctly - assert test_controller.rated_power_charging == test_hercules_dict["battery"]["charge_rate"] - assert ( - test_controller.rated_power_discharging == test_hercules_dict["battery"]["discharge_rate"] - ) - - -def test_BatteryPriceSOCController_compute_controls(test_hercules_dict): - # This test originally written assuming 4-hour battery - - test_interface = HerculesInterface(test_hercules_dict) - - # Initialize controller - test_controller = BatteryPriceSOCController(test_interface, test_hercules_dict) - - # For testing, overwrite the high_soc and low_soc - test_controller.high_soc = 0.8 - test_controller.low_soc = 0.2 - - DA_LMP_test = [i for i in range(24)] # Price is from 0 to 23 - - # Test the high soc condition when RT_LMP is below the charge price - # but above the low_soc_price. SOC is too high to justify charging. - measurement_dict = { - "battery": {"state_of_charge": 0.9}, - "RT_LMP": 2.5, - "DA_LMP_24hours": DA_LMP_test, - } - controls_dict = test_controller.compute_controls(measurement_dict) - assert controls_dict["power_setpoint"] == 0.0 - - # Now, change RT_LMP to be below the 1 hour low price - measurement_dict["RT_LMP"] = -0.5 - controls_dict = test_controller.compute_controls(measurement_dict) - assert controls_dict["power_setpoint"] == -test_controller.rated_power_charging - - # Test the high price / low soc condition - measurement_dict = { - "battery": {"state_of_charge": 0.1}, - "RT_LMP": 22, - "DA_LMP_24hours": DA_LMP_test, - } - controls_dict = test_controller.compute_controls(measurement_dict) - assert controls_dict["power_setpoint"] == 0.0 - - measurement_dict["RT_LMP"] = 25 - controls_dict = test_controller.compute_controls(measurement_dict) - assert controls_dict["power_setpoint"] == test_controller.rated_power_discharging - - # Middle SOC tests - measurement_dict = { - "battery": {"state_of_charge": 0.5}, - "RT_LMP": 2, - "DA_LMP_24hours": DA_LMP_test, - } - controls_dict = test_controller.compute_controls(measurement_dict) - assert controls_dict["power_setpoint"] == -test_controller.rated_power_charging - - measurement_dict["RT_LMP"] = 22 - controls_dict = test_controller.compute_controls(measurement_dict) - assert controls_dict["power_setpoint"] == test_controller.rated_power_discharging - - measurement_dict["RT_LMP"] = 10 - controls_dict = test_controller.compute_controls(measurement_dict) - assert controls_dict["power_setpoint"] == 0.0 - - -def test_BatteryPriceSOCController_compute_controls_2_hour_duration(test_hercules_dict): - # Set the duration to 2 hours - test_hercules_dict["battery"]["energy_capacity"] = 20.0e3 - test_interface = HerculesInterface(test_hercules_dict) - - # Initialize controller - test_controller = BatteryPriceSOCController(test_interface, test_hercules_dict) - - # For testing, overwrite the high_soc and low_soc - test_controller.high_soc = 0.8 - test_controller.low_soc = 0.2 - - DA_LMP_test = [i for i in range(24)] # Price is from 0 to 23 - - # Test the in-between bottom 1 and bottom d prices - measurement_dict = { - "battery": {"state_of_charge": 0.5}, - "RT_LMP": 0.5, - "DA_LMP_24hours": DA_LMP_test, - } - controls_dict = test_controller.compute_controls(measurement_dict) - assert controls_dict["power_setpoint"] == -test_controller.rated_power_charging - - # Now raise the state of charge to 0.85 - measurement_dict["battery"]["state_of_charge"] = 0.85 - controls_dict = test_controller.compute_controls(measurement_dict) - assert controls_dict["power_setpoint"] == 0.0 - - # Now drop the RT_LMP to -.5 (Going below bottom 1 price) - measurement_dict["RT_LMP"] = -0.5 - controls_dict = test_controller.compute_controls(measurement_dict) - assert controls_dict["power_setpoint"] == -test_controller.rated_power_charging diff --git a/tests/conftest.py b/tests/conftest.py index 13d8d109..0e6e0fd9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,13 +8,6 @@ def test_hercules_v1_dict(): return { "dt": 1, "time": 0, - "controller": { - "num_turbines": 2, - "initial_conditions": {"yaw": [270.0, 270.0]}, - "nominal_plant_power_kW": 10000, - "nominal_hydrogen_rate_kgps": 0.1, - "hydrogen_controller_gain": 1.0, - }, "hercules_comms": { "amr_wind": { "test_farm": { @@ -48,21 +41,23 @@ def test_hercules_dict(): "dt": 1, "time": 0, "plant": {"interconnect_limit": None}, - "controller": { - "test_controller_parameter": 1.0, - }, "wind_farm": { "n_turbines": 2, "capacity": 10000.0, "wind_direction_mean": 271.0, "turbine_powers": [4000.0, 4001.0], + "power": 8001.0, "wind_speed": 10.0, + "component_type": "WindFarm", + "component_category": "generator", }, "solar_farm": { "capacity": 1000.0, "power": 1000.0, # kW "dni": 1000.0, "aoi": 30.0, + "component_type": "SolarPySAMPVWatts", + "component_category": "generator", }, "battery": { "size": 10.0e3, @@ -71,20 +66,29 @@ def test_hercules_dict(): "soc": 0.3, "charge_rate": 20e3, "discharge_rate": 15e3, + "component_type": "BatterySimple", + "component_category": "storage", + "max_SOC": 0.95, + "min_SOC": 0.05, + "allow_grid_charging": False, }, "electrolyzer": { "H2_mfr": 0.03, + "component_type": "ElectrolyzerPlant", + "component_category": "load", + "power": 500.0, }, "external_signals": { - "wind_power_reference": 1000.0, - "solar_power_reference": 800.0, - "battery_power_reference": 0.0, + # "wind_power_reference": 1000.0, + # "solar_power_reference": 800.0, + # "battery_power_reference": 0.0, "plant_power_reference": 1000.0, "forecast_ws_mean_0": 8.0, "forecast_ws_mean_1": 8.1, "ws_median_0": 8.1, "hydrogen_reference": 0.02, }, + "component_names": ["wind_farm", "solar_farm", "battery", "electrolyzer"], } @@ -98,7 +102,6 @@ def __init__(self): self.dt = 1.0 # Set up stand-in plant parameters and controller parameters self.plant_parameters = {"n_turbines": 2} - self.controller_parameters = {} def get_measurements(self): pass diff --git a/tests/controller_base_test.py b/tests/controller_base_test.py index d11c15bb..c3a2510f 100644 --- a/tests/controller_base_test.py +++ b/tests/controller_base_test.py @@ -1,4 +1,5 @@ import pytest +from hycon.controllers import WindFarmPowerTrackingController from hycon.controllers.controller_base import ControllerBase @@ -19,6 +20,9 @@ class InheritanceTestClassGood(ControllerBase): def __init__(self, interface): super().__init__(interface) + def set_controller_parameters(self): + pass + def compute_controls(self): pass @@ -42,3 +46,10 @@ def test_inherited_methods(test_interface_standin): _ = InheritanceTestClassBad(test_interface_standin) _ = InheritanceTestClassGood(test_interface_standin) + + +def test_inherited_instantiation(test_interface_standin): + """ + Check that a subclass of InterfaceBase can be instantiated. + """ + _ = WindFarmPowerTrackingController(interface=test_interface_standin, cname="test_cname") diff --git a/tests/controller_library_test.py b/tests/controller_library_test.py deleted file mode 100644 index 7376ad90..00000000 --- a/tests/controller_library_test.py +++ /dev/null @@ -1,704 +0,0 @@ -import numpy as np -import pandas as pd -import pytest - -# import pandas as pd -from hycon.controllers import ( - BatteryController, - BatteryPassthroughController, - HybridSupervisoryControllerBaseline, - HybridSupervisoryControllerMultiRef, - HydrogenPlantController, - LookupBasedWakeSteeringController, - SolarPassthroughController, - WindFarmPowerDistributingController, - WindFarmPowerTrackingController, -) -from hycon.controllers.wind_farm_power_tracking_controller import POWER_SETPOINT_DEFAULT -from hycon.interfaces import ( - HerculesBatteryInterface, -) - - -def test_controller_instantiation(test_interface_standin, test_hercules_v1_dict): - """ - Tests whether all controllers can be imported correctly and that they - each implement the required methods specified by ControllerBase. - """ - _ = LookupBasedWakeSteeringController( - interface=test_interface_standin, input_dict=test_hercules_v1_dict - ) - _ = WindFarmPowerDistributingController( - interface=test_interface_standin, input_dict=test_hercules_v1_dict - ) - _ = WindFarmPowerTrackingController( - interface=test_interface_standin, input_dict=test_hercules_v1_dict - ) - _ = HybridSupervisoryControllerBaseline( - interface=test_interface_standin, - input_dict=test_hercules_v1_dict, - wind_controller=1, # Override error raised for empty controllers - ) - _ = SolarPassthroughController( - interface=test_interface_standin, input_dict=test_hercules_v1_dict - ) - _ = BatteryPassthroughController( - interface=test_interface_standin, input_dict=test_hercules_v1_dict - ) - _ = BatteryController(interface=test_interface_standin, input_dict=test_hercules_v1_dict) - - -def test_LookupBasedWakeSteeringController(test_hercules_v1_dict, test_interface_hercules_ad): - # No lookup table passed; simply passes through wind direction to yaw angles - test_controller = LookupBasedWakeSteeringController( - interface=test_interface_hercules_ad, input_dict=test_hercules_v1_dict - ) - - # Check that the controller can be stepped - test_hercules_v1_dict["time"] = 20 - test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) - test_angles = np.array( - test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_yaw_angles"] - ) - wind_directions = np.array( - test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_wind_directions"] - ) - assert np.allclose(test_angles, wind_directions) - - # Lookup table that specified 20 degree offset for T000, 10 degree offset for T001 for all - # wind directions - test_offsets = np.array([20.0, 10.0]) - df_opt_test = pd.DataFrame( - data={ - "wind_direction": [220.0, 220.0, 320.0, 320.0], - "wind_speed": [0.0, 20.0, 0.0, 20.0], - "yaw_angles_opt": [test_offsets] * 4, - "turbulence_intensity": [0.06] * 4, - } - ) - test_controller = LookupBasedWakeSteeringController( - interface=test_interface_hercules_ad, input_dict=test_hercules_v1_dict, df_yaw=df_opt_test - ) - - test_hercules_v1_dict["time"] = 20 - test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) - test_angles = np.array( - test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_yaw_angles"] - ) - wind_directions = np.array( - test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_wind_directions"] - ) - assert np.allclose(test_angles, wind_directions - test_offsets) - - -def test_WindFarmPowerDistributingController(test_hercules_v1_dict, test_interface_hercules_ad): - test_controller = WindFarmPowerDistributingController( - interface=test_interface_hercules_ad, input_dict=test_hercules_v1_dict - ) - - # Default behavior when no power reference is given - test_hercules_v1_dict["time"] = 20 - test_hercules_v1_dict["external_signals"] = {} - test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) - test_power_setpoints = np.array( - test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] - ) - assert np.allclose( - test_power_setpoints, - POWER_SETPOINT_DEFAULT / test_hercules_v1_dict["controller"]["num_turbines"], - ) - - # Test with power reference - test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 1000 - test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) - test_power_setpoints = np.array( - test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] - ) - assert np.allclose(test_power_setpoints, 500) - - # Test that ramp rate limits are applied - test_controller = WindFarmPowerDistributingController( - interface=test_interface_hercules_ad, input_dict=test_hercules_v1_dict, ramp_rate_limit=200 - ) - test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 1000 - test_controller.step(input_dict=test_hercules_v1_dict) # To initialize previous power setpoints - test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 500 - test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) - test_power_setpoints = np.array( - test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] - ) - assert np.allclose(test_power_setpoints, (1000 - 200) / 2) - - test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 2000 - test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) - test_power_setpoints = np.array( - test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] - ) - assert np.allclose(test_power_setpoints, 1000 / 2) - - -def test_WindFarmPowerTrackingController(test_hercules_v1_dict, test_interface_hercules_ad): - test_controller = WindFarmPowerTrackingController( - interface=test_interface_hercules_ad, input_dict=test_hercules_v1_dict - ) - - # Test no change to power setpoints if producing desired power - test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 1000 - test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [500, 500] - test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) - test_power_setpoints = np.array( - test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] - ) - assert np.allclose(test_power_setpoints, 500) - - # Test if power exceeds farm reference, power setpoints are reduced - test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [600, 600] - test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) - test_power_setpoints = np.array( - test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] - ) - assert ( - test_power_setpoints - <= test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] - ).all() - - # Test if power is less than farm reference, power setpoints are increased - test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [550, 400] - test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) - test_power_setpoints = np.array( - test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] - ) - assert ( - test_power_setpoints - >= test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] - ).all() - - # Test that more aggressive control leads to faster response - test_controller = WindFarmPowerTrackingController( - interface=test_interface_hercules_ad, input_dict=test_hercules_v1_dict, proportional_gain=2 - ) - test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [600, 600] - test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) - test_power_setpoints_a = np.array( - test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] - ) - assert (test_power_setpoints_a < test_power_setpoints).all() - - -def test_HybridSupervisoryControllerBaseline( - test_hercules_v1_dict, test_interface_hercules_hybrid_ad -): - # Establish lower controllers - wind_controller = WindFarmPowerTrackingController( - test_interface_hercules_hybrid_ad, test_hercules_v1_dict - ) - solar_controller = SolarPassthroughController( - test_interface_hercules_hybrid_ad, test_hercules_v1_dict - ) - battery_controller = BatteryPassthroughController( - test_interface_hercules_hybrid_ad, test_hercules_v1_dict - ) - - test_controller = HybridSupervisoryControllerBaseline( - interface=test_interface_hercules_hybrid_ad, - input_dict=test_hercules_v1_dict, - wind_controller=wind_controller, - solar_controller=solar_controller, - battery_controller=battery_controller, - ) - - solar_current = 800 - wind_current = [600, 300] - power_ref = 1000 - - # Simply test the supervisory_control method, for the time being - test_hercules_v1_dict["external_signals"]["plant_power_reference"] = power_ref - test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = ( - wind_current - ) - test_hercules_v1_dict["py_sims"]["test_solar"]["outputs"]["power_mw"] = solar_current / 1e3 - test_controller.prev_solar_power = solar_current # To override filtering - test_controller.prev_wind_power = sum(wind_current) # To override filtering - - test_controller.step(test_hercules_v1_dict) # Run the controller once to update measurements - supervisory_control_output = test_controller.supervisory_control( - test_controller._measurements_dict - ) - - # Expected outputs - wind_solar_current = sum(wind_current) + solar_current - wind_power_cmd = 20000 / 2 + sum(wind_current) - (wind_solar_current - power_ref) / 2 - solar_power_cmd = 20000 / 2 + solar_current - (wind_solar_current - power_ref) / 2 - battery_power_cmd = power_ref - wind_solar_current - - assert np.allclose( - supervisory_control_output, [wind_power_cmd, solar_power_cmd, battery_power_cmd] - ) # To charge battery - - -def test_HybridSupervisoryControllerBaseline_subsets( - test_hercules_v1_dict, test_interface_hercules_hybrid_ad -): - """ - Tests that the HybridSupervisoryControllerBaseline can be run with only - some of the wind, solar, and battery controllers. - """ - test_interface = test_interface_hercules_hybrid_ad - - # Establish lower controllers - wind_controller = WindFarmPowerTrackingController( - test_interface_hercules_hybrid_ad, test_hercules_v1_dict - ) - solar_controller = SolarPassthroughController( - test_interface_hercules_hybrid_ad, test_hercules_v1_dict - ) - battery_controller = BatteryPassthroughController( - test_interface_hercules_hybrid_ad, test_hercules_v1_dict - ) - - ## First, try with wind and solar only - test_controller = HybridSupervisoryControllerBaseline( - interface=test_interface_hercules_hybrid_ad, - input_dict=test_hercules_v1_dict, - wind_controller=wind_controller, - solar_controller=solar_controller, - battery_controller=None, - ) - - solar_current = 800 - wind_current = [600, 300] - power_ref = 1000 - - # Simply test the supervisory_control method, for the time being - test_hercules_v1_dict["external_signals"]["plant_power_reference"] = power_ref - test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = ( - wind_current - ) - test_hercules_v1_dict["py_sims"]["test_solar"]["outputs"]["power_mw"] = solar_current / 1e3 - test_controller.prev_solar_power = solar_current # To override filtering - test_controller.prev_wind_power = sum(wind_current) # To override filtering - - test_controller.step(test_hercules_v1_dict) # Run the controller once to update measurements - supervisory_control_output = test_controller.supervisory_control( - test_controller._measurements_dict - ) - - wind_solar_current = sum(wind_current) + solar_current - wind_power_cmd = sum(wind_current) - (wind_solar_current - power_ref) / 2 - solar_power_cmd = solar_current - (wind_solar_current - power_ref) / 2 - battery_power_cmd = 0 # No battery controller! - - assert np.allclose( - supervisory_control_output, [wind_power_cmd, solar_power_cmd, battery_power_cmd] - ) - - ## Next, wind and battery only - test_controller = HybridSupervisoryControllerBaseline( - interface=test_interface, - input_dict=test_hercules_v1_dict, - wind_controller=wind_controller, - solar_controller=None, - battery_controller=battery_controller, - ) - - test_controller.prev_solar_power = 0 - test_controller.prev_wind_power = sum(wind_current) # To override filtering - test_controller.step(test_hercules_v1_dict) # Run the controller once to update measurements - supervisory_control_output = test_controller.supervisory_control( - test_controller._measurements_dict - ) - - wind_power_cmd = 20000 + power_ref - solar_power_cmd = 0 # No solar controller! - battery_power_cmd = power_ref - sum(wind_current) - - assert np.allclose( - supervisory_control_output, [wind_power_cmd, solar_power_cmd, battery_power_cmd] - ) - - ## Finally, solar and battery only - test_controller = HybridSupervisoryControllerBaseline( - interface=test_interface, - input_dict=test_hercules_v1_dict, - wind_controller=None, - solar_controller=solar_controller, - battery_controller=battery_controller, - ) - - test_controller.prev_solar_power = solar_current # To override filtering - test_controller.prev_wind_power = 0 - test_controller.step(test_hercules_v1_dict) # Run the controller once to update measurements - supervisory_control_output = test_controller.supervisory_control( - test_controller._measurements_dict - ) - - wind_power_cmd = 0 # No wind controller! - solar_power_cmd = 20000 + power_ref - battery_power_cmd = power_ref - solar_current - - assert np.allclose( - supervisory_control_output, [wind_power_cmd, solar_power_cmd, battery_power_cmd] - ) - - ## Either wind or solar controller must be defined - with pytest.raises(ValueError): - _ = HybridSupervisoryControllerBaseline( - interface=test_interface, - input_dict=test_hercules_v1_dict, - wind_controller=None, - solar_controller=None, - battery_controller=battery_controller, - ) - - ## Only wind controller - test_controller = HybridSupervisoryControllerBaseline( - interface=test_interface, - input_dict=test_hercules_v1_dict, - wind_controller=wind_controller, - solar_controller=None, - battery_controller=None, - ) - - test_controller.prev_solar_power = 0 - test_controller.prev_wind_power = sum(wind_current) # To override filtering - test_controller.step(test_hercules_v1_dict) # Run the controller once to update measurements - supervisory_control_output = test_controller.supervisory_control( - test_controller._measurements_dict - ) - - wind_power_cmd = power_ref - solar_power_cmd = 0 # No solar controller! - battery_power_cmd = 0 # No battery controller! - - assert np.allclose( - supervisory_control_output, [wind_power_cmd, solar_power_cmd, battery_power_cmd] - ) - - ## Only solar controller - test_controller = HybridSupervisoryControllerBaseline( - interface=test_interface, - input_dict=test_hercules_v1_dict, - wind_controller=None, - solar_controller=solar_controller, - battery_controller=None, - ) - - test_controller.prev_solar_power = solar_current # To override filtering - test_controller.prev_wind_power = 0 - test_controller.step(test_hercules_v1_dict) # Run the controller once to update measurements - supervisory_control_output = test_controller.supervisory_control( - test_controller._measurements_dict - ) - - wind_power_cmd = 0 # No wind controller! - solar_power_cmd = power_ref - battery_power_cmd = 0 # No battery controller! - - assert np.allclose( - supervisory_control_output, [wind_power_cmd, solar_power_cmd, battery_power_cmd] - ) - - -def test_HybridSupervisoryControllerMultiRef_requirements( - test_hercules_dict, test_interface_hercules -): - test_interface = test_interface_hercules - # Check that errors are correctly raised if interconnect_limit is not set correctly - del test_interface.plant_parameters["interconnect_limit"] - with pytest.raises(KeyError): - HybridSupervisoryControllerMultiRef(test_interface, test_hercules_dict) - - test_interface.plant_parameters["interconnect_limit"] = "1" - with pytest.raises(ValueError): - HybridSupervisoryControllerMultiRef(test_interface, test_hercules_dict) - - test_interface.plant_parameters["interconnect_limit"] = -1 - with pytest.raises(ValueError): - HybridSupervisoryControllerMultiRef(test_interface, test_hercules_dict) - - -def test_HybridSupervisoryControllerMultiRef(test_hercules_dict, test_interface_hercules): - test_interface = test_interface_hercules - test_interface.plant_parameters["interconnect_limit"] = 10000.0 - - # Establish lower controllers - wind_controller = WindFarmPowerTrackingController(test_interface, test_hercules_dict) - solar_controller = SolarPassthroughController(test_interface, test_hercules_dict) - battery_controller = BatteryPassthroughController(test_interface, test_hercules_dict) - - test_controller = HybridSupervisoryControllerMultiRef( - interface=test_interface, - input_dict=test_hercules_dict, - wind_controller=wind_controller, - solar_controller=solar_controller, - battery_controller=battery_controller, - ) - - solar_current = 800 - wind_current = [600, 300] - - # Simply test the supervisory_control method, for the time being - test_hercules_dict["wind_farm"]["turbine_powers"] = wind_current - test_hercules_dict["solar_farm"]["power"] = solar_current - test_controller.prev_solar_power = solar_current # To override filtering - test_controller.prev_wind_power = sum(wind_current) # To override filtering - test_controller.step(test_hercules_dict) # Run the controller once to update measurements - - supervisory_control_output = test_controller.supervisory_control( - test_controller._measurements_dict - ) - - # Expected outputs - assert np.allclose( - supervisory_control_output, - [ - test_hercules_dict["external_signals"]["wind_power_reference"], - test_hercules_dict["external_signals"]["solar_power_reference"], - test_hercules_dict["external_signals"]["battery_power_reference"], - ], - ) # Check individual components producing according to their references - - -def test_BatteryPassthroughController(test_hercules_v1_dict, test_interface_hercules_hybrid_ad): - test_controller = BatteryPassthroughController( - test_interface_hercules_hybrid_ad, test_hercules_v1_dict - ) - - power_ref = 1000 - measurements_dict = {"battery": {"power_reference": power_ref}} - controls_dict = test_controller.compute_controls(measurements_dict) - assert controls_dict["power_setpoint"] == power_ref - - -def test_SolarPassthroughController(test_hercules_v1_dict, test_interface_hercules_hybrid_ad): - test_controller = SolarPassthroughController( - test_interface_hercules_hybrid_ad, test_hercules_v1_dict - ) - - power_ref = 1000 - measurements_dict = {"solar_farm": {"power_reference": power_ref}} - controls_dict = test_controller.compute_controls(measurements_dict) - assert controls_dict["power_setpoint"] == power_ref - - -def test_BatteryController(test_hercules_v1_dict): - test_interface = HerculesBatteryInterface(test_hercules_v1_dict) - test_controller = BatteryController(test_interface, test_hercules_v1_dict, {"k_batt": 0.1}) - - # Test when starting with 0 power output - power_ref = 1000 - test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"] = {"power": 0, "soc": 0.3} - test_hercules_v1_dict["external_signals"]["plant_power_reference"] = power_ref - test_controller.step(test_hercules_v1_dict) - out_0 = test_controller._controls_dict["power_setpoint"] - assert 0 < out_0 < power_ref - - # Test that increasing the gain increases the control response - test_controller = BatteryController(test_interface, test_hercules_v1_dict, {"k_batt": 0.5}) - test_controller.step(test_hercules_v1_dict) - out_1 = test_controller._controls_dict["power_setpoint"] - assert out_0 < out_1 < power_ref - - # Decreasing the gain slows the response - test_controller = BatteryController(test_interface, test_hercules_v1_dict, {"k_batt": 0.01}) - test_controller.step(test_hercules_v1_dict) - out_2 = test_controller._controls_dict["power_setpoint"] - assert 0 < out_2 < out_0 - - # More complex test for smoothing capabilities (mid-low gain) - power_refs_in = np.tile(np.array([1000.0, -1000.0]), 5) - power_refs_out = np.zeros_like(power_refs_in) - test_controller = BatteryController(test_interface, test_hercules_v1_dict, {"k_batt": 0.1}) - - battery_power = 0 - for i, pr_in in enumerate(power_refs_in): - test_hercules_v1_dict["external_signals"]["plant_power_reference"] = pr_in - test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"]["power"] = -battery_power - test_hercules_v1_dict["time"] += 1 - out = test_controller.step(test_hercules_v1_dict) - battery_power = out["py_sims"]["inputs"]["battery_signal"] - power_refs_out[i] = battery_power - - assert (power_refs_out > -1000.0).all() - assert (power_refs_out < 1000.0).all() - - # Test SOC-based clipping - clipping_threshold_0 = [0.0, 0.0, 1.0, 1.0] # No clipping - clipping_threshold_1 = [0.1, 0.2, 0.8, 0.9] # Clipping at 10%--20% and 80%--90% - clipping_threshold_2 = [0.0, 0.5, 0.5, 1.0] # Clipping throughout - - # at 30% SOC, all should match if power reference is small - test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"] = {"power": 0, "soc": 0.3} - test_hercules_v1_dict["external_signals"]["plant_power_reference"] = power_ref - test_controller_0 = BatteryController( - test_interface, test_hercules_v1_dict, {"clipping_thresholds": clipping_threshold_0} - ) - test_controller_0.step(test_hercules_v1_dict) - out_0 = test_controller_0._controls_dict["power_setpoint"] - - test_controller_1 = BatteryController( - test_interface, test_hercules_v1_dict, {"clipping_thresholds": clipping_threshold_1} - ) - test_controller_1.step(test_hercules_v1_dict) - out_1 = test_controller_1._controls_dict["power_setpoint"] - - test_controller_2 = BatteryController( - test_interface, test_hercules_v1_dict, {"clipping_thresholds": clipping_threshold_2} - ) - test_controller_2.step(test_hercules_v1_dict) - out_2 = test_controller_2._controls_dict["power_setpoint"] - - assert out_0 == out_1 - assert out_0 == out_0 - - # Clipping comes into play in 2 when the reference is large - test_controller_0.x = 0 - test_controller_1.x = 0 - test_controller_2.x = 0 - test_hercules_v1_dict["external_signals"]["plant_power_reference"] = 20000 - test_controller_0.step(test_hercules_v1_dict) - out_0 = test_controller_0._controls_dict["power_setpoint"] - test_controller_1.step(test_hercules_v1_dict) - out_1 = test_controller_1._controls_dict["power_setpoint"] - test_controller_2.step(test_hercules_v1_dict) - out_2 = test_controller_2._controls_dict["power_setpoint"] - - assert out_0 == out_1 - assert out_0 > out_2 - - # at 85% SOC and large reference, 1 should be clipped - test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"] = {"power": 0, "soc": 0.85} - test_controller_0.x = 0 - test_controller_1.x = 0 - test_controller_0.step(test_hercules_v1_dict) - out_0 = test_controller_0._controls_dict["power_setpoint"] - test_controller_1.step(test_hercules_v1_dict) - out_1 = test_controller_1._controls_dict["power_setpoint"] - - assert out_0 > out_1 - - -def test_HydrogenPlantController(test_hercules_v1_dict, test_interface_hercules_hybrid_ad): - """ - Tests that the HydrogenPlantController outputs a reasonable signal - """ - ## Test with only wind providing generation - wind_controller = WindFarmPowerTrackingController( - test_interface_hercules_hybrid_ad, test_hercules_v1_dict - ) - - test_controller = HydrogenPlantController( - interface=test_interface_hercules_hybrid_ad, - input_dict=test_hercules_v1_dict, - generator_controller=wind_controller, - ) - - wind_current = [600, 300] - hyrogen_ref = 0.03 - hydrogen_output = test_hercules_v1_dict["py_sims"]["test_hydrogen"]["outputs"]["H2_mfr"] - hydrogen_error = hyrogen_ref - hydrogen_output - - # Simply test the supervisory_control method, for the time being - test_hercules_v1_dict["external_signals"]["hydrogen_reference"] = hyrogen_ref - test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = ( - wind_current - ) - test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"]["power"] = 0.0 - test_hercules_v1_dict["py_sims"]["test_solar"]["outputs"]["power_mw"] = 0.0 - test_controller.filtered_power_prev = sum(wind_current) # To override filtering - - # Without removing wind power reference, wind controller can't reconcile its setpoint - with pytest.raises(KeyError): - test_controller.step(test_hercules_v1_dict) - # Remove wind power reference to allow wind controller to operate freely - del test_hercules_v1_dict["external_signals"]["wind_power_reference"] - test_controller.step(test_hercules_v1_dict) # Run the controller once to update measurements - supervisory_control_output = test_controller.supervisory_control( - test_controller._measurements_dict - ) - controller_gain = ( - test_hercules_v1_dict["controller"]["nominal_plant_power_kW"] - / test_hercules_v1_dict["controller"]["nominal_hydrogen_rate_kgps"] - * test_hercules_v1_dict["controller"]["hydrogen_controller_gain"] - ) - assert controller_gain == test_controller.K - - wind_power_cmd = sum(wind_current) + controller_gain * hydrogen_error - - assert supervisory_control_output == wind_power_cmd - - # Test with a full wind/solar/battery plant - hybrid_controller = HybridSupervisoryControllerBaseline( - interface=test_interface_hercules_hybrid_ad, - input_dict=test_hercules_v1_dict, - wind_controller=wind_controller, - solar_controller=SolarPassthroughController( - test_interface_hercules_hybrid_ad, test_hercules_v1_dict - ), - battery_controller=BatteryPassthroughController( - test_interface_hercules_hybrid_ad, test_hercules_v1_dict - ), - ) - - test_controller = HydrogenPlantController( - interface=test_interface_hercules_hybrid_ad, - input_dict=test_hercules_v1_dict, - generator_controller=hybrid_controller, - ) - - # Set up the dictionary - solar_current = 1000 - battery_current = 500 - total_current_power = sum(wind_current) + solar_current - battery_current - test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = ( - wind_current - ) - test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"]["power"] = battery_current - test_hercules_v1_dict["py_sims"]["test_solar"]["outputs"]["power_mw"] = solar_current / 1e3 - test_controller.filtered_power_prev = total_current_power # To override filtering - - test_controller.step(test_hercules_v1_dict) # Run the controller once to update measurements - supervisory_control_output = test_controller.supervisory_control( - test_controller._measurements_dict - ) - - power_cmd_base = total_current_power + controller_gain * hydrogen_error - - assert supervisory_control_output == power_cmd_base - - # Test instantiation using separate controller parameters - external_controller_parameters = { - "nominal_plant_power_kW": 10000, - "nominal_hydrogen_rate_kgps": 0.1, - "hydrogen_controller_gain": 1.0, - } - - # Test an error is raised if controller_parameters is passed while also specified on input_dict - with pytest.raises(KeyError): - HydrogenPlantController( - interface=test_interface_hercules_hybrid_ad, - input_dict=test_hercules_v1_dict, - generator_controller=hybrid_controller, - controller_parameters=external_controller_parameters, - ) - - # Check instantiation fails if a required parameter is missing from both controller_parameters - # and input_dict["controller"] - del test_hercules_v1_dict["controller"]["nominal_plant_power_kW"] - with pytest.raises(TypeError): - HydrogenPlantController( - interface=test_interface_hercules_hybrid_ad, - input_dict=test_hercules_v1_dict, - generator_controller=hybrid_controller, - ) - - # Check instantiation proceeds correctly if doubly-specified parameters are avoided - del test_hercules_v1_dict["controller"]["nominal_hydrogen_rate_kgps"] - del test_hercules_v1_dict["controller"]["hydrogen_controller_gain"] - - test_controller = HydrogenPlantController( - interface=test_interface_hercules_hybrid_ad, - input_dict=test_hercules_v1_dict, - generator_controller=hybrid_controller, - controller_parameters=external_controller_parameters, - ) diff --git a/tests/general_controllers_test.py b/tests/general_controllers_test.py new file mode 100644 index 00000000..d5094a1f --- /dev/null +++ b/tests/general_controllers_test.py @@ -0,0 +1,77 @@ +import numpy as np +from hycon.controllers import ( + PriceCurtailingController, + SolarPassthroughController, + WindFarmPowerDistributingController, +) + + +def test_PriceCurtailingController(test_hercules_dict, test_interface_hercules): + """ + Tests that the PriceCurtailingController outputs a reasonable signal + """ + # Consider a solar farm only + test_interface_hercules.component_names = ["solar_farm"] + test_controller = PriceCurtailingController( + interface=test_interface_hercules, + cname="solar_farm", + controller_parameters={ + "curtailment_price": 50, + "power_tracking_controller": SolarPassthroughController( + test_interface_hercules, "solar_farm" + ), + }, + ) + + # Test with price above curtailment threshold + power_setpoint_ref = 1000 + test_hercules_dict["external_signals"]["lmp_rt"] = 100 + test_hercules_dict["solar_farm"]["power_reference"] = power_setpoint_ref + out_dict = test_controller.step(test_hercules_dict) + power_setpoint_test = np.array(out_dict["solar_farm"]["power_setpoint"]) + assert np.isclose(power_setpoint_test, power_setpoint_ref) + + # Test with price below curtailment threshold + test_hercules_dict["external_signals"]["lmp_rt"] = 25 + out_dict = test_controller.step(test_hercules_dict) + power_setpoint_test = np.array(out_dict["solar_farm"]["power_setpoint"]) + assert np.isclose(power_setpoint_test, 0) + + # Test again with negative threshold + test_controller.set_controller_parameters( + curtailment_price=-10, + power_tracking_controller=SolarPassthroughController(test_interface_hercules, "solar_farm"), + ) + test_hercules_dict["external_signals"]["lmp_rt"] = -5 + test_hercules_dict["solar_farm"]["power_reference"] = power_setpoint_ref + out_dict = test_controller.step(test_hercules_dict) + power_setpoint_test = np.array(out_dict["solar_farm"]["power_setpoint"]) + assert np.isclose(power_setpoint_test, power_setpoint_ref) + + test_hercules_dict["external_signals"]["lmp_rt"] = -15 + out_dict = test_controller.step(test_hercules_dict) + power_setpoint_test = np.array(out_dict["solar_farm"]["power_setpoint"]) + assert np.isclose(power_setpoint_test, 0) + + # Test with wind farm + test_interface_hercules.component_names = ["wind_farm"] + test_controller = PriceCurtailingController( + interface=test_interface_hercules, + cname="wind_farm", + controller_parameters={ + "curtailment_price": 50, + "power_tracking_controller": WindFarmPowerDistributingController( + test_interface_hercules, "wind_farm" + ), + }, + ) + test_hercules_dict["external_signals"]["lmp_rt"] = 100 + test_hercules_dict["wind_farm"]["power_reference"] = power_setpoint_ref + out_dict = test_controller.step(test_hercules_dict) + power_setpoint_test = sum(out_dict["wind_farm"]["turbine_power_setpoints"]) + assert np.isclose(power_setpoint_test, power_setpoint_ref) + + test_hercules_dict["external_signals"]["lmp_rt"] = 25 + out_dict = test_controller.step(test_hercules_dict) + power_setpoint_test = sum(out_dict["wind_farm"]["turbine_power_setpoints"]) + assert np.isclose(power_setpoint_test, 0) diff --git a/tests/hercules_interface_test.py b/tests/hercules_interface_test.py index 738b12bb..28e21264 100644 --- a/tests/hercules_interface_test.py +++ b/tests/hercules_interface_test.py @@ -41,24 +41,24 @@ def test_HerculesInterface_windonly(test_hercules_dict): assert measurements["forecast"] == test_forecast # Test check_controls() - controls_dict = {"wind_power_setpoints": [2000.0, 3000.0]} + controls_dict = {"wind_farm": {"power_setpoint": [2000.0, 3000.0]}} + # Invalid key bad_controls_dict1 = { - "wind_power_setpoints": [2000.0, 3000.0], - "unavailable_control": [0.0, 0.0], + "wind_farm": { + "wind_power_setpoint": [2000.0, 3000.0], + "unavailable_control": [0.0, 0.0], + } } - bad_controls_dict2 = {"wind_power_setpoints": [2000.0, 3000.0, 0.0]} # Wrong number of turbines interface.check_controls(controls_dict) with pytest.raises(ValueError): interface.check_controls(bad_controls_dict1) - with pytest.raises(ValueError): - interface.check_controls(bad_controls_dict2) # test send_controls() - test_hercules_dict_out = interface.send_controls(h_dict=test_hercules_dict, **controls_dict) + test_hercules_dict_out = interface.send_controls(test_hercules_dict, controls_dict) assert ( - controls_dict["wind_power_setpoints"] + controls_dict["wind_farm"]["power_setpoint"] == test_hercules_dict_out["wind_farm"]["turbine_power_setpoints"] ) @@ -108,15 +108,15 @@ def test_HerculesInterface_hybrid(test_hercules_dict): # Test check_controls() controls_dict = { - "wind_power_setpoints": [2000.0, 3000.0], - "solar_power_setpoint": 500.0, - "battery_power_setpoint": -1000.0, + "wind_farm": {"power_setpoint": [2000.0, 3000.0]}, + "solar_farm": {"power_setpoint": 500.0}, + "battery": {"power_setpoint": -1000.0}, # "hydrogen_power_setpoint": 0.02, } bad_controls_dict1 = { - "wind_power_setpoints": [2000.0, 3000.0], - "solar_power_setpoint": 500.0, - "unavailable_control": [0.0, 0.0], + "wind_farm": {"power_setpoint": [2000.0, 3000.0]}, + "solar_farm": {"power_setpoint": 500.0}, + "battery": {"unavailable_control": [0.0, 0.0]}, } # Should run through without error @@ -126,22 +126,21 @@ def test_HerculesInterface_hybrid(test_hercules_dict): interface.check_controls(bad_controls_dict1) # Test send_controls() - test_hercules_dict_out = interface.send_controls(h_dict=test_hercules_dict, **controls_dict) + test_hercules_dict_out = interface.send_controls(test_hercules_dict, controls_dict) assert ( - controls_dict["wind_power_setpoints"] + controls_dict["wind_farm"]["power_setpoint"] == test_hercules_dict_out["wind_farm"]["turbine_power_setpoints"] ) assert ( - controls_dict["solar_power_setpoint"] + controls_dict["solar_farm"]["power_setpoint"] == test_hercules_dict_out["solar_farm"]["power_setpoint"] ) assert ( - controls_dict["battery_power_setpoint"] + controls_dict["battery"]["power_setpoint"] == test_hercules_dict_out["battery"]["power_setpoint"] ) - # Check that controller and plant parameters are set correctly - assert interface.controller_parameters == test_hercules_dict["controller"] + # Check that plant parameters are set correctly assert ( interface.plant_parameters["interconnect_limit"] == test_hercules_dict["plant"]["interconnect_limit"] diff --git a/tests/hercules_v1_interfaces_test.py b/tests/hercules_v1_interfaces_test.py index b6b644e7..9f69ecc3 100644 --- a/tests/hercules_v1_interfaces_test.py +++ b/tests/hercules_v1_interfaces_test.py @@ -40,44 +40,39 @@ def test_HerculesADInterface(test_hercules_v1_dict): assert measurements["forecast"] == test_forecast # Test check_controls() - controls_dict = {"yaw_angles": [270.0, 278.9]} + controls_dict = {"wind_farm": {"yaw_angles": [270.0, 278.9]}} controls_dict2 = { - "yaw_angles": [270.0, 268.9], - "power_setpoints": [3000.0, 3000.0], + "wind_farm": { + "yaw_angles": [270.0, 268.9], + "power_setpoint": [3000.0, 3000.0], + } } interface.check_controls(controls_dict) interface.check_controls(controls_dict2) - bad_controls_dict1 = {"yaw_angels": [270.0, 268.9]} # Misspelling + bad_controls_dict1 = {"wind_farm": {"yaw_angels": [270.0, 268.9]}} # Misspelling bad_controls_dict2 = { - "yaw_angles": [270.0, 268.9], - "power_setpoints": [3000.0, 3000.0], - "unavailable_control": [0.0, 0.0], + "wind_farm": { + "yaw_angles": [270.0, 268.9], + "power_setpoint": [3000.0, 3000.0], + "unavailable_control": [0.0, 0.0], + } } - bad_controls_dict3 = {"yaw_angles": [270.0, 268.9, 270.0]} # Mismatched number of turbines with pytest.raises(ValueError): interface.check_controls(bad_controls_dict1) with pytest.raises(ValueError): interface.check_controls(bad_controls_dict2) - with pytest.raises(ValueError): - interface.check_controls(bad_controls_dict3) # test send_controls() test_hercules_dict_out = interface.send_controls( - hercules_dict=test_hercules_v1_dict, **controls_dict + hercules_dict=test_hercules_v1_dict, controls_dict=controls_dict ) assert ( - controls_dict["yaw_angles"] + controls_dict["wind_farm"]["yaw_angles"] == test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_yaw_angles"] ) - with pytest.raises(TypeError): # Bad kwarg - interface.send_controls(test_hercules_v1_dict, **bad_controls_dict1) - with pytest.raises(TypeError): # Bad kwarg - interface.send_controls(test_hercules_v1_dict, **bad_controls_dict2) - # bad_controls_dict3 would pass, but faile the check_controls step. - # test that both wind_power_reference and plant_power_reference work, and that # wind_power_reference takes precedence test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 500.0 @@ -136,15 +131,14 @@ def test_HerculesHybridADInterface(test_hercules_v1_dict): # Test check_controls() controls_dict = { - "wind_power_setpoints": [1000.0, 1000.0], - "solar_power_setpoint": 1000.0, - "battery_power_setpoint": 0.0, + "wind_farm": {"power_setpoint": [1000.0, 1000.0]}, + "solar_farm": {"power_setpoint": 1000.0}, + "battery": {"power_setpoint": 0.0}, } bad_controls_dict = { - "wind_power_setpoints": [1000.0, 1000.0], - "solar_power_setpoint": 1000.0, - "battery_power_setpoint": 0.0, - "unavailable_control": 0.0, + "wind_farm": {"power_setpoint": [1000.0, 1000.0]}, + "solar_farm": {"power_setpoint": 1000.0}, + "battery": {"power_setpoint": 0.0, "unavailable_control": 0.0}, } interface.check_controls(controls_dict) @@ -154,20 +148,20 @@ def test_HerculesHybridADInterface(test_hercules_v1_dict): # Test send_controls() test_hercules_dict_out = interface.send_controls( - hercules_dict=test_hercules_v1_dict, **controls_dict + hercules_dict=test_hercules_v1_dict, controls_dict=controls_dict ) assert ( test_hercules_dict_out["py_sims"]["inputs"]["battery_signal"] - == -controls_dict["battery_power_setpoint"] + == -controls_dict["battery"]["power_setpoint"] ) assert ( test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] - == controls_dict["wind_power_setpoints"] + == controls_dict["wind_farm"]["power_setpoint"] ) assert ( test_hercules_dict_out["py_sims"]["inputs"]["solar_setpoint_mw"] - == controls_dict["solar_power_setpoint"] / 1000 + == controls_dict["solar_farm"]["power_setpoint"] / 1000 ) assert ( measurements["hydrogen"]["power_reference"] @@ -178,9 +172,6 @@ def test_HerculesHybridADInterface(test_hercules_v1_dict): == test_hercules_v1_dict["py_sims"]["test_hydrogen"]["outputs"]["H2_mfr"] ) - with pytest.raises(TypeError): # Bad kwarg - interface.send_controls(test_hercules_v1_dict, **bad_controls_dict) - def test_HerculesBatteryInterface(test_hercules_v1_dict): interface = HerculesV1BatteryInterface(hercules_dict=test_hercules_v1_dict) @@ -212,12 +203,12 @@ def test_HerculesBatteryInterface(test_hercules_v1_dict): ) # Test check_controls() - controls_dict = { - "power_setpoint": 20.0, - } + controls_dict = {"battery": {"power_setpoint": 20.0}} bad_controls_dict = { - "power_setpoint": 2.0, - "unavailable_control": 0.0, + "battery": { + "power_setpoint": 2.0, + "unavailable_control": 0.0, + } } with pytest.raises(ValueError): interface.check_controls(bad_controls_dict) @@ -225,12 +216,14 @@ def test_HerculesBatteryInterface(test_hercules_v1_dict): # Test send_controls() test_hercules_dict_out = interface.send_controls( - hercules_dict=test_hercules_v1_dict, **controls_dict + hercules_dict=test_hercules_v1_dict, controls_dict=controls_dict ) assert ( test_hercules_dict_out["py_sims"]["inputs"]["battery_signal"] - == -controls_dict["power_setpoint"] + == -controls_dict["battery"]["power_setpoint"] ) # defaults to zero - test_hercules_dict_out = interface.send_controls(hercules_dict=test_hercules_v1_dict) + test_hercules_dict_out = interface.send_controls( + hercules_dict=test_hercules_v1_dict, controls_dict={"battery": {}} + ) assert test_hercules_dict_out["py_sims"]["inputs"]["battery_signal"] == 0 diff --git a/tests/hybrid_controllers_test.py b/tests/hybrid_controllers_test.py new file mode 100644 index 00000000..bc662fc0 --- /dev/null +++ b/tests/hybrid_controllers_test.py @@ -0,0 +1,349 @@ +import copy + +import numpy as np +import pytest +from hycon.controllers import ( + BatteryPassthroughController, + HybridSupervisoryControllerGeneric, + HydrogenPlantController, + SolarPassthroughController, + WindFarmPowerDistributingController, + WindFarmPowerTrackingController, +) + + +def test_HybridSupervisoryControllerGeneric_reference_tracking( + test_hercules_dict, test_interface_hercules +): + """ + Tests for the HybridSupervisoryControllerGeneric when following a power reference. + """ + # Establish lower controllers + wind_controller = WindFarmPowerDistributingController(test_interface_hercules, "wind_farm") + solar_controller = SolarPassthroughController(test_interface_hercules, "solar_farm") + battery_controller = BatteryPassthroughController(test_interface_hercules, "battery") + + test_controller = HybridSupervisoryControllerGeneric( + interface=test_interface_hercules, + controller_parameters={ + "component_controllers": [wind_controller, solar_controller, battery_controller], + }, + ) + + solar_current = 800 + wind_current = 900 + power_ref = 1000 + + battery_charge_rate = test_hercules_dict["battery"]["charge_rate"] + + # Simply test the supervisory_control method, for the time being + test_hercules_dict["external_signals"]["plant_power_reference"] = power_ref + test_hercules_dict["wind_farm"]["power"] = wind_current + test_hercules_dict["solar_farm"]["power"] = solar_current + + # Step controller + out_dict = test_controller.step(test_hercules_dict) + wind_setpoint_test = sum(out_dict["wind_farm"]["turbine_power_setpoints"]) + solar_setpoint_test = out_dict["solar_farm"]["power_setpoint"] + battery_setpoint_test = out_dict["battery"]["power_setpoint"] + + # Expected outputs + wind_solar_current = wind_current + solar_current + wind_setpoint_ref = battery_charge_rate + power_ref + solar_setpoint_ref = wind_setpoint_ref - wind_current + battery_setpoint_ref = power_ref - wind_solar_current + + assert np.allclose( + [wind_setpoint_test, solar_setpoint_test, battery_setpoint_test], + [wind_setpoint_ref, solar_setpoint_ref, battery_setpoint_ref], + ) + + +def test_HybridSupervisoryControllerGeneric_subsets(test_hercules_dict, test_interface_hercules): + """ + Tests that the HybridSupervisoryControllerGeneric can be run with only + some of the wind, solar, and battery controllers. + """ + test_interface = test_interface_hercules + + # Alter dict for test + solar_current = 800.0 + wind_current = 900.0 + power_ref = 1000.0 + + test_hercules_dict["external_signals"]["plant_power_reference"] = power_ref + test_hercules_dict["wind_farm"]["power"] = wind_current + test_hercules_dict["solar_farm"]["power"] = solar_current + + battery_charge_rate = test_hercules_dict["battery"]["charge_rate"] + + # Establish lower controllers + wind_controller = WindFarmPowerTrackingController(test_interface, "wind_farm") + solar_controller = SolarPassthroughController(test_interface, "solar_farm") + battery_controller = BatteryPassthroughController(test_interface, "battery") + + ## First, try with wind and solar only + test_interface.component_names = ["wind_farm", "solar_farm"] + test_controller = HybridSupervisoryControllerGeneric( + interface=test_interface, + controller_parameters={"component_controllers": [wind_controller, solar_controller]}, + ) + + # Step controller + out_dict = test_controller.step(test_hercules_dict) + wind_setpoint_test = sum(out_dict["wind_farm"]["turbine_power_setpoints"]) + solar_setpoint_test = out_dict["solar_farm"]["power_setpoint"] + + wind_setpoint_ref = power_ref + solar_setpoint_ref = wind_setpoint_ref - wind_current + + assert np.allclose( + [wind_setpoint_test, solar_setpoint_test], [wind_setpoint_ref, solar_setpoint_ref] + ) + + ## Next, wind and battery only + test_interface.component_names = ["wind_farm", "battery"] + test_controller = HybridSupervisoryControllerGeneric( + interface=test_interface, + controller_parameters={"component_controllers": [wind_controller, battery_controller]}, + ) + + # Step controller + out_dict = test_controller.step(test_hercules_dict) + wind_setpoint_test = sum(out_dict["wind_farm"]["turbine_power_setpoints"]) + battery_setpoint_test = out_dict["battery"]["power_setpoint"] + + wind_setpoint_ref = battery_charge_rate + power_ref + battery_setpoint_ref = power_ref - wind_current + + assert np.allclose( + [wind_setpoint_test, battery_setpoint_test], [wind_setpoint_ref, battery_setpoint_ref] + ) + + ## Finally, solar and battery only + test_interface.component_names = ["solar_farm", "battery"] + test_controller = HybridSupervisoryControllerGeneric( + interface=test_interface, + controller_parameters={"component_controllers": [solar_controller, battery_controller]}, + ) + + # Step controller + out_dict = test_controller.step(test_hercules_dict) + solar_setpoint_test = out_dict["solar_farm"]["power_setpoint"] + battery_setpoint_test = out_dict["battery"]["power_setpoint"] + + solar_setpoint_ref = power_ref + battery_charge_rate + battery_setpoint_ref = power_ref - solar_current + + assert np.allclose( + [solar_setpoint_test, battery_setpoint_test], [solar_setpoint_ref, battery_setpoint_ref] + ) + + ## Test also the case where the battery is not allowed to charge from the grid + # Start with allowing grid charging + test_hercules_dict["external_signals"]["plant_power_reference"] = -100.0 # Must charge + out_dict = test_controller.step(test_hercules_dict) + battery_setpoint_test = out_dict["battery"]["power_setpoint"] + assert np.isclose(battery_setpoint_test, -100.0 - solar_current) + + # Switch to not allowing grid charging, capped at solar output + test_controller.component_controllers[1].plant_parameters["battery"]["allow_grid_charging"] = ( + False + ) + out_dict = test_controller.step(test_hercules_dict) + battery_setpoint_test = out_dict["battery"]["power_setpoint"] + assert np.isclose(battery_setpoint_test, -solar_current) + + ## Only wind controller + test_hercules_dict["external_signals"]["plant_power_reference"] = power_ref + test_interface.component_names = ["wind_farm"] + test_controller = HybridSupervisoryControllerGeneric( + interface=test_interface, + controller_parameters={"component_controllers": [wind_controller]}, + ) + + out_dict = test_controller.step(test_hercules_dict) + + assert np.isclose(sum(out_dict["wind_farm"]["turbine_power_setpoints"]), power_ref) + + ## Only solar controller + test_interface.component_names = ["solar_farm"] + test_controller = HybridSupervisoryControllerGeneric( + interface=test_interface, + controller_parameters={"component_controllers": [solar_controller]}, + ) + out_dict = test_controller.step(test_hercules_dict) + assert np.isclose(out_dict["solar_farm"]["power_setpoint"], power_ref) + + ## Only battery controller + test_interface.component_names = ["battery"] + test_controller = HybridSupervisoryControllerGeneric( + interface=test_interface, + controller_parameters={"component_controllers": [battery_controller]}, + ) + out_dict = test_controller.step(test_hercules_dict) + assert np.isclose(out_dict["battery"]["power_setpoint"], power_ref) + + +def test_HybridSupervisoryControllerGeneric_limits(test_hercules_dict, test_interface_hercules): + """ + Tests that the HybridSupervisoryControllerGeneric respects interconnection limits. + """ + # Set an interconnection limit + interconnect_limit = 1500.0 + test_hercules_dict["plant"]["interconnect_limit"] = interconnect_limit + test_interface_hercules.plant_parameters["interconnect_limit"] = interconnect_limit + test_hercules_dict["component_names"] = ["solar_farm", "battery"] + test_interface_hercules.component_names = ["solar_farm", "battery"] + # Establish lower controllers + solar_controller = SolarPassthroughController(test_interface_hercules, "solar_farm") + battery_controller = BatteryPassthroughController(test_interface_hercules, "battery") + + test_controller = HybridSupervisoryControllerGeneric( + interface=test_interface_hercules, + controller_parameters={ + "component_controllers": [solar_controller, battery_controller], + }, + ) + + solar_current = 800.0 + power_ref = 2000.0 # Over interconnection limit + + test_hercules_dict["external_signals"]["plant_power_reference"] = power_ref + test_hercules_dict["solar_farm"]["power"] = solar_current + + # Step controller + out_dict = test_controller.step(test_hercules_dict) + battery_setpoint_test = out_dict["battery"]["power_setpoint"] + + assert np.isclose(battery_setpoint_test, interconnect_limit - solar_current) + + # Now check lower limit for no grid charging case + test_controller.component_controllers[1].plant_parameters["battery"]["allow_grid_charging"] = ( + True + ) + solar_current = 500.0 + power_ref = -100.0 + test_hercules_dict["external_signals"]["plant_power_reference"] = power_ref + test_hercules_dict["solar_farm"]["power"] = solar_current + out_dict = test_controller.step(test_hercules_dict) + battery_setpoint_test = out_dict["battery"]["power_setpoint"] + + assert np.isclose(battery_setpoint_test, power_ref - solar_current) + + # Switch to disallowing grid charging + test_controller.component_controllers[1].plant_parameters["battery"]["allow_grid_charging"] = ( + False + ) + out_dict = test_controller.step(test_hercules_dict) + battery_setpoint_test = out_dict["battery"]["power_setpoint"] + + assert np.isclose(battery_setpoint_test, -solar_current) + + +def test_HydrogenPlantController(test_hercules_dict, test_interface_hercules): + """ + Tests that the HydrogenPlantController outputs a reasonable signal + """ + ## Test with only wind providing generation + wind_controller = WindFarmPowerTrackingController(test_interface_hercules, "wind_farm") + + # Remove components not used for first test + test_herc_dict_windonly = copy.deepcopy(test_hercules_dict) + del test_herc_dict_windonly["battery"] + del test_herc_dict_windonly["solar_farm"] + test_herc_dict_windonly["component_names"] = ["wind_farm", "electrolyzer"] + test_interface_hercules.component_names = ["wind_farm", "electrolyzer"] + + test_controller_parameters = { + "nominal_plant_power_kW": 10000, + "nominal_hydrogen_rate_kgps": 0.1, + "hydrogen_controller_gain": 1.0, + } + + test_controller_parameters["generator_controller"] = wind_controller + test_controller = HydrogenPlantController( + interface=test_interface_hercules, + cname="electrolyzer", + controller_parameters=test_controller_parameters, + ) + + wind_current = [600, 300] + hydrogen_ref = 0.028 + hydrogen_output = test_herc_dict_windonly["electrolyzer"]["H2_mfr"] + hydrogen_error = hydrogen_ref - hydrogen_output + + # Simply test the supervisory_control method, for the time being + test_herc_dict_windonly["external_signals"]["hydrogen_reference"] = hydrogen_ref + test_herc_dict_windonly["wind_farm"]["power"] = sum(wind_current) + test_controller.filtered_power_prev = sum(wind_current) # To override filtering + + # Without removing wind power reference, wind controller can't reconcile its setpoint + out_dict = test_controller.step(test_herc_dict_windonly) + controller_gain = 10000 / 0.1 * 1.0 # Based on parameters passed to controller + assert controller_gain == test_controller.K + + wind_cmd_ref = sum(wind_current) + controller_gain * hydrogen_error + + assert np.isclose(sum(out_dict["wind_farm"]["turbine_power_setpoints"]), wind_cmd_ref) + + # Test with a full wind/solar/battery plant + test_interface_hercules.component_names = ["wind_farm", "solar_farm", "battery"] + + hybrid_controller = HybridSupervisoryControllerGeneric( + interface=test_interface_hercules, + controller_parameters={ + "component_controllers": [ + wind_controller, + SolarPassthroughController(test_interface_hercules, "solar_farm"), + BatteryPassthroughController(test_interface_hercules, "battery"), + ], + }, + ) + + test_controller_parameters["generator_controller"] = hybrid_controller + test_controller = HydrogenPlantController( + interface=test_interface_hercules, + cname="electrolyzer", + controller_parameters=test_controller_parameters, + ) + + # Set up the dictionary + solar_current = 1000 + battery_current = 500 + total_current_power = sum(wind_current) + solar_current + battery_current + test_hercules_dict["wind_farm"]["power"] = sum(wind_current) + test_hercules_dict["solar_farm"]["power"] = solar_current + test_hercules_dict["battery"]["power"] = battery_current + test_hercules_dict["external_signals"]["hydrogen_reference"] = hydrogen_ref + + test_controller.filtered_power_prev = total_current_power # To override filtering + + meas = test_controller._s.get_measurements(test_hercules_dict) + power_cmd_test = test_controller.supervisory_control(meas) + + power_cmd_ref = total_current_power + controller_gain * hydrogen_error + + assert np.isclose(power_cmd_test, power_cmd_ref) + + # Test instantiation using separate controller parameters + external_controller_parameters = { + "nominal_plant_power_kW": 10000, + "nominal_hydrogen_rate_kgps": 0.1, + "hydrogen_controller_gain": 1.0, + } + + # Test an error is raised if controller_parameters is passed without generator_controller + with pytest.raises(KeyError): + HydrogenPlantController( + interface=test_interface_hercules, + controller_parameters=external_controller_parameters, + ) + + # Check instantiation fails if bad argument passed on controller_parameters + external_controller_parameters["invalid_parameter"] = 123 + with pytest.raises(KeyError): + HydrogenPlantController( + interface=test_interface_hercules, + controller_parameters=external_controller_parameters, + ) diff --git a/tests/wind_controllers_test.py b/tests/wind_controllers_test.py new file mode 100644 index 00000000..fa9cbe4f --- /dev/null +++ b/tests/wind_controllers_test.py @@ -0,0 +1,151 @@ +import numpy as np +import pandas as pd +from hycon.controllers import ( + LookupBasedWakeSteeringController, + WindFarmPowerDistributingController, + WindFarmPowerTrackingController, +) +from hycon.controllers.wind_farm_power_tracking_controller import POWER_SETPOINT_DEFAULT + + +def test_LookupBasedWakeSteeringController(test_hercules_v1_dict, test_interface_hercules_ad): + # No lookup table passed; simply passes through wind direction to yaw angles + test_controller = LookupBasedWakeSteeringController( + interface=test_interface_hercules_ad, cname="wind_farm" + ) + + # Check that the controller can be stepped + test_hercules_v1_dict["time"] = 20 + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) + test_angles = np.array( + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_yaw_angles"] + ) + wind_directions = np.array( + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_wind_directions"] + ) + assert np.allclose(test_angles, wind_directions) + + # Lookup table that specified 20 degree offset for T000, 10 degree offset for T001 for all + # wind directions + test_offsets = np.array([20.0, 10.0]) + df_opt_test = pd.DataFrame( + data={ + "wind_direction": [220.0, 220.0, 320.0, 320.0], + "wind_speed": [0.0, 20.0, 0.0, 20.0], + "yaw_angles_opt": [test_offsets] * 4, + "turbulence_intensity": [0.06] * 4, + } + ) + test_controller = LookupBasedWakeSteeringController( + interface=test_interface_hercules_ad, + cname="wind_farm", + controller_parameters={"df_yaw": df_opt_test}, + ) + + test_hercules_v1_dict["time"] = 20 + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) + test_angles = np.array( + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_yaw_angles"] + ) + wind_directions = np.array( + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_wind_directions"] + ) + assert np.allclose(test_angles, wind_directions - test_offsets) + + +def test_WindFarmPowerDistributingController(test_hercules_v1_dict, test_interface_hercules_ad): + test_controller = WindFarmPowerDistributingController( + interface=test_interface_hercules_ad, cname="wind_farm" + ) + + # Default behavior when no power reference is given + test_hercules_v1_dict["time"] = 20 + test_hercules_v1_dict["external_signals"] = {} + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) + test_power_setpoints = np.array( + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + ) + assert np.allclose( + test_power_setpoints, + POWER_SETPOINT_DEFAULT / 2, + ) + + # Test with power reference + test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 1000 + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) + test_power_setpoints = np.array( + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + ) + assert np.allclose(test_power_setpoints, 500) + + # Test that ramp rate limits are applied + test_controller = WindFarmPowerDistributingController( + interface=test_interface_hercules_ad, + cname="wind_farm", + controller_parameters={"ramp_rate_limit": 200}, + ) + test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 1000 + test_controller.step(input_dict=test_hercules_v1_dict) # To initialize previous power setpoints + test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 500 + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) + test_power_setpoints = np.array( + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + ) + assert np.allclose(test_power_setpoints, (1000 - 200) / 2) + + test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 2000 + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) + test_power_setpoints = np.array( + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + ) + assert np.allclose(test_power_setpoints, 1000 / 2) + + +def test_WindFarmPowerTrackingController(test_hercules_v1_dict, test_interface_hercules_ad): + test_controller = WindFarmPowerTrackingController( + interface=test_interface_hercules_ad, cname="wind_farm" + ) + + # Test no change to power setpoints if producing desired power + test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 1000 + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [500, 500] + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) + test_power_setpoints = np.array( + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + ) + assert np.allclose(test_power_setpoints, 500) + + # Test if power exceeds farm reference, power setpoints are reduced + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [600, 600] + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) + test_power_setpoints = np.array( + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + ) + assert ( + test_power_setpoints + <= test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] + ).all() + + # Test if power is less than farm reference, power setpoints are increased + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [550, 400] + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) + test_power_setpoints = np.array( + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + ) + assert ( + test_power_setpoints + >= test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] + ).all() + + # Test that more aggressive control leads to faster response + test_controller = WindFarmPowerTrackingController( + interface=test_interface_hercules_ad, + cname="wind_farm", + controller_parameters={"proportional_gain": 2}, + ) + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [600, 600] + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) + test_power_setpoints_a = np.array( + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + ) + assert (test_power_setpoints_a < test_power_setpoints).all()