linstabpy 1.0.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.
- linstabpy/__init__.py +83 -0
- linstabpy/baseflow/__init__.py +8 -0
- linstabpy/baseflow/baseflow.py +58 -0
- linstabpy/baseflow/grid_fields.py +146 -0
- linstabpy/baseflow/preprocess.py +250 -0
- linstabpy/baseflow/shock_cleanup.py +465 -0
- linstabpy/baseflow/sponge.py +443 -0
- linstabpy/baseflow/viscosity.py +66 -0
- linstabpy/boundary_conditions/__init__.py +44 -0
- linstabpy/boundary_conditions/dirichlet.py +436 -0
- linstabpy/boundary_conditions/neumann.py +349 -0
- linstabpy/boundary_conditions/recipe.py +543 -0
- linstabpy/config/__init__.py +29 -0
- linstabpy/config/defaults.py +89 -0
- linstabpy/config/env_registry.py +261 -0
- linstabpy/config/schema.py +341 -0
- linstabpy/config/typed.py +668 -0
- linstabpy/driver/__init__.py +14 -0
- linstabpy/driver/cli.py +242 -0
- linstabpy/driver/io.py +111 -0
- linstabpy/driver/payload.py +120 -0
- linstabpy/driver/preflight.py +625 -0
- linstabpy/driver/run.py +1826 -0
- linstabpy/linalg/__init__.py +40 -0
- linstabpy/linalg/backends.py +641 -0
- linstabpy/linalg/cudss_factor.py +426 -0
- linstabpy/linalg/factorize.py +1115 -0
- linstabpy/linalg/shifted.py +62 -0
- linstabpy/mesh/__init__.py +44 -0
- linstabpy/mesh/fdweights.py +38 -0
- linstabpy/mesh/filters.py +489 -0
- linstabpy/mesh/mesh.py +1859 -0
- linstabpy/mesh/sbp.py +951 -0
- linstabpy/mesh/sbp_generator.py +438 -0
- linstabpy/metrics_schema.py +151 -0
- linstabpy/mumps_options.py +14 -0
- linstabpy/operators/__init__.py +20 -0
- linstabpy/operators/assembly.py +1988 -0
- linstabpy/operators/coefficients.py +1831 -0
- linstabpy/operators/energy_norm.py +92 -0
- linstabpy/operators/metrics.py +297 -0
- linstabpy/operators/spatial.py +293 -0
- linstabpy/resolvent/__init__.py +29 -0
- linstabpy/resolvent/analysis.py +513 -0
- linstabpy/resolvent/conservative_forcing.py +341 -0
- linstabpy/resolvent/gain.py +205 -0
- linstabpy/resolvent/operator.py +128 -0
- linstabpy/solvers/__init__.py +31 -0
- linstabpy/solvers/_eps_helpers.py +142 -0
- linstabpy/solvers/eigenvalue.py +757 -0
- linstabpy/solvers/petsc_utils.py +20 -0
- linstabpy/solvers/router/__init__.py +79 -0
- linstabpy/solvers/router/anchors.py +363 -0
- linstabpy/solvers/router/band_marking.py +306 -0
- linstabpy/solvers/router/break_even.py +277 -0
- linstabpy/solvers/router/certification.py +411 -0
- linstabpy/solvers/router/map_record.py +174 -0
- linstabpy/solvers/router/map_walk.py +621 -0
- linstabpy/solvers/router/ram_autoselect.py +435 -0
- linstabpy/solvers/router/recycling.py +346 -0
- linstabpy/solvers/router/resolvent_map.py +1690 -0
- linstabpy/solvers/router/stable_sweep.py +1183 -0
- linstabpy/solvers/spatial_eig.py +218 -0
- linstabpy/stability/__init__.py +38 -0
- linstabpy/stability/_eigenvalues.py +831 -0
- linstabpy/stability/acceptance.py +303 -0
- linstabpy/stability/sensitivity.py +216 -0
- linstabpy/utils/__init__.py +10 -0
- linstabpy/utils/_solver_cfg.py +169 -0
- linstabpy/utils/cost_metrics.py +147 -0
- linstabpy/utils/floquet.py +71 -0
- linstabpy/utils/petsc_core.py +912 -0
- linstabpy/utils/petsc_init.py +120 -0
- linstabpy/utils/provenance.py +216 -0
- linstabpy/utils/yaml_io.py +65 -0
- linstabpy-1.0.0.dist-info/METADATA +1071 -0
- linstabpy-1.0.0.dist-info/RECORD +82 -0
- linstabpy-1.0.0.dist-info/WHEEL +5 -0
- linstabpy-1.0.0.dist-info/entry_points.txt +2 -0
- linstabpy-1.0.0.dist-info/licenses/LICENSE +674 -0
- linstabpy-1.0.0.dist-info/licenses/NOTICE +53 -0
- linstabpy-1.0.0.dist-info/top_level.txt +1 -0
linstabpy/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
"""Stability and resolvent analysis of compressible viscous flows.
|
|
3
|
+
|
|
4
|
+
The linearized Navier-Stokes operator is assembled on a two-dimensional
|
|
5
|
+
structured mesh. Summation-by-parts finite differences cover orders 2 to 8.
|
|
6
|
+
Temporal and adjoint eigenvalue problems, structural sensitivity, and
|
|
7
|
+
resolvent gain use PETSc, SLEPc, and a sparse direct factor (MUMPS by default).
|
|
8
|
+
|
|
9
|
+
A plain ``import linstabpy`` does not start PETSc or MPI. Public names load
|
|
10
|
+
on first use. The loader caches each name on this module.
|
|
11
|
+
|
|
12
|
+
Public names
|
|
13
|
+
------------
|
|
14
|
+
build_lnse_system, build_lnse_operator
|
|
15
|
+
Sparse LNSE operator. State ``q = [rho, u, v, w, T]``.
|
|
16
|
+
compute_chu_norm
|
|
17
|
+
Chu compressible energy inner product.
|
|
18
|
+
temporal_eigenvalues, adjoint_eigenvalues
|
|
19
|
+
SLEPc temporal eigenvalue problems.
|
|
20
|
+
wavemaker
|
|
21
|
+
Structural sensitivity from direct and adjoint modes.
|
|
22
|
+
resolvent_analysis, resolvent_map
|
|
23
|
+
Leading gain at one frequency, or a frequency walk.
|
|
24
|
+
BaseFlow
|
|
25
|
+
Mean flow, sponge damping, and viscosity.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import importlib
|
|
31
|
+
import re
|
|
32
|
+
from importlib import metadata
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
_PYPROJECT = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
36
|
+
_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"\s*$', re.MULTILINE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _package_version() -> str:
|
|
40
|
+
"""Return the version from pyproject.toml, then installed metadata."""
|
|
41
|
+
if _PYPROJECT.is_file():
|
|
42
|
+
match = _VERSION_RE.search(_PYPROJECT.read_text(encoding="utf-8"))
|
|
43
|
+
if match is not None:
|
|
44
|
+
return match.group(1)
|
|
45
|
+
try:
|
|
46
|
+
return metadata.version("linstabpy")
|
|
47
|
+
except metadata.PackageNotFoundError:
|
|
48
|
+
return "0.0.0+dev"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__version__ = _package_version()
|
|
52
|
+
|
|
53
|
+
# Each public name maps to (module, attribute). ``temporal_eigenvalues`` is
|
|
54
|
+
# the public alias of ``stability._eigenvalues.eigenvalues``.
|
|
55
|
+
_LAZY_EXPORTS: dict[str, tuple[str, str]] = {
|
|
56
|
+
"build_lnse_system": ("linstabpy.operators.assembly", "build_lnse_system"),
|
|
57
|
+
"build_lnse_operator": ("linstabpy.operators.assembly", "build_lnse_operator"),
|
|
58
|
+
"compute_chu_norm": ("linstabpy.operators.energy_norm", "compute_chu_norm"),
|
|
59
|
+
"temporal_eigenvalues": ("linstabpy.stability._eigenvalues", "eigenvalues"),
|
|
60
|
+
"adjoint_eigenvalues": ("linstabpy.stability._eigenvalues", "adjoint_eigenvalues"),
|
|
61
|
+
"wavemaker": ("linstabpy.stability.sensitivity", "wavemaker"),
|
|
62
|
+
"resolvent_analysis": ("linstabpy.resolvent.analysis", "resolvent_analysis"),
|
|
63
|
+
"resolvent_map": ("linstabpy.solvers.router.resolvent_map", "resolvent_map"),
|
|
64
|
+
"BaseFlow": ("linstabpy.baseflow", "BaseFlow"),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
__all__ = sorted(_LAZY_EXPORTS)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def __getattr__(name: str) -> object:
|
|
71
|
+
"""Load a public name on first use and cache it on this module."""
|
|
72
|
+
target = _LAZY_EXPORTS.get(name)
|
|
73
|
+
if target is None:
|
|
74
|
+
raise AttributeError(f"module 'linstabpy' has no attribute {name!r}")
|
|
75
|
+
module_name, attr_name = target
|
|
76
|
+
attr = getattr(importlib.import_module(module_name), attr_name)
|
|
77
|
+
globals()[name] = attr
|
|
78
|
+
return attr
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def __dir__() -> list[str]:
|
|
82
|
+
"""List bound names and public names that load on first use."""
|
|
83
|
+
return sorted(set(globals()) | set(_LAZY_EXPORTS))
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
"""Base-flow object, viscosity, and packed-field loader."""
|
|
3
|
+
|
|
4
|
+
from .baseflow import BaseFlow
|
|
5
|
+
from .grid_fields import load_case_fields, load_grid_fields, read_grid_fields
|
|
6
|
+
from .preprocess import apply_preprocess
|
|
7
|
+
from .sponge import generate_sponge, load_sponge_aux, sponge_from_config
|
|
8
|
+
from .viscosity import sutherland_air
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
"""Base flow data container for linear stability analysis."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class BaseFlow:
|
|
13
|
+
"""Base flow fields and parameters for linear stability analysis.
|
|
14
|
+
|
|
15
|
+
All flow field arrays have shape ``(ny, nx)`` matching the computational
|
|
16
|
+
mesh. :func:`~linstabpy.baseflow.viscosity.sutherland_air` later fills
|
|
17
|
+
viscosity fields and derived coefficients.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
Re : float
|
|
22
|
+
Reynolds number.
|
|
23
|
+
Ma : float
|
|
24
|
+
Mach number.
|
|
25
|
+
Pr : float
|
|
26
|
+
Prandtl number.
|
|
27
|
+
kappa : float
|
|
28
|
+
Heat capacity ratio (gamma).
|
|
29
|
+
T_0 : float
|
|
30
|
+
Reference temperature [K].
|
|
31
|
+
RHO, U, V, W, T : np.ndarray
|
|
32
|
+
Density, streamwise velocity, wall-normal velocity, spanwise
|
|
33
|
+
velocity, and temperature fields.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# Physical parameters
|
|
37
|
+
Re: float
|
|
38
|
+
Ma: float
|
|
39
|
+
Pr: float
|
|
40
|
+
kappa: float
|
|
41
|
+
T_0: float
|
|
42
|
+
|
|
43
|
+
# Flow fields -- shape (ny, nx) matching mesh
|
|
44
|
+
RHO: np.ndarray
|
|
45
|
+
U: np.ndarray
|
|
46
|
+
V: np.ndarray
|
|
47
|
+
W: np.ndarray
|
|
48
|
+
T: np.ndarray
|
|
49
|
+
|
|
50
|
+
# Viscosity fields (populated by sutherland_air)
|
|
51
|
+
MU: np.ndarray | None = None
|
|
52
|
+
dmudT: np.ndarray | None = None
|
|
53
|
+
d2mudT2: np.ndarray | None = None
|
|
54
|
+
|
|
55
|
+
# Derived coefficients (populated by sutherland_air)
|
|
56
|
+
cv: float | None = None
|
|
57
|
+
c1: float | None = None
|
|
58
|
+
c2: float | None = None
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
"""Load a rectilinear primitive field set from a npy directory or one npz."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .baseflow import BaseFlow
|
|
12
|
+
from .preprocess import apply_preprocess
|
|
13
|
+
|
|
14
|
+
FIELD_NAMES = ("x", "y", "rho", "u", "v", "w", "T", "sponge")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _as_field(name: str, arr: np.ndarray, source: Path) -> np.ndarray:
|
|
18
|
+
out = np.asarray(arr, dtype=float)
|
|
19
|
+
if out.ndim != 2:
|
|
20
|
+
raise ValueError(f"{source} field {name} must be 2-D, got shape {out.shape}")
|
|
21
|
+
return out
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read_grid_fields(source: str | Path) -> dict[str, np.ndarray]:
|
|
25
|
+
"""Read x, y, rho, u, v, w, T, sponge from an npz file or a npy directory."""
|
|
26
|
+
root = Path(source)
|
|
27
|
+
if root.is_file():
|
|
28
|
+
if root.suffix != ".npz":
|
|
29
|
+
raise ValueError(f"grid field file must be .npz, got {root}")
|
|
30
|
+
packed = np.load(root)
|
|
31
|
+
missing = [name for name in FIELD_NAMES if name not in packed.files]
|
|
32
|
+
if missing:
|
|
33
|
+
raise FileNotFoundError(f"{root} is missing fields: {missing}")
|
|
34
|
+
return {name: _as_field(name, packed[name], root) for name in FIELD_NAMES}
|
|
35
|
+
|
|
36
|
+
if not root.is_dir():
|
|
37
|
+
raise FileNotFoundError(f"grid field source not found: {root}")
|
|
38
|
+
|
|
39
|
+
fields: dict[str, np.ndarray] = {}
|
|
40
|
+
for name in FIELD_NAMES:
|
|
41
|
+
path = root / f"{name}.npy"
|
|
42
|
+
if not path.exists():
|
|
43
|
+
raise FileNotFoundError(f"Missing field file: {path}")
|
|
44
|
+
fields[name] = _as_field(name, np.load(path), path)
|
|
45
|
+
return fields
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_case_fields(
|
|
49
|
+
source: str | Path,
|
|
50
|
+
*,
|
|
51
|
+
Re: float,
|
|
52
|
+
Ma: float,
|
|
53
|
+
Pr: float,
|
|
54
|
+
kappa: float,
|
|
55
|
+
T_0: float,
|
|
56
|
+
stride_ksi: int = 1,
|
|
57
|
+
stride_eta: int = 1,
|
|
58
|
+
transpose_input: bool = False,
|
|
59
|
+
zero_spanwise: bool = False,
|
|
60
|
+
refuse_asymmetric_stride: bool = False,
|
|
61
|
+
preprocess: dict[str, Any] | None = None,
|
|
62
|
+
) -> tuple[np.ndarray, np.ndarray, BaseFlow, np.ndarray, dict[str, Any]]:
|
|
63
|
+
"""Build coordinates, a BaseFlow, and the sponge from a packed field.
|
|
64
|
+
|
|
65
|
+
``source`` is a directory of eight ``.npy`` files or one ``.npz`` with
|
|
66
|
+
the same keys. After an optional transpose the arrays are ``(ny, nx)``:
|
|
67
|
+
``stride_eta`` walks the first index and ``stride_ksi`` walks the second.
|
|
68
|
+
"""
|
|
69
|
+
fields = read_grid_fields(source)
|
|
70
|
+
if transpose_input:
|
|
71
|
+
fields = {name: arr.T for name, arr in fields.items()}
|
|
72
|
+
|
|
73
|
+
sx = int(stride_ksi)
|
|
74
|
+
sy = int(stride_eta)
|
|
75
|
+
if sx < 1 or sy < 1:
|
|
76
|
+
raise ValueError("stride_ksi and stride_eta must be >= 1")
|
|
77
|
+
if refuse_asymmetric_stride and sx != sy:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"Asymmetric strides are not supported on this mesh (stride_ksi={sx}, stride_eta={sy})."
|
|
80
|
+
)
|
|
81
|
+
if sx > 1 or sy > 1:
|
|
82
|
+
fields = {name: arr[::sy, ::sx] for name, arr in fields.items()}
|
|
83
|
+
|
|
84
|
+
shape = fields["x"].shape
|
|
85
|
+
for name, arr in fields.items():
|
|
86
|
+
if arr.shape != shape:
|
|
87
|
+
raise ValueError(f"Field '{name}' shape {arr.shape} does not match x shape {shape}")
|
|
88
|
+
|
|
89
|
+
X = fields["x"]
|
|
90
|
+
Y = fields["y"]
|
|
91
|
+
rho = fields["rho"]
|
|
92
|
+
u = fields["u"]
|
|
93
|
+
v = fields["v"]
|
|
94
|
+
w = fields["w"]
|
|
95
|
+
T = fields["T"]
|
|
96
|
+
sponge = fields["sponge"]
|
|
97
|
+
|
|
98
|
+
X, Y, rho, u, v, w, T, sponge, info = apply_preprocess(
|
|
99
|
+
X, Y, rho, u, v, w, T, sponge, preprocess
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if zero_spanwise:
|
|
103
|
+
w = np.zeros_like(u)
|
|
104
|
+
|
|
105
|
+
baseflow = BaseFlow(
|
|
106
|
+
Re=float(Re),
|
|
107
|
+
Ma=float(Ma),
|
|
108
|
+
Pr=float(Pr),
|
|
109
|
+
kappa=float(kappa),
|
|
110
|
+
T_0=float(T_0),
|
|
111
|
+
RHO=rho,
|
|
112
|
+
U=u,
|
|
113
|
+
V=v,
|
|
114
|
+
W=w,
|
|
115
|
+
T=T,
|
|
116
|
+
)
|
|
117
|
+
return X, Y, baseflow, sponge, info
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_grid_fields(
|
|
121
|
+
source: str | Path,
|
|
122
|
+
*,
|
|
123
|
+
Re: float,
|
|
124
|
+
Ma: float,
|
|
125
|
+
Pr: float,
|
|
126
|
+
kappa: float,
|
|
127
|
+
T_0: float,
|
|
128
|
+
stride_ksi: int = 1,
|
|
129
|
+
stride_eta: int = 1,
|
|
130
|
+
transpose_input: bool = False,
|
|
131
|
+
zero_spanwise: bool = False,
|
|
132
|
+
) -> tuple[np.ndarray, np.ndarray, BaseFlow, np.ndarray]:
|
|
133
|
+
"""Load a packed field and drop the preprocess report."""
|
|
134
|
+
X, Y, baseflow, sponge, _info = load_case_fields(
|
|
135
|
+
source,
|
|
136
|
+
Re=Re,
|
|
137
|
+
Ma=Ma,
|
|
138
|
+
Pr=Pr,
|
|
139
|
+
kappa=kappa,
|
|
140
|
+
T_0=T_0,
|
|
141
|
+
stride_ksi=stride_ksi,
|
|
142
|
+
stride_eta=stride_eta,
|
|
143
|
+
transpose_input=transpose_input,
|
|
144
|
+
zero_spanwise=zero_spanwise,
|
|
145
|
+
)
|
|
146
|
+
return X, Y, baseflow, sponge
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
"""Optional field filter, boundary sponge, and shock cleanup."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .shock_cleanup import apply_optional_shock_cleanup
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _binomial5_filter(arr: np.ndarray, axis: int) -> np.ndarray:
|
|
14
|
+
"""Apply a 5-point binomial filter along one axis."""
|
|
15
|
+
if arr.ndim != 2:
|
|
16
|
+
raise ValueError(f"Expected 2-D array for filtering, got shape {arr.shape}")
|
|
17
|
+
if axis not in (0, 1):
|
|
18
|
+
raise ValueError(f"axis must be 0 or 1, got {axis}")
|
|
19
|
+
|
|
20
|
+
pad = [(0, 0), (0, 0)]
|
|
21
|
+
pad[axis] = (2, 2)
|
|
22
|
+
padded = np.pad(arr, pad, mode="edge")
|
|
23
|
+
if axis == 0:
|
|
24
|
+
return (
|
|
25
|
+
padded[:-4, :]
|
|
26
|
+
+ 4.0 * padded[1:-3, :]
|
|
27
|
+
+ 6.0 * padded[2:-2, :]
|
|
28
|
+
+ 4.0 * padded[3:-1, :]
|
|
29
|
+
+ padded[4:, :]
|
|
30
|
+
) / 16.0
|
|
31
|
+
return (
|
|
32
|
+
padded[:, :-4]
|
|
33
|
+
+ 4.0 * padded[:, 1:-3]
|
|
34
|
+
+ 6.0 * padded[:, 2:-2]
|
|
35
|
+
+ 4.0 * padded[:, 3:-1]
|
|
36
|
+
+ padded[:, 4:]
|
|
37
|
+
) / 16.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _as_four_numbers(values: Any, *, cast, name: str) -> tuple[Any, Any, Any, Any]:
|
|
41
|
+
if values is None:
|
|
42
|
+
values = [0, 0, 0, 0]
|
|
43
|
+
if not isinstance(values, (list, tuple)) or len(values) != 4:
|
|
44
|
+
raise ValueError(f"{name} must be a list of four values [top, right, bottom, left]")
|
|
45
|
+
return tuple(cast(v) for v in values)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _generate_boundary_sponge(
|
|
49
|
+
shape: tuple[int, int],
|
|
50
|
+
*,
|
|
51
|
+
points: tuple[int, int, int, int],
|
|
52
|
+
amplitudes: tuple[float, float, float, float],
|
|
53
|
+
) -> np.ndarray:
|
|
54
|
+
"""Build a boundary sponge with quadratic ramps [top, right, bottom, left]."""
|
|
55
|
+
ny, nx = shape
|
|
56
|
+
sponge = np.zeros(shape, dtype=float)
|
|
57
|
+
|
|
58
|
+
def _ramp(count: int) -> np.ndarray:
|
|
59
|
+
if count <= 0:
|
|
60
|
+
return np.empty((0,), dtype=float)
|
|
61
|
+
return (np.arange(1, count + 1, dtype=float) / float(count)) ** 2
|
|
62
|
+
|
|
63
|
+
top_n, right_n, bottom_n, left_n = points
|
|
64
|
+
top_amp, right_amp, bottom_amp, left_amp = amplitudes
|
|
65
|
+
|
|
66
|
+
if top_n > 0:
|
|
67
|
+
ramp = top_amp * _ramp(top_n)
|
|
68
|
+
sponge[-top_n:, :] += ramp[:, None]
|
|
69
|
+
if bottom_n > 0:
|
|
70
|
+
ramp = bottom_amp * _ramp(bottom_n)[::-1]
|
|
71
|
+
sponge[:bottom_n, :] += ramp[:, None]
|
|
72
|
+
if right_n > 0:
|
|
73
|
+
ramp = right_amp * _ramp(right_n)
|
|
74
|
+
sponge[:, -right_n:] += ramp[None, :]
|
|
75
|
+
if left_n > 0:
|
|
76
|
+
ramp = left_amp * _ramp(left_n)[::-1]
|
|
77
|
+
sponge[:, :left_n:] += ramp[None, :]
|
|
78
|
+
|
|
79
|
+
return sponge
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def apply_preprocess(
|
|
83
|
+
X: np.ndarray,
|
|
84
|
+
Y: np.ndarray,
|
|
85
|
+
rho: np.ndarray,
|
|
86
|
+
u: np.ndarray,
|
|
87
|
+
v: np.ndarray,
|
|
88
|
+
w: np.ndarray,
|
|
89
|
+
T: np.ndarray,
|
|
90
|
+
sponge: np.ndarray,
|
|
91
|
+
preprocess_cfg: dict[str, Any] | None,
|
|
92
|
+
) -> tuple[
|
|
93
|
+
np.ndarray,
|
|
94
|
+
np.ndarray,
|
|
95
|
+
np.ndarray,
|
|
96
|
+
np.ndarray,
|
|
97
|
+
np.ndarray,
|
|
98
|
+
np.ndarray,
|
|
99
|
+
np.ndarray,
|
|
100
|
+
np.ndarray,
|
|
101
|
+
dict[str, Any],
|
|
102
|
+
]:
|
|
103
|
+
"""Apply optional filter, boundary sponge, and shock cleanup."""
|
|
104
|
+
info: dict[str, Any] = {
|
|
105
|
+
"enabled": False,
|
|
106
|
+
"applied": [],
|
|
107
|
+
"filter": {"enabled": False, "passes": 0, "fields": []},
|
|
108
|
+
"sponge": {"scale": 1.0, "boundary": {"enabled": False}},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if preprocess_cfg is None:
|
|
112
|
+
info["summary"] = "disabled"
|
|
113
|
+
return X, Y, rho, u, v, w, T, sponge, info
|
|
114
|
+
if not isinstance(preprocess_cfg, dict):
|
|
115
|
+
raise TypeError("preprocess must be a mapping when provided")
|
|
116
|
+
|
|
117
|
+
enabled = bool(preprocess_cfg.get("enabled", False))
|
|
118
|
+
info["enabled"] = enabled
|
|
119
|
+
if not enabled:
|
|
120
|
+
info["summary"] = "disabled"
|
|
121
|
+
return X, Y, rho, u, v, w, T, sponge, info
|
|
122
|
+
|
|
123
|
+
shock_cfg = preprocess_cfg.get("shock", {})
|
|
124
|
+
if isinstance(shock_cfg, dict) and bool(shock_cfg.get("enabled", False)):
|
|
125
|
+
rho, u, v, w, T, sponge, shock_info = apply_optional_shock_cleanup(
|
|
126
|
+
X, Y, rho, u, v, w, T, sponge, shock_cfg
|
|
127
|
+
)
|
|
128
|
+
info["applied"].append("shock_cleanup")
|
|
129
|
+
info["shock"] = shock_info
|
|
130
|
+
|
|
131
|
+
sponge_cfg = preprocess_cfg.get("sponge", {})
|
|
132
|
+
if sponge_cfg is None:
|
|
133
|
+
sponge_cfg = {}
|
|
134
|
+
if not isinstance(sponge_cfg, dict):
|
|
135
|
+
raise TypeError("preprocess.sponge must be a mapping when provided")
|
|
136
|
+
|
|
137
|
+
sponge_scale = float(sponge_cfg.get("scale", 1.0))
|
|
138
|
+
info["sponge"]["scale"] = sponge_scale
|
|
139
|
+
if sponge_scale != 1.0:
|
|
140
|
+
sponge = sponge * sponge_scale
|
|
141
|
+
info["applied"].append(f"sponge_scale={sponge_scale:.6g}")
|
|
142
|
+
|
|
143
|
+
boundary_cfg = sponge_cfg.get("boundary", {})
|
|
144
|
+
if boundary_cfg is None:
|
|
145
|
+
boundary_cfg = {}
|
|
146
|
+
if not isinstance(boundary_cfg, dict):
|
|
147
|
+
raise TypeError("preprocess.sponge.boundary must be a mapping when provided")
|
|
148
|
+
|
|
149
|
+
boundary_enabled = bool(boundary_cfg.get("enabled", False))
|
|
150
|
+
info["sponge"]["boundary"] = {"enabled": boundary_enabled}
|
|
151
|
+
if boundary_enabled:
|
|
152
|
+
points = _as_four_numbers(
|
|
153
|
+
boundary_cfg.get("points"), cast=int, name="boundary sponge points"
|
|
154
|
+
)
|
|
155
|
+
amplitudes = _as_four_numbers(
|
|
156
|
+
boundary_cfg.get("amplitudes"), cast=float, name="boundary sponge amplitudes"
|
|
157
|
+
)
|
|
158
|
+
mode = str(boundary_cfg.get("mode", "add")).strip().lower()
|
|
159
|
+
generated = _generate_boundary_sponge(sponge.shape, points=points, amplitudes=amplitudes)
|
|
160
|
+
if mode == "replace":
|
|
161
|
+
sponge = generated
|
|
162
|
+
elif mode == "max":
|
|
163
|
+
sponge = np.maximum(sponge, generated)
|
|
164
|
+
elif mode == "add":
|
|
165
|
+
sponge = sponge + generated
|
|
166
|
+
else:
|
|
167
|
+
raise ValueError("boundary sponge mode must be one of: add, max, replace")
|
|
168
|
+
info["sponge"]["boundary"] = {
|
|
169
|
+
"enabled": True,
|
|
170
|
+
"mode": mode,
|
|
171
|
+
"points": list(points),
|
|
172
|
+
"amplitudes": list(amplitudes),
|
|
173
|
+
"generated_max": float(np.max(generated)) if generated.size else 0.0,
|
|
174
|
+
}
|
|
175
|
+
info["applied"].append(
|
|
176
|
+
"boundary_sponge="
|
|
177
|
+
f"mode:{mode},points:{list(points)},amplitudes:{[float(a) for a in amplitudes]}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
filter_cfg = preprocess_cfg.get("filter", {})
|
|
181
|
+
if filter_cfg is None:
|
|
182
|
+
filter_cfg = {}
|
|
183
|
+
if not isinstance(filter_cfg, dict):
|
|
184
|
+
raise TypeError("preprocess.filter must be a mapping when provided")
|
|
185
|
+
|
|
186
|
+
filter_enabled = bool(filter_cfg.get("enabled", False))
|
|
187
|
+
passes = int(filter_cfg.get("passes", 0))
|
|
188
|
+
fields = [str(name) for name in filter_cfg.get("fields", ["rho", "u", "v", "T"])]
|
|
189
|
+
directions_raw = filter_cfg.get("directions", ["x", "y"])
|
|
190
|
+
if not isinstance(directions_raw, (list, tuple)) or not directions_raw:
|
|
191
|
+
raise ValueError("preprocess.filter.directions must be a non-empty list")
|
|
192
|
+
direction_map = {"x": 1, "y": 0}
|
|
193
|
+
directions: list[str] = []
|
|
194
|
+
axes: list[int] = []
|
|
195
|
+
for item in directions_raw:
|
|
196
|
+
name = str(item).strip().lower()
|
|
197
|
+
if name not in direction_map:
|
|
198
|
+
raise ValueError("filter directions must contain only 'x' and/or 'y'")
|
|
199
|
+
if name not in directions:
|
|
200
|
+
directions.append(name)
|
|
201
|
+
axes.append(direction_map[name])
|
|
202
|
+
|
|
203
|
+
eta_min = int(filter_cfg.get("eta_min_index", 0))
|
|
204
|
+
eta_max_raw = filter_cfg.get("eta_max_index")
|
|
205
|
+
eta_max = rho.shape[0] if eta_max_raw is None else int(eta_max_raw)
|
|
206
|
+
eta_min = max(0, min(rho.shape[0], eta_min))
|
|
207
|
+
eta_max = max(eta_min, min(rho.shape[0], eta_max))
|
|
208
|
+
|
|
209
|
+
info["filter"] = {
|
|
210
|
+
"enabled": filter_enabled,
|
|
211
|
+
"passes": passes,
|
|
212
|
+
"fields": fields,
|
|
213
|
+
"directions": directions,
|
|
214
|
+
"eta_min_index": eta_min,
|
|
215
|
+
"eta_max_index": None if eta_max_raw is None else eta_max,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if filter_enabled and passes > 0 and eta_min < eta_max:
|
|
219
|
+
# Filter copies: the caller's arrays stay untouched, as in shock_cleanup.
|
|
220
|
+
field_map = {
|
|
221
|
+
name: np.array(arr, copy=True)
|
|
222
|
+
for name, arr in {"rho": rho, "u": u, "v": v, "w": w, "T": T}.items()
|
|
223
|
+
}
|
|
224
|
+
invalid = [name for name in fields if name not in field_map]
|
|
225
|
+
if invalid:
|
|
226
|
+
raise ValueError(f"Unsupported filter field names: {invalid}")
|
|
227
|
+
|
|
228
|
+
change_norms: dict[str, float] = {}
|
|
229
|
+
region = (slice(eta_min, eta_max), slice(None))
|
|
230
|
+
for name in fields:
|
|
231
|
+
original = np.array(field_map[name], copy=True)
|
|
232
|
+
filtered_region = np.array(field_map[name][region], copy=True)
|
|
233
|
+
for _ in range(passes):
|
|
234
|
+
for axis in axes:
|
|
235
|
+
filtered_region = _binomial5_filter(filtered_region, axis=axis)
|
|
236
|
+
field_map[name][region] = filtered_region
|
|
237
|
+
denom = float(np.linalg.norm(original))
|
|
238
|
+
delta = float(np.linalg.norm(field_map[name] - original))
|
|
239
|
+
change_norms[name] = 0.0 if denom == 0.0 else delta / denom
|
|
240
|
+
|
|
241
|
+
rho, u, v, w, T = (field_map[name] for name in ("rho", "u", "v", "w", "T"))
|
|
242
|
+
info["filter"]["relative_l2_change"] = change_norms
|
|
243
|
+
info["applied"].append(
|
|
244
|
+
"explicit_filter="
|
|
245
|
+
f"passes:{passes},fields:{fields},directions:{directions},eta:[{eta_min},{eta_max})"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
info["sponge"]["max"] = float(np.max(sponge)) if sponge.size else 0.0
|
|
249
|
+
info["summary"] = "; ".join(info["applied"]) if info["applied"] else "enabled(no-op)"
|
|
250
|
+
return X, Y, rho, u, v, w, T, sponge, info
|