Skip to content

Implement envelope tracker#112

Draft
austin-hoover wants to merge 128 commits into
PyORBIT-Collaboration:mainfrom
austin-hoover:env-tracker
Draft

Implement envelope tracker#112
austin-hoover wants to merge 128 commits into
PyORBIT-Collaboration:mainfrom
austin-hoover:env-tracker

Conversation

@austin-hoover

@austin-hoover austin-hoover commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

This PR implements an envelope/centroid tracker (Issue #51). We track the covariance matrix $\mathbf{\Sigma} = \langle \mathbf{x} \mathbf{x}^T \rangle$ and centroid $\mathbf{\mu} = \langle \mathbf{x} \rangle$, where $\mathbf{x} = [x, x', y, y', z, \Delta E ]^T$ is the 6D phase space vector.

Evolution equations:

$$ \begin{align} \mathbf{x} &\rightarrow \mathbf{M} \mathbf{x} + \mathbf{u} , \\ \mathbf{\mu} &\rightarrow \mathbf{M} \mathbf{\mu} + \mathbf{u} , \\ \mathbf{\Sigma} &\rightarrow \mathbf{M} \mathbf{\Sigma} \mathbf{M}^T . \end{align} $$

We track the $7 \times 7$ matrix $\mathbf{S} = \langle \mathbf{y} \mathbf{y}^T \rangle$, where $\mathbf{y} = [x, x', y, y', z, dE, 1]^T$:

$$ \mathbf{S} = \langle \mathbf{y} \mathbf{y}^T \rangle = \begin{bmatrix} \langle \mathbf{x} \mathbf{x}^T \rangle & \langle \mathbf{x} \rangle \\ \langle \mathbf{x} \rangle^T & 1 \end{bmatrix} = \begin{bmatrix} \mathbf{\Sigma} + \mathbf{\mu}\mathbf{\mu}^T & \mathbf{\mu} \\ \mathbf{\mu}^T & 1 \end{bmatrix} . $$

Evolution equations for $\mathbf{y}$:

$$ \begin{align} \mathbf{y} &\rightarrow \mathbf{N} \mathbf{y} , \\ \mathbf{S} &\rightarrow \mathbf{N} \mathbf{S} \mathbf{N}^T , \end{align} $$

where $\mathbf{N}$ is defined as

$$ \mathbf{N} = \begin{bmatrix} \mathbf{M} & \mathbf{u} \\ \mathbf{0} & 1 \end{bmatrix} . $$

Accelerator nodes and child nodes are mapped to transfer matrices at the Python level. An error is raised if the node is not recognized. Space charge is handled by assuming a uniform charge density within an ellipsoid in the $x$-$y$-$z$ plane (for 3D space charge) or within an ellipse in the $x$-$y$ plane (for 2D space charge).

@austin-hoover

austin-hoover commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author
  • Added orbit.matrix_lattice.analytic which has a list of functions to generate transfer matrices for dipoles, quads, etc. This is used for envelope tracking but could be used elsewhere. These should be double-checked.
  • Each node in orbit.teapot and orbit.py_linac now has function matrix(sync_part: SyncParticle, index: int = -1) -> np.ndarray which returns the transfer matrix from the synchronous particle, optionally for one part of the node (index). This didn't speed up but I like the implementation more. However we could change back.
  • The Quad element needs $[B \rho]$, which requires the charge, which is an attribute of Bunch but not SyncParticle. Right now it is set to charge = 1.
  • Have not done anything with RF cavities, but added elements for all other elements like quad, bend, kick, solenoid, etc. + tests of these elements.
  • FIxed 3D space charge for tilted bunches. It is working for x-y-z tilted bunches at both low and high energies.

@woodtp

woodtp commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Re: performance

I commented out everything except setting up the envelope tracker and ran a profiler. On my system, tracking completes in less than 2 seconds and almost all of the time (63%) is spent just importing the PyORBIT C-extensions ({built-in method _imp.create_dynamic}) followed up reading the lattice madx lattice {method 'read' of '_io.BufferedReader' objects}

2272483 function calls (2247288 primitive calls) in 1.854 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       58    1.155    0.020    1.155    0.020 {built-in method _imp.create_dynamic}
      482    0.076    0.000    0.076    0.000 {method 'read' of '_io.BufferedReader' objects}
    80088    0.058    0.000    0.058    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/envelope/envelope.py:124(apply_transfer_matrix)
    52536    0.032    0.000    0.042    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/numpy/lib/_twodim_base_impl.py:176(eye)
       24    0.030    0.001    0.285    0.012 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/envelope/envelope.py:255(track)
      482    0.026    0.000    0.026    0.000 {built-in method _io.open_code}
    20184    0.023    0.000    0.033    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/matrix_lattice/analytic.py:29(convert_matrix_dp_p_to_dE)
    29184    0.020    0.000    0.069    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/matrix_lattice/analytic.py:130(tilt_matrix)
    52536    0.018    0.000    0.076    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/numpy/_core/numeric.py:2242(identity)
    58/31    0.013    0.000    0.169    0.005 {built-in method _imp.exec_dynamic}
      350    0.012    0.000    0.012    0.000 {built-in method marshal.loads}

If I'm a bit more careful and only profile the tracking sections in isolation and also disable all logging/bunch stats calcs, this is what I get:

Envelope Tracker

TRACK ENVELOPE
         1077938 function calls in 0.284 seconds

   Ordered by: internal time
   List reduced from 48 to 20 due to restriction <20>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    80088    0.057    0.000    0.057    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/envelope/envelope.py:124(apply_transfer_matrix)
    52536    0.033    0.000    0.044    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/numpy/lib/_twodim_base_impl.py:176(eye)
       24    0.031    0.001    0.284    0.012 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/envelope/envelope.py:255(track)
    20184    0.023    0.000    0.034    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/matrix_lattice/analytic.py:29(convert_matrix_dp_p_to_dE)
    29184    0.020    0.000    0.068    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/matrix_lattice/analytic.py:130(tilt_matrix)
    52536    0.017    0.000    0.077    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/numpy/_core/numeric.py:2242(identity)
    52536    0.010    0.000    0.015    0.000 <frozen importlib._bootstrap>:1409(_handle_fromlist)
    15960    0.008    0.000    0.059    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/matrix_lattice/analytic.py:59(drift_matrix)
    52536    0.007    0.000    0.007    0.000 {built-in method numpy.zeros}
     3840    0.007    0.000    0.028    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:920(matrix)
    72624    0.007    0.000    0.007    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/lattice/AccNode.py:200(getChildNodes)
    20184    0.007    0.000    0.011    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/matrix_lattice/analytic.py:9(get_dp_p_coeff)
    29184    0.006    0.000    0.076    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:1535(matrix)
     3168    0.005    0.000    0.024    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:1483(matrix)
     2496    0.004    0.000    0.013    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/matrix_lattice/analytic.py:68(quad_matrix)
   105072    0.004    0.000    0.004    0.000 {built-in method _operator.index}
    60096    0.003    0.000    0.003    0.000 {built-in method math.cos}
    52536    0.003    0.000    0.003    0.000 {built-in method builtins.hasattr}
    60096    0.003    0.000    0.003    0.000 {built-in method math.sin}
    17664    0.003    0.000    0.004    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/utils/ParamsDictObject.py:51(getParam)

Regular Tracker (Single Particle)

TRACK BUNCH 
         760753 function calls (702385 primitive calls) in 0.124 seconds

   Ordered by: internal time
   List reduced from 50 to 20 due to restriction <20>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
72960/14592    0.053    0.000    0.122    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/lattice/AccNode.py:307(trackActions)
   226008    0.016    0.000    0.065    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/lattice/AccActionsContainer.py:49(performActions)
    80088    0.012    0.000    0.049    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:160(track)
    46656    0.006    0.000    0.009    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/utils/ParamsDictObject.py:51(getParam)
   110712    0.006    0.000    0.006    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/lattice/AccNode.py:92(getLength)
     3840    0.004    0.000    0.007    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:875(track)
    29184    0.003    0.000    0.008    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:1558(track)
     3168    0.003    0.000    0.005    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:1447(track)
    46656    0.003    0.000    0.003    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/utils/ParamsDictObject.py:76(hasParam)
     8856    0.003    0.000    0.004    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:603(track)
     2496    0.002    0.000    0.005    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:1042(track)
     1728    0.002    0.000    0.004    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:1264(track)
       24    0.002    0.000    0.124    0.005 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/lattice/AccLattice.py:248(trackActions)
    29184    0.001    0.000    0.001    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:1526(track)
     1080    0.001    0.000    0.003    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:673(track)
    15864    0.001    0.000    0.001    0.000 {built-in method orbit.core.teapot_base.drift}
    21264    0.001    0.000    0.001    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/lattice/AccNode.py:101(getActivePartIndex)
      864    0.001    0.000    0.002    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:1132(fringeIN)
      864    0.001    0.000    0.002    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/teapot/teapot.py:1182(fringeOUT)
    12960    0.001    0.000    0.001    0.000 /opt/homebrew/Caskroom/miniforge/base/envs/pyo3-test/lib/python3.14/site-packages/orbit/lattice/AccNode.py:71(getnParts)

So, the envelope tracker spends most of its time in apply_transfer_matrix, one of the numpy routines (nothing to do about that), and the track method itself. Might just be the cost of doing business in Python.

@austin-hoover

Copy link
Copy Markdown
Contributor Author

Thanks! that's very helpful. If matrix multiplication is dominating then there's not all that much to do. I added some checks to skip the matrix multiplication tilt angle is zero in TiltTEAPOT. This speeds up because all teapot nodes have TiltTEAPOT child nodes at entrance and exit. Building the matrices also takes time, but I don't see a way around this to handle time-dependent nodes in rings.

@austin-hoover

Copy link
Copy Markdown
Contributor Author

I've added a function to generate the matrix for RF gap nodes, copied from orbit.core.linac.MatrixRFGap. This function updates the energy of the synchronous particle.

Each envelope tracking step needs to track the synchronous particle. For most elements this will just be increasing the time by sync_part.time(length / (sync_part.beta() * speed_of_light). RF gaps will update the synchonous particle energy. Not sure of the best way to handle this.

@woodtp

woodtp commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

One other thing you could try that may or may not be worth the effort is using Numba to do some JIT compilation where possible. You'll eat an initial cost to do the compilation the first time you run the tracker, but the byte code can be cached so that subsequent runs are faster (or have the potential to be). You would probably need to factor out types made accessible from bindings, e.g., the synchronous particle instance, and instead directly pass in the raw values you need to the kernels you want to be JIT compilable.

@austin-hoover

Copy link
Copy Markdown
Contributor Author

WIth these small modifications:

Envelope:

Time per turn: 0.011063957214355468
         675520 function calls (675419 primitive calls) in 0.277 seconds

   Ordered by: internal time
   List reduced from 439 to 10 due to restriction <10>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       25    0.054    0.002    0.254    0.010 /Users/46h/repo/PyORBIT3/py/orbit/envelope/envelope.py:250(track)
    92025    0.036    0.000    0.036    0.000 /Users/46h/repo/PyORBIT3/py/orbit/envelope/envelope.py:124(apply_transfer_matrix)
    16000    0.018    0.000    0.024    0.000 /Users/46h/miniforge3/envs/pyorbit/lib/python3.13/site-packages/numpy/lib/_twodim_base_impl.py:176(eye)
    72600    0.015    0.000    0.015    0.000 /Users/46h/repo/PyORBIT3/py/orbit/lattice/AccNode.py:200(getChildNodes)
    11600    0.012    0.000    0.054    0.000 /Users/46h/repo/PyORBIT3/py/orbit/matrix_lattice/analytic.py:51(drift_matrix)
     2600    0.012    0.000    0.022    0.000 /Users/46h/repo/PyORBIT3/py/orbit/matrix_lattice/analytic.py:60(quad_matrix)
     4000    0.011    0.000    0.042    0.000 /Users/46h/repo/PyORBIT3/py/orbit/teapot/teapot.py:924(matrix)
        5    0.010    0.002    0.010    0.002 {built-in method _imp.create_dynamic}
    30400    0.010    0.000    0.014    0.000 /Users/46h/repo/PyORBIT3/py/orbit/teapot/teapot.py:1548(matrix)
    16000    0.010    0.000    0.042    0.000 /Users/46h/miniforge3/envs/pyorbit/lib/python3.13/site-packages/numpy/_core/numeric.py:2231(identity)

Bunch (10,000 particles):

Time per turn: 0.04294008255004883
         1197659 function calls (1086109 primitive calls) in 1.073 seconds

   Ordered by: internal time
   List reduced from 147 to 10 due to restriction <10>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    15000    0.227    0.000    0.227    0.000 {built-in method orbit.core.teapot_base.drift}
126775/15225    0.202    0.000    1.066    0.000 /Users/46h/repo/PyORBIT3/py/orbit/lattice/AccNode.py:307(trackActions)
    10150    0.099    0.000    0.099    0.000 {built-in method orbit.core.teapot_base.wrapbunch}
     1125    0.080    0.000    0.080    0.000 {method 'analyzeBunch' of 'BunchTwissAnalysis' objects}
     3450    0.053    0.000    0.053    0.000 {built-in method orbit.core.teapot_base.multp}
   386175    0.052    0.000    0.849    0.000 /Users/46h/repo/PyORBIT3/py/orbit/lattice/AccActionsContainer.py:49(performActions)
     2600    0.041    0.000    0.041    0.000 {built-in method orbit.core.teapot_base.quad2}
     2600    0.037    0.000    0.037    0.000 {built-in method orbit.core.teapot_base.quad1}
     1800    0.033    0.000    0.033    0.000 {built-in method orbit.core.teapot_base.bend4}
   132625    0.032    0.000    0.797    0.000 /Users/46h/repo/PyORBIT3/py/orbit/teapot/teapot.py:160(track)

@austin-hoover

Copy link
Copy Markdown
Contributor Author

With 2D space charge:

Envelope:

Time per turn: 0.06159739494323731
         3340984 function calls (3340883 primitive calls) in 1.540 seconds

   Ordered by: internal time
   List reduced from 482 to 10 due to restriction <10>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    21075    0.133    0.000    1.154    0.000 /Users/46h/repo/PyORBIT3/py/orbit/envelope/envelope.py:137(sc_transfer_matrix_2d)
    21075    0.102    0.000    0.211    0.000 /Users/46h/miniforge3/envs/pyorbit/lib/python3.13/site-packages/numpy/linalg/_linalg.py:1536(eigh)
    79225    0.090    0.000    0.121    0.000 /Users/46h/miniforge3/envs/pyorbit/lib/python3.13/site-packages/numpy/lib/_twodim_base_impl.py:176(eye)
    21075    0.088    0.000    0.174    0.000 /Users/46h/miniforge3/envs/pyorbit/lib/python3.13/site-packages/numpy/linalg/_linalg.py:557(inv)
       25    0.086    0.003    1.517    0.061 /Users/46h/repo/PyORBIT3/py/orbit/envelope/envelope.py:250(track)
    42150    0.083    0.000    0.114    0.000 /Users/46h/miniforge3/envs/pyorbit/lib/python3.13/site-packages/numpy/_core/numeric.py:910(outer)
   113100    0.078    0.000    0.078    0.000 /Users/46h/repo/PyORBIT3/py/orbit/envelope/envelope.py:124(apply_transfer_matrix)
    42150    0.070    0.000    0.188    0.000 /Users/46h/repo/PyORBIT3/py/orbit/envelope/envelope.py:115(cov)
    21075    0.039    0.000    0.043    0.000 /Users/46h/miniforge3/envs/pyorbit/lib/python3.13/site-packages/numpy/linalg/_linalg.py:3009(_multi_dot_three)
    21075    0.038    0.000    0.067    0.000 /Users/46h/repo/PyORBIT3/py/orbit/envelope/envelope.py:26(build_diag_matrix_from_xyz_eig)

Bunch (100,000 particles, 64 x 64 x 1 grid):

Time per turn: 2.021827611923218
         1325513 function calls (1200163 primitive calls) in 50.548 seconds

   Ordered by: internal time
   List reduced from 167 to 10 due to restriction <10>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    13800   42.838    0.003   42.838    0.003 {method 'trackBunch' of 'SpaceChargeCalc2p5D' objects}
    15000    2.407    0.000    2.407    0.000 {built-in method orbit.core.teapot_base.drift}
    10150    1.007    0.000    1.007    0.000 {built-in method orbit.core.teapot_base.wrapbunch}
     1125    0.861    0.001    0.861    0.001 {method 'analyzeBunch' of 'BunchTwissAnalysis' objects}
     3450    0.550    0.000    0.550    0.000 {built-in method orbit.core.teapot_base.multp}
     2600    0.433    0.000    0.433    0.000 {built-in method orbit.core.teapot_base.quad2}
     2600    0.387    0.000    0.387    0.000 {built-in method orbit.core.teapot_base.quad1}
     1800    0.340    0.000    0.340    0.000 {built-in method orbit.core.teapot_base.bend4}
     1800    0.320    0.000    0.320    0.000 {built-in method orbit.core.teapot_base.bend3}
     1800    0.298    0.000    0.298    0.000 {built-in method orbit.core.teapot_base.bend2}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants