diff --git a/.gitignore b/.gitignore index 89ca8c7e..a6f401e1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,14 @@ slices # Data files *.csv *.log -examples/*/logstandin -examples/*/loghercules -examples/*/logfloris +examples/*/logstandin* +examples/*/loghercules* +examples/*/logfloris* + +# Exceptions +!examples/lookup-based_wake_steering_florisstandin/amr_standin_data.csv +!examples/wind_farm_power_tracking_florisstandin/amr_standin_data.csv +!examples/wind_farm_power_tracking_florisstandin/wind_power_reference_data.csv # macOS files .DS_Store diff --git a/docs/controllers.md b/docs/controllers.md index 9bbc4f33..5996b2ab 100644 --- a/docs/controllers.md +++ b/docs/controllers.md @@ -12,7 +12,7 @@ method of `ControllerBase`. ### LookupBasedWakeSteeringController Yaw controller that implements wake steering based on a lookup table. -Requires a df_opt object produced by a FLORIS yaw optimization routine. See example +Requires a `df_opt` object produced by a FLORIS yaw optimization routine. See example lookup-based_wake_steering_florisstandin for example usage. Currently, yaw angles are set based purely on the (local turbine) wind direction. The lookup table @@ -20,7 +20,31 @@ is sampled at a hardcoded wind speed of 8 m/s. This will be updated in future wh developed for a simulator that provides wind turbine wind speeds also. ### WakeSteeringROSCOStandin -May be combined into a universal simple wake steeringcontroller. +Not yet developed. May be combined into a universal simple LookupBasedWakeSteeringController. -### HerculesWindBatteryController -TO WRITE +### WindBatteryController +Placeholder for a controller that manages both a wind power plant and colocated +battery. + +### WindFarmPowerDistributingController + +Wind farm-level power controller that simply distributes a farm-level power +reference between wind turbines evenly, without checking whether turbines are +able to produce power at the requested level. Not expected to perform well when +wind turbines are waked or cannot produce the desired power for other reasons. +However, is a useful comparison case for the WindFarmPowerTrackingController +(described below). + +### 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 +[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 +simple proportional control appears sufficient currently. However, these may be enabled at a +later date if needed. The `proportional_gain` for the controller may be provided on instantiation, +and defaults to `proportional_gain = 1`. diff --git a/docs/examples.md b/docs/examples.md index 8110eb56..92079f89 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -6,7 +6,7 @@ of certain controllers and interfaces. ### lookup-based_wake_steering_florisstandin 2-turbine example of lookup-based wake steering control, run using Hercules with the FLORIS standin in place of AMR-Wind for exposition purposes. To run this example, navigate to the -examples/lookup-based_wake_steering_florisstandin and then run the following. +examples/lookup-based_wake_steering_florisstandin folder and then run the following. ``` python construct_yaw_offsets.py ``` @@ -32,4 +32,20 @@ python plot_output_data.py This should produce the following plot. ![Results of lookup-based_wake_steering_florisstandin example]( graphics/lookup-table-example-plot.png +) + +## wind_farm_power_tracking_florisstandin +2-turbine example of wind-farm-level power reference tracking, run using Hercules with the FLORIS +standin in place of AMR-Wind for exposition purposes. To run this example, navigate to the +examples/wind_farm_power_tracking_florisstandin folder and run the following: +``` +./bash_script.sh +``` + +This will run both a closed-loop controller, which compensates for underproduction at individual +turbines, and an open-loop controller, which simply distributes the farm-wide reference evenly +amongst the turbines of the farm without feedback. The resulting trajectories are plotted, +producing: +![Results of wind_farm_power_tracking_florisstandin example]( + graphics/wf-power-tracking-plot.png ) \ No newline at end of file diff --git a/docs/graphics/wf-power-tracking-plot.png b/docs/graphics/wf-power-tracking-plot.png new file mode 100644 index 00000000..fb75bc81 Binary files /dev/null and b/docs/graphics/wf-power-tracking-plot.png differ diff --git a/docs/interfaces.md b/docs/interfaces.md index a85035df..088106c3 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -5,14 +5,14 @@ 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 +- `get_measurements()`: Receive 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 +created as specific keyword arguments, 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. @@ -21,11 +21,12 @@ These methods will all be called in the `step()` method of `ControllerBase`. ## Available interfaces -### HerculesADYawInterface +### HerculesADInterface 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. +Hercules `Emulator` upon its instantiation. Support transmitting yaw angles +and power setpoints to wind turbines. ### ROSCO_ZMQInterface For sending and receiving communications from one or more ROSCO instances diff --git a/examples/lookup-based_wake_steering_florisstandin/amr_standin_data.csv b/examples/lookup-based_wake_steering_florisstandin/amr_standin_data.csv new file mode 100644 index 00000000..7de18d1c --- /dev/null +++ b/examples/lookup-based_wake_steering_florisstandin/amr_standin_data.csv @@ -0,0 +1,201 @@ +,time,amr_wind_speed,amr_wind_direction +0,0.0,8.0,260.0 +1,0.5,8.0,260.0 +2,1.0,8.0,260.0 +3,1.5,8.0,260.0 +4,2.0,8.0,260.0 +5,2.5,8.0,260.0 +6,3.0,8.0,260.0 +7,3.5,8.0,260.0 +8,4.0,8.0,260.0 +9,4.5,8.0,260.0 +10,5.0,8.0,260.0 +11,5.5,8.0,260.0 +12,6.0,8.0,260.0 +13,6.5,8.0,260.0 +14,7.0,8.0,260.0 +15,7.5,8.0,260.0 +16,8.0,8.0,260.0 +17,8.5,8.0,260.0 +18,9.0,8.0,260.0 +19,9.5,8.0,260.0 +20,10.0,8.0,260.0 +21,10.5,8.0,260.0 +22,11.0,8.0,260.0 +23,11.5,8.0,260.0 +24,12.0,8.0,260.0 +25,12.5,8.0,260.0 +26,13.0,8.0,260.0 +27,13.5,8.0,260.0 +28,14.0,8.0,260.0 +29,14.5,8.0,260.0 +30,15.0,8.0,260.0 +31,15.5,8.0,260.0 +32,16.0,8.0,260.0 +33,16.5,8.0,260.0 +34,17.0,8.0,260.0 +35,17.5,8.0,260.0 +36,18.0,8.0,260.0 +37,18.5,8.0,260.0 +38,19.0,8.0,260.0 +39,19.5,8.0,260.0 +40,20.0,8.0,260.0 +41,20.5,8.0,260.0 +42,21.0,8.0,260.0 +43,21.5,8.0,260.0 +44,22.0,8.0,260.0 +45,22.5,8.0,260.0 +46,23.0,8.0,260.0 +47,23.5,8.0,260.0 +48,24.0,8.0,260.0 +49,24.5,8.0,260.0 +50,25.0,8.0,260.0 +51,25.5,8.0,260.0 +52,26.0,8.0,260.0 +53,26.5,8.0,260.0 +54,27.0,8.0,260.0 +55,27.5,8.0,260.0 +56,28.0,8.0,260.0 +57,28.5,8.0,260.0 +58,29.0,8.0,260.0 +59,29.5,8.0,260.0 +60,30.0,8.0,260.0 +61,30.5,8.0,260.126582278481 +62,31.0,8.0,260.253164556962 +63,31.5,8.0,260.37974683544303 +64,32.0,8.0,260.50632911392404 +65,32.5,8.0,260.63291139240505 +66,33.0,8.0,260.75949367088606 +67,33.5,8.0,260.88607594936707 +68,34.0,8.0,261.0126582278481 +69,34.5,8.0,261.1392405063291 +70,35.0,8.0,261.26582278481015 +71,35.5,8.0,261.39240506329116 +72,36.0,8.0,261.5189873417722 +73,36.5,8.0,261.6455696202532 +74,37.0,8.0,261.7721518987342 +75,37.5,8.0,261.8987341772152 +76,38.0,8.0,262.0253164556962 +77,38.5,8.0,262.1518987341772 +78,39.0,8.0,262.27848101265823 +79,39.5,8.0,262.40506329113924 +80,40.0,8.0,262.53164556962025 +81,40.5,8.0,262.65822784810126 +82,41.0,8.0,262.7848101265823 +83,41.5,8.0,262.9113924050633 +84,42.0,8.0,263.0379746835443 +85,42.5,8.0,263.1645569620253 +86,43.0,8.0,263.2911392405063 +87,43.5,8.0,263.4177215189873 +88,44.0,8.0,263.54430379746833 +89,44.5,8.0,263.67088607594934 +90,45.0,8.0,263.7974683544304 +91,45.5,8.0,263.9240506329114 +92,46.0,8.0,264.0506329113924 +93,46.5,8.0,264.17721518987344 +94,47.0,8.0,264.30379746835445 +95,47.5,8.0,264.43037974683546 +96,48.0,8.0,264.55696202531647 +97,48.5,8.0,264.6835443037975 +98,49.0,8.0,264.8101265822785 +99,49.5,8.0,264.9367088607595 +100,50.0,8.0,265.0632911392405 +101,50.5,8.0,265.1898734177215 +102,51.0,8.0,265.3164556962025 +103,51.5,8.0,265.44303797468353 +104,52.0,8.0,265.56962025316454 +105,52.5,8.0,265.69620253164555 +106,53.0,8.0,265.82278481012656 +107,53.5,8.0,265.9493670886076 +108,54.0,8.0,266.0759493670886 +109,54.5,8.0,266.2025316455696 +110,55.0,8.0,266.32911392405066 +111,55.5,8.0,266.45569620253167 +112,56.0,8.0,266.5822784810127 +113,56.5,8.0,266.7088607594937 +114,57.0,8.0,266.8354430379747 +115,57.5,8.0,266.9620253164557 +116,58.0,8.0,267.0886075949367 +117,58.5,8.0,267.2151898734177 +118,59.0,8.0,267.34177215189874 +119,59.5,8.0,267.46835443037975 +120,60.0,8.0,267.59493670886076 +121,60.5,8.0,267.72151898734177 +122,61.0,8.0,267.8481012658228 +123,61.5,8.0,267.9746835443038 +124,62.0,8.0,268.1012658227848 +125,62.5,8.0,268.2278481012658 +126,63.0,8.0,268.3544303797468 +127,63.5,8.0,268.4810126582278 +128,64.0,8.0,268.60759493670884 +129,64.5,8.0,268.7341772151899 +130,65.0,8.0,268.8607594936709 +131,65.5,8.0,268.9873417721519 +132,66.0,8.0,269.11392405063293 +133,66.5,8.0,269.24050632911394 +134,67.0,8.0,269.36708860759495 +135,67.5,8.0,269.49367088607596 +136,68.0,8.0,269.62025316455697 +137,68.5,8.0,269.746835443038 +138,69.0,8.0,269.873417721519 +139,69.5,8.0,270.0 +140,70.0,8.0,278.8202617298383 +141,70.5,8.0,272.00078604183614 +142,71.0,8.0,274.8936899205287 +143,71.5,8.0,281.2044659960073 +144,72.0,8.0,279.3377899507498 +145,72.5,8.0,265.11361060061796 +146,73.0,8.0,274.75044208762796 +147,73.5,8.0,269.2432139585115 +148,74.0,8.0,269.48390574103223 +149,74.5,8.0,272.0529925096919 +150,75.0,8.0,270.7202178558044 +151,75.5,8.0,277.2713675348149 +152,76.0,8.0,273.80518862573496 +153,76.5,8.0,270.60837508246414 +154,77.0,8.0,272.2193161637271 +155,77.5,8.0,271.6683716368713 +156,78.0,8.0,277.470395365788 +157,78.5,8.0,268.974208681171 +158,79.0,8.0,271.5653385082545 +159,79.5,8.0,265.72952130349137 +160,80.0,8.0,257.2350509208296 +161,80.5,8.0,273.26809297720183 +162,81.0,8.0,274.3221809942975 +163,81.5,8.0,266.28917489796777 +164,82.0,8.0,281.348773119938 +165,82.5,8.0,262.7281716270062 +166,83.0,8.0,270.2287925865072 +167,83.5,8.0,269.0640807498708 +168,84.0,8.0,277.6638960717923 +169,84.5,8.0,277.3467938495014 +170,85.0,8.0,270.77473712848456 +171,85.5,8.0,271.8908125980109 +172,86.0,8.0,265.56107126184946 +173,86.5,8.0,260.0960176588804 +174,87.0,8.0,268.2604392533692 +175,87.5,8.0,270.7817448455199 +176,88.0,8.0,276.1514534036386 +177,88.5,8.0,276.0118992439221 +178,89.0,8.0,268.06336591296025 +179,89.5,8.0,268.4884862471233 +180,90.0,8.0,264.7572351746645 +181,90.5,8.0,262.8999103141051 +182,91.0,8.0,261.46864904687493 +183,91.5,8.0,279.75387697615895 +184,92.0,8.0,267.45173909124173 +185,92.5,8.0,267.80962849194407 +186,93.0,8.0,263.7360231997504 +187,93.5,8.0,273.88745177915956 +188,94.0,8.0,261.93051076221025 +189,94.5,8.0,268.93629859893014 +190,95.0,8.0,265.5226671940316 +191,95.5,8.0,271.9345124892963 +192,96.0,8.0,267.4459743121556 +193,96.5,8.0,264.09683907938796 +194,97.0,8.0,269.85908885830673 +195,97.5,8.0,272.1416593526521 +196,98.0,8.0,270.3325861119158 +197,98.5,8.0,271.5123594886989 +198,99.0,8.0,266.8283895315952 +199,99.5,8.0,268.1862941700643 diff --git a/examples/lookup-based_wake_steering_florisstandin/hercules_runscript.py b/examples/lookup-based_wake_steering_florisstandin/hercules_runscript.py index b3ea8f6b..1745b5b2 100644 --- a/examples/lookup-based_wake_steering_florisstandin/hercules_runscript.py +++ b/examples/lookup-based_wake_steering_florisstandin/hercules_runscript.py @@ -19,14 +19,14 @@ from hercules.py_sims import PySims from hercules.utilities import load_yaml from whoc.controllers.lookup_based_wake_steering_controller import LookupBasedWakeSteeringController -from whoc.interfaces.hercules_actuator_disk_yaw_interface import HerculesADYawInterface +from whoc.interfaces.hercules_actuator_disk_interface import HerculesADInterface input_dict = load_yaml(sys.argv[1]) # Load the optimal yaw angle lookup table for controller us df_opt = pd.read_pickle("yaw_offsets.pkl") -interface = HerculesADYawInterface(input_dict) +interface = HerculesADInterface(input_dict) controller = LookupBasedWakeSteeringController(interface, input_dict, df_yaw=df_opt) py_sims = PySims(input_dict) diff --git a/examples/wind_farm_power_tracking_florisstandin/amr_input.inp b/examples/wind_farm_power_tracking_florisstandin/amr_input.inp new file mode 100644 index 00000000..8ef0cd1a --- /dev/null +++ b/examples/wind_farm_power_tracking_florisstandin/amr_input.inp @@ -0,0 +1,163 @@ +#¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨# +# 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 +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 # +#.......................................# + +Actuator.type = UniformCtDisk +Actuator.UniformCtDisk.rotor_diameter = 126.0 +Actuator.UniformCtDisk.hub_height = 90.0 +Actuator.UniformCtDisk.thrust_coeff = 0.0 0.0 1.132034888 0.999470963 0.917697381 0.860849503 0.815371198 0.811614904 0.807939328 0.80443352 0.800993851 0.79768116 0.794529244 0.791495834 0.788560434 0.787217182 0.787127977 0.785839257 0.783812219 0.783568108 0.783328285 0.781194418 0.777292539 0.773464375 0.769690236 0.766001924 0.762348072 0.758760824 0.755242872 0.751792927 0.748434131 0.745113997 0.717806682 0.672204789 0.63831272 0.610176496 0.585456847 0.563222111 0.542912273 0.399312061 0.310517829 0.248633226 0.203543725 0.169616419 0.143478955 0.122938861 0.106515296 0.093026095 0.081648606 0.072197368 0.064388275 0.057782745 0.0 0.0 +Actuator.UniformCtDisk.wind_speed = 0.0 2.9 3.0 4.0 5.0 6.0 7.0 7.1 7.2 7.3 7.4 7.5 7.6 7.7 7.8 7.9 8.0 9.0 10.0 10.1 10.2 10.3 10.4 10.5 10.6 10.7 10.8 10.9 11.0 11.1 11.2 11.3 11.4 11.5 11.6 11.7 11.8 11.9 12.0 13.0 14.0 15.0 16.0 17.0 18.0 19.0 20.0 21.0 22.0 23.0 24.0 25.0 25.1 50.0 +Actuator.UniformCtDisk.epsilon = 10.0 +Actuator.UniformCtDisk.density = 1.225 +Actuator.UniformCtDisk.diameters_to_sample = 1.0 +Actuator.UniformCtDisk.num_points_r = 20 +Actuator.UniformCtDisk.num_points_t = 5 + + +Actuator.labels = T00 T01 +Actuator.T00.base_position = 0.0 0.0 0.0 +Actuator.T01.base_position = 1000.0 0.0 0.0 diff --git a/examples/wind_farm_power_tracking_florisstandin/amr_standin_data.csv b/examples/wind_farm_power_tracking_florisstandin/amr_standin_data.csv new file mode 100644 index 00000000..20c2bd53 --- /dev/null +++ b/examples/wind_farm_power_tracking_florisstandin/amr_standin_data.csv @@ -0,0 +1,201 @@ +,time,amr_wind_speed,amr_wind_direction +0,0.0,8.0,270.0 +1,0.5,8.0,270.0 +2,1.0,8.0,270.0 +3,1.5,8.0,270.0 +4,2.0,8.0,270.0 +5,2.5,8.0,270.0 +6,3.0,8.0,270.0 +7,3.5,8.0,270.0 +8,4.0,8.0,270.0 +9,4.5,8.0,270.0 +10,5.0,8.0,270.0 +11,5.5,8.0,270.0 +12,6.0,8.0,270.0 +13,6.5,8.0,270.0 +14,7.0,8.0,270.0 +15,7.5,8.0,270.0 +16,8.0,8.0,270.0 +17,8.5,8.0,270.0 +18,9.0,8.0,270.0 +19,9.5,8.0,270.0 +20,10.0,8.0,270.0 +21,10.5,8.0,270.0 +22,11.0,8.0,270.0 +23,11.5,8.0,270.0 +24,12.0,8.0,270.0 +25,12.5,8.0,270.0 +26,13.0,8.0,270.0 +27,13.5,8.0,270.0 +28,14.0,8.0,270.0 +29,14.5,8.0,270.0 +30,15.0,8.0,270.0 +31,15.5,8.0,270.0 +32,16.0,8.0,270.0 +33,16.5,8.0,270.0 +34,17.0,8.0,270.0 +35,17.5,8.0,270.0 +36,18.0,8.0,270.0 +37,18.5,8.0,270.0 +38,19.0,8.0,270.0 +39,19.5,8.0,270.0 +40,20.0,8.0,270.0 +41,20.5,8.0,270.0 +42,21.0,8.0,270.0 +43,21.5,8.0,270.0 +44,22.0,8.0,270.0 +45,22.5,8.0,270.0 +46,23.0,8.0,270.0 +47,23.5,8.0,270.0 +48,24.0,8.0,270.0 +49,24.5,8.0,270.0 +50,25.0,8.0,270.0 +51,25.5,8.0,270.0 +52,26.0,8.0,270.0 +53,26.5,8.0,270.0 +54,27.0,8.0,270.0 +55,27.5,8.0,270.0 +56,28.0,8.0,270.0 +57,28.5,8.0,270.0 +58,29.0,8.0,270.0 +59,29.5,8.0,270.0 +60,30.0,8.0,270.0 +61,30.5,8.0,270.0 +62,31.0,8.0,270.0 +63,31.5,8.0,270.0 +64,32.0,8.0,270.0 +65,32.5,8.0,270.0 +66,33.0,8.0,270.0 +67,33.5,8.0,270.0 +68,34.0,8.0,270.0 +69,34.5,8.0,270.0 +70,35.0,8.0,270.0 +71,35.5,8.0,270.0 +72,36.0,8.0,270.0 +73,36.5,8.0,270.0 +74,37.0,8.0,270.0 +75,37.5,8.0,270.0 +76,38.0,8.0,270.0 +77,38.5,8.0,270.0 +78,39.0,8.0,270.0 +79,39.5,8.0,270.0 +80,40.0,8.0,270.0 +81,40.5,8.0,270.0 +82,41.0,8.0,270.0 +83,41.5,8.0,270.0 +84,42.0,8.0,270.0 +85,42.5,8.0,270.0 +86,43.0,8.0,270.0 +87,43.5,8.0,270.0 +88,44.0,8.0,270.0 +89,44.5,8.0,270.0 +90,45.0,8.0,270.0 +91,45.5,8.0,270.0 +92,46.0,8.0,270.0 +93,46.5,8.0,270.0 +94,47.0,8.0,270.0 +95,47.5,8.0,270.0 +96,48.0,8.0,270.0 +97,48.5,8.0,270.0 +98,49.0,8.0,270.0 +99,49.5,8.0,270.0 +100,50.0,8.0,270.0 +101,50.5,8.0,270.0 +102,51.0,8.0,270.0 +103,51.5,8.0,270.0 +104,52.0,8.0,270.0 +105,52.5,8.0,270.0 +106,53.0,8.0,270.0 +107,53.5,8.0,270.0 +108,54.0,8.0,270.0 +109,54.5,8.0,270.0 +110,55.0,8.0,270.0 +111,55.5,8.0,270.0 +112,56.0,8.0,270.0 +113,56.5,8.0,270.0 +114,57.0,8.0,270.0 +115,57.5,8.0,270.0 +116,58.0,8.0,270.0 +117,58.5,8.0,270.0 +118,59.0,8.0,270.0 +119,59.5,8.0,270.0 +120,60.0,8.0,270.0 +121,60.5,8.0,270.0 +122,61.0,8.0,270.0 +123,61.5,8.0,270.0 +124,62.0,8.0,270.0 +125,62.5,8.0,270.0 +126,63.0,8.0,270.0 +127,63.5,8.0,270.0 +128,64.0,8.0,270.0 +129,64.5,8.0,270.0 +130,65.0,8.0,270.0 +131,65.5,8.0,270.0 +132,66.0,8.0,270.0 +133,66.5,8.0,270.0 +134,67.0,8.0,270.0 +135,67.5,8.0,270.0 +136,68.0,8.0,270.0 +137,68.5,8.0,270.0 +138,69.0,8.0,270.0 +139,69.5,8.0,270.0 +140,70.0,8.0,270.0 +141,70.5,8.0,270.0 +142,71.0,8.0,270.0 +143,71.5,8.0,270.0 +144,72.0,8.0,270.0 +145,72.5,8.0,270.0 +146,73.0,8.0,270.0 +147,73.5,8.0,270.0 +148,74.0,8.0,270.0 +149,74.5,8.0,270.0 +150,75.0,8.0,270.0 +151,75.5,8.0,270.0 +152,76.0,8.0,270.0 +153,76.5,8.0,270.0 +154,77.0,8.0,270.0 +155,77.5,8.0,270.0 +156,78.0,8.0,270.0 +157,78.5,8.0,270.0 +158,79.0,8.0,270.0 +159,79.5,8.0,270.0 +160,80.0,8.0,270.0 +161,80.5,8.0,270.0 +162,81.0,8.0,270.0 +163,81.5,8.0,270.0 +164,82.0,8.0,270.0 +165,82.5,8.0,270.0 +166,83.0,8.0,270.0 +167,83.5,8.0,270.0 +168,84.0,8.0,270.0 +169,84.5,8.0,270.0 +170,85.0,8.0,270.0 +171,85.5,8.0,270.0 +172,86.0,8.0,270.0 +173,86.5,8.0,270.0 +174,87.0,8.0,270.0 +175,87.5,8.0,270.0 +176,88.0,8.0,270.0 +177,88.5,8.0,270.0 +178,89.0,8.0,270.0 +179,89.5,8.0,270.0 +180,90.0,8.0,270.0 +181,90.5,8.0,270.0 +182,91.0,8.0,270.0 +183,91.5,8.0,270.0 +184,92.0,8.0,270.0 +185,92.5,8.0,270.0 +186,93.0,8.0,270.0 +187,93.5,8.0,270.0 +188,94.0,8.0,270.0 +189,94.5,8.0,270.0 +190,95.0,8.0,270.0 +191,95.5,8.0,270.0 +192,96.0,8.0,270.0 +193,96.5,8.0,270.0 +194,97.0,8.0,270.0 +195,97.5,8.0,270.0 +196,98.0,8.0,270.0 +197,98.5,8.0,270.0 +198,99.0,8.0,270.0 +199,99.5,8.0,270.0 diff --git a/examples/wind_farm_power_tracking_florisstandin/bash_script.sh b/examples/wind_farm_power_tracking_florisstandin/bash_script.sh new file mode 100755 index 00000000..89211499 --- /dev/null +++ b/examples/wind_farm_power_tracking_florisstandin/bash_script.sh @@ -0,0 +1,26 @@ +# 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 + +# Set the helics port to use: +export HELICS_PORT=32000 + +#make sure you use the same port number in the amr_input.inp and hercules_input_000.yaml files. + +# Clear old log files for clarity +rm loghercules_ol logfloris_ol loghercules_cl logfloris_cl + +# Set up the helics broker and run the open-loop control simulation +helics_broker -t zmq -f 2 --loglevel="debug" --local_port=$HELICS_PORT & +python3 hercules_runscript_OLcontrol.py hercules_input_000.yaml >> loghercules_ol 2>&1 & +python3 floris_runscript.py amr_input.inp amr_standin_data.csv >> logfloris_ol 2>&1 + +# Wait for the open-loop control simulation to finish and then run the closed-loop simulation +helics_broker -t zmq -f 2 --loglevel="debug" --local_port=$HELICS_PORT & +python3 hercules_runscript_CLcontrol.py hercules_input_000.yaml >> loghercules_cl 2>&1 & +python3 floris_runscript.py amr_input.inp amr_standin_data.csv >> logfloris_cl 2>&1 + +# Plot the outputs +python3 plot_output_data.py diff --git a/examples/wind_farm_power_tracking_florisstandin/floris_runscript.py b/examples/wind_farm_power_tracking_florisstandin/floris_runscript.py new file mode 100644 index 00000000..5a43afb5 --- /dev/null +++ b/examples/wind_farm_power_tracking_florisstandin/floris_runscript.py @@ -0,0 +1,19 @@ +import sys + +from hercules.floris_standin import launch_floris + +# Check that one command line argument was given +if len(sys.argv) < 2: + raise Exception("Usage: python floris_runscript.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 FLORIS standin with input file: {amr_input_file}") +if len(sys.argv) > 2: + amr_standin_data_file = sys.argv[2] + print(f"Using standin data for AMR-Wind from file: {amr_standin_data_file}") +else: + amr_standin_data_file = None + +launch_floris(amr_input_file, amr_standin_data_file) diff --git a/examples/wind_farm_power_tracking_florisstandin/hercules_input_000.yaml b/examples/wind_farm_power_tracking_florisstandin/hercules_input_000.yaml new file mode 100644 index 00000000..a1ce7c67 --- /dev/null +++ b/examples/wind_farm_power_tracking_florisstandin/hercules_input_000.yaml @@ -0,0 +1,67 @@ +# 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?) + +external_data_file: wind_power_reference_data.csv + + + + + + diff --git a/examples/wind_farm_power_tracking_florisstandin/hercules_runscript_CLcontrol.py b/examples/wind_farm_power_tracking_florisstandin/hercules_runscript_CLcontrol.py new file mode 100644 index 00000000..c5586e93 --- /dev/null +++ b/examples/wind_farm_power_tracking_florisstandin/hercules_runscript_CLcontrol.py @@ -0,0 +1,37 @@ +# 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.wind_farm_power_tracking_controller import WindFarmPowerTrackingController +from whoc.interfaces.hercules_actuator_disk_interface import HerculesADInterface + +input_dict = load_yaml(sys.argv[1]) +input_dict["output_file"] = "hercules_output_cl.csv" + +interface = HerculesADInterface(input_dict) + +print("Running closed-loop controller...") +controller = WindFarmPowerTrackingController(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("Finished running closed-loop controller.") \ No newline at end of file diff --git a/examples/wind_farm_power_tracking_florisstandin/hercules_runscript_OLcontrol.py b/examples/wind_farm_power_tracking_florisstandin/hercules_runscript_OLcontrol.py new file mode 100644 index 00000000..99658fe0 --- /dev/null +++ b/examples/wind_farm_power_tracking_florisstandin/hercules_runscript_OLcontrol.py @@ -0,0 +1,37 @@ +# 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.wind_farm_power_tracking_controller import WindFarmPowerDistributingController +from whoc.interfaces.hercules_actuator_disk_interface import HerculesADInterface + +input_dict = load_yaml(sys.argv[1]) +input_dict["output_file"] = "hercules_output_ol.csv" + +interface = HerculesADInterface(input_dict) + +print("Running open-loop controller...") +controller = WindFarmPowerDistributingController(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("Finished running open-loop controller.") diff --git a/examples/wind_farm_power_tracking_florisstandin/plot_output_data.py b/examples/wind_farm_power_tracking_florisstandin/plot_output_data.py new file mode 100644 index 00000000..740b57fc --- /dev/null +++ b/examples/wind_farm_power_tracking_florisstandin/plot_output_data.py @@ -0,0 +1,55 @@ +import matplotlib.pyplot as plt +import pandas as pd + +dfs = [pd.read_csv("hercules_output_ol.csv"), pd.read_csv("hercules_output_cl.csv")] +labels = ["Open-loop control", "Closed-loop control"] + +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)] +ref_col = "external_signals.wind_power_reference" + +# Create plots +fig, ax = plt.subplots(2, 1, sharex=True, sharey=True) +fig.set_size_inches(10, 5) + +for case, (df, label) in enumerate(zip(dfs, labels)): + # Extract data from larger array + time = df['time'].to_numpy() + powers = df[pow_cols].to_numpy() + wds = df[wd_cols].to_numpy() + yaws = df[yaw_cols].to_numpy() + ref = df[ref_col].to_numpy() + + # Direction + for t in range(n_turbines): + if t == 0: + line = 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].plot(time, powers.sum(axis=1), color="black", label="Farm power") + ax[case].plot(time, ref, color="gray", linestyle="dashed", label="Ref. power") + + # Plot aesthetics + ax[case].grid() + ax[case].set_title(label) + ax[case].set_ylabel("Power [kW]") +ax[0].set_xlim([time[0], time[-1]]) +ax[0].legend(loc="lower left") +ax[1].set_xlabel("Time [s]") + +# fig.savefig("../../docs/graphics/wf-power-tracking-plot.png", dpi=300, format="png") + +# 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 +# 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. + +plt.show() \ No newline at end of file diff --git a/examples/wind_farm_power_tracking_florisstandin/readme.txt b/examples/wind_farm_power_tracking_florisstandin/readme.txt new file mode 100644 index 00000000..3e2cc742 --- /dev/null +++ b/examples/wind_farm_power_tracking_florisstandin/readme.txt @@ -0,0 +1,4 @@ +This example runs a wind farm level power tracking controller, using FLORIS as a simulation +testbed rather than AMR-Wind. The control algorithm is based on the A2e2g project. +To run the example, run bash_script.sh. This will run both an open loop and closed loop power +tracking controller, and generate a comparison of their outputs as a plot. diff --git a/examples/wind_farm_power_tracking_florisstandin/wind_power_reference_data.csv b/examples/wind_farm_power_tracking_florisstandin/wind_power_reference_data.csv new file mode 100644 index 00000000..5472901a --- /dev/null +++ b/examples/wind_farm_power_tracking_florisstandin/wind_power_reference_data.csv @@ -0,0 +1,12 @@ +time,wind_power_reference +0.0,4000.0 +10.0,4000.0 +20.0,3000.0 +30.0,3000.0 +39.0,3000.0 +40.0,2000.0 +50.0,2000.0 +59.0,2000.0 +60.0,3000.0 +70.0,3000.0 +80.0,4000.0 \ No newline at end of file diff --git a/tests/controller_library_test.py b/tests/controller_library_test.py index 4235f2ae..ccdb712d 100644 --- a/tests/controller_library_test.py +++ b/tests/controller_library_test.py @@ -16,8 +16,11 @@ from whoc.controllers import ( LookupBasedWakeSteeringController, WindBatteryController, + WindFarmPowerDistributingController, + WindFarmPowerTrackingController, ) -from whoc.interfaces import HerculesADYawInterface, HerculesWindBatteryInterface +from whoc.controllers.wind_farm_power_tracking_controller import POWER_SETPOINT_DEFAULT +from whoc.interfaces import HerculesADInterface, HerculesWindBatteryInterface from whoc.interfaces.interface_base import InterfaceBase @@ -52,6 +55,7 @@ def send_controls(self): } }, "py_sims": {"test_battery": {"outputs": 10.0}}, + "external_signals": {"wind_power_reference": 1000.0}, } @@ -64,10 +68,12 @@ def test_controller_instantiation(): _ = LookupBasedWakeSteeringController(interface=test_interface, input_dict=test_hercules_dict) _ = WindBatteryController(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) def test_LookupBasedWakeSteeringController(): - test_interface = HerculesADYawInterface(test_hercules_dict) + test_interface = HerculesADInterface(test_hercules_dict) # No lookup table passed; simply passes through wind direction to yaw angles test_controller = LookupBasedWakeSteeringController( @@ -112,7 +118,6 @@ def test_LookupBasedWakeSteeringController(): assert np.allclose(test_angles, wind_directions - test_offsets) def test_WindBatteryController(): - # TODO: possibly clean up HerculesWindBatteryController class test_interface = HerculesWindBatteryInterface(test_hercules_dict) test_controller = WindBatteryController(test_interface, test_hercules_dict) @@ -140,6 +145,82 @@ def test_WindBatteryController(): hercules_dict_out = test_controller.step(test_hercules_dict) assert hercules_dict_out["setpoints"]["battery"]["signal"] == -500 +def test_WindFarmPowerDistributingController(): + test_interface = HerculesADInterface(test_hercules_dict) - + test_controller = WindFarmPowerDistributingController( + interface=test_interface, + input_dict=test_hercules_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(hercules_dict=test_hercules_dict) + test_power_setpoints = np.array( + test_hercules_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"] + ) + + # Test with power reference + test_hercules_dict["external_signals"]["wind_power_reference"] = 1000 + test_hercules_dict_out = test_controller.step(hercules_dict=test_hercules_dict) + test_power_setpoints = np.array( + test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + ) + assert np.allclose(test_power_setpoints, 500) +def test_WindFarmPowerTrackingController(): + test_interface = HerculesADInterface(test_hercules_dict) + + test_controller = WindFarmPowerTrackingController( + interface=test_interface, + input_dict=test_hercules_dict + ) + + # 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(hercules_dict=test_hercules_dict) + test_power_setpoints = np.array( + test_hercules_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(hercules_dict=test_hercules_dict) + test_power_setpoints = np.array( + test_hercules_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"] + ).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(hercules_dict=test_hercules_dict) + test_power_setpoints = np.array( + test_hercules_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"] + ).all() + + # Test that more aggressive control leads to faster response + test_controller = WindFarmPowerTrackingController( + interface=test_interface, + input_dict=test_hercules_dict, + proportional_gain=2 + ) + test_hercules_dict["hercules_comms"]["amr_wind"]["test_farm"]["turbine_powers"] = [600, 600] + test_hercules_dict_out = test_controller.step(hercules_dict=test_hercules_dict) + test_power_setpoints_a = np.array( + test_hercules_dict_out["hercules_comms"]["amr_wind"]["test_farm"]["turbine_power_setpoints"] + ) + assert (test_power_setpoints_a < test_power_setpoints).all() diff --git a/tests/interface_library_test.py b/tests/interface_library_test.py index b5622019..a0bdf860 100644 --- a/tests/interface_library_test.py +++ b/tests/interface_library_test.py @@ -14,7 +14,7 @@ import pytest from whoc.interfaces import ( - HerculesADYawInterface, + HerculesADInterface, HerculesWindBatteryInterface, ) @@ -31,6 +31,7 @@ } }, "py_sims": {"test_battery": {"outputs": 10.0}}, + "external_signals": {"wind_power_reference": 1000.0}, } @@ -40,13 +41,13 @@ def test_interface_instantiation(): each implement the required methods specified by InterfaceBase. """ - _ = HerculesADYawInterface(hercules_dict=test_hercules_dict) + _ = HerculesADInterface(hercules_dict=test_hercules_dict) _ = HerculesWindBatteryInterface(hercules_dict=test_hercules_dict) # _ = ROSCO_ZMQInterface() -def test_HerculesADYawInterface(): - interface = HerculesADYawInterface(hercules_dict=test_hercules_dict) +def test_HerculesADInterface(): + interface = HerculesADInterface(hercules_dict=test_hercules_dict) # Test get_measurements() measurements = interface.get_measurements(hercules_dict=test_hercules_dict) @@ -63,13 +64,19 @@ def test_HerculesADYawInterface(): # Test check_controls() controls_dict = {"yaw_angles": [270.0, 278.9]} - interface.check_controls(controls_dict) # Should not raise an error + controls_dict2 = { + "yaw_angles": [270.0, 268.9], + "power_setpoints": [3000.0, 3000.0], + } + interface.check_controls(controls_dict) + interface.check_controls(controls_dict2) 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 + "unavailable_control": [0.0, 0.0], + } bad_controls_dict3 = {"yaw_angles": [270.0, 268.9, 270.0]} # Mismatched number of turbines with pytest.raises(ValueError): diff --git a/whoc/controllers/__init__.py b/whoc/controllers/__init__.py index 584c73cd..600a43ef 100644 --- a/whoc/controllers/__init__.py +++ b/whoc/controllers/__init__.py @@ -1,3 +1,7 @@ from whoc.controllers.lookup_based_wake_steering_controller import LookupBasedWakeSteeringController from whoc.controllers.wake_steering_rosco_standin import WakeSteeringROSCOStandin from whoc.controllers.wind_battery_controller import WindBatteryController +from whoc.controllers.wind_farm_power_tracking_controller import ( + WindFarmPowerDistributingController, + WindFarmPowerTrackingController, +) diff --git a/whoc/controllers/lookup_based_wake_steering_controller.py b/whoc/controllers/lookup_based_wake_steering_controller.py index d504f2d0..28aec81a 100644 --- a/whoc/controllers/lookup_based_wake_steering_controller.py +++ b/whoc/controllers/lookup_based_wake_steering_controller.py @@ -59,7 +59,7 @@ def wake_steering_angles(self): # Handle possible bad data wind_directions = self.measurements_dict["wind_directions"] wind_speeds = [8.0]*self.n_turbines # TODO: enable extraction of wind speed in Hercules - if not wind_directions: # Recieved empty or None + 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 diff --git a/whoc/controllers/wind_farm_power_tracking_controller.py b/whoc/controllers/wind_farm_power_tracking_controller.py new file mode 100644 index 00000000..bd2f00b8 --- /dev/null +++ b/whoc/controllers/wind_farm_power_tracking_controller.py @@ -0,0 +1,143 @@ +# 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 + +# Default power setpoint in kW (meant to ensure power maximization) +POWER_SETPOINT_DEFAULT = 1e9 + +class WindFarmPowerDistributingController(ControllerBase): + """ + 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) + + 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 + self.controls_dict = {"power_setpoints": [POWER_SETPOINT_DEFAULT] * self.n_turbines} + + # For startup + + + def compute_controls(self): + if "wind_power_reference" in self.measurements_dict: + farm_power_reference = self.measurements_dict["wind_power_reference"] + else: + farm_power_reference = POWER_SETPOINT_DEFAULT + + self.turbine_power_references(farm_power_reference=farm_power_reference) + + def turbine_power_references(self, farm_power_reference=POWER_SETPOINT_DEFAULT): + """ + Compute turbine-level power setpoints based on farm-level power + reference signal. + Inputs: + - farm_power_reference: float, farm-level power reference signal + Outputs: + - None (sets self.controls_dict) + """ + + # Split farm power reference among turbines and set "no value" for yaw angles (Floris not + # compatible with both power_setpoints and yaw_angles). + self.controls_dict = { + "power_setpoints": [farm_power_reference/self.n_turbines]*self.n_turbines, + "yaw_angles": [-1000]*self.n_turbines + } + + return None + +class WindFarmPowerTrackingController(WindFarmPowerDistributingController): + """ + Based on controller developed under A2e2g project. Proportional control only--- + all integral action is disabled. + + Inherits from WindFarmPowerDistributingController. + """ + + def __init__(self, interface, input_dict, proportional_gain=1, verbose=False): + super().__init__(interface, input_dict, verbose=verbose) + + # No integral action for now. beta and omega_n not used. + # beta=0.7 + # omega_n=0.01 + # integral_gain=0 + + self.K_p = proportional_gain * 1/self.n_turbines + # self.K_i = integral_gain *(4*beta*omega_n) + + # Initialize controller (only used for integral action) + # self.e_prev = 0 + # self.u_prev = 0 + # self.u_i_prev = 0 + # self.ai_prev = [0.33]*self.n_turbines # TODO: different method for anti-windup? + # self.n_saturated = 0 + + def turbine_power_references(self, farm_power_reference=POWER_SETPOINT_DEFAULT): + """ + Compute turbine-level power setpoints based on farm-level power + reference signal. + Inputs: + - farm_power_reference: float, farm-level power reference signal + Outputs: + - None (sets self.controls_dict) + """ + + turbine_current_powers = self.measurements_dict["turbine_powers"] + farm_current_power = np.sum(turbine_current_powers) + farm_current_error = farm_power_reference - farm_current_power + + 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) + else: + gain_adjustment = self.n_turbines + 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 + + # Apply integral anti-windup + #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 + delta_P_ref = u + + turbine_power_setpoints = np.array(turbine_current_powers) + delta_P_ref + + # set "no value" for yaw angles (Floris not compatible with both + # power_setpoints and yaw_angles) + self.controls_dict = { + "power_setpoints": list(turbine_power_setpoints), + "yaw_angles": [-1000]*self.n_turbines + } + + # Store error, control (only needed for integral action, which is disabled) + # self.e_prev = farm_current_error + # self.u_prev = u + # self.u_i_prev = u_i + + return None diff --git a/whoc/interfaces/__init__.py b/whoc/interfaces/__init__.py index 8c80dc38..0d7c2f70 100644 --- a/whoc/interfaces/__init__.py +++ b/whoc/interfaces/__init__.py @@ -1,3 +1,3 @@ -from whoc.interfaces.hercules_actuator_disk_yaw_interface import HerculesADYawInterface +from whoc.interfaces.hercules_actuator_disk_interface import HerculesADInterface 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_interface.py similarity index 70% rename from whoc/interfaces/hercules_actuator_disk_yaw_interface.py rename to whoc/interfaces/hercules_actuator_disk_interface.py index 5dd76806..342ff0aa 100644 --- a/whoc/interfaces/hercules_actuator_disk_yaw_interface.py +++ b/whoc/interfaces/hercules_actuator_disk_interface.py @@ -14,10 +14,11 @@ # How will we handle other things here? May need to have a wind farm # version, an electrolyzer version, etc... +from whoc.controllers.wind_farm_power_tracking_controller import POWER_SETPOINT_DEFAULT from whoc.interfaces.interface_base import InterfaceBase -class HerculesADYawInterface(InterfaceBase): +class HerculesADInterface(InterfaceBase): def __init__(self, hercules_dict): super().__init__() @@ -38,20 +39,26 @@ def get_measurements(self, hercules_dict): # ["amr_wind"]\ # [self.wf_name]\ # ["turbine_wind_speeds"] - powers = hercules_dict["hercules_comms"]["amr_wind"][self.wf_name]["turbine_powers"] + turbine_powers = hercules_dict["hercules_comms"]["amr_wind"][self.wf_name]["turbine_powers"] time = hercules_dict["time"] + if "wind_power_reference" in hercules_dict["external_signals"]: + wind_power_reference = hercules_dict["external_signals"]["wind_power_reference"] + else: + wind_power_reference = POWER_SETPOINT_DEFAULT + measurements = { "time": time, "wind_directions": wind_directions, # "wind_speeds":wind_speeds, - "turbine_powers": powers, + "turbine_powers": turbine_powers, + "wind_power_reference": wind_power_reference, } return measurements def check_controls(self, controls_dict): - available_controls = ["yaw_angles"] + available_controls = ["yaw_angles", "power_setpoints"] for k in controls_dict.keys(): if k not in available_controls: @@ -61,10 +68,15 @@ def check_controls(self, controls_dict): "Length of setpoint " + k + " does not match the number of turbines." ) - def send_controls(self, hercules_dict, yaw_angles=None): + def send_controls(self, hercules_dict, yaw_angles=None, power_setpoints=None): if yaw_angles is None: - yaw_angles = [0.0] * self.n_turbines + yaw_angles = [-1000] * self.n_turbines + if power_setpoints is 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 return hercules_dict