Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## Unreleased

### Fixed

- `analyze_sweep(::XFoilSolver, ...)` throws `ArgumentError` for a contour XFoil's
panel code cannot take, rather than handing it to the Fortran. A self-crossing
contour could reach a bare `STOP` there, ending the Julia process with exit code 0
and no exception; a contour with more nodes than XFoil's panel arrays hold was
refused by XFoil, which then solved every angle on whichever airfoil was loaded
before and returned it as this one.

## VortexStepMethod v5.1.1 2026-09-12

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions docs/src/private_functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ resample_arc
smoothed_curvature
```

### Section solver preconditions
```@docs
validate_xfoil_contour
crossing_panels
segments_cross
side_of_line
```

### NeuralFoil network
```@docs
load_neuralfoil_model
Expand Down
35 changes: 35 additions & 0 deletions src/airfoil_aero/airfoil_solvers/common.jl
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,41 @@ function deform_section(x, y, delta; crease_frac=0.9, thickness_frac=1.0,
return DeformedSection(kulfan, xd, yd)
end

"""
side_of_line(a, b, p) -> Float64

Twice the signed area of the triangle `a`, `b`, `p`: positive with `p` left of the
line from `a` to `b`, negative right of it, zero on it.
"""
side_of_line(a, b, p) = (b[1] - a[1]) * (p[2] - a[2]) - (b[2] - a[2]) * (p[1] - a[1])

"""
segments_cross(p, q, r, s) -> Bool

Whether the segments `p`-`q` and `r`-`s` cross properly, each strictly separating
the other's endpoints. Touching at an endpoint or lying along each other does not
count.
"""
segments_cross(p, q, r, s) =
side_of_line(p, q, r) * side_of_line(p, q, s) < 0 &&
side_of_line(r, s, p) * side_of_line(r, s, q) < 0

"""
crossing_panels(x, y) -> Tuple{Int,Int} or nothing

The first pair of non-neighbouring panels of the closed contour `(x, y)` that
cross, or `nothing` when the contour is a simple closed curve.
"""
function crossing_panels(x, y)
nodes = collect(zip(x, y))
last_panel = length(nodes) - 1
for i in 1:last_panel, j in (i + 2):last_panel
i == 1 && j == last_panel && continue
segments_cross(nodes[i], nodes[i+1], nodes[j], nodes[j+1]) && return (i, j)
end
return nothing
end

"""
analyze_sweep(solver, def, alpha_range, Re) -> Vector{SectionSolution}

Expand Down
21 changes: 21 additions & 0 deletions src/airfoil_aero/airfoil_solvers/xfoil_solver.jl
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,23 @@ runs (`ncrit=9`, `max_iter=100`, incompressible) so the two backends are compara
repanel::Bool = false
end

"""
validate_xfoil_contour(def::DeformedSection)

Enforce what XFoil's panel code needs of `def`'s coordinates: no more nodes than its
panel arrays hold, and no two panels crossing. Throws `ArgumentError` on a violation.
"""
function validate_xfoil_contour(def::DeformedSection)
max_nodes = Xfoil.IQX - 5
length(def.x) <= max_nodes || throw(ArgumentError(
"XFoil holds $max_nodes panel nodes; this contour has $(length(def.x))."))
crossing = crossing_panels(def.x, def.y)
isnothing(crossing) || throw(ArgumentError(
"XFoil needs a contour that does not cross itself; panels $(crossing[1]) " *
"and $(crossing[2]) of this one do."))
return nothing
end

"""
analyze_sweep(solver::XFoilSolver, def, alpha_range, Re) -> Vector{SectionSolution}

Expand All @@ -37,8 +54,12 @@ reinit at each side for convergence. Each converged angle reads the surface pres
(`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.

Throws `ArgumentError` for a contour XFoil's panel code cannot take, see
[`validate_xfoil_contour`](@ref).
"""
function analyze_sweep(solver::XFoilSolver, def::DeformedSection, alpha_range, Re)
validate_xfoil_contour(def)
Xfoil.set_coordinates(def.x, def.y)
solver.repanel && Xfoil.pane(npan=solver.npan)
sols = Vector{SectionSolution}(undef, length(alpha_range))
Expand Down
22 changes: 21 additions & 1 deletion test/airfoil_aero/test_airfoil_aero.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import VortexStepMethod
using VortexStepMethod.AirfoilAero: KulfanParameters, LeastSquaresFit, ShrinkWrap,
shrink_wrap, fit_kulfan_parameters, kulfan_to_coordinates,
neuralfoil_aero, class_function, bernstein_basis,
leading_edge_basis, normalize_airfoil
leading_edge_basis, normalize_airfoil, crossing_panels,
DeformedSection, XFoilSolver, analyze_sweep, Xfoil
using VortexStepMethod: SectionAero, section_surface, read_section_aero
using VortexStepMethod.AirfoilAero: write_section_aero

Expand Down Expand Up @@ -299,3 +300,22 @@ end
@test maximum(abs, collect(extrema(written_y)) .-
collect(extrema(fitted_y))) < 1e-4
end

@testset "XFoil refuses a contour it has no solution for" begin
clean = KulfanParameters(fill(0.15, 8), fill(-0.15, 8), 0.0, 0.0)
x, y = collect.(kulfan_to_coordinates(clean; n_points=60))
alphas = deg2rad.([0.0])
@test isnothing(crossing_panels(x, y))

# the upper surface driven through the lower one over a stretch of the chord
folded = copy(y)
folded[20:40] .= -3 .* folded[20:40]
@test !isnothing(crossing_panels(x, folded))
@test_throws ArgumentError analyze_sweep(XFoilSolver(),
DeformedSection(clean, x, folded), alphas, 1e6)

crowded_x, crowded_y = collect.(kulfan_to_coordinates(clean; n_points=200))
@test length(crowded_x) > Xfoil.IQX - 5
@test_throws ArgumentError analyze_sweep(XFoilSolver(),
DeformedSection(clean, crowded_x, crowded_y), alphas, 1e6)
end
Loading