cavsim2d 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.
Files changed (92) hide show
  1. cavsim2d/__init__.py +83 -0
  2. cavsim2d/analysis/__init__.py +0 -0
  3. cavsim2d/analysis/impedance.py +366 -0
  4. cavsim2d/analysis/lorentz/__init__.py +7 -0
  5. cavsim2d/analysis/lorentz/pressure.py +226 -0
  6. cavsim2d/analysis/multipacting/__init__.py +21 -0
  7. cavsim2d/analysis/multipacting/driver.py +487 -0
  8. cavsim2d/analysis/multipacting/fields.py +134 -0
  9. cavsim2d/analysis/multipacting/integrators.py +825 -0
  10. cavsim2d/analysis/multipacting/metrics.py +82 -0
  11. cavsim2d/analysis/multipacting/particles.py +283 -0
  12. cavsim2d/analysis/multipacting/plots.py +831 -0
  13. cavsim2d/analysis/multipacting/sey.py +47 -0
  14. cavsim2d/analysis/optimisation.py +1423 -0
  15. cavsim2d/analysis/tune/__init__.py +0 -0
  16. cavsim2d/analysis/tune/pyTuner.py +935 -0
  17. cavsim2d/analysis/tune/tuner.py +207 -0
  18. cavsim2d/analysis/uq/__init__.py +7 -0
  19. cavsim2d/analysis/uq/models.py +140 -0
  20. cavsim2d/analysis/uq/plots.py +229 -0
  21. cavsim2d/analysis/uq/sobol_sa.py +204 -0
  22. cavsim2d/analysis/wakefield/__init__.py +0 -0
  23. cavsim2d/config_files/optimisation_config.py +56 -0
  24. cavsim2d/config_files/uq_config.py +19 -0
  25. cavsim2d/constants.py +119 -0
  26. cavsim2d/data_module/__init__.py +0 -0
  27. cavsim2d/data_module/abci_data.py +759 -0
  28. cavsim2d/data_module/operating_points.py +311 -0
  29. cavsim2d/data_module/sey/sey +23 -0
  30. cavsim2d/geometry/__init__.py +35 -0
  31. cavsim2d/geometry/beampipes.py +187 -0
  32. cavsim2d/geometry/contours.py +209 -0
  33. cavsim2d/geometry/plotting.py +653 -0
  34. cavsim2d/geometry/primitives.py +152 -0
  35. cavsim2d/geometry/profile.py +1241 -0
  36. cavsim2d/geometry/tangency.py +390 -0
  37. cavsim2d/geometry/writers/__init__.py +11 -0
  38. cavsim2d/geometry/writers/cst.py +93 -0
  39. cavsim2d/geometry/writers/gmsh.py +1719 -0
  40. cavsim2d/geometry/writers/multipac.py +783 -0
  41. cavsim2d/models/__init__.py +39 -0
  42. cavsim2d/models/assembly.py +726 -0
  43. cavsim2d/models/base.py +3510 -0
  44. cavsim2d/models/beampipe.py +200 -0
  45. cavsim2d/models/bellows.py +283 -0
  46. cavsim2d/models/bla.py +203 -0
  47. cavsim2d/models/elliptical.py +1524 -0
  48. cavsim2d/models/elliptical_flattop.py +772 -0
  49. cavsim2d/models/pillbox.py +326 -0
  50. cavsim2d/models/rfgun.py +585 -0
  51. cavsim2d/models/spline.py +567 -0
  52. cavsim2d/models/taper.py +206 -0
  53. cavsim2d/processes/__init__.py +5 -0
  54. cavsim2d/processes/eigenmode.py +168 -0
  55. cavsim2d/processes/tune.py +1259 -0
  56. cavsim2d/processes/uq.py +728 -0
  57. cavsim2d/processes/wakefield.py +423 -0
  58. cavsim2d/solvers/ABCI/abci.py +148 -0
  59. cavsim2d/solvers/NGSolve/__init__.py +0 -0
  60. cavsim2d/solvers/NGSolve/eigen_ngsolve.py +3310 -0
  61. cavsim2d/solvers/NGSolve/eigen_ports.py +615 -0
  62. cavsim2d/solvers/NGSolve/frequency_domain_ngsolve.py +217 -0
  63. cavsim2d/solvers/NGSolve/ports.py +266 -0
  64. cavsim2d/solvers/__init__.py +16 -0
  65. cavsim2d/solvers/eigenmode_result.py +97 -0
  66. cavsim2d/solvers/objectives.py +186 -0
  67. cavsim2d/solvers/solver_objects.py +4496 -0
  68. cavsim2d/solvers/wakefield/__init__.py +33 -0
  69. cavsim2d/solvers/wakefield/abci.py +139 -0
  70. cavsim2d/solvers/wakefield/base.py +118 -0
  71. cavsim2d/study.py +4274 -0
  72. cavsim2d/utils/__init__.py +13 -0
  73. cavsim2d/utils/config_validation.py +229 -0
  74. cavsim2d/utils/cubature.py +709 -0
  75. cavsim2d/utils/data_utils.py +337 -0
  76. cavsim2d/utils/printing.py +106 -0
  77. cavsim2d/utils/quadrature.py +852 -0
  78. cavsim2d/utils/run_log.py +76 -0
  79. cavsim2d/utils/sampling.py +402 -0
  80. cavsim2d/utils/sensitivity.py +808 -0
  81. cavsim2d/utils/shapes.py +449 -0
  82. cavsim2d/utils/shared_classes.py +5 -0
  83. cavsim2d/utils/shared_functions.py +30 -0
  84. cavsim2d/utils/slope_triangles.py +165 -0
  85. cavsim2d/utils/style.py +157 -0
  86. cavsim2d/utils/surrogate.py +385 -0
  87. cavsim2d/utils/welcome.py +40 -0
  88. cavsim2d-0.1.0.dist-info/METADATA +666 -0
  89. cavsim2d-0.1.0.dist-info/RECORD +92 -0
  90. cavsim2d-0.1.0.dist-info/WHEEL +5 -0
  91. cavsim2d-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. cavsim2d-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ """Multipacting analysis for 2D-axisymmetric RF cavities.
2
+
3
+ Relativistic electron tracking in the cavity's eigenmode field with secondary
4
+ emission, following the PyMultipact method (the user's validated physics). The
5
+ EM field is read from a cavsim2d eigenmode result and the whole analysis is
6
+ driven through the ``cav.multipacting`` solver object; this package holds the
7
+ pieces:
8
+
9
+ - :mod:`.particles` / :mod:`.integrators` -- particle state + RK4 Lorentz push,
10
+ - :mod:`.sey` -- the secondary-emission-yield table,
11
+ - :mod:`.fields` -- the eigenmode-field adapter (in-plane E + reconstructed H),
12
+ - :mod:`.driver` -- the peak-field sweep,
13
+ - :mod:`.metrics` -- counter / final-energy / distance-function metrics.
14
+
15
+ Only the ngsolve-free pieces (``SEY``) are re-exported here; ``driver``/``fields``
16
+ pull in ngsolve, so import them from their submodules where the field is needed —
17
+ keeping ``import cavsim2d`` soft on the ngsolve dependency.
18
+ """
19
+ from cavsim2d.analysis.multipacting.sey import SEY, DEFAULT_SEY
20
+
21
+ __all__ = ['SEY', 'DEFAULT_SEY']
@@ -0,0 +1,487 @@
1
+ """Multipacting field-level sweep driver.
2
+
3
+ Ported from PyMultipact's ``Domain.analyse_multipacting`` / ``_analyse_multipacting``,
4
+ reworked so the EM field comes from a cavsim2d eigenmode result folder (the
5
+ adapter in :mod:`.fields`) and everything is written under ``<cav>/multipacting/``.
6
+
7
+ For each peak-field value in the sweep, an electron cloud is launched from the
8
+ wall over a grid of RF launch phases and tracked with the RK4 integrator; the
9
+ counter function ``c20/c0`` is the fraction that survives to 20 impacts. The
10
+ sweep is embarrassingly parallel over field levels (one worker per chunk).
11
+ """
12
+ import itertools
13
+ from collections import defaultdict
14
+ import multiprocessing as mp
15
+ import os
16
+ import pickle
17
+ import subprocess
18
+ import sys
19
+ import threading
20
+ import time
21
+ import traceback
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+ from ngsolve import Norm
26
+ from tqdm.auto import tqdm
27
+
28
+ from cavsim2d.analysis.multipacting.fields import build_emfield, load_eigenmode_fields
29
+ from cavsim2d.constants import c0, m0, q0 # noqa: F401 (re-exported to the workers)
30
+ from cavsim2d.analysis.multipacting.integrators import Integrators
31
+ from cavsim2d.analysis.multipacting.metrics import distance_function
32
+ from cavsim2d.analysis.multipacting.particles import Particles
33
+ from cavsim2d.utils.run_log import RunTimer
34
+
35
+
36
+
37
+ def _surface_polyline(mesh, bc='PEC'):
38
+ """Wall (PEC) vertices in CONTOUR order, with a chain id per vertex.
39
+
40
+ The wall is walked along its boundary elements, so consecutive points are
41
+ neighbours on the wall, and each chain is oriented with the vacuum on its right:
42
+ the collision code takes a segment's right-hand normal as the inward normal.
43
+ Separate pieces of wall (chains) get separate ids and are never joined.
44
+
45
+ The points used to be sorted by z. That orders a wall correctly only while z
46
+ increases along it. A wall that turns back in z (a re-entrant iris, an RF gun's
47
+ funnel) or runs down a vertical face (a pillbox's right-hand plate) was then
48
+ joined out of order, into segments that cross the vacuum, and the normals of
49
+ an overhanging wall pointed into the metal.
50
+ """
51
+ adj = defaultdict(set)
52
+ for el in mesh.Boundaries(bc).Elements():
53
+ vs = [v.nr for v in el.vertices]
54
+ if len(vs) >= 2 and vs[0] != vs[-1]:
55
+ adj[vs[0]].add(vs[-1])
56
+ adj[vs[-1]].add(vs[0])
57
+ pos = {v: tuple(mesh.vertices[v].point) for v in adj}
58
+ unseen = set(adj)
59
+ chains = []
60
+ while unseen:
61
+ ends = [v for v in unseen if len(adj[v] & unseen) <= 1]
62
+ cur = min(ends or unseen, key=lambda v: pos[v])
63
+ chain = [cur]
64
+ unseen.discard(cur)
65
+ while True:
66
+ nxt = sorted((w for w in adj[cur] if w in unseen), key=lambda w: pos[w])
67
+ if not nxt:
68
+ break
69
+ cur = nxt[0]
70
+ chain.append(cur)
71
+ unseen.discard(cur)
72
+ pts = np.array([pos[v] for v in chain], dtype=float)
73
+ if len(pts) >= 2 and not _vacuum_on_right(mesh, pts):
74
+ pts = pts[::-1]
75
+ chains.append(pts)
76
+ chains.sort(key=lambda c: (c[:, 0].min(), c[:, 1].min()))
77
+ points = np.vstack(chains) if chains else np.empty((0, 2))
78
+ chain_id = np.concatenate([np.full(len(c), i) for i, c in enumerate(chains)]) \
79
+ if chains else np.empty(0, dtype=int)
80
+ return points, chain_id
81
+
82
+
83
+ def _vacuum_on_right(mesh, pts):
84
+ """True if the meshed region lies to the right of the polyline *pts*, tested a
85
+ fraction of a segment length off the longest segment's midpoint."""
86
+ d = np.diff(pts, axis=0)
87
+ k = int(np.argmax(np.hypot(d[:, 0], d[:, 1])))
88
+ mid = 0.5 * (pts[k] + pts[k + 1])
89
+ length = float(np.hypot(*d[k]))
90
+ right = np.array([d[k][1], -d[k][0]]) / length
91
+ probe = mid + 0.05 * length * right
92
+ return mesh(float(probe[0]), float(probe[1])).nr >= 0
93
+
94
+
95
+ def _surface_points(mesh, bc='PEC'):
96
+ """Wall (PEC) vertices in contour order -- the emission sites and the collision
97
+ polyline (see :func:`_surface_polyline`). cavsim2d names the cavity wall 'PEC'
98
+ (PyMultipact used 'default')."""
99
+ return _surface_polyline(mesh, bc)[0]
100
+
101
+
102
+ def _bounding_rect(xsurf):
103
+ z, r = xsurf[:, 0], xsurf[:, 1]
104
+ return [float(z.min()), float(z.max()), float(r.min()), float(r.max())]
105
+
106
+
107
+ def default_xrange(xsurf):
108
+ """Default emission band: a few wall points around the equator (the
109
+ max-radius point), where elliptical-cavity multipacting concentrates. Spans
110
+ the nearest wall points so the band always contains emission sites (an
111
+ empty band makes the ported Particles call exit())."""
112
+ z = xsurf[:, 0]
113
+ z_eq = float(z[np.argmax(xsurf[:, 1])])
114
+ near = np.sort(np.abs(z - z_eq))[:min(7, len(z))]
115
+ half = max(float(near.max()), 0.5e-3) + 1e-9
116
+ return [z_eq - half, z_eq + half]
117
+
118
+
119
+ def densify_wall(xsurf, xrange, n_points, chain=None):
120
+ """Insert extra vertices along the wall polyline inside *xrange* so the band
121
+ carries about ``n_points`` emission sites — WITHOUT refining the mesh.
122
+
123
+ Emission sites are wall-polyline vertices, so a coarse mesh offers only a
124
+ handful in a narrow band. The wall between two consecutive vertices is a
125
+ straight segment (the tracker's collision polyline is straight by
126
+ construction), so points interpolated along it lie exactly on the wall: the
127
+ polyline — and hence the collision geometry and the surface normals — is
128
+ unchanged, there are simply more places to launch from. Cheaper and far more
129
+ local than shrinking ``pec_maxh`` over the whole cavity.
130
+
131
+ *xsurf* is in contour order (see :func:`_surface_polyline`), and each stretch
132
+ of wall crossing the band is resampled along its own arclength, in place, so
133
+ the order survives. *chain* is the chain id per point; when given, the result
134
+ is ``(points, chain)``, otherwise just the points. ``n_points`` at or below the
135
+ number already present is a no-op.
136
+ """
137
+ xsurf = np.asarray(xsurf, dtype=float)
138
+ ids = np.zeros(len(xsurf), dtype=int) if chain is None else np.asarray(chain)
139
+
140
+ def done(pts, cid):
141
+ return pts if chain is None else (pts, cid)
142
+
143
+ n_points = int(n_points or 0)
144
+ if n_points <= 0 or len(xsurf) < 2:
145
+ return done(xsurf, ids)
146
+ lo, hi = float(xrange[0]), float(xrange[1])
147
+ z = xsurf[:, 0]
148
+ inb = (z >= lo) & (z <= hi)
149
+ if n_points <= int(inb.sum()):
150
+ return done(xsurf, ids) # already at least that many sites
151
+
152
+ # segments (i, i+1) of one chain whose z-span meets the band, grouped into runs
153
+ seg = [i for i in range(len(xsurf) - 1)
154
+ if ids[i] == ids[i + 1] and min(z[i], z[i + 1]) <= hi and max(z[i], z[i + 1]) >= lo]
155
+ runs = []
156
+ for i in seg:
157
+ if runs and runs[-1][-1] == i - 1:
158
+ runs[-1].append(i)
159
+ else:
160
+ runs.append([i])
161
+ if not runs:
162
+ return done(xsurf, ids)
163
+
164
+ def _resample(pts, n):
165
+ d = np.r_[0.0, np.cumsum(np.hypot(*np.diff(pts, axis=0).T))]
166
+ if d[-1] <= 0:
167
+ return None
168
+ t = np.linspace(0.0, d[-1], int(n))
169
+ return np.column_stack([np.interp(t, d, pts[:, 0]),
170
+ np.interp(t, d, pts[:, 1])])
171
+
172
+ out_pts, out_ids, cursor = [], [], 0
173
+ for run in runs:
174
+ a, b = run[0], run[-1] + 1 # points a..b span the run
175
+ stretch = xsurf[a:b + 1]
176
+ fine = _resample(stretch, max(n_points * 8, 64))
177
+ cand = None if fine is None else fine[(fine[:, 0] >= lo) & (fine[:, 0] <= hi)]
178
+ dense = _resample(cand, n_points) if cand is not None and len(cand) >= 2 else None
179
+ if dense is None:
180
+ continue
181
+ keep = ~inb[a:b + 1]
182
+ first_in = int(np.argmax(~keep)) if (~keep).any() else None
183
+ before = [p for j, p in enumerate(stretch) if keep[j] and (first_in is None or j < first_in)]
184
+ after = [p for j, p in enumerate(stretch) if keep[j] and first_in is not None and j > first_in]
185
+ if first_in is None: # no vertex inside: split by arclength
186
+ before, after = [stretch[0]], [stretch[-1]]
187
+ out_pts.append(xsurf[cursor:a])
188
+ out_ids.append(ids[cursor:a])
189
+ new = np.vstack([np.array(before).reshape(-1, 2), dense, np.array(after).reshape(-1, 2)])
190
+ out_pts.append(new)
191
+ out_ids.append(np.full(len(new), ids[a]))
192
+ cursor = b + 1
193
+ out_pts.append(xsurf[cursor:])
194
+ out_ids.append(ids[cursor:])
195
+ return done(np.vstack(out_pts), np.concatenate(out_ids))
196
+
197
+
198
+ def _peak_surface_field(em, mesh, xsurf):
199
+ """Peak in-plane |E| over the interior wall points (drives the Epk
200
+ normalisation)."""
201
+ zmin, zmax = xsurf[:, 0].min(), xsurf[:, 0].max()
202
+ interior = xsurf[(xsurf[:, 1] > 0) & (xsurf[:, 0] > zmin) & (xsurf[:, 0] < zmax)]
203
+ esurf = [Norm(em.e)(mesh(float(zi), float(ri))) for (zi, ri) in interior]
204
+ return float(max(esurf))
205
+
206
+
207
+ def _worker(proc_id, folder, fields_dir, freq_mode, mode, xrange, procs_epks,
208
+ phis, v_init, sey, Epk, step, bounding_rect, loss_model, t_max,
209
+ n_points=None):
210
+ """One process's slice of the Epk sweep. Reloads the mesh + eigenmode field
211
+ from *fields_dir*, builds the multipacting EM field, and tracks the cloud for
212
+ each assigned peak-field value; writes ``mresults_{proc_id}``.
213
+
214
+ Any failure is written to ``mresults_{proc_id}.err`` — a spawned child's
215
+ stderr is invisible in notebooks, so the traceback must reach a file the
216
+ parent can read back. BaseException also catches the ``exit()`` the ported
217
+ Particles code calls on an empty emission set (SystemExit)."""
218
+ try:
219
+ _worker_impl(proc_id, folder, fields_dir, freq_mode, mode, xrange,
220
+ procs_epks, phis, v_init, sey, Epk, step, bounding_rect,
221
+ loss_model, t_max, n_points)
222
+ except BaseException:
223
+ with open(os.path.join(folder, f"mresults_{proc_id}.err"), 'w') as f:
224
+ f.write(traceback.format_exc())
225
+ raise
226
+
227
+
228
+ def _worker_impl(proc_id, folder, fields_dir, freq_mode, mode, xrange, procs_epks,
229
+ phis, v_init, sey, Epk, step, bounding_rect, loss_model, t_max,
230
+ n_points=None):
231
+ mesh, gfu_E, _ = load_eigenmode_fields(fields_dir)
232
+ em = build_emfield(gfu_E, mode, freq_mode)
233
+ # Same wall the parent measured Epk on: densified in-band when n_points asks
234
+ # for more launch sites than the mesh vertices provide (geometry unchanged).
235
+ wall, chain = _surface_polyline(mesh)
236
+ xsurf, chain = densify_wall(wall, xrange, n_points, chain=chain)
237
+
238
+ dt = 1 / (freq_mode * 1e6 * 20 * 6)
239
+ w = 2 * np.pi * freq_mode * 1e6
240
+ integrator = Integrators(mesh, w, bounding_rect=bounding_rect, loss_model=loss_model)
241
+
242
+ n_init_particles = 1
243
+ particles_objects = []
244
+ particles_left = []
245
+ start = time.time()
246
+ for epk in procs_epks:
247
+ sub_start = time.time()
248
+ t = 0.0
249
+ particles = Particles(xrange, v_init, xsurf, phis, cmap='jet', step=step,
250
+ chain=chain)
251
+ n_init_particles = len(particles.x)
252
+
253
+ scale = epk
254
+ while t < t_max:
255
+ if particles.len != 0:
256
+ particles.save_old()
257
+ integrator.rk4(particles, t, dt, em, scale, sey)
258
+ particles.update_record()
259
+ t += dt
260
+
261
+ particles_objects.append(particles)
262
+ particles_left.append(len(particles.bright_set))
263
+ print(f"\tEpk: {epk * Epk * 1e-6:.2f} MV/m, bright set: "
264
+ f"{len(particles.bright_set)}, {time.time() - sub_start:.1f}s")
265
+ # Publish progress (field levels finished by this worker) so the parent
266
+ # can aggregate a live bar; a small file the parent polls, robust across
267
+ # the subprocess boundary.
268
+ try:
269
+ with open(os.path.join(folder, f"mprogress_{proc_id}"), 'w') as pf:
270
+ pf.write(str(len(particles_objects)))
271
+ except OSError:
272
+ pass
273
+
274
+ cn_c0 = np.array(particles_left) / n_init_particles
275
+ mresult = {'cn/c0': cn_c0, 'particles_objects': particles_objects,
276
+ 'n_init_particles': n_init_particles, 'epks': procs_epks,
277
+ 'phis_v': phis}
278
+ with open(os.path.join(folder, f"mresults_{proc_id}"), "wb") as file:
279
+ pickle.dump(mresult, file)
280
+ print(f"\tProc {proc_id} done ({time.time() - start:.1f}s).")
281
+
282
+
283
+ def _read_progress(folder, proc_count):
284
+ """Total field levels finished so far, summed over the workers' progress
285
+ files (each holds the count that worker has completed)."""
286
+ done = 0
287
+ for p in range(proc_count):
288
+ try:
289
+ with open(os.path.join(folder, f"mprogress_{p}")) as f:
290
+ done += int(f.read() or 0)
291
+ except (OSError, ValueError):
292
+ pass
293
+ return done
294
+
295
+
296
+ class _SweepProgress:
297
+ """Live tqdm bar over the whole sweep, driven by a daemon thread that polls
298
+ the workers' progress files — so it aggregates across the subprocess workers
299
+ (and the in-process one) without them writing to the same bar."""
300
+
301
+ def __init__(self, folder, proc_count, total, enabled=True):
302
+ self.folder, self.proc_count, self.total = folder, proc_count, total
303
+ self.enabled = enabled
304
+ self._stop = threading.Event()
305
+ self._thread = None
306
+ self._bar = None
307
+
308
+ def __enter__(self):
309
+ if self.enabled:
310
+ self._bar = tqdm(total=self.total, desc='Multipacting sweep',
311
+ unit='field')
312
+ self._thread = threading.Thread(target=self._poll, daemon=True)
313
+ self._thread.start()
314
+ return self
315
+
316
+ def _poll(self):
317
+ while not self._stop.is_set():
318
+ self._bar.n = min(_read_progress(self.folder, self.proc_count), self.total)
319
+ self._bar.refresh()
320
+ self._stop.wait(0.3)
321
+
322
+ def __exit__(self, *exc):
323
+ self._stop.set()
324
+ if self._thread is not None:
325
+ self._thread.join(timeout=1.0)
326
+ if self._bar is not None:
327
+ self._bar.n = self.total
328
+ self._bar.refresh()
329
+ self._bar.close()
330
+
331
+
332
+ def run_sweep(folder, fields_dir, freqs, sey, *, mode=1, xrange=None, epks=None,
333
+ phis=None, v_init=2, step=None, proc_count=None, loss_model='field',
334
+ t_max=1000e-10, progress=True, n_points=None):
335
+ """Run the peak-field sweep and return the assembled results dict.
336
+
337
+ Parameters mirror PyMultipact's ``analyse_multipacting``. *fields_dir* is the
338
+ eigenmode polarisation folder (``mesh.pkl`` + ``gfu_EH.pkl``); *freqs* is the
339
+ mode-frequency list [MHz]; *sey* is a :class:`~.sey.SEY`. ``progress`` shows a
340
+ live tqdm bar over the field levels as the workers finish them.
341
+ """
342
+ freq_mode = freqs[mode]
343
+ lmbda = c0 / (freq_mode * 1e6)
344
+
345
+ # peak field + geometry from the eigenmode mesh
346
+ mesh, gfu_E, _ = load_eigenmode_fields(fields_dir)
347
+ em = build_emfield(gfu_E, mode, freq_mode)
348
+ xsurf = _surface_points(mesh)
349
+ bounding_rect = _bounding_rect(xsurf)
350
+ Epk = _peak_surface_field(em, mesh, xsurf)
351
+
352
+ if epks is None:
353
+ epks_v = 1 / Epk * 1e6 * np.linspace(0, 80, 192)
354
+ else:
355
+ epks_v = 1 / Epk * np.asarray(epks, dtype=float) # user epks in V/m
356
+ phi_v = np.linspace(0, 2 * np.pi, 72) if phis is None else np.asarray(phis)
357
+ if xrange is None:
358
+ # PyMultipact used a fixed [-0.25, 0] mm band tied to its own z-origin;
359
+ # cavsim2d's profile has a different origin, so anchor to the geometry.
360
+ xrange = default_xrange(xsurf)
361
+ # More launch sites without a finer mesh: interpolate along the wall polyline
362
+ # inside the band (geometry unchanged). Workers densify identically.
363
+ xsurf = densify_wall(xsurf, xrange, n_points)
364
+
365
+ if proc_count is None:
366
+ proc_count = max(1, min(mp.cpu_count() - 1, len(epks_v)))
367
+ proc_count = max(1, int(proc_count))
368
+ print(f"Multipacting Epk sweep: {len(epks_v)} points on {proc_count} "
369
+ f"process{'es' if proc_count > 1 else ''}.")
370
+ sweep_start = time.time()
371
+
372
+ # round-robin split, remembering original indices for re-assembly
373
+ divided, divided_idx = [[] for _ in range(proc_count)], [[] for _ in range(proc_count)]
374
+ for idx, value in enumerate(epks_v):
375
+ divided[idx % proc_count].append(value)
376
+ divided_idx[idx % proc_count].append(idx)
377
+ proc_epks = [np.array(lst) for lst in divided]
378
+
379
+ for p in range(proc_count): # clear stale worker outputs
380
+ for stale in (os.path.join(folder, f"mresults_{p}"),
381
+ os.path.join(folder, f"mresults_{p}.err"),
382
+ os.path.join(folder, f"mworker_{p}.log"),
383
+ os.path.join(folder, f"mprogress_{p}")):
384
+ if os.path.exists(stale):
385
+ try:
386
+ os.remove(stale)
387
+ except OSError:
388
+ pass
389
+
390
+ args = lambda p: (p, folder, fields_dir, freq_mode, mode, xrange, proc_epks[p],
391
+ phi_v, v_init, sey, Epk, step, bounding_rect, loss_model, t_max,
392
+ n_points)
393
+ with _SweepProgress(folder, proc_count, len(epks_v), enabled=progress):
394
+ if proc_count == 1:
395
+ _worker(*args(0))
396
+ else:
397
+ # Workers are plain subprocesses (NOT multiprocessing.Process): mp's
398
+ # Windows spawn bootstrap re-executes the parent's __main__ in every
399
+ # child, which requires an `if __name__ == '__main__':` guard in
400
+ # scripts and breaks outright in notebooks/IPython. A fresh interpreter
401
+ # running `-m ...driver <argsfile>` imports only this module, so the
402
+ # sweep works the same from a notebook, an unguarded script, or a test.
403
+ # Worker stdout/stderr goes to mworker_{p}.log (a child's console is
404
+ # invisible in notebooks); failures also land in mresults_{p}.err.
405
+ env = dict(os.environ)
406
+ pkg_root = str(Path(__file__).resolve().parents[3]) # dir with cavsim2d/
407
+ env['PYTHONPATH'] = pkg_root + os.pathsep + env.get('PYTHONPATH', '')
408
+ procs = []
409
+ for p in range(proc_count):
410
+ args_path = os.path.join(folder, f"mworker_{p}.args")
411
+ with open(args_path, 'wb') as f:
412
+ pickle.dump(args(p), f)
413
+ log = open(os.path.join(folder, f"mworker_{p}.log"), 'w')
414
+ proc = subprocess.Popen(
415
+ [sys.executable, '-m', 'cavsim2d.analysis.multipacting.driver',
416
+ args_path],
417
+ stdout=log, stderr=subprocess.STDOUT, env=env)
418
+ procs.append((proc, log, args_path))
419
+ for proc, log, args_path in procs:
420
+ proc.wait()
421
+ log.close()
422
+ try:
423
+ os.remove(args_path)
424
+ except OSError:
425
+ pass
426
+
427
+ # re-assemble in epks_v order
428
+ cf_by_idx, po_by_idx = {}, {}
429
+ n_init = 1
430
+ for p in range(proc_count):
431
+ rf = os.path.join(folder, f"mresults_{p}")
432
+ if not os.path.exists(rf):
433
+ detail = ''
434
+ for extra in (f"{rf}.err", os.path.join(folder, f"mworker_{p}.log")):
435
+ if os.path.exists(extra):
436
+ with open(extra) as ef:
437
+ tail = ef.read()[-3000:]
438
+ if tail.strip():
439
+ detail += f"\n--- {os.path.basename(extra)} ---\n{tail}"
440
+ raise RuntimeError(f"Multipacting worker {p} produced no result "
441
+ f"({rf}) -- it crashed.{detail}")
442
+ with open(rf, "rb") as file:
443
+ m = pickle.load(file)
444
+ for local_i, global_i in enumerate(divided_idx[p]):
445
+ cf_by_idx[global_i] = m['cn/c0'][local_i]
446
+ po_by_idx[global_i] = m['particles_objects'][local_i]
447
+ if p == 0:
448
+ n_init = m['n_init_particles']
449
+
450
+ cn_c0 = np.array([cf_by_idx[i] for i in range(len(epks_v))])
451
+ particles_objects = [po_by_idx[i] for i in range(len(epks_v))]
452
+
453
+ for po in particles_objects:
454
+ distance_function(po, lmbda)
455
+
456
+ elapsed = time.time() - sweep_start
457
+ result = {'cn/c0': cn_c0, 'particles_objects': particles_objects,
458
+ 'n_init_particles': n_init, 'Epk': Epk, 'epks': epks_v,
459
+ 'phis_v': phi_v, 'freq [MHz]': freq_mode, 'mode': mode,
460
+ 'fields_dir': str(fields_dir), 'sweep_time [s]': elapsed}
461
+ with open(os.path.join(folder, "mresults.pkl"), "wb") as file:
462
+ pickle.dump(result, file)
463
+ # Consistent per-analysis timing log (see cavsim2d.utils.run_log).
464
+ timer = RunTimer(folder, 'multipacting', name=f'mode {mode}',
465
+ config={'freq [MHz]': freq_mode, 'mode': mode,
466
+ 'field_levels': len(epks_v), 'processes': proc_count})
467
+ timer._t0 = sweep_start
468
+ timer.add('sweep', elapsed)
469
+ timer.write()
470
+ print(f"Multipacting sweep done: {len(epks_v)} field levels, "
471
+ f"{n_init} launch sites x phases, {elapsed:.1f}s "
472
+ f"({elapsed / max(len(epks_v), 1):.2f}s per field level).")
473
+ return result
474
+
475
+
476
+ def _worker_main(args_path):
477
+ """Subprocess entry point: load the pickled worker arguments and run."""
478
+ with open(args_path, 'rb') as f:
479
+ worker_args = pickle.load(f)
480
+ _worker(*worker_args)
481
+
482
+
483
+ if __name__ == '__main__':
484
+ # Invoked by run_sweep as `python -m cavsim2d.analysis.multipacting.driver
485
+ # <argsfile>` — one sweep worker per interpreter (see run_sweep for why
486
+ # this is a subprocess, not a multiprocessing.Process).
487
+ _worker_main(sys.argv[1])
@@ -0,0 +1,134 @@
1
+ """Field adapter: cavsim2d eigenmode result -> multipacting EM field.
2
+
3
+ The particle integrator evaluates ``em.e(mesh_points)`` (in-plane E, a 2-vector
4
+ (E_z, E_r)) and ``em.h(mesh_points)`` (azimuthal H, scalar) directly on the
5
+ GridFunctions. cavsim2d's eigenmode field is a **product space** ``(u, u_phi)``,
6
+ so this adapter:
7
+
8
+ - takes E from the in-plane HCurl block, ``gfu_E[mode].components[0]``;
9
+ - **reconstructs** H as ``1j/(mu0*w) * curl(E_inplane)``.
10
+
11
+ The reconstruction is deliberate. cavsim2d stores a magnitude-only ``H_phi``
12
+ (the ``1j`` dropped, since only |H| enters the QOIs), but multipacting integrates
13
+ E and B together over the RF cycle and needs the physical 90-degree E-H phase
14
+ that the ``1j`` carries. Rebuilding H from ``curl(E)`` reproduces the field
15
+ PyMultipact used exactly.
16
+ """
17
+ import json
18
+ import os
19
+
20
+ import numpy as np
21
+ from ngsolve import curl
22
+
23
+ from cavsim2d.constants import mu0
24
+ from cavsim2d.solvers.NGSolve.eigen_ngsolve import NGSolveMEVP, mesh_h_metres
25
+ from cavsim2d.solvers.eigenmode_result import pol_number
26
+
27
+
28
+
29
+ class EMField:
30
+ """Holder whose ``.e`` / ``.h`` are coefficient functions the integrator
31
+ evaluates directly at mesh points (``em.e(mesh(z, r))``)."""
32
+
33
+ def __init__(self, e, h):
34
+ self.e = e
35
+ self.h = h
36
+
37
+
38
+ def build_emfield(gfu_E, mode, freq_mhz):
39
+ """In-plane E and reconstructed azimuthal H for a monopole product-space mode.
40
+
41
+ Parameters
42
+ ----------
43
+ gfu_E : list
44
+ The eigenmode E fields (product-space GridFunctions) from ``gfu_EH.pkl``.
45
+ mode : int
46
+ Mode index.
47
+ freq_mhz : float
48
+ Mode frequency [MHz], for the reconstruction weight ``w = 2*pi*f``.
49
+ """
50
+ w = 2 * np.pi * freq_mhz * 1e6
51
+ e_inplane = gfu_E[mode].components[0] # HCurl block: (E_z, E_r)
52
+ h_phi = 1j / (mu0 * w) * curl(e_inplane) # physical azimuthal H
53
+ return EMField(e_inplane, h_phi)
54
+
55
+
56
+ def load_eigenmode_fields(fields_dir):
57
+ """Load ``(mesh, gfu_E, gfu_H)`` from an eigenmode polarisation folder.
58
+
59
+ Delegates to :meth:`NGSolveMEVP.load_fields`, which rebuilds the product
60
+ space on the saved (round-tripping ``.vol``) mesh and reloads the projected
61
+ coefficient vectors — so this works uniformly for fixed and **adaptively
62
+ refined** solves (a raw ``gfu_EH.pkl`` pickle does not round-trip a refined
63
+ mesh). Kept separate from :func:`build_emfield` so a worker process can load
64
+ once and build the field per swept mode. The mesh returned is the field's own
65
+ (curved to the solve order), so field evaluations land on the render mesh.
66
+ """
67
+ solver = NGSolveMEVP()
68
+ gfu_E, gfu_H = solver.load_fields(fields_dir, 0)
69
+ mesh = gfu_E[0].space.mesh if gfu_E else solver.load_mesh(fields_dir)
70
+ return mesh, gfu_E, gfu_H
71
+
72
+
73
+ def solve_multipacting_field(cav, save_dir, mesh_config=None, n_modes=None,
74
+ polarisation='monopole'):
75
+ """Solve the eigenmode on a multipacting-specific mesh, into the
76
+ multipacting analysis' OWN field folder.
77
+
78
+ Used when a custom surface refinement (``pec_maxh``) is requested: the
79
+ surviving electrons live in a sub-millimetre layer at the wall, so
80
+ multipacting needs a finer PEC surface than the eigenmode QOIs do. Solving
81
+ on a separate mesh here leaves ``cav.eigenmode``'s results (and everything
82
+ downstream of them) completely untouched — each analysis stays internally
83
+ consistent with its own mesh.
84
+
85
+ *mesh_config* takes ``h`` / ``p`` (global size [mm] / order, as in the
86
+ eigenmode config) plus ``pec_maxh`` (local PEC surface size [mm]).
87
+ *polarisation* selects the azimuthal order to solve ('monopole', 'dipole', ...)
88
+ and *n_modes* how many modes of it — so multipacting can be driven by, say, the
89
+ second monopole mode or a dipole mode. Writes
90
+ ``mesh.pkl`` + ``gfu_EH.pkl`` + ``freqs.json`` into *save_dir* (the same
91
+ layout as an eigenmode polarisation folder, so the sweep driver reads it
92
+ unchanged) and returns the mode-frequency list [MHz].
93
+ """
94
+ cfg = dict(mesh_config or {})
95
+ maxh = mesh_h_metres(cfg, default=6)
96
+ order = cfg.get('p', 3)
97
+ pec_maxh = cfg.get('pec_maxh')
98
+ edge_maxh = {'PEC': float(pec_maxh) * 1e-3} if pec_maxh else None
99
+
100
+ maker = getattr(cav, 'profile', None)
101
+ profile = maker() if callable(maker) else None
102
+ if profile is None:
103
+ raise RuntimeError(
104
+ f"{type(cav).__name__} {cav.name!r} has no profile(), so a "
105
+ f"multipacting-specific (PEC-refined) mesh cannot be built. Run "
106
+ f"without pec_maxh to reuse the eigenmode field instead.")
107
+
108
+ solver = NGSolveMEVP()
109
+ n = solver.requested_n_modes(cav, None, n_modes)
110
+ # The mesh is deliberately STRAIGHT (order=1 geometry, no Curve): the
111
+ # tracker's collision surface is the polyline through the wall vertices,
112
+ # and on a curved mesh that polyline disagrees with the element boundary by
113
+ # the chord sagitta — impact points computed on the polyline then fall
114
+ # OUTSIDE the curved mesh and the field evaluation throws ("Meshpoint not
115
+ # in mesh"), first at concave wall sections. A straight mesh makes the
116
+ # collision polyline coincide with the element edges exactly — the same
117
+ # construction PyMultipact validated (polygon boundary + order-3 fields).
118
+ # With the fine pec_maxh wall the geometric error is the chord sagitta
119
+ # (~h^2/8R, micrometres); the field itself keeps the full order *p*.
120
+ mesh = profile.mesh(maxh=maxh, order=1, edge_maxh=edge_maxh)
121
+ system = solver._build_system(mesh, order, pol_number(polarisation))
122
+ freqs, gfu_E, gfu_H = solver._solve_system(system, n)
123
+
124
+ os.makedirs(save_dir, exist_ok=True)
125
+ solver.save_mesh(save_dir, mesh)
126
+ # geom_order=1: the own-field mesh is STRAIGHT (built with order=1 above) and
127
+ # must stay straight on reload so the tracker's collision polyline coincides
128
+ # with the element edges — see the mesh comment above.
129
+ solver.save_fields(save_dir, gfu_E, gfu_H, order, pol_number(polarisation),
130
+ freqs, geom_order=1)
131
+ freqs = [float(f) for f in freqs]
132
+ with open(os.path.join(save_dir, 'freqs.json'), 'w') as f:
133
+ json.dump(freqs, f, indent=4)
134
+ return freqs