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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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