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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Added

- `stability_derivatives` gives the force and moment coefficients and their derivatives
with respect to angle of attack and sideslip, and `trim_angle` the angles of attack at
which `CMy` changes sign, with the slope that says whether each trim is stable.
- Spanwise-flow viscous drag correction (Gaunaa et al. 2024,
doi:10.1088/1742-6596/2767/2/022068): each section gets a drag increment and a force
along its span from the flow across it, in `solve!`, `solve` and `linearize`. Opt-in
Expand Down
2 changes: 2 additions & 0 deletions docs/src/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ solve!
solve_base!
reinit!(body_aero::BodyAerodynamics{P, W, T}) where {P, W, T}
linearize
stability_derivatives
trim_angle
calculate_results
```

Expand Down
4 changes: 4 additions & 0 deletions docs/src/private_functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ calculate_cd
calculate_cm
calculate_cd_cm
set_pitch_rate_dist!
apparent_wind
coeffs_at_angles
nose_down
bisect_sign_change
calculate_relative_alpha_and_velocity
calculate_relative_alpha_and_relative_velocity
update_effective_angle_of_attack!
Expand Down
2 changes: 2 additions & 0 deletions src/VortexStepMethod.jl
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export slice_args, preview_args
export ObjWing, Section, Wing, refine!, reinit!
export BodyAerodynamics
export Solver, VSMSolution, linearize, solve, solve!, solve_base!, calc_forces!
export stability_derivatives, trim_angle
export SolveFailure
export calculate_results
export add_section!, set_va!, section_pitch_rate
Expand Down Expand Up @@ -432,6 +433,7 @@ include("panel.jl")
include("body_aerodynamics.jl")
include("wake.jl")
include("solver.jl")
include("stability.jl")

include("plotting_helpers.jl")

Expand Down
38 changes: 13 additions & 25 deletions src/body_aerodynamics.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1135,24 +1135,19 @@ function set_va!(body_aero::BodyAerodynamics, va_vec_dist::AbstractMatrix;
end

"""
set_va!(body_aero::BodyAerodynamics, settings::VSMSettings)

Set velocity array from VSM settings configuration.
apparent_wind(alpha, beta, wind_speed)

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)
Apparent wind vector in the body frame [m/s] at angle of attack `alpha` [rad], sideslip
`beta` [rad] and `wind_speed` [m/s].
"""
apparent_wind(alpha, beta, wind_speed) =
wind_speed .* [cos(alpha) * cos(beta), sin(beta), sin(alpha) * cos(beta)]

The velocity vector is constructed as:
- X_b (forward): wind_speed * cos(α) * cos(β)
- Y_b (right): wind_speed * sin(β)
- Z_b (down): wind_speed * sin(α) * cos(β)
"""
set_va!(body_aero::BodyAerodynamics, settings::VSMSettings)

# Arguments
- `body_aero::BodyAerodynamics`: The aerodynamic body to modify
- `settings::VSMSettings`: Settings object containing flight conditions
Set the uniform inflow of `body_aero` to the [`apparent_wind`](@ref) at the `alpha` and
`beta` [°] and `wind_speed` [m/s] of `settings.condition`.

# Example
```julia
Expand All @@ -1162,15 +1157,8 @@ set_va!(body_aero, settings)
```
"""
function set_va!(body_aero::BodyAerodynamics, settings::VSMSettings)
α = deg2rad(settings.condition.alpha)
β = deg2rad(settings.condition.beta)
wind_speed = settings.condition.wind_speed

va_vec = wind_speed * [
cos(α)*cos(β), # X_b (forward)
sin(β), # Y_b (right)
sin(α)*cos(β) # Z_b (down)
]

condition = settings.condition
va_vec = apparent_wind(deg2rad(condition.alpha), deg2rad(condition.beta),
condition.wind_speed)
set_va!(body_aero, va_vec)
end
92 changes: 92 additions & 0 deletions src/stability.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""
stability_derivatives(solver, body_aero, alpha, beta, wind_speed; kwargs...)

Aerodynamic coefficients `[CFx, CFy, CFz, CMx, CMy, CMz]` of `body_aero` at angle of attack
`alpha` [rad], sideslip `beta` [rad] and `wind_speed` [m/s], and their derivatives with
respect to `alpha` and `beta` [1/rad], at the rotation rate `body_aero.omega` and with
moments about `solver.reference_point`. `kwargs` go to [`linearize`](@ref), which leaves
`body_aero` at this inflow.

Returns `(coeffs, dalpha, dbeta, converged)`.
"""
function stability_derivatives(solver::Solver, body_aero::BodyAerodynamics, alpha, beta,
wind_speed; kwargs...)
va_vec = apparent_wind(alpha, beta, wind_speed)
jac, results, converged = linearize(solver, body_aero, va_vec;
theta_idxs=nothing, va_idxs=1:3, aero_coeffs=true, kwargs...)
dva_dalpha = ForwardDiff.derivative(
angle -> apparent_wind(angle, beta, wind_speed), alpha)
dva_dbeta = ForwardDiff.derivative(
angle -> apparent_wind(alpha, angle, wind_speed), beta)
coeff_jac = jac[1:6, :]
return (coeffs=results[1:6], dalpha=coeff_jac * dva_dalpha,
dbeta=coeff_jac * dva_dbeta, converged)
end

"""
trim_angle(solver, body_aero, beta, wind_speed; alpha_range=deg2rad.(-5:2:15),
alpha_tol=1e-5, backend=AutoForwardDiff())

Angles of attack [rad] at which `CMy` of `body_aero` about `solver.reference_point` changes
sign between neighbouring entries of `alpha_range`, bisected to `alpha_tol` [rad], at
sideslip `beta` [rad] and `wind_speed` [m/s]. Returns one `(alpha, dCMy_dalpha)` per trim,
the slope [1/rad] from [`stability_derivatives`](@ref) with `backend`; a trim is statically
stable where `dCMy_dalpha < 0`. Throws a [`SolveFailure`](@ref) if a solve misses the
solver's tolerances.
"""
function trim_angle(solver::Solver, body_aero::BodyAerodynamics, beta, wind_speed;
alpha_range=deg2rad.(-5:2:15), alpha_tol=1e-5, backend=AutoForwardDiff())
is_nose_down = alpha -> nose_down(solver, body_aero, alpha, beta, wind_speed)
nose_down_range = is_nose_down.(alpha_range)
trims = @NamedTuple{alpha::Float64, dCMy_dalpha::Float64}[]
for i in 1:length(alpha_range)-1
nose_down_range[i] == nose_down_range[i+1] && continue
alpha = bisect_sign_change(is_nose_down, alpha_range[i], alpha_range[i+1],
alpha_tol)
derivatives = stability_derivatives(solver, body_aero, alpha, beta, wind_speed;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: kwargs reach linearize and so solve! for the slope, but not the CMy sweep in pitch_moment_coeff. A caller passing reference_point= (a solve! keyword) gets trims found about solver.reference_point but slopes about another point, so the stable/unstable verdict can be silently wrong.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b50a2b5: trim_angle takes an explicit backend instead of splatting kwargs, so the sweep, the bisection and the slope all take moments about solver.reference_point.

backend, throw_on_fail=true)
push!(trims, (alpha=alpha, dCMy_dalpha=derivatives.dalpha[5]))
end
return trims
end

"""
coeffs_at_angles(solver, body_aero, alpha, beta, wind_speed)

Aerodynamic coefficients `[CFx, CFy, CFz, CMx, CMy, CMz]` of `body_aero` solved at angle of
attack `alpha` [rad], sideslip `beta` [rad] and `wind_speed` [m/s], at the rotation rate
`body_aero.omega`. Throws a [`SolveFailure`](@ref) if the solve misses the solver's
tolerances.
"""
function coeffs_at_angles(solver, body_aero, alpha, beta, wind_speed)
set_va!(body_aero, apparent_wind(alpha, beta, wind_speed), body_aero.omega)
sol = solve!(solver, body_aero; throw_on_fail=true)
return [sol.force_coeffs; sol.moment_coeffs]
end

"""
nose_down(solver, body_aero, alpha, beta, wind_speed)

Whether `CMy` from [`coeffs_at_angles`](@ref) is negative.
"""
nose_down(solver, body_aero, alpha, beta, wind_speed) =
coeffs_at_angles(solver, body_aero, alpha, beta, wind_speed)[5] < 0

"""
bisect_sign_change(predicate, low, high, tol)

Bisect `[low, high]`, across which the boolean `predicate` flips, to a width of `tol` and
return the midpoint.
"""
function bisect_sign_change(predicate, low, high, tol)
predicate_low = predicate(low)
while high - low > tol
middle = (low + high) / 2
if predicate(middle) == predicate_low
low = middle
else
high = middle
end
end
return (low + high) / 2
end
1 change: 1 addition & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function include_selected_tests()
should_run_test("solver/test_forwarddiff.jl") && include("solver/test_forwarddiff.jl")
should_run_test("solver/test_backend_comparison.jl") && include("solver/test_backend_comparison.jl")
should_run_test("solver/test_unrefined_dist.jl") && include("solver/test_unrefined_dist.jl")
should_run_test("solver/test_stability.jl") && include("solver/test_stability.jl")
should_run_test("verification/test_verification.jl") && include("verification/test_verification.jl")
should_run_test("VortexStepMethod/test_VortexStepMethod.jl") && include("VortexStepMethod/test_VortexStepMethod.jl")
should_run_test("wake/test_wake.jl") && include("wake/test_wake.jl")
Expand Down
92 changes: 92 additions & 0 deletions test/solver/test_stability.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using VortexStepMethod
using VortexStepMethod: coeffs_at_angles
using Test

"""
trimmable_wing_aero(cm)

A rectangular wing with lift slope 2π, `cl = 0.1` at zero angle of attack and a constant
section `cm`.
"""
function trimmable_wing_aero(cm)
chord, span = 1.0, 6.0
alpha_range = deg2rad.(-10.0:5.0:20.0)
polar = (alpha_range, 2π .* alpha_range .+ 0.1, fill(0.02, length(alpha_range)),
fill(cm, length(alpha_range)))
wing = Wing(10)
add_section!(wing, [0.0, span / 2, 0.0], [chord, span / 2, 0.0], POLAR_VECTORS, polar)
add_section!(wing, [0.0, -span / 2, 0.0], [chord, -span / 2, 0.0], POLAR_VECTORS,
polar)
refine!(wing)
return BodyAerodynamics([wing])
end

@testset "stability_derivatives match central differences of solve!" begin
body_aero = trimmable_wing_aero(0.05)
solver = Solver(body_aero; reference_point=[0.25, 0.5, 0.1], use_gamma_prev=false)
alpha, beta, wind_speed, step = deg2rad(4.0), deg2rad(3.0), 20.0, 1e-4

derivatives = stability_derivatives(solver, body_aero, alpha, beta, wind_speed)
@test derivatives.converged
@test derivatives.coeffs ≈
coeffs_at_angles(solver, body_aero, alpha, beta, wind_speed)

central_difference(coeffs_plus, coeffs_minus) = (coeffs_plus - coeffs_minus) / 2step
dalpha = central_difference(
coeffs_at_angles(solver, body_aero, alpha + step, beta, wind_speed),
coeffs_at_angles(solver, body_aero, alpha - step, beta, wind_speed))
dbeta = central_difference(
coeffs_at_angles(solver, body_aero, alpha, beta + step, wind_speed),
coeffs_at_angles(solver, body_aero, alpha, beta - step, wind_speed))
@test !iszero(dbeta)
@test derivatives.dalpha ≈ dalpha rtol = 1e-4 atol = 1e-6
@test derivatives.dbeta ≈ dbeta rtol = 1e-4 atol = 1e-6
end

@testset "trim_angle finds where CMy changes sign" begin
beta, wind_speed = 0.0, 20.0

@testset "moments about the leading edge: stable trim" begin
body_aero = trimmable_wing_aero(0.05)
solver = Solver(body_aero)
trims = trim_angle(solver, body_aero, beta, wind_speed)
@test length(trims) == 1
trim = only(trims)
trim_coeffs = coeffs_at_angles(solver, body_aero, trim.alpha, beta, wind_speed)
@test abs(trim_coeffs[5]) < 1e-5
@test trim.dCMy_dalpha < 0
derivatives = stability_derivatives(solver, body_aero, trim.alpha, beta, wind_speed)
@test trim.dCMy_dalpha ≈ derivatives.dalpha[5]
end

@testset "moments about the trailing edge: unstable trim" begin
body_aero = trimmable_wing_aero(-0.05)
solver = Solver(body_aero; reference_point=[1.0, 0.0, 0.0])
trim = only(trim_angle(solver, body_aero, beta, wind_speed))
trim_coeffs = coeffs_at_angles(solver, body_aero, trim.alpha, beta, wind_speed)
@test abs(trim_coeffs[5]) < 1e-5
@test trim.dCMy_dalpha > 0
end

@testset "a NONLIN solver with backend=nothing finds the same trim" begin
body_aero = trimmable_wing_aero(0.05)
trim_loop = only(trim_angle(Solver(body_aero), body_aero, beta, wind_speed))
solver = Solver(body_aero; solver_type=NONLIN)
trim = only(trim_angle(solver, body_aero, beta, wind_speed; backend=nothing))
@test trim.alpha ≈ trim_loop.alpha atol = 1e-4
@test trim.dCMy_dalpha ≈ trim_loop.dCMy_dalpha rtol = 1e-4
end

@testset "a solve that misses the tolerances throws" begin
body_aero = trimmable_wing_aero(0.05)
solver = Solver(body_aero; max_iterations=2)
@test_throws SolveFailure trim_angle(solver, body_aero, beta, wind_speed)
end

@testset "no sign change in alpha_range: no trim" begin
body_aero = trimmable_wing_aero(0.05)
solver = Solver(body_aero)
@test isempty(trim_angle(solver, body_aero, beta, wind_speed;
alpha_range=deg2rad.(4:2:12)))
end
end
Loading