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.
@@ -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
@@ -0,0 +1,5 @@
1
+ """Command-line interface for geometry-generator."""
2
+
3
+ from geometry_generator.cli.app import app
4
+
5
+ __all__ = ["app"]