Skip to content

Commit 703b071

Browse files
committed
Improve coverage and fix direction
1 parent a3d1a52 commit 703b071

3 files changed

Lines changed: 175 additions & 96 deletions

File tree

src/wing_geometry.jl

Lines changed: 109 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,44 +1213,22 @@ function refine_mesh_by_splitting_provided_sections!(
12131213
reuse_aero_data
12141214
)
12151215

1216-
# Apply catenary billowing to the just-created sections
1216+
# Apply billowing by rotating chords around LE
12171217
if billowing_percentage > 0 && idx > start_idx
1218-
LE_1 = LE[left_section_index]
1219-
TE_1 = TE[left_section_index]
1220-
LE_2 = LE[left_section_index + 1]
1221-
TE_2 = TE[left_section_index + 1]
1222-
1223-
chord_1 = TE_1 - LE_1
1224-
chord_2 = TE_2 - LE_2
1225-
x_hat = normalize((chord_1 + chord_2) / 2)
1226-
y_hat = normalize(LE_1 - LE_2)
1227-
z_hat = cross(x_hat, y_hat)
1228-
z_norm = norm(z_hat)
1229-
if z_norm > 1e-10
1230-
z_hat = z_hat / z_norm
1231-
1232-
d = abs(dot(TE_1 - TE_2, y_hat))
1233-
1234-
if d > 1e-12
1235-
a = catenary_parameter(
1236-
billowing_percentage, d)
1237-
u = d / (2a)
1238-
span_len = norm(LE_1 - LE_2)
1239-
TE_mid = (TE_1 + TE_2) / 2
1240-
1241-
for si in start_idx:(idx - 1)
1242-
sec = wing.refined_sections[si]
1243-
t = dot(sec.LE_point - LE_2,
1244-
y_hat) / span_len
1245-
x = d * (t - 0.5)
1246-
sag = a * (cosh(x / a) -
1247-
cosh(u))
1248-
arc_y = d * (t - 0.5)
1249-
sec.TE_point .= TE_mid +
1250-
arc_y * y_hat + sag * z_hat
1251-
end
1252-
end
1253-
end
1218+
y_hat = normalize(
1219+
LE[left_section_index] -
1220+
LE[left_section_index + 1])
1221+
span_len = norm(
1222+
LE[left_section_index] -
1223+
LE[left_section_index + 1])
1224+
apply_billowing_to_pair!(
1225+
wing.refined_sections,
1226+
start_idx, idx - 1,
1227+
y_hat, span_len,
1228+
LE[left_section_index + 1],
1229+
TE[left_section_index],
1230+
TE[left_section_index + 1],
1231+
billowing_percentage)
12541232
end
12551233
end
12561234
end
@@ -1279,52 +1257,112 @@ end
12791257

12801258

12811259
"""
1282-
catenary_parameter(percentage, d)
1260+
billowing_arc_length(sections, start_si, end_si, y_hat,
1261+
span_len, le_ref, te_left, te_right,
1262+
angle_max)
12831263
1284-
Compute the catenary parameter `a` such that a catenary spanning distance `d`
1285-
has an arc length that is `percentage`% longer than `d`.
1264+
Compute the TE arc length that would result from rotating each
1265+
section's chord around `y_hat` by `angle_max * sin(π t)` radians,
1266+
where `t` is the normalised spanwise position within the rib pair.
12861267
1287-
Solves `u / sinh(u) = 1 - percentage / 100` where `u = d / (2a)`,
1288-
using Newton's method.
1268+
Non-allocating: uses only stack-allocated MVec3 arithmetic.
1269+
"""
1270+
function billowing_arc_length(
1271+
sections, start_si, end_si,
1272+
y_hat, span_len, le_ref,
1273+
te_left, te_right, angle_max
1274+
)
1275+
prev_te = MVec3(te_left)
1276+
arc = 0.0
1277+
for si in start_si:end_si
1278+
sec = sections[si]
1279+
t = dot(sec.LE_point - le_ref, y_hat) / span_len
1280+
θ = -angle_max * sin* t)
1281+
chord_vec = sec.TE_point - sec.LE_point
1282+
ct = cos(θ); st = sin(θ)
1283+
d_y = dot(chord_vec, y_hat)
1284+
rotated = ct * chord_vec +
1285+
st * cross(y_hat, chord_vec) +
1286+
(1 - ct) * d_y * y_hat
1287+
current_te = sec.LE_point + rotated
1288+
arc += norm(current_te - prev_te)
1289+
prev_te = current_te
1290+
end
1291+
arc += norm(te_right - prev_te)
1292+
return arc
1293+
end
12891294

1290-
# Arguments
1291-
- `percentage::Real`: How much shorter the chord is relative to the arc
1292-
(0–100). 0 means no sag (flat), larger values mean deeper catenary.
1293-
- `d::Real`: Span distance between the two endpoints.
1295+
"""
1296+
apply_billowing_to_pair!(sections, start_si, end_si, y_hat,
1297+
span_len, le_ref, te_left, te_right,
1298+
percentage)
12941299
1295-
# Returns
1296-
- `Float64`: The catenary parameter `a`.
1300+
Apply billowing to refined sections between two ribs by rotating
1301+
each section's chord around `y_hat`. Uses Newton iteration to
1302+
find the rotation amplitude that matches the target TE arc-length
1303+
`percentage`, then applies the rotations in-place.
1304+
1305+
The angle profile is `angle_max * sin(π t)` (zero at ribs,
1306+
maximum at centre). All angles are in radians.
1307+
1308+
Non-allocating: modifies `sections[start_si:end_si].TE_point`
1309+
in-place using only stack-allocated MVec3 arithmetic.
12971310
"""
1298-
function catenary_parameter(percentage::Real, d::Real)
1299-
percentage == 0 && return Inf
1300-
0 < percentage || throw(ArgumentError(
1301-
"percentage must be ≥ 0, got $percentage"))
1302-
percentage < 100 || throw(ArgumentError(
1303-
"percentage must be < 100, got $percentage"))
1304-
target = 1 - percentage / 100 # u / sinh(u) = target
1305-
# Initial guess from Taylor: u/sinh(u) ≈ 1 - u²/6
1306-
u = sqrt(6 * (1 - target))
1307-
for _ in 1:100
1308-
f = u / sinh(u) - target
1309-
# d/du [u/sinh(u)] = (sinh(u) - u*cosh(u)) / sinh(u)^2
1310-
sh = sinh(u)
1311-
df = (sh - u * cosh(u)) / sh^2
1312-
δ = f / df
1313-
u -= δ
1314-
abs(δ) < 1e-12 && break
1311+
function apply_billowing_to_pair!(
1312+
sections, start_si, end_si,
1313+
y_hat, span_len, le_ref,
1314+
te_left, te_right, percentage
1315+
)
1316+
percentage <= 0 && return nothing
1317+
straight = norm(te_right - te_left)
1318+
straight < 1e-12 && return nothing
1319+
target_arc = straight / (1 - percentage / 100)
1320+
1321+
# Newton iteration on angle_max (rad)
1322+
angle_max = sqrt(4 * percentage / (100 - percentage))
1323+
for _ in 1:50
1324+
arc = billowing_arc_length(
1325+
sections, start_si, end_si,
1326+
y_hat, span_len, le_ref,
1327+
te_left, te_right, angle_max)
1328+
f = arc - target_arc
1329+
abs(f) < 1e-12 * target_arc && break
1330+
1331+
δ = max(1e-8, abs(angle_max) * 1e-8)
1332+
arc_p = billowing_arc_length(
1333+
sections, start_si, end_si,
1334+
y_hat, span_len, le_ref,
1335+
te_left, te_right, angle_max + δ)
1336+
df = (arc_p - arc) / δ
1337+
abs(df) < 1e-30 && break
1338+
angle_max -= f / df
13151339
end
1316-
return d / (2u)
1340+
1341+
# Apply converged rotations in-place
1342+
for si in start_si:end_si
1343+
sec = sections[si]
1344+
t = dot(sec.LE_point - le_ref, y_hat) / span_len
1345+
θ = -angle_max * sin* t)
1346+
chord_vec = sec.TE_point - sec.LE_point
1347+
ct = cos(θ); st = sin(θ)
1348+
d_y = dot(chord_vec, y_hat)
1349+
rotated = ct * chord_vec +
1350+
st * cross(y_hat, chord_vec) +
1351+
(1 - ct) * d_y * y_hat
1352+
sec.TE_point .= sec.LE_point + rotated
1353+
end
1354+
return nothing
13171355
end
13181356

13191357
"""
13201358
refine_mesh_with_billowing!(wing; reuse_aero_data)
13211359
1322-
Refine wing mesh using SPLIT_PROVIDED spacing with catenary TE billowing.
1360+
Refine wing mesh using SPLIT_PROVIDED spacing with TE billowing.
13231361
1324-
Between each pair of unrefined (rib) sections, the trailing edge follows a
1325-
catenary curve that sags perpendicular to the chord and span (simulating fabric
1326-
billowing between ribs on a ram-air kite). The leading edge stays linearly
1327-
interpolated.
1362+
Between each pair of unrefined (rib) sections, chord vectors are rotated
1363+
around the leading edge to simulate fabric billowing. The rotation
1364+
amplitude is found iteratively so that the TE arc length matches the
1365+
wing's `billowing_percentage`.
13281366
13291367
Delegates to [`refine_mesh_by_splitting_provided_sections!`](@ref).
13301368
"""

test/Project.toml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
33
BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
44
CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
5-
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
6-
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
75
CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
86
ControlPlots = "23c2ee80-7a9e-4350-b264-8e670f12517c"
7+
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
8+
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
99
Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59"
1010
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
1111
Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
@@ -14,16 +14,17 @@ Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
1414
StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
1515
Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
1616
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
17+
VortexStepMethod = "ed3cd733-9f0f-46a9-93e0-89b8d4998dd9"
1718
YAML = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6"
1819

1920
[compat]
2021
Aqua = "0.8"
2122
BenchmarkTools = "1"
2223
CSV = "0.10"
23-
DataFrames = "1.7"
24-
Documenter = "1.8"
2524
CairoMakie = "0"
2625
ControlPlots = "0.2.5"
26+
DataFrames = "1.7"
27+
Documenter = "1.8"
2728
Interpolations = "0.15, 0.16"
2829
LinearAlgebra = "1"
2930
Logging = "1"

test/wing_geometry/test_billowing.jl

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
using Test
22
using LinearAlgebra
33
using VortexStepMethod
4-
using VortexStepMethod: Wing, Section, add_section!, refine!,
5-
catenary_parameter
4+
using VortexStepMethod: Wing, Section, add_section!, refine!
65

76
"""
87
build_flat_wing(n_panels, n_ribs; billowing_percentage=0.0)
@@ -75,7 +74,7 @@ function te_arc_length_between_ribs(wing, rib_left, rib_right)
7574
return (; arc, straight)
7675
end
7776

78-
@testset "Billowing (catenary)" begin
77+
@testset "Billowing" begin
7978
n_ribs = 5 # 4 rib pairs
8079
n_panels = 4 * 8 # 8 panels per rib pair
8180

@@ -110,6 +109,40 @@ end
110109
end
111110
end
112111

112+
@testset "Billowing sags outward" begin
113+
wing = build_flat_wing(n_panels, n_ribs;
114+
billowing_percentage=10.0)
115+
# For this flat test wing the negative rotation around
116+
# y_hat=[0,-1,0] produces -z sag in global coords.
117+
# On a real kite this maps to +z in the local airfoil frame.
118+
for pair in 1:(n_ribs - 1)
119+
le_left = wing.unrefined_sections[pair].LE_point
120+
le_right = wing.unrefined_sections[pair + 1].LE_point
121+
mid_y = (le_left[2] + le_right[2]) / 2
122+
best = nothing
123+
best_dist = Inf
124+
for sec in wing.refined_sections
125+
dist = abs(sec.LE_point[2] - mid_y)
126+
if dist < best_dist
127+
best_dist = dist
128+
best = sec
129+
end
130+
end
131+
@test best.TE_point[3] < 0
132+
end
133+
end
134+
135+
@testset "Chord length preserved ($pct%)" for pct in
136+
[5.0, 10.0, 15.0]
137+
wing = build_flat_wing(n_panels, n_ribs;
138+
billowing_percentage=pct)
139+
for sec in wing.refined_sections
140+
chord = norm(sec.TE_point - sec.LE_point)
141+
# All ribs have chord=1.0, so interpolated chord=1.0
142+
@test isapprox(chord, 1.0; atol=1e-10)
143+
end
144+
end
145+
113146
@testset "TE arc length matches percentage ($pct%)" for pct in
114147
[1.0, 5.0, 10.0, 15.0]
115148
wing = build_flat_wing(n_panels, n_ribs;
@@ -121,28 +154,35 @@ end
121154
diff = measured - pct
122155
@info "Pair $pair: measured=$(round(measured; digits=3))%" *
123156
" target=$(pct)% diff=$(round(diff; digits=3))%"
124-
@test isapprox(measured, pct; rtol=0.05)
157+
@test isapprox(measured, pct; rtol=0.01)
125158
end
126159
end
127160

128-
@testset "catenary_parameter edge cases" begin
129-
# Zero percentage returns Inf (flat)
130-
@test catenary_parameter(0.0, 1.0) == Inf
131-
# Invalid inputs throw
132-
@test_throws ArgumentError catenary_parameter(-1.0, 1.0)
133-
@test_throws ArgumentError catenary_parameter(100.0, 1.0)
134-
end
161+
@testset "apply_billowing_to_pair! edge cases" begin
162+
using VortexStepMethod: apply_billowing_to_pair!, MVec3
163+
wing = build_flat_wing(n_panels, n_ribs;
164+
billowing_percentage=0.0)
165+
y_hat = MVec3(0.0, -1.0, 0.0)
166+
le_ref = MVec3(0.0, 2.5, 0.0)
167+
te_l = MVec3(-1.0, 0.0, 0.0)
168+
te_r = MVec3(-1.0, 2.5, 0.0)
169+
orig = [MVec3(s.TE_point)
170+
for s in wing.refined_sections]
171+
172+
# Zero percentage is a no-op
173+
apply_billowing_to_pair!(
174+
wing.refined_sections, 2, 8,
175+
y_hat, 2.5, le_ref, te_l, te_r, 0.0)
176+
for (i, sec) in enumerate(wing.refined_sections)
177+
@test sec.TE_point == orig[i]
178+
end
135179

136-
@testset "catenary_parameter arc length identity" begin
137-
# Verify that catenary_parameter produces the correct
138-
# arc length for a given percentage and distance.
139-
for pct in [1.0, 5.0, 10.0, 20.0, 40.0]
140-
d = 3.0
141-
a = catenary_parameter(pct, d)
142-
u = d / (2a)
143-
arc = 2a * sinh(u)
144-
expected_pct = (arc - d) / arc * 100
145-
@test isapprox(expected_pct, pct; atol=1e-8)
180+
# Coincident ribs (straight ≈ 0) is a no-op
181+
apply_billowing_to_pair!(
182+
wing.refined_sections, 2, 8,
183+
y_hat, 2.5, le_ref, te_l, te_l, 10.0)
184+
for (i, sec) in enumerate(wing.refined_sections)
185+
@test sec.TE_point == orig[i]
146186
end
147187
end
148188
end

0 commit comments

Comments
 (0)