pyflightstream 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyflightstream/__init__.py +27 -0
- pyflightstream/cases/__init__.py +332 -0
- pyflightstream/cases/matrix_legacy.py +337 -0
- pyflightstream/commands/__init__.py +544 -0
- pyflightstream/commands/_meta.yaml +18 -0
- pyflightstream/commands/actuators.yaml +167 -0
- pyflightstream/commands/advanced_settings.yaml +136 -0
- pyflightstream/commands/aeroelastic_coupling.yaml +172 -0
- pyflightstream/commands/boundary_conditions.yaml +153 -0
- pyflightstream/commands/ccs_wing_mesh.yaml +74 -0
- pyflightstream/commands/coordinate_systems.yaml +123 -0
- pyflightstream/commands/file_io.yaml +82 -0
- pyflightstream/commands/mesh_import_export.yaml +72 -0
- pyflightstream/commands/motion_definitions.yaml +185 -0
- pyflightstream/commands/probe_points.yaml +116 -0
- pyflightstream/commands/runtime_settings.yaml +160 -0
- pyflightstream/commands/scenes.yaml +14 -0
- pyflightstream/commands/script_controls.yaml +39 -0
- pyflightstream/commands/simulation_controls.yaml +30 -0
- pyflightstream/commands/solver_analysis.yaml +118 -0
- pyflightstream/commands/solver_export.yaml +138 -0
- pyflightstream/commands/solver_initialization.yaml +95 -0
- pyflightstream/commands/solver_settings.yaml +120 -0
- pyflightstream/commands/streamlines.yaml +63 -0
- pyflightstream/commands/surface_sections.yaml +145 -0
- pyflightstream/commands/sweeper.yaml +115 -0
- pyflightstream/commands/unsteady_solver.yaml +68 -0
- pyflightstream/commands/volume_sections.yaml +148 -0
- pyflightstream/farfield/__init__.py +613 -0
- pyflightstream/files/__init__.py +375 -0
- pyflightstream/fsi/__init__.py +57 -0
- pyflightstream/fsi/beam.py +417 -0
- pyflightstream/fsi/centrifugal.py +418 -0
- pyflightstream/fsi/cli.py +206 -0
- pyflightstream/fsi/config.py +316 -0
- pyflightstream/fsi/driver.py +501 -0
- pyflightstream/fsi/kinematics.py +153 -0
- pyflightstream/fsi/loads.py +740 -0
- pyflightstream/fsi/nodes.py +463 -0
- pyflightstream/fsi/state.py +181 -0
- pyflightstream/post/__init__.py +18 -0
- pyflightstream/post/writers.py +195 -0
- pyflightstream/probes/__init__.py +453 -0
- pyflightstream/probes/geometry.py +281 -0
- pyflightstream/probes/planar.py +477 -0
- pyflightstream/qa/__init__.py +59 -0
- pyflightstream/qa/cli.py +364 -0
- pyflightstream/qa/compat.py +285 -0
- pyflightstream/qa/drift.py +406 -0
- pyflightstream/qa/geometry.py +421 -0
- pyflightstream/qa/physics.py +1744 -0
- pyflightstream/qa/probes.py +1023 -0
- pyflightstream/qa/references/PHY-01.yaml +38 -0
- pyflightstream/qa/references/PHY-02.yaml +28 -0
- pyflightstream/qa/references/PHY-05.yaml +29 -0
- pyflightstream/qa/references/PHY-06.yaml +90 -0
- pyflightstream/qa/references/SMI-01.yaml +28 -0
- pyflightstream/qa/references/SMI-02.yaml +28 -0
- pyflightstream/qa/specs.py +1405 -0
- pyflightstream/reference.py +465 -0
- pyflightstream/results/__init__.py +547 -0
- pyflightstream/run/__init__.py +677 -0
- pyflightstream/script/__init__.py +474 -0
- pyflightstream/script/helpers.py +844 -0
- pyflightstream/versions.py +191 -0
- pyflightstream-0.2.0.dist-info/METADATA +88 -0
- pyflightstream-0.2.0.dist-info/RECORD +71 -0
- pyflightstream-0.2.0.dist-info/WHEEL +5 -0
- pyflightstream-0.2.0.dist-info/entry_points.txt +3 -0
- pyflightstream-0.2.0.dist-info/licenses/LICENSE +21 -0
- pyflightstream-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Planar Cartesian probe grids on arbitrary coordinate frames.
|
|
2
|
+
|
|
3
|
+
Pipeline role: the controlled replacement for FlightStream volume
|
|
4
|
+
sections. `CREATE_NEW_RECTANGLE_VOLUME_SECTION` places its sampling
|
|
5
|
+
points internally (SRC-003 p.366); a :class:`PlanarProbeGrid` instead
|
|
6
|
+
prescribes every probe position: element size or point distribution
|
|
7
|
+
per in-plane axis, on a plane defined by an explicit
|
|
8
|
+
:class:`FrameDefinition` (origin plus axes in the reference frame,
|
|
9
|
+
mirroring the EDIT_COORDINATE_SYSTEM grammar). Points are transformed
|
|
10
|
+
to the reference frame in Python and imported with FRAME 1, so the
|
|
11
|
+
geometry culling of :mod:`pyflightstream.probes.geometry` always
|
|
12
|
+
operates on the same coordinates the solver sees.
|
|
13
|
+
|
|
14
|
+
The grid is serializable like the cylindrical survey lattice: the
|
|
15
|
+
JSON round trip is the cross-consumer contract, and the deterministic
|
|
16
|
+
point ordering (base nodes row-major in u then v; refined nodes
|
|
17
|
+
appended cell-major) is what a probe-export parser keys its rows on.
|
|
18
|
+
|
|
19
|
+
Units: every stored length is in simulation length units; the frame
|
|
20
|
+
axes are dimensionless unit vectors in the reference frame.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Literal
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"FrameDefinition",
|
|
34
|
+
"AxisSpec",
|
|
35
|
+
"RefinementBand",
|
|
36
|
+
"PlanarProbeGrid",
|
|
37
|
+
"GeometryGateReport",
|
|
38
|
+
"PlannedProbes",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
_DEGENERATE = 1e-10
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _unit(vector: tuple[float, float, float], name: str) -> np.ndarray:
|
|
45
|
+
array = np.asarray(vector, dtype=float)
|
|
46
|
+
norm = float(np.linalg.norm(array))
|
|
47
|
+
if norm < _DEGENERATE:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"{name} has near-zero length; a frame axis must be a direction, "
|
|
50
|
+
"and a zero vector defines none"
|
|
51
|
+
)
|
|
52
|
+
return array / norm
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FrameDefinition(BaseModel):
|
|
56
|
+
"""A local coordinate frame expressed in the reference frame.
|
|
57
|
+
|
|
58
|
+
Mirrors what EDIT_COORDINATE_SYSTEM sends to the solver (origin
|
|
59
|
+
plus axis vectors in the reference frame, coordinate_systems
|
|
60
|
+
chapter): the same object can therefore both place probe grids in
|
|
61
|
+
Python and, optionally, create the matching solver-side frame via
|
|
62
|
+
:func:`pyflightstream.script.helpers.coordinate_frame`.
|
|
63
|
+
|
|
64
|
+
``x_axis`` and ``y_axis`` are normalized on construction and
|
|
65
|
+
``y_axis`` is orthogonalized against ``x_axis`` (Gram-Schmidt);
|
|
66
|
+
``z_axis`` is their cross product, so the frame is always
|
|
67
|
+
right-handed orthonormal.
|
|
68
|
+
|
|
69
|
+
Attributes
|
|
70
|
+
----------
|
|
71
|
+
origin : tuple of float
|
|
72
|
+
Frame origin in the reference frame (simulation length units).
|
|
73
|
+
x_axis, y_axis : tuple of float
|
|
74
|
+
In-plane directions of the grid, reference-frame components;
|
|
75
|
+
stored normalized and orthogonal.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
79
|
+
|
|
80
|
+
origin: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
|
81
|
+
x_axis: tuple[float, float, float]
|
|
82
|
+
y_axis: tuple[float, float, float]
|
|
83
|
+
|
|
84
|
+
@model_validator(mode="before")
|
|
85
|
+
@classmethod
|
|
86
|
+
def _orthonormalize(cls, data: dict) -> dict:
|
|
87
|
+
if not isinstance(data, dict) or "x_axis" not in data or "y_axis" not in data:
|
|
88
|
+
return data
|
|
89
|
+
x = _unit(tuple(data["x_axis"]), "x_axis")
|
|
90
|
+
y_raw = np.asarray(data["y_axis"], dtype=float)
|
|
91
|
+
y_perp = y_raw - np.dot(y_raw, x) * x
|
|
92
|
+
if float(np.linalg.norm(y_perp)) < _DEGENERATE:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"y_axis is parallel to x_axis; two independent in-plane "
|
|
95
|
+
"directions are needed to define a plane"
|
|
96
|
+
)
|
|
97
|
+
y = y_perp / float(np.linalg.norm(y_perp))
|
|
98
|
+
data = dict(data)
|
|
99
|
+
data["x_axis"] = tuple(float(c) for c in x)
|
|
100
|
+
data["y_axis"] = tuple(float(c) for c in y)
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def z_axis(self) -> tuple[float, float, float]:
|
|
105
|
+
"""Plane normal, the right-handed cross product of x and y axes."""
|
|
106
|
+
z = np.cross(self.x_axis, self.y_axis)
|
|
107
|
+
return tuple(float(c) for c in z)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def basis(self) -> np.ndarray:
|
|
111
|
+
"""Rows are the x, y, z axes in reference-frame components."""
|
|
112
|
+
return np.asarray([self.x_axis, self.y_axis, self.z_axis], dtype=float)
|
|
113
|
+
|
|
114
|
+
def to_reference(self, local_points: np.ndarray) -> np.ndarray:
|
|
115
|
+
"""Transform local ``(..., 3)`` coordinates into the reference frame.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
local_points : numpy.ndarray
|
|
120
|
+
Coordinates along the frame axes (simulation length units).
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
numpy.ndarray
|
|
125
|
+
Same shape, reference-frame coordinates.
|
|
126
|
+
"""
|
|
127
|
+
return np.asarray(self.origin) + np.asarray(local_points, dtype=float) @ self.basis
|
|
128
|
+
|
|
129
|
+
def from_reference(self, points: np.ndarray) -> np.ndarray:
|
|
130
|
+
"""Inverse of :meth:`to_reference`."""
|
|
131
|
+
return (np.asarray(points, dtype=float) - np.asarray(self.origin)) @ self.basis.T
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class AxisSpec(BaseModel):
|
|
135
|
+
"""Point distribution along one in-plane axis of a grid.
|
|
136
|
+
|
|
137
|
+
The primary path prescribes the element size directly
|
|
138
|
+
(``spacing``, uniform); the alternative prescribes a point
|
|
139
|
+
``count`` with a ``distribution``. Endpoints are always included
|
|
140
|
+
exactly.
|
|
141
|
+
|
|
142
|
+
Attributes
|
|
143
|
+
----------
|
|
144
|
+
start, stop : float
|
|
145
|
+
Axis extent in the local frame (simulation length units).
|
|
146
|
+
spacing : float, optional
|
|
147
|
+
Uniform element size; snapped so an integer number of elements
|
|
148
|
+
fills the extent exactly (the snapped size never exceeds the
|
|
149
|
+
requested one by more than the fill rounding).
|
|
150
|
+
count : int, optional
|
|
151
|
+
Number of points (at least 2); required for the nonuniform
|
|
152
|
+
distributions.
|
|
153
|
+
distribution : str
|
|
154
|
+
``uniform``, ``cosine`` (smooth clustering), or ``geometric``
|
|
155
|
+
(element sizes in geometric progression away from the
|
|
156
|
+
clustered end).
|
|
157
|
+
ratio : float, optional
|
|
158
|
+
Geometric growth ratio, greater than 1; geometric only.
|
|
159
|
+
cluster : str
|
|
160
|
+
Which end(s) get the small elements: ``start``, ``end``, or
|
|
161
|
+
``both``.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
165
|
+
|
|
166
|
+
start: float
|
|
167
|
+
stop: float
|
|
168
|
+
spacing: float | None = None
|
|
169
|
+
count: int | None = None
|
|
170
|
+
distribution: Literal["uniform", "cosine", "geometric"] = "uniform"
|
|
171
|
+
ratio: float | None = None
|
|
172
|
+
cluster: Literal["start", "end", "both"] = "both"
|
|
173
|
+
|
|
174
|
+
@model_validator(mode="after")
|
|
175
|
+
def _specification_is_consistent(self) -> AxisSpec:
|
|
176
|
+
if self.stop <= self.start:
|
|
177
|
+
raise ValueError("stop must exceed start: an axis needs a positive extent")
|
|
178
|
+
if self.distribution == "uniform":
|
|
179
|
+
if (self.spacing is None) == (self.count is None):
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"a uniform axis takes exactly one of spacing (element size) "
|
|
182
|
+
"or count (number of points)"
|
|
183
|
+
)
|
|
184
|
+
if self.ratio is not None:
|
|
185
|
+
raise ValueError("ratio only applies to the geometric distribution")
|
|
186
|
+
else:
|
|
187
|
+
if self.count is None or self.spacing is not None:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"the {self.distribution} distribution varies its element size, "
|
|
190
|
+
"so it takes count, never spacing"
|
|
191
|
+
)
|
|
192
|
+
if self.distribution == "geometric" and (self.ratio is None or self.ratio <= 1.0):
|
|
193
|
+
raise ValueError(
|
|
194
|
+
"the geometric distribution needs ratio > 1: it is the growth "
|
|
195
|
+
"factor of consecutive element sizes away from the clustered end"
|
|
196
|
+
)
|
|
197
|
+
if self.distribution == "cosine" and self.ratio is not None:
|
|
198
|
+
raise ValueError("ratio only applies to the geometric distribution")
|
|
199
|
+
if self.spacing is not None and self.spacing <= 0.0:
|
|
200
|
+
raise ValueError("spacing must be positive: it is the element size")
|
|
201
|
+
if self.count is not None and self.count < 2:
|
|
202
|
+
raise ValueError("count must be at least 2: an axis needs both endpoints")
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def extent(self) -> float:
|
|
207
|
+
"""Axis length, ``stop - start``."""
|
|
208
|
+
return self.stop - self.start
|
|
209
|
+
|
|
210
|
+
def points(self) -> np.ndarray:
|
|
211
|
+
"""Return the axis points, increasing, endpoints exact."""
|
|
212
|
+
if self.distribution == "uniform":
|
|
213
|
+
if self.spacing is not None:
|
|
214
|
+
cells = max(1, round(self.extent / self.spacing))
|
|
215
|
+
else:
|
|
216
|
+
cells = self.count - 1
|
|
217
|
+
return np.linspace(self.start, self.stop, cells + 1)
|
|
218
|
+
t = np.linspace(0.0, 1.0, self.count)
|
|
219
|
+
if self.distribution == "cosine":
|
|
220
|
+
if self.cluster == "both":
|
|
221
|
+
s = 0.5 * (1.0 - np.cos(np.pi * t))
|
|
222
|
+
elif self.cluster == "start":
|
|
223
|
+
s = 1.0 - np.cos(0.5 * np.pi * t)
|
|
224
|
+
else:
|
|
225
|
+
s = np.sin(0.5 * np.pi * t)
|
|
226
|
+
else:
|
|
227
|
+
cells = self.count - 1
|
|
228
|
+
index = np.arange(cells, dtype=float)
|
|
229
|
+
if self.cluster == "start":
|
|
230
|
+
sizes = self.ratio**index
|
|
231
|
+
elif self.cluster == "end":
|
|
232
|
+
sizes = self.ratio ** index[::-1]
|
|
233
|
+
else:
|
|
234
|
+
sizes = self.ratio ** np.minimum(index, cells - 1 - index)
|
|
235
|
+
s = np.concatenate([[0.0], np.cumsum(sizes)]) / float(np.sum(sizes))
|
|
236
|
+
points = self.start + self.extent * s
|
|
237
|
+
points[0], points[-1] = self.start, self.stop
|
|
238
|
+
return points
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class RefinementBand(BaseModel):
|
|
242
|
+
"""Two-level near-surface refinement of a planar grid.
|
|
243
|
+
|
|
244
|
+
Base cells whose center lies within ``distance`` of the body
|
|
245
|
+
surface (and outside the body) are re-sampled with elements
|
|
246
|
+
``factor`` times finer in both in-plane directions, the
|
|
247
|
+
boundary-layer band of the survey.
|
|
248
|
+
|
|
249
|
+
Attributes
|
|
250
|
+
----------
|
|
251
|
+
distance : float
|
|
252
|
+
Band thickness measured from the surface (simulation length
|
|
253
|
+
units), positive.
|
|
254
|
+
factor : int
|
|
255
|
+
Integer subdivision factor per direction, at least 2.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
259
|
+
|
|
260
|
+
distance: float
|
|
261
|
+
factor: int
|
|
262
|
+
|
|
263
|
+
@model_validator(mode="after")
|
|
264
|
+
def _band_is_physical(self) -> RefinementBand:
|
|
265
|
+
if self.distance <= 0.0:
|
|
266
|
+
raise ValueError(
|
|
267
|
+
"distance must be positive: it is the thickness of the "
|
|
268
|
+
"near-surface band that gets the finer sampling"
|
|
269
|
+
)
|
|
270
|
+
if self.factor < 2:
|
|
271
|
+
raise ValueError("factor must be at least 2, otherwise nothing is refined")
|
|
272
|
+
return self
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class PlanarProbeGrid(BaseModel):
|
|
276
|
+
"""A planar Cartesian probe grid on an explicit frame.
|
|
277
|
+
|
|
278
|
+
Attributes
|
|
279
|
+
----------
|
|
280
|
+
frame : FrameDefinition
|
|
281
|
+
Plane placement; grid points live at local ``(u, v, 0)``.
|
|
282
|
+
u, v : AxisSpec
|
|
283
|
+
In-plane distributions along the frame x and y axes.
|
|
284
|
+
refinement : RefinementBand, optional
|
|
285
|
+
Near-surface two-level refinement, applied by the geometry
|
|
286
|
+
gate (it needs the body mesh to know where the surface is).
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
290
|
+
|
|
291
|
+
frame: FrameDefinition
|
|
292
|
+
u: AxisSpec
|
|
293
|
+
v: AxisSpec
|
|
294
|
+
refinement: RefinementBand | None = None
|
|
295
|
+
|
|
296
|
+
def local_points(self) -> np.ndarray:
|
|
297
|
+
"""Grid nodes in the local frame, shape ``(n_u, n_v, 3)``."""
|
|
298
|
+
u = self.u.points()
|
|
299
|
+
v = self.v.points()
|
|
300
|
+
nodes = np.zeros((len(u), len(v), 3))
|
|
301
|
+
nodes[..., 0] = u[:, None]
|
|
302
|
+
nodes[..., 1] = v[None, :]
|
|
303
|
+
return nodes
|
|
304
|
+
|
|
305
|
+
def base_points(self) -> np.ndarray:
|
|
306
|
+
"""Grid nodes in the reference frame, flat, row-major in (u, v).
|
|
307
|
+
|
|
308
|
+
This ordering (u index major, v index minor) is part of the
|
|
309
|
+
serialization contract: probe-export rows map back to grid
|
|
310
|
+
nodes through it.
|
|
311
|
+
"""
|
|
312
|
+
return self.frame.to_reference(self.local_points()).reshape(-1, 3)
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def shape(self) -> tuple[int, int]:
|
|
316
|
+
"""Node counts ``(n_u, n_v)`` of the base grid."""
|
|
317
|
+
return len(self.u.points()), len(self.v.points())
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def point_count(self) -> int:
|
|
321
|
+
"""Base node count, before culling and refinement."""
|
|
322
|
+
n_u, n_v = self.shape
|
|
323
|
+
return n_u * n_v
|
|
324
|
+
|
|
325
|
+
def to_json(self) -> str:
|
|
326
|
+
"""Serialize the defining data; points are always derived."""
|
|
327
|
+
return self.model_dump_json()
|
|
328
|
+
|
|
329
|
+
@classmethod
|
|
330
|
+
def from_json(cls, text: str) -> PlanarProbeGrid:
|
|
331
|
+
"""Rebuild a grid from :meth:`to_json` output (cross-consumer contract)."""
|
|
332
|
+
return cls.model_validate_json(text)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class GeometryGateReport(BaseModel):
|
|
336
|
+
"""Accounting of what the geometry gate did to a grid.
|
|
337
|
+
|
|
338
|
+
Every run states how many probes it kept, discarded inside the
|
|
339
|
+
body, and added in the refinement band, so no probe budget is
|
|
340
|
+
spent silently (the design rule of the survey work: masked or
|
|
341
|
+
dropped content is always reported, never implied).
|
|
342
|
+
|
|
343
|
+
Attributes
|
|
344
|
+
----------
|
|
345
|
+
base_total : int
|
|
346
|
+
Base grid nodes before the gate.
|
|
347
|
+
base_culled : int
|
|
348
|
+
Base nodes discarded because they fall inside the body.
|
|
349
|
+
base_standoff_culled : int
|
|
350
|
+
Base nodes discarded because they hug the surface closer than
|
|
351
|
+
the standoff margin; a probe on or against the wall samples
|
|
352
|
+
the body's surface state, not the flow (the on-surface probe
|
|
353
|
+
of reports/RPT-004 exported zero velocity).
|
|
354
|
+
refined_added : int
|
|
355
|
+
Refinement-band nodes added (after their own culling).
|
|
356
|
+
refined_culled : int
|
|
357
|
+
Refinement-band candidates discarded inside the body.
|
|
358
|
+
refined_standoff_culled : int
|
|
359
|
+
Refinement-band candidates discarded by the standoff margin.
|
|
360
|
+
band_distance : float, optional
|
|
361
|
+
Refinement band thickness used; None when no refinement ran.
|
|
362
|
+
standoff : float, optional
|
|
363
|
+
Standoff margin used; None when no margin was requested.
|
|
364
|
+
mesh_path : str, optional
|
|
365
|
+
Surface mesh file the gate tested against; None when the gate
|
|
366
|
+
ran without a mesh (no culling, no refinement).
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
370
|
+
|
|
371
|
+
base_total: int
|
|
372
|
+
base_culled: int
|
|
373
|
+
refined_added: int
|
|
374
|
+
refined_culled: int
|
|
375
|
+
base_standoff_culled: int = 0
|
|
376
|
+
refined_standoff_culled: int = 0
|
|
377
|
+
band_distance: float | None = None
|
|
378
|
+
standoff: float | None = None
|
|
379
|
+
mesh_path: str | None = None
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def kept(self) -> int:
|
|
383
|
+
"""Probes that survive the gate."""
|
|
384
|
+
return self.base_total - self.base_culled - self.base_standoff_culled + self.refined_added
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def culled_fraction(self) -> float:
|
|
388
|
+
"""Fraction of base nodes discarded inside the body."""
|
|
389
|
+
return self.base_culled / self.base_total if self.base_total else 0.0
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@dataclass(frozen=True)
|
|
393
|
+
class PlannedProbes:
|
|
394
|
+
"""Final probe positions of a grid after the geometry gate.
|
|
395
|
+
|
|
396
|
+
Attributes
|
|
397
|
+
----------
|
|
398
|
+
grid : PlanarProbeGrid
|
|
399
|
+
The prescribing grid.
|
|
400
|
+
points : numpy.ndarray
|
|
401
|
+
Reference-frame positions, shape ``(n, 3)``, in the
|
|
402
|
+
deterministic order the export parser relies on: surviving
|
|
403
|
+
base nodes first (row-major in u, v), then surviving refined
|
|
404
|
+
nodes (cell-major, sub-row-major).
|
|
405
|
+
report : GeometryGateReport
|
|
406
|
+
The probe accounting of the gate.
|
|
407
|
+
"""
|
|
408
|
+
|
|
409
|
+
grid: PlanarProbeGrid
|
|
410
|
+
points: np.ndarray
|
|
411
|
+
report: GeometryGateReport
|
|
412
|
+
|
|
413
|
+
def verify_positions(self, positions: np.ndarray, *, rtol: float = 5e-4) -> None:
|
|
414
|
+
"""Assert exported probe rows follow this plan's order and count.
|
|
415
|
+
|
|
416
|
+
The 26.120 round trip (reports/RPT-004) shows the solver
|
|
417
|
+
preserves the count and row order of imported probes; this
|
|
418
|
+
check re-validates that contract on every load, because a
|
|
419
|
+
silent reordering would attach fields to the wrong positions.
|
|
420
|
+
The default tolerance matches the export's four significant
|
|
421
|
+
mantissa digits.
|
|
422
|
+
|
|
423
|
+
Parameters
|
|
424
|
+
----------
|
|
425
|
+
positions : numpy.ndarray
|
|
426
|
+
Exported probe positions, shape ``(n, 3)``, for example
|
|
427
|
+
``ProbePointsReport.positions``.
|
|
428
|
+
rtol : float
|
|
429
|
+
Largest tolerated relative coordinate mismatch.
|
|
430
|
+
|
|
431
|
+
Raises
|
|
432
|
+
------
|
|
433
|
+
ValueError
|
|
434
|
+
If the count differs or any row-aligned position deviates
|
|
435
|
+
beyond ``rtol``: the export does not belong to this plan,
|
|
436
|
+
or the solver reordered the probes.
|
|
437
|
+
"""
|
|
438
|
+
positions = np.asarray(positions, dtype=float)
|
|
439
|
+
if positions.shape != self.points.shape:
|
|
440
|
+
raise ValueError(
|
|
441
|
+
f"the export holds {len(positions)} probes but this plan placed "
|
|
442
|
+
f"{len(self.points)}; the export does not belong to this plan"
|
|
443
|
+
)
|
|
444
|
+
scale = np.maximum(np.abs(self.points), 1e-3)
|
|
445
|
+
mismatch = np.abs(positions - self.points) / scale
|
|
446
|
+
worst = int(np.argmax(mismatch.max(axis=1)))
|
|
447
|
+
if float(mismatch.max()) > rtol:
|
|
448
|
+
raise ValueError(
|
|
449
|
+
f"probe row {worst} sits at {positions[worst].tolist()} but the plan "
|
|
450
|
+
f"placed {self.points[worst].tolist()}; the row order contract is "
|
|
451
|
+
"broken (solver reordering, or an export from a different plan)"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def to_json(self) -> str:
|
|
455
|
+
"""Serialize grid, points, and report together.
|
|
456
|
+
|
|
457
|
+
The points are stored explicitly (not re-derived) because they
|
|
458
|
+
depend on the surface mesh the gate ran against; the file is
|
|
459
|
+
the complete loading contract for parsing the probe export.
|
|
460
|
+
"""
|
|
461
|
+
return json.dumps(
|
|
462
|
+
{
|
|
463
|
+
"grid": json.loads(self.grid.to_json()),
|
|
464
|
+
"points": np.asarray(self.points).tolist(),
|
|
465
|
+
"report": json.loads(self.report.model_dump_json()),
|
|
466
|
+
}
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
@classmethod
|
|
470
|
+
def from_json(cls, text: str) -> PlannedProbes:
|
|
471
|
+
"""Rebuild from :meth:`to_json` output."""
|
|
472
|
+
payload = json.loads(text)
|
|
473
|
+
return cls(
|
|
474
|
+
grid=PlanarProbeGrid.model_validate(payload["grid"]),
|
|
475
|
+
points=np.asarray(payload["points"], dtype=float),
|
|
476
|
+
report=GeometryGateReport.model_validate(payload["report"]),
|
|
477
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Probe harness and physics regression tooling.
|
|
2
|
+
|
|
3
|
+
Pipeline role: produces the evidence behind the command database. Tier 2
|
|
4
|
+
probes (:mod:`pyflightstream.qa.probes`) execute each database command in
|
|
5
|
+
a minimal script on a licensed machine and classify it as verified or
|
|
6
|
+
broken (a command that runs but does nothing is broken, not verified);
|
|
7
|
+
:mod:`pyflightstream.qa.compat` writes the compat report under
|
|
8
|
+
``reports/compat/`` and promotes database statuses from it. Tier 3 (the
|
|
9
|
+
physics regression matrix, milestone M4) will land here too. The
|
|
10
|
+
``pyfs-qa`` CLI (:mod:`pyflightstream.qa.cli`) drives both.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pyflightstream.qa.compat import (
|
|
14
|
+
COMPAT_SCHEMA,
|
|
15
|
+
apply_compat,
|
|
16
|
+
read_compat_report,
|
|
17
|
+
write_compat_report,
|
|
18
|
+
)
|
|
19
|
+
from pyflightstream.qa.probes import (
|
|
20
|
+
DEFAULT_ERROR_PATTERNS,
|
|
21
|
+
ProbeArtifacts,
|
|
22
|
+
ProbeEnvironmentError,
|
|
23
|
+
ProbeOutcome,
|
|
24
|
+
ProbeResult,
|
|
25
|
+
ProbeRun,
|
|
26
|
+
ProbeSpec,
|
|
27
|
+
Requires,
|
|
28
|
+
dump_changed,
|
|
29
|
+
dump_gained,
|
|
30
|
+
file_effect,
|
|
31
|
+
generate_probe_script,
|
|
32
|
+
printed_line,
|
|
33
|
+
probe_version,
|
|
34
|
+
region_printed,
|
|
35
|
+
)
|
|
36
|
+
from pyflightstream.qa.specs import PROBE_SPECS
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"COMPAT_SCHEMA",
|
|
40
|
+
"DEFAULT_ERROR_PATTERNS",
|
|
41
|
+
"PROBE_SPECS",
|
|
42
|
+
"ProbeArtifacts",
|
|
43
|
+
"ProbeEnvironmentError",
|
|
44
|
+
"ProbeOutcome",
|
|
45
|
+
"ProbeResult",
|
|
46
|
+
"ProbeRun",
|
|
47
|
+
"ProbeSpec",
|
|
48
|
+
"Requires",
|
|
49
|
+
"apply_compat",
|
|
50
|
+
"dump_changed",
|
|
51
|
+
"dump_gained",
|
|
52
|
+
"file_effect",
|
|
53
|
+
"generate_probe_script",
|
|
54
|
+
"printed_line",
|
|
55
|
+
"probe_version",
|
|
56
|
+
"read_compat_report",
|
|
57
|
+
"region_printed",
|
|
58
|
+
"write_compat_report",
|
|
59
|
+
]
|