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.
Files changed (71) hide show
  1. pyflightstream/__init__.py +27 -0
  2. pyflightstream/cases/__init__.py +332 -0
  3. pyflightstream/cases/matrix_legacy.py +337 -0
  4. pyflightstream/commands/__init__.py +544 -0
  5. pyflightstream/commands/_meta.yaml +18 -0
  6. pyflightstream/commands/actuators.yaml +167 -0
  7. pyflightstream/commands/advanced_settings.yaml +136 -0
  8. pyflightstream/commands/aeroelastic_coupling.yaml +172 -0
  9. pyflightstream/commands/boundary_conditions.yaml +153 -0
  10. pyflightstream/commands/ccs_wing_mesh.yaml +74 -0
  11. pyflightstream/commands/coordinate_systems.yaml +123 -0
  12. pyflightstream/commands/file_io.yaml +82 -0
  13. pyflightstream/commands/mesh_import_export.yaml +72 -0
  14. pyflightstream/commands/motion_definitions.yaml +185 -0
  15. pyflightstream/commands/probe_points.yaml +116 -0
  16. pyflightstream/commands/runtime_settings.yaml +160 -0
  17. pyflightstream/commands/scenes.yaml +14 -0
  18. pyflightstream/commands/script_controls.yaml +39 -0
  19. pyflightstream/commands/simulation_controls.yaml +30 -0
  20. pyflightstream/commands/solver_analysis.yaml +118 -0
  21. pyflightstream/commands/solver_export.yaml +138 -0
  22. pyflightstream/commands/solver_initialization.yaml +95 -0
  23. pyflightstream/commands/solver_settings.yaml +120 -0
  24. pyflightstream/commands/streamlines.yaml +63 -0
  25. pyflightstream/commands/surface_sections.yaml +145 -0
  26. pyflightstream/commands/sweeper.yaml +115 -0
  27. pyflightstream/commands/unsteady_solver.yaml +68 -0
  28. pyflightstream/commands/volume_sections.yaml +148 -0
  29. pyflightstream/farfield/__init__.py +613 -0
  30. pyflightstream/files/__init__.py +375 -0
  31. pyflightstream/fsi/__init__.py +57 -0
  32. pyflightstream/fsi/beam.py +417 -0
  33. pyflightstream/fsi/centrifugal.py +418 -0
  34. pyflightstream/fsi/cli.py +206 -0
  35. pyflightstream/fsi/config.py +316 -0
  36. pyflightstream/fsi/driver.py +501 -0
  37. pyflightstream/fsi/kinematics.py +153 -0
  38. pyflightstream/fsi/loads.py +740 -0
  39. pyflightstream/fsi/nodes.py +463 -0
  40. pyflightstream/fsi/state.py +181 -0
  41. pyflightstream/post/__init__.py +18 -0
  42. pyflightstream/post/writers.py +195 -0
  43. pyflightstream/probes/__init__.py +453 -0
  44. pyflightstream/probes/geometry.py +281 -0
  45. pyflightstream/probes/planar.py +477 -0
  46. pyflightstream/qa/__init__.py +59 -0
  47. pyflightstream/qa/cli.py +364 -0
  48. pyflightstream/qa/compat.py +285 -0
  49. pyflightstream/qa/drift.py +406 -0
  50. pyflightstream/qa/geometry.py +421 -0
  51. pyflightstream/qa/physics.py +1744 -0
  52. pyflightstream/qa/probes.py +1023 -0
  53. pyflightstream/qa/references/PHY-01.yaml +38 -0
  54. pyflightstream/qa/references/PHY-02.yaml +28 -0
  55. pyflightstream/qa/references/PHY-05.yaml +29 -0
  56. pyflightstream/qa/references/PHY-06.yaml +90 -0
  57. pyflightstream/qa/references/SMI-01.yaml +28 -0
  58. pyflightstream/qa/references/SMI-02.yaml +28 -0
  59. pyflightstream/qa/specs.py +1405 -0
  60. pyflightstream/reference.py +465 -0
  61. pyflightstream/results/__init__.py +547 -0
  62. pyflightstream/run/__init__.py +677 -0
  63. pyflightstream/script/__init__.py +474 -0
  64. pyflightstream/script/helpers.py +844 -0
  65. pyflightstream/versions.py +191 -0
  66. pyflightstream-0.2.0.dist-info/METADATA +88 -0
  67. pyflightstream-0.2.0.dist-info/RECORD +71 -0
  68. pyflightstream-0.2.0.dist-info/WHEEL +5 -0
  69. pyflightstream-0.2.0.dist-info/entry_points.txt +3 -0
  70. pyflightstream-0.2.0.dist-info/licenses/LICENSE +21 -0
  71. 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)