Skip to content

Live polars: regenerate a panel's polar from its deformed shape - #267

Open
1-Bart-1 wants to merge 8 commits into
mainfrom
feat/live-polars
Open

Live polars: regenerate a panel's polar from its deformed shape#267
1-Bart-1 wants to merge 8 commits into
mainfrom
feat/live-polars

Conversation

@1-Bart-1

Copy link
Copy Markdown
Member

Why

A trailing edge carried by a chordwise Timoshenko beam bends into a smooth camber shape. Projecting that onto a single flap angle δ throws most of it away — and which angle you get depends on which node pair you measure between, which is not a property of the aerodynamics.

This adds the machinery to skip the projection: deform the airfoil, re-generate the polar, and hand the solve a local expansion.

What

TAYLOR aero model (core). A per-panel polynomial of arbitrary order in α - α_ref [rad] — the local expansion a live source refits each solve. Sections carry aero_data = (alpha_ref, cl_coeffs, cd_coeffs, cm_coeffs) and blend spanwise like any other model; set_taylor_polar! rewrites a panel's fit in place (no allocation at fixed order). It is valid only inside its fit window, so it ignores delta and is skipped by the stall-angle scan — a local fit's curvature says nothing about stall 10° away.

Analytic shape deformation (AirfoilAero). KulfanBasis + deform_kulfan deform a fixed Kulfan fit by a matvec against a constant CST basis. Never by refitting: the fit is non-unique enough that the same shape fitted twice moves Cl by ~0.08, which reaches the polars as frame-to-frame jitter indistinguishable from flutter. control_point_deflection resamples a deflection given at arbitrary chord fractions onto that basis, so beam nodes today and membrane nodes later (upper and lower surfaces deformed independently) feed the same path.

Live polar source. LivePolars + refresh_live_polars! deform each panel's base airfoil, evaluate NeuralFoil at a few angles either side of the panel's own α — every panel and sample in one batched forward pass — and least-squares fit the coefficients. polar_drift reports how far the solve has left the window so the caller can refit before trusting the answer.

prepare_inputs now accepts one Kulfan shape per case, which is what makes the batching possible; the network's input layout moved into a single fill_case_input! so the scalar and batch paths cannot drift.

Order and window belong together

Order 2 over ±4° is the operating point. Measured here at Re 3e6:

order max abs err in Cl, α_op = 4° α_op = 12°
1 0.0245 0.1333
2 0.0147 0.0456
3 0.0071 0.0179

Higher order is better inside the window and diverges faster outside it, which is why the drift guard is a correctness requirement rather than an accuracy knob.

Cost

panels refresh
4 0.27 ms
20 2.81 ms
40 3.78 ms
80 7.08 ms

Under 3 % of a 10 Hz budget at 20 panels.

Tests

test/airfoil_aero/test_live_polar.jl (29 assertions): TAYLOR evaluation and its δ-blindness, in-place refit, camber projection to 0.024 % of the deflection, control-point resampling, a live fit tracking a direct NeuralFoil sweep across its own window, the drift guard, and the TAYLOR spanwise blend. test/airfoil_aero and test/panel pass unchanged.

Nothing existing changes behaviour: no current aero model touches the new branches.

A trailing edge carried by a chordwise beam bends into a smooth camber
shape. Projecting that onto one hinge angle throws most of it away, and
which angle you get depends on which node pair you measure between.

Add the pieces to skip the projection. `TAYLOR` is a per-panel
polynomial of arbitrary order in `α - α_ref`, the local expansion a live
source refits each solve; it is valid only inside its window, so it
ignores δ and is skipped by the stall-angle scan. `KulfanBasis` and
`deform_kulfan` deform a fixed Kulfan fit by a matvec against a constant
CST basis — never by refitting, which is non-unique enough that the same
shape fitted twice moves Cl by more than the deformation does.
`control_point_deflection` resamples a deflection given at arbitrary
chord fractions onto that basis, so beam nodes today and membrane nodes
later feed the same path. `LivePolars` then evaluates NeuralFoil a few
angles either side of each panel's own α, in one batched forward pass,
and least-squares fits the coefficients.

Order and window belong together: order 2 over ±4° tracks a full-polar
solve to a few tenths of a percent and bends with the stall knee, while
higher orders diverge as soon as the solve steps outside. `polar_drift`
reports how far it has, so the caller can refit before trusting it.

Costs 2.8 ms for 20 panels, 7.1 ms for 80.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A quadratic fitted over +-4 deg is worse than useless ten degrees out, and
that is exactly where a cold-started solve begins: with no previous angle
of attack to expand about, the first fit sits at zero while the wing flies
at fifteen. The arms then run away and the solve does not converge.

Past the window, carry the polynomial's own value and slope at the edge
on linearly instead. The fit is untouched inside its range, C1 at the
edge, and the answer outside is an honest extrapolation the caller's drift
guard then corrects by refitting where the solve went.

Shrink-wrap a panel's contour before fitting its base Kulfan parameters,
the route the offline polar generator already takes. Fitting a raw slice
directly returns weights that oscillate by an order of magnitude more
than any deformation will.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@1-Bart-1

Copy link
Copy Markdown
Member Author

Exercised end to end on the SK100 (44 refined panels, chordwise beam) through SymbolicAWEModels. Two things it found, both fixed in 26d9092:

A cold-started solve begins outside every window. With no previous solve to expand about, the first fit sat at α = 0 while the wing flew at 5–11°, and an order-2 fit's arms three windows out do not converge. Past ±half_window a TAYLOR polar now continues linearly off the polynomial's own value and slope at the edge — untouched inside its range, C1 at the edge, and honestly an extrapolation outside, which the caller's drift guard then corrects by refitting where the solve went. taylor_value is the one place that is written down.

A raw slice must be shrink-wrapped before fitting. panel_kulfan_parameters was fitting the contour directly, returning weights oscillating over ±1.4 with a leading-edge weight of −1.5 where a normal airfoil sits near 0.15. It now takes the same shrink_wrapLeastSquaresFit route the offline polar generator does. Build-time only, never in the loop.

With both, the SK100 solve converges: α 4.93–10.84°, drift 0.46 of the window, and against its own tabulated (α, δ) polars on the same geometry

live tabulated
Cl range over panels 0.726 – 1.288 0.768 – 1.337
Cm range over panels −0.068 – 0.042 −0.054 – 0.067

— about 5 % apart, which is the Kulfan refit's own non-uniqueness rather than a difference in physics. The durable fix for that gap is for the offline generator to write the weight vector it used, so the live mode starts from the same one instead of re-deriving it; out of scope here.

Worth knowing for LEI sections: NeuralFoil reports analysis confidence around 1e-5 to 3e-3 on the SK100's mid and outboard airfoils. That is not introduced here — the route that generated the SK100's existing tables (shrink_wrap(raw) → fit → NeuralFoil) reports the same, so the live polars are no further outside the trained distribution than the tables are.

It is not "nothing beyond the network's own forward pass": the
deformation allocates a weight vector a panel and the deflection path
allocates its resampled curve. Measured on a 44-panel wing, the
deformation is 0.57 us and 608 B a panel and the forward pass is 3.6 ms
and 6.2 MB, so the claim was wrong about the small part and right about
which part matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@1-Bart-1

Copy link
Copy Markdown
Member Author

Measured breakdown on the SK100 (44 refined panels), warm, best of five, since the earlier 0.166 s figure quoted downstream was still JIT:

deform_kulfan, one panel 0.57 µs / 608 B
control_point_deflection, one panel 1.44 µs / 2016 B
refresh_live_polars!, 44 panels 3.59 ms / 6.22 MB
whole AeroPressure refresh, live 11.44 ms / 18.46 MB
whole AeroPressure refresh, tabulated 6.42 ms / 12.07 MB

The analytic deformation lands where the plan predicted (0.84 µs), and the refresh is the forward pass and nothing else: 3.59 of the 5 ms live polars add. nn_forward builds a fresh matrix per layer and does it twice for the symmetry flip, which is where the 6.22 MB is; an in-place eval would remove it, but the refresh is not near the critical path — the surrounding traction freeze allocates twice as much.

ec5c50e corrects the LivePolars docstring, which claimed a refresh "allocates nothing beyond the network's own forward pass". It does allocate a little beyond it, and saying so is worth more than the claim was.

1-Bart-1 and others added 2 commits August 25, 2026 13:34
The lofted skin drew section_surface at the panel's delta, which under live
polars is the undeformed tabulated contour: the picture showed the shape the
solve was not flying. Deformation bugs could hide behind it.

A panel now carries the deformed KulfanParameters its polar was generated
from, written by the same call that installs the polar so the two cannot
drift apart, and the skin lofts that. It is the object the airfoil solver
was handed, not a second derivation of the same deformation, which is the
point: if the deformation is wrong the drawing is wrong with it.

KulfanParameters moves from AirfoilAero into the core module so a Panel can
hold one; the fitting and coordinate routines stay where they were and both
modules still export the type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The new docstring landed between airfoil_skin_geometry's docstring and
its function, so the first was documenting a string literal and the whole
Makie extension failed to precompile: not just airfoil skins, every plot
VortexStepMethod draws. It only surfaced on loading the extension for
real, which is the argument for the test this adds.

The test puts a cambered live_shape on every panel and checks the rib
node count switches from the tabulated contour's to the Kulfan one's and
back when it is cleared, asserting first that the two counts differ so it
cannot pass by drawing the wrong thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.07921% with 16 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/airfoil_aero/live_polar.jl 84.33% 13 Missing ⚠️
src/body_aerodynamics.jl 33.33% 2 Missing ⚠️
src/airfoil_aero/deform.jl 97.05% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

1-Bart-1 and others added 3 commits August 25, 2026 14:19
…drag

Two things the SK100 found.

C(x) vanishes at both chord ends, so the CST basis can put neither edge off
the chord line: a deflection that does not end at zero is asking for a chord
rotation and translation. Projecting it anyway is not merely inexact, it is
unstable, and pinv answered a 2.5% chord deflection with weights three times
larger than the camber they were meant to carry. chord_residual removes the
line through the deflection's own endpoints; chord_line hands it back, since
it is a chord rotation the caller may still owe its angle of attack.

A drag fit has its minimum inside the window, so the slope at the lower edge
points down and the linear continuation carried it through zero. A negative
drag coefficient is an energy source, not an extrapolation; calculate_cd
floors it.

Neither of these was what broke the SK100 settle, which was a bring-up
transient slewing alpha twenty degrees out of the window in ten
milliseconds. Both are wrong on their own terms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VortexStepMethod writes .arrow node tables itself, so generating polars
inside the repo leaves them stageable. Nothing has been committed, and
the two packages downstream already ignore them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A local polynomial cannot hold a stall. Measured on the SK100 over a causal
sweep, no order/window combination the fit offered ever turned CL over: the true
polar peaks at 24.0 deg with a lift slope collapsing to 0.0044/deg, every fit
reported 8x to 13x that, and narrowing the window made it worse because the fit
is centred on the previous step's alpha and never reaches the knee the solve is
walking into. On a saved failing state that mechanism killed the run — one tip
panel's window swallowed the post-peak collapse, its slope flipped to
-0.0230/deg where the local slope was +0.0503/deg, and the solve went non-finite.

SAMPLED replaces TAYLOR: cl/cd/cm at ascending angles per panel, linear between
them and the end value held past either end. The samples are the polar, so a
knee between two of them is represented rather than averaged into a slope, and
past the ends the answer is stale rather than wrong-signed. The knots move with
the panel and are written in place, so a refresh costs the network pass and
nothing else. TAYLOR is removed rather than kept: it never shipped, and two
local-polar models where one now works is one too many.

Live surface pressure follows the same shapes. refresh_live_pressure! takes Cp
from one batched pass at the converged alpha, contour_pressure resamples it onto
the panel's own contour nodes, and live_surface_friction! gives skin friction
from the flat-plate closure at the panel's live Reynolds. contour_shape_matrix
and live_shape_offset! then move the contour itself by the deformation's own
camber increment — a traction is -Cp*n with n a finite difference of neighbouring
node positions, so an undeformed contour points the whole load the wrong way
wherever the section has moved. The chord fractions never move, only the offsets,
so a surface-to-point map built on that contour stays valid.

decode_surface_pressure and contour_pressure also back the NeuralFoilSolver
contour assembly, so the live path and the offline table generator are one
implementation; a test pins them equal at rtol 1e-8.

Co-Authored-By: Claude Opus 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