Add stability_derivatives (angle of attack, sideslip) and trim_angle, built on linearize - #346
Conversation
Derivatives of the force and moment coefficients with respect to angle of attack and sideslip, by the chain rule through linearize's va columns, and a trim-angle search on CMy with the slope from those derivatives. The inflow formula moves out of set_va!(body_aero, settings) into apparent_wind so both share it. Refs #330 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1-Bort-1
left a comment
There was a problem hiding this comment.
Independent review (advisory)
Verdict: APPROVE WITH COMMENTS · 2 inline, 0 off the diff
Good
- The derivatives are built on
linearize, as the card promises:va_idxs=1:3withtheta_idxs=nothing, andomegaread frombody_aero.omegaatsrc/solver.jl:1319-1321, so there is no second finite-difference path. - The formula in
apparent_windmatches the removed body ofset_va!(settings)term for term, and that method now calls it, so the inflow has one source. dva/dalphaanddva/dbetacome from ForwardDiff overapparent_wind, so the formula's derivative is not written out a second time.- The docstring fix fits the geometry: the test wing runs from the leading edge at x=0 to the trailing edge at x=1, and
docs/src/settings.md:54has z = up, so the old forward/down labels were wrong. - The tests name what they protect, check both slope signs and a range with no trim, and compare against central differences of
solve!rather than againstlinearizeitself. - Public symbols are in
functions.md, private helpers inprivate_functions.md, and CHANGELOG and export and include lines are added, matching the plan in the card.
Not good
src/stability.jl:46—kwargsreachlinearizeand sosolve!for the slope, but not theCMysweep inpitch_moment_coeff. A caller passingreference_point=(asolve!keyword) gets trims found aboutsolver.reference_pointbut slopes about another point, so the stable/unstable verdict can be silently wrong.src/stability.jl:61—pitch_moment_coeffnever checkssolver.lr.converged, and line 48 throws awayderivatives.converged. A trim bisected on unconvergedCMyvalues comes back looking just like a good one.coeffs_atin the test ispitch_moment_coeffreturning all six coefficients; one private src helper returning all six, withtrim_angletaking[5], would serve both.- The logic of the trim predicate sits in the closure
is_nose_down(§6); a named helper, orpitch_moment_coeff(...) < 0passed straight in, reads the same without it. - The trim tests use the default
Solver, whereuse_gamma_previs on, so each bisection step starts from the last step's gamma; the derivative test turns it off, and the trim tests do not say why they leave it on. - The card says to pass
backend=nothingfor aNONLINsolver, but no test covers that path throughstability_derivativesortrim_angle. trimsis fixed toFloat64, so aFloat32or dualalpha_rangeis converted without warning; fine for now, but worth a line in the docstring if it is intended.
claude, rubric CLEAN_CODE.md. A different lab from the implementer
on purpose: a reviewer sharing its blind spots would not flag its mistakes.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…-and Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Local full suite: PASS (9 min, Julia 1.13.0, one cell of the matrix) |
…-and Resolves set_va!(body_aero, settings) against the va rename (#349): the body calls apparent_wind and names the vector va_vec, as does stability_derivatives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CMy sweep and bisection solve with throw_on_fail, as does the slope's linearize, so a trim is never bisected on unconverged moments. trim_angle takes an explicit backend instead of splatting kwargs into linearize, which let a reference_point reach the slope but not the sweep. coeffs_at_angles replaces pitch_moment_coeff and the test's copy of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fix this in this PR |
The examples, README and docs snippets wrote the body-frame inflow vector from alpha and beta by hand; they now call apparent_wind, the function set_va!(body_aero, settings) and stability_derivatives already use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Fixed in c8264df: apparent_wind is exported, and the six examples, the README and the docs pages call it instead of writing out the formula. |
|
I would like to see some figs in the PR body to make it easier to review. |
|
Added three figures to the #346 description: the trim search on the V3 kite, the V3 derivatives against central differences, and the two test wings (collapsed). |
TL;DR
New
stability_derivatives(solver, body_aero, alpha, beta, wind_speed)returns the six force and moment coefficients and their derivatives with respect to angle of attack and sideslip. Newtrim_angle(solver, body_aero, beta, wind_speed)finds whereCMychanges sign and returns the slopedCMy/dalphaat each trim, so you can see whether it is stable. The Python package has both and this package had neither. Both are built onlinearize, as #330 proposes, rather than on a second finite-difference path.Figures
The TU Delft V3 kite from
examples/V3_kite.jl, at 20 m/s, with moments about the origin.trim_anglesweeps the dots (2° apart), bisects the bracket whereCMychanges sign, and returns one trim at 7.40° with slope −2.71 /rad, so the trim is stable. The dashed line is that slope. The ragged curve past about 12° issolve!in stall, not the trim search.The same kite at β = 5°. Lines are
stability_derivativesand dots are central differences ofsolve!with a step of 1e-4 rad. The α derivatives agree to 0.006 /rad everywhere. The β derivatives agree except at α = 15°, where every coefficient's finite difference jumps. The cause issolve!, not the derivative: at that α, stepping β by 1e-4 rad movesCFzbetween 0.9889 and 0.9982 and back, so the solve is landing on two stall branches. The ForwardDiff derivative there is −0.40 /rad, in line with its neighbours.The two test wings in
test_stability.jlThe figures come from a one-off script that is not committed. It calls only the exported API and
coeffs_at_angles.How it works
linearize(...; va_idxs=1:3, aero_coeffs=true)gives the coefficient Jacobian with respect tova. That is multiplied bydva/dalphaanddva/dbeta, which ForwardDiff takes fromapparent_wind. Moments are aboutsolver.reference_point, the rotation rate is whateverbody_aero.omegaholds, and extra keywords go tolinearize(e.g.backend=nothingfor aNONLINsolver, since ForwardDiff only runs onLOOP).apparent_wind(alpha, beta, wind_speed)is the formulaset_va!(body_aero, settings)already used,V·[cosα cosβ, sinβ, sinα cosβ], moved out so both callers share it, and exported. The examples, the README and the docs pages build their inflow with it instead of writing the formula out; they all ran at zero sideslip, where the vector is bit-identical, so no example's output changes.linearize_check.jl,bench.jlandstall_model.jlhad leftcos βoff z, which only mattered had anyone set a sideslip. The old docstring labelled the components forward/right/down; the body frame is x back, z up, so the docstring now just names the formula.alpha_range(default −5° to 15° in 2° steps, in radians), bisects every bracket whereCMy < 0flips toalpha_tol(default 1e-5 rad), and takes each slope fromstability_derivatives. It returns every trim found, and an empty vector if there is none.throw_on_fail: the sweep, the bisection and the slope'slinearize. A solve that misses the solver's tolerances throwsSolveFailurerather than steering the bisection with a number the solver does not stand behind.trim_angletakesbackendexplicitly rather than passing keywords through tolinearize. With pass-through, areference_point=reached the slope but not theCMysweep, so the trim and its stability verdict could be about two different points.Where this differs from the Python package
cos βon z, asset_va!(settings)has it. Python main usessin αwithout it, so the numbers differ from Python at nonzero sideslip.Split
#330 holds more than one idea, so this is part 1:
linearizeandmake_dual_shadowtake a body with more than one wing, and landed as linearize and make_dual_shadow take a BodyAerodynamics with several wings #355.stability_derivativesandtrim_angletake one too, since neither adds a restriction of its own; the multi-wing coverage is main's, intest_body_aerodynamics.jl.Found on the way (not changed)
.github/workflows/CI.ymlsetsfail-fast: false. Changing that is its owncleanup:PR.solve!jumps between branches in stall. On the V3 kite at α = 15°, β = 5°, steps of 1e-4 rad in β moveCFzby about 0.009 and back (Figures). Atrim_anglebracket there would bisect onto the jump rather than onto a smooth zero. The fix belongs in the solver's stall handling, not here.src/,test/,examples/anddocs/for stability, derivative, trim, malz and bisect. Nothing to reuse; the one bisection, inobj_slice.jl, carries its own per-step payload.Verification
test/solver/test_stability.jl, red before, green after.includegave0 passed, 0 failed, 1 erroredbecausestability_derivatives,trim_angleandapparent_windwere undefined.apparent_wind points along body x, tilts up with alpha, right with beta(c8264df): 2 errored whileapparent_windwas unexported, 3/3 after; the file is 18/18.examples/bench.jlruns to the end, and the docs build withapparent_windon the public functions page.a solve that misses the tolerances throwstestset was red on the code before b50a2b5: it returned a trim instead of throwing.a NONLIN solver with backend=nothing finds the same trimtestset covers the finite-difference path. It matches theLOOP+ ForwardDiff trim to 1e-4.solve!(step 1e-4 rad) to about 1e-8 relative, e.g.dCFz/dα4.414345384 against 4.414345369. Tested to rtol 1e-4.cm = 0.05and moments about the leading edge, it finds one trim at 1.666° with slope −1.11 /rad. Withcm = −0.05and moments about the trailing edge, one trim at −0.050° with slope +3.34 /rad.test/body_aerodynamics/test_body_aerodynamics.jl: 4903/4903, andtest/solver/test_solver.jlgreen, both on the merged tree. The first is where theset_va!(body_aero, settings)merge resolution is checked —set_va! with VSMSettings applies the yaw rate about body zis 6/6 here, and 1/6 with only this branch's side of that conflict,body_aero.omegacoming out[0, 0, 0]against the expected[0, 0, 0.5236]. The second is the file Build a Solver from VSMSettings or from panel and section counts, deprecate the body_aero constructors, and check the sizes in solve! #340 rewrote, includingSolver Constructor Tests19/19.Solver(body_aero; kwargs...), so the test helper now returns the body and a solver built fromwing.n_panelsandwing.n_unrefined_sections, as the rest of the suite does; no source file needed a change. The only conflict was theAddedlist inCHANGELOG.md, where both sides' entries are kept. Earlier merges also conflicted inset_va!(body_aero, settings), which now builds its inflow fromapparent_windand turns the body atsettings.condition.yaw_ratefrom set_va! turns the body about a stored reference_point, and set_va!(body_aero, settings) applies yaw_rate #353.POLAR_MATRICESflake turned it red once on 0da60e3; dispatched rather than re-run, that same commit was green on all five jobs.jetlsis not installed on the box, so it was not run.dCFy/dβis exactly zero on a flat wing, so the side-force derivative is only checked against the finite difference where it is zero. The other five sideslip derivatives are nonzero and checked.Scope
+229 / −53 across 17 files:
src/stability.jl(92 lines, about half of them docstrings)test/solver/test_stability.jl(97 lines)src/body_aerodynamics.jl:apparent_windmoved out ofset_va!(settings), and its docstring cut from 29 lines to 4, net −15runtests.jl, the export list and the include listapparent_windinstead of the formula (net −12)Closes #330 · task
VortexStepMethod.jl-330