diff --git a/.github/workflows/continuous-integration-workflow.yaml b/.github/workflows/continuous-integration-workflow.yaml index 02744962..9dc0d403 100644 --- a/.github/workflows/continuous-integration-workflow.yaml +++ b/.github/workflows/continuous-integration-workflow.yaml @@ -7,10 +7,10 @@ jobs: runs-on: ${{ matrix.os }} strategy: - fail-fast: false matrix: python-version: ["3.9", "3.10", "3.11"] os: [ubuntu-latest] + fail-fast: False steps: - uses: actions/checkout@v3 @@ -22,6 +22,11 @@ jobs: run: | python -m pip install --upgrade pip pip install -e ".[develop]" + # - uses: pre-commit/action@v3.0.0 + - name: Run ruff + run: | + ruff . + ruff format - name: Run tests and collect coverage run: | # -rA displays the captured output for all tests after they're run diff --git a/.gitignore b/.gitignore index c85b43b1..2776dcf9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,12 @@ data.db *.DS_Store slices +# Data files +*.csv +*.log +examples/*/logstandin +examples/*/loghercules + # macOS files .DS_Store @@ -14,5 +20,6 @@ slices .idea .vscode - +# Outputs +*echo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..35eed70e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ + +repos: + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-executables-have-shebangs + - id: check-yaml + args: [--unsafe] + - id: check-merge-conflict + - id: check-symlinks + - id: mixed-line-ending + +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.1.7 + hooks: + # Run the linter. + - id: ruff + types_or: [ python, pyi, jupyter ] + args: [ --fix ] + # Run the formatter. + - id: ruff-format + types_or: [ python, pyi, jupyter ] diff --git a/README.md b/README.md index b66bec65..c3dbce90 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,5 @@ controllers, including: WHOC controllers will also call on design tools such as [FLORIS](https://github.com/NREL/floris). -## Code development -To contribute to WHOC, please consider forking the main github repository, -with the [main repo](https://github.com/NREL/wind-hybrid-open-controller) as an -upstream remote. To submit a new feature or bug fix, create a new branch -in your fork and submit a pull request back to the `develop` branch in the -main repo. The pull request will be reviewed by other WHOC developers and -merged (using "squash and merge") into the `develop` branch. Periodically, -the `develop` branch will be merged into the `main` branch and a version -number will be assigned. \ No newline at end of file +Documentation for WHOC can be found on +[nrel.github.io](https://nrel.github.io/wind-hybrid-open-controller/intro.html) \ No newline at end of file diff --git a/demo_main.py b/demo_main.py new file mode 100644 index 00000000..53c0ead3 --- /dev/null +++ b/demo_main.py @@ -0,0 +1,68 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +import matplotlib.pyplot as plt +import numpy as np +from whoc.controllers.wake_steering_actuatordisk_standin import WakeSteeringADStandin +from whoc.interfaces.hercules_actuator_disk_yaw_interface import HerculesADYawInterface + +demo_hercules_dict = { + "dt": 1.0, + "hercules_comms": { + "amr_wind": { + "wind_farm_0": { + "type": "amr_wind_local", + "amr_wind_input_file": "amr_input.inp", + } + } + }, + "controller": {"num_turbines": 2, "initial_conditions": {"yaw": [10.0, 15.0]}}, +} + +interface = HerculesADYawInterface(demo_hercules_dict) + +controller = WakeSteeringADStandin(interface, demo_hercules_dict) + +# Create a little loop to demonstrate how the controller works +wd_base = np.linspace(280, 300, 50) +np.random.seed(0) +wind_dir = np.tile(wd_base, (2, 1)).T + np.random.normal(scale=5.0, size=(len(wd_base), 2)) + +yaw_angles = [] +for i in range(wind_dir.shape[0]): + demo_hercules_dict["hercules_comms"]["amr_wind"]["wind_farm_0"][ + "turbine_wind_directions" + ] = wind_dir[i, :] + demo_hercules_dict["hercules_comms"]["amr_wind"]["wind_farm_0"]["turbine_wind_speeds"] = 8 + demo_hercules_dict["hercules_comms"]["amr_wind"]["wind_farm_0"]["turbine_powers"] = 2000 + demo_hercules_dict["time"] = float(i) + + demo_hercules_dict = controller.step(hercules_dict=demo_hercules_dict) + + yaw_angles.append( + demo_hercules_dict["hercules_comms"]["amr_wind"]["wind_farm_0"]["turbine_yaw_angles"] + ) + +yaw_angles = np.array(yaw_angles) +fig, ax = plt.subplots(2, 1, sharex=True, sharey=True) +for i in range(2): + ax[i].plot(range(wind_dir.shape[0]), wind_dir[:, i], color="C0", label="wd") + ax[i].plot(range(wind_dir.shape[0]), yaw_angles[:, i], color="black", label="yaw stpt") + ax[i].set_ylabel("Direction, T{0} [deg]".format(i)) + ax[i].grid() +ax[1].set_xlabel("Time") +ax[1].set_xlim([0, wind_dir.shape[0]]) +ax[0].legend() + +plt.show() diff --git a/docs/_config.yml b/docs/_config.yml index 5be837e7..e63c6699 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -33,7 +33,7 @@ html: use_issues_button: true use_repository_button: true use_edit_page_button: true - google_analytics_id: G-XXXX TODO: Update when public + google_analytics_id: G-BJZY823DHG # Sphinx for API doc generation diff --git a/docs/_toc.yml b/docs/_toc.yml index fef9c30d..66faf4a1 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -3,14 +3,15 @@ format: jb-book root: intro -# parts: -# - caption: Installation -# chapters: -# - file: install_instructions -# - file: install_old -# - file: install_on_kestrel -# - file: install_spack -# - caption: Usage -# chapters: -# - file: examples +parts: + - caption: General information + chapters: + - file: install_instructions + - file: code_development + - caption: Controllers + chapters: + - file: controllers + - caption: Interfaces + chapters: + - file: interfaces # - file: order_of_op diff --git a/docs/code_development.md b/docs/code_development.md new file mode 100644 index 00000000..124de22a --- /dev/null +++ b/docs/code_development.md @@ -0,0 +1,23 @@ +# Code development +To contribute to WHOC, please consider forking the main github repository, +with the [NREL repo](https://github.com/NREL/wind-hybrid-open-controller) as an +upstream remote. See the [Installation instructions](install_instructions) +for details about how to set up your repository as a developer. + +To submit a new feature or bug fix, create a new branch +in your fork and submit a pull request back to the `develop` branch in the +main repo. The pull request will be reviewed by other WHOC maintainers and +merged (using "squash and merge") into the `develop` branch. Periodically, +the `develop` branch will be merged into the `main` branch and a version +number will be assigned. + +Unless an existing controller or interface exist to suit your needs, most +users will need to generate: +- A new interface class inheriting from `InterfaceBase` +- A new controller class, implementing the desired control algorithm and +inheriting from `ControllerBase` + +Additionally, if you'd like to contribute to this base repository, please +include in your pull request: +- Unit tests for the implemented controller +- Possibly unit tests for the implemented interface, if needed \ No newline at end of file diff --git a/docs/controllers.md b/docs/controllers.md new file mode 100644 index 00000000..a53009b5 --- /dev/null +++ b/docs/controllers.md @@ -0,0 +1,18 @@ +# Controllers + +The `whoc.controllers` module contains a library of wind and hybrid power plant +controllers. Each controller must inherit from `ControllerBase` (see +controller_base.py) and implement a +mandatory `compute_controls()` method, which contains the relevant control +algorithm and writes final control signals to the `controls_dict` attribute +as key-value pairs. `compute_controls()` is, in turn, called in the `step()` +method of `ControllerBase`. + +## Available controllers + +### WakeSteeringADStandin +For yaw controller of actuator disk-type turbines (as a stand-in, will be +updated). + +### WakeSteeringROSCOStandin +May be combined into a universal simple wake steeringcontroller. diff --git a/docs/graphics/main_attribution_inheritance.png b/docs/graphics/main_attribution_inheritance.png new file mode 100644 index 00000000..651ed3de Binary files /dev/null and b/docs/graphics/main_attribution_inheritance.png differ diff --git a/docs/graphics/second-level_attribution_inheritance.png b/docs/graphics/second-level_attribution_inheritance.png new file mode 100644 index 00000000..29b634a4 Binary files /dev/null and b/docs/graphics/second-level_attribution_inheritance.png differ diff --git a/docs/install_instructions.md b/docs/install_instructions.md new file mode 100644 index 00000000..ec284b0b --- /dev/null +++ b/docs/install_instructions.md @@ -0,0 +1,46 @@ +# Installation + +WHOC is _not_ designed to be used as a stand-alone package. Most likely, +you'll want to add WHOC to an existing conda environment that contains your +simulation testbed, such as [Hercules](https://github.com/NREL/hercules). +For example, see the [Hercules installation instuctions](\ +https://nrel.github.io/hercules/install_instructions.html) for how to set up +an appropriate conda environment. + +## General users + +If you intend to use WHOC, but not contribute, the following lines should +be sufficient to install WHOC (presumably, after activating your conda +environment): + +``` +git clone https://github.com/NREL/wind-hybrid-open-controller +pip install wind-hybrid-open-controller/ +``` + +## Developers + +If you intend to contribute to WHOC, we request that your fork the WHOC +repository on github. You can then install WHOC (again, assuming you have +already activated your conda environment) according to: + +``` +git clone https://github.com/your-github-id/wind-hybrid-open-controller +pip install -e "wind-hybrid-open-controller/[develop]" +``` +To contribute back to the base repository +https://github.com/NREL/wind-hybrid-open-controller, 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 +a description of the new/updated capabilities +- The maintainers will review your pull request and provide feedback before +possibly merging the pull request (via the "squash and merge" method) into the +`NREL:develop` branch +- At the next release, `NREL:develop` will be merged into `NREL:main`, and your changes +contributions will appear there + +For more information on what your pull request should contain, see +[Code development](code_development.md). + + diff --git a/docs/interfaces.md b/docs/interfaces.md new file mode 100644 index 00000000..a85035df --- /dev/null +++ b/docs/interfaces.md @@ -0,0 +1,33 @@ +# Interfaces + +The `whoc.interfaces` module contains a library of interfaces for connecting +WHOC with various simulation platforms and other repositories. Each controller +run will require an `interface`, which is an instantiated object of a class +in this library. All interface classes should inherit from `InterfaceBase`, +which can be found n interface_base.py, and should implement three methods: +- `get_measurements()`: Recieve measurements from simulation assets and +organize into a dictionary that the calling controller can utilize. Optionally, +receives a large dictionary (for example, the Hercules `main_dict`), from which +useable measurements can be extracted/repackaged for easy use in the controller. +- `check_controls()`: Check that the keys in `controls_dict` are viable for +the receiving plant. +- `send_controls()`: Send controls to the simulation assets. Controls are +created as specific keyword arguements, which match those controls generated +by the calling controller. Optionally, receives a large dictionary +(for example, the Hercules `main_dict`), which can be written to and returned +with controls as needed. + +These methods will all be called in the `step()` method of `ControllerBase`. + +## Available interfaces + +### HerculesADYawInterface +For direct python communication with Hercules. This should be instantiated +in a runscript that is running Hercules; used to generate a `controller` from +the WHOC controllers submodule; and that `controller` should be passed to the +Hercules `Emulator` upon its instantiation. + +### ROSCO_ZMQInterface +For sending and receiving communications from one or more ROSCO instances +(which are likely connected to OpenFAST and FAST.Farm). Uses ZeroMQ to pass +messages between workers. diff --git a/docs/intro.md b/docs/intro.md index 8c119e39..8cbe1f98 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -1,7 +1,36 @@ # Wind Hybrid Open Controller (WHOC) -Wind Hybrid Open Controller +The Wind Hybrid Open Controller (WHOC) is a python-based tool for real-time +plant-level wind farm control and wind-based hybrid plant control. +WHOC will primarily be run in simulation, although we intend that it could be +used for physical plants in future. -Online documentation for Wind Hybrid Open Controller +The graphic below demonstrates the organization of the WHOC repository and +the controller objects that it contains. +![](graphics/main_attribution_inheritance.png) -More info \ No newline at end of file +`ControllerBase` is the core underlying class, which newly implemented +controllers should inherit from. Inheritance is shown using arrows in the +diagram above. The key method of `ControllerBase` is the `step()` method, +which progresses the controller by receiving plant measurements; computing +controls (which must be implemented in the children of +`ControllerBase`); and sending the controls back to the plant. Children of +`ControllerBase` should inherit `step()` rather than overloading it. +Additionally, on instantiation, `ControllerBase` expects to receive an +instantiated `interface` object (discussed next). For information can be +found in controllers.md. + +The `interface` object handles communications with the plant simulator, +allowing WHOC to be used with various simulation platforms (e.g. Hercules, +FAST.Farm) while keeping the controllers agnostic to the simulation platform +and the boilderplate code needed to handle different platforms. `interface` +objects should inherit from `InterfaceBase`. More information can be found in +interfaces.md. + + +We anticipate that, in future, multiple levels of inheritance may be useful +when defining top-level controllers that operate hybrid power plants with +various different assets. This situation is shown below, and we intend to +support such usage of WHOC. + +![](graphics/second-level_attribution_inheritance.png) \ No newline at end of file diff --git a/examples/simple_yaw_control_amrstandin/amr_input.inp b/examples/simple_yaw_control_amrstandin/amr_input.inp new file mode 100755 index 00000000..6d41e8e9 --- /dev/null +++ b/examples/simple_yaw_control_amrstandin/amr_input.inp @@ -0,0 +1,173 @@ +#¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨# +# SIMULATION STOP # +#.......................................# +time.stop_time = 100.0 # Max (simulated) time to evolve +time.max_step = -1 # Max number of time steps + +#¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨# +# TIME STEP COMPUTATION # +#.......................................# +time.fixed_dt = 0.5 # Use this constant dt if > 0 +time.cfl = 0.95 # CFL factor + +#¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨# +# INPUT AND OUTPUT # +#.......................................# +time.plot_interval = 3600 # Steps between plot files +time.checkpoint_interval = 3600 # Steps between checkpoint files +io.restart_file = "/projects/ssc/amr_precursors/b_abl_neutral_lowTI_redo/chk14400" + +#¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨# +# PHYSICS # +#.......................................# +incflo.gravity = 0. 0. -9.81 # Gravitational force (3D) +incflo.density = 1.0 # Reference density + +incflo.use_godunov = 1 +incflo.godunov_type = weno_z +incflo.diffusion_type = 1 +transport.viscosity = 1.0e-5 +transport.laminar_prandtl = 0.7 +transport.turbulent_prandtl = 0.3333 +turbulence.model = OneEqKsgsM84 + +incflo.physics = ABL Actuator +ICNS.source_terms = BoussinesqBuoyancy CoriolisForcing ABLMeanBoussinesq ActuatorForcing +TKE.source_terms = KsgsM84Src +BoussinesqBuoyancy.reference_temperature = 300.0 +CoriolisForcing.latitude = 41.3 +ABLForcing.abl_forcing_height = 90 +incflo.velocity = 6.928203230275509 4.0 0.0 + + +# Atmospheric boundary layer +ABL.temperature_heights = 0.0 700.0 800.0 1280.0 +ABL.temperature_values = 300.0 300.0 308.0 309.44 +ABL.reference_temperature = 300.0 +ABL.kappa = .40 +ABL.surface_roughness_z0 = 1.0E-4 +ABL.Uperiods = 25.0 +ABL.Vperiods = 25.0 +ABL.cutoff_height = 50.0 +ABL.deltaU = 1.0 +ABL.deltaV = 1.0 +ABL.normal_direction = 2 +ABL.perturb_ref_height = 50.0 +ABL.perturb_temperature = false +ABL.perturb_velocity = true +ABL.stats_output_format = netcdf +ABL.stats_output_frequency = 1 +ABL.surface_temp_flux = 0.00 +ABL.wall_shear_stress_type = "Moeng" + +ABL.bndry_file = "/projects/ssc/amr_precursors/b_abl_neutral_lowTI_redo/bndry_files" +ABL.bndry_io_mode = 1 #Input mode +ABL.bndry_planes = ylo xlo # I'm (Paul) adding this but not sure if I have to +ABL.bndry_var_names = velocity temperature tke + + +# Output boundary files +ABL.bndry_planes = ylo xlo +ABL.bndry_output_start_time = 7200.0 +ABL.bndry_var_names = velocity temperature tke +ABL.bndry_output_format = native +ABL.stats_output_frequency = 1 +ABL.stats_output_format = netcdf + +# Whether to use helics +helics.activated = true +helics.broker_port = 32000 + +#¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨# +# ADAPTIVE MESH REFINEMENT # +#.......................................# +amr.n_cell = 512 512 128 # Grid cells at coarsest AMRlevel +amr.max_level = 0 # Max AMR level in hierarchy + +#¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨# +# GEOMETRY # +#.......................................# +geometry.prob_lo = 0. 0. 0. # Lo corner coordinates +geometry.prob_hi = 5120. 5120. 1280. # Hi corner coordinates +geometry.is_periodic = 0 0 0 +xlo.type = "mass_inflow" +xlo.density = 1.0 +xlo.temperature = 0.0 # value required but ignored +xlo.tke = 0.0 +xhi.type = "pressure_outflow" + +ylo.type = "mass_inflow" +ylo.density = 1.0 +ylo.temperature = 0.0 +ylo.tke = 0.0 +yhi.type = "pressure_outflow" + +# Boundary conditions +zlo.type = "wall_model" +zlo.tke_type = "zero_gradient" + +zhi.type = "slip_wall" +zhi.temperature_type = "fixed_gradient" +zhi.temperature = 0.003 # tracer is used to specify potential temperature gradient + +#¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨# +# VERBOSITY # +#.......................................# +incflo.verbose = 0 # incflo_level + + + +#¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨# +# SAMPLING # +#.......................................# +incflo.post_processing = samplingPlane samplingLine + +samplingPlane.output_frequency = 600 +samplingPlane.labels = z_plane +samplingPlane.fields = velocity temperature +samplingPlane.z_plane.type = PlaneSampler +samplingPlane.z_plane.axis1 = 5110 0.0 0.0 +samplingPlane.z_plane.axis2 = 0.0 5110 0.0 +samplingPlane.z_plane.origin = 5.0 5.0 0.0 +samplingPlane.z_plane.num_points = 512 512 +samplingPlane.z_plane.normal = 0.0 0.0 1.0 +samplingPlane.z_plane.offsets = 5.0 85.0 155.0 255.0 + + +samplingLine.output_frequency = 1 +samplingLine.labels = z_line +samplingLine.fields = velocity temperature +samplingLine.z_line.type = LineSampler +samplingLine.z_line.num_points = 128 +samplingLine.z_line.start = 5.0 5.0 5.0 +samplingLine.z_line.end = 5.0 5.0 1275.0 + +#¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨# +# TURBINES # +#.......................................# + +# 2.3 MW Turbine inputs + +Actuator.labels = T00 T01 +Actuator.type = JoukowskyDisk +Actuator.JoukowskyDisk.rotor_diameter = 116.0 +Actuator.JoukowskyDisk.hub_height = 90.0 +Actuator.JoukowskyDisk.output_frequency = 10 +Actuator.JoukowskyDisk.diameters_to_sample = 2.5 +Actuator.JoukowskyDisk.num_points_r = 40 +Actuator.JoukowskyDisk.num_points_t = 5 +Actuator.JoukowskyDisk.num_blades = 3 +Actuator.JoukowskyDisk.use_tip_correction = true +Actuator.JoukowskyDisk.use_root_correction = true +Actuator.JoukowskyDisk.epsilon = 5.0 +Actuator.JoukowskyDisk.vortex_core_size = 13.0 + +Actuator.JoukowskyDisk.wind_speed = 3.0 3.889649963239854 4.684006996752303 5.377830233987229 5.966542092267928 6.44625847394617 6.8138143922059236 7.066784852446481 7.203500851477444 7.22306038896904 7.320786359429763 7.535153078939617 7.864746237154081 8.30739130337076 8.860167873258558 9.519428936578247 10.280824938773394 10.589724312062877 11.13933247768231 12.08928744604103 13.12442240111568 14.237907914913496 15.422397632159566 16.670076738763772 17.972713521001396 19.321713675239476 20.708177009893884 22.122956165519163 23.556716965618207 25.0 +Actuator.JoukowskyDisk.rpm = 5.500000000399841 5.7503730274604 6.924733009169061 7.950466035772244 8.820804709636782 9.530006866155707 10.073393458373337 10.447379427563192 10.649497356086282 10.678413746477254 10.82288967359941 11.139804720841314 11.627068002870239 12.28146474128283 13.098677474494233 13.141137992834643 13.141137992834643 13.141137992834643 13.141137992834643 13.141137992834643 13.141137992834643 13.141137992834643 13.141137992834643 13.141137992834643 13.141137992834643 13.141137992834643 13.141137992834643 13.141137992834643 13.141137992834643 13.141137992834643 +Actuator.JoukowskyDisk.thrust_coeff = 0.795419507524108 0.8163759621542088 0.8163759621542089 0.8163759621542088 0.8163759621542088 0.8163759621542093 0.8163759621542093 0.816375962154209 0.8163759621542089 0.816375962154209 0.8163759621542089 0.8163759621542088 0.8163759621542088 0.7816497292837605 0.6881024487256834 0.5960935792514858 0.5110705883010171 0.48169007343985104 0.3992418445397665 0.301726241148816 0.23137184415660814 0.17948146484830918 0.1408250546606467 0.11178366795199553 0.08975645587417404 0.07310080594700819 0.060299514659720915 0.0504505646442757 0.04286514300370846 0.03692885272686376 + + +Actuator.JoukowskyDisk.yaw = 240.0 + +Actuator.T00.base_position = 2000.0 2000.0 0.0 +Actuator.T01.base_position = 2500.0 2500.0 0.0 diff --git a/examples/simple_yaw_control_amrstandin/bash_script_amr_standin.sh b/examples/simple_yaw_control_amrstandin/bash_script_amr_standin.sh new file mode 100755 index 00000000..4de837dd --- /dev/null +++ b/examples/simple_yaw_control_amrstandin/bash_script_amr_standin.sh @@ -0,0 +1,21 @@ +# Example bash for running things locally +# I just run these one at a t time + +# A lot of modules and conda stuff +conda activate hercules + +export HELICS_PORT=32000 + +# Set up the helics broker +helics_broker -t zmq -f 2 --loglevel="debug" --local_port=$HELICS_PORT & + +# Need to set this to your emu_python folder +# cd /home/pfleming/emu_python/emu_python +python hercules_runscript.py hercules_input_shortsim.yaml >> loghercules 2>&1 & # Start the controller center and pass in input file + + +python3 hercules_runscript_amr_standin.py amr_input.inp >> logstandin 2>&1 +# Now go back to scratch folder and launch the job + +# cd /scratch/pfleming/c2c/example_sim_02 +# mpirun -n 72 /home/pfleming/amr-wind/build/amr_wind amr_input.inp >> logamr \ No newline at end of file diff --git a/examples/simple_yaw_control_amrstandin/hercules_input_shortsim.yaml b/examples/simple_yaw_control_amrstandin/hercules_input_shortsim.yaml new file mode 100644 index 00000000..0d45a06a --- /dev/null +++ b/examples/simple_yaw_control_amrstandin/hercules_input_shortsim.yaml @@ -0,0 +1,66 @@ +# Input YAML for emy_python + +# Name +name: example_000 + +### +# Describe this emulator setup +description: Just a solar plant + +dt: 0.5 + +hercules_comms: + + amr_wind: + + wind_farm_0: + type: amr_wind_local #options are amr_wind or amr_wind_local + amr_wind_input_file: amr_input.inp + yaw_simulator_name: yaw_system_0 # can also use "none" (without quotes) + + helics: + + config: + name: hercules # What is the purpose of this name + use_dash_frontend: False + KAFKA: False + KAFKA_topics: EMUV1py + helics: + # deltat: 1 # This will be assigned in software + subscription_topics: [status] + publication_topics: [control] + endpoints: [] + helicsport : 32000 + publication_interval: 1 + endpoint_interval: 1 + starttime: 0 + stoptime: 100 + + Agent: ControlCenter + +py_sims: + + solar_farm_0: # The name of py_sim object 1 + + py_sim_type: SimpleSolar + capacity: 50 # MW + efficiency: 0.5 #Fraction + + initial_conditions: + + power: 25 # MW + irradiance: 1000 + +controller: + + controller_type: SimpleYawController # This may not be needed + num_turbines: 2 # Should match AMR-Wind! Ideally, would come from AMR-wind + initial_conditions: + yaw: 270. # degrees (same for all turbines) (will this work?) + + + + + + + diff --git a/examples/simple_yaw_control_amrstandin/hercules_runscript.py b/examples/simple_yaw_control_amrstandin/hercules_runscript.py new file mode 100644 index 00000000..8181fe53 --- /dev/null +++ b/examples/simple_yaw_control_amrstandin/hercules_runscript.py @@ -0,0 +1,34 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +import sys + +from hercules.emulator import Emulator +from hercules.py_sims import PySims +from hercules.utilities import load_yaml +from whoc.controllers.wake_steering_actuatordisk_standin import WakeSteeringADStandin +from whoc.interfaces.hercules_actuator_disk_yaw_interface import HerculesADYawInterface + +input_dict = load_yaml(sys.argv[1]) + +interface = HerculesADYawInterface(input_dict) +controller = WakeSteeringADStandin(interface, input_dict) + +py_sims = PySims(input_dict) + +emulator = Emulator(controller, py_sims, input_dict) +emulator.run_helics_setup() +emulator.enter_execution(function_targets=[], function_arguments=[[]]) + +print("runscript complete.") diff --git a/examples/simple_yaw_control_amrstandin/hercules_runscript_amr_standin.py b/examples/simple_yaw_control_amrstandin/hercules_runscript_amr_standin.py new file mode 100644 index 00000000..73e9d0c5 --- /dev/null +++ b/examples/simple_yaw_control_amrstandin/hercules_runscript_amr_standin.py @@ -0,0 +1,29 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +import sys + +from hercules.amr_wind_standin import launch_amr_wind_standin + +# Check that one command line argument was given +if len(sys.argv) != 2: + raise Exception("Usage: python hercules_runscript_amr_standin.py ") + +# # Get the first command line argument +# This is the name of the file to read +amr_input_file = sys.argv[1] +print(f"Running AMR-Wind standin with input file: {amr_input_file}") + + +launch_amr_wind_standin(amr_input_file) diff --git a/examples/simple_yaw_control_amrstandin/readme.txt b/examples/simple_yaw_control_amrstandin/readme.txt new file mode 100644 index 00000000..bffa442c --- /dev/null +++ b/examples/simple_yaw_control_amrstandin/readme.txt @@ -0,0 +1,2 @@ +This example demonstrates the use of the WakeSteeringADStandin controller +on the AMR-Wind standin. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..852712d6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +[build-system] +requires = ["setuptools >= 40.6.0", "wheel"] +build-backend = "setuptools.build_meta" + + +[coverage.run] +# Coverage.py configuration file +# https://coverage.readthedocs.io/en/latest/config.html +branch = true +source = "whoc/*" +omit = [ + "setup.py", + "tests/*" +] + + +[tool.pytest.ini_options] +testpaths = "tests" +filterwarnings = [ + "ignore::DeprecationWarning:pandas.*:" +] + + +[tool.ruff] +src = ["whoc", "tests"] +line-length = 100 +target-version = "py310" +extend-include = ["*.ipynb"] +ignore-init-module-imports = true + +# See https://github.com/charliermarsh/ruff#supported-rules +# for rules included and matching to prefix. +select = ["E", "F", "I"] + +# F401 unused-import: Ignore until all used isort flags are adopted in ruff +# ignore = ["F401"] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +# fixable = ["A", "B", "C", "D", "E", "F"] +fixable = ["E", "F", "I"] +unfixable = [] + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", +] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + + +[tool.ruff.isort] +combine-as-imports = true +known-first-party = ["flasc"] +order-by-type = false + +# [tool.ruff.format] + +[tool.ruff.per-file-ignores] +# Ignore `F401` (import violations) in all `__init__.py` files, and in `path/to/file.py`. +"__init__.py" = ["F401"] diff --git a/run_whoc.py b/run_whoc.py new file mode 100644 index 00000000..bc45fb6c --- /dev/null +++ b/run_whoc.py @@ -0,0 +1,53 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +import multiprocessing as mp + +from ROSCO_toolbox.control_interface import turbine_zmq_server +from whoc.interfaces._temp_server import sim_rosco + +""" +NOTE: this is not yet working. +""" + + +def run_zmq(): + connect_zmq = True + s = turbine_zmq_server(network_address="tcp://*:5555", timeout=10.0, verbose=True) + while connect_zmq: + # Get latest measurements from ROSCO + measurements = s.get_measurements() + + # Decide new control input based on measurements + current_time = measurements["Time"] + if current_time <= 10.0: + yaw_setpoint = 0.0 + else: + yaw_setpoint = 20.0 + + # Send new setpoints back to ROSCO + s.send_controls(nacelleHeading=yaw_setpoint) + + if measurements["iStatus"] == -1: + connect_zmq = False + s._disconnect() + + +if __name__ == "__main__": + p1 = mp.Process(target=run_zmq) + p1.start() + p2 = mp.Process(target=sim_rosco) + p2.start() + p1.join() + p2.join() diff --git a/setup.py b/setup.py index 6c9acb58..20c60e60 100644 --- a/setup.py +++ b/setup.py @@ -23,11 +23,10 @@ import os from pathlib import Path -from setuptools import setup, find_packages - +from setuptools import setup # Package meta-data. -NAME = "wind-hybrid-open-controller" +NAME = "whoc" DESCRIPTION = "Wind Hybrid Open Controller." URL = "https://github.com/NREL/wind-hybrid-open-controller" EMAIL = "michael.sinner@nrel.gov" @@ -40,23 +39,18 @@ # "matplotlib~=3.0", # "pandas~=2.0", # "dash>=2.0.0", - - #GUI Stuff + # GUI Stuff # "tkinter", # Comes with python? # "plotly==5.5.0", # "dash", # "dash-daq==0.5.0", # "scikit-image", - - #ZMQ stuff - # "zmq", - + # ZMQ stuff + "zmq", # NETCDF # "netCDF4", - - #YAML + # YAML # "pyyaml" - ] # What packages are optional? @@ -93,7 +87,7 @@ # Load the package's VERSION module ROOT = Path(__file__).parent -with open(ROOT / "wind-hybrid-open-controller" / "version.py") as version_file: +with open(ROOT / "whoc" / "version.py") as version_file: VERSION = version_file.read().strip() setup( @@ -107,7 +101,7 @@ python_requires=REQUIRES_PYTHON, url=URL, # package_dir={"": "hercules"}, - packages=find_packages( exclude=["tests", "*.tests", "*.tests.*", "tests.*"]), + packages=["whoc"], # find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]), # If your package is a single module, use this instead of 'packages': # py_modules=['mypackage'], # entry_points={ diff --git a/tests/controller_base_test.py b/tests/controller_base_test.py new file mode 100644 index 00000000..65b6ad48 --- /dev/null +++ b/tests/controller_base_test.py @@ -0,0 +1,81 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +import pytest +from whoc.controllers.controller_base import ControllerBase +from whoc.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): + """ + Class that is missing necessary methods. + """ + + def __init__(self, interface): + super().__init__(interface) + + +class InheritanceTestClassGood(ControllerBase): + """ + Class that is missing necessary methods. + """ + + def __init__(self, interface): + super().__init__(interface) + + def compute_controls(self): + pass + + +def test_ControllerBase_methods(): + """ + Check that the base interface class establishes the correct methods. + """ + test_interface = StandinInterface() + + controller_base = InheritanceTestClassGood(test_interface) + 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(): + """ + Check that a subclass of InterfaceBase inherits methods correctly. + """ + test_interface = StandinInterface() + + with pytest.raises(TypeError): + _ = InheritanceTestClassBad(test_interface) + + _ = InheritanceTestClassGood(test_interface) diff --git a/tests/controller_library_test.py b/tests/controller_library_test.py new file mode 100644 index 00000000..d51f3123 --- /dev/null +++ b/tests/controller_library_test.py @@ -0,0 +1,88 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +from whoc.controllers import ( + HerculesWindBatteryController, + WakeSteeringADStandin, +) +from whoc.interfaces import HerculesADYawInterface +from whoc.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 + + +test_hercules_dict = { + "dt": 1, + "time": 0, + "controller": {"num_turbines": 2, "initial_conditions": {"yaw": [270.0, 270.0]}}, + "hercules_comms": { + "amr_wind": { + "test_farm": { + "turbine_wind_directions": [271.0, 272.5], + "turbine_powers": [4000.0, 4001.0], + } + } + }, + "py_sims": {"test_battery": {"outputs": 10.0}}, +} + + +def test_controller_instantiation(): + """ + Tests whether all controllers can be imported correctly and that they + each implement the required methods specified by ControllerBase. + """ + test_interface = StandinInterface() + + _ = WakeSteeringADStandin(interface=test_interface, input_dict=test_hercules_dict) + _ = HerculesWindBatteryController(interface=test_interface, input_dict=test_hercules_dict) + + +def test_WakeSteeringADStandin(): + test_interface = HerculesADYawInterface(test_hercules_dict) + test_controller = WakeSteeringADStandin(interface=test_interface, input_dict=test_hercules_dict) + + # Check that the controller can be stepped + test_hercules_dict_out = test_controller.step(hercules_dict=test_hercules_dict) + assert test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"][ + "turbine_yaw_angles" + ] == [270.0, 270.0] + + test_hercules_dict["time"] = 20 + test_hercules_dict_out = test_controller.step(hercules_dict=test_hercules_dict) + assert ( + test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_yaw_angles"] + == test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_wind_directions"] + ) + + +def test_HerculesWindBatteryController(): + # TODO: write this test, possibly clean up HerculesWindBatteryController class + pass diff --git a/tests/interface_base_test.py b/tests/interface_base_test.py new file mode 100644 index 00000000..ee9fe07c --- /dev/null +++ b/tests/interface_base_test.py @@ -0,0 +1,77 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +# import inspect +import pytest +from whoc.interfaces.interface_base import InterfaceBase + +# import whoc.interfaces + + +class InheritanceTestClassBad(InterfaceBase): + """ + Class that is missing necessary methods. + """ + + def __init__(self): + super().__init__() + + +class InheritanceTestClassGood(InterfaceBase): + """ + Class that is missing necessary methods. + """ + + def __init__(self): + super().__init__() + + def get_measurements(self): + pass + + def check_controls(self): + pass + + def send_controls(self): + pass + + +def test_InterfaceBase_methods(): + """ + Check that the base interface class establishes the correct methods. + """ + interface_base = InheritanceTestClassGood() + assert hasattr(interface_base, "get_measurements") + assert hasattr(interface_base, "check_controls") + assert hasattr(interface_base, "send_controls") + + +def test_inherited_methods(): + """ + Check that a subclass of InterfaceBase inherits methods correctly. + """ + + with pytest.raises(TypeError): + _ = InheritanceTestClassBad() + + _ = InheritanceTestClassGood() + + +def test_all_interfaces_implement_methods(): + # In future, I'd like to dynamically instantiate classes, but the different + # inputs that they require on __init__ is currently a roadblock, so I'll just + # explicitly instantiate each interface class for the time being. + + # class_dict = dict(inspect.getmembers(whoc.interfaces, inspect.isclass)) + + pass diff --git a/tests/interface_library_test.py b/tests/interface_library_test.py new file mode 100644 index 00000000..b5622019 --- /dev/null +++ b/tests/interface_library_test.py @@ -0,0 +1,126 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +import pytest +from whoc.interfaces import ( + HerculesADYawInterface, + HerculesWindBatteryInterface, +) + +test_hercules_dict = { + "dt": 1, + "time": 0, + "controller": {"num_turbines": 2}, + "hercules_comms": { + "amr_wind": { + "test_farm": { + "turbine_wind_directions": [271.0, 272.5], + "turbine_powers": [4000.0, 4001.0], + } + } + }, + "py_sims": {"test_battery": {"outputs": 10.0}}, +} + + +def test_interface_instantiation(): + """ + Tests whether all interfaces can be imported correctly and that they + each implement the required methods specified by InterfaceBase. + """ + + _ = HerculesADYawInterface(hercules_dict=test_hercules_dict) + _ = HerculesWindBatteryInterface(hercules_dict=test_hercules_dict) + # _ = ROSCO_ZMQInterface() + + +def test_HerculesADYawInterface(): + interface = HerculesADYawInterface(hercules_dict=test_hercules_dict) + + # Test get_measurements() + measurements = interface.get_measurements(hercules_dict=test_hercules_dict) + + assert measurements["time"] == test_hercules_dict["time"] + assert ( + measurements["wind_directions"] + == test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_wind_directions"] + ) + assert ( + measurements["turbine_powers"] + == test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] + ) + + # Test check_controls() + controls_dict = {"yaw_angles": [270.0, 278.9]} + interface.check_controls(controls_dict) # Should not raise an error + + bad_controls_dict1 = {"yaw_angels": [270.0, 268.9]} # Misspelling + bad_controls_dict2 = { + "yaw_angles": [270.0, 268.9], + "power_setpoints": [3000.0, 3000.0], + } # Unavailable control + bad_controls_dict3 = {"yaw_angles": [270.0, 268.9, 270.0]} # Mismatched number of turbines + + with pytest.raises(ValueError): + interface.check_controls(bad_controls_dict1) + with pytest.raises(ValueError): + interface.check_controls(bad_controls_dict2) + with pytest.raises(ValueError): + interface.check_controls(bad_controls_dict3) + + # test send_controls() + test_hercules_dict_out = interface.send_controls( + hercules_dict=test_hercules_dict, **controls_dict + ) + assert ( + controls_dict["yaw_angles"] + == test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_yaw_angles"] + ) + + with pytest.raises(TypeError): # Bad kwarg + interface.send_controls(test_hercules_dict, **bad_controls_dict1) + with pytest.raises(TypeError): # Bad kwarg + interface.send_controls(test_hercules_dict, **bad_controls_dict2) + # bad_controls_dict3 would pass, but faile the check_controls step. + + +def test_HerculesWindBatteryInterface(): + interface = HerculesWindBatteryInterface(hercules_dict=test_hercules_dict) + + # Test get_measurements() + measurements = interface.get_measurements(hercules_dict=test_hercules_dict) + + assert ( + measurements["py_sims"]["battery"] + == test_hercules_dict["py_sims"]["test_battery"]["outputs"] + ) + assert ( + measurements["wind_farm"]["turbine_powers"] + == test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] + ) + assert ( + measurements["wind_farm"]["turbine_wind_directions"] + == test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_wind_directions"] + ) + + # Test check_controls() + # check_controls is pass-through + + # Test send_controls() + controls_dict = {"test": 0} + test_hercules_dict_out = interface.send_controls( + hercules_dict=test_hercules_dict, controls_dict=controls_dict + ) + + assert test_hercules_dict_out["setpoints"] == controls_dict diff --git a/tests/simple_test.py b/tests/simple_test.py deleted file mode 100644 index b48412b8..00000000 --- a/tests/simple_test.py +++ /dev/null @@ -1,9 +0,0 @@ -import unittest - -import numpy as np - -class TestSimple(unittest.TestCase): - - def test_simple(self): - - x = np.array([1]) diff --git a/whoc/__init__.py b/whoc/__init__.py new file mode 100644 index 00000000..7ef16251 --- /dev/null +++ b/whoc/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2022 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +from pathlib import Path + +ROOT = Path(__file__).parent +with open(ROOT / "version.py") as version_file: + VERSION = version_file.read().strip() +__version__ = VERSION diff --git a/whoc/controllers/__init__.py b/whoc/controllers/__init__.py new file mode 100644 index 00000000..aa53dafc --- /dev/null +++ b/whoc/controllers/__init__.py @@ -0,0 +1,3 @@ +from whoc.controllers.hercules_wind_battery_controller import HerculesWindBatteryController +from whoc.controllers.wake_steering_actuatordisk_standin import WakeSteeringADStandin +from whoc.controllers.wake_steering_rosco_standin import WakeSteeringROSCOStandin diff --git a/whoc/controllers/controller_base.py b/whoc/controllers/controller_base.py new file mode 100644 index 00000000..a8ea8d7d --- /dev/null +++ b/whoc/controllers/controller_base.py @@ -0,0 +1,78 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +from abc import ABCMeta, abstractmethod + + +class ControllerBase(metaclass=ABCMeta): + def __init__(self, interface, verbose=True): + self._s = interface + self.verbose = verbose + + # if use_helics_interface: + # raise NotImplementedError( + # "HELICS interface has not yet been implemented." + # ) + + # # TODO: eventually, this would set up a federate (with same + # # public methods as the whoc_zmq_server + # #self._s = whoc_helics_federate() + + # elif use_zmq_interface: + # from servers.zmq_server import WHOC_zmq_server + + # # TODO: set up HELICS server + # # Set up connections with each turbine + # self._s = WHOC_zmq_server(network_address="tcp://*:5555", + # timeout=timeout, verbose=True) + + # elif use_direct_hercules_connection: + # from servers.direct_hercules_connection import WHOC_AD_yaw_connection + # self._s = WHOC_AD_yaw_connection(hercules_dict) + + # else: + # from servers.python_server import WHOC_python_server + # self._s = WHOC_python_server() + + # Initialize controls to send + self.controls_dict = None + + def _receive_measurements(self, dict=None): + # May need to eventually loop here, depending on server set up. + self.measurements_dict = self._s.get_measurements(dict) + + return None + + def _send_controls(self, dict=None): + self._s.check_controls(self.controls_dict) + dict = self._s.send_controls(dict, **self.controls_dict) + + return dict # or main_dict, or what? + + def step(self, hercules_dict=None): + # If not running with direct hercules integration, + # hercules_dict may simply be None throughout this method. + self._receive_measurements(hercules_dict) + + self.compute_controls() + + hercules_dict = self._send_controls(hercules_dict) + + return hercules_dict # May simply be None. + + @abstractmethod + def compute_controls(self): + # Control algorithms should be implemented in the compute_controls + # method of the child class. + pass diff --git a/whoc/controllers/hercules_wind_battery_controller.py b/whoc/controllers/hercules_wind_battery_controller.py new file mode 100644 index 00000000..09f1664e --- /dev/null +++ b/whoc/controllers/hercules_wind_battery_controller.py @@ -0,0 +1,64 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +import numpy as np + +from whoc.controllers.controller_base import ControllerBase + + +class HerculesWindBatteryController(ControllerBase): + def __init__(self, interface, input_dict, verbose=True): + super().__init__(interface, verbose) + + self.dt = input_dict["dt"] + self.n_turbines = input_dict["controller"]["num_turbines"] + + def send_controls(self, hercules_dict): + self._s.check_controls(self.setpoints_dict) + dict = self._s.send_controls(hercules_dict, self.setpoints_dict) + + return dict # or main_dict, or what? + + def step(self, hercules_dict=None): + self._receive_measurements(hercules_dict) + # receive measurements sets self.measurements_dict + self.compute_controls() + hercules_dict = self.send_controls(hercules_dict) + + return hercules_dict + + def compute_controls(self): + # set self.controls_dict + + # calc wind setpoints + wind_setpoints = self.calc_wind_setpoints() + battery_setpoints = self.calc_battery_setpoints() + + self.setpoints_dict = {"wind": wind_setpoints, "battery": battery_setpoints} + + return None + + def calc_wind_setpoints(self): + wind_setpoints = {} + return wind_setpoints + + def calc_battery_setpoints(self): + available_power = np.sum(self.measurements_dict["wind_farm"]["turbine_powers"]) + if available_power <= 1000: + signal = available_power + else: + signal = -500 + + battery_setpoints = {"signal": signal} + return battery_setpoints diff --git a/whoc/controllers/wake_steering_actuatordisk_standin.py b/whoc/controllers/wake_steering_actuatordisk_standin.py new file mode 100644 index 00000000..132d665c --- /dev/null +++ b/whoc/controllers/wake_steering_actuatordisk_standin.py @@ -0,0 +1,67 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +from whoc.controllers.controller_base import ControllerBase + + +class WakeSteeringADStandin(ControllerBase): + def __init__(self, interface, input_dict): + super().__init__(interface) + + self.dt = input_dict["dt"] # Won't be needed here, but generally good to have + self.n_turbines = input_dict["controller"]["num_turbines"] + self.turbines = range(self.n_turbines) + + # Set initial conditions + yaw_IC = input_dict["controller"]["initial_conditions"]["yaw"] + if hasattr(yaw_IC, "__len__"): + if len(yaw_IC) == self.n_turbines: + self.controls_dict = {"yaw_angles": yaw_IC} + else: + raise TypeError( + "yaw initial condition should be a float or " + + "a list of floats of length num_turbines." + ) + else: + self.controls_dict = {"yaw_angles": [yaw_IC] * self.n_turbines} + + # Grab name of wind farm (assumes there is only one!) + + def compute_controls(self): + self.generate_turbine_references() + + def generate_turbine_references(self): + # Based on an early implementation for Hercules + + current_time = self.measurements_dict["time"] + if current_time <= 10.0: + yaw_setpoint = [270.0] * self.n_turbines + else: + yaw_setpoint = self.measurements_dict["wind_directions"] + + self.controls_dict = {"yaw_angles": yaw_setpoint} + + return None + + # def run(self): + + # connect_zmq = True + # while connect_zmq: + # self.receive_turbine_outputs() + # self.generate_turbine_references() + # self.send_turbine_references() + + # if self.measurements_dict['iStatus'] == -1: + # connect_zmq = False + # self.s._disconnect() diff --git a/whoc/controllers/wake_steering_rosco_standin.py b/whoc/controllers/wake_steering_rosco_standin.py new file mode 100644 index 00000000..6da3218e --- /dev/null +++ b/whoc/controllers/wake_steering_rosco_standin.py @@ -0,0 +1,55 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +from whoc.controllers.controller_base import ControllerBase + + +class WakeSteeringROSCOStandin(ControllerBase): + def __init__(self, interface): + super.__init__(interface, timeout=100.0, verbose=True) + + def compute_controls(self): + self.generate_turbine_references() + + def generate_turbine_references(self): + # Something very minimal here, based on ROSCO example 17. + # west_offset = convert_absolute_nacelle_heading_to_offset(270, + # self.measurements_dict["NacelleHeading"]) + + current_time = self.measurements_dict["Time"] + if current_time <= 10.0: + yaw_setpoint = 0.0 + else: + yaw_setpoint = 20.0 + + self.controls_dict = { + "turbine_ID": 0, # TODO: hardcoded! Replace. + "genTorque": 0.0, + "nacelleHeading": yaw_setpoint, + "bladePitch": [0.0, 0.0, 0.0], + } + + return None + + # def run(self): + + # connect_zmq = True + # while connect_zmq: + # self.receive_turbine_outputs() + # self.generate_turbine_references() + # self.send_turbine_references() + + # if self.measurements_dict['iStatus'] == -1: + # connect_zmq = False + # self.s._disconnect() diff --git a/whoc/interfaces/__init__.py b/whoc/interfaces/__init__.py new file mode 100644 index 00000000..8c80dc38 --- /dev/null +++ b/whoc/interfaces/__init__.py @@ -0,0 +1,3 @@ +from whoc.interfaces.hercules_actuator_disk_yaw_interface import HerculesADYawInterface +from whoc.interfaces.hercules_wind_battery_interface import HerculesWindBatteryInterface +from whoc.interfaces.rosco_zmq_interface import ROSCO_ZMQInterface diff --git a/whoc/interfaces/hercules_actuator_disk_yaw_interface.py b/whoc/interfaces/hercules_actuator_disk_yaw_interface.py new file mode 100644 index 00000000..5dd76806 --- /dev/null +++ b/whoc/interfaces/hercules_actuator_disk_yaw_interface.py @@ -0,0 +1,70 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +# How will we handle other things here? May need to have a wind farm +# version, an electrolyzer version, etc... +from whoc.interfaces.interface_base import InterfaceBase + + +class HerculesADYawInterface(InterfaceBase): + def __init__(self, hercules_dict): + super().__init__() + + self.dt = hercules_dict["dt"] + self.n_turbines = hercules_dict["controller"]["num_turbines"] + self.turbines = range(self.n_turbines) + + # Grab name of wind farm (assumes there is only one!) + self.wf_name = list(hercules_dict["hercules_comms"]["amr_wind"].keys())[0] + + pass + + def get_measurements(self, hercules_dict): + wind_directions = hercules_dict["hercules_comms"]["amr_wind"][self.wf_name][ + "turbine_wind_directions" + ] + # wind_speeds = input_dict["hercules_comms"]\ + # ["amr_wind"]\ + # [self.wf_name]\ + # ["turbine_wind_speeds"] + powers = hercules_dict["hercules_comms"]["amr_wind"][self.wf_name]["turbine_powers"] + time = hercules_dict["time"] + + measurements = { + "time": time, + "wind_directions": wind_directions, + # "wind_speeds":wind_speeds, + "turbine_powers": powers, + } + + return measurements + + def check_controls(self, controls_dict): + available_controls = ["yaw_angles"] + + for k in controls_dict.keys(): + if k not in available_controls: + raise ValueError("Setpoint " + k + " is not available in this configuration.") + if len(controls_dict[k]) != self.n_turbines: + raise ValueError( + "Length of setpoint " + k + " does not match the number of turbines." + ) + + def send_controls(self, hercules_dict, yaw_angles=None): + if yaw_angles is None: + yaw_angles = [0.0] * self.n_turbines + + hercules_dict["hercules_comms"]["amr_wind"][self.wf_name]["turbine_yaw_angles"] = yaw_angles + + return hercules_dict diff --git a/whoc/interfaces/hercules_wind_battery_interface.py b/whoc/interfaces/hercules_wind_battery_interface.py new file mode 100644 index 00000000..c49b8ca6 --- /dev/null +++ b/whoc/interfaces/hercules_wind_battery_interface.py @@ -0,0 +1,49 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +from whoc.interfaces.interface_base import InterfaceBase + + +class HerculesWindBatteryInterface(InterfaceBase): + def __init__(self, hercules_dict): + super().__init__() + + # Grab name of wind farm (assumes there is only one!) + self.wf_name = list(hercules_dict["hercules_comms"]["amr_wind"].keys())[0] + + # Get the name of the battery (assumes the battery is the only pysim!) + self.battery_name = list(hercules_dict["py_sims"].keys())[0] + + def get_measurements(self, hercules_dict): + measurements = { + "py_sims": {"battery": hercules_dict["py_sims"][self.battery_name]["outputs"]}, + "wind_farm": { + "turbine_powers": hercules_dict["hercules_comms"]["amr_wind"][self.wf_name][ + "turbine_powers" + ], + "turbine_wind_directions": hercules_dict["hercules_comms"]["amr_wind"][ + self.wf_name + ]["turbine_wind_directions"], + }, + } + + return measurements + + def check_controls(self, controls_dict): + controls = {} + return controls + + def send_controls(self, hercules_dict, controls_dict=None): + hercules_dict.update({"setpoints": controls_dict}) + return hercules_dict diff --git a/whoc/interfaces/interface_base.py b/whoc/interfaces/interface_base.py new file mode 100644 index 00000000..8bf54db2 --- /dev/null +++ b/whoc/interfaces/interface_base.py @@ -0,0 +1,29 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +from abc import ABCMeta, abstractmethod + + +class InterfaceBase(metaclass=ABCMeta): + @abstractmethod + def get_measurements(self): + raise NotImplementedError + + @abstractmethod + def check_controls(self): + raise NotImplementedError + + @abstractmethod + def send_controls(self): + raise NotImplementedError diff --git a/whoc/interfaces/python_interface_temp.py b/whoc/interfaces/python_interface_temp.py new file mode 100644 index 00000000..a97578ad --- /dev/null +++ b/whoc/interfaces/python_interface_temp.py @@ -0,0 +1,32 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +from whoc.interfaces.interface_base import InterfaceBase + + +class WHOC_python_server(InterfaceBase): + def __init__(): + pass + + def get_measurements(self, dict): + # Possibly need to extract the measurements from the input dict? + measurements = dict + + return measurements + + def send_controls(self, dict): + # Not sure if anything needs to be done here. Can possibly return None. + controls = dict + + return controls diff --git a/whoc/interfaces/rosco_zmq_interface.py b/whoc/interfaces/rosco_zmq_interface.py new file mode 100644 index 00000000..17b2218f --- /dev/null +++ b/whoc/interfaces/rosco_zmq_interface.py @@ -0,0 +1,175 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +import zmq + +from whoc.interfaces.interface_base import InterfaceBase + +# Code copied from ROSCO; consider just importing and using that code +# directly?? + + +class ROSCO_ZMQInterface(InterfaceBase): + def __init__( + self, network_address="tcp://*:5555", identifier="0", timeout=600.0, verbose=False + ): + """Python implementation of the ZeroMQ server side for the ROSCO + ZeroMQ wind farm control interface. This class makes it easy for + users to receive measurements from ROSCO and then send back control + setpoints (generator torque, nacelle heading and/or blade pitch + angles). + Args: + network_address (str, optional): The network address to + communicate over with the desired instance of ROSCO. Note that, + if running a wind farm simulation in SOWFA or FAST.Farm, there + are multiple instances of ROSCO and each of these instances + needs to communicate over a unique port. Also, for each of those + instances, you will need an instance of zmq_server. Defaults to + "tcp://*:5555". + identifier (str, optional): Turbine identifier. Defaults to "0". + timeout (float, optional): Seconds to wait for a message from + the ZeroMQ server before timing out. Defaults to 600.0. + verbose (bool, optional): Print to console. Defaults to False. + """ + super().__init__() + + self.network_address = network_address + self.identifier = identifier + self.timeout = timeout + self.verbose = verbose + self._connect() + + def _connect(self): + """ + Connect to zmq server + """ + address = self.network_address + + # Connect socket + context = zmq.Context() + self.socket = context.socket(zmq.REP) + self.socket.setsockopt(zmq.LINGER, 0) + self.socket.bind(address) + + if self.verbose: + print("[%s] Successfully established connection with %s" % (self.identifier, address)) + + def _disconnect(self): + """ + Disconnect from zmq server + """ + self.socket.close() + context = zmq.Context() + context.term() + + def get_measurements(self, _): + """ + Receive measurements from ROSCO .dll + """ + if self.verbose: + print("[%s] Waiting to receive measurements from ROSCO..." % (self.identifier)) + + # Initialize a poller for timeouts + poller = zmq.Poller() + poller.register(self.socket, zmq.POLLIN) + timeout_ms = int(self.timeout * 1000) + if poller.poll(timeout_ms): + # Receive measurements over network protocol + message_in = self.socket.recv_string() + else: + raise IOError( + "[%s] Connection to '%s' timed out." % (self.identifier, self.network_address) + ) + + # Convert to individual strings and then to floats + measurements = message_in + measurements = measurements.replace("\x00", "").split(",") + measurements = [float(m) for m in measurements] + + # Convert to a measurement dict + measurements = dict( + { + "Turbine_ID": measurements[0], + "iStatus": measurements[1], + "Time": measurements[2], + "VS_MechGenPwr": measurements[3], + "VS_GenPwr": measurements[4], + "GenSpeed": measurements[5], + "RotSpeed": measurements[6], + "GenTqMeas": measurements[7], + "NacelleHeading": measurements[8], + "NacelleVane": measurements[9], + "HorWindV": measurements[10], + "rootMOOP1": measurements[11], + "rootMOOP2": measurements[12], + "rootMOOP3": measurements[13], + "FA_Acc": measurements[14], + "NacIMU_FA_Acc": measurements[15], + "Azimuth": measurements[16], + } + ) + + if self.verbose: + print("[%s] Measurements received:" % self.identifier, measurements) + + return measurements + + def check_controls(self, controls_dict): + available_controls = [ + "turbine_ID", + "genTorque", + "nacelleHeading", + "bladePitch", + ] + + for k in controls_dict.keys(): + if k not in available_controls: + raise ValueError("Setpoint " + k + " is not available in this configuration") + + def send_controls( + self, turbine_ID=0, genTorque=0.0, nacelleHeading=0.0, bladePitch=[0.0, 0.0, 0.0] + ): + """ + Send controls to ROSCO .dll ffor individual turbine control + + Parameters: + ----------- + genTorques: float + Generator torque setpoint + nacelleHeadings: float + Nacelle heading setpoint + bladePitchAngles: List (len=3) + Blade pitch angle setpoint + """ + # Create a message with controls to send to ROSCO + message_out = b"%016.5f, %016.5f, %016.5f, %016.5f, %016.5f, %016.5f" % ( + turbine_ID, + genTorque, + nacelleHeading, + bladePitch[0], + bladePitch[1], + bladePitch[2], + ) + + # Send reply back to client + if self.verbose: + print("[%s] Sending setpoint string to ROSCO: %s." % (self.identifier, message_out)) + + # Send control controls over network protocol + self.socket.send(message_out) + + if self.verbose: + print("[%s] Setpoints sent successfully." % self.identifier) + + return None diff --git a/whoc/utilities.py b/whoc/utilities.py new file mode 100644 index 00000000..d3d8408c --- /dev/null +++ b/whoc/utilities.py @@ -0,0 +1,22 @@ +# Copyright 2021 NREL + +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# See https://nrel.github.io/wind-hybrid-open-controller for documentation + +from floris.utilities import wrap_180 + + +def convert_absolute_nacelle_heading_to_offset(target_nac_heading, current_nac_heading): + # NOTE: by convention, absolute headings are given CW positive, but offsets + # are given CCW positive. + + return -1 * wrap_180(target_nac_heading - current_nac_heading) diff --git a/whoc/version.py b/whoc/version.py new file mode 100644 index 00000000..49d59571 --- /dev/null +++ b/whoc/version.py @@ -0,0 +1 @@ +0.1 diff --git a/wind-hybrid-open-controller/version.py b/wind-hybrid-open-controller/version.py deleted file mode 100644 index ba66466c..00000000 --- a/wind-hybrid-open-controller/version.py +++ /dev/null @@ -1 +0,0 @@ -0.0