pyffag 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.
- pyffag/__init__.py +51 -0
- pyffag/constants.py +73 -0
- pyffag/elements.py +116 -0
- pyffag/optics.py +118 -0
- pyffag/ring.py +173 -0
- pyffag/sector.py +99 -0
- pyffag-0.1.0.dist-info/METADATA +101 -0
- pyffag-0.1.0.dist-info/RECORD +11 -0
- pyffag-0.1.0.dist-info/WHEEL +5 -0
- pyffag-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyffag-0.1.0.dist-info/top_level.txt +1 -0
pyffag/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""pyffag — DA-based FFAG accelerator tracking.
|
|
2
|
+
|
|
3
|
+
Built on daceypy for arbitrary-order differential algebra arithmetic,
|
|
4
|
+
pyffag provides transfer map computation through FFAG sector magnets
|
|
5
|
+
via integration of the exact midplane Hamiltonian.
|
|
6
|
+
|
|
7
|
+
Example
|
|
8
|
+
-------
|
|
9
|
+
>>> from daceypy import DA
|
|
10
|
+
>>> from pyffag import sector_map, compose_n, tune
|
|
11
|
+
>>> from pyffag.constants import kinetic_to_brho, M_PROTON
|
|
12
|
+
>>>
|
|
13
|
+
>>> DA.init(5, 2)
|
|
14
|
+
>>> Brho = kinetic_to_brho(150.0, M_PROTON)
|
|
15
|
+
>>> cell = sector_map([1.0], Brho, angle=0.5236) # 30-degree uniform dipole
|
|
16
|
+
>>> ring = compose_n(cell, 12)
|
|
17
|
+
>>> print(f"Tune: {tune(ring):.4f}")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from pyffag.sector import sector_map
|
|
21
|
+
from pyffag.elements import (
|
|
22
|
+
drift_map,
|
|
23
|
+
drift_map_paraxial,
|
|
24
|
+
edge_kick,
|
|
25
|
+
thin_quad,
|
|
26
|
+
thin_sext,
|
|
27
|
+
thin_oct,
|
|
28
|
+
)
|
|
29
|
+
from pyffag.ring import compose, compose_sequence, compose_n, find_closed_orbit
|
|
30
|
+
from pyffag.optics import transfer_matrix, tune, is_stable, twiss, symplecticity_error
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"sector_map",
|
|
36
|
+
"drift_map",
|
|
37
|
+
"drift_map_paraxial",
|
|
38
|
+
"edge_kick",
|
|
39
|
+
"thin_quad",
|
|
40
|
+
"thin_sext",
|
|
41
|
+
"thin_oct",
|
|
42
|
+
"compose",
|
|
43
|
+
"compose_sequence",
|
|
44
|
+
"compose_n",
|
|
45
|
+
"find_closed_orbit",
|
|
46
|
+
"transfer_matrix",
|
|
47
|
+
"tune",
|
|
48
|
+
"is_stable",
|
|
49
|
+
"twiss",
|
|
50
|
+
"symplecticity_error",
|
|
51
|
+
]
|
pyffag/constants.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Physical constants for accelerator physics (SI + natural units)."""
|
|
2
|
+
|
|
3
|
+
# Speed of light [m/s]
|
|
4
|
+
C_LIGHT = 299792458.0
|
|
5
|
+
|
|
6
|
+
# Proton mass [MeV/c^2]
|
|
7
|
+
M_PROTON = 938.27208816
|
|
8
|
+
|
|
9
|
+
# Electron mass [MeV/c^2]
|
|
10
|
+
M_ELECTRON = 0.51099895069
|
|
11
|
+
|
|
12
|
+
# Muon mass [MeV/c^2]
|
|
13
|
+
M_MUON = 105.6583755
|
|
14
|
+
|
|
15
|
+
# Elementary charge [C]
|
|
16
|
+
E_CHARGE = 1.602176634e-19
|
|
17
|
+
|
|
18
|
+
# Conversion: momentum [MeV/c] to magnetic rigidity [T·m]
|
|
19
|
+
# Brho = p / (q * c) = p [eV/c] / (e * c) = p [MeV/c] * 1e6 / (c)
|
|
20
|
+
# In convenient units: Brho [T·m] = p [MeV/c] / 299.792458
|
|
21
|
+
BRHO_FACTOR = 1e6 / C_LIGHT # multiply by p [MeV/c] to get Brho [T·m]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def kinetic_to_momentum(T, mass):
|
|
25
|
+
"""Convert kinetic energy to momentum.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
T : float
|
|
30
|
+
Kinetic energy [MeV].
|
|
31
|
+
mass : float
|
|
32
|
+
Rest mass [MeV/c^2].
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
float
|
|
37
|
+
Momentum [MeV/c].
|
|
38
|
+
"""
|
|
39
|
+
return (T * (T + 2 * mass)) ** 0.5
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def momentum_to_brho(p):
|
|
43
|
+
"""Convert momentum to magnetic rigidity.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
p : float
|
|
48
|
+
Momentum [MeV/c].
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
float
|
|
53
|
+
Magnetic rigidity Bρ [T·m].
|
|
54
|
+
"""
|
|
55
|
+
return p * BRHO_FACTOR
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def kinetic_to_brho(T, mass):
|
|
59
|
+
"""Convert kinetic energy to magnetic rigidity.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
T : float
|
|
64
|
+
Kinetic energy [MeV].
|
|
65
|
+
mass : float
|
|
66
|
+
Rest mass [MeV/c^2].
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
float
|
|
71
|
+
Magnetic rigidity Bρ [T·m].
|
|
72
|
+
"""
|
|
73
|
+
return momentum_to_brho(kinetic_to_momentum(T, mass))
|
pyffag/elements.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Standard beam-line elements as DA maps (midplane, 1 DOF)."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from daceypy import DA
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def drift_map(length):
|
|
8
|
+
"""Exact drift-space map (no paraxial approximation).
|
|
9
|
+
|
|
10
|
+
Uses the exact expression x' = x + L * px / sqrt(1 - px^2).
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
length : float
|
|
15
|
+
Drift length [m].
|
|
16
|
+
|
|
17
|
+
Returns
|
|
18
|
+
-------
|
|
19
|
+
list of DA
|
|
20
|
+
[x_out, px_out].
|
|
21
|
+
"""
|
|
22
|
+
x, px = DA(1), DA(2)
|
|
23
|
+
ps = DA.sqrt(1.0 - px * px)
|
|
24
|
+
return [x + length * px / ps, px]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def drift_map_paraxial(length):
|
|
28
|
+
"""Paraxial drift-space map: x' = x + L*px.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
length : float
|
|
33
|
+
Drift length [m].
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
list of DA
|
|
38
|
+
[x_out, px_out].
|
|
39
|
+
"""
|
|
40
|
+
x, px = DA(1), DA(2)
|
|
41
|
+
return [x + length * px, px]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def edge_kick(h, e1):
|
|
45
|
+
"""Thin edge-focusing kick for a rectangular (non-sector) magnet.
|
|
46
|
+
|
|
47
|
+
At an edge tilted by angle e1 from the radial direction, the
|
|
48
|
+
horizontal kick is dpx = +h * tan(e1) * x.
|
|
49
|
+
|
|
50
|
+
For a sector magnet (radial edges), e1 = 0 and there is no kick.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
h : float
|
|
55
|
+
Reference curvature 1/rho [1/m].
|
|
56
|
+
e1 : float
|
|
57
|
+
Edge angle [rad]. Positive = edge rotated toward the magnet body.
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
list of DA
|
|
62
|
+
[x_out, px_out].
|
|
63
|
+
"""
|
|
64
|
+
x, px = DA(1), DA(2)
|
|
65
|
+
return [x, px + h * np.tan(e1) * x]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def thin_quad(k1L):
|
|
69
|
+
"""Thin quadrupole kick: dpx = -k1L * x.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
k1L : float
|
|
74
|
+
Integrated quadrupole strength [1/m].
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
list of DA
|
|
79
|
+
[x_out, px_out].
|
|
80
|
+
"""
|
|
81
|
+
x, px = DA(1), DA(2)
|
|
82
|
+
return [x, px - k1L * x]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def thin_sext(k2L):
|
|
86
|
+
"""Thin sextupole kick: dpx = -(k2L/2) * x^2.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
k2L : float
|
|
91
|
+
Integrated sextupole strength [1/m^2].
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
list of DA
|
|
96
|
+
[x_out, px_out].
|
|
97
|
+
"""
|
|
98
|
+
x, px = DA(1), DA(2)
|
|
99
|
+
return [x, px - (k2L / 2) * x ** 2]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def thin_oct(k3L):
|
|
103
|
+
"""Thin octupole kick: dpx = -(k3L/6) * x^3.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
k3L : float
|
|
108
|
+
Integrated octupole strength [1/m^3].
|
|
109
|
+
|
|
110
|
+
Returns
|
|
111
|
+
-------
|
|
112
|
+
list of DA
|
|
113
|
+
[x_out, px_out].
|
|
114
|
+
"""
|
|
115
|
+
x, px = DA(1), DA(2)
|
|
116
|
+
return [x, px - (k3L / 6) * x ** 3]
|
pyffag/optics.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Linear optics extraction from DA transfer maps."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def transfer_matrix(da_map):
|
|
7
|
+
"""Extract the 2x2 transfer matrix from a DA map.
|
|
8
|
+
|
|
9
|
+
Parameters
|
|
10
|
+
----------
|
|
11
|
+
da_map : list of DA
|
|
12
|
+
[x', px'] map components.
|
|
13
|
+
|
|
14
|
+
Returns
|
|
15
|
+
-------
|
|
16
|
+
ndarray, shape (2, 2)
|
|
17
|
+
Transfer matrix M.
|
|
18
|
+
"""
|
|
19
|
+
M = np.zeros((2, 2))
|
|
20
|
+
M[0, 0] = da_map[0].getCoefficient([1, 0])
|
|
21
|
+
M[0, 1] = da_map[0].getCoefficient([0, 1])
|
|
22
|
+
M[1, 0] = da_map[1].getCoefficient([1, 0])
|
|
23
|
+
M[1, 1] = da_map[1].getCoefficient([0, 1])
|
|
24
|
+
return M
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def tune(da_map):
|
|
28
|
+
"""Extract the fractional tune from a DA map.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
da_map : list of DA
|
|
33
|
+
One-turn (or one-cell) [x', px'] map.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
float
|
|
38
|
+
Fractional tune nu in [0, 0.5].
|
|
39
|
+
"""
|
|
40
|
+
M = transfer_matrix(da_map)
|
|
41
|
+
cos_mu = (M[0, 0] + M[1, 1]) / 2.0
|
|
42
|
+
cos_mu = np.clip(cos_mu, -1.0, 1.0)
|
|
43
|
+
return np.arccos(cos_mu) / (2.0 * np.pi)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_stable(da_map):
|
|
47
|
+
"""Check if the linear map is stable (|Tr M| < 2).
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
da_map : list of DA
|
|
52
|
+
[x', px'] map.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
bool
|
|
57
|
+
"""
|
|
58
|
+
M = transfer_matrix(da_map)
|
|
59
|
+
return abs(M[0, 0] + M[1, 1]) < 2.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def twiss(da_map):
|
|
63
|
+
"""Extract Courant-Snyder (Twiss) parameters from a periodic map.
|
|
64
|
+
|
|
65
|
+
Assumes the map is one full period (cell or ring) and is stable.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
da_map : list of DA
|
|
70
|
+
One-period [x', px'] map.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
dict
|
|
75
|
+
{'beta': float, 'alpha': float, 'gamma': float, 'tune': float}
|
|
76
|
+
beta in [m], alpha dimensionless, gamma in [1/m].
|
|
77
|
+
"""
|
|
78
|
+
M = transfer_matrix(da_map)
|
|
79
|
+
cos_mu = (M[0, 0] + M[1, 1]) / 2.0
|
|
80
|
+
|
|
81
|
+
if abs(cos_mu) >= 1.0:
|
|
82
|
+
raise ValueError(f"Unstable map: Tr/2 = {cos_mu:.6f}")
|
|
83
|
+
|
|
84
|
+
mu = np.arccos(np.clip(cos_mu, -1.0, 1.0))
|
|
85
|
+
sin_mu = np.sin(mu)
|
|
86
|
+
|
|
87
|
+
# Sign of sin(mu): from M12
|
|
88
|
+
if M[0, 1] < 0:
|
|
89
|
+
sin_mu = -sin_mu
|
|
90
|
+
mu = 2 * np.pi - mu
|
|
91
|
+
|
|
92
|
+
beta = M[0, 1] / sin_mu
|
|
93
|
+
alpha = (M[0, 0] - M[1, 1]) / (2.0 * sin_mu)
|
|
94
|
+
gamma = -M[1, 0] / sin_mu
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
'beta': beta,
|
|
98
|
+
'alpha': alpha,
|
|
99
|
+
'gamma': gamma,
|
|
100
|
+
'tune': mu / (2.0 * np.pi),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def symplecticity_error(da_map):
|
|
105
|
+
"""Check how far the linear map is from symplectic (det M - 1).
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
da_map : list of DA
|
|
110
|
+
[x', px'] map.
|
|
111
|
+
|
|
112
|
+
Returns
|
|
113
|
+
-------
|
|
114
|
+
float
|
|
115
|
+
|det(M) - 1|.
|
|
116
|
+
"""
|
|
117
|
+
M = transfer_matrix(da_map)
|
|
118
|
+
return abs(np.linalg.det(M) - 1.0)
|
pyffag/ring.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Ring-level operations: map composition and closed-orbit finding."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from daceypy import DA
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def compose(map1, map2):
|
|
8
|
+
"""Compose two maps: apply map1 first, then map2.
|
|
9
|
+
|
|
10
|
+
Parameters
|
|
11
|
+
----------
|
|
12
|
+
map1, map2 : list of DA
|
|
13
|
+
Map components [x', px'].
|
|
14
|
+
|
|
15
|
+
Returns
|
|
16
|
+
-------
|
|
17
|
+
list of DA
|
|
18
|
+
Composed map components.
|
|
19
|
+
"""
|
|
20
|
+
return [map2[i].eval(map1) for i in range(len(map1))]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compose_sequence(maps):
|
|
24
|
+
"""Compose a sequence of maps in order (first element applied first).
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
maps : list of list-of-DA
|
|
29
|
+
Each entry is [x', px'] for one element.
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
list of DA
|
|
34
|
+
Full composed map.
|
|
35
|
+
"""
|
|
36
|
+
result = maps[0]
|
|
37
|
+
for m in maps[1:]:
|
|
38
|
+
result = compose(result, m)
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compose_n(cell_map, n):
|
|
43
|
+
"""Compose a cell map with itself n times (n-turn or n-cell map).
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
cell_map : list of DA
|
|
48
|
+
One-cell map components.
|
|
49
|
+
n : int
|
|
50
|
+
Number of repetitions.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
list of DA
|
|
55
|
+
n-fold composed map.
|
|
56
|
+
"""
|
|
57
|
+
result = cell_map
|
|
58
|
+
for _ in range(n - 1):
|
|
59
|
+
result = compose(result, cell_map)
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find_closed_orbit(ring_builder, x_guess=0.0, px_guess=0.0,
|
|
64
|
+
tol=1e-12, max_iter=20):
|
|
65
|
+
"""Find the closed orbit of a ring using Newton's method with DA.
|
|
66
|
+
|
|
67
|
+
At each iteration, the ring map is evaluated with DA to simultaneously
|
|
68
|
+
obtain the map value and Jacobian, enabling a Newton update.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
ring_builder : callable
|
|
73
|
+
A function that takes no arguments, constructs the full ring DA map
|
|
74
|
+
(at the current DA.init settings), and returns [x_out, px_out].
|
|
75
|
+
The map should be built with DA(1) and DA(2) as initial conditions
|
|
76
|
+
offset from the current guess — the caller handles this by building
|
|
77
|
+
the map fresh each iteration.
|
|
78
|
+
x_guess, px_guess : float
|
|
79
|
+
Initial guess for the closed orbit position and angle.
|
|
80
|
+
tol : float
|
|
81
|
+
Convergence tolerance on |x_final - x_initial|.
|
|
82
|
+
max_iter : int
|
|
83
|
+
Maximum Newton iterations.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
x_co, px_co : float
|
|
88
|
+
Closed orbit position [m] and angle [rad].
|
|
89
|
+
converged : bool
|
|
90
|
+
Whether the iteration converged within tolerance.
|
|
91
|
+
"""
|
|
92
|
+
x_co, px_co = x_guess, px_guess
|
|
93
|
+
|
|
94
|
+
for iteration in range(max_iter):
|
|
95
|
+
# Build and evaluate the ring map around current guess
|
|
96
|
+
ring_map = ring_builder()
|
|
97
|
+
|
|
98
|
+
# Extract the fixed-point residual: M(x_co) - x_co
|
|
99
|
+
# DA(1) and DA(2) represent deviations from origin, but the
|
|
100
|
+
# ring_builder should construct the map with initial conditions
|
|
101
|
+
# at the guess. The constant part of the output is the image
|
|
102
|
+
# of (x_co, px_co), and we need it to equal (x_co, px_co).
|
|
103
|
+
x_out_0 = ring_map[0].getCoefficient([0, 0])
|
|
104
|
+
px_out_0 = ring_map[1].getCoefficient([0, 0])
|
|
105
|
+
|
|
106
|
+
res_x = x_out_0 - x_co
|
|
107
|
+
res_px = px_out_0 - px_co
|
|
108
|
+
|
|
109
|
+
if abs(res_x) < tol and abs(res_px) < tol:
|
|
110
|
+
return x_co, px_co, True
|
|
111
|
+
|
|
112
|
+
# Jacobian from linear DA terms
|
|
113
|
+
J11 = ring_map[0].getCoefficient([1, 0])
|
|
114
|
+
J12 = ring_map[0].getCoefficient([0, 1])
|
|
115
|
+
J21 = ring_map[1].getCoefficient([1, 0])
|
|
116
|
+
J22 = ring_map[1].getCoefficient([0, 1])
|
|
117
|
+
|
|
118
|
+
# Newton: (I - J) dx = residual
|
|
119
|
+
A = np.array([[1 - J11, -J12], [-J21, 1 - J22]])
|
|
120
|
+
b = np.array([res_x, res_px])
|
|
121
|
+
dx = np.linalg.solve(A, b)
|
|
122
|
+
|
|
123
|
+
x_co -= dx[0]
|
|
124
|
+
px_co -= dx[1]
|
|
125
|
+
|
|
126
|
+
return x_co, px_co, False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_closed_orbit_simple(ring_func, x_guess=0.0, tol=1e-12, max_iter=20):
|
|
130
|
+
"""Find closed orbit for a ring function that takes (x0, px0) floats.
|
|
131
|
+
|
|
132
|
+
Simpler interface: the ring function evaluates a scalar one-turn map
|
|
133
|
+
using DA internally and returns (x_final, px_final, J11, J12, J21, J22).
|
|
134
|
+
|
|
135
|
+
For most cases, use the pattern below instead::
|
|
136
|
+
|
|
137
|
+
DA.init(order, 2)
|
|
138
|
+
ring_map = build_ring_map(x_co_guess) # shifts DA origin
|
|
139
|
+
# extract residual and Jacobian from ring_map
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
ring_func : callable
|
|
144
|
+
Takes (x0: float, px0: float) and returns a dict with keys
|
|
145
|
+
'x', 'px' (floats, final coords) and 'J' (2x2 ndarray, Jacobian).
|
|
146
|
+
x_guess : float
|
|
147
|
+
Initial guess for closed orbit radius deviation.
|
|
148
|
+
tol : float
|
|
149
|
+
Convergence tolerance.
|
|
150
|
+
max_iter : int
|
|
151
|
+
Maximum iterations.
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
x_co : float
|
|
156
|
+
Closed orbit position.
|
|
157
|
+
converged : bool
|
|
158
|
+
Whether converged.
|
|
159
|
+
"""
|
|
160
|
+
x_co = x_guess
|
|
161
|
+
px_co = 0.0
|
|
162
|
+
|
|
163
|
+
for _ in range(max_iter):
|
|
164
|
+
result = ring_func(x_co, px_co)
|
|
165
|
+
res = np.array([result['x'] - x_co, result['px'] - px_co])
|
|
166
|
+
if np.max(np.abs(res)) < tol:
|
|
167
|
+
return x_co, True
|
|
168
|
+
A = np.eye(2) - result['J']
|
|
169
|
+
dx = np.linalg.solve(A, res)
|
|
170
|
+
x_co -= dx[0]
|
|
171
|
+
px_co -= dx[1]
|
|
172
|
+
|
|
173
|
+
return x_co, False
|
pyffag/sector.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""DA tracking through FFAG sector magnets.
|
|
2
|
+
|
|
3
|
+
Integrates the exact midplane Hamiltonian in Frenet-Serret (curvilinear)
|
|
4
|
+
coordinates with arc length s as the independent variable. The equations
|
|
5
|
+
of motion are derived from the generating Hamiltonian
|
|
6
|
+
|
|
7
|
+
K = -(1+hx) sqrt(p^2 - px^2) + h(1+hx/2)x * p
|
|
8
|
+
|
|
9
|
+
where h = 1/rho is the reference curvature and p = |p|/p0 is the scaled
|
|
10
|
+
total momentum (1 for on-momentum). For a pure magnetic sector with
|
|
11
|
+
By(x) = B0 + B1*x + B2*x^2 + ..., the resulting ODEs are:
|
|
12
|
+
|
|
13
|
+
dx/ds = (1 + hx) * px / sqrt(1 - px^2)
|
|
14
|
+
dpx/ds = h * sqrt(1 - px^2) - (1 + hx) * By(x) / Brho
|
|
15
|
+
|
|
16
|
+
For sector magnets, the edge faces are radial — no edge focusing.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from daceypy import DA, array, integrator
|
|
21
|
+
from daceypy.RK import RK78
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _SectorIntegrator(integrator):
|
|
25
|
+
"""RK7(8) integrator for a sector magnet with polynomial field."""
|
|
26
|
+
|
|
27
|
+
# Set by sector_map() before propagation
|
|
28
|
+
_h = 0.0 # curvature 1/rho [1/m]
|
|
29
|
+
_B_coeffs = [] # [B0, B1, B2, ...] where By(x) = sum(Bn * x^n)
|
|
30
|
+
_inv_Brho = 0.0 # 1/(Brho) [1/(T·m)]
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def f(z, s):
|
|
34
|
+
x, px = z[0], z[1]
|
|
35
|
+
h = _SectorIntegrator._h
|
|
36
|
+
inv_Brho = _SectorIntegrator._inv_Brho
|
|
37
|
+
B_coeffs = _SectorIntegrator._B_coeffs
|
|
38
|
+
|
|
39
|
+
# By(x) = B0 + B1*x + B2*x^2 + ...
|
|
40
|
+
By = DA(B_coeffs[0]) if isinstance(B_coeffs[0], (int, float)) else B_coeffs[0]
|
|
41
|
+
xn = DA(1.0) # x^0 = 1
|
|
42
|
+
for n in range(1, len(B_coeffs)):
|
|
43
|
+
xn = xn * x
|
|
44
|
+
By = By + B_coeffs[n] * xn
|
|
45
|
+
|
|
46
|
+
ohx = 1.0 + h * x # (1 + h*x)
|
|
47
|
+
ps = DA.sqrt(1.0 - px * px) # sqrt(1 - px^2)
|
|
48
|
+
|
|
49
|
+
dxds = ohx * px / ps
|
|
50
|
+
dpxds = h * ps - ohx * By * inv_Brho
|
|
51
|
+
|
|
52
|
+
return array([dxds, dpxds])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sector_map(B_coeffs, Brho, angle, rho=None, rtol=1e-14, atol=1e-14):
|
|
56
|
+
"""Compute the DA transfer map through a magnetic sector magnet.
|
|
57
|
+
|
|
58
|
+
Integrates the exact midplane Hamiltonian (no paraxial approximation)
|
|
59
|
+
through a sector magnet with radial edge faces.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
B_coeffs : list of float
|
|
64
|
+
Midplane field polynomial coefficients.
|
|
65
|
+
By(x) = B_coeffs[0] + B_coeffs[1]*x + B_coeffs[2]*x^2 + ...
|
|
66
|
+
Units: B_coeffs[0] in [T], B_coeffs[1] in [T/m], etc.
|
|
67
|
+
Brho : float
|
|
68
|
+
Magnetic rigidity p/q [T·m].
|
|
69
|
+
angle : float
|
|
70
|
+
Bending angle of the sector [rad]. The sign of angle should match
|
|
71
|
+
the sign of B_coeffs[0]: positive angle for positive B0.
|
|
72
|
+
rho : float, optional
|
|
73
|
+
Reference bending radius [m]. Default: |Brho / B_coeffs[0]|.
|
|
74
|
+
rtol, atol : float
|
|
75
|
+
Relative and absolute tolerance for the RK7(8) integrator.
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
list of DA
|
|
80
|
+
[x_out, px_out] — the DA transfer map components.
|
|
81
|
+
"""
|
|
82
|
+
B0 = B_coeffs[0]
|
|
83
|
+
if rho is None:
|
|
84
|
+
rho = abs(Brho / B0)
|
|
85
|
+
arc_length = rho * abs(angle)
|
|
86
|
+
|
|
87
|
+
_SectorIntegrator._h = 1.0 / rho * np.sign(B0)
|
|
88
|
+
_SectorIntegrator._B_coeffs = B_coeffs
|
|
89
|
+
_SectorIntegrator._inv_Brho = 1.0 / Brho
|
|
90
|
+
|
|
91
|
+
prop = _SectorIntegrator(RKcoeff=RK78(), stateType=array)
|
|
92
|
+
prop.loadTime(0.0, arc_length)
|
|
93
|
+
prop.loadTol(rtol, atol)
|
|
94
|
+
prop.loadStepSize()
|
|
95
|
+
|
|
96
|
+
z0 = array([DA(1), DA(2)])
|
|
97
|
+
zf = prop.propagate(z0, 0.0, arc_length)
|
|
98
|
+
|
|
99
|
+
return [zf[0], zf[1]]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyffag
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DA-based FFAG accelerator tracking using differential algebra
|
|
5
|
+
Author-email: Eremey Valetov <evv@msu.edu>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/evvaletov/pyffag
|
|
8
|
+
Keywords: FFAG,accelerator,beam physics,differential algebra,transfer map
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: daceypy>=1.3.0
|
|
18
|
+
Requires-Dist: numpy>=1.24
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# pyffag
|
|
22
|
+
|
|
23
|
+
DA-based FFAG accelerator tracking using differential algebra.
|
|
24
|
+
|
|
25
|
+
Built on [daceypy](https://pypi.org/project/daceypy/) for arbitrary-order
|
|
26
|
+
transfer map computation through FFAG sector magnets via integration of the
|
|
27
|
+
exact midplane Hamiltonian.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install pyffag
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import numpy as np
|
|
39
|
+
from daceypy import DA
|
|
40
|
+
from pyffag import sector_map, compose_sequence, compose_n, tune, twiss
|
|
41
|
+
from pyffag.constants import kinetic_to_brho, M_PROTON
|
|
42
|
+
|
|
43
|
+
# 150 MeV proton FFAG ring: 12 FDF-triplet cells
|
|
44
|
+
DA.init(7, 2) # DA order 7, 2 variables (x, px)
|
|
45
|
+
Brho = kinetic_to_brho(150.0, M_PROTON) # magnetic rigidity [T·m]
|
|
46
|
+
|
|
47
|
+
# F magnet: B(x) = 1.2 + 3.0*x + 4.0*x² [T], 12° sector
|
|
48
|
+
F = sector_map([1.2, 3.0, 4.0], Brho, angle=np.radians(12.0))
|
|
49
|
+
|
|
50
|
+
# D magnet: B(x) = 1.2 − 5.0*x − 5.0*x² [T], 6° sector
|
|
51
|
+
D = sector_map([1.2, -5.0, -5.0], Brho, angle=np.radians(6.0))
|
|
52
|
+
|
|
53
|
+
# One cell = F + D + F, full ring = 12 cells
|
|
54
|
+
cell = compose_sequence([F, D, F])
|
|
55
|
+
ring = compose_n(cell, 12)
|
|
56
|
+
|
|
57
|
+
print(f"Cell tune: {twiss(cell)['tune']:.4f}")
|
|
58
|
+
print(f"Ring tune: {tune(ring):.4f}")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Features
|
|
62
|
+
|
|
63
|
+
- **Sector magnet tracking**: Exact midplane Hamiltonian integration
|
|
64
|
+
(no paraxial approximation) through sector magnets with polynomial
|
|
65
|
+
field profiles B(x) = B₀ + B₁x + B₂x² + ...
|
|
66
|
+
- **Element maps**: Drift (exact), thin quadrupole, sextupole, octupole,
|
|
67
|
+
edge kicks for rectangular magnets
|
|
68
|
+
- **Ring operations**: Map composition, N-fold composition, closed orbit
|
|
69
|
+
finding via Newton's method with DA Jacobian
|
|
70
|
+
- **Optics**: Tune, Twiss parameters, stability check, symplecticity error
|
|
71
|
+
|
|
72
|
+
## Physics
|
|
73
|
+
|
|
74
|
+
The core `sector_map()` integrates the equations of motion in Frenet-Serret
|
|
75
|
+
(curvilinear) coordinates with arc length as the independent variable:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
dx/ds = (1 + hx) · px / √(1 − px²)
|
|
79
|
+
dpx/ds = h · √(1 − px²) − (1 + hx) · By(x) / (Bρ)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
where h = 1/ρ is the reference curvature. Sector magnets have radial edge
|
|
83
|
+
faces (no edge focusing). The exact sqrt formulation captures kinematic
|
|
84
|
+
nonlinearities that the paraxial approximation misses.
|
|
85
|
+
|
|
86
|
+
## Integration with danf
|
|
87
|
+
|
|
88
|
+
Use with [danf](https://pypi.org/project/danf/) for nonlinear normal form
|
|
89
|
+
analysis (amplitude-dependent tune shifts, resonance driving terms):
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from danf import NormalForm
|
|
93
|
+
|
|
94
|
+
nf = NormalForm(ring)
|
|
95
|
+
nf.compute()
|
|
96
|
+
print(f"ADTS: dν/dε = {nf.detuning['dnux_dJx'] / 2:.4f}")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyffag/__init__.py,sha256=8gcUrfJ4gLX3WvLYBaTMaS0TK_MVOnMjAtDC3omKPzk,1284
|
|
2
|
+
pyffag/constants.py,sha256=WC62B9hZwieCHxMBCYgmth9ipUwXfIlVjtqNcabT-8Y,1478
|
|
3
|
+
pyffag/elements.py,sha256=O4EetmmFjWQi9hu2Y4dmOhGE_TPCnEPWvqRQ9sImQ08,2271
|
|
4
|
+
pyffag/optics.py,sha256=BjbRQq75HqpOmO-3b9Q_yXZVvI7RYXYEhFIOAZNtJSY,2554
|
|
5
|
+
pyffag/ring.py,sha256=e1rD1Y_5dETwYx6VfWYL8AP7-QHgauydsX7O7yZj2RI,5002
|
|
6
|
+
pyffag/sector.py,sha256=yMck_n4LDqPEMH8EkAnMMbUOLpMo3mYBBppOYBdamWo,3257
|
|
7
|
+
pyffag-0.1.0.dist-info/licenses/LICENSE,sha256=-mIPUvK6wSxeUkO82LHW_GbhCgP23AFZPoTJyS8AMY0,1071
|
|
8
|
+
pyffag-0.1.0.dist-info/METADATA,sha256=oj1kRhU5RbWasRXqYfy0jLVGydAT9-n8P2ATwD7WKUk,3146
|
|
9
|
+
pyffag-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
pyffag-0.1.0.dist-info/top_level.txt,sha256=NHPXwUwaY_0GvErPdmP5OBsZkvZ6y5-x_NGjWNxCEwU,7
|
|
11
|
+
pyffag-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eremey Valetov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyffag
|