Skip to content

Add the attached-trailed vortex force as an opt-in solver setting - #389

Merged
1-Bart-1 merged 3 commits into
mainfrom
agent/319-the-chordwise-attached-trailed-vortex-se
Sep 28, 2026
Merged

1-Bart-1 merged 3 commits into
mainfrom
agent/319-the-chordwise-attached-trailed-vortex-se

Conversation

@1-Bort-1

@1-Bort-1 1-Bort-1 commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

TL;DR

A new setting, is_with_attached_trailed_force (default false), adds a Kutta–Joukowski force on each panel's two chordwise filaments, the ones from the quarter-chord bound points to the trailing edge. The force uses the total 3D velocity at each filament's three-quarter-chord point (Gaunaa, Li & Pirrung, TORQUE 2026, §3). Those filaments already carried circulation and induced velocity but took no force, and that force is the swept-wing term the paper reports as missing from lifting-line codes.

3D polars, before → after

The before curve is main at the merge base (1edac52's src/, loaded as its own module in the same session). The after curve is this branch with is_with_attached_trailed_force = true. Same geometry, inputs, solver settings and α samples on both. With the flag left off, the branch reproduces main to max|ΔCL| = 0, max|ΔCD| = 3e-17 on both wings, so the default is unchanged.

3D polars of the swept + anhedral elliptic wing (Gaunaa 2026 §5.2), main vs is_with_attached_trailed_force = true

On the paper's swept + anhedral wing (VSM, INVISCID, 60 panels, α −4…20°), the term moves CL down across the whole sweep: −2.18 % at α = 10° (0.6473 → 0.6332) and −0.97 % at 20°. CD falls more, −5.1 % at 10°, so the drag polar hardly moves. That is the direction the paper reports.

3D polars of the TU Delft V3 kite, main vs is_with_attached_trailed_force = true, with CFD and wind tunnel

The V3 kite (vsm_settings.yaml, VSM, α −5…25°) goes the other way: CL rises by +3.4 % at α = 10° (0.8932 → 0.9235) and +4.3 % at 20°. CD rises by +5.2 % and +5.8 %, so CL/CD at 10° drops from 8.95 to 8.80. Below 5° the two curves coincide. This matches the streamwise-chord probe below. The V3's chords are streamwise (chord·x̂ ≥ 0.9995 on every panel sampled), which puts them 2–9° off the crossflow direction mid-span and 63° off at the tip panel. So the AT segments pick up sidewash instead of the paper's crossflow correction. Nothing in either figure lets me say whether the V3 result is closer to the CFD or the wind tunnel. The stall region, where it moves most, is outside what a lifting-line model resolves anyway.

What the term does

Each segment gets ρ · V_3D × Γ · (TE − bound point). V_3D is the panel inflow, turned by omega about reference_point at the sample point itself, plus the velocity induced by every horseshoe and its wake. It is sampled two thirds of the way from the bound point to the TE (x/c = 0.75). This is the total velocity, not V⊥ or the 2D-corrected one. Each panel keeps its own two segments with its own Γ, so the two coincident segments at a shared junction sum to ρ Δa V × (Γ_i − Γ_{i+1}). The paper doesn't say how to attribute that junction force, and splitting it this way gives the same wing total with no extra bookkeeping. Each segment force acts at its sample point for the moment about reference_point. moment_dist and lift_dist/drag_dist stay 2D section quantities; only f_body_3D/m_body_3D and everything summed from them carry the term.

It runs in solve!, in solve and, through the Dual shadow, in linearize. The circulation is untouched, because the force is assembled after γ converges.

Result on the paper's §5.2 case

The paper gives only the tip offsets (10 % back, 25 % down) and CL_VLM = 0.753 at α = 10°, which implies AR ≈ 4. The exact planform is in its online appendix. The test wing is AR 4 with parabolic offsets, 60 panels, INVISCID (Cl = 2πα). Change in CL from turning the force on:

wing VSM LLT paper (LL-Gaunaa)
straight elliptic −0.26 % −0.24 % "very small"
swept + anhedral −2.18 % −1.99 % −2.1 % (+2.14 % → −0.04 %)

The blade-force Oswald factor rises, by +0.9 % (VSM) and +3.8 % (LLT), well short of the paper's ~+13 %. That gap is expected: the 2D inner model here is not LL-Gaunaa's (#317, #318), so only the lift shift is asserted.

Where I'd push back

The term only appears when sections are perpendicular to the quarter-chord line, which is the paper's Crossflow Principle (p. 3). My first probe used streamwise chords on the same swept + anhedral wing. There the AT segments lie along the freestream, and CL went +1.2 % instead of down, driven by sidewash alone. The sign convention checks out: Kutta–Joukowski on the bound filament with the same convention reproduces the panel lift. So the force follows whatever chord direction the geometry defines, and a streamwise-sectioned swept wing will not get the paper's correction. The V3 kite in data/ is one of those, as the second figure above shows. Getting the paper's correction on it would need crossflow sections in the geometry, which is a separate change.

Also in the diff

  • solve! and calculate_results each assembled a panel's force and moment from panel_loads in their own copy. Both now call one panel_force_moment, which is where the new term lives.
  • calculate_results kept va_ref_unit in work_vectors[1], which the Biot–Savart kernels overwrite. It is an SVector now, so calling the kernels inside that loop can no longer corrupt the drag projection.

On the review

  • induced_velocity_at reads each wake's own va/direction, which is the wake the filaments actually hold. The AIC loop passes the shared area-weighted vector that frozen_wake! also writes into every wake, so the two agree by construction today. If they ever diverge, the AIC loop is the one to change, not this helper.
  • The sample point lies on the panel's own trailed filament and on its neighbour's. Zero induction for a point on a filament is already pinned by test/filament/test_bound_filament.jl ("Point Exactly on Filament").
  • The cost number is labelled as one @time run; the term is opt-in, and a §5 benchmark belongs with whoever turns it on by default.

Verification

  • Nothing to reproduce (new term). The test test/solver/test_attached_trailed_force.jl goes red with the force zeroed in attached_trailed_loads: 49 passed, 4 failed, the CL-drop and Oswald-rise assertions for VSM and LLT. Green after: 53/53 (juliaserver).
  • Affected files green on the final commit: test_attached_trailed_force, test_solver (zero-alloc calc_forces!), test_viscous_drag_correction, test_body_aerodynamics, test_forwarddiff, test_verification, test_settings, test_backend_comparison.
  • With the flag on: calc_forces! allocates 0 bytes, solve and solve! agree to 0.0, and the ForwardDiff Jacobian matches FiniteDiff to < 1e-3.
  • After the review round (675f0e7): test_attached_trailed_force 53/53, test_forwarddiff 10/10, test_solver 50/50, test_body_aerodynamics 4909/4909 (juliaserver).
  • Local CI mirror (agent ci-local, Julia 1.13, on 675f0e7): PASS (8 min) · GitHub CI on 675f0e7: PASS (1.12 macOS/ubuntu/windows, 1.13 ubuntu, docs, setup).
  • Polar figures: flag off vs main at the merge base, max|ΔCL| = 0, max|ΔCD| = 3e-17 over both α sweeps (juliaserver, generate_polar_data).
  • Docs build: not run locally (the docs env has no manifest); the Documentation job in CI passes. The three new private helpers are on private_functions.md.
  • Cost: calc_forces! at 60 panels takes 0.62 ms on vs 0.036 ms off, a single @time each and not a §5 benchmark. The term is O(N²), two induction sweeps per panel, which is why it is opt-in.
  • Risk: the omega term at the sample point has no test of its own. The tests run with omega = 0, and only set_va!(body_aero, va_vec, omega) sets a nonzero one (the distributed set_va! zeroes it).
  • Not asserted: the Oswald check is direction only, because the size is not expected to match the paper here (see above). With the force zeroed it is one of the four assertions that fail.

Scope

+211 / −14 across 7 files. src/body_aerodynamics.jl holds the three helpers (induced_velocity_at, attached_trailed_loads, panel_force_moment) and the va_ref_unit fix. settings.jl/solver.jl thread the flag the way is_with_viscous_drag_correction is threaded, including make_dual_shadow. The new test file is 106 lines.

Opened by 1-Bort-1, an AI agent working for @1-Bart-1.
Closes #319 · task VortexStepMethod.jl-319

1-Bort-1 and others added 2 commits September 23, 2026 11:18
Kutta-Joukowski on each panel's two chordwise bound-to-trailing-edge
filaments, with the total velocity at their three-quarter-chord point,
behind is_with_attached_trailed_force (default false). solve! and
calculate_results now assemble each panel's force and moment through
one panel_force_moment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@1-Bort-1 1-Bort-1 added agent:running Agent task state agent:ci Agent task state and removed agent:running Agent task state labels Sep 23, 2026

@1-Bort-1 1-Bort-1 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review (advisory)

Verdict: APPROVE WITH COMMENTS · 2 inline, 0 off the diff

Good

  • The flag is threaded everywhere is_with_viscous_drag_correction is: settings, solver_kwargs, the Solver field, solve, calculate_results and make_dual_shadow. A grep for the viscous flag turns up no other place it would need to go.
  • panel_force_moment replaces the two copies of the force/moment assembly in calc_forces! and calculate_results. This is the one-codepath rule applied at the change, and the card names it.
  • moment_dist now reads loads.force, which is exactly what force held before, so the section moment is unchanged. This matches the card's claim that the 2D distributions stay section quantities.
  • The va_ref_unit fix is real: the old code kept it in work_vectors[1], which the filament kernels (filament.jl:84,206) overwrite, and the new helper now calls those kernels inside the loop. It is named in the card.
  • induced_velocity makes the same calculate_velocity_induced_single_ring_semiinfinite! call as the AIC loop and adds no second Biot–Savart path.
  • The tests pin the behaviour per panel: the force equals the off-run plus attached_trailed_loads, solve agrees with solve!, calc_forces! allocates nothing, and ForwardDiff matches FiniteDiff. The card also reports a red-before run with the force zeroed.
  • The card writes down the surprise with streamwise sections (+1.2 % instead of the paper's drop) instead of burying it.

Not good

  • src/body_aerodynamics.jl:622 — The segment velocity uses panel.va_vec, sampled at the control point, rather than recomputing the inflow with omega at the ¾-chord point. Kites almost always fly with nonzero omega, so the case the card calls exact (uniform inflow) is the rare one in practice, and the extra error only shows up with the flag on.
  • src/body_aerodynamics.jl:621 — The unlabelled (2 / 3) is what places the sample at x/c = 0.75. Only the docstring ties it to the three-quarter-chord point, and changing the bound-point convention would silently move it. A named local or a derivation from the chord fractions would make the choice readable.
  • induced_velocity reads each panel's wake.va/wake.direction, but the AIC loop passes one area-weighted va/va_unit. The two only agree as long as wake.jl gives every wake the same values, and that coupling is not stated anywhere.
  • The name induced_velocity is very generic for a module-level helper, and calculate_relative_alpha_and_velocity(panel, induced_velocity) already uses it as an argument name. Something like induced_velocity_at or horseshoe_induced_velocity would avoid the shadowing.
  • The sample point lies on the panel's own trailed filament and on its neighbour's coincident one. It relies on the core-radius desingularisation giving zero self-induction, and no test checks that.
  • The O(N²) cost runs again on every linearize evaluation with Duals. The card's 17× slowdown comes from one @time run, so it is only an estimate.
  • The Oswald assertion oswald_on > oswald_off checks only a direction, and the card itself says the size (+0.9 % against the paper's ~+13 %) does not match. Passing it confirms little.
  • The CHANGELOG.md entry repeats a benchmark number ('about 2 %') that belongs in the PR and will go stale when the test wing changes.

claude, rubric CLEAN_CODE.md. A different lab from the implementer
on purpose: a reviewer sharing its blind spots would not flag its mistakes.

Comment thread src/body_aerodynamics.jl Outdated
for (bound_point, te_point, orientation) in segments
chordwise = SVector{3, T}(te_point) - SVector{3, T}(bound_point)
point = SVector{3, T}(bound_point) + (2 / 3) * chordwise
velocity = SVector{3, T}(panel.va_vec) +

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: The segment velocity uses panel.va_vec, sampled at the control point, rather than recomputing the inflow with omega at the ¾-chord point. Kites almost always fly with nonzero omega, so the case the card calls exact (uniform inflow) is the rare one in practice, and the extra error only shows up with the flag on.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 675f0e7: the inflow is now va_vec − ω × (sample point − control point), which is the inflow set_va! would give at the sample point itself.

Comment thread src/body_aerodynamics.jl Outdated
(panel.bound_point_2, panel.TE_point_2, -1))
for (bound_point, te_point, orientation) in segments
chordwise = SVector{3, T}(te_point) - SVector{3, T}(bound_point)
point = SVector{3, T}(bound_point) + (2 / 3) * chordwise

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: The unlabelled (2 / 3) is what places the sample at x/c = 0.75. Only the docstring ties it to the three-quarter-chord point, and changing the bound-point convention would silently move it. A named local or a derivation from the chord fractions would make the choice readable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 675f0e7: named local three_quarter_chord = (0.75 − 0.25) / (1 − 0.25).

@1-Bort-1 1-Bort-1 added agent:queued Agent task state agent:running Agent task state and removed agent:ci Agent task state agent:queued Agent task state labels Sep 23, 2026
Name the three-quarter-chord fraction, rename induced_velocity to
induced_velocity_at, and drop the benchmark number from the changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@1-Bort-1 1-Bort-1 added agent:ci Agent task state and removed agent:running Agent task state labels Sep 23, 2026
@1-Bort-1

1-Bort-1 commented Sep 23, 2026 •

Copy link
Copy Markdown
Member Author

Local full suite: PASS (12 min, Julia 1.13.0, one cell of the matrix)

@codecov

codecov Bot commented Sep 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@1-Bart-1

Copy link
Copy Markdown
Member

3d polar figs with before/after in pr body please

@1-Bort-1 1-Bort-1 added agent:queued Agent task state agent:running Agent task state and removed agent:review Agent task state agent:queued Agent task state labels Sep 28, 2026
@1-Bort-1

Copy link
Copy Markdown
Member Author

Before/after 3D polars for the paper's swept wing and the V3 kite are now in the #389 description.

@1-Bort-1 1-Bort-1 added agent:ci Agent task state agent:review Agent task state and removed agent:running Agent task state agent:ci Agent task state labels Sep 28, 2026
@1-Bart-1
1-Bart-1 merged commit 802a6d5 into main Sep 28, 2026
7 checks passed
@1-Bart-1
1-Bart-1 deleted the agent/319-the-chordwise-attached-trailed-vortex-se branch September 28, 2026 17:48
@1-Bort-1 1-Bort-1 added agent:done Agent task state and removed agent:review Agent task state labels Sep 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:done Agent task state

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The chordwise attached-trailed vortex segments carry no force, which is the swept-wing term

2 participants