Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
def1594
First pass; working through controls_dict. Still need to handle measu…
misi9170 Apr 20, 2026
705579b
First-pass on measurements
misi9170 Apr 21, 2026
6cc4049
Update various controls for the simple_hybrid_plant example
misi9170 Apr 21, 2026
cf5b2d7
Formatting
misi9170 Apr 21, 2026
dd8f364
Battery control comparison example updated
misi9170 Apr 21, 2026
1fa6d4d
Handle None power reference
misi9170 Apr 21, 2026
f969267
Add battery charging logic with power reference
misi9170 Apr 21, 2026
a1aaf73
Minor formatting fix
misi9170 Apr 21, 2026
c439839
Progress on tests; not complete
misi9170 Apr 21, 2026
792a7e5
Formatting
misi9170 Apr 21, 2026
0e6075a
Further improvements in tests etc
misi9170 Apr 22, 2026
f04013d
control_dict includes cname
misi9170 Apr 23, 2026
ca62750
Remove MultiRef controller from library
misi9170 Apr 23, 2026
c7d96c9
Documentation and cleanup
misi9170 Apr 27, 2026
acd0731
Formatting
misi9170 Apr 27, 2026
2b5d26b
Reconfigure controller_parameters
misi9170 Apr 27, 2026
fc51a85
Remove input_dict, update examples
misi9170 Apr 27, 2026
5261dd9
Add handling to allow testing battery control for checking battery he…
misi9170 Apr 27, 2026
6e71ab7
Add SOC limitations
misi9170 Apr 27, 2026
4401878
Generalize solar farm name
misi9170 Apr 27, 2026
d5a347c
Docs page updates
misi9170 Apr 27, 2026
c674889
Add docstrings
misi9170 Apr 27, 2026
090705f
Move override down to component level
misi9170 Apr 28, 2026
1e6b42f
Add curtailing controller, not yet tested
misi9170 Apr 28, 2026
2b22976
Minor bugfix and price curtailing test
misi9170 Apr 28, 2026
992de65
switch to using main power_setpoint key for wind also
misi9170 Apr 29, 2026
cd5f991
Formatting
misi9170 Apr 29, 2026
f790f75
Update error message
misi9170 Apr 30, 2026
21282d3
Handle disallowing grid charging
misi9170 May 1, 2026
9541c3f
Pass upper and lower limits to SOC controller
misi9170 May 4, 2026
04b64b0
Add upper and lower limits to all battery controls; reorganize batter…
misi9170 May 5, 2026
8021525
Reorganize tests
misi9170 May 5, 2026
4daa2df
Add battery limits and tests
misi9170 May 5, 2026
d34d6e8
Minor example plots updates
misi9170 May 5, 2026
b48aa07
Apply suggestions from code review
misi9170 May 5, 2026
5cdba97
Fixes as suggested by copilot reviewer
misi9170 May 5, 2026
8e68096
Remove unneeded commented code
misi9170 May 5, 2026
a62fd64
Formatting
misi9170 May 5, 2026
82b7332
Formatting after copilot changes
misi9170 May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 47 additions & 18 deletions docs/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -94,14 +108,25 @@ 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.

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
Expand All @@ -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.
6 changes: 3 additions & 3 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Binary file modified docs/graphics/battery-market.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/graphics/flexible-interconnect.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/graphics/simple-hybrid-example-plot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 17 additions & 7 deletions docs/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion examples/battery_control_comparison/hercules_input.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ battery:
external_data:
external_data_file: power_reference.csv
log_channels:
- battery_power_reference
- plant_power_reference
24 changes: 17 additions & 7 deletions examples/battery_control_comparison/runscript.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
import argparse

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from hercules import HerculesOutput
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.
df = pd.read_csv("../example_inputs/lmp_rt.csv")
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)
Expand All @@ -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)
Expand All @@ -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

Expand Down
16 changes: 14 additions & 2 deletions examples/battery_market_revenue_control/plot_outputs.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
9 changes: 5 additions & 4 deletions examples/battery_market_revenue_control/runscript.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 1 addition & 1 deletion examples/simple_hybrid_plant/hercules_input.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 17 additions & 7 deletions examples/simple_hybrid_plant/plot_outputs.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import argparse

import matplotlib.pyplot as plt
import numpy as np
from hercules import HerculesOutput
Expand All @@ -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"]
Expand Down Expand Up @@ -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()
Loading
Loading