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,613 @@
|
|
|
1
|
+
"""Far-field conservation ledgers on the probe lattice.
|
|
2
|
+
|
|
3
|
+
Pipeline role: turns probe-sampled flow fields into the discrete
|
|
4
|
+
conservation ledgers of the far-field extraction (design note DLV-006
|
|
5
|
+
Sec. 3): one annular quadrature, an azimuthal FFT harmonic layer, and
|
|
6
|
+
the force, moment, and loss-channel ledgers, all on xarray structures
|
|
7
|
+
with dims ``(station, r, psi)``.
|
|
8
|
+
|
|
9
|
+
Field names on the dataset: axial velocity ``u`` (m/s, +x downstream),
|
|
10
|
+
transverse Cartesian ``v`` (+y) and ``w`` (+z), cylindrical ``v_r`` and
|
|
11
|
+
``v_theta`` (derived, see :func:`cylindrical_components`), pressure
|
|
12
|
+
perturbation ``p_prime`` = p - p_inf (Pa). Radii and stations are
|
|
13
|
+
nondimensionalized by the lattice tip radius; integrals are returned in
|
|
14
|
+
the field units times square meters (the tip radius rescales the
|
|
15
|
+
quadrature).
|
|
16
|
+
|
|
17
|
+
Solver asymmetry, by design (DLV-006 Sec. 2.3): the FlightStream side
|
|
18
|
+
runs the purely kinematic, reversible ledgers (momentum, angular
|
|
19
|
+
momentum, crossflow kinetic energy) with the constant free-stream
|
|
20
|
+
density. The rothalpy-based irreversible machinery
|
|
21
|
+
(:func:`rothalpy`, :func:`irreversible_deficit`) runs on the Euler CFD
|
|
22
|
+
side only, where total enthalpy and entropy exist; any cross-solver
|
|
23
|
+
delta in a reversible channel is numerics, never physics.
|
|
24
|
+
|
|
25
|
+
The azimuthal rectangle rule is spectrally accurate only on the
|
|
26
|
+
uniform azimuth spacing the lattice guarantees by construction; the
|
|
27
|
+
harmonic layer re-checks the spacing and refuses anything else,
|
|
28
|
+
because a nonuniform azimuth grid silently destroys both the
|
|
29
|
+
quadrature accuracy and the FFT (DLV-006 Sec. 2.2).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from collections.abc import Mapping
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
import xarray as xr
|
|
38
|
+
|
|
39
|
+
from pyflightstream.probes import ProbeLattice
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"lattice_dataset",
|
|
43
|
+
"cylindrical_components",
|
|
44
|
+
"ring_sample_weights",
|
|
45
|
+
"plane_integral",
|
|
46
|
+
"azimuthal_harmonics",
|
|
47
|
+
"symmetry_floor",
|
|
48
|
+
"mass_flux",
|
|
49
|
+
"mass_closure",
|
|
50
|
+
"axial_flux",
|
|
51
|
+
"axial_force",
|
|
52
|
+
"transverse_flux",
|
|
53
|
+
"transverse_force",
|
|
54
|
+
"shaft_torque",
|
|
55
|
+
"in_plane_moment",
|
|
56
|
+
"crossflow_kinetic_energy",
|
|
57
|
+
"rothalpy",
|
|
58
|
+
"irreversible_deficit",
|
|
59
|
+
"to_counts",
|
|
60
|
+
"spurious_diagnostic",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def lattice_dataset(lattice: ProbeLattice, fields: Mapping[str, np.ndarray]) -> xr.Dataset:
|
|
65
|
+
"""Assemble the ledger dataset for the annular planes of a lattice.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
lattice : ProbeLattice
|
|
70
|
+
Lattice that placed the probes; supplies stations, ring
|
|
71
|
+
centers, ring edges, azimuths, and the tip radius.
|
|
72
|
+
fields : mapping of str to numpy.ndarray
|
|
73
|
+
Sampled fields, each shaped ``(n_stations, n_r, n_psi)`` in
|
|
74
|
+
the lattice sampling order.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
xarray.Dataset
|
|
79
|
+
Dims ``(station, r, psi)``; coords ``station`` (x/R), ``r``
|
|
80
|
+
(ring centers, r/R), ``psi`` (rad), plus the ring edges on the
|
|
81
|
+
auxiliary ``r_edge`` dim; ``tip_radius`` stored in ``attrs``
|
|
82
|
+
(simulation length units).
|
|
83
|
+
"""
|
|
84
|
+
shape = (len(lattice.stations), lattice.n_r, lattice.n_psi)
|
|
85
|
+
data_vars = {}
|
|
86
|
+
for name, values in fields.items():
|
|
87
|
+
array = np.asarray(values, dtype=float)
|
|
88
|
+
if array.shape != shape:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"field {name!r} has shape {array.shape}, but this lattice samples "
|
|
91
|
+
f"{shape} (stations, rings, azimuths)"
|
|
92
|
+
)
|
|
93
|
+
data_vars[name] = (("station", "r", "psi"), array)
|
|
94
|
+
return xr.Dataset(
|
|
95
|
+
data_vars,
|
|
96
|
+
coords={
|
|
97
|
+
"station": ("station", np.asarray(lattice.stations)),
|
|
98
|
+
"r": ("r", lattice.ring_centers),
|
|
99
|
+
"psi": ("psi", lattice.psi),
|
|
100
|
+
"r_edge": ("r_edge", np.asarray(lattice.ring_edges)),
|
|
101
|
+
},
|
|
102
|
+
attrs={"tip_radius": lattice.tip_radius},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cylindrical_components(ds: xr.Dataset) -> xr.Dataset:
|
|
107
|
+
"""Add the cylindrical velocity components ``v_r`` and ``v_theta``.
|
|
108
|
+
|
|
109
|
+
Convention (fixed here and pinned by a tier-1 test, DLV-006
|
|
110
|
+
Sec. 3.1): ``y = r sin(psi)``, ``z = r cos(psi)``, so the radial
|
|
111
|
+
unit vector is ``(0, sin psi, cos psi)`` and the azimuthal unit
|
|
112
|
+
vector, in the direction of growing psi (+z toward +y), is
|
|
113
|
+
``(0, cos psi, -sin psi)``. A right-handed rotation about +x
|
|
114
|
+
therefore has negative ``v_theta`` in this convention.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
ds : xarray.Dataset
|
|
119
|
+
Ledger dataset carrying ``v`` and ``w`` (m/s).
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
xarray.Dataset
|
|
124
|
+
Copy of ``ds`` with ``v_r`` and ``v_theta`` added (m/s).
|
|
125
|
+
"""
|
|
126
|
+
sin_psi = np.sin(ds.coords["psi"])
|
|
127
|
+
cos_psi = np.cos(ds.coords["psi"])
|
|
128
|
+
out = ds.copy()
|
|
129
|
+
out["v_r"] = ds["v"] * sin_psi + ds["w"] * cos_psi
|
|
130
|
+
out["v_theta"] = ds["v"] * cos_psi - ds["w"] * sin_psi
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _delta_psi(da: xr.DataArray | xr.Dataset) -> float:
|
|
135
|
+
psi = np.asarray(da.coords["psi"])
|
|
136
|
+
steps = np.diff(np.concatenate([psi, [psi[0] + 2.0 * np.pi]]))
|
|
137
|
+
if not np.allclose(steps, steps[0], rtol=1e-10, atol=1e-12):
|
|
138
|
+
raise ValueError(
|
|
139
|
+
"the azimuth grid is not uniform; uniform periodic sampling is what "
|
|
140
|
+
"makes the rectangle rule spectrally accurate and the FFT direct "
|
|
141
|
+
"(DLV-006 Sec. 2.2), so nonuniform azimuths are refused, never averaged"
|
|
142
|
+
)
|
|
143
|
+
return float(steps[0])
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def ring_sample_weights(ds: xr.Dataset) -> xr.DataArray:
|
|
147
|
+
"""Per-sample annular quadrature weights on the ``r`` dim.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
xarray.DataArray
|
|
152
|
+
``0.5 (r_{j+1/2}^2 - r_{j-1/2}^2) dpsi`` per ring, in units of
|
|
153
|
+
the tip radius squared; multiplying an integrand sample and
|
|
154
|
+
summing over ``(r, psi)`` is the whole plane quadrature of
|
|
155
|
+
DLV-006 Sec. 3.1.
|
|
156
|
+
"""
|
|
157
|
+
edges = np.asarray(ds.coords["r_edge"])
|
|
158
|
+
weights = 0.5 * (edges[1:] ** 2 - edges[:-1] ** 2) * _delta_psi(ds)
|
|
159
|
+
return xr.DataArray(weights, dims=("r",), coords={"r": ds.coords["r"]})
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def plane_integral(ds: xr.Dataset, integrand: xr.DataArray) -> xr.DataArray:
|
|
163
|
+
"""Integrate a sampled field over each annular plane.
|
|
164
|
+
|
|
165
|
+
Implemented once and reused by every ledger (DLV-006 Sec. 3.1):
|
|
166
|
+
the ring-edge rule in radius times the rectangle rule in azimuth.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
ds : xarray.Dataset
|
|
171
|
+
Ledger dataset supplying weights and the tip radius.
|
|
172
|
+
integrand : xarray.DataArray
|
|
173
|
+
Any array with dims including ``(r, psi)``.
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
xarray.DataArray
|
|
178
|
+
The integral over ``(r, psi)``, remaining dims preserved, in
|
|
179
|
+
integrand units times square meters.
|
|
180
|
+
"""
|
|
181
|
+
scale = float(ds.attrs["tip_radius"]) ** 2
|
|
182
|
+
return (integrand * ring_sample_weights(ds)).sum(dim=("r", "psi")) * scale
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def azimuthal_harmonics(da: xr.DataArray, *, m_max: int = 6) -> xr.DataArray:
|
|
186
|
+
"""Azimuthal Fourier coefficients of a sampled field, the ledger spine.
|
|
187
|
+
|
|
188
|
+
Coefficients follow ``f(psi) = sum_m c_m exp(i m psi)`` with
|
|
189
|
+
``c_m = (1/N) sum_k f_k exp(-i m psi_k)``; for real fields the
|
|
190
|
+
negative orders are the conjugates and are not stored.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
da : xarray.DataArray
|
|
195
|
+
Field with a ``psi`` dim on the uniform lattice azimuths.
|
|
196
|
+
m_max : int
|
|
197
|
+
Highest stored order; DLV-006 Sec. 3.2 requires at least 6.
|
|
198
|
+
|
|
199
|
+
Returns
|
|
200
|
+
-------
|
|
201
|
+
xarray.DataArray
|
|
202
|
+
Complex coefficients with the ``psi`` dim replaced by ``m``
|
|
203
|
+
(orders 0..m_max). Order 0 carries forces, order 1 the
|
|
204
|
+
in-plane moments, orders 2 and above the distortion a
|
|
205
|
+
downstream surface sees.
|
|
206
|
+
"""
|
|
207
|
+
_delta_psi(da)
|
|
208
|
+
n = da.sizes["psi"]
|
|
209
|
+
if m_max >= n // 2:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"m_max={m_max} needs more than {2 * m_max} uniform azimuths to avoid "
|
|
212
|
+
f"aliasing; this grid has {n}"
|
|
213
|
+
)
|
|
214
|
+
spectrum = np.fft.fft(da.values, axis=da.dims.index("psi")) / n
|
|
215
|
+
kept = np.take(spectrum, np.arange(m_max + 1), axis=da.dims.index("psi"))
|
|
216
|
+
dims = tuple("m" if dim == "psi" else dim for dim in da.dims)
|
|
217
|
+
coords = {
|
|
218
|
+
name: coord
|
|
219
|
+
for name, coord in da.coords.items()
|
|
220
|
+
if name != "psi" and "psi" not in coord.dims
|
|
221
|
+
}
|
|
222
|
+
coords["m"] = np.arange(m_max + 1)
|
|
223
|
+
return xr.DataArray(kept, dims=dims, coords=coords)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def symmetry_floor(da: xr.DataArray, *, m_max: int = 6) -> float:
|
|
227
|
+
"""Largest azimuthal harmonic magnitude above order zero.
|
|
228
|
+
|
|
229
|
+
At zero incidence every ``m >= 1`` harmonic must vanish to the
|
|
230
|
+
numerical floor; the recorded floor becomes the detectability
|
|
231
|
+
threshold for the nonzero-incidence step (DLV-006 Sec. 3.2, gate
|
|
232
|
+
G3).
|
|
233
|
+
|
|
234
|
+
Returns
|
|
235
|
+
-------
|
|
236
|
+
float
|
|
237
|
+
``max_{m>=1} |c_m|`` over all remaining dims, in field units.
|
|
238
|
+
"""
|
|
239
|
+
harmonics = azimuthal_harmonics(da, m_max=m_max)
|
|
240
|
+
return float(np.abs(harmonics.sel(m=slice(1, None))).max())
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def mass_flux(ds: xr.Dataset, rho: float) -> xr.DataArray:
|
|
244
|
+
"""Mass flow through each annular plane, ``integral(rho u dA)`` in kg/s."""
|
|
245
|
+
return plane_integral(ds, rho * ds["u"])
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def mass_closure(ds: xr.Dataset, rho: float) -> xr.Dataset:
|
|
249
|
+
"""Gate G1: station-to-station mass-flow closure diagnostic.
|
|
250
|
+
|
|
251
|
+
Returns
|
|
252
|
+
-------
|
|
253
|
+
xarray.Dataset
|
|
254
|
+
``mdot`` per station (kg/s) and the scalar ``relative_spread``
|
|
255
|
+
(max deviation from the station mean over the mean); reported
|
|
256
|
+
per run, judged against the case tolerance.
|
|
257
|
+
"""
|
|
258
|
+
mdot = mass_flux(ds, rho)
|
|
259
|
+
mean = mdot.mean()
|
|
260
|
+
spread = float((np.abs(mdot - mean)).max() / np.abs(mean))
|
|
261
|
+
return xr.Dataset({"mdot": mdot, "relative_spread": xr.DataArray(spread)})
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def axial_flux(ds: xr.Dataset, rho: float, v_inf: float) -> xr.DataArray:
|
|
265
|
+
"""Axial momentum-plus-pressure flux per station (N).
|
|
266
|
+
|
|
267
|
+
``integral(rho u (u - v_inf) + p_prime) dA`` with the axial
|
|
268
|
+
perturbation ``u' = u - v_inf`` (DLV-006 Sec. 3.3).
|
|
269
|
+
"""
|
|
270
|
+
return plane_integral(ds, rho * ds["u"] * (ds["u"] - v_inf) + ds["p_prime"])
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def axial_force(ds: xr.Dataset, rho: float, v_inf: float, *, inlet: float, outlet: float) -> float:
|
|
274
|
+
"""Axial force from the control volume between two stations (N).
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
ds : xarray.Dataset
|
|
279
|
+
Ledger dataset.
|
|
280
|
+
rho : float
|
|
281
|
+
Density (kg/m3); the free-stream value on the FlightStream side.
|
|
282
|
+
v_inf : float
|
|
283
|
+
Free-stream axial velocity (m/s).
|
|
284
|
+
inlet, outlet : float
|
|
285
|
+
Station coordinates x/R of the inlet and outlet planes; the
|
|
286
|
+
lateral cylinder is assumed at free stream, which the lateral
|
|
287
|
+
ring probes must verify (DLV-006 Sec. 2.1).
|
|
288
|
+
|
|
289
|
+
Returns
|
|
290
|
+
-------
|
|
291
|
+
float
|
|
292
|
+
``F_X`` = outlet flux minus inlet flux, +x downstream, so a
|
|
293
|
+
propulsive disk yields positive axial force.
|
|
294
|
+
"""
|
|
295
|
+
flux = axial_flux(ds, rho, v_inf)
|
|
296
|
+
return float(flux.sel(station=outlet) - flux.sel(station=inlet))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def transverse_flux(
|
|
300
|
+
ds: xr.Dataset,
|
|
301
|
+
rho: float,
|
|
302
|
+
*,
|
|
303
|
+
component: str = "w",
|
|
304
|
+
method: str = "quadrature",
|
|
305
|
+
) -> xr.DataArray:
|
|
306
|
+
"""Transverse momentum flux ``integral(rho u c dA)`` per station (N).
|
|
307
|
+
|
|
308
|
+
Parameters
|
|
309
|
+
----------
|
|
310
|
+
ds : xarray.Dataset
|
|
311
|
+
Ledger dataset.
|
|
312
|
+
rho : float
|
|
313
|
+
Density (kg/m3).
|
|
314
|
+
component : str
|
|
315
|
+
Transverse velocity component: ``"w"`` (+z) for the F_Z / M_Y
|
|
316
|
+
ledger, ``"v"`` (+y) for F_Y / M_Z.
|
|
317
|
+
method : str
|
|
318
|
+
``"quadrature"`` is the direct product integral;
|
|
319
|
+
``"harmonic"`` evaluates the same number from the azimuthal
|
|
320
|
+
spectra of ``u`` and the component (Parseval), the second,
|
|
321
|
+
independent code path DLV-006 Sec. 3.3 demands; one tier-1
|
|
322
|
+
test holds them together.
|
|
323
|
+
"""
|
|
324
|
+
if method == "quadrature":
|
|
325
|
+
return plane_integral(ds, rho * ds["u"] * ds[component])
|
|
326
|
+
if method != "harmonic":
|
|
327
|
+
raise ValueError(f"unknown method {method!r}; use 'quadrature' or 'harmonic'")
|
|
328
|
+
m_max = ds.sizes["psi"] // 2 - 1
|
|
329
|
+
u_hat = azimuthal_harmonics(ds["u"], m_max=m_max)
|
|
330
|
+
c_hat = azimuthal_harmonics(ds[component], m_max=m_max)
|
|
331
|
+
# Parseval for real fields: mean(u c) over psi = sum_m Re[u_m conj(c_m)]
|
|
332
|
+
# with orders m >= 1 counted twice (conjugate pairs).
|
|
333
|
+
weights = np.where(np.arange(m_max + 1) == 0, 1.0, 2.0)
|
|
334
|
+
product_mean = (np.real(u_hat * np.conj(c_hat)) * xr.DataArray(weights, dims=("m",))).sum("m")
|
|
335
|
+
ring_area = ring_sample_weights(ds) * ds.sizes["psi"]
|
|
336
|
+
scale = float(ds.attrs["tip_radius"]) ** 2
|
|
337
|
+
return (rho * product_mean * ring_area).sum("r") * scale
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def transverse_force(
|
|
341
|
+
ds: xr.Dataset,
|
|
342
|
+
rho: float,
|
|
343
|
+
*,
|
|
344
|
+
inlet: float,
|
|
345
|
+
outlet: float,
|
|
346
|
+
component: str = "w",
|
|
347
|
+
lateral_flux: float = 0.0,
|
|
348
|
+
) -> xr.Dataset:
|
|
349
|
+
"""Transverse force with the lateral term reported separately (N).
|
|
350
|
+
|
|
351
|
+
``F_Z = integral_out(rho u w dA) - integral_in(rho u w dA) +
|
|
352
|
+
Delta_lat`` (DLV-006 Sec. 3.3). The lateral-cylinder momentum and
|
|
353
|
+
pressure flux is never folded silently into the plane term: it is
|
|
354
|
+
a separate variable of the result, zero only when zero incidence
|
|
355
|
+
makes it so.
|
|
356
|
+
|
|
357
|
+
Parameters
|
|
358
|
+
----------
|
|
359
|
+
ds : xarray.Dataset
|
|
360
|
+
Ledger dataset.
|
|
361
|
+
rho : float
|
|
362
|
+
Density (kg/m3).
|
|
363
|
+
inlet, outlet : float
|
|
364
|
+
Station coordinates x/R of the bounding planes.
|
|
365
|
+
component : str
|
|
366
|
+
``"w"`` for F_Z, ``"v"`` for F_Y.
|
|
367
|
+
lateral_flux : float
|
|
368
|
+
Lateral-surface momentum-plus-pressure flux (N), evaluated
|
|
369
|
+
from the lateral ring probes; passing the default 0.0 is
|
|
370
|
+
legitimate only at zero incidence, where symmetry nulls it.
|
|
371
|
+
|
|
372
|
+
Returns
|
|
373
|
+
-------
|
|
374
|
+
xarray.Dataset
|
|
375
|
+
``plane_term``, ``lateral_term``, and ``total`` (N).
|
|
376
|
+
"""
|
|
377
|
+
flux = transverse_flux(ds, rho, component=component)
|
|
378
|
+
plane_term = float(flux.sel(station=outlet) - flux.sel(station=inlet))
|
|
379
|
+
return xr.Dataset(
|
|
380
|
+
{
|
|
381
|
+
"plane_term": xr.DataArray(plane_term),
|
|
382
|
+
"lateral_term": xr.DataArray(float(lateral_flux)),
|
|
383
|
+
"total": xr.DataArray(plane_term + float(lateral_flux)),
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def shaft_torque(ds: xr.Dataset, rho: float) -> xr.DataArray:
|
|
389
|
+
"""Swirl flux ``integral(rho (r v_theta) u dA)`` per station (N m).
|
|
390
|
+
|
|
391
|
+
The order-0 ``v_theta`` content weighted by the dimensional moment
|
|
392
|
+
arm ``r`` (DLV-006 Sec. 3.4); the sign follows the ``v_theta``
|
|
393
|
+
convention of :func:`cylindrical_components`.
|
|
394
|
+
"""
|
|
395
|
+
arm = ds.coords["r"] * float(ds.attrs["tip_radius"])
|
|
396
|
+
return plane_integral(ds, rho * arm * ds["v_theta"] * ds["u"])
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def in_plane_moment(
|
|
400
|
+
ds: xr.Dataset,
|
|
401
|
+
rho: float,
|
|
402
|
+
v_inf: float,
|
|
403
|
+
*,
|
|
404
|
+
inlet: float,
|
|
405
|
+
outlet: float,
|
|
406
|
+
axis: str = "y",
|
|
407
|
+
method: str = "quadrature",
|
|
408
|
+
) -> xr.Dataset:
|
|
409
|
+
"""In-plane moment about an axis through the disk center (N m).
|
|
410
|
+
|
|
411
|
+
For ``axis="y"`` (DLV-006 Sec. 3.4): ``M_Y = integral(z g dA) -
|
|
412
|
+
x_p integral(rho u w dA)`` evaluated at the outlet minus the same
|
|
413
|
+
terms at the inlet, with ``g = rho u u' + p_prime`` and
|
|
414
|
+
``z = r cos(psi)``. The two contributions stay separate in the
|
|
415
|
+
output: the loading term is the order-1 cosine harmonic of the
|
|
416
|
+
axial flux (the 1P loading asymmetry) and the moment-arm term
|
|
417
|
+
carries the transverse flux; their ratio is the measured
|
|
418
|
+
disk-distortion versus tube-deflection split. ``axis="z"`` uses
|
|
419
|
+
``y = r sin(psi)`` and ``v`` instead.
|
|
420
|
+
|
|
421
|
+
Parameters
|
|
422
|
+
----------
|
|
423
|
+
ds : xarray.Dataset
|
|
424
|
+
Ledger dataset.
|
|
425
|
+
rho, v_inf : float
|
|
426
|
+
Density (kg/m3) and free-stream axial velocity (m/s).
|
|
427
|
+
inlet, outlet : float
|
|
428
|
+
Station coordinates x/R of the bounding planes; the moment arm
|
|
429
|
+
of each plane is its own station position.
|
|
430
|
+
axis : str
|
|
431
|
+
``"y"`` or ``"z"``, the moment axis through the disk center.
|
|
432
|
+
method : str
|
|
433
|
+
``"quadrature"`` weights the axial flux by the sampled lever
|
|
434
|
+
arm directly; ``"harmonic"`` rebuilds the loading term from
|
|
435
|
+
the order-1 azimuthal coefficient. Two independent code paths,
|
|
436
|
+
one test (DLV-006 Sec. 3.3 note).
|
|
437
|
+
|
|
438
|
+
Returns
|
|
439
|
+
-------
|
|
440
|
+
xarray.Dataset
|
|
441
|
+
``loading_term``, ``moment_arm_term``, and ``total`` (N m).
|
|
442
|
+
"""
|
|
443
|
+
if axis == "y":
|
|
444
|
+
lever = ds.coords["r"] * np.cos(ds.coords["psi"])
|
|
445
|
+
component = "w"
|
|
446
|
+
elif axis == "z":
|
|
447
|
+
lever = ds.coords["r"] * np.sin(ds.coords["psi"])
|
|
448
|
+
component = "v"
|
|
449
|
+
else:
|
|
450
|
+
raise ValueError(f"axis must be 'y' or 'z', got {axis!r}")
|
|
451
|
+
|
|
452
|
+
g = rho * ds["u"] * (ds["u"] - v_inf) + ds["p_prime"]
|
|
453
|
+
tip = float(ds.attrs["tip_radius"])
|
|
454
|
+
if method == "quadrature":
|
|
455
|
+
loading = plane_integral(ds, lever * tip * g)
|
|
456
|
+
elif method == "harmonic":
|
|
457
|
+
# sum_k cos(psi_k) g = N Re[g_hat_1]; sum_k sin(psi_k) g = -N Im[g_hat_1]
|
|
458
|
+
g_hat_1 = azimuthal_harmonics(g, m_max=1).sel(m=1)
|
|
459
|
+
projected = np.real(g_hat_1) if axis == "y" else -np.imag(g_hat_1)
|
|
460
|
+
ring_area = ring_sample_weights(ds) * ds.sizes["psi"]
|
|
461
|
+
loading = (projected * ds.coords["r"] * ring_area).sum("r") * tip**3
|
|
462
|
+
else:
|
|
463
|
+
raise ValueError(f"unknown method {method!r}; use 'quadrature' or 'harmonic'")
|
|
464
|
+
|
|
465
|
+
arm_flux = transverse_flux(ds, rho, component=component)
|
|
466
|
+
arm = -ds.coords["station"] * tip * arm_flux
|
|
467
|
+
loading_term = float(loading.sel(station=outlet) - loading.sel(station=inlet))
|
|
468
|
+
moment_arm_term = float(arm.sel(station=outlet) - arm.sel(station=inlet))
|
|
469
|
+
return xr.Dataset(
|
|
470
|
+
{
|
|
471
|
+
"loading_term": xr.DataArray(loading_term),
|
|
472
|
+
"moment_arm_term": xr.DataArray(moment_arm_term),
|
|
473
|
+
"total": xr.DataArray(loading_term + moment_arm_term),
|
|
474
|
+
}
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def crossflow_kinetic_energy(ds: xr.Dataset, rho: float) -> xr.Dataset:
|
|
479
|
+
"""Crossflow kinetic-energy flux split into swirl and induced content (W).
|
|
480
|
+
|
|
481
|
+
``E = 0.5 integral(rho (v_r^2 + v_theta^2) u dA)`` per station,
|
|
482
|
+
with the axisymmetric (order-0) ``v_theta`` part reported as the
|
|
483
|
+
stator-recoverable swirl channel and the remainder as the
|
|
484
|
+
non-axisymmetric induced channel (DLV-006 Sec. 3.5). The
|
|
485
|
+
integrand is Maskell-style, transverse components only: the
|
|
486
|
+
induced channel contains no axial-deficit term by construction,
|
|
487
|
+
which is a hard rule of the design note, not a modeling choice.
|
|
488
|
+
|
|
489
|
+
Returns
|
|
490
|
+
-------
|
|
491
|
+
xarray.Dataset
|
|
492
|
+
``total``, ``swirl``, and ``induced`` fluxes per station (W).
|
|
493
|
+
"""
|
|
494
|
+
v_theta_0 = ds["v_theta"].mean("psi")
|
|
495
|
+
total = plane_integral(ds, 0.5 * rho * (ds["v_r"] ** 2 + ds["v_theta"] ** 2) * ds["u"])
|
|
496
|
+
swirl = plane_integral(ds, 0.5 * rho * v_theta_0**2 * ds["u"])
|
|
497
|
+
return xr.Dataset({"total": total, "swirl": swirl, "induced": total - swirl})
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def rothalpy(
|
|
501
|
+
h_total: xr.DataArray,
|
|
502
|
+
omega: float,
|
|
503
|
+
r: xr.DataArray,
|
|
504
|
+
v_theta: xr.DataArray,
|
|
505
|
+
) -> xr.DataArray:
|
|
506
|
+
"""Rothalpy ``I = H - omega r v_theta`` (J/kg), Euler side only.
|
|
507
|
+
|
|
508
|
+
Conserved along relative streamlines of a steady inviscid flow, so
|
|
509
|
+
its change measures irreversibility; on the rotating side every
|
|
510
|
+
thermodynamic invariant is rothalpy based, never total-enthalpy
|
|
511
|
+
based (DLV-006 requirement R3).
|
|
512
|
+
|
|
513
|
+
Parameters
|
|
514
|
+
----------
|
|
515
|
+
h_total : xarray.DataArray
|
|
516
|
+
Total enthalpy H (J/kg), available on the Euler side only.
|
|
517
|
+
omega : float
|
|
518
|
+
Shaft angular speed (rad/s), positive about +x.
|
|
519
|
+
r : xarray.DataArray
|
|
520
|
+
Dimensional radius (m).
|
|
521
|
+
v_theta : xarray.DataArray
|
|
522
|
+
Azimuthal velocity (m/s) in the convention of
|
|
523
|
+
:func:`cylindrical_components`.
|
|
524
|
+
"""
|
|
525
|
+
return h_total - omega * r * v_theta
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def irreversible_deficit(
|
|
529
|
+
w_rel: xr.DataArray,
|
|
530
|
+
delta_rothalpy: xr.DataArray,
|
|
531
|
+
entropy_enthalpy_rise: xr.DataArray,
|
|
532
|
+
) -> xr.Dataset:
|
|
533
|
+
"""Guarded irreversible relative-velocity deficit (m/s), Euler side only.
|
|
534
|
+
|
|
535
|
+
The ideal relative speed a particle would keep on an isentropic,
|
|
536
|
+
rothalpy-conserving relative streamline is
|
|
537
|
+
``sqrt(w_rel^2 + 2 (delta_I - delta_h_s))``; the deficit is the
|
|
538
|
+
actual speed minus that ideal, nonzero only where irreversible
|
|
539
|
+
processes (viscous dissipation, shocks, or their spurious
|
|
540
|
+
numerical counterparts) acted. In a clean subsonic Euler solution
|
|
541
|
+
this channel measures spurious numerical entropy and is reported
|
|
542
|
+
in counts per station (DLV-006 Sec. 3.5).
|
|
543
|
+
|
|
544
|
+
Radicand guard (DLV-006 requirement R4): cells whose radicand goes
|
|
545
|
+
negative are masked to NaN, never clipped, and the masked fraction
|
|
546
|
+
is reported with the result so every run states how much of the
|
|
547
|
+
field the evaluation could not honor.
|
|
548
|
+
|
|
549
|
+
Parameters
|
|
550
|
+
----------
|
|
551
|
+
w_rel : xarray.DataArray
|
|
552
|
+
Relative-frame speed (m/s).
|
|
553
|
+
delta_rothalpy : xarray.DataArray
|
|
554
|
+
Rothalpy increment relative to the upstream state (J/kg).
|
|
555
|
+
entropy_enthalpy_rise : xarray.DataArray
|
|
556
|
+
Static-enthalpy rise attributable to the entropy increment
|
|
557
|
+
(J/kg); zero in a perfectly clean solution.
|
|
558
|
+
|
|
559
|
+
Returns
|
|
560
|
+
-------
|
|
561
|
+
xarray.Dataset
|
|
562
|
+
``w_ideal`` and ``deficit`` (m/s, NaN where masked) plus the
|
|
563
|
+
scalar ``masked_fraction``.
|
|
564
|
+
"""
|
|
565
|
+
radicand = w_rel**2 + 2.0 * (delta_rothalpy - entropy_enthalpy_rise)
|
|
566
|
+
masked = radicand < 0.0
|
|
567
|
+
w_ideal = xr.where(masked, np.nan, np.sqrt(xr.where(masked, 0.0, radicand)))
|
|
568
|
+
fraction = float(masked.mean())
|
|
569
|
+
return xr.Dataset(
|
|
570
|
+
{
|
|
571
|
+
"w_ideal": w_ideal,
|
|
572
|
+
"deficit": w_rel - w_ideal,
|
|
573
|
+
"masked_fraction": xr.DataArray(fraction),
|
|
574
|
+
}
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def to_counts(delta: float, *, rho_inf: float, v_inf: float, s_ref: float = 1.0) -> float:
|
|
579
|
+
"""Express a force delta in drag counts.
|
|
580
|
+
|
|
581
|
+
One count is ``1e-4`` of the dynamic-pressure force scale:
|
|
582
|
+
``counts = 2 delta / (rho_inf v_inf^2 s_ref) * 1e4``, the
|
|
583
|
+
bookkeeping unit the far-field literature uses for small loss
|
|
584
|
+
increments.
|
|
585
|
+
|
|
586
|
+
Parameters
|
|
587
|
+
----------
|
|
588
|
+
delta : float
|
|
589
|
+
Force difference (N).
|
|
590
|
+
rho_inf, v_inf : float
|
|
591
|
+
Free-stream density (kg/m3) and speed (m/s).
|
|
592
|
+
s_ref : float
|
|
593
|
+
Reference area (m2); 1.0 by the literature convention.
|
|
594
|
+
"""
|
|
595
|
+
return 2.0 * delta / (rho_inf * v_inf**2 * s_ref) * 1.0e4
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def spurious_diagnostic(
|
|
599
|
+
near_field: float,
|
|
600
|
+
far_field: float,
|
|
601
|
+
*,
|
|
602
|
+
rho_inf: float,
|
|
603
|
+
v_inf: float,
|
|
604
|
+
s_ref: float = 1.0,
|
|
605
|
+
) -> float:
|
|
606
|
+
"""Near-field minus far-field load, in counts (gate G4).
|
|
607
|
+
|
|
608
|
+
The residual between the integrated near-field load and the
|
|
609
|
+
far-field ledger is numerical, not physical; reported per station
|
|
610
|
+
in counts so runs are comparable across cases and solvers
|
|
611
|
+
(DLV-006 Sec. 3.5).
|
|
612
|
+
"""
|
|
613
|
+
return to_counts(near_field - far_field, rho_inf=rho_inf, v_inf=v_inf, s_ref=s_ref)
|