From 5c9b515de83bf38db8ae586ae714aac7811c42bb Mon Sep 17 00:00:00 2001 From: paulf81 Date: Tue, 9 Dec 2025 13:23:57 -0700 Subject: [PATCH 1/8] Predetermine LMP names --- hycon/interfaces/hercules_interface.py | 64 +++++++++++++------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/hycon/interfaces/hercules_interface.py b/hycon/interfaces/hercules_interface.py index 9a7398c7..396417f2 100644 --- a/hycon/interfaces/hercules_interface.py +++ b/hycon/interfaces/hercules_interface.py @@ -64,6 +64,9 @@ def __init__(self, h_dict): if self._has_hydrogen_component: self.plant_parameters["hydrogen"] = {} + # 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", @@ -126,47 +129,42 @@ def get_measurements(self, h_dict): # Handle external signals (parse and pass to individual components) if "external_signals" in h_dict: + external_signals = h_dict["external_signals"] - if "plant_power_reference" in h_dict["external_signals"]: - measurements["plant_power_reference"] = ( - h_dict["external_signals"]["plant_power_reference"] - ) + if "plant_power_reference" in external_signals: + measurements["plant_power_reference"] = 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 "wind_power_reference" in external_signals and self._has_wind_component: + measurements["wind_farm"]["power_reference"] = 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 "solar_power_reference" in external_signals and self._has_solar_component: + measurements["solar_farm"]["power_reference"] = 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"] - ) - - 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 - if "lmp_da_00" in h_dict["external_signals"]: - measurements["DA_LMP_24hours"] = [ - h_dict["external_signals"]["lmp_da_{:02d}".format(h)] for h in range(24) - ] - 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"] + if "battery_power_reference" in external_signals: + measurements["battery"]["power_reference"] = external_signals[ + "battery_power_reference" + ] + + if "hydrogen_reference" in external_signals and self._has_hydrogen_component: + measurements["hydrogen"]["power_reference"] = external_signals["hydrogen_reference"] + + # Grid price information (using pre-computed keys for performance) + if "lmp_da_00" in external_signals: + measurements["DA_LMP_24hours"] = [external_signals[k] for k in self._lmp_da_keys] + if "lmp_da" in external_signals: + measurements["DA_LMP"] = external_signals["lmp_da"] + if "lmp_rt" in external_signals: + measurements["RT_LMP"] = external_signals["lmp_rt"] # Special handling for forecast elements - for k in h_dict["external_signals"].keys(): + for k, v in external_signals.items(): if "forecast" in k: - measurements["forecast"][k] = h_dict["external_signals"][k] + measurements["forecast"][k] = v measurements["total_power"] = total_power From bcc124788ca66418aab6b3ec20b3ba2ba84ac8e2 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Tue, 9 Dec 2025 13:28:55 -0700 Subject: [PATCH 2/8] apply ruff format --- .../battery_control_comparison/runscript.py | 81 ++++--- .../plot_outputs.py | 17 +- .../runscript.py | 9 +- .../compare_yaw_offset_designs.py | 41 ++-- .../construct_yaw_offsets.py | 26 +- .../hercules_runscript.py | 7 +- .../plot_output_data.py | 33 ++- examples/simple_hybrid_plant/plot_outputs.py | 49 ++-- examples/simple_hybrid_plant/runscript.py | 10 +- .../plot_outputs.py | 71 +++--- .../runscript.py | 49 +++- .../wind_farm_power_tracking/plot_outputs.py | 17 +- hycon/__init__.py | 1 - hycon/controllers/battery_controller.py | 33 ++- hycon/controllers/controller_base.py | 6 +- .../hybrid_supervisory_controller.py | 109 +++++---- .../controllers/hydrogen_plant_controller.py | 28 +-- .../lookup_based_wake_steering_controller.py | 50 ++-- .../solar_passthrough_controller.py | 1 + .../wind_farm_power_tracking_controller.py | 63 +++-- hycon/design_tools/wake_steering_design.py | 222 ++++++++--------- .../wake_steering_visualization.py | 24 +- hycon/interfaces/hercules_interface.py | 28 +-- hycon/interfaces/hercules_v1_interface.py | 100 ++++---- tests/battery_test.py | 9 +- tests/controller_library_test.py | 225 +++++++++--------- tests/hercules_interface_test.py | 52 ++-- tests/hercules_v1_interfaces_test.py | 9 +- tests/wake_steering_design_test.py | 197 +++++++-------- 29 files changed, 801 insertions(+), 766 deletions(-) diff --git a/examples/battery_control_comparison/runscript.py b/examples/battery_control_comparison/runscript.py index dd050802..e98d4a16 100644 --- a/examples/battery_control_comparison/runscript.py +++ b/examples/battery_control_comparison/runscript.py @@ -17,7 +17,7 @@ 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)) +reference_input_sequence = np.tile(np.array([20000, 0]), int(len(df) / 2)) df["battery_power_reference"] = reference_input_sequence # Add end of step info df["time_utc"] = pd.to_datetime(df["time_utc"]) @@ -26,9 +26,9 @@ df = pd.merge(df, df_2, how="outer").sort_values("time_utc").reset_index(drop=True) df.to_csv("power_reference.csv", index=False) + # Create some functions for simulating for simplicity def simulate(soc_0, clipping_thresholds, gain): - h_dict = load_hercules_input("hercules_input.yaml") h_dict["battery"]["initial_conditions"]["SOC"] = soc_0 @@ -42,11 +42,9 @@ def simulate(soc_0, clipping_thresholds, gain): controller_parameters={"k_batt": gain, "clipping_thresholds": clipping_thresholds}, ) controller = HybridSupervisoryControllerMultiRef( - battery_controller=battery_controller, - interface=interface, - input_dict=hmodel.h_dict + battery_controller=battery_controller, interface=interface, input_dict=hmodel.h_dict ) - + hmodel.assign_controller(controller) # Run the simulation @@ -61,16 +59,19 @@ def simulate(soc_0, clipping_thresholds, gain): return time, power_sequence, soc_sequence, reference_sequence + def plot_results_soc(ax, color, time, power_sequence, soc_sequence): - ax[0].plot(time, power_sequence, color=color, - label="SOC initial: {:.3f}".format(soc_sequence[0])) + ax[0].plot( + time, power_sequence, color=color, label="SOC initial: {:.3f}".format(soc_sequence[0]) + ) ax[1].plot(time, soc_sequence, color=color, label="SOC") + def plot_results_gain(ax, color, time, power_sequence, soc_sequence, gain): - ax[0].plot(time, power_sequence, color=color, - label="Gain: {:.3f}".format(gain)) + ax[0].plot(time, power_sequence, color=color, label="Gain: {:.3f}".format(gain)) ax[1].plot(time, soc_sequence, color=color, label="SOC") + ### SOC clipping # Establish simulation options for demonstrating SOC clipping @@ -79,66 +80,76 @@ def plot_results_gain(ax, color, time, power_sequence, soc_sequence, gain): clipping_thresholds = [0.1, 0.2, 0.8, 0.9] # Run simulations and create plots for SOC clipping -fig, ax = plt.subplots(2,1,sharex=True) -fig.set_size_inches(10,5) +fig, ax = plt.subplots(2, 1, sharex=True) +fig.set_size_inches(10, 5) for soc_0, col in zip(starting_socs, colors): time, pow, soc, ref = simulate(soc_0, clipping_thresholds, 0.01) - plot_results_soc(ax, col, time/60, pow, soc) + plot_results_soc(ax, col, time / 60, pow, soc) # Add references and plot aesthetics -ax[0].plot(time/60, ref, color="black", linestyle="dashed", label="Reference") +ax[0].plot(time / 60, ref, color="black", linestyle="dashed", label="Reference") ax[0].set_ylabel("Power [kW]") ax[0].legend() ax[1].set_ylabel("SOC [-]") ax[1].set_xlabel("Time [min]") -ax[1].set_xlim([time[0]/60, time[-1]/60]) +ax[1].set_xlim([time[0] / 60, time[-1] / 60]) ax[0].grid() ax[1].grid() -ax[0].plot([time[0]/60, time[-1]/60], [20000, 20000], color="black", linestyle="dotted") -ax[0].plot([time[0]/60, time[-1]/60], [-20000, -20000], color="black", linestyle="dotted") +ax[0].plot([time[0] / 60, time[-1] / 60], [20000, 20000], color="black", linestyle="dotted") +ax[0].plot([time[0] / 60, time[-1] / 60], [-20000, -20000], color="black", linestyle="dotted") # Add shading for the different clipping regions -ax[1].fill_between(time/60, 0, clipping_thresholds[0], color="black", alpha=0.2, edgecolor=None) -ax[1].fill_between(time/60, clipping_thresholds[0], clipping_thresholds[1], color="black", - alpha=0.1, edgecolor=None) -ax[1].fill_between(time/60, clipping_thresholds[2], clipping_thresholds[3], color="black", - alpha=0.1, edgecolor=None) -ax[1].fill_between(time/60, clipping_thresholds[3], 1, color="black", alpha=0.2, edgecolor=None) -ax[1].set_ylim([0,1]) +ax[1].fill_between(time / 60, 0, clipping_thresholds[0], color="black", alpha=0.2, edgecolor=None) +ax[1].fill_between( + time / 60, + clipping_thresholds[0], + clipping_thresholds[1], + color="black", + alpha=0.1, + edgecolor=None, +) +ax[1].fill_between( + time / 60, + clipping_thresholds[2], + clipping_thresholds[3], + color="black", + alpha=0.1, + edgecolor=None, +) +ax[1].fill_between(time / 60, clipping_thresholds[3], 1, color="black", alpha=0.2, edgecolor=None) +ax[1].set_ylim([0, 1]) if save_figs: fig.savefig( - "../../docs/graphics/battery-soc-clipping.png", - format="png", bbox_inches="tight", dpi=300 + "../../docs/graphics/battery-soc-clipping.png", format="png", bbox_inches="tight", dpi=300 ) ### k_batt gain # Demonstrate different gains gains = [0.001, 0.01, 0.1] -fig, ax = plt.subplots(2,1,sharex=True) -fig.set_size_inches(10,5) +fig, ax = plt.subplots(2, 1, sharex=True) +fig.set_size_inches(10, 5) for gain, col in zip(gains, colors): time, pow, soc, ref = simulate(0.5, clipping_thresholds, gain) - plot_results_gain(ax, col, time/60, pow, soc, gain) + plot_results_gain(ax, col, time / 60, pow, soc, gain) # Add references and plot aesthetics -ax[0].plot(time/60, ref, color="black", linestyle="dashed", label="Reference") +ax[0].plot(time / 60, ref, color="black", linestyle="dashed", label="Reference") ax[0].set_ylabel("Power [kW]") ax[0].legend() ax[1].set_ylabel("SOC [-]") ax[1].set_xlabel("Time [min]") -ax[1].set_xlim([0, 15]) # Show only the first 15 to highlight differences +ax[1].set_xlim([0, 15]) # Show only the first 15 to highlight differences ax[1].set_ylim([0.45, 0.51]) ax[0].grid() ax[1].grid() -ax[0].plot([time[0]/60, time[-1]/60], [20000, 20000], color="black", linestyle="dotted") -ax[0].plot([time[0]/60, time[-1]/60], [-20000, -20000], color="black", linestyle="dotted") +ax[0].plot([time[0] / 60, time[-1] / 60], [20000, 20000], color="black", linestyle="dotted") +ax[0].plot([time[0] / 60, time[-1] / 60], [-20000, -20000], color="black", linestyle="dotted") if save_figs: fig.savefig( - "../../docs/graphics/battery-varying-gains.png", - format="png", bbox_inches="tight", dpi=300 + "../../docs/graphics/battery-varying-gains.png", format="png", bbox_inches="tight", dpi=300 ) plt.show() diff --git a/examples/battery_market_revenue_control/plot_outputs.py b/examples/battery_market_revenue_control/plot_outputs.py index 6328e2b8..baf2da5b 100644 --- a/examples/battery_market_revenue_control/plot_outputs.py +++ b/examples/battery_market_revenue_control/plot_outputs.py @@ -110,14 +110,14 @@ def plot_outputs(): ax = axarr[1] ax.plot( df["time"], - df["battery.power"]/1e3, # Base unit: kW + df["battery.power"] / 1e3, # Base unit: kW label="Battery output", color="k", linewidth=1.0, ) ax.plot( df["time"], - df["battery.power_setpoint"]/1e3, # Base unit: kW + df["battery.power_setpoint"] / 1e3, # Base unit: kW label="Battery setpoint", color="k", linestyle=":", @@ -129,11 +129,7 @@ def plot_outputs(): color = "C0" ax2.set_ylabel("State of charge [-]", color=color) - ax2.plot( - df["time"], - df["battery.soc"], - color=color - ) + ax2.plot(df["time"], df["battery.soc"], color=color) ax2.tick_params(axis="y", labelcolor=color) for ax in axarr: @@ -141,15 +137,12 @@ def plot_outputs(): ax.legend(loc="upper right") # Compute total revenue on real-time market - df["revenue_rt"] = ( - df["battery.power"]/1e3 - * df["external_signals.lmp_rt"] - / 3600 - ) + 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())) return fig + if __name__ == "__main__": fig = plot_outputs() # fig.savefig("../../docs/graphics/battery-market.png", dpi=300, format="png") diff --git a/examples/battery_market_revenue_control/runscript.py b/examples/battery_market_revenue_control/runscript.py index fc313063..fd7d356e 100644 --- a/examples/battery_market_revenue_control/runscript.py +++ b/examples/battery_market_revenue_control/runscript.py @@ -17,8 +17,7 @@ # Generate the LMP data needed for the simulation df_lmp = generate_locational_marginal_price_dataframe_from_gridstatus( - pd.read_csv("../example_inputs/lmp_da.csv"), - pd.read_csv("../example_inputs/lmp_rt.csv") + pd.read_csv("../example_inputs/lmp_da.csv"), pd.read_csv("../example_inputs/lmp_rt.csv") ) df_lmp.to_csv("lmp_data.csv", index=False) @@ -26,11 +25,9 @@ hmodel = HerculesModel("hercules_input.yaml") # Establish the interface and controller, assign to the Hercules model -interface=HerculesInterface(hmodel.h_dict) +interface = HerculesInterface(hmodel.h_dict) controller = HybridSupervisoryControllerMultiRef( - battery_controller=BatteryPriceSOCController( - interface=interface, input_dict=hmodel.h_dict - ), + battery_controller=BatteryPriceSOCController(interface=interface, input_dict=hmodel.h_dict), interface=HerculesInterface(hmodel.h_dict), input_dict=hmodel.h_dict, ) diff --git a/examples/lookup-based_wake_steering_florisstandin/compare_yaw_offset_designs.py b/examples/lookup-based_wake_steering_florisstandin/compare_yaw_offset_designs.py index 1975a90d..c44224d4 100644 --- a/examples/lookup-based_wake_steering_florisstandin/compare_yaw_offset_designs.py +++ b/examples/lookup-based_wake_steering_florisstandin/compare_yaw_offset_designs.py @@ -4,7 +4,6 @@ a range of offset lookup tables and comparing them to one-another. """ - import matplotlib.pyplot as plt from floris import FlorisModel from hycon.design_tools import wake_steering_design as wsd, wake_steering_visualization as wsv @@ -77,8 +76,8 @@ wd_std = 3.0 ws_main = 8.0 wd_rate_limit = 3.0 - ws_rate_limit = 100.0 # No rate limit on wind speed - ti_rate_limit = 1e3 # No rate limit on turbulence intensity + ws_rate_limit = 100.0 # No rate limit on wind speed + ti_rate_limit = 1e3 # No rate limit on turbulence intensity plot_turbine = 0 plot_wd_lims = (240, 300) @@ -87,7 +86,7 @@ col_unc = "C0" col_rate_limited = "C1" col_ws_ramps = "C2" - + fmodel = FlorisModel(floris_dict) print("Building simple lookup table.") @@ -149,7 +148,7 @@ ws_wake_steering_cut_in=3.0, ws_wake_steering_fully_engaged_low=4.0, ws_wake_steering_fully_engaged_high=11.0, - ws_wake_steering_cut_out=13.0 + ws_wake_steering_cut_out=13.0, ) # Plot various designs @@ -162,7 +161,7 @@ ti_plot=ti_min, color=col_simple, label="Simple", - ax=ax + ax=ax, ) wsv.plot_offsets_wd( @@ -172,7 +171,7 @@ ti_plot=ti_min, color=col_unc, label="Uncertain", - ax=ax + ax=ax, ) wsv.plot_offsets_wd( @@ -182,7 +181,7 @@ ti_plot=ti_min, color=col_rate_limited, label="Rate limited", - ax=ax + ax=ax, ) wsv.plot_offsets_wd( @@ -193,7 +192,7 @@ color=col_ws_ramps, label="Single wind speed", linestyle="dotted", - ax=ax + ax=ax, ) ax.set_title("Yaw offsets at {} m/s".format(ws_main)) @@ -204,52 +203,52 @@ ax.legend() # Also, plot heatmap of offsets for Simple design - fig, ax = plt.subplots(2, 2, sharex=True, sharey=True, figsize=(10,10)) + fig, ax = plt.subplots(2, 2, sharex=True, sharey=True, figsize=(10, 10)) _, cbar = wsv.plot_offsets_wdws_heatmap( df_opt_simple, plot_turbine, ti_plot=ti_min, vmax=maximum_yaw_angle, vmin=minimum_yaw_angle, - ax=ax[0,0] + ax=ax[0, 0], ) cbar.set_label("Yaw offset [deg]") - ax[0,0].set_title("Simple") + ax[0, 0].set_title("Simple") _, cbar = wsv.plot_offsets_wdws_heatmap( df_opt_unc, plot_turbine, ti_plot=ti_min, vmax=maximum_yaw_angle, vmin=minimum_yaw_angle, - ax=ax[0,1] + ax=ax[0, 1], ) cbar.set_label("Yaw offset [deg]") - ax[0,1].set_title("Uncertain") + ax[0, 1].set_title("Uncertain") _, cbar = wsv.plot_offsets_wdws_heatmap( df_opt_rate_limited, plot_turbine, ti_plot=ti_min, vmax=maximum_yaw_angle, vmin=minimum_yaw_angle, - ax=ax[1,0] + ax=ax[1, 0], ) cbar.set_label("Yaw offset [deg]") - ax[1,0].set_title("Rate limited") + ax[1, 0].set_title("Rate limited") _, cbar = wsv.plot_offsets_wdws_heatmap( df_opt_ws_ramps, plot_turbine, ti_plot=ti_min, vmax=maximum_yaw_angle, vmin=minimum_yaw_angle, - ax=ax[1,1] + ax=ax[1, 1], ) cbar.set_label("Yaw offset [deg]") - ax[1,1].set_title("Single wind speed heuristic") + ax[1, 1].set_title("Single wind speed heuristic") - for ax_ in ax[:,0]: + for ax_ in ax[:, 0]: ax_.set_ylabel("Wind speed [m/s]") - for ax_ in ax[-1,:]: + for ax_ in ax[-1, :]: ax_.set_xlabel("Wind direction [deg]") - ax[0,0].set_xlim(plot_wd_lims) + ax[0, 0].set_xlim(plot_wd_lims) plt.show() diff --git a/examples/lookup-based_wake_steering_florisstandin/construct_yaw_offsets.py b/examples/lookup-based_wake_steering_florisstandin/construct_yaw_offsets.py index e6aff466..d8e27c3e 100644 --- a/examples/lookup-based_wake_steering_florisstandin/construct_yaw_offsets.py +++ b/examples/lookup-based_wake_steering_florisstandin/construct_yaw_offsets.py @@ -85,18 +85,22 @@ df_opt.to_pickle(args.yaw_offset_filename) # Also, build an example external data file - total_time = 100 # seconds + total_time = 100 # seconds dt = 0.5 np.random.seed(0) - wind_directions = np.concatenate(( - 260*np.ones(60), - np.linspace(260., 270., 80), - 270. + 5.*np.random.randn(round(total_time/dt)-60-80) - )) - df_data = pd.DataFrame(data={ - "time": np.arange(0, total_time, dt), - "amr_wind_speed": 8.0*np.ones_like(wind_directions), - "amr_wind_direction": wind_directions - }) + wind_directions = np.concatenate( + ( + 260 * np.ones(60), + np.linspace(260.0, 270.0, 80), + 270.0 + 5.0 * np.random.randn(round(total_time / dt) - 60 - 80), + ) + ) + df_data = pd.DataFrame( + data={ + "time": np.arange(0, total_time, dt), + "amr_wind_speed": 8.0 * np.ones_like(wind_directions), + "amr_wind_direction": wind_directions, + } + ) df_data.to_csv(args.input_wind_filename, index=False) diff --git a/examples/lookup-based_wake_steering_florisstandin/hercules_runscript.py b/examples/lookup-based_wake_steering_florisstandin/hercules_runscript.py index e11c71e4..4aaff5d0 100644 --- a/examples/lookup-based_wake_steering_florisstandin/hercules_runscript.py +++ b/examples/lookup-based_wake_steering_florisstandin/hercules_runscript.py @@ -23,10 +23,7 @@ interface = HerculesADInterface(input_dict) controller = LookupBasedWakeSteeringController( - interface, input_dict, - df_yaw=df_opt, - hysteresis_dict=hysteresis_dict, - verbose=True + interface, input_dict, df_yaw=df_opt, hysteresis_dict=hysteresis_dict, verbose=True ) py_sims = PySims(input_dict) @@ -35,4 +32,4 @@ emulator.run_helics_setup() emulator.enter_execution(function_targets=[], function_arguments=[[]]) -print("runscript complete.") \ No newline at end of file +print("runscript complete.") 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 26462aeb..e73ca3f8 100644 --- a/examples/lookup-based_wake_steering_florisstandin/plot_output_data.py +++ b/examples/lookup-based_wake_steering_florisstandin/plot_output_data.py @@ -6,9 +6,9 @@ n_turbines = 2 wf_str = "hercules_comms.amr_wind.wind_farm_0." -pow_cols = [wf_str+"turbine_powers.{0:03d}".format(t) for t in range(n_turbines)] -wd_cols = [wf_str+"turbine_wind_directions.{0:03d}".format(t) for t in range(n_turbines)] -yaw_cols = [wf_str+"turbine_yaw_angles.{0:03d}".format(t) for t in range(n_turbines)] +pow_cols = [wf_str + "turbine_powers.{0:03d}".format(t) for t in range(n_turbines)] +wd_cols = [wf_str + "turbine_wind_directions.{0:03d}".format(t) for t in range(n_turbines)] +yaw_cols = [wf_str + "turbine_yaw_angles.{0:03d}".format(t) for t in range(n_turbines)] # Extract data from larger array time = df.dt.values * np.arange(0, len(df), 1) @@ -22,15 +22,22 @@ # Direction for t in range(n_turbines): - line, = ax[0].plot(time, wds[:,t], label="T{0:03d} wind dir.".format(t)) - ax[0].plot(time, yaws[:,t], color=line.get_color(), label="T{0:03d} yaw pos.".format(t), - linestyle=":") + (line,) = ax[0].plot(time, wds[:, t], label="T{0:03d} wind dir.".format(t)) + ax[0].plot( + time, yaws[:, t], color=line.get_color(), label="T{0:03d} yaw pos.".format(t), linestyle=":" + ) if t == 0: - ax[1].fill_between(time, powers[:,t], color=line.get_color(), - label="T{0:03d} power".format(t)) + ax[1].fill_between( + time, powers[:, t], color=line.get_color(), label="T{0:03d} power".format(t) + ) else: - ax[1].fill_between(time, powers[:,:t+1].sum(axis=1), powers[:,:t].sum(axis=1), - color=line.get_color(), label="T{0:03d} power".format(t)) + ax[1].fill_between( + time, + powers[:, : t + 1].sum(axis=1), + powers[:, :t].sum(axis=1), + color=line.get_color(), + label="T{0:03d} power".format(t), + ) ax[1].plot(time, powers.sum(axis=1), color="black", label="Farm power") # Plot aesthetics @@ -45,8 +52,8 @@ ax[1].set_xlabel("Time [s]") ax[1].legend(loc="lower left") -#fig.savefig("../../docs/graphics/lookup-table-example-plot.png", dpi=300, format="png") -#fig.savefig("../../docs/graphics/lookup-table-example-plot_hysteresis.png", dpi=300, format="png") +# fig.savefig("../../docs/graphics/lookup-table-example-plot.png", dpi=300, format="png") +# fig.savefig("../../docs/graphics/lookup-table-example-plot_hysteresis.png", dpi=300, format="png") # Almost equal power to begin with as turbines turbines are not aligned. As the wind direction # shifts towards the aligned direction beginning at t = 30s, the downstream turbine (T001) begins to @@ -56,4 +63,4 @@ # Note that in the upper plot, T000 dir., T001 dir., and T001 yaw are identical througout. -plt.show() \ No newline at end of file +plt.show() diff --git a/examples/simple_hybrid_plant/plot_outputs.py b/examples/simple_hybrid_plant/plot_outputs.py index e1fc2b28..2670dbe5 100644 --- a/examples/simple_hybrid_plant/plot_outputs.py +++ b/examples/simple_hybrid_plant/plot_outputs.py @@ -17,7 +17,7 @@ def plot_outputs(): # Get high-level signals power_output = df["plant.power"] - time = df["time"] / 60 # minutes + time = df["time"] / 60 # minutes power_ref_input = df["external_signals.plant_power_reference"] # Extract individual components powers as well as total power @@ -26,10 +26,13 @@ def plot_outputs(): else: solar_power = np.zeros(len(df)) n_wind_turbines = 10 - wind_power = df[["wind_farm.turbine_powers.{0:03d}".format(t) - for t in range(n_wind_turbines)]].to_numpy().sum(axis=1) + wind_power = ( + df[["wind_farm.turbine_powers.{0:03d}".format(t) for t in range(n_wind_turbines)]] + .to_numpy() + .sum(axis=1) + ) if "battery.power" in df.columns: - battery_power = df["battery.power"] # discharging positive + battery_power = df["battery.power"] # discharging positive else: battery_power = np.zeros(len(df)) @@ -41,12 +44,12 @@ def plot_outputs(): # Plotting power outputs from each technology as well as the total power output (top) # Plotting the SOC of the battery (bottom) - fig, ax = plt.subplots(1, 1, sharex=True, figsize=(7,5)) - ax.plot(time, wind_power/1e3, label="Wind", color=wind_col) - ax.plot(time, solar_power/1e3, label="Solar PV", color=solar_col) - ax.plot(time, battery_power/1e3, label="Battery", color=battery_col) - ax.plot(time, power_output/1e3, label="Plant output", color=plant_col) - ax.plot(time, power_ref_input/1e3, "k--", label="Reference") + fig, ax = plt.subplots(1, 1, sharex=True, figsize=(7, 5)) + ax.plot(time, wind_power / 1e3, label="Wind", color=wind_col) + ax.plot(time, solar_power / 1e3, label="Solar PV", color=solar_col) + ax.plot(time, battery_power / 1e3, label="Battery", color=battery_col) + ax.plot(time, power_output / 1e3, label="Plant output", color=plant_col) + ax.plot(time, power_ref_input / 1e3, "k--", label="Reference") ax.set_ylabel("Power [MW]") ax.set_xlabel("Time [mins]") ax.grid() @@ -56,8 +59,8 @@ def plot_outputs(): # Plot the battery power and state of charge, if battery component included if not (battery_power == 0).all(): battery_soc = df["battery.soc"] - figb, ax = plt.subplots(2, 1, sharex=True, figsize=(7,5)) - ax[0].plot(time, battery_power/1e3, color=battery_col) + figb, ax = plt.subplots(2, 1, sharex=True, figsize=(7, 5)) + ax[0].plot(time, battery_power / 1e3, color=battery_col) ax[1].plot(time, battery_soc, color=battery_col) ax[0].set_ylabel("Battery power [MW]") ax[0].grid() @@ -69,8 +72,8 @@ def plot_outputs(): if not (solar_power == 0).all(): angle_of_incidence = df["solar_farm.aoi"] direct_normal_irradiance = df["solar_farm.dni"] - figs, ax = plt.subplots(3, 1, sharex=True, figsize=(7,5)) - ax[0].plot(time, solar_power/1e3, color="C1") + figs, ax = plt.subplots(3, 1, sharex=True, figsize=(7, 5)) + ax[0].plot(time, solar_power / 1e3, color="C1") ax[0].set_ylabel("Solar power [MW]") ax[0].grid() @@ -84,17 +87,14 @@ def plot_outputs(): ax[2].grid() # Plot the wind data - wind_power_individuals = df[["wind_farm.turbine_powers.{0:03d}".format(t) - for t in range(n_wind_turbines)]].to_numpy() - figw, ax = plt.subplots(2, 1, sharex=True, figsize=(7,5)) - ax[0].plot(time, wind_power/1e3, color=wind_col) - for i in range (n_wind_turbines): + wind_power_individuals = df[ + ["wind_farm.turbine_powers.{0:03d}".format(t) for t in range(n_wind_turbines)] + ].to_numpy() + figw, ax = plt.subplots(2, 1, sharex=True, figsize=(7, 5)) + ax[0].plot(time, wind_power / 1e3, color=wind_col) + for i in range(n_wind_turbines): ax[1].plot( - time, - wind_power_individuals[:,i]/1e3, - label="WT"+str(i), - alpha=0.7, - color=wind_col + time, wind_power_individuals[:, i] / 1e3, label="WT" + str(i), alpha=0.7, color=wind_col ) ax[0].set_ylabel("Total wind power [MW]") ax[1].set_ylabel("Individual turbine power [MW]") @@ -104,6 +104,7 @@ def plot_outputs(): return fig + if __name__ == "__main__": fig = plot_outputs() # fig.savefig("../../docs/graphics/simple-hybrid-example-plot.png", dpi=300, format="png") diff --git a/examples/simple_hybrid_plant/runscript.py b/examples/simple_hybrid_plant/runscript.py index d4b22b92..5d606ccc 100644 --- a/examples/simple_hybrid_plant/runscript.py +++ b/examples/simple_hybrid_plant/runscript.py @@ -34,20 +34,16 @@ 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 -) +solar_controller = SolarPassthroughController(interface, hmodel.h_dict) if include_solar else None battery_controller = ( - BatteryPassthroughController(interface, hmodel.h_dict) if include_battery - else None + BatteryPassthroughController(interface, hmodel.h_dict) if include_battery else None ) controller = HybridSupervisoryControllerBaseline( interface, hmodel.h_dict, wind_controller=wind_controller, solar_controller=solar_controller, - battery_controller=battery_controller + battery_controller=battery_controller, ) hmodel.assign_controller(controller) diff --git a/examples/single_turbine_flexible_interconnect/plot_outputs.py b/examples/single_turbine_flexible_interconnect/plot_outputs.py index c1503914..7697b2a4 100644 --- a/examples/single_turbine_flexible_interconnect/plot_outputs.py +++ b/examples/single_turbine_flexible_interconnect/plot_outputs.py @@ -5,14 +5,14 @@ def plot_outputs(): # Plot settings - lw = 1 # linewidth + lw = 1 # linewidth wind_cl = "C0" batt_cl = "C1" fi_cl = "red" pow_cl = "black" av_cl = "gray" - fi_ls = "dotted" # linestyle - av_alpha = 1 # opacity + fi_ls = "dotted" # linestyle + av_alpha = 1 # opacity df_wind = HerculesOutput("outputs/hercules_output_wind_only.h5").df df_batt = HerculesOutput("outputs/hercules_output_with_battery.h5").df @@ -30,79 +30,79 @@ def plot_outputs(): fig.set_size_inches(10, 8) # Extract data from larger array - time = df_wind['time'].to_numpy() + time = df_wind["time"].to_numpy() powers_wind_only = df_wind[pow_col].to_numpy() powers_with_batt = df_batt[pow_col].to_numpy() - battery_power = df_batt[batt_col] # Discharging positive + battery_power = df_batt[batt_col] # Discharging positive powers_base = df_base[pow_col].to_numpy() flexible_interconnect = df_wind[ref_col].to_numpy() - ax[0].plot(time/3600, df_wind[ws_col], color=wind_cl, label="Wind speed", linewidth=lw) + ax[0].plot(time / 3600, df_wind[ws_col], color=wind_cl, label="Wind speed", linewidth=lw) ax[0].set_ylabel("Wind speed [m/s]") ax[0].grid() # Power output wind only ax[1].plot( - time/3600, + time / 3600, powers_base, color=av_cl, alpha=av_alpha, linewidth=lw, - label="Available wind power" + label="Available wind power", ) - ax[1].fill_between(time/3600, powers_wind_only, color=wind_cl, label="Wind power") - ax[1].plot(time/3600, powers_wind_only, color=pow_cl, linewidth=lw, label="Plant power") + ax[1].fill_between(time / 3600, powers_wind_only, color=wind_cl, label="Wind power") + ax[1].plot(time / 3600, powers_wind_only, color=pow_cl, linewidth=lw, label="Plant power") ax[1].plot( - time/3600, + time / 3600, flexible_interconnect, color=fi_cl, linestyle=fi_ls, linewidth=lw, - label="Flexible interconnect limit" + label="Flexible interconnect limit", ) # Power output wind + battery ax[2].plot( - time/3600, + time / 3600, powers_base, color=av_cl, alpha=av_alpha, linewidth=lw, - label="Available wind power" + label="Available wind power", ) - ax[2].fill_between(time/3600, battery_power, color=batt_cl, label="Battery power") + ax[2].fill_between(time / 3600, battery_power, color=batt_cl, label="Battery power") ax[2].fill_between( - time/3600, + time / 3600, np.maximum(battery_power, 0), - powers_with_batt+np.maximum(battery_power, 0), + powers_with_batt + np.maximum(battery_power, 0), color=wind_cl, - label="Wind power" + label="Wind power", ) ax[2].plot( - time/3600, - powers_with_batt+battery_power, + time / 3600, + powers_with_batt + battery_power, color=pow_cl, linewidth=lw, - label="Plant power" + label="Plant power", ) ax[2].plot( - time/3600, + time / 3600, flexible_interconnect, color=fi_cl, linestyle=fi_ls, linewidth=lw, - label="Flexible interconnect limit" + label="Flexible interconnect limit", ) # Plot aesthetics ax[1].grid() ax[1].set_ylabel("Power [kW]\n(wind only case)") - ax[1].set_xlim([time[0]/3600, time[-1]/3600]) + ax[1].set_xlim([time[0] / 3600, time[-1] / 3600]) ax[1].set_ylim([-200, 2000]) ax[1].legend(loc="lower center") ax[2].grid() ax[2].set_ylabel("Power [kW]\n(wind + battery case)") - ax[2].set_xlim([time[0]/3600, time[-1]/3600]) + ax[2].set_xlim([time[0] / 3600, time[-1] / 3600]) ax[2].set_ylim([-200, 2000]) ax[2].legend(loc="lower center") ax[-1].set_xlabel("Time [hr]") @@ -111,30 +111,33 @@ def plot_outputs(): # Report output results. Wind only case, then wind + battery dt = time[1] - time[0] - available_energy = powers_base.sum()*dt/3600 - curtailed_energy = (powers_base-powers_wind_only).sum()*dt/3600 + available_energy = powers_base.sum() * dt / 3600 + curtailed_energy = (powers_base - powers_wind_only).sum() * dt / 3600 percentage_curtailed = curtailed_energy / available_energy * 100 # 10 kW threshold for "curtailed" when computing time curtailed - curtailed_hrs = ((powers_base - powers_wind_only) > 10).sum() * dt/3600 + curtailed_hrs = ((powers_base - powers_wind_only) > 10).sum() * dt / 3600 print("\nResults for wind only case") - print("Curtailed energy: {0:.2f} kWh ({1:.1f}% of available)".format( - curtailed_energy, percentage_curtailed + print( + "Curtailed energy: {0:.2f} kWh ({1:.1f}% of available)".format( + curtailed_energy, percentage_curtailed ) ) print("Total time curtailed: {0:.1f} hours".format(curtailed_hrs)) - curtailed_energy = (powers_base-powers_with_batt).sum()*dt/3600 + curtailed_energy = (powers_base - powers_with_batt).sum() * dt / 3600 percentage_curtailed = curtailed_energy / available_energy * 100 - curtailed_hrs = ((powers_base - powers_with_batt) > 10).sum() * dt/3600 + curtailed_hrs = ((powers_base - powers_with_batt) > 10).sum() * dt / 3600 print("\nResults for wind + battery case") - print("Curtailed energy: {0:.2f} kWh ({1:.1f}% of available)".format( - curtailed_energy, percentage_curtailed + print( + "Curtailed energy: {0:.2f} kWh ({1:.1f}% of available)".format( + curtailed_energy, percentage_curtailed ) ) print("Total time curtailed: {0:.1f} hours".format(curtailed_hrs)) return fig + if __name__ == "__main__": fig = plot_outputs() # fig.savefig("../../docs/graphics/flexible-interconnect.png", dpi=300, format="png") diff --git a/examples/single_turbine_flexible_interconnect/runscript.py b/examples/single_turbine_flexible_interconnect/runscript.py index 017612c9..d725978a 100644 --- a/examples/single_turbine_flexible_interconnect/runscript.py +++ b/examples/single_turbine_flexible_interconnect/runscript.py @@ -18,13 +18,38 @@ # Generate the dynamic interconnect limit over a 24-hour period time_hours = pd.date_range(start="2018-05-10 00:00:00", periods=25, freq="1H", tz="UTC") -df = pd.DataFrame({ - "time_utc": time_hours, - "plant_power_reference": [1374.7, 1366.1, 1366.1, 1376.4, 1376.4, 1390.9, 1390.9, 1401.2, - 1401.2, 1401.2, 1401.2, 1401.2, 1401.2, 1401.2, 1401.2, 1401.2, - 1401.2, 1401.2, 1401.2, 1401.2, 1401.2, 1401.2, 1401.2, 1406.9, - 1406.9] -}) +df = pd.DataFrame( + { + "time_utc": time_hours, + "plant_power_reference": [ + 1374.7, + 1366.1, + 1366.1, + 1376.4, + 1376.4, + 1390.9, + 1390.9, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1401.2, + 1406.9, + 1406.9, + ], + } +) df2 = df.copy(deep=True) df2["time_utc"] = df2["time_utc"] + pd.Timedelta(seconds=3599) df = pd.merge(df, df2, how="outer").sort_values("time_utc").reset_index(drop=True) @@ -33,7 +58,7 @@ ### Run base case h_dict = load_hercules_input("hercules_input.yaml") del h_dict["battery"] -del h_dict["external_data"] # Remove wind reference +del h_dict["external_data"] # Remove wind reference h_dict["output_file"] = "outputs/hercules_output_baseline.h5" hmodel = HerculesModel(h_dict) @@ -41,7 +66,7 @@ controller = HybridSupervisoryControllerMultiRef( wind_controller=WindFarmPowerTrackingController(interface, hmodel.h_dict), interface=interface, - input_dict=hmodel.h_dict + input_dict=hmodel.h_dict, ) hmodel.assign_controller(controller) @@ -59,7 +84,7 @@ controller = HybridSupervisoryControllerBaseline( wind_controller=WindFarmPowerTrackingController(interface, hmodel.h_dict), interface=interface, - input_dict=hmodel.h_dict + input_dict=hmodel.h_dict, ) hmodel.assign_controller(controller) @@ -76,7 +101,7 @@ wind_controller=WindFarmPowerTrackingController(interface, hmodel.h_dict), battery_controller=BatteryController(interface, hmodel.h_dict), interface=interface, - input_dict=hmodel.h_dict + input_dict=hmodel.h_dict, ) hmodel.assign_controller(controller) @@ -87,4 +112,4 @@ ### Plot results if generate_output_plots: plot_outputs() - plt.show() \ No newline at end of file + plt.show() diff --git a/examples/wind_farm_power_tracking/plot_outputs.py b/examples/wind_farm_power_tracking/plot_outputs.py index 5ac55c07..1227773b 100644 --- a/examples/wind_farm_power_tracking/plot_outputs.py +++ b/examples/wind_farm_power_tracking/plot_outputs.py @@ -20,17 +20,21 @@ def plot_outputs(): for case, (df, label) in enumerate(zip(dfs, labels)): # Extract data from larger array - time = df['time'].to_numpy() + time = df["time"].to_numpy() powers = df[pow_cols].to_numpy() ref = df[ref_col].to_numpy() # Direction for t in range(n_turbines): if t == 0: - ax[case].fill_between(time, powers[:,t], label="T{0:03d} power".format(t)) + ax[case].fill_between(time, powers[:, t], label="T{0:03d} power".format(t)) else: - ax[case].fill_between(time, powers[:,:t+1].sum(axis=1), powers[:,:t].sum(axis=1), - label="T{0:03d} power".format(t)) + ax[case].fill_between( + time, + powers[:, : t + 1].sum(axis=1), + powers[:, :t].sum(axis=1), + label="T{0:03d} power".format(t), + ) ax[case].plot(time, powers.sum(axis=1), color="black", label="Farm power") ax[case].plot(time, ref, color="gray", linestyle="dashed", label="Ref. power") @@ -44,11 +48,12 @@ def plot_outputs(): return fig + # In this example, the wind turbines are aligned with the oncoming wind, so T000 wakes T001. # The farm power setpoint more than available to begin, so both # turbines are at max power. Between 10s and 20s, the setpoint ramps down to 3000kW; the open-loop # controller asks each turbine for 1500kW, but only the upstream turbine is able to meet the demand, -# so the total farm power is below the setpoint. The closed-loop controller is able to adjust the +# so the total farm power is below the setpoint. The closed-loop controller is able to adjust the # power of T000 to compensate for T001's underperformance, and the farm power tracks the setpoint. # When the setpoint shifts to 2000kW, there is sufficient resource for T001 to produce 1000kW, and # both controllers meet the setpoint. @@ -56,4 +61,4 @@ def plot_outputs(): if __name__ == "__main__": fig = plot_outputs() # fig.savefig("../../docs/graphics/wf-power-tracking-plot.png", dpi=300, format="png") - plt.show() \ No newline at end of file + plt.show() diff --git a/hycon/__init__.py b/hycon/__init__.py index cb841a45..46f5f49d 100644 --- a/hycon/__init__.py +++ b/hycon/__init__.py @@ -1,4 +1,3 @@ from importlib.metadata import version __version__ = version("hycon") - diff --git a/hycon/controllers/battery_controller.py b/hycon/controllers/battery_controller.py index ee7298f8..56899e23 100644 --- a/hycon/controllers/battery_controller.py +++ b/hycon/controllers/battery_controller.py @@ -10,6 +10,7 @@ class BatteryController(ControllerBase): In particular, ensures smoothness in battery reference signal to avoid rapid changes in power reference, which can lead to degradation. """ + def __init__(self, interface, input_dict, controller_parameters={}, verbose=True): """ Instantiate BatteryController. @@ -34,7 +35,7 @@ def __init__(self, interface, input_dict, controller_parameters={}, verbose=True for cp in controller_parameters.keys(): if cp in input_dict["controller"]: raise KeyError( - "Found key \""+cp+"\" in both input_dict[\"controller\"] and" + 'Found key "' + cp + '" in both input_dict["controller"] and' " in controller_parameters." ) controller_parameters = {**controller_parameters, **input_dict["controller"]} @@ -47,7 +48,7 @@ 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 + **_, # <- Allows arbitrary additional parameters to be passed, which are ignored ): """ Set gains and threshold limits for BatteryController. @@ -68,7 +69,7 @@ def set_controller_parameters( k_batt (float): Gain for controller. clipping_thresholds (list): SOC thresholds for clipping reference power. Should be a list of four values: [soc_min, soc_min_clip, soc_max_clip, soc_max]. - """ + """ zeta = 2 omega = 2 * np.pi * k_batt @@ -76,8 +77,8 @@ def set_controller_parameters( p = np.exp(-2 * zeta * omega * self.dt) self.a = p self.b = 1 - self.c = omega / (2 * zeta) * (1-p)/2 * (p + 1) - self.d = omega / (2 * zeta) * (1-p)/2 + self.c = omega / (2 * zeta) * (1 - p) / 2 * (p + 1) + self.d = omega / (2 * zeta) * (1 - p) / 2 self.clipping_thresholds = clipping_thresholds @@ -92,13 +93,7 @@ def soc_clipping(self, soc, reference_power): Returns: float: Clipped reference power. """ - clip_fraction = np.interp( - soc, - self.clipping_thresholds, - [0, 1, 1, 0], - left=0, - right=0 - ) + 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"] @@ -128,18 +123,20 @@ def compute_controls(self, measurements_dict): return controls_dict + class BatteryPassthroughController(ControllerBase): """ Simply passes power reference down to (single) battery. """ + def __init__(self, interface, input_dict, verbose=True): - """" + """ " Instantiate BatteryPassthroughController." """ super().__init__(interface, verbose) def compute_controls(self, measurements_dict): - """" + """ " Main compute_controls method for BatteryPassthroughController. """ return {"power_setpoint": measurements_dict["battery"]["power_reference"]} @@ -149,6 +146,7 @@ class BatteryPriceSOCController(ControllerBase): """ Controller considers price and SOC to determine power setpoint. """ + def __init__(self, interface, input_dict, controller_parameters={}, verbose=True): super().__init__(interface, verbose) @@ -158,7 +156,7 @@ def __init__(self, interface, input_dict, controller_parameters={}, verbose=True for cp in controller_parameters.keys(): if cp in input_dict["controller"]: raise KeyError( - "Found key \""+cp+"\" in both input_dict[\"controller\"] and" + 'Found key "' + cp + '" in both input_dict["controller"] and' " in controller_parameters." ) controller_parameters = {**controller_parameters, **input_dict["controller"]} @@ -171,7 +169,7 @@ def set_controller_parameters( self, high_soc=0.8, low_soc=0.2, - **_ # <- Allows arbitrary additional parameters to be passed, which are ignored + **_, # <- Allows arbitrary additional parameters to be passed, which are ignored ): """ Set parameters for BatteryPriceSOCController. @@ -190,7 +188,6 @@ def set_controller_parameters( self.low_soc = low_soc def compute_controls(self, measurements_dict): - day_ahead_lmps = np.array(measurements_dict["DA_LMP_24hours"]) sorted_day_ahead_lmps = np.sort(day_ahead_lmps) real_time_lmp = measurements_dict["RT_LMP"] @@ -205,7 +202,7 @@ def compute_controls(self, measurements_dict): soc = measurements_dict["battery"]["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 + # This matches what is in place in the hercules/hybrid_plant level and # will be inverted before passing into the battery modules if real_time_lmp > top_1: power_setpoint = self.rated_power_discharging diff --git a/hycon/controllers/controller_base.py b/hycon/controllers/controller_base.py index 6750492a..a2cd6af9 100644 --- a/hycon/controllers/controller_base.py +++ b/hycon/controllers/controller_base.py @@ -2,7 +2,7 @@ class ControllerBase(metaclass=ABCMeta): - def __init__(self, interface, verbose = True): + def __init__(self, interface, verbose=True): self._s = interface self.verbose = verbose @@ -64,5 +64,5 @@ def cname(self, value): @abstractmethod def compute_controls(self, measurements_dict: dict) -> dict: - pass # Control algorithms should be implemented in the compute_controls - # method of the child class. + pass # Control algorithms should be implemented in the compute_controls + # method of the child class. diff --git a/hycon/controllers/hybrid_supervisory_controller.py b/hycon/controllers/hybrid_supervisory_controller.py index 36915d85..242c3d06 100644 --- a/hycon/controllers/hybrid_supervisory_controller.py +++ b/hycon/controllers/hybrid_supervisory_controller.py @@ -7,6 +7,7 @@ class HybridSupervisoryControllerBase(ControllerBase): """ Base class for hybrid supervisory controllers, implementing shared functionality. """ + def __init__( self, interface, @@ -14,12 +15,9 @@ def __init__( wind_controller=None, solar_controller=None, battery_controller=None, - verbose=False + verbose=False, ): - super().__init__( - interface=interface, - verbose=verbose - ) + super().__init__(interface=interface, verbose=verbose) self.dt = input_dict["dt"] # Won't be needed here, but generally good to have @@ -57,29 +55,30 @@ def compute_controls(self, measurements_dict): 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 + 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 + class HybridSupervisoryControllerBaseline(HybridSupervisoryControllerBase): def __init__( - self, - interface, - input_dict, - wind_controller=None, - solar_controller=None, - battery_controller=None, - verbose=False - ): + 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 + verbose=verbose, ) if not self._has_wind_controller and not self._has_solar_controller: @@ -123,8 +122,7 @@ def supervisory_control(self, measurements_dict): " in measurements_dict." ) elif ( - "power_reference" in measurements_dict - and "plant_power_reference" in measurements_dict + "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." @@ -134,8 +132,8 @@ def supervisory_control(self, measurements_dict): # 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 + 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: @@ -146,38 +144,36 @@ def supervisory_control(self, measurements_dict): 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: + 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 + 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 + 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) - ): + if (wind_power + solar_power) > (plant_power_reference + battery_charge_rate) or ( + (wind_power + solar_power) > (plant_power_reference) and battery_soc > 0.89 + ): # go down wind_reference = wind_power - K solar_reference = solar_power - K - else: + else: # go up # Is the resource saturated? - if self.solar_reference > (self.prev_solar_power+0.05*self.solar_reference): + 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): + if self.wind_reference > (self.prev_wind_power + 0.05 * self.wind_reference): wind_reference = self.wind_reference else: wind_reference = wind_power - K @@ -204,28 +200,31 @@ class HybridSupervisoryControllerMultiRef(HybridSupervisoryControllerBase): 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 - ): + 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 + 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): + 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.") @@ -233,13 +232,16 @@ def __init__( # Establish curtailment protocols default_curtailment_order = ["battery", "solar", "wind"] default_curtailment_order = [ - c for c, a in zip( + c + for c, a in zip( default_curtailment_order, - [self._has_battery_controller, - self._has_solar_controller, - self._has_wind_controller - ] - ) if a + [ + self._has_battery_controller, + self._has_solar_controller, + self._has_wind_controller, + ], + ) + if a ] if "curtailment_order" in self.controller_parameters: # Check that curtailment order does not contain any invalid components @@ -267,8 +269,7 @@ def supervisory_control(self, measurements_dict): "power_reference", self.plant_parameters["wind_farm"]["capacity"] ) wind_reference = np.minimum( - wind_reference, - self.plant_parameters["wind_farm"]["capacity"] + wind_reference, self.plant_parameters["wind_farm"]["capacity"] ) else: wind_power = 0 @@ -289,9 +290,7 @@ def supervisory_control(self, measurements_dict): 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 - ) + battery_reference = measurements_dict["battery"].get("power_reference", 0) else: battery_reference = 0 battery_reference = np.minimum( diff --git a/hycon/controllers/hydrogen_plant_controller.py b/hycon/controllers/hydrogen_plant_controller.py index 06966e43..34f78ad3 100644 --- a/hycon/controllers/hydrogen_plant_controller.py +++ b/hycon/controllers/hydrogen_plant_controller.py @@ -5,13 +5,13 @@ class HydrogenPlantController(ControllerBase): def __init__( - self, - interface, - input_dict, - generator_controller=None, - controller_parameters={}, - verbose=False - ): + self, + interface, + input_dict, + generator_controller=None, + controller_parameters={}, + verbose=False, + ): super().__init__(interface, verbose=verbose) self.dt = input_dict["dt"] # Won't be needed here, but generally good to have @@ -24,7 +24,7 @@ def __init__( for cp in controller_parameters.keys(): if cp in input_dict["controller"]: raise KeyError( - "Found key \""+cp+"\" in both input_dict[\"controller\"] and" + 'Found key "' + cp + '" in both input_dict["controller"] and' " in controller_parameters." ) controller_parameters = {**controller_parameters, **input_dict["controller"]} @@ -38,7 +38,7 @@ def set_controller_parameters( nominal_plant_power_kW, nominal_hydrogen_rate_kgps, hydrogen_controller_gain=1.0, - **_ # <- Allows arbitrary additional parameters to be passed, which are ignored + **_, # <- Allows arbitrary additional parameters to be passed, which are ignored ): """ Set gains and threshold limits for HydrogenPlantController. @@ -82,9 +82,9 @@ def compute_controls(self, measurements_dict): if "yaw_angles" in generator_controls_dict: del generator_controls_dict["yaw_angles"] if "power_setpoints" in generator_controls_dict: - generator_controls_dict["wind_power_setpoints"] = ( - generator_controls_dict["power_setpoints"] - ) + generator_controls_dict["wind_power_setpoints"] = generator_controls_dict[ + "power_setpoints" + ] del generator_controls_dict["power_setpoints"] return generator_controls_dict @@ -97,7 +97,7 @@ def supervisory_control(self, measurements_dict): # Input filtering a = 0.05 - filtered_power = (1-a/self.dt)*self.filtered_power_prev + a/self.dt*current_power + filtered_power = (1 - a / self.dt) * self.filtered_power_prev + a / self.dt * current_power # Calculate difference between hydrogen reference and hydrogen actual hydrogen_error = hydrogen_reference - hydrogen_output @@ -107,7 +107,7 @@ def supervisory_control(self, measurements_dict): if power_reference < 0: power_reference = 0 - + self.filtered_power_prev = filtered_power return power_reference diff --git a/hycon/controllers/lookup_based_wake_steering_controller.py b/hycon/controllers/lookup_based_wake_steering_controller.py index d07dbeb6..16663f9c 100644 --- a/hycon/controllers/lookup_based_wake_steering_controller.py +++ b/hycon/controllers/lookup_based_wake_steering_controller.py @@ -11,20 +11,20 @@ class LookupBasedWakeSteeringController(ControllerBase): def __init__( - self, - interface: InterfaceBase, - input_dict: dict, - df_yaw: pd.DataFrame | None = None, - hysteresis_dict: dict | None = None, - verbose: bool = False - ): + self, + interface: InterfaceBase, + input_dict: dict, + df_yaw: pd.DataFrame | None = None, + hysteresis_dict: dict | None = None, + verbose: bool = False, + ): """ Constructor for LookupBasedWakeSteeringController. 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 + 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. @@ -41,8 +41,7 @@ def __init__( if df_yaw is None: if hysteresis_dict is not None: raise ValueError( - "Hysteresis zones provided without yaw offsets. " - "Please provide yaw offsets." + "Hysteresis zones provided without yaw offsets. Please provide yaw offsets." ) if self.verbose: print("No offsets received; assuming nominal aligned control.") @@ -51,10 +50,12 @@ def __init__( self.wake_steering_interpolant = get_yaw_angles_interpolant(df_yaw) if isinstance(hysteresis_dict, dict) and len(hysteresis_dict) == 0: - print(( - "Received empty hysteresis dictionary. Assuming no hysteresis." - "This may happen if yaw offsets are one-sided." - )) + print( + ( + "Received empty hysteresis dictionary. Assuming no hysteresis." + "This may happen if yaw offsets are one-sided." + ) + ) hysteresis_dict = None self.hysteresis_dict = hysteresis_dict @@ -73,18 +74,16 @@ def __init__( self.controls_dict = {"yaw_angles": [yaw_IC] * self.n_turbines} # For startup - self.wd_store = [270.]*self.n_turbines # TODO: update this? + self.wd_store = [270.0] * self.n_turbines # TODO: update this? self.yaw_store = yaw_IC - def compute_controls(self, measurements_dict): return self.wake_steering_angles(measurements_dict["wind_farm"]["wind_directions"]) def wake_steering_angles(self, wind_directions): - # Handle possible bad data - wind_speeds = [8.0]*self.n_turbines # TODO: enable extraction of wind speed in Hercules - if not wind_directions: # Received empty or None + wind_speeds = [8.0] * self.n_turbines # TODO: enable extraction of wind speed in Hercules + if not wind_directions: # Received empty or None if self.verbose: print("Bad wind direction measurement received, reverting to previous measurement.") wind_directions = self.wd_store @@ -95,11 +94,7 @@ def wake_steering_angles(self, wind_directions): if self.wake_steering_interpolant is None: yaw_setpoint = wind_directions else: - interpolated_angles = self.wake_steering_interpolant( - wind_directions, - wind_speeds, - None - ) + interpolated_angles = self.wake_steering_interpolant(wind_directions, wind_speeds, None) yaw_offsets = np.diag(interpolated_angles) yaw_setpoint = (np.array(wind_directions) - yaw_offsets).tolist() @@ -107,10 +102,9 @@ def wake_steering_angles(self, wind_directions): if self.hysteresis_dict is not None: for t in range(self.n_turbines): for zone in self.hysteresis_dict["T{:03d}".format(t)]: - if ( - (zone[0] < wind_directions[t] < zone[1]) - or (wrap_180(zone[0]) < wrap_180(wind_directions[t]) < wrap_180(zone[1])) - ): + if (zone[0] < wind_directions[t] < zone[1]) or ( + wrap_180(zone[0]) < wrap_180(wind_directions[t]) < wrap_180(zone[1]) + ): # In hysteresis zone, overwrite yaw angle with previous setpoint yaw_setpoint[t] = self.yaw_store[t] diff --git a/hycon/controllers/solar_passthrough_controller.py b/hycon/controllers/solar_passthrough_controller.py index 09c95c06..ba053b79 100644 --- a/hycon/controllers/solar_passthrough_controller.py +++ b/hycon/controllers/solar_passthrough_controller.py @@ -5,6 +5,7 @@ class SolarPassthroughController(ControllerBase): """ Simply passes power reference down to (scalar) solar simulator. """ + def __init__(self, interface, input_dict, verbose=True): super().__init__(interface, verbose) diff --git a/hycon/controllers/wind_farm_power_tracking_controller.py b/hycon/controllers/wind_farm_power_tracking_controller.py index bf57a190..1a1d0fe8 100644 --- a/hycon/controllers/wind_farm_power_tracking_controller.py +++ b/hycon/controllers/wind_farm_power_tracking_controller.py @@ -3,19 +3,21 @@ from hycon.controllers.controller_base import ControllerBase # Default power setpoint in kW (meant to ensure power maximization) -POWER_SETPOINT_DEFAULT = 1e9 +POWER_SETPOINT_DEFAULT = 1e9 + class WindFarmPowerDistributingController(ControllerBase): """ - Evenly distributes wind farm power reference between turbines without + Evenly distributes wind farm power reference between turbines without feedback on current power generation. """ + def __init__(self, interface, input_dict, verbose=False): super().__init__(interface, verbose=verbose) # Pull plant parameters for ease of use self.cname = "wind_farm" - + if self.cname in self.plant_parameters: self.n_turbines = self.plant_parameters[self.cname]["n_turbines"] else: @@ -34,7 +36,8 @@ def compute_controls(self, measurements_dict): if ref_in_lower_dict and ref_in_upper_dict: raise KeyError( "Found 'power_reference' in both measurements_dict['" - +self.cname+"'] and measurements_dict." + + self.cname + + "'] and measurements_dict." ) elif ref_in_lower_dict: farm_power_reference = measurements_dict[self.cname]["power_reference"] @@ -45,14 +48,12 @@ def compute_controls(self, measurements_dict): return self.turbine_power_references( farm_power_reference=farm_power_reference, - turbine_powers=measurements_dict[self.cname]["turbine_powers"] + turbine_powers=measurements_dict[self.cname]["turbine_powers"], ) def turbine_power_references( - self, - farm_power_reference=POWER_SETPOINT_DEFAULT, - turbine_powers=None - ): + self, farm_power_reference=POWER_SETPOINT_DEFAULT, turbine_powers=None + ): """ Compute turbine-level power setpoints based on farm-level power reference signal. @@ -64,11 +65,12 @@ def turbine_power_references( # Split farm power reference among turbines. controls_dict = { - "power_setpoints": [farm_power_reference/self.n_turbines]*self.n_turbines, + "power_setpoints": [farm_power_reference / self.n_turbines] * self.n_turbines, } return controls_dict + class WindFarmPowerTrackingController(WindFarmPowerDistributingController): """ Based on controller developed under A2e2g project. Proportional control only--- @@ -78,13 +80,8 @@ class WindFarmPowerTrackingController(WindFarmPowerDistributingController): """ def __init__( - self, - interface, - input_dict, - proportional_gain=1, - ramp_rate_limit=None, - verbose=False - ): + self, interface, input_dict, proportional_gain=1, ramp_rate_limit=None, verbose=False + ): """ Constructor for WindFarmPowerTrackingController. @@ -98,16 +95,14 @@ def __init__( super().__init__(interface, input_dict, verbose=verbose) # Proportional gain - self.K_p = proportional_gain * 1/self.n_turbines + self.K_p = proportional_gain * 1 / self.n_turbines # Ramp rate limit self.ramp_rate_limit = ramp_rate_limit def turbine_power_references( - self, - farm_power_reference=POWER_SETPOINT_DEFAULT, - turbine_powers=None - ): + self, farm_power_reference=POWER_SETPOINT_DEFAULT, turbine_powers=None + ): """ Compute turbine-level power setpoints based on farm-level power reference signal. @@ -116,7 +111,7 @@ def turbine_power_references( Outputs: - None (sets self.controls_dict) """ - + farm_current_power = np.sum(turbine_powers) farm_current_error = farm_power_reference - farm_current_power @@ -125,29 +120,29 @@ def turbine_power_references( farm_current_error = np.clip( farm_current_error, farm_current_power - self.ramp_rate_limit * self.dt, - farm_current_power + self.ramp_rate_limit * self.dt + farm_current_power + self.ramp_rate_limit * self.dt, ) - self.n_saturated = 0 # TODO: determine whether to use gain scheduling + self.n_saturated = 0 # TODO: determine whether to use gain scheduling if self.n_saturated < self.n_turbines: # with self.n_saturated = 0, gain_adjustment = 1 - gain_adjustment = self.n_turbines/(self.n_turbines-self.n_saturated) + gain_adjustment = self.n_turbines / (self.n_turbines - self.n_saturated) else: gain_adjustment = self.n_turbines - K_p_gs = gain_adjustment*self.K_p - #K_i_gs = gain_adjustment*self.K_i + K_p_gs = gain_adjustment * self.K_p + # K_i_gs = gain_adjustment*self.K_i # Discretize and apply difference equation (trapezoid rule) - u_p = K_p_gs*farm_current_error - #u_i = self.dt/2*K_i_gs * (farm_current_error + self.e_prev) + self.u_i_prev + u_p = K_p_gs * farm_current_error + # u_i = self.dt/2*K_i_gs * (farm_current_error + self.e_prev) + self.u_i_prev # Apply integral anti-windup - #eps = 0.0001 # Threshold for anti-windup - #if (np.array(self.ai_prev) > 1/3-eps).all() or \ + # eps = 0.0001 # Threshold for anti-windup + # if (np.array(self.ai_prev) > 1/3-eps).all() or \ # (np.array(self.ai_prev) < 0+eps).all(): # u_i = 0 - - u = u_p #+ u_i + + u = u_p # + u_i delta_P_ref = u turbine_power_setpoints = np.array(turbine_powers) + delta_P_ref diff --git a/hycon/design_tools/wake_steering_design.py b/hycon/design_tools/wake_steering_design.py index 7577a3bf..0db2748b 100755 --- a/hycon/design_tools/wake_steering_design.py +++ b/hycon/design_tools/wake_steering_design.py @@ -176,7 +176,7 @@ def apply_static_rate_limits( wind speed [deg / m/s]. Defaults to 10. ti_rate_limit (float, optional): The maximum rate of change in yaw offset per change in turbulence intensity [deg / -]. Defaults to 500. - + Returns: pd.DataFrame: A yaw offset lookup table with rate limits applied. """ @@ -200,45 +200,45 @@ def apply_static_rate_limits( # Apply wd rate limits offsets_limited_lr = offsets_array.copy() for i in range(1, len(wd_array)): - delta_yaw = offsets_limited_lr[i, :, :, :] - offsets_limited_lr[i-1, :, :, :] - delta_yaw = np.clip(delta_yaw, -wd_rate_limit*wd_step, wd_rate_limit*wd_step) - offsets_limited_lr[i, :, :, :] = offsets_limited_lr[i-1, :, :, :] + delta_yaw + delta_yaw = offsets_limited_lr[i, :, :, :] - offsets_limited_lr[i - 1, :, :, :] + delta_yaw = np.clip(delta_yaw, -wd_rate_limit * wd_step, wd_rate_limit * wd_step) + offsets_limited_lr[i, :, :, :] = offsets_limited_lr[i - 1, :, :, :] + delta_yaw offsets_limited_rl = offsets_array.copy() - for i in range(len(wd_array)-2, -1, -1): - delta_yaw = offsets_limited_rl[i, :, :, :] - offsets_limited_rl[i+1, :, :, :] - delta_yaw = np.clip(delta_yaw, -wd_rate_limit*wd_step, wd_rate_limit*wd_step) - offsets_limited_rl[i, :, :, :] = offsets_limited_rl[i+1, :, :, :] + delta_yaw + for i in range(len(wd_array) - 2, -1, -1): + delta_yaw = offsets_limited_rl[i, :, :, :] - offsets_limited_rl[i + 1, :, :, :] + delta_yaw = np.clip(delta_yaw, -wd_rate_limit * wd_step, wd_rate_limit * wd_step) + offsets_limited_rl[i, :, :, :] = offsets_limited_rl[i + 1, :, :, :] + delta_yaw offsets_array = (offsets_limited_lr + offsets_limited_rl) / 2 # Apply ws rate limits offsets_limited_lr = offsets_array.copy() for j in range(1, len(ws_array)): - delta_yaw = offsets_limited_lr[:, j, :, :] - offsets_limited_lr[:, j-1, :, :] - delta_yaw = np.clip(delta_yaw, -ws_rate_limit*ws_step, ws_rate_limit*ws_step) - offsets_limited_lr[:, j, :, :] = offsets_limited_lr[:, j-1, :, :] + delta_yaw + delta_yaw = offsets_limited_lr[:, j, :, :] - offsets_limited_lr[:, j - 1, :, :] + delta_yaw = np.clip(delta_yaw, -ws_rate_limit * ws_step, ws_rate_limit * ws_step) + offsets_limited_lr[:, j, :, :] = offsets_limited_lr[:, j - 1, :, :] + delta_yaw offsets_limited_rl = offsets_array.copy() - for j in range(len(ws_array)-2, -1, -1): - delta_yaw = offsets_limited_rl[:, j, :, :] - offsets_limited_rl[:, j+1, :, :] - delta_yaw = np.clip(delta_yaw, -ws_rate_limit*ws_step, ws_rate_limit*ws_step) - offsets_limited_rl[:, j, :, :] = offsets_limited_rl[:, j+1, :, :] + delta_yaw + for j in range(len(ws_array) - 2, -1, -1): + delta_yaw = offsets_limited_rl[:, j, :, :] - offsets_limited_rl[:, j + 1, :, :] + delta_yaw = np.clip(delta_yaw, -ws_rate_limit * ws_step, ws_rate_limit * ws_step) + offsets_limited_rl[:, j, :, :] = offsets_limited_rl[:, j + 1, :, :] + delta_yaw offsets_array = (offsets_limited_lr + offsets_limited_rl) / 2 # Apply ti rate limits offsets_limited_lr = offsets_array.copy() for k in range(1, len(ti_array)): - delta_yaw = offsets_limited_lr[:, :, k, :] - offsets_limited_lr[:, :, k-1, :] - delta_yaw = np.clip(delta_yaw, -ti_rate_limit*ti_step, ti_rate_limit*ti_step) - offsets_limited_lr[:, :, k, :] = offsets_limited_lr[:, :, k-1, :] + delta_yaw + delta_yaw = offsets_limited_lr[:, :, k, :] - offsets_limited_lr[:, :, k - 1, :] + delta_yaw = np.clip(delta_yaw, -ti_rate_limit * ti_step, ti_rate_limit * ti_step) + offsets_limited_lr[:, :, k, :] = offsets_limited_lr[:, :, k - 1, :] + delta_yaw offsets_limited_rl = offsets_array.copy() - for k in range(len(ti_array)-2, -1, -1): - delta_yaw = offsets_limited_rl[:, :, k, :] - offsets_limited_rl[:, :, k+1, :] - delta_yaw = np.clip(delta_yaw, -ti_rate_limit*ti_step, ti_rate_limit*ti_step) - offsets_limited_rl[:, :, k, :] = offsets_limited_rl[:, :, k+1, :] + delta_yaw + for k in range(len(ti_array) - 2, -1, -1): + delta_yaw = offsets_limited_rl[:, :, k, :] - offsets_limited_rl[:, :, k + 1, :] + delta_yaw = np.clip(delta_yaw, -ti_rate_limit * ti_step, ti_rate_limit * ti_step) + offsets_limited_rl[:, :, k, :] = offsets_limited_rl[:, :, k + 1, :] + delta_yaw offsets_array = (offsets_limited_lr + offsets_limited_rl) / 2 # Flatten array back into 2D array for dataframe offsets_all_limited = offsets_array.reshape( - (len(wd_array)*len(ws_array)*len(ti_array), offsets_all.shape[-1]) + (len(wd_array) * len(ws_array) * len(ti_array), offsets_all.shape[-1]) ) df_opt_rate_limited = df_opt.copy() df_opt_rate_limited["yaw_angles_opt"] = [*offsets_all_limited] @@ -251,7 +251,7 @@ def compute_hysteresis_zones( min_zone_width: float = 2.0, yaw_rate_threshold: float = 10.0, verbose: bool = False, -) -> dict[str: list[tuple[float, float]]]: +) -> dict[str : list[tuple[float, float]]]: """ Compute wind direction sectors where hysteresis is applied. @@ -284,33 +284,29 @@ def compute_hysteresis_zones( offsets = offsets_stacked.reshape( len(wind_directions), len(np.unique(df_opt.wind_speed)), - len(np.unique(df_opt.turbulence_intensity)), - offsets_stacked.shape[1] + len(np.unique(df_opt.turbulence_intensity)), + offsets_stacked.shape[1], ) # Add 360 to end, if full wind rose and wraps if len(wind_directions) == 1: raise ValueError("Cannot compute hysteresis regions for single wind direction.") - wd_steps = wind_directions[1:]-wind_directions[:-1] - if ((wind_directions[0] - wd_steps[0] < 0) - & (wind_directions[-1] + wd_steps[-1] >= 360) - ): + wd_steps = wind_directions[1:] - wind_directions[:-1] + if (wind_directions[0] - wd_steps[0] < 0) & (wind_directions[-1] + wd_steps[-1] >= 360): offsets = np.concatenate((offsets, offsets[0:1, :, :, :]), axis=0) wind_directions = np.concatenate((wind_directions, [wind_directions[-1] + wd_steps[-1]])) - wd_steps = wind_directions[1:]-wind_directions[:-1] + wd_steps = wind_directions[1:] - wind_directions[:-1] wd_centers = wind_directions[:-1] + 0.5 * wd_steps # Define function that identifies hysteresis zones - jump_threshold = yaw_rate_threshold*wd_steps[:,None,None,None] + jump_threshold = yaw_rate_threshold * wd_steps[:, None, None, None] jump_idx = np.argwhere(np.abs(np.diff(offsets, axis=0)) >= jump_threshold) # Drop information about ws, ti jump_idx = np.unique(jump_idx[:, [0, 3]], axis=0) # Convert to a per-turbine dictionary of switching wind directions centers_dict = {} - for t in np.unique(jump_idx[:,1]): - centers_dict["T{:03d}".format(t)] = ( - wd_centers[jump_idx[jump_idx[:,1] == t][:,0]] - ) + for t in np.unique(jump_idx[:, 1]): + centers_dict["T{:03d}".format(t)] = wd_centers[jump_idx[jump_idx[:, 1] == t][:, 0]] if verbose: print("Center wind directions for hysteresis, per turbine: {}".format(centers_dict)) print("Computing hysteresis regions.") @@ -322,8 +318,8 @@ def compute_hysteresis_zones( for wd_switch_point in centers_dict[turbine_tag]: t = int(turbine_tag[1:]) # Create region of minimum width - lb = wrap_360(wd_switch_point - min_zone_width/2) - ub = wrap_360(wd_switch_point + min_zone_width/2) + lb = wrap_360(wd_switch_point - min_zone_width / 2) + ub = wrap_360(wd_switch_point + min_zone_width / 2) hysteresis_wds.append((lb, ub)) # Consolidate regions @@ -334,6 +330,7 @@ def compute_hysteresis_zones( return hysteresis_dict + def consolidate_hysteresis_zones(hysteresis_wds): """ Merge hysteresis zones that overlap. @@ -355,29 +352,25 @@ def consolidate_hysteresis_zones(hysteresis_wds): hysteresis_wds = sorted(hysteresis_wds, key=lambda x: x[0]) i_h = 0 - while i_h < len(hysteresis_wds)-1: + while i_h < len(hysteresis_wds) - 1: # Continue merging into the ith until no more overlaps with the ith - while ((hysteresis_wds[i_h+1][0] <= hysteresis_wds[i_h][1]) - or ((hysteresis_wds[i_h][1] < hysteresis_wds[i_h][0]) - and (hysteresis_wds[i_h+1][0] > hysteresis_wds[i_h+1][1]) - ) - ): + while (hysteresis_wds[i_h + 1][0] <= hysteresis_wds[i_h][1]) or ( + (hysteresis_wds[i_h][1] < hysteresis_wds[i_h][0]) + and (hysteresis_wds[i_h + 1][0] > hysteresis_wds[i_h + 1][1]) + ): # Merge regions - hysteresis_wds[i_h] = ( - hysteresis_wds[i_h][0], - hysteresis_wds[i_h+1][1] - ) + hysteresis_wds[i_h] = (hysteresis_wds[i_h][0], hysteresis_wds[i_h + 1][1]) # Remove next region - hysteresis_wds.pop(i_h+1) - if len(hysteresis_wds) <= i_h+1: + hysteresis_wds.pop(i_h + 1) + if len(hysteresis_wds) <= i_h + 1: break i_h += 1 # Handle wrap-around at 360 degrees - for _ in range(len(hysteresis_wds)): # Multiple loops in case multiple overlaps - if ((hysteresis_wds[-1][1] >= hysteresis_wds[0][0]) - and (hysteresis_wds[-1][1] < hysteresis_wds[-1][0]) - ): + for _ in range(len(hysteresis_wds)): # Multiple loops in case multiple overlaps + if (hysteresis_wds[-1][1] >= hysteresis_wds[0][0]) and ( + hysteresis_wds[-1][1] < hysteresis_wds[-1][0] + ): # Merge last and first regions hysteresis_wds[0] = (hysteresis_wds[-1][0], hysteresis_wds[0][1]) if len(hysteresis_wds) > 1: @@ -385,6 +378,7 @@ def consolidate_hysteresis_zones(hysteresis_wds): return hysteresis_wds + def apply_wind_speed_ramps( df_opt: pd.DataFrame, ws_resolution: float = 1.0, @@ -420,10 +414,12 @@ def apply_wind_speed_ramps( check_df_opt_ordering(df_opt) # Check valid ordering of wind speeds - if (ws_wake_steering_cut_in + if ( + ws_wake_steering_cut_in <= ws_wake_steering_fully_engaged_low <= ws_wake_steering_fully_engaged_high - <= ws_wake_steering_cut_out): + <= ws_wake_steering_cut_out + ): pass else: raise ValueError( @@ -440,13 +436,13 @@ def apply_wind_speed_ramps( ws_specified = df_opt["wind_speed"].unique() # Check that provided wind speed is between the fully engaged limits - if (ws_specified < ws_wake_steering_fully_engaged_low - or ws_specified > ws_wake_steering_fully_engaged_high): - raise ValueError( - "Provided wind speed must be between fully engaged limits." - ) + if ( + ws_specified < ws_wake_steering_fully_engaged_low + or ws_specified > ws_wake_steering_fully_engaged_high + ): + raise ValueError("Provided wind speed must be between fully engaged limits.") - offsets_specified = np.vstack(df_opt.yaw_angles_opt.to_numpy())[None,:,:] + offsets_specified = np.vstack(df_opt.yaw_angles_opt.to_numpy())[None, :, :] # Pack offsets with zero values at the cut in, start, finish, and cut out wind speeds offsets_ramps = np.concatenate( @@ -456,18 +452,20 @@ def apply_wind_speed_ramps( offsets_specified, offsets_specified, np.zeros_like(offsets_specified), - np.zeros_like(offsets_specified) + np.zeros_like(offsets_specified), ), - axis=0 + axis=0, + ) + wind_speed_ramps = np.array( + [ + ws_min, + ws_wake_steering_cut_in, + ws_wake_steering_fully_engaged_low, + ws_wake_steering_fully_engaged_high, + ws_wake_steering_cut_out, + ws_max, + ] ) - wind_speed_ramps = np.array([ - ws_min, - ws_wake_steering_cut_in, - ws_wake_steering_fully_engaged_low, - ws_wake_steering_fully_engaged_high, - ws_wake_steering_cut_out, - ws_max - ]) # Build interpolator and interpolate to desired wind speeds interp = interp1d( @@ -475,7 +473,7 @@ def apply_wind_speed_ramps( offsets_ramps, axis=0, bounds_error=False, - fill_value=np.zeros_like(offsets_ramps[0,:,:]) + fill_value=np.zeros_like(offsets_ramps[0, :, :]), ) wind_speed_all = np.arange(ws_min, ws_max, ws_resolution) offsets_stacked = interp(wind_speed_all).reshape(-1, offsets_ramps.shape[2]) @@ -484,12 +482,14 @@ def apply_wind_speed_ramps( wind_speed_stacked = np.repeat(wind_speed_all, len(df_opt)) turbulence_intensity_stacked = np.tile(df_opt.turbulence_intensity, len(wind_speed_all)) - return pd.DataFrame({ - "wind_direction": wind_direction_stacked, - "wind_speed": wind_speed_stacked, - "turbulence_intensity": turbulence_intensity_stacked, - "yaw_angles_opt": [offsets_stacked[i,:] for i in range(offsets_stacked.shape[0])] - }) + return pd.DataFrame( + { + "wind_direction": wind_direction_stacked, + "wind_speed": wind_speed_stacked, + "turbulence_intensity": turbulence_intensity_stacked, + "yaw_angles_opt": [offsets_stacked[i, :] for i in range(offsets_stacked.shape[0])], + } + ) def get_yaw_angles_interpolant(df_opt): @@ -509,7 +509,7 @@ def get_yaw_angles_interpolant(df_opt): Wind speeds and turbulence intensities are extended to include all reasonable values by copying the first and last values. Wind directions are extended to handle wind directions - up to 360 degrees only if the first value is 0 degrees. + up to 360 degrees only if the first value is 0 degrees. An error is raised if the resulting interpolant is queried outside of the extended wind direction, wind speed, or turbulence intensity ranges. @@ -556,23 +556,21 @@ def get_yaw_angles_interpolant(df_opt): # Create lower and upper wind speed and turbulence intensity bounds wind_speeds = np.concatenate([[-1.0], wind_speeds, [999.0]]) yaw_offsets = np.concatenate( - [yaw_offsets[:, 0:1, :, :], yaw_offsets, yaw_offsets[:, -1:, :, :]], - axis=1 + [yaw_offsets[:, 0:1, :, :], yaw_offsets, yaw_offsets[:, -1:, :, :]], axis=1 ) turbulence_intensities = np.concatenate([[-1.0], turbulence_intensities, [999.0]]) yaw_offsets = np.concatenate( - [yaw_offsets[:, :, 0:1, :], yaw_offsets, yaw_offsets[:, :, -1:, :]], - axis=2 + [yaw_offsets[:, :, 0:1, :], yaw_offsets, yaw_offsets[:, :, -1:, :]], axis=2 ) # Linear interpolant for the yaw angles interpolant = RegularGridInterpolator( points=(wind_directions, wind_speeds, turbulence_intensities), values=yaw_offsets, - bounds_error=True + bounds_error=True, ) - # Store for bounds checks + # Store for bounds checks wd_min = wind_directions.min() wd_max = wind_directions.max() ws_min = wind_speeds.min() @@ -592,18 +590,23 @@ def yaw_angle_interpolant(wd_array, ws_array, ti_array=None): ti_array = np.array(ti_array, dtype=float) # Check inputs are within bounds - if (np.any(wd_array < wd_min) or np.any(wd_array > wd_max) - or np.any(ws_array < ws_min) or np.any(ws_array > ws_max) - or np.any(ti_array < ti_min) or np.any(ti_array > ti_max)): + if ( + np.any(wd_array < wd_min) + or np.any(wd_array > wd_max) + or np.any(ws_array < ws_min) + or np.any(ws_array > ws_max) + or np.any(ti_array < ti_min) + or np.any(ti_array > ti_max) + ): err_msg = ( "Interpolator queried outside of allowable bounds:\n" - "Wind direction bounds: ["+str(wd_min)+", "+str(wd_max)+"]\n" - "Wind speed bounds: ["+str(ws_min)+", "+str(ws_max)+"]\n" - "Turbulence intensity bounds: ["+str(ti_min)+", "+str(ti_max)+"]\n\n" + "Wind direction bounds: [" + str(wd_min) + ", " + str(wd_max) + "]\n" + "Wind speed bounds: [" + str(ws_min) + ", " + str(ws_max) + "]\n" + "Turbulence intensity bounds: [" + str(ti_min) + ", " + str(ti_max) + "]\n\n" "Queried at:\n" - "Wind directions: "+str(wd_array)+" \n" - "Wind speeds: "+str(ws_array)+" \n" - "Turbulence intensities: "+str(ti_array) + "Wind directions: " + str(wd_array) + " \n" + "Wind speeds: " + str(ws_array) + " \n" + "Turbulence intensities: " + str(ti_array) ) raise ValueError(err_msg) @@ -624,7 +627,7 @@ def create_uniform_wind_rose( ti_min: float = 0.06, ti_max: float = 0.06, ): - """" + """ " Create a uniform wind rose to use for wake steering optimizations. Args: @@ -641,9 +644,9 @@ def create_uniform_wind_rose( if wd_min == 0 and wd_max == 360: wd_max = wd_max - wd_resolution - wind_directions = np.arange(wd_min, wd_max+0.001, wd_resolution) - - wind_speeds = np.arange(ws_min, ws_max+0.001, ws_resolution) + wind_directions = np.arange(wd_min, wd_max + 0.001, wd_resolution) + + wind_speeds = np.arange(ws_min, ws_max + 0.001, ws_resolution) if ti_min == ti_max: return WindRose( @@ -652,7 +655,7 @@ def create_uniform_wind_rose( ti_table=ti_min, ) else: - turbulence_intensities = np.arange(ti_min, ti_max+0.0001, ti_resolution) + turbulence_intensities = np.arange(ti_min, ti_max + 0.0001, ti_resolution) return WindTIRose( wind_speeds=wind_speeds, @@ -660,6 +663,7 @@ def create_uniform_wind_rose( turbulence_intensities=turbulence_intensities, ) + def check_df_opt_ordering(df_opt): """ Check that the ordering of inputs is first wind direction, then wind speed, @@ -677,20 +681,22 @@ def check_df_opt_ordering(df_opt): ti_unique = np.unique(df_opt["turbulence_intensity"]) # Check full - if not inputs_all.shape[0] == len(wd_unique)*len(ws_unique)*len(ti_unique): + if not inputs_all.shape[0] == len(wd_unique) * len(ws_unique) * len(ti_unique): raise ValueError( "All combinations of wind direction, wind speed, and turbulence intensity " "must be specified." ) # Check order is correct - wds_reshaped = inputs_all[:,0].reshape((len(wd_unique), len(ws_unique), len(ti_unique))) - wss_reshaped = inputs_all[:,1].reshape((len(wd_unique), len(ws_unique), len(ti_unique))) - tis_reshaped = inputs_all[:,2].reshape((len(wd_unique), len(ws_unique), len(ti_unique))) - - if (not np.all(wds_reshaped == wd_unique[:,None,None]) - or not np.all(wss_reshaped == ws_unique[None,:,None]) - or not np.all(tis_reshaped == ti_unique[None,None,:])): + wds_reshaped = inputs_all[:, 0].reshape((len(wd_unique), len(ws_unique), len(ti_unique))) + wss_reshaped = inputs_all[:, 1].reshape((len(wd_unique), len(ws_unique), len(ti_unique))) + tis_reshaped = inputs_all[:, 2].reshape((len(wd_unique), len(ws_unique), len(ti_unique))) + + if ( + not np.all(wds_reshaped == wd_unique[:, None, None]) + or not np.all(wss_reshaped == ws_unique[None, :, None]) + or not np.all(tis_reshaped == ti_unique[None, None, :]) + ): raise ValueError( "df_opt must be ordered first by wind direction, then by wind speed, " "then by turbulence intensity." diff --git a/hycon/design_tools/wake_steering_visualization.py b/hycon/design_tools/wake_steering_visualization.py index a5155026..28e5b216 100755 --- a/hycon/design_tools/wake_steering_visualization.py +++ b/hycon/design_tools/wake_steering_visualization.py @@ -5,13 +5,7 @@ def plot_offsets_wdws_heatmap( - df_opt, - turb_id, - ti_plot=None, - vmin=None, - vmax=None, - cmap="coolwarm", - ax=None + df_opt, turb_id, ti_plot=None, vmin=None, vmax=None, cmap="coolwarm", ax=None ): """Plot offsets for a single turbine as a heatmap in wind speed. @@ -89,12 +83,12 @@ def plot_offsets_wd( df_opt, turb_id, ws_plot, - ti_plot = None, - color = "black", - linestyle = "-", - alpha = 1.0, - label = None, - ax = None + ti_plot=None, + color="black", + linestyle="-", + alpha=1.0, + label=None, + ax=None, ): """Plot offsets for a single turbine as a function of wind direction. @@ -153,7 +147,7 @@ def plot_offsets_wd( pass else: raise ValueError("One or more ws_plot values not found in df_opt.wind_speed.") - + if set(ti_plot) <= set(df_opt.turbulence_intensity): pass else: @@ -181,4 +175,4 @@ def plot_offsets_wd( ax.set_xlabel("Wind direction") ax.set_ylabel("Yaw offset") - return ax \ No newline at end of file + return ax diff --git a/hycon/interfaces/hercules_interface.py b/hycon/interfaces/hercules_interface.py index 396417f2..152e795d 100644 --- a/hycon/interfaces/hercules_interface.py +++ b/hycon/interfaces/hercules_interface.py @@ -8,6 +8,7 @@ class HerculesInterface(InterfaceBase): """ Class for interfacing with Hercules v2 simulator. """ + def __init__(self, h_dict): super().__init__() self.dt = h_dict["dt"] @@ -43,9 +44,7 @@ def __init__(self, h_dict): # Solar farm parameters if self._has_solar_component: - self.plant_parameters["solar_farm"] = { - "capacity": h_dict["solar_farm"]["capacity"] - } + self.plant_parameters["solar_farm"] = {"capacity": h_dict["solar_farm"]["capacity"]} # Battery parameters if self._has_battery_component: @@ -55,9 +54,8 @@ def __init__(self, h_dict): "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 - ) + "allow_grid_power_consumption", False + ), } # Electrolyzer parameters (placeholder for future electrolyzer parameters) @@ -80,8 +78,8 @@ def check_controls(self, controls_dict): 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) + "Number of wind power setpoints ({0})".format(len(controls_dict[k])) + + " must match number of turbines ({0}).".format(self._n_turbines) ) def get_measurements(self, h_dict): @@ -99,7 +97,7 @@ def get_measurements(self, h_dict): 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, + "wind_directions": [h_dict["wind_farm"]["wind_direction_mean"]] * self._n_turbines, # TODO: wind_speeds? } total_power += sum(measurements["wind_farm"]["turbine_powers"]) @@ -171,12 +169,12 @@ def get_measurements(self, h_dict): return measurements def send_controls( - self, - h_dict, - wind_power_setpoints=None, - solar_power_setpoint=None, - battery_power_setpoint=None - ): + self, + h_dict, + wind_power_setpoints=None, + solar_power_setpoint=None, + battery_power_setpoint=None, + ): if wind_power_setpoints is None: wind_power_setpoints = [POWER_SETPOINT_DEFAULT] * self._n_turbines if solar_power_setpoint is None: diff --git a/hycon/interfaces/hercules_v1_interface.py b/hycon/interfaces/hercules_v1_interface.py index 5b8f3d64..17ab6924 100644 --- a/hycon/interfaces/hercules_v1_interface.py +++ b/hycon/interfaces/hercules_v1_interface.py @@ -71,9 +71,9 @@ def send_controls(self, hercules_dict, yaw_angles=None, power_setpoints=None): power_setpoints = [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" - ] = power_setpoints + hercules_dict["hercules_comms"]["amr_wind"][self.wf_name]["turbine_power_setpoints"] = ( + power_setpoints + ) return hercules_dict @@ -91,37 +91,34 @@ def __init__(self, hercules_dict): self._has_wind_component = False self._has_battery_component = False self._has_hydrogen_component = False - # Grab name of wind, solar, and battery + # Grab name of wind, solar, and battery self.plant_parameters = {} for i in py_sims: - if tech_keys[0] in i.split('_'): + if tech_keys[0] in i.split("_"): self.solar_name = [ps for ps in py_sims if "solar" in ps][0] self._has_solar_component = True - if tech_keys[1] in i.split('_'): + if tech_keys[1] in i.split("_"): self.battery_name = [ps for ps in py_sims if "battery" in ps][0] self._has_battery_component = True self.plant_parameters["battery"] = { - "charge_rate": hercules_dict["py_sims"][self.battery_name]\ - ["charge_rate"]*1000, - "discharge_rate": hercules_dict["py_sims"][self.battery_name]\ - ["discharge_rate"]*1000, - } # Convert to kW + "charge_rate": hercules_dict["py_sims"][self.battery_name]["charge_rate"] + * 1000, + "discharge_rate": hercules_dict["py_sims"][self.battery_name]["discharge_rate"] + * 1000, + } # Convert to kW if tech_keys[3] in i.split("_"): self.hydrogen_name = [ps for ps in py_sims if "hydrogen" in ps][0] self._has_hydrogen_component = True for i in hercules_comms: - if tech_keys[2] in i.split('_'): + 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.turbines = range(self.n_turbines) self._has_wind_component = True - self.plant_parameters["wind_farm"] = { - "n_turbines": self.n_turbines - } + self.plant_parameters["wind_farm"] = {"n_turbines": self.n_turbines} def get_measurements(self, hercules_dict): - time = hercules_dict["time"] # Defaults for external signals @@ -147,9 +144,9 @@ def get_measurements(self, hercules_dict): if "solar_power_reference" in hercules_dict["external_signals"]: solar_power_reference = hercules_dict["external_signals"]["solar_power_reference"] if "battery_power_reference" in hercules_dict["external_signals"]: - battery_power_reference = ( - hercules_dict["external_signals"]["battery_power_reference"] - ) + battery_power_reference = hercules_dict["external_signals"][ + "battery_power_reference" + ] if "hydrogen_reference" in hercules_dict["external_signals"]: hydrogen_power_reference = hercules_dict["external_signals"]["hydrogen_reference"] @@ -159,24 +156,26 @@ def get_measurements(self, hercules_dict): "time": time, "plant_power_reference": plant_power_reference, "forecast": forecast, - } + } if self._has_wind_component: - turbine_powers = ( - hercules_dict["hercules_comms"]["amr_wind"][self.wind_name]["turbine_powers"] - ) + turbine_powers = hercules_dict["hercules_comms"]["amr_wind"][self.wind_name][ + "turbine_powers" + ] measurements["wind_farm"] = { "turbine_powers": turbine_powers, - "wind_speed": hercules_dict["hercules_comms"]["amr_wind"][self.wind_name]\ - ["wind_speed"], + "wind_speed": hercules_dict["hercules_comms"]["amr_wind"][self.wind_name][ + "wind_speed" + ], "power_reference": wind_power_reference, } total_power += sum(turbine_powers) if self._has_solar_component: measurements["solar_farm"] = { "power": hercules_dict["py_sims"][self.solar_name]["outputs"]["power_mw"] * 1000, - "direct_normal_irradiance": hercules_dict["py_sims"][self.solar_name]["outputs"]\ - ["dni"], + "direct_normal_irradiance": hercules_dict["py_sims"][self.solar_name]["outputs"][ + "dni" + ], "angle_of_incidence": hercules_dict["py_sims"][self.solar_name]["outputs"]["aoi"], "power_reference": solar_power_reference, } @@ -191,8 +190,9 @@ def get_measurements(self, hercules_dict): if self._has_hydrogen_component: # hydrogen production rate in kg/s measurements["hydrogen"] = { - "production_rate": hercules_dict["py_sims"][self.hydrogen_name]["outputs"]\ - ["H2_mfr"], + "production_rate": hercules_dict["py_sims"][self.hydrogen_name]["outputs"][ + "H2_mfr" + ], "power_reference": hydrogen_power_reference, } measurements["total_power"] = total_power @@ -203,7 +203,7 @@ def check_controls(self, controls_dict): available_controls = [ "wind_power_setpoints", "solar_power_setpoint", - "battery_power_setpoint" + "battery_power_setpoint", ] for k in controls_dict.keys(): @@ -216,12 +216,12 @@ def check_controls(self, controls_dict): ) def send_controls( - self, - hercules_dict, - wind_power_setpoints=None, - solar_power_setpoint=None, - battery_power_setpoint=None - ): + self, + hercules_dict, + wind_power_setpoints=None, + solar_power_setpoint=None, + battery_power_setpoint=None, + ): if wind_power_setpoints is None: wind_power_setpoints = [POWER_SETPOINT_DEFAULT] * self.n_turbines if solar_power_setpoint is None: @@ -229,16 +229,19 @@ def send_controls( 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["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 + { + "battery_signal": -battery_power_setpoint, + "solar_setpoint_mw": solar_power_setpoint / 1000, + } # Convert to MW ) return hercules_dict + class HerculesV1BatteryInterface(InterfaceBase): def __init__(self, hercules_dict): super().__init__() @@ -256,17 +259,18 @@ def __init__(self, hercules_dict): self.plant_parameters = { "battery": { - "charge_rate": hercules_dict["py_sims"][self.battery_name]\ - ["charge_rate"]*1000, - "discharge_rate": hercules_dict["py_sims"][self.battery_name]\ - ["discharge_rate"]*1000, + "charge_rate": hercules_dict["py_sims"][self.battery_name]["charge_rate"] * 1000, + "discharge_rate": hercules_dict["py_sims"][self.battery_name]["discharge_rate"] + * 1000, } } def get_measurements(self, hercules_dict): # Extract externally-provided power signal - if ("external_signals" in hercules_dict - and "plant_power_reference" in hercules_dict["external_signals"]): + if ( + "external_signals" in hercules_dict + and "plant_power_reference" in hercules_dict["external_signals"] + ): plant_power_reference = hercules_dict["external_signals"]["plant_power_reference"] else: plant_power_reference = 0 @@ -290,11 +294,11 @@ def check_controls(self, controls_dict): 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}) return hercules_dict + # Aliases for backward compatibility HerculesBatteryInterface = HerculesV1BatteryInterface HerculesADInterface = HerculesV1ADInterface diff --git a/tests/battery_test.py b/tests/battery_test.py index dfccb65b..74ccf4f7 100644 --- a/tests/battery_test.py +++ b/tests/battery_test.py @@ -28,12 +28,9 @@ def test_BatteryPriceSOCController_init(): 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_charging == test_hercules_dict["battery"]["charge_rate"] - ) - assert ( - test_controller.rated_power_discharging - == test_hercules_dict["battery"]["discharge_rate"] + test_controller.rated_power_discharging == test_hercules_dict["battery"]["discharge_rate"] ) @@ -47,7 +44,7 @@ def test_BatteryPriceSOCController_compute_controls(): 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 + 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 diff --git a/tests/controller_library_test.py b/tests/controller_library_test.py index e215c2b5..c4a97efa 100644 --- a/tests/controller_library_test.py +++ b/tests/controller_library_test.py @@ -46,6 +46,7 @@ def check_controls(self): def send_controls(self): pass + # TODO: make these fixtures, use across interfaces and controller tests test_hercules_dict = { "dt": 1, @@ -69,15 +70,18 @@ def send_controls(self): "py_sims": { "test_battery": { "outputs": {"power": 10.0, "soc": 0.3}, - "charge_rate":20, - "discharge_rate":20 + "charge_rate": 20, + "discharge_rate": 20, }, "test_solar": {"outputs": {"power_mw": 1.0, "dni": 1000.0, "aoi": 30.0}}, "test_hydrogen": {"outputs": {"H2_mfr": 0.03}}, "inputs": {}, }, - "external_signals": {"wind_power_reference": 1000.0, "plant_power_reference": 1000.0, - "hydrogen_reference": 0.02}, + "external_signals": { + "wind_power_reference": 1000.0, + "plant_power_reference": 1000.0, + "hydrogen_reference": 0.02, + }, } test_hercules_v2_dict = { @@ -98,7 +102,7 @@ def send_controls(self): }, "solar_farm": { "capacity": 1000.0, - "power": 1000.0, # kW + "power": 1000.0, # kW "dni": 1000.0, "aoi": 30.0, }, @@ -139,7 +143,7 @@ def test_controller_instantiation(): _ = HybridSupervisoryControllerBaseline( interface=test_interface, input_dict=test_hercules_dict, - wind_controller=1, # Override error raised for empty controllers + wind_controller=1, # Override error raised for empty controllers ) _ = SolarPassthroughController(interface=test_interface, input_dict=test_hercules_dict) _ = BatteryPassthroughController(interface=test_interface, input_dict=test_hercules_dict) @@ -151,8 +155,7 @@ def test_LookupBasedWakeSteeringController(): # No lookup table passed; simply passes through wind direction to yaw angles test_controller = LookupBasedWakeSteeringController( - interface=test_interface, - input_dict=test_hercules_dict + interface=test_interface, input_dict=test_hercules_dict ) # Check that the controller can be stepped @@ -169,16 +172,16 @@ def test_LookupBasedWakeSteeringController(): # 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 - }) + 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, - input_dict=test_hercules_dict, - df_yaw=df_opt_test + interface=test_interface, input_dict=test_hercules_dict, df_yaw=df_opt_test ) test_hercules_dict["time"] = 20 @@ -191,11 +194,11 @@ def test_LookupBasedWakeSteeringController(): ) assert np.allclose(test_angles, wind_directions - test_offsets) + def test_WindFarmPowerDistributingController(): test_interface = HerculesADInterface(test_hercules_dict) test_controller = WindFarmPowerDistributingController( - interface=test_interface, - input_dict=test_hercules_dict + interface=test_interface, input_dict=test_hercules_dict ) # Default behavior when no power reference is given @@ -207,7 +210,7 @@ def test_WindFarmPowerDistributingController(): ) assert np.allclose( test_power_setpoints, - POWER_SETPOINT_DEFAULT/test_hercules_dict["controller"]["num_turbines"], + POWER_SETPOINT_DEFAULT / test_hercules_dict["controller"]["num_turbines"], ) # Test with power reference @@ -217,12 +220,12 @@ def test_WindFarmPowerDistributingController(): test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] ) assert np.allclose(test_power_setpoints, 500) - + + def test_WindFarmPowerTrackingController(): test_interface = HerculesADInterface(test_hercules_dict) test_controller = WindFarmPowerTrackingController( - interface=test_interface, - input_dict=test_hercules_dict + interface=test_interface, input_dict=test_hercules_dict ) # Test no change to power setpoints if producing desired power @@ -258,9 +261,7 @@ def test_WindFarmPowerTrackingController(): # Test that more aggressive control leads to faster response test_controller = WindFarmPowerTrackingController( - interface=test_interface, - input_dict=test_hercules_dict, - proportional_gain=2 + interface=test_interface, input_dict=test_hercules_dict, proportional_gain=2 ) test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [600, 600] test_hercules_dict_out = test_controller.step(input_dict=test_hercules_dict) @@ -269,6 +270,7 @@ def test_WindFarmPowerTrackingController(): ) assert (test_power_setpoints_a < test_power_setpoints).all() + def test_HybridSupervisoryControllerBaseline(): test_interface = HerculesHybridADInterface(test_hercules_dict) @@ -282,10 +284,10 @@ def test_HybridSupervisoryControllerBaseline(): input_dict=test_hercules_dict, wind_controller=wind_controller, solar_controller=solar_controller, - battery_controller=battery_controller + battery_controller=battery_controller, ) - solar_current = 800 + solar_current = 800 wind_current = [600, 300] power_ref = 1000 @@ -293,24 +295,24 @@ def test_HybridSupervisoryControllerBaseline(): test_hercules_dict["external_signals"]["plant_power_reference"] = power_ref test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = wind_current test_hercules_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.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 + 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 - 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 + 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 + supervisory_control_output, [wind_power_cmd, solar_power_cmd, battery_power_cmd] + ) # To charge battery + def test_HybridSupervisoryControllerBaseline_subsets(): """ @@ -330,10 +332,10 @@ def test_HybridSupervisoryControllerBaseline_subsets(): input_dict=test_hercules_dict, wind_controller=wind_controller, solar_controller=solar_controller, - battery_controller=None + battery_controller=None, ) - solar_current = 800 + solar_current = 800 wind_current = [600, 300] power_ref = 1000 @@ -341,23 +343,22 @@ def test_HybridSupervisoryControllerBaseline_subsets(): test_hercules_dict["external_signals"]["plant_power_reference"] = power_ref test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = wind_current test_hercules_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.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 + test_controller.step(test_hercules_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! + 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] - ) + supervisory_control_output, [wind_power_cmd, solar_power_cmd, battery_power_cmd] + ) ## Next, wind and battery only test_controller = HybridSupervisoryControllerBaseline( @@ -365,23 +366,22 @@ def test_HybridSupervisoryControllerBaseline_subsets(): input_dict=test_hercules_dict, wind_controller=wind_controller, solar_controller=None, - battery_controller=battery_controller + 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_dict) # Run the controller once to update measurements + 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 ) wind_power_cmd = 20000 + power_ref - solar_power_cmd = 0 # No solar controller! + 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] + supervisory_control_output, [wind_power_cmd, solar_power_cmd, battery_power_cmd] ) ## Finally, solar and battery only @@ -390,23 +390,22 @@ def test_HybridSupervisoryControllerBaseline_subsets(): input_dict=test_hercules_dict, wind_controller=None, solar_controller=solar_controller, - battery_controller=battery_controller + battery_controller=battery_controller, ) - test_controller.prev_solar_power = solar_current # To override filtering + test_controller.prev_solar_power = solar_current # To override filtering test_controller.prev_wind_power = 0 - test_controller.step(test_hercules_dict) # Run the controller once to update measurements + test_controller.step(test_hercules_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! + 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] + supervisory_control_output, [wind_power_cmd, solar_power_cmd, battery_power_cmd] ) ## Either wind or solar controller must be defined @@ -416,7 +415,7 @@ def test_HybridSupervisoryControllerBaseline_subsets(): input_dict=test_hercules_dict, wind_controller=None, solar_controller=None, - battery_controller=battery_controller + battery_controller=battery_controller, ) ## Only wind controller @@ -425,23 +424,22 @@ def test_HybridSupervisoryControllerBaseline_subsets(): input_dict=test_hercules_dict, wind_controller=wind_controller, solar_controller=None, - battery_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_dict) # Run the controller once to update measurements + 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 ) wind_power_cmd = power_ref - solar_power_cmd = 0 # No solar controller! - battery_power_cmd = 0 # No battery controller! + 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] + supervisory_control_output, [wind_power_cmd, solar_power_cmd, battery_power_cmd] ) ## Only solar controller @@ -450,25 +448,25 @@ def test_HybridSupervisoryControllerBaseline_subsets(): input_dict=test_hercules_dict, wind_controller=None, solar_controller=solar_controller, - battery_controller=None + battery_controller=None, ) - test_controller.prev_solar_power = solar_current # To override filtering + test_controller.prev_solar_power = solar_current # To override filtering test_controller.prev_wind_power = 0 - test_controller.step(test_hercules_dict) # Run the controller once to update measurements + test_controller.step(test_hercules_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! + wind_power_cmd = 0 # No wind controller! solar_power_cmd = power_ref - battery_power_cmd = 0 # No battery controller! + battery_power_cmd = 0 # No battery controller! assert np.allclose( - supervisory_control_output, - [wind_power_cmd, solar_power_cmd, battery_power_cmd] + supervisory_control_output, [wind_power_cmd, solar_power_cmd, battery_power_cmd] ) + def test_HybridSupervisoryControllerMultiRef_requirements(): # Check that errors are correctly raised if interconnect_limit is not set correctly test_hercules_v2_dict_temp = copy.deepcopy(test_hercules_v2_dict) @@ -481,12 +479,13 @@ def test_HybridSupervisoryControllerMultiRef_requirements(): with pytest.raises(ValueError): interface = HerculesInterface(test_hercules_v2_dict_temp) HybridSupervisoryControllerMultiRef(interface, test_hercules_v2_dict_temp) - + test_hercules_v2_dict_temp["plant"]["interconnect_limit"] = -1 with pytest.raises(ValueError): interface = HerculesInterface(test_hercules_v2_dict_temp) HybridSupervisoryControllerMultiRef(interface, test_hercules_v2_dict_temp) + def test_HybridSupervisoryControllerMultiRef(): test_interface = HerculesInterface(test_hercules_v2_dict) @@ -500,7 +499,7 @@ def test_HybridSupervisoryControllerMultiRef(): input_dict=test_hercules_v2_dict, wind_controller=wind_controller, solar_controller=solar_controller, - battery_controller=battery_controller + battery_controller=battery_controller, ) solar_current = 800 @@ -509,9 +508,9 @@ def test_HybridSupervisoryControllerMultiRef(): # Simply test the supervisory_control method, for the time being test_hercules_v2_dict["wind_farm"]["turbine_powers"] = wind_current test_hercules_v2_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_v2_dict) # Run the controller once to update measurements + 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_v2_dict) # Run the controller once to update measurements supervisory_control_output = test_controller.supervisory_control( test_controller._measurements_dict @@ -523,31 +522,34 @@ def test_HybridSupervisoryControllerMultiRef(): [ test_hercules_v2_dict["external_signals"]["wind_power_reference"], test_hercules_v2_dict["external_signals"]["solar_power_reference"], - test_hercules_v2_dict["external_signals"]["battery_power_reference"] - ] + test_hercules_v2_dict["external_signals"]["battery_power_reference"], + ], ) # Check individual components producing according to their references + def test_BatteryPassthroughController(): test_interface = HerculesHybridADInterface(test_hercules_dict) test_controller = BatteryPassthroughController(test_interface, test_hercules_dict) power_ref = 1000 - measurements_dict = {"battery":{"power_reference": power_ref}} + 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_interface = HerculesHybridADInterface(test_hercules_dict) test_controller = SolarPassthroughController(test_interface, test_hercules_dict) power_ref = 1000 - measurements_dict = {"solar_farm":{"power_reference": power_ref}} + 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_interface = HerculesBatteryInterface(test_hercules_dict) - test_controller = BatteryController(test_interface, test_hercules_dict, {"k_batt":0.1}) + test_controller = BatteryController(test_interface, test_hercules_dict, {"k_batt": 0.1}) # Test when starting with 0 power output power_ref = 1000 @@ -558,13 +560,13 @@ def test_BatteryController(): assert 0 < out_0 < power_ref # Test that increasing the gain increases the control response - test_controller = BatteryController(test_interface, test_hercules_dict, {"k_batt":0.5}) + test_controller = BatteryController(test_interface, test_hercules_dict, {"k_batt": 0.5}) test_controller.step(test_hercules_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_dict, {"k_batt":0.01}) + test_controller = BatteryController(test_interface, test_hercules_dict, {"k_batt": 0.01}) test_controller.step(test_hercules_dict) out_2 = test_controller._controls_dict["power_setpoint"] assert 0 < out_2 < out_0 @@ -572,7 +574,7 @@ def test_BatteryController(): # 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_dict, {"k_batt":0.1}) + test_controller = BatteryController(test_interface, test_hercules_dict, {"k_batt": 0.1}) battery_power = 0 for i, pr_in in enumerate(power_refs_in): @@ -587,33 +589,27 @@ def test_BatteryController(): 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 + 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["py_sims"]["test_battery"]["outputs"] = {"power": 0, "soc": 0.3} test_hercules_dict["external_signals"]["plant_power_reference"] = power_ref test_controller_0 = BatteryController( - test_interface, - test_hercules_dict, - {"clipping_thresholds":clipping_threshold_0} + test_interface, test_hercules_dict, {"clipping_thresholds": clipping_threshold_0} ) test_controller_0.step(test_hercules_dict) out_0 = test_controller_0._controls_dict["power_setpoint"] test_controller_1 = BatteryController( - test_interface, - test_hercules_dict, - {"clipping_thresholds":clipping_threshold_1} + test_interface, test_hercules_dict, {"clipping_thresholds": clipping_threshold_1} ) test_controller_1.step(test_hercules_dict) out_1 = test_controller_1._controls_dict["power_setpoint"] test_controller_2 = BatteryController( - test_interface, - test_hercules_dict, - {"clipping_thresholds":clipping_threshold_2} + test_interface, test_hercules_dict, {"clipping_thresholds": clipping_threshold_2} ) test_controller_2.step(test_hercules_dict) out_2 = test_controller_2._controls_dict["power_setpoint"] @@ -644,9 +640,10 @@ def test_BatteryController(): out_0 = test_controller_0._controls_dict["power_setpoint"] test_controller_1.step(test_hercules_dict) out_1 = test_controller_1._controls_dict["power_setpoint"] - + assert out_0 > out_1 + def test_HydrogenPlantController(): """ Tests that the HydrogenPlantController outputs a reasonable signal @@ -672,20 +669,22 @@ def test_HydrogenPlantController(): test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = wind_current test_hercules_dict["py_sims"]["test_battery"]["outputs"]["power"] = 0.0 test_hercules_dict["py_sims"]["test_solar"]["outputs"]["power_mw"] = 0.0 - test_controller.filtered_power_prev = sum(wind_current) # To override filtering + 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_dict) # Remove wind power reference to allow wind controller to operate freely del test_hercules_dict["external_signals"]["wind_power_reference"] - test_controller.step(test_hercules_dict) # Run the controller once to update measurements + test_controller.step(test_hercules_dict) # Run the controller once to update measurements supervisory_control_output = test_controller.supervisory_control( test_controller._measurements_dict ) - controller_gain = test_hercules_dict["controller"]["nominal_plant_power_kW"] / \ - test_hercules_dict["controller"]["nominal_hydrogen_rate_kgps"] * \ - test_hercules_dict["controller"]["hydrogen_controller_gain"] + controller_gain = ( + test_hercules_dict["controller"]["nominal_plant_power_kW"] + / test_hercules_dict["controller"]["nominal_hydrogen_rate_kgps"] + * test_hercules_dict["controller"]["hydrogen_controller_gain"] + ) assert controller_gain == test_controller.K wind_power_cmd = sum(wind_current) + controller_gain * hydrogen_error @@ -698,7 +697,7 @@ def test_HydrogenPlantController(): input_dict=test_hercules_dict, wind_controller=wind_controller, solar_controller=SolarPassthroughController(test_interface, test_hercules_dict), - battery_controller=BatteryPassthroughController(test_interface, test_hercules_dict) + battery_controller=BatteryPassthroughController(test_interface, test_hercules_dict), ) test_controller = HydrogenPlantController( @@ -714,9 +713,9 @@ def test_HydrogenPlantController(): test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = wind_current test_hercules_dict["py_sims"]["test_battery"]["outputs"]["power"] = battery_current test_hercules_dict["py_sims"]["test_solar"]["outputs"]["power_mw"] = solar_current / 1e3 - test_controller.filtered_power_prev = total_current_power # To override filtering + test_controller.filtered_power_prev = total_current_power # To override filtering - test_controller.step(test_hercules_dict) # Run the controller once to update measurements + test_controller.step(test_hercules_dict) # Run the controller once to update measurements supervisory_control_output = test_controller.supervisory_control( test_controller._measurements_dict ) @@ -726,7 +725,7 @@ def test_HydrogenPlantController(): assert supervisory_control_output == power_cmd_base # Test instantiation using separate controller parameters - external_controller_parameters={ + external_controller_parameters = { "nominal_plant_power_kW": 10000, "nominal_hydrogen_rate_kgps": 0.1, "hydrogen_controller_gain": 1.0, @@ -738,7 +737,7 @@ def test_HydrogenPlantController(): interface=test_interface, input_dict=test_hercules_dict, generator_controller=hybrid_controller, - controller_parameters=external_controller_parameters + controller_parameters=external_controller_parameters, ) # Check instantiation fails if a required parameter is missing from both controller_parameters @@ -759,5 +758,5 @@ def test_HydrogenPlantController(): interface=test_interface, input_dict=test_hercules_dict, generator_controller=hybrid_controller, - controller_parameters=external_controller_parameters + controller_parameters=external_controller_parameters, ) diff --git a/tests/hercules_interface_test.py b/tests/hercules_interface_test.py index 5e0f7a3d..1f0f9995 100644 --- a/tests/hercules_interface_test.py +++ b/tests/hercules_interface_test.py @@ -4,9 +4,7 @@ test_hercules_dict = { "dt": 1, "time": 0, - "plant": { - "interconnect_limit": None - }, + "plant": {"interconnect_limit": None}, "controller": { "test_controller_parameter": 1.0, }, @@ -19,7 +17,7 @@ }, "solar_farm": { "capacity": 1000.0, - "power": 1000.0, # kW + "power": 1000.0, # kW "dni": 1000.0, "aoi": 30.0, }, @@ -34,7 +32,7 @@ "electrolyzer": { "H2_mfr": 0.03, }, - "external_signals": { # Is this OK like this? + "external_signals": { # Is this OK like this? "wind_power_reference": 1000.0, "plant_power_reference": 1000.0, "forecast_ws_mean_0": 8.0, @@ -44,6 +42,7 @@ }, } + def test_interface_instantiation(): """ Tests whether all interfaces can be imported correctly and that they @@ -52,15 +51,18 @@ def test_interface_instantiation(): _ = HerculesInterface(h_dict=test_hercules_dict) + def test_HerculesInterface_windonly(): # Test instantiation interface = HerculesInterface(h_dict=test_hercules_dict) assert interface.dt == test_hercules_dict["dt"] - assert interface.plant_parameters["wind_farm"]["capacity"] == ( - test_hercules_dict["wind_farm"]["capacity"] + assert ( + interface.plant_parameters["wind_farm"]["capacity"] + == (test_hercules_dict["wind_farm"]["capacity"]) ) - assert interface.plant_parameters["wind_farm"]["n_turbines"] == ( - test_hercules_dict["wind_farm"]["n_turbines"] + assert ( + interface.plant_parameters["wind_farm"]["n_turbines"] + == (test_hercules_dict["wind_farm"]["n_turbines"]) ) # Test get_measurements() @@ -85,7 +87,7 @@ def test_HerculesInterface_windonly(): "wind_power_setpoints": [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 + bad_controls_dict2 = {"wind_power_setpoints": [2000.0, 3000.0, 0.0]} # Wrong number of turbines interface.check_controls(controls_dict) @@ -104,24 +106,30 @@ def test_HerculesInterface_windonly(): with pytest.raises(TypeError): # Bad kwarg interface.send_controls(test_hercules_dict, **bad_controls_dict1) + def test_HerculesInterface_hybrid(): # Test instantiation interface = HerculesInterface(h_dict=test_hercules_dict) assert interface.dt == test_hercules_dict["dt"] - assert interface.plant_parameters["wind_farm"]["capacity"] == ( - test_hercules_dict["wind_farm"]["capacity"] + assert ( + interface.plant_parameters["wind_farm"]["capacity"] + == (test_hercules_dict["wind_farm"]["capacity"]) ) - assert interface.plant_parameters["solar_farm"]["capacity"] == ( - test_hercules_dict["solar_farm"]["capacity"] + assert ( + interface.plant_parameters["solar_farm"]["capacity"] + == (test_hercules_dict["solar_farm"]["capacity"]) ) - assert interface.plant_parameters["battery"]["power_capacity"] == ( - test_hercules_dict["battery"]["size"] + assert ( + interface.plant_parameters["battery"]["power_capacity"] + == (test_hercules_dict["battery"]["size"]) ) - assert interface.plant_parameters["battery"]["energy_capacity"] == ( - test_hercules_dict["battery"]["energy_capacity"] + assert ( + interface.plant_parameters["battery"]["energy_capacity"] + == (test_hercules_dict["battery"]["energy_capacity"]) ) - assert interface.plant_parameters["wind_farm"]["n_turbines"] == ( - test_hercules_dict["wind_farm"]["n_turbines"] + assert ( + interface.plant_parameters["wind_farm"]["n_turbines"] + == (test_hercules_dict["wind_farm"]["n_turbines"]) ) # Test get_measurements() @@ -159,9 +167,7 @@ def test_HerculesInterface_hybrid(): 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(h_dict=test_hercules_dict, **controls_dict) assert ( controls_dict["wind_power_setpoints"] == test_hercules_dict_out["wind_farm"]["turbine_power_setpoints"] diff --git a/tests/hercules_v1_interfaces_test.py b/tests/hercules_v1_interfaces_test.py index 53e75c81..c4bb821b 100644 --- a/tests/hercules_v1_interfaces_test.py +++ b/tests/hercules_v1_interfaces_test.py @@ -21,11 +21,11 @@ "py_sims": { "test_battery": { "outputs": {"power": 10.0, "soc": 0.3}, - "charge_rate":20, - "discharge_rate":20, + "charge_rate": 20, + "discharge_rate": 20, }, "test_solar": {"outputs": {"power_mw": 1.0, "dni": 1000.0, "aoi": 30.0}}, - "test_hydrogen": {"outputs": {"H2_mfr": 0.03} }, + "test_hydrogen": {"outputs": {"H2_mfr": 0.03}}, "inputs": {}, }, "external_signals": { @@ -120,6 +120,7 @@ def test_HerculesADInterface(): test_hercules_dict["external_signals"]["wind_power_reference"] = 1000.0 test_hercules_dict["external_signals"]["plant_power_reference"] = 1000.0 + def test_HerculesHybridADInterface(): interface = HerculesV1HybridADInterface(hercules_dict=test_hercules_dict) @@ -207,8 +208,8 @@ def test_HerculesHybridADInterface(): with pytest.raises(TypeError): # Bad kwarg interface.send_controls(test_hercules_dict, **bad_controls_dict) -def test_HerculesBatteryInterface(): +def test_HerculesBatteryInterface(): interface = HerculesV1BatteryInterface(hercules_dict=test_hercules_dict) # Check instantiation with no battery raises and error diff --git a/tests/wake_steering_design_test.py b/tests/wake_steering_design_test.py index 3075bdfc..279acc0d 100644 --- a/tests/wake_steering_design_test.py +++ b/tests/wake_steering_design_test.py @@ -18,22 +18,22 @@ TEST_DATA = Path(__file__).resolve().parent YAML_INPUT = TEST_DATA / "floris_input.yaml" -def generic_df_opt( - wd_resolution=4.0, - wd_min=220.0, - wd_max=310.0, - ws_resolution=0.5, - ws_min=8.0, - ws_max=10.0, - ti_resolution=0.02, - ti_min=0.06, - ti_max=0.08, - minimum_yaw_angle=-20, - maximum_yaw_angle=20, - wd_std=None, - kwargs_UncertainFlorisModel = {}, - ): +def generic_df_opt( + wd_resolution=4.0, + wd_min=220.0, + wd_max=310.0, + ws_resolution=0.5, + ws_min=8.0, + ws_max=10.0, + ti_resolution=0.02, + ti_min=0.06, + ti_max=0.08, + minimum_yaw_angle=-20, + maximum_yaw_angle=20, + wd_std=None, + kwargs_UncertainFlorisModel={}, +): fmodel_test = FlorisModel(YAML_INPUT) if wd_std is None: @@ -67,8 +67,8 @@ def generic_df_opt( kwargs_UncertainFlorisModel=kwargs_UncertainFlorisModel, ) -def test_build_simple_wake_steering_lookup_table(): +def test_build_simple_wake_steering_lookup_table(): # Start with the simple case wd_resolution = 4.0 wd_min = 220.0 @@ -93,15 +93,14 @@ def test_build_simple_wake_steering_lookup_table(): ti_max=ti_max, ) - df_opt = generic_df_opt() opt_yaw_angles = np.vstack(df_opt["yaw_angles_opt"]) n_conditions = ( - ((ws_max-ws_min)//ws_resolution+1) - * ((wd_max-wd_min)//wd_resolution+1) - * ((ti_max-ti_min)//ti_resolution+1) + ((ws_max - ws_min) // ws_resolution + 1) + * ((wd_max - wd_min) // wd_resolution + 1) + * ((ti_max - ti_min) // ti_resolution + 1) ) assert opt_yaw_angles.shape == (n_conditions, 2) @@ -111,7 +110,7 @@ def test_build_simple_wake_steering_lookup_table(): # More complex case (include 360, minimum yaw greater than zero) wd_min = 0.0 wd_max = 360.0 - minimum_yaw_angle = -5 # Positive numbers DO NOT WORK. FLORIS bug? + minimum_yaw_angle = -5 # Positive numbers DO NOT WORK. FLORIS bug? df_opt = generic_df_opt( wd_resolution=wd_resolution, wd_min=wd_min, @@ -129,9 +128,9 @@ def test_build_simple_wake_steering_lookup_table(): opt_yaw_angles = np.vstack(df_opt["yaw_angles_opt"]) n_conditions = ( - ((ws_max-ws_min)//ws_resolution+1) - * ((wd_max-wd_min)//wd_resolution) - * ((ti_max-ti_min)//ti_resolution+1) + ((ws_max - ws_min) // ws_resolution + 1) + * ((wd_max - wd_min) // wd_resolution) + * ((ti_max - ti_min) // ti_resolution + 1) ) assert opt_yaw_angles.shape == (n_conditions, 2) @@ -140,7 +139,7 @@ def test_build_simple_wake_steering_lookup_table(): # Also check case that doesn't include 0/360 wd_min = 2.0 - wd_max = 360.0 # Shouldn't appear in output; max should be 358.0 + wd_max = 360.0 # Shouldn't appear in output; max should be 358.0 df_opt = generic_df_opt( wd_resolution=wd_resolution, wd_min=wd_min, @@ -149,9 +148,9 @@ def test_build_simple_wake_steering_lookup_table(): assert df_opt.wind_direction.min() == wd_min assert df_opt.wind_direction.max() == 358.0 -def test_build_uncertain_wake_steering_lookup_table(): - max_yaw_angle = 35 # To force split between basic and uncertain +def test_build_uncertain_wake_steering_lookup_table(): + max_yaw_angle = 35 # To force split between basic and uncertain df_opt_simple = generic_df_opt(wd_std=None, maximum_yaw_angle=max_yaw_angle) df_opt_uncertain = generic_df_opt(wd_std=3.0, maximum_yaw_angle=max_yaw_angle) @@ -164,10 +163,11 @@ def test_build_uncertain_wake_steering_lookup_table(): df_opt_uncertain_fixed = generic_df_opt( wd_std=3.0, maximum_yaw_angle=max_yaw_angle, - kwargs_UncertainFlorisModel={"fix_yaw_to_nominal_direction": True} + kwargs_UncertainFlorisModel={"fix_yaw_to_nominal_direction": True}, ) assert not np.allclose(df_opt_uncertain.farm_power_opt, df_opt_uncertain_fixed.farm_power_opt) + def test_apply_static_rate_limits(): eps = 1e-4 @@ -175,19 +175,14 @@ def test_apply_static_rate_limits(): ws_resolution = 0.5 ti_resolution = 0.01 df_opt = generic_df_opt( - wd_resolution=wd_resolution, - ws_resolution=ws_resolution, - ti_resolution=ti_resolution + wd_resolution=wd_resolution, ws_resolution=ws_resolution, ti_resolution=ti_resolution ) wd_rate_limit = 4 ws_rate_limit = 4 ti_rate_limit = 200 df_opt_rate_limited = apply_static_rate_limits( - df_opt, - wd_rate_limit, - ws_rate_limit, - ti_rate_limit + df_opt, wd_rate_limit, ws_rate_limit, ti_rate_limit ) # Check that the rate limits are applied @@ -195,25 +190,25 @@ def test_apply_static_rate_limits(): len(np.unique(df_opt.wind_direction)), len(np.unique(df_opt.wind_speed)), len(np.unique(df_opt.turbulence_intensity)), - 2 + 2, ) - assert (np.abs(np.diff(offsets, axis=0)) <= wd_rate_limit*wd_resolution+eps).all() - assert (np.abs(np.diff(offsets, axis=1)) <= ws_rate_limit*ws_resolution+eps).all() - assert (np.abs(np.diff(offsets, axis=2)) <= ti_rate_limit*ti_resolution+eps).all() + assert (np.abs(np.diff(offsets, axis=0)) <= wd_rate_limit * wd_resolution + eps).all() + assert (np.abs(np.diff(offsets, axis=1)) <= ws_rate_limit * ws_resolution + eps).all() + assert (np.abs(np.diff(offsets, axis=2)) <= ti_rate_limit * ti_resolution + eps).all() # Check wd test would have failed before rate limits applied offsets_unlimited = np.vstack(df_opt.yaw_angles_opt.values).reshape( len(np.unique(df_opt.wind_direction)), len(np.unique(df_opt.wind_speed)), len(np.unique(df_opt.turbulence_intensity)), - 2 + 2, ) - assert not (np.abs(np.diff(offsets_unlimited, axis=0)) <= wd_rate_limit*wd_resolution).all() - assert not (np.abs(np.diff(offsets_unlimited, axis=1)) <= ws_rate_limit*ws_resolution).all() - assert not (np.abs(np.diff(offsets_unlimited, axis=2)) <= ti_rate_limit*ti_resolution).all() + assert not (np.abs(np.diff(offsets_unlimited, axis=0)) <= wd_rate_limit * wd_resolution).all() + assert not (np.abs(np.diff(offsets_unlimited, axis=1)) <= ws_rate_limit * ws_resolution).all() + assert not (np.abs(np.diff(offsets_unlimited, axis=2)) <= ti_rate_limit * ti_resolution).all() -def test_apply_wind_speed_ramps(): +def test_apply_wind_speed_ramps(): ws_specified = 8.0 ws_wake_steering_cut_out = 13.0 ws_wake_steering_fully_engaged_high = 10.0 @@ -229,31 +224,38 @@ def test_apply_wind_speed_ramps(): # Check that the dataframes match at the specified wind speed assert np.allclose( np.vstack(df_opt_single_ws.yaw_angles_opt.values), - np.vstack(df_opt_ramps[df_opt_ramps.wind_speed == ws_specified].yaw_angles_opt.values) + np.vstack(df_opt_ramps[df_opt_ramps.wind_speed == ws_specified].yaw_angles_opt.values), ) # Check that above wake steering cut out, all values are zero assert np.allclose( np.vstack( - df_opt_ramps[df_opt_ramps.wind_speed >= ws_wake_steering_cut_out] - .yaw_angles_opt.values + df_opt_ramps[df_opt_ramps.wind_speed >= ws_wake_steering_cut_out].yaw_angles_opt.values ), - 0.0 + 0.0, ) # Check that between the cut out and fully engaged, values are linearly interpolated - ws_midpoint = (ws_wake_steering_fully_engaged_high + ws_wake_steering_cut_out)/2 + ws_midpoint = (ws_wake_steering_fully_engaged_high + ws_wake_steering_cut_out) / 2 assert np.allclose( np.vstack(df_opt_ramps[df_opt_ramps.wind_speed == ws_midpoint].yaw_angles_opt.values), - (np.vstack(df_opt_ramps[df_opt_ramps.wind_speed == ws_wake_steering_cut_out] - .yaw_angles_opt.values) - + np.vstack(df_opt_ramps[df_opt_ramps.wind_speed == ws_wake_steering_fully_engaged_high] - .yaw_angles_opt.values) - )/2 + ( + np.vstack( + df_opt_ramps[ + df_opt_ramps.wind_speed == ws_wake_steering_cut_out + ].yaw_angles_opt.values + ) + + np.vstack( + df_opt_ramps[ + df_opt_ramps.wind_speed == ws_wake_steering_fully_engaged_high + ].yaw_angles_opt.values + ) + ) + / 2, ) -def test_wake_steering_interpolant(): +def test_wake_steering_interpolant(): df_opt = generic_df_opt() yaw_interpolant = get_yaw_angles_interpolant(df_opt) @@ -268,24 +270,26 @@ def test_wake_steering_interpolant(): # Check interpolation at a specific point # (data at wd (268, 272) ws (8.0, 8.5) ti (0.06, 0.08)) - interpolated_offset = yaw_interpolant(271, 8.25, 0.06) - data = np.vstack(df_opt[ - (df_opt.wind_direction >= 268) - & (df_opt.wind_direction <= 272) - & (df_opt.wind_speed >= 8.0) - & (df_opt.wind_speed <= 8.5) - ].yaw_angles_opt.values).reshape(2,2,2,2) - temp = 0.25*data[0,:,:,:] + 0.75*data[1,:,:,:] # wd interp - temp = 0.5*temp[0,:,:] + 0.5*temp[1,:,:] # ws interp - base = 1.0*temp[0,:] + 0.0*temp[1,:] # ti interp + interpolated_offset = yaw_interpolant(271, 8.25, 0.06) + data = np.vstack( + df_opt[ + (df_opt.wind_direction >= 268) + & (df_opt.wind_direction <= 272) + & (df_opt.wind_speed >= 8.0) + & (df_opt.wind_speed <= 8.5) + ].yaw_angles_opt.values + ).reshape(2, 2, 2, 2) + temp = 0.25 * data[0, :, :, :] + 0.75 * data[1, :, :, :] # wd interp + temp = 0.5 * temp[0, :, :] + 0.5 * temp[1, :, :] # ws interp + base = 1.0 * temp[0, :] + 0.0 * temp[1, :] # ti interp assert np.allclose(interpolated_offset, base) # Check extrapolation with pytest.raises(ValueError): - _ = yaw_interpolant(200.0, 8.0, 0.06) # min specified wd is 220 + _ = yaw_interpolant(200.0, 8.0, 0.06) # min specified wd is 220 # Check wrapping works - df_0_270 = generic_df_opt(wd_min=0.0, wd_max=270.0, wd_resolution=10.0) # Includes 0 degree WD + df_0_270 = generic_df_opt(wd_min=0.0, wd_max=270.0, wd_resolution=10.0) # Includes 0 degree WD yaw_interpolant = get_yaw_angles_interpolant(df_0_270) _ = yaw_interpolant(0.0, 8.0, 0.06) _ = yaw_interpolant(355.0, 8.0, 0.06) @@ -295,12 +299,12 @@ def test_wake_steering_interpolant(): with pytest.raises(ValueError): _ = yaw_interpolant(361.0, 8.0, 0.06) -def test_hysteresis_zones(): +def test_hysteresis_zones(): df_opt = generic_df_opt() min_zone_width = 4.0 - hysteresis_dict_base = {"T000": [(270-min_zone_width/2, 270+min_zone_width/2)]} + hysteresis_dict_base = {"T000": [(270 - min_zone_width / 2, 270 + min_zone_width / 2)]} # Calculate hysteresis regions hysteresis_dict_test = compute_hysteresis_zones(df_opt, min_zone_width=min_zone_width) @@ -316,8 +320,10 @@ def test_hysteresis_zones(): df_opt_2.wind_direction = (df_opt_2.wind_direction + 90.0) % 360.0 df_opt_2 = df_opt_2.sort_values(by=["wind_direction", "wind_speed", "turbulence_intensity"]) hysteresis_dict_test = compute_hysteresis_zones(df_opt_2, min_zone_width=min_zone_width) - assert ((np.array(hysteresis_dict_test["T000"][0]) - 90.0) % 360.0 - == np.array(hysteresis_dict_base["T000"][0])).all() + assert ( + (np.array(hysteresis_dict_test["T000"][0]) - 90.0) % 360.0 + == np.array(hysteresis_dict_base["T000"][0]) + ).all() # Check 0 low end, less than 360 upper end df_opt = generic_df_opt(wd_min=0.0, wd_max=300.0) @@ -337,13 +343,12 @@ def test_hysteresis_zones(): df_opt = generic_df_opt() hysteresis_dict_test = compute_hysteresis_zones( df_opt, - min_zone_width=3*min_zone_width, # Force regions to be grouped - yaw_rate_threshold=1.0 + min_zone_width=3 * min_zone_width, # Force regions to be grouped + yaw_rate_threshold=1.0, ) # Check actual grouping occurs (not purely due to larger region width) assert ( - hysteresis_dict_test["T000"][0][1] - hysteresis_dict_test["T000"][0][0] - > 3*min_zone_width + hysteresis_dict_test["T000"][0][1] - hysteresis_dict_test["T000"][0][0] > 3 * min_zone_width ) # Check new region covers original region assert hysteresis_dict_test["T000"][0][0] < hysteresis_dict_base["T000"][0][0] @@ -354,23 +359,18 @@ def test_hysteresis_zones(): df_opt_2.wind_direction = (df_opt_2.wind_direction + 90.0) % 360.0 df_opt_2 = df_opt_2.sort_values(by=["wind_direction", "wind_speed", "turbulence_intensity"]) hysteresis_dict_test = compute_hysteresis_zones( - df_opt_2, - min_zone_width=3*min_zone_width, - yaw_rate_threshold=1.0, - verbose=True + df_opt_2, min_zone_width=3 * min_zone_width, yaw_rate_threshold=1.0, verbose=True ) # Check actual grouping occurs (not purely due to larger region width) assert ( - (hysteresis_dict_test["T000"][0][1] - hysteresis_dict_test["T000"][0][0]) % 360.0 - > 3*min_zone_width - ) + hysteresis_dict_test["T000"][0][1] - hysteresis_dict_test["T000"][0][0] + ) % 360.0 > 3 * min_zone_width # Check new region covers original region assert (hysteresis_dict_test["T000"][0][0] - 90.0) % 360.0 < hysteresis_dict_base["T000"][0][0] assert (hysteresis_dict_test["T000"][0][1] - 90.0) % 360.0 > hysteresis_dict_base["T000"][0][1] def test_consolidate_hysteresis_zones(): - # Check basic grouping hysteresis_wds_base = [(10, 30)] hysteresis_wds_unconsolidated = [(10, 20), (18, 25), (25, 30)] @@ -397,20 +397,20 @@ def test_consolidate_hysteresis_zones(): # Larger set width = 6.0 - hysteresis_centers = [ 8., 12., 16., 344., 348., 352., 359.] - hysteresis_wds_base = [(344.0-width, 16.0+width)] + hysteresis_centers = [8.0, 12.0, 16.0, 344.0, 348.0, 352.0, 359.0] + hysteresis_wds_base = [(344.0 - width, 16.0 + width)] hysteresis_wds_unconsolidated = [ - (hysteresis_centers[0]-width, hysteresis_centers[0]+width), - (hysteresis_centers[1]-width, hysteresis_centers[1]+width), - (hysteresis_centers[2]-width, hysteresis_centers[2]+width), - (hysteresis_centers[3]-width, hysteresis_centers[3]+width), - (hysteresis_centers[4]-width, hysteresis_centers[4]+width), - (hysteresis_centers[5]-width, hysteresis_centers[5]+width), - (hysteresis_centers[6]-width, (hysteresis_centers[6]+width)%360.0) + (hysteresis_centers[0] - width, hysteresis_centers[0] + width), + (hysteresis_centers[1] - width, hysteresis_centers[1] + width), + (hysteresis_centers[2] - width, hysteresis_centers[2] + width), + (hysteresis_centers[3] - width, hysteresis_centers[3] + width), + (hysteresis_centers[4] - width, hysteresis_centers[4] + width), + (hysteresis_centers[5] - width, hysteresis_centers[5] + width), + (hysteresis_centers[6] - width, (hysteresis_centers[6] + width) % 360.0), ] hysteresis_wds_test = consolidate_hysteresis_zones(hysteresis_wds_unconsolidated) assert hysteresis_wds_test == hysteresis_wds_base - + # Two crossing 0/360 divide hysteresis_wds_base = [(350, 10)] hysteresis_wds_unconsolidated = [(350, 355), (355, 5), (357, 8), (7, 10)] @@ -433,18 +433,25 @@ def test_consolidate_hysteresis_zones(): assert consolidate_hysteresis_zones(hysteresis_wds_unconsolidated) == hysteresis_wds_base hysteresis_wds_unconsolidated = [ - (0, 15), (10, 25), (15, 30), (35, 50), (340, 355), (345, 0), (355, 10) + (0, 15), + (10, 25), + (15, 30), + (35, 50), + (340, 355), + (345, 0), + (355, 10), ] hysteresis_wds_base = [(340, 30), (35, 50)] assert consolidate_hysteresis_zones(hysteresis_wds_unconsolidated) == hysteresis_wds_base + def test_create_uniform_wind_rose(): wind_rose = create_uniform_wind_rose() frequencies = wind_rose.unpack_freq() assert (frequencies == frequencies[0]).all() -def test_check_df_opt_ordering(): +def test_check_df_opt_ordering(): # Pass tests df_opt = generic_df_opt() check_df_opt_ordering(df_opt) From debc5fd9593e305d0ea491c3b486571c7de40a7b Mon Sep 17 00:00:00 2001 From: paulf81 Date: Tue, 9 Dec 2025 13:33:01 -0700 Subject: [PATCH 3/8] Add check to format --- .github/workflows/continuous-integration-workflow.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous-integration-workflow.yaml b/.github/workflows/continuous-integration-workflow.yaml index 337fb6d1..000bd45b 100644 --- a/.github/workflows/continuous-integration-workflow.yaml +++ b/.github/workflows/continuous-integration-workflow.yaml @@ -26,7 +26,7 @@ jobs: - name: Run ruff run: | ruff check . - ruff format + ruff format --check - name: Run tests and collect coverage run: | # -rA displays the captured output for all tests after they're run From ae336e227caa24fbcf0721fd11b3d9d6ef033627 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Wed, 10 Dec 2025 10:23:40 -0700 Subject: [PATCH 4/8] fix docstrings --- hycon/controllers/battery_controller.py | 4 ++-- hycon/design_tools/wake_steering_design.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hycon/controllers/battery_controller.py b/hycon/controllers/battery_controller.py index 56899e23..882ecfef 100644 --- a/hycon/controllers/battery_controller.py +++ b/hycon/controllers/battery_controller.py @@ -130,13 +130,13 @@ class BatteryPassthroughController(ControllerBase): """ def __init__(self, interface, input_dict, verbose=True): - """ " + """ Instantiate BatteryPassthroughController." """ super().__init__(interface, verbose) def compute_controls(self, measurements_dict): - """ " + """ Main compute_controls method for BatteryPassthroughController. """ return {"power_setpoint": measurements_dict["battery"]["power_reference"]} diff --git a/hycon/design_tools/wake_steering_design.py b/hycon/design_tools/wake_steering_design.py index 0db2748b..b9253f21 100755 --- a/hycon/design_tools/wake_steering_design.py +++ b/hycon/design_tools/wake_steering_design.py @@ -627,7 +627,7 @@ def create_uniform_wind_rose( ti_min: float = 0.06, ti_max: float = 0.06, ): - """ " + """ Create a uniform wind rose to use for wake steering optimizations. Args: From c9c5ebd4cb2effcd271231783ae7953e090629f0 Mon Sep 17 00:00:00 2001 From: paulf81 Date: Wed, 10 Dec 2025 10:25:17 -0700 Subject: [PATCH 5/8] fix type --- hycon/design_tools/wake_steering_design.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hycon/design_tools/wake_steering_design.py b/hycon/design_tools/wake_steering_design.py index b9253f21..6f285a39 100755 --- a/hycon/design_tools/wake_steering_design.py +++ b/hycon/design_tools/wake_steering_design.py @@ -251,7 +251,7 @@ def compute_hysteresis_zones( min_zone_width: float = 2.0, yaw_rate_threshold: float = 10.0, verbose: bool = False, -) -> dict[str : list[tuple[float, float]]]: +) -> dict[str, list[tuple[float, float]]]: """ Compute wind direction sectors where hysteresis is applied. From c4c5e002c0c13af75f321842e7ab85e98b6c777e Mon Sep 17 00:00:00 2001 From: paulf81 Date: Wed, 10 Dec 2025 10:27:22 -0700 Subject: [PATCH 6/8] limit changes to hercules interface to formatting --- hycon/interfaces/hercules_interface.py | 49 +++++++++++++------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/hycon/interfaces/hercules_interface.py b/hycon/interfaces/hercules_interface.py index 152e795d..136da67e 100644 --- a/hycon/interfaces/hercules_interface.py +++ b/hycon/interfaces/hercules_interface.py @@ -62,9 +62,6 @@ def __init__(self, h_dict): if self._has_hydrogen_component: self.plant_parameters["hydrogen"] = {} - # 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", @@ -127,42 +124,46 @@ def get_measurements(self, h_dict): # Handle external signals (parse and pass to individual components) if "external_signals" in h_dict: - external_signals = h_dict["external_signals"] - - if "plant_power_reference" in external_signals: - measurements["plant_power_reference"] = external_signals["plant_power_reference"] + 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 external_signals and self._has_wind_component: - measurements["wind_farm"]["power_reference"] = external_signals[ + 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 external_signals and self._has_solar_component: - measurements["solar_farm"]["power_reference"] = external_signals[ + 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 external_signals: - measurements["battery"]["power_reference"] = external_signals[ + if "battery_power_reference" in h_dict["external_signals"]: + measurements["battery"]["power_reference"] = h_dict["external_signals"][ "battery_power_reference" ] - if "hydrogen_reference" in external_signals and self._has_hydrogen_component: - measurements["hydrogen"]["power_reference"] = 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 external_signals: - measurements["DA_LMP_24hours"] = [external_signals[k] for k in self._lmp_da_keys] - if "lmp_da" in external_signals: - measurements["DA_LMP"] = external_signals["lmp_da"] - if "lmp_rt" in external_signals: - measurements["RT_LMP"] = external_signals["lmp_rt"] + # Grid price information + if "lmp_da_00" in h_dict["external_signals"]: + measurements["DA_LMP_24hours"] = [ + h_dict["external_signals"]["lmp_da_{:02d}".format(h)] for h in range(24) + ] + 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, v in external_signals.items(): + for k in h_dict["external_signals"].keys(): if "forecast" in k: - measurements["forecast"][k] = v + measurements["forecast"][k] = h_dict["external_signals"][k] measurements["total_power"] = total_power From 60b111b47aa1390fac702653df5d0188a586a545 Mon Sep 17 00:00:00 2001 From: misi9170 Date: Wed, 10 Dec 2025 12:41:29 -0700 Subject: [PATCH 7/8] Add line with spaces --- examples/battery_control_comparison/runscript.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/battery_control_comparison/runscript.py b/examples/battery_control_comparison/runscript.py index e98d4a16..c88c8f6c 100644 --- a/examples/battery_control_comparison/runscript.py +++ b/examples/battery_control_comparison/runscript.py @@ -44,6 +44,7 @@ def simulate(soc_0, clipping_thresholds, gain): controller = HybridSupervisoryControllerMultiRef( battery_controller=battery_controller, interface=interface, input_dict=hmodel.h_dict ) + hmodel.assign_controller(controller) From 14c028f99584bba5e32ac59897dcd6cb76406fce Mon Sep 17 00:00:00 2001 From: misi9170 Date: Wed, 10 Dec 2025 12:48:36 -0700 Subject: [PATCH 8/8] Ruff format --- examples/battery_control_comparison/runscript.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/battery_control_comparison/runscript.py b/examples/battery_control_comparison/runscript.py index c88c8f6c..e98d4a16 100644 --- a/examples/battery_control_comparison/runscript.py +++ b/examples/battery_control_comparison/runscript.py @@ -44,7 +44,6 @@ def simulate(soc_0, clipping_thresholds, gain): controller = HybridSupervisoryControllerMultiRef( battery_controller=battery_controller, interface=interface, input_dict=hmodel.h_dict ) - hmodel.assign_controller(controller)