diff --git a/docs/OtherSupporting/OutListParameters.xlsx b/docs/OtherSupporting/OutListParameters.xlsx index 97fd75bc0b..76550afcc0 100644 Binary files a/docs/OtherSupporting/OutListParameters.xlsx and b/docs/OtherSupporting/OutListParameters.xlsx differ diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000000..33dff05dde --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,40 @@ +/* Set the screen width maximum a bit wider than default. Improves readability on wide screens, but still scales down in width on smaller screen */ +.wy-nav-content { + max-width: 1200px !important; /* the default was 800. allowing full width is too much on super wide screens */ +} + + +/* Target the last column of any table and force word-wrapping */ +table.docutils td:last-child, +table.citation td:last-child { + white-space: normal !important; + word-break: break-word; + min-width: 300px; /* Adjust this to match your ~100 character preference */ +} + +/* rather than the above, for a targetted table approach, use the below one by adding the following at the table +.. csv-table:: My Table Name + :class: wrap-last + :headers: "Command", "Description", "Very Long Comment" + :widths: 20, 20, 60 +*/ +/* Target only tables explicitly marked with the 'wrap-last' class */ +table.wrap-last td:last-child { + white-space: normal !important; + word-break: break-word; + min-width: 300px; +} + + + +/* Mermaid diagrams: fill the content column rather than rendering at + natural size (mermaid only scales down, never up). sphinxcontrib-mermaid + 2.x emits
, older versions, + so match the class alone. */ +.mermaid svg { + width: 70% !important; + max-width: 70% !important; + height: auto !important; + display: block; + margin: 0 auto; +} diff --git a/docs/conf.py b/docs/conf.py index e3ece79960..dc288c8302 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -283,6 +283,29 @@ def setup(app): objname="CMake configuration value", indextemplate="pair: %s; CMake configuration" ) + + +def setup(app): + # Register your new custom width override file + app.add_css_file('custom.css') + + try: + app.add_css_file('css/math_eq.css') + except: + pass + app.add_object_type( + "confval", + "confval", + objname="input file parameter", + indextemplate="pair: %s; input file parameter" + ) + app.add_object_type( + "cmakeval", + "cmakeval", + objname="CMake configuration value", + indextemplate="pair: %s; CMake configuration" + ) + # --- Prolog that will be included at the top of every rst file # Here: defining the role :red: for html and latex rst_prolog = r""" diff --git a/docs/requirements.txt b/docs/requirements.txt index 0754df7926..21bc3ed2ee 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,7 +5,7 @@ pyparsing>=2.1 Sphinx>=1.8.5 sphinxcontrib-bibtex>=2.0.0 sphinxcontrib-doxylink>=1.3 -sphinxcontrib-mermaid>=0.6 +sphinxcontrib-mermaid>=2.1 sphinx_rtd_theme>=0.3 requests[security] urllib3<2.0.0 diff --git a/docs/source/user/aerodyn/driver.rst b/docs/source/user/aerodyn/driver.rst index 23577f3a34..3eb06f8044 100644 --- a/docs/source/user/aerodyn/driver.rst +++ b/docs/source/user/aerodyn/driver.rst @@ -161,6 +161,20 @@ Yawing occurs around the :math:`z_n` axis, the rotor rotates about the :math:`x **Turbine geometry definition** +Each turbine block begins with `BasicHAWTFormat`, selecting between the two +geometry formats described below, followed by `MirrorRotor`. `MirrorRotor` is +read for both formats. Setting it to `True` runs the turbine as its mirror +image, so the rotor turns counter-clockwise viewed from upwind, without changing +any of the geometry, blade or airfoil inputs that describe it. The driver +mirrors the prescribed hub motion and blade pitch to match. In the basic HAWT +format the blade azimuth spacing also carries the rotation sense, so the blades +are numbered in the direction of rotation and blade k of a mirrored rotor is +the mirror image of blade k of a clockwise one. The advanced format takes the +blade azimuths (`BldOrientation_h`) as given, so for a mirrored rotor the user +supplies the mirrored azimuths themselves. See +:numref:`glue-code-mirror-rotor` for what the flag does and what it does not +mirror. + Two turbine input formats are supported: - basic (`BasicHAWTFormat=True`): Basic horizontal axis wind turbine (HAWT) format. @@ -178,7 +192,8 @@ Two turbine input formats are supported: .. code:: ----- Turbine(1) Geometry ------------------------------------------------------- - True BasicHAWTFormat(1) - Flag to switch between basic or generic input format {True: next 7 lines are basic inputs, False: Base/Twr/Nac/Hub/Bld geometry and motion must follow} + True BasicHAWTFormat(1) - Flag to switch between basic or generic input format {True: basic HAWT inputs BaseOriginInit to Twr2Shft, False: Base/Twr/Nac/Hub/Bld geometry and motion must follow} + False MirrorRotor(1) - Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind) 0,0,0 BaseOriginInit(1) - Coordinates of turbine base in global coordinates (m) 3 NumBlades(1) - Number of blades (-) 3. HubRad(1) - Hub radius (m) @@ -215,7 +230,8 @@ Two turbine input formats are supported: .. code:: ----- Turbine(1) Geometry ------------------------------------------------------- - False BasicHAWTFormat(1) - Flag to switch between basic or generic input format {True: next 7 lines are basic inputs, False: Base/Twr/Nac/Hub/Bld geometry and motion must follow} + False BasicHAWTFormat(1) - Flag to switch between basic or generic input format {True: basic HAWT inputs BaseOriginInit to Twr2Shft, False: Base/Twr/Nac/Hub/Bld geometry and motion must follow} + False MirrorRotor(1) - Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind) 0,0,0 BaseOriginInit(1) - x,y,z coordinates of turbine base origin (m) 0,0,0 BaseOrientationInit(1) - successive rotations (theta_x, theta_y, theta_z) defining initial orientation of the base frame from the global frame (e.g. roll, tilt, yaw) (deg) True HasTower(1) - True if turbine has a tower (flag) @@ -492,7 +508,8 @@ An example of an AeroDyn driver for a basic inflow, basic HAWT, and combined cas ----- Turbine Data ---------------------------------------------------------------------- 1 NumTurbines - Number of turbines ----- Turbine(1) Geometry --------------------------------------------------------------- - True BasicHAWTFormat(1) - Flag to switch between basic or generic input format {True: next 7 lines are basic inputs, False: Base/Twr/Nac/Hub/Bld geometry and motion must follow} + True BasicHAWTFormat(1) - Flag to switch between basic or generic input format {True: basic HAWT inputs BaseOriginInit to Twr2Shft, False: Base/Twr/Nac/Hub/Bld geometry and motion must follow} + False MirrorRotor(1) - Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind) 0,0,0 BaseOriginInit(1) - Coordinate of tower base in base coordinates (m) 3 NumBlades(1) - Number of blades (-) 3. HubRad(1) - Hub radius (m) diff --git a/docs/source/user/aerodyn/examples/ad_driver_example.dvr b/docs/source/user/aerodyn/examples/ad_driver_example.dvr index 35c27d5f59..67fc5d15da 100644 --- a/docs/source/user/aerodyn/examples/ad_driver_example.dvr +++ b/docs/source/user/aerodyn/examples/ad_driver_example.dvr @@ -26,7 +26,8 @@ False Echo - Echo input parameters to ".ech"? ----- Turbine Data ---------------------------------------------------------------------- 1 NumTurbines - Number of turbines ----- Turbine(1) Geometry --------------------------------------------------------------- - True BasicHAWTFormat(1) - Flag to switch between basic or generic input format {True: next 7 lines are basic inputs, False: Base/Twr/Nac/Hub/Bld geometry and motion must follow} + True BasicHAWTFormat(1) - Flag to switch between basic or generic input format {True: basic HAWT inputs BaseOriginInit to Twr2Shft, False: Base/Twr/Nac/Hub/Bld geometry and motion must follow} +False MirrorRotor(1) - Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind) 0,0,0 BaseOriginInit(1) - Coordinate of tower base in base coordinates (m) 3 NumBlades(1) - Number of blades (-) 3. HubRad(1) - Hub radius (m) diff --git a/docs/source/user/aerodyn/examples/ad_driver_multiple.dvr b/docs/source/user/aerodyn/examples/ad_driver_multiple.dvr index 8cd074dfe0..54b84f13e7 100644 --- a/docs/source/user/aerodyn/examples/ad_driver_multiple.dvr +++ b/docs/source/user/aerodyn/examples/ad_driver_multiple.dvr @@ -26,7 +26,8 @@ False Echo - Echo input parameters to " .ech"? ----- Turbine Data ---------------------------------------------------------------------- 2 NumTurbines - Number of turbines ----- Turbine(1) ------------------------------------------------------------------------ - False BasicHAWTFormat(1) - Flag to switch between basic or generic input format {True: next 7 lines are basic inputs, False: Base/Twr/Nac/Hub/Bld geometry and motion must follow} + False BasicHAWTFormat(1) - Flag to switch between basic or generic input format {True: basic HAWT inputs BaseOriginInit to Twr2Shft, False: Base/Twr/Nac/Hub/Bld geometry and motion must follow} +False MirrorRotor(1) - Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind) 00,00,00 BaseOriginInit(1) - x,y,z coordinates of base origin (m) 0,0,0 BaseOrientationInit(1) - successive rotations (theta_x, theta_y, theta_z) defining initial orientation of the base frame from the global frame (e.g. roll, tilt, yaw) (deg) True HasTower(1) - True if turbine has a tower (flag) @@ -66,7 +67,8 @@ True HAWTprojection(1) - True if turbine is a horizontal axis tu "unused" BldMotionFileName(1_2) - Filename containing blade pitch motion [used only when BldMotionType=1] "unused" BldMotionFileName(1_3) - Filename containing blade pitch motion [used only when BldMotionType=1] ----- Turbine(2) Geometry --------------------------------------------------------------- - True BasicHAWTFormat(2) - Flag to switch between basic or generic input format {True: next 7 lines are basic inputs, False: Base/Twr/Nac/Hub/Bld geometry and motion must follow} + True BasicHAWTFormat(2) - Flag to switch between basic or generic input format {True: basic HAWT inputs BaseOriginInit to Twr2Shft, False: Base/Twr/Nac/Hub/Bld geometry and motion must follow} +False MirrorRotor(2) - Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind) 100,0,0 BaseOriginInit(2) - Coordinate of tower base in base coordinates [used only when HasTower is True] (m) 3 NumBlades(2) - Number of blades (-) 3. HubRad(2) - Hub radius (m) diff --git a/docs/source/user/fast.farm/InputFiles.rst b/docs/source/user/fast.farm/InputFiles.rst index bcc524f4e7..f8a48191be 100644 --- a/docs/source/user/fast.farm/InputFiles.rst +++ b/docs/source/user/fast.farm/InputFiles.rst @@ -390,6 +390,13 @@ For each wind turbine: :numref:`FF:Input:OFInput` for information on the contents of the OpenFAST input files. + Two OpenFAST features are refused under FAST.Farm at initialization, + with a fatal error, rather than run unverified: an OpenFAST model with + more than one rotor (**NRotors** > 1 — only one rotor per OpenFAST + instance is supported with FAST.Farm), and a mirrored rotor + (**MirrorRotor** = True — the wake coupling has not yet been verified + for a counter-clockwise rotor; see :numref:`glue-code-mirror-rotor`). + - When **Mod_AmbWind** = 2 or 3, the Wind Turbines table has six additional columns to complete the spatial discretization of the high-resolution wind domain for each wind turbine: diff --git a/docs/source/user/glue-code/index.rst b/docs/source/user/glue-code/index.rst index cebb573088..3186107aff 100644 --- a/docs/source/user/glue-code/index.rst +++ b/docs/source/user/glue-code/index.rst @@ -15,4 +15,5 @@ the glue code from a user and module-developer perspective. modvar modglue solver + mirror_rotor linearization diff --git a/docs/source/user/glue-code/mirror_rotor.rst b/docs/source/user/glue-code/mirror_rotor.rst new file mode 100644 index 0000000000..e3571fb5ff --- /dev/null +++ b/docs/source/user/glue-code/mirror_rotor.rst @@ -0,0 +1,618 @@ +.. _glue-code-mirror-rotor: + +Mirrored (counter-clockwise) rotors +=================================== + +By convention an OpenFAST turbine rotor turns **clockwise when viewed from +upwind**. Setting ``MirrorRotor = T`` in the main OpenFAST input file makes the +rotor turn **counter-clockwise viewed from upwind** instead, without changing any +of the input files that describe it. + +The purpose is to model a counter-clockwise rotor, not to mirror a whole turbine. +The reflection is applied internally to the rotor and to the drivetrain +quantities attached to it. The tower, nacelle, support structure, mooring and +the environment are left alone, and are described exactly as they really are. + +.. contents:: + :local: + :depth: 2 + +.. _glue-code-mirror-rotor-input: + +User input +---------- + +``MirrorRotor`` is read from the **Feature Switches and Flags** section of the +main OpenFAST input file (``*.fst``) and holds one flag per rotor: + +.. code-block:: text + + 1 NRotors - Number of rotors in turbine (-) + F MirrorRotor - Flag to reverse rotor rotation direction [1 to NRotors] {F=Normal, T=Mirror} + +Nothing else changes. The ElastoDyn, AeroDyn and blade input files, the airfoil +polars and the controller are all supplied exactly as they would be for the +clockwise machine, and the turbine described by those files is mirrored +internally. + +Airfoil tables in particular are used **verbatim** — there is no polar +transformation, and the airfoil coordinate files are not modified. + +.. _glue-code-mirror-rotor-concept: + +What the flag does +------------------ + +Mirroring is the reflection :math:`S = \mathrm{diag}(1, -1, 1)` about the rotor +:math:`xz` plane. Under that reflection + +.. math:: + + \mathbf{p}' = S\,\mathbf{p}, \qquad + \mathbf{v}' = S\,\mathbf{v}, \qquad + \boldsymbol{\omega}' = -S\,\boldsymbol{\omega}, \qquad + R' = S\,R\,S + +for positions, true vectors such as force and velocity, pseudovectors such as +moment and angular velocity, and direction cosine matrices respectively. + +The reflection is applied **at module boundaries**, with one exception noted +below for the BeamDyn blade description. The physics kernels — +the blade-element momentum solver, the unsteady aerodynamics and dynamic wake +models, the airfoil interpolation, and the structural finite elements — are +never told the rotor is mirrored. They continue to solve the equivalent +clockwise problem, which is why the polars are used unchanged and why the +angle of attack, inflow angle and lift and drag coefficients come out +numerically identical to the clockwise machine. + +Quantities crossing a boundary are converted on the way in and back on the way +out. Inside ElastoDyn the azimuth and rotor speed states are the **physical** +ones, so a mirrored rotor really does have a negative shaft speed about the +:math:`+x` axis. + +The flow of the flag through the code, and what changes where: + +.. mermaid:: + + %%{init: {"theme": "base", "flowchart": {"rankSpacing": 110, "nodeSpacing": 45}, "themeVariables": {"fontSize": "18px", "primaryColor": "#d8e9f7", "primaryTextColor": "#000000", "primaryBorderColor": "#5b8db8", "lineColor": "#5b8db8"}}}%% + flowchart LR + FST["OpenFAST primary input
MirrorRotor = T (per rotor)"] --> GLUE["Glue code
hands the flag to each module at Init"] + + GLUE --> ED["ElastoDyn /
Simplified-ElastoDyn"] + GLUE --> AD["AeroDyn"] + GLUE --> BD["BeamDyn"] + GLUE --> SRV["ServoDyn + DLL"] + GLUE --> X["Refused combinations"] + GLUE -.-> K["Physics kernels unchanged:
BEMT, UA, DBEMT, AirfoilInfo,
BD finite elements, HydroDyn, MoorDyn;
airfoil polars used verbatim"] + + ED --> ED1["Azimuth, rotor speed and
acceleration reversed"] + ED --> ED2["Blade spacing carries the rotation
sense: blades numbered in the
direction of rotation"] + ED --> ED3["Rotor-convention channels and pitch
presented in the clockwise convention
at the boundary"] + + AD --> AD1["Per-blade azimuth taken from
the mirrored root meshes"] + AD --> AD2["Angle of attack in the clockwise-
equivalent airfoil frame"] + AD --> AD3["OLAF: bound circulation sign and
lifting-line LE/TE mirrored per wing —
mixed CW/mirrored rotors share one wake"] + AD --> AD4["AeroDisk: lateral force and
moment components signed"] + AD --> AD5["VTK blade surfaces: airfoil section
drawn as its mirror image"] + + BD --> BD1["Blade mass/stiffness transformed on
read: M' = T M T', T = diag(1,-1,1,-1,1,-1)
root motion follows ElastoDyn"] + + SRV --> SRV1["Sees the clockwise convention —
no controller change; yaw untouched
(inertial-frame quantity)"] + + X --> X1["Linearization · aero-map solver ·
ExtLoads · FAST.Farm · AeroAcoustics:
fatal error at Init"] + +.. _glue-code-mirror-rotor-where: + +Where the mirror is applied +--------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 22 12 66 + + * - Module + - Changed + - Where the transformation happens + * - Glue code + - yes + - Reads ``MirrorRotor`` and distributes it to ElastoDyn, AeroDyn and + BeamDyn; holds the restrictions listed below; and carries the whole + ElastoDyn-to-ServoDyn presentation layer + * - ServoDyn + - **no** + - Nothing internal. Signals are converted in the glue code, and the + generator and brake torque signs are applied inside ElastoDyn. A + Bladed-style controller is likewise untouched + * - AeroDyn + - yes + - Four places, all at the edges — see the table below + * - BeamDyn + - yes + - The blade **input data**, not the boundary. See + :ref:`glue-code-mirror-rotor-beamdyn` + * - ElastoDyn + - yes + - Initial states, blade-pitch geometry, the gearbox, and the outputs + * - SimplifiedElastoDyn + - yes + - The same pattern as ElastoDyn + * - AeroDisk + - yes + - The rotor speed and tip-speed ratio going into the coefficient table, + the skew-aligned disk triad, and the moment components coming out. The + triad is built so that all three basis vectors mirror as true vectors, + which makes the disk-frame force components invariant and every + disk-frame moment component change sign + * - InflowWind + - **no** + - The flag mirrors the turbine, not the environment; a reflected + turbulence box is supplied by the user + * - BEMT, UnsteadyAero, DBEMT, AirfoilInfo + - **no** + - The physics kernels solve the equivalent clockwise problem + * - OLAF free vortex wake + - yes + - The circulation, the angle of attack and the lifting-line geometry, per + wing. The wake itself is left in the physical frame; see + :ref:`glue-code-mirror-rotor-olaf` + * - AeroDyn driver + - yes + - Per-turbine flag; mirrors the prescribed hub kinematics and pitch + * - SimplifiedElastoDyn driver + - yes + - Flag pass-through only + +Within AeroDyn: + +.. list-table:: + :header-rows: 1 + :widths: 34 46 20 + + * - What + - Where + - Direction + * - Blade twist and sweep + - On read, after the cant angle is derived from the twist + - In + * - Rotor speed, blade pitch, toe angle, in-plane inflow, blade angular rate + - ``SetInputsForBEMT`` + - In + * - Skew-aligned disk frame + - ``DiskAvgValues`` — the disk normal is a pseudovector and needs an + explicit flip + - In + * - Hub and airfoil loads + - Load conversion out of the blade-element solver + - Out + * - Output channels + - Aerodynamic power, tangential force, and the lateral force, moment and + induction coefficients + - Out + +.. _glue-code-mirror-rotor-olaf: + +The free wake +------------- + +OLAF holds the wings of **every** rotor in one shared wake and one Biot-Savart +solve, so a mirrored rotor cannot be handled by reflecting the world the wake +solver sees: a farm may mix the two directions, and both must coexist in the +same frame. The rotation direction is therefore carried per wing, and the wake +stays in the physical frame. This keeps the wake visualisation, the ambient +wind sampled along the filaments, and any circulation written to a checkpoint in +the frame the user expects. + +Three things change for a mirrored wing. The angle of attack is taken in the +clockwise-equivalent frame, so the airfoil tables are used verbatim as they are +everywhere else. The bound circulation reverses, because it is a pseudovector +along a span axis that is itself a true vector. And the lifting line's leading +and trailing edges are placed on the other side of the reference point, because +the section is used as the mirror image of the tabulated one; without that the +bound vortex and the shed sheet sit on the wrong side of the chord and the wake +is not the mirror image, an error of order chord over span. + +One detail is worth stating because it looks wrong. The sign factor sits on the +**chordwise** term where the angle is taken from the airfoil direction cosine +matrix, and on the **normal** term where it is taken from the lifting-line panel. +Both describe the same angle. The panel normal is a cross product of two true +vectors and so is a pseudovector, while the rows of a direction cosine matrix +are not; the two carry opposite signs under the reflection. + +.. _glue-code-mirror-rotor-vtk: + +Visualisation surfaces +---------------------- + +The blade surface written for ``VTK_type = 1`` is built from the airfoil +coordinate files rather than from the mesh, so it carries its own sign. +``MeshWrVTK_Ln2Surface`` places each vertex at +``Position + TranslationDisp + matmul(xyz, Orientation)``, which puts +``AirfoilCoords`` component 1 along row 1 of the node's direction cosine matrix +and component 2 along row 2. Under :math:`R' = S R S` the rows do not transform +alike: row 1 becomes :math:`S\,r_1` while row 2 becomes :math:`-S\,r_2`. The +coordinates therefore carry ``RotDir`` on **component 2 alone**, so that the two +negations cancel and the leading edge remains the leading edge while the section +is drawn as the mirror image of the tabulated aerofoil, which is how it is being +used. Putting the factor on component 1, or on both, was measured and is wrong. + +This is visualisation only — these coordinates never reach the loads — but it is +worth recording, because it is the one part of the mirrored geometry that no +output channel and no node comparison can police. It was found by looking at a +rendered case, not by any tolerance: the aerofoils were drawn 2.33 m from where +they belonged while every channel and every node position agreed exactly. + +The surface is drawn three different ways, and each carries the sign separately. +When the airfoil files supply coordinates, ``AD_SetInitOut`` builds the section. +When they do not, ``BladeShape`` is left unallocated and a generic section is +synthesised instead — an S809 by ``SetVTKDefaultBladeParams`` when AeroDyn is in +use, or a rectangle when the blades come from BeamDyn or ElastoDyn without it. +A surface is written either way; it is never skipped. Note that the fallback is +reached not only when ``NumCoords = 0`` but also when the airfoil files merely +disagree on how many coordinates they have, a check the code itself describes as +overly restrictive, so a blade mixing airfoil families can land there without the +user intending it. The rotation direction reaches the fallback through ``RotDir`` +in AeroDyn's initialisation output, and through ``p_FAST%MirrorRotor`` in the glue +code. + +The generic shapes are not exempt. Before the sign was applied the S809 fallback +was drawn 1.611 m out and the rectangle 0.615 m; the rectangle is nearly immune, +being symmetric about its own chord line, but only nearly, because its closing +vertex sits at the midpoint of one long edge and its mirror lies on the opposite +edge, displacing exactly one vertex per node. Simplified ElastoDyn is the one +configuration that writes no blade surface at all, having no blade mesh. + +``reg_tests/otherTests/check_vtk_surface_mirror.py`` guards all three paths, and +has been shown to fail on each when the sign is removed. + +.. _glue-code-mirror-rotor-beamdyn: + +BeamDyn blades +-------------- + +BeamDyn is the one place where the mirror is **not** confined to a module +boundary. A BeamDyn blade is described by a reference line and by full +:math:`6 \times 6` stiffness and mass matrices that couple bending, extension, +shear and torsion, and those cross-couplings carry a handedness of their own. +Presenting mirrored motion to an unmirrored blade would not give the mirrored +result. + +The blade data is therefore transformed once, as it is read. The key-point +:math:`y` coordinates and the structural twist are negated, and each matrix is +transformed by :math:`T\,M\,T` with + +.. math:: + + T = \mathrm{diag}(1, -1, 1, -1, 1, -1) + +which is the reflection written in BeamDyn's ordering of three translational +followed by three rotational degrees of freedom. In practice an entry changes +sign if exactly one of its two indices is a :math:`y` translation, an :math:`x` +rotation or a :math:`z` rotation. The transform is its own inverse, and it +preserves the polar-inertia constraint that BeamDyn validates on input. + +The blade input file itself still describes the **clockwise** blade and is +supplied unchanged, exactly as for ElastoDyn. The finite-element solver is +still never told the rotor is mirrored. + +.. _glue-code-mirror-rotor-conventions: + +Output conventions +------------------ + +Two families of output channel behave differently, and the distinction matters +only for a mirrored rotor. + +**Channels named after a rotor or drivetrain quantity** report it in the +**rotor's own convention** — positive when the rotor turns the way it was +designed to turn, whichever way that is. ``RotSpeed`` is positive for a +normally operating rotor whether or not it is mirrored, and negative only when +the rotor is genuinely running backwards. + +**Channels carrying an explicit axis suffix** (``*Mxa``, ``*Vxa``, ``*Axa``, +``*Pxa`` and their ``*xs`` counterparts) report the **physical** component about +that axis, so they change sign under the mirror. + +Several names used to be aliases of a single value and are now separate +channels. For a clockwise rotor the two are numerically identical, so no +existing model or output file changes. + +.. list-table:: + :header-rows: 1 + :widths: 25 25 50 + + * - Rotor convention + - Physical + - Quantity + * - ``RotSpeed`` + - ``LSSTipVxa``, ``LSSTipVxs``, ``LSSTipV`` + - Rotor angular speed + * - ``RotAccel`` + - ``LSSTipAxa``, ``LSSTipAxs``, ``LSSTipA`` + - Rotor angular acceleration + * - ``Azimuth`` + - ``LSSTipPxa``, ``LSSTipPxs``, ``LSSTipP`` + - Rotor azimuth + * - ``RotTorq``, ``LSShftTq`` + - ``LSShftMxa``, ``LSShftMxs``, ``LSSGagMxa``, ``LSSGagMxs`` + - Low-speed shaft torque + +``GenSpeed``, ``GenAccel``, ``HSShftV``, ``HSShftA``, ``HSShftTq`` and +``HSShftPwr`` follow the rotor convention, so the generator side reads positive +during normal operation regardless of rotation direction. ``RotPwr`` and +``RotThrust`` are unchanged by the mirror: power is a product of two quantities +that both flip, and thrust is along the mirror axis. + +.. _glue-code-mirror-rotor-signs: + +Measured sign table +------------------- + +The table below is **measured**, not asserted. It is generated by running every +registered mirrored case and comparing each channel against its clockwise +counterpart, so it records behaviour that is actually observed rather than +behaviour that is expected from reading the code. The cases contributing to it +are the ElastoDyn + AeroDyn pair, the BeamDyn pair, the marine-turbine pair, the +yawed AeroDisk pair, the free-wake pair, the two-rotor aerodynamic driver case +and the twin-rotor semisubmersible. + +The blade azimuth spacing carries the rotation sense, so the blades are +numbered in the direction of rotation for either convention: blade k of the +mirrored rotor occupies the mirror image of blade k of the clockwise one, the +blades pass a fixed azimuth in numeric order either way, and every per-blade +channel compares directly against the channel of the same number. Mooring +lines are different: the platform layout mirrors as geometry, so which line +pairs with which depends on the layout — on the OC4 semisubmersible, lines 1 +and 3 exchange. + +It records how the two runs of a **symmetric** comparison relate to each other, +which is how the implementation is verified. It is not a claim that these +channels change sign whenever the flag is set: in an ordinary simulation the +tower, support structure and inflow are not mirrored, so a quantity such as +``TwrBsMxt`` is simply the response of an unchanged structure to a +counter-clockwise rotor. + +The table lists only what **changes** between the two runs of the symmetric +comparison, grouped by the physical rule that flips each family. Every channel +not listed here measures **identical**; the complete per-channel classification +of all 1206 channels, identical ones included, is generated as +:download:`mirror_rotor_sign_map.yaml`. + +.. list-table:: + :header-rows: 1 + :widths: 35 65 + + * - Sign-flipped family + - Representative channels + * - Lateral (:math:`y`) structural forces, deflections, velocities and + accelerations — the mirrored axis itself + - ``LSShftFya``, ``YawBrFyp``, ``TwrBsFyt``, ``TTDspSS``, ``TwrTpTDyi``, + ``NcIMUTAys``, ``PtfmSway``, ``HydroFyi``, ``IPDefl*``, ``TipDyc*``, + ``RootFyc*``, ``RootFyb*``, BeamDyn ``B*RootFyr`` and ``B*TipTDyr`` + * - Moments and rotations about the :math:`x`- and :math:`z`-axes — + pseudovector components in the mirror plane + - ``TwrBsMxt``, ``TwrBsMzt``, ``YawBrMxp``, ``YawBrMzp``, ``PtfmRoll``, + ``PtfmYaw``, ``RootMxc*``, ``RootMzc*``, ``RootMxb*``, ``RootMzb*``, + BeamDyn ``B*RootMxr``, ``B*RootMzr``, ``B*TipRDxr``, ``B*TipRDzr`` + * - Shaft quantities in the physical ``xa``/``xs`` frames (the + rotor-convention twins ``RotSpeed``, ``RotTorq`` etc. stay identical) + - ``LSSTipVxa``, ``LSSTipAxa``, ``LSShftMxa``, ``LSShftFya``, + ``LSSTipMza``, ``LSSGagMxa``, ``LSSGagMza`` + * - Aerodynamic tangential and in-plane families, in both the module + (``B1N001Ft``) and nodal (``AB1N001Ft``) forms + - ``*Ft``, ``*Fy``, ``*Cy``, ``*Cm``, ``*Ct``, ``*Mm``, ``*Vindy``, + ``*STVy``, ``*VUndy``, ``*VDisy`` + * - Rotor-aggregate lateral loads and inflow + - ``RtAeroFyh``, ``RtAeroMxh``, ``RtAeroMzh``, ``RtAeroCq``, + ``RtVAvgyh``, ``RtFldFyh``, ``RtFldMxh``, ``RtFldMzh`` + * - AeroDisk lateral loads and speed + - ``ADFyi``, ``ADMx``, ``ADMy``, ``ADMz``, ``ADMxi``, ``ADMzi``, + ``ADSpeed`` + * - Marine-turbine buoyancy, tangential and moment components + - ``*Fbt``, ``*Mbn``, ``*Mbs``, ``HbFby``, ``HbMbx``, ``HbMbz`` + * - **Mirrored angle** (:math:`v' = -v`, wrapped) + - ``LSSTipPxa``, ``LSSGagPxa`` + +Every channel that carries a mirror sign in the code is now requested by at +least one registered case, so no entry in this table is inferred from reading +the source. The coefficient families in particular are measured together with +their mirror-invariant partners — ``*Cy``, ``*Cm`` and ``*Ct`` alongside +``*Cl``, ``*Cd``, ``*Cx`` and ``*Cn`` — so that a sign applied to a whole group +by mistake cannot pass unnoticed. + +Across the seven pairs, 1018 channels resolve: 689 identical, 328 sign-flipped +and one mirrored angle, with a further 55 below the noise floor in every case and +133 mooring channels set aside because their pairing is layout-specific. No +channel is unresolved. + +The grouped form above is the one to read. The complete per-channel map, +listing all 1206 channels alphabetically with the pairs each was measured in, +is generated by the same measurement as +:download:`mirror_rotor_sign_map.yaml ` — run +``reg_tests/otherTests/emit_sign_table.py --tol 0.005 --emit-map`` to +regenerate it; it should never be edited by hand. + + +.. _glue-code-mirror-rotor-control: + +Controllers +----------- + +ServoDyn is not told that the rotor has been reversed. Everything crossing its +boundary is presented in the **clockwise convention**, so an unmodified +controller — including a Bladed-style DLL such as ROSCO — sees exactly what it +would see on a clockwise machine and behaves identically. Rotor speed, blade +pitch, generator and brake torque, shaft azimuth and the blade root moments are +all converted; the controller needs no mirrored copy and no new input. + +Assumptions of even blade spacing *in the direction of rotation* also hold, +because the blades are numbered that way for either convention: ServoDyn's +built-in cosine flap schedule and the per-blade azimuth offsets a Bladed-style +DLL computes for itself from the rotor azimuth are correct for a mirrored rotor +with no controller-side change. + +Yaw is the exception, and it is deliberate. Yaw acts about the vertical axis in +the inertial frame, so it is **not** a rotor-convention quantity and is left +alone. The yaw angle, the wind direction and hence the yaw error all stay +physically correct, which means a yaw controller still points the nacelle into +the real wind rather than into its mirror image. + +.. _glue-code-mirror-rotor-asymmetric: + +Nothing outside the rotor is mirrored +------------------------------------- + +The flag reverses the **rotor**. It does not touch the environment, the control +setpoints, the support structure, or anything else that happens to be one-sided. +For ordinary use that is exactly what is wanted: a counter-clockwise rotor, on +the turbine and in the conditions you actually have. A real mooring spread, a +real wind field and a real yaw setpoint should all be left as they are. + +The list below matters only when **verifying** the implementation. That check +compares a clockwise run against a mirrored one and expects the two to be +reflections of each other, which requires the whole problem — not just the rotor +— to be symmetric about the rotor ``xz`` plane. For that comparison, and only +for it, these have to be mirrored by hand: + +- initial or fixed nacelle yaw (``NacYaw``) and the neutral yaw position + (``YawNeut``); +- wind direction and horizontal shear in a uniform wind file — vertical shear is + symmetric about the mirror plane and needs no change; +- a full-field turbulence box, which has to be reflected in :math:`y`; +- prescribed force and moment time series for a structural control, where the + lateral force and the roll and yaw moments change sign; +- lateral geometry such as ``NacCMyn``; +- the furl geometry of a furling turbine — the tail boom, tail fin and the + rotor- and tail-furl axes are all offset to one side. Setting + ``MirrorRotor`` with ``Furling = True`` raises a warning and continues: the + tail is modelled as a drag force applying a moment about the yaw axis, and + that calculation does not depend on which way the rotor turns, since tail + interaction with the wake is not modelled. The combination has not been + verified against a mirror pair, however, because doing so requires mirroring + the furl input file as well. + +Leaving one of these unmirrored during a verification run looks exactly like a +sign error in the code, which is the only reason the list is written down. + +.. _glue-code-mirror-rotor-limits: + +Limitations +----------- + +``MirrorRotor = T`` currently produces a fatal error when combined with any of +the following. Each restriction is removed as that part of the code is worked +through. + +.. list-table:: + :header-rows: 1 + :widths: 35 65 + + * - Not yet supported with + - Notes + * - Linearization + - ``Linearize = T`` + * - Steady-state solver + - ``CompAeroMaps = T`` + * - ExtLoads + - ``CompAero = 3`` + * - AeroAcoustics + - ``CompAA = True`` in the AeroDyn input file + * - FAST.Farm + - Any turbine in a farm. The mirror is confined to its own OpenFAST + instance and the blade surfaces render correctly, since each turbine is a + separate single-rotor instance, but the wake coupling has not been + verified: ``FWrap_CalcOutput`` builds the skew angle from a cross product + of the disk-averaged wind with the disk normal, which is the pseudovector + pattern that carries a sign at every other module boundary here, and the + curled-wake model encodes a swirl direction of its own. + +.. _glue-code-mirror-rotor-verification: + +Verification +------------ + +The mirror is verified by running a model twice, once clockwise and once +mirrored, and requiring **every** output channel to resolve to one of: identical, +exactly sign-flipped, a mirrored angle, or below the numerical noise floor. +Anything else indicates that two quantities have been combined while expressed in +different frames. + +The check is repeated across a matrix of conditions, since any single condition +leaves most of the sign map untested — rigid and flexible blades, ElastoDyn and +BeamDyn blades, fixed and free drivetrain, vertical shear, positive and negative +nacelle yaw, fixed and free yaw, and combinations of those. At the AeroDyn +module level the same comparison is run over blade pitch, wind speed, tip-speed +ratio, the propeller-brake state, shaft tilt, precone, both BEM models, dynamic +wake, and four unsteady-aerodynamic models. + +Some of those comparisons are kept as regression cases, each paired with the +clockwise model it mirrors — either a case that already existed, or a ``_CW`` +case registered alongside it. They all carry the ctest label ``mirrorrotor``, +so ``ctest -R mirrorrotor`` runs the set: + +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Case + - What it covers + * - ``5MW_Land_noDLL_Steady_MirrorRotor`` + - Steady wind, no controller, ElastoDyn blades. The baseline pair, pinned + against ``5MW_Land_noDLL_Steady_CW`` + * - ``5MW_Land_BD_noDLL_Steady_MirrorRotor`` + - The same with BeamDyn blades, against ``5MW_Land_BD_noDLL_Steady_CW`` + * - ``AWT_WSt_StartUp_HighSpShutDown_MirrorRotor`` + - The high-speed-shaft brake taking the rotor down through zero speed, + which is the one torque signed by the direction of rotation. Also a + two-bladed teetering rotor, for which the reversed blade spacing places + blade 2 at the same opposing azimuth either way + * - ``5MW_Land_DLL_WTurb_MirrorRotor`` + - Turbulence and a Bladed-style controller, ElastoDyn blades + * - ``5MW_Land_BD_DLL_WTurb_MirrorRotor`` + - The same with BeamDyn blades + * - ``5MW_OC4Semi_WSt_WavesWN_MirrorRotor`` + - A floating platform, exercising HydroDyn, SeaState and MoorDyn beneath a + mirrored rotor + * - ``5MW_Land_DLL_WTurb_ADsk_SED_MirrorRotor`` + - SimplifiedElastoDyn and AeroDisk in place of ElastoDyn and AeroDyn + * - ``5MW_MRSemi_DLL_WSt_WavesIrr_MirrorRotor`` + - A twin-rotor floating machine, with the whole stack solved together, so + one rotor is mirrored and the other is not. It runs OLAF, so the two + rotation directions also share a single wake + * - ``MHK_RM1_Floating_Steady_MirrorRotor`` + - A marine turbine, where the blade buoyancy and centre-of-buoyancy offset + are mirrored too. Paired with ``MHK_RM1_Floating_Steady_CW`` + * - ``ad_MultipleHAWT_MirrorRotor`` + - The AeroDyn driver rather than the glue code, covering the **nodal** + output path with two rotors in a single run + * - ``ad_B1n2_OLAF_MirrorRotor`` + - The free wake, paired with ``ad_B1n2_OLAF_CW``. A single blade of two + aerodynamic sections, which is the cheapest case that is still a rotor + * - ``5MW_Land_ADsk_SED_Yaw_MirrorRotor`` + - A yawed AeroDisk rotor, paired with ``5MW_Land_ADsk_SED_Yaw_CW``. The + nacelle yaw is reversed between the two, since yaw acts about the + inertial vertical and is not mirrored. It uses a coefficient table with + the lateral coefficients filled in, because the shipped 5MW table has + them identically zero and so cannot reach the lateral sign factors at all + +The two turbulent cases are the ones that demonstrate the controller claim. The +DISCON library is used completely unchanged, and blade pitch, generator torque, +generator power, generator speed and rotor speed all come out identical between +the clockwise and mirrored runs. They read a ``y``-reflected copy of the +turbulence box, for the reason given above. + +For a clockwise rotor every mirror-related expression reduces to a multiplication +by ``+1``, so existing regression baselines reproduce bit-for-bit. + +Two further checks live in ``reg_tests/otherTests/``. +``check_rtest_mirror_pair.py`` compares the committed baselines of every pair +against each other and carries a **sweep-order assertion** on the ``BAzimuth`` +channels: the blade spacing from blade 1 must read +120° and +240° in the +rotor's own convention for the clockwise *and* the mirrored half, which guards +the blade-numbering convention directly — under the reversed numbering the +mirrored half reads 240° and 120°, a full blade spacing of discrimination. +``run_guards.sh`` proves every restriction in the table above actually +**fires** when its combination is requested, and stays silent for a clockwise +rotor — including the FAST.Farm refusals, which it exercises against the real +FAST.Farm executable — alongside the visualisation-surface comparison. A guard +that is never reached looks exactly like a guard that stayed silent, which is +why the firing is tested rather than assumed. diff --git a/docs/source/user/glue-code/mirror_rotor_sign_map.yaml b/docs/source/user/glue-code/mirror_rotor_sign_map.yaml new file mode 100644 index 0000000000..11d5c0d38b --- /dev/null +++ b/docs/source/user/glue-code/mirror_rotor_sign_map.yaml @@ -0,0 +1,1223 @@ +# Measured mirror sign map -- GENERATED, do not edit by hand. +# Regenerate with: +# python3 reg_tests/otherTests/emit_sign_table.py --tol 0.005 --emit-map +# +# Every entry is observed by running a registered clockwise/mirrored +# pair and comparing the channel against its counterpart. Nothing here +# is inferred from reading the source. +tolerance: 0.005 +noise_floor: 1e-08 +behaviours: + S: {name: "identical", meaning: "v' = v"} + F: {name: "sign-flipped", meaning: "v' = -v"} + A: {name: "mirrored angle", meaning: "v' = -v, wrapped"} + negligible: {name: "below the noise floor", meaning: "indistinguishable from zero everywhere"} + "?": {name: "unresolved", meaning: "measured but not classified"} + mooring: {name: "set aside", meaning: "pairing is layout-specific"} +channels: + AB1N001Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N001Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Fx: {behaviour: negligible, measured_in: [AeroDyn nodal outputs]} + AB1N001Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N001Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Mm: {behaviour: negligible, measured_in: [AeroDyn nodal outputs]} + AB1N001Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001STVx: {behaviour: negligible, measured_in: [OLAF free wake]} + AB1N001STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001STVz: {behaviour: negligible, measured_in: [OLAF free wake]} + AB1N001Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N001TnInd: {behaviour: negligible, measured_in: [AeroDyn nodal outputs]} + AB1N001VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N001Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001VRel: {behaviour: S, measured_in: [OLAF free wake]} + AB1N001Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N001VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N001VUndy: {behaviour: F, measured_in: [OLAF free wake]} + AB1N001VUndz: {behaviour: S, measured_in: [OLAF free wake]} + AB1N002Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N002Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N002Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N002Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N002Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002STVx: {behaviour: negligible, measured_in: [OLAF free wake]} + AB1N002STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002STVz: {behaviour: negligible, measured_in: [OLAF free wake]} + AB1N002Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N002TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N002VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N002Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002VRel: {behaviour: S, measured_in: [OLAF free wake]} + AB1N002Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N002VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + AB1N002VUndy: {behaviour: F, measured_in: [OLAF free wake]} + AB1N002VUndz: {behaviour: S, measured_in: [OLAF free wake]} + AB1N003Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N003Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N003Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N003Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N003Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N003Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N003Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N003Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N003Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N003VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N004Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N004Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N004Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N004Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N004Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N004Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N004Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N004Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N004VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N005Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N005Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N005Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N005Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N005Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N005Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N005Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N005Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N005VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N006Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N006Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N006Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N006Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N006Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N006Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N006Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N006Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N006VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N007Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N007Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N007Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N007Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N007Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N007Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N007Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N007Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N007VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N008Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N008Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N008Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N008Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N008Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N008Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N008Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N008Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N008VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N009Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N009Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N009Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N009Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N009Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N009Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N009Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N009Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N009VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N010Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N010Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N010Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N010Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N010Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N010Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N010Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N010Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N010VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N011Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N011Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N011Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N011Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N011Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N011Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N011Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N011Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N011VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N012Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N012Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N012Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N012Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N012Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N012Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N012Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N012Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N012VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N013Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N013Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N013Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N013Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N013Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N013Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N013Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N013Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N013VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N014Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N014Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N014Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N014Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N014Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N014Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N014Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N014Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N014VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N015Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N015Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N015Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N015Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N015Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N015Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N015Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N015Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N015VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N016Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N016Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N016Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N016Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N016Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N016Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N016Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N016Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N016VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N017Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N017Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N017Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N017Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N017Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N017Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N017Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N017Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N017VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N018Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N018Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N018Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N018Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N018Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N018Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N018Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N018Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N018VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N019Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N019Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N019Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N019Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N019Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N019Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N019Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N019Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N019VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N020Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N020Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N020Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N020Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N020Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N020Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N020Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N020Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N020VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N021Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N021Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N021Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N021Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N021Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N021Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N021Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N021Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N021VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N022Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N022Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N022Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N022Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N022Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N022Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N022Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N022Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N022VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N023Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N023Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N023Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N023Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N023Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N023Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N023Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N023Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N023VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N024Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N024Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N024Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N024Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N024Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N024Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N024Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N024Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N024VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N025Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N025Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N025Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N025Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N025Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N025Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N025Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N025Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N025VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N026Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N026Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N026Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N026Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N026Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N026Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N026Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N026Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N026VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N027Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N027Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N027Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N027Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N027Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N027Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N027Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N027Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N027VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N028Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N028Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N028Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N028Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N028Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N028Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N028Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N028Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N028VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N029Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N029Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N029Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N029Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N029Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N029Phi: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N029Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029TnInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029Vindy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N029Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N029VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Alpha: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030AxInd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Cd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Cl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Cm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N030Cn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Ct: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N030Cx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Cy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N030Fd: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Fl: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Fn: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Ft: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N030Fx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Fy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N030Gam: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Mm: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N030Phi: {behaviour: negligible, measured_in: [AeroDyn nodal outputs]} + AB1N030STVy: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + AB1N030Theta: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030TnInd: {behaviour: negligible, measured_in: [AeroDyn nodal outputs]} + AB1N030VDisx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Vindx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030Vindy: {behaviour: negligible, measured_in: [AeroDyn nodal outputs]} + AB1N030Vrel: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + AB1N030VUndx: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + ADCp: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADCq: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADCt: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADFx: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADFxi: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADFy: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADFyi: {behaviour: F, measured_in: [AeroDisk, yawed]} + ADFz: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADFzi: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADMx: {behaviour: F, measured_in: [AeroDisk, yawed]} + ADMxi: {behaviour: F, measured_in: [AeroDisk, yawed]} + ADMy: {behaviour: F, measured_in: [AeroDisk, yawed]} + ADMyi: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADMz: {behaviour: F, measured_in: [AeroDisk, yawed]} + ADMzi: {behaviour: F, measured_in: [AeroDisk, yawed]} + ADPitch: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + ADPower: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADSkew: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADSpeed: {behaviour: F, measured_in: [AeroDisk, yawed]} + ADSTVx: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + ADSTVxi: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + ADSTVy: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + ADSTVyi: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + ADSTVz: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + ADSTVzi: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + ADTSR: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADVRel: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADVWindx: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADVWindxi: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADVWindy: {behaviour: S, measured_in: [AeroDisk, yawed]} + ADVWindyi: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + ADVWindz: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + ADVWindzi: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + ADYawErr: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + ANCHTEN1: {behaviour: mooring, measured_in: [MHK buoyancy]} + ANCHTEN2: {behaviour: mooring, measured_in: [MHK buoyancy]} + ANCHTEN3: {behaviour: mooring, measured_in: [MHK buoyancy]} + ANCHTEN4: {behaviour: mooring, measured_in: [MHK buoyancy]} + Azimuth: {behaviour: S, measured_in: [AeroDisk, yawed, AeroDyn nodal outputs, BeamDyn blades, ElastoDyn + AeroDyn, OLAF free wake, Twin-rotor semisubmersible]} + B1AeroPwr: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1Azimuth: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1FldFz: {behaviour: S, measured_in: [MHK buoyancy]} + B1N1Alpha: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + B1N1Cd: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N1Cl: {behaviour: negligible, measured_in: [ElastoDyn + AeroDyn]} + B1N1Cm: {behaviour: negligible, measured_in: [ElastoDyn + AeroDyn]} + B1N1Cn: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N1Ct: {behaviour: F, measured_in: [ElastoDyn + AeroDyn]} + B1N1Cx: {behaviour: negligible, measured_in: [ElastoDyn + AeroDyn]} + B1N1Cy: {behaviour: F, measured_in: [ElastoDyn + AeroDyn]} + B1N1Fd: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N1Fl: {behaviour: negligible, measured_in: [ElastoDyn + AeroDyn]} + B1N1Fn: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + B1N1Ft: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + B1N1Fx: {behaviour: negligible, measured_in: [ElastoDyn + AeroDyn]} + B1N1Fy: {behaviour: F, measured_in: [ElastoDyn + AeroDyn]} + B1N1Theta: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + B1N1VIndx: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N1VIndy: {behaviour: negligible, measured_in: [ElastoDyn + AeroDyn]} + B1N2Alpha: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + B1N2Cd: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N2Cl: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N2Cm: {behaviour: F, measured_in: [ElastoDyn + AeroDyn]} + B1N2Cn: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N2Ct: {behaviour: F, measured_in: [ElastoDyn + AeroDyn]} + B1N2Cx: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N2Cy: {behaviour: F, measured_in: [ElastoDyn + AeroDyn]} + B1N2Fd: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N2Fl: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N2Fn: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + B1N2Ft: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + B1N2Fx: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N2Fy: {behaviour: F, measured_in: [ElastoDyn + AeroDyn]} + B1N2Mbn: {behaviour: F, measured_in: [MHK buoyancy]} + B1N2SgCav: {behaviour: S, measured_in: [MHK buoyancy]} + B1N2Theta: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + B1N2VIndx: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N2VIndy: {behaviour: F, measured_in: [ElastoDyn + AeroDyn]} + B1N3Alpha: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + B1N3Cd: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N3Cl: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N3Clrnc: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + B1N3Cm: {behaviour: F, measured_in: [ElastoDyn + AeroDyn]} + B1N3Cn: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N3Ct: {behaviour: F, measured_in: [ElastoDyn + AeroDyn]} + B1N3Cx: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N3Cy: {behaviour: F, measured_in: [ElastoDyn + AeroDyn]} + B1N3Fd: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N3Fl: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N3Fn: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + B1N3Ft: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + B1N3Fx: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N3Fy: {behaviour: F, measured_in: [ElastoDyn + AeroDyn]} + B1N3SigCr: {behaviour: S, measured_in: [MHK buoyancy]} + B1N3Theta: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + B1N3VIndx: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B1N3VIndy: {behaviour: negligible, measured_in: [ElastoDyn + AeroDyn]} + B1N6Mbs: {behaviour: F, measured_in: [MHK buoyancy]} + B1N7Fbt: {behaviour: F, measured_in: [MHK buoyancy]} + B1RootFxr: {behaviour: S, measured_in: [BeamDyn blades]} + B1RootFyr: {behaviour: F, measured_in: [BeamDyn blades]} + B1RootFzr: {behaviour: S, measured_in: [BeamDyn blades]} + B1RootMxr: {behaviour: F, measured_in: [BeamDyn blades]} + B1RootMyr: {behaviour: S, measured_in: [BeamDyn blades]} + B1RootMzr: {behaviour: F, measured_in: [BeamDyn blades]} + B1TipRDxr: {behaviour: F, measured_in: [BeamDyn blades]} + B1TipRDyr: {behaviour: S, measured_in: [BeamDyn blades]} + B1TipRDzr: {behaviour: F, measured_in: [BeamDyn blades]} + B1TipTDxr: {behaviour: S, measured_in: [BeamDyn blades]} + B1TipTDyr: {behaviour: F, measured_in: [BeamDyn blades]} + B1TipTDzr: {behaviour: S, measured_in: [BeamDyn blades]} + B2AeroPwr: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B2Azimuth: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B2FldMx: {behaviour: F, measured_in: [MHK buoyancy]} + B2N3Clrnc: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + B2N3Mbt: {behaviour: S, measured_in: [MHK buoyancy]} + B2N4Fbn: {behaviour: S, measured_in: [MHK buoyancy]} + B2N5SigCr: {behaviour: S, measured_in: [MHK buoyancy]} + B2N6SgCav: {behaviour: S, measured_in: [MHK buoyancy]} + B2N8Fbs: {behaviour: S, measured_in: [MHK buoyancy]} + B2RootFxr: {behaviour: S, measured_in: [BeamDyn blades]} + B2RootFyr: {behaviour: F, measured_in: [BeamDyn blades]} + B2RootFzr: {behaviour: S, measured_in: [BeamDyn blades]} + B2RootMxr: {behaviour: F, measured_in: [BeamDyn blades]} + B2RootMyr: {behaviour: S, measured_in: [BeamDyn blades]} + B2RootMzr: {behaviour: F, measured_in: [BeamDyn blades]} + B2TipRDxr: {behaviour: F, measured_in: [BeamDyn blades]} + B2TipRDyr: {behaviour: S, measured_in: [BeamDyn blades]} + B2TipRDzr: {behaviour: F, measured_in: [BeamDyn blades]} + B2TipTDxr: {behaviour: S, measured_in: [BeamDyn blades]} + B2TipTDyr: {behaviour: F, measured_in: [BeamDyn blades]} + B2TipTDzr: {behaviour: S, measured_in: [BeamDyn blades]} + B3AeroPwr: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B3Azimuth: {behaviour: S, measured_in: [ElastoDyn + AeroDyn]} + B3N3Clrnc: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + B3RootFxr: {behaviour: S, measured_in: [BeamDyn blades]} + B3RootFyr: {behaviour: F, measured_in: [BeamDyn blades]} + B3RootFzr: {behaviour: S, measured_in: [BeamDyn blades]} + B3RootMxr: {behaviour: F, measured_in: [BeamDyn blades]} + B3RootMyr: {behaviour: S, measured_in: [BeamDyn blades]} + B3RootMzr: {behaviour: F, measured_in: [BeamDyn blades]} + B3TipRDxr: {behaviour: F, measured_in: [BeamDyn blades]} + B3TipRDyr: {behaviour: S, measured_in: [BeamDyn blades]} + B3TipRDzr: {behaviour: F, measured_in: [BeamDyn blades]} + B3TipTDxr: {behaviour: S, measured_in: [BeamDyn blades]} + B3TipTDyr: {behaviour: F, measured_in: [BeamDyn blades]} + B3TipTDzr: {behaviour: S, measured_in: [BeamDyn blades]} + BldPitch1: {behaviour: negligible, measured_in: [AeroDyn nodal outputs, OLAF free wake, Twin-rotor semisubmersible]} + BldPitch2: {behaviour: negligible, measured_in: [AeroDyn nodal outputs]} + BldPitch3: {behaviour: negligible, measured_in: [AeroDyn nodal outputs]} + BlPitch1: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + BlPitch2: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + BlPitch3: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + Case: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + CON10PX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON10PY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON10PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON11PX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON11PY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON11PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON1FX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON1FY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON1FZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON2FX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON2FY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON2FZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON3FX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON3FY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON3FZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON4FX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON4FY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON4FZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON5FX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON5FY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON5FZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON5PX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON5PY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON5PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON6FX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON6FY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON6FZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON6PX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON6PY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON6PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON7FX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON7FY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON7FZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON7PX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON7PY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON7PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON8FX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON8FY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON8FZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON8PX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON8PY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON8PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON9PX: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON9PY: {behaviour: mooring, measured_in: [MHK buoyancy]} + CON9PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + FAIRTEN1: {behaviour: mooring, measured_in: [MHK buoyancy]} + FAIRTEN2: {behaviour: mooring, measured_in: [MHK buoyancy]} + FAIRTEN3: {behaviour: mooring, measured_in: [MHK buoyancy]} + FAIRTEN4: {behaviour: mooring, measured_in: [MHK buoyancy]} + GenAcc: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + GenAccel: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + GenPwr: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + GenSpeed: {behaviour: S, measured_in: [AeroDisk, yawed, BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + GenTq: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + HbFbx: {behaviour: S, measured_in: [MHK buoyancy]} + HbFby: {behaviour: F, measured_in: [MHK buoyancy]} + HbFbz: {behaviour: S, measured_in: [MHK buoyancy]} + HbMbx: {behaviour: F, measured_in: [MHK buoyancy]} + HbMby: {behaviour: S, measured_in: [MHK buoyancy]} + HbMbz: {behaviour: F, measured_in: [MHK buoyancy]} + HSShftPwr: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + HSShftTq: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + HWindSpeedX: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + HWindSpeedY: {behaviour: negligible, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + HWindSpeedZ: {behaviour: negligible, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + HydroFxi: {behaviour: S, measured_in: [MHK buoyancy]} + HydroFyi: {behaviour: F, measured_in: [MHK buoyancy]} + HydroFzi: {behaviour: S, measured_in: [MHK buoyancy]} + IPDefl1: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + IPDefl2: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + IPDefl3: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + L1N10PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N11PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N12PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N13PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N14PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N15PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N16PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N17PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N18PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N19PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N1PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N20PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N21PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N22PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N23PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N24PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N25PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N26PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N27PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N28PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N29PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N2PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N30PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N31PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N32PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N33PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N34PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N35PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N36PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N37PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N38PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N39PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N3PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N40PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N4PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N5PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N6PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N7PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N8PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L1N9PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N10PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N11PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N12PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N13PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N14PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N15PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N16PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N17PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N18PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N19PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N1PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N20PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N21PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N22PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N23PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N24PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N25PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N26PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N27PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N28PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N29PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N2PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N30PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N31PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N32PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N33PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N34PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N35PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N36PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N37PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N38PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N39PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N3PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N40PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N4PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N5PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N6PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N7PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N8PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + L3N9PZ: {behaviour: mooring, measured_in: [MHK buoyancy]} + LSSGagMxa: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + LSSGagMya: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + LSSGagMza: {behaviour: F, measured_in: [Twin-rotor semisubmersible]} + LSShftFxa: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + LSShftFya: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + LSShftFza: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + LSShftMxa: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + LSShftTq: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + LSSTipAxa: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + LSSTipMya: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + LSSTipMza: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + LSSTipPxa: {behaviour: A, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + LSSTipVxa: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + NcFbx: {behaviour: negligible, measured_in: [MHK buoyancy]} + NcFby: {behaviour: negligible, measured_in: [MHK buoyancy]} + NcFbz: {behaviour: negligible, measured_in: [MHK buoyancy]} + NcIMUTAxs: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + NcIMUTAys: {behaviour: F, measured_in: [Twin-rotor semisubmersible]} + NcIMUTAzs: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + NcMbx: {behaviour: negligible, measured_in: [MHK buoyancy]} + NcMby: {behaviour: negligible, measured_in: [MHK buoyancy]} + NcMbz: {behaviour: negligible, measured_in: [MHK buoyancy]} + OoPDefl1: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, MHK buoyancy, Twin-rotor semisubmersible]} + OoPDefl2: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + OoPDefl3: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + PtfmHeave: {behaviour: S, measured_in: [AeroDyn nodal outputs, MHK buoyancy, OLAF free wake, Twin-rotor semisubmersible]} + PtfmPitch: {behaviour: S, measured_in: [AeroDyn nodal outputs, MHK buoyancy, OLAF free wake, Twin-rotor semisubmersible]} + PtfmRoll: {behaviour: F, measured_in: [AeroDyn nodal outputs, MHK buoyancy, OLAF free wake, Twin-rotor semisubmersible]} + PtfmSurge: {behaviour: S, measured_in: [AeroDyn nodal outputs, MHK buoyancy, OLAF free wake, Twin-rotor semisubmersible]} + PtfmSway: {behaviour: F, measured_in: [AeroDyn nodal outputs, MHK buoyancy, OLAF free wake, Twin-rotor semisubmersible]} + PtfmYaw: {behaviour: F, measured_in: [AeroDyn nodal outputs, MHK buoyancy, OLAF free wake, Twin-rotor semisubmersible]} + RootFxb1: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + RootFxc1: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + RootFyb1: {behaviour: F, measured_in: [Twin-rotor semisubmersible]} + RootFyc1: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + RootFzb1: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + RootFzc1: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + RootMxb1: {behaviour: F, measured_in: [Twin-rotor semisubmersible]} + RootMxc1: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + RootMxc2: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + RootMxc3: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + RootMyb1: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + RootMyc1: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + RootMyc2: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + RootMyc3: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + RootMzb1: {behaviour: F, measured_in: [Twin-rotor semisubmersible]} + RootMzc1: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + RotAcc: {behaviour: negligible, measured_in: [AeroDisk, yawed]} + RotAccel: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + RotPwr: {behaviour: S, measured_in: [AeroDisk, yawed, BeamDyn blades, ElastoDyn + AeroDyn]} + RotSpeed: {behaviour: S, measured_in: [AeroDisk, yawed, AeroDyn nodal outputs, BeamDyn blades, ElastoDyn + AeroDyn, OLAF free wake, Twin-rotor semisubmersible]} + RotThrust: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + RotTorq: {behaviour: S, measured_in: [AeroDisk, yawed, BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + RtAeroCp: {behaviour: S, measured_in: [AeroDyn nodal outputs, ElastoDyn + AeroDyn]} + RtAeroCq: {behaviour: F, measured_in: [AeroDyn nodal outputs, ElastoDyn + AeroDyn]} + RtAeroCt: {behaviour: S, measured_in: [AeroDyn nodal outputs, ElastoDyn + AeroDyn]} + RtAeroFxh: {behaviour: S, measured_in: [AeroDyn nodal outputs, BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + RtAeroFyh: {behaviour: F, measured_in: [AeroDyn nodal outputs, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + RtAeroFzh: {behaviour: S, measured_in: [AeroDyn nodal outputs, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + RtAeroMxh: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + RtAeroMyh: {behaviour: S, measured_in: [AeroDyn nodal outputs, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + RtAeroMzh: {behaviour: F, measured_in: [ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + RtAeroPwr: {behaviour: S, measured_in: [AeroDyn nodal outputs, BeamDyn blades, ElastoDyn + AeroDyn]} + RtArea: {behaviour: S, measured_in: [AeroDyn nodal outputs, Twin-rotor semisubmersible]} + RtFldFxh: {behaviour: S, measured_in: [MHK buoyancy]} + RtFldFyh: {behaviour: F, measured_in: [MHK buoyancy]} + RtFldFzg: {behaviour: S, measured_in: [MHK buoyancy]} + RtFldMxh: {behaviour: F, measured_in: [MHK buoyancy]} + RtFldMyg: {behaviour: S, measured_in: [MHK buoyancy]} + RtFldMzh: {behaviour: F, measured_in: [MHK buoyancy]} + RtSkew: {behaviour: S, measured_in: [AeroDyn nodal outputs, BeamDyn blades, ElastoDyn + AeroDyn]} + RtSpeed: {behaviour: S, measured_in: [AeroDyn nodal outputs, ElastoDyn + AeroDyn]} + RtTSR: {behaviour: S, measured_in: [AeroDyn nodal outputs, BeamDyn blades, ElastoDyn + AeroDyn]} + RtVAvgxh: {behaviour: S, measured_in: [AeroDyn nodal outputs, Twin-rotor semisubmersible]} + RtVAvgyh: {behaviour: F, measured_in: [AeroDyn nodal outputs]} + RtVAvgzh: {behaviour: S, measured_in: [AeroDyn nodal outputs]} + ShearExp: {behaviour: S, measured_in: [AeroDyn nodal outputs, OLAF free wake]} + Time: {behaviour: S, measured_in: [AeroDisk, yawed, AeroDyn nodal outputs, BeamDyn blades, ElastoDyn + AeroDyn, MHK buoyancy, OLAF free wake]} + TipDxc1: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + TipDyc1: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn]} + TTDspFA: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + TTDspSS: {behaviour: F, measured_in: [Twin-rotor semisubmersible]} + TTDspTwst: {behaviour: negligible, measured_in: [Twin-rotor semisubmersible]} + TwN1Fbx: {behaviour: negligible, measured_in: [MHK buoyancy]} + TwN1Mbx: {behaviour: negligible, measured_in: [MHK buoyancy]} + TwN2Mby: {behaviour: negligible, measured_in: [MHK buoyancy]} + TwN3Fby: {behaviour: negligible, measured_in: [MHK buoyancy]} + TwN3Mbz: {behaviour: negligible, measured_in: [MHK buoyancy]} + TwN4Fbz: {behaviour: negligible, measured_in: [MHK buoyancy]} + TwrBsFxt: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + TwrBsFyt: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + TwrBsFzt: {behaviour: S, measured_in: [Twin-rotor semisubmersible]} + TwrBsMxt: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + TwrBsMyt: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + TwrBsMzt: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + TwrTpTDxi: {behaviour: S, measured_in: [MHK buoyancy]} + TwrTpTDyi: {behaviour: F, measured_in: [MHK buoyancy]} + TwrTpTDzi: {behaviour: S, measured_in: [MHK buoyancy]} + TwstDefl1: {behaviour: negligible, measured_in: [Twin-rotor semisubmersible]} + Wave1Elev: {behaviour: S, measured_in: [MHK buoyancy]} + Wind1VelX: {behaviour: S, measured_in: [AeroDisk, yawed, AeroDyn nodal outputs, BeamDyn blades, ElastoDyn + AeroDyn]} + Wind1VelY: {behaviour: negligible, measured_in: [AeroDisk, yawed, AeroDyn nodal outputs, BeamDyn blades, ElastoDyn + AeroDyn]} + Wind1VelZ: {behaviour: negligible, measured_in: [AeroDisk, yawed, AeroDyn nodal outputs, BeamDyn blades, ElastoDyn + AeroDyn]} + Yaw: {behaviour: F, measured_in: [AeroDisk, yawed, AeroDyn nodal outputs, OLAF free wake]} + YawBrFxp: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + YawBrFyp: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + YawBrFzp: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + YawBrMxp: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + YawBrMyp: {behaviour: S, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + YawBrMzp: {behaviour: F, measured_in: [BeamDyn blades, ElastoDyn + AeroDyn, Twin-rotor semisubmersible]} + YawRate: {behaviour: negligible, measured_in: [AeroDisk, yawed]} diff --git a/modules/aerodisk/src/AeroDisk.f90 b/modules/aerodisk/src/AeroDisk.f90 index da72cbc5a5..f80da0d60a 100644 --- a/modules/aerodisk/src/AeroDisk.f90 +++ b/modules/aerodisk/src/AeroDisk.f90 @@ -488,7 +488,10 @@ SUBROUTINE ADsk_CalcOutput( t, u, p, x, xd, z, OtherState, y, m, ErrStat, ErrMsg if (EqualRealNos(m%VRel_xd,0.0_SiKi)) then m%lambda = 0.0_SiKi else - m%lambda = real((u%RotSpeed * p%RotorRad),SiKi) / abs(m%VRel_xd) + ! MirrorRotor: the rotor speed arrives physical, and the table is defined in the + ! clockwise convention, so feed it the clockwise value to keep the tip-speed ratio + ! positive and inside the tabulated range. + m%lambda = real((p%RotDir * u%RotSpeed * p%RotorRad),SiKi) / abs(m%VRel_xd) endif if (EqualRealNos(m%VRel,0.0_SiKi)) then m%Chi = 0.0_SiKi @@ -518,7 +521,9 @@ SUBROUTINE ADsk_CalcOutput( t, u, p, x, xd, z, OtherState, y, m, ErrStat, ErrMsg else m%x_hat = x_hatDisk m%y_hat = tmp3 / tmp1 - m%z_hat = cross_product( VRel_vec, x_hatDisk ) / tmp1 + ! MirrorRotor: a cross product of two true vectors is a pseudovector, so without + ! this the skew-aligned triad comes out left-handed on a mirrored rotor. + m%z_hat = p%RotDir * cross_product( VRel_vec, x_hatDisk ) / tmp1 endif !--------------- @@ -533,7 +538,7 @@ SUBROUTINE ADsk_CalcOutput( t, u, p, x, xd, z, OtherState, y, m, ErrStat, ErrMsg !------------------------------------------- !> Interpolate Force and Moment coefficients - call ADskTableInterp(p%AeroTable, p%UseTSR, m%lambda, real(u%RotSpeed,SiKi), m%VRel_xd, real(u%BlPitch,SiKi), m%Chi, m%idx_last, m%C_F, m%C_M, ErrStat2, ErrMsg2) + call ADskTableInterp(p%AeroTable, p%UseTSR, m%lambda, real(p%RotDir*u%RotSpeed,SiKi), m%VRel_xd, real(u%BlPitch,SiKi), m%Chi, m%idx_last, m%C_F, m%C_M, ErrStat2, ErrMsg2) if (Failed()) return !> Apply skew if not in table @@ -553,9 +558,20 @@ SUBROUTINE ADsk_CalcOutput( t, u, p, x, xd, z, OtherState, y, m, ErrStat, ErrMsg !! - \f$ M_x = \frac{1}{2} \rho A \left( V_\textrm{rel,x} \right)^2 * C_\textrm{M,x}\left(\text{TSR}@\lambda,\text{RtSpd}@\Omega,\text{V}_\text{rel}@V_\textrm{rel},\text{Pitch}@\theta,\text{Skew}@\chi\right) \f$ !! - \f$ M_y = \frac{1}{2} \rho A \left( V_\textrm{rel,x} \right)^2 * C_\textrm{M,y}\left(\text{TSR}@\lambda,\text{RtSpd}@\Omega,\text{V}_\text{rel}@V_\textrm{rel},\text{Pitch}@\theta,\text{Skew}@\chi\right) \f$ !! - \f$ M_z = \frac{1}{2} \rho A \left( V_\textrm{rel,x} \right)^2 * C_\textrm{M,z}\left(\text{TSR}@\lambda,\text{RtSpd}@\Omega,\text{V}_\text{rel}@V_\textrm{rel},\text{Pitch}@\theta,\text{Skew}@\chi\right) \f$ + ! MirrorRotor: the coefficients come out of the table in the clockwise convention and + ! are reflected here as they become physical loads. The skew-aligned triad above is + ! built so that all three basis vectors mirror as true vectors, which is what the + ! RotDir on z_hat achieves. Taking components against that triad, a true vector such + ! as force keeps every component, since both the vector and the basis vector reflect + ! together, while a pseudovector such as moment reverses every one. C_F and C_M are + ! left alone, since the Ct and Cq outputs report the rotor's own convention. tmp1 = real(p%halfRhoA,SiKi) * m%VRel_xd * m%VRel_xd - m%Force(1:3) = tmp1 * m%C_F(1:3) - m%Moment(1:3) = tmp1 * real(p%RotorRad,SiKi) * m%C_M(1:3) + m%Force(1) = tmp1 * m%C_F(1) + m%Force(2) = tmp1 * m%C_F(2) + m%Force(3) = tmp1 * m%C_F(3) + m%Moment(1) = tmp1 * real(p%RotorRad,SiKi) * m%C_M(1) * real(p%RotDir,SiKi) + m%Moment(2) = tmp1 * real(p%RotorRad,SiKi) * m%C_M(2) * real(p%RotDir,SiKi) + m%Moment(3) = tmp1 * real(p%RotorRad,SiKi) * m%C_M(3) * real(p%RotDir,SiKi) diff --git a/modules/aerodisk/src/AeroDisk_IO.f90 b/modules/aerodisk/src/AeroDisk_IO.f90 index 997edf8f0c..ee6fa238a6 100644 --- a/modules/aerodisk/src/AeroDisk_IO.f90 +++ b/modules/aerodisk/src/AeroDisk_IO.f90 @@ -745,6 +745,8 @@ subroutine ADskInput_SetParameters( InitInp, Interval, InputFileData, p, ErrStat p%RotorRad = InputFileData%RotorRad p%AirDens = InputFileData%AirDens p%UseTSR = .false. ! Reset below if N_TSR>1 + p%RotDir = 1.0_ReKi + if (InitInp%MirrorRotor) p%RotDir = -1.0_ReKi ! Derived parameter p%halfRhoA = 0.5_ReKi * p%AirDens * Pi * p%RotorRad*p%RotorRad @@ -902,8 +904,10 @@ subroutine Calc_WriteOutput( u, p, y, m, ErrStat, ErrMsg, CalcWriteOutput ) m%AllOuts( ADVRel ) = real(m%VRel, ReKi) ! magnitude of VRel vector (m/s) m%AllOuts( ADSkew ) = real(m%Chi * 180.0_ReKi / Pi, ReKi) - ! Wind in local frame, inertial frame - Tmp3 = matmul(Rxyz(1:3,1:3), m%DiskAvgVel) + ! Wind in local frame, inertial frame. DiskAvgVel is inertial, so this is a + ! projection onto the disk axes: Rxyz holds them as columns, so v is the left + ! operand. With z_hat perpendicular to VRel by construction, ADVWindz is zero. + Tmp3 = matmul(m%DiskAvgVel, Rxyz(1:3,1:3)) m%AllOuts( ADVWindx ) = Tmp3(1) m%AllOuts( ADVWindy ) = Tmp3(2) m%AllOuts( ADVWindz ) = Tmp3(3) @@ -911,8 +915,8 @@ subroutine Calc_WriteOutput( u, p, y, m, ErrStat, ErrMsg, CalcWriteOutput ) m%AllOuts( ADVWindyi ) = m%DiskAvgVel(2) m%AllOuts( ADVWindzi ) = m%DiskAvgVel(3) - ! Rotor velocity in local frame, inertial frame - Tmp3 = matmul(Rxyz(1:3,1:3), u%HubMotion%TranslationVel(1:3,1)) + ! Rotor velocity in local frame, inertial frame. Projection, as for the wind. + Tmp3 = matmul(u%HubMotion%TranslationVel(1:3,1), Rxyz(1:3,1:3)) m%AllOuts( ADSTVx ) = Tmp3(1) m%AllOuts( ADSTVy ) = Tmp3(2) m%AllOuts( ADSTVz ) = Tmp3(3) @@ -939,13 +943,14 @@ subroutine Calc_WriteOutput( u, p, y, m, ErrStat, ErrMsg, CalcWriteOutput ) m%AllOuts( ADMx ) = real(m%Moment(1),ReKi) m%AllOuts( ADMy ) = real(m%Moment(2),ReKi) m%AllOuts( ADMz ) = real(m%Moment(3),ReKi) - !Tmp3 = m%Force( 1)*m%x_hat + m%Force( 2)*m%y_hat + m%Force( 3)*m%z_hat - Tmp3 = matmul(real(m%Force(1:3),ReKi), Rxyz(1:3,1:3)) + ! Force is held in disk components, so this rebuilds the inertial vector + ! m%Force(1)*x_hat + m%Force(2)*y_hat + m%Force(3)*z_hat; Rxyz is the left operand. + Tmp3 = matmul(Rxyz(1:3,1:3), real(m%Force(1:3),ReKi)) m%AllOuts( ADFxi ) = Tmp3(1) m%AllOuts( ADFyi ) = Tmp3(2) m%AllOuts( ADFzi ) = Tmp3(3) - !Tmp3 = m%Moment(1)*m%x_hat + m%Moment(2)*m%y_hat + m%Moment(3)*m%z_hat - Tmp3 = matmul(real(m%Force(1:3),ReKi), Rxyz(1:3,1:3)) + ! Likewise for the moment, from the moment components. + Tmp3 = matmul(Rxyz(1:3,1:3), real(m%Moment(1:3),ReKi)) m%AllOuts( ADMxi ) = Tmp3(1) m%AllOuts( ADMyi ) = Tmp3(2) m%AllOuts( ADMzi ) = Tmp3(3) diff --git a/modules/aerodisk/src/AeroDisk_Registry.txt b/modules/aerodisk/src/AeroDisk_Registry.txt index 990dfbaafd..39fdf62990 100644 --- a/modules/aerodisk/src/AeroDisk_Registry.txt +++ b/modules/aerodisk/src/AeroDisk_Registry.txt @@ -56,6 +56,7 @@ typedef ^ InitInputType ReKi HubPosition {3} - typedef ^ InitInputType R8Ki HubOrientation {3}{3} - - "Hub orientation" - typedef ^ InitInputType ReKi defAirDens - - - "Default atmospheric density from the driver; may be overwritten" "kg/m^3" typedef ^ InitInputType LOGICAL Linearize - .false. - "this module cannot be linearized at present" - +typedef ^ InitInputType LOGICAL MirrorRotor - .false. - "Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind)" - typedef ^ InitInputType LOGICAL UseInputFile - .TRUE. - "Supplied by Driver: .TRUE. if using a input file, .FALSE. if all inputs are being passed in by the caller" - typedef ^ InitInputType FileInfoType PassedFileData - - - "If we don't use the input file, pass everything through this" - typedef ^ InitInputType FlowFieldType *FlowField - - - "Pointer of InflowWinds flow field data type" - @@ -115,6 +116,7 @@ typedef ^ ParameterType IntKi NumOuts - - typedef ^ ParameterType ReKi halfRhoA - - - "half air density times rotor swept area" "kg/m" typedef ^ ParameterType ADsk_AeroTable AeroTable - - - "Data table" - typedef ^ ParameterType LOGICAL UseTSR - .false. - "Use TSR values from table instead of VRel + RtSpd" - +typedef ^ ParameterType ReKi RotDir - 1.0 - "Rotation direction: +1 normal, -1 for a mirrored rotor" - typedef ^ ParameterType OutParmType OutParam {:} - - "Names and units (and other characteristics) of all requested output parameters" - typedef ^ ParameterType FlowFieldType *FlowField - - - "Pointer of InflowWinds flow field data type" - typedef ^ ParameterType ReKi DiskWindPosRel {:}{:} - - "Disk locations for sampling to get disk avarage velocity (relative to hub)" m diff --git a/modules/aerodisk/src/AeroDisk_Types.f90 b/modules/aerodisk/src/AeroDisk_Types.f90 index 6113bb03c2..8d53961f4c 100644 --- a/modules/aerodisk/src/AeroDisk_Types.f90 +++ b/modules/aerodisk/src/AeroDisk_Types.f90 @@ -76,6 +76,7 @@ MODULE AeroDisk_Types REAL(R8Ki) , DIMENSION(1:3,1:3) :: HubOrientation = 0.0_R8Ki !< Hub orientation [-] REAL(ReKi) :: defAirDens = 0.0_ReKi !< Default atmospheric density from the driver; may be overwritten [kg/m^3] LOGICAL :: Linearize = .false. !< this module cannot be linearized at present [-] + LOGICAL :: MirrorRotor = .false. !< Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind) [-] LOGICAL :: UseInputFile = .TRUE. !< Supplied by Driver: .TRUE. if using a input file, .FALSE. if all inputs are being passed in by the caller [-] TYPE(FileInfoType) :: PassedFileData !< If we don't use the input file, pass everything through this [-] TYPE(FlowFieldType) , POINTER :: FlowField => NULL() !< Pointer of InflowWinds flow field data type [-] @@ -139,6 +140,7 @@ MODULE AeroDisk_Types REAL(ReKi) :: halfRhoA = 0.0_ReKi !< half air density times rotor swept area [kg/m] TYPE(ADsk_AeroTable) :: AeroTable !< Data table [-] LOGICAL :: UseTSR = .false. !< Use TSR values from table instead of VRel + RtSpd [-] + REAL(ReKi) :: RotDir = 1.0 !< Rotation direction: +1 normal, -1 for a mirrored rotor [-] TYPE(OutParmType) , DIMENSION(:), ALLOCATABLE :: OutParam !< Names and units (and other characteristics) of all requested output parameters [-] TYPE(FlowFieldType) , POINTER :: FlowField => NULL() !< Pointer of InflowWinds flow field data type [-] REAL(ReKi) , DIMENSION(:,:), ALLOCATABLE :: DiskWindPosRel !< Disk locations for sampling to get disk avarage velocity (relative to hub) [m] @@ -530,6 +532,7 @@ subroutine ADsk_CopyInitInput(SrcInitInputData, DstInitInputData, CtrlCode, ErrS DstInitInputData%HubOrientation = SrcInitInputData%HubOrientation DstInitInputData%defAirDens = SrcInitInputData%defAirDens DstInitInputData%Linearize = SrcInitInputData%Linearize + DstInitInputData%MirrorRotor = SrcInitInputData%MirrorRotor DstInitInputData%UseInputFile = SrcInitInputData%UseInputFile call NWTC_Library_CopyFileInfoType(SrcInitInputData%PassedFileData, DstInitInputData%PassedFileData, CtrlCode, ErrStat2, ErrMsg2) call SetErrStat(ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName) @@ -564,6 +567,7 @@ subroutine ADsk_PackInitInput(RF, Indata) call RegPack(RF, InData%HubOrientation) call RegPack(RF, InData%defAirDens) call RegPack(RF, InData%Linearize) + call RegPack(RF, InData%MirrorRotor) call RegPack(RF, InData%UseInputFile) call NWTC_Library_PackFileInfoType(RF, InData%PassedFileData) call RegPack(RF, associated(InData%FlowField)) @@ -593,6 +597,7 @@ subroutine ADsk_UnPackInitInput(RF, OutData) call RegUnpack(RF, OutData%HubOrientation); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%defAirDens); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%Linearize); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%MirrorRotor); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%UseInputFile); if (RegCheckErr(RF, RoutineName)) return call NWTC_Library_UnpackFileInfoType(RF, OutData%PassedFileData) ! PassedFileData if (associated(OutData%FlowField)) deallocate(OutData%FlowField) @@ -1021,6 +1026,7 @@ subroutine ADsk_CopyParam(SrcParamData, DstParamData, CtrlCode, ErrStat, ErrMsg) call SetErrStat(ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName) if (ErrStat >= AbortErrLev) return DstParamData%UseTSR = SrcParamData%UseTSR + DstParamData%RotDir = SrcParamData%RotDir if (allocated(SrcParamData%OutParam)) then LB(1:1) = lbound(SrcParamData%OutParam) UB(1:1) = ubound(SrcParamData%OutParam) @@ -1096,6 +1102,7 @@ subroutine ADsk_PackParam(RF, Indata) call RegPack(RF, InData%halfRhoA) call ADsk_PackAeroTable(RF, InData%AeroTable) call RegPack(RF, InData%UseTSR) + call RegPack(RF, InData%RotDir) call RegPack(RF, allocated(InData%OutParam)) if (allocated(InData%OutParam)) then call RegPackBounds(RF, 1, lbound(InData%OutParam), ubound(InData%OutParam)) @@ -1135,6 +1142,7 @@ subroutine ADsk_UnPackParam(RF, OutData) call RegUnpack(RF, OutData%halfRhoA); if (RegCheckErr(RF, RoutineName)) return call ADsk_UnpackAeroTable(RF, OutData%AeroTable) ! AeroTable call RegUnpack(RF, OutData%UseTSR); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%RotDir); if (RegCheckErr(RF, RoutineName)) return if (allocated(OutData%OutParam)) deallocate(OutData%OutParam) call RegUnpack(RF, IsAllocAssoc); if (RegCheckErr(RF, RoutineName)) return if (IsAllocAssoc) then diff --git a/modules/aerodyn/src/AeroDyn.f90 b/modules/aerodyn/src/AeroDyn.f90 index eba033d71d..e7d51e4b34 100644 --- a/modules/aerodyn/src/AeroDyn.f90 +++ b/modules/aerodyn/src/AeroDyn.f90 @@ -146,9 +146,16 @@ subroutine AD_SetInitOut(MHK, WtrDpth, p, p_AD, InputFileData, AA_InitOut, InitO ! set visualization data: - ! this check is overly restrictive, but it would be a lot of work to ensure that only the *used* airfoil + ! MirrorRotor: the rotation direction travels with the visualisation data because + ! the blade surface is not always built here. When the airfoil files carry no + ! coordinates, or carry differing numbers of them, BladeShape is left unallocated + ! and the caller synthesises a generic section instead; that fallback needs the + ! same sign this routine applies below. + InitOut%RotDir = p%RotDir + + ! this check is overly restrictive, but it would be a lot of work to ensure that only the *used* airfoil ! tables have the same number of coordinates. - if ( allocated(p_AD%AFI) ) then + if ( allocated(p_AD%AFI) ) then if ( p_AD%AFI(1)%NumCoords > 0 ) then NumCoords = p_AD%AFI(1)%NumCoords @@ -180,10 +187,20 @@ subroutine AD_SetInitOut(MHK, WtrDpth, p, p_AD, InputFileData, AA_InitOut, InitO do j=1,InputFileData%BladeProps(k)%NumBlNds f = InputFileData%BladeProps(k)%BlAFID(j) - do i=1,NumCoords-1 - InitOut%BladeShape(k)%AirfoilCoords(1,i,j) = InputFileData%BladeProps(k)%BlChord(j)*( p_AD%AFI(f)%Y_Coord(i+1) - p_AD%AFI(f)%Y_Coord(1) ) - InitOut%BladeShape(k)%AirfoilCoords(2,i,j) = InputFileData%BladeProps(k)%BlChord(j)*( p_AD%AFI(f)%X_Coord(i+1) - p_AD%AFI(f)%X_Coord(1) ) - end do + ! MirrorRotor: these vertices are placed by MeshWrVTK_Ln2Surface as + ! matmul(xyz, Orientation), so component 1 rides row 1 of the node's + ! direction cosine matrix and component 2 rides row 2. Under the + ! mirror R' = S R S the rows do not transform alike: row 1 becomes + ! S*row1 while row 2 becomes -S*row2. RotDir therefore belongs on + ! component 2 alone, so that the two negations cancel and the leading + ! edge stays the leading edge while the section is drawn as the mirror + ! image of the tabulated airfoil, which is how it is being used. + ! Putting the factor on component 1, or on both, was measured and is + ! wrong. Visualisation only; these coordinates never reach the loads. + do i=1,NumCoords-1 + InitOut%BladeShape(k)%AirfoilCoords(1,i,j) = InputFileData%BladeProps(k)%BlChord(j)*( p_AD%AFI(f)%Y_Coord(i+1) - p_AD%AFI(f)%Y_Coord(1) ) + InitOut%BladeShape(k)%AirfoilCoords(2,i,j) = p%RotDir * InputFileData%BladeProps(k)%BlChord(j)*( p_AD%AFI(f)%X_Coord(i+1) - p_AD%AFI(f)%X_Coord(1) ) + end do end do end do @@ -405,6 +422,19 @@ subroutine AD_Init( InitInp, u, p, x, xd, z, OtherState, y, m, Interval, InitOut k = k + 1 end do end do + + ! MirrorRotor: mirror the blade geometry about the rotor XZ plane. Twist is a rotation + ! about the span axis and sweep is the in-plane offset, so both flip; curvature (out of + ! plane) does not. This must follow setCantAngle, which derives BlCrvAng from BlTwist. + ! BlCenBt is the MHK centre-of-buoyancy offset in the same in-plane direction as sweep. + do iR = 1, nRotors + if (.not. InitInp%rotors(iR)%MirrorRotor) cycle + do I=1,NumBlades(iR) + InputFileData%rotors(iR)%BladeProps(I)%BlTwist = -InputFileData%rotors(iR)%BladeProps(I)%BlTwist + InputFileData%rotors(iR)%BladeProps(I)%BlSwpAC = -InputFileData%rotors(iR)%BladeProps(I)%BlSwpAC + InputFileData%rotors(iR)%BladeProps(I)%BlCenBt = -InputFileData%rotors(iR)%BladeProps(I)%BlCenBt + end do + end do !............................................................................................ ! Define parameters @@ -424,6 +454,15 @@ subroutine AD_Init( InitInp, u, p, x, xd, z, OtherState, y, m, Interval, InitOut p%Wake_Mod = InputFileData%Wake_Mod do iR = 1, nRotors p%rotors(iR)%AeroProjMod = AeroProjMod(iR) + p%rotors(iR)%RotDir = 1.0_ReKi + if (InitInp%rotors(iR)%MirrorRotor) then + p%rotors(iR)%RotDir = -1.0_ReKi + ! Not yet worked through for the acoustics model. + if (InputFileData%CompAA) then + call SetErrStat(ErrID_Fatal, 'MirrorRotor is not yet supported with the AeroAcoustics model.', ErrStat, ErrMsg, RoutineName) + end if + if (ErrStat >= AbortErrLev) return + end if call WrScr(' AeroDyn: projMod: '//trim(num2lstr(p%rotors(iR)%AeroProjMod))) call SetParameters( InitInp, InputFileData, InputFileData%rotors(iR), p%rotors(iR), p, ErrStat2, ErrMsg2 ) if (Failed()) return; @@ -3642,7 +3681,8 @@ subroutine SetInputsForBEMT(p, p_AD, u, RotInflow, m, indx, errStat, errMsg) m%tilt = tilt ! "Angular velocity of rotor" rad/s - m%BEMT_u(indx)%omega = dot_product( u%HubMotion%RotationVel(:,1), x_hat_disk ) + ! MirrorRotor: BEMT is always presented a CW-equivalent problem; see mirror_rotor docs. + m%BEMT_u(indx)%omega = p%RotDir * dot_product( u%HubMotion%RotationVel(:,1), x_hat_disk ) ! "Angle between the vector normal to the rotor plane and the wind vector (e.g., the yaw angle in the case of no tilt)" rad denom = TwoNorm( m%V_diskAvg ) @@ -3665,7 +3705,9 @@ subroutine SetInputsForBEMT(p, p_AD, u, RotInflow, m, indx, errStat, errMsg) endif if (p%BEM_Mod /= BEMMod_2D) then ! TODO - m%BEMT_u(indx)%chi0 = sign( m%BEMT_u(indx)%chi0, signOfAngle ) + ! MirrorRotor: SkewVec is a pseudovector, so RotDir keeps the reported skew + ! angle in the rotor's own convention, matching the BEMMod_2D path above. + m%BEMT_u(indx)%chi0 = sign( m%BEMT_u(indx)%chi0, p%RotDir * signOfAngle ) endif end if @@ -3675,6 +3717,8 @@ subroutine SetInputsForBEMT(p, p_AD, u, RotInflow, m, indx, errStat, errMsg) !.......................... if (p%AeroProjMod==APM_BEM_NoSweepPitchTwist .or. p%AeroProjMod==APM_LiftingLine) then + ! Azimuth is measured in the skew-aligned disk frame, which DiskAvgValues already + ! keeps in the CW-equivalent orientation, so no further mirroring here. m%BEMT_u(indx)%psi_s = Azimuth elseif (p%AeroProjMod==APM_BEM_Polar) then @@ -3685,7 +3729,7 @@ subroutine SetInputsForBEMT(p, p_AD, u, RotInflow, m, indx, errStat, errMsg) ! Extract azimuth angle for blade k ! NOTE: EB, this might need improvements (express wrt hub, also deal with case hubRad=0). This is likely not psi_skew. theta = -EulerExtract( transpose(orientationBladeAzimuth(:,:,1)) ) - m%BEMT_u(indx)%psi_s(k) = theta(1) + m%BEMT_u(indx)%psi_s(k) = p%RotDir * theta(1) end do !k=blades ! Find the most-downwind azimuth angle needed by the skewed wake correction model @@ -3695,7 +3739,9 @@ subroutine SetInputsForBEMT(p, p_AD, u, RotInflow, m, indx, errStat, errMsg) m%BEMT_u(indx)%psiSkewOffset = PiBy2 else ! Assemble blade azimuth unit vectors and orientation matrix - z_vec = windCrossDisk / windCrossDiskMag + ! MirrorRotor: windCrossDisk is a pseudovector; RotDir keeps this triad + ! right-handed so it mirrors as S*R*S and theta(1) simply negates. + z_vec = p%RotDir * windCrossDisk / windCrossDiskMag x_vec = x_hat_disk y_vec = cross_product( z_vec, x_vec ) orientation(1,:) = x_vec @@ -3703,7 +3749,7 @@ subroutine SetInputsForBEMT(p, p_AD, u, RotInflow, m, indx, errStat, errMsg) orientation(3,:) = z_vec ! Extract azimuth angle for most down-wind blade orientation theta = -EulerExtract( transpose(orientation) ) - m%BEMT_u(indx)%psiSkewOffset = theta(1)+PiBy2 ! cross-product of wind vector and rotor axis will lead downwind blade azimuth by 90 degrees + m%BEMT_u(indx)%psiSkewOffset = p%RotDir * theta(1)+PiBy2 ! cross-product of wind vector and rotor axis will lead downwind blade azimuth by 90 degrees end if @@ -3786,7 +3832,7 @@ subroutine SetInputsForBEMT(p, p_AD, u, RotInflow, m, indx, errStat, errMsg) ! Local and instantaneous blade twist+pitch (aerodynamic + elastic), cant and toe (include elastic deformation) do k=1,p%NumBlades do j=1,p%NumBlNds - m%BEMT_u(indx)%theta(j,k) = thetaBladeNds(j,k) ! local pitch + twist (aerodyanmic + elastic) angle of the jth node in the kth blade + m%BEMT_u(indx)%theta(j,k) = p%RotDir * thetaBladeNds(j,k) ! local pitch + twist (aerodyanmic + elastic) angle of the jth node in the kth blade ! NOTE: curve computed by Calculate_MeshOrientation_* m%BEMT_u(indx)%toeAngle(j,k) = 0.0_ReKi @@ -3796,8 +3842,8 @@ subroutine SetInputsForBEMT(p, p_AD, u, RotInflow, m, indx, errStat, errMsg) elseif (p%AeroProjMod==APM_BEM_Polar) then do k=1,p%NumBlades do j=1,p%NumBlNds - m%BEMT_u(indx)%theta(j,k) = thetaBladeNds(j,k) - m%BEMT_u(indx)%toeAngle(j,k) = m%Toe(j,k) + m%BEMT_u(indx)%theta(j,k) = p%RotDir * thetaBladeNds(j,k) + m%BEMT_u(indx)%toeAngle(j,k) = p%RotDir * m%Toe(j,k) m%BEMT_u(indx)%cantAngle(j,k) = m%Cant(j,k) end do !j=nodes end do !k=blades @@ -3818,7 +3864,7 @@ subroutine SetInputsForBEMT(p, p_AD, u, RotInflow, m, indx, errStat, errMsg) endif ! Velocity in "p" or "w" system (depending) on AeroProjMod m%BEMT_u(indx)%Vx(j,k) = dot_product( tmp, m%orientationAnnulus(1,:,j,k) ) ! normal component (normal to the plane, not chord) of the inflow velocity of the jth node in the kth blade - m%BEMT_u(indx)%Vy(j,k) = dot_product( tmp, m%orientationAnnulus(2,:,j,k) ) !+ TwoNorm(m%DisturbedInflow(:,j,k))*(sin()*sin(tilt)*)! tangential component (tangential to the plane, not chord) of the inflow velocity of the jth node in the kth blade + m%BEMT_u(indx)%Vy(j,k) = p%RotDir * dot_product( tmp, m%orientationAnnulus(2,:,j,k) ) !+ TwoNorm(m%DisturbedInflow(:,j,k))*(sin()*sin(tilt)*)! tangential component (tangential to the plane, not chord) of the inflow velocity of the jth node in the kth blade m%BEMT_u(indx)%Vz(j,k) = dot_product( tmp, m%orientationAnnulus(3,:,j,k) ) ! radial component (tangential to the plane, not chord) of the inflow velocity of the jth node in the kth blade ! NOTE: We'll likely remove that: @@ -3835,7 +3881,7 @@ subroutine SetInputsForBEMT(p, p_AD, u, RotInflow, m, indx, errStat, errMsg) do j=1,p%NumBlNds ! inputs for CUA (and CDBEMT): ! TODO Here we should take the rotation in the airfoil coordinate system instead of the "l" or "w" system - m%BEMT_u(indx)%omega_z(j,k) = dot_product( u%BladeMotion(k)%RotationVel( :,j), m%orientationAnnulus(3,:,j,k) ) ! rotation of no-sweep-pitch coordinate system around z of the jth node in the kth blade + m%BEMT_u(indx)%omega_z(j,k) = p%RotDir * dot_product( u%BladeMotion(k)%RotationVel( :,j), m%orientationAnnulus(3,:,j,k) ) ! rotation of no-sweep-pitch coordinate system around z of the jth node in the kth blade end do !j=nodes end do !k=blades @@ -3925,7 +3971,10 @@ subroutine DiskAvgValues(p, u, RotInflow, m, x_hat_disk, y_hat_disk, z_hat_disk, z_hat_disk = u%HubMotion%Orientation(3,:,1) else y_hat_disk = tmp / tmp_sz - z_hat_disk = cross_product( m%V_diskAvg, x_hat_disk ) / tmp_sz + ! MirrorRotor: this cross product is a pseudovector, so without RotDir the + ! skew-aligned frame would flip handedness and Azimuth would come out as + ! pi-Azimuth instead of the CW-equivalent value the skew model expects. + z_hat_disk = p%RotDir * cross_product( m%V_diskAvg, x_hat_disk ) / tmp_sz end if ! "Azimuth angle" rad @@ -4312,26 +4361,28 @@ subroutine SetOutputsFromBEMT( p, u, m, y ) Cya = -Cl*sin(aoa) + Cd*cos(aoa) ! Dimensionalize the aero forces and moment + ! MirrorRotor: BEMT returns CW-frame coefficients; RotDir maps the y-component of + ! forces and the x/z-components of moments back into the mirrored rotor frame. q = 0.5 * p%airDens * m%BEMT_y%Vrel(j,k)**2 ! dynamic pressure of the jth node in the kth blade c = p%BEMT%chord(j,k) forceAirfoil(1) = Cxa * q * c - forceAirfoil(2) = Cya * q * c + forceAirfoil(2) = p%RotDir * Cya * q * c forceAirfoil(3) = 0.0_reki momentAirfoil(1) = 0.0_reki momentAirfoil(2) = 0.0_reki - momentAirfoil(3) = Cm * q * c**2 + momentAirfoil(3) = p%RotDir * Cm * q * c**2 m%M(j,k) = momentAirfoil(3) ! TODO EB ! NOTE! - NOTE! - NOTE! - NOTE! - NOTE! - NOTE! - NOTE! - NOTE! - NOTE! - NOTE! - NOTE! - NOTE! - NOTE! - NOTE! !EAM (fix this!) These output variables are possibly not what they should be ! relative to the original AeroDyn manual and intent !!!! force(1) = m%BEMT_y%cx(j,k) * q * p%BEMT%chord(j,k) ! X = normal force per unit length (normal to the plane, not chord) of the jth node in the kth blade - force(2) = -m%BEMT_y%cy(j,k) * q * p%BEMT%chord(j,k) ! Y = tangential force per unit length (tangential to the plane, not chord) of the jth node in the kth blade + force(2) = -p%RotDir * m%BEMT_y%cy(j,k) * q * p%BEMT%chord(j,k) ! Y = tangential force per unit length (tangential to the plane, not chord) of the jth node in the kth blade force(3) = m%BEMT_y%cz(j,k) * q * p%BEMT%chord(j,k) ! Z = axial force per unit length of the jth node in the kth blade - moment(1)= m%BEMT_y%Cmx(j,k) * q * p%BEMT%chord(j,k)**2 ! Mx = pitching moment (x-component) per unit length of the jth node in the kth blade + moment(1)= p%RotDir * m%BEMT_y%Cmx(j,k) * q * p%BEMT%chord(j,k)**2 ! Mx = pitching moment (x-component) per unit length of the jth node in the kth blade moment(2)= m%BEMT_y%Cmy(j,k) * q * p%BEMT%chord(j,k)**2 ! My = pitching moment (y-component) per unit length of the jth node in the kth blade - moment(3)= m%BEMT_y%Cmz(j,k) * q * p%BEMT%chord(j,k)**2 ! Mz = pitching moment (z-component) per unit length of the jth node in the kth blade + moment(3)= p%RotDir * m%BEMT_y%Cmz(j,k) * q * p%BEMT%chord(j,k)**2 ! Mz = pitching moment (z-component) per unit length of the jth node in the kth blade ! save these values for possible output later: m%X(j,k) = force(1) @@ -4415,7 +4466,7 @@ subroutine SetOutputsFromFVW(t, u, p, OtherState, x, xd, m, y, ErrStat, ErrMsg) Vwnd = m%rotors(iR)%DisturbedInflow(1:3,j,k) ! NOTE: contains tower shadow theta = m%FVW%W(iW)%PitchAndTwist(j) ! TODO call FVW_AeroOuts( m%rotors(iR)%orientationAnnulus(1:3,1:3,j,k), u%rotors(iR)%BladeMotion(k)%Orientation(1:3,1:3,j), & ! inputs - theta, Vstr(1:3), Vind(1:3), VWnd(1:3), p%rotors(iR)%KinVisc, p%FVW%W(iW)%chord_LL(j), & ! inputs + theta, p%rotors(iR)%RotDir, Vstr(1:3), Vind(1:3), VWnd(1:3), p%rotors(iR)%KinVisc, p%FVW%W(iW)%chord_LL(j), & ! inputs AxInd, TanInd, Vrel, phi, alpha, Re, UrelWind_s(1:3), ErrStat2, ErrMsg2 ) ! outputs call SetErrStat(ErrStat2, ErrMsg2, ErrStat, ErrMsg, 'SetOutputsFromFVW') @@ -4455,9 +4506,13 @@ subroutine SetOutputsFromFVW(t, u, p, OtherState, x, xd, m, y, ErrStat, ErrMsg) Cy = Cl_dyn*sp - Cd_dyn*cp q = 0.5 * p%rotors(iR)%airDens * Vrel**2 ! dynamic pressure of the jth node in the kth blade + ! MirrorRotor: Cx, Cy and Cm_dyn are clockwise-frame coefficients, exactly as + ! BEMT returns them, so RotDir maps the tangential force and the pitching + ! moment back into the mirrored rotor frame before they are applied through + ! the physical annulus orientation. See SetOutputsFromBEMT. force(1) = Cx * q * p%FVW%W(iW)%chord_LL(j) ! X = normal force per unit length (normal to the plane, not chord) of the jth node in the kth blade - force(2) = -Cy * q * p%FVW%W(iW)%chord_LL(j) ! Y = tangential force per unit length (tangential to the plane, not chord) of the jth node in the kth blade - moment(3)= Cm_dyn * q * p%FVW%W(iW)%chord_LL(j)**2 ! M = pitching moment per unit length of the jth node in the kth blade + force(2) = -p%rotors(iR)%RotDir * Cy * q * p%FVW%W(iW)%chord_LL(j) ! Y = tangential force per unit length (tangential to the plane, not chord) of the jth node in the kth blade + moment(3)= p%rotors(iR)%RotDir * Cm_dyn * q * p%FVW%W(iW)%chord_LL(j)**2 ! M = pitching moment per unit length of the jth node in the kth blade ! save these values for possible output later: m%rotors(iR)%X(j,k) = force(1) @@ -5660,6 +5715,9 @@ SUBROUTINE Init_OLAF( InputFileData, u_AD, u, p, x, xd, z, OtherState, m, ErrSta do iB=1,p%rotors(iR)%numBlades iW=iW_incr+iB InitInp%W(iW)%iRotor = iR ! Indicate OLAF which wing belongs to which rotor + ! MirrorRotor: OLAF holds every rotor's wings in one shared wake, so the + ! rotation direction has to travel per wing rather than per simulation. + InitInp%W(iW)%RotDir = p%rotors(iR)%RotDir call AllocAry(InitInp%W(iW)%Chord, InitInp%numBladeNodes, 'chord', ErrStat2,ErrMsg2); if(Failed()) return call AllocAry(InitInp%W(iW)%AFindx,InitInp%numBladeNodes,1,'AFindx',ErrStat2,ErrMsg2); if(Failed()) return diff --git a/modules/aerodyn/src/AeroDyn_AllBldNdOuts_IO.f90 b/modules/aerodyn/src/AeroDyn_AllBldNdOuts_IO.f90 index e2c2786eb6..a89f01f093 100644 --- a/modules/aerodyn/src/AeroDyn_AllBldNdOuts_IO.f90 +++ b/modules/aerodyn/src/AeroDyn_AllBldNdOuts_IO.f90 @@ -435,7 +435,7 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI if (p_AD%Wake_Mod /= WakeMod_FVW) then DO iB=1,nB do iNdL=1,nNd; iNd=Nd(iNdL); - y%WriteOutput(iOut) = m%BEMT_u(Indx)%Vy(iNd,iB) * m%BEMT_y%tanInduction(iNd,iB) + y%WriteOutput(iOut) = p%RotDir * m%BEMT_u(Indx)%Vy(iNd,iB) * m%BEMT_y%tanInduction(iNd,iB) iOut = iOut + 1 END DO END DO @@ -443,7 +443,7 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI DO iB=1,nB iW = W2B(iB) do iNdL=1,nNd; iNd=Nd(iNdL); - y%WriteOutput(iOut) = m_AD%FVW%W(iW)%BN_UrelWind_s(2,iNd) * m_AD%FVW%W(iW)%BN_TanInd(iNd) + y%WriteOutput(iOut) = p%RotDir * m_AD%FVW%W(iW)%BN_UrelWind_s(2,iNd) * m_AD%FVW%W(iW)%BN_TanInd(iNd) iOut = iOut + 1 END DO END DO @@ -734,7 +734,7 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI if (p_AD%Wake_Mod /= WakeMod_FVW) then DO iB=1,nB do iNdL=1,nNd; iNd=Nd(iNdL); - y%WriteOutput(iOut) = m%BEMT_y%Cm(iNd,iB) + y%WriteOutput(iOut) = p%RotDir * m%BEMT_y%Cm(iNd,iB) iOut = iOut + 1 END DO END DO @@ -742,7 +742,7 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI DO iB=1,nB iW = W2B(iB) do iNdL=1,nNd; iNd=Nd(iNdL); - y%WriteOutput(iOut) = m_AD%FVW%W(iW)%BN_Cm(iNd) + y%WriteOutput(iOut) = p%RotDir * m_AD%FVW%W(iW)%BN_Cm(iNd) iOut = iOut + 1 END DO END DO @@ -772,7 +772,7 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI if (p_AD%Wake_Mod /= WakeMod_FVW) then DO iB=1,nB do iNdL=1,nNd; iNd=Nd(iNdL); - y%WriteOutput(iOut) = m%BEMT_y%Cy(iNd,iB) + y%WriteOutput(iOut) = p%RotDir * m%BEMT_y%Cy(iNd,iB) iOut = iOut + 1 END DO END DO @@ -780,7 +780,7 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI DO iB=1,nB iW = W2B(iB) do iNdL=1,nNd; iNd=Nd(iNdL); - y%WriteOutput(iOut) = m_AD%FVW%W(iW)%BN_Cy(iNd) + y%WriteOutput(iOut) = p%RotDir * m_AD%FVW%W(iW)%BN_Cy(iNd) iOut = iOut + 1 END DO END DO @@ -815,7 +815,7 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI do iNdL=1,nNd; iNd=Nd(iNdL); ct=cos(m%BEMT_u(Indx)%theta(iNd,iB)) st=sin(m%BEMT_u(Indx)%theta(iNd,iB)) - y%WriteOutput(iOut) = -m%BEMT_y%Cx(iNd,iB)*st + m%BEMT_y%Cy(iNd,iB)*ct + y%WriteOutput(iOut) = p%RotDir * (-m%BEMT_y%Cx(iNd,iB)*st + m%BEMT_y%Cy(iNd,iB)*ct) iOut = iOut + 1 END DO END DO @@ -823,9 +823,9 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI DO iB=1,nB iW = W2B(iB) do iNdL=1,nNd; iNd=Nd(iNdL); - ct=cos(m_AD%FVW%W(iW)%PitchAndTwist(iNd)) ! cos(theta) - st=sin(m_AD%FVW%W(iW)%PitchAndTwist(iNd)) ! sin(theta) - y%WriteOutput(iOut) = -m_AD%FVW%W(iW)%BN_Cx(iNd)*st + m_AD%FVW%W(iW)%BN_Cy(iNd)*ct + ct=cos(p%RotDir*m_AD%FVW%W(iW)%PitchAndTwist(iNd)) ! cos(theta) + st=sin(p%RotDir*m_AD%FVW%W(iW)%PitchAndTwist(iNd)) ! sin(theta) + y%WriteOutput(iOut) = p%RotDir * (-m_AD%FVW%W(iW)%BN_Cx(iNd)*st + m_AD%FVW%W(iW)%BN_Cy(iNd)*ct) iOut = iOut + 1 END DO END DO @@ -833,13 +833,15 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI ! Lift force, drag force, pitching moment + ! MirrorRotor: m%X and m%Y are in the mirrored frame while phi and theta are the + ! CW-equivalent BEMT values, so the y-component is converted back before combining. CASE ( BldNd_Fl ) if (p_AD%Wake_Mod /= WakeMod_FVW) then DO iB=1,nB do iNdL=1,nNd; iNd=Nd(iNdL); cp=cos(m%BEMT_y%phi(iNd,iB)) sp=sin(m%BEMT_y%phi(iNd,iB)) - y%WriteOutput(iOut) = m%X(iNd,iB)*cp - m%Y(iNd,iB)*sp + y%WriteOutput(iOut) = m%X(iNd,iB)*cp - p%RotDir*m%Y(iNd,iB)*sp iOut = iOut + 1 END DO END DO @@ -849,7 +851,7 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI do iNdL=1,nNd; iNd=Nd(iNdL); cp=cos(m_AD%FVW%W(iW)%BN_phi(iNd)) sp=sin(m_AD%FVW%W(iW)%BN_phi(iNd)) - y%WriteOutput(iOut) = m%X(iNd,iB)*cp - m%Y(iNd,iB)*sp + y%WriteOutput(iOut) = m%X(iNd,iB)*cp - p%RotDir*m%Y(iNd,iB)*sp iOut = iOut + 1 END DO END DO @@ -861,7 +863,7 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI do iNdL=1,nNd; iNd=Nd(iNdL); cp=cos(m%BEMT_y%phi(iNd,iB)) sp=sin(m%BEMT_y%phi(iNd,iB)) - y%WriteOutput(iOut) = m%X(iNd,iB)*sp + m%Y(iNd,iB)*cp + y%WriteOutput(iOut) = m%X(iNd,iB)*sp + p%RotDir*m%Y(iNd,iB)*cp iOut = iOut + 1 END DO END DO @@ -871,7 +873,7 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI do iNdL=1,nNd; iNd=Nd(iNdL); cp=cos(m_AD%FVW%W(iW)%BN_phi(iNd)) sp=sin(m_AD%FVW%W(iW)%BN_phi(iNd)) - y%WriteOutput(iOut) = m%X(iNd,iB)*sp + m%Y(iNd,iB)*cp + y%WriteOutput(iOut) = m%X(iNd,iB)*sp + p%RotDir*m%Y(iNd,iB)*cp iOut = iOut + 1 END DO END DO @@ -892,7 +894,7 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI do iNdL=1,nNd; iNd=Nd(iNdL); ct=cos(m%BEMT_u(Indx)%theta(iNd,iB)) st=sin(m%BEMT_u(Indx)%theta(iNd,iB)) - y%WriteOutput(iOut) = m%X(iNd,iB)*ct - m%Y(iNd,iB)*st + y%WriteOutput(iOut) = m%X(iNd,iB)*ct - p%RotDir*m%Y(iNd,iB)*st iOut = iOut + 1 END DO END DO @@ -900,9 +902,9 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI DO iB=1,nB iW = W2B(iB) do iNdL=1,nNd; iNd=Nd(iNdL); - ct=cos(m_AD%FVW%W(iW)%PitchAndTwist(iNd)) ! cos(theta) - st=sin(m_AD%FVW%W(iW)%PitchAndTwist(iNd)) ! sin(theta) - y%WriteOutput(iOut) = m%X(iNd,iB)*ct - m%Y(iNd,iB)*st + ct=cos(p%RotDir*m_AD%FVW%W(iW)%PitchAndTwist(iNd)) ! cos(theta) + st=sin(p%RotDir*m_AD%FVW%W(iW)%PitchAndTwist(iNd)) ! sin(theta) + y%WriteOutput(iOut) = m%X(iNd,iB)*ct - p%RotDir*m%Y(iNd,iB)*st iOut = iOut + 1 END DO END DO @@ -915,7 +917,7 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI do iNdL=1,nNd; iNd=Nd(iNdL); ct=cos(m%BEMT_u(Indx)%theta(iNd,iB)) st=sin(m%BEMT_u(Indx)%theta(iNd,iB)) - y%WriteOutput(iOut) = -m%X(iNd,iB)*st - m%Y(iNd,iB)*ct + y%WriteOutput(iOut) = p%RotDir * (-m%X(iNd,iB)*st - p%RotDir*m%Y(iNd,iB)*ct) iOut = iOut + 1 END DO END DO @@ -923,9 +925,9 @@ SUBROUTINE Calc_WriteAllBldNdOutput( p, p_AD, u, m, m_AD, x, y, OtherState, RotI DO iB=1,nB iW = W2B(iB) do iNdL=1,nNd; iNd=Nd(iNdL); - ct=cos(m_AD%FVW%W(iW)%PitchAndTwist(iNd)) ! cos(theta) - st=sin(m_AD%FVW%W(iW)%PitchAndTwist(iNd)) ! sin(theta) - y%WriteOutput(iOut) = -m%X(iNd,iB)*st - m%Y(iNd,iB)*ct + ct=cos(p%RotDir*m_AD%FVW%W(iW)%PitchAndTwist(iNd)) ! cos(theta) + st=sin(p%RotDir*m_AD%FVW%W(iW)%PitchAndTwist(iNd)) ! sin(theta) + y%WriteOutput(iOut) = p%RotDir * (-m%X(iNd,iB)*st - p%RotDir*m%Y(iNd,iB)*ct) iOut = iOut + 1 END DO END DO diff --git a/modules/aerodyn/src/AeroDyn_Driver_Registry.txt b/modules/aerodyn/src/AeroDyn_Driver_Registry.txt index 64ea7e4a2a..2e89412822 100644 --- a/modules/aerodyn/src/AeroDyn_Driver_Registry.txt +++ b/modules/aerodyn/src/AeroDyn_Driver_Registry.txt @@ -114,6 +114,7 @@ typedef ^ ^ NacData nac typedef ^ ^ TwrData twr - - - "" - typedef ^ ^ IntKi numBlades - - - "" - typedef ^ ^ logical basicHAWTFormat - - - "If true simply input HubRad/Pitch/Overhang/Cone, otherwise all turbine inputs" - +typedef ^ ^ logical MirrorRotor - .FALSE. - "Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind)" - typedef ^ ^ logical hasTower - - - "" - typedef ^ ^ IntKi projMod - - - "If true simply input HubRad/Pitch/Overhang/Cone, otherwise all turbine inputs" - typedef ^ ^ IntKi BEM_Mod - - - "Switch for different BEM implementations" - diff --git a/modules/aerodyn/src/AeroDyn_Driver_Subs.f90 b/modules/aerodyn/src/AeroDyn_Driver_Subs.f90 index a305d32879..20220bf6d9 100644 --- a/modules/aerodyn/src/AeroDyn_Driver_Subs.f90 +++ b/modules/aerodyn/src/AeroDyn_Driver_Subs.f90 @@ -559,6 +559,7 @@ subroutine Init_ADI_ForDriver(iCase, ADI, dvr, FED, dt, needInitIW, errStat, err InitInp%AD%rotors(iWT)%AeroProjMod = wt%projMod endif call WrScr(' Driver: projMod: '//trim(num2lstr(InitInp%AD%rotors(iWT)%AeroProjMod))) + InitInp%AD%rotors(iWT)%MirrorRotor = wt%MirrorRotor InitInp%AD%rotors(iWT)%HubPosition = y_ED%HubPtMotion%Position(:,1) InitInp%AD%rotors(iWT)%HubOrientation = y_ED%HubPtMotion%RefOrientation(:,:,1) InitInp%AD%rotors(iWT)%NacellePosition = y_ED%NacelleMotion%Position(:,1) @@ -741,6 +742,7 @@ subroutine Set_Mesh_Motion(nt, dvr, ADI, FED, errStat, errMsg) real(ReKi) :: bldMotion(3) ! Pitch, Pitch speed, Pitch Acc real(ReKi) :: timeState(5) ! HWindSpeed, PLExp, RotSpeed, Pitch, yaw real(ReKi) :: rotSpeedPrev ! Used for backward compatibility + real(ReKi) :: rotDirDvr ! +1 normal, -1 when the rotor rotation is mirrored real(R8Ki) :: orientation(3,3) real(R8Ki) :: orientation_loc(3,3) real(DbKi) :: time, timePrev @@ -885,13 +887,17 @@ subroutine Set_Mesh_Motion(nt, dvr, ADI, FED, errStat, errMsg) print*,'Unknown hun motion type, should never happen' STOP endif - theta(1) = wt%hub%azimuth*D2R + dvr%dt * wt%hub%rotSpeed + ! MirrorRotor: the driver stands in for ElastoDyn, so it prescribes the mirrored hub + ! kinematics here; hub%azimuth and hub%rotSpeed stay in the rotor's own convention. + rotDirDvr = 1.0_ReKi + if (wt%MirrorRotor) rotDirDvr = -1.0_ReKi + theta(1) = rotDirDvr * (wt%hub%azimuth*D2R + dvr%dt * wt%hub%rotSpeed) theta(2) = 0.0_ReKi theta(3) = 0.0_ReKi orientation_loc = EulerConstruct( theta ) y_ED%HubPtMotion%Orientation(:,:,1) = matmul(orientation_loc, y_ED%HubPtMotion%Orientation(:,:,1)) - y_ED%HubPtMotion%RotationVel( :,1) = y_ED%HubPtMotion%RotationVel(:,1) + y_ED%HubPtMotion%Orientation(1,:,1) * wt%hub%rotSpeed - y_ED%HubPtMotion%RotationAcc( :,1) = y_ED%HubPtMotion%RotationAcc(:,1) + y_ED%HubPtMotion%Orientation(1,:,1) * wt%hub%rotAcc + y_ED%HubPtMotion%RotationVel( :,1) = y_ED%HubPtMotion%RotationVel(:,1) + y_ED%HubPtMotion%Orientation(1,:,1) * rotDirDvr * wt%hub%rotSpeed + y_ED%HubPtMotion%RotationAcc( :,1) = y_ED%HubPtMotion%RotationAcc(:,1) + y_ED%HubPtMotion%Orientation(1,:,1) * rotDirDvr * wt%hub%rotAcc ! --- Blade motion ! Hub 2 blade root @@ -904,13 +910,15 @@ subroutine Set_Mesh_Motion(nt, dvr, ADI, FED, errStat, errMsg) elseif (wt%bld(iB)%motionType==idBldMotionVariable) then call interpTimeValue(wt%bld(iB)%motion, time, wt%bld(iB)%iMotion, bldMotion) wt%bld(iB)%pitch =bldMotion(1) - y_ED%BladeRootMotion(iB)%RotationVel(:,1) = y_ED%BladeRootMotion(iB)%RotationVel(:,1) + y_ED%BladeRootMotion(iB)%Orientation(3,:,1)* (-bldMotion(2)) - y_ED%BladeRootMotion(iB)%RotationAcc(:,1) = y_ED%BladeRootMotion(iB)%RotationAcc(:,1) + y_ED%BladeRootMotion(iB)%Orientation(3,:,1)* (-bldMotion(3)) + y_ED%BladeRootMotion(iB)%RotationVel(:,1) = y_ED%BladeRootMotion(iB)%RotationVel(:,1) + y_ED%BladeRootMotion(iB)%Orientation(3,:,1)* (-rotDirDvr*bldMotion(2)) + y_ED%BladeRootMotion(iB)%RotationAcc(:,1) = y_ED%BladeRootMotion(iB)%RotationAcc(:,1) + y_ED%BladeRootMotion(iB)%Orientation(3,:,1)* (-rotDirDvr*bldMotion(3)) else print*,'Unknown blade motion type, should never happen' STOP endif - theta(3) = - wt%bld(iB)%pitch ! NOTE: sign, wind turbine convention ... + ! MirrorRotor: pitch is supplied in the CW convention and mirrored here, matching + ! the blade twist that was negated on read. + theta(3) = - rotDirDvr * wt%bld(iB)%pitch ! NOTE: sign, wind turbine convention ... orientation_loc = EulerConstruct(theta) y_ED%BladeRootMotion(iB)%Orientation(:,:,1) = matmul(orientation_loc, y_ED%BladeRootMotion(iB)%Orientation(:,:,1)) enddo @@ -984,6 +992,7 @@ subroutine Dvr_ReadInputFile(fileName, dvr, errStat, errMsg ) ! Basic inputs real(ReKi) :: hubRad, hubHt, overhang, shftTilt, precone, twr2Shft ! Basic inputs when basicHAWTFormat is true real(ReKi) :: nacYaw, bldPitch, rotSpeed + real(ReKi) :: rotDirDvr ! +1 normal, -1 when the rotor rotation is mirrored errStat = ErrID_None errMsg = '' UnIn = -1 @@ -1072,6 +1081,7 @@ subroutine Dvr_ReadInputFile(fileName, dvr, errStat, errMsg ) wt%projMod = -1 endif call ParseVar(FileInfo_In, CurLine, 'BasicHAWTFormat'//sWT , wt%basicHAWTFormat , errStat2, errMsg2, unEc); if(Failed()) return + call ParseVar(FileInfo_In, CurLine, 'MirrorRotor'//sWT , wt%MirrorRotor , errStat2, errMsg2, unEc); if(Failed()) return ! Basic init wt%hub%azimuth = myNan @@ -1105,12 +1115,17 @@ subroutine Dvr_ReadInputFile(fileName, dvr, errStat, errMsg ) wt%hub%origin_n = (/ overhang * cos(shftTilt), 0.0_ReKi, -overhang * sin(shftTilt) + twr2shft /) ! IDEM wt%hub%orientation_n = (/ 0.0_ReKi, shftTilt, 0.0_ReKi /) - ! blades + ! blades. The azimuth spacing carries the rotation sense, so the blades are + ! numbered in the direction of rotation for a mirrored rotor as well as a + ! clockwise one. The advanced format takes BldOrientation_h as given, so the + ! user supplies mirrored azimuths there themselves. + rotDirDvr = 1.0_ReKi + if (wt%MirrorRotor) rotDirDvr = -1.0_ReKi allocate(wt%bld(wt%numBlades)) do iB=1,wt%numBlades wt%bld(iB)%pitch = myNaN wt%bld(iB)%origin_h(1:3) = 0.0_ReKi - wt%bld(iB)%orientation_h(1) = (iB-1)*(2._ReKi*Pi)/wt%numBlades + wt%bld(iB)%orientation_h(1) = rotDirDvr*(iB-1)*(2._ReKi*Pi)/wt%numBlades wt%bld(iB)%orientation_h(2) = precone wt%bld(iB)%orientation_h(3) = 0.0_ReKi wt%bld(iB)%hubRad_bl = hubRad diff --git a/modules/aerodyn/src/AeroDyn_Driver_Types.f90 b/modules/aerodyn/src/AeroDyn_Driver_Types.f90 index fd0ed3e19a..defd9cc9f8 100644 --- a/modules/aerodyn/src/AeroDyn_Driver_Types.f90 +++ b/modules/aerodyn/src/AeroDyn_Driver_Types.f90 @@ -146,6 +146,7 @@ MODULE AeroDyn_Driver_Types TYPE(TwrData) :: twr !< [-] INTEGER(IntKi) :: numBlades = 0_IntKi !< [-] LOGICAL :: basicHAWTFormat = .false. !< If true simply input HubRad/Pitch/Overhang/Cone, otherwise all turbine inputs [-] + LOGICAL :: MirrorRotor = .FALSE. !< Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind) [-] LOGICAL :: hasTower = .false. !< [-] INTEGER(IntKi) :: projMod = 0_IntKi !< If true simply input HubRad/Pitch/Overhang/Cone, otherwise all turbine inputs [-] INTEGER(IntKi) :: BEM_Mod = 0_IntKi !< Switch for different BEM implementations [-] @@ -911,6 +912,7 @@ subroutine AD_Dvr_CopyWTData(SrcWTDataData, DstWTDataData, CtrlCode, ErrStat, Er if (ErrStat >= AbortErrLev) return DstWTDataData%numBlades = SrcWTDataData%numBlades DstWTDataData%basicHAWTFormat = SrcWTDataData%basicHAWTFormat + DstWTDataData%MirrorRotor = SrcWTDataData%MirrorRotor DstWTDataData%hasTower = SrcWTDataData%hasTower DstWTDataData%projMod = SrcWTDataData%projMod DstWTDataData%BEM_Mod = SrcWTDataData%BEM_Mod @@ -1046,6 +1048,7 @@ subroutine AD_Dvr_PackWTData(RF, Indata) call AD_Dvr_PackTwrData(RF, InData%twr) call RegPack(RF, InData%numBlades) call RegPack(RF, InData%basicHAWTFormat) + call RegPack(RF, InData%MirrorRotor) call RegPack(RF, InData%hasTower) call RegPack(RF, InData%projMod) call RegPack(RF, InData%BEM_Mod) @@ -1107,6 +1110,7 @@ subroutine AD_Dvr_UnPackWTData(RF, OutData) call AD_Dvr_UnpackTwrData(RF, OutData%twr) ! twr call RegUnpack(RF, OutData%numBlades); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%basicHAWTFormat); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%MirrorRotor); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%hasTower); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%projMod); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%BEM_Mod); if (RegCheckErr(RF, RoutineName)) return diff --git a/modules/aerodyn/src/AeroDyn_IO.f90 b/modules/aerodyn/src/AeroDyn_IO.f90 index 8493b31a57..9eb64fe2f2 100644 --- a/modules/aerodyn/src/AeroDyn_IO.f90 +++ b/modules/aerodyn/src/AeroDyn_IO.f90 @@ -137,7 +137,10 @@ SUBROUTINE Calc_WriteOutput( p, p_AD, u, RotInflow, x, m, m_AD, y, OtherState, x omega = m%BEMT_u(indx)%omega else rmax = Calc_MaxRadius(p, u) - omega = Calc_Omega(u) + ! MirrorRotor: present omega in the same clockwise-equivalent convention that + ! BEMT supplies above, since RtSpeed, RtTSR and the power channels below are + ! shared between the two wake models and cannot carry two conventions at once. + omega = p%RotDir * Calc_Omega(u) endif @@ -345,7 +348,7 @@ subroutine Calc_WriteOutput_AD() if (k<=size(BAeroFxi)) then ! Power contribution of blade wrt hub tmp = matmul( u%HubMotion%Orientation(:,:,1), m%HubLoad%moment(:,1) ) - m%AllOuts( BAeroPwr(k) ) = omega * tmp(1) + m%AllOuts( BAeroPwr(k) ) = p%RotDir * omega * tmp(1) ! In global, wrt hub! m%AllOuts( BAeroFxi(k) ) = m%HubLoad%force(1,1) @@ -374,7 +377,7 @@ subroutine Calc_WriteOutput_AD() m%AllOuts( RtAeroMyh ) = tmp(2) m%AllOuts( RtAeroMzh ) = tmp(3) - m%AllOuts( RtAeroPwr ) = omega * m%AllOuts( RtAeroMxh ) + m%AllOuts( RtAeroPwr ) = p%RotDir * omega * m%AllOuts( RtAeroMxh ) @@ -435,6 +438,7 @@ subroutine Calc_WriteOutput_BEMT() REAL(ReKi) :: denom !, rmax REAL(ReKi) :: ct, st ! cosine, sine of theta REAL(ReKi) :: cp, sp ! cosine, sine of phi + REAL(ReKi) :: Xcw, Ycw ! blade node forces in the CW-equivalent (BEMT) frame ! Induced velocity in Global @@ -465,7 +469,7 @@ subroutine Calc_WriteOutput_BEMT() m%AllOuts( BNM( beta,k) ) = m%BEMT_y%Vrel(j,k) / p%SpdSound m%AllOuts( BNVIndx(beta,k) ) = - m%BEMT_u(indx)%Vx(j,k) * m%BEMT_y%axInduction( j,k) - m%AllOuts( BNVIndy(beta,k) ) = m%BEMT_u(indx)%Vy(j,k) * m%BEMT_y%tanInduction(j,k) + m%AllOuts( BNVIndy(beta,k) ) = p%RotDir * m%BEMT_u(indx)%Vy(j,k) * m%BEMT_y%tanInduction(j,k) m%AllOuts( BNAxInd(beta,k) ) = m%BEMT_y%axInduction(j,k) m%AllOuts( BNTnInd(beta,k) ) = m%BEMT_y%tanInduction(j,k) @@ -484,22 +488,26 @@ subroutine Calc_WriteOutput_BEMT() sp=sin(m%BEMT_y%phi(j,k)) m%AllOuts( BNCl( beta,k) ) = m%BEMT_y%Cx(j,k)*cp + m%BEMT_y%Cy(j,k)*sp m%AllOuts( BNCd( beta,k) ) = m%BEMT_y%Cx(j,k)*sp - m%BEMT_y%Cy(j,k)*cp - m%AllOuts( BNCm( beta,k) ) = m%BEMT_y%Cm(j,k) + m%AllOuts( BNCm( beta,k) ) = p%RotDir * m%BEMT_y%Cm(j,k) m%AllOuts( BNCx( beta,k) ) = m%BEMT_y%Cx(j,k) - m%AllOuts( BNCy( beta,k) ) = m%BEMT_y%Cy(j,k) + m%AllOuts( BNCy( beta,k) ) = p%RotDir * m%BEMT_y%Cy(j,k) ct=cos(m%BEMT_u(indx)%theta(j,k)) st=sin(m%BEMT_u(indx)%theta(j,k)) - m%AllOuts( BNCn( beta,k) ) = m%BEMT_y%Cx(j,k)*ct + m%BEMT_y%Cy(j,k)*st - m%AllOuts( BNCt( beta,k) ) =-m%BEMT_y%Cx(j,k)*st + m%BEMT_y%Cy(j,k)*ct - - m%AllOuts( BNFl( beta,k) ) = m%X(j,k)*cp - m%Y(j,k)*sp - m%AllOuts( BNFd( beta,k) ) = m%X(j,k)*sp + m%Y(j,k)*cp + m%AllOuts( BNCn( beta,k) ) = m%BEMT_y%Cx(j,k)*ct + m%BEMT_y%Cy(j,k)*st + m%AllOuts( BNCt( beta,k) ) = p%RotDir*(-m%BEMT_y%Cx(j,k)*st + m%BEMT_y%Cy(j,k)*ct) + + ! MirrorRotor: m%X/m%Y are in the mirrored frame while phi and theta are the + ! CW-equivalent BEMT values, so convert the forces back before combining them. + Xcw = m%X(j,k) + Ycw = p%RotDir * m%Y(j,k) + m%AllOuts( BNFl( beta,k) ) = Xcw*cp - Ycw*sp + m%AllOuts( BNFd( beta,k) ) = Xcw*sp + Ycw*cp m%AllOuts( BNMm( beta,k) ) = m%M(j,k) m%AllOuts( BNFx( beta,k) ) = m%X(j,k) m%AllOuts( BNFy( beta,k) ) = -m%Y(j,k) - m%AllOuts( BNFn( beta,k) ) = m%X(j,k)*ct - m%Y(j,k)*st - m%AllOuts( BNFt( beta,k) ) = -m%X(j,k)*st - m%Y(j,k)*ct + m%AllOuts( BNFn( beta,k) ) = Xcw*ct - Ycw*st + m%AllOuts( BNFt( beta,k) ) = p%RotDir*(-Xcw*st - Ycw*ct) m%AllOuts( BNGam( beta,k) ) = 0.5_ReKi * p%BEMT%chord(j,k) * m%BEMT_y%Vrel(j,k) * m%BEMT_y%Cl(j,k) ! "Gam" [m^2/s] @@ -527,6 +535,7 @@ end subroutine Calc_WriteOutput_BEMT !! Make sure these are set! subroutine Calc_WriteOutput_FVW integer :: iW + real(ReKi) :: Xcw, Ycw ! blade-element loads converted back to the clockwise frame ! Induced velocity in global ! FVW already return this, we do a simple copy from Wings to Blades @@ -552,36 +561,40 @@ subroutine Calc_WriteOutput_FVW m%AllOuts( BNM( beta,k) ) = m_AD%FVW%W(iW)%BN_Vrel(j) / p%SpdSound m%AllOuts( BNVIndx(beta,k) ) = -m_AD%FVW%W(iW)%BN_UrelWind_s(1,j) * m_AD%FVW%W(iW)%BN_AxInd(j) - m%AllOuts( BNVIndy(beta,k) ) = m_AD%FVW%W(iW)%BN_UrelWind_s(2,j) * m_AD%FVW%W(iW)%BN_TanInd(j) + ! MirrorRotor: the FVW quantities below are clockwise-frame, as BEMT's are, + ! so this block now carries the same RotDir factors as its BEMT counterpart. + m%AllOuts( BNVIndy(beta,k) ) = p%RotDir * m_AD%FVW%W(iW)%BN_UrelWind_s(2,j) * m_AD%FVW%W(iW)%BN_TanInd(j) m%AllOuts( BNAxInd(beta,k) ) = m_AD%FVW%W(iW)%BN_AxInd(j) m%AllOuts( BNTnInd(beta,k) ) = m_AD%FVW%W(iW)%BN_TanInd(j) m%AllOuts( BNAlpha(beta,k) ) = m_AD%FVW%W(iW)%BN_alpha(j)*R2D - m%AllOuts( BNTheta(beta,k) ) = m_AD%FVW%W(iW)%PitchAndTwist(j)*R2D + m%AllOuts( BNTheta(beta,k) ) = p%RotDir * m_AD%FVW%W(iW)%PitchAndTwist(j)*R2D m%AllOuts( BNPhi( beta,k) ) = m_AD%FVW%W(iW)%BN_phi(j)*R2D m%AllOuts( BNCpmin(beta,k) ) = m_AD%FVW%W(iW)%BN_Cpmin(j) m%AllOuts( BNCl( beta,k) ) = m_AD%FVW%W(iW)%BN_Cl(j) m%AllOuts( BNCd( beta,k) ) = m_AD%FVW%W(iW)%BN_Cd(j) - m%AllOuts( BNCm( beta,k) ) = m_AD%FVW%W(iW)%BN_Cm(j) + m%AllOuts( BNCm( beta,k) ) = p%RotDir * m_AD%FVW%W(iW)%BN_Cm(j) m%AllOuts( BNCx( beta,k) ) = m_AD%FVW%W(iW)%BN_Cx(j) - m%AllOuts( BNCy( beta,k) ) = m_AD%FVW%W(iW)%BN_Cy(j) + m%AllOuts( BNCy( beta,k) ) = p%RotDir * m_AD%FVW%W(iW)%BN_Cy(j) - ct=cos(m_AD%FVW%W(iW)%PitchAndTwist(j)) ! cos(theta) - st=sin(m_AD%FVW%W(iW)%PitchAndTwist(j)) ! sin(theta) + ct=cos(p%RotDir*m_AD%FVW%W(iW)%PitchAndTwist(j)) ! cos(theta) + st=sin(p%RotDir*m_AD%FVW%W(iW)%PitchAndTwist(j)) ! sin(theta) m%AllOuts( BNCn( beta,k) ) = m_AD%FVW%W(iW)%BN_Cx(j)*ct + m_AD%FVW%W(iW)%BN_Cy(j)*st - m%AllOuts( BNCt( beta,k) ) =-m_AD%FVW%W(iW)%BN_Cx(j)*st + m_AD%FVW%W(iW)%BN_Cy(j)*ct + m%AllOuts( BNCt( beta,k) ) = p%RotDir*(-m_AD%FVW%W(iW)%BN_Cx(j)*st + m_AD%FVW%W(iW)%BN_Cy(j)*ct) cp=cos(m_AD%FVW%W(iW)%BN_phi(j)) sp=sin(m_AD%FVW%W(iW)%BN_phi(j)) - m%AllOuts( BNFl( beta,k) ) = m%X(j,k)*cp - m%Y(j,k)*sp - m%AllOuts( BNFd( beta,k) ) = m%X(j,k)*sp + m%Y(j,k)*cp + Xcw = m%X(j,k) + Ycw = p%RotDir * m%Y(j,k) + m%AllOuts( BNFl( beta,k) ) = Xcw*cp - Ycw*sp + m%AllOuts( BNFd( beta,k) ) = Xcw*sp + Ycw*cp m%AllOuts( BNMm( beta,k) ) = m%M(j,k) m%AllOuts( BNFx( beta,k) ) = m%X(j,k) m%AllOuts( BNFy( beta,k) ) = -m%Y(j,k) - m%AllOuts( BNFn( beta,k) ) = m%X(j,k)*ct - m%Y(j,k)*st - m%AllOuts( BNFt( beta,k) ) = -m%X(j,k)*st - m%Y(j,k)*ct + m%AllOuts( BNFn( beta,k) ) = Xcw*ct - Ycw*st + m%AllOuts( BNFt( beta,k) ) = p%RotDir*(-Xcw*st - Ycw*ct) m%AllOuts( BNGam( beta,k) ) = 0.5_ReKi * p_AD%FVW%W(iW)%chord_LL(j) * m_AD%FVW%W(iW)%BN_Vrel(j) * m_AD%FVW%W(iW)%BN_Cl(j) ! "Gam" [m^2/s] end do ! nodes @@ -2677,7 +2690,7 @@ subroutine AD_SetVTKSurface(InitOutData_AD, u_AD, VTK_Surface, errStat, errMsg) do K=1, nBlades tipNode = u_AD%rotors(iWT)%BladeMotion(K)%NNodes cylNode = min(3,u_AD%rotors(iWT)%BladeMotion(K)%Nnodes) - call AD_SetVTKDefaultBladeParams(u_AD%rotors(iWT)%BladeMotion(K), VTK_Surface(iWT)%BladeShape(K), tipNode, rootNode, cylNode, errStat2, errMsg2, BlChord=InitOutData_AD%rotors(iWT)%BladeProps(k)%BlChord); if (Failed()) return + call AD_SetVTKDefaultBladeParams(u_AD%rotors(iWT)%BladeMotion(K), VTK_Surface(iWT)%BladeShape(K), tipNode, rootNode, cylNode, InitOutData_AD%rotors(iWT)%RotDir, errStat2, errMsg2, BlChord=InitOutData_AD%rotors(iWT)%BladeProps(k)%BlChord); if (Failed()) return end do endif enddo ! iWT, turbines @@ -2793,15 +2806,17 @@ subroutine AD_WrVTK_LinesPoints(u_AD, y_AD, RefPoint, VTK_count, OutFileRoot, tW end subroutine AD_WrVTK_LinesPoints !---------------------------------------------------------------------------------------------------------------------------------- !> This subroutine comes up with some default airfoils for blade surfaces for a given blade mesh, M. -SUBROUTINE AD_SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode, errStat, errMsg, BlChord) +SUBROUTINE AD_SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode, RotDir, errStat, errMsg, BlChord) TYPE(MeshType), INTENT(IN ) :: M !< The Mesh the defaults should be calculated for TYPE(AD_VTK_BLSurfaceType), INTENT(INOUT) :: BladeShape !< BladeShape to set to default values INTEGER(IntKi), INTENT(IN ) :: rootNode !< Index of root node (innermost node) for this mesh INTEGER(IntKi), INTENT(IN ) :: tipNode !< Index of tip node (outermost node) for this mesh INTEGER(IntKi), INTENT(IN ) :: cylNode !< Index of last node to have a cylinder shape + REAL(ReKi), INTENT(IN ) :: RotDir !< MirrorRotor: +1 normal, -1 mirrored. See the comment on the chordwise term below. INTEGER(IntKi), INTENT( OUT) :: errStat !< Error status of the operation CHARACTER(*), INTENT( OUT) :: errMsg !< Error message if errStat /= ErrID_None REAL(ReKi), OPTIONAL, INTENT(IN ) :: BlChord(:) + REAL(SiKi) :: chordSign !< RotDir as SiKi REAL(SiKi) :: bladeLength, chord, pitchAxis REAL(SiKi) :: bladeLengthFract, bladeLengthFract2, ratio, posLength ! temporary quantities REAL(SiKi) :: cylinderLength, x, y, angle @@ -2813,6 +2828,7 @@ SUBROUTINE AD_SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode ! default airfoil shape coordinates; uses S809 values from http://wind.nrel.gov/airfoils/Shapes/S809_Shape.html: real, parameter, dimension(N) :: xc=(/ 1.0,0.996203,0.98519,0.967844,0.945073,0.917488,0.885293,0.848455,0.80747,0.763042,0.715952,0.667064,0.617331,0.56783,0.519832,0.474243,0.428461,0.382612,0.33726,0.29297,0.250247,0.209576,0.171409,0.136174,0.104263,0.076035,0.051823,0.03191,0.01659,0.006026,0.000658,0.000204,0.0,0.000213,0.001045,0.001208,0.002398,0.009313,0.02323,0.04232,0.065877,0.093426,0.124111,0.157653,0.193738,0.231914,0.271438,0.311968,0.35337,0.395329,0.438273,0.48192,0.527928,0.576211,0.626092,0.676744,0.727211,0.776432,0.823285,0.86663,0.905365,0.938474,0.965086,0.984478,0.996141,1.0 /) real, parameter, dimension(N) :: yc=(/ 0.0,0.000487,0.002373,0.00596,0.011024,0.017033,0.023458,0.03028,0.037766,0.045974,0.054872,0.064353,0.074214,0.084095,0.093268,0.099392,0.10176,0.10184,0.10007,0.096703,0.091908,0.085851,0.078687,0.07058,0.061697,0.052224,0.042352,0.032299,0.02229,0.012615,0.003723,0.001942,-0.00002,-0.001794,-0.003477,-0.003724,-0.005266,-0.011499,-0.020399,-0.030269,-0.040821,-0.051923,-0.063082,-0.07373,-0.083567,-0.092442,-0.099905,-0.105281,-0.108181,-0.108011,-0.104552,-0.097347,-0.086571,-0.073979,-0.060644,-0.047441,-0.0351,-0.024204,-0.015163,-0.008204,-0.003363,-0.000487,0.000743,0.000775,0.00029,0.0 /) + chordSign = real(RotDir, SiKi) call AllocAry(BladeShape%AirfoilCoords, 2, N, M%NNodes, 'BladeShape%AirfoilCoords', errStat2, errMsg2) CALL SetErrStat(errStat2,errMsg2,errStat,errMsg,RoutineName) IF (errStat >= AbortErrLev) RETURN @@ -2843,8 +2859,13 @@ SUBROUTINE AD_SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode y = xc(j) - 0.5 angle = ATAN2( y, x) ! x,y coordinates for cylinder - BladeShape%AirfoilCoords(1,j,i) = chord*COS(angle) ! x (note that "chord" is really representing chord/2 here) - BladeShape%AirfoilCoords(2,j,i) = chord*SIN(angle) ! y (note that "chord" is really representing chord/2 here) + ! MirrorRotor: MeshWrVTK_Ln2Surface places these as matmul(xyz, Orientation), + ! so component 1 rides row 1 of the node's direction cosine matrix and + ! component 2 rides row 2. Under R' = S R S row 1 becomes S*row1 while row 2 + ! becomes -S*row2, so the chordwise term carries the sign and the thickness + ! term does not. Visualisation only. + BladeShape%AirfoilCoords(1,j,i) = chord*COS(angle) ! x (note that "chord" is really representing chord/2 here) + BladeShape%AirfoilCoords(2,j,i) = chordSign * chord*SIN(angle) ! y (note that "chord" is really representing chord/2 here) END DO ELSE ! create an airfoil for this node @@ -2853,8 +2874,8 @@ SUBROUTINE AD_SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode x = yc(j) y = xc(j) - pitchAxis ! x,y coordinates for airfoil - BladeShape%AirfoilCoords(1,j,i) = chord*x - BladeShape%AirfoilCoords(2,j,i) = chord*y + BladeShape%AirfoilCoords(1,j,i) = chord*x + BladeShape%AirfoilCoords(2,j,i) = chordSign * chord*y END DO END IF END DO ! nodes on mesh diff --git a/modules/aerodyn/src/AeroDyn_Registry.txt b/modules/aerodyn/src/AeroDyn_Registry.txt index a44c0c58e8..24a94eebc6 100644 --- a/modules/aerodyn/src/AeroDyn_Registry.txt +++ b/modules/aerodyn/src/AeroDyn_Registry.txt @@ -137,6 +137,7 @@ typedef ^ RotInitInputType R8Ki NacellePosition {3} - - "X-Y-Z reference positio typedef ^ RotInitInputType R8Ki NacelleOrientation {3}{3} - - "DCM reference orientation of nacelle" - typedef ^ RotInitInputType IntKi AeroProjMod - 1 - "Flag to switch between different projection models" - typedef ^ RotInitInputType ReKi RotSpeed - - 0 "Rotor speed used when AeroDyn is computing aero maps" "rad/s" +typedef ^ RotInitInputType Logical MirrorRotor - .FALSE. - "Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind)" - typedef ^ InitInputType RotInitInputType rotors {:} - - "Init Input Types for rotors" - typedef ^ InitInputType CHARACTER(1024) InputFile - - - "Name of the input file" - @@ -186,6 +187,7 @@ typedef ^ RotInitOutputType ReKi AirDens - - - "Air density" kg/m^3 typedef ^ RotInitOutputType CHARACTER(ChanLen) WriteOutputHdr {:} - - "Names of the output-to-file channels" - typedef ^ RotInitOutputType CHARACTER(ChanLen) WriteOutputUnt {:} - - "Units of the output-to-file channels" - typedef ^ RotInitOutputType AD_BladeShape BladeShape {:} - - "airfoil coordinates for each blade" m +typedef ^ RotInitOutputType ReKi RotDir - 1.0 - "Rotor rotation direction: +1 normal (CW viewed from upwind), -1 mirrored (CCW). Needed alongside BladeShape so the VTK surface fallback can mirror the generic section." - typedef ^ RotInitOutputType AD_BladePropsType BladeProps {:} - - "blade property information from blade input files" - typedef ^ RotInitOutputType ReKi TwrElev {:} - - "Elevation at tower node" m typedef ^ RotInitOutputType ReKi TwrDiam {:} - - "Diameter of tower at node" m @@ -407,6 +409,7 @@ typedef ^ RotParameterType ReKi WtrDpth - - - "Water depth" m typedef ^ RotParameterType ReKi MSL2SWL - - - "Offset between still-water level and mean sea level" m typedef ^ RotParameterType IntKi AeroProjMod - 1 - "Flag to switch between different projection models" - typedef ^ RotParameterType IntKi BEM_Mod - -1 - "Flag to switch between different BEM Model" - +typedef ^ RotParameterType ReKi RotDir - 1.0 - "Rotor rotation direction: +1 normal (CW viewed from upwind), -1 mirrored (CCW)" - # parameters for output typedef ^ RotParameterType IntKi NumOuts - - - "Number of parameters in the output list (number of outputs requested)" - typedef ^ RotParameterType CHARACTER(1024) RootName - - - "RootName for writing output files" - diff --git a/modules/aerodyn/src/AeroDyn_Types.f90 b/modules/aerodyn/src/AeroDyn_Types.f90 index ff49e7ff1b..4a5fbcae83 100644 --- a/modules/aerodyn/src/AeroDyn_Types.f90 +++ b/modules/aerodyn/src/AeroDyn_Types.f90 @@ -158,6 +158,7 @@ MODULE AeroDyn_Types REAL(R8Ki) , DIMENSION(1:3,1:3) :: NacelleOrientation = 0.0_R8Ki !< DCM reference orientation of nacelle [-] INTEGER(IntKi) :: AeroProjMod = 1 !< Flag to switch between different projection models [-] REAL(ReKi) :: RotSpeed = 0.0_ReKi !< Rotor speed used when AeroDyn is computing aero maps [rad/s] + LOGICAL :: MirrorRotor = .FALSE. !< Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind) [-] END TYPE RotInitInputType ! ======================= ! ========= AD_InitInputType ======= @@ -215,6 +216,7 @@ MODULE AeroDyn_Types CHARACTER(ChanLen) , DIMENSION(:), ALLOCATABLE :: WriteOutputHdr !< Names of the output-to-file channels [-] CHARACTER(ChanLen) , DIMENSION(:), ALLOCATABLE :: WriteOutputUnt !< Units of the output-to-file channels [-] TYPE(AD_BladeShape) , DIMENSION(:), ALLOCATABLE :: BladeShape !< airfoil coordinates for each blade [m] + REAL(ReKi) :: RotDir = 1.0 !< Rotor rotation direction: +1 normal (CW viewed from upwind), -1 mirrored (CCW). Needed alongside BladeShape so the VTK surface fallback can mirror the generic section. [-] TYPE(AD_BladePropsType) , DIMENSION(:), ALLOCATABLE :: BladeProps !< blade property information from blade input files [-] REAL(ReKi) , DIMENSION(:), ALLOCATABLE :: TwrElev !< Elevation at tower node [m] REAL(ReKi) , DIMENSION(:), ALLOCATABLE :: TwrDiam !< Diameter of tower at node [m] @@ -460,6 +462,7 @@ MODULE AeroDyn_Types REAL(ReKi) :: MSL2SWL = 0.0_ReKi !< Offset between still-water level and mean sea level [m] INTEGER(IntKi) :: AeroProjMod = 1 !< Flag to switch between different projection models [-] INTEGER(IntKi) :: BEM_Mod = -1 !< Flag to switch between different BEM Model [-] + REAL(ReKi) :: RotDir = 1.0 !< Rotor rotation direction: +1 normal (CW viewed from upwind), -1 mirrored (CCW) [-] INTEGER(IntKi) :: NumOuts = 0_IntKi !< Number of parameters in the output list (number of outputs requested) [-] CHARACTER(1024) :: RootName !< RootName for writing output files [-] TYPE(OutParmType) , DIMENSION(:), ALLOCATABLE :: OutParam !< Names and units (and other characteristics) of all requested output parameters [-] @@ -1249,6 +1252,7 @@ subroutine AD_CopyRotInitInputType(SrcRotInitInputTypeData, DstRotInitInputTypeD DstRotInitInputTypeData%NacelleOrientation = SrcRotInitInputTypeData%NacelleOrientation DstRotInitInputTypeData%AeroProjMod = SrcRotInitInputTypeData%AeroProjMod DstRotInitInputTypeData%RotSpeed = SrcRotInitInputTypeData%RotSpeed + DstRotInitInputTypeData%MirrorRotor = SrcRotInitInputTypeData%MirrorRotor end subroutine subroutine AD_DestroyRotInitInputType(RotInitInputTypeData, ErrStat, ErrMsg) @@ -1281,6 +1285,7 @@ subroutine AD_PackRotInitInputType(RF, Indata) call RegPack(RF, InData%NacelleOrientation) call RegPack(RF, InData%AeroProjMod) call RegPack(RF, InData%RotSpeed) + call RegPack(RF, InData%MirrorRotor) if (RegCheckErr(RF, RoutineName)) return end subroutine @@ -1302,6 +1307,7 @@ subroutine AD_UnPackRotInitInputType(RF, OutData) call RegUnpack(RF, OutData%NacelleOrientation); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%AeroProjMod); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%RotSpeed); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%MirrorRotor); if (RegCheckErr(RF, RoutineName)) return end subroutine subroutine AD_CopyInitInput(SrcInitInputData, DstInitInputData, CtrlCode, ErrStat, ErrMsg) @@ -1911,6 +1917,7 @@ subroutine AD_CopyRotInitOutputType(SrcRotInitOutputTypeData, DstRotInitOutputTy if (ErrStat >= AbortErrLev) return end do end if + DstRotInitOutputTypeData%RotDir = SrcRotInitOutputTypeData%RotDir if (allocated(SrcRotInitOutputTypeData%BladeProps)) then LB(1:1) = lbound(SrcRotInitOutputTypeData%BladeProps) UB(1:1) = ubound(SrcRotInitOutputTypeData%BladeProps) @@ -2018,6 +2025,7 @@ subroutine AD_PackRotInitOutputType(RF, Indata) call AD_PackBladeShape(RF, InData%BladeShape(i1)) end do end if + call RegPack(RF, InData%RotDir) call RegPack(RF, allocated(InData%BladeProps)) if (allocated(InData%BladeProps)) then call RegPackBounds(RF, 1, lbound(InData%BladeProps), ubound(InData%BladeProps)) @@ -2058,6 +2066,7 @@ subroutine AD_UnPackRotInitOutputType(RF, OutData) call AD_UnpackBladeShape(RF, OutData%BladeShape(i1)) ! BladeShape end do end if + call RegUnpack(RF, OutData%RotDir); if (RegCheckErr(RF, RoutineName)) return if (allocated(OutData%BladeProps)) deallocate(OutData%BladeProps) call RegUnpack(RF, IsAllocAssoc); if (RegCheckErr(RF, RoutineName)) return if (IsAllocAssoc) then @@ -4234,6 +4243,7 @@ subroutine AD_CopyRotParameterType(SrcRotParameterTypeData, DstRotParameterTypeD DstRotParameterTypeData%MSL2SWL = SrcRotParameterTypeData%MSL2SWL DstRotParameterTypeData%AeroProjMod = SrcRotParameterTypeData%AeroProjMod DstRotParameterTypeData%BEM_Mod = SrcRotParameterTypeData%BEM_Mod + DstRotParameterTypeData%RotDir = SrcRotParameterTypeData%RotDir DstRotParameterTypeData%NumOuts = SrcRotParameterTypeData%NumOuts DstRotParameterTypeData%RootName = SrcRotParameterTypeData%RootName if (allocated(SrcRotParameterTypeData%OutParam)) then @@ -4484,6 +4494,7 @@ subroutine AD_PackRotParameterType(RF, Indata) call RegPack(RF, InData%MSL2SWL) call RegPack(RF, InData%AeroProjMod) call RegPack(RF, InData%BEM_Mod) + call RegPack(RF, InData%RotDir) call RegPack(RF, InData%NumOuts) call RegPack(RF, InData%RootName) call RegPack(RF, allocated(InData%OutParam)) @@ -4592,6 +4603,7 @@ subroutine AD_UnPackRotParameterType(RF, OutData) call RegUnpack(RF, OutData%MSL2SWL); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%AeroProjMod); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%BEM_Mod); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%RotDir); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%NumOuts); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%RootName); if (RegCheckErr(RF, RoutineName)) return if (allocated(OutData%OutParam)) deallocate(OutData%OutParam) diff --git a/modules/aerodyn/src/FVW.f90 b/modules/aerodyn/src/FVW.f90 index ba78825ab9..04bec51b48 100644 --- a/modules/aerodyn/src/FVW.f90 +++ b/modules/aerodyn/src/FVW.f90 @@ -392,6 +392,7 @@ subroutine FVW_SetParametersFromInputs( InitInp, p, ErrStat, ErrMsg ) call AllocAry(p%W(iW)%AFindx, size(InitInp%W(iW)%AFindx,1), 1, 'AFindx',ErrStat,ErrMsg) p%W(iW)%AFindx = InitInp%W(iW)%AFindx ! Copying in case AD15 still needs these p%W(iW)%iRotor = InitInp%W(iW)%iRotor + p%W(iW)%RotDir = InitInp%W(iW)%RotDir p%W(iW)%nSpan = size(InitInp%W(iW)%chord)-1 call move_alloc(InitInp%W(iW)%chord, p%W(iW)%chord_LL) @@ -1764,11 +1765,13 @@ subroutine CalculateInputsAndOtherStatesForUA(InputIndex, u, p, x, xd, z, m, Err !! ....... compute inputs to UA ........... ! NOTE: To be consistent with CalcOutput we take Vwind_LL that was set using m%DisturbedInflow from AeroDyn.. ! This is not clean, but done to be consistent, waiting for AeroDyn to handle UA - call AlphaVrel_Generic(u%WingsMesh(iW)%Orientation(1:3,1:3,i), u%WingsMesh(iW)%TranslationVel(1:3,i), m%W(iW)%Vind_LL(1:3,i), u%W(iW)%Vwnd_LL(1:3,i), & + call AlphaVrel_Generic(u%WingsMesh(iW)%Orientation(1:3,1:3,i), p%W(iW)%RotDir, u%WingsMesh(iW)%TranslationVel(1:3,i), m%W(iW)%Vind_LL(1:3,i), u%W(iW)%Vwnd_LL(1:3,i), & p%KinVisc, p%W(iW)%chord_LL(i), u_UA%U, u_UA%alpha, u_UA%Re) u_UA%v_ac(1) = sin(u_UA%alpha)*u_UA%U u_UA%v_ac(2) = cos(u_UA%alpha)*u_UA%U - u_UA%omega = u%W(iW)%omega_z(i) + ! MirrorRotor: the torsion rate is the section-frame z component of a + ! pseudovector, which reverses under the reflection, matching BEMT's omega_z. + u_UA%omega = p%W(iW)%RotDir * u%W(iW)%omega_z(i) u_UA%UserProp = 0 ! u1%UserProp(i,j) ! TODO end do ! i nSpan end do ! iW nWings diff --git a/modules/aerodyn/src/FVW_Registry.txt b/modules/aerodyn/src/FVW_Registry.txt index 72968d627f..ce77596588 100644 --- a/modules/aerodyn/src/FVW_Registry.txt +++ b/modules/aerodyn/src/FVW_Registry.txt @@ -86,6 +86,7 @@ typedef ^ ^ ReKi typedef ^ ^ ReKi s_LL : - - "Spanwise coordinate of LL elements" m typedef ^ ^ ReKi s_CP : - - "Spanwise coordinate of LL CP" m typedef ^ ^ IntKi iRotor - 0 - "Index of rotor the wing belong to" - +typedef ^ ^ ReKi RotDir - 1.0 - "Rotation direction of the rotor the wing belongs to: +1 clockwise, -1 mirrored (counter-clockwise viewed from upwind)" - typedef ^ ^ IntKi AFindx :: - - "Index to the airfoils from AD15 [BladeNode,BladeIndex=1]" - typedef ^ ^ IntKi nSpan - 0 - "TODO, should be defined per wing. Number of spanwise element" - typedef ^ ^ ReKi PrescribedCirculation : - - "Prescribed circulation on all lifting lines" "m/s" @@ -293,6 +294,7 @@ typedef FVW/FVW Wng_InitInputType IntKi typedef ^ ^ ReKi chord : - - "Chord of each blade element from input file [idx1=BladeNode, idx2=Blade number]" - typedef ^ ^ ReKi RElm : - - "radius of center of each element" - typedef ^ ^ IntKi iRotor - - - "Index of rotor the wing belong to" - +typedef ^ ^ ReKi RotDir - 1.0 - "Rotation direction of the rotor the wing belongs to: +1 clockwise, -1 mirrored (counter-clockwise viewed from upwind)" - typedef ^ ^ INTEGER UAOff_innerNode - - - "Last node on each blade where UA should be turned off based on span location from blade root (0 if always on)" - typedef ^ ^ INTEGER UAOff_outerNode - - - "First node on each blade where UA should be turned off based on span location from blade tip (>nNodesPerBlade if always on)" - diff --git a/modules/aerodyn/src/FVW_Subs.f90 b/modules/aerodyn/src/FVW_Subs.f90 index 95c3aa0b2c..8a087e6daa 100644 --- a/modules/aerodyn/src/FVW_Subs.f90 +++ b/modules/aerodyn/src/FVW_Subs.f90 @@ -1716,11 +1716,12 @@ end subroutine FakeGroundEffect !! - some transformation matrices !! - M_ag : from global to airfoil (this is well defined, also called "n-t" system in AeroDyn) !! - M_sg : from global to section (this is ill-defined), this coordinate is used to define the "axial" and "tangential" inductions -subroutine FVW_AeroOuts( M_sg, M_ag, PitchAndTwist, Vstr_g, Vind_g, Vwnd_g, KinVisc, Chord, & +subroutine FVW_AeroOuts( M_sg, M_ag, PitchAndTwist, RotDir, Vstr_g, Vind_g, Vwnd_g, KinVisc, Chord, & AxInd, TanInd, Vrel_norm, phi, alpha, Re, Urel_s, ErrStat, ErrMsg ) real(R8Ki), intent(in ) :: M_sg(3,3) ! m%WithoutSweepPitchTwist global coord to "section" coord real(R8Ki), intent(in ) :: M_ag(3,3) ! u%BladeMotion(k)%Orientation(1:3,1:3,j) global coord to airfoil coord real(ReKi), intent(in ) :: PitchAndTwist ! Pitch and twist of section + real(ReKi), intent(in ) :: RotDir ! +1 clockwise rotor, -1 mirrored (counter-clockwise) real(ReKi), intent(in ) :: Vstr_g(3) ! Structural velocity global coord real(ReKi), intent(in ) :: Vind_g(3) ! Induced wind velocity global coord real(ReKi), intent(in ) :: Vwnd_g(3) ! Disturbed inflow global coord @@ -1756,14 +1757,19 @@ subroutine FVW_AeroOuts( M_sg, M_ag, PitchAndTwist, Vstr_g, Vind_g, Vwnd_g, Kin ! --- Airfoil coordinates: used to define alpha, and Vrel, also called "n-t" system Vtot_g = Vwnd_g - Vstr_g + Vind_g Vtot_a = matmul(M_ag, Vtot_g) + ! MirrorRotor: the airfoil tables describe the clockwise section, so the angles are + ! taken in the clockwise-equivalent frame. Reflecting about the rotor xz plane sends + ! a true vector's components in the airfoil and section frames to (v1, -v2, v3), so + ! RotDir applies to the chordwise and tangential components and to nothing else. + Vtot_a(2) = RotDir * Vtot_a(2) alpha = atan2( Vtot_a(1), Vtot_a(2) ) Vrel_norm = sqrt(Vtot_a(1)**2 + Vtot_a(2)**2) ! NOTE: z component shoudn't be used Re = Chord * Vrel_norm / KinVisc ! Reynolds number (not in million) ! Section coordinates: used to define axial induction andflow angle - Vstr_s = matmul(M_sg, Vstr_g) - Vind_s = matmul(M_sg, Vind_g) - Vwnd_s = matmul(M_sg, Vwnd_g) + Vstr_s = matmul(M_sg, Vstr_g); Vstr_s(2) = RotDir * Vstr_s(2) + Vind_s = matmul(M_sg, Vind_g); Vind_s(2) = RotDir * Vind_s(2) + Vwnd_s = matmul(M_sg, Vwnd_g); Vwnd_s(2) = RotDir * Vwnd_s(2) Urel_s = Vwnd_s - Vstr_s ! relative wind Vtot_s = Vwnd_s - Vstr_s + Vind_s if (EqualRealNos(Urel_s(1),0.0_ReKi)) then @@ -1782,8 +1788,9 @@ subroutine FVW_AeroOuts( M_sg, M_ag, PitchAndTwist, Vstr_g, Vind_g, Vwnd_g, Kin end subroutine FVW_AeroOuts !> Generic function to compute alpha, Vrel and Re based on global data -subroutine AlphaVrel_Generic(M_ag, Vstr_g, Vind_g, Vwnd_g, KinVisc, Chord, Vrel_norm, alpha, Re) +subroutine AlphaVrel_Generic(M_ag, RotDir, Vstr_g, Vind_g, Vwnd_g, KinVisc, Chord, Vrel_norm, alpha, Re) real(R8Ki), intent(in ) :: M_ag(3,3) ! u%BladeMotion(k)%Orientation(1:3,1:3,j) global coord to airfoil coord + real(ReKi), intent(in ) :: RotDir ! +1 clockwise rotor, -1 mirrored (counter-clockwise) real(ReKi), intent(in ) :: Vstr_g(3) ! Structural velocity global coord real(ReKi), intent(in ) :: Vind_g(3) ! Induced wind velocity global coord real(ReKi), intent(in ) :: Vwnd_g(3) ! Disturbed inflow global coord @@ -1798,6 +1805,7 @@ subroutine AlphaVrel_Generic(M_ag, Vstr_g, Vind_g, Vwnd_g, KinVisc, Chord, Vrel ! --- Airfoil coordinates: used to define alpha, and Vrel, also called "n-t" system Vtot_g = Vwnd_g - Vstr_g + Vind_g Vtot_a = matmul(M_ag, Vtot_g) + Vtot_a(2) = RotDir * Vtot_a(2) ! MirrorRotor: see FVW_AeroOuts alpha = atan2( Vtot_a(1), Vtot_a(2) ) Vrel_norm = sqrt(Vtot_a(1)**2 + Vtot_a(2)**2) ! NOTE: z component shoudn't be used Re = Chord * Vrel_norm / KinVisc ! Reynolds number NOTE: not in million diff --git a/modules/aerodyn/src/FVW_Types.f90 b/modules/aerodyn/src/FVW_Types.f90 index e8d100f37b..48ae64295a 100644 --- a/modules/aerodyn/src/FVW_Types.f90 +++ b/modules/aerodyn/src/FVW_Types.f90 @@ -128,6 +128,7 @@ MODULE FVW_Types REAL(ReKi) , DIMENSION(:), ALLOCATABLE :: s_LL !< Spanwise coordinate of LL elements [m] REAL(ReKi) , DIMENSION(:), ALLOCATABLE :: s_CP !< Spanwise coordinate of LL CP [m] INTEGER(IntKi) :: iRotor = 0 !< Index of rotor the wing belong to [-] + REAL(ReKi) :: RotDir = 1.0 !< Rotation direction of the rotor the wing belongs to: +1 clockwise, -1 mirrored (counter-clockwise viewed from upwind) [-] INTEGER(IntKi) , DIMENSION(:,:), ALLOCATABLE :: AFindx !< Index to the airfoils from AD15 [BladeNode,BladeIndex=1] [-] INTEGER(IntKi) :: nSpan = 0 !< TODO, should be defined per wing. Number of spanwise element [-] REAL(ReKi) , DIMENSION(:), ALLOCATABLE :: PrescribedCirculation !< Prescribed circulation on all lifting lines [m/s] @@ -344,6 +345,7 @@ MODULE FVW_Types REAL(ReKi) , DIMENSION(:), ALLOCATABLE :: chord !< Chord of each blade element from input file [idx1=BladeNode, idx2=Blade number] [-] REAL(ReKi) , DIMENSION(:), ALLOCATABLE :: RElm !< radius of center of each element [-] INTEGER(IntKi) :: iRotor = 0_IntKi !< Index of rotor the wing belong to [-] + REAL(ReKi) :: RotDir = 1.0 !< Rotation direction of the rotor the wing belongs to: +1 clockwise, -1 mirrored (counter-clockwise viewed from upwind) [-] INTEGER(IntKi) :: UAOff_innerNode = 0_IntKi !< Last node on each blade where UA should be turned off based on span location from blade root (0 if always on) [-] INTEGER(IntKi) :: UAOff_outerNode = 0_IntKi !< First node on each blade where UA should be turned off based on span location from blade tip (>nNodesPerBlade if always on) [-] END TYPE Wng_InitInputType @@ -1394,6 +1396,7 @@ subroutine FVW_CopyWng_ParameterType(SrcWng_ParameterTypeData, DstWng_ParameterT DstWng_ParameterTypeData%s_CP = SrcWng_ParameterTypeData%s_CP end if DstWng_ParameterTypeData%iRotor = SrcWng_ParameterTypeData%iRotor + DstWng_ParameterTypeData%RotDir = SrcWng_ParameterTypeData%RotDir if (allocated(SrcWng_ParameterTypeData%AFindx)) then LB(1:2) = lbound(SrcWng_ParameterTypeData%AFindx) UB(1:2) = ubound(SrcWng_ParameterTypeData%AFindx) @@ -1458,6 +1461,7 @@ subroutine FVW_PackWng_ParameterType(RF, Indata) call RegPackAlloc(RF, InData%s_LL) call RegPackAlloc(RF, InData%s_CP) call RegPack(RF, InData%iRotor) + call RegPack(RF, InData%RotDir) call RegPackAlloc(RF, InData%AFindx) call RegPack(RF, InData%nSpan) call RegPackAlloc(RF, InData%PrescribedCirculation) @@ -1477,6 +1481,7 @@ subroutine FVW_UnPackWng_ParameterType(RF, OutData) call RegUnpackAlloc(RF, OutData%s_LL); if (RegCheckErr(RF, RoutineName)) return call RegUnpackAlloc(RF, OutData%s_CP); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%iRotor); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%RotDir); if (RegCheckErr(RF, RoutineName)) return call RegUnpackAlloc(RF, OutData%AFindx); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%nSpan); if (RegCheckErr(RF, RoutineName)) return call RegUnpackAlloc(RF, OutData%PrescribedCirculation); if (RegCheckErr(RF, RoutineName)) return @@ -3979,6 +3984,7 @@ subroutine FVW_CopyWng_InitInputType(SrcWng_InitInputTypeData, DstWng_InitInputT DstWng_InitInputTypeData%RElm = SrcWng_InitInputTypeData%RElm end if DstWng_InitInputTypeData%iRotor = SrcWng_InitInputTypeData%iRotor + DstWng_InitInputTypeData%RotDir = SrcWng_InitInputTypeData%RotDir DstWng_InitInputTypeData%UAOff_innerNode = SrcWng_InitInputTypeData%UAOff_innerNode DstWng_InitInputTypeData%UAOff_outerNode = SrcWng_InitInputTypeData%UAOff_outerNode end subroutine @@ -4010,6 +4016,7 @@ subroutine FVW_PackWng_InitInputType(RF, Indata) call RegPackAlloc(RF, InData%chord) call RegPackAlloc(RF, InData%RElm) call RegPack(RF, InData%iRotor) + call RegPack(RF, InData%RotDir) call RegPack(RF, InData%UAOff_innerNode) call RegPack(RF, InData%UAOff_outerNode) if (RegCheckErr(RF, RoutineName)) return @@ -4027,6 +4034,7 @@ subroutine FVW_UnPackWng_InitInputType(RF, OutData) call RegUnpackAlloc(RF, OutData%chord); if (RegCheckErr(RF, RoutineName)) return call RegUnpackAlloc(RF, OutData%RElm); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%iRotor); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%RotDir); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%UAOff_innerNode); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%UAOff_outerNode); if (RegCheckErr(RF, RoutineName)) return end subroutine diff --git a/modules/aerodyn/src/FVW_Wings.f90 b/modules/aerodyn/src/FVW_Wings.f90 index 4279d9d59c..89952b82ae 100644 --- a/modules/aerodyn/src/FVW_Wings.f90 +++ b/modules/aerodyn/src/FVW_Wings.f90 @@ -153,8 +153,13 @@ subroutine Wings_Panelling(Meshes, p, m, ErrStat, ErrMsg ) DP_LE(1) = -p%W(iW)%chord_LL(iSpan)/4. DP_TE(1:3) = 0.0 DP_TE(1) = +3.*p%W(iW)%chord_LL(iSpan)/4. - m%W(iW)%LE(1:3, iSpan) = P_ref + DP_LE(1)*Meshes(iW)%Orientation(2,1:3,iSpan) - m%W(iW)%TE(1:3, iSpan) = P_ref + DP_TE(1)*Meshes(iW)%Orientation(2,1:3,iSpan) + ! MirrorRotor: the section is used as the mirror image of the tabulated one, + ! so its leading and trailing edges lie on the other side of the reference + ! point. Without this the bound vortex and the shedding line sit on the wrong + ! side of the chord, and the wake is not the mirror image of the clockwise + ! one - an error of order chord over span. + m%W(iW)%LE(1:3, iSpan) = P_ref + p%W(iW)%RotDir*DP_LE(1)*Meshes(iW)%Orientation(2,1:3,iSpan) + m%W(iW)%TE(1:3, iSpan) = P_ref + p%W(iW)%RotDir*DP_TE(1)*Meshes(iW)%Orientation(2,1:3,iSpan) enddo enddo ! --- Generic code below to compute normal/tangential vectors of a lifting line panel @@ -564,7 +569,18 @@ subroutine CirculationFromPolarData(Gamma_LL, p, m, AFInfo, ErrStat, ErrMsg) Vjouk_orth_norm = TwoNorm(Vjouk_orth) Vrel_norm = TwoNorm(Vrel) - alpha = atan2(dot_product(Vrel,N) , dot_product(Vrel,Tc) ) ! [rad] + ! MirrorRotor: the airfoil tables describe the clockwise section, so the angle + ! of attack has to be taken in the clockwise-equivalent frame. Note that the + ! factor sits on the normal term here, not the tangential one as it does in + ! FVW_AeroOuts. Tang is a true vector but Norm is a cross product of two true + ! vectors, so it is a pseudovector and picks up the extra sign of the + ! reflection; the two routines describe the same angle through oppositely + ! signed quantities. + ! With the leading and trailing edges mirrored above, Tang reflects as a true + ! vector while Norm, being their cross product with the span, reflects as a + ! pseudovector and carries the extra sign. So RotDir sits on the normal term + ! here, unlike FVW_AeroOuts, which works from the airfoil DCM instead. + alpha = atan2(p%W(iW)%RotDir*dot_product(Vrel,N) , dot_product(Vrel,Tc) ) ! [rad] Re = p%W(iW)%chord_CP(icp) * Vrel_norm / p%KinVisc ! Reynolds number (not in Million) !if (p%CircSolvPolar==idPolarAeroDyn) then @@ -577,7 +593,11 @@ subroutine CirculationFromPolarData(Gamma_LL, p, m, AFInfo, ErrStat, ErrMsg) ! Simple method: ! Gamma_LL=(0.5 * Cl * Vrel_orth_norm*chord) ! VanGarrel's method: - Gamma_LL(kCP) =(0.5_ReKi * Cl * Vrel_orth_norm**2*m%W(iW)%Area(icp)/(Vjouk_orth_norm)) ! TODO + ! MirrorRotor: Cl above is the clockwise-equivalent value, so this is the + ! clockwise circulation. Bound circulation is a pseudovector along the span + ! while the span axis itself is a true vector, so the scalar strength + ! reverses on a mirrored wing. The wake stays in the physical frame. + Gamma_LL(kCP) = p%W(iW)%RotDir*(0.5_ReKi * Cl * Vrel_orth_norm**2*m%W(iW)%Area(icp)/(Vjouk_orth_norm)) ! TODO ! Convenient storage m%W(iW)%alpha_LL(icp) = alpha ! [rad] m%W(iW)%Vreln_LL(icp) = Vrel_norm diff --git a/modules/beamdyn/src/BeamDyn.f90 b/modules/beamdyn/src/BeamDyn.f90 index 65791d3b43..e89fb80327 100644 --- a/modules/beamdyn/src/BeamDyn.f90 +++ b/modules/beamdyn/src/BeamDyn.f90 @@ -117,6 +117,8 @@ SUBROUTINE BD_Init( InitInp, u, p, x, xd, z, OtherState, y, MiscVar, Interval, I CALL DispNVD( BeamDyn_Ver ) CALL BD_ReadInput(InitInp%InputFile,InputFileData,InitInp%RootName,Interval,ErrStat2,ErrMsg2); if (Failed()) return + ! Mirrored before validating, so the validator also checks the mirrored blade. + if (InitInp%MirrorRotor) CALL BD_MirrorBladeData( InputFileData ) CALL BD_ValidateInputData( InitInp, InputFileData, ErrStat2, ErrMsg2 ); if (Failed()) return diff --git a/modules/beamdyn/src/BeamDyn_IO.f90 b/modules/beamdyn/src/BeamDyn_IO.f90 index 3ac90b1a52..b8503f2c57 100644 --- a/modules/beamdyn/src/BeamDyn_IO.f90 +++ b/modules/beamdyn/src/BeamDyn_IO.f90 @@ -508,6 +508,54 @@ MODULE BeamDyn_IO CONTAINS !---------------------------------------------------------------------------------------------------------------------------------- +!> Mirror the blade description about its local x-z plane, so a deck written for a +!! clockwise rotor describes the counter-clockwise mirror image of the same blade. +!! +!! The sectional degrees of freedom are ordered [u_x, u_y, u_z, theta_x, theta_y, theta_z]. +!! Reflecting y negates u_y and, because rotations are pseudovectors, theta_x and theta_z, +!! giving T = diag(1,-1,1,-1,1,-1). The 6x6 matrices transform as T*M*T^T, which in +!! practice flips every entry with exactly one index in {2,4,6}. T is its own inverse, so +!! applying this twice returns the original blade. +!! +!! Diagonal entries never flip, so the mass0(6,6) = mass0(4,4) + mass0(5,5) check in +!! BD_ValidateInputData still holds afterwards. +SUBROUTINE BD_MirrorBladeData( InputFileData ) + + TYPE(BD_InputFile), INTENT(INOUT) :: InputFileData !< Data stored in the module's input file + + REAL(BDKi), PARAMETER :: T(6) = (/ 1.0_BDKi, -1.0_BDKi, 1.0_BDKi, -1.0_BDKi, 1.0_BDKi, -1.0_BDKi /) + INTEGER(IntKi) :: i, j, k + + ! Key points: reflect the y offset and the twist. Note the twist stored here is + ! already the negative of the value in the file (see BD_ReadBladeFileInput), so this + ! leaves it equal to the file value rather than doubly negated. + if (allocated(InputFileData%kp_coordinate)) then + InputFileData%kp_coordinate(:,2) = -InputFileData%kp_coordinate(:,2) + InputFileData%kp_coordinate(:,4) = -InputFileData%kp_coordinate(:,4) + end if + + if (allocated(InputFileData%InpBl%stiff0)) then + do k = 1,size(InputFileData%InpBl%stiff0,3) + do j = 1,6 + do i = 1,6 + InputFileData%InpBl%stiff0(i,j,k) = T(i)*T(j)*InputFileData%InpBl%stiff0(i,j,k) + end do + end do + end do + end if + + if (allocated(InputFileData%InpBl%mass0)) then + do k = 1,size(InputFileData%InpBl%mass0,3) + do j = 1,6 + do i = 1,6 + InputFileData%InpBl%mass0(i,j,k) = T(i)*T(j)*InputFileData%InpBl%mass0(i,j,k) + end do + end do + end do + end if + +END SUBROUTINE BD_MirrorBladeData +!---------------------------------------------------------------------------------------------------------------------------------- SUBROUTINE BD_ReadInput(InputFileName,InputFileData,OutFileRoot, Default_DT,ErrStat,ErrMsg) ! Passed Variables: diff --git a/modules/beamdyn/src/BeamDyn_Types.f90 b/modules/beamdyn/src/BeamDyn_Types.f90 index 09d754db0b..82d4328764 100644 --- a/modules/beamdyn/src/BeamDyn_Types.f90 +++ b/modules/beamdyn/src/BeamDyn_Types.f90 @@ -52,6 +52,7 @@ MODULE BeamDyn_Types LOGICAL :: Linearize = .FALSE. !< Flag that tells this module if the glue code wants to linearize. [-] LOGICAL :: DynamicSolve = .TRUE. !< Use dynamic solve option. Set to False for static solving (handled by glue code or driver code). [-] LOGICAL :: CompAeroMaps = .FALSE. !< flag to determine if BeamDyn is computing aero maps (true) or running a normal simulation (false) [-] + LOGICAL :: MirrorRotor = .FALSE. !< Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind) [-] END TYPE BD_InitInputType ! ======================= ! ========= BD_InitOutputType ======= @@ -354,6 +355,7 @@ subroutine BD_CopyInitInput(SrcInitInputData, DstInitInputData, CtrlCode, ErrSta DstInitInputData%Linearize = SrcInitInputData%Linearize DstInitInputData%DynamicSolve = SrcInitInputData%DynamicSolve DstInitInputData%CompAeroMaps = SrcInitInputData%CompAeroMaps + DstInitInputData%MirrorRotor = SrcInitInputData%MirrorRotor end subroutine subroutine BD_DestroyInitInput(InitInputData, ErrStat, ErrMsg) @@ -381,6 +383,7 @@ subroutine BD_PackInitInput(RF, Indata) call RegPack(RF, InData%Linearize) call RegPack(RF, InData%DynamicSolve) call RegPack(RF, InData%CompAeroMaps) + call RegPack(RF, InData%MirrorRotor) if (RegCheckErr(RF, RoutineName)) return end subroutine @@ -400,6 +403,7 @@ subroutine BD_UnPackInitInput(RF, OutData) call RegUnpack(RF, OutData%Linearize); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%DynamicSolve); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%CompAeroMaps); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%MirrorRotor); if (RegCheckErr(RF, RoutineName)) return end subroutine subroutine BD_CopyInitOutput(SrcInitOutputData, DstInitOutputData, CtrlCode, ErrStat, ErrMsg) diff --git a/modules/beamdyn/src/Registry_BeamDyn.txt b/modules/beamdyn/src/Registry_BeamDyn.txt index b07e467e99..eeb8ab9844 100644 --- a/modules/beamdyn/src/Registry_BeamDyn.txt +++ b/modules/beamdyn/src/Registry_BeamDyn.txt @@ -33,7 +33,7 @@ typedef ^ InitInputType ReKi RootVel {6} - - "Initial typedef ^ InitInputType Logical Linearize - .FALSE. - "Flag that tells this module if the glue code wants to linearize." - typedef ^ InitInputType Logical DynamicSolve - .TRUE. - "Use dynamic solve option. Set to False for static solving (handled by glue code or driver code)." - typedef ^ InitInputType LOGICAL CompAeroMaps - .FALSE. - "flag to determine if BeamDyn is computing aero maps (true) or running a normal simulation (false)" - - +typedef ^ InitInputType LOGICAL MirrorRotor - .FALSE. - "Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind)" - # Define outputs that the initialization routine may need here: # e.g., the name of the input file, the file root name, etc. diff --git a/modules/beamdyn/tests/beamdyn_utest.F90 b/modules/beamdyn/tests/beamdyn_utest.F90 index ee549641a0..10860158fd 100644 --- a/modules/beamdyn/tests/beamdyn_utest.F90 +++ b/modules/beamdyn/tests/beamdyn_utest.F90 @@ -6,6 +6,7 @@ program beamdyn_utest use test_BD_diffmtc, only: test_BD_diffmtc_suite use test_BD_InitializeNodalLocations, only: test_BD_InitializeNodalLocations_suite use test_BD_MemberEta, only: test_BD_MemberEta_suite + use test_BD_MirrorBladeData, only: test_BD_MirrorBladeData_suite use test_BD_Misc, only: test_BD_Misc_suite use test_BD_QuadraturePointData, only: test_BD_QuadraturePointData_suite use test_BD_ShapeFuncs, only: test_BD_ShapeFuncs_suite @@ -26,6 +27,7 @@ program beamdyn_utest new_testsuite("diffmtc", test_BD_diffmtc_suite), & new_testsuite("InitializeNodalLocations", test_BD_InitializeNodalLocations_suite), & new_testsuite("MemberEta", test_BD_MemberEta_suite), & + new_testsuite("MirrorBladeData", test_BD_MirrorBladeData_suite), & new_testsuite("Misc", test_BD_Misc_suite), & new_testsuite("QuadraturePointData", test_BD_QuadraturePointData_suite), & new_testsuite("ShapeFuncs", test_BD_ShapeFuncs_suite), & diff --git a/modules/beamdyn/tests/test_BD_MirrorBladeData.F90 b/modules/beamdyn/tests/test_BD_MirrorBladeData.F90 new file mode 100644 index 0000000000..9f0ba744bb --- /dev/null +++ b/modules/beamdyn/tests/test_BD_MirrorBladeData.F90 @@ -0,0 +1,142 @@ +module test_BD_MirrorBladeData + +! Tests the blade mirror transform used for counter-clockwise rotors. +! +! The transform is a similarity transform T*M*T^T with T = diag(1,-1,1,-1,1,-1), +! reflecting the sectional degrees of freedom about the local x-z plane. + +use BeamDyn +use BeamDyn_IO +use NWTC_Num +use test_tools + +implicit none + +private +public :: test_BD_MirrorBladeData_suite + +real(BDKi), parameter :: tol = 1.0e-14_BDKi + +contains + +!> Collect all exported unit tests +subroutine test_BD_MirrorBladeData_suite(testsuite) + type(unittest_type), allocatable, intent(out) :: testsuite(:) + testsuite = [ & + new_unittest("test_BD_Mirror_involution", test_BD_Mirror_involution), & + new_unittest("test_BD_Mirror_signs", test_BD_Mirror_signs), & + new_unittest("test_BD_Mirror_inertia_constraint", test_BD_Mirror_inertia_constraint), & + new_unittest("test_BD_Mirror_keypoints", test_BD_Mirror_keypoints) & + ] +end subroutine + +!> Build an input-file structure with one arbitrary but asymmetric station. +subroutine make_blade(InputFileData) + type(BD_InputFile), intent(out) :: InputFileData + integer(IntKi) :: i, j + + allocate (InputFileData%InpBl%stiff0(6, 6, 1)) + allocate (InputFileData%InpBl%mass0(6, 6, 1)) + allocate (InputFileData%kp_coordinate(2, 4)) + + ! Distinct, non-symmetric entries so a wrong sign cannot cancel out. + do j = 1, 6 + do i = 1, 6 + InputFileData%InpBl%stiff0(i, j, 1) = real(10*i + j, BDKi) + InputFileData%InpBl%mass0(i, j, 1) = real(100 + 10*i + j, BDKi) + end do + end do + + InputFileData%kp_coordinate(1, :) = (/0.0_BDKi, 0.0_BDKi, 0.0_BDKi, 0.0_BDKi/) + InputFileData%kp_coordinate(2, :) = (/0.25_BDKi, 1.5_BDKi, 30.0_BDKi, -0.2_BDKi/) +end subroutine + +!> T is its own inverse, so mirroring twice must return the original blade. +subroutine test_BD_Mirror_involution(error) + type(error_type), allocatable, intent(out) :: error + type(BD_InputFile) :: bld, ref + character(1024) :: testname + + testname = "mirroring twice returns the original blade:" + call make_blade(bld) + call make_blade(ref) + + call BD_MirrorBladeData(bld) + call BD_MirrorBladeData(bld) + + call check_array(error, ref%InpBl%stiff0(:, :, 1), bld%InpBl%stiff0(:, :, 1), testname, tol) + if (allocated(error)) return + call check_array(error, ref%InpBl%mass0(:, :, 1), bld%InpBl%mass0(:, :, 1), testname, tol) + if (allocated(error)) return + call check_array(error, ref%kp_coordinate, bld%kp_coordinate, testname, tol) +end subroutine + +!> Every entry flips sign exactly when one index is in {2,4,6} and the other is not. +subroutine test_BD_Mirror_signs(error) + type(error_type), allocatable, intent(out) :: error + type(BD_InputFile) :: bld, ref + real(BDKi) :: expected(6, 6) + integer(IntKi) :: i, j + logical :: flip_i, flip_j + character(1024) :: testname + + testname = "entries flip when exactly one index is in {2,4,6}:" + call make_blade(bld) + call make_blade(ref) + call BD_MirrorBladeData(bld) + + do j = 1, 6 + do i = 1, 6 + flip_i = (i == 2 .or. i == 4 .or. i == 6) + flip_j = (j == 2 .or. j == 4 .or. j == 6) + if (flip_i .neqv. flip_j) then + expected(i, j) = -ref%InpBl%stiff0(i, j, 1) + else + expected(i, j) = ref%InpBl%stiff0(i, j, 1) + end if + end do + end do + + call check_array(error, expected, bld%InpBl%stiff0(:, :, 1), testname, tol) +end subroutine + +!> The polar-inertia check in BD_ValidateInputData must survive the mirror. +subroutine test_BD_Mirror_inertia_constraint(error) + type(error_type), allocatable, intent(out) :: error + type(BD_InputFile) :: bld + real(BDKi) :: r1, r2 + character(1024) :: testname + + testname = "mass0(6,6) = mass0(4,4) + mass0(5,5) survives the mirror:" + call make_blade(bld) + ! Make the station satisfy the constraint going in. + bld%InpBl%mass0(6, 6, 1) = bld%InpBl%mass0(4, 4, 1) + bld%InpBl%mass0(5, 5, 1) + + call BD_MirrorBladeData(bld) + + r1 = bld%InpBl%mass0(6, 6, 1) + r2 = bld%InpBl%mass0(4, 4, 1) + bld%InpBl%mass0(5, 5, 1) + call check(error, r1, r2, testname, thr=tol) +end subroutine + +!> Key points reflect in y and reverse twist; the x and z offsets are untouched. +subroutine test_BD_Mirror_keypoints(error) + type(error_type), allocatable, intent(out) :: error + type(BD_InputFile) :: bld, ref + real(BDKi) :: expected(4) + character(1024) :: testname + + testname = "key points reflect in y and reverse twist:" + call make_blade(bld) + call make_blade(ref) + call BD_MirrorBladeData(bld) + + expected(1) = ref%kp_coordinate(2, 1) + expected(2) = -ref%kp_coordinate(2, 2) + expected(3) = ref%kp_coordinate(2, 3) + expected(4) = -ref%kp_coordinate(2, 4) + + call check_array(error, expected, bld%kp_coordinate(2, :), testname, tol) +end subroutine + +end module diff --git a/modules/elastodyn/src/ElastoDyn.f90 b/modules/elastodyn/src/ElastoDyn.f90 index f087c68256..27236a34a1 100644 --- a/modules/elastodyn/src/ElastoDyn.f90 +++ b/modules/elastodyn/src/ElastoDyn.f90 @@ -971,10 +971,20 @@ SUBROUTINE ED_CalcOutput( t, u, p, x, xd, z, OtherState, y, m, ErrStat, ErrMsg ) m%AllOuts( LSSTipVxa) = ( x%QDT (DOF_GeAz) + x%QDT (DOF_DrTr) )*RPS2RPM m%AllOuts( LSSTipAxa) = ( m%QD2T(DOF_GeAz) + m%QD2T(DOF_DrTr) )*R2D + + ! Rotor-convention counterparts: azimuth increases and speed is positive while the + ! rotor turns its design direction, whichever way it was built. + m%AllOuts( AzimuthRC) = p%RotDir*y%LSSTipPxa + if (.not. m%IgnoreMod) CALL Zero2TwoPi(m%AllOuts(AzimuthRC)) + m%AllOuts( AzimuthRC) = m%AllOuts(AzimuthRC)*R2D + m%AllOuts( RotSpeedRC) = p%RotDir*m%AllOuts(LSSTipVxa) + m%AllOuts( RotAccelRC) = p%RotDir*m%AllOuts(LSSTipAxa) m%AllOuts( LSSGagVxa) = x%QDT (DOF_GeAz) *RPS2RPM m%AllOuts( LSSGagAxa) = m%QD2T(DOF_GeAz) *R2D - m%AllOuts( HSShftV) = ABS(p%GBRatio)*m%AllOuts(LSSGagVxa) - m%AllOuts( HSShftA) = ABS(p%GBRatio)*m%AllOuts(LSSGagAxa) + ! MirrorRotor: the generator side keeps its own convention, so these stay positive + ! while the rotor turns its design direction (CONVENTIONS section 4). + m%AllOuts( HSShftV) = p%RotDir*ABS(p%GBRatio)*m%AllOuts(LSSGagVxa) + m%AllOuts( HSShftA) = p%RotDir*ABS(p%GBRatio)*m%AllOuts(LSSGagAxa) !IF ( .NOT. EqualRealNos( m%AllOuts(WindVxi), 0.0_ReKi ) ) THEN ! .TRUE. if the denominator in the following equation is not zero. ! m%AllOuts(TipSpdRat) = ( x%QDT (DOF_GeAz) + x%QDT (DOF_DrTr) )*p%AvgNrmTpRd / m%AllOuts( WindVxi) @@ -1221,6 +1231,7 @@ SUBROUTINE ED_CalcOutput( t, u, p, x, xd, z, OtherState, y, m, ErrStat, ErrMsg ) m%AllOuts(LSShftFys) = -DOT_PRODUCT( FrcPRot, m%CoordSys%c3 ) m%AllOuts(LSShftFzs) = DOT_PRODUCT( FrcPRot, m%CoordSys%c2 ) m%AllOuts(LSShftMxa) = DOT_PRODUCT( MomLPRot, m%CoordSys%e1 ) + m%AllOuts(RotTorqRC) = p%RotDir*m%AllOuts(LSShftMxa) m%AllOuts(LSSTipMya) = DOT_PRODUCT( MomLPRot, m%CoordSys%e2 ) m%AllOuts(LSSTipMza) = DOT_PRODUCT( MomLPRot, m%CoordSys%e3 ) m%AllOuts(LSSTipMys) = -DOT_PRODUCT( MomLPRot, m%CoordSys%c3 ) @@ -1268,9 +1279,11 @@ SUBROUTINE ED_CalcOutput( t, u, p, x, xd, z, OtherState, y, m, ErrStat, ErrMsg ) ! Generator and High-Speed Shaft Loads: - m%AllOuts( HSShftTq) = m%AllOuts(LSShftMxa)*m%RtHS%GBoxEffFac/ABS(p%GBRatio) - m%AllOuts(HSShftPwr) = m%AllOuts( HSShftTq)*ABS(p%GBRatio)*x%QDT(DOF_GeAz) - m%AllOuts(HSSBrTq) = OtherState%HSSBrTrq*0.001_ReKi + ! MirrorRotor: LSShftMxa is the physical component about the shaft axis, while the + ! high-speed shaft torque and power are reported in the rotor's own convention. + m%AllOuts( HSShftTq) = p%RotDir*m%AllOuts(LSShftMxa)*m%RtHS%GBoxEffFac/ABS(p%GBRatio) + m%AllOuts(HSShftPwr) = m%AllOuts( HSShftTq)*ABS(p%GBRatio)*p%RotDir*x%QDT(DOF_GeAz) + m%AllOuts(HSSBrTq) = p%RotDir*OtherState%HSSBrTrq*0.001_ReKi !IF ( .NOT. EqualRealNos( ComDenom, 0.0_ReKi ) ) THEN ! .TRUE. if the denominator in the following equations is not zero (ComDenom is the same as it is calculated above). @@ -1871,15 +1884,16 @@ SUBROUTINE ED_CalcOutput( t, u, p, x, xd, z, OtherState, y, m, ErrStat, ErrMsg ) y%YawAngle = x%QT( DOF_Yaw) + x%QT(DOF_Y) !crude approximation for yaw error... (without subtracting it from the wind direction) DO K=1,p%NumBl IF ( p%DOF_Flag(DOF_BP(K)) ) THEN - y%BlPRate(K) = x%QDT( DOF_BP(K) ) - y%BlPitch(K) = x%QT( DOF_BP(K) ) + ! MirrorRotor: reported in the pitch command's convention, as in the branch below. + y%BlPRate(K) = p%RotDir * x%QDT( DOF_BP(K) ) + y%BlPitch(K) = p%RotDir * x%QT( DOF_BP(K) ) ELSE y%BlPRate(K) = 0.0_ReKi y%BlPitch(K) = u%BlPitchCom(K) END IF END DO y%LSS_Spd = x%QDT(DOF_GeAz) - y%HSS_Spd = ABS(p%GBRatio)*x%QDT(DOF_GeAz) + y%HSS_Spd = p%RotDir*ABS(p%GBRatio)*x%QDT(DOF_GeAz) y%RotSpeed = x%QDT(DOF_GeAz) + x%QDT(DOF_DrTr) IF ( t > 0.0_DbKi ) THEN @@ -2753,6 +2767,10 @@ SUBROUTINE SetBladeParameters( p, BladeInData, BladeMeshData, ErrStat, ErrMsg ) end if + ! MirrorRotor: the blade is mirrored about the rotor XZ plane, so the structural twist + ! reverses. This only orients the mode shapes; a rigid blade is unaffected. + p%ThetaS = p%RotDir * p%ThetaS + p%CThetaS = COS(REAL(p%ThetaS,R8Ki)) p%SThetaS = SIN(REAL(p%ThetaS,R8Ki)) @@ -3390,6 +3408,17 @@ SUBROUTINE SetPrimaryParameters( InitInp, p, InputFileData, ErrStat, ErrMsg ) p%PtfmXZIner = InputFileData%PtfmXZIner p%GBoxEff = InputFileData%GBoxEff p%GBRatio = InputFileData%GBRatio + p%RotDir = 1.0_ReKi + if (InitInp%MirrorRotor) then + p%RotDir = -1.0_ReKi + ! A furling machine is deliberately one-sided, and that geometry is not mirrored. + if (InputFileData%Furling) then + CALL SetErrStat( ErrID_Warn, 'MirrorRotor has not been fully tested for a furling turbine. '// & + 'The rotor is mirrored but the furl geometry is not, so the tail and furl inputs must be '// & + 'mirrored as well for the result to be the mirror image of the clockwise turbine.', & + ErrStat, ErrMsg, 'SetPrimaryParameters' ) + end if + end if p%DTTorSpr = InputFileData%DTTorSpr p%DTTorDmp = InputFileData%DTTorDmp @@ -3544,7 +3573,9 @@ SUBROUTINE SetPrimaryParameters( InitInp, p, InputFileData, ErrStat, ErrMsg ) !p%PtfmPDOF = InputFileData%PtfmPDOF !p%PtfmYDOF = InputFileData%PtfmYDOF !p%Azimuth = InputFileData%Azimuth - p%RotSpeed = InputFileData%RotSpeed + ! MirrorRotor: RotSpeed is supplied in the CW convention (validated non-negative) and + ! mirrored here; the state itself is the physical shaft speed about +x. + p%RotSpeed = p%RotDir * InputFileData%RotSpeed !p%TTDspFA = InputFileData%TTDspFA !p%TTDspSS = InputFileData%TTDspSS !p%PtfmSurge = InputFileData%PtfmSurge @@ -3605,7 +3636,7 @@ SUBROUTINE Init_ContStates( x, p, InputFileData, OtherState, ErrStat, ErrMsg ) InitQE1 = 0.0_ReKi END IF - x%QT ( DOF_BP(1:p%NumBl ) ) = InputFileData%BlPitch(1:p%NumBl) + x%QT ( DOF_BP(1:p%NumBl ) ) = p%RotDir * InputFileData%BlPitch(1:p%NumBl) x%QT ( DOF_BF(1:p%NumBl,1) ) = InitQF1 ! These come from InitBlDefl(). x%QT ( DOF_BF(1:p%NumBl,2) ) = InitQF2 ! These come from InitBlDefl(). x%QT ( DOF_BE(1:p%NumBl,1) ) = InitQE1 ! These come from InitBlDefl(). @@ -3633,7 +3664,7 @@ SUBROUTINE Init_ContStates( x, p, InputFileData, OtherState, ErrStat, ErrMsg ) !JASON: CHANGE THESE MOD() FUNCTIONS INTO MODULO() FUNCTIONS SO THAT YOU CAN ELIMINATE ADDING 360: ! x%QT (DOF_GeAz) = MOD( (InputFileData%Azimuth - p%AzimB1Up)*R2D + 270.0 + 360.0, 360.0 )*D2R ! Internal position of blade 1 - x%QT (DOF_GeAz) = REAL(InputFileData%Azimuth, R8Ki) - p%AzimB1Up - REAL(Piby2_D, R8Ki) + x%QT (DOF_GeAz) = p%RotDir * REAL(InputFileData%Azimuth, R8Ki) - p%AzimB1Up - REAL(Piby2_D, R8Ki) CALL Zero2TwoPi( x%QT (DOF_GeAz) ) x%QDT(DOF_GeAz) = p%RotSpeed ! Rotor speed in rad/sec. @@ -4022,14 +4053,14 @@ SUBROUTINE SetOutParam(OutList, p, ErrStat, ErrMsg ) "YAWFRIMOM ","YAWFRIMZ ","YAWPOS ","YAWPZN ","YAWPZP ","YAWRATE ","YAWVZN ", & "YAWVZP "/) INTEGER(IntKi), PARAMETER :: ParamIndxAry(1121) = (/ & ! This lists the index into AllOuts(:) of the allowed parameters ValidParamAry(:) - LSSTipPxa , BldPAcc1 , BldPAcc2 , BldPAcc3 , PtchPMzc1 , PtchPMzc2 , PtchPMzc3 , & + AzimuthRC , BldPAcc1 , BldPAcc2 , BldPAcc3 , PtchPMzc1 , PtchPMzc2 , PtchPMzc3 , & BldPRate1 , BldPRate2 , BldPRate3 , PtchPMzc1 , PtchPMzc2 , PtchPMzc3 , dOmegaYF , & HSShftA , HSShftV , HSSBrTq , HSShftA , HSShftPwr , HSShftTq , HSShftV , & TipDyc1 , TipDyc2 , TipDyc3 , LSSGagAxa , LSSGagAxa , LSSGagAxa , LSShftFxa , & LSShftFxa , LSShftFya , LSShftFys , LSShftFza , LSShftFzs , LSShftMxa , LSShftMxa , & LSSGagMya , LSSGagMys , LSSGagMza , LSSGagMzs , LSSGagPxa , LSSGagPxa , LSSGagPxa , & LSSGagVxa , LSSGagVxa , LSSGagVxa , LSShftFxa , LSShftFxa , LSShftFya , LSShftFys , & - LSShftFza , LSShftFzs , LSShftMxa , LSShftMxa , RotPwr , LSShftMxa , LSSTipAxa , & + LSShftFza , LSShftFzs , LSShftMxa , LSShftMxa , RotPwr , RotTorqRC , LSSTipAxa , & LSSTipAxa , LSSTipAxa , LSSTipMya , LSSTipMys , LSSTipMza , LSSTipMzs , LSSTipPxa , & LSSTipPxa , LSSTipPxa , LSSTipVxa , LSSTipVxa , LSSTipVxa , YawPzn , YawAzn , & YawPzn , YawVzn , NcIMURAxs , NcIMURAys , NcIMURAzs , NcIMURVxs , NcIMURVys , & @@ -4060,9 +4091,9 @@ SUBROUTINE SetOutParam(OutList, p, ErrStat, ErrMsg ) RootMyb1 , RootMyb2 , RootMyb3 , RootMxc1 , RootMxc2 , RootMxc3 , RootMyc1 , & RootMyc2 , RootMyc3 , RootMxb1 , RootMxb2 , RootMxb3 , RootMxc1 , RootMxc2 , & RootMxc3 , RootMyb1 , RootMyb2 , RootMyb3 , RootMyc1 , RootMyc2 , RootMyc3 , & - RootMzc1 , RootMzc2 , RootMzc3 , RootMzc1 , RootMzc2 , RootMzc3 , LSSTipAxa , & - RotFurlP , RotFurlA , RotFurlP , RotFurlV , RotPwr , LSSTipVxa , TeetAya , & - TeetPya , TeetVya , LSShftFxa , LSShftMxa , Spn1ALgxb1 , Spn1ALgxb2 , Spn1ALgxb3 , & + RootMzc1 , RootMzc2 , RootMzc3 , RootMzc1 , RootMzc2 , RootMzc3 , RotAccelRC , & + RotFurlP , RotFurlA , RotFurlP , RotFurlV , RotPwr , RotSpeedRC , TeetAya , & + TeetPya , TeetVya , LSShftFxa , RotTorqRC , Spn1ALgxb1 , Spn1ALgxb2 , Spn1ALgxb3 , & Spn1ALgyb1 , Spn1ALgyb2 , Spn1ALgyb3 , Spn1ALgzb1 , Spn1ALgzb2 , Spn1ALgzb3 , Spn1ALxb1 , & Spn1ALxb2 , Spn1ALxb3 , Spn1ALyb1 , Spn1ALyb2 , Spn1ALyb3 , Spn1ALzb1 , Spn1ALzb2 , & Spn1ALzb3 , Spn1FLxb1 , Spn1FLxb2 , Spn1FLxb3 , Spn1FLyb1 , Spn1FLyb2 , Spn1FLyb3 , & @@ -6234,8 +6265,10 @@ SUBROUTINE SetCoordSy( t, CoordSys, RtHSdat, BlPitch, p, x, ErrStat, ErrMsg ) ! Hub (Prime) coordinate system rotated to match blade K. + ! The spacing carries the rotation sense, so the blades are numbered in the + ! direction of rotation for a mirrored rotor as well as a clockwise one. - gRotAng = p%TwoPiNB*(K-1) + gRotAng = REAL(p%RotDir,R8Ki)*p%TwoPiNB*(K-1) CgRotAng = COS( gRotAng ) SgRotAng = SIN( gRotAng ) @@ -6257,8 +6290,10 @@ SUBROUTINE SetCoordSy( t, CoordSys, RtHSdat, BlPitch, p, x, ErrStat, ErrMsg ) CosPitch = COS( x%QT(DOF_BP(K)) ) SinPitch = SIN( x%QT(DOF_BP(K)) ) ELSE - CosPitch = COS( REAL(BlPitch(K),R8Ki) ) - SinPitch = SIN( REAL(BlPitch(K),R8Ki) ) + ! MirrorRotor: the pitch command is in the CW convention (the pitch DOF state + ! above is already physical), so mirror it here. + CosPitch = COS( REAL(p%RotDir*BlPitch(K),R8Ki) ) + SinPitch = SIN( REAL(p%RotDir*BlPitch(K),R8Ki) ) END IF CoordSys%j1(K,:) = CosPitch*CoordSys%i1(K,:) - SinPitch*CoordSys%i2(K,:) ! j1(K,:) = vector / direction j1 for blade K (= xbK from the IEC coord. system). @@ -6672,7 +6707,10 @@ FUNCTION SignLSSTrq( p, m ) ! MomLProt has now been found. Now dot this with e1 to get the ! low-speed shaft torque and take the SIGN of the result: - SignLSSTrq = NINT( SIGN( 1.0_R8Ki, DOT_PRODUCT( MomLPRot, m%CoordSys%e1 ) ) ) + ! MirrorRotor: the gearbox efficiency factor depends on which way power flows, not + ! on the sign of the torque about +x. A mirrored rotor generates with a negative + ! shaft torque, so without RotDir the loss would be inverted into a gain. + SignLSSTrq = NINT( SIGN( 1.0_R8Ki, p%RotDir*DOT_PRODUCT( MomLPRot, m%CoordSys%e1 ) ) ) END FUNCTION SignLSSTrq !---------------------------------------------------------------------------------------------------------------------------------- @@ -8235,7 +8273,10 @@ SUBROUTINE FillAugMat( p, x, CoordSys, u, HSSBrTrq, RtHSdat, AugMat ) ! Initialize the matrix: AugMat = 0.0 - GBoxTrq = ( u%GenTrq + HSSBrTrq )*ABS(p%GBRatio) ! bjj: do we use HSSBrTrqC or HSSBrTrq? + ! MirrorRotor: GenTrq arrives from ServoDyn in the CW convention and needs RotDir to put + ! it on the physical shaft. HSSBrTrq is already signed by the rotation direction + ! (SIGN(HSSBrTrqC, qdt) below), so mirroring the bracket would make the brake drive the rotor. + GBoxTrq = ( p%RotDir*u%GenTrq + HSSBrTrq )*ABS(p%GBRatio) ! bjj: do we use HSSBrTrqC or HSSBrTrq? DO K = 1,p%NumBl ! Loop through all blades diff --git a/modules/elastodyn/src/ElastoDyn_IO.f90 b/modules/elastodyn/src/ElastoDyn_IO.f90 index 33996cd46f..6088334191 100644 --- a/modules/elastodyn/src/ElastoDyn_IO.f90 +++ b/modules/elastodyn/src/ElastoDyn_IO.f90 @@ -1243,9 +1243,17 @@ MODULE ElastoDyn_Parameters INTEGER(IntKi), PARAMETER :: QD2_P = 1002 INTEGER(IntKi), PARAMETER :: QD2_Y = 1003 + ! Rotor-convention counterparts of the shaft-axis channels. These report the + ! quantity as the rotor experiences it, so a mirrored rotor turning its design + ! direction still reads positive. Identical to the shaft-axis slots when RotDir=+1. + INTEGER(IntKi), PARAMETER :: AzimuthRC = 1004 + INTEGER(IntKi), PARAMETER :: RotSpeedRC = 1005 + INTEGER(IntKi), PARAMETER :: RotAccelRC = 1006 + INTEGER(IntKi), PARAMETER :: RotTorqRC = 1007 + ! The maximum number of output channels which can be output by the code. - INTEGER(IntKi), PARAMETER :: MaxOutPts = 1003 + INTEGER(IntKi), PARAMETER :: MaxOutPts = 1007 !End of code generated by Matlab script Write_ChckOutLst ! =================================================================================================== diff --git a/modules/elastodyn/src/ElastoDyn_Registry.txt b/modules/elastodyn/src/ElastoDyn_Registry.txt index e0988d4c0d..94dc557ad4 100644 --- a/modules/elastodyn/src/ElastoDyn_Registry.txt +++ b/modules/elastodyn/src/ElastoDyn_Registry.txt @@ -26,6 +26,7 @@ typedef ^ InitInputType CHARACTER(1024) RootName - - - "RootName for writing out typedef ^ InitInputType ReKi Gravity - - - "Gravitational acceleration" m/s^2 typedef ^ InitInputType IntKi MHK - - - "MHK turbine type switch" - typedef ^ InitInputType ReKi WtrDpth - - - "Water depth" m +typedef ^ InitInputType LOGICAL MirrorRotor - .FALSE. - "Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind)" - typedef ^ InitInputType LOGICAL CompAeroMaps - .FALSE. - "flag to determine if ElastoDyn is computing aero maps (true) or running a normal simulation (false)" - typedef ^ InitInputType ReKi RotSpeed - - - "Rotor speed used when ElastoDyn is computing aero maps" "rad/s" @@ -748,6 +749,7 @@ typedef ^ ParameterType DbKi TStart - - - "Time to begin tabular output" typedef ^ ParameterType ReKi DTTorDmp - - - "Drivetrain torsional damper" typedef ^ ParameterType ReKi DTTorSpr - - - "Drivetrain torsional spring" typedef ^ ParameterType ReKi GBRatio - - - "Gearbox ratio" +typedef ^ ParameterType ReKi RotDir - 1.0 - "Rotor rotation direction: +1 normal (CW viewed from upwind), -1 mirrored (CCW)" typedef ^ ParameterType ReKi GBoxEff - - - "Gearbox efficiency" typedef ^ ParameterType ReKi RotSpeed - - - "Initial or fixed rotor speed" rad/s typedef ^ ParameterType CHARACTER(1024) RootName - - - "RootName for writing output files" diff --git a/modules/elastodyn/src/ElastoDyn_Types.f90 b/modules/elastodyn/src/ElastoDyn_Types.f90 index 6eb00b68c1..2a7bb9c062 100644 --- a/modules/elastodyn/src/ElastoDyn_Types.f90 +++ b/modules/elastodyn/src/ElastoDyn_Types.f90 @@ -45,6 +45,7 @@ MODULE ElastoDyn_Types REAL(ReKi) :: Gravity = 0.0_ReKi !< Gravitational acceleration [m/s^2] INTEGER(IntKi) :: MHK = 0_IntKi !< MHK turbine type switch [-] REAL(ReKi) :: WtrDpth = 0.0_ReKi !< Water depth [m] + LOGICAL :: MirrorRotor = .FALSE. !< Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind) [-] LOGICAL :: CompAeroMaps = .FALSE. !< flag to determine if ElastoDyn is computing aero maps (true) or running a normal simulation (false) [-] REAL(ReKi) :: RotSpeed = 0.0_ReKi !< Rotor speed used when ElastoDyn is computing aero maps [rad/s] END TYPE ED_InitInputType @@ -753,6 +754,7 @@ MODULE ElastoDyn_Types REAL(ReKi) :: DTTorDmp = 0.0_ReKi !< Drivetrain torsional damper [-] REAL(ReKi) :: DTTorSpr = 0.0_ReKi !< Drivetrain torsional spring [-] REAL(ReKi) :: GBRatio = 0.0_ReKi !< Gearbox ratio [-] + REAL(ReKi) :: RotDir = 1.0 !< Rotor rotation direction: +1 normal (CW viewed from upwind), -1 mirrored (CCW) [-] REAL(ReKi) :: GBoxEff = 0.0_ReKi !< Gearbox efficiency [-] REAL(ReKi) :: RotSpeed = 0.0_ReKi !< Initial or fixed rotor speed [rad/s] CHARACTER(1024) :: RootName !< RootName for writing output files [-] @@ -933,6 +935,7 @@ subroutine ED_CopyInitInput(SrcInitInputData, DstInitInputData, CtrlCode, ErrSta DstInitInputData%Gravity = SrcInitInputData%Gravity DstInitInputData%MHK = SrcInitInputData%MHK DstInitInputData%WtrDpth = SrcInitInputData%WtrDpth + DstInitInputData%MirrorRotor = SrcInitInputData%MirrorRotor DstInitInputData%CompAeroMaps = SrcInitInputData%CompAeroMaps DstInitInputData%RotSpeed = SrcInitInputData%RotSpeed end subroutine @@ -959,6 +962,7 @@ subroutine ED_PackInitInput(RF, Indata) call RegPack(RF, InData%Gravity) call RegPack(RF, InData%MHK) call RegPack(RF, InData%WtrDpth) + call RegPack(RF, InData%MirrorRotor) call RegPack(RF, InData%CompAeroMaps) call RegPack(RF, InData%RotSpeed) if (RegCheckErr(RF, RoutineName)) return @@ -977,6 +981,7 @@ subroutine ED_UnPackInitInput(RF, OutData) call RegUnpack(RF, OutData%Gravity); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%MHK); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%WtrDpth); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%MirrorRotor); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%CompAeroMaps); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%RotSpeed); if (RegCheckErr(RF, RoutineName)) return end subroutine @@ -5599,6 +5604,7 @@ subroutine ED_CopyParam(SrcParamData, DstParamData, CtrlCode, ErrStat, ErrMsg) DstParamData%DTTorDmp = SrcParamData%DTTorDmp DstParamData%DTTorSpr = SrcParamData%DTTorSpr DstParamData%GBRatio = SrcParamData%GBRatio + DstParamData%RotDir = SrcParamData%RotDir DstParamData%GBoxEff = SrcParamData%GBoxEff DstParamData%RotSpeed = SrcParamData%RotSpeed DstParamData%RootName = SrcParamData%RootName @@ -6069,6 +6075,7 @@ subroutine ED_PackParam(RF, Indata) call RegPack(RF, InData%DTTorDmp) call RegPack(RF, InData%DTTorSpr) call RegPack(RF, InData%GBRatio) + call RegPack(RF, InData%RotDir) call RegPack(RF, InData%GBoxEff) call RegPack(RF, InData%RotSpeed) call RegPack(RF, InData%RootName) @@ -6331,6 +6338,7 @@ subroutine ED_UnPackParam(RF, OutData) call RegUnpack(RF, OutData%DTTorDmp); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%DTTorSpr); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%GBRatio); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%RotDir); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%GBoxEff); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%RotSpeed); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%RootName); if (RegCheckErr(RF, RoutineName)) return diff --git a/modules/openfast-library/src/FAST_Mapping.f90 b/modules/openfast-library/src/FAST_Mapping.f90 index f11f3202c0..87c8eea5ed 100644 --- a/modules/openfast-library/src/FAST_Mapping.f90 +++ b/modules/openfast-library/src/FAST_Mapping.f90 @@ -3353,6 +3353,7 @@ subroutine Custom_InputSolve(Mapping, ModSrc, ModDst, iInput, T, ErrStat, ErrMsg real(R8Ki) :: r(3), r_hub(3) real(R8Ki) :: Vrot(3), Vel(3) real(R8Ki) :: DampingForce(3) + real(ReKi) :: RotDir !< +1 normal, -1 for a mirrored rotor ErrStat = ErrID_None ErrMsg = '' @@ -3428,10 +3429,21 @@ subroutine Custom_InputSolve(Mapping, ModSrc, ModDst, iInput, T, ErrStat, ErrMsg case (Custom_SrvD_to_ED) + ! MirrorRotor: ServoDyn works entirely in the clockwise convention, so signals + ! crossing this boundary are converted rather than ServoDyn being told anything. + ! GenTrq and HSSBrTrqC are not converted: ElastoDyn applies RotDir to the generator + ! torque itself, and the brake torque is signed there by the rotation direction. + ! Yaw is not converted either: it acts about the vertical axis in the inertial + ! frame, so it is not a rotor-convention quantity. ServoDyn is given the physical + ! yaw angle and rate through the ordinary variable mapping, which already makes + ! its yaw spring and damper mirror correctly. + RotDir = 1.0_ReKi + if (T%p_FAST%MirrorRotor(ModDst%iRotor)) RotDir = -1.0_ReKi + T%ED%Input(iInput, ModDst%Ins)%GenTrq = T%SrvD%y(ModSrc%Ins)%GenTrq T%ED%Input(iInput, ModDst%Ins)%HSSBrTrqC = T%SrvD%y(ModSrc%Ins)%HSSBrTrqC T%ED%Input(iInput, ModDst%Ins)%BlPitchCom = T%SrvD%y(ModSrc%Ins)%BlPitchCom - T%ED%Input(iInput, ModDst%Ins)%BlPitchMom = T%SrvD%y(ModSrc%Ins)%BlPitchMom + T%ED%Input(iInput, ModDst%Ins)%BlPitchMom = RotDir*T%SrvD%y(ModSrc%Ins)%BlPitchMom T%ED%Input(iInput, ModDst%Ins)%YawMom = T%SrvD%y(ModSrc%Ins)%YawMom case (Custom_ED_Tower_Damping) @@ -3587,9 +3599,14 @@ subroutine Custom_InputSolve(Mapping, ModSrc, ModDst, iInput, T, ErrStat, ErrMsg iBld = T%p_FAST%BDBldMap(ModSrc%Ins) - T%SrvD%Input(iInput,ModDst%Ins)%RootMxc(iBld) = & + ! MirrorRotor: in-plane root moment is presented in the clockwise convention, as in + ! the ElastoDyn path below. + RotDir = 1.0_ReKi + if (T%p_FAST%MirrorRotor(ModDst%iRotor)) RotDir = -1.0_ReKi + + T%SrvD%Input(iInput,ModDst%Ins)%RootMxc(iBld) = RotDir*( & T%BD%y(Mapping%SrcIns)%RootMxr*cos(T%ED%y(ModDst%iRotor)%BlPitch(iBld)) + & - T%BD%y(Mapping%SrcIns)%RootMyr*sin(T%ED%y(ModDst%iRotor)%BlPitch(iBld)) + T%BD%y(Mapping%SrcIns)%RootMyr*sin(T%ED%y(ModDst%iRotor)%BlPitch(iBld))) T%SrvD%Input(iInput,ModDst%Ins)%RootMyc(iBld) = & -T%BD%y(Mapping%SrcIns)%RootMxr*sin(T%ED%y(ModDst%iRotor)%BlPitch(iBld)) + & @@ -3597,43 +3614,63 @@ subroutine Custom_InputSolve(Mapping, ModSrc, ModDst, iInput, T, ErrStat, ErrMsg case (Custom_ED_to_SrvD) + ! MirrorRotor: everything ServoDyn sees is presented in the clockwise convention, so + ! an unmodified controller behaves exactly as it does on a clockwise rotor. + ! In-plane and about-axis quantities flip; out-of-plane ones do not. HSS_Spd and + ! BlPitch already arrive in that convention from ElastoDyn, and RotPwr is a product + ! of two flipped quantities, so none of those are converted here. + RotDir = 1.0_ReKi + if (T%p_FAST%MirrorRotor(ModSrc%iRotor)) RotDir = -1.0_ReKi + ! Blade root moment if not using BeamDyn if (T%p_FAST%CompElast /= Module_BD) then - T%SrvD%Input(iInput,ModDst%Ins)%RootMxc = T%ED%y(ModSrc%Ins)%RootMxc ! fixed-size arrays: always size 3 + T%SrvD%Input(iInput,ModDst%Ins)%RootMxc = RotDir*T%ED%y(ModSrc%Ins)%RootMxc ! fixed-size arrays: always size 3 T%SrvD%Input(iInput,ModDst%Ins)%RootMyc = T%ED%y(ModSrc%Ins)%RootMyc ! fixed-size arrays: always size 3 end if + ! Yaw is left alone: it is an inertial-frame quantity, so the yaw error stays + ! physically correct and an unmodified yaw controller still points the nacelle + ! into the real wind. T%SrvD%Input(iInput,ModDst%Ins)%YawAngle = T%ED%y(ModSrc%Ins)%YawAngle ! nacelle yaw plus platform yaw T%SrvD%Input(iInput,ModDst%Ins)%YawErr = T%SrvD%Input(iInput,ModDst%Ins)%WindDir - T%SrvD%Input(iInput,ModDst%Ins)%YawAngle ! the nacelle yaw error estimate (positive about zi-axis) T%SrvD%Input(iInput,ModDst%Ins)%BlPitch = T%ED%y(ModSrc%Ins)%BlPitch - T%SrvD%Input(iInput,ModDst%Ins)%LSS_Spd = T%ED%y(ModSrc%Ins)%LSS_Spd - T%SrvD%Input(iInput,ModDst%Ins)%RotSpeed = T%ED%y(ModSrc%Ins)%RotSpeed + T%SrvD%Input(iInput,ModDst%Ins)%LSS_Spd = RotDir*T%ED%y(ModSrc%Ins)%LSS_Spd + T%SrvD%Input(iInput,ModDst%Ins)%RotSpeed = RotDir*T%ED%y(ModSrc%Ins)%RotSpeed T%SrvD%Input(iInput,ModDst%Ins)%YawBrTAxp = T%ED%y(ModSrc%Ins)%YawBrTAxp - T%SrvD%Input(iInput,ModDst%Ins)%YawBrTAyp = T%ED%y(ModSrc%Ins)%YawBrTAyp + T%SrvD%Input(iInput,ModDst%Ins)%YawBrTAyp = RotDir*T%ED%y(ModSrc%Ins)%YawBrTAyp T%SrvD%Input(iInput,ModDst%Ins)%LSSTipPxa = T%ED%y(ModSrc%Ins)%LSSTipPxa + if (T%p_FAST%MirrorRotor(ModSrc%iRotor)) then + ! Present a clockwise-increasing azimuth, re-wrapped into [0, 2pi). + T%SrvD%Input(iInput,ModDst%Ins)%LSSTipPxa = -T%SrvD%Input(iInput,ModDst%Ins)%LSSTipPxa + call Zero2TwoPi(T%SrvD%Input(iInput,ModDst%Ins)%LSSTipPxa) + end if - T%SrvD%Input(iInput,ModDst%Ins)%LSSTipMxa = T%ED%y(ModSrc%Ins)%LSSTipMxa + T%SrvD%Input(iInput,ModDst%Ins)%LSSTipMxa = RotDir*T%ED%y(ModSrc%Ins)%LSSTipMxa T%SrvD%Input(iInput,ModDst%Ins)%LSSTipMya = T%ED%y(ModSrc%Ins)%LSSTipMya - T%SrvD%Input(iInput,ModDst%Ins)%LSSTipMza = T%ED%y(ModSrc%Ins)%LSSTipMza + T%SrvD%Input(iInput,ModDst%Ins)%LSSTipMza = RotDir*T%ED%y(ModSrc%Ins)%LSSTipMza T%SrvD%Input(iInput,ModDst%Ins)%LSSTipMys = T%ED%y(ModSrc%Ins)%LSSTipMys - T%SrvD%Input(iInput,ModDst%Ins)%LSSTipMzs = T%ED%y(ModSrc%Ins)%LSSTipMzs + T%SrvD%Input(iInput,ModDst%Ins)%LSSTipMzs = RotDir*T%ED%y(ModSrc%Ins)%LSSTipMzs T%SrvD%Input(iInput,ModDst%Ins)%YawBrMyn = T%ED%y(ModSrc%Ins)%YawBrMyn - T%SrvD%Input(iInput,ModDst%Ins)%YawBrMzn = T%ED%y(ModSrc%Ins)%YawBrMzn - T%SrvD%Input(iInput,ModDst%Ins)%NcIMURAxs = T%ED%y(ModSrc%Ins)%NcIMURAxs + T%SrvD%Input(iInput,ModDst%Ins)%YawBrMzn = RotDir*T%ED%y(ModSrc%Ins)%YawBrMzn + T%SrvD%Input(iInput,ModDst%Ins)%NcIMURAxs = RotDir*T%ED%y(ModSrc%Ins)%NcIMURAxs T%SrvD%Input(iInput,ModDst%Ins)%NcIMURAys = T%ED%y(ModSrc%Ins)%NcIMURAys - T%SrvD%Input(iInput,ModDst%Ins)%NcIMURAzs = T%ED%y(ModSrc%Ins)%NcIMURAzs + T%SrvD%Input(iInput,ModDst%Ins)%NcIMURAzs = RotDir*T%ED%y(ModSrc%Ins)%NcIMURAzs T%SrvD%Input(iInput,ModDst%Ins)%RotPwr = T%ED%y(ModSrc%Ins)%RotPwr T%SrvD%Input(iInput,ModDst%Ins)%LSShftFxa = T%ED%y(ModSrc%Ins)%LSShftFxa - T%SrvD%Input(iInput,ModDst%Ins)%LSShftFys = T%ED%y(ModSrc%Ins)%LSShftFys + T%SrvD%Input(iInput,ModDst%Ins)%LSShftFys = RotDir*T%ED%y(ModSrc%Ins)%LSShftFys T%SrvD%Input(iInput,ModDst%Ins)%LSShftFzs = T%ED%y(ModSrc%Ins)%LSShftFzs case (Custom_SED_to_SrvD) + ! MirrorRotor: as for ElastoDyn, ServoDyn is handed the clockwise convention. + RotDir = 1.0_ReKi + if (T%p_FAST%MirrorRotor(ModDst%iRotor)) RotDir = -1.0_ReKi + ! ServoDyn inputs from combination of InflowWind and ElastoDyn T%SrvD%Input(iInput,ModDst%Ins)%YawAngle = T%SED%y%Yaw !nacelle yaw (platform rigid) T%SrvD%Input(iInput,ModDst%Ins)%YawErr = T%SrvD%Input(iInput,ModDst%Ins)%WindDir - T%SrvD%Input(iInput,ModDst%Ins)%YawAngle ! the nacelle yaw error estimate (positive about zi-axis) @@ -3641,9 +3678,9 @@ subroutine Custom_InputSolve(Mapping, ModSrc, ModDst, iInput, T, ErrStat, ErrMsg ! ServoDyn inputs from Simplified-ElastoDyn T%SrvD%Input(iInput,ModDst%Ins)%Yaw = T%SED%y%Yaw !nacelle yaw T%SrvD%Input(iInput,ModDst%Ins)%YawRate = T%SED%y%YawRate - T%SrvD%Input(iInput,ModDst%Ins)%LSS_Spd = T%SED%y%RotSpeed + T%SrvD%Input(iInput,ModDst%Ins)%LSS_Spd = RotDir*T%SED%y%RotSpeed T%SrvD%Input(iInput,ModDst%Ins)%HSS_Spd = T%SED%y%HSS_Spd - T%SrvD%Input(iInput,ModDst%Ins)%RotSpeed = T%SED%y%RotSpeed + T%SrvD%Input(iInput,ModDst%Ins)%RotSpeed = RotDir*T%SED%y%RotSpeed T%SrvD%Input(iInput,ModDst%Ins)%BlPitch = T%SED%y%BlPitch ! root moments diff --git a/modules/openfast-library/src/FAST_Subs.f90 b/modules/openfast-library/src/FAST_Subs.f90 index 08507d8b68..cb26c9b289 100644 --- a/modules/openfast-library/src/FAST_Subs.f90 +++ b/modules/openfast-library/src/FAST_Subs.f90 @@ -311,6 +311,7 @@ SUBROUTINE FAST_InitializeAll( t_initial, m_Glue, p_FAST, y_FAST, m_FAST, ED, SE Init%InData_SED%Linearize = p_FAST%Linearize Init%InData_SED%InputFile = p_FAST%EDFile(1) Init%InData_SED%RootName = TRIM(p_FAST%OutFileRoot)//'.'//TRIM(y_FAST%Module_Abrev(Module_SED)) + Init%InData_SED%MirrorRotor = p_FAST%MirrorRotor(1) ! Call module initialization routine dt_module = p_FAST%DT @@ -354,6 +355,7 @@ SUBROUTINE FAST_InitializeAll( t_initial, m_Glue, p_FAST, y_FAST, m_FAST, ED, SE Init%InData_ED%Gravity = p_FAST%Gravity Init%InData_ED%MHK = p_FAST%MHK Init%InData_ED%WtrDpth = p_FAST%WtrDpth + Init%InData_ED%MirrorRotor = p_FAST%MirrorRotor(iRot) ! Call module initialization routine dt_module = p_FAST%DT @@ -443,6 +445,7 @@ SUBROUTINE FAST_InitializeAll( t_initial, m_Glue, p_FAST, y_FAST, m_FAST, ED, SE Init%InData_BD%RootName = TRIM(p_FAST%OutFileRoot)//'.'//TRIM(y_FAST%Module_Abrev(Module_BD))& &//'.R'//TRIM(Num2LStr(iRot))//'.B'//TRIM(Num2LStr(k)) Init%InData_BD%InputFile = p_FAST%BDBldFile(k, iRot) + Init%InData_BD%MirrorRotor = p_FAST%MirrorRotor(iRot) Init%InData_BD%GlbPos = ED%y(iRot)%BladeRootMotion(k)%Position(:,1) ! {:} - - "Initial Position Vector of the local blade coordinate system" Init%InData_BD%GlbRot = ED%y(iRot)%BladeRootMotion(k)%RefOrientation(:,:,1) ! {:}{:} - - "Initial direction cosine matrix of the local blade coordinate system" @@ -670,6 +673,7 @@ SUBROUTINE FAST_InitializeAll( t_initial, m_Glue, p_FAST, y_FAST, m_FAST, ED, SE Init%InData_AD%Linearize = p_FAST%Linearize Init%InData_AD%CompAeroMaps = p_FAST%CompAeroMaps Init%InData_AD%rotors(iRot)%RotSpeed = p_FAST%RotSpeedInit ! used only for aeromaps + Init%InData_AD%rotors(iRot)%MirrorRotor = p_FAST%MirrorRotor(iRot) Init%InData_AD%InputFile = p_FAST%AeroFile Init%InData_AD%RootName = p_FAST%OutFileRoot Init%InData_AD%MHK = p_FAST%MHK @@ -758,6 +762,7 @@ SUBROUTINE FAST_InitializeAll( t_initial, m_Glue, p_FAST, y_FAST, m_FAST, ED, SE Init%InData_ADsk%InputFile = p_FAST%AeroFile Init%InData_ADsk%RootName = p_FAST%OutFileRoot + Init%InData_ADsk%MirrorRotor = p_FAST%MirrorRotor(1) ! NOTE: cone angle is not included in the RotorRad calculation!!! if (p_FAST%CompElast == Module_SED) then @@ -2206,6 +2211,32 @@ SUBROUTINE ValidateInputData(p, m_FAST, ErrStat, ErrMsg) end if + ! FAST.Farm drives one OpenFAST instance per turbine, and every rotor reference in + ! FASTWrapper is rotors(1). A multi-rotor instance would therefore simulate the + ! first rotor and silently ignore the rest, which is worse than refusing it. + ! FASTWrapper is the only thing that sets FarmIntegration. + if (p%FarmIntegration .and. p%NRotors > 1) then + CALL SetErrStat( ErrID_Fatal, 'Only one rotor per OpenFAST instance is supported with FAST.Farm.', ErrStat, ErrMsg, RoutineName ) + end if + + ! Combinations not yet supported with a mirrored (counter-clockwise) rotor. Each of + ! these is removed as the corresponding module is worked through. + if (allocated(p%MirrorRotor)) then + if (any(p%MirrorRotor)) then + if (p%Linearize) CALL SetErrStat( ErrID_Fatal, 'MirrorRotor is not yet supported with linearization.', ErrStat, ErrMsg, RoutineName ) + if (p%CompAeroMaps) CALL SetErrStat( ErrID_Fatal, 'MirrorRotor is not yet supported with the steady-state (aero map) solver.', ErrStat, ErrMsg, RoutineName ) + if (p%CompAero == Module_ExtLd) CALL SetErrStat( ErrID_Fatal, 'MirrorRotor is not yet supported with ExtLoads.', ErrStat, ErrMsg, RoutineName ) + ! The mirror itself is confined to this instance, and the blade surfaces do + ! render correctly, since each turbine is its own single-rotor instance. What + ! is not verified is the wake coupling: FWrap_CalcOutput forms the skew angle + ! from a cross product of the disk-averaged wind with the disk normal, which is + ! the pseudovector pattern that carries a sign at every other module boundary + ! here, and the curled-wake model encodes a swirl direction of its own. Neither + ! has been measured. See Phase 6 in the plan. + if (p%FarmIntegration) CALL SetErrStat( ErrID_Fatal, 'MirrorRotor is not yet supported with FAST.Farm.', ErrStat, ErrMsg, RoutineName ) + end if + end if + END SUBROUTINE ValidateInputData !---------------------------------------------------------------------------------------------------------------------------------- !> This routine initializes the output for the glue code, including writing the header for the primary output file. @@ -3836,6 +3867,7 @@ SUBROUTINE SetVTKParameters(p_FAST, InitOutData_ED, InitOutData_SED, InitOutData REAL(SiKi) :: x, y REAL(SiKi) :: TwrDiam_top, TwrDiam_base, TwrRatio, TwrLength REAL(SiKi) :: BladeLength, HubRad + REAL(ReKi) :: VTKRotDir INTEGER(IntKi) :: topNode, baseNode INTEGER(IntKi) :: NumBl, k, Indx LOGICAL :: UseADtwr @@ -3997,6 +4029,14 @@ SUBROUTINE SetVTKParameters(p_FAST, InitOutData_ED, InitOutData_SED, InitOutData return end if + ! MirrorRotor: the generic blade shapes below are synthesised here rather than taken + ! from AeroDyn, so they need the rotation direction to be drawn the right way round. + ! Only rotor 1 is visualised by this routine, as the TODOs below record. + VTKRotDir = 1.0_ReKi + if (allocated(p_FAST%MirrorRotor)) then + if (p_FAST%MirrorRotor(1)) VTKRotDir = -1.0_ReKi + end if + IF ( p_FAST%CompAero == Module_AD ) THEN ! These meshes may have airfoil data associated with nodes... IF (ALLOCATED(InitOutData_AD%rotors(1)%BladeShape)) THEN @@ -4013,7 +4053,7 @@ SUBROUTINE SetVTKParameters(p_FAST, InitOutData_ED, InitOutData_SED, InitOutData tipNode = AD%Input(1)%rotors(1)%BladeMotion(K)%NNodes cylNode = min(3,AD%Input(1)%rotors(1)%BladeMotion(K)%Nnodes) - call SetVTKDefaultBladeParams(AD%Input(1)%rotors(1)%BladeMotion(K), p_FAST%VTK_Surface%BladeShape(K), tipNode, rootNode, cylNode, 1, ErrStat2, ErrMsg2) + call SetVTKDefaultBladeParams(AD%Input(1)%rotors(1)%BladeMotion(K), p_FAST%VTK_Surface%BladeShape(K), tipNode, rootNode, cylNode, 1, VTKRotDir, ErrStat2, ErrMsg2) CALL SetErrStat(ErrStat2,ErrMsg2,ErrStat,ErrMsg,RoutineName) IF (ErrStat >= AbortErrLev) RETURN END DO @@ -4026,7 +4066,7 @@ SUBROUTINE SetVTKParameters(p_FAST, InitOutData_ED, InitOutData_SED, InitOutData tipNode = BD%y(k)%BldMotion%NNodes cylNode = min(3,BD%y(k)%BldMotion%NNodes) - call SetVTKDefaultBladeParams(BD%y(k)%BldMotion, p_FAST%VTK_Surface%BladeShape(K), tipNode, rootNode, cylNode, 4, ErrStat2, ErrMsg2) + call SetVTKDefaultBladeParams(BD%y(k)%BldMotion, p_FAST%VTK_Surface%BladeShape(K), tipNode, rootNode, cylNode, 4, VTKRotDir, ErrStat2, ErrMsg2) CALL SetErrStat(ErrStat2,ErrMsg2,ErrStat,ErrMsg,RoutineName) IF (ErrStat >= AbortErrLev) RETURN END DO @@ -4041,7 +4081,7 @@ SUBROUTINE SetVTKParameters(p_FAST, InitOutData_ED, InitOutData_SED, InitOutData tipNode = ED%y(1)%BladeLn2Mesh(K)%NNodes-1 cylNode = min(2,ED%y(1)%BladeLn2Mesh(K)%NNodes) - call SetVTKDefaultBladeParams(ED%y(1)%BladeLn2Mesh(K), p_FAST%VTK_Surface%BladeShape(K), tipNode, rootNode, cylNode, 4, ErrStat2, ErrMsg2) + call SetVTKDefaultBladeParams(ED%y(1)%BladeLn2Mesh(K), p_FAST%VTK_Surface%BladeShape(K), tipNode, rootNode, cylNode, 4, VTKRotDir, ErrStat2, ErrMsg2) CALL SetErrStat(ErrStat2,ErrMsg2,ErrStat,ErrMsg,RoutineName) IF (ErrStat >= AbortErrLev) RETURN END DO @@ -4079,7 +4119,7 @@ SUBROUTINE SetVTKParameters(p_FAST, InitOutData_ED, InitOutData_SED, InitOutData END SUBROUTINE SetVTKParameters !---------------------------------------------------------------------------------------------------------------------------------- !> This subroutine comes up with some default airfoils for blade surfaces for a given blade mesh, M. -SUBROUTINE SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode, iShape, ErrStat, ErrMsg) +SUBROUTINE SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode, iShape, RotDir, ErrStat, ErrMsg) TYPE(MeshType), INTENT(IN ) :: M !< The Mesh the defaults should be calculated for TYPE(FAST_VTK_BLSurfaceType), INTENT(INOUT) :: BladeShape !< BladeShape to set to default values @@ -4087,6 +4127,7 @@ SUBROUTINE SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode, i INTEGER(IntKi), INTENT(IN ) :: tipNode !< Index of tip node (outermost node) for this mesh INTEGER(IntKi), INTENT(IN ) :: cylNode !< Index of last node to have a cylinder shape INTEGER(IntKi), INTENT(IN ) :: iShape !< 1: S809, 2: circle, 3: square, 4: rectangle + REAL(ReKi), INTENT(IN ) :: RotDir !< MirrorRotor: +1 normal, -1 mirrored INTEGER(IntKi), INTENT( OUT) :: ErrStat !< Error status of the operation CHARACTER(*), INTENT( OUT) :: ErrMsg !< Error message if ErrStat /= ErrID_None @@ -4094,6 +4135,13 @@ SUBROUTINE SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode, i REAL(SiKi) :: bladeLength, chord, pitchAxis REAL(SiKi) :: bladeLengthFract, bladeLengthFract2, ratio, posLength ! temporary quantities REAL(SiKi) :: cylinderLength, x, y, angle + ! MirrorRotor: MeshWrVTK_Ln2Surface places these vertices as matmul(xyz, Orientation), + ! so component 1 rides row 1 of the node's direction cosine matrix and component 2 + ! rides row 2. Under R' = S R S row 1 becomes S*row1 while row 2 becomes -S*row2, so + ! the chordwise term carries the sign and the thickness term does not. Without it a + ! mirrored rotor is drawn with its sections back to front about the pitch axis. + ! Visualisation only; these coordinates never reach the loads. + REAL(SiKi) :: chordSign INTEGER(IntKi) :: i, j INTEGER(IntKi) :: ErrStat2 CHARACTER(ErrMsgLen) :: ErrMsg2 @@ -4103,6 +4151,7 @@ SUBROUTINE SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode, i ErrStat = ErrID_None ErrMsg = '' + chordSign = real(RotDir, SiKi) select case (iShape) case (1) @@ -4166,8 +4215,8 @@ SUBROUTINE SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode, i x = yc(j) y = xc(j) - 0.5 ! x,y coordinates for cylinder - BladeShape%AirfoilCoords(1,j,i) = chord*x - BladeShape%AirfoilCoords(2,j,i) = chord*y + BladeShape%AirfoilCoords(1,j,i) = chord*x + BladeShape%AirfoilCoords(2,j,i) = chordSign * chord*y END DO enddo return ! We exit this routine @@ -4199,8 +4248,8 @@ SUBROUTINE SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode, i angle = ATAN2( y, x) ! x,y coordinates for cylinder - BladeShape%AirfoilCoords(1,j,i) = chord*COS(angle) ! x (note that "chord" is really representing chord/2 here) - BladeShape%AirfoilCoords(2,j,i) = chord*SIN(angle) ! y (note that "chord" is really representing chord/2 here) + BladeShape%AirfoilCoords(1,j,i) = chord*COS(angle) ! x (note that "chord" is really representing chord/2 here) + BladeShape%AirfoilCoords(2,j,i) = chordSign * chord*SIN(angle) ! y (note that "chord" is really representing chord/2 here) END DO ELSE @@ -4212,8 +4261,8 @@ SUBROUTINE SetVTKDefaultBladeParams(M, BladeShape, tipNode, rootNode, cylNode, i y = xc(j) - pitchAxis ! x,y coordinates for airfoil - BladeShape%AirfoilCoords(1,j,i) = chord*x - BladeShape%AirfoilCoords(2,j,i) = chord*y + BladeShape%AirfoilCoords(1,j,i) = chord*x + BladeShape%AirfoilCoords(2,j,i) = chordSign * chord*y END DO END IF diff --git a/modules/simple-elastodyn/src/SED.f90 b/modules/simple-elastodyn/src/SED.f90 index 4e54d98b08..41097fbe0c 100644 --- a/modules/simple-elastodyn/src/SED.f90 +++ b/modules/simple-elastodyn/src/SED.f90 @@ -165,7 +165,10 @@ subroutine SED_SetParameters(ErrStat3,ErrMsg3) p%GenDOF = InputFileData%GenDOF p%YawDOF = InputFileData%YawDOF p%InitYaw = InputFileData%NacYaw - p%InitAzimuth = InputFileData%Azimuth + p%RotDir = 1.0_ReKi + if (InitInp%MirrorRotor) p%RotDir = -1.0_ReKi + ! MirrorRotor: the azimuth state is physical, the input is in the CW convention. + p%InitAzimuth = p%RotDir * InputFileData%Azimuth ! geometry p%NumBl = InputFileData%NumBl @@ -215,8 +218,10 @@ subroutine Init_States(ErrStat3,ErrMsg3) call AllocAry( x%QDT, 1, 'x%QDT', ErrStat3, ErrMsg3); if (ErrStat3 >= AbortErrLev) return ! Set initial conditions - x%QT( DOF_Az) = InputFileData%Azimuth - x%QDT(DOF_Az) = InputFileData%RotSpeed + ! MirrorRotor: Azimuth and RotSpeed are supplied in the CW convention (RotSpeed is + ! validated non-negative) and mirrored here; the states themselves are physical. + x%QT( DOF_Az) = p%RotDir * InputFileData%Azimuth + x%QDT(DOF_Az) = p%RotDir * InputFileData%RotSpeed ! Unused states xd%DummyDiscreteState = 0.0_ReKi @@ -399,8 +404,9 @@ subroutine Init_Mesh(ErrStat3,ErrMSg3) ! apply to ref orientation of hub Orient = matmul(y%HubPtMotion%RefOrientation(1:3,1:3,1),transpose(R33b)) - ! now apply azimuth rotation about hub X - RootAz = real((i-1),R8Ki) * TwoPi_R8 / real(p%NumBl,R8Ki) + ! now apply azimuth rotation about hub X. The spacing carries the rotation + ! sense, so the blades are numbered in the direction of rotation. + RootAz = real(p%RotDir,R8Ki) * real((i-1),R8Ki) * TwoPi_R8 / real(p%NumBl,R8Ki) R33c(1:3,1:3) = SkewSymMat( y%HubPtMotion%RefOrientation(1,1:3,1) ) ! x axis call Eye(R33b,ErrStat3,ErrMsg3); if (errStat3 >= AbortErrLev) return ! Rodrigues formula for rotation about a vector @@ -519,7 +525,7 @@ subroutine Init_InitY(ErrStat3,ErrMsg3) call AllocAry( InitOut%BlPitch, p%NumBl, 'InitOut%BlPitch', ErrStat3, ErrMsg3 ); if (errStat3 >= AbortErrLev) return InitOut%BlPitch = InputFileData%BlPitch - InitOut%RotSpeed = x%QDT(DOF_Az) + InitOut%RotSpeed = p%RotDir * x%QDT(DOF_Az) InitOut%PlatformPos(1:3) = real(y%PlatformPtMesh%Position(1:3,1), ReKi) + real(y%PlatformPtMesh%TranslationDisp(1:3,1), ReKi) theta(1:3) = GetSmllRotAngs(y%PlatformPtMesh%Orientation(1:3,1:3,1), ErrStat3, ErrMsg3); if (errStat3 >= AbortErrLev) return InitOut%PlatformPos(4:6) = real(theta, ReKi) @@ -842,7 +848,9 @@ subroutine SED_RK4( t, n, u, utimes, p, x, xd, z, OtherState, m, ErrStat, ErrMsg ! interpolate u to find u_interp = u(t) call SED_Input_ExtrapInterp( u, utimes, u_interp, t, ErrStat2, ErrMsg2 ); if (Failed()) return - OtherState%HSSBrTrq = u_interp%HSSBrTrqC + ! The brake opposes the direction of rotation, as in AB4/ABM4. Without the sign it + ! would drive a shaft that is turning backwards. + OtherState%HSSBrTrq = SIGN( u_interp%HSSBrTrqC, real(x%qdt(DOF_Az),ReKi) ) ! find xdot at t call SED_CalcContStateDeriv( t, u_interp, p, x, xd, z, OtherState, m, xdot, ErrStat2, ErrMsg2 ) @@ -987,7 +995,9 @@ subroutine FixHSSBrTq ( Integrator, u, p, x, OtherState, m, ErrStat, ErrMsg ) ! Find the force required to produce RqdQD2Az from the equations of ! motion using the new accelerations: - GenTrqLSS = p%GBoxRatio * u%GenTrq + ! MirrorRotor: GenTrq arrives in the CW convention, so RotDir puts it on the physical + ! shaft axis. HSSBrTrqC is already signed by the rotation direction and must not be. + GenTrqLSS = p%RotDir * p%GBoxRatio * u%GenTrq BrkTrqLSS = p%GBoxRatio * OtherState%HSSBrTrqC AeroTrq = dot_product(u%HubPtLoad%Moment(:,1), m%HubPt_X(1:3)) ! torque about hub X RqdFrcAz = RqdQD2Az * p%J_DT - AeroTrq + GenTrqLSS + BrkTrqLSS @@ -1051,8 +1061,8 @@ function SignLSSTrq( u, p, m ) real(ReKi) :: BrkTrqLSS ! HSS brake torque, expressed on LSS real(ReKi) :: AeroTrq ! AeroDynamic torque -- passed in on HubPt - GenTrqLSS = p%GBoxRatio * u%GenTrq - BrkTrqLSS = p%GBoxRatio * u%HSSBrTrqC + GenTrqLSS = p%RotDir * p%GBoxRatio * u%GenTrq + BrkTrqLSS = p%RotDir * p%GBoxRatio * u%HSSBrTrqC AeroTrq = dot_product(u%HubPtLoad%Moment(:,1), m%HubPt_X(1:3)) ! torque about hub X MomLPRot = AeroTrq - GenTrqLSS - BrkTrqLSS @@ -1308,6 +1318,7 @@ SUBROUTINE SED_CalcOutput( t, u, p, x, xd, z, OtherState, y, m, ErrStat, ErrMsg, real(ReKi) :: YawRotVel(3) real(ReKi) :: YawAng real(ReKi) :: AzRotVel(3) + real(ReKi) :: BlPitchPhys !< Blade pitch on the physical rotor integer(IntKi) :: i !< Generic counter logical :: CalcWriteOutput type(SED_ContinuousStateType) :: dxdt !< Derivatives of continuous states at t @@ -1404,7 +1415,9 @@ SUBROUTINE SED_CalcOutput( t, u, p, x, xd, z, OtherState, y, m, ErrStat, ErrMsg, R33(1:3,1:3) = SkewSymMat( y%BladeRootMotion(i)%Orientation(3,1:3,1) ) ! blade z-axis call Eye(Orient,ErrStat2,ErrMsg2); if (Failed()) return; ! Rodrigues formula for rotation about a vector (NOTE: BlPitch does not follow right hand rule) - Orient = Orient + sin(real(-u%BlPitchCom(i),R8Ki)) * R33 + (1-cos(real(-u%BlPitchCom(i),R8Ki))) * matmul(R33,R33) + ! MirrorRotor: BlPitchCom arrives in the CW convention, so mirror it here. + BlPitchPhys = p%RotDir * u%BlPitchCom(i) + Orient = Orient + sin(real(-BlPitchPhys,R8Ki)) * R33 + (1-cos(real(-BlPitchPhys,R8Ki))) * matmul(R33,R33) y%BladeRootMotion(i)%Orientation(1:3,1:3,1) = matmul(y%BladeRootMotion(i)%Orientation(1:3,1:3,1),transpose(Orient)) ! We don't have a blade pitching rate, so we will not include it here @@ -1416,15 +1429,17 @@ SUBROUTINE SED_CalcOutput( t, u, p, x, xd, z, OtherState, y, m, ErrStat, ErrMsg, !-------------------- ! Other outputs - y%LSSTipPxa = x%QT( DOF_Az) + ! MirrorRotor: the states are physical; these are reported in the rotor's own (CW) + ! convention, so a mirrored rotor turning its design direction still reads positive. + y%LSSTipPxa = p%RotDir * x%QT( DOF_Az) call Zero2TwoPi(y%LSSTipPxa) ! Modulo y%RotSpeed = x%QDT(DOF_Az) - y%HSS_Spd = x%QDT(DOF_Az) * p%GBoxRatio + y%HSS_Spd = p%RotDir * x%QDT(DOF_Az) * p%GBoxRatio ! Rotor torque is the torque applied to the LSS shaft by the rotor. ! NOTE: this is equivalent to the reactionary torque of the generator due to its torque and inertia. - y%RotTrq = p%GBoxRatio * u%GenTrq + dxdt%QDT(DOF_Az) * RPS2RPM * p%GBoxRatio * p%GenIner + p%GBoxRatio * OtherState%HSSBrTrq + y%RotTrq = p%GBoxRatio * u%GenTrq + p%RotDir * (dxdt%QDT(DOF_Az) * RPS2RPM * p%GBoxRatio * p%GenIner + p%GBoxRatio * OtherState%HSSBrTrq) ! y%RotTrq = dot_product(u%HubPtLoad%Moment(:,1), m%HubPt_X(1:3)) - dxdt%QDT(DOF_Az) * RPS2RPM * p%RotIner ! this equation is somehow wrong. - y%RotPwr = x%QDT(DOF_Az) * y%RotTrq + y%RotPwr = p%RotDir * x%QDT(DOF_Az) * y%RotTrq ! Simply pass the yaw cammend through as the current yaw y%Yaw = u%YawPosCom @@ -1509,7 +1524,7 @@ SUBROUTINE SED_CalcContStateDeriv( t, u, p, x, xd, z, OtherState, m, dxdt, ErrSt !! - \f$Q_b = n_g Q_{b,\text{HSS}}\f$ is the HSS brake torque projected to the LSS !! if (p%GenDOF) then - GenTrqLSS = p%GBoxRatio * u%GenTrq + GenTrqLSS = p%RotDir * p%GBoxRatio * u%GenTrq BrkTrqLSS = p%GBoxRatio * OtherState%HSSBrTrq AeroTrq = dot_product(u%HubPtLoad%Moment(:,1), m%HubPt_X(1:3)) ! torque about hub X dxdt%QDT(DOF_Az) = real((AeroTrq - GenTrqLSS - BrkTrqLSS)/p%J_DT, R8Ki) diff --git a/modules/simple-elastodyn/src/SED_IO.f90 b/modules/simple-elastodyn/src/SED_IO.f90 index 083de3e903..6114d63bfd 100644 --- a/modules/simple-elastodyn/src/SED_IO.f90 +++ b/modules/simple-elastodyn/src/SED_IO.f90 @@ -309,17 +309,23 @@ subroutine Calc_WriteOutput( u, p, x, dxdt, y, m, ErrStat, ErrMsg, CalcWriteOutp if (.not. CalcWriteOutput) return ! Azimuth - m%AllOuts( Azimuth ) = x%QT( DOF_Az) + ! MirrorRotor: the states are physical; these channels report the rotor's own + ! convention, so a mirrored rotor reads the same as its CW twin. + m%AllOuts( Azimuth ) = p%RotDir * x%QT( DOF_Az) call Zero2TwoPi(m%AllOuts( Azimuth )) ! modulo m%AllOuts( Azimuth ) = m%AllOuts( Azimuth ) * R2D ! speed - m%AllOuts( RotSpeed ) = x%QDT(DOF_Az) * RPS2RPM - m%AllOuts( GenSpeed ) = x%QDT(DOF_Az) * RPS2RPM * p%GBoxRatio + m%AllOuts( RotSpeed ) = p%RotDir * x%QDT(DOF_Az) * RPS2RPM + m%AllOuts( GenSpeed ) = p%RotDir * x%QDT(DOF_Az) * RPS2RPM * p%GBoxRatio ! accel - m%AllOuts( RotAcc ) = dxdt%QDT(DOF_Az) * RPS2RPM - m%AllOuts( GenAcc ) = dxdt%QDT(DOF_Az) * RPS2RPM * p%GBoxRatio + m%AllOuts( RotAcc ) = p%RotDir * dxdt%QDT(DOF_Az) * RPS2RPM + m%AllOuts( GenAcc ) = p%RotDir * dxdt%QDT(DOF_Az) * RPS2RPM * p%GBoxRatio + + ! MirrorRotor: physical components about the shaft x axis, which do change sign. + m%AllOuts( LSSTipVxa ) = x%QDT(DOF_Az) * RPS2RPM + m%AllOuts( LSSTipAxa ) = dxdt%QDT(DOF_Az) * RPS2RPM ! Yaw commands m%AllOuts( Yaw ) = y%Yaw * R2D @@ -379,8 +385,8 @@ SUBROUTINE SetOutParam(OutList, p, ErrStat, ErrMsg ) "YAWRATE "/) INTEGER(IntKi), PARAMETER :: ParamIndxAry(25) = (/ & ! This lists the index into AllOuts(:) of the allowed parameters ValidParamAry(:) Azimuth , BlPitch1 , BlPitch2 , BlPitch3 , BlPitch1 , BlPitch2 , BlPitch3 , GenAcc , & - GenSpeed , GenAcc , GenSpeed , RotPwr , RotTorq , RotAcc , RotAcc , RotAcc , & - RotSpeed , RotSpeed , RotSpeed , RotAcc , RotPwr , RotSpeed , RotTorq , Yaw , & + GenSpeed , GenAcc , GenSpeed , RotPwr , RotTorq , LSSTipAxa , LSSTipAxa , LSSTipAxa , & + LSSTipVxa , LSSTipVxa , LSSTipVxa , RotAcc , RotPwr , RotSpeed , RotTorq , Yaw , & YawRate /) CHARACTER(ChanLen), PARAMETER :: ParamUnitsAry(25) = (/ & ! This lists the units corresponding to the allowed parameters "(deg) ","(deg) ","(deg) ","(deg) ","(deg) ","(deg) ","(deg) ","(deg/s^2)", & diff --git a/modules/simple-elastodyn/src/SED_Output_Params.f90 b/modules/simple-elastodyn/src/SED_Output_Params.f90 index 7d64c07ea0..153bb4edc3 100644 --- a/modules/simple-elastodyn/src/SED_Output_Params.f90 +++ b/modules/simple-elastodyn/src/SED_Output_Params.f90 @@ -43,9 +43,13 @@ module SED_Output_Params INTEGER(IntKi), PARAMETER :: RotTorq = 11 INTEGER(IntKi), PARAMETER :: RotPwr = 12 + ! MirrorRotor: physical components about the shaft x axis, as opposed to the + ! rotor-convention RotSpeed and RotAcc above. + INTEGER(IntKi), PARAMETER :: LSSTipVxa = 13 + INTEGER(IntKi), PARAMETER :: LSSTipAxa = 14 ! The maximum number of output channels which can be output by the code. - INTEGER(IntKi), PARAMETER :: MaxOutPts = 12 + INTEGER(IntKi), PARAMETER :: MaxOutPts = 14 !End of code generated by Matlab script ! =================================================================================================== diff --git a/modules/simple-elastodyn/src/SED_Registry.txt b/modules/simple-elastodyn/src/SED_Registry.txt index da9b22187c..587ca1f6fe 100644 --- a/modules/simple-elastodyn/src/SED_Registry.txt +++ b/modules/simple-elastodyn/src/SED_Registry.txt @@ -49,6 +49,7 @@ typedef ^ InitInputType CHARACTER(1024) RootName - - typedef ^ InitInputType LOGICAL Linearize - .false. - "this module cannot be linearized at present" - typedef ^ InitInputType LOGICAL UseInputFile - .TRUE. - "Supplied by Driver: .TRUE. if using a input file, .FALSE. if all inputs are being passed in by the caller" - typedef ^ InitInputType FileInfoType PassedFileData - - - "If we don't use the input file, pass everything through this" - +typedef ^ InitInputType LOGICAL MirrorRotor - .FALSE. - "Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind)" - # outputs from initialization: @@ -135,6 +136,7 @@ typedef ^ ParameterType R8Ki InitAzimuth - - typedef ^ ParameterType ReKi RotIner - - - "Hub inertia about teeter axis (2-blader) or rotor axis (3-blader)" "kg m^2" typedef ^ ParameterType ReKi GenIner - - - "Generator inertia about HSS" "kg m^2" typedef ^ ParameterType ReKi GBoxRatio - - - "Gearbox ratio" - +typedef ^ ParameterType ReKi RotDir - 1.0 - "Rotor rotation direction: +1 normal (CW viewed from upwind), -1 mirrored (CCW)" - typedef ^ ParameterType IntKi NumBl - - - "Number of blades on the turbine" - typedef ^ ParameterType ReKi TipRad - - - "Preconed blade-tip radius (distance from the rotor apex to the blade tip)" m typedef ^ ParameterType ReKi HubRad - - - "Preconed hub radius (distance from the rotor apex to the blade root)" m diff --git a/modules/simple-elastodyn/src/SED_Types.f90 b/modules/simple-elastodyn/src/SED_Types.f90 index ea94df58bd..06101b2c79 100644 --- a/modules/simple-elastodyn/src/SED_Types.f90 +++ b/modules/simple-elastodyn/src/SED_Types.f90 @@ -69,6 +69,7 @@ MODULE SED_Types LOGICAL :: Linearize = .false. !< this module cannot be linearized at present [-] LOGICAL :: UseInputFile = .TRUE. !< Supplied by Driver: .TRUE. if using a input file, .FALSE. if all inputs are being passed in by the caller [-] TYPE(FileInfoType) :: PassedFileData !< If we don't use the input file, pass everything through this [-] + LOGICAL :: MirrorRotor = .FALSE. !< Flag indicating the rotor rotation direction is mirrored (counter-clockwise viewed from upwind) [-] END TYPE SED_InitInputType ! ======================= ! ========= SED_InitOutputType ======= @@ -158,6 +159,7 @@ MODULE SED_Types REAL(ReKi) :: RotIner = 0.0_ReKi !< Hub inertia about teeter axis (2-blader) or rotor axis (3-blader) [kg m^2] REAL(ReKi) :: GenIner = 0.0_ReKi !< Generator inertia about HSS [kg m^2] REAL(ReKi) :: GBoxRatio = 0.0_ReKi !< Gearbox ratio [-] + REAL(ReKi) :: RotDir = 1.0 !< Rotor rotation direction: +1 normal (CW viewed from upwind), -1 mirrored (CCW) [-] INTEGER(IntKi) :: NumBl = 0_IntKi !< Number of blades on the turbine [-] REAL(ReKi) :: TipRad = 0.0_ReKi !< Preconed blade-tip radius (distance from the rotor apex to the blade tip) [m] REAL(ReKi) :: HubRad = 0.0_ReKi !< Preconed hub radius (distance from the rotor apex to the blade root) [m] @@ -355,6 +357,7 @@ subroutine SED_CopyInitInput(SrcInitInputData, DstInitInputData, CtrlCode, ErrSt call NWTC_Library_CopyFileInfoType(SrcInitInputData%PassedFileData, DstInitInputData%PassedFileData, CtrlCode, ErrStat2, ErrMsg2) call SetErrStat(ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName) if (ErrStat >= AbortErrLev) return + DstInitInputData%MirrorRotor = SrcInitInputData%MirrorRotor end subroutine subroutine SED_DestroyInitInput(InitInputData, ErrStat, ErrMsg) @@ -380,6 +383,7 @@ subroutine SED_PackInitInput(RF, Indata) call RegPack(RF, InData%Linearize) call RegPack(RF, InData%UseInputFile) call NWTC_Library_PackFileInfoType(RF, InData%PassedFileData) + call RegPack(RF, InData%MirrorRotor) if (RegCheckErr(RF, RoutineName)) return end subroutine @@ -393,6 +397,7 @@ subroutine SED_UnPackInitInput(RF, OutData) call RegUnpack(RF, OutData%Linearize); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%UseInputFile); if (RegCheckErr(RF, RoutineName)) return call NWTC_Library_UnpackFileInfoType(RF, OutData%PassedFileData) ! PassedFileData + call RegUnpack(RF, OutData%MirrorRotor); if (RegCheckErr(RF, RoutineName)) return end subroutine subroutine SED_CopyInitOutput(SrcInitOutputData, DstInitOutputData, CtrlCode, ErrStat, ErrMsg) @@ -1049,6 +1054,7 @@ subroutine SED_CopyParam(SrcParamData, DstParamData, CtrlCode, ErrStat, ErrMsg) DstParamData%RotIner = SrcParamData%RotIner DstParamData%GenIner = SrcParamData%GenIner DstParamData%GBoxRatio = SrcParamData%GBoxRatio + DstParamData%RotDir = SrcParamData%RotDir DstParamData%NumBl = SrcParamData%NumBl DstParamData%TipRad = SrcParamData%TipRad DstParamData%HubRad = SrcParamData%HubRad @@ -1120,6 +1126,7 @@ subroutine SED_PackParam(RF, Indata) call RegPack(RF, InData%RotIner) call RegPack(RF, InData%GenIner) call RegPack(RF, InData%GBoxRatio) + call RegPack(RF, InData%RotDir) call RegPack(RF, InData%NumBl) call RegPack(RF, InData%TipRad) call RegPack(RF, InData%HubRad) @@ -1165,6 +1172,7 @@ subroutine SED_UnPackParam(RF, OutData) call RegUnpack(RF, OutData%RotIner); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%GenIner); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%GBoxRatio); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%RotDir); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%NumBl); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%TipRad); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%HubRad); if (RegCheckErr(RF, RoutineName)) return diff --git a/modules/simple-elastodyn/src/driver/SED_Driver.f90 b/modules/simple-elastodyn/src/driver/SED_Driver.f90 index 900c7ea938..4e06b57e5f 100644 --- a/modules/simple-elastodyn/src/driver/SED_Driver.f90 +++ b/modules/simple-elastodyn/src/driver/SED_Driver.f90 @@ -238,6 +238,7 @@ PROGRAM SED_Driver InitInData%InputFile = Settings%SEDIptFileName InitInData%RootName = Settings%OutRootName + InitInData%MirrorRotor = Settings%MirrorRotor ! Initialize the module CALL SED_Init( InitInData, u(1), p, x, xd, z, OtherState, y, misc, TimeInterval, InitOutData, ErrStat, ErrMsg ) diff --git a/modules/simple-elastodyn/src/driver/SED_Driver_Subs.f90 b/modules/simple-elastodyn/src/driver/SED_Driver_Subs.f90 index 554526d1ff..9cf956fc6d 100644 --- a/modules/simple-elastodyn/src/driver/SED_Driver_Subs.f90 +++ b/modules/simple-elastodyn/src/driver/SED_Driver_Subs.f90 @@ -448,6 +448,10 @@ SUBROUTINE ParseDvrIptFile( DvrFileName, DvrFileInfo, DvrFlags, DvrSettings, Pro ! WrVTK -- writing VTK visualization files must be [0: none, 1: init only, 2: animation] call ParseVar( DvrFileInfo, CurLine, "WrVTK", DvrSettings%WrVTK, ErrStatTmp, ErrMsgTmp, UnEc ) + ! MirrorRotor -- run the rotor counter-clockwise viewed from upwind + call ParseVar( DvrFileInfo, CurLine, "MirrorRotor", DvrSettings%MirrorRotor, ErrStatTmp, ErrMsgTmp, UnEc ) + if (Failed()) return + !====== Case analysis =============================================================================== if ( EchoFileContents ) WRITE(UnEc, '(A)') DvrFileInfo%Lines(CurLine) ! Write section break to echo diff --git a/modules/simple-elastodyn/src/driver/SED_Driver_Types.f90 b/modules/simple-elastodyn/src/driver/SED_Driver_Types.f90 index 65b6ed4167..6f0eda31eb 100644 --- a/modules/simple-elastodyn/src/driver/SED_Driver_Types.f90 +++ b/modules/simple-elastodyn/src/driver/SED_Driver_Types.f90 @@ -65,6 +65,7 @@ module SED_Driver_Types type(ProgDesc) :: SEDProgInfo !< Program info for SED integer(IntKi) :: WrVTK !< Write VTK outputs [0: none, 1: init only, 2: animation] + logical :: MirrorRotor = .FALSE. !< Rotor rotation direction is mirrored (CCW viewed from upwind) integer(IntKi) :: VTK_tWidth !< width of the time field in the VTK character(1024) :: VTK_OutFileRoot !< Output root name for VTK end type SEDDriver_Settings diff --git a/reg_tests/CTestList.cmake b/reg_tests/CTestList.cmake index 75ee500f81..df88548831 100644 --- a/reg_tests/CTestList.cmake +++ b/reg_tests/CTestList.cmake @@ -332,6 +332,7 @@ endfunction(py_wavetank_regression) # OpenFAST regression tests of_regression("AWT_YFix_WSt" "openfast;elastodyn;aerodyn;servodyn") of_regression("AWT_WSt_StartUp_HighSpShutDown" "openfast;elastodyn;aerodyn;servodyn") +of_regression("AWT_WSt_StartUp_HighSpShutDown_MirrorRotor" "openfast;elastodyn;aerodyn;servodyn;mirrorrotor") of_regression("AWT_YFree_WSt" "openfast;elastodyn;aerodyn;servodyn") of_regression("AWT_YFree_WTurb" "openfast;elastodyn;aerodyn;servodyn") of_regression("AWT_WSt_StartUpShutDown" "openfast;elastodyn;aerodyn;servodyn") @@ -349,6 +350,11 @@ of_regression("SWRT_YFree_VS_EDG01" "openfast;elastodyn;aerod of_regression("SWRT_YFree_VS_EDC01" "openfast;elastodyn;aerodyn;servodyn") of_regression("SWRT_YFree_VS_WTurb" "openfast;elastodyn;aerodyn;servodyn") of_regression("5MW_Land_DLL_WTurb" "openfast;elastodyn;aerodyn;servodyn") +of_regression("5MW_Land_DLL_WTurb_MirrorRotor" "openfast;elastodyn;aerodyn;servodyn;mirrorrotor") +of_regression("5MW_Land_noDLL_Steady_CW" "openfast;elastodyn;aerodyn;mirrorrotor") +of_regression("5MW_Land_noDLL_Steady_MirrorRotor" "openfast;elastodyn;aerodyn;mirrorrotor") +of_regression("5MW_Land_BD_noDLL_Steady_CW" "openfast;elastodyn;beamdyn;aerodyn;mirrorrotor") +of_regression("5MW_Land_BD_noDLL_Steady_MirrorRotor" "openfast;elastodyn;beamdyn;aerodyn;mirrorrotor") of_regression("5MW_Land_DLL_WTurb_wNacDrag" "openfast;elastodyn;aerodyn;servodyn") of_regression("5MW_Land_DLL_WTurb_wBlPDyn" "openfast;elastodyn;aerodyn;servodyn") of_regression("5MW_OC3Mnpl_DLL_WTurb_WavesIrr" "openfast;elastodyn;aerodyn;servodyn;hydrodyn;subdyn;offshore") @@ -361,8 +367,13 @@ of_regression("5MW_ITIBarge_DLL_WTurb_WavesIrr" "openfast;elastodyn;aerod of_regression("5MW_TLP_DLL_WTurb_WavesIrr_WavesMulti" "openfast;elastodyn;aerodyn;servodyn;hydrodyn;map;offshore") of_regression("5MW_OC3Spar_DLL_WTurb_WavesIrr" "openfast;elastodyn;aerodyn;servodyn;hydrodyn;map;offshore") of_regression("5MW_OC4Semi_WSt_WavesWN" "openfast;elastodyn;aerodyn;servodyn;hydrodyn;moordyn;offshore") +of_regression("5MW_OC4Semi_WSt_WavesWN_MirrorRotor" "openfast;elastodyn;aerodyn;servodyn;hydrodyn;moordyn;offshore;mirrorrotor") +of_regression("5MW_MRSemi_DLL_WSt_WavesIrr_MirrorRotor" "openfast;elastodyn;aerodyn;servodyn;hydrodyn;subdyn;moordyn;offshore;mirrorrotor") +of_regression("MHK_RM1_Floating_Steady_CW" "openfast;elastodyn;aerodyn;hydrodyn;moordyn;offshore;mhk;mirrorrotor") +of_regression("MHK_RM1_Floating_Steady_MirrorRotor" "openfast;elastodyn;aerodyn;hydrodyn;moordyn;offshore;mhk;mirrorrotor") of_regression("5MW_MRSemi_DLL_WSt_WavesIrr" "openfast;elastodyn;aerodyn;servodyn;hydrodyn;moordyn;offshore;subdyn;olaf;multirotor") of_regression("5MW_Land_BD_DLL_WTurb" "openfast;beamdyn;aerodyn;servodyn") +of_regression("5MW_Land_BD_DLL_WTurb_MirrorRotor" "openfast;beamdyn;aerodyn;servodyn;mirrorrotor") of_regression("5MW_Land_BD_DLL_WTurb_StC" "openfast;beamdyn;aerodyn;servodyn;stc") of_regression("5MW_Land_BD_Init" "openfast;beamdyn;aerodyn;servodyn") of_regression("5MW_OC4Jckt_ExtPtfm" "openfast;elastodyn;extptfm;offshore") @@ -379,6 +390,9 @@ of_regression("Tailfin_FreeYaw1DOF_PolarBased" "openfast;elastodyn;aerod of_regression("Tailfin_FreeYaw1DOF_Unsteady" "openfast;elastodyn;aerodyn") of_regression("5MW_Land_DLL_WTurb_ADsk" "openfast;elastodyn;aerodisk") of_regression("5MW_Land_DLL_WTurb_ADsk_SED" "openfast;simple-elastodyn;aerodisk") +of_regression("5MW_Land_DLL_WTurb_ADsk_SED_MirrorRotor" "openfast;simple-elastodyn;aerodisk;mirrorrotor") +of_regression("5MW_Land_ADsk_SED_Yaw_CW" "openfast;simple-elastodyn;aerodisk;mirrorrotor") +of_regression("5MW_Land_ADsk_SED_Yaw_MirrorRotor" "openfast;simple-elastodyn;aerodisk;mirrorrotor") of_regression("5MW_Land_DLL_WTurb_SED" "openfast;simple-elastodyn;aerodyn") of_regression("IEA22MW_ModalDamping" "openfast;beamdyn;servodyn") of_regression("IEA22MW_ModalDampingLoose" "openfast;beamdyn;servodyn") @@ -452,6 +466,7 @@ ad_regression("ad_EllipticalWingInf_OLAF" "aerodyn;bem") ad_regression("ad_HelicalWakeInf_OLAF" "aerodyn;bem") ad_regression("ad_Kite_OLAF" "aerodyn;bem") ad_regression("ad_MultipleHAWT" "aerodyn;bem") +ad_regression("ad_MultipleHAWT_MirrorRotor" "aerodyn;bem;mirrorrotor") ad_regression("ad_QuadRotor_OLAF" "aerodyn;bem") ad_regression("ad_VerticalAxis_OLAF" "aerodyn;bem") ad_regression("ad_MHK_RM1_Fixed" "aerodyn;bem;mhk") @@ -465,6 +480,8 @@ ad_regression("ad_BAR_SineMotion" "aerodyn;bem") ad_regression("ad_BAR_SineMotion_UA4_DBEMT3" "aerodyn;bem") ad_regression("ad_BAR_RNAMotion" "aerodyn;bem") ad_regression("ad_B1n2_OLAF" "aerodyn;OLAF") +ad_regression("ad_B1n2_OLAF_CW" "aerodyn;OLAF;mirrorrotor") +ad_regression("ad_B1n2_OLAF_MirrorRotor" "aerodyn;OLAF;mirrorrotor") ad_regression("ad_Sphere_OLAF" "aerodyn;OLAF") py_ad_regression("py_ad_5MW_OC4Semi_WSt_WavesWN" "aerodyn;bem;python") py_ad_regression("py_ad_B1n2_OLAF" "aerodyn;OLAF;python") diff --git a/reg_tests/otherTests/README.md b/reg_tests/otherTests/README.md new file mode 100644 index 0000000000..ca3ed274ee --- /dev/null +++ b/reg_tests/otherTests/README.md @@ -0,0 +1,54 @@ +# otherTests + +Verification tooling that is not part of the ctest regression suite but is +needed to justify what the regression suite asserts. These scripts measure +behaviour that a stored baseline cannot express on its own — most of them exist +to support the counter-clockwise rotor work described in +`docs/source/user/glue-code/mirror_rotor.rst`. + +Nothing here runs automatically. The scripts are run by hand when a claim needs +re-establishing, or when a change touches the rotor convention. + +Each script locates the repository from its own position on disk, so they work +from any checkout. Set `OPENFAST_REPO` to override that. + +## The mirror comparison + +A mirrored rotor is verified by running a model twice, once clockwise and once +with `MirrorRotor = T`, and requiring **every** output channel to resolve to one +of: identical, exactly sign-flipped, a mirrored angle, or below the numerical +noise floor. Anything else means two quantities have been combined while +expressed in different frames. + +| Script | What it does | +|---|---| +| `compare_mirror.py` | the shared classifier. `classify(x, y, tol, floor)` returns `S`, `F`, `A`, `negligible` or `?`. Blade spacing follows the rotation sense, so blade k pairs with blade k directly and no permutation is needed; the mooring `--swap` mapping (layout pairings) remains where a script needs it. Everything else imports this | +| `emit_sign_table.py` | measures the sign map by running every registered mirrored pair and classifying every channel; `--emit-map` regenerates `docs/source/user/glue-code/mirror_rotor_sign_map.yaml` (the rendered per-channel page was retired 2026-09-30 — the grouped summary in `mirror_rotor.rst` is maintained by hand) | +| `check_rtest_mirror_pair.py` | checks the registered clockwise/mirrored pairs, with per-pair tolerance and start time | +| `openfast_case_mirror.py` | clones a regression case verbatim and toggles only `MirrorRotor` | +| `mr_two_rotor.py` | compares rotor 1 against rotor 2 inside one multi-rotor glue-code run | +| `v2_two_rotor.py` | the same for the AeroDyn driver, handling the `A?B N...` nodal naming | +| `sweep_mirror.py`, `openfast_mirror.py`, `openfast_mirror_matrix.py` | sweep the comparison over pitch, wind speed, tip-speed ratio, tilt, precone and the wake and unsteady-aerodynamics models | +| `sed_canary.py` | the SimplifiedElastoDyn brake check | +| `run_guards.sh` | proves the `MirrorRotor` guard rails fire, and fire only for a mirrored rotor | +| `make_adsk_lateral_table.py` | derives an AeroDisk coefficient table carrying lateral coefficients, since the shipped 5MW table has them identically zero | +| `bts_io.py`, `mirror_bts.py`, `check_mirror_bts.py` | reflect a TurbSim box in `y` and verify the result. `bts_io.py` also documents the `.bts` format | + +## Reading output files + +`compare_mirror.main()` reads **text** `.out` files. For `.outb`, import +`classify` and use `reg_tests/lib/pass_fail.readFASTOut` directly, as the other +scripts do. + +`.outb` written with `OutFileFmt = 2` packs each channel into int16, giving +about `1/65536`, or `1.5e-5`, relative resolution. **Do not claim agreement +tighter than that from such a file.** For precision work set `OutFileFmt = 4`. +Text output is worse, not better, at the default `OutFmt`. + +## Other checks + +| Script | What it does | +|---|---| +| `check_ed_outparams.py`, `check_bd_vs_ed.py` | cross-check output parameter tables against the source | +| `check_outlist_dupes.py`, `dump_outlist_xlsx.py` | check `OutListParameters.xlsx` against the registered channels | +| `make_rtest_cases.py`, `rtest_regression.py`, `openfast_regression.py`, `fix_dvr_comment.py` | case generation and bulk regression helpers | diff --git a/reg_tests/otherTests/bts_io.py b/reg_tests/otherTests/bts_io.py new file mode 100644 index 0000000000..16cc7a9bf7 --- /dev/null +++ b/reg_tests/otherTests/bts_io.py @@ -0,0 +1,263 @@ +""" +Reader/writer for TurbSim binary full-field (.bts) files, matching the format +implemented in OpenFAST: + - Writer: modules/turbsim/src/TS_FileIO.f90 (SUBROUTINE WrBinTURBSIM) + - Reader: modules/inflowwind/src/InflowWind_IO.f90 (SUBROUTINE IfW_TurbSim_Init) + +Header layout (stream/unformatted, little-endian, 70 bytes total): + int16 FileID (7 = non-periodic, 8 = periodic) + int32 NZGrids + int32 NYGrids + int32 NTGrids (number of tower points, may be 0) + int32 NSteps + float32 dz + float32 dy + float32 dt + float32 MeanWS (mws, hub-height mean wind speed) + float32 RefHeight (hub height) + float32 GridBase (height of bottom of grid) + float32 VslopeX, VoffsetX (U-component scale/offset) + float32 VslopeY, VoffsetY (V-component scale/offset) + float32 VslopeZ, VoffsetZ (W-component scale/offset) + int32 DescLen (number of ASCII bytes in description string) + +Followed by: + DescLen bytes -> ASCII description string (no null terminator) + +Then, for each of NSteps time steps: + int16 grid data, shape (NZGrids, NYGrids, 3) in C order + -> for iz in 0..NZGrids-1: for iy in 0..NYGrids-1: for ic in (U,V,W) + -> IY varies fastest, then IZ; component (U,V,W) is fastest of all + if NTGrids > 0: + int16 tower data, shape (NTGrids, 3) in C order + +Grid Y-index convention (confirmed from InflowWind_IO.f90, IfW_FlowField.f90): + Y_grid_index (1-based) = (Y_position + YHWid) / dy + 1 + => iy = 0 (0-based) corresponds to Y = -YHWid (most negative Y) + => iy = NYGrids-1 corresponds to Y = +YHWid (most positive Y) + i.e. increasing iy <=> increasing Y coordinate. + +Wind components are ordered (U, V, W) = (longitudinal-X, lateral-Y, vertical-Z). +Tower points all lie on the Y=0 centerline (X,Y=0 column, various Z below grid base). +""" + +import struct +from dataclasses import dataclass +from typing import Optional + +import numpy as np + +HEADER_STRUCT = struct.Struct(" np.ndarray: + """Return de-normalized (physical, m/s) grid velocities, shape (NSteps, NZGrids, NYGrids, 3), float32.""" + out = np.empty(self.grid.shape, dtype=np.float32) + for ic in range(3): + out[..., ic] = (self.grid[..., ic].astype(np.float32) - self.Voffset[ic]) / self.Vslope[ic] + return out + + def decode_tower(self) -> Optional[np.ndarray]: + if self.tower is None: + return None + out = np.empty(self.tower.shape, dtype=np.float32) + for ic in range(3): + out[..., ic] = (self.tower[..., ic].astype(np.float32) - self.Voffset[ic]) / self.Vslope[ic] + return out + + +def read_bts(path: str) -> BTSFile: + with open(path, "rb") as f: + raw_header = f.read(HEADER_SIZE) + if len(raw_header) != HEADER_SIZE: + raise IOError(f"Could not read {HEADER_SIZE}-byte header from {path}") + vals = HEADER_STRUCT.unpack(raw_header) + h = dict(zip(_HEADER_FIELDS, vals)) + + desc_bytes = f.read(h["DescLen"]) + if len(desc_bytes) != h["DescLen"]: + raise IOError(f"Could not read {h['DescLen']}-byte description string from {path}") + desc_str = desc_bytes.decode("ascii", errors="replace") + + n_grid_vals_per_step = 3 * h["NYGrids"] * h["NZGrids"] + n_twr_vals_per_step = 3 * h["NTGrids"] + + grid = np.empty((h["NSteps"], h["NZGrids"], h["NYGrids"], 3), dtype=np.int16) + tower = None + if h["NTGrids"] > 0: + tower = np.empty((h["NSteps"], h["NTGrids"], 3), dtype=np.int16) + + for it in range(h["NSteps"]): + buf = f.read(2 * n_grid_vals_per_step) + if len(buf) != 2 * n_grid_vals_per_step: + raise IOError(f"Truncated grid data at time step {it} in {path}") + grid[it] = np.frombuffer(buf, dtype=" 0: + buf = f.read(2 * n_twr_vals_per_step) + if len(buf) != 2 * n_twr_vals_per_step: + raise IOError(f"Truncated tower data at time step {it} in {path}") + tower[it] = np.frombuffer(buf, dtype=" None: + desc_bytes = bts.DescStr.encode("ascii") + desc_len = len(desc_bytes) + + header_vals = [ + bts.FileID, + bts.NZGrids, bts.NYGrids, bts.NTGrids, bts.NSteps, + bts.dz, bts.dy, bts.dt, + bts.MeanWS, bts.RefHeight, bts.GridBase, + bts.VslopeX, bts.VoffsetX, + bts.VslopeY, bts.VoffsetY, + bts.VslopeZ, bts.VoffsetZ, + desc_len, + ] + + with open(path, "wb") as f: + f.write(HEADER_STRUCT.pack(*header_vals)) + f.write(desc_bytes) + + for it in range(bts.NSteps): + f.write(np.ascontiguousarray(bts.grid[it], dtype=" 0: + f.write(np.ascontiguousarray(bts.tower[it], dtype=" np.ndarray: + """Encode physical (m/s) values back to clamped, rounded int16 raw values.""" + raw = np.round(v_phys * slope + offset) + raw = np.clip(raw, -32768, 32767) + return raw.astype(np.int16) + + +def mirror_xz(bts: BTSFile, note: Optional[str] = None) -> BTSFile: + """Return a new BTSFile mirrored across the XZ plane (Y -> -Y). + + - Reverses the Y-index ordering of the grid data (grid point at +Y swaps + with the corresponding point at -Y). + - Flips the sign of the V (lateral, Y) velocity component for both the + grid and tower data (physical velocity, decoded/re-encoded using the + file's own slope/offset so the int16 raw values remain internally + consistent). + - Tower points lie on the Y=0 centerline, so only the V-component sign + flip applies there (no reordering needed). + + Note on V scaling: the original file's VslopeY/VoffsetY were calibrated + (by TurbSim) to the min/max of the *original* (generally asymmetric) V + data. After negating V, those extremes land on the opposite side of the + range and would clip against the original int16 scaling. So VslopeY and + VoffsetY are recomputed here from the mirrored V data's own min/max, + following TurbSim's own scaling formula (WrBinTURBSIM in TS_FileIO.f90). + U and W values are unchanged by mirroring (only their Y location moves), + so their slope/offset are left untouched. + """ + IntMin, IntMax = -32768.0, 32767.0 + IntRng = IntMax - IntMin + + grid_phys = bts.decode_grid() + grid_phys = grid_phys[:, :, ::-1, :] # reverse Y index + grid_phys[..., 1] = -grid_phys[..., 1] # flip V component + + twr_phys = None + if bts.tower is not None: + twr_phys = bts.decode_tower() + twr_phys[..., 1] = -twr_phys[..., 1] + + # Recompute V-component slope/offset from the mirrored data's min/max + # (grid + tower combined), matching TurbSim's own scaling algorithm. + v_vals = [grid_phys[..., 1]] + if twr_phys is not None: + v_vals.append(twr_phys[..., 1]) + v_min = min(float(v.min()) for v in v_vals) + v_max = max(float(v.max()) for v in v_vals) + if v_max == v_min: + new_VslopeY = 1.0 + else: + new_VslopeY = IntRng / (v_max - v_min) + new_VoffsetY = IntMin - new_VslopeY * v_min + + new_slope = (bts.VslopeX, new_VslopeY, bts.VslopeZ) + new_offset = (bts.VoffsetX, new_VoffsetY, bts.VoffsetZ) + + new_grid = np.empty_like(bts.grid) + for ic in range(3): + new_grid[..., ic] = _renormalize(grid_phys[..., ic], new_slope[ic], new_offset[ic]) + + new_tower = None + if twr_phys is not None: + new_tower = np.empty_like(bts.tower) + for ic in range(3): + new_tower[..., ic] = _renormalize(twr_phys[..., ic], new_slope[ic], new_offset[ic]) + + new_desc = bts.DescStr + if note: + new_desc = (new_desc + " " + note) if new_desc else note + + return BTSFile( + FileID=bts.FileID, + NZGrids=bts.NZGrids, NYGrids=bts.NYGrids, NTGrids=bts.NTGrids, NSteps=bts.NSteps, + dz=bts.dz, dy=bts.dy, dt=bts.dt, + MeanWS=bts.MeanWS, RefHeight=bts.RefHeight, GridBase=bts.GridBase, + VslopeX=bts.VslopeX, VoffsetX=bts.VoffsetX, + VslopeY=new_VslopeY, VoffsetY=new_VoffsetY, + VslopeZ=bts.VslopeZ, VoffsetZ=bts.VoffsetZ, + DescStr=new_desc, + grid=new_grid, + tower=new_tower, + ) diff --git a/reg_tests/otherTests/check_bd_vs_ed.py b/reg_tests/otherTests/check_bd_vs_ed.py new file mode 100644 index 0000000000..673d389794 --- /dev/null +++ b/reg_tests/otherTests/check_bd_vs_ed.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Phase 2 acceptance: BeamDyn and ElastoDyn must disagree no more when mirrored. + +BeamDyn and ElastoDyn are different blade models, so their results differ even for +a clockwise rotor. What matters is that mirroring does not make them disagree any +more than they already do: if the mirrored discrepancy matches the clockwise one, +the mirror is not introducing error of its own. + +Usage: check_bd_vs_ed.py +""" +import os +import sys + +import numpy as np + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +REPO = REPO_ROOT +RTEST = f"{REPO}/reg_tests/r-test/glue-codes/openfast" +sys.path.insert(0, f"{REPO}/reg_tests/lib") +import fast_io # noqa: E402 + +DIAG = {"Time", "ConvError", "ConvIter", "NumUJac"} + + +def read(case): + data, info, _ = fast_io.load_output(os.path.join(RTEST, case, f"{case}.outb")) + return info["attribute_names"], np.asarray(data) + + +def discrepancy(ed_case, bd_case): + """Per-channel relative difference between the two blade models.""" + n1, a = read(ed_case) + n2, b = read(bd_case) + i1 = {k: v for v, k in enumerate(n1)} + i2 = {k: v for v, k in enumerate(n2)} + shared = [c for c in n1 if c in i2 and c not in DIAG] + + # BeamDyn runs at a finer time step, so put it on the ElastoDyn time base. + t1, t2 = a[:, i1["Time"]], b[:, i2["Time"]] + keep = t1 >= 0.5 * t1[-1] + scale = np.abs(a[keep][:, 1:]).max() + + out = {} + for c in shared: + x = a[keep, i1[c]] + y = np.interp(t1[keep], t2, b[:, i2[c]]) + den = max(np.abs(x).max(), 1e-12) + if den < 1e-9 * scale: + continue + out[c] = np.abs(y - x).max() / den + return out + + +def main(): + cw = discrepancy("5MW_Land_noDLL_Steady_CW", "5MW_Land_BD_noDLL_Steady_CW") + mir = discrepancy("5MW_Land_noDLL_Steady_MirrorRotor", + "5MW_Land_BD_noDLL_Steady_MirrorRotor") + + shared = sorted(set(cw) & set(mir)) + print(f"{'channel':14s} {'CW BD-vs-ED':>13s} {'MIR BD-vs-ED':>13s} {'ratio':>8s}") + worst = 0.0 + worst_ch = "" + for c in shared: + # A mirrored channel that flips sign is compared on magnitude, so take the + # smaller of the two orientations. + r = mir[c] / max(cw[c], 1e-12) + if cw[c] > 1e-6 and r > worst: + worst, worst_ch = r, c + print(f"{c:14s} {cw[c]:13.4e} {mir[c]:13.4e} {r:8.3f}") + + print(f"\nchannels compared: {len(shared)}") + print(f"worst ratio: {worst:.3f} on {worst_ch}") + # The mirrored discrepancy should track the clockwise one. Allow a factor of two + # for the two runs drifting differently within an already-different model. + if worst > 2.0: + print("FAIL: mirroring widens the BeamDyn/ElastoDyn gap") + sys.exit(1) + print("PASS: mirroring does not widen the BeamDyn/ElastoDyn gap") + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/check_ed_outparams.py b/reg_tests/otherTests/check_ed_outparams.py new file mode 100644 index 0000000000..b20ed1189d --- /dev/null +++ b/reg_tests/otherTests/check_ed_outparams.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Check the ElastoDyn output-parameter tables stay consistent. + +ValidParamAry, ParamIndxAry and ParamUnitsAry are three parallel Fortran array +literals indexed by position. Nothing in the build checks they line up, so a +misalignment silently reports the wrong channel. This parses all three straight +out of the source and verifies: + + - all three have the declared length, and the same length as each other + - ValidParamAry is sorted alphabetically (SetOutParam binary-searches it) + - no duplicate channel names + - every ParamIndxAry symbol is a declared AllOuts index within MaxOutPts + +With channel names as arguments it also prints what each currently maps to, +which is the safe way to retarget an alias. + +Usage: check_ed_outparams.py [CHANNEL ...] +""" +import os +import re +import sys + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +ED = os.path.join(REPO_ROOT, "modules", "elastodyn", "src", "ElastoDyn.f90") +IO = os.path.join(REPO_ROOT, "modules", "elastodyn", "src", "ElastoDyn_IO.f90") + + +def grab_array(text, name): + """Return (declared_length, [entries]) for a Fortran array literal.""" + m = re.search(rf"{name}\((\d+)\)\s*=\s*\(/", text) + if not m: + sys.exit(f"could not find {name}") + declared = int(m.group(1)) + start = m.end() + end = text.index("/)", start) + body = text[start:end] + # Strip Fortran trailing comments and continuations before splitting, or the + # comment on the declaration line gets parsed as the first entry. + body = "\n".join(l.split("!")[0] for l in body.split("\n")) + body = re.sub(r"character\(ChanLen\)\s*::", "", body) + body = body.replace("&", " ") + items = [t.strip() for t in body.split(",")] + items = [t for t in items if t] + items = [t[1:-1].strip() if t.startswith('"') else t for t in items] + return declared, items + + +def main(): + ed = open(ED).read() + io = open(IO).read() + + indices = dict(re.findall( + r"INTEGER\(IntKi\),\s*PARAMETER\s*::\s*(\w+)\s*=\s*(\d+)", io)) + indices = {k: int(v) for k, v in indices.items()} + maxout = indices.get("MaxOutPts") + + n_names, names = grab_array(ed, "ValidParamAry") + n_indx, indx = grab_array(ed, "ParamIndxAry") + n_unit, units = grab_array(ed, "ParamUnitsAry") + + bad = 0 + print(f"MaxOutPts = {maxout}") + for label, declared, got in (("ValidParamAry", n_names, len(names)), + ("ParamIndxAry", n_indx, len(indx)), + ("ParamUnitsAry", n_unit, len(units))): + ok = declared == got + bad += not ok + print(f"{label:15s} declared {declared:5d} parsed {got:5d} " + f"{'ok' if ok else 'MISMATCH'}") + + if not (len(names) == len(indx) == len(units)): + print("ARRAYS ARE NOT THE SAME LENGTH - they are indexed in parallel") + bad += 1 + + unsorted = [(a, b) for a, b in zip(names, names[1:]) if a.strip() > b.strip()] + if unsorted: + bad += 1 + print(f"NOT ALPHABETICAL ({len(unsorted)}): first at {unsorted[0]}") + else: + print("ValidParamAry alphabetical ok") + + seen, dupes = set(), [] + for nm in names: + s = nm.strip() + if s in seen: + dupes.append(s) + seen.add(s) + if dupes: + bad += 1 + print(f"DUPLICATE NAMES: {dupes}") + else: + print("ValidParamAry no duplicates ok") + + unknown = sorted({s for s in indx if s not in indices}) + if unknown: + bad += 1 + print(f"UNKNOWN INDEX SYMBOLS: {unknown[:10]}") + else: + over = sorted({s for s in indx if indices[s] > maxout}) + if over: + bad += 1 + print(f"INDEX SYMBOLS BEYOND MaxOutPts: {over}") + else: + print("ParamIndxAry all symbols known and within MaxOutPts ok") + + wanted = [a.upper() for a in sys.argv[1:]] + if wanted: + print("\nrequested channels:") + lookup = {nm.strip(): i for i, nm in enumerate(names)} + for w in wanted: + if w not in lookup: + print(f" {w:12s} NOT FOUND") + continue + i = lookup[w] + sym = indx[i] + print(f" {w:12s} pos {i:5d} -> {sym:12s} " + f"(AllOuts {indices.get(sym, '?')}) units {units[i].strip()}") + + print("\nOK" if not bad else f"\n{bad} problem(s)") + sys.exit(1 if bad else 0) + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/check_mirror_bts.py b/reg_tests/otherTests/check_mirror_bts.py new file mode 100644 index 0000000000..c58c16e646 --- /dev/null +++ b/reg_tests/otherTests/check_mirror_bts.py @@ -0,0 +1,53 @@ +"""Check that the mirrored .bts really is the y-reflection of the original. + +Reads both boxes back through the same reader and requires u and w to be the +reverse of each other along y, and v to be the reverse and sign-flipped. Reading +the written file back matters: the format stores int16 with a per-component +scale and offset, so the round trip is where a mistake would show up. +""" +import os +import sys + +import numpy as np + +import bts_io + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +BASE = os.path.join(REPO_ROOT, "reg_tests", "r-test", "glue-codes", + "openfast", "5MW_Baseline", "Wind") +orig = bts_io.read_bts(f"{BASE}/90m_12mps_twr.bts") +mir = bts_io.read_bts(f"{BASE}/90m_12mps_twr_MirrorY.bts") + +for name in ("FileID", "NYGrids", "NZGrids", "NTGrids", "NSteps", + "dy", "dz", "dt", "MeanWS", "RefHeight", "GridBase"): + a, b = getattr(orig, name), getattr(mir, name) + if a != b: + sys.exit(f"{name} differs: {a} vs {b}") + +o = orig.decode_grid() # (NSteps, NZGrids, NYGrids, 3) +m = mir.decode_grid() +print(f"grid {o.shape} (time, z, y, component)") + +sign = (1.0, -1.0, 1.0) +worst = 0.0 +for c, s in enumerate(sign): + expect = s * o[:, :, ::-1, c] + err = np.abs(m[:, :, :, c] - expect).max() / max(np.abs(o[..., c]).max(), 1e-12) + worst = max(worst, err) + print(f" component {'uvw'[c]}: max rel error {err:.3e}") + +# The tower line sits at y = 0, so it maps onto itself and only v changes sign. +ot, mt = orig.decode_tower(), mir.decode_tower() +if ot is not None: + for c, s in enumerate(sign): + err = np.abs(mt[..., c] - s * ot[..., c]).max() / max(np.abs(ot[..., c]).max(), 1e-12) + worst = max(worst, err) + print(f" tower {'uvw'[c]}: max rel error {err:.3e}") + +# One int16 quantum is 1/32768 of the stored range, and the two files are quantised +# independently, so a couple of quanta is the floor. +ok = worst < 1e-3 +print("PASS" if ok else "FAIL", f"worst={worst:.3e}") +sys.exit(0 if ok else 1) diff --git a/reg_tests/otherTests/check_outlist_dupes.py b/reg_tests/otherTests/check_outlist_dupes.py new file mode 100644 index 0000000000..c882ca5044 --- /dev/null +++ b/reg_tests/otherTests/check_outlist_dupes.py @@ -0,0 +1,35 @@ +"""Check no output channel name appears twice in a tab. + +A name listed both as a primary and as somebody else's alias is exactly the +condition the alias split exists to remove: the workbook would claim two +channels are the same while the code gives them opposite signs. +""" +import collections +import subprocess +import sys + +TABS = ["ElastoDyn", "SimpleElastoDyn"] + +for tab in TABS: + out = subprocess.run([sys.executable, "dump_outlist_xlsx.py", tab], + capture_output=True, text=True).stdout + seen = collections.defaultdict(list) + for line in out.split("\n"): + if not line.strip(): + continue + row, _, rest = line.strip().partition(" ") + for field in rest.split("|"): + field = field.strip() + if field.startswith("B:"): + seen[field[2:].strip()].append(row) + elif field.startswith("C:"): + for name in field[2:].split(","): + if name.strip(): + seen[name.strip()].append(row) + + dupes = {k: v for k, v in seen.items() if len(v) > 1 and k not in ("Name", "Other Name(s)")} + print(f"{tab}: {len(seen)} names") + for name, rows in sorted(dupes.items()): + print(f" DUPLICATE {name!r} in rows {', '.join(rows)}") + if not dupes: + print(" no duplicates") diff --git a/reg_tests/otherTests/check_rtest_mirror_pair.py b/reg_tests/otherTests/check_rtest_mirror_pair.py new file mode 100644 index 0000000000..33c373b25c --- /dev/null +++ b/reg_tests/otherTests/check_rtest_mirror_pair.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Verify the committed r-test mirror pair actually mirrors. + +Compares the two stored baselines against each other, so the property the pair +exists to demonstrate is checked at the same time the baselines are produced. + +Usage: check_rtest_mirror_pair.py +""" +import os +import sys + +import numpy as np + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +REPO = REPO_ROOT +RTEST = f"{REPO}/reg_tests/r-test/glue-codes/openfast" +# The r-test baselines are .outb, which packs each channel into int16 with a per-channel +# scale and offset. That quantises to about 1.5e-5 of the channel range, so comparing the +# pair any tighter than this measures the file format rather than the physics. +TOL = 1e-4 + +# (clockwise case, mirrored case, tolerance, start of comparison window) +# The AWT case brings the shaft to a halt under the HSS brake. The stop is a genuine +# discontinuity -- the brake torque switches sign as the shaft sticks -- and it amplifies +# round-off, so that pair needs a looser tolerance than the steady cases. Its window opens +# earlier as well, to cover the period when the generator is still connected. +PAIRS = [("5MW_Land_noDLL_Steady_CW", "5MW_Land_noDLL_Steady_MirrorRotor", TOL, 0.5), + ("5MW_Land_BD_noDLL_Steady_CW", "5MW_Land_BD_noDLL_Steady_MirrorRotor", TOL, 0.5), + ("AWT_WSt_StartUp_HighSpShutDown", + "AWT_WSt_StartUp_HighSpShutDown_MirrorRotor", 5e-4, 0.25), + # Turbulent inflow with ROSCO in the loop, against a y-reflected turbulence box. + # The ElastoDyn pair holds the same tolerance as the steady cases. The BeamDyn + # one needs a little more, and only for the root torsional moment, which is small + # and stiff enough that its own peak is a harsh denominator. + ("5MW_Land_DLL_WTurb", "5MW_Land_DLL_WTurb_MirrorRotor", TOL, 0.25), + ("5MW_Land_BD_DLL_WTurb", "5MW_Land_BD_DLL_WTurb_MirrorRotor", 1e-3, 0.25)] + +# Channels that pair up by layout rather than by channel name. The OC4 semi's mooring +# line 2 lies on the mirror plane, so lines 1 and 3 swap -- unlike the blades, whose +# numbering now follows the rotation sense directly, so each pairs with the same number. +SWAP = {"5MW_OC4Semi_WSt_WavesWN_MirrorRotor": + {"FAIRTEN1": "FAIRTEN3", "FAIRTEN3": "FAIRTEN1", + "ANCHTEN1": "ANCHTEN3", "ANCHTEN3": "ANCHTEN1"}} + +# Unlike the other pairs, the clockwise half here is an upstream baseline produced by a +# different build, so this comparison carries a build difference on top of the mirror. +# Measured: re-running the clockwise case with the current build differs from its stored +# baseline by a median of 1.3e-4 and a maximum of 2.6e-3, and those figures reappear +# channel-for-channel in the pair residual to four significant figures. Compared within a +# single build the pair resolves completely, so the tolerance is set by provenance, not by +# the mirror. Discrimination is still a factor of ~1000. +PAIRS.append(("5MW_OC4Semi_WSt_WavesWN", "5MW_OC4Semi_WSt_WavesWN_MirrorRotor", 5e-3, 0.25)) + +sys.path.insert(0, f"{REPO}/reg_tests/lib") +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import fast_io # noqa: E402 +from compare_mirror import classify # noqa: E402 + +DIAG = {"Time", "ConvError", "ConvIter", "NumUJac"} + +# The r-test baselines are .outb, which packs each channel into int16 with a per-channel +# scale and offset. That quantises to about 1.5e-5 of the channel range, so comparing the +# pair any tighter than this measures the file format rather than the physics. +TOL = 1e-4 + + +def read(case): + path = os.path.join(RTEST, case, f"{case}.outb") + data, info, _ = fast_io.load_output(path) + return info["attribute_names"], np.asarray(data) + + +def check_sweep_order(name, names, data, sl): + """Blades must pass a fixed azimuth in numeric order for either rotation sense. + + The AD BAzimuth channels are in the rotor's own convention, so the spacing from + blade 1 reads +120 degrees (three blades) for a clockwise and a mirrored rotor + alike once the blades are numbered in the direction of rotation. Under the old + convention a mirrored rotor read 240 degrees here, which is what this assertion + exists to catch: the renumbering is nearly invisible to the channel comparison + on an axisymmetric case, so it needs its own check. + + Returns the number of failed spacings, or None when the case does not output + the azimuth channels. + """ + idx = {k: v for v, k in enumerate(names)} + if "B1Azimuth" not in idx or "B2Azimuth" not in idx: + return None + nb = 3 if "B3Azimuth" in idx else 2 + bad = 0 + for k in range(2, nb + 1): + want = 360.0 * (k - 1) / nb + d = np.median(np.mod(data[sl, idx[f"B{k}Azimuth"]] + - data[sl, idx["B1Azimuth"]], 360.0)) + ok = abs(d - want) < 1.0 + print(f" sweep order {name}: B{k}Azimuth - B1Azimuth = {d:7.3f} deg " + f"(want {want:.0f}) {'ok' if ok else 'WRONG ORDER'}") + if not ok: + bad += 1 + return bad + + +def check_pair(cw, mir, tol, start): + n1, a = read(cw) + n2, b = read(mir) + if n1 != n2: + sys.exit("channel lists differ between the two cases") + idx = {k: v for v, k in enumerate(n1)} + sl = slice(int(start * len(a)), None) + phys = [idx[c] for c in n1 if c not in DIAG] + floor = 1e-9 * np.abs(a[sl][:, phys]).max() + + groups = {} + for ch in n1: + if ch in DIAG: + continue + mate = SWAP.get(mir, {}).get(ch) or ch + if mate not in idx: + mate = ch + got, rel = classify(a[sl, idx[ch]], b[sl, idx[mate]], tol, floor) + groups.setdefault(got, []).append(ch) + + for k in ("S", "F", "A", "negligible", "?"): + if groups.get(k): + print(f"{k:11s} ({len(groups[k]):3d}): {' '.join(groups[k])}") + sweep_bad = 0 + for name, arr in ((cw, a), (mir, b)): + r = check_sweep_order(name, n1, arr, sl) + if r: + sweep_bad += r + + bad = groups.get("?", []) + print(f" {len(n1) - len(DIAG & set(n1))} channels compared") + if bad: + print(f" FAIL: {len(bad)} channel(s) unresolved: {' '.join(bad)}") + return 1 + if sweep_bad: + print(f" FAIL: blade sweep order wrong in {sweep_bad} spacing(s)") + return 1 + print(" PASS: clean mirror pair") + return 0 + + +def main(): + bad = 0 + for cw, mir, tol, start in PAIRS: + print(f"=== {cw} vs {mir} ===") + bad += check_pair(cw, mir, tol, start) + sys.exit(1 if bad else 0) + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/check_vtk_surface_mirror.py b/reg_tests/otherTests/check_vtk_surface_mirror.py new file mode 100644 index 0000000000..0e174232b8 --- /dev/null +++ b/reg_tests/otherTests/check_vtk_surface_mirror.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Check that the VTK blade surfaces mirror. + +The blade surface written for ``VTK_type = 1`` is built from the airfoil +coordinate files, not from the mesh nodes, and it is the one part of the +mirrored geometry that no output channel and no node comparison can police. A +mirrored rotor whose nodes are all in the right place can still be drawn with +its aerofoils the wrong way round, which is what happened: the coordinates were +carried across without ``RotDir`` and the surfaces sat 2.33 m from where they +belonged, while every channel and every node position agreed exactly. + +The vertices are placed by ``MeshWrVTK_Ln2Surface`` as +``matmul(xyz, Orientation)``, so ``AirfoilCoords`` component 1 rides row 1 of the +node's direction cosine matrix and component 2 rides row 2. Under ``R' = S R S`` +the rows do not transform alike -- row 1 becomes ``S*row1`` and row 2 becomes +``-S*row2`` -- so the coordinates need ``RotDir`` on component 2 alone for the +two negations to cancel. See ``AD_SetInitOut`` in ``modules/aerodyn/src/AeroDyn.f90``. + +Three configurations are checked, because the surface is drawn three different +ways and each carries the sign separately: + +``coords`` + The airfoil files supply coordinates, and ``AD_SetInitOut`` builds the + section. This is the normal case. +``nocoords`` + ``NumCoords = 0``, so ``BladeShape`` is never allocated and the glue code + synthesises a generic S809 section instead. The same fallback is reached + when the airfoil files merely disagree on how many coordinates they have, + which the code itself calls an overly restrictive check. +``noaero`` + ``CompAero = 0``, so the blades come from BeamDyn and the fallback draws a + rectangle. Nearly symmetric, so nearly immune -- but only nearly: the + closing vertex of the rectangle sits at the midpoint of one long edge and + its mirror lies on the opposite edge, which displaces one vertex per node. + +Run it directly, or through ``run_guards.sh``:: + + python3 check_vtk_surface_mirror.py + python3 check_vtk_surface_mirror.py --only coords + python3 check_vtk_surface_mirror.py --keep # leave the runs in place + +It clones the registered pair `5MW_Land_BD_DLL_WTurb` and +`5MW_Land_BD_DLL_WTurb_MirrorRotor` from the build tree, so the DISCON library +resolves, shortens them, turns on surface output and compares. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tempfile + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from render_vtk_pair import read_vtp, S # noqa: E402 + + +CW = "5MW_Land_BD_DLL_WTurb" +MR = "5MW_Land_BD_DLL_WTurb_MirrorRotor" + +# Blade spacing follows the rotation sense, so each blade pairs with the +# same-numbered blade. +PAIRS = {"1": "1", "2": "2", "3": "3"} + +# The ASCII .vtp files carry five decimal places, so agreement cannot be +# demonstrated below 1e-5 m no matter how exact the arithmetic is. +TOL = 2.0e-5 + +# Each draws the surface a different way, and each carries the sign separately. +MODES = ("coords", "nocoords", "noaero") + + +def repo_root(): + here = os.path.dirname(os.path.abspath(__file__)) + return os.environ.get("OPENFAST_REPO", os.path.abspath(os.path.join(here, "..", ".."))) + + +def strip_coords(work, airfoil_src, aero_deck): + """Copy the airfoil files with NumCoords switched off and point the deck at them. + + Setting NumCoords to 0 is what a user does when they have no profile geometry + to hand, and it sends the surface down the generic-shape fallback. + """ + dst = os.path.join(work, "Airfoils_nocoords") + os.makedirs(dst, exist_ok=True) + for fn in os.listdir(airfoil_src): + if not fn.endswith(".dat"): + continue + with open(os.path.join(airfoil_src, fn)) as fh: + text = fh.read() + text = re.sub(r'^@"[^"]*"(\s+NumCoords)', r"0\1", text, flags=re.M) + with open(os.path.join(dst, fn), "w") as fh: + fh.write(text) + with open(aero_deck) as fh: + text = fh.read() + text = text.replace('"../5MW_Baseline/Airfoils/', '"../Airfoils_nocoords/') + with open(aero_deck, "w") as fh: + fh.write(text) + + +def stage(repo, work, tmax, mode): + """Clone both cases from the staged build tree and turn on surface VTK.""" + built = os.path.join(repo, "build-docker-double", "reg_tests", "glue-codes", "openfast") + baseline = os.path.join(built, "5MW_Baseline") + if not os.path.isdir(baseline): + raise SystemExit(f"no staged 5MW_Baseline in {built}; run ctest once first") + # The decks reach for ../5MW_Baseline, and the mirrored deck also reaches for + # ../5MW_Land_BD_DLL_WTurb, so both must sit beside each other. + os.symlink(baseline, os.path.join(work, "5MW_Baseline")) + + for case in (CW, MR): + src = os.path.join(built, case) + if not os.path.isdir(src): + raise SystemExit(f"case not staged: {src}; run ctest -R {case} once first") + dst = os.path.join(work, case) + shutil.copytree(src, dst) + shutil.rmtree(os.path.join(dst, "vtk"), ignore_errors=True) + + fst = os.path.join(dst, case + ".fst") + with open(fst) as fh: + text = fh.read() + text = re.sub(r"^(\s*)\d+(\s+WrVTK\s)", r"\g<1>2\g<2>", text, flags=re.M) + text = re.sub(r"^(\s*)\d+(\s+VTK_type\s)", r"\g<1>1\g<2>", text, flags=re.M) + text = re.sub(r"^(\s*)[\d.]+(\s+TMax\s)", rf"\g<1>{tmax}\g<2>", text, flags=re.M) + if mode == "noaero": + # No AeroDyn at all, so the blades come from BeamDyn and the fallback + # draws a rectangle. ServoDyn goes too, since its controller expects + # aerodynamic torque. + text = re.sub(r"^(\s*)\d+(\s+CompAero\s)", r"\g<1>0\g<2>", text, flags=re.M) + text = re.sub(r"^(\s*)\d+(\s+CompServo\s)", r"\g<1>0\g<2>", text, flags=re.M) + with open(fst, "w") as fh: + fh.write(text) + + if mode == "nocoords": + strip_coords(work, + os.path.join(baseline, "Airfoils"), + os.path.join(work, CW, "NRELOffshrBsline5MW_Onshore_AeroDyn.dat")) + + +def run(repo, work, case): + exe = os.path.join(repo, "build-docker-double", "glue-codes", "openfast", "openfast") + if not os.path.isfile(exe): + raise SystemExit(f"no openfast binary at {exe}") + d = os.path.join(work, case) + with open(os.path.join(d, "run.log"), "w") as log: + rc = subprocess.run([exe, case + ".fst"], cwd=d, stdout=log, stderr=log).returncode + if rc != 0: + raise SystemExit(f"{case} exited {rc}; see {d}/run.log") + + +def surfaces(work, case, blade): + """Every frame of one blade's surface, in file order.""" + vtk = os.path.join(work, case, "vtk") + pat = re.compile(rf"^{re.escape(case)}\.Blade_R1B{blade}Surface\.(\d+)\.vtp$") + found = [] + for fn in sorted(os.listdir(vtk)): + m = pat.match(fn) + if m: + found.append((int(m.group(1)), os.path.join(vtk, fn))) + return [p for _, p in sorted(found)] + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--tmax", type=float, default=2.0, help="run length (s), default 2") + ap.add_argument("--tol", type=float, default=TOL, help=f"tolerance (m), default {TOL}") + ap.add_argument("--keep", action="store_true", help="keep the run directories") + ap.add_argument("--only", choices=MODES, help="check just one configuration") + args = ap.parse_args(argv) + + repo = repo_root() + modes = (args.only,) if args.only else MODES + ok = True + + for mode in modes: + work = tempfile.mkdtemp(prefix=f"vtk_surface_{mode}_") + try: + stage(repo, work, args.tmax, mode) + for case in (CW, MR): + run(repo, work, case) + + for i, j in PAIRS.items(): + a_files = surfaces(work, CW, i) + b_files = surfaces(work, MR, j) + if not a_files: + print(f"FAIL {mode:9s} blade {i}: no surface files written") + ok = False + continue + if len(a_files) != len(b_files): + print(f"FAIL {mode:9s} blade {i}->{j}: " + f"{len(a_files)} frames vs {len(b_files)}") + ok = False + continue + + worst = 0.0 + bad = False + for pa, pb in zip(a_files, b_files): + a = read_vtp(pa)["points"] @ S.T + b = read_vtp(pb)["points"] + if a.shape != b.shape: + print(f"FAIL {mode:9s} blade {i}->{j}: vertex counts differ, " + f"{a.shape[0]} vs {b.shape[0]}") + ok = False + bad = True + break + worst = max(worst, float(np.abs(a - b).max())) + if bad: + continue + good = worst <= args.tol + ok &= good + print(f"{'PASS' if good else 'FAIL'} {mode:9s} blade {i}->{j} " + f"{len(a_files)} frames {a_files and len(read_vtp(a_files[0])['points'])} verts " + f"max separation {worst:.3e} m (tol {args.tol:.1e})") + finally: + if args.keep: + print(f" runs kept in {work}") + else: + shutil.rmtree(work, ignore_errors=True) + + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/reg_tests/otherTests/compare_mirror.py b/reg_tests/otherTests/compare_mirror.py new file mode 100644 index 0000000000..5fe5dabb2a --- /dev/null +++ b/reg_tests/otherTests/compare_mirror.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Compare a CW baseline run against a mirrored run. + +Every channel must resolve to one of: identical, exactly sign-flipped, a mirrored +angle, or below the file's numerical noise floor. Anything else is reported as +unclassified, which is what a frame-mixing bug looks like. + +Blade spacing follows the rotation direction, so mirrored blade k compares +directly against clockwise blade k: no channel remapping is required. + +Usage: compare_mirror.py [--tol 1e-6] [--all] +""" +import re +import sys + +import numpy as np + +# Expected symmetry class per channel. +# S = identical, F = exactly sign-flipped, A = mirrored angle (mod 360) +EXPECT = { + "RtTSR": "S", "RtArea": "S", "RtSkew": "S", + "RtAeroFxh": "S", "RtAeroFzh": "S", "RtAeroMyh": "S", + "RtAeroPwr": "S", "RtAeroCp": "S", "RtAeroCt": "S", + "RtVAvgxh": "S", "RtVAvgzh": "S", + "RtAeroFyh": "F", "RtAeroMxh": "F", "RtAeroMzh": "F", + "RtAeroCq": "F", "RtVAvgyh": "F", + "Azimuth": "S", "RotSpeed": "S", +} +for _b in range(1, 4): + for _n in range(1, 10): + for _q in ("Alpha", "Phi", "Vrel", "Cl", "Cd", "Cx", "Cn", "Vindx", + "AxInd", "TnInd", "Fn", "Fl", "Fd", "M", "Gam", "Theta", + "Curve", "Vdisx", "Vundx"): + EXPECT[f"B{_b}N{_n}{_q}"] = "S" + for _q in ("Cy", "Ct", "Cm", "Ft", "Mm", "Vindy", "Toe", + "Vdisy", "Vundy"): + EXPECT[f"B{_b}N{_n}{_q}"] = "F" + +# Reported but not asserted. Under the old blade numbering the per-blade azimuth's +# mirror reference depended on blade index and rotor tilt, so B*Azimuth sat here. +# With the blades numbered in the direction of rotation it measures as identical +# (class S) across every registered pair -- 2026-09-30, emit_sign_table at +# tol 5e-3 -- so it is asserted like any other channel and this list is empty. +INFORMATIONAL = [] + + +def load(path): + lines = open(path, errors="replace").read().split("\n") + h = next(i for i, l in enumerate(lines) if l.strip().startswith("Time")) + names = lines[h].split() + rows = [[float(x) for x in l.split()] for l in lines[h + 2:] if l.strip()] + return names, np.array(rows) + + +def classify(x, y, tol, floor): + """Return (class, residual) for one channel pair.""" + peak = np.abs(x).max() + if peak < floor: + return "negligible", 0.0 + den = max(peak, 1e-12) + rs = np.abs(y - x).max() / den + if rs < tol: + return "S", rs + rf = np.abs(y + x).max() / den + if rf < tol: + return "F", rf + # Wrapped angle. Scaled by the same denominator as the tests above, otherwise a + # 1/360 normalisation makes this far more lenient and it shadows a genuine flip. + # Only meaningful for a quantity actually measured in degrees: the residual can + # never exceed 180, so for a large channel this test passes on magnitude alone + # and will happily label a mooring tension a mirrored angle. + if den <= 400.0: + ra = np.abs(((y + x + 180.0) % 360.0) - 180.0).max() / den + if ra < tol: + return "A", ra + return "?", min(rs, rf) + + +def compare(cw_path, mir_path, tol=1e-6, only=None): + """Return (nchecked, mismatches, unclassified, observed).""" + n1, a = load(cw_path) + n2, b = load(mir_path) + if n1 != n2: + raise ValueError("channel lists differ between the two files") + idx = {k: v for v, k in enumerate(n1)} + sl = slice(int(0.75 * len(a)), None) + + # Channels that are zero by symmetry still carry cancellation error; they must + # not be judged against their own magnitude. Keep this well below the smallest + # channel that carries real signal. + floor = 1e-9 * np.abs(a[sl, 1:]).max() + + checked, bad, unknown, observed = 0, [], [], {} + for ch in n1: + if ch == "Time": + continue + if only is not None and not any(re.search(p, ch) for p in only): + continue + got, rel = classify(a[sl, idx[ch]], b[sl, idx[ch]], tol, floor) + observed[ch] = got + if any(re.search(p, ch) for p in INFORMATIONAL): + continue + if got == "?": + unknown.append((ch, rel)) + exp = EXPECT.get(ch) + if exp is not None and got != "negligible": + checked += 1 + if got != exp: + bad.append((ch, exp, got, rel)) + return checked, bad, unknown, observed + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("--")] + tol = float(sys.argv[sys.argv.index("--tol") + 1]) if "--tol" in sys.argv else 1e-6 + checked, bad, unknown, observed = compare(args[0], args[1], tol) + + if "--all" in sys.argv: + for ch, cls in observed.items(): + print(f" {ch:14s} {cls}") + + print(f"channels checked : {checked}") + for ch, rel in unknown: + print(f" UNCLASSIFIED {ch:14s} rel={rel:.3e}") + for ch, exp, got, rel in bad: + print(f" MISMATCH {ch:14s} expected {exp}, got {got} rel={rel:.3e}") + if bad or unknown: + sys.exit(1) + print("ALL PASS - every channel is SAME, FLIP, mirrored angle, or negligible") + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/dump_outlist_xlsx.py b/reg_tests/otherTests/dump_outlist_xlsx.py new file mode 100644 index 0000000000..fda8b7d611 --- /dev/null +++ b/reg_tests/otherTests/dump_outlist_xlsx.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Dump rows from OutListParameters.xlsx without needing openpyxl. + +An xlsx is a zip of XML, so the sheet can be read directly. Used to give exact +row numbers for the OutListParameters edits. + +Usage: dump_outlist_xlsx.py [CHANNEL ...] + dump_outlist_xlsx.py --tabs +""" +import os +import re +import sys +import zipfile +from xml.etree import ElementTree as ET + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +XLSX = os.path.join(REPO_ROOT, "docs", "OtherSupporting", "OutListParameters.xlsx") +NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}" +RNS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" + + +def load(): + z = zipfile.ZipFile(XLSX) + strings = [] + if "xl/sharedStrings.xml" in z.namelist(): + root = ET.fromstring(z.read("xl/sharedStrings.xml")) + for si in root.findall(f"{NS}si"): + strings.append("".join(t.text or "" for t in si.iter(f"{NS}t"))) + wb = ET.fromstring(z.read("xl/workbook.xml")) + rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels")) + target = {r.get("Id"): r.get("Target") for r in rels} + sheets = {} + for sh in wb.find(f"{NS}sheets"): + rid = sh.get(f"{RNS}id") + path = target[rid] + if not path.startswith("xl/"): + path = "xl/" + path.lstrip("/") + sheets[sh.get("name")] = path + return z, strings, sheets + + +def cells(z, strings, path): + root = ET.fromstring(z.read(path)) + out = {} + for row in root.iter(f"{NS}row"): + r = int(row.get("r")) + vals = {} + for c in row.findall(f"{NS}c"): + ref = c.get("r") + col = re.match(r"([A-Z]+)", ref).group(1) + v = c.find(f"{NS}v") + isel = c.find(f"{NS}is") + if c.get("t") == "s" and v is not None: + vals[col] = strings[int(v.text)] + elif isel is not None: + vals[col] = "".join(t.text or "" for t in isel.iter(f"{NS}t")) + elif v is not None: + vals[col] = v.text + if vals: + out[r] = vals + return out + + +def main(): + z, strings, sheets = load() + if "--tabs" in sys.argv: + for name in sheets: + print(name) + return + + tab = sys.argv[1] + if tab not in sheets: + sys.exit(f"tab {tab!r} not found; have: {', '.join(sheets)}") + rows = cells(z, strings, sheets[tab]) + wanted = [a.upper() for a in sys.argv[2:]] + + for r in sorted(rows): + vals = rows[r] + joined = " | ".join(f"{k}:{v}" for k, v in sorted(vals.items())) + if not wanted: + print(f"{r:5d} {joined[:200]}") + continue + for w in wanted: + if any(str(v).strip().upper() == w for v in vals.values()): + print(f"{r:5d} {joined[:220]}") + break + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/emit_sign_table.py b/reg_tests/otherTests/emit_sign_table.py new file mode 100644 index 0000000000..08d28dfef4 --- /dev/null +++ b/reg_tests/otherTests/emit_sign_table.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Measure the observed mirror sign of every output channel across all registered pairs. + +The sign table in mirror_rotor.rst claims to be measured rather than asserted, so +it should be generated rather than edited by hand. This reads each clockwise / +mirrored pair, classifies every channel, and reports the aggregate. A channel seen +in more than one pair must agree across them; disagreements are reported rather +than silently resolved. +""" +import argparse +import os +import re +import sys +from collections import Counter, defaultdict + +import numpy as np + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO_ROOT, "reg_tests", "lib")) +from compare_mirror import classify # noqa: E402 +import pass_fail # noqa: E402 + +R = os.path.join(REPO_ROOT, "reg_tests", "r-test") +GC = f"{R}/glue-codes/openfast" + +# label, clockwise case, mirrored case +FILE_PAIRS = [ + ("ElastoDyn + AeroDyn", "5MW_Land_noDLL_Steady_CW", + "5MW_Land_noDLL_Steady_MirrorRotor"), + ("BeamDyn blades", "5MW_Land_BD_noDLL_Steady_CW", + "5MW_Land_BD_noDLL_Steady_MirrorRotor"), + ("MHK buoyancy", "MHK_RM1_Floating_Steady_CW", + "MHK_RM1_Floating_Steady_MirrorRotor"), + ("AeroDisk, yawed", "5MW_Land_ADsk_SED_Yaw_CW", + "5MW_Land_ADsk_SED_Yaw_MirrorRotor"), + ("OLAF free wake", + f"{R}/modules/aerodyn/ad_B1n2_OLAF_CW/ad_driver.outb", + f"{R}/modules/aerodyn/ad_B1n2_OLAF_MirrorRotor/ad_driver.outb"), +] + +# label, single output file, per-rotor channel prefixes +WITHIN_PAIRS = [ + ("AeroDyn nodal outputs", + os.path.join(REPO_ROOT, "build-docker-double", "reg_tests", "modules", + "aerodyn", "ad_MultipleHAWT_MirrorRotor", "ad_driver.T1.outb"), + os.path.join(REPO_ROOT, "build-docker-double", "reg_tests", "modules", + "aerodyn", "ad_MultipleHAWT_MirrorRotor", "ad_driver.T2.outb"), + None), + ("Twin-rotor semisubmersible", + f"{GC}/5MW_MRSemi_DLL_WSt_WavesIrr_MirrorRotor/" + "5MW_MRSemi_DLL_WSt_WavesIrr_MirrorRotor.outb", + None, ("R1", "R2")), +] + + +# Mooring lines and connections exchange in mirror pairs as well, but the pairing +# depends on the layout. Reported separately rather than guessed at. +MOORING = re.compile(r"^(FAIRTEN|ANCHTEN|CON\d|L\d+N|M\d+N|P\d+F)") + + +def measure(a, na, b, nb, tol, floor): + out = {} + idx = {n: i for i, n in enumerate(nb)} + for i, n in enumerate(na): + j = idx.get(n) + if j is None: + continue + cls, res = classify(a[:, i], b[:, j], tol, floor) + out[n] = (cls, res) + return out + + + +# The behaviour codes, in the order they are presented everywhere else. +BEHAVIOUR = ( + ("S", "identical", "v' = v"), + ("F", "sign-flipped", "v' = -v"), + ("A", "mirrored angle", "v' = -v, wrapped"), + ("negligible", "below the noise floor", "indistinguishable from zero everywhere"), + ("?", "unresolved", "measured but not classified"), +) + + +def emit_map(groups, mooring, origin, conflicts, tol, floor): + """Write the per-channel sign map into docs/source. + + One artefact from one measurement: a YAML file for anything that wants to + consume the map as data. It is generated -- never hand-edited, because the + map is measured on every run and a hand edit would be silently overwritten + or, worse, silently disagree with what the code actually does. The rendered + per-channel page was retired on 2026-09-30; a grouped summary lives in + mirror_rotor.rst and the full listing travels with the pull request text. + """ + docs = os.path.join(REPO_ROOT, "docs", "source", "user", "glue-code") + labels = {k: lbl for k, lbl, _ in BEHAVIOUR} + + rows = [] + for k, lbl, _ in BEHAVIOUR: + for n in sorted(groups.get(k, [])): + rows.append((n, k, lbl, sorted(origin[n]))) + for n in sorted(mooring): + rows.append((n, "mooring", "set aside, layout-specific", + sorted(origin[n]))) + rows.sort(key=lambda r: r[0].upper()) + + yml = os.path.join(docs, "mirror_rotor_sign_map.yaml") + with open(yml, "w") as fh: + fh.write("# Measured mirror sign map -- GENERATED, do not edit by hand.\n") + fh.write("# Regenerate with:\n") + fh.write("# python3 reg_tests/otherTests/emit_sign_table.py " + f"--tol {tol} --emit-map\n") + fh.write("#\n") + fh.write("# Every entry is observed by running a registered clockwise/mirrored\n") + fh.write("# pair and comparing the channel against its counterpart. Nothing here\n") + fh.write("# is inferred from reading the source.\n") + fh.write(f"tolerance: {tol}\n") + fh.write(f"noise_floor: {floor}\n") + fh.write("behaviours:\n") + for k, lbl, meaning in BEHAVIOUR: + # "?" is a YAML indicator character; quote the key so every parser + # reads it as the scalar it is meant to be. + key = f'"{k}"' if k == "?" else k + fh.write(f' {key}: {{name: "{lbl}", meaning: "{meaning}"}}\n') + fh.write(' mooring: {name: "set aside", ' + 'meaning: "pairing is layout-specific"}\n') + fh.write("channels:\n") + for n, k, lbl, src in rows: + fh.write(f' {n}: {{behaviour: {k}, measured_in: [{", ".join(src)}]}}\n') + + print(f"\nwrote {yml}") + print(f" {len(rows)} channels") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--tol", type=float, default=2e-3) + ap.add_argument("--floor", type=float, default=1e-8) + ap.add_argument("--show-unresolved", action="store_true") + ap.add_argument("--emit-map", action="store_true", + help="write the per-channel sign map into docs/source as YAML " + "and as a reStructuredText table") + a = ap.parse_args() + + seen = defaultdict(set) + origin = defaultdict(set) + + for label, cw, mir in FILE_PAIRS: + if cw.startswith("/") or "/" in cw: + pa, pb = cw, mir # explicit paths, for the module-level driver cases + else: + pa, pb = f"{GC}/{cw}/{cw}.outb", f"{GC}/{mir}/{mir}.outb" + if not (os.path.exists(pa) and os.path.exists(pb)): + print(f" skip {label}: missing baseline", file=sys.stderr) + continue + da, ia, _ = pass_fail.readFASTOut(pa) + db, ib, _ = pass_fail.readFASTOut(pb) + res = measure(da, list(ia["attribute_names"]), db, + list(ib["attribute_names"]), a.tol, a.floor) + c = Counter(v[0] for v in res.values()) + print(f"{label:28s} same={c['S']:4d} flipped={c['F']:3d} " + f"wrapped={c['A']:3d} negligible={c['negligible']:3d} " + f"unresolved={c['?']:3d}") + for n, (cl, _) in res.items(): + seen[n].add(cl) + origin[n].add(label) + + for label, pa, pb, prefixes in WITHIN_PAIRS: + if not os.path.exists(pa): + print(f" skip {label}: missing output", file=sys.stderr) + continue + da, ia, _ = pass_fail.readFASTOut(pa) + if pb: + db, ib, _ = pass_fail.readFASTOut(pb) + na, nbn = list(ia["attribute_names"]), list(ib["attribute_names"]) + else: + db, ib = da, ia + na = nbn = list(ia["attribute_names"]) + if prefixes: + p1, p2 = prefixes + na2 = [n for n in na if n.startswith(p1)] + res = {} + idx = {n: i for i, n in enumerate(nbn)} + for n in na2: + base = n[len(p1):] + j = idx.get(p2 + base) + if j is None: + continue + cls, r = classify(da[:, na.index(n)], db[:, j], a.tol, a.floor) + res[base] = (cls, r) + else: + res = measure(da, na, db, nbn, a.tol, a.floor) + c = Counter(v[0] for v in res.values()) + print(f"{label:28s} same={c['S']:4d} flipped={c['F']:3d} " + f"wrapped={c['A']:3d} negligible={c['negligible']:3d} " + f"unresolved={c['?']:3d}") + for n, (cl, _) in res.items(): + seen[n].add(cl) + origin[n].add(label) + + # A channel measured in several pairs must agree; 'negligible' carries no + # information, so it never contradicts a real observation. + groups = defaultdict(list) + conflicts = [] + mooring = [] + for n, classes in sorted(seen.items()): + if MOORING.match(n): + mooring.append(n) + continue + real = classes - {"negligible", "?"} + if len(real) > 1: + conflicts.append((n, sorted(real), sorted(origin[n]))) + elif len(real) == 1: + groups[real.pop()].append(n) + elif "?" in classes: + groups["?"].append(n) + else: + groups["negligible"].append(n) + + print("\n=== channel counts by observed behaviour") + for k, lbl in (("S", "identical"), ("F", "sign-flipped"), + ("A", "mirrored angle"), ("negligible", "always ~zero"), + ("?", "unresolved")): + print(f" {lbl:16s} {len(groups[k])}") + print(f" mooring, set aside {len(mooring)}") + + if conflicts: + print("\n=== CONFLICTS (same channel, different behaviour between pairs)") + for n, cl, src in conflicts: + print(f" {n:20s} {cl} seen in {src}") + + if a.show_unresolved and groups["?"]: + print("\n=== unresolved") + print(" " + ", ".join(groups["?"])) + + for k, lbl in (("S", "IDENTICAL"), ("F", "SIGN-FLIPPED"), + ("A", "MIRRORED ANGLE")): + print(f"\n=== {lbl}") + print(" " + ", ".join(groups[k])) + + if a.emit_map: + emit_map(groups, mooring, origin, conflicts, a.tol, a.floor) + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/fix_dvr_comment.py b/reg_tests/otherTests/fix_dvr_comment.py new file mode 100644 index 0000000000..287b2612f3 --- /dev/null +++ b/reg_tests/otherTests/fix_dvr_comment.py @@ -0,0 +1,52 @@ +"""Fix the BasicHAWTFormat comment, and add the missing MirrorRotor input. + +The comment claimed the basic inputs were the next 7 lines. There are 8, from +BaseOriginInit to Twr2Shft, and MirrorRotor now sits between the flag and them, +so a positional description is doubly wrong. Describe the range by name instead. + +The two example files under docs/ never gained the MirrorRotor input that the +driver now requires, so anyone copying one would have written a file the driver +rejects. +""" +import os +import re +import pathlib + +ROOT = pathlib.Path(os.environ.get("OPENFAST_REPO") or + pathlib.Path(__file__).resolve().parents[2]) + +OLD = "True: next 7 lines are basic inputs, False:" +NEW = "True: basic HAWT inputs BaseOriginInit to Twr2Shft, False:" + +MIRROR = ("False MirrorRotor({n}) - Flag indicating the rotor rotation " + "direction is mirrored (counter-clockwise viewed from upwind)") + +EXAMPLES = [ROOT / "docs/source/user/aerodyn/examples/ad_driver_example.dvr", + ROOT / "docs/source/user/aerodyn/examples/ad_driver_multiple.dvr"] + +added = 0 +for path in EXAMPLES: + lines = path.read_text().split("\n") + out = [] + for line in lines: + out.append(line) + m = re.match(r"\s*\S+\s+BasicHAWTFormat\((\d+)\)", line) + if m: + out.append(MIRROR.format(n=m.group(1))) + added += 1 + path.write_text("\n".join(out)) +print(f"added {added} MirrorRotor lines") + +fixed = 0 +for path in ROOT.rglob("*"): + if not path.is_file() or "/build" in str(path): + continue + if path.suffix not in (".dvr", ".rst"): + continue + text = path.read_text(errors="replace") + if OLD not in text: + continue + path.write_text(text.replace(OLD, NEW)) + fixed += 1 + print(f" {path.relative_to(ROOT)}") +print(f"reworded {fixed} files") diff --git a/reg_tests/otherTests/make_adsk_lateral_table.py b/reg_tests/otherTests/make_adsk_lateral_table.py new file mode 100644 index 0000000000..a8632b1cd4 --- /dev/null +++ b/reg_tests/otherTests/make_adsk_lateral_table.py @@ -0,0 +1,33 @@ +"""Derive an AeroDisk coefficient table that carries lateral coefficients. + +The shipped 5MW table has C_Fy, C_Fz, C_My and C_Mz identically zero, so the +mirror-related sign factors on Force(2), Force(3), Moment(2) and Moment(3) - +and the z_hat pseudovector flip that multiplies them - never execute. This +scales the lateral columns off the axial ones so every component is non-zero +while keeping the TSR and pitch dependence of the original. + +The ratios are arbitrary but fixed; the table is a regression fixture, not a +physical rotor. +""" +import io, sys + +RATIO = {6: -0.08, 7: 0.05, 9: 0.06, 10: -0.04} # col idx -> factor +SRC_COL = {6: 5, 7: 5, 9: 8, 10: 8} # lateral col -> driving col + +def main(src, dst): + out = [] + for n, line in enumerate(io.open(src, encoding="utf-8")): + s = line.rstrip("\n") + if n < 3 or not s.strip() or s.lstrip().startswith("#"): + out.append(s); continue + f = [c.strip() for c in s.split(",")] + if len(f) < 11: + out.append(s); continue + for col, fac in RATIO.items(): + f[col] = "%10.4f" % (fac * float(f[SRC_COL[col]])) + out.append(", ".join("%10s" % c for c in f)) + io.open(dst, "w", encoding="utf-8").write("\n".join(out) + "\n") + print("wrote", dst, len(out), "lines") + +if __name__ == "__main__": + main(sys.argv[1], sys.argv[2]) diff --git a/reg_tests/otherTests/make_rtest_cases.py b/reg_tests/otherTests/make_rtest_cases.py new file mode 100644 index 0000000000..d65e31c451 --- /dev/null +++ b/reg_tests/otherTests/make_rtest_cases.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Generate the Phase 1 mirrored-rotor regression cases in r-test. + +Creates 5MW_Land_noDLL_Steady_CW and 5MW_Land_noDLL_Steady_MirrorRotor from the +5MW land baseline. The pair is identical apart from the MirrorRotor flag, which +is the whole point: the mirrored deck describes the same clockwise turbine. + +Case design, and why each choice is there: + CompServo = 0 ServoDyn is still guard-railed for mirrored rotors + GenDOF = True free drivetrain, so shaft torque actually matters + ModCoupling = 1 SignLSSTrq is only reached from the loose-coupling + integrators, so tight coupling cannot exercise the + gearbox efficiency direction at all + GBoxEff = 95 the baseline 100% makes both branches of the efficiency + factor identical and hides errors in that direction test + ShftTilt = -5 inherited from the baseline; gives azimuthal variation so + the blades are not interchangeable + steady 8 m/s, TMax 20 s, flexible blades and tower + +Usage: make_rtest_cases.py [--write] +""" +import os +import re +import shutil +import sys + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +RTEST = os.path.join(REPO_ROOT, "reg_tests", "r-test", "glue-codes", "openfast") +ED_SRC = os.path.join(RTEST, "5MW_Land_DLL_WTurb") +BD_SRC = os.path.join(RTEST, "5MW_Land_BD_DLL_WTurb") + +# name -> (mirrored, beamdyn) +CASES = {"5MW_Land_noDLL_Steady_CW": (False, False), + "5MW_Land_noDLL_Steady_MirrorRotor": (True, False), + "5MW_Land_BD_noDLL_Steady_CW": (False, True), + "5MW_Land_BD_noDLL_Steady_MirrorRotor": (True, True)} + +OUTLIST = """"RotSpeed" +"RotAccel" +"Azimuth" +"GenSpeed" +"GenAccel" +"LSSTipVxa" +"LSSTipAxa" +"LSSTipPxa" +"LSShftMxa" +"LSSGagMxa" +"RotTorq" +"LSShftTq" +"HSShftTq" +"HSShftPwr" +"RotPwr" +"RotThrust" +"LSShftFxa" +"LSShftFya" +"LSShftFza" +"LSSTipMya" +"LSSTipMza" +"YawBrFxp" +"YawBrFyp" +"YawBrFzp" +"YawBrMxp" +"YawBrMyp" +"YawBrMzp" +"OoPDefl1" +"IPDefl1" +"TipDxc1" +"TipDyc1" +"RootFxc1" +"RootFyc1" +"RootMxc1" +"RootMyc1" +"RootMzc1" +"OoPDefl2" +"IPDefl2" +"RootMxc2" +"RootMyc2" +"OoPDefl3" +"IPDefl3" +"RootMxc3" +"RootMyc3" +"TwrBsFxt" +"TwrBsFyt" +"TwrBsMxt" +"TwrBsMyt" +"TwrBsMzt" +""" + +AD_OUTLIST = """"RtAeroFxh" +"RtAeroMxh" +"RtAeroPwr" +"RtTSR" +"RtSkew" +"B1N1Alpha" +"B1N2Alpha" +"B1N3Alpha" +"B1N1Theta" +"B1N2Theta" +"B1N3Theta" +"B1N1Fn" +"B1N2Fn" +"B1N3Fn" +"B1N1Ft" +"B1N2Ft" +"B1N3Ft" +""" + + +def set_var(text, var, value): + pat = re.compile(r"^(\s*)(\S+)(\s+" + re.escape(var) + r"\s)", re.MULTILINE) + new, n = pat.subn(lambda m: f"{m.group(1)}{value}{m.group(3)}", text, count=1) + if n != 1: + raise KeyError(f"could not set {var!r}") + return new + + +def swap_outlist(text, entries, marker="OutList"): + head, _, rest = text.partition(marker) + _, _, tail = rest.partition("END of OutList section") + return head + marker + " - Output channels\n" + entries + "END of OutList section" + tail + + +def build(case, mirrored, beamdyn, write): + src = BD_SRC if beamdyn else ED_SRC + dst = os.path.join(RTEST, case) + if write: + # Only the generated files are replaced. Hand-written README.md files and the + # committed .outb baselines are left alone, so re-running this does not quietly + # delete them. + os.makedirs(dst, exist_ok=True) + + ed_name = ("NRELOffshrBsline5MW_Onshore_ElastoDyn_BDoutputs.dat" if beamdyn + else "NRELOffshrBsline5MW_Onshore_ElastoDyn.dat") + ed = open(os.path.join(src, ed_name)).read() + ed = set_var(ed, "GenDOF", "True") + ed = set_var(ed, "YawDOF", "False") + ed = set_var(ed, "RotSpeed", "9.15") + ed = set_var(ed, "GBoxEff", "95.0") + for k in (1, 2, 3): + ed = set_var(ed, f"BlPitch({k})", "1.0") + ed = swap_outlist(ed, OUTLIST) + + ad = open(os.path.join(src, "NRELOffshrBsline5MW_Onshore_AeroDyn.dat")).read() + ad = set_var(ad, "NBlOuts", "3") + ad = swap_outlist(ad, AD_OUTLIST, "OutList ") + + fst_name = ("5MW_Land_BD_DLL_WTurb.fst" if beamdyn else "5MW_Land_DLL_WTurb.fst") + fst = open(os.path.join(src, fst_name)).read() + fst = set_var(fst, "TMax", "20") + fst = set_var(fst, "Echo", "False") + # BeamDyn is unstable with loose coupling, so only the ElastoDyn pair uses it. That + # costs the BeamDyn pair the gearbox-efficiency path, which the ElastoDyn pair covers. + if not beamdyn: + fst = set_var(fst, "ModCoupling", "1") + fst = set_var(fst, "CompServo", "0") + fst = set_var(fst, "CompInflow", "1") + fst = set_var(fst, "MirrorRotor", "True" if mirrored else "False") + fst = set_var(fst, "EDFile", '"ElastoDyn.dat"') + fst = set_var(fst, "AeroFile", '"AeroDyn.dat"') + fst = set_var(fst, "InflowFile", + '"../5MW_Baseline/NRELOffshrBsline5MW_InflowWind_Steady8mps.dat"') + fst = set_var(fst, "OutFileFmt", "2") + + files = { + "ElastoDyn.dat": ed, + "AeroDyn.dat": ad, + f"{case}.fst": fst, + } + if write: + for name, text in files.items(): + open(os.path.join(dst, name), "w").write(text) + shutil.copy(os.path.join(src, "NRELOffshrBsline5MW_Onshore_ElastoDyn_Tower.dat"), + os.path.join(dst, "ElastoDyn_Tower.dat")) + ed2 = open(os.path.join(dst, "ElastoDyn.dat")).read() + ed2 = set_var(ed2, "TwrFile", '"ElastoDyn_Tower.dat"') + open(os.path.join(dst, "ElastoDyn.dat"), "w").write(ed2) + print(f" {case:40s} MirrorRotor={'True' if mirrored else 'False':5s} " + f"{'BeamDyn' if beamdyn else 'ElastoDyn':9s} " + f"{'written' if write else '(dry run)'}") + + +def main(): + write = "--write" in sys.argv + for case, (mirrored, beamdyn) in CASES.items(): + build(case, mirrored, beamdyn, write) + if not write: + print("\ndry run; pass --write to create the cases") + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/mirror_bts.py b/reg_tests/otherTests/mirror_bts.py new file mode 100644 index 0000000000..219165c4dc --- /dev/null +++ b/reg_tests/otherTests/mirror_bts.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Create a mirror-image (across the XZ plane, i.e. Y -> -Y) copy of a TurbSim +.bts full-field wind file. + +Usage: + python3 mirror_bts.py +""" +import sys + +import bts_io + + +def main(): + if len(sys.argv) != 3: + print(__doc__) + sys.exit(1) + + in_path, out_path = sys.argv[1], sys.argv[2] + + print(f"Reading {in_path} ...") + bts = bts_io.read_bts(in_path) + print(f" FileID={bts.FileID} NYGrids={bts.NYGrids} NZGrids={bts.NZGrids} " + f"NTGrids={bts.NTGrids} NSteps={bts.NSteps} dy={bts.dy} dz={bts.dz} dt={bts.dt}") + print(f" DescStr: {bts.DescStr!r}") + + print("Mirroring across XZ plane (Y -> -Y; reversing Y grid index, flipping V sign)...") + mirrored = bts_io.mirror_xz(bts, note="Mirrored across XZ plane (Y -> -Y; V-component sign flipped) for comparison testing.") + + print(f"Writing {out_path} ...") + bts_io.write_bts(out_path, mirrored) + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/mr_two_rotor.py b/reg_tests/otherTests/mr_two_rotor.py new file mode 100644 index 0000000000..0e5e2aa1cc --- /dev/null +++ b/reg_tests/otherTests/mr_two_rotor.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Compare rotor 1 against rotor 2 inside one multi-rotor OpenFAST run. + +The glue code prefixes per-rotor channels with R1/R2. When rotor 1 is clockwise +and rotor 2 is mirrored, and the platform, substructure, mooring and sea are all +symmetric about y = 0, the whole system maps onto itself under y -> -y, so R2 +must be the mirror image of R1 within the same solve. + +Blade spacing follows the rotation sense, so a mirrored blade compares directly +against the clockwise blade of the same number. + +Shared (unprefixed) channels belong to the single platform. Under a symmetric +solution the antisymmetric ones must vanish, which is a much sharper statement +than any pairwise comparison, so they are reported separately. +""" +import argparse +import os +import sys +from collections import Counter + +import numpy as np + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +sys.path.insert(0, os.path.join(os.path.dirname(__file__))) +from compare_mirror import classify # noqa: E402 + +sys.path.insert(0, os.path.join(REPO_ROOT, "reg_tests", "lib")) +import pass_fail # noqa: E402 + +# Platform quantities that must be zero in a solution symmetric about y = 0. +ANTISYM_SHARED = ("HydroFyi", "HydroMxi", "HydroMzi", + "RBTDYss", "RBRDXss", "RBRDZss") +SYM_SHARED = ("HydroFxi", "HydroFzi", "HydroMyi", + "RBTDXss", "RBTDZss", "RBRDYss") + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("outb") + ap.add_argument("--tol", type=float, default=1e-6) + ap.add_argument("--floor", type=float, default=1e-8) + ap.add_argument("--start", type=float, default=0.0) + ap.add_argument("--show", action="store_true", + help="list every channel and its observed class") + a = ap.parse_args() + + data, info, _ = pass_fail.readFASTOut(a.outb) + names = list(info["attribute_names"]) + t = data[:, 0] + k = t >= a.start + col = {n: data[k, i] for i, n in enumerate(names)} + + r1 = {n[2:]: n for n in names if n.startswith("R1")} + r2 = {n[2:]: n for n in names if n.startswith("R2")} + + counts, unresolved, rows = Counter(), [], [] + for base in sorted(r1): + if base not in r2: + counts["missing"] += 1 + continue + cls, res = classify(col[r1[base]], col[r2[base]], a.tol, a.floor) + counts[cls] += 1 + rows.append((base, cls, res)) + if cls == "?": + unresolved.append((base, res, + np.abs(col[r1[base]]).max(), + np.abs(col[r2[base]]).max())) + + label = {"S": "same", "F": "flipped", "A": "angle-wrapped", + "negligible": "negligible", "?": "unresolved"} + print(f"--- rotor 1 vs rotor 2 ({a.outb})") + for key in ("S", "F", "A", "negligible", "?", "missing"): + if counts[key]: + print(f"{label.get(key, key):16s} {counts[key]}") + + if a.show: + for base, cls, res in rows: + print(f" {base:22s} {label.get(cls, cls):14s} {res:.3e}") + + if unresolved: + print("\nunresolved:") + for base, res, p1, p2 in unresolved: + print(f" {base:20s} res={res:.3e} " + f"peakR1={p1:.6g} peakR2={p2:.6g}") + + print("\n--- shared platform channels") + print(" (a solution symmetric about y=0 has zero antisymmetric response)") + for n in ANTISYM_SHARED: + if n in col: + print(f" {n:12s} peak {np.abs(col[n]).max():.6e} expect ~0") + for n in SYM_SHARED: + if n in col: + print(f" {n:12s} peak {np.abs(col[n]).max():.6e} expect nonzero") + + return 1 if unresolved else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/reg_tests/otherTests/openfast_case_mirror.py b/reg_tests/otherTests/openfast_case_mirror.py new file mode 100644 index 0000000000..7b04a754f8 --- /dev/null +++ b/reg_tests/otherTests/openfast_case_mirror.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Clone an r-test OpenFAST case and run it clockwise and mirrored. + +Unlike openfast_mirror.py this changes *nothing* except the MirrorRotor flag, so +it is the right tool once ServoDyn is in the loop: the controller, its DLL and +every input file stay exactly as the clockwise case ships them. + +Usage: openfast_case_mirror.py [--tmax N] [--dump CH1,CH2] [--tol X] +""" +import os +import re +import shutil +import subprocess +import sys + +import numpy as np + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +REPO = REPO_ROOT +RTEST = f"{REPO}/reg_tests/r-test/glue-codes/openfast" +# Cases with a Bladed-style controller reference a DISCON library that is built, not +# stored, so it exists only under the build tree. Prefer that copy of a referenced +# sibling directory when it is there; ctest stages it from the r-test source. +STAGED = f"{REPO}/build-docker-double/reg_tests/glue-codes/openfast" +EXE = f"{REPO}/build-docker-double-debug/glue-codes/openfast/openfast" +WORK = os.path.join(os.environ.get("TMPDIR", "/tmp"), "of_case_mirror") + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, f"{REPO}/reg_tests/lib") +from compare_mirror import classify # noqa: E402 +import fast_io # noqa: E402 + +DIAG = {"Time", "ConvError", "ConvIter", "NumUJac"} + + +def set_var(text, var, value): + pat = re.compile(r"^(\s*)(\S+)(\s+" + re.escape(var) + r"\s)", re.MULTILINE) + new, n = pat.subn(lambda m: f"{m.group(1)}{value}{m.group(3)}", text, count=1) + if n != 1: + raise KeyError(f"could not set {var!r}") + return new + + +def negate_vars(d, names): + """Negate asymmetric scalar inputs in the mirrored clone. + + Quantities such as an initial nacelle yaw or a wind direction are ordinary + asymmetric inputs, not rotor-convention ones, so mirroring the turbine does not + mirror them. A symmetry test has to mirror them by hand. + """ + for f in sorted(os.listdir(d)): + if not f.lower().endswith((".dat", ".fst")): + continue + p = os.path.join(d, f) + txt = open(p, errors="replace").read() + orig = txt + for var in names: + pat = re.compile(r"^(\s*)(-?[\d.]+(?:[eEdD][-+]?\d+)?)(\s+" + re.escape(var) + r"\s)", + re.MULTILINE) + txt = pat.sub(lambda m: f"{m.group(1)}{-float(m.group(2)):g}{m.group(3)}", txt) + if txt != orig: + open(p, "w").write(txt) + + +def mirror_series(d, names): + """Apply the mirror to a prescribed force/moment series (t, FX..MZ). + + Under S = diag(1, -1, 1) the true vector components FY flips, and for the moment + pseudovector MX and MZ flip instead. + """ + sign = [1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0] + for f in names: + p = os.path.join(d, f) + if not os.path.exists(p): + sys.exit(f"no such series file: {f}") + out = [] + for line in open(p, errors="replace").read().split("\n"): + if line.lstrip().startswith(("#", "!")): + out.append(line) + continue + # Rows may carry a trailing inline comment. + cut = min((i for i in (line.find("#"), line.find("!")) if i >= 0), default=len(line)) + body, tail = line[:cut], line[cut:] + parts = body.split() + if len(parts) != len(sign): + out.append(line) + continue + try: + vals = [float(x) for x in parts] + except ValueError: + out.append(line) + continue + out.append(" ".join(f"{s*v:.8E}" for s, v in zip(sign, vals)) + (" " + tail if tail else "")) + open(p, "w").write("\n".join(out)) + + +def build(case, mirrored, tmax, negate=(), series=(), reuse=False): + name = "mir" if mirrored else "cw" + d = os.path.join(WORK, f"{case}_{name}") + if reuse and os.path.exists(os.path.join(d, f"{case}.out")): + return os.path.join(d, f"{case}.out") + os.makedirs(WORK, exist_ok=True) + # Sibling directories are referenced by relative path from the case folder. + for entry in os.listdir(RTEST): + link = os.path.join(WORK, entry) + if not os.path.exists(link): + staged = os.path.join(STAGED, entry) + os.symlink(staged if os.path.isdir(staged) else os.path.join(RTEST, entry), link) + + d = os.path.join(WORK, f"{case}_{name}") + shutil.rmtree(d, ignore_errors=True) + shutil.copytree(os.path.join(RTEST, case), d) + + fst = os.path.join(d, f"{case}.fst") + txt = open(fst).read() + txt = set_var(txt, "MirrorRotor", "True" if mirrored else "False") + txt = set_var(txt, "OutFileFmt", "1") + if tmax is not None: + txt = set_var(txt, "TMax", str(tmax)) + open(fst, "w").write(txt) + + if mirrored and negate: + negate_vars(d, negate) + if mirrored and series: + mirror_series(d, series) + + p = subprocess.run([EXE, f"{case}.fst"], cwd=d, capture_output=True, text=True) + out = os.path.join(d, f"{case}.out") + if not os.path.exists(out): + tail = [l for l in (p.stdout + p.stderr).split("\n") if l.strip()][-10:] + print("\n".join(tail)) + sys.exit(f"{name} run failed") + return out + + +def load(path): + lines = open(path, errors="replace").read().split("\n") + h = next(i for i, l in enumerate(lines) if l.strip().startswith("Time")) + names = lines[h].split() + rows = [[float(x) for x in l.split()] for l in lines[h + 2:] if l.strip()] + return names, np.array(rows) + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("--")] + case = args[0] + tmax = float(sys.argv[sys.argv.index("--tmax") + 1]) if "--tmax" in sys.argv else None + tol = float(sys.argv[sys.argv.index("--tol") + 1]) if "--tol" in sys.argv else 1e-6 + reuse = "--reuse" in sys.argv + negate = sys.argv[sys.argv.index("--negate") + 1].split(",") if "--negate" in sys.argv else () + series = sys.argv[sys.argv.index("--mirror-series") + 1].split(",") if "--mirror-series" in sys.argv else () + + # Mooring lines and other layout components also swap under the mirror, but which + # ones pair up depends on the layout rather than being fixed by channel name -- the + # OC4 semi's mooring line 2 lies on the mirror plane, so lines 1 and 3 swap. Blades + # need no such mapping: blade numbering now follows the rotation sense directly, so + # each pairs with the same number. State the layout pairings explicitly. + swap = {} + if "--swap" in sys.argv: + for pair in sys.argv[sys.argv.index("--swap") + 1].split(","): + a, _, b = pair.partition("=") + swap[a.strip()], swap[b.strip()] = b.strip(), a.strip() + + cw = build(case, False, tmax, negate, series, reuse) + mir = build(case, True, tmax, negate, series, reuse) + n1, a = load(cw) + n2, b = load(mir) + if n1 != n2: + sys.exit("channel lists differ") + idx = {k: v for v, k in enumerate(n1)} + sl = slice(int(0.25 * len(a)), None) + phys = [idx[c] for c in n1 if c not in DIAG] + floor = 1e-9 * np.abs(a[sl][:, phys]).max() + + if "--dump" in sys.argv: + rows = list(range(0, len(a), max(1, len(a) // 14))) + for ch in sys.argv[sys.argv.index("--dump") + 1].split(","): + print(f"\n{ch}:") + print(f"{'t':>8s} {'CW':>14s} {'MIRROR':>14s}") + for r in rows: + print(f"{a[r,0]:8.3f} {a[r,idx[ch]]:14.6g} {b[r,idx[ch]]:14.6g}") + return + + groups = {} + for ch in n1: + if ch in DIAG: + continue + mate = swap.get(ch) or ch + if mate not in idx: + mate = ch + got, rel = classify(a[sl, idx[ch]], b[sl, idx[mate]], tol, floor) + groups.setdefault(got, []).append((ch, rel)) + + for k in ("S", "F", "A", "negligible", "?"): + if groups.get(k): + print(f"{k:11s} ({len(groups[k]):3d}): {' '.join(c for c, _ in groups[k])}") + bad = groups.get("?", []) + if bad: + print(f"\nFAIL: {len(bad)} channel(s) unresolved") + for c, r in bad[:10]: + print(f" {c:14s} rel={r:.3e}") + sys.exit(1) + print("\nPASS: every channel resolves") + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/openfast_mirror.py b/reg_tests/otherTests/openfast_mirror.py new file mode 100644 index 0000000000..42a810369e --- /dev/null +++ b/reg_tests/otherTests/openfast_mirror.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Phase 1.2/1.3: build and run the OpenFAST clockwise/mirrored pair. + +Builds the pair in scratch from the 5MW land baseline so r-test stays clean until +the case is settled. ServoDyn is off because the mirrored ServoDyn interface is +Phase 3 and is still guard-railed, so the rotor runs at a fixed speed and the +comparison isolates the aerodynamic and structural mirror. + +By default this reports the *measured* symmetry of every channel rather than +asserting a table, which is how the sign map in CONVENTIONS is meant to be built. +Pass --check to assert the expectations in ED_EXPECT once they are settled. + +Usage: openfast_mirror.py [--check] [--tmax 20] +""" +import os +import re +import shutil +import subprocess +import sys + +import numpy as np + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +REPO = REPO_ROOT +RTEST = f"{REPO}/reg_tests/r-test/glue-codes/openfast" +SRC = f"{RTEST}/5MW_Land_BD_DLL_WTurb" if "--beamdyn" in sys.argv else f"{RTEST}/5MW_Land_DLL_WTurb" +BASE = f"{RTEST}/5MW_Baseline" +EXE = f"{REPO}/build-docker-double-debug/glue-codes/openfast/openfast" +WORK = os.path.join(os.environ.get("TMPDIR", "/tmp"), "of_mirror") + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from compare_mirror import classify # noqa: E402 + +RIGID = "--rigid" in sys.argv +# Free drivetrain: exercises the gearbox efficiency factor and shaft torque paths, +# which a fixed rotor speed never reaches. +GENDOF = "--gendof" in sys.argv +# Loose coupling is needed to reach SignLSSTrq, but BeamDyn is unstable with it, so it +# is requested separately rather than being implied by a free drivetrain. +LOOSE = "--loose" in sys.argv + +# Vertical shear is symmetric about the mirror plane, so both decks get the same +# exponent. Nacelle yaw is a global-frame orientation rather than a rotor-convention +# input, so the mirrored deck gets the opposite sign. +# BeamDyn blades instead of the ElastoDyn beam model. +BEAMDYN = "--beamdyn" in sys.argv +SHEAR = float(sys.argv[sys.argv.index("--shear") + 1]) if "--shear" in sys.argv else None +YAW = float(sys.argv[sys.argv.index("--yaw") + 1]) if "--yaw" in sys.argv else None +# Free yaw exercises the yaw bearing dynamics and the yaw moment mirror. +YAWDOF = "--yawdof" in sys.argv + +# With every structural DOF locked the BEMT iteration converges to slightly +# different round-off in the two runs; the real configurations hold 1e-6. +TOL = 1e-5 if RIGID else 1e-6 + +# One channel per line: the AeroDyn OutList parser only takes the first quoted +# name on each line and silently drops the rest. +AD_OUTLIST = """"RtAeroFxh" +"RtAeroMxh" +"RtTSR" +"RtSkew" +"B1N1Alpha" +"B1N2Alpha" +"B1N3Alpha" +"B1N1Theta" +"B1N2Theta" +"B1N3Theta" +"B1N1Phi" +"B1N2Phi" +"B1N3Phi" +"B1N1VDisx" +"B1N2VDisx" +"B1N3VDisx" +"B1N1VDisy" +"B1N2VDisy" +"B1N3VDisy" +"B1N1Fn" +"B1N2Fn" +"B1N3Fn" +"B1N1Ft" +"B1N2Ft" +"B1N3Ft" +"B1N1Cl" +"B1N2Cl" +"B1N3Cl" +"B1N1Curve" +"B1N2Curve" +"B1N3Curve" +""" + +OUTLIST = """"RotSpeed" - Rotor speed +"RotAccel" - Rotor acceleration +"Azimuth" - Blade 1 azimuth +"GenSpeed" - Generator speed +"LSShftFxa" - LSS thrust +"LSShftFya" +"LSShftFza" +"LSShftMxa" - LSS torque +"LSSTipVxa" +"LSSTipAxa" +"LSSTipPxa" +"LSShftTq" +"HSShftTq" +"HSShftPwr" +"HSSBrTq" +"LSSGagMxa" +"LSSGagPxa" +"GenAccel" +"LSSTipMya" +"LSSTipMza" +"RotPwr" - Rotor power +"RotThrust" - Rotor thrust +"RotTorq" - Rotor torque +"YawBrFxp" +"YawBrFyp" +"YawBrFzp" +"YawBrMxp" +"YawBrMyp" +"YawBrMzp" +"OoPDefl1" - Blade 1 out-of-plane tip deflection +"IPDefl1" - Blade 1 in-plane tip deflection +"TipDxc1" +"TipDyc1" +"TipDzc1" +"RootFxc1" +"RootFyc1" +"RootFzc1" +"RootMxc1" +"RootMyc1" +"RootMzc1" +"OoPDefl2" +"IPDefl2" +"RootMxc2" +"RootMyc2" +"OoPDefl3" +"IPDefl3" +"RootMxc3" +"RootMyc3" +"TwrBsFxt" +"TwrBsFyt" +"TwrBsMxt" +"TwrBsMyt" +"TwrBsMzt" +""" + + +def set_var(text, var, value): + pat = re.compile(r"^(\s*)(\S+)(\s+" + re.escape(var) + r"\s)", re.MULTILINE) + new, n = pat.subn(lambda m: f"{m.group(1)}{value}{m.group(3)}", text, count=1) + if n != 1: + raise KeyError(f"could not set {var!r}") + return new + + +def build(mirrored, tmax): + name = "mir" if mirrored else "cw" + # The baseline files are referenced as ../5MW_Baseline/..., so stage each case as a + # sibling of a symlink to the real one. + os.makedirs(WORK, exist_ok=True) + link = os.path.join(WORK, "5MW_Baseline") + if not os.path.exists(link): + os.symlink(BASE, link) + d = os.path.join(WORK, name) + shutil.rmtree(d, ignore_errors=True) + os.makedirs(d) + for f in os.listdir(SRC): + if f.endswith(".dat"): + shutil.copy(os.path.join(SRC, f), d) + + # ElastoDyn: fixed rotor speed (no controller), keep the flexible DOFs on so + # the structural side of the mirror is actually exercised. + ed_src = ("NRELOffshrBsline5MW_Onshore_ElastoDyn_BDoutputs.dat" if BEAMDYN + else "NRELOffshrBsline5MW_Onshore_ElastoDyn.dat") + ed = open(os.path.join(d, ed_src)).read() + ed = set_var(ed, "GenDOF", "True" if GENDOF else "False") + if GENDOF: + # The baseline gearbox is 100% efficient, which makes GBoxEffFac 1.0 whichever + # branch is taken and hides any error in the power-flow direction test. + ed = set_var(ed, "GBoxEff", "95.0") + ed = set_var(ed, "YawDOF", "True" if YAWDOF else "False") + ed = set_var(ed, "RotSpeed", "9.15") + if YAW is not None: + ed = set_var(ed, "NacYaw", str(-YAW if mirrored else YAW)) + ed = set_var(ed, "BlPitch(1)", "1.0") + ed = set_var(ed, "BlPitch(2)", "1.0") + ed = set_var(ed, "BlPitch(3)", "1.0") + if RIGID: + # Bisection aid: with every structural DOF off, only the geometry handed to + # AeroDyn can differ between the two runs. + for dof in ("FlapDOF1", "FlapDOF2", "EdgeDOF", "DrTrDOF", + "TwFADOF1", "TwFADOF2", "TwSSDOF1", "TwSSDOF2"): + ed = set_var(ed, dof, "False") + # Swap only the channel list, keeping the optional nodal-outputs section intact. + head, _, rest = ed.partition("OutList") + _, _, tail = rest.partition("END of OutList section") + ed = (head + "OutList - Output channels\n" + OUTLIST + + "END of OutList section" + tail) + open(os.path.join(d, "ElastoDyn.dat"), "w").write(ed) + + ad_name = "NRELOffshrBsline5MW_Onshore_AeroDyn.dat" + ad = open(os.path.join(d, ad_name)).read() + ad = set_var(ad, "NBlOuts", "3") + ad = set_var(ad, "SumPrint", "True") + head, _, rest = ad.partition("OutList ") + _, _, tail = rest.partition("END of OutList section") + ad = (head + "OutList - Output channels\n" + AD_OUTLIST + + "END of OutList section" + tail) + open(os.path.join(d, ad_name), "w").write(ad) + + fst_name = "5MW_Land_BD_DLL_WTurb.fst" if BEAMDYN else "5MW_Land_DLL_WTurb.fst" + fst = open(os.path.join(SRC, fst_name)).read() + fst = set_var(fst, "TMax", str(tmax)) + fst = set_var(fst, "CompServo", "0") + if LOOSE: + # SignLSSTrq, and so the gearbox efficiency direction, is only reached from + # the loose-coupling integrators. + fst = set_var(fst, "ModCoupling", "1") + fst = set_var(fst, "CompInflow", "1") + inflow = f"{BASE}/NRELOffshrBsline5MW_InflowWind_Steady8mps.dat" + if SHEAR is not None: + iw = open(inflow).read() + iw = set_var(iw, "PLExp", str(SHEAR)) + inflow = os.path.join(d, "InflowWind.dat") + open(inflow, "w").write(iw) + inflow = "InflowWind.dat" + fst = set_var(fst, "MirrorRotor", "True" if mirrored else "False") + fst = set_var(fst, "EDFile", '"ElastoDyn.dat"') + fst = set_var(fst, "InflowFile", + f'"{inflow}"') + fst = set_var(fst, "AeroFile", '"NRELOffshrBsline5MW_Onshore_AeroDyn.dat"') + fst = set_var(fst, "OutFileFmt", "1") + fst = set_var(fst, "Echo", "False") + open(os.path.join(d, f"{name}.fst"), "w").write(fst) + + p = subprocess.run([EXE, f"{name}.fst"], cwd=d, capture_output=True, text=True) + out = os.path.join(d, f"{name}.out") + if not os.path.exists(out): + tail = [l for l in (p.stdout + p.stderr).split("\n") if l.strip()][-8:] + print("\n".join(tail)) + sys.exit(f"{name} run failed") + return out + + +def load(path): + lines = open(path, errors="replace").read().split("\n") + h = next(i for i, l in enumerate(lines) if l.strip().startswith("Time")) + names = lines[h].split() + rows = [[float(x) for x in l.split()] for l in lines[h + 2:] if l.strip()] + return names, np.array(rows) + + +def main(): + tmax = 20 + if "--tmax" in sys.argv: + tmax = float(sys.argv[sys.argv.index("--tmax") + 1]) + shutil.rmtree(WORK, ignore_errors=True) + cw, mir = build(False, tmax), build(True, tmax) + n1, a = load(cw) + n2, b = load(mir) + idx = {k: v for v, k in enumerate(n1)} + sl = slice(int(0.5 * len(a)), None) + # Solver diagnostics are not physical and can be orders of magnitude larger than + # any load, which would drag the noise floor up and mask real channels. + DIAG = {"ConvError", "ConvIter", "NumUJac", "Time"} + phys = [idx[c] for c in n1 if c not in DIAG] + floor = 1e-9 * np.abs(a[sl][:, phys]).max() + + groups = {"S": [], "F": [], "A": [], "negligible": [], "?": []} + if "--dump" in sys.argv: + want = sys.argv[sys.argv.index("--dump") + 1].split(",") + rows = list(range(0, len(a), max(1, len(a) // 12))) + for ch in want: + print(f"\n{ch}:") + print(f"{'t':>8s} {'CW':>14s} {'MIRROR':>14s}") + for r in rows: + print(f"{a[r,0]:8.3f} {a[r,idx[ch]]:14.6g} {b[r,idx[ch]]:14.6g}") + return + + print(f"{'channel':14s} {'peak(CW)':>12s} {'class':>10s} {'resid':>10s}") + for ch in n1: + if ch == "Time": + continue + got, rel = classify(a[sl, idx[ch]], b[sl, idx[ch]], TOL, floor) + if ch in DIAG: + got = "diagnostic" + groups.setdefault("diagnostic", []).append(ch) + continue + groups[got].append(ch) + print(f"{ch:14s} {np.abs(a[sl, idx[ch]]).max():12.5g} " + f"{got:>10s} {rel:10.2e}") + + print("\n--- measured summary ---") + for k in ("S", "F", "A", "negligible", "?"): + if groups[k]: + print(f"{k:11s} ({len(groups[k]):2d}): {' '.join(groups[k])}") + if groups["?"]: + print(f"\n{len(groups['?'])} channel(s) neither same, flipped nor mirrored angle") + sys.exit(1) + print("\nEvery channel resolves to same, flipped, mirrored angle, or negligible") + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/openfast_mirror_matrix.py b/reg_tests/otherTests/openfast_mirror_matrix.py new file mode 100644 index 0000000000..d7f3ef75d9 --- /dev/null +++ b/reg_tests/otherTests/openfast_mirror_matrix.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Phase 1.5 acceptance: run the OpenFAST mirror check across the condition matrix. + +Each row exercises a different assumption in the sign map. A single condition +proves very little on its own; the point is that they all have to hold at once. + +Usage: openfast_mirror_matrix.py [name ...] +""" +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +RUNNER = os.path.join(HERE, "openfast_mirror.py") + +MATRIX = [ + ("rigid", ["--rigid"]), + ("base_flexible", []), + ("free_drivetrain", ["--gendof", "--loose"]), + ("shear", ["--shear", "0.2"]), + ("yaw_+15", ["--yaw", "15"]), + ("yaw_-15", ["--yaw", "-15"]), + ("shear_yaw", ["--shear", "0.2", "--yaw", "15"]), + ("free_yaw", ["--yawdof", "--yaw", "15"]), + ("shear_freeyaw_dt", ["--shear", "0.2", "--yaw", "10", "--yawdof", "--gendof", "--loose"]), + # BeamDyn blades. Not run with --loose: BeamDyn needs tight coupling. + ("bd_base", ["--beamdyn", "--tmax", "10"]), + ("bd_shear", ["--beamdyn", "--tmax", "10", "--shear", "0.2"]), + ("bd_yaw", ["--beamdyn", "--tmax", "10", "--yaw", "15"]), + ("bd_free_drivetrain", ["--beamdyn", "--tmax", "10", "--gendof"]), +] + + +def main(): + wanted = sys.argv[1:] + rows = [r for r in MATRIX if not wanted or r[0] in wanted] + fails = 0 + for name, args in rows: + p = subprocess.run([sys.executable, RUNNER] + args, + capture_output=True, text=True) + tail = [l for l in p.stdout.split("\n") if l.strip()] + verdict = tail[-1] if tail else "(no output)" + ok = p.returncode == 0 + fails += not ok + print(f" {name:20s} {'PASS' if ok else 'FAIL'} {verdict[:70]}", flush=True) + if not ok: + for l in tail[-6:]: + print(f" {l[:150]}") + print(f"\n{len(rows) - fails}/{len(rows)} conditions pass") + sys.exit(1 if fails else 0) + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/openfast_regression.py b/reg_tests/otherTests/openfast_regression.py new file mode 100644 index 0000000000..13690a6cef --- /dev/null +++ b/reg_tests/otherTests/openfast_regression.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Run OpenFAST regression cases and compare against their stored baselines. + +Runs each case in a scratch copy of the r-test tree so the committed reference +outputs are never overwritten. + +Usage: openfast_regression.py [ ...] +""" +import os +import shutil +import subprocess +import sys +import tempfile + +import numpy as np + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +REPO = REPO_ROOT +RTEST = f"{REPO}/reg_tests/r-test/glue-codes/openfast" +EXE = f"{REPO}/build-docker-double-debug/glue-codes/openfast/openfast" +sys.path.insert(0, f"{REPO}/reg_tests/lib") +import fast_io # noqa: E402 + +DETAIL = "--detail" in sys.argv + + +def read(path): + data, info, _ = fast_io.load_output(path) + return info["attribute_names"], np.asarray(data) + + +def run_case(case): + src = os.path.join(RTEST, case) + fst = [f for f in os.listdir(src) if f.endswith(".fst")] + if not fst: + return case, "SKIP", "no .fst" + fst = fst[0] + stem = fst[:-4] + ref = None + for cand in (f"{stem}.outb", f"{stem}.out"): + if os.path.exists(os.path.join(src, cand)): + ref = cand + break + if ref is None: + return case, "SKIP", "no reference output" + + with tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR")) as tmp: + for entry in os.listdir(RTEST): + if entry != case: + os.symlink(os.path.join(RTEST, entry), os.path.join(tmp, entry)) + work = os.path.join(tmp, case) + shutil.copytree(src, work) + + p = subprocess.run([EXE, fst], cwd=work, capture_output=True, text=True) + out = os.path.join(work, ref) + if not os.path.exists(out): + tail = [l for l in (p.stdout + p.stderr).split("\n") if l.strip()][-3:] + return case, "RUNFAIL", " | ".join(tail) + + rn, rd = read(os.path.join(src, ref)) + tn, td = read(out) + if rn != tn: + return case, "FAIL", "channel list changed" + if rd.shape != td.shape: + return case, "FAIL", f"shape {rd.shape} vs {td.shape}" + + scale = np.maximum(np.abs(rd).max(axis=0), 1e-12) + col = (np.abs(td - rd) / scale).max(axis=0) + rel = col.max() + if DETAIL and rel > 0.0: + order = np.argsort(col)[::-1] + print(f" {case}: {int((col > 0).sum())} of {len(col)} channels differ") + for k in order[:10]: + if col[k] > 0: + print(f" {tn[k]:16s} rel={col[k]:.3e}") + if rel == 0.0: + return case, "IDENTICAL", "rel=0" + if rel < 1e-10: + return case, "OK", f"rel={rel:.2e}" + return case, "FAIL", f"rel={rel:.2e} worst={tn[int(np.argmax(col))]}" + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("--")] + fails = 0 + for case in args: + name, status, detail = run_case(case) + if status in ("FAIL", "RUNFAIL"): + fails += 1 + print(f" {name:34s} {status:10s} {detail}", flush=True) + print(f"\n{len(args) - fails}/{len(args)} cases match") + sys.exit(1 if fails else 0) + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/render_vtk_pair.py b/reg_tests/otherTests/render_vtk_pair.py new file mode 100644 index 0000000000..fff72f5c1b --- /dev/null +++ b/reg_tests/otherTests/render_vtk_pair.py @@ -0,0 +1,910 @@ +#!/usr/bin/env python3 +"""Render an OpenFAST VTK mesh pair for visual inspection of the mirror. + +Every other check in the MirrorRotor work is numerical, and a tolerance cannot +see a blade drawn on the wrong side, a rotor turning the right way with its +geometry built the wrong way round, or a mesh that mirrors correctly in its own +frame but is attached to the hub backwards. This renders the geometry so that a +human can look at it. + +The container this was written in has no OpenGL -- GLX, EGL and OSMesa are all +absent, so VTK cannot open a render window -- and the VTK python module is +therefore not used at all. The ``.vtp`` files OpenFAST writes are ASCII XML +PolyData, so they are parsed directly and drawn with matplotlib. + +Three figures are produced for a pair of case directories: + +``geometry.png`` + The clockwise and mirrored geometry side by side at one instant, in three + views, on identical axes. Look at these first, and independently: they are + the only output that can show an error the mirror operator itself shares. + +``overlay.png`` + The mirrored geometry with ``y -> -y`` re-applied, drawn over the clockwise + geometry. Where the implementation is right the two coincide. This is much + the easier judgement to make, but it is a numerical test wearing a visual + costume, so it does not replace ``geometry.png``. + +``rotation.png`` + Blade tip paths over the run, viewed looking downwind, coloured by time. + This is what shows the sense of rotation. + +Usage:: + + python3 render_vtk_pair.py --cw --mirror --out + +Each case directory is the one holding the ``vtk/`` subdirectory that OpenFAST +wrote. ``--frame`` picks the instant for the first two figures. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from collections import defaultdict + +import numpy as np + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.collections import LineCollection, PolyCollection +from mpl_toolkits.mplot3d.art3d import Line3DCollection, Poly3DCollection + + +# The mirror operator. See docs/source/user/glue-code/mirror_rotor.rst. +S = np.diag([1.0, -1.0, 1.0]) + + +# --------------------------------------------------------------------------- +# reading + + +def _array(el): + """Text of a DataArray as a flat float array.""" + return np.fromstring(el.text.replace("\n", " "), sep=" ") + + +def _cells(piece, tag): + """(connectivity, offsets) for a Lines or Polys block, or None.""" + block = piece.find(tag) + if block is None: + return None + named = {da.get("Name"): da for da in block.findall("DataArray")} + if "connectivity" not in named or "offsets" not in named: + return None + conn = _array(named["connectivity"]).astype(int) + offs = _array(named["offsets"]).astype(int) + if conn.size == 0: + return None + return conn, offs + + +def _split(conn, offs): + """Split a connectivity array at its offsets into per-cell index arrays.""" + return [conn[a:b] for a, b in zip(np.concatenate(([0], offs[:-1])), offs)] + + +def read_vtp(path): + """Parse one OpenFAST .vtp file. + + Returns a dict with ``points`` (n,3), ``lines`` and ``polys`` (lists of + index arrays) and ``fields`` (name -> (n,3) array) for whatever point data + the file carries. OpenFAST writes orientation and, with VTK_fields, motion + and load fields; all are read the same way. + """ + piece = ET.parse(path).getroot().find(".//Piece") + if piece is None: + raise ValueError(f"{path}: no Piece element") + + pts_el = piece.find("Points/DataArray") + points = _array(pts_el).reshape(-1, 3) + + fields = {} + pd = piece.find("PointData") + if pd is not None: + for da in pd.findall("DataArray"): + name = da.get("Name") + ncomp = int(da.get("NumberOfComponents", "1")) + if name: + fields[name] = _array(da).reshape(-1, ncomp) + + lines = _cells(piece, "Lines") + polys = _cells(piece, "Polys") + return { + "points": points, + "lines": _split(*lines) if lines else [], + "polys": _split(*polys) if polys else [], + "fields": fields, + } + + +# VTK_tWidth scales with the run length, so the frame field is not always four +# digits; a greedy root takes the last dotted number group, which is the frame. +_FRAME_RE = re.compile(r"^(?P .+)\.(?P\d+)\.vtp$") + + +def scan(case_dir): + """Index a case's vtk directory. + + Returns (meshes, frames) where meshes maps a mesh name -- the file root with + the case prefix stripped -- to {frame_number: path}, and frames is the + sorted list of frames present in every mesh. Reference and static files are + indexed under frame None. + """ + vtk_dir = os.path.join(case_dir, "vtk") + if not os.path.isdir(vtk_dir): + raise SystemExit(f"no vtk/ directory in {case_dir}") + + meshes = defaultdict(dict) + for fn in sorted(os.listdir(vtk_dir)): + if not fn.endswith(".vtp"): + continue + path = os.path.join(vtk_dir, fn) + m = _FRAME_RE.match(fn) + if m: + meshes[m.group("root")][int(m.group("frame"))] = path + else: + meshes[fn[: -len(".vtp")]][None] = path + + # Strip the leading case name, which differs between the two members of a + # pair and would otherwise stop the meshes from lining up. + prefix = os.path.basename(os.path.normpath(case_dir)) + "." + stripped = {} + for name, byframe in meshes.items(): + stripped[name[len(prefix):] if name.startswith(prefix) else name] = byframe + + animated = [f for m in stripped.values() for f in m if f is not None] + frames = sorted(set(animated)) + return stripped, frames + + +# --------------------------------------------------------------------------- +# what each mesh is, and how to draw it + + +def vtk_dt(case_dir): + """Seconds between VTK frames, from the case summary file. + + OpenFAST rounds 1/VTK_fps to an integer multiple of DT, so the frame + spacing is generally not 1/VTK_fps and cannot be taken from the deck. The + summary reports the effective rate directly. Note that the time-step table + just above it truncates the exponent off every row that carries a subcycle + count -- see FAST_Subs.f90, the T37 in the format string -- so the rate line + is the one to read, not the table. + """ + for fn in sorted(os.listdir(case_dir)): + if not fn.endswith(".sum"): + continue + with open(os.path.join(case_dir, fn), errors="ignore") as fh: + for line in fh: + if "Frame rate" in line: + m = re.search(r"([0-9]+\.[0-9]+)\s*fps", line) + if m and float(m.group(1)) > 0: + return 1.0 / float(m.group(1)) + return None + + +def when(dt, frame): + """Label for a frame: its time if we know the rate, else the index.""" + if dt is None: + return f"frame {frame}" + return f"t = {dt * frame:.2f} s (frame {frame})" + + +def style(name): + """Colour, line width and z-order for a mesh, by family.""" + if "GroundSurface" in name: + return "0.80", 0.8, 0 + # VTK_type = 1 writes surfaces rather than the debug line meshes. + if "BladeSurface" in name or ("Blade" in name and name.endswith("Surface")): + return "#1f77b4", 0.4, 3 + if "TowerSurface" in name: + return "0.35", 0.4, 1 + if "NacelleSurface" in name or "HubSurface" in name: + return "#ff7f0e", 0.4, 5 + if name.startswith("BD_Blade"): + return "#1f77b4", 2.2, 3 # the structural blade + if name.startswith("AD_Blade_"): + return "#d62728", 1.4, 2 # the aerodynamic blade + if "BladeRootMotion" in name: + return "#2ca02c", 3.0, 4 + if "ReactionForce" in name: + return "#9467bd", 3.0, 4 + if "HubMotion" in name: + return "#ff7f0e", 3.0, 5 + if "Tower" in name: + return "0.35", 2.0, 1 + return "0.5", 1.0, 1 + + +def collect(meshes, frame, want=None): + """Read every mesh at a frame. Falls back to the static file if a mesh has + no animated frames, so the ground plane and any reference-only mesh still + appear.""" + out = {} + for name, byframe in sorted(meshes.items()): + if want is not None and not want(name): + continue + path = byframe.get(frame) + if path is None: + path = byframe.get(None) + if path is None: + continue + out[name] = read_vtp(path) + return out + + +def draw(ax, data, transform=None, colour=None, alpha=1.0, points=True): + """Draw a set of meshes into a 3-D axis. + + ``transform`` is applied to every point, which is how the overlay re-applies + the mirror. ``colour`` overrides the per-family colour, which is how the + overlay tells the two turbines apart. + """ + for name, mesh in data.items(): + p = mesh["points"] + if transform is not None: + p = p @ transform.T + base, lw, zo = style(name) + c = colour or base + + segs = [p[idx] for idx in mesh["lines"] if len(idx) >= 2] + if segs: + ax.add_collection3d( + Line3DCollection(segs, colors=c, linewidths=lw, alpha=alpha, zorder=zo) + ) + faces = [p[idx] for idx in mesh["polys"] if len(idx) >= 3] + if faces: + ax.add_collection3d( + Poly3DCollection( + faces, facecolors=c, edgecolors="none", alpha=alpha * 0.35, zorder=zo + ) + ) + # A mesh with no cells is a single node -- the hub, a blade root -- and + # is invisible unless its points are drawn. + if points and not segs and not faces: + ax.scatter(p[:, 0], p[:, 1], p[:, 2], c=c, s=28, alpha=alpha, zorder=zo, + depthshade=False) + + +def draw2d(ax, data, axes, transform=None, colour=None, alpha=1.0): + """Draw a set of meshes as a flat orthographic projection. + + ``axes`` picks the two coordinate indices to plot. True coordinates are + plotted and the axis is reversed by ``setup2d`` where a view needs it, so + that the tick labels keep meaning what they say; negating the data instead + puts a node at y = +63 under a tick reading -60. + + These views are planar, so drawing them in a 3-D axis only buys + matplotlib's pane and tick furniture over the top of the geometry. + """ + i, j = axes + si = sj = 1 + for name, mesh in data.items(): + p = mesh["points"] + if transform is not None: + p = p @ transform.T + base, lw, zo = style(name) + c = colour or base + u, v = si * p[:, i], sj * p[:, j] + + segs = [np.column_stack((u[idx], v[idx])) for idx in mesh["lines"] if len(idx) >= 2] + if segs: + ax.add_collection( + LineCollection(segs, colors=c, linewidths=lw, alpha=alpha, zorder=zo) + ) + faces = [np.column_stack((u[idx], v[idx])) for idx in mesh["polys"] if len(idx) >= 3] + if faces: + ax.add_collection( + PolyCollection(faces, facecolors=c, edgecolors="none", + alpha=alpha * 0.25, zorder=zo) + ) + if not segs and not faces: + ax.scatter(u, v, c=c, s=30, alpha=alpha, zorder=zo) + + +# Two planar views plus one isometric. (label, axis indices, which axes to +# reverse, axis labels). Looking downwind means +x into the page; the frame is +# right-handed with z up, so +y runs to the left and the horizontal axis of +# that view is reversed. +FLAT_VIEWS = [ + ("looking downwind, +x into page", (1, 2), (True, False), + "y (m), increasing to the left", "z (m)"), + ("plan, from above", (0, 1), (False, False), "x (m), downwind", "y (m)"), +] + + +def frame_limits(*datasets): + """A single cubic bounding box covering everything, so that the two members + of a pair are drawn to exactly the same scale and cannot be compared + misleadingly.""" + pts = [] + for data in datasets: + for mesh in data.values(): + pts.append(mesh["points"]) + allp = np.vstack(pts) + # The ground plane is huge and would shrink the turbine to nothing. + turbine = allp[allp[:, 2] > 1.0] + if len(turbine) > 8: + allp = turbine + lo, hi = allp.min(axis=0), allp.max(axis=0) + ctr = 0.5 * (lo + hi) + half = 0.55 * (hi - lo).max() + return np.array([ctr - half, ctr + half]) + + +def setup(ax, lim, title, elev=22, azim=-125): + ax.set_xlim(lim[0, 0], lim[1, 0]) + ax.set_ylim(lim[0, 1], lim[1, 1]) + ax.set_zlim(lim[0, 2], lim[1, 2]) + ax.set_box_aspect((1, 1, 1)) + ax.view_init(elev=elev, azim=azim) + ax.set_xlabel("x", fontsize=7) + ax.set_ylabel("y", fontsize=7) + ax.set_zlabel("z", fontsize=7) + ax.set_title(title, fontsize=9) + ax.tick_params(labelsize=5) + + +def setup2d(ax, lim, view, title): + _, (i, j), (rx, ry), xl, yl = view + ax.set_xlim(lim[0, i], lim[1, i]) + ax.set_ylim(lim[0, j], lim[1, j]) + if rx: + ax.invert_xaxis() + if ry: + ax.invert_yaxis() + ax.set_aspect("equal") + ax.set_xlabel(xl, fontsize=8) + ax.set_ylabel(yl, fontsize=8) + ax.set_title(title, fontsize=9) + ax.tick_params(labelsize=7) + ax.grid(alpha=0.25, lw=0.5) + + +# --------------------------------------------------------------------------- +# figures + + +LEGEND = ("blue BD blade, red AD blade, green blade root, " + "purple BD reaction, orange hub, grey tower") + + +def fig_geometry(cw, mr, lim, label, out): + fig = plt.figure(figsize=(15, 9.5)) + for row, (case, data) in enumerate((("clockwise", cw), ("mirrored", mr))): + for col, view in enumerate(FLAT_VIEWS): + ax = fig.add_subplot(2, 3, row * 3 + col + 1) + draw2d(ax, data, view[1]) + setup2d(ax, lim, view, f"{case} -- {view[0]}") + ax = fig.add_subplot(2, 3, row * 3 + 3, projection="3d") + draw(ax, data) + setup(ax, lim, f"{case} -- isometric") + fig.suptitle( + f"Geometry at {label}. Identical axes throughout. {LEGEND}", + fontsize=10, + ) + fig.tight_layout(rect=(0, 0, 1, 0.95)) + fig.savefig(out, dpi=130) + plt.close(fig) + + +def fig_overlay(cw, mr, lim, label, out, deviations): + fig = plt.figure(figsize=(15, 5.5)) + for col, view in enumerate(FLAT_VIEWS): + ax = fig.add_subplot(1, 3, col + 1) + draw2d(ax, cw, view[1], colour="#1f77b4", alpha=1.0) + draw2d(ax, mr, view[1], transform=S, colour="#d62728", alpha=0.6) + setup2d(ax, lim, view, view[0]) + ax = fig.add_subplot(1, 3, 3, projection="3d") + draw(ax, cw, colour="#1f77b4", alpha=1.0) + draw(ax, mr, transform=S, colour="#d62728", alpha=0.6) + setup(ax, lim, "isometric") + worst = max((v for v, _ in deviations.values()), default=float("nan")) + fig.suptitle( + f"Mirrored geometry with y -> -y re-applied (red) over clockwise (blue), {label}. " + f"They should coincide -- no blue should be visible. " + f"Largest node separation {worst:.3e} m", + fontsize=10, + ) + fig.tight_layout(rect=(0, 0, 1, 0.93)) + fig.savefig(out, dpi=130) + plt.close(fig) + + +def tip_paths(meshes, frames, pattern="AD_Blade_"): + """Tip position of each blade over the run.""" + out = {} + for name, byframe in sorted(meshes.items()): + if not name.startswith(pattern): + continue + pts = [] + for f in frames: + path = byframe.get(f) + if path is None: + continue + # The last point of the blade line mesh is the tip. + pts.append(read_vtp(path)["points"][-1]) + if pts: + out[name] = np.array(pts) + return out + + +def azimuth(path, hub): + """Unwrapped blade azimuth about the shaft, radians. + + Measured in the plane normal to the wind, from vertical, positive in the + sense a clockwise rotor turns when viewed from upwind. Looking downwind + with z up the frame is right-handed and +y runs to the left, so that sense + carries -y into +z, giving atan2(-y, z). + """ + d = path - hub + return np.unwrap(np.arctan2(-d[:, 1], d[:, 2])) + + +def fig_rotation(cw_paths, mr_paths, cw_hub, mr_hub, out): + """Sense of rotation. + + The earlier version of this figure drew every frame of the run. The rotor + turns about four times in twenty seconds, so the tip paths wrapped over + themselves and the colour gradient aliased into noise that showed nothing at + all. It is restricted to a single revolution here, and the direction of + travel is drawn as arrows taken from consecutive positions rather than left + to be inferred from colour. The right-hand panel is the unambiguous one: + the sign of the slope is the sense of rotation, and no question of how the + view is oriented enters into it. + """ + fig = plt.figure(figsize=(15, 6.0)) + cases = (("clockwise", cw_paths, cw_hub, "#1f77b4"), + ("mirrored", mr_paths, mr_hub, "#d62728")) + + for col, (label, paths, hub, _) in enumerate(cases): + ax = fig.add_subplot(1, 3, col + 1) + # One blade only. Drawing all three puts three colour ramps on one + # circle, which reads as a ramp that cycles and hides the very thing + # the panel exists to show. + for name, p in sorted(paths.items())[:1]: + psi = azimuth(p, hub) + # One revolution's worth of frames, or the whole run if shorter. + adv = np.abs(psi - psi[0]) + n = int(np.argmax(adv >= 2 * np.pi)) + 1 if (adv >= 2 * np.pi).any() else len(p) + q = p[:n] + u, v = q[:, 1], q[:, 2] + ax.scatter(u, v, c=np.arange(n), cmap="viridis", s=14, zorder=3) + step = max(1, n // 16) + for k in range(0, n - step, step): + ax.annotate( + "", xy=(u[k + step], v[k + step]), xytext=(u[k], v[k]), + arrowprops=dict(arrowstyle="-|>", lw=1.6, color="k", + shrinkA=0, shrinkB=0), + zorder=4, + ) + ax.annotate(name.replace("AD_Blade_R1", ""), (u[0], v[0]), + fontsize=9, weight="bold", zorder=5) + ax.plot([hub[1]], [hub[2]], "k+", ms=14) + ax.invert_xaxis() # looking downwind: +y to the left + ax.set_aspect("equal") + ax.set_xlabel("y (m), increasing to the left", fontsize=8) + ax.set_ylabel("z (m)", fontsize=8) + ax.set_title(f"{label}: blade 1 tip path, one revolution", fontsize=10) + ax.grid(alpha=0.25, lw=0.5) + + ax = fig.add_subplot(1, 3, 3) + for label, paths, hub, colour in cases: + for i, (name, p) in enumerate(sorted(paths.items())): + psi = np.degrees(azimuth(p, hub) - azimuth(p, hub)[0]) + ax.plot(np.arange(len(psi)), psi, color=colour, lw=1.4, + label=label if i == 0 else None) + ax.axhline(0, color="k", lw=0.6) + ax.set_xlabel("frame", fontsize=8) + ax.set_ylabel("azimuth advance from start (deg)", fontsize=8) + ax.set_title("unwrapped azimuth: the slope is the sense", fontsize=10) + ax.grid(alpha=0.25, lw=0.5) + ax.legend(fontsize=8) + + fig.suptitle( + "Sense of rotation, viewed from upwind looking downwind (+x into page). " + "The two rotors must turn in opposite senses.", + fontsize=11, + ) + fig.tight_layout(rect=(0, 0, 1, 0.93)) + fig.savefig(out, dpi=130) + plt.close(fig) + + +def hub_position(meshes, frame): + """Hub node position at a frame, for the azimuth origin.""" + for name, byframe in meshes.items(): + if "HubMotion" in name and "_Reference" not in name: + path = byframe.get(frame) or byframe.get(None) + if path: + return read_vtp(path)["points"][0] + return np.array([0.0, 0.0, 90.0]) + + +# --------------------------------------------------------------------------- + + + +# --------------------------------------------------------------------------- +# animation + + +class MeshCache: + """Parsed meshes, kept so each .vtp is read once. + + A run of this length holds four thousand files per case and every figure + wants the same ones, so without this the encode spends most of its time in + the XML parser. The meshes are small -- a blade is nineteen points -- so + holding all of them costs little. + """ + + def __init__(self, meshes, want=None): + self.meshes = meshes + self.want = want + self._cache = {} + + def at(self, frame): + if frame not in self._cache: + self._cache[frame] = collect(self.meshes, frame, self.want) + return self._cache[frame] + + +def animation_limits(cw, mr, frames): + """Axis limits fixed for the whole animation. + + Taken over a spread of frames rather than one, so that nothing wanders out + of frame part way through -- an axis that rescales mid-clip would make two + cases look different when they are not. + """ + sample = frames[:: max(1, len(frames) // 12)] + [frames[-1]] + return frame_limits(*[cw.at(f) for f in sample], *[mr.at(f) for f in sample]) + + +def rotation_bounds(cw_paths, mr_paths, cw_hub, mr_hub): + """Fixed axis limits for the rotation animation. + + Every axis in an animation has to be pinned to its final extent. Left to + autoscale, the first frames hold one point, so the tip circle appears to + grow out of nothing and the azimuth trace always touches the top of its + box -- both of which show motion that is not there. + """ + tips = np.vstack([p for p in list(cw_paths.values()) + list(mr_paths.values())]) + y, z = tips[:, 1], tips[:, 2] + pad = 0.08 * max(np.ptp(y), np.ptp(z)) + yz = (y.min() - pad, y.max() + pad, z.min() - pad, z.max() + pad) + + psis = [] + for paths, hub in ((cw_paths, cw_hub), (mr_paths, mr_hub)): + for p in sorted(paths.values(), key=lambda a: a[0, 1])[:1]: + a = azimuth(p, hub) + psis.append(np.degrees(a - a[0])) + # Unwrapping assumes successive samples are less than half a revolution + # apart. A large --stride breaks that: at 0.85 rev per sample the unwrap + # resolves the wrong way and the azimuth trace understates the turning by + # a factor of several, quietly. + for q in psis: + step = np.abs(np.diff(q)).max() if len(q) > 1 else 0.0 + if step > 150.0: + print(f"warning: {step:.0f} deg between sampled frames -- the stride " + "aliases the azimuth unwrap; use a smaller --stride for a " + "trustworthy rotation panel", file=sys.stderr) + break + + lo = min(float(q.min()) for q in psis) + hi = max(float(q.max()) for q in psis) + m = 0.05 * (hi - lo) + return yz, (lo - m, hi + m) + + +def anim_rotation(fig, axes, cw_paths, mr_paths, cw_hub, mr_hub, upto, nframes, + bounds): + """One frame of the rotation animation: the trace accumulated so far.""" + (y0, y1, z0, z1), (psi0, psi1) = bounds + ax_cw, ax_mr, ax_psi = axes + cases = (("clockwise", cw_paths, cw_hub, ax_cw, "#1f77b4"), + ("mirrored", mr_paths, mr_hub, ax_mr, "#d62728")) + + for label, paths, hub, ax, _ in cases: + ax.cla() + for name, p in sorted(paths.items())[:1]: + q = p[: upto + 1] + ax.plot(q[:, 1], q[:, 2], "-", color="0.7", lw=1.0, zorder=2) + ax.scatter(q[:, 1], q[:, 2], c=np.arange(len(q)), cmap="viridis", + s=10, vmin=0, vmax=nframes - 1, zorder=3) + ax.plot(q[-1, 1], q[-1, 2], "o", color="k", ms=9, zorder=5) + ax.plot([hub[1]], [hub[2]], "k+", ms=14) + ax.set_xlim(y0, y1) + ax.set_ylim(z0, z1) + ax.set_aspect("equal") + ax.invert_xaxis() + ax.set_xlabel("y (m), increasing to the left", fontsize=8) + ax.set_ylabel("z (m)", fontsize=8) + ax.set_title(f"{label}: blade 1 tip", fontsize=10) + ax.grid(alpha=0.25, lw=0.5) + + ax_psi.cla() + for label, paths, hub, _, colour in cases: + for i, (name, p) in enumerate(sorted(paths.items())[:1]): + psi = np.degrees(azimuth(p, hub) - azimuth(p, hub)[0]) + ax_psi.plot(np.arange(upto + 1), psi[: upto + 1], color=colour, lw=1.8, + label=label if i == 0 else None) + ax_psi.axhline(0, color="k", lw=0.6) + ax_psi.set_xlim(0, nframes - 1) + ax_psi.set_ylim(psi0, psi1) + ax_psi.set_xlabel("frame", fontsize=8) + ax_psi.set_ylabel("azimuth advance from start (deg)", fontsize=8) + ax_psi.set_title("unwrapped azimuth: the slope is the sense", fontsize=10) + ax_psi.grid(alpha=0.25, lw=0.5) + ax_psi.legend(fontsize=8, loc="upper left") + + +def encode(pattern, out, fps): + """PNG sequence to H.264 mp4, the format a GitHub PR comment accepts.""" + try: + import imageio_ffmpeg + exe = imageio_ffmpeg.get_ffmpeg_exe() + except Exception: + exe = shutil.which("ffmpeg") + if not exe: + raise SystemExit("no ffmpeg available; install imageio-ffmpeg or ffmpeg") + + cmd = [ + exe, "-y", "-loglevel", "error", + "-framerate", f"{fps:.6f}", "-i", pattern, + # H.264 with yuv420p needs even dimensions, and a figure saved at an + # arbitrary dpi rarely has them. + "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0:white", + "-c:v", "libx264", "-preset", "slow", "-crf", "23", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", + out, + ] + subprocess.run(cmd, check=True) + return out + + +def animate(cw_meshes, mr_meshes, frames, out_dir, want, fps, dpi, dt): + """Write geometry.mp4, overlay.mp4 and rotation.mp4.""" + cw = MeshCache(cw_meshes, want) + mr = MeshCache(mr_meshes, want) + lim = animation_limits(cw, mr, frames) + + tmp = os.path.join(out_dir, "_frames") + if os.path.isdir(tmp): + shutil.rmtree(tmp) + os.makedirs(tmp) + + # Figures are built once and their axes cleared each frame; rebuilding a + # figure per frame is most of the cost otherwise. + fig_g = plt.figure(figsize=(15, 9.5)) + ax_g = [fig_g.add_subplot(2, 3, k + 1) for k in (0, 1)] + \ + [fig_g.add_subplot(2, 3, 3, projection="3d")] + \ + [fig_g.add_subplot(2, 3, k + 1) for k in (3, 4)] + \ + [fig_g.add_subplot(2, 3, 6, projection="3d")] + sup_g = fig_g.suptitle("", fontsize=10) + + fig_o = plt.figure(figsize=(15, 5.5)) + ax_o = [fig_o.add_subplot(1, 3, 1), fig_o.add_subplot(1, 3, 2), + fig_o.add_subplot(1, 3, 3, projection="3d")] + sup_o = fig_o.suptitle("", fontsize=10) + + fig_r = plt.figure(figsize=(15, 6.0)) + ax_r = [fig_r.add_subplot(1, 3, k + 1) for k in range(3)] + sup_r = fig_r.suptitle( + "Sense of rotation, viewed from upwind looking downwind (+x into page). " + "The two rotors must turn in opposite senses.", fontsize=11) + + cw_paths = tip_paths(cw_meshes, frames) + mr_paths = tip_paths(mr_meshes, frames) + cw_hub = hub_position(cw_meshes, frames[0]) + mr_hub = hub_position(mr_meshes, frames[0]) + rbounds = rotation_bounds(cw_paths, mr_paths, cw_hub, mr_hub) + + for k, f in enumerate(frames): + dcw, dmr = cw.at(f), mr.at(f) + label = when(dt, f) + + for a in ax_g: + a.cla() + for col, view in enumerate(FLAT_VIEWS): + draw2d(ax_g[col], dcw, view[1]) + setup2d(ax_g[col], lim, view, f"clockwise -- {view[0]}") + draw2d(ax_g[3 + col], dmr, view[1]) + setup2d(ax_g[3 + col], lim, view, f"mirrored -- {view[0]}") + draw(ax_g[2], dcw) + setup(ax_g[2], lim, "clockwise -- isometric") + draw(ax_g[5], dmr) + setup(ax_g[5], lim, "mirrored -- isometric") + sup_g.set_text(f"Geometry at {label}. Identical axes throughout. {LEGEND}") + fig_g.savefig(os.path.join(tmp, f"geometry_{k:05d}.png"), dpi=dpi) + + for a in ax_o: + a.cla() + for col, view in enumerate(FLAT_VIEWS): + draw2d(ax_o[col], dcw, view[1], colour="#1f77b4", alpha=1.0) + draw2d(ax_o[col], dmr, view[1], transform=S, colour="#d62728", alpha=0.6) + setup2d(ax_o[col], lim, view, view[0]) + draw(ax_o[2], dcw, colour="#1f77b4", alpha=1.0) + draw(ax_o[2], dmr, transform=S, colour="#d62728", alpha=0.6) + setup(ax_o[2], lim, "isometric") + worst = max((v for v, _ in deviation(dcw, dmr).values()), + default=float("nan")) + sup_o.set_text( + f"Mirrored geometry with y -> -y re-applied (red) over clockwise (blue), " + f"{label}. No blue should be visible. " + f"Largest node separation {worst:.3e} m") + fig_o.savefig(os.path.join(tmp, f"overlay_{k:05d}.png"), dpi=dpi) + + anim_rotation(fig_r, ax_r, cw_paths, mr_paths, cw_hub, mr_hub, k, + len(frames), rbounds) + fig_r.savefig(os.path.join(tmp, f"rotation_{k:05d}.png"), dpi=dpi) + + if k % 20 == 0 or k == len(frames) - 1: + print(f" frame {k + 1}/{len(frames)} ({label})", flush=True) + + for fig in (fig_g, fig_o, fig_r): + plt.close(fig) + + outs = [] + for stem in ("geometry", "overlay", "rotation"): + out = os.path.join(out_dir, f"{stem}.mp4") + encode(os.path.join(tmp, f"{stem}_%05d.png"), out, fps) + outs.append(out) + print(f" {out} {os.path.getsize(out) / 1e6:.2f} MB") + + shutil.rmtree(tmp) + return outs + +# Meshes built by sweeping an angle -- the ground quad, the tower, nacelle and +# hub surfaces -- have their vertex order reversed by the reflection, so a +# node-by-node comparison reports a large separation for two identical shapes. +# They are compared as point clouds instead. +_SWEPT = ("GroundSurface", "TowerSurface", "NacelleSurface", "HubSurface") + +def partner(name): + """The mirrored mesh a clockwise mesh should be compared against. + + Blade spacing follows the rotation sense, so a mirrored blade's mesh is + directly the mirror image of the same-numbered clockwise blade: the + partner mesh is simply the same name. + """ + return name + + +def _cloud_sep(a, b): + """Largest distance from a point of b to the nearest point of a.""" + step = max(1, len(a) // 1500) + a, b = a[::step], b[::step] + d = np.linalg.norm(b[:, None, :] - a[None, :, :], axis=2) + return float(d.min(axis=1).max()) + + +def deviation(cw, mr): + """Largest node separation between each clockwise mesh and its mirrored + partner with the mirror re-applied. + + Returns {name: (separation, ordered)} where ordered is False for the swept + shapes measured as point clouds. + """ + out = {} + for name, mesh in cw.items(): + p = partner(name) + if p not in mr: + continue + a = mesh["points"] + b = mr[p]["points"] @ S.T + if a.shape != b.shape: + continue + if any(k in name for k in _SWEPT): + out[name] = (_cloud_sep(a, b), False) + else: + out[name] = (float(np.abs(a - b).max()), True) + return out + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--cw", required=True, help="clockwise case directory") + ap.add_argument("--mirror", required=True, help="mirrored case directory") + ap.add_argument("--out", required=True, help="directory to write PNGs into") + ap.add_argument("--frame", type=int, default=None, + help="frame for the geometry and overlay figures " + "(default: the last one)") + ap.add_argument("--no-ground", action="store_true", + help="omit the ground plane") + ap.add_argument("--with-reference", action="store_true", + help="also draw the _Reference meshes. They sit at the " + "reference azimuth rather than the frame's, so by " + "default they are measured but not drawn") + ap.add_argument("--animate", action="store_true", + help="also write geometry.mp4, overlay.mp4 and rotation.mp4 " + "over every frame") + ap.add_argument("--fps", type=float, default=None, + help="playback frame rate (default: the VTK output rate, " + "so the clip runs in real time)") + ap.add_argument("--dpi", type=int, default=100, + help="dpi for the animation frames (default 100)") + ap.add_argument("--stride", type=int, default=1, + help="use every Nth frame in the animation (default 1)") + args = ap.parse_args(argv) + + os.makedirs(args.out, exist_ok=True) + + cw_meshes, cw_frames = scan(args.cw) + mr_meshes, mr_frames = scan(args.mirror) + + common = sorted(set(cw_frames) & set(mr_frames)) + if not common: + raise SystemExit("the two cases share no animated frames") + frame = args.frame if args.frame is not None else common[-1] + if frame not in common: + raise SystemExit(f"frame {frame} not present in both cases") + + want = (lambda n: "GroundSurface" not in n) if args.no_ground else None + cw = collect(cw_meshes, frame, want) + mr = collect(mr_meshes, frame, want) + + # Every mesh is measured, including the reference ones, but drawing a + # reference mesh beside the current geometry puts a second copy of the rotor + # at a different azimuth in the same picture and reads as a defect. + devs = deviation(cw, mr) + if not args.with_reference: + cw = {k: v for k, v in cw.items() if "_Reference" not in k} + mr = {k: v for k, v in mr.items() if "_Reference" not in k} + + missing = set(cw) ^ set(mr) + if missing: + print(f"warning: meshes present in only one case: {sorted(missing)}", + file=sys.stderr) + + lim = frame_limits(cw, mr) + + dt = vtk_dt(args.cw) + label = when(dt, frame) + + fig_geometry(cw, mr, lim, label, os.path.join(args.out, "geometry.png")) + fig_overlay(cw, mr, lim, label, os.path.join(args.out, "overlay.png"), devs) + fig_rotation( + tip_paths(cw_meshes, common), + tip_paths(mr_meshes, common), + hub_position(cw_meshes, common[0]), + hub_position(mr_meshes, common[0]), + os.path.join(args.out, "rotation.png"), + ) + + if dt is not None: + print(f"frames {common[0]}..{common[-1]} at {dt:.4f} s spacing, " + f"t = 0 to {dt * common[-1]:.2f} s") + print(f"rendered {label}") + print(f"{len(cw)} meshes\n") + print("largest node separation after re-applying the mirror, per mesh") + print("(each blade compared against the same-numbered mirrored blade)") + for name in sorted(devs, key=lambda n: devs[n][0], reverse=True): + val, ordered = devs[name] + note = "" if ordered else " (point cloud; vertex order reverses)" + print(f" {name:<44s} {val:.6e} m{note}") + print(f"\nPNGs in {args.out}") + + if args.animate: + fps = args.fps if args.fps else (1.0 / dt if dt else 15.0) + seq = common[:: max(1, args.stride)] + # A stride changes how much simulated time each played frame covers, so + # the rate has to follow it or the clip no longer runs in real time. + if args.stride > 1 and args.fps is None: + fps = fps / args.stride + print(f"\nanimating {len(seq)} frames at {fps:.4f} fps " + f"({len(seq) / fps:.1f} s of video)") + animate(cw_meshes, mr_meshes, seq, args.out, want, fps, args.dpi, dt) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/reg_tests/otherTests/rtest_regression.py b/reg_tests/otherTests/rtest_regression.py new file mode 100644 index 0000000000..f057d8675f --- /dev/null +++ b/reg_tests/otherTests/rtest_regression.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Regression guard: MirrorRotor=F must reproduce the stored r-test baselines bit-for-bit. + +Copies each AeroDyn driver case to a scratch dir, runs the freshly built driver, +and compares every channel against the committed reference output. + +Usage: rtest_regression.py [ ...] +""" +import os +import shutil +import subprocess +import sys +import tempfile + +import numpy as np + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +REPO = REPO_ROOT +RTEST = f"{REPO}/reg_tests/r-test/modules/aerodyn" +DRIVER = f"{REPO}/build-docker-double-debug/modules/aerodyn/aerodyn_driver" +sys.path.insert(0, f"{REPO}/reg_tests/lib") +import fast_io # noqa: E402 + + +def read(path): + data, info, _ = fast_io.load_output(path) + return info["attribute_names"], np.asarray(data) + + +def run_case(case): + src = os.path.join(RTEST, case) + ref = None + for cand in ("ad_driver.outb", "ad_driver.out"): + if os.path.exists(os.path.join(src, cand)): + ref = cand + break + if ref is None: + return case, "SKIP", "no reference output" + + with tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR")) as tmp: + # Cases reference files by relative paths that escape the module folder + # (e.g. ../../../glue-codes/...), so rebuild the r-test layout in scratch. + rtest_root = os.path.abspath(os.path.join(RTEST, "..", "..")) + mod_dir = os.path.join(tmp, "modules", "aerodyn") + os.makedirs(mod_dir) + for entry in os.listdir(rtest_root): + if entry != "modules": + os.symlink(os.path.join(rtest_root, entry), + os.path.join(tmp, entry)) + for entry in os.listdir(os.path.join(rtest_root, "modules")): + if entry != "aerodyn": + os.symlink(os.path.join(rtest_root, "modules", entry), + os.path.join(tmp, "modules", entry)) + for entry in os.listdir(RTEST): + if entry != case: + os.symlink(os.path.join(RTEST, entry), + os.path.join(mod_dir, entry)) + + work = os.path.join(mod_dir, case) + shutil.copytree(src, work) + + dvr = next(f for f in os.listdir(work) if f.endswith(".dvr")) + proc = subprocess.run([DRIVER, dvr], cwd=work, + capture_output=True, text=True) + out = os.path.join(work, ref) + if proc.returncode != 0 or not os.path.exists(out): + tail = (proc.stdout + proc.stderr).strip().split("\n")[-3:] + return case, "RUNFAIL", " | ".join(tail) + + try: + rn, rd = read(os.path.join(src, ref)) + tn, td = read(out) + except Exception as exc: + return case, "READFAIL", str(exc) + + if rn != tn: + return case, "FAIL", "channel list changed" + if rd.shape != td.shape: + return case, "FAIL", f"shape {rd.shape} vs {td.shape}" + + diff = np.abs(td - rd) + scale = np.maximum(np.abs(rd).max(axis=0), 1e-12) + rel = (diff / scale).max() + if rel == 0.0: + return case, "IDENTICAL", "rel=0" + if rel < 1e-10: + return case, "OK", f"rel={rel:.2e}" + worst = tn[int(np.argmax((diff / scale).max(axis=0)))] + return case, "FAIL", f"rel={rel:.2e} worst={worst}" + + +def main(): + cases = sys.argv[1:] + fails = 0 + for case in cases: + name, status, detail = run_case(case) + if status in ("FAIL", "RUNFAIL", "READFAIL"): + fails += 1 + print(f" {name:32s} {status:10s} {detail}") + print(f"\n{len(cases) - fails}/{len(cases)} cases match the baseline") + sys.exit(1 if fails else 0) + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/run_guards.sh b/reg_tests/otherTests/run_guards.sh new file mode 100755 index 0000000000..f0bb4b3bb6 --- /dev/null +++ b/reg_tests/otherTests/run_guards.sh @@ -0,0 +1,185 @@ +#!/bin/bash +# Prove the MirrorRotor guard rails actually fire (and only fire when they should). +# Uses the DEBUG build: these runs abort during init, nothing is baselined. +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="${OPENFAST_REPO:-$(cd "$HERE/../.." && pwd)}" +GC="${GUARDCHECK_DIR:-${TMPDIR:-/tmp}/openfast_guardcheck}" +mkdir -p "$GC" +RT=$REPO/reg_tests/r-test/glue-codes/openfast +OF=$REPO/build-docker-double-debug/glue-codes/openfast/openfast +SRC=$RT/5MW_Land_noDLL_Steady_MirrorRotor + +# The deck refers to ../5MW_Baseline for the inflow and airfoil data. Without this +# the runs abort while reading their inputs, before reaching the guard at all - and a +# guard that is never reached looks exactly like a guard that stayed silent, so the +# clockwise controls would pass for the wrong reason. +ln -sfn "$RT/5MW_Baseline" "$GC/5MW_Baseline" + +# $1 = variant name, $2 = MirrorRotor (True/False), $3 = Linearize (True/False), +# $4 = Wake_Mod (1/3), $5 = CompAA (True/False) +mkvariant () { + local name=$1 mirror=$2 lin=$3 wake=$4 aa=$5 + rm -rf "$GC/$name"; mkdir -p "$GC/$name" + cp "$SRC"/*.fst "$SRC"/AeroDyn.dat "$SRC"/ElastoDyn.dat "$SRC"/ElastoDyn_Tower.dat "$GC/$name/" + local fst="$GC/$name/5MW_Land_noDLL_Steady_MirrorRotor.fst" + sed -i -E "s#^([[:space:]]*)[A-Za-z]+([[:space:]]+MirrorRotor)#\1$mirror\2#" "$fst" + sed -i -E "s#^[A-Za-z]+([[:space:]]+Linearize)#$lin\1#" "$fst" + sed -i -E "s#^[0-9]+([[:space:]]+Wake_Mod)#$wake\1#" "$GC/$name/AeroDyn.dat" + sed -i -E "s#^[A-Za-z]+([[:space:]]+CompAA)#$aa\1#" "$GC/$name/AeroDyn.dat" + if [ "$wake" = "3" ]; then + cp "$RT/HelicalWake_OLAF"/*OLAF*.dat "$GC/$name/OLAF.dat" 2>/dev/null || \ + cp "$(find $RT/HelicalWake_OLAF -iname '*olaf*' -type f | head -1)" "$GC/$name/OLAF.dat" + sed -i -E "s#^\"[^\"]*\"([[:space:]]+OLAFInputFileName)#\"OLAF.dat\"\1#" "$GC/$name/AeroDyn.dat" + fi + # keep it short - we only care about init + sed -i -E "s#^([[:space:]]*)[0-9.]+([[:space:]]+TMax)#\1 0.5\2#" "$fst" +} + +# $3 = "yes" if the message is expected, "no" if it must be absent +run () { + local name=$1 expect=$2 want=$3 + cd "$GC/$name" || return + timeout 300 "$OF" 5MW_Land_noDLL_Steady_MirrorRotor.fst > run.log 2>&1 + local rc=$? + local got=no + grep -qi "$expect" run.log && got=yes + if [ "$got" = "$want" ]; then + echo "PASS $name (rc=$rc) guard fired=$got, expected=$want" + else + echo "FAIL $name (rc=$rc) guard fired=$got, expected=$want" + grep -i "FATAL\|Error" run.log | head -4 | sed 's/^/ /' + fi +} + +echo "=== guard rail proof, debug build ===" +mkvariant lin_mirror True True 1 False +mkvariant lin_cw False True 1 False +mkvariant olaf_mirror True False 3 False +mkvariant olaf_cw False False 3 False +mkvariant aa_mirror True False 1 True + +run lin_mirror "not yet supported with linearization" yes +run lin_cw "not yet supported with linearization" no +run aa_mirror "not yet supported with the AeroAcoustics" yes + +# The free wake is supported, so these two check that the restriction stays gone +# rather than that it fires. If someone reintroduces a refusal for OLAF, both fail. +run olaf_mirror "not yet supported with the OLAF" no +run olaf_cw "not yet supported with the OLAF" no + +# The VTK blade surfaces are built from the airfoil coordinate files rather than +# from the mesh, so they carry a sign of their own that no channel comparison and +# no node comparison can reach. This uses the release build and takes about 12 s. +echo +echo "=== VTK surface mirror ===" +python3 "$HERE/check_vtk_surface_mirror.py" + +# The blade renumbering (spacing carries the rotation sense) is nearly invisible +# to the channel comparisons on an axisymmetric case, so the pair check carries a +# dedicated sweep-order assertion on the BAzimuth channels. This reads the stored +# baselines only, no simulation. +echo +echo "=== mirror pairs and blade sweep order (stored baselines) ===" +python3 "$HERE/check_rtest_mirror_pair.py" + +# --------------------------------------------------------------------------- +# FAST.Farm guards +# +# FAST.Farm is a separate executable and "make openfast" does not rebuild it. +# That is not hypothetical: when this check was written the binary in +# build-docker-double was six days stale and still carried a guard message that +# had been removed from the branch, which very nearly recorded a restriction +# that no longer exists. A check that runs FAST.Farm catches that. +# +# The debug build does not include FAST.Farm, so fall back to the release one. +FFBIN="" +for cand in "$REPO/build-docker-double-debug/glue-codes/fast-farm/FAST.Farm" \ + "$REPO/build-docker-double/glue-codes/fast-farm/FAST.Farm"; do + [ -x "$cand" ] && FFBIN="$cand" && break +done + +# Prefer the staged build tree: the DISCON controller libraries are built, not +# stored, so they do not exist in the r-test source tree at all, and a deck that +# cannot load its controller aborts before reaching any guard. +FRT=$REPO/reg_tests/r-test/glue-codes/fast-farm +FSTAGE=$REPO/build-docker-double/reg_tests/glue-codes/fast-farm +[ -d "$FSTAGE/5MW_Baseline/ServoData" ] && FSRC=$FSTAGE || FSRC=$FRT +FG=$GC/farm + +# $1 = variant name. Edits are applied to turbine 1 only; turbine 2 is the +# control within every run, so a guard that fires for the wrong turbine shows up. +mkfarm () { + local name=$1 + rm -rf "$FG/$name"; mkdir -p "$FG" + cp -r "$FSRC/TSinflow" "$FG/$name" + # Long enough to get through initialisation, short enough not to be a test run. + sed -i -E 's/^[0-9.]+([[:space:]]+TMax)/5.0\1/' "$FG/$name/FAST.Farm.fstf" +} + +# $1 = variant, $2 = text to look for, $3 = "yes" if it must appear +runfarm () { + local name=$1 expect=$2 want=$3 + if [ -z "$FFBIN" ]; then + echo "SKIP $name (no FAST.Farm binary; build it with 'make FAST.Farm')" + return + fi + cd "$FG/$name" || return + timeout 900 "$FFBIN" FAST.Farm.fstf > run.log 2>&1 + local rc=$? got=no + grep -qF "$expect" run.log && got=yes + if [ "$got" = "$want" ]; then + echo "PASS $name (rc=$rc) guard fired=$got, expected=$want" + else + echo "FAIL $name (rc=$rc) guard fired=$got, expected=$want" + grep -iE "FATAL|Error" run.log | head -4 | sed 's/^/ /' + fi +} + +echo +echo "=== FAST.Farm guard rails ===" + +# The siblings the decks reach for. Without them the runs abort while reading +# inputs, before reaching any guard, and a control that never initialises looks +# exactly like a control whose guard stayed silent. +mkdir -p "$FG" +ln -sfn "$FSRC/WAT_MannBoxDB" "$FG/WAT_MannBoxDB" +ln -sfn "$FSRC/5MW_Baseline" "$FG/5MW_Baseline" + +MIRROR_MSG="MirrorRotor is not yet supported with FAST.Farm." +NROTOR_MSG="Only one rotor per OpenFAST instance is supported with FAST.Farm." + +# Control: one rotor, not mirrored. Neither guard may fire, and it must run. +mkfarm farm_control +runfarm farm_control "$MIRROR_MSG" no +runfarm farm_control "$NROTOR_MSG" no +runfarm farm_control "FAST.Farm terminated normally." yes + +# Multiple rotors in one instance. Every rotor reference in FASTWrapper is +# rotors(1), so rotors 2 and beyond would be simulated as though absent. +mkfarm farm_multirotor +sed -i -E 's/^([[:space:]]*)1([[:space:]]+NRotors)/\1 2\2/' "$FG/farm_multirotor/FFTest_WT1.fst" +sed -i -E 's/^([[:space:]]*)F([[:space:]]+MirrorRotor)/\1 F F\2/' "$FG/farm_multirotor/FFTest_WT1.fst" +# NRotors > 1 needs a second block of per-rotor input files or the deck will not +# read, and a deck that fails to read never reaches the guard. +awk '/^---------------------- OUTPUT/ && !ins { + print "---------------------- INPUT FILES Rotor 2 -------------------------------------"; + print "\"NRELOffshrBsline5MW_Onshore_ElastoDyn_8mps.dat\" EDFile"; + print "\"unused\" BDBldFile(1)"; + print "\"unused\" BDBldFile(2)"; + print "\"unused\" BDBldFile(3)"; + print "\"NRELOffshrBsline5MW_Onshore_ServoDyn_WT1.dat\" ServoFile"; + ins=1 } { print }' \ + "$FG/farm_multirotor/FFTest_WT1.fst" > "$FG/farm_multirotor/.tmp" \ + && mv "$FG/farm_multirotor/.tmp" "$FG/farm_multirotor/FFTest_WT1.fst" +runfarm farm_multirotor "$NROTOR_MSG" yes + +# A mirrored turbine in a farm. The mirror is confined to its own OpenFAST +# instance and the blade surfaces do render correctly, but the wake coupling has +# never been measured: FWrap_CalcOutput builds the skew angle from a cross +# product of the disk-averaged wind with the disk normal, which is the +# pseudovector pattern that carries a sign at every other module boundary here. +# Until that is checked the combination is refused rather than run quietly. +mkfarm farm_mirror +sed -i -E 's/^([[:space:]]*)F([[:space:]]+MirrorRotor)/\1 True\2/' "$FG/farm_mirror/FFTest_WT1.fst" +runfarm farm_mirror "$MIRROR_MSG" yes diff --git a/reg_tests/otherTests/sed_canary.py b/reg_tests/otherTests/sed_canary.py new file mode 100644 index 0000000000..53f91b911c --- /dev/null +++ b/reg_tests/otherTests/sed_canary.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Phase 1.0b canary: the HSS brake must behave identically on a mirrored rotor. + +The brake is the one drivetrain load that is signed by the direction the shaft is +actually turning rather than by a convention, so it is the easiest place to get a +mirrored rotor wrong: a blanket sign flip makes the brake drive the rotor instead +of stopping it. This runs the SED HSS-brake case clockwise and mirrored and +requires the rotor-convention channels to be identical, not merely similar. + +Usage: sed_canary.py +""" +import os +import re +import shutil +import subprocess +import sys + +import numpy as np + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +REPO = REPO_ROOT +CASE = f"{REPO}/reg_tests/r-test/modules/simple-elastodyn/sed_test_HSSbrk" +DRIVER = f"{REPO}/build-docker-double-debug/modules/simple-elastodyn/sed_driver" +WORK = os.path.join(os.environ.get("TMPDIR", "/tmp"), "sed_canary") + +# RotSpeed and Azimuth are reported in the rotor's own convention, so a mirrored +# rotor spinning its design direction must read exactly the same as its CW twin. +IDENTICAL = ["Azimuth", "RotSpeed", "RotAcc", "GenSpeed", "GenAcc"] + +# The same two quantities carrying an explicit axis suffix are the physical components +# about the shaft x axis, so they must come out exactly opposite. Requesting them is the +# only way the split is visible at all -- the case ships with the rotor-convention names +# alone, and those read identically whether or not the split exists. +FLIPPED = ["LSSTipVxa", "LSSTipAxa"] + + +def load(path): + lines = open(path, errors="replace").read().split("\n") + h = next(i for i, l in enumerate(lines) if l.strip().startswith("Time")) + names = lines[h].split() + rows = [[float(x) for x in l.split()] for l in lines[h + 2:] if l.strip()] + return names, np.array(rows) + + +def build(mirrored): + name = "mir" if mirrored else "cw" + d = os.path.join(WORK, name) + shutil.rmtree(d, ignore_errors=True) + shutil.copytree(CASE, d) + + inp = os.path.join(d, "sed_primary.inp") + txt = open(inp).read() + txt = txt.replace('"RotTorq"\n', '"RotTorq"\n' + "".join(f'"{c}"\n' for c in FLIPPED)) + open(inp, "w").write(txt) + + dvr = os.path.join(d, "sed_driver.dvr") + txt = open(dvr).read() + txt = re.sub(r"^(\s*)\S+(\s+MirrorRotor\s)", + lambda m: f"{m.group(1)}{'True' if mirrored else 'False'}{m.group(2)}", + txt, count=1, flags=re.MULTILINE) + txt = re.sub(r'^("?)[^"\n]*\1(\s+OutRootName\s)', + lambda m: f'"{name}"{m.group(2)}', txt, count=1, flags=re.MULTILINE) + open(dvr, "w").write(txt) + p = subprocess.run([DRIVER, "sed_driver.dvr"], cwd=d, + capture_output=True, text=True) + out = os.path.join(d, f"{name}.out") + if not os.path.exists(out): + print((p.stdout + p.stderr)[-1500:]) + sys.exit(f"{name} run failed") + return out + + +def main(): + cw, mir = build(False), build(True) + n1, a = load(cw) + n2, b = load(mir) + idx = {k: v for v, k in enumerate(n1)} + + print(f"{'channel':10s} {'max|CW|':>12s} {'max abs diff':>14s} verdict") + bad = 0 + for ch in IDENTICAL: + if ch not in idx: + continue + x, y = a[:, idx[ch]], b[:, idx[ch]] + diff = np.abs(y - x).max() + ok = diff == 0.0 + bad += not ok + print(f"{ch:10s} {np.abs(x).max():12.5g} {diff:14.3e} " + f"{'IDENTICAL' if ok else 'DIFFERS'}") + + for ch in FLIPPED: + if ch not in idx: + print(f"{ch:10s} {'':>12s} {'':>14s} MISSING - split not exercised") + bad += 1 + continue + x, y = a[:, idx[ch]], b[:, idx[ch]] + diff = np.abs(y + x).max() + ok = diff == 0.0 and np.abs(x).max() > 0.0 + bad += not ok + print(f"{ch:10s} {np.abs(x).max():12.5g} {diff:14.3e} " + f"{'EXACTLY OPPOSITE' if ok else 'NOT OPPOSITE'}") + + # The brake must remove energy in both runs, whichever way the shaft turns. + sp = a[:, idx["RotSpeed"]] + spm = b[:, idx["RotSpeed"]] + print(f"\nCW RotSpeed {sp[0]:.4f} -> {sp[-1]:.4f} rpm") + print(f"MIR RotSpeed {spm[0]:.4f} -> {spm[-1]:.4f} rpm") + if abs(spm[-1]) > abs(spm[0]): + print("FAIL: mirrored rotor sped up - the brake is driving it") + bad += 1 + + print("\nCANARY PASS" if not bad else "\nCANARY FAIL") + sys.exit(1 if bad else 0) + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/sweep_mirror.py b/reg_tests/otherTests/sweep_mirror.py new file mode 100644 index 0000000000..80b6a877f3 --- /dev/null +++ b/reg_tests/otherTests/sweep_mirror.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Phase 1.0a sweep: run the CW/mirrored driver pair across a matrix of conditions. + +Each variant edits the base driver deck and AeroDyn primary file, runs the pair, +and requires every channel to resolve to same / flipped / mirrored-angle / +negligible. A single condition proves very little on its own; the point of the +matrix is that each row exercises a different assumption in the sign map. + +Quantities defined in the global frame (nacelle yaw) are not rotor-convention +inputs, so the mirrored deck must be given the mirrored value explicitly. That is +what "mirror" entries do. Everything else stays identical between the pair, +which is the whole claim being tested. + +Usage: sweep_mirror.py [name ...] (no args runs the full matrix) +""" +import os +import re +import shutil +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from compare_mirror import compare # noqa: E402 + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(HERE)) +BASE = os.path.join(os.path.dirname(HERE), "driver_checks") +DRIVER = os.path.join(REPO_ROOT, "build-docker-double-debug", + "modules", "aerodyn", "aerodyn_driver") +WORK = os.path.join(os.environ.get("TMPDIR", "/tmp"), "mirror_sweep") + +# name -> (dvr edits, primary edits, mirrored-deck overrides[, tol]) +# Tolerance defaults to 1e-6; raise it only where a stateful model makes exact +# agreement unreasonable, and say why. +MATRIX = [ + ("base", {}, {}, {}), + ("pitch_+10", {"BldPitch(1)": "10.0"}, {}, {}), + ("pitch_-3", {"BldPitch(1)": "-3.0"}, {}, {}), + ("wind_4", {"HWindSpeed": "4.0"}, {}, {}), + ("wind_20_stall", {"HWindSpeed": "20.0"}, {}, {}), + ("highTSR_25rpm", {"RotSpeed(1)": "25.0"}, {}, {}), + ("lowTSR_3rpm", {"RotSpeed(1)": "3.0"}, {}, {}), + ("propbrake", {"HWindSpeed": "25.0", "RotSpeed(1)": "3.0", + "BldPitch(1)": "-10.0"}, {}, {}), + # Shaft tilt skews the inflow, so the rotor sees azimuthal variation, but blade + # spacing still follows the rotation sense, so the blades pair up index for index. + ("tilt_5", {"ShftTilt(1)": "-5.0"}, {}, {}), + ("precone_2.5", {"Precone(1)": "2.5"}, {}, {}), + # Vertical shear is symmetric about the mirror plane, so the deck is shared; + # the blades still pair up index for index. + ("shear_0.2", {"PLExp": "0.2"}, {}, {}), + # Yaw is a global-frame orientation, so the mirrored run gets -yaw. + ("yaw_20", {"NacYaw(1)": "20.0"}, {}, {"NacYaw(1)": "-20.0"}), + ("yaw_20_skew", {"NacYaw(1)": "20.0"}, {"Skew_Mod": "1"}, + {"NacYaw(1)": "-20.0"}), + ("yaw_-20", {"NacYaw(1)": "-20.0"}, {}, {"NacYaw(1)": "20.0"}), + ("wake_off", {}, {"Wake_Mod": "0"}, {}), + ("bem_polar", {}, {"BEM_Mod": "2"}, {}), + # The polar BEM path measures skew with its own psiSkewOffset, built from a + # separate cross product, so it needs covering independently of BEM_Mod=1. + ("bem_polar_skew", {"NacYaw(1)": "20.0"}, {"BEM_Mod": "2", "Skew_Mod": "1"}, + {"NacYaw(1)": "-20.0"}), + ("bem_polar_shear", {"PLExp": "0.2"}, {"BEM_Mod": "2"}, {}), + ("dbemt_2", {}, {"DBEMT_Mod": "2"}, {}), + ("ua_3", {}, {"UA_Mod": "3"}, {}), + ("ua_6_oye", {}, {"UA_Mod": "6"}, {}), + ("ua_4_hgm", {}, {"UA_Mod": "4"}, {}), + # Shear + yaw + unsteady aero is the stiffest combination in the matrix. UA carries + # state and is solved iteratively, so the two runs converge to slightly different + # round-off; the residual here is ~1.5 N-m on a 9e5 N-m moment. + ("shear_yaw_ua", {"PLExp": "0.2", "NacYaw(1)": "15.0"}, + {"UA_Mod": "3", "Skew_Mod": "1"}, + {"NacYaw(1)": "-15.0"}, 1e-5), +] + + +def set_var(text, var, value): + """Replace the value column on the line whose comment names `var`.""" + pat = re.compile(r"^(\s*)(\S+)(\s+" + re.escape(var) + r"\s)", + re.MULTILINE) + new, n = pat.subn(lambda m: f"{m.group(1)}{value}{m.group(3)}", text, count=1) + if n != 1: + raise KeyError(f"could not set {var!r}") + return new + + +def build_case(dst, mirrored, dvr_edits, pri_edits, overrides): + os.makedirs(dst, exist_ok=True) + for f in os.listdir(BASE): + if f.endswith((".dat", ".dvr", ".csv", ".ipt")): + shutil.copy(os.path.join(BASE, f), dst) + + pri = open(os.path.join(BASE, "ad_primary.dat")).read() + for k, v in pri_edits.items(): + pri = set_var(pri, k, v) + open(os.path.join(dst, "ad_primary.dat"), "w").write(pri) + + dvr = open(os.path.join(BASE, "ad_CW.dvr")).read() + edits = dict(dvr_edits) + if mirrored: + edits.update(overrides) + for k, v in edits.items(): + dvr = set_var(dvr, k, v) + dvr = set_var(dvr, "MirrorRotor(1)", "True" if mirrored else "False") + name = "mir" if mirrored else "cw" + open(os.path.join(dst, f"{name}.dvr"), "w").write(dvr) + return os.path.join(dst, f"{name}.dvr") + + +def run(dvr_path): + d, f = os.path.split(dvr_path) + p = subprocess.run([DRIVER, f], cwd=d, capture_output=True, text=True) + out = os.path.join(d, f[:-4] + ".out") + if p.returncode != 0 or not os.path.exists(out): + tail = [l for l in (p.stdout + p.stderr).split("\n") if l.strip()][-2:] + return None, " | ".join(tail) + return out, "" + + +def main(): + wanted = sys.argv[1:] + rows = [r for r in MATRIX if not wanted or r[0] in wanted] + shutil.rmtree(WORK, ignore_errors=True) + + fails = 0 + print(f"{'variant':18s} {'chk':>4s} result") + for row in rows: + name, dvr_e, pri_e, over = row[:4] + tol = row[4] if len(row) > 4 else 1e-6 + d = os.path.join(WORK, name) + try: + cw = build_case(d, False, dvr_e, pri_e, over) + mir = build_case(d, True, dvr_e, pri_e, over) + except KeyError as exc: + print(f"{name:18s} {'-':>4s} SETUP {exc}") + fails += 1 + continue + + cw_out, err = run(cw) + if cw_out is None: + print(f"{name:18s} {'-':>4s} CW RUN FAILED {err}") + fails += 1 + continue + mir_out, err = run(mir) + if mir_out is None: + print(f"{name:18s} {'-':>4s} MIRROR RUN FAILED {err}") + fails += 1 + continue + + checked, bad, unknown, _ = compare(cw_out, mir_out, tol=tol) + tag = f"tol={tol:g}" if tol != 1e-6 else "" + if bad or unknown: + fails += 1 + print(f"{name:18s} {checked:4d} FAIL {tag}") + for ch, rel in unknown[:4]: + print(f"{'':18s} unclassified {ch} rel={rel:.2e}") + for ch, exp, got, rel in bad[:4]: + print(f"{'':18s} {ch} expected {exp} got {got} rel={rel:.2e}") + extra = len(bad) + len(unknown) - 8 + if extra > 0: + print(f"{'':18s} ... and {extra} more") + else: + print(f"{name:18s} {checked:4d} PASS {tag}") + + print(f"\n{len(rows) - fails}/{len(rows)} variants pass") + sys.exit(1 if fails else 0) + + +if __name__ == "__main__": + main() diff --git a/reg_tests/otherTests/v2_two_rotor.py b/reg_tests/otherTests/v2_two_rotor.py new file mode 100644 index 0000000000..e7c618a389 --- /dev/null +++ b/reg_tests/otherTests/v2_two_rotor.py @@ -0,0 +1,74 @@ +"""V2: one clockwise rotor and one mirrored rotor inside a single driver run. + +Turbine 2 carries MirrorRotor, turbine 1 does not, and the two are otherwise the +same machine in the same uniform inflow. Their loads must therefore be exact +mirror images of each other, with no run-to-run difference of any kind to hide +behind: same solver, same time steps, same wind. + +Blade spacing follows the rotation sense, so a mirrored blade compares directly +against the clockwise blade of the same number. +""" +import os +import sys + +import numpy as np + +REPO_ROOT = os.environ.get("OPENFAST_REPO") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +sys.path.insert(0, os.path.join(REPO_ROOT, "reg_tests", "lib")) +import fast_io # noqa: E402 + +WORK = sys.argv[1] if len(sys.argv) > 1 else os.path.join( + os.environ.get("TMPDIR", "/tmp"), "v2test", "mir") +TOL = float(sys.argv[sys.argv.index("--tol") + 1]) if "--tol" in sys.argv else 1e-6 + + +def load(path): + d, info, _ = fast_io.load_output(path) + return info["attribute_names"], np.asarray(d) + + +def find(turbine): + for f in sorted(os.listdir(WORK)): + if f".T{turbine}." in f and f.endswith((".outb", ".out")): + return os.path.join(WORK, f) + sys.exit(f"no output file for turbine {turbine} in {WORK}") + + +n1, a = load(find(1)) +n2, b = load(find(2)) +if n1 != n2: + sys.exit("turbine 1 and turbine 2 report different channels") + +idx = {k: v for v, k in enumerate(n1)} +sl = slice(int(0.25 * len(a)), None) +phys = [idx[c] for c in n1 if c != "Time"] +floor = 1e-9 * np.abs(a[sl][:, phys]).max() + +groups = {} +for ch in n1: + if ch == "Time": + continue + x, y = a[sl, idx[ch]], b[sl, idx[ch]] + peak = np.abs(x).max() + if peak < floor: + groups.setdefault("negligible", []).append((ch, 0.0)) + continue + rs = np.abs(y - x).max() / peak + rf = np.abs(y + x).max() / peak + if rs < TOL: + groups.setdefault("S", []).append((ch, rs)) + elif rf < TOL: + groups.setdefault("F", []).append((ch, rf)) + else: + groups.setdefault("?", []).append((ch, min(rs, rf))) + +for k, label in (("S", "same"), ("F", "flipped"), ("negligible", "negligible")): + if groups.get(k): + print(f"{label:11s} {len(groups[k]):4d}") +bad = groups.get("?", []) +print(f"unresolved {len(bad):4d}") +for c, r in sorted(bad, key=lambda t: -t[1])[:25]: + print(f" {c:16s} {r:.3e}") +sys.exit(1 if bad else 0) diff --git a/reg_tests/r-test b/reg_tests/r-test index e773db8bbf..b52af0e9fd 160000 --- a/reg_tests/r-test +++ b/reg_tests/r-test @@ -1 +1 @@ -Subproject commit e773db8bbf04208641791de8171a4607d1997a2a +Subproject commit b52af0e9fd93767c8611ae9c59e3060131213955 diff --git a/unit_tests/CMakeLists.txt b/unit_tests/CMakeLists.txt index c85dae1018..232144e037 100644 --- a/unit_tests/CMakeLists.txt +++ b/unit_tests/CMakeLists.txt @@ -38,6 +38,7 @@ add_executable(beamdyn_utest ${PROJECT_SOURCE_DIR}/modules/beamdyn/tests/test_BD_Misc.F90 ${PROJECT_SOURCE_DIR}/modules/beamdyn/tests/test_BD_diffmtc.F90 ${PROJECT_SOURCE_DIR}/modules/beamdyn/tests/test_BD_MemberEta.F90 + ${PROJECT_SOURCE_DIR}/modules/beamdyn/tests/test_BD_MirrorBladeData.F90 ${PROJECT_SOURCE_DIR}/modules/beamdyn/tests/test_BD_QuadraturePointData.F90 ${PROJECT_SOURCE_DIR}/modules/beamdyn/tests/test_BD_ShapeFuncs.F90 ${PROJECT_SOURCE_DIR}/modules/beamdyn/tests/test_BD_TrapezoidalPointWeight.F90