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,418 @@
1
+ """Centrifugal loads of the rotating blade: tension and propeller moment.
2
+
3
+ Pipeline role: WP4 of DLV-007. Computes the loads that rotation adds
4
+ on top of the aerodynamic ones at every coupling call: the axial
5
+ tension that stiffens bending (through P-Delta, FSI-R05) and the
6
+ propeller moment that twists the blade toward flat pitch (evaluated at
7
+ the current total pitch and re-solved in a small inner iteration,
8
+ FSI-R06 and FSI-R11). The frequency sweep of the Campbell diagram
9
+ (Gate 1) also lives here.
10
+
11
+ Evidence status (DLV-007 Section 2): the primary sources of the model
12
+ (Bielawa; Houbolt and Brooks, NACA Report 1346) have not yet been
13
+ independently verified against the plan formulas (TSR-014). Every
14
+ formula therefore lives in a small isolated function with the source
15
+ in the docstring, so a later correction is a localized change.
16
+
17
+ All quantities are in SI in the rotating blade frame of
18
+ :mod:`pyflightstream.fsi.config`.
19
+ """
20
+
21
+ import logging
22
+ import math
23
+ from collections.abc import Sequence
24
+ from dataclasses import dataclass
25
+
26
+ from pyflightstream.fsi import beam
27
+ from pyflightstream.fsi.config import FsiConfig
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Twist stabilization tolerance of the inner iteration: three orders of
32
+ # magnitude below the phase 3 convergence criterion of 0.05 deg
33
+ # (8.7e-4 rad), reached in the 2 to 3 solves the plan expects for
34
+ # realistic stiffness ratios (contraction is roughly the ratio of the
35
+ # propeller moment stiffening to GJ).
36
+ _INNER_TOLERANCE_RAD = 1.0e-6
37
+ _INNER_MAX_SOLVES = 8
38
+
39
+
40
+ def axial_load_distribution(cfg: FsiConfig) -> list[float]:
41
+ """Distributed centrifugal axial force at every station [N/m].
42
+
43
+ f(r) = mu(r) Omega^2 r, pointing from root to tip. Applied as a
44
+ distributed axial member load, it makes the internal tension N(r)
45
+ emerge from the solver, and P-Delta turns it into bending
46
+ stiffness with no manual correction terms (FSI-R05).
47
+
48
+ Source: FSI Blade Coupling Plan rev. 2 (July 2026), Section on
49
+ centrifugal loads; elementary centrifugal force on a rotating
50
+ mass element.
51
+
52
+ Parameters
53
+ ----------
54
+ cfg : FsiConfig
55
+ Configuration with mu(r) and Omega.
56
+
57
+ Returns
58
+ -------
59
+ list of float
60
+ f(r_i) at every station [N/m].
61
+ """
62
+ omega_sq = cfg.omega_rad_per_s**2
63
+ return [
64
+ mu * omega_sq * r
65
+ for mu, r in zip(cfg.blade.mass_per_length_kg_per_m, cfg.blade.station_radii_m, strict=True)
66
+ ]
67
+
68
+
69
+ def axial_tension(cfg: FsiConfig) -> list[float]:
70
+ """Return the internal centrifugal tension N(r) at every station [N].
71
+
72
+ N(r) = integral from r to R of mu(s) Omega^2 s ds, evaluated with
73
+ the trapezoid rule on the configured stations. This closed form is
74
+ the cross-check of what the P-Delta solve builds internally; the
75
+ solver never receives it directly.
76
+
77
+ Source: FSI Blade Coupling Plan rev. 2 (July 2026), Section on
78
+ centrifugal loads.
79
+
80
+ Parameters
81
+ ----------
82
+ cfg : FsiConfig
83
+ Configuration with mu(r) and Omega.
84
+
85
+ Returns
86
+ -------
87
+ list of float
88
+ N(r_i) at every station [N]; zero at the tip.
89
+ """
90
+ load = axial_load_distribution(cfg)
91
+ radii = cfg.blade.station_radii_m
92
+ n = len(radii)
93
+ tension = [0.0] * n
94
+ for i in range(n - 2, -1, -1):
95
+ bay = radii[i + 1] - radii[i]
96
+ tension[i] = tension[i + 1] + 0.5 * (load[i] + load[i + 1]) * bay
97
+ return tension
98
+
99
+
100
+ def total_pitch_rad(cfg: FsiConfig, elastic_twist_rad: Sequence[float]) -> list[float]:
101
+ """Total local pitch theta_tot [rad]: geometric plus elastic.
102
+
103
+ Source: FSI Blade Coupling Plan rev. 2 (July 2026), definition of
104
+ the total pitch entering the propeller moment (FSI-R06).
105
+ """
106
+ return [
107
+ math.radians(geometric) + elastic
108
+ for geometric, elastic in zip(cfg.blade.geometric_pitch_deg, elastic_twist_rad, strict=True)
109
+ ]
110
+
111
+
112
+ def propeller_moment_distribution(
113
+ cfg: FsiConfig, elastic_twist_rad: Sequence[float]
114
+ ) -> list[float]:
115
+ """Distributed propeller moment about the elastic axis [N m / m].
116
+
117
+ m_theta(r) = -Omega^2 (I1(r) - I2(r)) sin(theta_tot) cos(theta_tot),
118
+ with theta_tot the total local pitch. The moment drives every
119
+ section toward flat pitch, so it depends on the deformation and is
120
+ re-evaluated inside the inner iteration (FSI-R06).
121
+
122
+ Source: FSI Blade Coupling Plan rev. 2 (July 2026), propeller
123
+ moment term after Houbolt and Brooks, NACA Report 1346 (primary
124
+ source verification pending, TSR-014).
125
+
126
+ Parameters
127
+ ----------
128
+ cfg : FsiConfig
129
+ Configuration with I1(r), I2(r), geometric pitch, and Omega.
130
+ elastic_twist_rad : sequence of float
131
+ Current elastic twist at every station [rad].
132
+
133
+ Returns
134
+ -------
135
+ list of float
136
+ m_theta(r_i) at every station [N m / m].
137
+ """
138
+ omega_sq = cfg.omega_rad_per_s**2
139
+ return [
140
+ -omega_sq * (i1 - i2) * math.sin(theta) * math.cos(theta)
141
+ for i1, i2, theta in zip(
142
+ cfg.blade.inertia_major_kg_m,
143
+ cfg.blade.inertia_minor_kg_m,
144
+ total_pitch_rad(cfg, elastic_twist_rad),
145
+ strict=True,
146
+ )
147
+ ]
148
+
149
+
150
+ def propeller_moment_twist_stiffness(
151
+ cfg: FsiConfig, elastic_twist_rad: Sequence[float]
152
+ ) -> list[float]:
153
+ """Lumped torsional stiffening of the propeller moment [N m / rad].
154
+
155
+ Linearizing m_theta about the current pitch gives the distributed
156
+ restoring stiffness k_theta(r) = Omega^2 (I1 - I2) cos(2 theta_tot),
157
+ lumped here over the station tributary lengths for the modal
158
+ problem. For a thin blade near flat pitch this term alone yields
159
+ the classic torsional Southwell coefficient near 1.
160
+
161
+ Source: FSI Blade Coupling Plan rev. 2 (July 2026), derivative of
162
+ the propeller moment term after Houbolt and Brooks, NACA Report
163
+ 1346 (primary source verification pending, TSR-014).
164
+
165
+ Parameters
166
+ ----------
167
+ cfg : FsiConfig
168
+ Configuration with I1(r), I2(r), geometric pitch, and Omega.
169
+ elastic_twist_rad : sequence of float
170
+ Twist state to linearize about [rad].
171
+
172
+ Returns
173
+ -------
174
+ list of float
175
+ Lumped stiffness at every station [N m / rad].
176
+ """
177
+ omega_sq = cfg.omega_rad_per_s**2
178
+ tributary = beam.tributary_lengths(cfg.blade.station_radii_m)
179
+ return [
180
+ omega_sq * (i1 - i2) * math.cos(2.0 * theta) * length
181
+ for i1, i2, theta, length in zip(
182
+ cfg.blade.inertia_major_kg_m,
183
+ cfg.blade.inertia_minor_kg_m,
184
+ total_pitch_rad(cfg, elastic_twist_rad),
185
+ tributary,
186
+ strict=True,
187
+ )
188
+ ]
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class RotatingSolution:
193
+ """Static solution of the rotating blade with iteration diagnostics.
194
+
195
+ Attributes
196
+ ----------
197
+ solution : beam.StaticBeamSolution
198
+ Converged (w, theta) at the stations.
199
+ inner_solves : int
200
+ Beam solves spent in the inner twist iteration (FSI-R11).
201
+ twist_residual_rad : float
202
+ Largest twist change of the last inner iteration [rad].
203
+ """
204
+
205
+ solution: beam.StaticBeamSolution
206
+ inner_solves: int
207
+ twist_residual_rad: float
208
+
209
+
210
+ def solve_rotating_static(
211
+ cfg: FsiConfig,
212
+ flap_load_n_per_m: Sequence[float] | None = None,
213
+ torsion_moment_n_m_per_m: Sequence[float] | None = None,
214
+ ) -> RotatingSolution:
215
+ """Solve the rotating blade statically with the inner twist iteration.
216
+
217
+ Each pass rebuilds the beam, applies the aerodynamic loads plus
218
+ the centrifugal tension and the propeller moment evaluated at the
219
+ current twist, and solves with P-Delta. The loop repeats until the
220
+ twist distribution stabilizes, converging the structural
221
+ nonlinearity implicitly at millisecond cost, decoupled from the
222
+ aerodynamic loop (FSI-R11; typically 2 to 3 solves).
223
+
224
+ Parameters
225
+ ----------
226
+ cfg : FsiConfig
227
+ Configuration; Omega may be zero, which reduces to the plain
228
+ static solve in one pass.
229
+ flap_load_n_per_m : sequence of float, optional
230
+ Aerodynamic distributed flap load at the stations [N/m].
231
+ torsion_moment_n_m_per_m : sequence of float, optional
232
+ Aerodynamic distributed twisting moment about the elastic
233
+ axis at the stations [N m / m].
234
+
235
+ Returns
236
+ -------
237
+ RotatingSolution
238
+ Converged solution and iteration diagnostics.
239
+ """
240
+ n = len(cfg.blade.station_radii_m)
241
+ aero_torsion = list(torsion_moment_n_m_per_m or [0.0] * n)
242
+ axial = axial_load_distribution(cfg)
243
+ twist = [0.0] * n
244
+ residual = math.inf
245
+ solve_count = 0
246
+ while solve_count < _INNER_MAX_SOLVES:
247
+ solve_count += 1
248
+ model = beam.build_beam_model(cfg)
249
+ propeller = propeller_moment_distribution(cfg, twist)
250
+ torsion = [aero + prop for aero, prop in zip(aero_torsion, propeller, strict=True)]
251
+ beam.apply_station_loads(
252
+ model,
253
+ cfg,
254
+ flap_load_n_per_m=flap_load_n_per_m,
255
+ torsion_moment_n_m_per_m=torsion,
256
+ axial_load_n_per_m=axial if cfg.omega_rad_per_s > 0.0 else None,
257
+ )
258
+ beam.solve_static(model, p_delta=cfg.omega_rad_per_s > 0.0)
259
+ solution = beam.extract_solution(model, cfg)
260
+ new_twist = list(solution.elastic_twist_rad)
261
+ residual = max(abs(a - b) for a, b in zip(new_twist, twist, strict=True))
262
+ twist = new_twist
263
+ if residual < _INNER_TOLERANCE_RAD:
264
+ break
265
+ if residual >= _INNER_TOLERANCE_RAD:
266
+ logger.warning(
267
+ "inner twist iteration hit %d solves with residual %.3e rad; the "
268
+ "propeller moment is unusually strong for this blade stiffness",
269
+ _INNER_MAX_SOLVES,
270
+ residual,
271
+ )
272
+ logger.debug("rotating static solve: %d inner solves", solve_count)
273
+ return RotatingSolution(
274
+ solution=solution, inner_solves=solve_count, twist_residual_rad=residual
275
+ )
276
+
277
+
278
+ def rotating_frequencies(cfg: FsiConfig, n_modes: int = 6) -> beam.ModalResult:
279
+ """Natural frequencies of the blade spinning at the configured Omega.
280
+
281
+ The blade is first solved statically under its centrifugal loads
282
+ (inner iteration included); the modal problem then adds PyNite's
283
+ geometric stiffness of that tension state and the lumped propeller
284
+ moment stiffening about the converged twist.
285
+
286
+ Parameters
287
+ ----------
288
+ cfg : FsiConfig
289
+ Configuration; Omega taken from ``omega_rad_per_s``.
290
+ n_modes : int
291
+ Number of lowest modes to return.
292
+
293
+ Returns
294
+ -------
295
+ beam.ModalResult
296
+ Ascending frequencies with flap or torsion classification.
297
+ """
298
+ rotating = solve_rotating_static(cfg)
299
+ model = beam.build_beam_model(cfg)
300
+ spinning = cfg.omega_rad_per_s > 0.0
301
+ if spinning:
302
+ beam.apply_station_loads(model, cfg, axial_load_n_per_m=axial_load_distribution(cfg))
303
+ beam.solve_static(model, p_delta=spinning)
304
+ twist_stiffness = (
305
+ propeller_moment_twist_stiffness(cfg, rotating.solution.elastic_twist_rad)
306
+ if spinning
307
+ else None
308
+ )
309
+ return beam.modal_frequencies(
310
+ model,
311
+ cfg,
312
+ n_modes=n_modes,
313
+ include_geometric_stiffness=spinning,
314
+ twist_stiffness_n_m_per_rad=twist_stiffness,
315
+ )
316
+
317
+
318
+ @dataclass(frozen=True)
319
+ class CampbellData:
320
+ """Frequency tracks over a rotor speed sweep (the Campbell diagram).
321
+
322
+ Attributes
323
+ ----------
324
+ omegas_rad_per_s : tuple of float
325
+ Rotor speeds of the sweep [rad/s].
326
+ modal_results : tuple of beam.ModalResult
327
+ Modal result at each rotor speed, same order.
328
+ """
329
+
330
+ omegas_rad_per_s: tuple[float, ...]
331
+ modal_results: tuple["beam.ModalResult", ...]
332
+
333
+ def family_track(self, kind: str) -> list[float]:
334
+ """First frequency of a family (``"flap"`` or ``"torsion"``) per Omega."""
335
+ track = []
336
+ for result in self.modal_results:
337
+ track.append(
338
+ next(
339
+ f
340
+ for f, k in zip(result.frequencies_rad_per_s, result.kinds, strict=True)
341
+ if k == kind
342
+ )
343
+ )
344
+ return track
345
+
346
+
347
+ def campbell_sweep(
348
+ cfg: FsiConfig, omegas_rad_per_s: Sequence[float], n_modes: int = 6
349
+ ) -> CampbellData:
350
+ """Sweep rotor speed and collect the natural frequencies (Gate 1).
351
+
352
+ Parameters
353
+ ----------
354
+ cfg : FsiConfig
355
+ Base configuration; its Omega is overridden per sweep point.
356
+ omegas_rad_per_s : sequence of float
357
+ Rotor speeds to sweep [rad/s].
358
+ n_modes : int
359
+ Modes per point.
360
+
361
+ Returns
362
+ -------
363
+ CampbellData
364
+ Frequency tracks of the sweep.
365
+ """
366
+ results = []
367
+ for omega in omegas_rad_per_s:
368
+ point = cfg.model_copy(update={"omega_rad_per_s": float(omega)})
369
+ results.append(rotating_frequencies(point, n_modes=n_modes))
370
+ return CampbellData(
371
+ omegas_rad_per_s=tuple(float(w) for w in omegas_rad_per_s),
372
+ modal_results=tuple(results),
373
+ )
374
+
375
+
376
+ def southwell_fit(
377
+ omegas_rad_per_s: Sequence[float], frequencies_rad_per_s: Sequence[float]
378
+ ) -> tuple[float, float, float]:
379
+ """Fit omega_n^2 = omega_0^2 + S Omega^2 to a frequency track.
380
+
381
+ The Southwell coefficient S measures the centrifugal stiffening of
382
+ a mode; the fit quality measures how straight the track is in the
383
+ (Omega^2, omega_n^2) plane, which is the WP4 verification.
384
+
385
+ Source: Southwell's linear frequency-rise approximation for
386
+ rotating beams, as used in the FSI Blade Coupling Plan rev. 2
387
+ (July 2026; after Bielawa, "Rotary Wing Structural Dynamics",
388
+ primary source verification pending, TSR-014).
389
+
390
+ Parameters
391
+ ----------
392
+ omegas_rad_per_s : sequence of float
393
+ Rotor speeds of the sweep [rad/s].
394
+ frequencies_rad_per_s : sequence of float
395
+ Natural frequency of one tracked mode at each speed [rad/s].
396
+
397
+ Returns
398
+ -------
399
+ tuple of float
400
+ (omega_0 [rad/s], Southwell coefficient S, r_squared of the fit).
401
+ """
402
+ if len(omegas_rad_per_s) != len(frequencies_rad_per_s) or len(omegas_rad_per_s) < 3:
403
+ raise ValueError(
404
+ "the Southwell fit needs at least three sweep points with one frequency per rotor speed"
405
+ )
406
+ x = [w**2 for w in omegas_rad_per_s]
407
+ y = [f**2 for f in frequencies_rad_per_s]
408
+ n = len(x)
409
+ x_mean = sum(x) / n
410
+ y_mean = sum(y) / n
411
+ sxx = sum((xi - x_mean) ** 2 for xi in x)
412
+ sxy = sum((xi - x_mean) * (yi - y_mean) for xi, yi in zip(x, y, strict=True))
413
+ slope = sxy / sxx
414
+ intercept = y_mean - slope * x_mean
415
+ ss_res = sum((yi - (intercept + slope * xi)) ** 2 for xi, yi in zip(x, y, strict=True))
416
+ ss_tot = sum((yi - y_mean) ** 2 for yi in y)
417
+ r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0.0 else 1.0
418
+ return math.sqrt(max(intercept, 0.0)), slope, r_squared
@@ -0,0 +1,206 @@
1
+ """``pyfs-fsi`` console entry point: the FSI coupling executable.
2
+
3
+ Pipeline role: FlightStream's Aeroelastic Coupling Toolbox calls an
4
+ external executable bare, once per time step, in the directory set by
5
+ ``SET_AEROELASTIC_WORKING_DIRECTORY`` (SRC-003 pp.375-376; evidence
6
+ reports/RPT-005). This module is that executable, with two modes
7
+ dispatched on the run folder's content:
8
+
9
+ * Coupled mode (WP7): a ``config.json`` in the working directory makes
10
+ every call run :func:`pyflightstream.fsi.driver.coupling_step`, the
11
+ complete four-phase machine of WP6. The loads and displacement
12
+ files of every call are archived under ``fsi_archive/`` so a run is
13
+ replayable offline afterwards.
14
+ * Dummy mode (WP1, kept as the fallback): with no ``config.json``,
15
+ write zero displacements so the blade stays rigid and archive every
16
+ interface file FlightStream produces; this is how the dry-run
17
+ fixtures were collected.
18
+
19
+ Nothing printed to stdout is visible from inside the solver, so both
20
+ modes log to files and any coupled-mode failure lands with its
21
+ traceback in ``pyfs_fsi_error.log`` instead of a silent nonzero exit.
22
+ """
23
+
24
+ import argparse
25
+ import datetime
26
+ import json
27
+ import shutil
28
+ import sys
29
+ import traceback
30
+ from pathlib import Path
31
+
32
+ COUPLED_CONFIG = "config.json"
33
+
34
+ DUMMY_CONFIG = "pyfs_fsi_dummy.json"
35
+ STATE_FILE = "pyfs_fsi_dummy_state.json"
36
+ DISPLACEMENT_FILE = "FSIDisp.txt"
37
+ CALL_LOG = "pyfs_fsi_calls.log"
38
+ ERROR_LOG = "pyfs_fsi_error.log"
39
+ ARCHIVE_DIR = "fsi_archive"
40
+ # Interface files worth archiving on every call. The exact export set
41
+ # and cadence are WP1 open questions; the directory listing recorded in
42
+ # every archive folder catches anything not matched here.
43
+ ARCHIVE_PATTERNS = ("FS_*.txt", "FSLoad*", "FSI_output.txt", "FSIDisp.txt", "*.sldl")
44
+
45
+
46
+ def init_dummy(directory: Path, node_count: int) -> None:
47
+ """Write the dummy configuration into the future run directory.
48
+
49
+ Parameters
50
+ ----------
51
+ directory : Path
52
+ Directory FlightStream will run the executable in (the
53
+ simulation folder of the dry run).
54
+ node_count : int
55
+ Number of structural nodes of the imported node list; the
56
+ dummy writes one zero translation per node, in list order.
57
+ """
58
+ directory.mkdir(parents=True, exist_ok=True)
59
+ config = {"node_count": node_count}
60
+ (directory / DUMMY_CONFIG).write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
61
+ print(f"dummy config written: {directory / DUMMY_CONFIG} (node_count {node_count})")
62
+
63
+
64
+ def coupled_step(cwd: Path, received_argv: tuple[str, ...] = ()) -> int:
65
+ """Execute one real coupling call in ``cwd`` (WP7 wiring).
66
+
67
+ Runs :func:`pyflightstream.fsi.driver.coupling_step`, archives the
68
+ call's loads and displacement files for offline replay, and logs
69
+ the outcome. Any failure writes its traceback to the error log
70
+ and returns a nonzero exit code, which aborts the FlightStream
71
+ coupling instead of silently continuing rigid.
72
+ """
73
+ stamp = datetime.datetime.now().isoformat(timespec="milliseconds")
74
+ argv_note = f"argv {list(received_argv)}" if received_argv else "argv none"
75
+ # PyNite lives behind the optional [fsi] extra; import at call time
76
+ # so the dummy mode keeps working without it.
77
+ from pyflightstream.fsi import driver
78
+
79
+ try:
80
+ result = driver.coupling_step(cwd)
81
+ except Exception:
82
+ with (cwd / ERROR_LOG).open("a", encoding="utf-8") as log:
83
+ log.write(f"{stamp} coupled step failed in {cwd} ({argv_note})\n")
84
+ log.write(traceback.format_exc() + "\n")
85
+ return 1
86
+ archive = cwd / ARCHIVE_DIR / f"call_{result.call:04d}"
87
+ archive.mkdir(parents=True, exist_ok=True)
88
+ for name in (driver.LOADS_FILE, driver.DISPLACEMENT_FILE):
89
+ source = cwd / name
90
+ if source.is_file():
91
+ shutil.copy2(source, archive / name)
92
+ with (cwd / CALL_LOG).open("a", encoding="utf-8") as log:
93
+ log.write(
94
+ f"{stamp} coupled call {result.call} (step {result.step}, phase "
95
+ f"{result.phase}, cwd {cwd}, {argv_note}): FSIDisp written\n"
96
+ )
97
+ return 0
98
+
99
+
100
+ def dummy_step(cwd: Path, received_argv: tuple[str, ...] = ()) -> int:
101
+ """Execute one dummy coupling call in ``cwd``.
102
+
103
+ Archives the visible interface files, writes zero displacements,
104
+ and appends to the call log. The working directory and any
105
+ received arguments are recorded on every call: both are open
106
+ questions of the WP1 dry run. Returns a process exit code.
107
+ """
108
+ stamp = datetime.datetime.now().isoformat(timespec="milliseconds")
109
+ argv_note = f"argv {list(received_argv)}" if received_argv else "argv none"
110
+ config_path = cwd / DUMMY_CONFIG
111
+ if not config_path.is_file():
112
+ listing = "\n".join(sorted(p.name for p in cwd.iterdir()))
113
+ (cwd / ERROR_LOG).write_text(
114
+ f"{stamp} pyfs-fsi called without {DUMMY_CONFIG} in {cwd} ({argv_note})\n"
115
+ f"directory listing:\n{listing}\n",
116
+ encoding="utf-8",
117
+ )
118
+ return 1
119
+ node_count = json.loads(config_path.read_text(encoding="utf-8"))["node_count"]
120
+
121
+ state_path = cwd / STATE_FILE
122
+ if state_path.is_file():
123
+ state = json.loads(state_path.read_text(encoding="utf-8"))
124
+ else:
125
+ state = {"calls": 0}
126
+ call_number = state["calls"] + 1
127
+
128
+ archive = cwd / ARCHIVE_DIR / f"call_{call_number:04d}"
129
+ archive.mkdir(parents=True, exist_ok=True)
130
+ copied = []
131
+ for pattern in ARCHIVE_PATTERNS:
132
+ for path in sorted(cwd.glob(pattern)):
133
+ shutil.copy2(path, archive / path.name)
134
+ copied.append(path.name)
135
+ listing_lines = [
136
+ f"{p.name}\t{p.stat().st_size}\t{datetime.datetime.fromtimestamp(p.stat().st_mtime).isoformat(timespec='seconds')}"
137
+ for p in sorted(cwd.iterdir())
138
+ if p.is_file()
139
+ ]
140
+ (archive / "directory_listing.txt").write_text(
141
+ "\n".join(listing_lines) + "\n", encoding="utf-8"
142
+ )
143
+
144
+ # Comma separated per the FSIDisp.txt format of SRC-003 p.273.
145
+ zero_line = "0.000000000000e+00,0.000000000000e+00,0.000000000000e+00"
146
+ (cwd / DISPLACEMENT_FILE).write_text(
147
+ "\n".join([zero_line] * node_count) + "\n", encoding="utf-8"
148
+ )
149
+
150
+ state["calls"] = call_number
151
+ tmp = state_path.with_suffix(".tmp")
152
+ tmp.write_text(json.dumps(state) + "\n", encoding="utf-8")
153
+ tmp.replace(state_path)
154
+
155
+ with (cwd / CALL_LOG).open("a", encoding="utf-8") as log:
156
+ log.write(
157
+ f"{stamp} call {call_number} (cwd {cwd}, {argv_note}): wrote "
158
+ f"{node_count} zero displacement vectors; archived {copied or 'nothing'}\n"
159
+ )
160
+ return 0
161
+
162
+
163
+ def _step(cwd: Path, received_argv: tuple[str, ...] = ()) -> int:
164
+ """Dispatch one coupling call: coupled if configured, dummy otherwise."""
165
+ if (cwd / COUPLED_CONFIG).is_file():
166
+ return coupled_step(cwd, received_argv=received_argv)
167
+ return dummy_step(cwd, received_argv=received_argv)
168
+
169
+
170
+ def main(argv: list[str] | None = None) -> int:
171
+ """Entry point of the ``pyfs-fsi`` console script."""
172
+ argv = sys.argv[1:] if argv is None else argv
173
+ if not argv:
174
+ # FlightStream calls the executable bare: one coupling step,
175
+ # coupled when the working directory carries a config.json.
176
+ return _step(Path.cwd())
177
+ if argv[0] not in ("init-dummy", "step", "-h", "--help"):
178
+ # Unknown call convention: the Toolbox may pass arguments of its
179
+ # own. Execute the coupling step anyway and record the arguments
180
+ # as evidence instead of dying on argparse.
181
+ return _step(Path.cwd(), received_argv=tuple(argv))
182
+ parser = argparse.ArgumentParser(
183
+ prog="pyfs-fsi",
184
+ description=(
185
+ "FSI coupling executable for the FlightStream Aeroelastic Toolbox. "
186
+ "Called with no arguments it executes one coupling step in the "
187
+ "current directory (dummy mode until the coupled driver lands)."
188
+ ),
189
+ )
190
+ sub = parser.add_subparsers(dest="command", required=True)
191
+ p_init = sub.add_parser("init-dummy", help="write the dummy configuration into a run directory")
192
+ p_init.add_argument("--node-count", type=int, required=True)
193
+ p_init.add_argument("--dir", type=Path, default=Path.cwd())
194
+ p_step = sub.add_parser("step", help="execute one coupling step explicitly")
195
+ p_step.add_argument("--dir", type=Path, default=Path.cwd())
196
+ args = parser.parse_args(argv)
197
+ if args.command == "init-dummy":
198
+ if args.node_count < 1:
199
+ parser.error("a structural node list has at least one node")
200
+ init_dummy(args.dir, args.node_count)
201
+ return 0
202
+ return _step(args.dir)
203
+
204
+
205
+ if __name__ == "__main__":
206
+ raise SystemExit(main())