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.
Files changed (82) hide show
  1. linstabpy/__init__.py +83 -0
  2. linstabpy/baseflow/__init__.py +8 -0
  3. linstabpy/baseflow/baseflow.py +58 -0
  4. linstabpy/baseflow/grid_fields.py +146 -0
  5. linstabpy/baseflow/preprocess.py +250 -0
  6. linstabpy/baseflow/shock_cleanup.py +465 -0
  7. linstabpy/baseflow/sponge.py +443 -0
  8. linstabpy/baseflow/viscosity.py +66 -0
  9. linstabpy/boundary_conditions/__init__.py +44 -0
  10. linstabpy/boundary_conditions/dirichlet.py +436 -0
  11. linstabpy/boundary_conditions/neumann.py +349 -0
  12. linstabpy/boundary_conditions/recipe.py +543 -0
  13. linstabpy/config/__init__.py +29 -0
  14. linstabpy/config/defaults.py +89 -0
  15. linstabpy/config/env_registry.py +261 -0
  16. linstabpy/config/schema.py +341 -0
  17. linstabpy/config/typed.py +668 -0
  18. linstabpy/driver/__init__.py +14 -0
  19. linstabpy/driver/cli.py +242 -0
  20. linstabpy/driver/io.py +111 -0
  21. linstabpy/driver/payload.py +120 -0
  22. linstabpy/driver/preflight.py +625 -0
  23. linstabpy/driver/run.py +1826 -0
  24. linstabpy/linalg/__init__.py +40 -0
  25. linstabpy/linalg/backends.py +641 -0
  26. linstabpy/linalg/cudss_factor.py +426 -0
  27. linstabpy/linalg/factorize.py +1115 -0
  28. linstabpy/linalg/shifted.py +62 -0
  29. linstabpy/mesh/__init__.py +44 -0
  30. linstabpy/mesh/fdweights.py +38 -0
  31. linstabpy/mesh/filters.py +489 -0
  32. linstabpy/mesh/mesh.py +1859 -0
  33. linstabpy/mesh/sbp.py +951 -0
  34. linstabpy/mesh/sbp_generator.py +438 -0
  35. linstabpy/metrics_schema.py +151 -0
  36. linstabpy/mumps_options.py +14 -0
  37. linstabpy/operators/__init__.py +20 -0
  38. linstabpy/operators/assembly.py +1988 -0
  39. linstabpy/operators/coefficients.py +1831 -0
  40. linstabpy/operators/energy_norm.py +92 -0
  41. linstabpy/operators/metrics.py +297 -0
  42. linstabpy/operators/spatial.py +293 -0
  43. linstabpy/resolvent/__init__.py +29 -0
  44. linstabpy/resolvent/analysis.py +513 -0
  45. linstabpy/resolvent/conservative_forcing.py +341 -0
  46. linstabpy/resolvent/gain.py +205 -0
  47. linstabpy/resolvent/operator.py +128 -0
  48. linstabpy/solvers/__init__.py +31 -0
  49. linstabpy/solvers/_eps_helpers.py +142 -0
  50. linstabpy/solvers/eigenvalue.py +757 -0
  51. linstabpy/solvers/petsc_utils.py +20 -0
  52. linstabpy/solvers/router/__init__.py +79 -0
  53. linstabpy/solvers/router/anchors.py +363 -0
  54. linstabpy/solvers/router/band_marking.py +306 -0
  55. linstabpy/solvers/router/break_even.py +277 -0
  56. linstabpy/solvers/router/certification.py +411 -0
  57. linstabpy/solvers/router/map_record.py +174 -0
  58. linstabpy/solvers/router/map_walk.py +621 -0
  59. linstabpy/solvers/router/ram_autoselect.py +435 -0
  60. linstabpy/solvers/router/recycling.py +346 -0
  61. linstabpy/solvers/router/resolvent_map.py +1690 -0
  62. linstabpy/solvers/router/stable_sweep.py +1183 -0
  63. linstabpy/solvers/spatial_eig.py +218 -0
  64. linstabpy/stability/__init__.py +38 -0
  65. linstabpy/stability/_eigenvalues.py +831 -0
  66. linstabpy/stability/acceptance.py +303 -0
  67. linstabpy/stability/sensitivity.py +216 -0
  68. linstabpy/utils/__init__.py +10 -0
  69. linstabpy/utils/_solver_cfg.py +169 -0
  70. linstabpy/utils/cost_metrics.py +147 -0
  71. linstabpy/utils/floquet.py +71 -0
  72. linstabpy/utils/petsc_core.py +912 -0
  73. linstabpy/utils/petsc_init.py +120 -0
  74. linstabpy/utils/provenance.py +216 -0
  75. linstabpy/utils/yaml_io.py +65 -0
  76. linstabpy-1.0.0.dist-info/METADATA +1071 -0
  77. linstabpy-1.0.0.dist-info/RECORD +82 -0
  78. linstabpy-1.0.0.dist-info/WHEEL +5 -0
  79. linstabpy-1.0.0.dist-info/entry_points.txt +2 -0
  80. linstabpy-1.0.0.dist-info/licenses/LICENSE +674 -0
  81. linstabpy-1.0.0.dist-info/licenses/NOTICE +53 -0
  82. 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