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.
- cavsim2d/__init__.py +83 -0
- cavsim2d/analysis/__init__.py +0 -0
- cavsim2d/analysis/impedance.py +366 -0
- cavsim2d/analysis/lorentz/__init__.py +7 -0
- cavsim2d/analysis/lorentz/pressure.py +226 -0
- cavsim2d/analysis/multipacting/__init__.py +21 -0
- cavsim2d/analysis/multipacting/driver.py +487 -0
- cavsim2d/analysis/multipacting/fields.py +134 -0
- cavsim2d/analysis/multipacting/integrators.py +825 -0
- cavsim2d/analysis/multipacting/metrics.py +82 -0
- cavsim2d/analysis/multipacting/particles.py +283 -0
- cavsim2d/analysis/multipacting/plots.py +831 -0
- cavsim2d/analysis/multipacting/sey.py +47 -0
- cavsim2d/analysis/optimisation.py +1423 -0
- cavsim2d/analysis/tune/__init__.py +0 -0
- cavsim2d/analysis/tune/pyTuner.py +935 -0
- cavsim2d/analysis/tune/tuner.py +207 -0
- cavsim2d/analysis/uq/__init__.py +7 -0
- cavsim2d/analysis/uq/models.py +140 -0
- cavsim2d/analysis/uq/plots.py +229 -0
- cavsim2d/analysis/uq/sobol_sa.py +204 -0
- cavsim2d/analysis/wakefield/__init__.py +0 -0
- cavsim2d/config_files/optimisation_config.py +56 -0
- cavsim2d/config_files/uq_config.py +19 -0
- cavsim2d/constants.py +119 -0
- cavsim2d/data_module/__init__.py +0 -0
- cavsim2d/data_module/abci_data.py +759 -0
- cavsim2d/data_module/operating_points.py +311 -0
- cavsim2d/data_module/sey/sey +23 -0
- cavsim2d/geometry/__init__.py +35 -0
- cavsim2d/geometry/beampipes.py +187 -0
- cavsim2d/geometry/contours.py +209 -0
- cavsim2d/geometry/plotting.py +653 -0
- cavsim2d/geometry/primitives.py +152 -0
- cavsim2d/geometry/profile.py +1241 -0
- cavsim2d/geometry/tangency.py +390 -0
- cavsim2d/geometry/writers/__init__.py +11 -0
- cavsim2d/geometry/writers/cst.py +93 -0
- cavsim2d/geometry/writers/gmsh.py +1719 -0
- cavsim2d/geometry/writers/multipac.py +783 -0
- cavsim2d/models/__init__.py +39 -0
- cavsim2d/models/assembly.py +726 -0
- cavsim2d/models/base.py +3510 -0
- cavsim2d/models/beampipe.py +200 -0
- cavsim2d/models/bellows.py +283 -0
- cavsim2d/models/bla.py +203 -0
- cavsim2d/models/elliptical.py +1524 -0
- cavsim2d/models/elliptical_flattop.py +772 -0
- cavsim2d/models/pillbox.py +326 -0
- cavsim2d/models/rfgun.py +585 -0
- cavsim2d/models/spline.py +567 -0
- cavsim2d/models/taper.py +206 -0
- cavsim2d/processes/__init__.py +5 -0
- cavsim2d/processes/eigenmode.py +168 -0
- cavsim2d/processes/tune.py +1259 -0
- cavsim2d/processes/uq.py +728 -0
- cavsim2d/processes/wakefield.py +423 -0
- cavsim2d/solvers/ABCI/abci.py +148 -0
- cavsim2d/solvers/NGSolve/__init__.py +0 -0
- cavsim2d/solvers/NGSolve/eigen_ngsolve.py +3310 -0
- cavsim2d/solvers/NGSolve/eigen_ports.py +615 -0
- cavsim2d/solvers/NGSolve/frequency_domain_ngsolve.py +217 -0
- cavsim2d/solvers/NGSolve/ports.py +266 -0
- cavsim2d/solvers/__init__.py +16 -0
- cavsim2d/solvers/eigenmode_result.py +97 -0
- cavsim2d/solvers/objectives.py +186 -0
- cavsim2d/solvers/solver_objects.py +4496 -0
- cavsim2d/solvers/wakefield/__init__.py +33 -0
- cavsim2d/solvers/wakefield/abci.py +139 -0
- cavsim2d/solvers/wakefield/base.py +118 -0
- cavsim2d/study.py +4274 -0
- cavsim2d/utils/__init__.py +13 -0
- cavsim2d/utils/config_validation.py +229 -0
- cavsim2d/utils/cubature.py +709 -0
- cavsim2d/utils/data_utils.py +337 -0
- cavsim2d/utils/printing.py +106 -0
- cavsim2d/utils/quadrature.py +852 -0
- cavsim2d/utils/run_log.py +76 -0
- cavsim2d/utils/sampling.py +402 -0
- cavsim2d/utils/sensitivity.py +808 -0
- cavsim2d/utils/shapes.py +449 -0
- cavsim2d/utils/shared_classes.py +5 -0
- cavsim2d/utils/shared_functions.py +30 -0
- cavsim2d/utils/slope_triangles.py +165 -0
- cavsim2d/utils/style.py +157 -0
- cavsim2d/utils/surrogate.py +385 -0
- cavsim2d/utils/welcome.py +40 -0
- cavsim2d-0.1.0.dist-info/METADATA +666 -0
- cavsim2d-0.1.0.dist-info/RECORD +92 -0
- cavsim2d-0.1.0.dist-info/WHEEL +5 -0
- cavsim2d-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|