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,501 @@
1
+ """Coupling driver: the four-phase state machine of one FSI call (WP6).
2
+
3
+ Pipeline role: this is the brain of the coupling executable. Per call
4
+ FlightStream has already run its post-processing script, so the run
5
+ folder holds a fresh ``FS_SurfaceSection_Loads.txt``; the driver
6
+ parses it, assembles per-blade loads about the elastic axis, solves
7
+ the rotating beam per blade, relaxes the displacements against the
8
+ previous call, writes ``FSIDisp.txt`` in the single-source node order,
9
+ appends the convergence log, and persists ``state.json`` atomically.
10
+ Everything is file-driven, so the offline replay harness of the tier 1
11
+ suite exercises the complete machine on archived fixtures with no
12
+ FlightStream in the loop (DLV-007 Section 8).
13
+
14
+ The phase machine is keyed on the step counter (DLV-007 Section 4.5),
15
+ with the step-to-revolution conversion taken from the configured Omega
16
+ and the time increment printed in the loads file itself:
17
+
18
+ 1. Wake development: zero displacements while the wake develops on
19
+ the rigid blade.
20
+ 2. Averaged coupling: loads averaged over the configured window,
21
+ relaxed updates (FSI-R07).
22
+ 3. Convergence watch: as phase 2; per completed revolution the tip
23
+ response enters the log, and convergence is declared when the tip
24
+ elastic twist change per revolution drops below the configured
25
+ tolerance (FSI-R09; the revolution-averaged thrust stability is
26
+ judged from the same log downstream).
27
+ 4. Recording: instantaneous loads, no relaxation (lambda = 1 by
28
+ design: relaxing here would low-pass exactly the 1P amplitude and
29
+ phase being measured), twist distributions recorded per step.
30
+
31
+ Frozen mode (FSI-R10) is first class: when the run folder holds a
32
+ ``fsi_frozen_displacements.txt``, every call replays it verbatim, with
33
+ no loads parsing and no solve, so a stored deformation can be held
34
+ fixed for sensitivity runs and Gate 2.
35
+
36
+ The convergence log carries the config hash on every row (FSI-R15)
37
+ and states the quasi-steady validity boundary in its header (DLV-007
38
+ Section 4.1).
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import logging
44
+ import math
45
+ from dataclasses import dataclass
46
+ from pathlib import Path
47
+
48
+ import numpy as np
49
+
50
+ from pyflightstream.fsi import beam, centrifugal, kinematics, nodes
51
+ from pyflightstream.fsi.config import FsiConfig, config_hash, load_config
52
+ from pyflightstream.fsi.loads import (
53
+ ElasticAxisLoads,
54
+ SectionFamilyMap,
55
+ parse_sectional_loads,
56
+ to_elastic_axis,
57
+ )
58
+ from pyflightstream.fsi.state import (
59
+ FsiState,
60
+ LoadSample,
61
+ RecordedTwist,
62
+ RevolutionSample,
63
+ initial_state,
64
+ load_state,
65
+ write_state_atomic,
66
+ )
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+ CONFIG_FILE = "config.json"
71
+ STATE_FILE = "state.json"
72
+ LOADS_FILE = "FS_SurfaceSection_Loads.txt"
73
+ DISPLACEMENT_FILE = "FSIDisp.txt"
74
+ FAMILY_MAP_FILE = "fsi_family_map.json"
75
+ LOG_FILE = "fsi_convergence_log.csv"
76
+ FROZEN_FILE = "fsi_frozen_displacements.txt"
77
+
78
+ _LOG_HEADER = (
79
+ "# pyflightstream FSI convergence log (FSI-R09, FSI-R15)\n"
80
+ "# quasi-steady model: azimuthal (1P) content is trustworthy only where\n"
81
+ "# n Omega / omega_n stays at or below about 0.3 (DLV-007 Section 4.1)\n"
82
+ "call,step,phase,revolutions,solver_iteration,total_normal_force_n,"
83
+ "tip_flap_m,tip_twist_deg,inner_solves,relaxation,config_hash\n"
84
+ )
85
+
86
+
87
+ class StaleLoadsError(ValueError):
88
+ """The loads file did not advance between calls (FSI-R12).
89
+
90
+ A call receiving the same solver iteration as the previous one is
91
+ a second FSI iteration inside one time step: the Toolbox is not
92
+ configured with ``SET_AEROELASTIC_ITERATIONS 1``, and continuing
93
+ would average duplicated loads and desynchronize the call and step
94
+ counters.
95
+ """
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class StepResult:
100
+ """Summary of one executed coupling call.
101
+
102
+ Attributes
103
+ ----------
104
+ call, step : int
105
+ Counters after the call (equal while iterations stay 1).
106
+ phase : int or str
107
+ Executed phase (1 to 4) or ``"frozen"``.
108
+ revolutions : float or None
109
+ Rotor revolutions completed at this step; None in frozen mode.
110
+ relaxation : float or None
111
+ Relaxation factor applied to the displacement update; None in
112
+ phase 1 (zeros are written unconditionally) and frozen mode.
113
+ displacements : numpy.ndarray
114
+ The FSIDisp rows written, shape ``(total_nodes, 3)`` [m].
115
+ solutions : tuple of beam.StaticBeamSolution or None
116
+ Per-blade beam solutions of this call; None when no solve ran
117
+ (phase 1 and frozen mode).
118
+ """
119
+
120
+ call: int
121
+ step: int
122
+ phase: int | str
123
+ revolutions: float | None
124
+ relaxation: float | None
125
+ displacements: np.ndarray
126
+ solutions: tuple[beam.StaticBeamSolution, ...] | None
127
+
128
+
129
+ def revolutions_per_step(omega_rad_per_s: float, time_increment_s: float) -> float:
130
+ """Rotor revolutions swept by one unsteady time step.
131
+
132
+ Omega dt / (2 pi): constant-speed rotation kinematics, the
133
+ conversion the phase schedule of DLV-007 Section 4.5 is keyed on.
134
+ Omega comes from the configuration and dt from the loads file of
135
+ the run itself, so a config/run mismatch shows up as a wrong phase
136
+ schedule instead of hiding.
137
+
138
+ Source: DLV-007 Section 4.5 (phase schedule in revolutions);
139
+ elementary kinematics of rotation at constant angular speed.
140
+ """
141
+ if omega_rad_per_s <= 0.0 or time_increment_s <= 0.0:
142
+ raise ValueError(
143
+ "revolutions need a spinning rotor and an advancing clock: got "
144
+ f"Omega {omega_rad_per_s} rad/s and dt {time_increment_s} s; the "
145
+ "coupled unsteady driver schedules its phases in revolutions "
146
+ "(DLV-007 Section 4.5)"
147
+ )
148
+ return omega_rad_per_s * time_increment_s / (2.0 * math.pi)
149
+
150
+
151
+ def relax_displacements(
152
+ previous: np.ndarray, computed: np.ndarray, relaxation: float
153
+ ) -> np.ndarray:
154
+ """Relaxed displacement update d_new = d_old + lambda (d_calc - d_old).
155
+
156
+ Under-relaxation (lambda below 1) damps the aeroelastic feedback
157
+ of the averaged coupling phases; lambda = 1 returns the computed
158
+ displacements unchanged, the phase 4 behavior (FSI-R07).
159
+
160
+ Source: DLV-007 Section 4.5 (relaxation and phases).
161
+ """
162
+ previous = np.asarray(previous, dtype=float)
163
+ computed = np.asarray(computed, dtype=float)
164
+ return previous + relaxation * (computed - previous)
165
+
166
+
167
+ def _blade_densities(
168
+ ea_loads: ElasticAxisLoads, station_radii_m: list[float]
169
+ ) -> tuple[list[float], list[float]]:
170
+ """Interpolate one blade's load densities at the config stations.
171
+
172
+ The export rows already are line densities (RPT-006), so this is
173
+ pure resampling; constant extrapolation covers the small root and
174
+ tip margins the section distribution does not reach.
175
+ """
176
+ order = np.argsort(ea_loads.radius_m)
177
+ radii = ea_loads.radius_m[order]
178
+ flap = np.interp(station_radii_m, radii, ea_loads.flap_load_n_per_m[order])
179
+ torsion = np.interp(station_radii_m, radii, ea_loads.torsion_moment_nm_per_m[order])
180
+ return flap.tolist(), torsion.tolist()
181
+
182
+
183
+ def _schedule_phase(cfg: FsiConfig, state: FsiState, revolutions: float) -> int:
184
+ """Phase of the current call from the schedule and the state."""
185
+ if state.phase == 4:
186
+ return 4
187
+ schedule = cfg.phases
188
+ if revolutions < schedule.wake_development_revolutions:
189
+ return 1
190
+ if revolutions < schedule.wake_development_revolutions + schedule.averaging_window_revolutions:
191
+ return 2
192
+ return 3
193
+
194
+
195
+ def _averaged_history(history: list[LoadSample]) -> tuple[np.ndarray, np.ndarray]:
196
+ """Mean flap and torsion densities over the buffered samples."""
197
+ flap = np.mean([sample.flap_n_per_m for sample in history], axis=0)
198
+ torsion = np.mean([sample.torsion_nm_per_m for sample in history], axis=0)
199
+ return flap, torsion
200
+
201
+
202
+ def _append_log(run_dir: Path, row: dict[str, object]) -> None:
203
+ """Append one convergence-log row, writing the header on first use."""
204
+ path = run_dir / LOG_FILE
205
+ line = (
206
+ f"{row['call']},{row['step']},{row['phase']},{row['revolutions']},"
207
+ f"{row['solver_iteration']},{row['total_normal_force_n']},"
208
+ f"{row['tip_flap_m']},{row['tip_twist_deg']},{row['inner_solves']},"
209
+ f"{row['relaxation']},{row['config_hash']}\n"
210
+ )
211
+ if not path.is_file():
212
+ path.write_text(_LOG_HEADER + line, encoding="utf-8")
213
+ else:
214
+ with path.open("a", encoding="utf-8") as handle:
215
+ handle.write(line)
216
+
217
+
218
+ def _verified_layout(cfg: FsiConfig, run_dir: Path) -> nodes.NodeOrderingMap:
219
+ """Regenerate the node layout and hold the staged map to it (FSI-R14).
220
+
221
+ The layout is always regenerated from the configuration (the
222
+ single source); a serialized map in the run folder must agree with
223
+ it, and a missing one is written so downstream consumers read the
224
+ same bookkeeping.
225
+ """
226
+ layout = nodes.generate_node_layout(cfg)
227
+ map_path = run_dir / cfg.node_map_file
228
+ if map_path.is_file():
229
+ staged = nodes.load_node_map(map_path)
230
+ if staged != layout:
231
+ raise ValueError(
232
+ f"the staged node map {map_path.name} disagrees with the layout "
233
+ "generated from config.json; the imported node file and the "
234
+ "FSIDisp ordering would desynchronize, which is exactly the "
235
+ "corruption FSI-R14 forbids. Regenerate the run folder from one "
236
+ "configuration."
237
+ )
238
+ else:
239
+ nodes.write_node_map(layout, map_path)
240
+ return layout
241
+
242
+
243
+ def _frozen_step(run_dir: Path, cfg: FsiConfig, state: FsiState) -> StepResult:
244
+ """Replay the stored deformation without coupling (FSI-R10)."""
245
+ layout = _verified_layout(cfg, run_dir)
246
+ translations = nodes.read_fsidisp(run_dir / FROZEN_FILE, expected_rows=layout.total_nodes)
247
+ nodes.write_fsidisp(run_dir / DISPLACEMENT_FILE, translations)
248
+ state.call_count += 1
249
+ state.step_count += 1
250
+ state.previous_displacements = translations.tolist()
251
+ _append_log(
252
+ run_dir,
253
+ {
254
+ "call": state.call_count,
255
+ "step": state.step_count,
256
+ "phase": "frozen",
257
+ "revolutions": "",
258
+ "solver_iteration": "",
259
+ "total_normal_force_n": "",
260
+ "tip_flap_m": "",
261
+ "tip_twist_deg": "",
262
+ "inner_solves": "",
263
+ "relaxation": "",
264
+ "config_hash": config_hash(cfg),
265
+ },
266
+ )
267
+ write_state_atomic(state, run_dir / STATE_FILE)
268
+ logger.info("frozen replay: call %d wrote the stored deformation", state.call_count)
269
+ return StepResult(
270
+ call=state.call_count,
271
+ step=state.step_count,
272
+ phase="frozen",
273
+ revolutions=None,
274
+ relaxation=None,
275
+ displacements=translations,
276
+ solutions=None,
277
+ )
278
+
279
+
280
+ def coupling_step(run_dir: str | Path) -> StepResult:
281
+ """Execute one coupling call inside a run folder.
282
+
283
+ Reads ``config.json``, ``state.json`` (or starts fresh),
284
+ ``fsi_family_map.json``, and the loads export; writes
285
+ ``FSIDisp.txt``, the convergence log row, and the atomically
286
+ updated ``state.json``. With a ``fsi_frozen_displacements.txt``
287
+ present the call replays it instead (FSI-R10).
288
+
289
+ Parameters
290
+ ----------
291
+ run_dir : str or Path
292
+ Working directory set by ``SET_AEROELASTIC_WORKING_DIRECTORY``.
293
+
294
+ Returns
295
+ -------
296
+ StepResult
297
+ Summary of the executed call.
298
+ """
299
+ run_dir = Path(run_dir)
300
+ cfg = load_config(run_dir / CONFIG_FILE)
301
+ state_path = run_dir / STATE_FILE
302
+ state = load_state(state_path) if state_path.is_file() else initial_state()
303
+
304
+ if (run_dir / FROZEN_FILE).is_file():
305
+ return _frozen_step(run_dir, cfg, state)
306
+
307
+ layout = _verified_layout(cfg, run_dir)
308
+ report = parse_sectional_loads((run_dir / LOADS_FILE).read_text(encoding="utf-8"))
309
+ if state.last_solver_iteration is not None and (
310
+ report.current_iteration <= state.last_solver_iteration
311
+ ):
312
+ raise StaleLoadsError(
313
+ f"call {state.call_count + 1} received solver iteration "
314
+ f"{report.current_iteration}, not ahead of the previous "
315
+ f"{state.last_solver_iteration}: FlightStream is running more than "
316
+ "one FSI iteration per time step; SET_AEROELASTIC_ITERATIONS must "
317
+ "stay 1 (FSI-R12)"
318
+ )
319
+ if report.time_increment_s is None:
320
+ raise ValueError(
321
+ "the loads export carries no time increment, so it comes from a "
322
+ "steady solve; the coupled driver runs inside the unsteady solver "
323
+ "(SET_AEROELASTIC_COUPLING_IN_UNSTEADY, RPT-005)"
324
+ )
325
+ # The header prints the increment with three decimals only (RPT-006),
326
+ # so a configured dt drives the revolution bookkeeping and the printed
327
+ # value is held to it at print precision.
328
+ time_increment_s = report.time_increment_s
329
+ if cfg.time_increment_s is not None:
330
+ if abs(cfg.time_increment_s - report.time_increment_s) > 5.0e-4:
331
+ raise ValueError(
332
+ f"the loads export prints a time increment of "
333
+ f"{report.time_increment_s} s but the configuration declares "
334
+ f"{cfg.time_increment_s} s; beyond the header's three-decimal "
335
+ "print precision this is a different run than the "
336
+ "configuration describes (RPT-006)"
337
+ )
338
+ time_increment_s = cfg.time_increment_s
339
+ state.call_count += 1
340
+ state.step_count += 1
341
+ state.last_solver_iteration = report.current_iteration
342
+
343
+ family_map = SectionFamilyMap.model_validate_json(
344
+ (run_dir / FAMILY_MAP_FILE).read_text(encoding="utf-8")
345
+ )
346
+ blade_families = [family.name for family in family_map.families if family.is_blade]
347
+ if len(blade_families) != cfg.blade_count:
348
+ raise ValueError(
349
+ f"the family map marks {len(blade_families)} blade families "
350
+ f"({blade_families}) but the configuration expects {cfg.blade_count} "
351
+ "blades; attribution is single-sourced in the map (RPT-005 finding 6)"
352
+ )
353
+ blocks = report.split(family_map)
354
+
355
+ rev_per_step = revolutions_per_step(cfg.omega_rad_per_s, time_increment_s)
356
+ steps_per_rev = 1.0 / rev_per_step
357
+ revolutions = state.step_count * rev_per_step
358
+ phase = _schedule_phase(cfg, state, revolutions)
359
+
360
+ stations = cfg.blade.station_radii_m
361
+ flap_per_blade, torsion_per_blade = [], []
362
+ total_normal_force = 0.0
363
+ for name in blade_families:
364
+ ea_loads = to_elastic_axis(blocks[name], cfg)
365
+ flap, torsion = _blade_densities(ea_loads, stations)
366
+ flap_per_blade.append(flap)
367
+ torsion_per_blade.append(torsion)
368
+ total_normal_force += float(
369
+ (ea_loads.force_normal_n_per_m * ea_loads.tributary_width_m).sum()
370
+ )
371
+ state.load_history.append(
372
+ LoadSample(
373
+ step=state.step_count,
374
+ flap_n_per_m=flap_per_blade,
375
+ torsion_nm_per_m=torsion_per_blade,
376
+ )
377
+ )
378
+ window_steps = max(1, math.ceil(cfg.phases.averaging_window_revolutions * steps_per_rev))
379
+ state.load_history = state.load_history[-window_steps:]
380
+
381
+ zeros = np.zeros((layout.total_nodes, 3))
382
+ le = np.asarray(layout.le_offset_m)
383
+ te = np.asarray(layout.te_offset_m)
384
+ if phase == 1:
385
+ relaxation = None
386
+ solutions: tuple[beam.StaticBeamSolution, ...] | None = None
387
+ written = zeros
388
+ inner_solves = 0
389
+ else:
390
+ if phase == 4:
391
+ relaxation = 1.0
392
+ flap_solve = np.asarray(flap_per_blade)
393
+ torsion_solve = np.asarray(torsion_per_blade)
394
+ else:
395
+ relaxation = cfg.phases.coupling_relaxation
396
+ flap_solve, torsion_solve = _averaged_history(state.load_history)
397
+ solved = [
398
+ centrifugal.solve_rotating_static(
399
+ cfg,
400
+ flap_load_n_per_m=list(flap_solve[i]),
401
+ torsion_moment_n_m_per_m=list(torsion_solve[i]),
402
+ )
403
+ for i in range(cfg.blade_count)
404
+ ]
405
+ solutions = tuple(result.solution for result in solved)
406
+ computed = nodes.flatten_blade_translations(
407
+ layout,
408
+ [
409
+ kinematics.encode_station_translations(
410
+ np.asarray(sol.flap_deflection_m),
411
+ np.asarray(sol.elastic_twist_rad),
412
+ le,
413
+ te,
414
+ )
415
+ for sol in solutions
416
+ ],
417
+ )
418
+ previous = (
419
+ np.asarray(state.previous_displacements, dtype=float)
420
+ if state.previous_displacements is not None
421
+ else zeros
422
+ )
423
+ written = relax_displacements(previous, computed, relaxation)
424
+ state.previous_twist_rad = [list(sol.elastic_twist_rad) for sol in solutions]
425
+ inner_solves = max(result.inner_solves for result in solved)
426
+ nodes.write_fsidisp(run_dir / DISPLACEMENT_FILE, written)
427
+ state.previous_displacements = written.tolist()
428
+
429
+ tip_twist_deg = [math.degrees(sol.elastic_twist_rad[-1]) for sol in (solutions or ())] or [
430
+ 0.0
431
+ ] * cfg.blade_count
432
+ tip_flap_m = [sol.flap_deflection_m[-1] for sol in (solutions or ())] or [0.0] * cfg.blade_count
433
+
434
+ completed = math.floor(revolutions + 1e-9)
435
+ if completed > state.completed_revolutions:
436
+ state.revolution_history.append(
437
+ RevolutionSample(
438
+ revolution=completed, tip_twist_deg=tip_twist_deg, tip_flap_m=tip_flap_m
439
+ )
440
+ )
441
+ if phase == 3 and len(state.revolution_history) >= 2:
442
+ last, previous_rev = state.revolution_history[-1], state.revolution_history[-2]
443
+ change = max(
444
+ abs(a - b)
445
+ for a, b in zip(last.tip_twist_deg, previous_rev.tip_twist_deg, strict=True)
446
+ )
447
+ if change < cfg.phases.tip_twist_tolerance_deg:
448
+ state.phase = 4
449
+ state.phase4_start_step = state.step_count + 1
450
+ logger.info(
451
+ "convergence declared at revolution %d (tip twist change "
452
+ "%.4f deg < %.4f deg); phase 4 recording starts next step",
453
+ completed,
454
+ change,
455
+ cfg.phases.tip_twist_tolerance_deg,
456
+ )
457
+ if phase != 4 and state.phase != 4:
458
+ state.phase = phase
459
+ if phase == 4 and state.phase4_start_step is not None:
460
+ recording_steps = math.ceil(cfg.phases.recording_revolutions * steps_per_rev)
461
+ if state.step_count - state.phase4_start_step < recording_steps:
462
+ state.recorded_twist.append(
463
+ RecordedTwist(
464
+ step=state.step_count,
465
+ elastic_twist_rad=[list(sol.elastic_twist_rad) for sol in solutions],
466
+ )
467
+ )
468
+
469
+ _append_log(
470
+ run_dir,
471
+ {
472
+ "call": state.call_count,
473
+ "step": state.step_count,
474
+ "phase": phase,
475
+ "revolutions": f"{revolutions:.6f}",
476
+ "solver_iteration": report.current_iteration,
477
+ "total_normal_force_n": f"{total_normal_force:.6f}",
478
+ "tip_flap_m": f"{max(abs(v) for v in tip_flap_m):.6e}",
479
+ "tip_twist_deg": f"{max(abs(v) for v in tip_twist_deg):.6e}",
480
+ "inner_solves": inner_solves,
481
+ "relaxation": "" if relaxation is None else f"{relaxation:.3f}",
482
+ "config_hash": config_hash(cfg),
483
+ },
484
+ )
485
+ write_state_atomic(state, run_dir / STATE_FILE)
486
+ logger.info(
487
+ "coupling call %d (step %d, phase %s, %.3f rev) written",
488
+ state.call_count,
489
+ state.step_count,
490
+ phase,
491
+ revolutions,
492
+ )
493
+ return StepResult(
494
+ call=state.call_count,
495
+ step=state.step_count,
496
+ phase=phase,
497
+ revolutions=revolutions,
498
+ relaxation=relaxation,
499
+ displacements=written,
500
+ solutions=solutions,
501
+ )
@@ -0,0 +1,153 @@
1
+ """Rigid-section kinematics: (w, theta) to node translations and back (WP5).
2
+
3
+ Pipeline role: ``FSIDisp.txt`` carries translations only, no
4
+ rotational degrees of freedom (DLV-007 Section 3), so the elastic
5
+ twist must be encoded geometrically: three structural nodes per radial
6
+ station, one on the elastic axis and one offset toward each of the
7
+ leading and trailing edges. The beam solution (w, theta) maps to nodal
8
+ translations by rigid section kinematics, and FlightStream
9
+ interpolates the surface deflection from them, so the twist field
10
+ emerges from differential translations (DLV-007 Section 4.4).
11
+
12
+ Frames: everything lives in the rotating blade frame of
13
+ :mod:`pyflightstream.fsi.config`: X chordwise toward the leading edge,
14
+ Y normal (the flap direction), Z spanwise from root to tip. The flap
15
+ deflection w translates a section along +Y; the elastic twist theta is
16
+ positive nose up about +Z, so a node at chordwise offset d from the
17
+ elastic axis translates by theta d along +Y (linearized rotation,
18
+ consistent with the quasi-steady small-deformation model). The
19
+ encoding is exactly linear by design: translations are w + theta d
20
+ along the normal axis and nothing else, so the inverse map is exact
21
+ and the round trip closes at machine precision.
22
+
23
+ The node ordering that turns per-station translations into the flat
24
+ ``FSIDisp.txt`` rows is owned by :mod:`pyflightstream.fsi.nodes`
25
+ (FSI-R14); this module only maps solutions to per-station node
26
+ translations and back.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import numpy as np
32
+
33
+ #: Per-station node roles, in the encoding order used everywhere.
34
+ NODE_ROLES = ("elastic_axis", "leading_edge", "trailing_edge")
35
+
36
+
37
+ def station_normal_translation(
38
+ flap_deflection_m: np.ndarray,
39
+ elastic_twist_rad: np.ndarray,
40
+ chordwise_offset_m: np.ndarray,
41
+ ) -> np.ndarray:
42
+ """Return the normal translation of a node offset chordwise from the EA.
43
+
44
+ dy = w + theta d: the linearized rigid-section displacement along
45
+ the blade-frame normal axis of a node at chordwise offset d from
46
+ the elastic axis, under flap deflection w and elastic twist theta
47
+ (positive nose up about the spanwise axis, so a leading-edge node,
48
+ d > 0, moves up under positive twist). Inputs broadcast; lengths
49
+ in m, angles in rad.
50
+
51
+ Source: DLV-007 Section 4.4 (twist encoded as differential
52
+ translations, EA node receives w, offset nodes w plus theta cross
53
+ d).
54
+ """
55
+ return np.asarray(flap_deflection_m, dtype=float) + np.asarray(
56
+ elastic_twist_rad, dtype=float
57
+ ) * np.asarray(chordwise_offset_m, dtype=float)
58
+
59
+
60
+ def twist_from_node_translations(
61
+ normal_translation_le_m: np.ndarray,
62
+ normal_translation_te_m: np.ndarray,
63
+ le_offset_m: np.ndarray,
64
+ te_offset_m: np.ndarray,
65
+ ) -> np.ndarray:
66
+ """Elastic twist reconstructed from the offset-node translations.
67
+
68
+ theta = (dy_LE - dy_TE) / (d_LE - d_TE): the exact inverse of the
69
+ linear encoding of :func:`station_normal_translation`, independent
70
+ of the flap deflection, which cancels in the difference. Inputs
71
+ broadcast; the two chordwise offsets must differ.
72
+
73
+ Source: DLV-007 Section 4.4 (inverse of the differential
74
+ translation encoding).
75
+ """
76
+ return (
77
+ np.asarray(normal_translation_le_m, dtype=float)
78
+ - np.asarray(normal_translation_te_m, dtype=float)
79
+ ) / (np.asarray(le_offset_m, dtype=float) - np.asarray(te_offset_m, dtype=float))
80
+
81
+
82
+ def encode_station_translations(
83
+ flap_deflection_m: np.ndarray,
84
+ elastic_twist_rad: np.ndarray,
85
+ le_offset_m: np.ndarray,
86
+ te_offset_m: np.ndarray,
87
+ ) -> np.ndarray:
88
+ """Encode a beam solution as per-station three-node translations.
89
+
90
+ Parameters
91
+ ----------
92
+ flap_deflection_m : numpy.ndarray
93
+ Flap deflection w per station [m], positive +Y.
94
+ elastic_twist_rad : numpy.ndarray
95
+ Elastic twist theta per station [rad], positive nose up.
96
+ le_offset_m, te_offset_m : numpy.ndarray
97
+ Chordwise offsets [m] of the leading-edge and trailing-edge
98
+ nodes from the elastic axis (leading edge positive, trailing
99
+ edge negative).
100
+
101
+ Returns
102
+ -------
103
+ numpy.ndarray
104
+ Translations, shape ``(n_stations, 3, 3)``: stations by node
105
+ role (:data:`NODE_ROLES` order) by (dx, dy, dz) in the blade
106
+ frame. Only dy is nonzero under the linear encoding.
107
+ """
108
+ w = np.asarray(flap_deflection_m, dtype=float)
109
+ theta = np.asarray(elastic_twist_rad, dtype=float)
110
+ translations = np.zeros((len(w), len(NODE_ROLES), 3))
111
+ translations[:, 0, 1] = w
112
+ translations[:, 1, 1] = station_normal_translation(w, theta, le_offset_m)
113
+ translations[:, 2, 1] = station_normal_translation(w, theta, te_offset_m)
114
+ return translations
115
+
116
+
117
+ def decode_station_translations(
118
+ translations: np.ndarray,
119
+ le_offset_m: np.ndarray,
120
+ te_offset_m: np.ndarray,
121
+ ) -> tuple[np.ndarray, np.ndarray]:
122
+ """Reconstruct (w, theta) from per-station three-node translations.
123
+
124
+ The exact inverse of :func:`encode_station_translations`: the flap
125
+ deflection is the elastic-axis node's normal translation and the
126
+ twist comes from the offset-node difference, so an
127
+ encode-decode round trip closes at machine precision (the WP5
128
+ verification).
129
+
130
+ Parameters
131
+ ----------
132
+ translations : numpy.ndarray
133
+ Shape ``(n_stations, 3, 3)`` as produced by the encoder.
134
+ le_offset_m, te_offset_m : numpy.ndarray
135
+ The chordwise node offsets [m] the encoding used.
136
+
137
+ Returns
138
+ -------
139
+ tuple of numpy.ndarray
140
+ ``(flap_deflection_m, elastic_twist_rad)`` per station.
141
+ """
142
+ translations = np.asarray(translations, dtype=float)
143
+ if translations.ndim != 3 or translations.shape[1:] != (len(NODE_ROLES), 3):
144
+ raise ValueError(
145
+ f"station translations must have shape (n_stations, {len(NODE_ROLES)}, 3), "
146
+ f"got {translations.shape}; rows are stations, then the node roles "
147
+ f"{NODE_ROLES}, then (dx, dy, dz)"
148
+ )
149
+ flap = translations[:, 0, 1]
150
+ twist = twist_from_node_translations(
151
+ translations[:, 1, 1], translations[:, 2, 1], le_offset_m, te_offset_m
152
+ )
153
+ return flap, twist