diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..98b5f032 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: "pip" + directory: "/" # Location of package manifests + target-branch: "develop" + schedule: + interval: "monthly" + labels: + - "package" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-minor", "version-update:semver-patch"] \ No newline at end of file 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 diff --git a/.github/workflows/deploy-pages.yaml b/.github/workflows/deploy-pages.yaml index 595a093c..5b7874b9 100644 --- a/.github/workflows/deploy-pages.yaml +++ b/.github/workflows/deploy-pages.yaml @@ -27,9 +27,8 @@ jobs: # Build the book - name: Build the book - working-directory: ${{runner.workspace}}/hycon/docs/ run: | - jupyter-book build . + jupyter-book build docs/ # Push the book's HTML to github-pages - name: GitHub Pages action diff --git a/LICENSE.txt b/LICENSE.txt index 4389e867..957ad99f 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2025 Alliance for Sustainable Energy, LLC and Colorado School of Mines. +Copyright (c) 2025 Alliance for Energy Innovation, LLC and Colorado School of Mines. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 250944df..975435af 100644 --- a/README.md +++ b/README.md @@ -11,21 +11,21 @@ and creates an entry point for the development of more advanced controllers. Hycon will interface with various simulation testbeds and lower level controllers, including: -- [Hercules](https://github.com/NREL/hercules) +- [Hercules](https://github.com/NatLabRockies/hercules) - [FAST.Farm](https://github.com/OpenFAST/openfast) -- [ROSCO](https://github.com/NREL/rosco) +- [ROSCO](https://github.com/NatLabRockies/rosco) Hycon controllers will also call on design tools such as -[FLORIS](https://github.com/NREL/floris). +[FLORIS](https://github.com/NatLabRockies/floris). ## WETO software Hycon is primarily developed with the support from the U.S. Department of Energy and -is part of the [WETO Software Stack](https://nrel.github.io/WETOStack). +is part of the [WETO Software Stack](https://natlabrockies.github.io/WETOStack). For more information and other integrated modeling software, see: -- [Portfolio Overview](https://nrel.github.io/WETOStack/portfolio_analysis/overview.html) -- [Entry Guide](https://nrel.github.io/WETOStack/_static/entry_guide/index.html) +- [Portfolio Overview](https://natlabrockies.github.io/WETOStack/portfolio_analysis/overview.html) +- [Entry Guide](https://natlabrockies.github.io/WETOStack/_static/entry_guide/index.html) - [Wind Farm Controls Workshop](https://www.youtube.com/watch?v=f-w6whxIBrA&list=PL6ksUtsZI1dwRXeWFCmJT6cEN1xijsHJz) NLR's software record for Hycon is SWR-25-54. @@ -33,4 +33,4 @@ NLR's software record for Hycon is SWR-25-54. ## Documentation Documentation for Hycon, including installation instructions, can be found -[here](https://nrel.github.io/hycon/intro.html). +[here](https://natlabrockies.github.io/hycon/intro.html). diff --git a/docs/_config.yml b/docs/_config.yml index f78f5764..4cb57871 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -23,7 +23,7 @@ bibtex_bibfiles: # Information about where the book exists on the web repository: - url: https://github.com/NREL/hycon + url: https://github.com/NatLabRockies/hycon path_to_book: docs branch: main diff --git a/docs/code_development.md b/docs/code_development.md index 93a4ceba..b283382c 100644 --- a/docs/code_development.md +++ b/docs/code_development.md @@ -1,6 +1,6 @@ # Code development To contribute to Hycon, please consider forking the main github repository, -with the [NLR repo](https://github.com/NREL/hycon) as an +with the [NLR repo](https://github.com/NatLabRockies/hycon) as an upstream remote. See the [Installation instructions](install_instructions) for details about how to set up your repository as a developer. diff --git a/docs/controllers.md b/docs/controllers.md index 8801bbe7..6ef095b4 100644 --- a/docs/controllers.md +++ b/docs/controllers.md @@ -43,8 +43,7 @@ However, is a useful comparison case for the WindFarmPowerTrackingController Closed-loop wind farm-level power controller that distributes a farm-level power reference among the wind turbines in a farm and adjusts the requests made from each turbine depending on whether the power reference has been met. -Developed under the [A2e2g project](https://github.com/NREL/a2e2g), with -further details provided in +Further details provided in [Sinner et al.](https://pubs.aip.org/aip/jrse/article/15/5/053304/2913100). Integral action, as well as gain scheduling based on turbine saturation, has been disabled as diff --git a/docs/install_instructions.md b/docs/install_instructions.md index c3700e9f..47067db1 100644 --- a/docs/install_instructions.md +++ b/docs/install_instructions.md @@ -3,9 +3,9 @@ Hycon is _not_ designed to be used as a stand-alone package. Most likely, you'll want to add Hycon to an existing conda environment that contains your -simulation testbed, such as [Hercules](https://github.com/NREL/hercules). +simulation testbed, such as [Hercules](https://github.com/NatLabRockies/hercules). For example, see the -[Hercules installation instructions](https://nrel.github.io/hercules/install_instructions.html) +[Hercules installation instructions](https://natlabrockies.github.io/hercules/install_instructions.html) for how to set up an appropriate conda environment. (installation_general_users)= @@ -16,7 +16,7 @@ be sufficient to install Hycon (presumably, after activating your conda environment): ``` -git clone https://github.com/NREL/hycon +git clone https://github.com/NatLabRockies/hycon pip install hycon/ ``` @@ -32,7 +32,7 @@ git clone https://github.com/your-github-id/hycon pip install -e "hycon/[develop]" ``` To contribute back to the base repository -https://github.com/NREL/hycon, please do the following: +https://github.com/NatLabRockies/hycon, please do the following: - Create a branch from the base repository's `develop` branch on your fork containing your code changes (e.g. `your-github-id:feature/your-new-feature`) - Open a pull request into the base repository's `NREL:develop` branch, and provide @@ -49,7 +49,7 @@ For more information on what your pull request should contain, see (installation_examples)= ## To run examples -All Hycon examples run in the [Hercules](https://github.com/NREL/hercules) simulation environment. +All Hycon examples run in the [Hercules](https://github.com/NatLabRockies/hercules) simulation environment. To run the examples, you will need to additionally install Hercules. See the -[Hercules installation instructions](https://nrel.github.io/hercules/install_instructions.html) +[Hercules installation instructions](https://natlabrockies.github.io/hercules/install_instructions.html) for details. diff --git a/docs/wake_steering_design.md b/docs/wake_steering_design.md index 5fbc2e56..eed656f2 100644 --- a/docs/wake_steering_design.md +++ b/docs/wake_steering_design.md @@ -3,11 +3,11 @@ The `hycon.design_tools.wake_steering_design` module provides various tools for the design of yaw offset lookup tables for "open-loop" wake steering. The two primary functions are `build_simple_wake_steering_lookup_table` and `build_uncertain_wake_steering_lookup_table`, both of which take an instantiated -[`FlorisModel`](https://nrel.github.io/floris/_autosummary/floris.floris_model.html), +[`FlorisModel`](https://natlabrockies.github.io/floris/_autosummary/floris.floris_model.html), along with various design parameters, and return a pandas DataFrame `df_opt` containing the optimal yaw offset angles for each wind turbine. Under the hood, both functions run an optimization using FLORIS' -[`YawOptimizerSR`](https://nrel.github.io/floris/_autosummary/floris.optimization.yaw_optimization.yaw_optimizer_sr.html) methodology. The `uncertain` version takes into account wind direction +[`YawOptimizerSR`](https://natlabrockies.github.io/floris/_autosummary/floris.optimization.yaw_optimization.yaw_optimizer_sr.html) methodology. The `uncertain` version takes into account wind direction uncertainty via the second required argument `wd_std`, representing the wind direction standard deviation. 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/readme.txt b/examples/readme.txt index 488dbc7a..b36d652e 100644 --- a/examples/readme.txt +++ b/examples/readme.txt @@ -1,2 +1,2 @@ -See https://nrel.github.io/hycon/examples.html for documentation describing +See https://natlabrockies.github.io/hycon/examples.html for documentation describing the Hycon examples. \ No newline at end of file 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..7dec25ae 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"]} @@ -148,7 +145,33 @@ def compute_controls(self, measurements_dict): class BatteryPriceSOCController(ControllerBase): """ Controller considers price and SOC to determine power setpoint. + + This controller implements a price-arbitrage strategy that uses day-ahead (DA) + locational marginal prices (LMPs) and real-time (RT) LMPs to decide when to + charge or discharge the battery. The algorithm identifies the top and bottom + price hours of the day based on battery duration (e.g., for a 4-hour battery, + it targets the "top_d" = 4 highest and "bottom_d" = 4 lowest priced hours). + + The decision logic is as follows: + 1. If RT price exceeds the highest DA price: discharge at full rate + (unconditionally). + 2. Else if RT price is in the top-d highest DA prices AND SOC > low_soc: + discharge at full rate. + 3. Else if RT price is below the lowest DA price: charge at full rate + (unconditionally). + 4. Else if RT price is in the bottom-d lowest DA prices AND SOC < high_soc: + charge at full rate. + 5. Otherwise: hold (power setpoint = 0). + + The SOC thresholds (high_soc, low_soc) prevent over-charging or over-discharging + during moderate price signals, while still allowing full charge/discharge when + prices move outside the expected DA range. + + Note: + Charging power is represented as negative values, matching the convention + used at the Hercules/hybrid_plant level. """ + def __init__(self, interface, input_dict, controller_parameters={}, verbose=True): super().__init__(interface, verbose) @@ -158,7 +181,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"]} @@ -167,37 +190,61 @@ def __init__(self, interface, input_dict, controller_parameters={}, verbose=True self.rated_power_charging = input_dict["battery"]["charge_rate"] self.rated_power_discharging = input_dict["battery"]["discharge_rate"] + # Save the duration rounded to nearest hour + self.duration = round( + interface.plant_parameters["battery"]["energy_capacity"] + / interface.plant_parameters["battery"]["power_capacity"] + ) + + # Raise if duration makes this controller implausible + if self.duration >= 12: + raise ValueError( + f"Battery duration is {self.duration} hours, which is not " + "supported by BatteryPriceSOCController." + " This controller is only intended for durations shorter than 12 hours." + ) + + if self.duration < 1: + raise ValueError( + f"Battery duration is {self.duration} hours, which is not " + "supported by BatteryPriceSOCController." + " This controller is only intended for durations of at least 1 hour." + ) + def set_controller_parameters( self, - high_soc=0.8, + high_soc=1.0, 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. - high_soc is the SOC threshold above which the battery will only charge if the price is above - the highest (hourly) DA price of the day. + high_soc is the SOC threshold above which the battery will only charge if the price is below + the lowest (hourly) DA price of the day. Defaults to 1.0. low_soc is the SOC threshold below which the battery will only discharge if the price is - below the lowest (hourly) DA price of the day. + above the highest (hourly) DA price of the day. Defaults to 0.2. + + high_soc defaults to 1.0 (effectively disabled) as experience suggests waiting for + very low prices is not worthwhile. low_soc defaults to 0.2 as experience suggests waiting + for very high prices is worthwhile. Args: - high_soc (float): High SOC threshold (0 to 1). - low_soc (float): Low SOC threshold (0 to 1). + high_soc (float): High SOC threshold (0 to 1). Defaults to 1.0. + low_soc (float): Low SOC threshold (0 to 1). Defaults to 0.2. """ self.high_soc = high_soc 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"] # Extract limits - bottom_4 = sorted_day_ahead_lmps[3] - top_4 = sorted_day_ahead_lmps[-4] + bottom_d = sorted_day_ahead_lmps[self.duration - 1] + top_d = sorted_day_ahead_lmps[-self.duration] bottom_1 = sorted_day_ahead_lmps[0] top_1 = sorted_day_ahead_lmps[-1] @@ -205,15 +252,15 @@ 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 - elif (real_time_lmp > top_4) & (soc < self.high_soc): + elif (real_time_lmp > top_d) & (soc > self.low_soc): power_setpoint = self.rated_power_discharging elif real_time_lmp < bottom_1: power_setpoint = -self.rated_power_charging - elif (real_time_lmp < bottom_4) & (soc > self.low_soc): + elif (real_time_lmp < bottom_d) & (soc < self.high_soc): power_setpoint = -self.rated_power_charging else: power_setpoint = 0.0 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..6f285a39 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 9a7398c7..75b3a589 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,15 +54,17 @@ 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) 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", @@ -77,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): @@ -96,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"]) @@ -126,37 +127,36 @@ def get_measurements(self, h_dict): # Handle external signals (parse and pass to individual components) if "external_signals" in h_dict: - if "plant_power_reference" in h_dict["external_signals"]: - measurements["plant_power_reference"] = ( - h_dict["external_signals"]["plant_power_reference"] - ) + measurements["plant_power_reference"] = h_dict["external_signals"][ + "plant_power_reference" + ] if "wind_power_reference" in h_dict["external_signals"] and self._has_wind_component: - measurements["wind_farm"]["power_reference"] = ( - h_dict["external_signals"]["wind_power_reference"] - ) + measurements["wind_farm"]["power_reference"] = h_dict["external_signals"][ + "wind_power_reference" + ] if "solar_power_reference" in h_dict["external_signals"] and self._has_solar_component: - measurements["solar_farm"]["power_reference"] = ( - h_dict["external_signals"]["solar_power_reference"] - ) + measurements["solar_farm"]["power_reference"] = h_dict["external_signals"][ + "solar_power_reference" + ] if self._has_battery_component: if "battery_power_reference" in h_dict["external_signals"]: - measurements["battery"]["power_reference"] = ( - h_dict["external_signals"]["battery_power_reference"] - ) + 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"] - ) + measurements["hydrogen"]["power_reference"] = h_dict["external_signals"][ + "hydrogen_reference" + ] - # Grid price information + # Grid price information (using pre-computed keys for performance) if "lmp_da_00" in h_dict["external_signals"]: measurements["DA_LMP_24hours"] = [ - h_dict["external_signals"]["lmp_da_{:02d}".format(h)] for h in range(24) + h_dict["external_signals"][k] for k in self._lmp_da_keys ] if "lmp_da" in h_dict["external_signals"]: measurements["DA_LMP"] = h_dict["external_signals"]["lmp_da"] @@ -173,12 +173,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/pyproject.toml b/pyproject.toml index 06bb0906..0b4faba4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "hycon" -version = "0.6" +version = "0.7" description = "Hybrid power plant controller." readme = "README.md" requires-python = ">=3.10" @@ -32,7 +32,7 @@ dependencies = [ [project.optional-dependencies] docs = [ - "jupyter-book", + "jupyter-book==1.0.4", "sphinx-book-theme", ] develop = [ @@ -46,8 +46,8 @@ develop = [ include = ["hycon*"] [project.urls] -Homepage = "https://github.com/NREL/hycon" -Documentation = "https://nrel.github.io/hycon/intro.html" +Homepage = "https://github.com/NatLabRockies/hycon" +Documentation = "https://natlabrockies.github.io/hycon/intro.html" [coverage.run] # Coverage.py configuration file diff --git a/tests/battery_test.py b/tests/battery_test.py index dfccb65b..511cf385 100644 --- a/tests/battery_test.py +++ b/tests/battery_test.py @@ -3,41 +3,23 @@ ) from hycon.interfaces import HerculesInterface -test_hercules_dict = { - "dt": 1, - "time": 0, - "plant": {"interconnect_limit": 10}, - "battery": { - "size": 100.0, - "energy_capacity": 400.0, - "power": 100.0, - "soc": 0.5, - "charge_rate": 50.0 * 1e3, - "discharge_rate": 100.0 * 1e3, - }, - "external_signals": { - "RT_LMP": 10.0, - }, -} - - -def test_BatteryPriceSOCController_init(): + +def test_BatteryPriceSOCController_init(test_hercules_dict): test_interface = HerculesInterface(test_hercules_dict) # Initialize controller test_controller = BatteryPriceSOCController(test_interface, test_hercules_dict) # Check that the controller is initialized correctly + assert test_controller.rated_power_charging == test_hercules_dict["battery"]["charge_rate"] assert ( - test_controller.rated_power_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"] ) -def test_BatteryPriceSOCController_compute_controls(): +def test_BatteryPriceSOCController_compute_controls(test_hercules_dict): + # This test originally written assuming 4-hour battery + test_interface = HerculesInterface(test_hercules_dict) # Initialize controller @@ -47,13 +29,13 @@ 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 + # but above the low_soc_price. SOC is too high to justify charging. measurement_dict = { "battery": {"state_of_charge": 0.9}, - "RT_LMP": 3, + "RT_LMP": 2.5, "DA_LMP_24hours": DA_LMP_test, } controls_dict = test_controller.compute_controls(measurement_dict) @@ -64,9 +46,9 @@ def test_BatteryPriceSOCController_compute_controls(): controls_dict = test_controller.compute_controls(measurement_dict) assert controls_dict["power_setpoint"] == -test_controller.rated_power_charging - # Test the high SOC conditions + # Test the high price / low soc condition measurement_dict = { - "battery": {"state_of_charge": 0.9}, + "battery": {"state_of_charge": 0.1}, "RT_LMP": 22, "DA_LMP_24hours": DA_LMP_test, } @@ -93,3 +75,37 @@ def test_BatteryPriceSOCController_compute_controls(): measurement_dict["RT_LMP"] = 10 controls_dict = test_controller.compute_controls(measurement_dict) assert controls_dict["power_setpoint"] == 0.0 + + +def test_BatteryPriceSOCController_compute_controls_2_hour_duration(test_hercules_dict): + # Set the duration to 2 hours + test_hercules_dict["battery"]["energy_capacity"] = 20.0e3 + test_interface = HerculesInterface(test_hercules_dict) + + # Initialize controller + test_controller = BatteryPriceSOCController(test_interface, test_hercules_dict) + + # For testing, overwrite the high_soc and low_soc + test_controller.high_soc = 0.8 + test_controller.low_soc = 0.2 + + DA_LMP_test = [i for i in range(24)] # Price is from 0 to 23 + + # Test the in-between bottom 1 and bottom d prices + measurement_dict = { + "battery": {"state_of_charge": 0.5}, + "RT_LMP": 0.5, + "DA_LMP_24hours": DA_LMP_test, + } + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["power_setpoint"] == -test_controller.rated_power_charging + + # Now raise the state of charge to 0.85 + measurement_dict["battery"]["state_of_charge"] = 0.85 + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["power_setpoint"] == 0.0 + + # Now drop the RT_LMP to -.5 (Going below bottom 1 price) + measurement_dict["RT_LMP"] = -0.5 + controls_dict = test_controller.compute_controls(measurement_dict) + assert controls_dict["power_setpoint"] == -test_controller.rated_power_charging diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..13d8d109 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,206 @@ +import pytest +from hycon.interfaces import HerculesADInterface, HerculesHybridADInterface, HerculesInterface +from hycon.interfaces.interface_base import InterfaceBase + + +@pytest.fixture +def test_hercules_v1_dict(): + return { + "dt": 1, + "time": 0, + "controller": { + "num_turbines": 2, + "initial_conditions": {"yaw": [270.0, 270.0]}, + "nominal_plant_power_kW": 10000, + "nominal_hydrogen_rate_kgps": 0.1, + "hydrogen_controller_gain": 1.0, + }, + "hercules_comms": { + "amr_wind": { + "test_farm": { + "turbine_wind_directions": [271.0, 272.5], + "turbine_powers": [4000.0, 4001.0], + "wind_speed": 10.0, + } + } + }, + "py_sims": { + "test_battery": { + "outputs": {"power": 10.0, "soc": 0.3}, + "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, + }, + } + + +@pytest.fixture +def test_hercules_dict(): + return { + "dt": 1, + "time": 0, + "plant": {"interconnect_limit": None}, + "controller": { + "test_controller_parameter": 1.0, + }, + "wind_farm": { + "n_turbines": 2, + "capacity": 10000.0, + "wind_direction_mean": 271.0, + "turbine_powers": [4000.0, 4001.0], + "wind_speed": 10.0, + }, + "solar_farm": { + "capacity": 1000.0, + "power": 1000.0, # kW + "dni": 1000.0, + "aoi": 30.0, + }, + "battery": { + "size": 10.0e3, + "energy_capacity": 40.0e3, + "power": 10.0e3, + "soc": 0.3, + "charge_rate": 20e3, + "discharge_rate": 15e3, + }, + "electrolyzer": { + "H2_mfr": 0.03, + }, + "external_signals": { + "wind_power_reference": 1000.0, + "solar_power_reference": 800.0, + "battery_power_reference": 0.0, + "plant_power_reference": 1000.0, + "forecast_ws_mean_0": 8.0, + "forecast_ws_mean_1": 8.1, + "ws_median_0": 8.1, + "hydrogen_reference": 0.02, + }, + } + + +class StandinInterface(InterfaceBase): + """ + Empty class to test controllers. + """ + + def __init__(self): + super().__init__() + self.dt = 1.0 + # Set up stand-in plant parameters and controller parameters + self.plant_parameters = {"n_turbines": 2} + self.controller_parameters = {} + + def get_measurements(self): + pass + + def check_controls(self): + pass + + def send_controls(self): + pass + + +@pytest.fixture +def test_interface_standin(): + return StandinInterface() + + +@pytest.fixture +def test_interface_hercules(test_hercules_dict): + """ + Fixture to create a Hercules v2 dictionary for testing. + """ + return HerculesInterface(test_hercules_dict) + + +@pytest.fixture +def test_interface_hercules_ad(test_hercules_v1_dict): + """ + Fixture to create a HerculesADInterface for testing. + """ + return HerculesADInterface(test_hercules_v1_dict) + + +@pytest.fixture +def test_interface_hercules_hybrid_ad(test_hercules_v1_dict): + """ + Fixture to create a HerculesHybridADInterface for testing. + """ + test_hercules_v1_dict["controller"]["num_batteries"] = 1 + test_hercules_v1_dict["controller"]["num_solar"] = 1 + return HerculesHybridADInterface(test_hercules_v1_dict) + + +@pytest.fixture +def floris_dict(): + """ + Fixture to create a FLORIS dictionary for testing. + """ + return { + "name": "test_input", + "description": "Two-turbine farm for testing", + "floris_version": "v4", + "logging": { + "console": {"enable": False, "level": "WARNING"}, + "file": {"enable": False, "level": "WARNING"}, + }, + "solver": {"type": "turbine_grid", "turbine_grid_points": 3}, + "farm": { + "layout_x": [0.0, 500.0], + "layout_y": [0.0, 0.0], + "turbine_type": ["nrel_5MW"], + }, + "flow_field": { + "air_density": 1.225, + "reference_wind_height": 90.0, + "turbulence_intensities": [0.06], + "wind_directions": [270.0], + "wind_shear": 0.12, + "wind_speeds": [8.0], + "wind_veer": 0.0, + }, + "wake": { + "model_strings": { + "combination_model": "sosfs", + "deflection_model": "gauss", + "turbulence_model": "crespo_hernandez", + "velocity_model": "gauss", + }, + "enable_secondary_steering": True, + "enable_yaw_added_recovery": True, + "enable_active_wake_mixing": True, + "enable_transverse_velocities": True, + "wake_deflection_parameters": { + "gauss": { + "ad": 0.0, + "alpha": 0.58, + "bd": 0.0, + "beta": 0.077, + "dm": 1.0, + "ka": 0.38, + "kb": 0.004, + }, + }, + "wake_velocity_parameters": { + "gauss": {"alpha": 0.58, "beta": 0.077, "ka": 0.38, "kb": 0.004}, + }, + "wake_turbulence_parameters": { + "crespo_hernandez": { + "initial": 0.01, + "constant": 0.9, + "ai": 0.83, + "downstream": -0.25, + } + }, + }, + } diff --git a/tests/controller_base_test.py b/tests/controller_base_test.py index cafb14aa..d11c15bb 100644 --- a/tests/controller_base_test.py +++ b/tests/controller_base_test.py @@ -1,24 +1,5 @@ import pytest from hycon.controllers.controller_base import ControllerBase -from hycon.interfaces.interface_base import InterfaceBase - - -class StandinInterface(InterfaceBase): - """ - Empty class to test controllers. - """ - - def __init__(self): - super().__init__() - - def get_measurements(self): - pass - - def check_controls(self): - pass - - def send_controls(self): - pass class InheritanceTestClassBad(ControllerBase): @@ -42,26 +23,22 @@ def compute_controls(self): pass -def test_ControllerBase_methods(): +def test_ControllerBase_methods(test_interface_standin): """ Check that the base interface class establishes the correct methods. """ - test_interface = StandinInterface() - - controller_base = InheritanceTestClassGood(test_interface) + controller_base = InheritanceTestClassGood(test_interface_standin) assert hasattr(controller_base, "_receive_measurements") assert hasattr(controller_base, "_send_controls") assert hasattr(controller_base, "step") assert hasattr(controller_base, "compute_controls") -def test_inherited_methods(): +def test_inherited_methods(test_interface_standin): """ Check that a subclass of InterfaceBase inherits methods correctly. """ - test_interface = StandinInterface() - with pytest.raises(TypeError): - _ = InheritanceTestClassBad(test_interface) + _ = InheritanceTestClassBad(test_interface_standin) - _ = InheritanceTestClassGood(test_interface) + _ = InheritanceTestClassGood(test_interface_standin) diff --git a/tests/controller_library_test.py b/tests/controller_library_test.py index e215c2b5..444995a8 100644 --- a/tests/controller_library_test.py +++ b/tests/controller_library_test.py @@ -1,5 +1,3 @@ -import copy - import numpy as np import pandas as pd import pytest @@ -18,500 +16,413 @@ ) from hycon.controllers.wind_farm_power_tracking_controller import POWER_SETPOINT_DEFAULT from hycon.interfaces import ( - HerculesADInterface, HerculesBatteryInterface, - HerculesHybridADInterface, - HerculesInterface, ) -from hycon.interfaces.interface_base import InterfaceBase - - -class StandinInterface(InterfaceBase): - """ - Empty class to test controllers. - """ - - def __init__(self): - super().__init__() - self.dt = 1.0 - # Set up stand-in plant parameters - self.plant_parameters = {"n_turbines": 2} - def get_measurements(self): - pass - def check_controls(self): - pass - - def send_controls(self): - pass - -# TODO: make these fixtures, use across interfaces and controller tests -test_hercules_dict = { - "dt": 1, - "time": 0, - "controller": { - "num_turbines": 2, - "initial_conditions": {"yaw": [270.0, 270.0]}, - "nominal_plant_power_kW": 10000, - "nominal_hydrogen_rate_kgps": 0.1, - "hydrogen_controller_gain": 1.0, - }, - "hercules_comms": { - "amr_wind": { - "test_farm": { - "turbine_wind_directions": [271.0, 272.5], - "turbine_powers": [4000.0, 4001.0], - "wind_speed": 10.0, - } - } - }, - "py_sims": { - "test_battery": { - "outputs": {"power": 10.0, "soc": 0.3}, - "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}, -} - -test_hercules_v2_dict = { - "dt": 1, - "time": 0, - "plant": { - "interconnect_limit": 10000.0, - }, - "controller": { - "test_controller_parameter": 1.0, - }, - "wind_farm": { - "n_turbines": 2, - "capacity": 10000.0, - "wind_direction_mean": 271.0, - "turbine_powers": [4000.0, 4001.0], - "wind_speed": 10.0, - }, - "solar_farm": { - "capacity": 1000.0, - "power": 1000.0, # kW - "dni": 1000.0, - "aoi": 30.0, - }, - "battery": { - "size": 10.0e3, - "energy_capacity": 40.0e3, - "power": 10.0e3, - "soc": 0.3, - "charge_rate": 20e3, - "discharge_rate": 15e3, - }, - "electrolyzer": { - "H2_mfr": 0.03, - }, - "external_signals": { - "wind_power_reference": 1000.0, - "solar_power_reference": 800.0, - "battery_power_reference": 0.0, - "plant_power_reference": 1000.0, - "forecast_ws_mean_0": 8.0, - "forecast_ws_mean_1": 8.1, - "ws_median_0": 8.1, - "hydrogen_reference": 0.02, - }, -} - - -def test_controller_instantiation(): +def test_controller_instantiation(test_interface_standin, test_hercules_v1_dict): """ Tests whether all controllers can be imported correctly and that they each implement the required methods specified by ControllerBase. """ - test_interface = StandinInterface() - - _ = LookupBasedWakeSteeringController(interface=test_interface, input_dict=test_hercules_dict) - _ = WindFarmPowerDistributingController(interface=test_interface, input_dict=test_hercules_dict) - _ = WindFarmPowerTrackingController(interface=test_interface, input_dict=test_hercules_dict) + _ = LookupBasedWakeSteeringController( + interface=test_interface_standin, input_dict=test_hercules_v1_dict + ) + _ = WindFarmPowerDistributingController( + interface=test_interface_standin, input_dict=test_hercules_v1_dict + ) + _ = WindFarmPowerTrackingController( + interface=test_interface_standin, input_dict=test_hercules_v1_dict + ) _ = HybridSupervisoryControllerBaseline( - interface=test_interface, - input_dict=test_hercules_dict, - wind_controller=1, # Override error raised for empty controllers + interface=test_interface_standin, + input_dict=test_hercules_v1_dict, + 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) - _ = BatteryController(interface=test_interface, input_dict=test_hercules_dict) - + _ = SolarPassthroughController( + interface=test_interface_standin, input_dict=test_hercules_v1_dict + ) + _ = BatteryPassthroughController( + interface=test_interface_standin, input_dict=test_hercules_v1_dict + ) + _ = BatteryController(interface=test_interface_standin, input_dict=test_hercules_v1_dict) -def test_LookupBasedWakeSteeringController(): - test_interface = HerculesADInterface(test_hercules_dict) +def test_LookupBasedWakeSteeringController(test_hercules_v1_dict, test_interface_hercules_ad): # No lookup table passed; simply passes through wind direction to yaw angles test_controller = LookupBasedWakeSteeringController( - interface=test_interface, - input_dict=test_hercules_dict + interface=test_interface_hercules_ad, input_dict=test_hercules_v1_dict ) # Check that the controller can be stepped - test_hercules_dict["time"] = 20 - test_hercules_dict_out = test_controller.step(input_dict=test_hercules_dict) + test_hercules_v1_dict["time"] = 20 + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) test_angles = np.array( - test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_yaw_angles"] + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_yaw_angles"] ) wind_directions = np.array( - test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_wind_directions"] + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_wind_directions"] ) assert np.allclose(test_angles, wind_directions) # Lookup table that specified 20 degree offset for T000, 10 degree offset for T001 for all # wind directions test_offsets = np.array([20.0, 10.0]) - df_opt_test = pd.DataFrame(data={ - "wind_direction":[220.0, 220.0, 320.0, 320.0], - "wind_speed":[0.0, 20.0, 0.0, 20.0], - "yaw_angles_opt":[test_offsets]*4, - "turbulence_intensity":[0.06]*4 - }) + 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_hercules_ad, input_dict=test_hercules_v1_dict, df_yaw=df_opt_test ) - test_hercules_dict["time"] = 20 - test_hercules_dict_out = test_controller.step(input_dict=test_hercules_dict) + test_hercules_v1_dict["time"] = 20 + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) test_angles = np.array( - test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_yaw_angles"] + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_yaw_angles"] ) wind_directions = np.array( - test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_wind_directions"] + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_wind_directions"] ) assert np.allclose(test_angles, wind_directions - test_offsets) -def test_WindFarmPowerDistributingController(): - test_interface = HerculesADInterface(test_hercules_dict) + +def test_WindFarmPowerDistributingController(test_hercules_v1_dict, test_interface_hercules_ad): test_controller = WindFarmPowerDistributingController( - interface=test_interface, - input_dict=test_hercules_dict + interface=test_interface_hercules_ad, input_dict=test_hercules_v1_dict ) # Default behavior when no power reference is given - test_hercules_dict["time"] = 20 - test_hercules_dict["external_signals"] = {} - test_hercules_dict_out = test_controller.step(input_dict=test_hercules_dict) + test_hercules_v1_dict["time"] = 20 + test_hercules_v1_dict["external_signals"] = {} + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) test_power_setpoints = np.array( - test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] ) assert np.allclose( test_power_setpoints, - POWER_SETPOINT_DEFAULT/test_hercules_dict["controller"]["num_turbines"], + POWER_SETPOINT_DEFAULT / test_hercules_v1_dict["controller"]["num_turbines"], ) # Test with power reference - test_hercules_dict["external_signals"]["wind_power_reference"] = 1000 - test_hercules_dict_out = test_controller.step(input_dict=test_hercules_dict) + test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 1000 + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) test_power_setpoints = np.array( - test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + test_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) + + +def test_WindFarmPowerTrackingController(test_hercules_v1_dict, test_interface_hercules_ad): test_controller = WindFarmPowerTrackingController( - interface=test_interface, - input_dict=test_hercules_dict + interface=test_interface_hercules_ad, input_dict=test_hercules_v1_dict ) # Test no change to power setpoints if producing desired power - test_hercules_dict["external_signals"]["wind_power_reference"] = 1000 - test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [500, 500] - test_hercules_dict_out = test_controller.step(input_dict=test_hercules_dict) + test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 1000 + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [500, 500] + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) test_power_setpoints = np.array( - test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] ) assert np.allclose(test_power_setpoints, 500) # Test if power exceeds farm reference, power setpoints are reduced - test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [600, 600] - test_hercules_dict_out = test_controller.step(input_dict=test_hercules_dict) + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [600, 600] + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) test_power_setpoints = np.array( - test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] ) assert ( test_power_setpoints - <= test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] + <= test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] ).all() # Test if power is less than farm reference, power setpoints are increased - test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [550, 400] - test_hercules_dict_out = test_controller.step(input_dict=test_hercules_dict) + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [550, 400] + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) test_power_setpoints = np.array( - test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] ) assert ( test_power_setpoints - >= test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] + >= test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] ).all() # Test that more aggressive control leads to faster response test_controller = WindFarmPowerTrackingController( - interface=test_interface, - input_dict=test_hercules_dict, - proportional_gain=2 + interface=test_interface_hercules_ad, input_dict=test_hercules_v1_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) + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [600, 600] + test_dict_out = test_controller.step(input_dict=test_hercules_v1_dict) test_power_setpoints_a = np.array( - test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + test_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] ) assert (test_power_setpoints_a < test_power_setpoints).all() -def test_HybridSupervisoryControllerBaseline(): - test_interface = HerculesHybridADInterface(test_hercules_dict) +def test_HybridSupervisoryControllerBaseline( + test_hercules_v1_dict, test_interface_hercules_hybrid_ad +): # Establish lower controllers - wind_controller = WindFarmPowerTrackingController(test_interface, test_hercules_dict) - solar_controller = SolarPassthroughController(test_interface, test_hercules_dict) - battery_controller = BatteryPassthroughController(test_interface, test_hercules_dict) + wind_controller = WindFarmPowerTrackingController( + test_interface_hercules_hybrid_ad, test_hercules_v1_dict + ) + solar_controller = SolarPassthroughController( + test_interface_hercules_hybrid_ad, test_hercules_v1_dict + ) + battery_controller = BatteryPassthroughController( + test_interface_hercules_hybrid_ad, test_hercules_v1_dict + ) test_controller = HybridSupervisoryControllerBaseline( - interface=test_interface, - input_dict=test_hercules_dict, + interface=test_interface_hercules_hybrid_ad, + input_dict=test_hercules_v1_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 # Simply test the supervisory_control method, for the time being - 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_hercules_v1_dict["external_signals"]["plant_power_reference"] = power_ref + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = ( + wind_current + ) + test_hercules_v1_dict["py_sims"]["test_solar"]["outputs"]["power_mw"] = solar_current / 1e3 + test_controller.prev_solar_power = solar_current # To override filtering + test_controller.prev_wind_power = sum(wind_current) # To override filtering - test_controller.step(test_hercules_dict) # Run the controller once to update measurements + test_controller.step(test_hercules_v1_dict) # Run the controller once to update measurements supervisory_control_output = test_controller.supervisory_control( test_controller._measurements_dict ) # Expected outputs - wind_solar_current = sum(wind_current)+solar_current - wind_power_cmd = 20000/2 + sum(wind_current)-(wind_solar_current - power_ref)/2 - solar_power_cmd = 20000/2 + solar_current-(wind_solar_current - power_ref)/2 + 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(): +def test_HybridSupervisoryControllerBaseline_subsets( + test_hercules_v1_dict, test_interface_hercules_hybrid_ad +): """ Tests that the HybridSupervisoryControllerBaseline can be run with only some of the wind, solar, and battery controllers. """ - test_interface = HerculesHybridADInterface(test_hercules_dict) + test_interface = test_interface_hercules_hybrid_ad # Establish lower controllers - wind_controller = WindFarmPowerTrackingController(test_interface, test_hercules_dict) - solar_controller = SolarPassthroughController(test_interface, test_hercules_dict) - battery_controller = BatteryPassthroughController(test_interface, test_hercules_dict) + wind_controller = WindFarmPowerTrackingController( + test_interface_hercules_hybrid_ad, test_hercules_v1_dict + ) + solar_controller = SolarPassthroughController( + test_interface_hercules_hybrid_ad, test_hercules_v1_dict + ) + battery_controller = BatteryPassthroughController( + test_interface_hercules_hybrid_ad, test_hercules_v1_dict + ) ## First, try with wind and solar only test_controller = HybridSupervisoryControllerBaseline( - interface=test_interface, - input_dict=test_hercules_dict, + interface=test_interface_hercules_hybrid_ad, + input_dict=test_hercules_v1_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 # Simply test the supervisory_control method, for the time being - 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_hercules_v1_dict["external_signals"]["plant_power_reference"] = power_ref + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = ( + wind_current + ) + test_hercules_v1_dict["py_sims"]["test_solar"]["outputs"]["power_mw"] = solar_current / 1e3 + test_controller.prev_solar_power = solar_current # To override filtering + test_controller.prev_wind_power = sum(wind_current) # To override filtering - test_controller.step(test_hercules_dict) # Run the controller once to update measurements + test_controller.step(test_hercules_v1_dict) # Run the controller once to update measurements supervisory_control_output = test_controller.supervisory_control( test_controller._measurements_dict ) - wind_solar_current = sum(wind_current)+solar_current - wind_power_cmd = sum(wind_current)-(wind_solar_current - power_ref)/2 - solar_power_cmd = solar_current-(wind_solar_current - power_ref)/2 - battery_power_cmd = 0 # No battery controller! + 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( interface=test_interface, - input_dict=test_hercules_dict, + input_dict=test_hercules_v1_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_v1_dict) # Run the controller once to update measurements supervisory_control_output = test_controller.supervisory_control( test_controller._measurements_dict ) wind_power_cmd = 20000 + power_ref - solar_power_cmd = 0 # No solar controller! + 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 test_controller = HybridSupervisoryControllerBaseline( interface=test_interface, - input_dict=test_hercules_dict, + input_dict=test_hercules_v1_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_v1_dict) # Run the controller once to update measurements supervisory_control_output = test_controller.supervisory_control( test_controller._measurements_dict ) - wind_power_cmd = 0 # No wind controller! + 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 with pytest.raises(ValueError): _ = HybridSupervisoryControllerBaseline( interface=test_interface, - input_dict=test_hercules_dict, + input_dict=test_hercules_v1_dict, wind_controller=None, solar_controller=None, - battery_controller=battery_controller + battery_controller=battery_controller, ) ## Only wind controller test_controller = HybridSupervisoryControllerBaseline( interface=test_interface, - input_dict=test_hercules_dict, + input_dict=test_hercules_v1_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_v1_dict) # Run the controller once to update measurements supervisory_control_output = test_controller.supervisory_control( test_controller._measurements_dict ) wind_power_cmd = power_ref - solar_power_cmd = 0 # No solar controller! - battery_power_cmd = 0 # No battery controller! + 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 test_controller = HybridSupervisoryControllerBaseline( interface=test_interface, - input_dict=test_hercules_dict, + input_dict=test_hercules_v1_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_v1_dict) # Run the controller once to update measurements supervisory_control_output = test_controller.supervisory_control( test_controller._measurements_dict ) - wind_power_cmd = 0 # No wind controller! + 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(): + +def test_HybridSupervisoryControllerMultiRef_requirements( + test_hercules_dict, test_interface_hercules +): + test_interface = test_interface_hercules # Check that errors are correctly raised if interconnect_limit is not set correctly - test_hercules_v2_dict_temp = copy.deepcopy(test_hercules_v2_dict) - del test_hercules_v2_dict_temp["plant"]["interconnect_limit"] - interface = HerculesInterface(test_hercules_v2_dict_temp) + del test_interface.plant_parameters["interconnect_limit"] with pytest.raises(KeyError): - HybridSupervisoryControllerMultiRef(interface, test_hercules_v2_dict_temp) + HybridSupervisoryControllerMultiRef(test_interface, test_hercules_dict) - test_hercules_v2_dict_temp["plant"]["interconnect_limit"] = "1" + test_interface.plant_parameters["interconnect_limit"] = "1" 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 + HybridSupervisoryControllerMultiRef(test_interface, test_hercules_dict) + + test_interface.plant_parameters["interconnect_limit"] = -1 with pytest.raises(ValueError): - interface = HerculesInterface(test_hercules_v2_dict_temp) - HybridSupervisoryControllerMultiRef(interface, test_hercules_v2_dict_temp) + HybridSupervisoryControllerMultiRef(test_interface, test_hercules_dict) + -def test_HybridSupervisoryControllerMultiRef(): - test_interface = HerculesInterface(test_hercules_v2_dict) +def test_HybridSupervisoryControllerMultiRef(test_hercules_dict, test_interface_hercules): + test_interface = test_interface_hercules + test_interface.plant_parameters["interconnect_limit"] = 10000.0 # Establish lower controllers - wind_controller = WindFarmPowerTrackingController(test_interface, test_hercules_v2_dict) - solar_controller = SolarPassthroughController(test_interface, test_hercules_v2_dict) - battery_controller = BatteryPassthroughController(test_interface, test_hercules_v2_dict) + wind_controller = WindFarmPowerTrackingController(test_interface, test_hercules_dict) + solar_controller = SolarPassthroughController(test_interface, test_hercules_dict) + battery_controller = BatteryPassthroughController(test_interface, test_hercules_dict) test_controller = HybridSupervisoryControllerMultiRef( interface=test_interface, - input_dict=test_hercules_v2_dict, + input_dict=test_hercules_dict, wind_controller=wind_controller, solar_controller=solar_controller, - battery_controller=battery_controller + battery_controller=battery_controller, ) solar_current = 800 wind_current = [600, 300] # 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_hercules_dict["wind_farm"]["turbine_powers"] = wind_current + test_hercules_dict["solar_farm"]["power"] = solar_current + test_controller.prev_solar_power = solar_current # To override filtering + test_controller.prev_wind_power = sum(wind_current) # To override filtering + test_controller.step(test_hercules_dict) # Run the controller once to update measurements supervisory_control_output = test_controller.supervisory_control( test_controller._measurements_dict @@ -521,65 +432,70 @@ def test_HybridSupervisoryControllerMultiRef(): assert np.allclose( supervisory_control_output, [ - 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_dict["external_signals"]["wind_power_reference"], + test_hercules_dict["external_signals"]["solar_power_reference"], + test_hercules_dict["external_signals"]["battery_power_reference"], + ], ) # Check individual components producing according to their references -def test_BatteryPassthroughController(): - test_interface = HerculesHybridADInterface(test_hercules_dict) - test_controller = BatteryPassthroughController(test_interface, test_hercules_dict) + +def test_BatteryPassthroughController(test_hercules_v1_dict, test_interface_hercules_hybrid_ad): + test_controller = BatteryPassthroughController( + test_interface_hercules_hybrid_ad, test_hercules_v1_dict + ) power_ref = 1000 - measurements_dict = {"battery":{"power_reference": power_ref}} + 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) + +def test_SolarPassthroughController(test_hercules_v1_dict, test_interface_hercules_hybrid_ad): + test_controller = SolarPassthroughController( + test_interface_hercules_hybrid_ad, test_hercules_v1_dict + ) power_ref = 1000 - measurements_dict = {"solar_farm":{"power_reference": power_ref}} + 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}) + +def test_BatteryController(test_hercules_v1_dict): + test_interface = HerculesBatteryInterface(test_hercules_v1_dict) + test_controller = BatteryController(test_interface, test_hercules_v1_dict, {"k_batt": 0.1}) # Test when starting with 0 power output power_ref = 1000 - test_hercules_dict["py_sims"]["test_battery"]["outputs"] = {"power": 0, "soc": 0.3} - test_hercules_dict["external_signals"]["plant_power_reference"] = power_ref - test_controller.step(test_hercules_dict) + test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"] = {"power": 0, "soc": 0.3} + test_hercules_v1_dict["external_signals"]["plant_power_reference"] = power_ref + test_controller.step(test_hercules_v1_dict) out_0 = test_controller._controls_dict["power_setpoint"] assert 0 < out_0 < power_ref # Test that increasing the gain increases the control response - test_controller = BatteryController(test_interface, test_hercules_dict, {"k_batt":0.5}) - test_controller.step(test_hercules_dict) + test_controller = BatteryController(test_interface, test_hercules_v1_dict, {"k_batt": 0.5}) + test_controller.step(test_hercules_v1_dict) out_1 = test_controller._controls_dict["power_setpoint"] assert out_0 < out_1 < power_ref # Decreasing the gain slows the response - test_controller = BatteryController(test_interface, test_hercules_dict, {"k_batt":0.01}) - test_controller.step(test_hercules_dict) + test_controller = BatteryController(test_interface, test_hercules_v1_dict, {"k_batt": 0.01}) + test_controller.step(test_hercules_v1_dict) out_2 = test_controller._controls_dict["power_setpoint"] assert 0 < out_2 < out_0 # More complex test for smoothing capabilities (mid-low gain) power_refs_in = np.tile(np.array([1000.0, -1000.0]), 5) power_refs_out = np.zeros_like(power_refs_in) - test_controller = BatteryController(test_interface, test_hercules_dict, {"k_batt":0.1}) + test_controller = BatteryController(test_interface, test_hercules_v1_dict, {"k_batt": 0.1}) battery_power = 0 for i, pr_in in enumerate(power_refs_in): - test_hercules_dict["external_signals"]["plant_power_reference"] = pr_in - test_hercules_dict["py_sims"]["test_battery"]["outputs"]["power"] = -battery_power - test_hercules_dict["time"] += 1 - out = test_controller.step(test_hercules_dict) + test_hercules_v1_dict["external_signals"]["plant_power_reference"] = pr_in + test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"]["power"] = -battery_power + test_hercules_v1_dict["time"] += 1 + out = test_controller.step(test_hercules_v1_dict) battery_power = out["py_sims"]["inputs"]["battery_signal"] power_refs_out[i] = battery_power @@ -587,35 +503,29 @@ 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_hercules_v1_dict["py_sims"]["test_battery"]["outputs"] = {"power": 0, "soc": 0.3} + test_hercules_v1_dict["external_signals"]["plant_power_reference"] = power_ref test_controller_0 = BatteryController( - test_interface, - test_hercules_dict, - {"clipping_thresholds":clipping_threshold_0} + test_interface, test_hercules_v1_dict, {"clipping_thresholds": clipping_threshold_0} ) - test_controller_0.step(test_hercules_dict) + test_controller_0.step(test_hercules_v1_dict) out_0 = test_controller_0._controls_dict["power_setpoint"] test_controller_1 = BatteryController( - test_interface, - test_hercules_dict, - {"clipping_thresholds":clipping_threshold_1} + test_interface, test_hercules_v1_dict, {"clipping_thresholds": clipping_threshold_1} ) - test_controller_1.step(test_hercules_dict) + test_controller_1.step(test_hercules_v1_dict) out_1 = test_controller_1._controls_dict["power_setpoint"] test_controller_2 = BatteryController( - test_interface, - test_hercules_dict, - {"clipping_thresholds":clipping_threshold_2} + test_interface, test_hercules_v1_dict, {"clipping_thresholds": clipping_threshold_2} ) - test_controller_2.step(test_hercules_dict) + test_controller_2.step(test_hercules_v1_dict) out_2 = test_controller_2._controls_dict["power_setpoint"] assert out_0 == out_1 @@ -625,67 +535,72 @@ def test_BatteryController(): test_controller_0.x = 0 test_controller_1.x = 0 test_controller_2.x = 0 - test_hercules_dict["external_signals"]["plant_power_reference"] = 20000 - test_controller_0.step(test_hercules_dict) + test_hercules_v1_dict["external_signals"]["plant_power_reference"] = 20000 + test_controller_0.step(test_hercules_v1_dict) out_0 = test_controller_0._controls_dict["power_setpoint"] - test_controller_1.step(test_hercules_dict) + test_controller_1.step(test_hercules_v1_dict) out_1 = test_controller_1._controls_dict["power_setpoint"] - test_controller_2.step(test_hercules_dict) + test_controller_2.step(test_hercules_v1_dict) out_2 = test_controller_2._controls_dict["power_setpoint"] assert out_0 == out_1 assert out_0 > out_2 # at 85% SOC and large reference, 1 should be clipped - test_hercules_dict["py_sims"]["test_battery"]["outputs"] = {"power": 0, "soc": 0.85} + test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"] = {"power": 0, "soc": 0.85} test_controller_0.x = 0 test_controller_1.x = 0 - test_controller_0.step(test_hercules_dict) + test_controller_0.step(test_hercules_v1_dict) out_0 = test_controller_0._controls_dict["power_setpoint"] - test_controller_1.step(test_hercules_dict) + test_controller_1.step(test_hercules_v1_dict) out_1 = test_controller_1._controls_dict["power_setpoint"] - + assert out_0 > out_1 -def test_HydrogenPlantController(): + +def test_HydrogenPlantController(test_hercules_v1_dict, test_interface_hercules_hybrid_ad): """ Tests that the HydrogenPlantController outputs a reasonable signal """ - test_interface = HerculesHybridADInterface(test_hercules_dict) - ## Test with only wind providing generation - wind_controller = WindFarmPowerTrackingController(test_interface, test_hercules_dict) + wind_controller = WindFarmPowerTrackingController( + test_interface_hercules_hybrid_ad, test_hercules_v1_dict + ) test_controller = HydrogenPlantController( - interface=test_interface, - input_dict=test_hercules_dict, + interface=test_interface_hercules_hybrid_ad, + input_dict=test_hercules_v1_dict, generator_controller=wind_controller, ) wind_current = [600, 300] hyrogen_ref = 0.03 - hydrogen_output = test_hercules_dict["py_sims"]["test_hydrogen"]["outputs"]["H2_mfr"] + hydrogen_output = test_hercules_v1_dict["py_sims"]["test_hydrogen"]["outputs"]["H2_mfr"] hydrogen_error = hyrogen_ref - hydrogen_output # Simply test the supervisory_control method, for the time being - test_hercules_dict["external_signals"]["hydrogen_reference"] = hyrogen_ref - 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_hercules_v1_dict["external_signals"]["hydrogen_reference"] = hyrogen_ref + test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = ( + wind_current + ) + test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"]["power"] = 0.0 + test_hercules_v1_dict["py_sims"]["test_solar"]["outputs"]["power_mw"] = 0.0 + test_controller.filtered_power_prev = sum(wind_current) # To override filtering # Without removing wind power reference, wind controller can't reconcile its setpoint with pytest.raises(KeyError): - test_controller.step(test_hercules_dict) + test_controller.step(test_hercules_v1_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 + del test_hercules_v1_dict["external_signals"]["wind_power_reference"] + test_controller.step(test_hercules_v1_dict) # Run the controller once to update measurements supervisory_control_output = test_controller.supervisory_control( test_controller._measurements_dict ) - controller_gain = test_hercules_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_v1_dict["controller"]["nominal_plant_power_kW"] + / test_hercules_v1_dict["controller"]["nominal_hydrogen_rate_kgps"] + * test_hercules_v1_dict["controller"]["hydrogen_controller_gain"] + ) assert controller_gain == test_controller.K wind_power_cmd = sum(wind_current) + controller_gain * hydrogen_error @@ -694,16 +609,20 @@ def test_HydrogenPlantController(): # Test with a full wind/solar/battery plant hybrid_controller = HybridSupervisoryControllerBaseline( - interface=test_interface, - input_dict=test_hercules_dict, + interface=test_interface_hercules_hybrid_ad, + input_dict=test_hercules_v1_dict, wind_controller=wind_controller, - solar_controller=SolarPassthroughController(test_interface, test_hercules_dict), - battery_controller=BatteryPassthroughController(test_interface, test_hercules_dict) + solar_controller=SolarPassthroughController( + test_interface_hercules_hybrid_ad, test_hercules_v1_dict + ), + battery_controller=BatteryPassthroughController( + test_interface_hercules_hybrid_ad, test_hercules_v1_dict + ), ) test_controller = HydrogenPlantController( - interface=test_interface, - input_dict=test_hercules_dict, + interface=test_interface_hercules_hybrid_ad, + input_dict=test_hercules_v1_dict, generator_controller=hybrid_controller, ) @@ -711,12 +630,14 @@ def test_HydrogenPlantController(): solar_current = 1000 battery_current = 500 total_current_power = sum(wind_current) + solar_current - battery_current - 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_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = ( + wind_current + ) + test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"]["power"] = battery_current + test_hercules_v1_dict["py_sims"]["test_solar"]["outputs"]["power_mw"] = solar_current / 1e3 + test_controller.filtered_power_prev = total_current_power # To override filtering - test_controller.step(test_hercules_dict) # Run the controller once to update measurements + test_controller.step(test_hercules_v1_dict) # Run the controller once to update measurements supervisory_control_output = test_controller.supervisory_control( test_controller._measurements_dict ) @@ -726,7 +647,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, @@ -735,29 +656,29 @@ def test_HydrogenPlantController(): # Test an error is raised if controller_parameters is passed while also specified on input_dict with pytest.raises(KeyError): HydrogenPlantController( - interface=test_interface, - input_dict=test_hercules_dict, + interface=test_interface_hercules_hybrid_ad, + input_dict=test_hercules_v1_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 # and input_dict["controller"] - del test_hercules_dict["controller"]["nominal_plant_power_kW"] + del test_hercules_v1_dict["controller"]["nominal_plant_power_kW"] with pytest.raises(TypeError): HydrogenPlantController( - interface=test_interface, - input_dict=test_hercules_dict, + interface=test_interface_hercules_hybrid_ad, + input_dict=test_hercules_v1_dict, generator_controller=hybrid_controller, ) # Check instantiation proceeds correctly if doubly-specified parameters are avoided - del test_hercules_dict["controller"]["nominal_hydrogen_rate_kgps"] - del test_hercules_dict["controller"]["hydrogen_controller_gain"] + del test_hercules_v1_dict["controller"]["nominal_hydrogen_rate_kgps"] + del test_hercules_v1_dict["controller"]["hydrogen_controller_gain"] test_controller = HydrogenPlantController( - interface=test_interface, - input_dict=test_hercules_dict, + interface=test_interface_hercules_hybrid_ad, + input_dict=test_hercules_v1_dict, generator_controller=hybrid_controller, - controller_parameters=external_controller_parameters + controller_parameters=external_controller_parameters, ) diff --git a/tests/floris_input.yaml b/tests/floris_input.yaml deleted file mode 100644 index 34ab3675..00000000 --- a/tests/floris_input.yaml +++ /dev/null @@ -1,92 +0,0 @@ - -name: test_input -description: Two-turbine farm for testing -floris_version: v4 - -logging: - console: - enable: false - level: WARNING - file: - enable: false - level: WARNING - -solver: - type: turbine_grid - turbine_grid_points: 3 - -farm: - layout_x: - - 0.0 - - 500.0 - layout_y: - - 0.0 - - 0.0 - turbine_type: - - nrel_5MW - -flow_field: - air_density: 1.225 - reference_wind_height: 90.0 - turbulence_intensities: - - 0.06 - wind_directions: - - 270.0 - wind_shear: 0.12 - wind_speeds: - - 8.0 - wind_veer: 0.0 - -wake: - model_strings: - combination_model: sosfs - deflection_model: gauss - turbulence_model: crespo_hernandez - velocity_model: gauss - - enable_secondary_steering: true - enable_yaw_added_recovery: true - enable_active_wake_mixing: true - enable_transverse_velocities: true - - wake_deflection_parameters: - gauss: - ad: 0.0 - alpha: 0.58 - bd: 0.0 - beta: 0.077 - dm: 1.0 - ka: 0.38 - kb: 0.004 - jimenez: - ad: 0.0 - bd: 0.0 - kd: 0.05 - - wake_velocity_parameters: - cc: - a_s: 0.179367259 - b_s: 0.0118889215 - c_s1: 0.0563691592 - c_s2: 0.13290157 - a_f: 3.11 - b_f: -0.68 - c_f: 2.41 - alpha_mod: 1.0 - gauss: - alpha: 0.58 - beta: 0.077 - ka: 0.38 - kb: 0.004 - jensen: - we: 0.05 - turboparkgauss: - A: 0.04 - include_mirror_wake: True - - wake_turbulence_parameters: - crespo_hernandez: - initial: 0.01 - constant: 0.9 - ai: 0.83 - downstream: -0.25 diff --git a/tests/hercules_interface_test.py b/tests/hercules_interface_test.py index 5e0f7a3d..738b12bb 100644 --- a/tests/hercules_interface_test.py +++ b/tests/hercules_interface_test.py @@ -1,50 +1,8 @@ import pytest from hycon.interfaces import HerculesInterface -test_hercules_dict = { - "dt": 1, - "time": 0, - "plant": { - "interconnect_limit": None - }, - "controller": { - "test_controller_parameter": 1.0, - }, - "wind_farm": { - "n_turbines": 2, - "capacity": 10000.0, - "wind_direction_mean": 271.0, - "turbine_powers": [4000.0, 4001.0], - "wind_speed": 10.0, - }, - "solar_farm": { - "capacity": 1000.0, - "power": 1000.0, # kW - "dni": 1000.0, - "aoi": 30.0, - }, - "battery": { - "size": 10.0e3, - "energy_capacity": 40.0e3, - "power": 10.0e3, - "soc": 0.3, - "charge_rate": 20e3, - "discharge_rate": 15e3, - }, - "electrolyzer": { - "H2_mfr": 0.03, - }, - "external_signals": { # Is this OK like this? - "wind_power_reference": 1000.0, - "plant_power_reference": 1000.0, - "forecast_ws_mean_0": 8.0, - "forecast_ws_mean_1": 8.1, - "ws_median_0": 8.1, - "hydrogen_reference": 0.02, - }, -} - -def test_interface_instantiation(): + +def test_interface_instantiation(test_hercules_dict): """ Tests whether all interfaces can be imported correctly and that they each implement the required methods specified by InterfaceBase. @@ -52,15 +10,18 @@ def test_interface_instantiation(): _ = HerculesInterface(h_dict=test_hercules_dict) -def test_HerculesInterface_windonly(): + +def test_HerculesInterface_windonly(test_hercules_dict): # 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 +46,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 +65,30 @@ def test_HerculesInterface_windonly(): with pytest.raises(TypeError): # Bad kwarg interface.send_controls(test_hercules_dict, **bad_controls_dict1) -def test_HerculesInterface_hybrid(): + +def test_HerculesInterface_hybrid(test_hercules_dict): # 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 +126,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..b6b644e7 100644 --- a/tests/hercules_v1_interfaces_test.py +++ b/tests/hercules_v1_interfaces_test.py @@ -5,68 +5,37 @@ HerculesV1HybridADInterface, ) -test_hercules_dict = { - "dt": 1, - "time": 0, - "controller": {"num_turbines": 2, "wind_capacity_MW": 10}, - "hercules_comms": { - "amr_wind": { - "test_farm": { - "turbine_wind_directions": [271.0, 272.5], - "turbine_powers": [4000.0, 4001.0], - "wind_speed": 10.0, - } - } - }, - "py_sims": { - "test_battery": { - "outputs": {"power": 10.0, "soc": 0.3}, - "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, - "forecast_ws_mean_0": 8.0, - "forecast_ws_mean_1": 8.1, - "ws_median_0": 8.1, - "hydrogen_reference": 0.02, - }, -} - -def test_interface_instantiation(): +def test_interface_instantiation(test_hercules_v1_dict): """ Tests whether all interfaces can be imported correctly and that they each implement the required methods specified by InterfaceBase. """ - _ = HerculesV1ADInterface(hercules_dict=test_hercules_dict) - _ = HerculesV1HybridADInterface(hercules_dict=test_hercules_dict) - _ = HerculesV1BatteryInterface(hercules_dict=test_hercules_dict) + _ = HerculesV1ADInterface(hercules_dict=test_hercules_v1_dict) + _ = HerculesV1HybridADInterface(hercules_dict=test_hercules_v1_dict) + _ = HerculesV1BatteryInterface(hercules_dict=test_hercules_v1_dict) -def test_HerculesADInterface(): - interface = HerculesV1ADInterface(hercules_dict=test_hercules_dict) +def test_HerculesADInterface(test_hercules_v1_dict): + interface = HerculesV1ADInterface(hercules_dict=test_hercules_v1_dict) # Test get_measurements() - measurements = interface.get_measurements(hercules_dict=test_hercules_dict) + measurements = interface.get_measurements(hercules_dict=test_hercules_v1_dict) - assert measurements["time"] == test_hercules_dict["time"] + assert measurements["time"] == test_hercules_v1_dict["time"] assert ( measurements["wind_farm"]["wind_directions"] - == test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_wind_directions"] + == test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"][ + "turbine_wind_directions" + ] ) assert ( measurements["wind_farm"]["turbine_powers"] - == test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] + == test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] ) test_forecast = { - k: v for k, v in test_hercules_dict["external_signals"].items() if "forecast" in k + k: v for k, v in test_hercules_v1_dict["external_signals"].items() if "forecast" in k } assert measurements["forecast"] == test_forecast @@ -96,7 +65,7 @@ def test_HerculesADInterface(): # test send_controls() test_hercules_dict_out = interface.send_controls( - hercules_dict=test_hercules_dict, **controls_dict + hercules_dict=test_hercules_v1_dict, **controls_dict ) assert ( controls_dict["yaw_angles"] @@ -104,59 +73,64 @@ def test_HerculesADInterface(): ) with pytest.raises(TypeError): # Bad kwarg - interface.send_controls(test_hercules_dict, **bad_controls_dict1) + interface.send_controls(test_hercules_v1_dict, **bad_controls_dict1) with pytest.raises(TypeError): # Bad kwarg - interface.send_controls(test_hercules_dict, **bad_controls_dict2) + interface.send_controls(test_hercules_v1_dict, **bad_controls_dict2) # bad_controls_dict3 would pass, but faile the check_controls step. # test that both wind_power_reference and plant_power_reference work, and that # wind_power_reference takes precedence - test_hercules_dict["external_signals"]["wind_power_reference"] = 500.0 - test_hercules_dict["external_signals"]["plant_power_reference"] = 400.0 - assert interface.get_measurements(test_hercules_dict)["wind_farm"]["power_reference"] == 500.0 - del test_hercules_dict["external_signals"]["wind_power_reference"] - assert interface.get_measurements(test_hercules_dict)["wind_farm"]["power_reference"] == 400.0 + test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 500.0 + test_hercules_v1_dict["external_signals"]["plant_power_reference"] = 400.0 + assert ( + interface.get_measurements(test_hercules_v1_dict)["wind_farm"]["power_reference"] == 500.0 + ) + del test_hercules_v1_dict["external_signals"]["wind_power_reference"] + assert ( + interface.get_measurements(test_hercules_v1_dict)["wind_farm"]["power_reference"] == 400.0 + ) # Reinstate original values for future tests - test_hercules_dict["external_signals"]["wind_power_reference"] = 1000.0 - test_hercules_dict["external_signals"]["plant_power_reference"] = 1000.0 + test_hercules_v1_dict["external_signals"]["wind_power_reference"] = 1000.0 + test_hercules_v1_dict["external_signals"]["plant_power_reference"] = 1000.0 + -def test_HerculesHybridADInterface(): - interface = HerculesV1HybridADInterface(hercules_dict=test_hercules_dict) +def test_HerculesHybridADInterface(test_hercules_v1_dict): + interface = HerculesV1HybridADInterface(hercules_dict=test_hercules_v1_dict) # Test get_measurements() - measurements = interface.get_measurements(hercules_dict=test_hercules_dict) + measurements = interface.get_measurements(hercules_dict=test_hercules_v1_dict) - assert measurements["time"] == test_hercules_dict["time"] + assert measurements["time"] == test_hercules_v1_dict["time"] assert ( measurements["wind_farm"]["turbine_powers"] - == test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] + == test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] ) assert ( measurements["wind_farm"]["wind_speed"] - == test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["wind_speed"] + == test_hercules_v1_dict["hercules_comms"]["amr_wind"]["test_farm"]["wind_speed"] ) assert ( measurements["wind_farm"]["power_reference"] - == test_hercules_dict["external_signals"]["wind_power_reference"] + == test_hercules_v1_dict["external_signals"]["wind_power_reference"] ) assert ( measurements["battery"]["power"] - == -test_hercules_dict["py_sims"]["test_battery"]["outputs"]["power"] + == -test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"]["power"] ) assert ( measurements["solar_farm"]["power"] - == test_hercules_dict["py_sims"]["test_solar"]["outputs"]["power_mw"] * 1000 + == test_hercules_v1_dict["py_sims"]["test_solar"]["outputs"]["power_mw"] * 1000 ) assert ( measurements["solar_farm"]["direct_normal_irradiance"] - == test_hercules_dict["py_sims"]["test_solar"]["outputs"]["dni"] + == test_hercules_v1_dict["py_sims"]["test_solar"]["outputs"]["dni"] ) assert ( measurements["solar_farm"]["angle_of_incidence"] - == test_hercules_dict["py_sims"]["test_solar"]["outputs"]["aoi"] + == test_hercules_v1_dict["py_sims"]["test_solar"]["outputs"]["aoi"] ) test_forecast = { - k: v for k, v in test_hercules_dict["external_signals"].items() if "forecast" in k + k: v for k, v in test_hercules_v1_dict["external_signals"].items() if "forecast" in k } assert measurements["forecast"] == test_forecast @@ -180,7 +154,7 @@ def test_HerculesHybridADInterface(): # Test send_controls() test_hercules_dict_out = interface.send_controls( - hercules_dict=test_hercules_dict, **controls_dict + hercules_dict=test_hercules_v1_dict, **controls_dict ) assert ( @@ -197,44 +171,44 @@ def test_HerculesHybridADInterface(): ) assert ( measurements["hydrogen"]["power_reference"] - == test_hercules_dict["external_signals"]["hydrogen_reference"] + == test_hercules_v1_dict["external_signals"]["hydrogen_reference"] ) assert ( measurements["hydrogen"]["production_rate"] - == test_hercules_dict["py_sims"]["test_hydrogen"]["outputs"]["H2_mfr"] + == test_hercules_v1_dict["py_sims"]["test_hydrogen"]["outputs"]["H2_mfr"] ) with pytest.raises(TypeError): # Bad kwarg - interface.send_controls(test_hercules_dict, **bad_controls_dict) + interface.send_controls(test_hercules_v1_dict, **bad_controls_dict) -def test_HerculesBatteryInterface(): - interface = HerculesV1BatteryInterface(hercules_dict=test_hercules_dict) +def test_HerculesBatteryInterface(test_hercules_v1_dict): + interface = HerculesV1BatteryInterface(hercules_dict=test_hercules_v1_dict) # Check instantiation with no battery raises and error - temp = test_hercules_dict["py_sims"].pop("test_battery") + temp = test_hercules_v1_dict["py_sims"].pop("test_battery") with pytest.raises(ValueError): - _ = HerculesV1BatteryInterface(hercules_dict=test_hercules_dict) + _ = HerculesV1BatteryInterface(hercules_dict=test_hercules_v1_dict) # Reinstate and add second battery; test that 2 batteries causes error - test_hercules_dict["py_sims"]["test_battery"] = temp - test_hercules_dict["py_sims"]["test_battery_2"] = temp + test_hercules_v1_dict["py_sims"]["test_battery"] = temp + test_hercules_v1_dict["py_sims"]["test_battery_2"] = temp with pytest.raises(ValueError): - _ = HerculesV1BatteryInterface(hercules_dict=test_hercules_dict) - test_hercules_dict["py_sims"].pop("test_battery_2") + _ = HerculesV1BatteryInterface(hercules_dict=test_hercules_v1_dict) + test_hercules_v1_dict["py_sims"].pop("test_battery_2") # Test get_measurements() - measurements = interface.get_measurements(hercules_dict=test_hercules_dict) + measurements = interface.get_measurements(hercules_dict=test_hercules_v1_dict) assert ( measurements["battery"]["power"] - == -test_hercules_dict["py_sims"]["test_battery"]["outputs"]["power"] + == -test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"]["power"] ) assert ( measurements["battery"]["state_of_charge"] - == test_hercules_dict["py_sims"]["test_battery"]["outputs"]["soc"] + == test_hercules_v1_dict["py_sims"]["test_battery"]["outputs"]["soc"] ) assert ( measurements["battery"]["power_reference"] - == test_hercules_dict["external_signals"]["plant_power_reference"] + == test_hercules_v1_dict["external_signals"]["plant_power_reference"] ) # Test check_controls() @@ -251,12 +225,12 @@ def test_HerculesBatteryInterface(): # Test send_controls() test_hercules_dict_out = interface.send_controls( - hercules_dict=test_hercules_dict, **controls_dict + hercules_dict=test_hercules_v1_dict, **controls_dict ) assert ( test_hercules_dict_out["py_sims"]["inputs"]["battery_signal"] == -controls_dict["power_setpoint"] ) # defaults to zero - test_hercules_dict_out = interface.send_controls(hercules_dict=test_hercules_dict) + test_hercules_dict_out = interface.send_controls(hercules_dict=test_hercules_v1_dict) assert test_hercules_dict_out["py_sims"]["inputs"]["battery_signal"] == 0 diff --git a/tests/wake_steering_design_test.py b/tests/wake_steering_design_test.py index 3075bdfc..592b2bd0 100644 --- a/tests/wake_steering_design_test.py +++ b/tests/wake_steering_design_test.py @@ -1,5 +1,3 @@ -from pathlib import Path - import numpy as np import pytest from floris import FlorisModel @@ -15,26 +13,24 @@ get_yaw_angles_interpolant, ) -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 = {}, - ): - - fmodel_test = FlorisModel(YAML_INPUT) + floris_dictionary, + 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(floris_dictionary) if wd_std is None: return build_simple_wake_steering_lookup_table( @@ -67,8 +63,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(floris_dict): # Start with the simple case wd_resolution = 4.0 wd_min = 220.0 @@ -82,6 +78,7 @@ def test_build_simple_wake_steering_lookup_table(): minimum_yaw_angle = -20 maximum_yaw_angle = 20 df_opt = generic_df_opt( + floris_dict, wd_resolution=wd_resolution, wd_min=wd_min, wd_max=wd_max, @@ -93,15 +90,14 @@ def test_build_simple_wake_steering_lookup_table(): ti_max=ti_max, ) - - df_opt = generic_df_opt() + df_opt = generic_df_opt(floris_dict) 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,8 +107,9 @@ 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( + floris_dict, wd_resolution=wd_resolution, wd_min=wd_min, wd_max=wd_max, @@ -129,9 +126,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,8 +137,9 @@ 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( + floris_dict, wd_resolution=wd_resolution, wd_min=wd_min, wd_max=wd_max, @@ -149,12 +147,12 @@ 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(floris_dict): + 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) + df_opt_simple = generic_df_opt(floris_dict, wd_std=None, maximum_yaw_angle=max_yaw_angle) + df_opt_uncertain = generic_df_opt(floris_dict, wd_std=3.0, maximum_yaw_angle=max_yaw_angle) max_offset_simple = df_opt_simple.yaw_angles_opt.apply(lambda x: np.max(x)).max() max_offset_uncertain = df_opt_uncertain.yaw_angles_opt.apply(lambda x: np.max(x)).max() @@ -162,32 +160,32 @@ def test_build_uncertain_wake_steering_lookup_table(): # Check that kwargs are passed correctly (results not identical) df_opt_uncertain_fixed = generic_df_opt( + floris_dict, 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(): + +def test_apply_static_rate_limits(floris_dict): eps = 1e-4 wd_resolution = 4 ws_resolution = 0.5 ti_resolution = 0.01 df_opt = generic_df_opt( + floris_dict, wd_resolution=wd_resolution, ws_resolution=ws_resolution, - ti_resolution=ti_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,29 +193,29 @@ 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(floris_dict): ws_specified = 8.0 ws_wake_steering_cut_out = 13.0 ws_wake_steering_fully_engaged_high = 10.0 - df_opt_single_ws = generic_df_opt(ws_min=ws_specified, ws_max=ws_specified) + df_opt_single_ws = generic_df_opt(floris_dict, ws_min=ws_specified, ws_max=ws_specified) df_opt_ramps = apply_wind_speed_ramps( df_opt_single_ws, @@ -229,32 +227,39 @@ 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(): - df_opt = generic_df_opt() +def test_wake_steering_interpolant(floris_dict): + df_opt = generic_df_opt(floris_dict) yaw_interpolant = get_yaw_angles_interpolant(df_opt) @@ -268,24 +273,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 + # Check wrapping works (includes 0 degree wind direction) + df_0_270 = generic_df_opt(floris_dict, wd_min=0.0, wd_max=270.0, wd_resolution=10.0) 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,55 +302,56 @@ def test_wake_steering_interpolant(): with pytest.raises(ValueError): _ = yaw_interpolant(361.0, 8.0, 0.06) -def test_hysteresis_zones(): - df_opt = generic_df_opt() +def test_hysteresis_zones(floris_dict): + df_opt = generic_df_opt(floris_dict) 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) assert hysteresis_dict_test == hysteresis_dict_base # Check angle wrapping works (runs through) - df_opt = generic_df_opt(wd_min=0.0, wd_max=360.0) + df_opt = generic_df_opt(floris_dict, wd_min=0.0, wd_max=360.0) hysteresis_dict_test = compute_hysteresis_zones(df_opt, min_zone_width=min_zone_width) assert hysteresis_dict_test["T000"] == hysteresis_dict_base["T000"] # Limited wind directions that span 360/0 \ - df_opt_2 = generic_df_opt() + df_opt_2 = generic_df_opt(floris_dict) 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) + df_opt = generic_df_opt(floris_dict, wd_min=0.0, wd_max=300.0) hysteresis_dict_test = compute_hysteresis_zones(df_opt, min_zone_width=min_zone_width) assert hysteresis_dict_test["T000"] == hysteresis_dict_base["T000"] # Check nonzero low end, 360 upper end - df_opt = generic_df_opt(wd_min=200.0, wd_max=360.0) + df_opt = generic_df_opt(floris_dict, wd_min=200.0, wd_max=360.0) hysteresis_dict_test = compute_hysteresis_zones(df_opt, min_zone_width=min_zone_width) assert hysteresis_dict_test["T000"] == hysteresis_dict_base["T000"] # Close to zero low end, 360 upper end - df_opt = generic_df_opt(wd_min=2.0, wd_max=360.0) + df_opt = generic_df_opt(floris_dict, wd_min=2.0, wd_max=360.0) _ = compute_hysteresis_zones(df_opt) # Check grouping of regions by reducing yaw rate threshold - df_opt = generic_df_opt() + df_opt = generic_df_opt(floris_dict) 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 +362,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 +400,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,20 +436,27 @@ 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(floris_dict): # Pass tests - df_opt = generic_df_opt() + df_opt = generic_df_opt(floris_dict) check_df_opt_ordering(df_opt) # Remove a row so that not all data is present