plasmasds-fields 0.1.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.
@@ -0,0 +1,17 @@
1
+ """
2
+ Plasma field and magnetic geometry inputs for the plasma-sds synthetic
3
+ diagnostics.
4
+
5
+ Subpackages:
6
+
7
+ * ``plasmasds_fields.w7x``: W7-X Poincaré flux surfaces and density
8
+ allocation on them.
9
+ * ``plasmasds_fields.tools``: profile interpolation and plotting of fields.
10
+ * ``plasmasds_fields.models``: HESEL output, background profiles and
11
+ time-dependent perturbations.
12
+
13
+ Import what you need from the subpackages, for example
14
+ ``from plasmasds_fields.w7x.poincare import load_w7x_flux_surfaces``.
15
+ """
16
+
17
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,278 @@
1
+ """
2
+ Reading and extending HESEL simulation output.
3
+ """
4
+ import h5py as h5
5
+ import numpy as np
6
+
7
+
8
+ class HESEL:
9
+ """
10
+ Data handler for HESEL simulation output stored in large HDF5 files.
11
+
12
+ On construction the R, Z and time axes are loaded from the
13
+ ``data/xanimation`` group and their grid spacings are derived. The HDF5
14
+ file stays open as ``self.file``, so field datasets can be read lazily,
15
+ section by section, with :meth:`extract_field`.
16
+
17
+ The handler can be used as a context manager::
18
+
19
+ with HESEL("run.h5") as hesel:
20
+ ...
21
+
22
+ or closed explicitly with :meth:`close`.
23
+
24
+ Parameters
25
+ ----------
26
+ path : str or path-like
27
+ Path to the HESEL HDF5 output file.
28
+
29
+ Attributes
30
+ ----------
31
+ path : str
32
+ Path of the opened file.
33
+ file : h5py.File or None
34
+ Open HDF5 file (read-only); None after :meth:`close`.
35
+ R_axis, Z_axis, time_axis : numpy.ndarray
36
+ 1-D R [m], Z [m] and time [s] axes, read from
37
+ ``data/xanimation/xgrid[0, :]``, ``data/xanimation/ygrid[:, 0]`` and
38
+ ``data/xanimation/time``.
39
+ dR, dZ, dt : float
40
+ Step between the first two samples of each axis (the grid spacing for
41
+ uniform axes).
42
+ """
43
+
44
+ def __init__(self, path):
45
+ self.path = str(path)
46
+ self.file = h5.File(self.path, "r")
47
+
48
+ self.R_axis = self.file["data/xanimation/xgrid"][0, :]
49
+ self.Z_axis = self.file["data/xanimation/ygrid"][:, 0]
50
+ self.time_axis = self.file["data/xanimation/time"][:, 0]
51
+
52
+ self.dR = self._axis_spacing(self.R_axis)
53
+ self.dZ = self._axis_spacing(self.Z_axis)
54
+ self.dt = self._axis_spacing(self.time_axis)
55
+
56
+ @staticmethod
57
+ def _axis_spacing(axis):
58
+ """
59
+ Return the step between the first two samples of a 1-D axis.
60
+ """
61
+ return float(axis[1] - axis[0])
62
+
63
+ @staticmethod
64
+ def _range_to_selector(axis, rng):
65
+ """
66
+ Translate a range into an h5py selector for the 1-D ``axis``.
67
+
68
+ * None: ``slice(None)``, the full axis.
69
+ * Scalar: index of the nearest sample (collapses the axis).
70
+ * ``[min, max]``: ``slice(i_lo, i_hi + 1)`` covering the samples
71
+ nearest to ``min`` and ``max``.
72
+ """
73
+ if rng is None:
74
+ return slice(None)
75
+
76
+ arr = np.atleast_1d(rng)
77
+ if arr.size == 1:
78
+ return int(np.argmin(np.abs(axis - arr[0])))
79
+ if arr.size == 2:
80
+ lo, hi = float(arr[0]), float(arr[1])
81
+ if lo > hi:
82
+ lo, hi = hi, lo
83
+ i_lo = int(np.argmin(np.abs(axis - lo)))
84
+ i_hi = int(np.argmin(np.abs(axis - hi)))
85
+ if i_lo > i_hi:
86
+ i_lo, i_hi = i_hi, i_lo
87
+ return slice(i_lo, i_hi + 1)
88
+
89
+ raise ValueError(
90
+ "Range must be None, a scalar, or a two-element [min, max]; "
91
+ f"got an array of size {arr.size}."
92
+ )
93
+
94
+ def extract_field(self, field, r_range=None, z_range=None, t_range=None):
95
+ """
96
+ Read a section of a HESEL field from ``data/xanimation/<field>``.
97
+
98
+ The dataset is 3-D with shape (time, Z, R). Only the requested section
99
+ is read from disk.
100
+
101
+ Parameters
102
+ ----------
103
+ field : {"density", "electron_temperature", "ion_temperature"}
104
+ Name of the dataset to read.
105
+ r_range, z_range, t_range : float or sequence of float, default None
106
+ Selection along R [m], Z [m] and time [s]:
107
+
108
+ * None: keep the whole axis.
109
+ * Scalar: keep only the nearest sample; the axis is dropped from
110
+ the result.
111
+ * Two-element ``[min, max]``: keep the contiguous block of samples
112
+ between the samples nearest to the endpoints.
113
+
114
+ Returns
115
+ -------
116
+ numpy.ndarray
117
+ The selected section, with axes in the order (time, Z, R), minus
118
+ the axes selected by a scalar.
119
+
120
+ Raises
121
+ ------
122
+ ValueError
123
+ If ``field`` is not a valid dataset name, or if a range has more
124
+ than two elements.
125
+ """
126
+ valid = ("density", "electron_temperature", "ion_temperature")
127
+ if field not in valid:
128
+ raise ValueError(
129
+ f"field must be one of {valid}; got {field!r}."
130
+ )
131
+
132
+ dataset = self.file[f"data/xanimation/{field}"]
133
+
134
+ t_sel = self._range_to_selector(self.time_axis, t_range)
135
+ z_sel = self._range_to_selector(self.Z_axis, z_range)
136
+ r_sel = self._range_to_selector(self.R_axis, r_range)
137
+
138
+ return dataset[t_sel, z_sel, r_sel]
139
+
140
+ def close(self):
141
+ """
142
+ Close the HDF5 file. Safe to call more than once.
143
+ """
144
+ if self.file is not None:
145
+ self.file.close()
146
+ self.file = None
147
+
148
+ def __enter__(self):
149
+ return self
150
+
151
+ def __exit__(self, exc_type, exc, tb):
152
+ self.close()
153
+ return False
154
+
155
+ def __del__(self):
156
+ try:
157
+ self.close()
158
+ except Exception:
159
+ pass
160
+
161
+ def __repr__(self):
162
+ nR = getattr(self.R_axis, "size", 0)
163
+ nZ = getattr(self.Z_axis, "size", 0)
164
+ nT = getattr(self.time_axis, "size", 0)
165
+ return f"HESEL(path={self.path!r}, R={nR}, Z={nZ}, time={nT})"
166
+
167
+
168
+ def expand_hesel(field, R_axis, Z_axis, n_z=1, n_sol=0, n_edge=0,
169
+ r_axis=-1, z_axis=-2):
170
+ """
171
+ Expand a HESEL field along R and/or Z.
172
+
173
+ Three independent expansions can be combined in one call:
174
+
175
+ * **Edge** (``n_edge > 0``): prepend ``n_edge`` R samples below
176
+ ``R_axis[0]``, filled per frame with the Z-average of the innermost R
177
+ column of ``field``.
178
+ * **SOL** (``n_sol > 0``): append ``n_sol`` R samples above ``R_axis[-1]``,
179
+ filled per frame with the Z-average of the outermost R column.
180
+ * **Periodic Z stacking** (``n_z > 1``): concatenate ``n_z`` copies of the
181
+ field along Z, assuming periodic boundary conditions in Z.
182
+
183
+ The extended axes keep the original grid spacings. With the defaults
184
+ ``n_z=1, n_sol=0, n_edge=0`` the inputs are returned unchanged (as arrays).
185
+
186
+ Parameters
187
+ ----------
188
+ field : array_like
189
+ Field to expand, e.g. (time, Z, R) from :meth:`HESEL.extract_field` or
190
+ a 2-D (Z, R) snapshot.
191
+ R_axis, Z_axis : array_like
192
+ 1-D, uniformly spaced R and Z axes [m] matching the ``r_axis`` and
193
+ ``z_axis`` dimensions of ``field``.
194
+ n_z : int, default 1
195
+ Number of periodic copies along Z; 1 leaves Z unchanged.
196
+ n_sol : int, default 0
197
+ Number of SOL samples appended on the high-R side.
198
+ n_edge : int, default 0
199
+ Number of edge samples prepended on the low-R side.
200
+ r_axis : int, default -1
201
+ Axis of ``field`` corresponding to R.
202
+ z_axis : int, default -2
203
+ Axis of ``field`` corresponding to Z.
204
+
205
+ Returns
206
+ -------
207
+ field_expanded : numpy.ndarray
208
+ The expanded field, with the R axis longer by ``n_edge + n_sol`` and
209
+ the Z axis ``n_z`` times longer.
210
+ R_expanded : numpy.ndarray
211
+ Extended R axis (ascending), length ``R_axis.size + n_edge + n_sol``
212
+ [m].
213
+ Z_expanded : numpy.ndarray
214
+ Extended Z axis, length ``n_z * Z_axis.size`` [m].
215
+
216
+ Raises
217
+ ------
218
+ ValueError
219
+ If ``n_z``, ``n_sol`` or ``n_edge`` is not an integer, if ``n_z`` < 1,
220
+ or if ``n_sol`` or ``n_edge`` is negative.
221
+ """
222
+ for name, n in (("n_z", n_z), ("n_sol", n_sol), ("n_edge", n_edge)):
223
+ if not isinstance(n, (int, np.integer)):
224
+ raise ValueError(f"{name} must be an integer; got {n!r}.")
225
+ if n_z < 1:
226
+ raise ValueError(f"n_z must be >= 1; got {n_z!r}.")
227
+ if n_sol < 0 or n_edge < 0:
228
+ raise ValueError(
229
+ f"n_sol and n_edge must be >= 0; got n_sol={n_sol}, n_edge={n_edge}."
230
+ )
231
+
232
+ field = np.asarray(field)
233
+ R_axis = np.asarray(R_axis)
234
+ Z_axis = np.asarray(Z_axis)
235
+
236
+ def _radial_extend(field, r_index, count):
237
+ """
238
+ Return a constant R extension of ``count`` samples.
239
+
240
+ It is filled with the Z-average of the R column at index ``r_index``.
241
+ """
242
+ slc = [slice(None)] * field.ndim
243
+ slc[r_axis] = slice(r_index, r_index + 1) if r_index >= 0 else slice(r_index, None)
244
+ column = field[tuple(slc)]
245
+ fill = column.mean(axis=z_axis, keepdims=True)
246
+ ext_shape = list(field.shape)
247
+ ext_shape[r_axis] = count
248
+ return np.broadcast_to(fill, ext_shape)
249
+
250
+ if n_edge > 0:
251
+ edge_ext = _radial_extend(field, 0, n_edge)
252
+ field = np.concatenate([edge_ext, field], axis=r_axis)
253
+
254
+ if n_sol > 0:
255
+ sol_ext = _radial_extend(field, -1, n_sol)
256
+ field = np.concatenate([field, sol_ext], axis=r_axis)
257
+
258
+ if n_z > 1:
259
+ field = np.concatenate([field] * n_z, axis=z_axis)
260
+
261
+ dR = R_axis[1] - R_axis[0]
262
+ R_expanded = R_axis
263
+ if n_edge > 0:
264
+ R_expanded = np.concatenate(
265
+ [R_axis[0] - dR * np.arange(n_edge, 0, -1), R_expanded]
266
+ )
267
+ if n_sol > 0:
268
+ R_expanded = np.concatenate(
269
+ [R_expanded, R_axis[-1] + dR * np.arange(1, n_sol + 1)]
270
+ )
271
+
272
+ if n_z > 1:
273
+ L = Z_axis.size * (Z_axis[1] - Z_axis[0])
274
+ Z_expanded = np.concatenate([Z_axis + i * L for i in range(n_z)])
275
+ else:
276
+ Z_expanded = Z_axis
277
+
278
+ return field, R_expanded, Z_expanded
@@ -0,0 +1,359 @@
1
+ """
2
+ Time-dependent density perturbations on 2-D background fields: Gaussian
3
+ filaments and sine waves, with HDF5 input and output.
4
+ """
5
+ import numpy as np
6
+ import h5py
7
+
8
+ def _field_rz(R, Z, field):
9
+ """
10
+ Return ``field`` in (NR, NZ) layout.
11
+
12
+ Accepts (NR, NZ), and (NZ, NR) as produced by
13
+ plasmasds_fields.w7x.fields.make_regular_density_field or HESEL snapshots.
14
+ A square field (NR == NZ) is taken as (NR, NZ).
15
+ """
16
+ field = np.asarray(field)
17
+ if field.shape == (len(R), len(Z)):
18
+ return field
19
+ if field.shape == (len(Z), len(R)):
20
+ return field.T
21
+ raise ValueError(
22
+ f"field shape {field.shape} does not match R ({len(R)}) and "
23
+ f"Z ({len(Z)}); expected ({len(R)}, {len(Z)}) or "
24
+ f"({len(Z)}, {len(R)}).")
25
+
26
+ def perturbation_gaussian(R, Z, field,
27
+ dt, r_trajectory , z_trajectory ,
28
+ perturbation_fwhm, perturbation_ampl,
29
+ file=None, return_array=True):
30
+ """
31
+ Simulate travelling Gaussian filaments on a 2-D background density field.
32
+
33
+ At each time step, every filament is added to the background field as a
34
+ round 2-D Gaussian, evaluated within ±3 sigma of its centre. The result can
35
+ be written to an HDF5 file and/or returned in memory.
36
+
37
+ Parameters
38
+ ----------
39
+ R : numpy.ndarray, shape (NR,)
40
+ R coordinates of the grid [m].
41
+ Z : numpy.ndarray, shape (NZ,)
42
+ Z coordinates of the grid [m].
43
+ field : numpy.ndarray, shape (NR, NZ) or (NZ, NR)
44
+ Background density field [m^-3]. The (NZ, NR) layout of
45
+ :func:`plasmasds_fields.w7x.fields.make_regular_density_field` is
46
+ transposed automatically; a square field is taken as (NR, NZ).
47
+ dt : float
48
+ Time step [s].
49
+ r_trajectory : list of array_like, length n_filaments
50
+ R position of each filament at each time step [m]:
51
+ ``r_trajectory[f_i][t_i]`` is the R of filament ``f_i`` at step
52
+ ``t_i``. None or NaN values mean the filament is absent.
53
+ z_trajectory : list of array_like, length n_filaments
54
+ Z position of each filament at each time step [m], indexed like
55
+ ``r_trajectory``. None or NaN values mean the filament is absent.
56
+
57
+ Trajectories (and per-step ``perturbation_fwhm`` /
58
+ ``perturbation_ampl`` lists) may have different lengths: the number of
59
+ frames is set by the longest trajectory, and a filament is absent in
60
+ the frames beyond the end of its own data.
61
+ perturbation_fwhm : float or list of array_like, length n_filaments
62
+ Full width at half maximum of the filaments [m]. A scalar applies to
63
+ all filaments and time steps; a list is indexed like ``r_trajectory``.
64
+ The Gaussian sigma is FWHM / (2 sqrt(2 ln 2)) ≈ FWHM / 2.3548.
65
+ perturbation_ampl : float or list of array_like, length n_filaments
66
+ Peak amplitude of the filaments [m^-3]. A scalar applies to all
67
+ filaments and time steps; a list is indexed like ``r_trajectory``.
68
+ file : str or path-like, default None
69
+ If given, the result is written to an HDF5 file at this path, with
70
+ datasets ``"R"``, ``"Z"``, ``"t"`` and one dataset per time step named
71
+ ``"field_TTTTT"`` (zero-padded frame number). The file is closed even
72
+ if an error occurs.
73
+ return_array : bool, default True
74
+ If True, the time-resolved field is kept in memory and returned. Set to
75
+ False to save memory when only the file output is needed.
76
+
77
+ Returns
78
+ -------
79
+ dict or None
80
+ If ``return_array`` is True, a dictionary with:
81
+
82
+ * ``"R"``: R coordinates, shape (NR,) [m].
83
+ * ``"Z"``: Z coordinates, shape (NZ,) [m].
84
+ * ``"t"``: time axis, shape (Nt,) [s].
85
+ * ``"field"``: density field at each time step, shape (Nt, NR, NZ)
86
+ [m^-3].
87
+
88
+ None if ``return_array`` is False.
89
+
90
+ Raises
91
+ ------
92
+ ValueError
93
+ If ``field`` does not match the lengths of ``R`` and ``Z``, or if
94
+ ``z_trajectory``, ``perturbation_fwhm`` or ``perturbation_ampl`` (when
95
+ given per filament) has a different number of filaments than
96
+ ``r_trajectory``.
97
+ """
98
+ field = _field_rz(R, Z, field)
99
+
100
+ _scalar_types = (int, float, np.integer, np.floating)
101
+ fwhm_is_scalar = isinstance(perturbation_fwhm, _scalar_types)
102
+ ampl_is_scalar = isinstance(perturbation_ampl, _scalar_types)
103
+ n_f = len(r_trajectory)
104
+
105
+ # every per-filament input must describe the same filaments
106
+ per_filament = [("z_trajectory", z_trajectory)]
107
+ if not fwhm_is_scalar: per_filament.append(("perturbation_fwhm",
108
+ perturbation_fwhm))
109
+ if not ampl_is_scalar: per_filament.append(("perturbation_ampl",
110
+ perturbation_ampl))
111
+ for name, values in per_filament:
112
+ if len(values) != n_f:
113
+ raise ValueError(f"{name} has {len(values)} filaments, "
114
+ f"r_trajectory has {n_f}.")
115
+
116
+ # the longest trajectory sets the number of frames
117
+ n_t = max(len(traj) for traj in list(r_trajectory) + list(z_trajectory))
118
+ t = np.arange(n_t) * dt
119
+
120
+ if return_array:
121
+ data = {"R": R, "Z": Z, "t": t,
122
+ "field": np.zeros((len(t), len(R), len(Z)))}
123
+
124
+ # 2-D Gaussian, evaluated within a ±3 sigma window around each filament
125
+ def gaussian_2d(R, Z, r0=0, z0=0, sigma_r=1, sigma_z=1, A=1):
126
+ """
127
+ Return a 2-D Gaussian of amplitude ``A`` centred at (r0, z0).
128
+ """
129
+
130
+ return A * np.exp(-(((R - r0) ** 2) / (2 * sigma_r ** 2)
131
+ + ((Z - z0) ** 2) / (2 * sigma_z ** 2)))
132
+
133
+ fwhm_to_sigma = 2 * np.sqrt(2 * np.log(2)) # FWHM = 2.3548 sigma
134
+
135
+ def step(values, f_i, t_i):
136
+ """Value of filament f_i at step t_i, None beyond its data."""
137
+ return values[f_i][t_i] if t_i < len(values[f_i]) else None
138
+
139
+ f = None
140
+ try:
141
+ if file is not None:
142
+ # create the output file, fill it after
143
+ f = h5py.File(file, "w")
144
+ f.create_dataset("R", data = R, compression="gzip")
145
+ f.create_dataset("Z", data = Z, compression="gzip")
146
+ f.create_dataset("t", data = t, compression="gzip")
147
+
148
+ # iterate through all the frames and the filaments
149
+ for t_i in range(len(t)):
150
+ den_act = field.copy()
151
+ for f_i in range(n_f):
152
+ posR = step(r_trajectory, f_i, t_i)
153
+ posZ = step(z_trajectory, f_i, t_i)
154
+ fwhm_val = (perturbation_fwhm if fwhm_is_scalar
155
+ else step(perturbation_fwhm, f_i, t_i))
156
+ ampl = (perturbation_ampl if ampl_is_scalar
157
+ else step(perturbation_ampl, f_i, t_i))
158
+ # missing data (None or NaN): no filament in this frame
159
+ if any(v is None or np.isnan(v)
160
+ for v in (posR, posZ, fwhm_val, ampl)):
161
+ continue
162
+ sigR = sigZ = fwhm_val / fwhm_to_sigma
163
+ effR = effZ = 3 * sigR
164
+ # grid indices within the ±3 sigma window
165
+ G_ind_r = np.where((R >= posR - effR) &
166
+ (R <= posR + effR))[0]
167
+ G_ind_z = np.where((Z >= posZ - effZ) &
168
+ (Z <= posZ + effZ))[0]
169
+ # filament window entirely outside the grid: nothing to add
170
+ if G_ind_r.size == 0 or G_ind_z.size == 0:
171
+ continue
172
+ GR, GZ = np.meshgrid(R[G_ind_r], Z[G_ind_z], indexing="ij")
173
+ G_field = gaussian_2d(GR, GZ, r0 = posR, z0 = posZ,
174
+ sigma_r = sigR, sigma_z = sigZ,
175
+ A = ampl)
176
+ # adding the Gaussian influence to the original den. field
177
+ den_act[G_ind_r[0] : G_ind_r[-1] +1,
178
+ G_ind_z[0] : G_ind_z[-1] +1] += G_field
179
+
180
+ if return_array: data["field"][t_i, :, :] = den_act
181
+
182
+ if f is not None:
183
+ # Save arrays to an HDF5 file
184
+ f.create_dataset("field_" + str(t_i).zfill(5),
185
+ data = den_act, compression="gzip")
186
+ finally:
187
+ if f is not None: f.close()
188
+
189
+ if return_array: return data
190
+ else: return None
191
+
192
+ def perturbation_sine(R, Z, field, dt, nt, r_trajectory, perturbation_speed,
193
+ perturbation_wavelength, perturbation_ampl,
194
+ file=None, return_array=True):
195
+ """
196
+ Simulate a travelling sine perturbation of a 2-D background density field.
197
+
198
+ The perturbation is a sine wave around a vertical centre line,
199
+ ``r_trajectory``, travelling along Z. At each time step and Z grid point,
200
+ all densities between the centre line and the displaced wave are set to the
201
+ background density at the centre line, i.e. the density profile is
202
+ flattened over the displacement. The result can be written to an HDF5 file
203
+ and/or returned in memory.
204
+
205
+ Parameters
206
+ ----------
207
+ R : numpy.ndarray, shape (NR,)
208
+ R coordinates of the grid [m].
209
+ Z : numpy.ndarray, shape (NZ,)
210
+ Z coordinates of the grid [m].
211
+ field : numpy.ndarray, shape (NR, NZ) or (NZ, NR)
212
+ Background density field [m^-3]. The (NZ, NR) layout of
213
+ :func:`plasmasds_fields.w7x.fields.make_regular_density_field` is
214
+ transposed automatically; a square field is taken as (NR, NZ).
215
+ dt : float
216
+ Time step [s].
217
+ nt : int
218
+ Number of time steps (total time nt * dt).
219
+ r_trajectory : float or numpy.ndarray, shape (NZ,)
220
+ R position of the centre line of the wave at each Z grid point [m],
221
+ usually a parabolic line along Z. A scalar gives a straight vertical
222
+ centre line.
223
+ perturbation_speed : float
224
+ Propagation speed of the wave along Z [m/s]; positive values travel
225
+ towards larger Z.
226
+ perturbation_wavelength : float
227
+ Wavelength of the wave along Z [m].
228
+ perturbation_ampl : float
229
+ Amplitude of the radial displacement of the wave [m].
230
+ file : str or path-like, default None
231
+ If given, the result is written to an HDF5 file at this path, with
232
+ datasets ``"R"``, ``"Z"``, ``"t"`` and one dataset per time step named
233
+ ``"field_TTTTT"`` (zero-padded frame number). The file is closed even
234
+ if an error occurs.
235
+ return_array : bool, default True
236
+ If True, the time-resolved field is kept in memory and returned. Set to
237
+ False to save memory when only the file output is needed.
238
+
239
+ Returns
240
+ -------
241
+ dict or None
242
+ If ``return_array`` is True, a dictionary with:
243
+
244
+ * ``"R"``: R coordinates, shape (NR,) [m].
245
+ * ``"Z"``: Z coordinates, shape (NZ,) [m].
246
+ * ``"t"``: time axis, shape (Nt,) [s].
247
+ * ``"field"``: density field at each time step, shape (Nt, NR, NZ)
248
+ [m^-3].
249
+
250
+ None if ``return_array`` is False.
251
+
252
+ Raises
253
+ ------
254
+ ValueError
255
+ If ``field`` does not match the lengths of ``R`` and ``Z``, or if
256
+ ``r_trajectory`` is neither a scalar nor one value per Z grid point.
257
+ """
258
+
259
+ field = _field_rz(R, Z, field)
260
+
261
+ # centre line: one R value per Z grid point; a scalar gives a straight line
262
+ r_trajectory = np.asarray(r_trajectory, dtype=float)
263
+ if r_trajectory.ndim == 0:
264
+ r_trajectory = np.full(len(Z), float(r_trajectory))
265
+ if r_trajectory.shape != (len(Z),):
266
+ raise ValueError(
267
+ f"r_trajectory must be a scalar or have one value per Z grid "
268
+ f"point ({len(Z)}), got shape {r_trajectory.shape}.")
269
+
270
+ t = np.arange(nt) * dt
271
+
272
+ if return_array:
273
+ data = {"R": R, "Z": Z, "t": t,
274
+ "field": np.zeros((len(t), len(R), len(Z)))}
275
+
276
+ f = None
277
+ try:
278
+ if file is not None:
279
+ # create the output file, fill it after
280
+ f = h5py.File(file, "w")
281
+ f.create_dataset("R", data = R, compression="gzip")
282
+ f.create_dataset("Z", data = Z, compression="gzip")
283
+ f.create_dataset("t", data = t, compression="gzip")
284
+
285
+ # iterate through all the frames and the filaments
286
+ mask = np.zeros(len(R), dtype=bool)
287
+ for t_i in range(len(t)):
288
+ den_act = field.copy()
289
+
290
+ for z_i in range(len(Z)):
291
+ r0_center = r_trajectory[z_i]
292
+
293
+ k = 2 * np.pi / perturbation_wavelength
294
+ omega = k * perturbation_speed
295
+ r0_act = (r0_center + perturbation_ampl
296
+ * np.cos(k * Z[z_i] - omega * t[t_i]))
297
+
298
+ peak = np.interp(r0_center, R, den_act[:, z_i])
299
+
300
+
301
+ if r0_act < r0_center: r_min, r_max = r0_act, r0_center
302
+ else: r_min, r_max = r0_center, r0_act
303
+ mask[:] = (R < r_max) & (R > r_min)
304
+
305
+ den_act[mask, z_i] = peak
306
+
307
+
308
+ if return_array: data["field"][t_i, :, :] = den_act
309
+
310
+ if f is not None:
311
+ # Save arrays to an HDF5 file
312
+ f.create_dataset("field_" + str(t_i).zfill(5),
313
+ data = den_act, compression="gzip")
314
+ finally:
315
+ if f is not None: f.close()
316
+
317
+ if return_array: return data
318
+ else: return None
319
+
320
+ def import_density_field(file):
321
+ """
322
+ Read a density field simulation from an HDF5 file.
323
+
324
+ Reads files written by :func:`perturbation_gaussian` or
325
+ :func:`perturbation_sine`, with datasets ``"R"``, ``"Z"``, ``"t"`` and one
326
+ 2-D snapshot per time step named ``"field_TTTTT"`` (zero-padded frame
327
+ number).
328
+
329
+ Parameters
330
+ ----------
331
+ file : str or path-like
332
+ Path to the HDF5 file.
333
+
334
+ Returns
335
+ -------
336
+ dict
337
+ Dictionary with:
338
+
339
+ * ``"R"``: R coordinates, shape (NR,) [m].
340
+ * ``"Z"``: Z coordinates, shape (NZ,) [m].
341
+ * ``"t"``: time axis, shape (Nt,) [s].
342
+ * ``"field"``: density field at each time step, shape (Nt, NR, NZ)
343
+ [m^-3], in order of the frame number in the dataset names.
344
+
345
+ Raises
346
+ ------
347
+ KeyError
348
+ If the file has no ``"R"``, ``"Z"`` or ``"t"`` dataset.
349
+ """
350
+
351
+ with h5py.File(file, "r") as f:
352
+ Z, R = np.array(f["Z"]), np.array(f["R"])
353
+ # sort by frame number, not alphabetically (robust beyond 5 digits)
354
+ keys = sorted((key for key in f.keys() if key.startswith("field_")),
355
+ key=lambda key: int(key[len("field_"):]))
356
+ field = np.asarray([f[key] for key in keys])
357
+ t = np.array(f["t"])
358
+ return {"R": R, "Z": Z, "field": field, "t": t}
359
+