geometry-generator 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geometry_generator/__init__.py +34 -0
- geometry_generator/blunted_tangent_ogive.py +362 -0
- geometry_generator/blunted_wedge.py +291 -0
- geometry_generator/cli/__init__.py +5 -0
- geometry_generator/cli/app.py +58 -0
- geometry_generator/cli/callbacks.py +39 -0
- geometry_generator/cli/cmd_geometry.py +115 -0
- geometry_generator/config/__init__.py +31 -0
- geometry_generator/config/geometry.py +113 -0
- geometry_generator/config/moldline.py +179 -0
- geometry_generator/config/templates.py +159 -0
- geometry_generator/moldline.py +103 -0
- geometry_generator/profile.py +114 -0
- geometry_generator/writers/__init__.py +74 -0
- geometry_generator/writers/columns.py +57 -0
- geometry_generator/writers/csv.py +46 -0
- geometry_generator/writers/tecplot_ascii.py +59 -0
- geometry_generator-0.5.0.dist-info/METADATA +143 -0
- geometry_generator-0.5.0.dist-info/RECORD +23 -0
- geometry_generator-0.5.0.dist-info/WHEEL +5 -0
- geometry_generator-0.5.0.dist-info/entry_points.txt +2 -0
- geometry_generator-0.5.0.dist-info/licenses/LICENSE +674 -0
- geometry_generator-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Public package interface for geometry_generator."""
|
|
2
|
+
|
|
3
|
+
# --------------------------------------------------
|
|
4
|
+
# load necessary modules
|
|
5
|
+
# --------------------------------------------------
|
|
6
|
+
from importlib.metadata import (
|
|
7
|
+
PackageNotFoundError,
|
|
8
|
+
version,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from geometry_generator.blunted_tangent_ogive import BluntedTangentOgive
|
|
12
|
+
from geometry_generator.blunted_wedge import BluntedWedge
|
|
13
|
+
from geometry_generator.moldline import Moldline, sample_moldline
|
|
14
|
+
from geometry_generator.profile import Profile2D
|
|
15
|
+
from geometry_generator.writers import write_moldline
|
|
16
|
+
|
|
17
|
+
# --------------------------------------------------
|
|
18
|
+
# resolve package version
|
|
19
|
+
# --------------------------------------------------
|
|
20
|
+
try:
|
|
21
|
+
__version__ = version("geometry-generator")
|
|
22
|
+
except PackageNotFoundError:
|
|
23
|
+
__version__ = "0+unknown"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"BluntedTangentOgive",
|
|
28
|
+
"BluntedWedge",
|
|
29
|
+
"Moldline",
|
|
30
|
+
"Profile2D",
|
|
31
|
+
"__version__",
|
|
32
|
+
"sample_moldline",
|
|
33
|
+
"write_moldline",
|
|
34
|
+
]
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""2D spherically blunted tangent ogive, optionally followed by a cylinder (upper half only).
|
|
2
|
+
|
|
3
|
+
The profile is the meridian of an axisymmetric body: a spherical nose, a tangent
|
|
4
|
+
ogive, and an optional cylinder of the base radius.
|
|
5
|
+
|
|
6
|
+
Sharp ogive (the definition the length refers to)
|
|
7
|
+
-------------------------------------------------
|
|
8
|
+
A tangent ogive of length L and base radius R is a circular arc of radius
|
|
9
|
+
|
|
10
|
+
rho = (R^2 + L^2) / (2 R)
|
|
11
|
+
|
|
12
|
+
from the sharp tip to the base, where it is tangent to the cylinder (slope 0).
|
|
13
|
+
Blunting replaces the tip with a sphere of radius r_n, centred on the axis and
|
|
14
|
+
tangent to the arc from inside. The blunted body is therefore shorter than L.
|
|
15
|
+
|
|
16
|
+
Coordinates
|
|
17
|
+
-----------
|
|
18
|
+
- origin at the stagnation point, x downstream, y up (y is the radius)
|
|
19
|
+
- nose: circular arc with centre (r_n, 0). Its polar angle phi is measured from
|
|
20
|
+
the stagnation point and runs from 0 to phi_j = 90 deg - theta_t
|
|
21
|
+
- theta_t: slope angle where nose and ogive meet, cos(theta_t) = (rho - R) / (rho - r_n)
|
|
22
|
+
- ogive: circular arc with centre (x_b, R - rho), where x_b = r_n + (rho - r_n) sin(theta_t)
|
|
23
|
+
is the base position. Its angle alpha from the vertical runs from theta_t at the
|
|
24
|
+
junction down to 0 at the base point (x_b, R)
|
|
25
|
+
- cylinder (optional): straight line y = R from x_b to x_b + cylinder_length
|
|
26
|
+
|
|
27
|
+
The sharp ogive's tip would lie at x = x_b - L, upstream of the stagnation point.
|
|
28
|
+
|
|
29
|
+
At both junctions position and slope are continuous. Curvature jumps from 1 / r_n
|
|
30
|
+
to 1 / rho at the nose/ogive junction, and from 1 / rho to 0 at the base.
|
|
31
|
+
|
|
32
|
+
Arc length s runs from 0 at the stagnation point, around the nose, along the
|
|
33
|
+
ogive, and along the cylinder:
|
|
34
|
+
|
|
35
|
+
- nose: 0 <= s <= s_j, with s_j = r_n phi_j and phi = s / r_n
|
|
36
|
+
- ogive: s_j < s <= s_b, with s_b = s_j + rho theta_t and alpha = theta_t - (s - s_j) / rho
|
|
37
|
+
- cylinder: s_b < s <= s_b + cylinder_length
|
|
38
|
+
|
|
39
|
+
At a junction itself, the curvature is reported as the upstream piece's value.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# --------------------------------------------------
|
|
43
|
+
# load necessary modules
|
|
44
|
+
# --------------------------------------------------
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import math
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
|
|
50
|
+
import numpy as np
|
|
51
|
+
from numpy.typing import ArrayLike, NDArray
|
|
52
|
+
|
|
53
|
+
from geometry_generator.profile import check_arc_length, check_sharp_profile_inputs
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------
|
|
57
|
+
# blunted tangent ogive profile
|
|
58
|
+
# --------------------------------------------------
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class BluntedTangentOgive:
|
|
61
|
+
"""Spherical nose, tangent ogive, optional cylinder; upper half, parametrized by arc length.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
nose_radius: Nose radius r_n [m], smaller than the base radius.
|
|
65
|
+
length: Length L of the sharp ogive, tip to base [m]; the blunted body is shorter.
|
|
66
|
+
base_radius: Base radius R [m], smaller than L.
|
|
67
|
+
cylinder_length: Length of the cylinder after the base [m]; 0 for none.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
nose_radius: float
|
|
71
|
+
length: float
|
|
72
|
+
base_radius: float
|
|
73
|
+
cylinder_length: float = 0.0
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
# validate inputs
|
|
77
|
+
if self.nose_radius <= 0.0:
|
|
78
|
+
raise ValueError(f"nose_radius must be positive, got {self.nose_radius}")
|
|
79
|
+
if self.base_radius <= 0.0:
|
|
80
|
+
raise ValueError(f"base_radius must be positive, got {self.base_radius}")
|
|
81
|
+
if self.cylinder_length < 0.0:
|
|
82
|
+
raise ValueError(f"cylinder_length must not be negative, got {self.cylinder_length}")
|
|
83
|
+
|
|
84
|
+
# a tangent ogive needs L > R (otherwise the arc bulges forward past the tip)
|
|
85
|
+
if self.length <= self.base_radius:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"length ({self.length}) must be greater than base_radius ({self.base_radius})"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# the nose sphere must fit inside the ogive (r_n = R would be a sphere-cylinder)
|
|
91
|
+
if self.nose_radius >= self.base_radius:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"nose_radius ({self.nose_radius}) must be smaller than base_radius "
|
|
94
|
+
f"({self.base_radius})"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# --------------------------------------------------
|
|
98
|
+
# derived geometry
|
|
99
|
+
# --------------------------------------------------
|
|
100
|
+
@property
|
|
101
|
+
def ogive_radius(self) -> float:
|
|
102
|
+
"""Radius rho = (R^2 + L^2) / (2 R) of the ogive arc."""
|
|
103
|
+
rho = (self.base_radius**2 + self.length**2) / (2.0 * self.base_radius)
|
|
104
|
+
return rho
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def tangency_angle(self) -> float:
|
|
108
|
+
"""Slope angle theta_t [rad] where the nose meets the ogive."""
|
|
109
|
+
rho = self.ogive_radius
|
|
110
|
+
theta_t = math.acos((rho - self.base_radius) / (rho - self.nose_radius))
|
|
111
|
+
return theta_t
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def junction_angle(self) -> float:
|
|
115
|
+
"""Polar angle phi_j = 90 deg - theta_t [rad] where the nose meets the ogive."""
|
|
116
|
+
phi_junction = 0.5 * math.pi - self.tangency_angle
|
|
117
|
+
return phi_junction
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def junction_point(self) -> tuple[float, float]:
|
|
121
|
+
"""Coordinates (x_j, y_j) of the tangency point between nose and ogive."""
|
|
122
|
+
theta_t = self.tangency_angle
|
|
123
|
+
x_junction = self.nose_radius * (1.0 - math.sin(theta_t))
|
|
124
|
+
y_junction = self.nose_radius * math.cos(theta_t)
|
|
125
|
+
return x_junction, y_junction
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def junction_arc_length(self) -> float:
|
|
129
|
+
"""Arc length s_j = r_n phi_j where the nose meets the ogive."""
|
|
130
|
+
s_junction = self.nose_radius * self.junction_angle
|
|
131
|
+
return s_junction
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def base_point(self) -> tuple[float, float]:
|
|
135
|
+
"""Coordinates (x_b, R) of the ogive base, where the cylinder starts."""
|
|
136
|
+
rho = self.ogive_radius
|
|
137
|
+
x_base = self.nose_radius + (rho - self.nose_radius) * math.sin(self.tangency_angle)
|
|
138
|
+
return x_base, self.base_radius
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def base_arc_length(self) -> float:
|
|
142
|
+
"""Arc length s_b = s_j + rho theta_t at the ogive base."""
|
|
143
|
+
s_base = self.junction_arc_length + self.ogive_radius * self.tangency_angle
|
|
144
|
+
return s_base
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def sharp_tip_x(self) -> float:
|
|
148
|
+
"""x position of the sharp ogive's tip, x_b - L (upstream of the stagnation point)."""
|
|
149
|
+
x_base, _ = self.base_point
|
|
150
|
+
x_tip = x_base - self.length
|
|
151
|
+
return x_tip
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def total_length(self) -> float:
|
|
155
|
+
"""Arc length of the whole profile, s_b + cylinder_length."""
|
|
156
|
+
total = self.base_arc_length + self.cylinder_length
|
|
157
|
+
return total
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def junctions(self) -> NDArray[np.float64]:
|
|
161
|
+
"""Arc lengths where the pieces meet: nose/ogive, and ogive/cylinder if there is one."""
|
|
162
|
+
if self.cylinder_length > 0.0:
|
|
163
|
+
junctions = np.array([self.junction_arc_length, self.base_arc_length])
|
|
164
|
+
else:
|
|
165
|
+
junctions = np.array([self.junction_arc_length])
|
|
166
|
+
return junctions
|
|
167
|
+
|
|
168
|
+
# --------------------------------------------------
|
|
169
|
+
# public evaluation methods
|
|
170
|
+
# --------------------------------------------------
|
|
171
|
+
def point(self, s: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
172
|
+
"""Return the surface coordinates at arc length s.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
s: Arc length(s) in [0, total_length].
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Tuple (x, y) of arrays with the shape of s.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
ValueError: If any s lies outside [0, total_length].
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
# validate inputs
|
|
185
|
+
s_arr = check_arc_length(s, self.total_length)
|
|
186
|
+
r_n = self.nose_radius
|
|
187
|
+
rho = self.ogive_radius
|
|
188
|
+
x_base, y_base = self.base_point
|
|
189
|
+
|
|
190
|
+
# nose: circular arc around (r_n, 0)
|
|
191
|
+
phi = self._nose_angle(s_arr)
|
|
192
|
+
x_nose = r_n - r_n * np.cos(phi)
|
|
193
|
+
y_nose = r_n * np.sin(phi)
|
|
194
|
+
|
|
195
|
+
# ogive: circular arc around (x_b, R - rho), angle alpha from the vertical
|
|
196
|
+
alpha = self._ogive_angle(s_arr)
|
|
197
|
+
x_ogive = x_base - rho * np.sin(alpha)
|
|
198
|
+
y_ogive = (y_base - rho) + rho * np.cos(alpha)
|
|
199
|
+
|
|
200
|
+
# cylinder: straight line at the base radius
|
|
201
|
+
x_cylinder = x_base + (s_arr - self.base_arc_length)
|
|
202
|
+
y_cylinder = np.full_like(s_arr, y_base)
|
|
203
|
+
|
|
204
|
+
# pick the piece each s belongs to
|
|
205
|
+
pieces = self._pieces(s_arr)
|
|
206
|
+
x = np.select(pieces, [x_nose, x_ogive, x_cylinder])
|
|
207
|
+
y = np.select(pieces, [y_nose, y_ogive, y_cylinder])
|
|
208
|
+
|
|
209
|
+
return x, y
|
|
210
|
+
|
|
211
|
+
def tangent(self, s: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
212
|
+
"""Return the unit tangent at arc length s, pointing towards increasing s.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
s: Arc length(s) in [0, total_length].
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Tuple (tx, ty) of arrays with the shape of s.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
ValueError: If any s lies outside [0, total_length].
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
# validate inputs
|
|
225
|
+
s_arr = check_arc_length(s, self.total_length)
|
|
226
|
+
|
|
227
|
+
# nose: derivative of (r_n - r_n cos(phi), r_n sin(phi)) w.r.t. s
|
|
228
|
+
phi = self._nose_angle(s_arr)
|
|
229
|
+
tx_nose = np.sin(phi)
|
|
230
|
+
ty_nose = np.cos(phi)
|
|
231
|
+
|
|
232
|
+
# ogive: derivative of (x_b - rho sin(alpha), ... + rho cos(alpha)), d(alpha)/ds = -1/rho
|
|
233
|
+
alpha = self._ogive_angle(s_arr)
|
|
234
|
+
tx_ogive = np.cos(alpha)
|
|
235
|
+
ty_ogive = np.sin(alpha)
|
|
236
|
+
|
|
237
|
+
# cylinder: along x
|
|
238
|
+
tx_cylinder = np.ones_like(s_arr)
|
|
239
|
+
ty_cylinder = np.zeros_like(s_arr)
|
|
240
|
+
|
|
241
|
+
# pick the piece each s belongs to
|
|
242
|
+
pieces = self._pieces(s_arr)
|
|
243
|
+
tx = np.select(pieces, [tx_nose, tx_ogive, tx_cylinder])
|
|
244
|
+
ty = np.select(pieces, [ty_nose, ty_ogive, ty_cylinder])
|
|
245
|
+
|
|
246
|
+
return tx, ty
|
|
247
|
+
|
|
248
|
+
def normal(self, s: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
249
|
+
"""Return the unit normal at arc length s, pointing away from the body into the flow.
|
|
250
|
+
|
|
251
|
+
The normal is the tangent rotated by +90 degrees, (nx, ny) = (-ty, tx).
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
s: Arc length(s) in [0, total_length].
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Tuple (nx, ny) of arrays with the shape of s.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
ValueError: If any s lies outside [0, total_length].
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
# build from the tangent (validates s)
|
|
264
|
+
tx, ty = self.tangent(s)
|
|
265
|
+
|
|
266
|
+
# rotate by +90 degrees
|
|
267
|
+
nx = -ty
|
|
268
|
+
ny = tx
|
|
269
|
+
|
|
270
|
+
return nx, ny
|
|
271
|
+
|
|
272
|
+
def curvature(self, s: ArrayLike) -> NDArray[np.float64]:
|
|
273
|
+
"""Return the surface curvature at arc length s.
|
|
274
|
+
|
|
275
|
+
Curvature is 1 / r_n on the nose, 1 / rho on the ogive (both convex, positive)
|
|
276
|
+
and 0 on the cylinder. At a junction the upstream piece's value is returned.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
s: Arc length(s) in [0, total_length].
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Array of curvatures [1/m] with the shape of s.
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
ValueError: If any s lies outside [0, total_length].
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
# validate inputs
|
|
289
|
+
s_arr = check_arc_length(s, self.total_length)
|
|
290
|
+
|
|
291
|
+
# constant on each piece
|
|
292
|
+
pieces = self._pieces(s_arr)
|
|
293
|
+
kappa = np.select(pieces, [1.0 / self.nose_radius, 1.0 / self.ogive_radius, 0.0])
|
|
294
|
+
|
|
295
|
+
return kappa
|
|
296
|
+
|
|
297
|
+
def sharp_profile(
|
|
298
|
+
self, x_end: float, n_points: int
|
|
299
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
300
|
+
"""The sharp tangent ogive the blunted body follows, from its tip.
|
|
301
|
+
|
|
302
|
+
Past the base the profile continues as the cylinder the ogive is tangent to,
|
|
303
|
+
whether or not this body has one.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
x_end: Downstream end [m], past the sharp tip (sharp_tip_x).
|
|
307
|
+
n_points: Points, evenly spaced in x.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Tuple (x, y) of arrays, with y = 0 at the tip.
|
|
311
|
+
|
|
312
|
+
Raises:
|
|
313
|
+
ValueError: If x_end doesn't lie past the tip, or n_points < 2.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
# validate inputs
|
|
317
|
+
x_tip = self.sharp_tip_x
|
|
318
|
+
check_sharp_profile_inputs(x_tip, x_end, n_points)
|
|
319
|
+
x_base, y_base = self.base_point
|
|
320
|
+
rho = self.ogive_radius
|
|
321
|
+
|
|
322
|
+
# ogive arc from the tip to the base (centre at (x_b, R - rho)), cylinder past it
|
|
323
|
+
x = np.linspace(x_tip, x_end, n_points)
|
|
324
|
+
distance_to_base = np.maximum(x_base - x, 0.0)
|
|
325
|
+
y = np.sqrt(np.maximum(rho**2 - distance_to_base**2, 0.0)) + y_base - rho
|
|
326
|
+
|
|
327
|
+
# the tip lies on the axis (rounding would leave a tiny y there)
|
|
328
|
+
y[0] = 0.0
|
|
329
|
+
|
|
330
|
+
return x, y
|
|
331
|
+
|
|
332
|
+
# --------------------------------------------------
|
|
333
|
+
# private helpers
|
|
334
|
+
# --------------------------------------------------
|
|
335
|
+
def _pieces(self, s_arr: NDArray[np.float64]) -> list[NDArray[np.bool_]]:
|
|
336
|
+
"""Masks for the nose, ogive and cylinder pieces (in that order)."""
|
|
337
|
+
|
|
338
|
+
# build: each junction belongs to the upstream piece
|
|
339
|
+
on_nose = s_arr <= self.junction_arc_length
|
|
340
|
+
on_ogive = ~on_nose & (s_arr <= self.base_arc_length)
|
|
341
|
+
on_cylinder = s_arr > self.base_arc_length
|
|
342
|
+
pieces = [on_nose, on_ogive, on_cylinder]
|
|
343
|
+
|
|
344
|
+
return pieces
|
|
345
|
+
|
|
346
|
+
def _nose_angle(self, s_arr: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
347
|
+
"""Nose polar angle phi, clipped to the nose so other pieces don't overshoot it."""
|
|
348
|
+
|
|
349
|
+
# convert arc length to angle
|
|
350
|
+
phi = np.minimum(s_arr, self.junction_arc_length) / self.nose_radius
|
|
351
|
+
return phi
|
|
352
|
+
|
|
353
|
+
def _ogive_angle(self, s_arr: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
354
|
+
"""Ogive angle alpha from the vertical, clipped to [0, theta_t]."""
|
|
355
|
+
|
|
356
|
+
# distance along the ogive, clipped to the ogive piece
|
|
357
|
+
ogive_arc = self.ogive_radius * self.tangency_angle
|
|
358
|
+
distance_on_ogive = np.clip(s_arr - self.junction_arc_length, 0.0, ogive_arc)
|
|
359
|
+
|
|
360
|
+
# convert to angle: theta_t at the junction, 0 at the base
|
|
361
|
+
alpha = self.tangency_angle - distance_on_ogive / self.ogive_radius
|
|
362
|
+
return alpha
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""2D circular nose followed by a straight body at a half-angle (upper half only).
|
|
2
|
+
|
|
3
|
+
The same profile describes three test geometries:
|
|
4
|
+
|
|
5
|
+
- half_angle = 0: flat plate with a circular leading edge (blunted plate)
|
|
6
|
+
- half_angle > 0, planar: blunted wedge
|
|
7
|
+
- half_angle > 0, axisymmetric: meridian of a sphere-cone (e.g. 7 deg)
|
|
8
|
+
|
|
9
|
+
Planar vs axisymmetric is a choice for the grid and the solver, not the profile.
|
|
10
|
+
|
|
11
|
+
Coordinates
|
|
12
|
+
-----------
|
|
13
|
+
- origin at the stagnation point, x downstream, y up
|
|
14
|
+
- nose: circular arc with centre (r_n, 0). Its polar angle phi is measured from
|
|
15
|
+
the stagnation point and runs from 0 to phi_j = 90 deg - half_angle
|
|
16
|
+
- junction (tangency point): (r_n (1 - sin theta), r_n cos theta), theta = half_angle
|
|
17
|
+
- body: straight line from the junction in direction (cos theta, sin theta),
|
|
18
|
+
ending at x = L (L is measured from the nose tip along x)
|
|
19
|
+
|
|
20
|
+
At the junction the arc's tangent equals the body direction, so the profile is
|
|
21
|
+
continuous in position and slope. Curvature jumps from 1 / r_n to 0 there.
|
|
22
|
+
|
|
23
|
+
Arc length s runs from 0 at the stagnation point, around the nose, to the end of
|
|
24
|
+
the body:
|
|
25
|
+
|
|
26
|
+
- nose: 0 <= s <= s_j, with s_j = r_n phi_j and phi = s / r_n
|
|
27
|
+
- body: s_j < s <= s_j + (L - x_j) / cos theta
|
|
28
|
+
|
|
29
|
+
Equal steps in s on the nose are equal steps in angle, and the nose normals point
|
|
30
|
+
radially away from the nose centre, so a constant-height grid around the nose is a
|
|
31
|
+
polar grid. At s = s_j exactly, the curvature is reported as the nose value.
|
|
32
|
+
|
|
33
|
+
Sharp body
|
|
34
|
+
----------
|
|
35
|
+
For half_angle > 0 the straight body, extended forward, meets the axis at the
|
|
36
|
+
virtual tip x = r_n (1 - 1 / sin theta), upstream of the stagnation point. The sharp
|
|
37
|
+
wedge or cone from there is the body the blunted one follows behind its nose.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------
|
|
41
|
+
# load necessary modules
|
|
42
|
+
# --------------------------------------------------
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import math
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
|
|
48
|
+
import numpy as np
|
|
49
|
+
from numpy.typing import ArrayLike, NDArray
|
|
50
|
+
|
|
51
|
+
from geometry_generator.profile import check_arc_length, check_sharp_profile_inputs
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --------------------------------------------------
|
|
55
|
+
# blunted wedge profile
|
|
56
|
+
# --------------------------------------------------
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class BluntedWedge:
|
|
59
|
+
"""Circular nose plus straight body, upper half, parametrized by arc length.
|
|
60
|
+
|
|
61
|
+
With the default half_angle = 0 this is the blunted flat plate.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
nose_radius: Nose radius r_n [m].
|
|
65
|
+
length: Axial length L [m], measured from the nose tip (stagnation point).
|
|
66
|
+
half_angle: Body half-angle theta [deg], 0 <= theta < 90.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
nose_radius: float
|
|
70
|
+
length: float
|
|
71
|
+
half_angle: float = 0.0
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
# validate inputs
|
|
75
|
+
if self.nose_radius <= 0.0:
|
|
76
|
+
raise ValueError(f"nose_radius must be positive, got {self.nose_radius}")
|
|
77
|
+
if not 0.0 <= self.half_angle < 90.0:
|
|
78
|
+
raise ValueError(f"half_angle must lie in [0, 90) deg, got {self.half_angle}")
|
|
79
|
+
|
|
80
|
+
# the body must extend past the junction
|
|
81
|
+
x_junction = self.nose_radius * (1.0 - math.sin(math.radians(self.half_angle)))
|
|
82
|
+
if self.length <= x_junction:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"length must be greater than the junction x position ({x_junction}), "
|
|
85
|
+
f"got {self.length}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------
|
|
89
|
+
# derived geometry
|
|
90
|
+
# --------------------------------------------------
|
|
91
|
+
@property
|
|
92
|
+
def junction_angle(self) -> float:
|
|
93
|
+
"""Polar angle phi_j = 90 deg - half_angle [rad] where the nose meets the body."""
|
|
94
|
+
phi_junction = 0.5 * math.pi - math.radians(self.half_angle)
|
|
95
|
+
return phi_junction
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def junction_point(self) -> tuple[float, float]:
|
|
99
|
+
"""Coordinates (x_j, y_j) of the tangency point between nose and body."""
|
|
100
|
+
theta = math.radians(self.half_angle)
|
|
101
|
+
x_junction = self.nose_radius * (1.0 - math.sin(theta))
|
|
102
|
+
y_junction = self.nose_radius * math.cos(theta)
|
|
103
|
+
return x_junction, y_junction
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def junction_arc_length(self) -> float:
|
|
107
|
+
"""Arc length s_j = r_n phi_j where the nose meets the body."""
|
|
108
|
+
s_junction = self.nose_radius * self.junction_angle
|
|
109
|
+
return s_junction
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def total_length(self) -> float:
|
|
113
|
+
"""Arc length of the whole profile, s_j + (L - x_j) / cos(theta)."""
|
|
114
|
+
theta = math.radians(self.half_angle)
|
|
115
|
+
x_junction, _ = self.junction_point
|
|
116
|
+
body_length = (self.length - x_junction) / math.cos(theta)
|
|
117
|
+
total = self.junction_arc_length + body_length
|
|
118
|
+
return total
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def junctions(self) -> NDArray[np.float64]:
|
|
122
|
+
"""Arc lengths where the pieces meet: the nose-body junction."""
|
|
123
|
+
junctions = np.array([self.junction_arc_length])
|
|
124
|
+
return junctions
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def sharp_tip_x(self) -> float:
|
|
128
|
+
"""x position of the sharp body's virtual tip, r_n (1 - 1 / sin theta).
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ValueError: If half_angle is 0 (a flat plate has no sharp tip).
|
|
132
|
+
"""
|
|
133
|
+
if self.half_angle == 0.0:
|
|
134
|
+
raise ValueError("a flat plate (half_angle = 0) has no sharp tip")
|
|
135
|
+
theta = math.radians(self.half_angle)
|
|
136
|
+
x_tip = self.nose_radius * (1.0 - 1.0 / math.sin(theta))
|
|
137
|
+
return x_tip
|
|
138
|
+
|
|
139
|
+
# --------------------------------------------------
|
|
140
|
+
# public evaluation methods
|
|
141
|
+
# --------------------------------------------------
|
|
142
|
+
def point(self, s: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
143
|
+
"""Return the surface coordinates at arc length s.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
s: Arc length(s) in [0, total_length].
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Tuple (x, y) of arrays with the shape of s.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
ValueError: If any s lies outside [0, total_length].
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
# validate inputs
|
|
156
|
+
s_arr = check_arc_length(s, self.total_length)
|
|
157
|
+
r_n = self.nose_radius
|
|
158
|
+
s_j = self.junction_arc_length
|
|
159
|
+
theta = math.radians(self.half_angle)
|
|
160
|
+
x_j, y_j = self.junction_point
|
|
161
|
+
|
|
162
|
+
# nose: circular arc around (r_n, 0)
|
|
163
|
+
# clip to the nose range so body points don't produce angles past phi_j
|
|
164
|
+
phi = np.minimum(s_arr, s_j) / r_n
|
|
165
|
+
x_nose = r_n - r_n * np.cos(phi)
|
|
166
|
+
y_nose = r_n * np.sin(phi)
|
|
167
|
+
|
|
168
|
+
# body: straight line from the junction at the half-angle
|
|
169
|
+
distance_on_body = s_arr - s_j
|
|
170
|
+
x_body = x_j + distance_on_body * math.cos(theta)
|
|
171
|
+
y_body = y_j + distance_on_body * math.sin(theta)
|
|
172
|
+
|
|
173
|
+
# pick the piece each s belongs to
|
|
174
|
+
on_nose = s_arr <= s_j
|
|
175
|
+
x = np.where(on_nose, x_nose, x_body)
|
|
176
|
+
y = np.where(on_nose, y_nose, y_body)
|
|
177
|
+
|
|
178
|
+
return x, y
|
|
179
|
+
|
|
180
|
+
def tangent(self, s: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
181
|
+
"""Return the unit tangent at arc length s, pointing towards increasing s.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
s: Arc length(s) in [0, total_length].
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Tuple (tx, ty) of arrays with the shape of s.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
ValueError: If any s lies outside [0, total_length].
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
# validate inputs
|
|
194
|
+
s_arr = check_arc_length(s, self.total_length)
|
|
195
|
+
s_j = self.junction_arc_length
|
|
196
|
+
theta = math.radians(self.half_angle)
|
|
197
|
+
|
|
198
|
+
# nose: derivative of (r_n - r_n cos(phi), r_n sin(phi)) w.r.t. s
|
|
199
|
+
phi = np.minimum(s_arr, s_j) / self.nose_radius
|
|
200
|
+
tx_nose = np.sin(phi)
|
|
201
|
+
ty_nose = np.cos(phi)
|
|
202
|
+
|
|
203
|
+
# body: constant direction (cos theta, sin theta)
|
|
204
|
+
tx_body = np.full_like(s_arr, math.cos(theta))
|
|
205
|
+
ty_body = np.full_like(s_arr, math.sin(theta))
|
|
206
|
+
|
|
207
|
+
# pick the piece each s belongs to
|
|
208
|
+
on_nose = s_arr <= s_j
|
|
209
|
+
tx = np.where(on_nose, tx_nose, tx_body)
|
|
210
|
+
ty = np.where(on_nose, ty_nose, ty_body)
|
|
211
|
+
|
|
212
|
+
return tx, ty
|
|
213
|
+
|
|
214
|
+
def normal(self, s: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
215
|
+
"""Return the unit normal at arc length s, pointing away from the body into the flow.
|
|
216
|
+
|
|
217
|
+
The normal is the tangent rotated by +90 degrees, (nx, ny) = (-ty, tx). With
|
|
218
|
+
s running along the wall and the normal into the flow, (s, n) is a
|
|
219
|
+
right-handed (counter-clockwise) pair.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
s: Arc length(s) in [0, total_length].
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Tuple (nx, ny) of arrays with the shape of s.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
ValueError: If any s lies outside [0, total_length].
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
# build from the tangent (validates s)
|
|
232
|
+
tx, ty = self.tangent(s)
|
|
233
|
+
|
|
234
|
+
# rotate by +90 degrees
|
|
235
|
+
nx = -ty
|
|
236
|
+
ny = tx
|
|
237
|
+
|
|
238
|
+
return nx, ny
|
|
239
|
+
|
|
240
|
+
def curvature(self, s: ArrayLike) -> NDArray[np.float64]:
|
|
241
|
+
"""Return the surface curvature at arc length s.
|
|
242
|
+
|
|
243
|
+
Curvature is 1 / r_n on the nose (convex, positive) and 0 on the body. It
|
|
244
|
+
jumps at the junction; s = s_j itself returns the nose value.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
s: Arc length(s) in [0, total_length].
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Array of curvatures [1/m] with the shape of s.
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
ValueError: If any s lies outside [0, total_length].
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
# validate inputs
|
|
257
|
+
s_arr = check_arc_length(s, self.total_length)
|
|
258
|
+
|
|
259
|
+
# constant on each piece
|
|
260
|
+
on_nose = s_arr <= self.junction_arc_length
|
|
261
|
+
kappa = np.where(on_nose, 1.0 / self.nose_radius, 0.0)
|
|
262
|
+
|
|
263
|
+
return kappa
|
|
264
|
+
|
|
265
|
+
def sharp_profile(
|
|
266
|
+
self, x_end: float, n_points: int
|
|
267
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
268
|
+
"""The sharp wedge or cone the blunted body follows, from its virtual tip.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
x_end: Downstream end [m], past the virtual tip.
|
|
272
|
+
n_points: Points, evenly spaced in x.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Tuple (x, y) of arrays, with y = 0 at the tip.
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
ValueError: If half_angle is 0, x_end doesn't lie past the tip, or
|
|
279
|
+
n_points < 2.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
# validate inputs
|
|
283
|
+
x_tip = self.sharp_tip_x
|
|
284
|
+
check_sharp_profile_inputs(x_tip, x_end, n_points)
|
|
285
|
+
|
|
286
|
+
# straight line from the virtual tip at the half-angle
|
|
287
|
+
theta = math.radians(self.half_angle)
|
|
288
|
+
x = np.linspace(x_tip, x_end, n_points)
|
|
289
|
+
y = (x - x_tip) * math.tan(theta)
|
|
290
|
+
|
|
291
|
+
return x, y
|