diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b837e2a..60c9c778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,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(