From 245ab962004b02b826782f1400ae57c2c247afb0 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:30:47 +0200 Subject: [PATCH] Truncate the SVD in the least-squares Kulfan fit Stations crowded into a narrow band of the chord make the upper Bernstein columns numerically dependent, and A \ y returned weights that resample the contour at 1e4 scale. Singular values below 1e-4 of the largest are now dropped; fits of well-spread stations (cond ~1e3) are unchanged to 1e-14. Refs #295 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++++ src/airfoil_aero/kulfan.jl | 10 ++++++---- test/airfoil_aero/test_airfoil_aero.jl | 12 ++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c00c754..7669769f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ - The `VSMSolution` docstring gives `lift_dist`, `drag_dist` and `panel_moment_dist` in the per-unit-span units they hold, [N/m] and [Nm/m], instead of [N] and [Nm]. +- `fit_kulfan_parameters` with `LeastSquaresFit` drops singular values below `1e-4` + times the largest, so a contour whose stations crowd into a narrow band of the chord + fits to an airfoil-sized shape instead of weights that resample it to 1e4 scale. + Fits of well-spread stations are unchanged. ## VortexStepMethod v5.1.1 2026-09-12 diff --git a/src/airfoil_aero/kulfan.jl b/src/airfoil_aero/kulfan.jl index dce90044..c7488b0b 100644 --- a/src/airfoil_aero/kulfan.jl +++ b/src/airfoil_aero/kulfan.jl @@ -106,6 +106,8 @@ function leading_edge_basis(x::AbstractVector{T}, n_weights::Int) where T return x .* max.(1 .- x, zero(T)).^(n_weights + 0.5) end +const KULFAN_FIT_RTOL = 1e-4 # [-] singular values dropped below this times the largest + """ fit_kulfan_parameters(x::Vector, y::Vector, method::KulfanFitMethod) fit_kulfan_parameters(x::Vector, y::Vector; n_weights=8) @@ -128,7 +130,8 @@ end 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. +trailing-edge thickness. Singular values below `1e-4` times the largest are dropped, so +stations crowded into part of the chord give bounded weights. """ function fit_kulfan_parameters(x::Vector{T}, y::Vector{T}, method::LeastSquaresFit) where T @@ -144,12 +147,11 @@ function fit_kulfan_parameters(x::Vector{T}, y::Vector{T}, te_col = ifelse.(is_upper, xv ./ 2, .-xv ./ 2) A = hcat((.!is_upper) .* CS, is_upper .* CS, le_col, te_col) - coeffs = A \ y_norm + coeffs = pinv(A; rtol=KULFAN_FIT_RTOL) * y_norm TE_thickness = coeffs[end] if TE_thickness < 0 - A = hcat((.!is_upper) .* CS, is_upper .* CS, le_col) - coeffs = A \ y_norm + coeffs = pinv(A[:, 1:end-1]; rtol=KULFAN_FIT_RTOL) * y_norm TE_thickness = zero(T) end diff --git a/test/airfoil_aero/test_airfoil_aero.jl b/test/airfoil_aero/test_airfoil_aero.jl index ac4b9363..e35a18a5 100644 --- a/test/airfoil_aero/test_airfoil_aero.jl +++ b/test/airfoil_aero/test_airfoil_aero.jl @@ -43,6 +43,18 @@ end @test params.TE_thickness ≈ 0.0 atol = 1e-12 end + @testset "Fit to stations crowded into a narrow band stays airfoil-sized" begin + spread = (1 .- cos.(range(0, pi, 121))) ./ 2 + crowded = vcat(0.0, range(0.30, 0.31, 118), 1.0) + surface(weights, xs) = class_function(xs) .* (bernstein_basis(xs, 7) * weights) + y_upper = surface(fill(0.2, 8), crowded) .+ 0.005 .* sin.(40pi .* crowded) + y_lower = surface(fill(-0.1, 8), spread) + x = vcat(reverse(crowded), spread[2:end]) + y = vcat(reverse(y_upper), y_lower[2:end]) + _, y_fit = kulfan_to_coordinates(fit_kulfan_parameters(x, y)) + @test maximum(abs, y_fit) < 2 * maximum(abs, y) + end + @testset "Shrink-wrap encloses points with clearance" begin xn, yn, _ = normalize_airfoil(collect(float.(xr)), collect(float.(yr))) cloud_to_wrap(xw, yw) = minimum(