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,1405 @@
1
+ """The probe specification catalog, one entry per database command.
2
+
3
+ Pipeline role: encodes how each FlightStream command is probed (what
4
+ minimal session state it needs, how it is emitted with distinctive
5
+ values, and which observable effect proves it acted). Instruments and
6
+ tokens are pinned from real 26.120 runs (reports/compat, HND-011
7
+ recon): the settings sheet header of EXPORT_PROBE_POINTS reflects the
8
+ solver settings even before initialization; OUTPUT_SETTINGS_AND_STATUS
9
+ dumps the fluid state always and the solver state once initialized;
10
+ object names (coordinate systems, actuators) survive as readable text
11
+ in a SAVEAS file, while numeric fields do not; OPEN, INITIALIZE_SOLVER
12
+ and START_SOLVER print distinctive log messages.
13
+
14
+ Assertion strictness follows the evidence rules: strict assertions
15
+ (absence is ``broken``) only where the instrument is recon-proven to
16
+ expose the state; everywhere else the assertion returns None and the
17
+ command lands ``unprobed``, because a probe may never guess. Three
18
+ commands carry no specification yet: SET_PROP_ACTUATOR_PROFILE and the
19
+ two FSI commands need input-file fixtures whose format awaits a manual
20
+ pass.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from collections.abc import Callable
27
+ from pathlib import Path
28
+
29
+ from pyflightstream.qa.probes import (
30
+ ProbeArtifacts,
31
+ ProbeSpec,
32
+ Requires,
33
+ dump_gained,
34
+ emit_solver_setup,
35
+ file_effect,
36
+ printed_line,
37
+ region_printed,
38
+ )
39
+ from pyflightstream.script import Script
40
+ from pyflightstream.script.helpers import initialize_solver
41
+
42
+ _NESTED_NAME = "nested_probe.txt"
43
+ _SHEET = "sheet.txt"
44
+
45
+
46
+ # --- shared instruments -------------------------------------------------
47
+
48
+
49
+ def _sheet(script: Script, workdir: Path) -> None:
50
+ """Export the settings sheet, the universal state instrument."""
51
+ script.emit("EXPORT_PROBE_POINTS", workdir / _SHEET)
52
+
53
+
54
+ def _saveas(script: Script, workdir: Path) -> None:
55
+ """Save the simulation for the name-greppability instrument."""
56
+ script.emit("SAVEAS", workdir / "saved.fsm")
57
+
58
+
59
+ def _seq(*parts: Callable[[Script, Path], None]) -> Callable[[Script, Path], None]:
60
+ """Chain build callables into one."""
61
+
62
+ def build(script: Script, workdir: Path) -> None:
63
+ for part in parts:
64
+ part(script, workdir)
65
+
66
+ return build
67
+
68
+
69
+ def _read(workdir: Path, name: str) -> str | None:
70
+ path = workdir / name
71
+ if not path.is_file():
72
+ return None
73
+ return path.read_text(encoding="utf-8", errors="replace").replace("\x00", "")
74
+
75
+
76
+ def sheet_matches(pattern: str, strict: bool = True) -> Callable[[ProbeArtifacts], bool | None]:
77
+ """Effect: the settings sheet matches ``pattern`` (regex).
78
+
79
+ Strict only for labels the recon proved the sheet exposes; a
80
+ missing sheet always returns None (the instrument, not the target,
81
+ failed).
82
+ """
83
+
84
+ def check(artifacts: ProbeArtifacts) -> bool | None:
85
+ sheet = _read(artifacts.workdir, _SHEET)
86
+ if sheet is None:
87
+ return None
88
+ if re.search(pattern, sheet):
89
+ return True
90
+ return False if strict else None
91
+
92
+ return check
93
+
94
+
95
+ def fsm_grep(token: str, expect: bool = True) -> Callable[[ProbeArtifacts], bool | None]:
96
+ """Effect: a readable name is present (or absent) in the saved file.
97
+
98
+ Object names survive as text in a SAVEAS file (recon-proven);
99
+ numeric fields do not. A missing saved file returns None.
100
+ """
101
+
102
+ def check(artifacts: ProbeArtifacts) -> bool | None:
103
+ path = artifacts.workdir / "saved.fsm"
104
+ if not path.is_file():
105
+ return None
106
+ present = token.encode("ascii") in path.read_bytes()
107
+ return present is expect
108
+
109
+ return check
110
+
111
+
112
+ def _unobservable(artifacts: ProbeArtifacts) -> None:
113
+ """Effect placeholder: the state is not observable yet (unprobed)."""
114
+ return None
115
+
116
+
117
+ def region_printed_lax(marker: str) -> Callable[[ProbeArtifacts], bool | None]:
118
+ """Effect: a log message in the target region, None when silent."""
119
+
120
+ def check(artifacts: ProbeArtifacts) -> bool | None:
121
+ return True if printed_line(artifacts.target_region(), marker) else None
122
+
123
+ return check
124
+
125
+
126
+ def _log_printed(marker: str) -> Callable[[ProbeArtifacts], bool | None]:
127
+ """Effect: a message in the log exported after the epilogue.
128
+
129
+ None when that log never appeared (the epilogue aborted, so the
130
+ instrument, not the target, failed).
131
+ """
132
+
133
+ def check(artifacts: ProbeArtifacts) -> bool | None:
134
+ final = artifacts.log_final()
135
+ if final is None:
136
+ return None
137
+ return printed_line(final, marker)
138
+
139
+ return check
140
+
141
+
142
+ def _file_lax(name: str, minimum_bytes: int = 1) -> Callable[[ProbeArtifacts], bool | None]:
143
+ """Effect: an epilogue-produced file has content, None when absent.
144
+
145
+ Lax because the file is written by a support command, not by the
146
+ target: absence may mean the instrument broke, never proof of a
147
+ target no-op.
148
+ """
149
+
150
+ def check(artifacts: ProbeArtifacts) -> bool | None:
151
+ path = artifacts.workdir / name
152
+ if path.is_file() and path.stat().st_size >= minimum_bytes:
153
+ return True
154
+ return None
155
+
156
+ return check
157
+
158
+
159
+ def _emit(command: str, *args: object, **kwargs: object) -> Callable[[Script, Path], None]:
160
+ """Make a build callable that emits one fixed command."""
161
+
162
+ def build(script: Script, workdir: Path) -> None:
163
+ script.emit(command, *args, **kwargs)
164
+
165
+ return build
166
+
167
+
168
+ def _named_frame(name: str) -> Callable[[Script, Path], None]:
169
+ """Create local frame 2 and give it a greppable name."""
170
+
171
+ def build(script: Script, workdir: Path) -> None:
172
+ script.emit("CREATE_NEW_COORDINATE_SYSTEM")
173
+ script.emit(
174
+ "EDIT_COORDINATE_SYSTEM",
175
+ frame=2,
176
+ name=name,
177
+ origin_x=0.1,
178
+ origin_y=0.0,
179
+ origin_z=0.0,
180
+ vector_x_x=1.0,
181
+ vector_x_y=0.0,
182
+ vector_x_z=0.0,
183
+ vector_y_x=0.0,
184
+ vector_y_y=1.0,
185
+ vector_y_z=0.0,
186
+ vector_z_x=0.0,
187
+ vector_z_y=0.0,
188
+ vector_z_z=1.0,
189
+ )
190
+
191
+ return build
192
+
193
+
194
+ PROBE_SPECS: dict[str, ProbeSpec] = {}
195
+
196
+
197
+ def _spec(**kwargs: object) -> None:
198
+ spec = ProbeSpec(**kwargs)
199
+ PROBE_SPECS[spec.command] = spec
200
+
201
+
202
+ # --- script controls (SRC-003 p.281), the pilot family -----------------
203
+
204
+
205
+ def _run_script_target(script: Script, workdir: Path) -> None:
206
+ # The nested file is fixed probe support data (a single PRINT), not
207
+ # emitted through a builder: it must exist on disk before the run.
208
+ nested = workdir / _NESTED_NAME
209
+ nested.write_text(
210
+ "# nested script for the RUN_SCRIPT probe\nPRINT PYFS_EFFECT_NESTED\n",
211
+ encoding="utf-8",
212
+ )
213
+ script.emit("RUN_SCRIPT", nested)
214
+
215
+
216
+ _spec(
217
+ command="PRINT",
218
+ build_target=_emit("PRINT", "PYFS_EFFECT_PRINT"),
219
+ assert_effect=region_printed("PYFS_EFFECT_PRINT"),
220
+ effect_note="the probe message PYFS_EFFECT_PRINT appears as a log line of its own",
221
+ )
222
+ _spec(
223
+ command="STOP",
224
+ build_target=_emit("STOP"),
225
+ expects_halt=True,
226
+ effect_note="script processing halts at STOP",
227
+ timeout_s=60.0,
228
+ )
229
+ _spec(
230
+ command="RUN_SCRIPT",
231
+ build_target=_run_script_target,
232
+ assert_effect=lambda artifacts: printed_line(artifacts.target_region(), "PYFS_EFFECT_NESTED"),
233
+ effect_note=(
234
+ "the nested script's message PYFS_EFFECT_NESTED appears in the log, so the "
235
+ "called script really ran"
236
+ ),
237
+ )
238
+
239
+
240
+ # --- file io (SRC-003 pp.282-283) --------------------------------------
241
+
242
+
243
+ def _open_reopen_target(script: Script, workdir: Path) -> None:
244
+ script.emit("OPEN", workdir / "reopen.fsm")
245
+
246
+
247
+ def _new_sim_effect(artifacts: ProbeArtifacts) -> bool | None:
248
+ saved = artifacts.workdir / "after_new.fsm"
249
+ if not saved.is_file():
250
+ return None
251
+ return saved.stat().st_size < 100_000
252
+
253
+
254
+ _spec(
255
+ command="OPEN",
256
+ build_target=_open_reopen_target,
257
+ prelude=lambda script, workdir: script.emit("SAVEAS", workdir / "reopen.fsm"),
258
+ assert_effect=region_printed("Simulation file opened"),
259
+ effect_note=(
260
+ "the log confirms 'Simulation file opened' for a file the probe saved just before"
261
+ ),
262
+ )
263
+ _spec(
264
+ command="SAVEAS",
265
+ build_target=lambda script, workdir: script.emit("SAVEAS", workdir / "saveas_target.fsm"),
266
+ assert_effect=file_effect("saveas_target.fsm"),
267
+ effect_note="the simulation file the command names exists and is not empty",
268
+ )
269
+ _spec(
270
+ command="NEW_SIMULATION",
271
+ build_target=_emit("NEW_SIMULATION"),
272
+ requires=Requires.SIM,
273
+ epilogue=lambda script, workdir: script.emit("SAVEAS", workdir / "after_new.fsm"),
274
+ assert_effect=_new_sim_effect,
275
+ effect_note=(
276
+ "after NEW_SIMULATION on an opened 582 kB simulation, the session saved by the "
277
+ "epilogue is below 100 kB (the geometry is gone)"
278
+ ),
279
+ )
280
+ _spec(
281
+ command="CLOSE_FLIGHTSTREAM",
282
+ build_target=_emit("CLOSE_FLIGHTSTREAM"),
283
+ expects_halt=True,
284
+ effect_note="script processing ends at CLOSE_FLIGHTSTREAM and the solver exits",
285
+ timeout_s=60.0,
286
+ )
287
+ _spec(
288
+ command="EXPORT_LOG",
289
+ build_target=lambda script, workdir: script.emit("EXPORT_LOG", workdir / "target_log.txt"),
290
+ assert_effect=file_effect("target_log.txt"),
291
+ effect_note="the log file the command names exists and is not empty",
292
+ )
293
+ _spec(
294
+ command="OUTPUT_SETTINGS_AND_STATUS",
295
+ build_target=lambda script, workdir: script.emit(
296
+ "OUTPUT_SETTINGS_AND_STATUS", workdir / "target_dump.txt"
297
+ ),
298
+ assert_effect=file_effect("target_dump.txt"),
299
+ effect_note="the settings file the command names exists and is not empty",
300
+ )
301
+
302
+
303
+ # --- simulation controls (SRC-003 p.328) -------------------------------
304
+
305
+
306
+ def _units_epilogue(script: Script, workdir: Path) -> None:
307
+ emit_solver_setup(script)
308
+ initialize_solver(script)
309
+ script.emit("OUTPUT_SETTINGS_AND_STATUS", workdir / "dump_epilogue.txt")
310
+
311
+
312
+ def _units_effect(artifacts: ProbeArtifacts) -> bool | None:
313
+ dump = _read(artifacts.workdir, "dump_epilogue.txt")
314
+ if dump is None:
315
+ return None
316
+ return True if ",cm" in dump else None
317
+
318
+
319
+ _spec(
320
+ command="SET_SIMULATION_LENGTH_UNITS",
321
+ build_target=_emit("SET_SIMULATION_LENGTH_UNITS", "CENTIMETER"),
322
+ requires=Requires.SIM,
323
+ epilogue=_units_epilogue,
324
+ assert_effect=_units_effect,
325
+ effect_note="the initialized settings dump reports lengths in cm",
326
+ )
327
+
328
+
329
+ # --- coordinate systems (SRC-003 pp.329-331) ---------------------------
330
+
331
+ _spec(
332
+ command="CREATE_NEW_COORDINATE_SYSTEM",
333
+ build_target=_emit("CREATE_NEW_COORDINATE_SYSTEM"),
334
+ epilogue=_seq(_named_frame("PYFS_CREATED_FRAME"), _saveas),
335
+ assert_effect=fsm_grep("PYFS_CREATED_FRAME"),
336
+ effect_note=(
337
+ "the epilogue names the created frame and the name is readable in the saved "
338
+ "simulation file (via EDIT_COORDINATE_SYSTEM, whose own probe disambiguates)"
339
+ ),
340
+ )
341
+
342
+
343
+ _CREATE_FRAME_PRELUDE = _emit("CREATE_NEW_COORDINATE_SYSTEM")
344
+
345
+ _spec(
346
+ command="EDIT_COORDINATE_SYSTEM",
347
+ build_target=lambda script, workdir: script.emit(
348
+ "EDIT_COORDINATE_SYSTEM",
349
+ frame=2,
350
+ name="PYFS_EDITED_FRAME",
351
+ origin_x=0.1,
352
+ origin_y=0.0,
353
+ origin_z=0.0,
354
+ vector_x_x=1.0,
355
+ vector_x_y=0.0,
356
+ vector_x_z=0.0,
357
+ vector_y_x=0.0,
358
+ vector_y_y=1.0,
359
+ vector_y_z=0.0,
360
+ vector_z_x=0.0,
361
+ vector_z_y=0.0,
362
+ vector_z_z=1.0,
363
+ ),
364
+ prelude=_CREATE_FRAME_PRELUDE,
365
+ epilogue=_saveas,
366
+ assert_effect=fsm_grep("PYFS_EDITED_FRAME"),
367
+ effect_note="the frame name set by the command is readable in the saved simulation file",
368
+ )
369
+ _spec(
370
+ command="SET_COORDINATE_SYSTEM_ORIGIN",
371
+ build_target=_emit("SET_COORDINATE_SYSTEM_ORIGIN", 2, 0.5511, 0.1, 0.2, "METER"),
372
+ prelude=_CREATE_FRAME_PRELUDE,
373
+ assert_effect=_unobservable,
374
+ effect_note="frame origins are stored in binary form; no instrument observes them yet",
375
+ )
376
+ _spec(
377
+ command="SET_COORDINATE_SYSTEM_AXIS",
378
+ build_target=_emit("SET_COORDINATE_SYSTEM_AXIS", 2, "X", 1.0, 0.0, 0.0, "TRUE"),
379
+ prelude=_CREATE_FRAME_PRELUDE,
380
+ assert_effect=_unobservable,
381
+ effect_note="frame axes are stored in binary form; no instrument observes them yet",
382
+ )
383
+
384
+
385
+ # --- boundary conditions (SRC-003 pp.319-328) --------------------------
386
+
387
+ _spec(
388
+ command="AUTO_DETECT_TRAILING_EDGES",
389
+ build_target=_emit("AUTO_DETECT_TRAILING_EDGES"),
390
+ requires=Requires.SIM,
391
+ assert_effect=region_printed_lax("trailing edge"),
392
+ effect_note=(
393
+ "the command runs silently and INITIALIZE_SOLVER detects edges on its own; no "
394
+ "instrument separates the two yet"
395
+ ),
396
+ )
397
+ _spec(
398
+ command="SET_TRAILING_EDGE_TYPE",
399
+ build_target=_emit("SET_TRAILING_EDGE_TYPE", 1, "RELAXED"),
400
+ requires=Requires.SIM,
401
+ prelude=_emit("AUTO_DETECT_TRAILING_EDGES"),
402
+ assert_effect=_unobservable,
403
+ effect_note="trailing-edge types are not exposed by any instrument yet",
404
+ )
405
+ _spec(
406
+ command="DISABLE_WAKE_NODES_ON_TRAILING_EDGE",
407
+ build_target=_emit("DISABLE_WAKE_NODES_ON_TRAILING_EDGE", 1),
408
+ requires=Requires.SIM,
409
+ prelude=_emit("AUTO_DETECT_TRAILING_EDGES"),
410
+ assert_effect=_unobservable,
411
+ effect_note="wake-node states are not exposed by any instrument yet",
412
+ )
413
+ _spec(
414
+ command="AUTO_DETECT_WAKE_TERMINATION_NODES",
415
+ build_target=_emit("AUTO_DETECT_WAKE_TERMINATION_NODES"),
416
+ requires=Requires.SIM,
417
+ assert_effect=_unobservable,
418
+ effect_note="wake termination nodes are not exposed by any instrument yet",
419
+ )
420
+ _spec(
421
+ command="SET_FREESTREAM",
422
+ build_target=_emit("SET_FREESTREAM", "CONSTANT"),
423
+ requires=Requires.SIM,
424
+ assert_effect=_unobservable,
425
+ effect_note=(
426
+ "the CONSTANT form is the solver default and leaves no observable trace; the "
427
+ "ROTATION and CUSTOM forms await dedicated fixtures"
428
+ ),
429
+ )
430
+ _spec(
431
+ command="FLUID_PROPERTIES",
432
+ build_target=_emit(
433
+ "FLUID_PROPERTIES",
434
+ density=1.179,
435
+ pressure=98765.4,
436
+ temperature=291.55,
437
+ viscosity=0.0000185,
438
+ specific_heat_ratio=1.31,
439
+ ),
440
+ dump_state=True,
441
+ assert_effect=dump_gained("Density,1.179", strict=True),
442
+ effect_note="the settings dump reports the distinctive density 1.179 kg/m^3",
443
+ )
444
+ _spec(
445
+ command="AIR_ALTITUDE",
446
+ build_target=_emit("AIR_ALTITUDE", 5000.0, "METERS"),
447
+ dump_state=True,
448
+ assert_effect=dump_gained("Density,.736", strict=True),
449
+ effect_note=(
450
+ "the settings dump reports the 5000 m standard-atmosphere density "
451
+ "(0.736 kg/m^3); the first full sweep observed 1.056 kg/m^3 instead, which is "
452
+ "the 5000 ft standard state, so the METERS units argument reads ignored"
453
+ ),
454
+ )
455
+
456
+
457
+ # --- runtime settings (SRC-003 pp.339-343) -----------------------------
458
+
459
+
460
+ def _sheet_setter(
461
+ command: str,
462
+ value: object,
463
+ pattern: str,
464
+ note: str,
465
+ strict: bool = True,
466
+ ) -> None:
467
+ _spec(
468
+ command=command,
469
+ build_target=_emit(command, value),
470
+ requires=Requires.SIM,
471
+ epilogue=_sheet,
472
+ assert_effect=sheet_matches(pattern, strict=strict),
473
+ effect_note=note,
474
+ )
475
+
476
+
477
+ _sheet_setter(
478
+ "SOLVER_SET_AOA",
479
+ 7.253,
480
+ r"Angle of attack \(Deg\)\s+7\.253",
481
+ "the settings sheet reports the distinctive angle of attack 7.253 deg",
482
+ )
483
+ _sheet_setter(
484
+ "SOLVER_SET_SIDESLIP",
485
+ 3.414,
486
+ r"Side-slip angle \(Deg\)\s+3\.414",
487
+ "the settings sheet reports the distinctive side-slip 3.414 deg",
488
+ )
489
+ _sheet_setter(
490
+ "SOLVER_SET_VELOCITY",
491
+ 51.617,
492
+ r"Freestream velocity \(m/s\)\s+51\.617",
493
+ "the settings sheet reports the distinctive free-stream velocity 51.617 m/s",
494
+ )
495
+ _sheet_setter(
496
+ "SOLVER_SET_ITERATIONS",
497
+ 123,
498
+ r"Requested solver iterations\s+123\b",
499
+ "the settings sheet reports the distinctive iteration count 123",
500
+ )
501
+ _sheet_setter(
502
+ "SOLVER_SET_CONVERGENCE",
503
+ 0.000271828,
504
+ r"Solver convergence limit\s+2\.718E-04",
505
+ "the settings sheet reports the distinctive convergence limit 2.718E-04",
506
+ )
507
+ _sheet_setter(
508
+ "SOLVER_SET_FORCED_ITERATIONS",
509
+ "ENABLE",
510
+ r"Force solver to run all iterations\s+T\b",
511
+ "the settings sheet reports forced iterations as T",
512
+ )
513
+ _sheet_setter(
514
+ "SOLVER_SET_REF_VELOCITY",
515
+ 47.513,
516
+ r"Reference velocity \(m/s\)\s+47\.513",
517
+ "the settings sheet reports the distinctive reference velocity 47.513 m/s",
518
+ )
519
+ _sheet_setter(
520
+ "SOLVER_SET_REF_AREA",
521
+ 2.727,
522
+ r"Reference area \(m\^2\)\s+2\.727",
523
+ "the settings sheet reports the distinctive reference area 2.727 m^2",
524
+ )
525
+ _sheet_setter(
526
+ "SOLVER_SET_REF_LENGTH",
527
+ 3.131,
528
+ r"Reference length \(m\)\s+3\.131",
529
+ "the settings sheet reports the distinctive reference length 3.131 m",
530
+ )
531
+ _spec(
532
+ command="SOLVER_SET_MACH_NUMBER",
533
+ build_target=_emit("SOLVER_SET_MACH_NUMBER", 0.213),
534
+ requires=Requires.SOLVER,
535
+ dump_state=True,
536
+ assert_effect=dump_gained("Mach Number,.213", strict=True),
537
+ effect_note="the initialized settings dump reports the distinctive Mach number .213",
538
+ )
539
+ _spec(
540
+ command="SOLVER_SET_REF_MACH_NUMBER",
541
+ build_target=_emit("SOLVER_SET_REF_MACH_NUMBER", 0.157),
542
+ requires=Requires.SOLVER,
543
+ dump_state=True,
544
+ assert_effect=dump_gained("Reference Mach,.157", strict=True),
545
+ effect_note="the initialized settings dump reports the distinctive reference Mach .157",
546
+ )
547
+ _spec(
548
+ command="SET_MAX_PARALLEL_THREADS",
549
+ build_target=_emit("SET_MAX_PARALLEL_THREADS", 3),
550
+ requires=Requires.SIM,
551
+ assert_effect=_unobservable,
552
+ effect_note="the thread count is not exposed by any instrument yet",
553
+ )
554
+
555
+
556
+ # --- advanced settings (SRC-003 pp.344-345) ----------------------------
557
+
558
+ _spec(
559
+ command="SET_SOLVER_CONVERGENCE_ITERATIONS",
560
+ build_target=_emit("SET_SOLVER_CONVERGENCE_ITERATIONS", 7),
561
+ requires=Requires.SIM,
562
+ assert_effect=_unobservable,
563
+ effect_note="the convergence-iterations window is not exposed by any instrument yet",
564
+ )
565
+ _spec(
566
+ command="SOLVER_MINIMUM_CP",
567
+ build_target=_emit("SOLVER_MINIMUM_CP", -4.5),
568
+ requires=Requires.SIM,
569
+ assert_effect=_unobservable,
570
+ effect_note="the minimum-Cp floor is not exposed by any instrument yet",
571
+ )
572
+
573
+
574
+ # --- solver settings (SRC-003 pp.339-343) ------------------------------
575
+
576
+ _spec(
577
+ command="SET_SOLVER_STEADY",
578
+ build_target=_emit("SET_SOLVER_STEADY"),
579
+ requires=Requires.SIM,
580
+ prelude=_emit("SET_SOLVER_UNSTEADY", time_iterations=3, delta_time=0.0123),
581
+ epilogue=_sheet,
582
+ assert_effect=sheet_matches(r"Solver mode:\s+Steady", strict=True),
583
+ effect_note="the settings sheet reports Steady after the prelude set the unsteady mode",
584
+ )
585
+ _spec(
586
+ command="SET_SOLVER_UNSTEADY",
587
+ build_target=_emit("SET_SOLVER_UNSTEADY", time_iterations=7, delta_time=0.0123),
588
+ requires=Requires.SIM,
589
+ epilogue=_sheet,
590
+ assert_effect=sheet_matches(r"Time increment \(sec\)\s+\.012", strict=True),
591
+ effect_note="the settings sheet reports the distinctive time increment .012 s",
592
+ )
593
+ _spec(
594
+ command="SET_BOUNDARY_LAYER_TYPE",
595
+ build_target=_emit("SET_BOUNDARY_LAYER_TYPE", "TURBULENT"),
596
+ requires=Requires.SIM,
597
+ assert_effect=_unobservable,
598
+ effect_note="the boundary-layer model choice is not exposed by any instrument yet",
599
+ )
600
+ _spec(
601
+ command="SET_SOLVER_VISCOUS_COUPLING",
602
+ build_target=_emit("SET_SOLVER_VISCOUS_COUPLING", "ENABLE"),
603
+ requires=Requires.SIM,
604
+ assert_effect=_unobservable,
605
+ effect_note="the viscous-coupling toggle is not exposed by any instrument yet",
606
+ )
607
+ _spec(
608
+ command="SET_VISCOUS_EXCLUDED_BOUNDARIES",
609
+ build_target=_emit("SET_VISCOUS_EXCLUDED_BOUNDARIES", 1, [1]),
610
+ requires=Requires.SIM,
611
+ assert_effect=_unobservable,
612
+ effect_note="viscous exclusions are not exposed by any instrument yet",
613
+ )
614
+
615
+
616
+ # --- solver initialization (SRC-003 p.337) -----------------------------
617
+
618
+
619
+ def _initialize_target(script: Script, workdir: Path) -> None:
620
+ initialize_solver(script)
621
+
622
+
623
+ _spec(
624
+ command="INITIALIZE_SOLVER",
625
+ build_target=_initialize_target,
626
+ requires=Requires.SIM,
627
+ prelude=lambda script, workdir: emit_solver_setup(script),
628
+ assert_effect=region_printed("Solver initialized"),
629
+ effect_note="the log reports 'Solver initialized' with the mesh statistics",
630
+ )
631
+ _spec(
632
+ command="SOLVER_PROXIMAL_BOUNDARIES",
633
+ build_target=_emit("SOLVER_PROXIMAL_BOUNDARIES", 1, [1]),
634
+ requires=Requires.SIM,
635
+ assert_effect=_unobservable,
636
+ effect_note="proximal-boundary marking is not exposed by any instrument yet",
637
+ )
638
+ _spec(
639
+ command="REMOVE_INITIALIZATION",
640
+ build_target=_emit("REMOVE_INITIALIZATION"),
641
+ requires=Requires.SOLVER,
642
+ dump_state=True,
643
+ assert_effect=dump_gained("Not initialized", strict=True),
644
+ effect_note=("the settings dump flips from the initialized solver state to 'Not initialized'"),
645
+ )
646
+ _spec(
647
+ command="START_SOLVER",
648
+ build_target=_emit("START_SOLVER"),
649
+ requires=Requires.SOLVER,
650
+ assert_effect=region_printed("Solver run time"),
651
+ effect_note="the log carries the iteration table and 'Solver run time'",
652
+ )
653
+ _spec(
654
+ command="CLEAR_SOLUTION",
655
+ build_target=_emit("CLEAR_SOLUTION"),
656
+ requires=Requires.SOLUTION,
657
+ epilogue=_sheet,
658
+ assert_effect=sheet_matches(r"Current solver iteration number:\s+0\b", strict=False),
659
+ effect_note="the settings sheet reports the solver iteration counter back at 0",
660
+ )
661
+
662
+
663
+ # --- solver analysis (SRC-003 pp.350-351) ------------------------------
664
+
665
+
666
+ def _inviscid_effect(artifacts: ProbeArtifacts) -> bool | None:
667
+ loads = _read(artifacts.workdir, "loads_inviscid.txt")
668
+ if loads is None:
669
+ return None
670
+ for line in loads.splitlines():
671
+ fields = line.strip().split(",")
672
+ if len(fields) == 10 and fields[0] == "B":
673
+ try:
674
+ return abs(float(fields[6])) < 1e-6
675
+ except ValueError:
676
+ return None
677
+ return None
678
+
679
+
680
+ _spec(
681
+ command="SET_VORTICITY_DRAG_BOUNDARIES",
682
+ build_target=_emit("SET_VORTICITY_DRAG_BOUNDARIES", 1, [1]),
683
+ requires=Requires.SOLUTION,
684
+ assert_effect=_unobservable,
685
+ effect_note="the vorticity-drag boundary list is not exposed by any instrument yet",
686
+ )
687
+ _spec(
688
+ command="DELETE_VORTICITY_DRAG_BOUNDARIES",
689
+ build_target=_emit("DELETE_VORTICITY_DRAG_BOUNDARIES"),
690
+ requires=Requires.SOLUTION,
691
+ prelude=_emit("SET_VORTICITY_DRAG_BOUNDARIES", 1, [1]),
692
+ assert_effect=_unobservable,
693
+ effect_note="the vorticity-drag boundary list is not exposed by any instrument yet",
694
+ )
695
+ _spec(
696
+ command="SET_SOLVER_ANALYSIS_LOADS_FRAME",
697
+ build_target=_emit("SET_SOLVER_ANALYSIS_LOADS_FRAME", 2),
698
+ requires=Requires.SOLUTION,
699
+ early_prelude=_named_frame("PYFS_FRAME_NAME"),
700
+ epilogue=_sheet,
701
+ assert_effect=sheet_matches(r"Coordinate frame for analysis:\s+PYFS_FRAME_NAME", strict=True),
702
+ effect_note=(
703
+ "the settings sheet reports the analysis frame by its probe-given name PYFS_FRAME_NAME"
704
+ ),
705
+ )
706
+ _spec(
707
+ command="SET_ANALYSIS_MOMENTS_MODEL",
708
+ build_target=_emit("SET_ANALYSIS_MOMENTS_MODEL", "VORTICITY"),
709
+ requires=Requires.SOLUTION,
710
+ assert_effect=_unobservable,
711
+ effect_note="the moments-model choice is not exposed by any instrument yet",
712
+ )
713
+ _spec(
714
+ command="SET_ANALYSIS_SYMMETRY_LOADS",
715
+ build_target=_emit("SET_ANALYSIS_SYMMETRY_LOADS", "ENABLE"),
716
+ requires=Requires.SOLVER,
717
+ assert_effect=_unobservable,
718
+ effect_note=(
719
+ "the symmetry-loads toggle is not exposed by any instrument yet; probed "
720
+ "pre-solve since the 2026-07-21 phase correction (the in-solve monitors "
721
+ "consume it)"
722
+ ),
723
+ )
724
+ _spec(
725
+ command="SET_LOADS_AND_MOMENTS_UNITS",
726
+ build_target=_emit("SET_LOADS_AND_MOMENTS_UNITS", "NEWTONS"),
727
+ requires=Requires.SOLUTION,
728
+ epilogue=_sheet,
729
+ assert_effect=sheet_matches(r"Force Units:\s+(?i:newtons)", strict=False),
730
+ effect_note="the settings sheet footer reports the force units as Newtons",
731
+ )
732
+ _spec(
733
+ command="SET_SOLVER_ANALYSIS_BOUNDARIES",
734
+ build_target=_emit("SET_SOLVER_ANALYSIS_BOUNDARIES", 1, [1]),
735
+ requires=Requires.SOLUTION,
736
+ assert_effect=_unobservable,
737
+ effect_note=(
738
+ "the analysis boundary selection is indistinguishable on a single-boundary "
739
+ "geometry; needs a multi-boundary fixture"
740
+ ),
741
+ )
742
+ _spec(
743
+ command="SET_INVISCID_LOADS",
744
+ build_target=_emit("SET_INVISCID_LOADS", "ENABLE"),
745
+ requires=Requires.SOLUTION,
746
+ epilogue=lambda script, workdir: script.emit(
747
+ "EXPORT_SOLVER_ANALYSIS_SPREADSHEET", workdir / "loads_inviscid.txt"
748
+ ),
749
+ assert_effect=_inviscid_effect,
750
+ effect_note=(
751
+ "the loads exported after enabling inviscid-only report CDo exactly zero "
752
+ "(the viscous default on this run is nonzero)"
753
+ ),
754
+ )
755
+
756
+
757
+ # --- solver export (SRC-003 pp.352-354) --------------------------------
758
+
759
+
760
+ def _export_spec(command: str, filename: str, *extra: object, note: str | None = None) -> None:
761
+ _spec(
762
+ command=command,
763
+ build_target=lambda script, workdir: script.emit(command, workdir / filename, *extra),
764
+ requires=Requires.SOLUTION,
765
+ assert_effect=file_effect(filename),
766
+ effect_note=note or "the export file the command names exists and is not empty",
767
+ )
768
+
769
+
770
+ _export_spec("EXPORT_SOLVER_ANALYSIS_SPREADSHEET", "loads_target.txt")
771
+ _export_spec("EXPORT_SOLVER_ANALYSIS_TECPLOT", "solution.dat")
772
+ _export_spec("EXPORT_SOLVER_ANALYSIS_VTK", "solution.vtk", -1)
773
+ _export_spec("EXPORT_SOLVER_ANALYSIS_CSV", "solution.csv", "CP-FREESTREAM", "PASCALS", 1, -1)
774
+ _export_spec("EXPORT_SOLVER_ANALYSIS_PLOAD_BDF", "loads.bdf", -1)
775
+ _export_spec("EXPORT_SOLVER_ANALYSIS_FORCE_DISTRIBUTIONS", "forces.txt", -1)
776
+
777
+
778
+ def _vtk_variables_effect(artifacts: ProbeArtifacts) -> bool | None:
779
+ vtk = _read(artifacts.workdir, "variables.vtk")
780
+ if vtk is None:
781
+ return None
782
+ return True if "CP_REFERENCE" in vtk else None
783
+
784
+
785
+ _spec(
786
+ command="SET_VTK_EXPORT_VARIABLES",
787
+ build_target=_emit("SET_VTK_EXPORT_VARIABLES", 2, "DISABLE", ["X", "CP_REFERENCE"]),
788
+ requires=Requires.SOLUTION,
789
+ epilogue=lambda script, workdir: script.emit(
790
+ "EXPORT_SOLVER_ANALYSIS_VTK", workdir / "variables.vtk", -1
791
+ ),
792
+ assert_effect=_vtk_variables_effect,
793
+ effect_note="the VTK exported afterwards carries the selected CP_REFERENCE variable",
794
+ )
795
+
796
+
797
+ # --- probe points (SRC-003 pp.362-363) ---------------------------------
798
+
799
+ _spec(
800
+ command="NEW_PROBE_POINT",
801
+ build_target=_emit("NEW_PROBE_POINT", "VOLUME", 1.2345, 2.3456, 3.4567),
802
+ requires=Requires.SOLUTION,
803
+ epilogue=_sheet,
804
+ assert_effect=sheet_matches(r"0\.1234E\+01", strict=True),
805
+ effect_note="the probe export lists the distinctive point coordinate 0.1234E+01",
806
+ )
807
+ _spec(
808
+ command="NEW_PROBE_LINE",
809
+ build_target=_emit("NEW_PROBE_LINE", 3, 0.9876, 0.0, 0.1, 1.1111, 0.0, 0.1),
810
+ requires=Requires.SOLUTION,
811
+ epilogue=_sheet,
812
+ assert_effect=sheet_matches(r"Number of Probe Points:\s+3\b", strict=True),
813
+ effect_note="the probe export counts exactly the 3 requested line points",
814
+ )
815
+
816
+
817
+ def _probe_import_target(script: Script, workdir: Path) -> None:
818
+ # Lattice format per the curated helper evidence (SRC-003
819
+ # pp.362-363): first line the count, then X,Y,Z,TYPE rows.
820
+ lattice = workdir / "lattice.csv"
821
+ lattice.write_text("2\n0.8765,0.1,0.2,1\n0.7654,0.3,0.4,1\n", encoding="utf-8")
822
+ script.emit("PROBE_POINTS_IMPORT", "METER", 1, lattice)
823
+
824
+
825
+ _spec(
826
+ command="PROBE_POINTS_IMPORT",
827
+ build_target=_probe_import_target,
828
+ requires=Requires.SOLUTION,
829
+ epilogue=_sheet,
830
+ assert_effect=sheet_matches(r"Number of Probe Points:\s+2\b", strict=True),
831
+ effect_note="the probe export counts exactly the 2 imported lattice points",
832
+ )
833
+ _spec(
834
+ command="UPDATE_PROBE_POINTS",
835
+ build_target=_emit("UPDATE_PROBE_POINTS"),
836
+ requires=Requires.SOLUTION,
837
+ prelude=_emit("NEW_PROBE_POINT", "VOLUME", 1.2345, 2.3456, 3.4567),
838
+ assert_effect=_unobservable,
839
+ effect_note=(
840
+ "the probe export may refresh values on its own, so it cannot discriminate "
841
+ "UPDATE_PROBE_POINTS; needs a dedicated instrument"
842
+ ),
843
+ )
844
+ _spec(
845
+ command="EXPORT_PROBE_POINTS",
846
+ build_target=lambda script, workdir: script.emit("EXPORT_PROBE_POINTS", workdir / "probes.txt"),
847
+ requires=Requires.SOLUTION,
848
+ prelude=_emit("NEW_PROBE_POINT", "VOLUME", 1.2345, 2.3456, 3.4567),
849
+ assert_effect=file_effect("probes.txt"),
850
+ effect_note="the probe export file the command names exists and is not empty",
851
+ )
852
+ _spec(
853
+ command="DELETE_PROBE_POINTS",
854
+ build_target=_emit("DELETE_PROBE_POINTS"),
855
+ requires=Requires.SOLUTION,
856
+ prelude=_emit("NEW_PROBE_POINT", "VOLUME", 1.2345, 2.3456, 3.4567),
857
+ epilogue=_sheet,
858
+ assert_effect=sheet_matches(r"Number of Probe Points:\s+0\b", strict=True),
859
+ effect_note="the probe export counts 0 points after the prelude created one",
860
+ )
861
+
862
+
863
+ # --- streamlines (SRC-003 pp.360-361) ----------------------------------
864
+
865
+
866
+ def _streamline_epilogue(script: Script, workdir: Path) -> None:
867
+ script.emit("GENERATE_ALL_OFF_BODY_STREAMLINES")
868
+ script.emit("EXPORT_ALL_OFF_BODY_STREAMLINES", workdir / "streamlines.txt")
869
+
870
+
871
+ _spec(
872
+ command="NEW_OFF_BODY_STREAMLINE",
873
+ build_target=_emit(
874
+ "NEW_OFF_BODY_STREAMLINE",
875
+ position_x=0.5,
876
+ position_y=0.3,
877
+ position_z=0.2,
878
+ upstream="DISABLE",
879
+ ),
880
+ requires=Requires.SOLUTION,
881
+ epilogue=_streamline_epilogue,
882
+ assert_effect=_file_lax("streamlines.txt", minimum_bytes=200),
883
+ effect_note=(
884
+ "the streamline export written after generation carries data for the seeded streamline"
885
+ ),
886
+ )
887
+ _spec(
888
+ command="NEW_STREAMLINE_DISTRIBUTION",
889
+ build_target=_emit(
890
+ "NEW_STREAMLINE_DISTRIBUTION",
891
+ position_1_x=0.4,
892
+ position_1_y=0.2,
893
+ position_1_z=0.1,
894
+ position_2_x=0.6,
895
+ position_2_y=0.4,
896
+ position_2_z=0.1,
897
+ subdivisions=4,
898
+ ),
899
+ requires=Requires.SOLUTION,
900
+ epilogue=_streamline_epilogue,
901
+ assert_effect=_file_lax("streamlines.txt", minimum_bytes=200),
902
+ effect_note=(
903
+ "the streamline export written after generation carries data for the seeded distribution"
904
+ ),
905
+ )
906
+ # The seeding prelude of the two commands below uses the distribution
907
+ # form: the first full sweep showed the single-streamline form aborts
908
+ # the script (its own probe records that), so it cannot serve as a
909
+ # support instrument.
910
+ _SEED_STREAMLINES = _emit(
911
+ "NEW_STREAMLINE_DISTRIBUTION",
912
+ position_1_x=0.4,
913
+ position_1_y=0.2,
914
+ position_1_z=0.1,
915
+ position_2_x=0.6,
916
+ position_2_y=0.4,
917
+ position_2_z=0.1,
918
+ subdivisions=4,
919
+ )
920
+
921
+ _spec(
922
+ command="GENERATE_ALL_OFF_BODY_STREAMLINES",
923
+ build_target=_emit("GENERATE_ALL_OFF_BODY_STREAMLINES"),
924
+ requires=Requires.SOLUTION,
925
+ prelude=_SEED_STREAMLINES,
926
+ epilogue=lambda script, workdir: script.emit(
927
+ "EXPORT_ALL_OFF_BODY_STREAMLINES", workdir / "streamlines.txt"
928
+ ),
929
+ assert_effect=_file_lax("streamlines.txt", minimum_bytes=200),
930
+ effect_note="the streamline export written afterwards carries generated data",
931
+ )
932
+ _spec(
933
+ command="EXPORT_ALL_OFF_BODY_STREAMLINES",
934
+ build_target=lambda script, workdir: script.emit(
935
+ "EXPORT_ALL_OFF_BODY_STREAMLINES", workdir / "streamlines.txt"
936
+ ),
937
+ requires=Requires.SOLUTION,
938
+ prelude=_seq(_SEED_STREAMLINES, _emit("GENERATE_ALL_OFF_BODY_STREAMLINES")),
939
+ assert_effect=file_effect("streamlines.txt"),
940
+ effect_note="the streamline export file the command names exists and is not empty",
941
+ )
942
+
943
+
944
+ # --- surface sections (SRC-003 pp.357-359) -----------------------------
945
+
946
+ _CREATE_SECTION = _emit("CREATE_NEW_SURFACE_SECTION", 1, "XZ", 0.05, "1", "DISABLE", -1)
947
+
948
+ _spec(
949
+ command="CREATE_NEW_SURFACE_SECTION",
950
+ build_target=_CREATE_SECTION,
951
+ requires=Requires.SOLUTION,
952
+ epilogue=lambda script, workdir: script.emit(
953
+ "EXPORT_ALL_SURFACE_SECTIONS", workdir / "sections.txt"
954
+ ),
955
+ assert_effect=_file_lax("sections.txt", minimum_bytes=100),
956
+ effect_note="the all-sections export written afterwards carries the created section",
957
+ )
958
+ _spec(
959
+ command="NEW_SURFACE_SECTION_DISTRIBUTION",
960
+ build_target=_emit(
961
+ "NEW_SURFACE_SECTION_DISTRIBUTION",
962
+ frame=1,
963
+ plane="XZ",
964
+ num_sections=3,
965
+ plot_direction="1",
966
+ include_symmetry="DISABLE",
967
+ surfaces=-1,
968
+ ),
969
+ requires=Requires.SOLVER,
970
+ epilogue=lambda script, workdir: script.emit(
971
+ "EXPORT_ALL_SURFACE_SECTIONS", workdir / "sections.txt"
972
+ ),
973
+ assert_effect=_file_lax("sections.txt", minimum_bytes=100),
974
+ effect_note="the all-sections export written afterwards carries the distribution",
975
+ )
976
+ _spec(
977
+ command="COMPUTE_SURFACE_SECTIONAL_LOADS",
978
+ build_target=_emit("COMPUTE_SURFACE_SECTIONAL_LOADS", "COEFFICIENTS"),
979
+ requires=Requires.SOLUTION,
980
+ prelude=_CREATE_SECTION,
981
+ epilogue=lambda script, workdir: script.emit(
982
+ "EXPORT_SURFACE_SECTIONAL_LOADS", workdir / "sectional_loads.txt"
983
+ ),
984
+ assert_effect=_file_lax("sectional_loads.txt", minimum_bytes=100),
985
+ effect_note="the sectional-loads export written afterwards carries computed loads",
986
+ )
987
+ _spec(
988
+ command="EXPORT_SURFACE_SECTIONAL_LOADS",
989
+ build_target=lambda script, workdir: script.emit(
990
+ "EXPORT_SURFACE_SECTIONAL_LOADS", workdir / "sectional_loads.txt"
991
+ ),
992
+ requires=Requires.SOLUTION,
993
+ prelude=_seq(_CREATE_SECTION, _emit("COMPUTE_SURFACE_SECTIONAL_LOADS", "COEFFICIENTS")),
994
+ assert_effect=file_effect("sectional_loads.txt"),
995
+ effect_note="the sectional-loads file the command names exists and is not empty",
996
+ )
997
+ _spec(
998
+ command="UPDATE_ALL_SURFACE_SECTIONS",
999
+ build_target=_emit("UPDATE_ALL_SURFACE_SECTIONS"),
1000
+ requires=Requires.SOLUTION,
1001
+ prelude=_CREATE_SECTION,
1002
+ assert_effect=_unobservable,
1003
+ effect_note=(
1004
+ "the sections export may refresh on its own, so it cannot discriminate the "
1005
+ "update command; needs a dedicated instrument"
1006
+ ),
1007
+ )
1008
+ _spec(
1009
+ command="EXPORT_ALL_SURFACE_SECTIONS",
1010
+ build_target=lambda script, workdir: script.emit(
1011
+ "EXPORT_ALL_SURFACE_SECTIONS", workdir / "sections.txt"
1012
+ ),
1013
+ requires=Requires.SOLUTION,
1014
+ prelude=_CREATE_SECTION,
1015
+ assert_effect=file_effect("sections.txt"),
1016
+ effect_note="the all-sections file the command names exists and is not empty",
1017
+ )
1018
+ _spec(
1019
+ command="DELETE_SURFACE_SECTION",
1020
+ build_target=_emit("DELETE_SURFACE_SECTION", 1),
1021
+ requires=Requires.SOLUTION,
1022
+ prelude=_CREATE_SECTION,
1023
+ assert_effect=_unobservable,
1024
+ effect_note=(
1025
+ "the surviving-section listing format is not pinned yet, so deletion is not "
1026
+ "discriminated; needs a dedicated instrument"
1027
+ ),
1028
+ )
1029
+
1030
+
1031
+ # --- volume sections (SRC-003 pp.355-356) ------------------------------
1032
+
1033
+ _CREATE_RECT_VSECTION = _emit(
1034
+ "CREATE_NEW_RECTANGLE_VOLUME_SECTION",
1035
+ 1,
1036
+ "XZ",
1037
+ 0.0,
1038
+ 1,
1039
+ -1.0,
1040
+ -1.0,
1041
+ 1.0,
1042
+ 1.0,
1043
+ "NONE",
1044
+ 0.1,
1045
+ 1,
1046
+ 1.2,
1047
+ )
1048
+
1049
+
1050
+ def _vsection_export_epilogue(script: Script, workdir: Path) -> None:
1051
+ script.emit("EXPORT_VOLUME_SECTION_VTK", 1, workdir / "vsection.vtk")
1052
+
1053
+
1054
+ def _delete_vsection_effect(artifacts: ProbeArtifacts) -> bool:
1055
+ return not (artifacts.workdir / "vsection.vtk").is_file()
1056
+
1057
+
1058
+ _spec(
1059
+ command="CREATE_NEW_RECTANGLE_VOLUME_SECTION",
1060
+ build_target=_CREATE_RECT_VSECTION,
1061
+ requires=Requires.SOLUTION,
1062
+ epilogue=_vsection_export_epilogue,
1063
+ assert_effect=_file_lax("vsection.vtk", minimum_bytes=100),
1064
+ effect_note="exporting volume section 1 afterwards succeeds, so the section exists",
1065
+ )
1066
+ _spec(
1067
+ command="CREATE_NEW_CIRCLE_VOLUME_SECTION",
1068
+ build_target=_emit(
1069
+ "CREATE_NEW_CIRCLE_VOLUME_SECTION",
1070
+ 1,
1071
+ "XZ",
1072
+ 0.0,
1073
+ 10,
1074
+ 10,
1075
+ 0.2,
1076
+ 1.0,
1077
+ "NONE",
1078
+ 0.1,
1079
+ 1,
1080
+ 1.2,
1081
+ ),
1082
+ requires=Requires.SOLUTION,
1083
+ epilogue=_vsection_export_epilogue,
1084
+ assert_effect=_file_lax("vsection.vtk", minimum_bytes=100),
1085
+ effect_note="exporting volume section 1 afterwards succeeds, so the section exists",
1086
+ )
1087
+ _spec(
1088
+ command="UPDATE_ALL_VOLUME_SECTIONS",
1089
+ build_target=_emit("UPDATE_ALL_VOLUME_SECTIONS"),
1090
+ requires=Requires.SOLUTION,
1091
+ prelude=_CREATE_RECT_VSECTION,
1092
+ assert_effect=_unobservable,
1093
+ effect_note=(
1094
+ "the section export may refresh on its own, so it cannot discriminate the "
1095
+ "update command; needs a dedicated instrument"
1096
+ ),
1097
+ )
1098
+ _spec(
1099
+ command="EXPORT_VOLUME_SECTION_VTK",
1100
+ build_target=lambda script, workdir: script.emit(
1101
+ "EXPORT_VOLUME_SECTION_VTK", 1, workdir / "vsection.vtk"
1102
+ ),
1103
+ requires=Requires.SOLUTION,
1104
+ prelude=_CREATE_RECT_VSECTION,
1105
+ assert_effect=file_effect("vsection.vtk"),
1106
+ effect_note="the VTK file the command names exists and is not empty",
1107
+ )
1108
+ _spec(
1109
+ command="EXPORT_VOLUME_SECTION_TECPLOT",
1110
+ build_target=lambda script, workdir: script.emit(
1111
+ "EXPORT_VOLUME_SECTION_TECPLOT", 1, workdir / "vsection.dat"
1112
+ ),
1113
+ requires=Requires.SOLUTION,
1114
+ prelude=_CREATE_RECT_VSECTION,
1115
+ assert_effect=file_effect("vsection.dat"),
1116
+ effect_note="the Tecplot file the command names exists and is not empty",
1117
+ )
1118
+ _spec(
1119
+ command="DELETE_VOLUME_SECTION",
1120
+ build_target=_emit("DELETE_VOLUME_SECTION", 1),
1121
+ requires=Requires.SOLUTION,
1122
+ prelude=_CREATE_RECT_VSECTION,
1123
+ epilogue=_vsection_export_epilogue,
1124
+ assert_effect=_delete_vsection_effect,
1125
+ effect_note=(
1126
+ "exporting the deleted section 1 afterwards produces no file (the export "
1127
+ "command's own probe rules out an export failure)"
1128
+ ),
1129
+ )
1130
+
1131
+
1132
+ # --- actuators (SRC-003 pp.323-324) ------------------------------------
1133
+
1134
+ _ACTUATOR_PRELUDE = _seq(
1135
+ _emit("CREATE_NEW_COORDINATE_SYSTEM"),
1136
+ _emit("CREATE_NEW_ACTUATOR", "PROPELLER", subtype="ELLIPTICAL", name="PYFS_ACT_BASE"),
1137
+ )
1138
+
1139
+ _spec(
1140
+ command="CREATE_NEW_ACTUATOR",
1141
+ build_target=_emit(
1142
+ "CREATE_NEW_ACTUATOR", "PROPELLER", subtype="ELLIPTICAL", name="PYFS_ACT_CREATED"
1143
+ ),
1144
+ requires=Requires.SIM,
1145
+ epilogue=_saveas,
1146
+ assert_effect=fsm_grep("PYFS_ACT_CREATED"),
1147
+ effect_note="the actuator name is readable in the saved simulation file",
1148
+ )
1149
+ _spec(
1150
+ command="SET_ACTUATOR_NAME",
1151
+ build_target=_emit("SET_ACTUATOR_NAME", 1, "PYFS_ACT_RENAMED"),
1152
+ requires=Requires.SIM,
1153
+ prelude=_ACTUATOR_PRELUDE,
1154
+ epilogue=_saveas,
1155
+ assert_effect=fsm_grep("PYFS_ACT_RENAMED"),
1156
+ effect_note="the new actuator name is readable in the saved simulation file",
1157
+ )
1158
+ _spec(
1159
+ command="SET_ACTUATOR_AXIS",
1160
+ build_target=_emit("SET_ACTUATOR_AXIS", 1, 2, "X", 0.6622),
1161
+ requires=Requires.SIM,
1162
+ prelude=_ACTUATOR_PRELUDE,
1163
+ assert_effect=_unobservable,
1164
+ effect_note="actuator axis fields are stored in binary form; no instrument yet",
1165
+ )
1166
+ _spec(
1167
+ command="SET_ACTUATOR_RADIUS",
1168
+ build_target=_emit("SET_ACTUATOR_RADIUS", 1, 1.234, 0.321),
1169
+ requires=Requires.SIM,
1170
+ prelude=_ACTUATOR_PRELUDE,
1171
+ assert_effect=_unobservable,
1172
+ effect_note="actuator radii are stored in binary form; no instrument yet",
1173
+ )
1174
+ _spec(
1175
+ command="SET_PROP_ACTUATOR_RPM",
1176
+ build_target=_emit("SET_PROP_ACTUATOR_RPM", 1, 3456.7),
1177
+ requires=Requires.SIM,
1178
+ prelude=_ACTUATOR_PRELUDE,
1179
+ assert_effect=_unobservable,
1180
+ effect_note="the actuator rpm is stored in binary form; no instrument yet",
1181
+ )
1182
+ _spec(
1183
+ command="SET_PROP_ACTUATOR_THRUST",
1184
+ build_target=_emit("SET_PROP_ACTUATOR_THRUST", 1, 45.678, "NEWTONS"),
1185
+ requires=Requires.SIM,
1186
+ prelude=_ACTUATOR_PRELUDE,
1187
+ assert_effect=_unobservable,
1188
+ effect_note="the actuator thrust is stored in binary form; no instrument yet",
1189
+ )
1190
+ _spec(
1191
+ command="SET_PROP_ACTUATOR_SWIRL",
1192
+ build_target=_emit("SET_PROP_ACTUATOR_SWIRL", 1, 0.777),
1193
+ requires=Requires.SIM,
1194
+ prelude=_ACTUATOR_PRELUDE,
1195
+ assert_effect=_unobservable,
1196
+ effect_note="the swirl fraction is stored in binary form; no instrument yet",
1197
+ )
1198
+ _spec(
1199
+ command="ENABLE_ACTUATOR",
1200
+ build_target=_emit("ENABLE_ACTUATOR", 1),
1201
+ requires=Requires.SIM,
1202
+ prelude=_ACTUATOR_PRELUDE,
1203
+ assert_effect=_unobservable,
1204
+ effect_note="the enable flag is stored in binary form; no instrument yet",
1205
+ )
1206
+ _spec(
1207
+ command="DELETE_ACTUATOR",
1208
+ build_target=_emit("DELETE_ACTUATOR", 1),
1209
+ requires=Requires.SIM,
1210
+ prelude=_seq(
1211
+ _emit("CREATE_NEW_COORDINATE_SYSTEM"),
1212
+ _emit("CREATE_NEW_ACTUATOR", "PROPELLER", subtype="ELLIPTICAL", name="PYFS_ACT_DOOMED"),
1213
+ ),
1214
+ epilogue=_saveas,
1215
+ assert_effect=fsm_grep("PYFS_ACT_DOOMED", expect=False),
1216
+ effect_note=("the deleted actuator's name is no longer readable in the saved simulation file"),
1217
+ )
1218
+
1219
+
1220
+ # --- motion definitions (SRC-003 pp.332-336) ---------------------------
1221
+
1222
+ _MOTION_PRELUDE = _emit("CREATE_NEW_MOTION", "ROTARY")
1223
+
1224
+
1225
+ def _motion_setter(command: str, *args: object, note: str) -> None:
1226
+ _spec(
1227
+ command=command,
1228
+ build_target=_emit(command, *args),
1229
+ requires=Requires.SIM,
1230
+ prelude=_MOTION_PRELUDE,
1231
+ assert_effect=_unobservable,
1232
+ effect_note=note,
1233
+ )
1234
+
1235
+
1236
+ _spec(
1237
+ command="CREATE_NEW_MOTION",
1238
+ build_target=_emit("CREATE_NEW_MOTION", "ROTARY"),
1239
+ requires=Requires.SIM,
1240
+ assert_effect=_unobservable,
1241
+ effect_note=(
1242
+ "motions are unnamed and stored in binary form (recon-checked); no instrument "
1243
+ "observes them yet"
1244
+ ),
1245
+ )
1246
+ _motion_setter(
1247
+ "SET_MOTION_BOUNDARIES",
1248
+ 1,
1249
+ -1,
1250
+ note="motion boundary lists are stored in binary form; no instrument yet",
1251
+ )
1252
+ _motion_setter(
1253
+ "SET_MOTION_MOVING_FRAMES",
1254
+ 1,
1255
+ -1,
1256
+ note="motion frame lists are stored in binary form; no instrument yet",
1257
+ )
1258
+
1259
+
1260
+ def _motion_frame_prelude(script: Script, workdir: Path) -> None:
1261
+ script.emit("CREATE_NEW_COORDINATE_SYSTEM")
1262
+ script.emit("CREATE_NEW_MOTION", "ROTARY")
1263
+
1264
+
1265
+ _spec(
1266
+ command="SET_MOTION_COORDINATE_SYSTEM",
1267
+ build_target=_emit("SET_MOTION_COORDINATE_SYSTEM", 1, 2),
1268
+ requires=Requires.SIM,
1269
+ prelude=_motion_frame_prelude,
1270
+ assert_effect=_unobservable,
1271
+ effect_note="the motion frame binding is stored in binary form; no instrument yet",
1272
+ )
1273
+ _motion_setter(
1274
+ "SET_MOTION_START_TIME",
1275
+ 1,
1276
+ 0.05,
1277
+ note="the motion start time is stored in binary form; no instrument yet",
1278
+ )
1279
+ _motion_setter(
1280
+ "SET_MOTION_ROTOR_AXIS",
1281
+ 1,
1282
+ "X",
1283
+ note="the rotor axis is stored in binary form; no instrument yet",
1284
+ )
1285
+ _motion_setter(
1286
+ "SET_MOTION_ROTOR_RPM",
1287
+ 1,
1288
+ 4567.8,
1289
+ note="the rotor rpm is stored in binary form; no instrument yet",
1290
+ )
1291
+ _motion_setter(
1292
+ "SET_MOTION_SLIPSTREAM_WAKE_STABILIZATION",
1293
+ 1,
1294
+ "ENABLE",
1295
+ 6,
1296
+ note="the wake-stabilization state is stored in binary form; no instrument yet",
1297
+ )
1298
+ _motion_setter(
1299
+ "DELETE_MOTION",
1300
+ 1,
1301
+ note="motions are unnamed in the saved file, so deletion is not discriminated yet",
1302
+ )
1303
+
1304
+
1305
+ # --- sweeper toolbox (SRC-003 p.406) -----------------------------------
1306
+
1307
+ _SWEEP_AOA_PRELUDE = _emit("SWEEPER_SET_AOA_SWEEP", "CUSTOM", [2.0])
1308
+
1309
+
1310
+ def _sweep_pair_effect(artifacts: ProbeArtifacts) -> bool | None:
1311
+ sweep = _read(artifacts.workdir, "sweep.txt")
1312
+ if sweep is None:
1313
+ return None
1314
+ return True if ("2.000" in sweep and "4.000" in sweep) else None
1315
+
1316
+
1317
+ def _sweep_epilogue(script: Script, workdir: Path) -> None:
1318
+ script.emit("SWEEPER_START")
1319
+ script.emit("SWEEPER_EXPORT_SPREADSHEET", workdir / "sweep.txt")
1320
+
1321
+
1322
+ def _postrun_target(script: Script, workdir: Path) -> None:
1323
+ postrun = workdir / "postrun.txt"
1324
+ postrun.write_text("PRINT PYFS_POSTRUN\n", encoding="utf-8")
1325
+ script.emit("SWEEPER_POST_RUN_SCRIPT", "ENABLE", postrun)
1326
+
1327
+
1328
+ _spec(
1329
+ command="SWEEPER_SET_AOA_SWEEP",
1330
+ build_target=_emit("SWEEPER_SET_AOA_SWEEP", "CUSTOM", [2.0, 4.0]),
1331
+ requires=Requires.SOLVER,
1332
+ epilogue=_sweep_epilogue,
1333
+ assert_effect=_sweep_pair_effect,
1334
+ effect_note="the sweep spreadsheet carries both requested angles 2.000 and 4.000",
1335
+ timeout_s=240.0,
1336
+ )
1337
+ _spec(
1338
+ command="SWEEPER_SET_BETA_SWEEP",
1339
+ build_target=_emit("SWEEPER_SET_BETA_SWEEP", "CUSTOM", [1.5, 3.5]),
1340
+ requires=Requires.SOLVER,
1341
+ epilogue=_sweep_epilogue,
1342
+ assert_effect=lambda artifacts: (
1343
+ True
1344
+ if (sweep := _read(artifacts.workdir, "sweep.txt")) is not None
1345
+ and "1.500" in sweep
1346
+ and "3.500" in sweep
1347
+ else None
1348
+ ),
1349
+ effect_note="the sweep spreadsheet carries both requested side-slips 1.500 and 3.500",
1350
+ timeout_s=240.0,
1351
+ )
1352
+ _spec(
1353
+ command="SWEEPER_SET_VELOCITY_SWEEP",
1354
+ build_target=_emit("SWEEPER_SET_VELOCITY_SWEEP", "DISABLE"),
1355
+ requires=Requires.SOLVER,
1356
+ assert_effect=_unobservable,
1357
+ effect_note=(
1358
+ "only the DISABLE form is probed (the CUSTOM velocity list file format awaits "
1359
+ "a manual pass); the disabled state leaves no observable trace"
1360
+ ),
1361
+ )
1362
+ _spec(
1363
+ command="SWEEPER_POST_RUN_SCRIPT",
1364
+ build_target=_postrun_target,
1365
+ requires=Requires.SOLVER,
1366
+ prelude=_SWEEP_AOA_PRELUDE,
1367
+ epilogue=lambda script, workdir: script.emit("SWEEPER_START"),
1368
+ assert_effect=_log_printed("PYFS_POSTRUN"),
1369
+ effect_note=("the post-run script's message PYFS_POSTRUN appears in the log after the sweep"),
1370
+ timeout_s=240.0,
1371
+ )
1372
+ _spec(
1373
+ command="SWEEPER_CLEAR_SOLUTION",
1374
+ build_target=_emit("SWEEPER_CLEAR_SOLUTION", "ENABLE"),
1375
+ requires=Requires.SOLVER,
1376
+ assert_effect=_unobservable,
1377
+ effect_note="the sweeper clear-solution toggle leaves no observable trace yet",
1378
+ )
1379
+ _spec(
1380
+ command="SWEEPER_REF_VELOCITY_SAME",
1381
+ build_target=_emit("SWEEPER_REF_VELOCITY_SAME", "ENABLE"),
1382
+ requires=Requires.SOLVER,
1383
+ assert_effect=_unobservable,
1384
+ effect_note="the sweeper reference-velocity toggle leaves no observable trace yet",
1385
+ )
1386
+ _spec(
1387
+ command="SWEEPER_START",
1388
+ build_target=_emit("SWEEPER_START"),
1389
+ requires=Requires.SOLVER,
1390
+ prelude=_SWEEP_AOA_PRELUDE,
1391
+ assert_effect=region_printed_lax("Solver run time"),
1392
+ effect_note="the sweep run prints the solver iteration table and run time",
1393
+ timeout_s=240.0,
1394
+ )
1395
+ _spec(
1396
+ command="SWEEPER_EXPORT_SPREADSHEET",
1397
+ build_target=lambda script, workdir: script.emit(
1398
+ "SWEEPER_EXPORT_SPREADSHEET", workdir / "sweep.txt"
1399
+ ),
1400
+ requires=Requires.SOLVER,
1401
+ prelude=_seq(_SWEEP_AOA_PRELUDE, _emit("SWEEPER_START")),
1402
+ assert_effect=file_effect("sweep.txt"),
1403
+ effect_note="the sweep spreadsheet the command names exists and is not empty",
1404
+ timeout_s=240.0,
1405
+ )