PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
periodic_spectral.py
Go to the documentation of this file.
1"""!
2@file periodic_spectral.py
3@brief Fourier machinery for the uniform periodic axes of PICurv's Cartesian grids.
4@details Shared by `ic.gen`, which synthesizes and resamples initial conditions, and
5 `spectra.gen`, which measures spectra. Both need the same answer to three
6 questions: which axes may be transformed, what the continuum and PICurv-discrete
7 wavenumbers are, and how a periodic field is moved between resolutions. Arrays are
8 in PICurv storage order, `[k, j, i, component]`; lengths and wavenumbers are in
9 Cartesian `x, y, z` order. Every function imports NumPy through
10 `require_numpy()`, so importing this module never imports NumPy itself.
11"""
12
13import sys
14
15
16def _drop_imported_package(package_name: str):
17 """!
18 @brief Remove a failed/partial import package tree from sys.modules.
19 @param[in] package_name Top-level package name.
20 """
21 prefix = package_name + "."
22 for module_name in list(sys.modules):
23 if module_name == package_name or module_name.startswith(prefix):
24 sys.modules.pop(module_name, None)
25
26
28 """!
29 @brief Remove site-package paths for a different Python major/minor version.
30 @param[in] paths Candidate sys.path entries.
31 @return Filtered path list.
32 """
33 current = (sys.version_info[0], sys.version_info[1])
34 filtered = []
35 for path in paths:
36 text = str(path)
37 marker = "python"
38 idx = text.lower().find(marker)
39 if idx >= 0 and ("site-packages" in text or "dist-packages" in text):
40 version_text = text[idx + len(marker):idx + len(marker) + 4].strip("-/")
41 parts = version_text.split(".")
42 try:
43 path_version = (int(parts[0]), int(parts[1]))
44 except (IndexError, ValueError):
45 filtered.append(path)
46 continue
47 if path_version != current:
48 continue
49 filtered.append(path)
50 return filtered
51
52
54 """!
55 @brief Import NumPy with the same compatibility retry used by the conductor.
56 @return Imported NumPy module.
57 """
58 try:
59 import numpy
60 return numpy
61 except Exception as exc:
62 first_error = exc
63 original_path = list(sys.path)
64 try:
66 sys.path = _prune_incompatible_python_site_paths(original_path)
67 import numpy
68 return numpy
69 except Exception as retry_exc:
70 raise RuntimeError(
71 "NumPy is required for PICurv spectral generators, but no compatible "
72 "NumPy could be imported. "
73 f"First error: {first_error}. Retry error: {retry_exc}"
74 ) from retry_exc
75 finally:
76 sys.path = original_path
77
78
79def validate_spectral_grid(nodes, transform_axes=(0, 1, 2), subject="spectra require"):
80 """!
81 @brief Validate the uniform axis-aligned Cartesian grid an FFT on periodic axes requires.
82 @param[in] nodes PICGRID node-coordinate array shaped `(KM, JM, IM, 3)`.
83 @param[in] transform_axes Cartesian axes that are transformed and so must be uniform;
84 the remaining axes may stretch.
85 @param[in] subject What needs the grid and its verb, opening each error message.
86 @return Cell counts in storage order and physical lengths in Cartesian order.
87 """
88 numpy = require_numpy()
89 km, jm, im, _ = nodes.shape
90 cells = (km - 1, jm - 1, im - 1)
91 if min(cells) < 4:
92 raise ValueError(f"{subject} at least four cells per axis; found {cells}.")
93 axes = (nodes[0, 0, :, 0], nodes[0, :, 0, 1], nodes[:, 0, 0, 2])
94 for axis, (physical_axis, values) in enumerate(zip("xyz", axes)):
95 delta = numpy.diff(values)
96 if not numpy.all(numpy.isfinite(delta)) or not numpy.all(delta > 0) or (
97 axis in transform_axes and not numpy.allclose(delta, delta[0], rtol=1e-10, atol=1e-12)):
98 raise ValueError(f"{subject} uniform positive {physical_axis} spacing on every "
99 "transformed axis.")
100 if not (numpy.allclose(nodes[..., 0], axes[0][None, None, :]) and
101 numpy.allclose(nodes[..., 1], axes[1][None, :, None]) and
102 numpy.allclose(nodes[..., 2], axes[2][:, None, None])):
103 raise ValueError(f"{subject} an axis-aligned Cartesian grid (x->i, y->j, z->k).")
104 return cells, tuple(float(values[-1] - values[0]) for values in axes)
105
106
107def spectral_symbols(cells_kji, lengths_xyz):
108 """!
109 @brief Build continuum and centered-discrete Fourier symbols.
110 @param[in] cells_kji Cell counts in storage order.
111 @param[in] lengths_xyz Physical box lengths in Cartesian order.
112 @return Continuum symbols, centered-discrete symbols, and Cartesian spacings.
113 """
114 numpy = require_numpy()
115 nk, nj, ni = cells_kji
116 lx, ly, lz = lengths_xyz
117 dx, dy, dz = lx / ni, ly / nj, lz / nk
118 kz = 2*numpy.pi*numpy.fft.fftfreq(nk, d=dz)[:, None, None]
119 ky = 2*numpy.pi*numpy.fft.fftfreq(nj, d=dy)[None, :, None]
120 kx = 2*numpy.pi*numpy.fft.fftfreq(ni, d=dx)[None, None, :]
121 discrete = (numpy.sin(kx*dx)/dx, numpy.sin(ky*dy)/dy, numpy.sin(kz*dz)/dz)
122 return (kx, ky, kz), discrete, (dx, dy, dz)
123
124
125#: Filter kernels `fourier_resample` applies, by name.
126RESAMPLE_FILTERS = ("none", "sharp", "gaussian", "box")
127
128
129def _resample_axis(values, storage_axis, target_count, length, shift):
130 """!
131 @brief Move one periodic axis of an array of samples to another uniform resolution.
132 @details The samples are read as a truncated Fourier series, which is re-evaluated at
133 the target points. Modes are kept only where both resolutions represent them
134 unambiguously, |m| < min(N, M)/2, so each Nyquist mode is dropped and the
135 result stays real.
136 @param[in] values Real samples; `storage_axis` is the axis being moved.
137 @param[in] storage_axis Array axis to resample.
138 @param[in] target_count Number of target samples along that axis.
139 @param[in] length Physical period of the axis.
140 @param[in] shift Target first-sample position minus source first-sample position.
141 @return Real samples with `target_count` points along `storage_axis`.
142 """
143 numpy = require_numpy()
144 source_count = values.shape[storage_axis]
145 coeff = numpy.fft.fft(values, axis=storage_axis) / source_count
146 modes = numpy.rint(numpy.fft.fftfreq(source_count) * source_count).astype(int)
147 keep = numpy.abs(modes) < min(source_count, target_count) / 2.0
148 phase = numpy.exp(1j * 2.0 * numpy.pi * modes[keep] * shift / length)
149 shape = [1] * values.ndim
150 shape[storage_axis] = int(keep.sum())
151 kept = numpy.compress(keep, coeff, axis=storage_axis) * phase.reshape(shape)
152 out_shape = list(values.shape)
153 out_shape[storage_axis] = target_count
154 target = numpy.zeros(out_shape, dtype=complex)
155 index = [slice(None)] * values.ndim
156 index[storage_axis] = modes[keep] % target_count
157 target[tuple(index)] = kept
158 return (numpy.fft.ifft(target, axis=storage_axis) * target_count).real
159
160
161def filter_transfer(continuum_symbols, filter_spec):
162 """!
163 @brief Transfer function of a resampling filter on the target grid's wavenumbers.
164 @param[in] continuum_symbols Continuum `(kx, ky, kz)` from `spectral_symbols()`.
165 @param[in] filter_spec Mapping with `type` in `RESAMPLE_FILTERS`; `sharp` takes
166 `cutoff` (a wavenumber), `gaussian` and `box` take `width`
167 (the filter width Delta, a length).
168 @return Broadcastable transfer array, or None for `none`.
169 """
170 numpy = require_numpy()
171 kind = filter_spec.get("type", "none")
172 kx, ky, kz = continuum_symbols
173 if kind == "none":
174 return None
175 if kind == "sharp":
176 return (kx*kx + ky*ky + kz*kz <= float(filter_spec["cutoff"])**2).astype(float)
177 width = float(filter_spec["width"])
178 if kind == "gaussian":
179 return numpy.exp(-(kx*kx + ky*ky + kz*kz) * width * width / 24.0)
180 if kind == "box":
181 return numpy.sinc(kx*width/(2*numpy.pi)) * numpy.sinc(ky*width/(2*numpy.pi)) * \
182 numpy.sinc(kz*width/(2*numpy.pi))
183 raise ValueError(f"filter type must be one of {RESAMPLE_FILTERS}; got {kind!r}.")
184
185
186def fourier_resample(values, target_cells_kji, lengths_xyz, source_first_xyz, target_first_xyz,
187 filter_spec=None):
188 """!
189 @brief Resample a triply periodic vector field onto another uniform grid, optionally filtered.
190 @details Exact for band-limited data: every mode both grids resolve is carried over with
191 its amplitude and phase, then the filter multiplies its transfer function. Each
192 axis is moved separately, so memory stays at one array of the larger size.
193 @param[in] values Real samples `[k, j, i, component]` of the source field.
194 @param[in] target_cells_kji Target sample counts in storage order.
195 @param[in] lengths_xyz Physical periods, shared by source and target, in Cartesian order.
196 @param[in] source_first_xyz Position of the first source sample on each axis, relative
197 to the box origin.
198 @param[in] target_first_xyz Position of the first target sample on each axis.
199 @param[in] filter_spec Optional filter mapping for `filter_transfer()`.
200 @return Real target samples `[k, j, i, component]`.
201 """
202 numpy = require_numpy()
203 out = numpy.asarray(values, dtype=float)
204 for axis in range(3):
205 storage_axis = 2 - axis
206 out = _resample_axis(out, storage_axis, int(target_cells_kji[storage_axis]),
207 float(lengths_xyz[axis]),
208 float(target_first_xyz[axis]) - float(source_first_xyz[axis]))
209 if filter_spec and filter_spec.get("type", "none") != "none":
210 continuum, _discrete, _spacing = spectral_symbols(tuple(target_cells_kji), lengths_xyz)
211 transfer = filter_transfer(continuum, filter_spec)
212 coeff = numpy.fft.fftn(out, axes=(0, 1, 2))
213 out = numpy.fft.ifftn(coeff * transfer[..., None], axes=(0, 1, 2)).real
214 return out
filter_transfer(continuum_symbols, filter_spec)
Transfer function of a resampling filter on the target grid's wavenumbers.
fourier_resample(values, target_cells_kji, lengths_xyz, source_first_xyz, target_first_xyz, filter_spec=None)
Resample a triply periodic vector field onto another uniform grid, optionally filtered.
_resample_axis(values, storage_axis, target_count, length, shift)
Move one periodic axis of an array of samples to another uniform resolution.
_prune_incompatible_python_site_paths(paths)
Remove site-package paths for a different Python major/minor version.
_drop_imported_package(str package_name)
Remove a failed/partial import package tree from sys.modules.
validate_spectral_grid(nodes, transform_axes=(0, 1, 2), subject="spectra require")
Validate the uniform axis-aligned Cartesian grid an FFT on periodic axes requires.
spectral_symbols(cells_kji, lengths_xyz)
Build continuum and centered-discrete Fourier symbols.
require_numpy()
Import NumPy with the same compatibility retry used by the conductor.
Head of a generic C-style linked list.
Definition variables.h:476