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,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)