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 ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("hycon")
@@ -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.