Functions for creating the geometry
VortexStepMethod.add_section! — Function
add_section!(wing::Wing, LE_point::PosVector, TE_point::PosVector,
aero_model, aero_data::AeroData=nothing, section_aero=nothing)Add a new section to the wing.
Arguments:
- wing::Wing: The
Wingto which a section shall be added - LE_point::PosVector:
PosVectorof the point on the side of the leading edge - TE_point::PosVector:
PosVectorof the point on the side of the trailing edge aero_model::AeroModel:AeroModelaero_data::AeroData: SeeAeroDatasection_aero::Union{Nothing, SectionAero}: optional surface aero table, seeSectionAero
VortexStepMethod.refine! — Function
refine!(wing::AbstractWing; recompute_mapping=true, sort_sections=true)Refine the wing aerodynamic mesh from unrefined sections to refined sections.
This function interpolates the wing geometry from a coarse set of unrefined sections to a fine mesh of refined sections (npanels+1 sections) based on the wing's spanwisedistribution setting. It also populates nondeformedsections which enables deformation support via unrefined_deform!.
Required Workflow
Must be called after wing construction and before creating BodyAerodynamics:
wing = Wing("wing.yaml"; n_panels=40) # or a manually built Wing
refine!(wing) # Refine mesh
body_aero = BodyAerodynamics([wing]) # Create aerodynamicsDistribution Methods
LINEAR: Linear interpolation between sectionsCOSINE: Cosine spacing (more panels near tips)SPLIT_PROVIDED: Split each unrefined section into sub-panelsUNCHANGED: 1:1 copy when nunrefinedsections == n_panels+1
Keyword Arguments
recompute_mapping::Bool=true: Recompute the mapping from refined panels to unrefined sectionssort_sections::Bool=true: Sort sections by spanwise position using globalLE_point[2](Y-axis). Disable for structural ordering.
Effects
- Populates
wing.refined_sections(n_panels+1 sections) - Populates
wing.non_deformed_sections(copy of refined_sections for deformation reference) - Computes
wing.refined_panel_mapping(panel → unrefined section mapping) - Resizes
wing.theta_distandwing.delta_distto n_panels
Example
# YAML wing
wing = Wing("wing.yaml"; n_panels=40)
refine!(wing)
body_aero = BodyAerodynamics([wing])
# After refinement, deformation is supported
unrefined_deform!(wing, theta_angles, delta_angles)VortexStepMethod.calculate_span — Function
calculate_span(wing::AbstractWing)Calculate wing span along spanwise direction.
Returns: Float64: Wing span
VortexStepMethod.calculate_projected_area — Function
calculate_projected_area(wing::AbstractWing, z_plane_vector=[0.0, 0.0, 1.0])Calculate projected wing area onto plane defined by normal vector.
Returns: Float64: Projected area
Surface aero (contour + Cp + cf) tables
The per-section surface aero table type and its loader live in the core package; the generation and CSV/dat writing (from XFoil/NeuralFoil) live in AirfoilAero.
VortexStepMethod.SectionAero — Type
SectionAeroPer-2D-section surface aerodynamics at native airfoil resolution: the closed contour (x, y) per trailing-edge deflection, plus surface pressure cp and skin friction cf per contour node over an (alpha, delta) grid (both radians). Replaces the old chord-slice Cp table. Use section_surface to get the interpolated contour + cp/cf at any (alpha, delta).
Fields:
alpha_range,delta_range: grid axes (radians).x,y: contour node coordinates,n_node × n_delta.cp,cf: surface pressure and skin friction,n_node × n_alpha × n_delta.cp_interp,cf_interp,x_interp,y_interp: per-node interpolants.
VortexStepMethod.section_surface — Function
section_surface(aero::SectionAero, alpha, delta) -> (x, y, cp, cf)Interpolated closed contour (x, y) and per-node surface pressure cp and skin friction cf at (alpha, delta) (radians).
VortexStepMethod.read_section_aero — Function
read_section_aero(dat_file, cp_file, cf_file) -> Union{Nothing, SectionAero}Assemble a SectionAero from the human-readable files: the airfoil contour (dat_file, plus {stem}_{delta_suffix(δ)}.dat per non-zero deflection) and the per-node Cp and cf tables in the .dat node order, CSV or Arrow as their suffix says (see read_node_table). Returns nothing if any file is missing. This is the single loader for both provided and generated aero.
Airfoil aerodynamics (AirfoilAero)
These live in the AirfoilAero submodule of VortexStepMethod, which converts airfoil coordinates to polars and Cp tables. Load it with using VortexStepMethod.AirfoilAero.
VortexStepMethod.AirfoilAero.fit_kulfan_parameters — Function
fit_kulfan_parameters(x::Vector, y::Vector, method::KulfanFitMethod)
fit_kulfan_parameters(x::Vector, y::Vector; n_weights=8)Fit Kulfan CST parameters to airfoil coordinates with LeastSquaresFit, which fits through the points. The keyword form is shorthand for LeastSquaresFit(; n_weights). For a noisy or open point cloud, wrap it with shrink_wrap first.
Input coordinates are assumed to be in Selig order (TE upper -> LE -> TE lower).
Returns
KulfanParameters: Fitted parameters
fit_kulfan_parameters(x, y, method::LeastSquaresFit)Least-squares fit matching AeroSandbox's get_kulfan_parameters: both surfaces share a single least-squares system with a shared leading-edge weight and a trailing-edge thickness.
VortexStepMethod.AirfoilAero.kulfan_to_coordinates — Function
kulfan_to_coordinates(params::KulfanParameters; n_points=100)Generate airfoil coordinates from Kulfan parameters.
Returns (x, y) coordinates in Selig format (TE upper -> LE -> TE lower).
VortexStepMethod.AirfoilAero.KulfanFitMethod — Type
KulfanFitMethodAbstract supertype for Kulfan-parameter fitting strategies. The concrete method LeastSquaresFit is passed to fit_kulfan_parameters, which dispatches on it. To wrap a noisy or open point cloud into a clean airfoil first, use shrink_wrap and then fit the result.
VortexStepMethod.AirfoilAero.LeastSquaresFit — Type
LeastSquaresFit(; n_weights=8)Least-squares Kulfan fit: solves a single system that fits both surfaces through the points, matching AeroSandbox's get_kulfan_parameters (the parameterization NeuralFoil was trained on). Sensitive to noisy interior points; wrap the cloud with shrink_wrap first when fitting a raw slice.
VortexStepMethod.AirfoilAero.ShrinkWrap — Type
ShrinkWrap(; clearance=0.006, min_concave_radius=0.02, cell_size=0.001,
n_points=120, curvature_weight=0.05)Distance-field shrink wrap: the rolling-ball offset of a raw slice point cloud, extracted as one closed airfoil contour. A grid distance field is thresholded at the rolling-ball radius (bridging cloud gaps and crevices narrower than about twice min_concave_radius), flood-filled, eroded back to clearance, and the resulting level set is traced with marching squares, faired (each pass clamped so the contour keeps clearance) and resampled. Because the contour is parameterized by arclength rather than x, the leading edge comes out genuinely round — the offset of the cloud nose — the blunt trailing edge is capped by an arc of radius clearance, and a single-membrane cloud becomes a thin capsule (at least one cell_size half-thickness).
Fields
clearance: offset the contour keeps outside every cloud point; floored at onecell_size(the grid cannot represent a tighter wrap). Also the radius every convex corner is rounded at.min_concave_radius: rolling-ball radius — concave features narrower than about twice this are bridged by a fillet of roughly this radius; convex geometry is unaffected. Auto-raised so the ball can neither fall through the cloud's largest point gap nor pinch off between points during erosion, so sparse clouds get a correspondingly looser, smoother wrap.cell_size: distance-field grid resolution as a chord fraction; sets the geometric fidelity of the wrap.n_points: output stations per surface; the contour has2*n_points - 1points, cosine-clustered in arclength at the leading and trailing edges.curvature_weight: extra sampling measure per radian of contour turning (chord fraction), concentrating output points into corners so XFoil's spline can follow them;0gives plain cosine-in-arclength sampling.
VortexStepMethod.AirfoilAero.shrink_wrap — Function
shrink_wrap(x, y, method::ShrinkWrap) -> (x, y)Wrap the point cloud (x, y) into a clean closed airfoil in Selig order (TE upper → LE → TE lower), following ShrinkWrap: distance field on a cell_size grid, closing with the rolling ball (min_concave_radius), offset outward by clearance, traced as a single closed contour and resampled to cosine panels in a curvature-weighted arclength measure. The first and last point coincide at the trailing edge (the TE cap is part of the contour). The output stays in the normalized frame of the input cloud (chord slightly longer than 1, nose apex near x = -clearance) and is ready to write as a .dat or fit with LeastSquaresFit.
VortexStepMethod.AirfoilAero.AbstractAirfoilSolver — Type
AbstractAirfoilSolverSupertype for a 2D-airfoil solver backend. Concrete subtypes (XFoilSolver, NeuralFoilSolver) select, by dispatch, how a deformed section is analysed. Add a backend by subtyping this and defining analyze_section (and optionally analyze_sweep); the deformation and Cp resampling are shared.
VortexStepMethod.AirfoilAero.XFoilSolver — Type
XFoilSolverXFoil backend. Loads the deformed coordinates (optionally repaneling them) and reads the pressure distribution from Xfoil.cpdump. Defaults match NeuralFoil's training runs (ncrit=9, max_iter=100, incompressible) so the two backends are comparable.
Fields
npan: XFoil panel count when repaneling (default 160, XFoil'sPANEdefault).max_iter: viscous iterations per angle (default 100).xtrip: forced transition(upper, lower)asx/c(default(0.05, 0.05)). Tripping near the leading edge is a valid NeuralFoil transition input and, unlike free transition, converges on the shrink-wrapped hinge of a deflected section.ncrit: e^N transition criticality (default 9.0, the standard clean-tunnel value).mach: Mach number (default 0.0, incompressible).repanel: repanel the input coordinates withXfoil.panebefore solving (defaultfalse). Theshrink_wrapalready emits smoothed cosine panels, and XFoil's own curvature-attracted repaneling re-clusters a deflected section's hinge crease — which NeuralFoil (analysing the smooth Kulfan fit) never sees — sofalsetracks NeuralFoil more closely. Settrueto let XFoil repanel anyway.
VortexStepMethod.AirfoilAero.NeuralFoilSolver — Type
NeuralFoilSolverNeuralFoil backend. Evaluates the deformed section's Kulfan parameters through the network (vectorized over angle) and reconstructs the surface pressure from the predicted edge-velocity ratios. Fast, differentiable, and guarded by analysis_confidence (carried into SectionSolution.confidence).
Fields
model_size: network size (default "large").n_crit: critical amplification factor (default 9.0).xtr_upper,xtr_lower: forced transition locations (default 1.0).weights_dir: override for the weights directory (default package data).
VortexStepMethod.AirfoilAero.SectionSolution — Type
SectionSolutionResult of a single 2D analysis: integrated coefficients plus the full closed surface as node arrays (x, y) with surface pressure cp and skin friction cf per node (one continuous contour, trailing edge → upper → leading edge → lower → trailing edge). confidence is 1.0/NaN for XFoil (converged/not) or NeuralFoil's analysis_confidence. Non-converged angles carry empty node arrays. cf is the tangential skin-friction coefficient (exact from XFoil bldump, approximate from a flat-plate closure for NeuralFoil).
VortexStepMethod.AirfoilAero.DeformedSection — Type
DeformedSectionA section deformed by a trailing-edge deflection: its coordinates (x, y) and the LeastSquaresFit kulfan parameters fitted to them. XFoil consumes (x, y) directly, NeuralFoil consumes kulfan — both describe the same shape.
VortexStepMethod.AirfoilAero.deform_section — Function
deform_section(x, y, delta; crease_frac=0.9, thickness_frac=1.0,
flip_thickness_neg=true,
wrap_method=ShrinkWrap(clearance=0.0))
-> DeformedSectionDeform the airfoil coordinates (x, y) by trailing-edge deflection delta (radians) about the crease at crease_frac along the chord (pivoting through thickness at thickness_frac, 1.0 = top surface), then re-shrink_wrap the deflected shape into clean cosine panels and fit LeastSquaresFit Kulfan parameters to it. XFoil consumes the coordinates directly, NeuralFoil the Kulfan parameters.
flip_thickness_neg folds a soft membrane about its lower surface for negative delta. The re-wrap uses zero clearance (it hugs the deflected shape at grid resolution); the rolling-ball wrap bridges the crease with a min_concave_radius fillet instead of the overlapping panels that XFoil's own repaneling can hit there. The wrap runs for every delta including 0, so all deflections share the same node count (2·n_points - 1).
VortexStepMethod.AirfoilAero.analyze_section — Function
analyze_section(solver::XFoilSolver, def, alpha, Re) -> SectionSolutionSingle-angle convenience; delegates to analyze_sweep.
analyze_section(solver::NeuralFoilSolver, def, alpha, Re) -> SectionSolutionSingle-angle convenience; delegates to analyze_sweep.
VortexStepMethod.AirfoilAero.analyze_sweep — Function
analyze_sweep(solver, def, alpha_range, Re) -> Vector{SectionSolution}Analyse a deformed section over alpha_range (radians). Generic fallback maps analyze_section; backends override for efficiency (XFoil reinit sweep, NeuralFoil vectorization).
analyze_sweep(solver::XFoilSolver, def, alpha_range, Re) -> Vector{SectionSolution}Set the deformed coordinates once (repaneling if solver.repanel), then solve alpha_range (radians) sweeping negative and positive angles outward from zero with a reinit at each side for convergence. Each converged angle reads the surface pressure (Xfoil.cpdump) and the boundary layer (Xfoil.bldump, giving cf and the node coordinates) at the same panel nodes. Non-converged angles yield empty node arrays and NaN confidence.
analyze_sweep(solver::NeuralFoilSolver, def, alpha_range, Re) -> Vector{SectionSolution}Evaluate all angles (radians) in one vectorized NeuralFoil call on the deformed Kulfan parameters, then assemble a per-node cp on the deformed contour nodes (def.x, def.y) from the predicted edge velocities (neuralfoil_contour_solution). cf is a flat-plate closure (flat_plate_cf), NeuralFoil not exposing skin friction.
VortexStepMethod.AirfoilAero.neuralfoil_aero — Function
neuralfoil_aero(params::KulfanParameters, alpha, Re;
model_size="xlarge", weights_dir=nothing, kwargs...)Compute aerodynamic coefficients using NeuralFoil.
Arguments
params: Kulfan CST parametersalpha: Angle of attack in degrees (scalar or vector)Re: Reynolds numbermodel_size: Neural network size (default "xlarge")weights_dir: Directory containing weight files
Keyword Arguments
n_crit: Critical amplification factor (default 9.0)xtr_upper: Upper surface transition location (default 1.0)xtr_lower: Lower surface transition location (default 1.0)
Returns
NeuralFoilResult: CL, CD, CM and analysis confidence
neuralfoil_aero(x::Vector, y::Vector, alpha, Re; kwargs...)Convenience function that fits Kulfan parameters from coordinates first.
VortexStepMethod.AirfoilAero.deform_kulfan — Function
deform_kulfan(basis, base, upper_deflection, lower_deflection) -> KulfanParameters
deform_kulfan(basis, base, camber) -> KulfanParametersAdd a surface deflection to a fixed set of Kulfan parameters, both deflections sampled on basis.x and normalized by chord. The three-argument form applies one camber deflection to both surfaces, leaving the thickness distribution untouched; the four-argument form deforms the surfaces independently, which is what a double-skin membrane with its own upper and lower control points needs.
Each deflection is first reduced to the part the basis can carry, see chord_residual: the straight line through its own endpoints is a chord rotation and translation, which the basis cannot express and pinv answers with runaway weights. Take that line from chord_line if the frame the deflection was measured in has not already absorbed it.
The leading-edge weight and the trailing-edge thickness are carried over unchanged: they are the two shape freedoms a chord-referenced deflection cannot resolve.
VortexStepMethod.AirfoilAero.chord_residual — Function
chord_residual(basis, deflection) -> Vector{Float64}The part of a deflection the CST basis can represent. C(x) vanishes at both chord ends, so the basis can put neither the leading nor the trailing edge off the chord line: a deflection that does not end at zero is asking for a chord rotation and translation, not a camber change. Projecting it anyway is not merely inexact, it is unstable — pinv answers an unrepresentable end displacement with weights an order of magnitude past the ones it is correcting, and the airfoil that comes back is not one.
Removes the straight line through the deflection's own endpoints, which chord_line returns, and gives back what is left.
VortexStepMethod.AirfoilAero.chord_line — Function
chord_line(basis, deflection) -> (offset, slope)The straight line through a deflection's own endpoints, both over chord: offset moves the leading edge and slope is the chord rotation atan(slope) [rad] the caller owes its angle of attack, when the frame it measured the deflection in has not already absorbed it. It is the part chord_residual removes.
VortexStepMethod.AirfoilAero.control_point_deflection — Function
control_point_deflection(basis, fractions, deflections) -> Vector{Float64}Resample a deflection given at arbitrary chord fractions onto basis.x, by linear interpolation, held flat outside the sampled range. This is the generic bridge from control points — beam nodes now, membrane nodes later — to the CST basis: the caller only has to say where along the chord each point sits and how far off the chord line it moved, both normalized by the local chord.
fractions need not be sorted, but must not repeat a station.
VortexStepMethod.AirfoilAero.panel_kulfan_parameters — Function
panel_kulfan_parameters(panels; delta=0.0) -> Vector{KulfanParameters}Fit the undeformed Kulfan parameters of every panel from the surface contour its section_aero carries, the starting point LivePolars deforms.
The contour is shrink_wrapped first, the same route the offline polar generator takes: fitting a raw slice directly returns weights that oscillate by an order of magnitude more than the deformation ever will, and neighbouring panels then disagree enough to cost the VSM solve its convergence. The wrap costs ~15 ms a panel, which is why this is done once at build time and never inside the loop.
VortexStepMethod.AirfoilAero.refresh_live_polars! — Function
refresh_live_polars!(live, panels, alpha_ref, reynolds; deflection=nothing)
-> Float64Regenerate every panel's polar table from its current shape and write it in (see set_polar!). Per panel: deform the base airfoil by deflection (a chord-normalized deflection on live.basis.x, or nothing to keep the base shape), evaluate NeuralFoil at alpha_ref .+ offsets, and hand those values straight to the panel.
The write is in place — same knot count, same vectors — and so is everything around it: the shapes, the network inputs, both symmetries of the forward pass and the decoded coefficients all land in storage live already holds, so a refresh every solve costs the forward pass and nothing else.
Each panel keeps the deformed shape it was evaluated at as its live_shape, so a plot draws the airfoil the network actually saw.
alpha_ref [rad] and reynolds are per panel or scalar. Every panel and sample goes through the network in one forward pass. Returns the lowest analysis confidence over the batch, a value near zero meaning the deformed shape has left the region the network was trained on.
Call it after the mesh has been rebuilt from the structure and before the solve: a mesh rebuild re-seeds each panel's aero from its section, which would drop the polar.
VortexStepMethod.AirfoilAero.deform_live_shapes! — Function
deform_live_shapes!(live::LivePolars, deflection) -> Vector{KulfanParameters}Deform every base airfoil by its camber increment and store the result in live.deformed, which is returned. nothing writes the base shapes back. The deformation is an analytic perturbation of one fixed weight vector, so it never refits and never inherits the non-uniqueness of a fit.
Each entry keeps the object it already was, its weights overwritten, so a panel holding one from an earlier refresh follows the current shape and no frame allocates a new one.
VortexStepMethod.AirfoilAero.apply_live_shapes! — Function
apply_live_shapes!(live::LivePolars, panels; deflection=nothing)Write each panel's deformed airfoil into live_shape without touching its polar, so the shape a panel reports is the one its current deformation gives. Replaying a log re-derives the drawn airfoil this way: the frame is never solved, so its polars would say nothing, and the network pass they cost is the whole price of a refresh.
VortexStepMethod.AirfoilAero.polar_drift — Function
polar_drift(live, alpha) -> Float64How far the largest panel angle of attack has drifted off the reference angle its polar was sampled about, as a fraction of the reach the samples give it. Above 1 a panel is being evaluated past the last sample, where the polar is held flat and says nothing about how the panel is really behaving, so this is the diagnostic a caller's solve loop reports or refreshes on. Asymmetric offsets are measured by their shorter side.
VortexStepMethod.AirfoilAero.compare_live_polar — Function
compare_live_polar(live::LivePolars, panel, panel_idx, alpha, reynolds;
solver=live_xfoil_solver(live)) -> NamedTupleOne viscous solve of panel panel_idx's current deformed shape against the live polar that panel is flying, at the same angle and Reynolds. Returns (; panel_idx, alpha, requested, reynolds, confidence, cl, cd, cm), each coefficient a (live, reference) pair.
XFoil is marched out from zero in ramp_step increments rather than jumped to the angle, and the comparison is made at the nearest angle it converged at — alpha is that angle and requested the one asked for. Both coefficients are read there, so the pair stays like for like even when the march stops short.
Run it when confidence is low. A confidence is the network's opinion of its own inputs — a shape far from what it was trained on scores badly whether or not the answer is wrong — so it says to go and check, not what the check will find. cl here is read off the panel's polar table rather than from a fresh network call, so what is compared is what the solver actually flew.
A reference that will not converge anywhere on the ramp comes back NaN rather than throwing, so a sweep over panels does not stop at the first one that fails.
VortexStepMethod.AirfoilAero.refresh_live_pressure! — Function
refresh_live_pressure!(cp, live, contour_x, contour_y, leading_edge, alpha,
reynolds) -> Vector{Vector{Float64}}Fill cp[i] with the surface pressure of panel i's current deformed shape at alpha[i], reconstructed on the contour nodes (contour_x[i], contour_y[i]) (see contour_pressure). cp is written in place and returned.
This is the pressure half of a live polar. refresh_live_polars! makes the panel forces follow the deformed shape; without this the pattern that spreads those forces over the structure would still come from the undeformed section, so a deformation would change how hard a panel pulls but not where it pulls.
One batched forward pass over all panels — through the same workspace the polar refresh uses, whose inputs it has already consumed — at the converged angle of attack rather than at the sampled ones — a panel's Cp is wanted at exactly one angle, and evaluating there is both cheaper than storing the samples and exact. Call it after the solve has converged, with the contours the traction pattern is indexed on — their deformed y, since the reconstruction runs in arc length and a deformed nose is a different distance around. Deform the shapes first, which refresh_live_polars! already did for this solve.
VortexStepMethod.AirfoilAero.live_surface_friction! — Function
live_surface_friction!(cf, contour_x, reynolds) -> Vector{Vector{Float64}}Fill cf[i] with the skin friction of panel i's contour nodes at reynolds[i], by the same flat-plate closure (flat_plate_cf) the offline tables carry — NeuralFoil does not predict skin friction. It depends on chord fraction and Reynolds only, not on the shape or the angle of attack, so the live value differs from the tabulated one purely by being at the panel's own flight Reynolds instead of the one the tables were generated at.
VortexStepMethod.AirfoilAero.contour_shape_matrix — Function
contour_shape_matrix(contour_x, n_weights) -> Matrix{Float64}The CST shape matrix C(x)·B(x) at chord fractions contour_x, mapping a change in Kulfan weights to the normal offset it produces there. Build it once per contour: the chord fractions of a panel's contour nodes never move, only the weights do.
VortexStepMethod.AirfoilAero.live_shape_offset! — Function
live_shape_offset!(offset, live::LivePolars, shape) -> Vector{Vector{Float64}}Fill offset[i] with the normal offset, over chord, that panel i's current deformation adds to its contour, given the panel's shape matrix from contour_shape_matrix. Written in place and returned.
A deflection deforms both surfaces by the same camber increment, so one offset serves the upper and lower halves of a contour alike. Added to a reference contour it gives the surface the network was actually evaluated on, which is what a traction pattern has to be draped over for its normals and segment areas to mean anything.
VortexStepMethod.AirfoilAero.generate_aero_matrices — Function
generate_aero_matrices(solver, x, y; alpha_range, delta_range, Re,
crease_frac=0.75, remove_nan=true, on_deform=nothing)
-> (cl, cd, cm)Build (alpha × delta) coefficient matrices for a base airfoil given as coordinates (x, y). Each delta deflects the trailing edge (deform_section); the deflected shape is then swept over alpha_range (radians) with solver — any AbstractAirfoilSolver, so this works identically for XFoil and NeuralFoil. Re is the Reynolds number. With remove_nan the (non-converged) NaN entries are interpolated away. on_deform(delta, x, y), if given, is called with each deflected shape's coordinates (e.g. to write a per-deflection .dat).
VortexStepMethod.AirfoilAero.generate_polar_from_coordinates — Function
generate_polar_from_coordinates(x, y, output_path; Re, alpha_range=-180:1:180,
solver=NeuralFoilSolver(), delta_range=nothing,
crease_frac=0.75, dat_prefix=nothing)Sweep solver over the airfoil coordinates (x, y) and write the polar CSV. XFoil uses the coordinates directly; NeuralFoil fits LeastSquaresFit Kulfan parameters (deform_section). Wrap a raw or open single-membrane slice with shrink_wrap before calling this. Pass a NeuralFoilSolver or XFoilSolver to pick the backend. With delta_range === nothing the sweep is over alpha_range only and written as a POLAR_VECTORS CSV (returns the Vector{SectionSolution}); pass a delta_range of trailing-edge deflections to sweep (alpha, delta) and write a long-format POLAR_MATRICES CSV (returns the (cl, cd, cm) matrices). Both angle ranges are in degrees. crease_frac is the chordwise hinge location (0–1) about which each delta_range deflection pivots. With dat_prefix set, each deflected shape is also written to {dat_prefix}_{delta_suffix(δ)}.dat.
VortexStepMethod.AirfoilAero.generate_polar_from_dat — Function
generate_polar_from_dat(dat_path, output_path; Re, kwargs...)Read a .dat airfoil and generate its polar CSV via generate_polar_from_coordinates. This is AirfoilAero's .dat → CSV entry point.
VortexStepMethod.AirfoilAero.generate_airfoil_aero — Function
generate_airfoil_aero(solver, base; alpha_range, delta_range, reynolds_number,
crease_frac=0.9, remove_nan=true) -> (SectionAero, sols)Run one solver sweep of a base airfoil (Kulfan) over the (alpha, delta) grid (radians) and return both the SectionAero (contour + Cp + cf per node) and the raw sols::Vector{Vector{SectionSolution}} (one inner vector per delta). The sols also carry cl/cd/cm, so a caller can write the polar from the same sweep — this is how obj_to_yaml avoids a second sweep. Non-converged points stay NaN and, when remove_nan, are filled per node with interpolate_matrix_nans!.
generate_airfoil_aero(solver, x::Vector, y::Vector; kwargs...) -> (SectionAero, sols)Convenience: shrink_wrap the coordinates and fit base Kulfan parameters (LeastSquaresFit) first.
VortexStepMethod.AirfoilAero.generate_section_aero — Function
generate_section_aero(solver, base_or_coords; kwargs...) -> SectionAeroJust the SectionAero from generate_airfoil_aero (drops the raw sols).
VortexStepMethod.AirfoilAero.generate_airfoils — Function
generate_airfoils(airfoils, output_dir; Re, alpha_range=-180:1:180,
delta_range=nothing, aero_solver=NeuralFoilSolver(),
reuse_valid_airfoils=true, crease_frac=0.75, verbose=true,
table_format=:csv) -> (airfoil_rows, ok)Run the 2D solver over a set of already-shrink-wrapped airfoils and write the per section files each geometry route references. Shared by the .obj and Surfplan adapters: the front-end supplies the clean airfoils and their leading/trailing-edge placement; this writes the surface pressure/friction tables, polars and airfoil .dats, and returns the wing_airfoils rows plus the ids that solved.
airfoils is a vector of (; id, x_fit, y_fit, x_raw, y_raw): x_fit/y_fit is the wrapped airfoil the solver analyses; x_raw/y_raw the raw points it enclosed.
Writes into output_dir (indexed by id), one directory per file kind: airfoils/{id}.dat (wrapped shape), airfoils/{id}_{delta_suffix(δ)}.dat (per deflection), airfoils/{id}_raw.dat (raw points); pressure/{id}_cp.{table_format} / _cf.{table_format} (per-node surface pressure and skin friction, :csv or the far faster-loading :arrow); and polars/{id}.csv (POLAR_VECTORS, or a POLAR_MATRICES grid when delta_range is set). aero_solver selects the backend (NeuralFoilSolver default, XFoilSolver opt-in).
With reuse_valid_airfoils=true an airfoil the solver cannot converge is skipped (the caller maps its sections to the nearest solved id); otherwise it errors.
VortexStepMethod.AirfoilAero.write_section_aero — Function
write_section_aero(dat_prefix, aero::SectionAero; table_prefix=dat_prefix,
table_format=:csv) -> (dat, cp_table, cf_table)Write a SectionAero as human-readable files. The airfoil contours share dat_prefix: {dat_prefix}.dat (contour at delta=0) plus {dat_prefix}_{delta_suffix(δ)}.dat per non-zero deflection. The per-node Cp/cf tables share table_prefix (defaults to dat_prefix): {table_prefix}_cp.{ext} and {table_prefix}_cf.{ext}, where ext is table_format, either :csv (default, readable) or :arrow (binary, an order of magnitude faster to load). Pass a separate table_prefix to keep the surface tables out of the airfoil-shape directory. read_section_aero reads either format back, detecting it from the suffix. The single writer for surface aero (submodule side); loading lives in the main package.
OBJ mesh conversion (ObjAdapter)
These live in the ObjAdapter submodule of VortexStepMethod, which converts a 3D wing .obj mesh to the native YAML/CSV geometry format. Load it with using VortexStepMethod.ObjAdapter.
VortexStepMethod.ObjAdapter.obj_to_yaml — Function
obj_to_yaml(obj_path, output_dir; n_sections, Re, alpha_range=-180:1:180,
aero_solver=NeuralFoilSolver(), wrap_method=ShrinkWrap(),
spanwise_direction=[0.0, 1.0, 0.0], crease_frac=0.75, verbose=true)Convert a 3D wing .obj mesh to the native YAML geometry route.
Stations are placed at equal leading-edge arc-length intervals and sliced perpendicular to the local span (see perpendicular_sections), which keeps the airfoil undistorted near curved tips; each shape is then shrink-wrapped into a clean airfoil and evaluated with aero_solver. A tip that tapers to a point carries no airfoil, so the outermost stations stop at the last slice that still has a chord (station_indices); wingtip_distance moves them a further arc length inboard.
aero_solver selects the 2D-airfoil backend: NeuralFoilSolver (default, fast) or XFoilSolver (viscous panel code); pass aero_solver=XFoilSolver() to use XFoil instead. Each section's polar is written as POLAR_VECTORS.
crease_frac is the chordwise hinge location (0–1) about which each delta_range trailing-edge deflection pivots.
With force=false (default) an existing geometry YAML is reused; force=true regenerates it (e.g. after changing delta_range or the mesh).
geometry_path names the YAML itself, output_dir/geometry.yaml by default. Point it elsewhere to keep the YAML out of the table directory — the emitted table references then carry the path from the YAML's directory to output_dir, which is what the geometry loader resolves them against.
wrap_method (ShrinkWrap) wraps each slice's point cloud into a clean closed airfoil, robust both to the noisy interior-structure points (ribs, spars) of a ram-air kite slice that otherwise pull a plain least-squares fit inward, and to the complex, harsh corners of an LEI kite slice; NeuralFoil then fits Kulfan parameters to it and XFoil uses it directly.
A slice whose fitted airfoil is implausibly thick relative to the others (e.g. a near-vanishing wingtip slice that fits to a blob) is flagged as degenerate: a section is degenerate when its fitted max|y| exceeds max_thickness_ratio times the median over all sections. With reuse_valid_airfoils=true (default) each degenerate section reuses the nearest valid section's airfoil shape and polar, while keeping its own leading- and trailing-edge positions, so the station is still placed but carries a sane airfoil; a warning lists which sections were reused. With reuse_valid_airfoils=false the degenerate fit is written as is.
Output
Writes into output_dir, indexed by source-airfoil id j (degenerate sections share a neighbour's id):
airfoils/{j}.dat— shrink-wrapped airfoil coordinates (matches the polar)airfoils/{j}_raw.dat— raw sliced section points (the wrap encloses these)airfoils/{j}_d{tag}.dat— each trailing-edge-deflected shape (with adelta_range);{tag}is the deflection in degrees withmfor minus andpfor the decimal point (e.g._dm3.dat,_d2p5.dat)polars/{j}.csv— NeuralFoil polar (alpha, Cd, Cs, Cl, Cm)pressure/{j}_cp.{table_format},_cf.{table_format}— per-node surface pressure and skin friction;table_formatis:csv(default, readable) or:arrow(binary, an order of magnitude faster to load)geometry.yaml—wing_sections+wing_airfoilsreferencing the above
Returns
- Path to the written
geometry.yaml.
VortexStepMethod.AirfoilAero.write_yaml — Function
write_yaml(path, data)Write data (nested Dicts, vectors, scalars) to path as YAML with every float rounded to millimetre precision (3 decimals) and every leaf list on one line. The single writer for all generated geometry YAMLs, so their formatting stays consistent. Mapping keys are emitted in sorted order.
VortexStepMethod.ObjAdapter.perpendicular_sections — Function
perpendicular_sections(vertices, faces, n_sections; n_bins=60, rotation=I)Extract n_sections airfoil cross-sections following a curved or swept span. The leading edge is marched into n_bins stations (march_edges); the airfoil is built (build_section) at the marched station nearest each equal leading-edge arc-length target. Each section is (; LE_point, TE_point, span_dir, contour3d, x_airfoil, y_airfoil).
Stations closed to a point at the tips are skipped (station_indices), and wingtip_distance insets the outermost sections a further arc length.
The slicer assumes x = chordwise, y = spanwise, z = up. Pass a 3×3 rotation matrix to reorient a mesh stored in another convention before slicing.
VortexStepMethod.ObjAdapter.plot_airfoils — Function
plot_airfoils(geometry_file::String; kwargs...)Plot every airfoil of a YAML wing geometry. The airfoil shapes are read from the .dat files referenced by the geometry's wing_airfoils. Implemented in the VortexStepMethodMakieExt extension (load a Makie backend, e.g. GLMakie).
VortexStepMethod.ObjAdapter.plot_slices_3d — Function
plot_slices_3d(path; n_slices=10, rotation=I, delta=0.0, is_show=true)3D slice diagnostic with a hover 2D airfoil panel. path is either a mesh .obj (live preview: slice and wrap here) or a generated obj_to_yaml output directory (audit: plot the written .dat airfoils the polar pipeline analysed). Implemented in the Makie extension (load Makie/GLMakie); see it for all options.
Surfplan conversion (SurfplanAdapter)
These live in the SurfplanAdapter submodule of VortexStepMethod, which converts the output of the (Python) SurfplanAdapter export into the native, VSM-loadable YAML/CSV geometry format. Load it with using VortexStepMethod.SurfplanAdapter.
VortexStepMethod.SurfplanAdapter.surfplan_to_aero_yaml — Function
surfplan_to_aero_yaml(adapter_dir, output_dir; aero_solver=NeuralFoilSolver(),
wrap_method=ShrinkWrap(), alpha_range=-180:1:180, delta_range=nothing,
Re=nothing, crease_frac=0.75, force=false, verbose=true) -> geometry_yaml_pathGenerate a pressure-ready geometry.yaml (per-node surface cp/cf tables plus polars) from a SurfplanAdapter aero export, so the wing can be flown with the AeroPressure continuous coupling instead of only integrated polars. The Surfplan counterpart of obj_to_yaml: where .obj slices a mesh, this reads the already-clean per-rib airfoil .dat profiles the Python adapter exported.
Reads adapter_dir/aero_geometry.yaml for each section's leading/trailing-edge placement (wing_sections) and each airfoil's .dat path (wing_airfoils info_dict.dat_file_path), shrink-wraps every unique profile, and runs the shared generate_airfoils core with aero_solver (NeuralFoilSolver by default, XFoilSolver opt-in). Sections that share an airfoil generate its tables once. Re defaults to the export's wing_airfoils.reynolds; alpha_range defaults to the full -180:1:180 sweep rather than the export's narrow polar range. table_format writes the per-node surface tables as :csv (default, readable) or :arrow (binary, an order of magnitude faster to load).
The .txt → adapter-YAML step (the upstream Python SurfplanAdapter) is the documented prerequisite. Load the result with Wing(geometry_yaml_path).
An existing geometry.yaml in output_dir is reused as-is, skipping the expensive polar generation; set force=true to regenerate the polars.
Setting the inflow conditions and solving
VortexStepMethod.set_va! — Function
set_va!(body_aero::BodyAerodynamics, va::VelVector, omega=zeros(MVec3))Set velocity array and update wake filaments.
Arguments
- body_aero::BodyAerodynamics: The
BodyAerodynamicsstruct to modify va::VelVector: Velocity vector of the apparent wind speed [m/s]omega::VelVector: Turn rate vector around x y and z axis [rad/s]
omega is also projected onto each panel's spanwise axis into pitch_rate_dist, which the solver reads when flow_curvature is enabled.
set_va!(body_aero::BodyAerodynamics, va_distribution::AbstractMatrix;
pitch_rate_dist=nothing)Set a per-panel inflow distribution. pitch_rate_dist gives each panel's rotation rate about its own spanwise axis [rad/s], positive nose-up; build it with section_pitch_rate when the structure deforms, since twist and flapping rates differ per section and no single body rate describes them. It is reset to zero when omitted, because this method takes no omega and a stale one would silently feed the flow_curvature moment.
set_va!(body_aero::BodyAerodynamics, settings::VSMSettings)Set velocity array from VSM settings configuration.
This convenience method extracts flight conditions from VSMSettings and constructs the velocity vector in the body reference frame based on:
- Wind speed from settings.condition.wind_speed
- Angle of attack from settings.condition.alpha (converted from degrees)
- Sideslip angle from settings.condition.beta (converted from degrees)
The velocity vector is constructed as:
- Xb (forward): windspeed * cos(α) * cos(β)
- Yb (right): windspeed * sin(β)
- Zb (down): windspeed * sin(α) * cos(β)
Arguments
body_aero::BodyAerodynamics: The aerodynamic body to modifysettings::VSMSettings: Settings object containing flight conditions
Example
settings = VSMSettings("path/to/settings.yaml")
body_aero = BodyAerodynamics([wing])
set_va!(body_aero, settings)VortexStepMethod.section_pitch_rate — Function
section_pitch_rate(delta_va, z_airf, chord)
section_pitch_rate(velocity_leading, velocity_trailing, z_airf, chord)Rate a section rotates about its own spanwise axis, positive nose-up. delta_va is the trailing minus leading edge apparent wind; apparent wind is wind - velocity, so that is the leading minus trailing edge velocity, hence the reversed order in the four-argument form. Chordwise wind variation enters here too. Builds a pitch_rate_dist for set_va! on a deforming structure, where no single body rate describes every section.
CommonSolve.solve — Function
solve(solver::Solver, body_aero::BodyAerodynamics, gamma_distribution=nothing;
log=false, reference_point=solver.reference_point)Main solving routine for the aerodynamic model. Reference point is in the kite body (KB) frame. See also: solve!
Arguments:
- solver::Solver: The solver to use, could be a VSM or LLT solver. See:
Solver - body_aero::BodyAerodynamics: The aerodynamic body. See:
BodyAerodynamics - gamma_distribution: Initial circulation vector or nothing; Length: Number of segments. [m²/s]
Keyword Arguments:
- log=false: If true, print the number of iterations and other info.
- referencepoint=solver.referencepoint
Returns
A dictionary with the results.
CommonSolve.solve! — Function
solve!(solver::Solver, body_aero::BodyAerodynamics, gamma_distribution=solver.sol.gamma_distribution;
log=false, reference_point=solver.reference_point, moment_frac=0.1)Main solving routine for the aerodynamic model. Reference point is in the kite body (KB) frame. This version is modifying the solver.sol struct and is faster than the solve function which returns a dictionary.
Arguments:
- solver::Solver: The solver to use, could be a VSM or LLT solver. See:
Solver - body_aero::BodyAerodynamics: The aerodynamic body. See:
BodyAerodynamics - gamma_distribution: Initial circulation vector or nothing; Length: Number of segments. [m²/s]
Keyword Arguments:
- log=false: If true, print the number of iterations and other info.
- referencepoint=solver.referencepoint
- moment_frac=0.1: X-coordinate of normalized panel around which the moment distribution should be calculated.
Returns
The solution of type VSMSolution
VortexStepMethod.solve_base! — Function
solve_base!(solver::Solver, body_aero::BodyAerodynamics, gamma_distribution=nothing;
log=false)Converge the circulation distribution and leave it in solver.lr.gamma_new, without turning it into forces. Fills the solver's panel arrays, builds the AIC matrices, starts from gamma_distribution (or an elliptical/zero guess when it is nothing or solver.use_gamma_prev is false) and iterates; a LOOP solver that fails to converge retries once with half the relaxation factor. The circulation half of solve!, paired with calc_forces!. Returns nothing.
VortexStepMethod.reinit! — Method
reinit!(body_aero::BodyAerodynamics; init_aero, va, omega, refine_mesh, recompute_mapping, sort_sections)Initialize a BodyAerodynamics struct in-place by setting up panels and coefficients.
Arguments
body_aero::BodyAerodynamics: The structure to initialize
Keyword Arguments
init_aero::Bool: Whether to initialize the aero data or notva=[15.0, 0.0, 0.0]: Apparent wind vectoromega=zeros(3): Turn rate in kite body frame x y and z
Returns
nothing
VortexStepMethod.linearize — Function
linearize(solver, body_aero, y; theta_idxs=1:4, delta_idxs=nothing,
va_idxs=nothing, omega_idxs=nothing, aero_coeffs=false,
backend=AutoForwardDiff(), kwargs...)Jacobian of aerodynamic outputs w.r.t. control and kinematic inputs at y. Each *_idxs selects which entries of y map to twist angles (one per unrefined section), trailing-edge deflections (one per unrefined section), apparent wind (vx, vy, vz), and angular rate (ωx, ωy, ωz) respectively.
backend accepts any DifferentiationInterface backend; AutoForwardDiff() (the default) requires solver_type=LOOP. fd_absstep/fd_relstep are forwarded only when the backend is AutoFiniteDiff.
Returns (jac, results, converged) where results is (F, M, moment_unrefined_dist...) — or the corresponding coefficients when aero_coeffs=true — and converged is false (with a warning) if any internal solve missed the solver's tolerances.
VortexStepMethod.calculate_results — Function
calculate_results(body_aero::BodyAerodynamics, gamma_new,
density,
core_radius_fraction, mu,
alpha_dist, v_a_dist,
chord_array, x_airf_array,
z_airf_array,
va_array, va_norm_array,
va_unit_array, panels::Vector{<:Panel},
is_only_f_and_gamma_output::Bool)Calculate final aerodynamic results. Reference point is in the kite body (KB) frame.
flow_curvature adds flow_curvature_cm to every section moment, read from body_aero.omega.
Returns: Dict: Results including forces, coefficients and distributions
Main Plotting Functions
The plotting functions are implemented as package extensions. They are available once a Makie backend (GLMakie or CairoMakie) and MakieControlPlots are loaded before VortexStepMethod. The examples use GLMakie.
VortexStepMethod.plot_geometry — Function
plot_geometry(body_aero::BodyAerodynamics, title; kwargs...)Plot wing geometry from different viewpoints and optionally save/show plots.
Arguments
body_aero: theBodyAerodynamicsto plottitle: plot title
Keyword arguments
data_type: file extension for saving (default:".png")save_path: path for saving the graphic (default:nothing)is_save: whether to save the graphic (default:false)is_show: whether to display the graphic (default:false)view_elevation: initial view elevation angle in degrees (default:15)view_azimuth: initial view azimuth angle in degrees (default:-120)use_tex: use externalpdflatexfor rendering (default:false; ignored by Makie)
VortexStepMethod.plot_distribution — Function
plot_distribution(y_coordinates_list, results_list, label_list; kwargs...)Plot spanwise distributions of aerodynamic properties.
Arguments
y_coordinates_list: list of spanwise coordinate arraysresults_list: list of result dictionaries fromsolve!label_list: list of labels for each result
Keyword arguments
title: plot title (default:"spanwise_distribution")data_type: file extension for saving (default:".png")save_path: path to save plots (default:nothing)is_save: whether to save (default:false)is_show: whether to display (default:true)use_tex: use externalpdflatexfor rendering (default:false; ignored by Makie)
VortexStepMethod.plot_polars — Function
plot_polars(solver_list, body_aero_list, label_list; kwargs...)Plot polar data comparing different solvers and configurations.
Arguments
solver_list: list of aerodynamic solversbody_aero_list: list ofBodyAerodynamicsobjectslabel_list: list of labels for each configuration
Keyword arguments
literature_path_list: optional paths to literature data CSV files (default:String[])angle_range: range of angles to analyze in degrees (default:range(0, 20, 2))angle_type:"angle_of_attack"or"side_slip"(default:"angle_of_attack")angle_of_attack: AoA for the polar sweep (default:0.0) [°]side_slip: side slip angle (default:0.0) [°]v_a: apparent wind speed magnitude (default:10.0) [m/s]title: plot title (default:"polar")data_type: file extension for saving (default:".png")save_path: path to save plots (default:nothing)is_save: whether to save (default:true)is_show: whether to display (default:true)use_tex: use externalpdflatexfor rendering (default:false; ignored by Makie)cl_over_cd: plot CL/CD vs angle instead of CL vs CD (default:true)
VortexStepMethod.plot_polar_data — Function
plot_polar_data(body_aero::BodyAerodynamics; kwargs...)Plot polar data (Cl, Cd, Cm) as 3-D surfaces against angle of attack and trailing edge deflection.
Arguments
body_aero: theBodyAerodynamicsto plot (must usePOLAR_MATRICESaero model)
Keyword arguments
alphas: AoA values in radians (default:deg2rad.(-5:0.3:25))delta_tes: trailing edge deflection angles in radians (default:deg2rad.(-5:0.3:25))is_show: whether to display (default:true)use_tex: use externalpdflatexfor rendering (default:false; ignored by Makie)
VortexStepMethod.plot_combined_analysis — Function
plot_combined_analysis(solver, body_aero, results; kwargs...)Create a combined analysis by calling plot_geometry, plot_distribution, and plot_polars in sequence.
Arguments
solver: solver or vector of solversbody_aero:BodyAerodynamicsobject or vector thereofresults: results dictionary (or vector) fromsolve!
Keyword arguments
solver_label: label string for the solver (backward-compatible alias forlabels)labels: optional label string or vectorangle_range: range of angles for polar plots (default:range(0, 20, length=20))angle_type:"angle_of_attack"or"side_slip"(default:"angle_of_attack")angle_of_attack: AoA in degrees (default:0.0)side_slip: side slip angle in degrees (default:0.0)v_a: wind speed in m/s (default:10.0)title: overall figure title (default:"Combined Analysis")view_elevation: geometry view elevation in degrees (default:15)view_azimuth: geometry view azimuth in degrees (default:-120)is_show: whether to display (default:true)use_tex: use externalpdflatexfor rendering (default:false; ignored by Makie)literature_path_list: paths to literature CSV files (default:String[])data_type: file extension for saving (default:".png")save_path: directory to save files (default:nothing)is_save: whether to save (default:false)cl_over_cd: plot CL/CD vs angle (default:true)
VortexStepMethod.plot_section_polars — Function
plot_section_polars(body_aero::BodyAerodynamics, coefficient=:cl; kwargs...)Plot one polar coefficient (:cl, :cd, or :cm) against angle of attack for every section of a wing using stored POLAR_VECTORS data. Rendered through MakieControlPlots.
Arguments
body_aero: theBodyAerodynamicsto plotcoefficient::cl,:cd, or:cm(default::cl)
Keyword arguments
is_show: whether to display (default:true)is_save: whether to save (default:false)save_path: directory to save the figure (default:nothing)data_type: file extension for saving (default:".png")
Helper Functions
VortexStepMethod.save_plot — Function
save_plot(fig, save_path, title; data_type=nothing)Save a Makie figure to a file.
Arguments
fig: Makie Figure objectsave_path: Path to save the plottitle: Title of the plot
Keyword arguments
data_type: File extension. Ifnothing, defaults to".pdf"when the active Makie backend is CairoMakie and".png"otherwise.
VortexStepMethod.show_plot — Function
show_plot(fig; name="", dpi=130)Display a Makie figure in an interactive session, in the window named name.
Arguments
fig: Makie Figure object
Keyword arguments
name: Window to draw in; reusing a name reuses its window (default: "")dpi: Dots per inch for the figure (default: 130) - currently unused in Makie