pyflightstream 0.2.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.
- pyflightstream/__init__.py +27 -0
- pyflightstream/cases/__init__.py +332 -0
- pyflightstream/cases/matrix_legacy.py +337 -0
- pyflightstream/commands/__init__.py +544 -0
- pyflightstream/commands/_meta.yaml +18 -0
- pyflightstream/commands/actuators.yaml +167 -0
- pyflightstream/commands/advanced_settings.yaml +136 -0
- pyflightstream/commands/aeroelastic_coupling.yaml +172 -0
- pyflightstream/commands/boundary_conditions.yaml +153 -0
- pyflightstream/commands/ccs_wing_mesh.yaml +74 -0
- pyflightstream/commands/coordinate_systems.yaml +123 -0
- pyflightstream/commands/file_io.yaml +82 -0
- pyflightstream/commands/mesh_import_export.yaml +72 -0
- pyflightstream/commands/motion_definitions.yaml +185 -0
- pyflightstream/commands/probe_points.yaml +116 -0
- pyflightstream/commands/runtime_settings.yaml +160 -0
- pyflightstream/commands/scenes.yaml +14 -0
- pyflightstream/commands/script_controls.yaml +39 -0
- pyflightstream/commands/simulation_controls.yaml +30 -0
- pyflightstream/commands/solver_analysis.yaml +118 -0
- pyflightstream/commands/solver_export.yaml +138 -0
- pyflightstream/commands/solver_initialization.yaml +95 -0
- pyflightstream/commands/solver_settings.yaml +120 -0
- pyflightstream/commands/streamlines.yaml +63 -0
- pyflightstream/commands/surface_sections.yaml +145 -0
- pyflightstream/commands/sweeper.yaml +115 -0
- pyflightstream/commands/unsteady_solver.yaml +68 -0
- pyflightstream/commands/volume_sections.yaml +148 -0
- pyflightstream/farfield/__init__.py +613 -0
- pyflightstream/files/__init__.py +375 -0
- pyflightstream/fsi/__init__.py +57 -0
- pyflightstream/fsi/beam.py +417 -0
- pyflightstream/fsi/centrifugal.py +418 -0
- pyflightstream/fsi/cli.py +206 -0
- pyflightstream/fsi/config.py +316 -0
- pyflightstream/fsi/driver.py +501 -0
- pyflightstream/fsi/kinematics.py +153 -0
- pyflightstream/fsi/loads.py +740 -0
- pyflightstream/fsi/nodes.py +463 -0
- pyflightstream/fsi/state.py +181 -0
- pyflightstream/post/__init__.py +18 -0
- pyflightstream/post/writers.py +195 -0
- pyflightstream/probes/__init__.py +453 -0
- pyflightstream/probes/geometry.py +281 -0
- pyflightstream/probes/planar.py +477 -0
- pyflightstream/qa/__init__.py +59 -0
- pyflightstream/qa/cli.py +364 -0
- pyflightstream/qa/compat.py +285 -0
- pyflightstream/qa/drift.py +406 -0
- pyflightstream/qa/geometry.py +421 -0
- pyflightstream/qa/physics.py +1744 -0
- pyflightstream/qa/probes.py +1023 -0
- pyflightstream/qa/references/PHY-01.yaml +38 -0
- pyflightstream/qa/references/PHY-02.yaml +28 -0
- pyflightstream/qa/references/PHY-05.yaml +29 -0
- pyflightstream/qa/references/PHY-06.yaml +90 -0
- pyflightstream/qa/references/SMI-01.yaml +28 -0
- pyflightstream/qa/references/SMI-02.yaml +28 -0
- pyflightstream/qa/specs.py +1405 -0
- pyflightstream/reference.py +465 -0
- pyflightstream/results/__init__.py +547 -0
- pyflightstream/run/__init__.py +677 -0
- pyflightstream/script/__init__.py +474 -0
- pyflightstream/script/helpers.py +844 -0
- pyflightstream/versions.py +191 -0
- pyflightstream-0.2.0.dist-info/METADATA +88 -0
- pyflightstream-0.2.0.dist-info/RECORD +71 -0
- pyflightstream-0.2.0.dist-info/WHEEL +5 -0
- pyflightstream-0.2.0.dist-info/entry_points.txt +3 -0
- pyflightstream-0.2.0.dist-info/licenses/LICENSE +21 -0
- pyflightstream-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Synthetic committable geometry for the Tier 3 physics cases.
|
|
2
|
+
|
|
3
|
+
Pipeline role: manufactures the NACA wing meshes the physics
|
|
4
|
+
regression matrix imports (PHY-01, PHY-02; SAD Section 11), so every
|
|
5
|
+
committed physics case is reproducible from code alone and no research
|
|
6
|
+
geometry ever enters the repository (CLAUDE.md invariant 5). The wing
|
|
7
|
+
is written as ASCII STL, one of the mesh formats the IMPORT command
|
|
8
|
+
accepts (SRC-003 p.307).
|
|
9
|
+
|
|
10
|
+
Reference frame: chord along +X (leading edge at x = 0), span along
|
|
11
|
+
+Y, thickness along +Z; all lengths in meters. A half wing spans
|
|
12
|
+
0 <= y <= span/2 with an open root section lying in the XZ plane, the
|
|
13
|
+
shape the solver's MIRROR symmetry expects: a face on the symmetry
|
|
14
|
+
plane would duplicate itself into coincident faces (the tip-treatment
|
|
15
|
+
caveat of SRC-003 p.386, paraphrased).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"WingSpec",
|
|
27
|
+
"BladeSpec",
|
|
28
|
+
"naca4_contour",
|
|
29
|
+
"wing_triangles",
|
|
30
|
+
"blade_triangles",
|
|
31
|
+
"write_stl",
|
|
32
|
+
"generate_wing_stl",
|
|
33
|
+
"generate_blade_stl",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class WingSpec:
|
|
39
|
+
"""A rectangular untwisted wing meshed from one NACA 4-digit section.
|
|
40
|
+
|
|
41
|
+
Attributes
|
|
42
|
+
----------
|
|
43
|
+
naca : str
|
|
44
|
+
Four-digit NACA designation, for example ``"0012"``: maximum
|
|
45
|
+
camber in percent chord, camber position in tenths of chord,
|
|
46
|
+
thickness in percent chord.
|
|
47
|
+
chord_m : float
|
|
48
|
+
Chord length in meters; constant along the span.
|
|
49
|
+
span_m : float
|
|
50
|
+
Full span in meters, tip to tip.
|
|
51
|
+
n_chord : int
|
|
52
|
+
Chordwise panel count per surface side (upper and lower each).
|
|
53
|
+
n_span : int
|
|
54
|
+
Spanwise panel count over the full span; a half wing uses half
|
|
55
|
+
of them.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
naca: str = "0012"
|
|
59
|
+
chord_m: float = 1.0
|
|
60
|
+
span_m: float = 8.0
|
|
61
|
+
n_chord: int = 25
|
|
62
|
+
n_span: int = 40
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
"""Reject definitions outside the implemented 4-digit family."""
|
|
66
|
+
if len(self.naca) != 4 or not self.naca.isdigit():
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"NACA designation {self.naca!r} is not four digits; the generator "
|
|
69
|
+
"implements the 4-digit family only"
|
|
70
|
+
)
|
|
71
|
+
if self.chord_m <= 0 or self.span_m <= 0:
|
|
72
|
+
raise ValueError("chord_m and span_m must be positive lengths in meters")
|
|
73
|
+
if self.n_chord < 4 or self.n_span < 2:
|
|
74
|
+
raise ValueError("mesh needs at least 4 chordwise and 2 spanwise panels")
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def area_m2(self) -> float:
|
|
78
|
+
"""Planform reference area in m^2 (chord times span)."""
|
|
79
|
+
return self.chord_m * self.span_m
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def aspect_ratio(self) -> float:
|
|
83
|
+
"""Aspect ratio span^2 / area of the rectangular planform."""
|
|
84
|
+
return self.span_m / self.chord_m
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def naca4_contour(naca: str, n_chord: int) -> np.ndarray:
|
|
88
|
+
"""Return the closed NACA 4-digit section contour in unit-chord axes.
|
|
89
|
+
|
|
90
|
+
The analytic thickness polynomial uses the closed-trailing-edge
|
|
91
|
+
variant (last coefficient -0.1036), so the surface can be meshed
|
|
92
|
+
watertight. Chordwise stations are cosine spaced, clustering points
|
|
93
|
+
at the leading and trailing edges.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
naca : str
|
|
98
|
+
Four-digit designation, for example ``"2412"``.
|
|
99
|
+
n_chord : int
|
|
100
|
+
Panels per side; the contour has ``2 * n_chord + 1`` points.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
numpy.ndarray
|
|
105
|
+
Shape ``(2 * n_chord + 1, 2)`` array of (x/c, z/c) points,
|
|
106
|
+
traversing the lower surface from the trailing edge to the
|
|
107
|
+
leading edge and the upper surface back; first and last point
|
|
108
|
+
are both the trailing edge, so the polygon is closed.
|
|
109
|
+
"""
|
|
110
|
+
m = int(naca[0]) / 100.0
|
|
111
|
+
p = int(naca[1]) / 10.0
|
|
112
|
+
t = int(naca[2:]) / 100.0
|
|
113
|
+
beta = np.linspace(0.0, np.pi, n_chord + 1)
|
|
114
|
+
x = 0.5 * (1.0 - np.cos(beta)) # 0 (leading edge) to 1 (trailing edge)
|
|
115
|
+
half_thickness = (
|
|
116
|
+
5.0 * t * (0.2969 * np.sqrt(x) - 0.1260 * x - 0.3516 * x**2 + 0.2843 * x**3 - 0.1036 * x**4)
|
|
117
|
+
)
|
|
118
|
+
camber = np.zeros_like(x)
|
|
119
|
+
slope = np.zeros_like(x)
|
|
120
|
+
if m > 0.0:
|
|
121
|
+
front = x < p
|
|
122
|
+
camber[front] = m / p**2 * (2.0 * p * x[front] - x[front] ** 2)
|
|
123
|
+
slope[front] = 2.0 * m / p**2 * (p - x[front])
|
|
124
|
+
rear = ~front
|
|
125
|
+
camber[rear] = m / (1.0 - p) ** 2 * (1.0 - 2.0 * p + 2.0 * p * x[rear] - x[rear] ** 2)
|
|
126
|
+
slope[rear] = 2.0 * m / (1.0 - p) ** 2 * (p - x[rear])
|
|
127
|
+
theta = np.arctan(slope)
|
|
128
|
+
upper = np.column_stack(
|
|
129
|
+
(x - half_thickness * np.sin(theta), camber + half_thickness * np.cos(theta))
|
|
130
|
+
)
|
|
131
|
+
lower = np.column_stack(
|
|
132
|
+
(x + half_thickness * np.sin(theta), camber - half_thickness * np.cos(theta))
|
|
133
|
+
)
|
|
134
|
+
return np.vstack((lower[::-1], upper[1:]))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def wing_triangles(spec: WingSpec, half: bool = False) -> np.ndarray:
|
|
138
|
+
"""Mesh the wing surface into outward-oriented triangles.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
spec : WingSpec
|
|
143
|
+
Wing definition; see :class:`WingSpec`.
|
|
144
|
+
half : bool
|
|
145
|
+
When True, mesh only the y >= 0 half with an open root section
|
|
146
|
+
in the XZ symmetry plane (for MIRROR-symmetry runs, PHY-02);
|
|
147
|
+
when False, mesh the full span with closed caps on both tips.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
numpy.ndarray
|
|
152
|
+
Shape ``(n_triangles, 3, 3)`` vertex array in meters; every
|
|
153
|
+
triangle winds counterclockwise seen from outside the wing.
|
|
154
|
+
"""
|
|
155
|
+
contour = naca4_contour(spec.naca, spec.n_chord) * spec.chord_m
|
|
156
|
+
if half:
|
|
157
|
+
stations = np.linspace(0.0, spec.span_m / 2.0, spec.n_span // 2 + 1)
|
|
158
|
+
else:
|
|
159
|
+
stations = np.linspace(-spec.span_m / 2.0, spec.span_m / 2.0, spec.n_span + 1)
|
|
160
|
+
# Sections indexed [span, contour, xyz]; y ascends, which keeps one
|
|
161
|
+
# winding rule valid across the whole span.
|
|
162
|
+
sections = np.empty((stations.size, contour.shape[0], 3))
|
|
163
|
+
sections[:, :, 0] = contour[:, 0]
|
|
164
|
+
sections[:, :, 1] = stations[:, None]
|
|
165
|
+
sections[:, :, 2] = contour[:, 1]
|
|
166
|
+
triangles: list[np.ndarray] = []
|
|
167
|
+
for j in range(stations.size - 1):
|
|
168
|
+
near, far = sections[j], sections[j + 1]
|
|
169
|
+
for i in range(contour.shape[0] - 1):
|
|
170
|
+
quad = (near[i], near[i + 1], far[i + 1], far[i])
|
|
171
|
+
triangles.append(np.array((quad[0], quad[1], quad[2])))
|
|
172
|
+
triangles.append(np.array((quad[0], quad[2], quad[3])))
|
|
173
|
+
triangles.extend(_tip_cap(sections[-1], outward_positive_y=True))
|
|
174
|
+
if not half:
|
|
175
|
+
triangles.extend(_tip_cap(sections[0], outward_positive_y=False))
|
|
176
|
+
return np.array(triangles)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _tip_cap(section: np.ndarray, outward_positive_y: bool) -> list[np.ndarray]:
|
|
180
|
+
"""Triangulate one tip section as a fan from a mid-camber point.
|
|
181
|
+
|
|
182
|
+
The airfoil polygon is star shaped seen from a point on the camber
|
|
183
|
+
line near mid-chord, so the fan cannot self-intersect. The contour
|
|
184
|
+
traverses lower-then-upper, which is clockwise in the XZ plane and
|
|
185
|
+
therefore winds +Y outward as built; the -Y cap flips each fan
|
|
186
|
+
triangle.
|
|
187
|
+
"""
|
|
188
|
+
hub = 0.5 * (section[0] + section[section.shape[0] // 2])
|
|
189
|
+
hub[1] = section[0, 1]
|
|
190
|
+
fan = []
|
|
191
|
+
for i in range(section.shape[0] - 1):
|
|
192
|
+
triangle = np.array((hub, section[i], section[i + 1]))
|
|
193
|
+
if not outward_positive_y:
|
|
194
|
+
triangle = triangle[::-1]
|
|
195
|
+
fan.append(triangle)
|
|
196
|
+
return fan
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def write_stl(triangles: np.ndarray, path: str | Path, name: str = "pyflightstream_wing") -> Path:
|
|
200
|
+
"""Write triangles as deterministic ASCII STL.
|
|
201
|
+
|
|
202
|
+
Parameters
|
|
203
|
+
----------
|
|
204
|
+
triangles : numpy.ndarray
|
|
205
|
+
Shape ``(n, 3, 3)`` vertex array in meters, outward wound.
|
|
206
|
+
path : str or Path
|
|
207
|
+
Destination file; parent directories must exist.
|
|
208
|
+
name : str
|
|
209
|
+
Solid name recorded in the STL header.
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
Path
|
|
214
|
+
The written path.
|
|
215
|
+
"""
|
|
216
|
+
lines = [f"solid {name}"]
|
|
217
|
+
for triangle in triangles:
|
|
218
|
+
edge_a = triangle[1] - triangle[0]
|
|
219
|
+
edge_b = triangle[2] - triangle[0]
|
|
220
|
+
normal = np.cross(edge_a, edge_b)
|
|
221
|
+
norm = np.linalg.norm(normal)
|
|
222
|
+
if norm > 0.0:
|
|
223
|
+
normal = normal / norm
|
|
224
|
+
lines.append(f" facet normal {normal[0]:.9e} {normal[1]:.9e} {normal[2]:.9e}")
|
|
225
|
+
lines.append(" outer loop")
|
|
226
|
+
for vertex in triangle:
|
|
227
|
+
lines.append(f" vertex {vertex[0]:.9e} {vertex[1]:.9e} {vertex[2]:.9e}")
|
|
228
|
+
lines.append(" endloop")
|
|
229
|
+
lines.append(" endfacet")
|
|
230
|
+
lines.append(f"endsolid {name}")
|
|
231
|
+
destination = Path(path)
|
|
232
|
+
destination.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
233
|
+
return destination
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True)
|
|
237
|
+
class BladeSpec:
|
|
238
|
+
"""A generic propeller blade lofted from one NACA 4-digit section.
|
|
239
|
+
|
|
240
|
+
Every shape law is analytic and public (textbook blade-element
|
|
241
|
+
relations with round coefficients), so the blade carries no
|
|
242
|
+
proprietary geometry and any committed case built on it is
|
|
243
|
+
shareable (CLAUDE.md invariant 5). It is deliberately NOT a model
|
|
244
|
+
of any research propeller; cases built on it seed their own
|
|
245
|
+
references.
|
|
246
|
+
|
|
247
|
+
Reference frame: rotor axis along +X (free stream +X), blade
|
|
248
|
+
spanning +Z from the hub radius to the tip, chord initially along
|
|
249
|
+
+Y and twisted toward +X. With the suction side facing -X, thrust
|
|
250
|
+
points -X (upstream) for positive rotation rates about +X when the
|
|
251
|
+
twist exceeds the local inflow angle.
|
|
252
|
+
|
|
253
|
+
Attributes
|
|
254
|
+
----------
|
|
255
|
+
naca : str
|
|
256
|
+
Four-digit section designation, for example ``"4409"``.
|
|
257
|
+
r_tip_m : float
|
|
258
|
+
Tip radius in meters.
|
|
259
|
+
hub_ratio : float
|
|
260
|
+
Root radius as a fraction of ``r_tip_m``.
|
|
261
|
+
chord_root_ratio, chord_tip_ratio : float
|
|
262
|
+
Chord over tip radius at the root and tip stations; the chord
|
|
263
|
+
varies linearly in radius between them.
|
|
264
|
+
advance_ratio_design : float
|
|
265
|
+
Design advance ratio J = V / (n D) shaping the ideal twist
|
|
266
|
+
law beta(r) = atan(J / (pi r/R)) + collective.
|
|
267
|
+
beta_75_deg : float
|
|
268
|
+
Blade angle at 75 percent radius in degrees; fixes the
|
|
269
|
+
collective offset (the standard propeller pitch convention).
|
|
270
|
+
n_chord : int
|
|
271
|
+
Chordwise panels per surface side.
|
|
272
|
+
n_span : int
|
|
273
|
+
Radial panel count.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
naca: str = "4409"
|
|
277
|
+
r_tip_m: float = 1.8288
|
|
278
|
+
hub_ratio: float = 0.15
|
|
279
|
+
chord_root_ratio: float = 0.14
|
|
280
|
+
chord_tip_ratio: float = 0.06
|
|
281
|
+
advance_ratio_design: float = 1.7
|
|
282
|
+
beta_75_deg: float = 45.0
|
|
283
|
+
n_chord: int = 25
|
|
284
|
+
n_span: int = 30
|
|
285
|
+
|
|
286
|
+
def __post_init__(self) -> None:
|
|
287
|
+
"""Reject definitions the loft cannot mesh sensibly."""
|
|
288
|
+
if len(self.naca) != 4 or not self.naca.isdigit():
|
|
289
|
+
raise ValueError(
|
|
290
|
+
f"NACA designation {self.naca!r} is not four digits; the generator "
|
|
291
|
+
"implements the 4-digit family only"
|
|
292
|
+
)
|
|
293
|
+
if self.r_tip_m <= 0 or not 0.0 < self.hub_ratio < 1.0:
|
|
294
|
+
raise ValueError("r_tip_m must be positive and hub_ratio inside (0, 1)")
|
|
295
|
+
if self.chord_root_ratio <= 0 or self.chord_tip_ratio <= 0:
|
|
296
|
+
raise ValueError("chord ratios must be positive")
|
|
297
|
+
if self.advance_ratio_design <= 0:
|
|
298
|
+
raise ValueError("advance_ratio_design must be positive")
|
|
299
|
+
if self.n_chord < 4 or self.n_span < 2:
|
|
300
|
+
raise ValueError("mesh needs at least 4 chordwise and 2 radial panels")
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def r_hub_m(self) -> float:
|
|
304
|
+
"""Root radius in meters."""
|
|
305
|
+
return self.hub_ratio * self.r_tip_m
|
|
306
|
+
|
|
307
|
+
def chord_m(self, rr: float) -> float:
|
|
308
|
+
"""Chord in meters at radius fraction ``rr`` (linear taper)."""
|
|
309
|
+
blend = (rr - self.hub_ratio) / (1.0 - self.hub_ratio)
|
|
310
|
+
ratio = self.chord_root_ratio + (self.chord_tip_ratio - self.chord_root_ratio) * blend
|
|
311
|
+
return ratio * self.r_tip_m
|
|
312
|
+
|
|
313
|
+
def beta_rad(self, rr: float) -> float:
|
|
314
|
+
"""Blade angle from the rotation plane at radius fraction ``rr``.
|
|
315
|
+
|
|
316
|
+
Ideal blade-element twist for the design advance ratio,
|
|
317
|
+
beta = atan(J / (pi rr)) plus the collective offset anchored
|
|
318
|
+
at beta(0.75) = ``beta_75_deg``.
|
|
319
|
+
"""
|
|
320
|
+
ideal = np.arctan(self.advance_ratio_design / (np.pi * rr))
|
|
321
|
+
anchor = np.arctan(self.advance_ratio_design / (np.pi * 0.75))
|
|
322
|
+
return float(ideal - anchor + np.radians(self.beta_75_deg))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def blade_triangles(spec: BladeSpec) -> np.ndarray:
|
|
326
|
+
"""Mesh one blade into outward-oriented, watertight triangles.
|
|
327
|
+
|
|
328
|
+
The section loft places each scaled NACA contour at its radius,
|
|
329
|
+
rotated by the local blade angle about the radial (+Z) axis, with
|
|
330
|
+
the pitch axis at quarter chord; root and tip are capped so the
|
|
331
|
+
body closes (the blade-only case has no spinner to join).
|
|
332
|
+
|
|
333
|
+
Parameters
|
|
334
|
+
----------
|
|
335
|
+
spec : BladeSpec
|
|
336
|
+
Blade definition; see :class:`BladeSpec`.
|
|
337
|
+
|
|
338
|
+
Returns
|
|
339
|
+
-------
|
|
340
|
+
numpy.ndarray
|
|
341
|
+
Shape ``(n_triangles, 3, 3)`` vertex array in meters; every
|
|
342
|
+
triangle winds counterclockwise seen from outside.
|
|
343
|
+
"""
|
|
344
|
+
contour = naca4_contour(spec.naca, spec.n_chord)
|
|
345
|
+
fractions = np.linspace(spec.hub_ratio, 1.0, spec.n_span + 1)
|
|
346
|
+
sections = np.empty((fractions.size, contour.shape[0], 3))
|
|
347
|
+
for j, rr in enumerate(fractions):
|
|
348
|
+
chord = spec.chord_m(float(rr))
|
|
349
|
+
beta = spec.beta_rad(float(rr))
|
|
350
|
+
xi = (contour[:, 0] - 0.25) * chord
|
|
351
|
+
eta = contour[:, 1] * chord
|
|
352
|
+
# chord direction d = (sin b, cos b, 0); upper normal
|
|
353
|
+
# n = (-cos b, sin b, 0): suction side faces -X.
|
|
354
|
+
sections[j, :, 0] = xi * np.sin(beta) - eta * np.cos(beta)
|
|
355
|
+
sections[j, :, 1] = xi * np.cos(beta) + eta * np.sin(beta)
|
|
356
|
+
sections[j, :, 2] = rr * spec.r_tip_m
|
|
357
|
+
triangles: list[np.ndarray] = []
|
|
358
|
+
for j in range(fractions.size - 1):
|
|
359
|
+
near, far = sections[j], sections[j + 1]
|
|
360
|
+
for i in range(contour.shape[0] - 1):
|
|
361
|
+
# The (chord, thickness) basis crosses to +Z here (the wing
|
|
362
|
+
# loft crosses to -span), so the quad split flips relative
|
|
363
|
+
# to wing_triangles to keep the normals outward.
|
|
364
|
+
quad = (near[i], near[i + 1], far[i + 1], far[i])
|
|
365
|
+
triangles.append(np.array((quad[0], quad[2], quad[1])))
|
|
366
|
+
triangles.append(np.array((quad[0], quad[3], quad[2])))
|
|
367
|
+
triangles.extend(_blade_cap(sections[-1], outward_positive_z=True))
|
|
368
|
+
triangles.extend(_blade_cap(sections[0], outward_positive_z=False))
|
|
369
|
+
return np.array(triangles)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _blade_cap(section: np.ndarray, outward_positive_z: bool) -> list[np.ndarray]:
|
|
373
|
+
"""Triangulate one blade end section as a fan from mid camber."""
|
|
374
|
+
hub = 0.5 * (section[0] + section[section.shape[0] // 2])
|
|
375
|
+
hub[2] = section[0, 2]
|
|
376
|
+
fan = []
|
|
377
|
+
for i in range(section.shape[0] - 1):
|
|
378
|
+
triangle = np.array((hub, section[i + 1], section[i]))
|
|
379
|
+
if not outward_positive_z:
|
|
380
|
+
triangle = triangle[::-1]
|
|
381
|
+
fan.append(triangle)
|
|
382
|
+
return fan
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def generate_blade_stl(spec: BladeSpec, path: str | Path) -> Path:
|
|
386
|
+
"""Mesh ``spec`` and write it as ASCII STL in one call.
|
|
387
|
+
|
|
388
|
+
Parameters
|
|
389
|
+
----------
|
|
390
|
+
spec : BladeSpec
|
|
391
|
+
Blade definition.
|
|
392
|
+
path : str or Path
|
|
393
|
+
Destination STL file.
|
|
394
|
+
|
|
395
|
+
Returns
|
|
396
|
+
-------
|
|
397
|
+
Path
|
|
398
|
+
The written path.
|
|
399
|
+
"""
|
|
400
|
+
return write_stl(blade_triangles(spec), path, name=f"generic_blade_naca{spec.naca}")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def generate_wing_stl(spec: WingSpec, path: str | Path, half: bool = False) -> Path:
|
|
404
|
+
"""Mesh ``spec`` and write it as ASCII STL in one call.
|
|
405
|
+
|
|
406
|
+
Parameters
|
|
407
|
+
----------
|
|
408
|
+
spec : WingSpec
|
|
409
|
+
Wing definition.
|
|
410
|
+
path : str or Path
|
|
411
|
+
Destination STL file.
|
|
412
|
+
half : bool
|
|
413
|
+
Mesh the open-root y >= 0 half instead of the full span.
|
|
414
|
+
|
|
415
|
+
Returns
|
|
416
|
+
-------
|
|
417
|
+
Path
|
|
418
|
+
The written path.
|
|
419
|
+
"""
|
|
420
|
+
label = f"naca{spec.naca}_{'half' if half else 'full'}"
|
|
421
|
+
return write_stl(wing_triangles(spec, half=half), path, name=label)
|