hycon 0.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hycon/__init__.py +3 -0
- hycon/controllers/__init__.py +19 -0
- hycon/controllers/battery_controller.py +268 -0
- hycon/controllers/controller_base.py +68 -0
- hycon/controllers/hybrid_supervisory_controller.py +354 -0
- hycon/controllers/hydrogen_plant_controller.py +113 -0
- hycon/controllers/lookup_based_wake_steering_controller.py +115 -0
- hycon/controllers/solar_passthrough_controller.py +13 -0
- hycon/controllers/wake_steering_rosco_standin.py +41 -0
- hycon/controllers/wind_farm_power_tracking_controller.py +171 -0
- hycon/design_tools/wake_steering_design.py +703 -0
- hycon/design_tools/wake_steering_visualization.py +178 -0
- hycon/interfaces/__init__.py +10 -0
- hycon/interfaces/hercules_interface.py +201 -0
- hycon/interfaces/hercules_v1_interface.py +305 -0
- hycon/interfaces/interface_base.py +56 -0
- hycon/interfaces/python_interface_temp.py +18 -0
- hycon/interfaces/rosco_zmq_interface.py +161 -0
- hycon/utilities.py +8 -0
- hycon-0.7.1.dist-info/METADATA +93 -0
- hycon-0.7.1.dist-info/RECORD +24 -0
- hycon-0.7.1.dist-info/WHEEL +5 -0
- hycon-0.7.1.dist-info/licenses/LICENSE.txt +26 -0
- hycon-0.7.1.dist-info/top_level.txt +1 -0
hycon/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from hycon.controllers.battery_controller import (
|
|
2
|
+
BatteryController,
|
|
3
|
+
BatteryPassthroughController,
|
|
4
|
+
BatteryPriceSOCController,
|
|
5
|
+
)
|
|
6
|
+
from hycon.controllers.hybrid_supervisory_controller import (
|
|
7
|
+
HybridSupervisoryControllerBaseline,
|
|
8
|
+
HybridSupervisoryControllerMultiRef,
|
|
9
|
+
)
|
|
10
|
+
from hycon.controllers.hydrogen_plant_controller import HydrogenPlantController
|
|
11
|
+
from hycon.controllers.lookup_based_wake_steering_controller import (
|
|
12
|
+
LookupBasedWakeSteeringController,
|
|
13
|
+
)
|
|
14
|
+
from hycon.controllers.solar_passthrough_controller import SolarPassthroughController
|
|
15
|
+
from hycon.controllers.wake_steering_rosco_standin import WakeSteeringROSCOStandin
|
|
16
|
+
from hycon.controllers.wind_farm_power_tracking_controller import (
|
|
17
|
+
WindFarmPowerDistributingController,
|
|
18
|
+
WindFarmPowerTrackingController,
|
|
19
|
+
)
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from hycon.controllers.controller_base import ControllerBase
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BatteryController(ControllerBase):
|
|
7
|
+
"""
|
|
8
|
+
Modifies power reference to consider battery degradation for single battery.
|
|
9
|
+
|
|
10
|
+
In particular, ensures smoothness in battery reference signal to avoid rapid
|
|
11
|
+
changes in power reference, which can lead to degradation.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, interface, input_dict, controller_parameters={}, verbose=True):
|
|
15
|
+
"""
|
|
16
|
+
Instantiate BatteryController.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
interface (object): Interface object for communicating with simulator.
|
|
20
|
+
input_dict (dict): Dictionary of input parameters (e.g. from Hercules).
|
|
21
|
+
controller_parameters (dict): Dictionary of controller parameters k_batt and
|
|
22
|
+
clipping_thresholds. See set_controller_parameters for more details. If
|
|
23
|
+
controller parameters are provided both in input_dict and controller_parameters,
|
|
24
|
+
the latter will take precedence.
|
|
25
|
+
verbose (bool): If True, print debug information.
|
|
26
|
+
"""
|
|
27
|
+
super().__init__(interface, verbose)
|
|
28
|
+
|
|
29
|
+
# Extract global parameters
|
|
30
|
+
self.dt = input_dict["dt"]
|
|
31
|
+
|
|
32
|
+
# Check that parameters are not specified both in input file
|
|
33
|
+
# and in controller_parameters
|
|
34
|
+
if "controller" in input_dict:
|
|
35
|
+
for cp in controller_parameters.keys():
|
|
36
|
+
if cp in input_dict["controller"]:
|
|
37
|
+
raise KeyError(
|
|
38
|
+
'Found key "' + cp + '" in both input_dict["controller"] and'
|
|
39
|
+
" in controller_parameters."
|
|
40
|
+
)
|
|
41
|
+
controller_parameters = {**controller_parameters, **input_dict["controller"]}
|
|
42
|
+
self.set_controller_parameters(**controller_parameters)
|
|
43
|
+
|
|
44
|
+
# Initialize controller internal state
|
|
45
|
+
self.x = 0
|
|
46
|
+
|
|
47
|
+
def set_controller_parameters(
|
|
48
|
+
self,
|
|
49
|
+
k_batt=0.1,
|
|
50
|
+
clipping_thresholds=[0, 0, 1, 1],
|
|
51
|
+
**_, # <- Allows arbitrary additional parameters to be passed, which are ignored
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Set gains and threshold limits for BatteryController.
|
|
55
|
+
|
|
56
|
+
k_batt is the controller gain. The controller will be stable and slow to react for small
|
|
57
|
+
values of k_batt (e.g. k_batt=0.01), and will be fast to react (and eventually unstable)
|
|
58
|
+
for large values of k_batt (e.g. k_batt=1).
|
|
59
|
+
|
|
60
|
+
clipping_thresholds is a list of four values: [soc_min, soc_min_clip, soc_max_clip,
|
|
61
|
+
soc_max]. soc_min is the minimum allowable SOC value, below which the controller output
|
|
62
|
+
reference power will be zero. soc_min_clip is the SOC value below which the controller
|
|
63
|
+
applies clipping to the reference power (the reference power is clipped linearly between
|
|
64
|
+
soc_min and soc_min_clip). Similarly, soc_max_clip is the SOC value above which linear
|
|
65
|
+
clipping is applied, until soc_max, after which the output is zero. Between soc_min_clip
|
|
66
|
+
and soc_max_clip, the full reference power is used.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
k_batt (float): Gain for controller.
|
|
70
|
+
clipping_thresholds (list): SOC thresholds for clipping reference power. Should be a
|
|
71
|
+
list of four values: [soc_min, soc_min_clip, soc_max_clip, soc_max].
|
|
72
|
+
"""
|
|
73
|
+
zeta = 2
|
|
74
|
+
omega = 2 * np.pi * k_batt
|
|
75
|
+
|
|
76
|
+
# Discrete-time, first-order state-space model of controller
|
|
77
|
+
p = np.exp(-2 * zeta * omega * self.dt)
|
|
78
|
+
self.a = p
|
|
79
|
+
self.b = 1
|
|
80
|
+
self.c = omega / (2 * zeta) * (1 - p) / 2 * (p + 1)
|
|
81
|
+
self.d = omega / (2 * zeta) * (1 - p) / 2
|
|
82
|
+
|
|
83
|
+
self.clipping_thresholds = clipping_thresholds
|
|
84
|
+
|
|
85
|
+
def soc_clipping(self, soc, reference_power):
|
|
86
|
+
"""
|
|
87
|
+
Clip the input reference based on the state of charge and clipping_thresholds.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
soc (float): Current state of charge.
|
|
91
|
+
reference_power (float): Reference power to be clipped.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
float: Clipped reference power.
|
|
95
|
+
"""
|
|
96
|
+
clip_fraction = np.interp(soc, self.clipping_thresholds, [0, 1, 1, 0], left=0, right=0)
|
|
97
|
+
|
|
98
|
+
r_charge = clip_fraction * self.plant_parameters["battery"]["charge_rate"]
|
|
99
|
+
r_discharge = clip_fraction * self.plant_parameters["battery"]["discharge_rate"]
|
|
100
|
+
|
|
101
|
+
return np.clip(reference_power, -r_discharge, r_charge)
|
|
102
|
+
|
|
103
|
+
def compute_controls(self, measurements_dict):
|
|
104
|
+
"""
|
|
105
|
+
Main compute_controls method for BatteryController.
|
|
106
|
+
"""
|
|
107
|
+
reference_power = measurements_dict["battery"]["power_reference"]
|
|
108
|
+
current_power = measurements_dict["battery"]["power"]
|
|
109
|
+
soc = measurements_dict["battery"]["state_of_charge"]
|
|
110
|
+
|
|
111
|
+
# Apply reference clipping
|
|
112
|
+
reference_power = self.soc_clipping(soc, reference_power)
|
|
113
|
+
|
|
114
|
+
e = reference_power - current_power
|
|
115
|
+
|
|
116
|
+
# Compute control
|
|
117
|
+
u = self.c * self.x + self.d * e
|
|
118
|
+
|
|
119
|
+
# Update controller internal state
|
|
120
|
+
self.x = self.a * self.x + self.b * e
|
|
121
|
+
|
|
122
|
+
controls_dict = {"power_setpoint": current_power + u}
|
|
123
|
+
|
|
124
|
+
return controls_dict
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class BatteryPassthroughController(ControllerBase):
|
|
128
|
+
"""
|
|
129
|
+
Simply passes power reference down to (single) battery.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, interface, input_dict, verbose=True):
|
|
133
|
+
"""
|
|
134
|
+
Instantiate BatteryPassthroughController."
|
|
135
|
+
"""
|
|
136
|
+
super().__init__(interface, verbose)
|
|
137
|
+
|
|
138
|
+
def compute_controls(self, measurements_dict):
|
|
139
|
+
"""
|
|
140
|
+
Main compute_controls method for BatteryPassthroughController.
|
|
141
|
+
"""
|
|
142
|
+
return {"power_setpoint": measurements_dict["battery"]["power_reference"]}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class BatteryPriceSOCController(ControllerBase):
|
|
146
|
+
"""
|
|
147
|
+
Controller considers price and SOC to determine power setpoint.
|
|
148
|
+
|
|
149
|
+
This controller implements a price-arbitrage strategy that uses day-ahead (DA)
|
|
150
|
+
locational marginal prices (LMPs) and real-time (RT) LMPs to decide when to
|
|
151
|
+
charge or discharge the battery. The algorithm identifies the top and bottom
|
|
152
|
+
price hours of the day based on battery duration (e.g., for a 4-hour battery,
|
|
153
|
+
it targets the "top_d" = 4 highest and "bottom_d" = 4 lowest priced hours).
|
|
154
|
+
|
|
155
|
+
The decision logic is as follows:
|
|
156
|
+
1. If RT price exceeds the highest DA price: discharge at full rate
|
|
157
|
+
(unconditionally).
|
|
158
|
+
2. Else if RT price is in the top-d highest DA prices AND SOC > low_soc:
|
|
159
|
+
discharge at full rate.
|
|
160
|
+
3. Else if RT price is below the lowest DA price: charge at full rate
|
|
161
|
+
(unconditionally).
|
|
162
|
+
4. Else if RT price is in the bottom-d lowest DA prices AND SOC < high_soc:
|
|
163
|
+
charge at full rate.
|
|
164
|
+
5. Otherwise: hold (power setpoint = 0).
|
|
165
|
+
|
|
166
|
+
The SOC thresholds (high_soc, low_soc) prevent over-charging or over-discharging
|
|
167
|
+
during moderate price signals, while still allowing full charge/discharge when
|
|
168
|
+
prices move outside the expected DA range.
|
|
169
|
+
|
|
170
|
+
Note:
|
|
171
|
+
Charging power is represented as negative values, matching the convention
|
|
172
|
+
used at the Hercules/hybrid_plant level.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, interface, input_dict, controller_parameters={}, verbose=True):
|
|
176
|
+
super().__init__(interface, verbose)
|
|
177
|
+
|
|
178
|
+
# Check that parameters are not specified both in input file
|
|
179
|
+
# and in controller_parameters
|
|
180
|
+
if "controller" in input_dict:
|
|
181
|
+
for cp in controller_parameters.keys():
|
|
182
|
+
if cp in input_dict["controller"]:
|
|
183
|
+
raise KeyError(
|
|
184
|
+
'Found key "' + cp + '" in both input_dict["controller"] and'
|
|
185
|
+
" in controller_parameters."
|
|
186
|
+
)
|
|
187
|
+
controller_parameters = {**controller_parameters, **input_dict["controller"]}
|
|
188
|
+
self.set_controller_parameters(**controller_parameters)
|
|
189
|
+
|
|
190
|
+
self.rated_power_charging = input_dict["battery"]["charge_rate"]
|
|
191
|
+
self.rated_power_discharging = input_dict["battery"]["discharge_rate"]
|
|
192
|
+
|
|
193
|
+
# Save the duration rounded to nearest hour
|
|
194
|
+
self.duration = round(
|
|
195
|
+
interface.plant_parameters["battery"]["energy_capacity"]
|
|
196
|
+
/ interface.plant_parameters["battery"]["power_capacity"]
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Raise if duration makes this controller implausible
|
|
200
|
+
if self.duration >= 12:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"Battery duration is {self.duration} hours, which is not "
|
|
203
|
+
"supported by BatteryPriceSOCController."
|
|
204
|
+
" This controller is only intended for durations shorter than 12 hours."
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if self.duration < 1:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"Battery duration is {self.duration} hours, which is not "
|
|
210
|
+
"supported by BatteryPriceSOCController."
|
|
211
|
+
" This controller is only intended for durations of at least 1 hour."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def set_controller_parameters(
|
|
215
|
+
self,
|
|
216
|
+
high_soc=1.0,
|
|
217
|
+
low_soc=0.2,
|
|
218
|
+
**_, # <- Allows arbitrary additional parameters to be passed, which are ignored
|
|
219
|
+
):
|
|
220
|
+
"""
|
|
221
|
+
Set parameters for BatteryPriceSOCController.
|
|
222
|
+
|
|
223
|
+
high_soc is the SOC threshold above which the battery will only charge if the price is below
|
|
224
|
+
the lowest (hourly) DA price of the day. Defaults to 1.0.
|
|
225
|
+
|
|
226
|
+
low_soc is the SOC threshold below which the battery will only discharge if the price is
|
|
227
|
+
above the highest (hourly) DA price of the day. Defaults to 0.2.
|
|
228
|
+
|
|
229
|
+
high_soc defaults to 1.0 (effectively disabled) as experience suggests waiting for
|
|
230
|
+
very low prices is not worthwhile. low_soc defaults to 0.2 as experience suggests waiting
|
|
231
|
+
for very high prices is worthwhile.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
high_soc (float): High SOC threshold (0 to 1). Defaults to 1.0.
|
|
235
|
+
low_soc (float): Low SOC threshold (0 to 1). Defaults to 0.2.
|
|
236
|
+
"""
|
|
237
|
+
self.high_soc = high_soc
|
|
238
|
+
self.low_soc = low_soc
|
|
239
|
+
|
|
240
|
+
def compute_controls(self, measurements_dict):
|
|
241
|
+
day_ahead_lmps = np.array(measurements_dict["DA_LMP_24hours"])
|
|
242
|
+
sorted_day_ahead_lmps = np.sort(day_ahead_lmps)
|
|
243
|
+
real_time_lmp = measurements_dict["RT_LMP"]
|
|
244
|
+
|
|
245
|
+
# Extract limits
|
|
246
|
+
bottom_d = sorted_day_ahead_lmps[self.duration - 1]
|
|
247
|
+
top_d = sorted_day_ahead_lmps[-self.duration]
|
|
248
|
+
bottom_1 = sorted_day_ahead_lmps[0]
|
|
249
|
+
top_1 = sorted_day_ahead_lmps[-1]
|
|
250
|
+
|
|
251
|
+
# Access the state of charge and LMP in real-time
|
|
252
|
+
soc = measurements_dict["battery"]["state_of_charge"]
|
|
253
|
+
|
|
254
|
+
# Note that the convention is followed where charging is negative power
|
|
255
|
+
# This matches what is in place in the hercules/hybrid_plant level and
|
|
256
|
+
# will be inverted before passing into the battery modules
|
|
257
|
+
if real_time_lmp > top_1:
|
|
258
|
+
power_setpoint = self.rated_power_discharging
|
|
259
|
+
elif (real_time_lmp > top_d) & (soc > self.low_soc):
|
|
260
|
+
power_setpoint = self.rated_power_discharging
|
|
261
|
+
elif real_time_lmp < bottom_1:
|
|
262
|
+
power_setpoint = -self.rated_power_charging
|
|
263
|
+
elif (real_time_lmp < bottom_d) & (soc < self.high_soc):
|
|
264
|
+
power_setpoint = -self.rated_power_charging
|
|
265
|
+
else:
|
|
266
|
+
power_setpoint = 0.0
|
|
267
|
+
|
|
268
|
+
return {"power_setpoint": power_setpoint}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from abc import ABCMeta, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ControllerBase(metaclass=ABCMeta):
|
|
5
|
+
def __init__(self, interface, verbose=True):
|
|
6
|
+
self._s = interface
|
|
7
|
+
self.verbose = verbose
|
|
8
|
+
|
|
9
|
+
# Initialize measurements and controls to send
|
|
10
|
+
self._measurements_dict = {}
|
|
11
|
+
self._controls_dict = {}
|
|
12
|
+
|
|
13
|
+
def _receive_measurements(self, input_dict=None):
|
|
14
|
+
# May need to eventually loop here, depending on server set up.
|
|
15
|
+
self._measurements_dict = self._s.get_measurements(input_dict)
|
|
16
|
+
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
def _send_controls(self, input_dict=None):
|
|
20
|
+
self._s.check_controls(self._controls_dict)
|
|
21
|
+
output_dict = self._s.send_controls(input_dict, **self._controls_dict)
|
|
22
|
+
|
|
23
|
+
return output_dict
|
|
24
|
+
|
|
25
|
+
def step(self, input_dict=None):
|
|
26
|
+
# If not running with direct hercules integration, hercules_dict may simply be None
|
|
27
|
+
# throughout this method.
|
|
28
|
+
self._receive_measurements(input_dict)
|
|
29
|
+
|
|
30
|
+
self._controls_dict = self.compute_controls(self._measurements_dict)
|
|
31
|
+
|
|
32
|
+
output_dict = self._send_controls(input_dict)
|
|
33
|
+
|
|
34
|
+
return output_dict
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def controller_parameters(self):
|
|
38
|
+
return self._s.controller_parameters
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def plant_parameters(self):
|
|
42
|
+
return self._s.plant_parameters
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def dt(self):
|
|
46
|
+
return self._s.dt
|
|
47
|
+
|
|
48
|
+
@dt.setter
|
|
49
|
+
def dt(self, _):
|
|
50
|
+
print(
|
|
51
|
+
"Warning: Setting dt directly is deprecated. Use the interface's dt property instead."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def cname(self):
|
|
56
|
+
if hasattr(self, "_cname"):
|
|
57
|
+
return self._cname
|
|
58
|
+
else:
|
|
59
|
+
return ValueError("cname has not been set for this controller.")
|
|
60
|
+
|
|
61
|
+
@cname.setter
|
|
62
|
+
def cname(self, value):
|
|
63
|
+
self._cname = value
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def compute_controls(self, measurements_dict: dict) -> dict:
|
|
67
|
+
pass # Control algorithms should be implemented in the compute_controls
|
|
68
|
+
# method of the child class.
|