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
cavsim2d/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """
2
+ cavsim2d - 2D axisymmetric RF cavity simulation toolkit.
3
+
4
+ Main entry points:
5
+ Study - Container for multiple Cavity objects
6
+ EllipticalCavity - Standard elliptical cavity
7
+ EllipticalCavityFlatTop - Flat-top elliptical cavity
8
+ SplineCavity - Spline-based cavity
9
+ RFGun - RF gun cavity
10
+ Pillbox - Pillbox cavity
11
+ Beampipe - Straight circular beam pipe (drift element)
12
+ Bellows - Corrugated bellows section
13
+ BLA - Beam line absorber (pipe + lossy ring)
14
+ Taper - Conical transition between two bore radii
15
+ Assembly - Several devices concatenated into one beam line
16
+
17
+ Solver objects (attached as lazy properties on Cavity/Study):
18
+ cav.tune - TuneSolver
19
+ cav.eigenmode - EigenmodeSolver
20
+ cav.wakefield - WakefieldSolver
21
+ cavs.optimisation - OptimisationSolver
22
+ """
23
+ __version__ = '0.1.0'
24
+
25
+ from cavsim2d.models import (
26
+ Cavity,
27
+ EllipticalCavity,
28
+ EllipticalCavityFlatTop,
29
+ SplineCavity,
30
+ RFGun,
31
+ Pillbox,
32
+ Beampipe,
33
+ Bellows,
34
+ BLA,
35
+ Taper,
36
+ Assembly,
37
+ CircularWaveguide,
38
+ )
39
+ from cavsim2d.study import Study
40
+ from cavsim2d.data_module.operating_points import OperationPoints
41
+ from cavsim2d.utils.welcome import show_welcome
42
+
43
+ from cavsim2d.solvers import (
44
+ TuneSolver,
45
+ EigenmodeSolver,
46
+ WakefieldSolver,
47
+ OptimisationSolver,
48
+ EigenmodeResult,
49
+ )
50
+
51
+ from cavsim2d.utils.style import apply_style, house_style, WARM
52
+ from cavsim2d.analysis.uq import (plot_uq_comparison, uq_comparison_table,
53
+ perturbation_slots, perturbation_nodes)
54
+
55
+ __all__ = [
56
+ 'Study',
57
+ 'Cavity',
58
+ 'EllipticalCavity',
59
+ 'EllipticalCavityFlatTop',
60
+ 'SplineCavity',
61
+ 'RFGun',
62
+ 'Pillbox',
63
+ 'Beampipe',
64
+ 'Bellows',
65
+ 'BLA',
66
+ 'Taper',
67
+ 'Assembly',
68
+ 'OperationPoints',
69
+ 'show_welcome',
70
+ 'CircularWaveguide',
71
+ 'TuneSolver',
72
+ 'EigenmodeSolver',
73
+ 'WakefieldSolver',
74
+ 'OptimisationSolver',
75
+ 'EigenmodeResult',
76
+ 'apply_style',
77
+ 'house_style',
78
+ 'WARM',
79
+ 'plot_uq_comparison',
80
+ 'uq_comparison_table',
81
+ 'perturbation_slots',
82
+ 'perturbation_nodes',
83
+ ]
File without changes
@@ -0,0 +1,366 @@
1
+ """Impedance reconstructed from eigenmode results (equivalent-circuit model).
2
+
3
+ Each resonant mode is treated as a parallel RLC resonator, so a handful of
4
+ eigenmode quantities — the resonant frequency, the R/Q and the Q — reproduce the
5
+ impedance spectrum the beam sees around those resonances::
6
+
7
+ Z(f) = sum_i R_i / (1 + i Q_i (f/f_i - f_i/f)) [Ohm]
8
+
9
+ with the shunt impedance ``R = 1/2 Q (R/Q)``.
10
+
11
+ This is a *reconstruction*, not a wakefield simulation: it contains exactly the
12
+ modes that were solved for and nothing else — no broadband/resistive-wall
13
+ contribution, and nothing above the highest computed mode. Within that range it
14
+ is cheap, has no wake-length truncation, and lets an eigenmode result be compared
15
+ directly against a beam spectrum or against a wakefield solve (the frames use the
16
+ same columns as ``cav.wakefield.wake_z`` / ``wake_t``).
17
+
18
+ The cavity is axisymmetric, so the two transverse planes are degenerate — a
19
+ single transverse R/Q describes both, and the x/y average collapses to it.
20
+ """
21
+ import re
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ from cavsim2d.constants import c0
27
+
28
+ C0 = c0 # speed of light [m/s]
29
+
30
+ # Impedance is quoted in kOhm as often as Ohm, so the unit is an argument rather
31
+ # than a convention to memorise: '' = Ohm, 'k' = kOhm, 'M' = MOhm, 'G' = GOhm
32
+ # (per metre for the transverse impedance).
33
+ SI_PREFIXES = {'': 1.0, 'k': 1e3, 'M': 1e6, 'G': 1e9}
34
+
35
+ # The unit the wakefield backends store their impedance in (ABCI's native kOhm).
36
+ # The eigenmode reconstruction is computed in SI Ohm and converted on the way out,
37
+ # so both end up in the same default unit.
38
+ NATIVE_Z_UNIT = 'k'
39
+
40
+
41
+ def prefix_factor(unit):
42
+ """``(divisor, prefix)`` for an SI prefix. ``'k'`` -> ``(1e3, 'k')``."""
43
+ u = '' if unit in (None, 'Ohm', 'ohm') else str(unit)
44
+ if u not in SI_PREFIXES:
45
+ raise ValueError(
46
+ f"unknown unit prefix {unit!r}; use one of {sorted(SI_PREFIXES)} "
47
+ f"— '' for Ohm, 'k' for kOhm, 'M' for MOhm.")
48
+ return SI_PREFIXES[u], u
49
+
50
+
51
+ def impedance_unit(unit, transverse=False):
52
+ """Column/axis unit string, e.g. ``'kOhm'`` or ``'kOhm/m'``."""
53
+ _, u = prefix_factor(unit)
54
+ return f'{u}Ohm/m' if transverse else f'{u}Ohm'
55
+
56
+
57
+ def frame_unit(df, default=None):
58
+ """The SI prefix a frame *declares* in its ``|Z|`` column.
59
+
60
+ ``'|Z| [kOhm]'`` -> ``'k'``, ``'|Z| [Ohm/m]'`` -> ``''``. Read the unit from
61
+ the data rather than assuming a backend's convention: a backend that reports
62
+ in Ohm and one that reports in kOhm then both convert correctly.
63
+ """
64
+ if df is None or df.empty:
65
+ return NATIVE_Z_UNIT if default is None else default
66
+ col = next((c for c in df.columns if c.startswith('|Z|')), None)
67
+ if col is None:
68
+ return NATIVE_Z_UNIT if default is None else default
69
+ match = re.search(r'\[([kMG]?)Ohm', col)
70
+ return match.group(1) if match else (NATIVE_Z_UNIT if default is None else default)
71
+
72
+
73
+ def convert_impedance_frame(df, from_unit, to_unit, transverse=False):
74
+ """Rescale and relabel the ``|Z|``/``Re(Z)``/``Im(Z)`` columns of a frame.
75
+
76
+ Columns are matched on their stem, so this works whatever prefix the source
77
+ frame declares. Other columns (``f [MHz]``, ``s [m]``, ``W``) pass through.
78
+ """
79
+ if df is None or df.empty:
80
+ return df
81
+ f_from, _ = prefix_factor(from_unit)
82
+ f_to, _ = prefix_factor(to_unit)
83
+ if f_from == f_to:
84
+ return df
85
+ ratio = f_from / f_to
86
+ label = impedance_unit(to_unit, transverse)
87
+
88
+ out = df.copy()
89
+ renames = {}
90
+ for stem in ('|Z|', 'Re(Z)', 'Im(Z)'):
91
+ col = next((c for c in out.columns if c.startswith(stem)), None)
92
+ if col is None:
93
+ continue
94
+ out[col] = out[col] * ratio
95
+ renames[col] = f'{stem} [{label}]'
96
+ return out.rename(columns=renames)
97
+
98
+
99
+ def impedance_frame(f_mhz, z, unit='k', transverse=False):
100
+ """Standard impedance frame from a complex spectrum *z* in **Ohm** (SI)."""
101
+ factor, _ = prefix_factor(unit)
102
+ label = impedance_unit(unit, transverse)
103
+ return pd.DataFrame({
104
+ 'f [MHz]': f_mhz,
105
+ f'|Z| [{label}]': np.abs(z) / factor,
106
+ f'Re(Z) [{label}]': z.real / factor,
107
+ f'Im(Z) [{label}]': z.imag / factor,
108
+ })
109
+
110
+
111
+ def reconstruct_impedance(freqs, r_over_q, q_factors, f_span, transverse=False):
112
+ """Sum-of-resonators impedance spectrum. All frequencies in **Hz**.
113
+
114
+ Parameters
115
+ ----------
116
+ freqs : array-like
117
+ Resonant frequency of each mode [Hz].
118
+ r_over_q : array-like
119
+ R/Q of each mode [Ohm]. Longitudinal for ``transverse=False``; for
120
+ ``transverse=True`` this is the transverse (Panofsky-Wenzel) R/Q, also
121
+ in Ohm — the ``omega/c`` factor below turns it into Ohm/m.
122
+ q_factors : array-like
123
+ Quality factor of each mode. Pass the *loaded* Q if that is what the
124
+ beam sees; the eigenmode solver reports the unloaded Q0.
125
+ f_span : array-like
126
+ Frequencies to evaluate the spectrum at [Hz]. ``f = 0`` is allowed and
127
+ is evaluated by its limit.
128
+ transverse : bool
129
+ Transverse (dipole) impedance [Ohm/m] instead of longitudinal [Ohm].
130
+
131
+ Returns
132
+ -------
133
+ ndarray of complex
134
+ The impedance at each frequency in *f_span*: Ohm (longitudinal) or
135
+ Ohm/m (transverse).
136
+ """
137
+ f0 = np.asarray(freqs, dtype=float)
138
+ roq = np.asarray(r_over_q, dtype=float)
139
+ q = np.asarray(q_factors, dtype=float)
140
+ f = np.asarray(f_span, dtype=float)
141
+
142
+ if not (f0.shape == roq.shape == q.shape):
143
+ raise ValueError(f"freqs, r_over_q and q_factors must have the same length; "
144
+ f"got {f0.shape}, {roq.shape}, {q.shape}.")
145
+ if np.any(f0 <= 0):
146
+ raise ValueError("mode frequencies must be positive.")
147
+
148
+ # Shunt impedance of each resonator. The transverse case picks up omega_0/c,
149
+ # which is what converts a transverse R/Q [Ohm] into a shunt impedance [Ohm/m].
150
+ r_shunt = 0.5 * q * roq
151
+ if transverse:
152
+ r_shunt = r_shunt * (2 * np.pi * f0 / C0)
153
+
154
+ z = np.zeros(f.shape, dtype=complex)
155
+ live = f > 0 # f = 0 is singular in the resonator term
156
+ f_live = f[live]
157
+
158
+ for fi, ri, qi in zip(f0, r_shunt, q):
159
+ # Breit-Wigner resonator: 1 + i Q (f/f0 - f0/f)
160
+ denom = 1 + 1j * qi * (f_live / fi - fi / f_live)
161
+ term = ri / denom
162
+ if transverse:
163
+ term = term * (fi / f_live)
164
+ z[live] += term
165
+
166
+ if not live.all():
167
+ # DC limit. denom -> -i Q f0/f as f -> 0, so the longitudinal term tends
168
+ # to 0 while the transverse term tends to R/(-iQ) = i R/Q (finite).
169
+ z[~live] = np.sum(1j * r_shunt / q) if transverse else 0.0
170
+
171
+ return z
172
+
173
+
174
+ def reconstruct_impedance_qnm(freqs, r_over_q_complex, q_factors, f_span,
175
+ transverse=False):
176
+ """Pole-expansion impedance from **complex** mode residues. Frequencies in Hz.
177
+
178
+ :func:`reconstruct_impedance` gives every mode a positive-real shunt impedance,
179
+ which is exact for an isolated resonance and wrong for overlapping ones: it makes
180
+ neighbouring poles add in phase. Above a beam-pipe cutoff, where radiating modes
181
+ have Q of order 10 and linewidths wider than the mode spacing, that coherent
182
+ addition overestimates ``|Z|`` by roughly the number of modes overlapping at each
183
+ frequency.
184
+
185
+ .. warning::
186
+ **Experimental, and currently not validated on radiating modes.** The residue
187
+ *magnitudes* are exact — ``|(R/Q)~|`` reproduces ``R/Q`` to machine precision —
188
+ but their *phases* do not yet come out physical: on a PML solve of a TESLA
189
+ 3-cell the resulting spectrum violates passivity (``Re Z < 0``) over most of
190
+ the band above cutoff, and is further from a wakefield reference than the plain
191
+ RLC sum. Correct QNM normalisation of a PML eigenvector needs more than the
192
+ unconjugated volume integral used here. Treat the output as a diagnostic, not
193
+ as an impedance. Above the cutoff the reliable route today is to filter the
194
+ mode set with :func:`pml_stable_modes` and sum it with
195
+ :func:`reconstruct_impedance`.
196
+
197
+ This builds the same spectrum from the quasi-normal-mode residues instead. With
198
+ ``w~ = w(1 - i/2Q)`` the decaying complex eigenfrequency and ``a~ = w~ (R/Q)~ / 2``
199
+ the wake amplitude, the wake ``W(t) = Re[a~ exp(-i w~ t)]`` transforms to::
200
+
201
+ Z(w) = (i/2) sum_n [ a~_n / (w - w~_n) + conj(a~_n) / (w + conj(w~_n)) ]
202
+
203
+ The conjugate-pole partner is what enforces ``Z(-w) = conj(Z(w))``, i.e. a real
204
+ wake. For a real ``(R/Q)~`` and large Q this reduces term by term to the RLC form,
205
+ peaking at ``1/2 Q (R/Q)``.
206
+
207
+ Parameters
208
+ ----------
209
+ freqs : array-like
210
+ Real part of each mode frequency [Hz].
211
+ r_over_q_complex : array-like of complex
212
+ ``(R/Q)~ = V~^2 / (w~ U~)`` per mode [Ohm] — the solver's
213
+ ``'Re(R/Q) [Ohm]'`` + 1j*``'Im(R/Q) [Ohm]'``.
214
+ q_factors : array-like
215
+ Quality factor of each mode.
216
+ f_span : array-like
217
+ Frequencies to evaluate at [Hz]. ``f = 0`` is evaluated by its limit.
218
+ transverse : bool
219
+ Transverse impedance [Ohm/m], using the same ``w_0/c`` and ``w_0/w``
220
+ convention as :func:`reconstruct_impedance`.
221
+
222
+ Returns
223
+ -------
224
+ ndarray of complex
225
+ """
226
+ f0 = np.asarray(freqs, dtype=float)
227
+ roq = np.asarray(r_over_q_complex, dtype=complex)
228
+ q = np.asarray(q_factors, dtype=float)
229
+ f = np.asarray(f_span, dtype=float)
230
+
231
+ if not (f0.shape == roq.shape == q.shape):
232
+ raise ValueError(f"freqs, r_over_q_complex and q_factors must have the same "
233
+ f"length; got {f0.shape}, {roq.shape}, {q.shape}.")
234
+ if np.any(f0 <= 0):
235
+ raise ValueError("mode frequencies must be positive.")
236
+
237
+ w0 = 2 * np.pi * f0
238
+ w = 2 * np.pi * f
239
+ z = np.zeros(f.shape, dtype=complex)
240
+
241
+ for w0i, roqi, qi in zip(w0, roq, q):
242
+ w_c = w0i * (1 - 0.5j / max(abs(qi), 1e-30)) # decaying pole
243
+ a_c = w_c * roqi / 2 # wake amplitude
244
+ term = 0.5j * (a_c / (w - w_c) + np.conj(a_c) / (w + np.conj(w_c)))
245
+ if transverse:
246
+ # Same convention as the RLC path: R/Q_t [Ohm] -> Ohm/m via w_0/c, and
247
+ # the extra w_0/w that makes the transverse resonance peak at w_0.
248
+ with np.errstate(divide='ignore', invalid='ignore'):
249
+ term = term * (w0i / C0) * np.where(w > 0, w0i / np.where(w > 0, w, 1), 0.0)
250
+ z += term
251
+
252
+ return z
253
+
254
+
255
+ def classify_modes(modes, other, rtol_f=2e-4, linewidth_frac=0.02, rtol_q=0.25,
256
+ pair_frac=0.1, runner_up=3.0):
257
+ """Cavity mode or artefact, for each mode of an open solve, from a second solve.
258
+
259
+ An open (PML) solve returns genuine resonances of the cavity mixed with modes of
260
+ the beam pipe and of the absorbing block, and they look alike in the results.
261
+ Re-solving with a different ``beampipe_length`` (or ``pml_length``) separates
262
+ them: a cavity mode does not care how long the pipe is, a pipe mode moves. This
263
+ is the best discriminant found so far, and it is not perfect. On pillbox and
264
+ TESLA test cases it miscounts a few per cent of the modes, in both directions.
265
+
266
+ Each mode of *modes* is judged against *other*, per azimuthal order ``m``:
267
+
268
+ 1. Frequency, the primary test. Its partner must be its mutual nearest neighbour
269
+ (each is the other's closest mode), within ``max(rtol_f, linewidth_frac / Q)``
270
+ relative. The tolerance scales with the linewidth ``f/Q``: a broad radiating
271
+ mode moves by a larger fraction than a narrow one for the same physical
272
+ change. A partner is ambiguous, and rejected, when a runner-up sits within
273
+ the tolerance and less than ``runner_up`` times further away.
274
+ 2. Q, a tie-breaker only. A mode whose Q moved by more than ``rtol_q`` is still
275
+ kept, since the Q of a mode near the cutoff converges slowly with pipe length
276
+ (the layer keeps reaching its evanescent tail). It is dropped only when it
277
+ also has a near-degenerate partner of the same ``m``, closer than
278
+ ``pair_frac`` of the typical spacing. At fixed ``m`` an axisymmetric cavity
279
+ has no degeneracy, so such a pair is the left and right pipe, not one mode.
280
+
281
+ Parameters
282
+ ----------
283
+ modes, other : pandas.DataFrame
284
+ Two ``cav.eigenmode.qois_df`` frames of the same cavity, solved with a
285
+ different pipe (or PML) length. Needs ``'freq [MHz]'`` and ``'Q []'``;
286
+ ``'m'`` is used when present.
287
+
288
+ Returns
289
+ -------
290
+ pandas.DataFrame
291
+ Indexed like *modes*: ``'cavity mode'`` (the verdict), and the evidence behind
292
+ it, ``'shift [ppm]'``, ``'tolerance [ppm]'``, ``'mutual'``, ``'f ok'``,
293
+ ``'Q ok'``, ``'paired'`` and ``'reached'``.
294
+
295
+ Notes
296
+ -----
297
+ Compare only where both runs reach. Each solve returns its lowest ``n_modes``,
298
+ and a mode above *other*'s highest has nothing to be matched against: it comes
299
+ back ``'reached' == False`` and is not called a cavity mode (unverified, not
300
+ disproved). Raise ``n_modes`` until both runs cover the band of interest.
301
+ """
302
+ out = pd.DataFrame(index=modes.index)
303
+ for col, default in (('shift [ppm]', np.nan), ('tolerance [ppm]', np.nan),
304
+ ('mutual', False), ('f ok', False), ('Q ok', False),
305
+ ('paired', False), ('reached', False)):
306
+ out[col] = default
307
+
308
+ def groups(df):
309
+ if 'm' in df.columns:
310
+ return {m: g for m, g in df.groupby('m')}
311
+ return {0: df}
312
+
313
+ other_groups = groups(other)
314
+ for m, a in groups(modes).items():
315
+ b = other_groups.get(m)
316
+ f_a = a['freq [MHz]'].to_numpy(dtype=float)
317
+ q_a = a['Q []'].to_numpy(dtype=float)
318
+ if b is None or len(b) == 0:
319
+ continue
320
+ f_b = b['freq [MHz]'].to_numpy(dtype=float)
321
+ q_b = b['Q []'].to_numpy(dtype=float)
322
+
323
+ rel = np.abs(f_a[:, None] - f_b[None, :]) / f_a[:, None]
324
+ j = rel.argmin(axis=1)
325
+ d1 = rel[np.arange(len(f_a)), j]
326
+ d2 = (np.partition(rel, 1, axis=1)[:, 1] if rel.shape[1] > 1
327
+ else np.full(len(f_a), np.inf))
328
+ back = (np.abs(f_b[:, None] - f_a[None, :]) / f_b[:, None]).argmin(axis=1)
329
+ mutual = back[j] == np.arange(len(f_a))
330
+ tol = np.maximum(rtol_f, linewidth_frac / np.maximum(np.abs(q_a), 1e-30))
331
+ ambiguous = (d2 <= tol) & (d2 < runner_up * d1)
332
+ f_ok = mutual & (d1 <= tol) & ~ambiguous
333
+ q_ok = np.abs(q_b[j] - q_a) <= rtol_q * np.maximum(np.abs(q_a), 1e-30)
334
+
335
+ # near-degenerate partner of the same m, relative to the typical spacing
336
+ if len(f_a) > 1:
337
+ gaps = np.abs(f_a[:, None] - f_a[None, :]) / f_a[:, None]
338
+ np.fill_diagonal(gaps, np.inf)
339
+ nearest = gaps.min(axis=1)
340
+ paired = nearest < pair_frac * np.median(nearest)
341
+ else:
342
+ paired = np.zeros(len(f_a), dtype=bool)
343
+
344
+ idx = a.index
345
+ out.loc[idx, 'shift [ppm]'] = d1 * 1e6
346
+ out.loc[idx, 'tolerance [ppm]'] = tol * 1e6
347
+ out.loc[idx, 'mutual'] = mutual
348
+ out.loc[idx, 'f ok'] = f_ok
349
+ out.loc[idx, 'Q ok'] = q_ok
350
+ out.loc[idx, 'paired'] = paired
351
+ out.loc[idx, 'reached'] = f_a <= f_b.max() * (1 + rtol_f)
352
+
353
+ out['cavity mode'] = (out['f ok'] & ~(~out['Q ok'] & out['paired'])
354
+ & out['reached']).astype(bool)
355
+ return out
356
+
357
+
358
+ def pml_stable_modes(modes, other, **kwargs):
359
+ """Boolean mask over *modes*: the cavity modes, judged against a second solve
360
+ with a different pipe or PML length. See :func:`classify_modes`, which returns
361
+ the same verdict with the evidence behind it.
362
+
363
+ >>> keep = pml_stable_modes(cav_a.eigenmode.qois_df, cav_b.eigenmode.qois_df)
364
+ >>> trusted = cav_a.eigenmode.qois_df[keep]
365
+ """
366
+ return classify_modes(modes, other, **kwargs)['cavity mode'].to_numpy(dtype=bool)
@@ -0,0 +1,7 @@
1
+ """Lorentz radiation pressure, wall deflection and Lorentz detuning."""
2
+ from cavsim2d.analysis.lorentz.pressure import (pressure_cf, wall_pressure,
3
+ stored_energy, slater_shift,
4
+ detuning_coefficient)
5
+
6
+ __all__ = ['pressure_cf', 'wall_pressure', 'stored_energy', 'slater_shift',
7
+ 'detuning_coefficient']
@@ -0,0 +1,226 @@
1
+ r"""Lorentz radiation pressure on a cavity wall, and the detuning it causes.
2
+
3
+ Theory
4
+ ------
5
+ The RF field exerts a pressure on the conducting wall. With fields written as peak
6
+ amplitudes the time-averaged energy densities are :math:`u_H=\tfrac14\mu_0|H|^2` and
7
+ :math:`u_E=\tfrac14\varepsilon_0|E|^2`, and the outward pressure is their difference:
8
+
9
+ .. math:: P = \tfrac14\left(\mu_0|H|^2 - \varepsilon_0|E|^2\right)
10
+
11
+ so the magnetic field near the equator pushes the wall **out** and the electric field near
12
+ the iris pulls it **in**. On a perfect conductor the tangential electric and normal
13
+ magnetic components vanish, so :math:`|E|` is purely normal there and :math:`|H|` purely
14
+ tangential.
15
+
16
+ Given a wall displacement :math:`\mathbf{u}`, Slater's theorem gives the frequency shift
17
+ as the work the pressure does against that displacement, normalised by the stored energy:
18
+
19
+ .. math:: \frac{\Delta\omega}{\omega}
20
+ = -\frac{1}{U}\oint_S P\,(\mathbf{u}\cdot\mathbf{n})\,\mathrm{d}S
21
+
22
+ Pushing the wall **in** where the magnetic field is strong raises the frequency; pushing
23
+ it in where the electric field is strong lowers it. The static deflection is itself
24
+ proportional to the pressure, and the pressure to :math:`E_\mathrm{acc}^2`, so the shift
25
+ is quoted as the Lorentz detuning coefficient
26
+
27
+ .. math:: K_L = \frac{\Delta f}{E_\mathrm{acc}^2}\quad[\mathrm{Hz/(MV/m)^2}]
28
+
29
+ which is negative for an unstiffened cavity: the inward pull at the iris and the outward
30
+ push at the equator both lower the frequency of the accelerating mode.
31
+
32
+ Scope
33
+ -----
34
+ The pressure follows from the RF solution with no further modelling. The **displacement
35
+ does not** - it depends on the wall thickness, the elastic constants, and above all on how
36
+ the cavity is held, none of which the RF model knows. :func:`slater_shift` therefore takes
37
+ the displacement as an argument rather than computing it: supply one from a mechanical
38
+ solve and this module turns it into a frequency shift.
39
+
40
+ Validation
41
+ ----------
42
+ A TM010 pillbox has :math:`f = c\,x_{01}/(2\pi R)`, which depends on the radius and not on
43
+ the length. Both identities are reproduced: a uniform outward displacement of the barrel
44
+ gives :math:`\Delta f/f = -\alpha/R` to 0.001%, and the same displacement of the end
45
+ plates gives zero.
46
+ """
47
+ import numpy as np
48
+ from ngsolve import (Conj, GridFunction, H1, Integrate, InnerProduct, Norm,
49
+ VectorH1, specialcf, y)
50
+
51
+ from cavsim2d.constants import eps0, mu0
52
+ from cavsim2d.solvers.NGSolve.eigen_ngsolve import get_boundary_nodes
53
+
54
+ __all__ = ['MU0', 'EPS0', 'pressure_cf', 'wall_pressure', 'stored_energy',
55
+ 'slater_shift', 'wall_force', 'detuning_coefficient']
56
+
57
+ MU0 = mu0
58
+ EPS0 = eps0
59
+
60
+
61
+ def _projected_fields(mesh, gfu_E, gfu_H, mode, order):
62
+ """E and H projected into continuous H1, so their traces integrate on a boundary.
63
+
64
+ Two separate reasons, both of which silently corrupt a wall integral:
65
+
66
+ * **H** is reconstructed as ``curl(E)/(mu0 w)``, and a GridFunction curl cannot
67
+ be SIMD-evaluated on a boundary - the integral fails outright with "SIMD:
68
+ don't know how I shall evaluate".
69
+ * **E** lives in HCurl, whose boundary trace keeps only the TANGENTIAL part. On
70
+ a perfect conductor that part is zero by definition, so an unprojected
71
+ ``|E|^2`` evaluates to nothing on the wall and the electric half of the
72
+ pressure disappears without any error being raised.
73
+
74
+ The eigenmode solver projects H the same way for its wall-loss integral. On a
75
+ PML mesh the projection is restricted to the physical region: H1 is continuous,
76
+ so projecting across the interface would average in the complex-stretched
77
+ layer's own field.
78
+ """
79
+ u_gf, uphi_gf = gfu_E[mode].components
80
+ H_inplane, H_phi = gfu_H[mode]
81
+ kw = ({'definedon': mesh.Materials('phys')}
82
+ if 'pml' in mesh.GetMaterials() else {})
83
+ ein = GridFunction(VectorH1(mesh, order=order, complex=True))
84
+ ein.Set(u_gf, **kw)
85
+ hphi = GridFunction(H1(mesh, order=order, complex=True))
86
+ hphi.Set(H_phi, **kw)
87
+ hin = GridFunction(VectorH1(mesh, order=order, complex=True))
88
+ hin.Set(H_inplane, **kw)
89
+ return ein, uphi_gf, hin, hphi
90
+
91
+
92
+ def _field_norms(gfu_E, gfu_H, mode=0, m=0, mesh=None, project=False):
93
+ """``(|E|^2, |H|^2)`` as coefficient functions.
94
+
95
+ A monopole keeps all of E in the in-plane block and all of H in the azimuthal
96
+ one, so the other terms drop out. The general form is kept because an m>=1 mode
97
+ populates both.
98
+
99
+ Set *project* for a boundary integral; see :func:`_projected_H`.
100
+ """
101
+ u_gf, uphi_gf = gfu_E[mode].components
102
+ if project:
103
+ u_gf, uphi_gf, H_inplane, H_phi = _projected_fields(
104
+ mesh, gfu_E, gfu_H, mode, u_gf.space.globalorder)
105
+ e2 = InnerProduct(u_gf, u_gf)
106
+ h2 = InnerProduct(H_inplane, H_inplane) + Norm(H_phi) ** 2
107
+ if m != 0:
108
+ e2 = e2 + Norm(uphi_gf) ** 2 / (y * y + 1e-30)
109
+ return e2, h2
110
+ H_inplane, H_phi = gfu_H[mode]
111
+ e2 = InnerProduct(u_gf, u_gf)
112
+ h2 = InnerProduct(H_inplane, H_inplane) + Norm(H_phi) ** 2
113
+ if m != 0:
114
+ # E_phi = u_phi / r. The wall reaches the axis only at a beam-pipe end cap,
115
+ # where u_phi vanishes anyway, so the guard only avoids a 0/0.
116
+ e2 = e2 + Norm(uphi_gf) ** 2 / (y * y + 1e-30)
117
+ return e2, h2
118
+
119
+
120
+ def _azimuthal_factor(m):
121
+ """Integral over phi: 2*pi for a monopole, pi for m>=1 (cos^2 averages to 1/2)."""
122
+ return 2 * np.pi if m == 0 else np.pi
123
+
124
+
125
+ def pressure_cf(gfu_E, gfu_H, mode=0, m=0, scale=1.0, mesh=None, project=False):
126
+ """Lorentz radiation pressure as a coefficient function, in pascals.
127
+
128
+ Positive pushes the wall outward (magnetic), negative inward (electric). *scale*
129
+ multiplies the field amplitude, so the pressure scales with its square - pass the
130
+ factor that takes the stored solution to the gradient you want.
131
+ """
132
+ e2, h2 = _field_norms(gfu_E, gfu_H, mode, m, mesh, project)
133
+ return 0.25 * (MU0 * h2 - EPS0 * e2) * (scale ** 2)
134
+
135
+
136
+ def stored_energy(mesh, gfu_E, gfu_H, mode=0, m=0, eps_cf=None):
137
+ r"""Total stored energy of the mode, in joules.
138
+
139
+ The time-averaged electric and magnetic energies are equal at resonance, so the
140
+ total is twice either one: :math:`U=\tfrac12\varepsilon_0\int|E|^2\,\mathrm{d}V`.
141
+ """
142
+ e2, _ = _field_norms(gfu_E, gfu_H, mode, m)
143
+ eps = EPS0 if eps_cf is None else EPS0 * eps_cf
144
+ return float(Integrate(0.5 * eps * e2 * y, mesh).real) * _azimuthal_factor(m)
145
+
146
+
147
+ def wall_pressure(mesh, gfu_E, gfu_H, mode=0, m=0, scale=1.0, wall='PEC',
148
+ points=None):
149
+ """Sample the pressure on the wall.
150
+
151
+ Parameters
152
+ ----------
153
+ points : array_like, optional
154
+ ``(n, 2)`` array of ``(z, r)`` in metres to evaluate at. Defaults to the
155
+ mesh's boundary nodes, which are ordered by coordinate rather than along the
156
+ contour - pass ``profile.contour_points(ds)`` when the samples must follow
157
+ the wall, as a line plot needs.
158
+
159
+ Returns
160
+ -------
161
+ (points, p) : ndarray, ndarray
162
+ Sample coordinates in metres, and the pressure at each in pascals.
163
+ """
164
+ cf = pressure_cf(gfu_E, gfu_H, mode, m, scale)
165
+ if points is None:
166
+ points = sorted(get_boundary_nodes(mesh, wall))
167
+ pts = np.asarray(points, dtype=float).reshape(-1, 2)
168
+ vals = np.asarray(cf(mesh(pts[:, 0], pts[:, 1]))).ravel().real
169
+ return pts, vals
170
+
171
+
172
+ def slater_shift(mesh, gfu_E, gfu_H, normal_displacement, freq_hz,
173
+ mode=0, m=0, wall='PEC', scale=1.0, U=None):
174
+ r"""Frequency shift from a wall displacement, by Slater's theorem, in hertz.
175
+
176
+ Parameters
177
+ ----------
178
+ normal_displacement : CoefficientFunction
179
+ The OUTWARD normal component of the wall displacement, in metres. A
180
+ GridFunction from a mechanical solve is the intended input; an analytic
181
+ expression built from ``ngsolve.x`` / ``ngsolve.y`` also works.
182
+ freq_hz : float
183
+ Unperturbed frequency.
184
+ U : float, optional
185
+ Stored energy. Computed from the fields when omitted; pass it to avoid
186
+ recomputing it across a sweep.
187
+
188
+ Returns
189
+ -------
190
+ float
191
+ :math:`\Delta f`, negative when the wall moves so as to lower the frequency,
192
+ which is the usual sense for an unstiffened cavity.
193
+
194
+ Notes
195
+ -----
196
+ The mesh is a meridian, so the surface element of the body of revolution carries
197
+ the factor ``r``, and the azimuthal integral contributes ``2*pi`` (``pi`` for
198
+ m>=1).
199
+ """
200
+ p_cf = pressure_cf(gfu_E, gfu_H, mode, m, scale, mesh, project=True)
201
+ if U is None:
202
+ U = stored_energy(mesh, gfu_E, gfu_H, mode, m)
203
+ work = float(Integrate(p_cf * normal_displacement * y, mesh,
204
+ definedon=mesh.Boundaries(wall)).real)
205
+ return -freq_hz * work * _azimuthal_factor(m) / U
206
+
207
+
208
+ def wall_force(mesh, gfu_E, gfu_H, mode=0, m=0, wall='PEC', scale=1.0):
209
+ """Net force the field exerts on *wall*, as ``(F_z, F_r)`` in newtons.
210
+
211
+ A cheap sanity check on the pressure: for a mode symmetric about the mid-plane
212
+ the axial components cancel, so ``F_z`` should come out near zero.
213
+ """
214
+ p_cf = pressure_cf(gfu_E, gfu_H, mode, m, scale, mesh, project=True)
215
+ n = specialcf.normal(2)
216
+ az = _azimuthal_factor(m)
217
+ fz = float(Integrate(p_cf * n[0] * y, mesh,
218
+ definedon=mesh.Boundaries(wall)).real) * az
219
+ fr = float(Integrate(p_cf * n[1] * y, mesh,
220
+ definedon=mesh.Boundaries(wall)).real) * az
221
+ return fz, fr
222
+
223
+
224
+ def detuning_coefficient(delta_f_hz, eacc_mv_per_m):
225
+ r"""Lorentz detuning coefficient :math:`K_L`, in Hz/(MV/m)^2."""
226
+ return delta_f_hz / (float(eacc_mv_per_m) ** 2)