Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ LocalPreferences.toml
# Pipeline-generated polars/dat/cp (regenerate via AirfoilAero/ObjAdapter)
data/**/polars_neuralfoil/
data/**/polars_xfoil/
data/**/generated_neuralfoil/
data/TUDELFT_V3_KITE/aero_geometry_neuralfoil.yaml

# Local planning docs (not tracked)
Expand Down
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
and `surfplan_to_aero_yaml`: `:csv` (default, readable) or `:arrow` (binary, ~40×
faster to load and 2.6× smaller). `read_section_aero` detects the format from the
file suffix, so a geometry YAML can reference either.
- `geometry_path` keyword on `obj_to_yaml`, naming the geometry YAML itself
instead of always writing `output_dir/geometry.yaml`. Point it outside the
table directory and the emitted table references carry the path from the YAML's
directory to `output_dir`, which is what the geometry loader resolves them
against — so a generated dataset can keep its bulk in a subdirectory while the
geometry sits with the hand-written ones.
- `convert_node_table` and `write_node_rows` rewrite a per-node table in the format
the destination suffix names. `obj_to_yaml` migrates an existing dataset with
them when `table_format` differs from what the directory holds, so a dataset
Expand All @@ -18,6 +24,32 @@
instead of its own (`ZEROS`, `0.05`), and never said what `core_radius_fraction`
measures. It now documents the `Solver` defaults and cites Damiani et al. (2019) for
the 0.05 cut-off.
- A remesh under `use_prior_polar` no longer resamples the refined sections'
`SectionAero` surface tables down to whatever unrefined sections survive it.
`compute_refined_section_interpolation!` reblended contour, `cp` and `cf` from the
unrefined sections unconditionally while `aero_data` was preserved, so a wing rebuilt
onto fewer structural stations kept full-resolution polars but lost the surface tables
pressure integration reads. The reblend is now skipped when the polars are preserved
and the refined sections already carry tables.

- `obj_to_yaml` no longer places sections on a wingtip that has closed to a point.
`station_indices` spreads its targets over the stations that still have a chord,
so the outermost section lands on the last sliceable one. A V3 mesh sliced with
the default `wingtip_distance` used to put a zero-chord section at each tip,
whose polar was `NaN` and took the whole solve with it; working around it meant
guessing a `wingtip_distance` large enough to skip past the tip. That workaround
is no longer the default: `wingtip_distance` is now `0.0`, an inset on top of the
trim for meshes whose slices just short of the tip are still too thin to analyse.

- Wing sections are normalized to `+y` to `-y` order on load (`normalize_span_order!`),
and by `refine!` for wings built through `add_section!`. Panel `y_airf` and `z_airf`
follow the order sections are stored in, so a geometry file written the other way
round inverted every panel normal, and a wing whose sections were replaced after its
panels were built (a structural remesh) inverted them mid-run, one panel at a time as
each crossed `spanwise_direction`. `obj_to_yaml` and `surfplan_to_aero_yaml` emit that
order too; files of either order keep loading the same.
- Spanwise distribution plots put `+y` on the left, matching that order and the kite
seen from the front.

### Changed
- `SolverSettings` now defaults to the same values as `Solver`: `core_radius_fraction`
Expand All @@ -40,6 +72,10 @@
- `read_node_table` parses into a preallocated matrix instead of `reduce(vcat, …)`
over a generator, which was quadratic in the row count: ~21× faster on a 16 MB
surface table (2.49 s → 0.12 s), benefiting every existing dataset.
- `is_show=true` draws into a window named after the plot title instead of into
whichever window the backend last used, so a script showing several plots gets
one window each and re-running it redraws them in place. `show_plot` takes the
window `name` as a keyword.

## VortexStepMethod v4.0.0 2026-08-03

Expand Down
9 changes: 6 additions & 3 deletions docs/src/airfoil_pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,12 @@ For each unique airfoil id `j`, `obj_to_yaml` writes into `output_dir`:
- `geometry.yaml` — `wing_sections` (leading/trailing-edge points) plus `wing_airfoils`
(each section's `type` and the `.dat`/`.csv` paths above)

A near-vanishing wingtip slice can shrink-wrap to an implausibly thick blob; such a
degenerate section reuses its nearest valid neighbour's airfoil and polar while keeping its
own edge positions, and a warning lists the reuse. All floats are rounded to millimetre
A tip that tapers to a point has no airfoil to slice, so the outermost stations stop at
the last slice that still has a chord; `wingtip_distance` moves them a further arc length
inboard when the slices just short of the tip are still too thin to analyse. A
near-vanishing slice that does get through can shrink-wrap to an implausibly thick blob;
such a degenerate section reuses its nearest valid neighbour's airfoil and polar while
keeping its own edge positions, and a warning lists the reuse. All floats are rounded to millimetre
precision by the single [`write_yaml`](@ref VortexStepMethod.ObjAdapter.write_yaml) writer,
so generated geometry files stay diff-friendly and consistent.

Expand Down
11 changes: 9 additions & 2 deletions docs/src/private_functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ calculate_relative_alpha_and_velocity
calculate_relative_alpha_and_relative_velocity
update_effective_angle_of_attack!
calculate_stall_angle_list
wing_span_flip
calculate_circulation_distribution_elliptical_wing
_compute_reference_velocity_from_distribution
smooth_circulation!
Expand All @@ -44,7 +45,7 @@ update_panel_properties!
build_interps
panel_interp_types
reinit!(wing::AbstractWing)
reinit!(panel::Panel, section_1::Section, section_2::Section, aero_center, control_point, bound_point_1, bound_point_2, x_airf, y_airf, z_airf, delta, vec, spanwise_direction)
reinit!(panel::Panel, section_1::Section, section_2::Section, aero_center, control_point, bound_point_1, bound_point_2, x_airf, y_airf, z_airf, delta, vec)
rotated_te
calculate_filaments_for_plotting
```
Expand All @@ -60,7 +61,9 @@ copy_sections_to_refined!
_apply_refined_section_thetas!
_panel_thetas_to_section_thetas!
_interpolate_unrefined_to_refined
_section_sort_key
span_order_key
normalize_span_order!
can_reuse_prior_refined_surface_tables
refine_mesh_for_linear_cosine_distribution!
refine_mesh_by_splitting_provided_sections!
refine_mesh_with_billowing!
Expand Down Expand Up @@ -176,6 +179,8 @@ center_to_com!
airfoils_from_yaml
write_geometry_yaml
resolve_aero_geometry
table_path_prefix
prefix_table_paths!
plot_airfoil_fit
migrate_node_tables
```
Expand All @@ -185,6 +190,8 @@ migrate_node_tables
CurrentModule = Base.get_extension(VortexStepMethod, :VortexStepMethodMakieExt)
```
```@docs
display_named
span_axis
create_geometry_plot_makie
plot_line_segment_makie!
set_axes_equal_makie!
Expand Down
186 changes: 64 additions & 122 deletions examples/V3_kite.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@ using Pkg
if Base.active_project() != joinpath(@__DIR__, "Project.toml")
Pkg.activate(@__DIR__)
end
using LinearAlgebra
using GLMakie
using MakieControlPlots
using VortexStepMethod
using VortexStepMethod.ObjAdapter
using VortexStepMethod.AirfoilAero: ShrinkWrap, NeuralFoilSolver

PLOT = true
SAVE_ALL = false
USE_TEX = false
DEFORM = false
NEURALFOIL = true
# Rolling-ball radius of the shrink wrap; fillets the concave tube-canopy junction.
MIN_CONCAVE_RADIUS = 0.4
OUTPUT_DIR = joinpath(dirname(@__DIR__), "output")
REFERENCE_POINT = [0.422646, 0.0, 9.3667]

project_dir = dirname(@__DIR__)
literature_paths = [
Expand All @@ -24,43 +29,21 @@ literature_paths = [
joinpath(project_dir, "data", "TUDELFT_V3_KITE", "literature_results",
"windtunnel_alpha_sweep_beta_00_0_Poland_2025_Rey_5e5.csv"),
]
labels = [
"VSM Julia Re=5e5",
"CFD Re=5e5",
"CFD Re=10e5", #with struts
"VSM Python Re=5e5",
"WindTunnel Re=5e5" #with struts
]
beta_literature_paths = [
joinpath(project_dir, "data", "TUDELFT_V3_KITE", "literature_results",
"windtunnel_beta_sweep_alpha_07_4_Poland_2025_Rey_5e5.csv"),
]
beta_labels = [
labels[1],
"Wind Tunnel Re=5e5 beta sweep alpha=7.4",
]

# Load YAML settings directly
settings_path = joinpath(
project_dir, "data", "TUDELFT_V3_KITE", "vsm_settings.yaml")
settings_data = VortexStepMethod.YAML.load_file(settings_path)
condition_cfg = settings_data["condition"]
wing_cfg = settings_data["wings"][1]
solver_cfg = settings_data["solver_settings"]

# Create wing, body_aero, and solver objects using settings
wing = Wing(
joinpath(project_dir, wing_cfg["geometry_file"]);
n_panels=wing_cfg["n_panels"],
spanwise_distribution=getproperty(
VortexStepMethod,
Symbol(wing_cfg["spanwise_panel_distribution"])),
spanwise_direction=Float64.(wing_cfg["spanwise_direction"]),
remove_nan=wing_cfg["remove_nan"],
)
settings = VSMSettings(joinpath(project_dir, "data", "TUDELFT_V3_KITE",
"vsm_settings.yaml"); data_prefix=false)
settings.wings[1].geometry_file = joinpath(project_dir,
settings.wings[1].geometry_file)
wing = Wing(settings)
refine!(wing)
body_aero = BodyAerodynamics([wing])
VortexStepMethod.reinit!(body_aero)
solver = Solver(body_aero, settings)
solver.reference_point .= REFERENCE_POINT

if DEFORM
VortexStepMethod.unrefined_deform!(
Expand All @@ -72,52 +55,56 @@ if DEFORM
VortexStepMethod.reinit!(body_aero; init_aero=false)
end

# Construct Solver using keyword arguments from solver settings
solver = Solver(body_aero;
solver_type=(solver_cfg["solver_type"] == "NONLIN" ? NONLIN : LOOP),
aerodynamic_model_type=getproperty(
VortexStepMethod,
Symbol(solver_cfg["aerodynamic_model_type"])),
density=solver_cfg["density"],
max_iterations=solver_cfg["max_iterations"],
rtol=solver_cfg["rtol"],
tol_reference_error=solver_cfg["tol_reference_error"],
relaxation_factor=solver_cfg["relaxation_factor"],
is_with_artificial_damping=solver_cfg["artificial_damping"],
artificial_damping=(k2=solver_cfg["k2"], k4=solver_cfg["k4"]),
type_initial_gamma_distribution=getproperty(
VortexStepMethod,
Symbol(solver_cfg["type_initial_gamma_distribution"])),
use_gamma_prev=get(solver_cfg, "use_gamma_prev",
get(solver_cfg, "use_gamme_prev", true)),
core_radius_fraction=solver_cfg["core_radius_fraction"],
mu=solver_cfg["mu"],
is_only_f_and_gamma_output=get(
solver_cfg, "calc_only_f_and_gamma", false),
correct_aoa=get(solver_cfg, "correct_aoa", false),
reference_point=get(solver_cfg, "reference_point",
[0.422646, 0.0, 9.3667]),
)

# Extract values for plotting
wind_speed = condition_cfg["wind_speed"]
angle_of_attack_deg = condition_cfg["alpha"]
sideslip_deg = condition_cfg["beta"]
yaw_rate = condition_cfg["yaw_rate"]

# Set flight conditions from settings
α0 = deg2rad(angle_of_attack_deg)
β0 = deg2rad(sideslip_deg)
set_va!(body_aero,
wind_speed .* [cos(α0) * cos(β0), sin(β0), sin(α0) * cos(β0)])
# Second sweep on generated polars: slice V3_25.obj, shrink-wrap every section into a
# closed airfoil and sweep it with NeuralFoil, so the same kite flies on polars derived
# from its own CAD surface instead of the checked-in CFD tables.
if NEURALFOIL
obj_file = joinpath(project_dir, "data", "TUDELFT_V3_KITE", "V3_25.obj")
generated_dir = joinpath(project_dir, "data", "TUDELFT_V3_KITE",
"generated_neuralfoil")
nf_yaml = obj_to_yaml(obj_file, generated_dir;
n_sections=settings.wings[1].n_panels, Re=1e6, force=false,
aero_solver=NeuralFoilSolver(model_size="large", n_crit=4.0,
xtr_upper=0.05, xtr_lower=0.05),
wrap_method=ShrinkWrap(clearance=0.0,
min_concave_radius=MIN_CONCAVE_RADIUS),
)
settings_nf = deepcopy(settings)
settings_nf.wings[1].geometry_file = nf_yaml
wing_nf = Wing(settings_nf)
refine!(wing_nf)
body_nf = BodyAerodynamics([wing_nf])
VortexStepMethod.reinit!(body_nf)
solver_nf = Solver(body_nf, settings_nf)
solver_nf.reference_point .= REFERENCE_POINT

# Reading the generated directory instead of the OBJ shows the airfoils the polar
# pipeline actually analysed. Hover a slice to inspect its 2D fit.
PLOT && plot_slices_3d(generated_dir; obj_path=obj_file)
end

# Solve
solvers = NEURALFOIL ? [solver, solver_nf] : [solver]
bodies = NEURALFOIL ? [body_aero, body_nf] : [body_aero]
solver_labels = NEURALFOIL ? ["VSM Julia CFD", "VSM Julia NeuralFoil"] :
["VSM Julia CFD"]
labels = [solver_labels;
["CFD Re=5e5",
"CFD Re=10e5", #with struts
"VSM Python Re=5e5",
"WindTunnel Re=5e5"]] #with struts
beta_labels = [solver_labels; ["Wind Tunnel Re=5e5 beta sweep alpha=7.4"]]

wind_speed = settings.condition.wind_speed
angle_of_attack_deg = settings.condition.alpha
sideslip_deg = settings.condition.beta
yaw_rate = settings.condition.yaw_rate

set_va!(body_aero, settings)
results = VortexStepMethod.solve(solver, body_aero; log=true)

# Plotting polars with moment coefficients
PLOT && plot_polars(
[solver],
[body_aero],
solvers,
bodies,
labels,
literature_path_list=literature_paths,
angle_range=range(-5, 25, length=31),
Expand All @@ -130,7 +117,8 @@ PLOT && plot_polars(
is_save=false || SAVE_ALL,
is_show=true,
use_tex=USE_TEX,
show_moments=true
show_moments=false,
cl_over_cd=true
)

# Plotting geometry
Expand Down Expand Up @@ -160,56 +148,10 @@ PLOT && plot_distribution(
use_tex=USE_TEX
)

# --- Dual solver comparison: NONLIN vs LOOP ---
solver_cfg["solver_type"] = "LOOP"
solver_loop = Solver(body_aero;
solver_type=LOOP,
aerodynamic_model_type=getproperty(
VortexStepMethod,
Symbol(solver_cfg["aerodynamic_model_type"])),
density=solver_cfg["density"],
max_iterations=solver_cfg["max_iterations"],
rtol=solver_cfg["rtol"],
tol_reference_error=solver_cfg["tol_reference_error"],
relaxation_factor=solver_cfg["relaxation_factor"],
is_with_artificial_damping=solver_cfg["artificial_damping"],
artificial_damping=(k2=solver_cfg["k2"], k4=solver_cfg["k4"]),
type_initial_gamma_distribution=getproperty(
VortexStepMethod,
Symbol(solver_cfg["type_initial_gamma_distribution"])),
use_gamma_prev=get(solver_cfg, "use_gamma_prev",
get(solver_cfg, "use_gamme_prev", true)),
core_radius_fraction=solver_cfg["core_radius_fraction"],
mu=solver_cfg["mu"],
is_only_f_and_gamma_output=get(
solver_cfg, "calc_only_f_and_gamma", false),
correct_aoa=get(solver_cfg, "correct_aoa", false),
reference_point=get(solver_cfg, "reference_point",
[0.422646, 0.0, 9.3667]),
)

PLOT && plot_polars(
[solver_loop],
[body_aero],
labels;
literature_path_list=literature_paths,
angle_range=range(-5, 20, step=1),
angle_type="angle_of_attack",
angle_of_attack=angle_of_attack_deg,
side_slip=sideslip_deg,
v_a=wind_speed,
title="LOOP solver",
show_moments=true,
save_path=OUTPUT_DIR,
is_save=false || SAVE_ALL,
is_show=true,
use_tex=USE_TEX
)

# --- Beta sweep ---
PLOT && plot_polars(
[solver_loop],
[body_aero],
solvers,
bodies,
beta_labels;
literature_path_list=beta_literature_paths,
angle_range=range(0, 12, step=1),
Expand All @@ -225,4 +167,4 @@ PLOT && plot_polars(
use_tex=USE_TEX
)

nothing
nothing
3 changes: 1 addition & 2 deletions examples/ram_air_kite.jl
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ alpha_range = -8:2:26
delta_range = 0:1
N_SECTIONS = 10

# Inset the tip stations 10 cm along the leading edge so they avoid the near-zero-chord
# wingtips, which slice to degenerate airfoils that XFoil cannot analyse.
# At 0.0 XFoil only converges above 16 deg on the near-tip slice this mesh still has.
WINGTIP_DISTANCE = 0.1

# Shrink-wrap each raw slice into a clean closed airfoil: the distance-field wrap
Expand Down
Loading
Loading