Skip to content

F/reverse rotor - #3436

Draft
andrew-platt wants to merge 51 commits into
OpenFAST:devfrom
andrew-platt:f/reverseRotor
Draft

F/reverse rotor#3436
andrew-platt wants to merge 51 commits into
OpenFAST:devfrom
andrew-platt:f/reverseRotor

Conversation

@andrew-platt

Copy link
Copy Markdown
Collaborator

Draft

Feature or improvement description
Enabling the MirrorRotor option in OpenFAST. Complete description coming soon.

Current development status

Phase Scope Status
0 Scope, findings, definition of done ✅ Complete
1 AeroDyn + ElastoDyn, steady wind, no controller ✅ Complete
2 BeamDyn blades ✅ Complete
3 Controller in the loop (ServoDyn) ✅ Complete
4 System integration (floating, SED/ADsk, multi-rotor) ⬜ Not started
5 Documentation consolidation + implementation report 🟡 Partial
6 Remaining coverage (linearization, FAST.Farm, OLAF, …) ⬜ Not started

Phase detail

Step Description Status
1.0 Isolated AeroDyn/SED driver checks ✅ 23/23 sweep, SED canary
1.1 Guard rails and baseline hygiene
1.1a HSS brake + tight coupling guard ✅ Already upstream
1.2–1.3 CW + mirrored steady cases ✅ Registered, passing
1.4 Code changes (AD, ED, SED, glue)
1.5 Iterate to a clean mirror ✅ 9/9 condition matrix
1.6 Documentation
2.1 BeamDyn cases ✅ Registered pair
2.2 The 6×6 transform ✅ + 4 unit tests
2.3 Verification ladder ✅ 13/13 matrix, ratio 1.000
3.1 CW presentation layer
3.2 Mirrored inflow (MirrorY.bts) ✅ Verified to 8.9e-9
3.3 Cases (brake + turbulent ROSCO ×2) ✅ Registered, passing
4.1 Floating system
4.2 Simplified ElastoDyn + AeroDisk ⬜ Includes SED alias split
4.3 Two-rotor mirrored case
4.4 Existing multi-rotor cases
5.1 User documentation ✅ Written, needs final pass
5.2 Implementation report
5.3 Final home for the tooling ⬜ Still in scratch
6.1 Linearization / MBC3 ⬜ Guard rail active
6.2 FAST.Farm
6.3a OLAF rotor-level triage ⬜ Guard rail active
6.3b OLAF / FVW full work ⬜ Guard rail active
6.4 AeroAcoustics ⬜ Guard rail active
6.5 C++ / ExtLoads / ExtInflow, AeroMap, StrucCtrl
6.6 openfast_io round-trip

Verification

Check Result
Full regression (Release, double) ✅ 76/79 — 3 known-temperamental, pre-existing
ctest -L mirrorrotor ✅ 7/7
Committed mirror pairs ✅ 5/5 clean
Condition matrix ✅ 13/13
BeamDyn unit tests ✅ 4/4

Outstanding non-blockers

Item Note
OutListParameters.xlsx You apply by hand; list in DECISIONS.md
Tooling location 18 scripts still in tools
Furling Now operational with a warning; no mirror pair verifies it

Created 3 todos

Test results, if applicable

  • r-test branch merging required

andrew-platt and others added 21 commits August 18, 2026 18:25
Points at r-test commit baef6e291, which sets MirrorRotor to F in the
three multi-rotor cases that previously set "F T". The flag is
currently a parsing-only stub (FAST_Subs.f90:3080-3083), so results
are unchanged.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ment-mismatch)

Debug builds with gfortran fail to compile fftpack5.1.f with 64 hard errors:

  Error: Type mismatch in argument 'dsum' at (1); passed REAL(4) to REAL(8)
  Error: Type mismatch in argument 'c' at (1); passed REAL(4) to COMPLEX(4)

These mismatches are intentional: FFTPACK 5.1 uses legacy Fortran 77 type
punning and is deliberately compiled with -fno-default-real-8 while the rest
of the build uses -fdefault-real-8, so its callers can pass explicit SiKi/R4Ki
arrays. -fallow-argument-mismatch is already applied to downgrade them to
warnings.

However, Debug builds add -pedantic, which promotes the argument mismatches
back to errors and overrides -fallow-argument-mismatch. Appending
-Wno-pedantic to this file's compile flags restores the intended behaviour.
The suppression is scoped to this single legacy source; Release builds are
unaffected as they do not pass -pedantic.

Verified on gfortran 12.2.0 with
  cmake -DDOUBLE_PRECISION=On -DCMAKE_BUILD_TYPE=Debug -DGENERATE_TYPES=On ..
Isolation test on the exact compile line:
  -pedantic                            -> 64 errors
  -pedantic -std=legacy                -> 64 errors
  -pedantic -Wno-argument-mismatch     -> 64 errors
  -pedantic -Wno-pedantic              ->  0 errors (object builds)

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adds the first working slice of counter-clockwise rotor support. A new
RotInitInputType flag MirrorRotor selects a RotParameterType RotDir of
-1, which is applied only at the AeroDyn module boundary. BEMT, unsteady
aero, DBEMT and AirfoilInfo are untouched and continue to solve the
equivalent clockwise problem, so airfoil polars are used verbatim and no
polar transformation is needed.

Applied on the way in (SetInputsForBEMT): omega, psi_s, theta, toeAngle,
Vy and omega_z. The cant angle is deliberately not flipped. Blade twist
and swept-AC offset are negated during init, after the setCantAngle loop,
because setCantAngle derives BlCrvAng from BlTwist and the cant must not
follow the mirror.

Applied on the way out: the y-component of forces and the x and z
components of moments, following v' = Sv for true vectors and w' = -Sw
for pseudovectors with S = diag(1,-1,1).

Several write-outputs mixed the two frames and are corrected here. Rotor
and blade power used the clockwise omega against a mirrored Mxh, which
produced negative power. BNFl, BNFd, BNFn and BNFt combined mirrored
forces with clockwise phi and theta. BNVIndy multiplied two clockwise
quantities. BNCy, BNCt and BNCm now carry the mirror. RtTSR keeps the
clockwise omega, which is the value that stays positive. The free-wake
copy of the blade output block is left alone; OLAF remains guard-railed.

The AeroDyn driver gains a per-turbine MirrorRotor(i) input and mirrors
the prescribed hub kinematics, standing in for ElastoDyn.

Verified against a paired clockwise/mirrored NREL 5MW driver case: every
one of the 150 output channels is exactly equal, exactly sign-flipped, a
mirrored angle, or below the numerical noise floor, with zero residual.
BEMT internals including Alpha, Phi, Vrel, Cl, Cd and the induction
factors are bit-identical between the two runs.

MirrorRotor defaults to false and the clockwise path is unchanged; the
AeroDyn driver regression cases reproduce their baselines bit-for-bit.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A condition sweep over the AeroDyn driver (pitch, wind speed, tip-speed
ratio, propeller-brake, tilt, precone, shear, yaw, both BEM models, DBEMT
and four unsteady-aero models) exposed four sign errors that the original
axisymmetric zero-pitch check could not reach.

Blade pitch was not mirrored by the driver. Blade twist is negated when
the inputs are read, so with a non-zero pitch the two ended up in opposite
frames and the local angle of attack was wrong. The driver stands in for
ElastoDyn here, so it now applies RotDir to the prescribed pitch and its
rate, matching p%BlPitch = p%RotDir * BlPitch.

The skew-aligned disk frame used to measure the azimuth fed to the skewed
wake correction is built from cross(V_diskAvg, x_hat_disk). That is a
pseudovector, so under the mirror the frame changed handedness and the
azimuth came out as pi minus azimuth rather than negated. The correction
was then applied with the wrong sign, moving the axial induction the wrong
way around the disk. RotDir is now applied to that vector, which leaves
the azimuth already in the clockwise-equivalent frame, so psi_s no longer
needs its own factor. This was invisible without Skew_Mod = 1 because
psi_s is not used elsewhere.

The polar BEM path builds psiSkewOffset from a second cross product and
needed the same treatment; applying RotDir to z_vec makes that triad
mirror as S R S, after which theta(1) simply negates.

Finally the reported skew angle is placed in the rotor's own convention.
Its sign is cosmetic, since chi is formed from abs(chi0), and this makes
the two BEM models agree with each other under the mirror.

All 23 sweep variants now pass: every channel is identical, exactly
sign-flipped, or below the numerical noise floor. The clockwise path is
unchanged and the AeroDyn driver regression cases stay bit-identical.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adds MirrorRotor support to SimplifiedElastoDyn, the drivetrain half of
the counter-clockwise rotor work. As in AeroDyn the mirror lives at the
module boundary: the azimuth and rotor speed states are physical, while
inputs and outputs stay in the rotor's own convention so a mirrored rotor
turning its design direction still reads positive.

RotDir is applied to the initial azimuth and rotor speed, to the
generator torque on its way in, to the commanded blade pitch where it is
turned into blade root geometry, and to the reported azimuth, speed,
acceleration, torque and power. The HSS brake torque is deliberately left
alone: it is already signed by the direction the shaft is turning, so a
blanket sign flip would make the brake drive the rotor instead of
stopping it.

That last point exposed a latent bug. SED_AB4 and SED_ABM4 both sign the
brake with SIGN(HSSBrTrqC, qdt), but SED_RK4 assigned the commanded
torque directly, so the brake always acted in one fixed direction
regardless of which way the shaft was turning. A clockwise rotor spinning
forwards never notices, but ABM4 uses RK4 for startup, so a mirrored
rotor was accelerated by its own brake for the first two steps before the
multistep scheme took over and corrected the sign. RK4 now matches the
other two integrators.

Verified with a brake canary: the HSS brake case run clockwise and
mirrored now produces bit-identical azimuth, rotor speed, rotor
acceleration, generator speed and generator acceleration, and both runs
decelerate 12 rpm to rest.

The clockwise path is unchanged. sed_test_freewheel is bit-identical.
sed_test_HSSbrk is identical while the rotor is turning and differs only
after it has stopped, where the brake sign is now taken from a rotor
speed of order 1e-18; that shifts the far digits of the existing
stopped-state chatter by a relative 1e-10.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
MirrorRotor already existed in the OpenFAST primary input file as a stub:
it was read into p_FAST%MirrorRotor but never used. This connects it.

The glue code now passes the per-rotor flag into the ElastoDyn and
AeroDyn initialization inputs, and rejects the combinations that have not
been worked through yet: linearization, the steady-state aero map solver,
SimplifiedElastoDyn, BeamDyn, AeroDisk, ExtLoads and ServoDyn. AeroDyn
adds two more of its own, for the OLAF free wake and the acoustics model.
Each of these guards is removed as the corresponding module is done.

ElastoDyn gets the same boundary treatment as AeroDyn and SED. The
azimuth and rotor speed states are physical, so the initial azimuth,
rotor speed and blade pitch are mirrored as they are read, and the
structural twist is negated to match the aerodynamic twist that AeroDyn
already negates. Blade one sits at QT(GeAz) + AzimB1Up + pi/2, so the
azimuth initial condition keeps those offsets outside the mirror.

On the drivetrain, the generator torque is converted from the ServoDyn
convention onto the physical shaft and the high-speed shaft speed is
reported in the rotor's own convention. As in SED the brake torque is
left alone, since it is already signed by the direction the shaft is
turning and mirroring the whole bracket would make it drive the rotor.

MirrorRotor still defaults to false everywhere. 5MW_Land_DLL_WTurb and
5MW_Land_DLL_WTurb_SED remain bit-identical to their baselines.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The blade coordinate system is built from the commanded pitch, not from
the pitch degree of freedom, so mirroring only the pitch DOF state left
the blade geometry unmirrored whenever the pitch DOF is off, which is the
usual case. The rotor then ran with the twist mirrored but the pitch not,
which put every blade at the wrong angle of attack.

With a rigid rotor the mirrored machine produced 12 percent more thrust
than its clockwise twin. The blade root twist angle reported by AeroDyn
made it obvious: 14.308 degrees clockwise against 12.308 mirrored, where
13.308 is the root twist and 1.0 the pitch, so the two were arriving with
opposite relative signs instead of both negated.

SetCoordSy now mirrors the commanded pitch, matching the pitch DOF state
which is already physical. The AeroDyn summary confirmed the blade twist
and swept-axis offset were already being negated correctly, so only the
pitch needed changing.

With a rigid rotor the two runs now agree exactly: thrust identical,
torque exactly opposite. With the flexible blade and tower degrees of
freedom enabled every one of the eighty output channels resolves to
identical, exactly sign-flipped, a mirrored angle, or the numerical noise
floor. The signs follow the mirror operator throughout, with tower base
side-to-side force and fore-aft moment flipping while fore-aft force and
side-to-side moment do not, and out-of-plane blade deflection matching
while in-plane deflection flips.

5MW_Land_DLL_WTurb, 5MW_Land_DLL_WTurb_SED and 5MW_Land_BD_DLL_WTurb are
all still bit-identical to their baselines.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The gearbox efficiency factor is selected from the sign of the low-speed
shaft torque about the shaft axis. A mirrored rotor generates with a
negative shaft torque, so that test classified generation as motoring and
raised the gearbox factor to 1/GBoxEff instead of GBoxEff, turning the
loss into a gain. The test now uses the torque in the rotor's own
convention, which is the direction power actually flows.

The high-speed shaft torque and power are reported in the rotor's own
convention as well, so they are unchanged by the mirror. The low-speed
shaft torque keeps its axis suffix and stays physical, so it flips.

Verified with a free drivetrain, loose coupling and a 95 percent gearbox.
Those all matter: the baseline gearbox is 100 percent efficient, which
makes both branches of the factor identical, and SignLSSTrq is only
reached from the loose-coupling integrators, so a tight-coupled run with a
lossless gearbox cannot see this at all. With the fix reverted under those
settings, 74 channels fail; with it in place every channel resolves.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Several ElastoDyn channel names share one AllOuts slot even though they
mean different things once a rotor can be mirrored. RotSpeed and
LSSTipVxa were the same number, as were Azimuth and LSSTipPxa, RotAccel
and LSSTipAxa, and RotTorq, LSShftTq and LSShftMxa. A name carrying an
explicit axis suffix should report the physical component, while a name
describing a rotor or drivetrain quantity should report it as the rotor
experiences it.

Four AllOuts slots are added for the rotor-convention channels and the
five affected names are pointed at them. The names, units and ordering of
the parameter tables are untouched, so the three parallel arrays keep
their length and alphabetical ordering; only the index array changes.
Generator speed and acceleration move to the rotor convention too, since
the generator side stays positive whichever way the rotor was built.

For a clockwise rotor RotDir is +1 and every one of these is numerically
identical to what it was, so no existing deck or baseline changes. The
split only becomes visible when MirrorRotor is set.

The three parameter tables are indexed in parallel with nothing in the
build checking they agree, so a misalignment would silently report the
wrong channel. tools/check_ed_outparams.py parses all three out of the
source and verifies the lengths match, the names stay sorted and unique,
and every index symbol is declared and within MaxOutPts.

Mirrored against the clockwise pair, RotSpeed, RotAccel, Azimuth,
RotTorq, LSShftTq, HSShftTq, GenSpeed, GenAccel, RotPwr and RotThrust now
all read identically, while LSShftMxa, LSSTipVxa, LSSTipAxa and LSSGagMxa
flip and LSSTipPxa and LSSGagPxa mirror as angles. Rigid, flexible and
free-drivetrain runs all resolve completely. Four regression cases
including an offshore one stay bit-identical.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adds docs/source/user/glue-code/mirror_rotor.rst covering what MirrorRotor
does, the two output conventions and why they differ, the measured sign
table, the current limitations, and how the mirror is verified.

The sign table is measured rather than asserted: it comes from running the
same model clockwise and mirrored and comparing every output channel. The
note that mirroring reverses the blade sweep order, so blade 2 of one
rotor corresponds to blade 3 of the other under non-axisymmetric inflow,
is there because that is an easy way to misread a comparison.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adds 5MW_Land_noDLL_Steady_CW and 5MW_Land_noDLL_Steady_MirrorRotor to the
regression suite under a new mirrorrotor label, so the pair can be run on
its own while a mirrored rotor is still being worked through.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
BeamDyn describes a blade by its key-point geometry and a 6x6 sectional
mass and stiffness matrix. Mirroring the blade about its local x-z plane
reflects y, which negates the key-point y offset and the twist, and
transforms the matrices by T*M*T^T with T = diag(1,-1,1,-1,1,-1). The
theta entries pick up a sign because rotations are pseudovectors. In
practice every matrix entry flips exactly when one of its indices is in
{2,4,6}.

The transform is applied between reading the input and validating it, so
the polar-inertia check runs against the mirrored blade. That check
compares mass0(6,6) against mass0(4,4) + mass0(5,5), all diagonal
entries, which the transform leaves alone.

Note the twist stored on read is already the negative of the value in the
file, so negating it here leaves it equal to the file value rather than
doubly negated.

A unit test covers the transform directly: that applying it twice is the
identity, that entries flip exactly when one index is in {2,4,6}, that
the polar-inertia constraint survives, and that key points reflect in y
and reverse twist while the x and z offsets are untouched.

Coupled, a mirrored BeamDyn rotor now resolves cleanly against its
clockwise twin, including the BeamDyn root and tip channels: the x and z
forces, the y moment and the x and z deflections match, while the y
force, the x and z moments and the y deflection flip. This holds with
steady inflow, shear, yaw and a free drivetrain.

BeamDyn's guard rail is removed. The clockwise path is unchanged and
5MW_Land_BD_DLL_WTurb and 5MW_Land_BD_DLL_WTurb_StC stay bit-identical.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adds 5MW_Land_BD_noDLL_Steady_CW and 5MW_Land_BD_noDLL_Steady_MirrorRotor
under the mirrorrotor label alongside the ElastoDyn pair.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
y%BlPitch was inconsistent: it carried the physical pitch angle when the pitch
DOF was enabled, but the command it was handed when it was not. Apply RotDir to
the DOF branch so the output is always in the command's convention, which is
what ServoDyn is given.

HSSBrTq belongs to the same generator-side family as HSShftTq and HSShftPwr,
so it reads in the rotor's own convention rather than about the +x shaft axis.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A furling machine is chiral by design: the tail boom, tail fin and the rotor-
and tail-furl axes are all offset to one side, and the nacelle mass centre is
laterally offset with them. Reversing only the rotor leaves that geometry
untouched and produces a turbine that is not the mirror of anything.

Mirroring it properly means transforming the furl input file as well, which is
out of scope, so fail rather than answer quietly.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ServoDyn is left entirely alone. Instead the signals crossing its boundary are
converted, so an unmodified controller -- including a Bladed-style DLL such as
ROSCO -- sees exactly what it would see on a clockwise rotor and needs no
knowledge that the rotor has been reversed.

In-plane and about-axis quantities flip; out-of-plane ones do not. HSS_Spd and
BlPitch already arrive in that convention from ElastoDyn, RotPwr is a product of
two flipped quantities, and ElastoDyn signs the generator and brake torques
itself, so none of those are converted here. The shaft azimuth is negated and
re-wrapped so it still increases clockwise.

Yaw is deliberately untouched. It acts about the vertical axis in the inertial
frame, so it is not a rotor-convention quantity: the yaw error stays physically
correct and a yaw controller still points the nacelle into the real wind.
ServoDyn also receives the yaw angle and rate through the ordinary variable
mapping rather than here, so converting the yaw moment on its own would inverse
the sign of its yaw spring and drive the nacelle unstable.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adds AWT_WSt_StartUp_HighSpShutDown_MirrorRotor under the mirrorrotor label.
This is the first mirrored regression case with ServoDyn in the loop, and it
covers the high-speed-shaft brake through zero rotor speed.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adds two sections. The first records that ServoDyn is left unmodified and is
handed everything in the clockwise convention, and why yaw is deliberately
excluded from that.

The second is the one that costs time if it is not written down: mirroring the
turbine does not mirror the environment or the control setpoints. Initial
nacelle yaw, wind direction, horizontal shear, a turbulence box, a prescribed
structural-control load and lateral geometry are all left as written, and
leaving one of them unmirrored looks exactly like a sign error in the code.

Also corrects the limitations table, which still listed BeamDyn and ServoDyn as
unsupported, and adds the furling restriction.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Adds 5MW_Land_DLL_WTurb_MirrorRotor and 5MW_Land_BD_DLL_WTurb_MirrorRotor under
the mirrorrotor label. These are the cases that drive a mirrored rotor with an
unmodified Bladed-style controller through a y-reflected turbulence box, and
they complete the mirrored-rotor coverage for ServoDyn.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Downgrades the furling guard from fatal to a warning and lets the run proceed.

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 the tail
does not interact with the wake. With the aerodynamic and structural mirror
already verified, there is reasonable confidence the combination behaves as
intended once the whole structure is mirrored.

What the flag does not do is mirror the furl geometry itself. That puts furling
in the same category as an initial nacelle yaw or a wind direction: an
asymmetric input the user must mirror as well if the intent is to reproduce the
mirror image of the clockwise turbine. The documentation moves it accordingly,
out of the unsupported table and into the list of inputs the mirror leaves
alone.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@andrew-platt
andrew-platt marked this pull request as draft August 19, 2026 14:11
@andrew-platt

Copy link
Copy Markdown
Collaborator Author

Where the mirror is applied

Module Source changed Where the transformation happens
Glue code (FAST_Subs, FAST_Mapping) Reads MirrorRotor and distributes it to ED, AD and BD; holds the guard rails; and carries the entire ED ↔ ServoDyn presentation layer
ServoDyn none Nothing internal. Signals are converted in the glue code, and the generator/brake torque signs are applied inside ElastoDyn. A Bladed DLL is likewise untouched
AeroDyn Four places, all at the edges — see below
BeamDyn Input data, not the boundary — the exception to the rule
ElastoDyn Initial states, blade-pitch geometry, gearbox, outputs
SimplifiedElastoDyn Same pattern as ElastoDyn
InflowWind none The user supplies a y-reflected box; the mirror does not touch the environment
BEMT / UnsteadyAero / DBEMT / AirfoilInfo none Physics kernels solve the equivalent clockwise problem
AeroDyn driver Per-turbine flag; mirrors the prescribed hub kinematics and pitch
SED driver Flag pass-through only

AeroDyn, in a little more detail

What Where Direction
Blade twist and sweep On read, after cant is derived In
Rotor speed, pitch, toe, inflow $V_y$, blade angular rate SetInputsForBEMT In
Skew-aligned disk frame DiskAvgValues$\hat{z}$ is a pseudovector, so it needs an explicit flip In
Hub and airfoil loads Load conversion out of BEMT Out
Output channels AeroDyn_IO — power, tangential force, $C_y$, $C_t$, $C_m$, lateral induction Out

Airfoil polars are used verbatim; there is no polar transformation.

The one exception

ElastoDyn, SimplifiedElastoDyn and AeroDyn all convert signals crossing a boundary. BeamDyn instead transforms the blade description itself as it is read — key-point $y$, structural twist, and the $6\times6$ stiffness and mass matrices via $T M T$ with $T = \mathrm{diag}(1,-1,1,-1,1,-1)$.

That is necessary because those matrices carry bend–twist and shear–extension couplings with a handedness of their own; presenting mirrored motion to an unmirrored blade would not give the mirrored answer. The blade input file still describes the clockwise blade, and the finite-element solver is still never told anything.

andrew-platt and others added 4 commits August 19, 2026 08:34
Three gaps found while auditing what phases 1 to 3 actually left behind.

BeamDyn was never documented. It gained support two phases ago and the only
change made here at the time was to delete the row saying it was unsupported,
which left the page implying it did not work. Worse, the concept section stated
the reflection is applied at module boundaries only, and BeamDyn is precisely
where that is untrue: its blade description is transformed on read, because the
6x6 stiffness and mass matrices carry couplings with a handedness of their own.
That claim is now qualified and the transform is written out.

Adds a module map giving, for each module, whether it changes at all and where.
ServoDyn, InflowWind and the aerodynamic and structural kernels are listed with
the others precisely because the answer for them is "nothing".

The AeroDyn driver gained a required MirrorRotor input per turbine, which the
driver documentation never mentioned and its example input blocks did not show,
so a reader copying one would have written a file the driver rejects.

Also brings the verification section up to date: it described only the first
phase, and now names the regression pairs and the controller result.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The two example driver files shipped with the documentation never gained the
MirrorRotor input the driver now requires, so anyone copying one would have
written a file the driver rejects. ad_driver_multiple.dvr needs it for both of
its turbines.

Also rewords the BasicHAWTFormat comment here and in the driver documentation to
match the regression inputs: the basic inputs are named as a range rather than
counted, since the count was wrong and MirrorRotor now sits between the flag and
them.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
andrew-platt and others added 2 commits August 19, 2026 12:54
Adds 5MW_OC4Semi_WSt_WavesWN_MirrorRotor under the mirrorrotor label, extending
mirrored-rotor coverage to HydroDyn, SeaState, MoorDyn and the platform degrees
of freedom.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
andrew-platt and others added 24 commits August 19, 2026 13:57
The page opened by saying the flag runs the same turbine as its mirror image.
It does not. It reverses the rotor, and the reflection is applied internally to
the rotor and the drivetrain quantities attached to it. The tower, nacelle,
support structure, mooring and the environment are untouched.

That distinction matters in use. A counter-clockwise machine sits in a real
wind field, on a real support structure, with a real mooring spread and real
yaw setpoints, none of which should be mirrored. The previous wording invited
the opposite conclusion, that a user should reflect their environment to match
the rotor.

The list of inputs the flag leaves alone is therefore reframed: it is a
requirement of the verification comparison, which needs the whole problem to be
symmetric, not advice for ordinary use. The measured sign table gets the same
qualification, since it records how two runs of a symmetric comparison relate
to each other rather than promising that those channels change sign whenever
the flag is set.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
SED reported rotor speed and acceleration through one slot each, reached by
both the rotor-convention names and the axis-suffixed ones. For a mirrored
rotor those two families disagree in sign, so a single slot cannot serve both.

RotSpeed and RotAcc keep the rotor's own convention, reading positive whenever
the rotor turns its design direction. LSSTipV, LSSTipVxa, LSSTipVxs and the
matching acceleration names now reach new slots holding the physical component
about the shaft x axis. This is the same split already applied to ElastoDyn.

RotTorq and LSShftTq are deliberately left sharing a slot, matching the
judgement made for ElastoDyn: a torque name with no axis suffix follows the
rotor convention.

For a clockwise rotor every one of these reduces to the same value, so no
existing model or output file changes.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
SED handed out its rotor speed already converted to the rotor's own convention,
while ElastoDyn hands out the physical value. Two consumers read it, and they
disagreed about which they wanted: ServoDyn expects the clockwise convention,
and AeroDisk was receiving the clockwise value from SED but the physical value
from ElastoDyn. No single correction inside AeroDisk could have served both.

y%RotSpeed is now the physical shaft speed, matching ElastoDyn, and the
ServoDyn boundary converts it exactly as the ElastoDyn boundary already does.
Both structural modules now hand AeroDisk the same thing.

The write-output channels are unaffected: they are computed from the states
directly and already carry the split between the rotor-convention and
axis-suffixed names.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
AeroDisk had no notion of rotation direction at all. Its aero table is defined
in the clockwise convention, so the rotor speed is converted to that convention
before the table is read, which keeps the tip-speed ratio positive and inside
the tabulated range. The coefficients that come back are reflected as they
become physical loads: force is a true vector and moment a pseudovector, so
different components change sign.

C_F and C_M themselves are left in the table's convention, which makes the Ct
and Cq outputs read in the rotor's own convention while the resulting forces
and moments read physically, with no further work. Cp and the power are
products of two flipped quantities and are unaffected.

The skew-aligned triad also needed a correction. Its third axis is built from a
cross product of two true vectors, which is a pseudovector, so on a mirrored
rotor the triad came out left-handed. This is the same error already fixed in
AeroDyn's disk-average frame. Note that it is not exercised by the regression
case added here, which is unyawed, so the out-of-plane force and moment
components are identically zero.

SimplifiedElastoDyn was never handed the flag through the glue code at all,
only through its standalone driver. Both it and AeroDisk are wired up here and
their restrictions removed.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Gemini 3.6 Flash <gemini@google.com>
Adds 5MW_Land_DLL_WTurb_ADsk_SED_MirrorRotor under the mirrorrotor label,
completing mirrored-rotor coverage of the second structural and aerodynamic
path.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
The nodal outputs are a third copy of the blade output block, alongside the one
in AeroDyn_IO.f90 and the free-wake copy beside it. Only the first was mirrored
in phase 1, so a mirrored rotor reported nodal lift, drag, normal and tangential
force, and the y-direction coefficients and induction, in a mixture of frames.

The error was invisible to every test written so far. The nodal channels are
named AB1N001Fd where the module channels are B1N001Fd, and no mirrored test
requested the nodal form. It was caught by running one clockwise and one
mirrored rotor inside a single driver run and comparing them: the angle of
attack agreed exactly, so the momentum solution was right, but the lift and drag
split was rotated by twice the inflow angle, with the magnitude preserved.

The same sign map as AeroDyn_IO.f90 is applied. The free-wake branches are left
alone deliberately: there m%Y and the inflow angle both come from the wake
solution in the same frame, so those expressions are already self-consistent.
Mirroring that path means converting its loads and its outputs together, along
with the wake itself, and is not attempted here.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Runs a clockwise and a counter-clockwise rotor in one driver run. This is the
only mirrored case whose blade outputs are in the nodal form, and the two rotors
can be compared against each other directly rather than only against a stored
baseline.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
A clockwise and a counter-clockwise rotor on one semisubmersible, which makes
the whole system symmetric about the vertical plane. Rotor 2 mirrors rotor 1
within a single solve, so the drivetrain, substructure, moorings and two
controller instances are all checked at once, with no run-to-run difference to
explain away.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
BlCenBt is the chordwise offset of a blade section's centre of buoyancy, and it
sits in the same in-plane direction as BlSwpAC, which is already mirrored two
lines above. Leaving it alone put the buoyant load of a counter-clockwise MHK
rotor on the wrong side of the blade.

The neighbouring columns are correctly left alone. BlCenBn is the out-of-plane
offset, which no more flips than curvature does. BlCpn, BlCpt, BlCan, BlCat and
BlCam are dimensionless coefficients that end up scaling chord squared, so they
carry no direction at all.

Found by working through what a mirrored marine turbine would need, rather than
by a failing test: buoyancy is computed only for MHK turbines, and no mirrored
case is an MHK case. In the RM1 rotor BlSwpAC is zero at every station while
BlCenBt is not, so the mirror that was present did nothing and the one that was
missing did all the damage. Correcting it moves 116 of 184 channels, and the
nodal buoyant moments by more than their own magnitude.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
The only mirrored cases that reach the buoyancy calculation, and so the only
regression cover for the centre-of-buoyancy mirror.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The table claimed to be measured but had been maintained by hand, and had
drifted. It said the AeroDyn Ft, Cy and Vindy families change sign, which was
true of the module channels and false of the nodal ones until that path was
mirrored. It also predated the marine turbine work, so nothing buoyant appeared
at all.

It is now generated by tools/emit_sign_table.py, which runs every registered
mirrored case, compares each channel against its clockwise counterpart, and
reports the observed behaviour. All five pairs resolve completely, so every
family listed has been seen rather than reasoned about.

The families that carry a sign in the code but that no registered case requests
are called out separately as inferred rather than measured, since that gap is
what allowed two sign errors to survive this long.

Co-authored-by: GitHub Copilot <copilot@github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
The skew-aligned disk triad is built so that all three basis vectors mirror
as true vectors, which is exactly what the RotDir on z_hat achieves: the
cross product of two true vectors is a pseudovector, and the factor turns it
back. Taking components against a triad that reflects with the flow, a true
vector such as force keeps every component, because the vector and the basis
vector reflect together, while a pseudovector such as moment reverses every
one.

The loads did not follow that rule. Force(2) carried a RotDir it should not,
and Moment(2) lacked one it should have had. Both are only reachable when
the coefficient table supplies lateral coefficients, which the shipped 5MW
table does not, so neither had ever executed.

Measured against the new yawed pair, the disk-frame forces are now identical
between the clockwise and mirrored runs and the disk-frame moments are all
sign-flipped, and in the inertial frame ADFyi, ADMxi and ADMzi flip while
ADFxi, ADFzi and ADMyi do not. Before the change ADFyi came out identical,
which is what a mirror image cannot do.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Rxyz holds x_hat, y_hat and z_hat as its columns. Multiplying it on the left
of a vector rebuilds an inertial vector from disk components; multiplying on
the right projects an inertial vector onto the disk axes. Both call sites
used the wrong one, in opposite directions.

The wind and structural velocity outputs hold an inertial vector and want
its components, but reconstructed instead, which reduced ADVWindx/y/z to the
shaft axis scaled by the wind speed. ADVWindz should be identically zero,
since z_hat is built perpendicular to the relative wind, and instead read
2.04 m/s in the case used here. It is now zero exactly. The load outputs
hold disk components and want an inertial vector, and projected instead. The
moment block additionally reconstructed from m%Force, so ADMxi, ADMyi and
ADMzi were copies of ADFxi, ADFyi and ADFzi rather than moments at all.

This predates the counter-clockwise rotor work and is not caused by it, but
the mirror comparison is what exposed it: the affected channels could not be
made to resolve, because the quantities themselves were not what their names
claimed.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
RtSpeed, RtTSR, RtAeroPwr and BAeroPwr are written by code shared between
the wake models, from a local omega that the two paths set differently.
BEMT supplies m%BEMT_u%omega, which already carries RotDir because BEMT is
presented a clockwise-equivalent problem. OLAF supplied Calc_Omega, which is
the bare physical speed.

The power channels multiply by RotDir again. For BEMT the two factors cancel
and the reported power is physical, which is correct. For OLAF the single
factor would not cancel, so a mirrored rotor would report negative power and
a negative power coefficient, and RtSpeed and RtTSR, which do not multiply
by RotDir at all, would come out with the opposite sign to the same rotor
run under BEMT.

OLAF now presents omega in the same clockwise-equivalent convention. RotDir
is unity for every rotor that can currently reach this path, since mirrored
rotors are refused with OLAF, so no existing result changes; the fix simply
removes a trap from the path that work will take.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Both halves carry the mirrorrotor label, so ctest -R mirrorrotor runs them
with the rest. The r-test pointer also brings in the extended AeroDyn output
lists, their regenerated baselines, and the AeroDisk baselines that moved
with the corrected output transforms.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
The scripts that establish what the regression suite asserts have lived in a
scratch directory outside version control, which meant the evidence for the
sign table could not be re-created from the repository alone. They now sit
in reg_tests/otherTests, alongside a README describing what each one does and
the two traps that have cost time before: that the shared classifier reads
text output rather than packed binary, and that a .outb written in the packed
format resolves to about 1.5e-5, so no comparison drawn from one should be
run tighter than that.

Each script locates the repository from its own position on disk, honouring
OPENFAST_REPO, rather than the absolute path of one developer's checkout.

Nothing here is wired into ctest. These are run by hand when a claim needs
re-establishing or when a change touches the rotor convention.

run_guards.sh is new. It runs the MirrorRotor restrictions in both states and
checks that each fatal error appears for a mirrored rotor and does not appear
for a clockwise one. The linearisation, OLAF and AeroAcoustics guards all
behave correctly. The steady-state solver guard cannot be reached this way,
because the flag it tests is a parameter fixed to false on the path the
openfast binary takes, and only the aero-map program passes it as true.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Three things had drifted. The restrictions table still listed
SimplifiedElastoDyn and AeroDisk as unsupported, although both were enabled
some commits ago and each has a registered case. The table of where the
mirror is applied did not mention AeroDisk at all. The verification section
said three comparisons were kept as regression cases, then listed five, while
thirteen tests carry the mirrorrotor label.

The measured sign table is regenerated. The caveat that the coefficient
families were inferred rather than measured is gone, because they are now
requested by a registered case and observed. Across the six pairs 1002
channels resolve: 677 identical, 324 sign-flipped and one mirrored angle,
with 58 below the noise floor everywhere and 133 mooring channels set aside
because their pairing depends on the layout. None is unresolved.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
The deck it copies refers to ../5MW_Baseline for the inflow and airfoil data,
and the script never created that link. Run from a fresh directory the cases
aborted while reading their inputs, before reaching the guard.

That failure mode is worth naming, because it is not symmetric. The mirrored
variants reported the guard missing and failed loudly, but the clockwise
controls check that the message is *absent*, and a run that never reaches the
guard satisfies that trivially. Half the check was passing for the wrong
reason and would have gone on doing so.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
OLAF is no longer refused for a mirrored rotor. The angle of attack was taken
from geometry that had not been told the rotor turns the other way, so the
section flew backwards: a case that gave 9.1 degrees clockwise gave 169.3
mirrored, and the two runs differed from the first time step.

The rotation direction is carried **per wing**, not per simulation. OLAF holds
the wings of every rotor in one shared wake and one Biot-Savart solve, so a farm
may mix the two directions and both have to live in the same frame. Reflecting
the world OLAF sees would have been simpler and would have broken exactly on the
mixed cases this flag already supports. Carrying it per wing also leaves the
wake in the physical frame, which 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 stay verbatim as they are
everywhere else in this feature. The bound circulation reverses, being a
pseudovector along a span axis that is itself a true vector. And the lifting
line's leading and trailing edges move to 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 at all. That last one is worth the emphasis: it
is the difference between agreeing to about seven per cent and agreeing exactly.

The sign factor sits on the chordwise term in FVW_AeroOuts and on the normal
term in Wings_ComputeCirculationPolarData. That looks inconsistent and both are
required. The lifting-line 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,
so the two carry opposite signs under the reflection. Putting the factor on the
matching term in both places was tried and measured, and is wrong.

The loads leave the free-wake path in the same convention the blade-element path
produces, so the two share their downstream treatment rather than diverging
again.

Measured on ad_B1n2_OLAF, a mirrored rotor now reproduces the clockwise one to
0.000e+00 relative difference over the whole time history. Nothing changes for a
clockwise rotor, where every factor here is unity; all ten registered OLAF cases
reproduce their baselines.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Each blade-node output family is written twice, once for the blade-element path
and once for the free wake, and only the first carried the mirror sign. The
Vindy, Cm, Cy, Ct, Fl, Fd, Fn and Ft families therefore came out of a mirrored
free-wake rotor in the wrong convention, in both the module and the nodal form.

Now that the free-wake quantities reach these blocks in the same
clockwise-equivalent convention the blade-element ones use, the two branches
carry the same factors in the same places, including the twist angle used to
rotate the coefficients.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Both halves carry the mirrorrotor label. The r-test pointer also brings in the
twin-rotor case's return to the free wake and its regenerated baseline.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
The pair is added to the set the table is generated from, and pairs may now name
their output files directly, since the module-level driver cases do not follow
the glue-code naming.

Seven pairs now contribute. 1015 channels resolve: 686 identical, 328
sign-flipped and one mirrored angle, with 55 below the noise floor everywhere and
133 mooring channels set aside. None is unresolved.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
OLAF leaves the list of restrictions and gains a section of its own, since the
reasoning behind it is not obvious from the code: why the direction is carried
per wing rather than by reflecting the world the wake solver sees, what the three
changes are, and why the sign factor sits on a different term in the two places
the angle of attack is formed.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant