Skip to content

Commit e568a9d

Browse files
JoschDfsoubelet
andauthored
fix weights and phases in some CTA calculations, add new CTA methods (#20)
Co-authored-by: Felix Soubelet <[email protected]>
1 parent e6cfa1c commit e568a9d

File tree

6 files changed

+258
-106
lines changed

6 files changed

+258
-106
lines changed

doc/helper/bibliography.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ Bibliography
1919
`Improved control of the betatron coupling in the Large Hadron Collider`,
2020
Phys. Rev. ST Accel. Beams **17**, 051004 (2014).
2121
http://cds.cern.ch/record/2135848/files/PhysRevSTAB.17.051004.pdf
22+
23+
.. [HoydalsvikEvaluationOfTheClosestTuneApproach2021]
24+
E. J. Hoydalsvik et al.,
25+
`Evaluation of the closest tune approach and its MAD-X implementation`,
26+
Report, CERN-ACC-NOTE-2021-0022, (2021)
27+
https://cds.cern.ch/record/2778887/files/CERN-ACC-NOTE-2021-0022.pdf

optics_functions/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
__title__ = "optics_functions"
66
__description__ = "Calculate optics parameters from TWISS outputs."
77
__url__ = "https://github.com/pylhc/optics_functions"
8-
__version__ = "0.1.0"
8+
__version__ = "0.1.1"
99
__author__ = "pylhc"
1010
__author_email__ = "[email protected]"
1111
__license__ = "MIT"

optics_functions/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
PHASE_ADV = "MU"
2525
DISPERSION = "D"
2626
CHROM_TERM = "CHROM"
27+
F1010 = "F1010"
28+
F1001 = "F1001"
2729

2830
X, Y = PLANES
2931

optics_functions/coupling.py

Lines changed: 129 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,24 @@
22
Coupling
33
********
44
5-
Functions to estimate coupling from twiss dataframes and
6-
different methods to calculate the closest tune approach from
7-
the calculated coupling RDTs.
8-
5+
Functions to estimate coupling from twiss dataframes and different methods to calculate the closest tune
6+
approach from the calculated coupling RDTs.
97
"""
108
import logging
119
from contextlib import suppress
12-
from typing import Sequence
10+
from typing import Sequence, Tuple
1311

1412
import numpy as np
1513
from pandas import DataFrame, Series
1614
from tfs import TfsDataFrame
1715

1816
from optics_functions.constants import (ALPHA, BETA, GAMMA, X, Y, TUNE, DELTA,
19-
MINIMUM, PI2, PHASE_ADV, S, LENGTH, IMAG, REAL)
17+
MINIMUM, PI2, PHASE_ADV, S, LENGTH,
18+
IMAG, REAL, F1010, F1001)
2019
from optics_functions.rdt import calculate_rdts
2120
from optics_functions.utils import split_complex_columns, timeit
2221

23-
COUPLING_RDTS = ["F1001", "F1010"]
24-
25-
22+
COUPLING_RDTS = [F1001, F1010]
2623
LOG = logging.getLogger(__name__)
2724

2825

@@ -38,7 +35,7 @@ def coupling_via_rdts(df: TfsDataFrame, complex_columns: bool = True, **kwargs)
3835
calculations from [CalagaBetatronCoupling2005]_ .
3936
4037
Args:
41-
df (TfsDataFrame): Twiss Dataframe
38+
df (TfsDataFrame): Twiss Dataframe.
4239
complex_columns (bool): Output complex values in single column of type complex.
4340
If ``False``, split complex columns into
4441
two real-valued columns.
@@ -49,11 +46,13 @@ def coupling_via_rdts(df: TfsDataFrame, complex_columns: bool = True, **kwargs)
4946
and ``hamiltionian_terms``.
5047
5148
Returns:
52-
New TfsDataFrame with Coupling Columns.
49+
A new ``TfsDataFrame`` with Coupling Columns.
5350
"""
5451
df_res = calculate_rdts(df, rdts=COUPLING_RDTS, **kwargs)
5552
for rdt in COUPLING_RDTS:
56-
df_res.loc[:, rdt].to_numpy().real *= -1 # definition, also: sets value in dataframe
53+
rdt_array = df_res[rdt].to_numpy() # might return a copy!
54+
rdt_array.real *= -1 # definition
55+
df_res.loc[:, rdt] = rdt_array
5756

5857
if not complex_columns:
5958
df_res = split_complex_columns(df_res, COUPLING_RDTS)
@@ -110,10 +109,10 @@ def coupling_via_cmatrix(df: DataFrame, complex_columns: bool = True,
110109
if "rdts" in output:
111110
# Eq. (9) and Eq. (10)
112111
denom = 1 / (4 * gamma)
113-
df_res.loc[:, "F1001"] = denom * (+c[:, 0, 1] - c[:, 1, 0] + (c[:, 0, 0] + c[:, 1, 1]) * 1j)
114-
df_res.loc[:, "F1010"] = denom * (-c[:, 0, 1] - c[:, 1, 0] + (c[:, 0, 0] - c[:, 1, 1]) * 1j)
115-
LOG.info(f"Average coupling amplitude |F1001|: {df_res['F1001'].abs().mean():g}")
116-
LOG.info(f"Average coupling amplitude |F1010|: {df_res['F1010'].abs().mean():g}")
112+
df_res.loc[:, F1001] = denom * (+c[:, 0, 1] - c[:, 1, 0] + (c[:, 0, 0] + c[:, 1, 1]) * 1j)
113+
df_res.loc[:, F1010] = denom * (-c[:, 0, 1] - c[:, 1, 0] + (c[:, 0, 0] - c[:, 1, 1]) * 1j)
114+
LOG.info(f"Average coupling amplitude |F1001|: {df_res[F1001].abs().mean():g}")
115+
LOG.info(f"Average coupling amplitude |F1010|: {df_res[F1010].abs().mean():g}")
117116

118117
if not complex_columns:
119118
df_res = split_complex_columns(df_res, COUPLING_RDTS)
@@ -141,13 +140,13 @@ def rmatrix_from_coupling(df: DataFrame, complex_columns: bool = True) -> DataFr
141140
See [CalagaBetatronCoupling2005]_ .
142141
143142
Args:
144-
df (DataFrame): Twiss Dataframe
143+
df (DataFrame): Twiss Dataframe.
145144
complex_columns (bool): Tells the function if the coupling input columns
146145
are complex-valued or split into real and
147146
imaginary parts.
148147
149148
Returns:
150-
New DataFrame containing the R-columns.
149+
A new ``DataFrame`` containing the R-columns.
151150
"""
152151
LOG.info("Calculating r-matrix from coupling rdts.")
153152
df_res = DataFrame(index=df.index)
@@ -174,19 +173,19 @@ def rmatrix_from_coupling(df: DataFrame, complex_columns: bool = True) -> DataFr
174173

175174
# Eq. (15)
176175
if complex_columns:
177-
abs_squared_diff = df["F1001"].abs()**2 - df["F1010"].abs()**2
176+
abs_squared_diff = df[F1001].abs()**2 - df[F1010].abs()**2
178177
else:
179-
abs_squared_diff = (df[f"F1001{REAL}"]**2 + df[f"F1001{IMAG}"]**2 -
180-
df[f"F1010{REAL}"]**2 - df[f"F1010{IMAG}"]**2)
178+
abs_squared_diff = (df[f"{F1001}{REAL}"]**2 + df[f"{F1001}{IMAG}"]**2 -
179+
df[f"{F1010}{REAL}"]**2 - df[f"{F1010}{IMAG}"]**2)
181180

182181
gamma = np.sqrt(1.0 / (1.0 + 4.0 * abs_squared_diff))
183182

184183
# Eq. (11) and Eq. (12)
185184
cbar = np.zeros((n, 2, 2))
186-
cbar[:, 0, 0] = (df[f"F1001{IMAG}"] + df[f"F1010{IMAG}"]).to_numpy()
187-
cbar[:, 0, 1] = -(df[f"F1010{REAL}"] - df[f"F1001{REAL}"]).to_numpy()
188-
cbar[:, 1, 0] = -(df[f"F1010{REAL}"] + df[f"F1001{REAL}"]).to_numpy()
189-
cbar[:, 1, 1] = (df[f"F1001{IMAG}"] - df[f"F1010{IMAG}"]).to_numpy()
185+
cbar[:, 0, 0] = (df[f"{F1001}{IMAG}"] + df[f"{F1010}{IMAG}"]).to_numpy()
186+
cbar[:, 0, 1] = -(df[f"{F1010}{REAL}"] - df[f"{F1001}{REAL}"]).to_numpy()
187+
cbar[:, 1, 0] = -(df[f"{F1010}{REAL}"] + df[f"{F1001}{REAL}"]).to_numpy()
188+
cbar[:, 1, 1] = (df[f"{F1001}{IMAG}"] - df[f"{F1010}{IMAG}"]).to_numpy()
190189
cbar = 2 * gamma.to_numpy()[:, None, None] * cbar
191190

192191
# Gx^-1 * Cbar * Gy = C (Eq. (5) inverted)
@@ -210,43 +209,57 @@ def rmatrix_from_coupling(df: DataFrame, complex_columns: bool = True) -> DataFr
210209

211210
# Closest Tune Approach --------------------------------------------------------
212211

213-
def closest_tune_approach(df: TfsDataFrame, qx: float = None, qy: float = None,
214-
method: str = "calaga") -> TfsDataFrame:
212+
def closest_tune_approach(
213+
df: TfsDataFrame, qx: float = None, qy: float = None, method: str = "teapot"
214+
) -> TfsDataFrame:
215215
"""Calculates the closest tune approach from coupling resonances.
216216
217217
A complex F1001 column is assumed to be present in the DataFrame.
218218
This can be calculated by :func:`~optics_functions.rdt.rdts`
219219
:func:`~optics_functions.coupling.coupling_from_rdts` or
220220
:func:`~optics_functions.coupling.coupling_from_cmatrix`.
221-
If F1010 is also present it is used, otherwise assumed 0.
221+
If F1010 is also present it is used, otherwise it is assumed 0.
222222
223223
The closest tune approach is calculated by means of Eq. (27) in
224-
[CalagaBetatronCoupling2005]_ (method='calaga') by default,
225-
or approximated by Eq. (1) in [PerssonImprovedControlCoupling2014]_
226-
(method='franchi') or Eq. (2) in [PerssonImprovedControlCoupling2014]_
227-
(method='persson') or the latter without the exp(i(Qx-Qy)s/R) term
228-
(method='persson_alt').
229-
230-
For the 'persson' and 'persson_alt' methods, also MUX and MUY columns
224+
[CalagaBetatronCoupling2005]_ (method="teapot" or "calaga") by default,
225+
or approximated by
226+
Eq. (1) in [PerssonImprovedControlCoupling2014]_ (method="franchi"),
227+
Eq. (27) in [CalagaBetatronCoupling2005]_ with the Franchi appoximation (method="teapot_franchi"),
228+
Eq. (2) in [PerssonImprovedControlCoupling2014]_ (method="persson"),
229+
the latter without the exp(i(Qx-Qy)s/R) term (method="persson_alt"),
230+
Eq. (14) in [HoydalsvikEvaluationOfTheClosestTuneApproach2021]_ (method="hoydalsvik"),
231+
or the latter without the exp(i(Qx-Qy)s/R) term (method="hoydalsvik_alt").
232+
233+
For "persson[_alt]" and "hoydalsvik[_alt]" methods, also MUX and MUY columns
231234
are needed in the DataFrame as well as LENGTH (of the machine) and S column
232-
for the 'persson' method.
235+
for the "persson" and "hoydalsvik" methods.
233236
234237
Args:
235238
df (TfsDataFrame): Twiss Dataframe, needs to have complex-valued F1001 column.
236239
qx (float): Tune in X-Plane (if not given, header df.Q1 is assumed present).
237240
qy (float): Tune in Y-Plane (if not given, header df.Q2 is assumed present).
238241
method (str): Which method to use for evaluation.
239-
Choices: 'calaga', 'franchi', 'persson' and 'persson_alt'.
242+
Choices: "calaga", "teapot", "franchi", "teapot_franchi",
243+
"persson", "persson_alt", "hoydalsvik" or "hoydalsvik_alt".
240244
241245
Returns:
242-
New TfsDataFrame with closest tune approach (DELTAQMIN) column.
243-
The value is real for 'calaga' and 'franchi' methods,
246+
A new ``TfsDataFrame`` with a closest tune approach (DELTAQMIN) column.
247+
The value is real for "calaga", "teapot", "teapot_franchi" and "franchi"
248+
methods. The actual closest tune approach value is the absolute value
249+
of the mean of this column.
244250
"""
251+
if F1001 not in df.columns:
252+
raise KeyError(f"'{F1001}' column not in dataframe. Needed to calculated closest tune approach.")
253+
245254
method_map = {
246-
"calaga": _cta_calaga,
255+
"teapot": _cta_teapot, # as named in [HoydalsvikEvaluationOfTheClosestTuneApproach2021]_
256+
"calaga": _cta_teapot, # for compatibility reasons
257+
"teapot_franchi": _cta_teapot_franchi,
247258
"franchi": _cta_franchi,
248259
"persson": _cta_persson,
249260
"persson_alt": _cta_persson_alt,
261+
"hoydalsvik": _cta_hoydalsvik,
262+
"hoydalsvik_alt": _cta_hoydalsvik_alt,
250263
}
251264
if qx is None:
252265
qx = df.headers[f"{TUNE}1"]
@@ -255,44 +268,111 @@ def closest_tune_approach(df: TfsDataFrame, qx: float = None, qy: float = None,
255268

256269
qx_frac, qy_frac = qx % 1, qy % 1
257270

271+
check_resonance_relation(df)
272+
258273
dqmin_str = f"{DELTA}{TUNE}{MINIMUM}"
259274
df_res = TfsDataFrame(index=df.index, columns=[dqmin_str])
260275
df_res[dqmin_str] = method_map[method.lower()](df, qx_frac, qy_frac)
261276

262-
LOG.info(f"({method}) |C-| = {np.abs(df_res[dqmin_str].mean())}")
277+
LOG.info(f"({method.lower()}) |C-| = {np.abs(df_res[dqmin_str].dropna().mean())}")
263278
return df_res
264279

265280

266281
def _cta_franchi(df: TfsDataFrame, qx_frac: float, qy_frac: float) -> Series:
267282
""" Closest tune approach calculated by Eq. (1) in [PerssonImprovedControlCoupling2014]_ . """
268-
return 4 * (qx_frac - qy_frac) * df["F1001"].abs()
283+
return 4 * (qx_frac - qy_frac) * df[F1001].abs()
269284

270285

271286
def _cta_persson_alt(df: TfsDataFrame, qx_frac: float, qy_frac: float) -> Series:
272287
"""Closest tune approach calculated by Eq. (2) in [PerssonImprovedControlCoupling2014]_ .
273288
The exp(i(Qx-Qy)s/R) term is omitted.
274289
"""
275290
deltaq = qx_frac - qy_frac # fractional tune split
276-
return 4 * deltaq * df["F1001"] * np.exp(-1j * (df[f"{PHASE_ADV}{X}"] - df[f"{PHASE_ADV}{Y}"]))
291+
length_weights = _get_weights_from_lengths(df)
292+
phase_diff = df[f"{PHASE_ADV}{X}"] - df[f"{PHASE_ADV}{Y}"]
293+
return 4 * deltaq * length_weights * df[F1001] * np.exp(-1j * PI2 * phase_diff)
277294

278295

279296
def _cta_persson(df: TfsDataFrame, qx_frac: float, qy_frac: float) -> Series:
280297
""" Closest tune approach calculated by Eq. (2) in [PerssonImprovedControlCoupling2014]_ . """
281298
deltaq = qx_frac - qy_frac # fractional tune split
282-
exponential_term = ((deltaq * df[S] / (df.headers[LENGTH] / PI2)) - (df[f"{PHASE_ADV}{X}"] - df[f"{PHASE_ADV}{Y}"]))
283-
return 4 * deltaq * df['F1001'] * np.exp(1j * exponential_term)
299+
location_term = np.exp(1j * PI2 * (deltaq * df[S] / (df.headers[LENGTH] / PI2)))
300+
return _cta_persson_alt(df, qx_frac, qy_frac) * location_term
301+
302+
303+
def _cta_hoydalsvik(df: TfsDataFrame, qx_frac: float, qy_frac: float) -> Series:
304+
""" Closest tune approach calculated by Eq. (14) in
305+
[HoydalsvikEvaluationOfTheClosestTuneApproach2021]_ .
306+
This is like the persson estimate but divided by 1 + 4|F1001|^2 ."""
307+
return _cta_persson(df, qx_frac, qy_frac) / (1 + 4 * df[F1001].abs() ** 2)
284308

285309

286-
def _cta_calaga(df: TfsDataFrame, qx_frac: float, qy_frac: float) -> Series:
310+
def _cta_hoydalsvik_alt(df: TfsDataFrame, qx_frac: float, qy_frac: float) -> Series:
311+
""" Closest tune approach calculated by Eq. (14) without the s-term in
312+
[HoydalsvikEvaluationOfTheClosestTuneApproach2021]_ .
313+
This is like the persson_alt estimate but divided by 1 + 4|F1001|^2 ."""
314+
return _cta_persson_alt(df, qx_frac, qy_frac) / (1 + 4 * df[F1001].abs() ** 2)
315+
316+
317+
def _cta_teapot(df: TfsDataFrame, qx_frac: float, qy_frac: float) -> Series:
287318
"""Closest tune approach calculated by Eq. (27) in [CalagaBetatronCoupling2005]_ .
288319
If F1010 is not given, it is assumed to be zero.
289320
"""
290-
f_diff = df["F1001"].abs() ** 2
291-
with suppress(KeyError):
292-
f_diff -= df["1010"].abs() ** 2
321+
f_diff = df[F1001].abs() ** 2
322+
with suppress(KeyError): # this is the only estimate that uses sum resonance
323+
f_diff -= df[F1010].abs() ** 2
293324

294325
return (
295326
(np.cos(PI2 * qx_frac) - np.cos(PI2 * qy_frac))
296327
/ (np.pi * (np.sin(PI2 * qx_frac) + np.sin(PI2 * qy_frac)))
297328
* (4 * np.sqrt(f_diff) / (1 + 4 * f_diff))
298329
)
330+
331+
332+
def _cta_teapot_franchi(df: TfsDataFrame, qx_frac: float, qy_frac: float) -> Series:
333+
"""Closest tune approach calculated by Eq. (12) in
334+
[HoydalsvikEvaluationOfTheClosestTuneApproach2021]_ .
335+
This is the teapot approach with the Franchi approximation. """
336+
return 4 * (qx_frac - qy_frac) * df[F1001].abs() / (1 + 4 * df[F1001].abs() ** 2)
337+
338+
339+
def _get_weights_from_lengths(df: TfsDataFrame) -> Tuple[float, np.array]:
340+
"""Coefficients for the `persson` method. """
341+
# approximate length of each element (ds in integral)
342+
s_periodic = np.zeros(len(df) + 1)
343+
s_periodic[1:] = df[S].to_numpy()
344+
s_periodic[0] = df[S][-1] - df.headers[LENGTH]
345+
346+
# weight ds/(2*pi*R) * N (as we take the mean afterwards)
347+
weights = np.diff(s_periodic) / df.headers[LENGTH] * len(df)
348+
return weights
349+
350+
351+
def check_resonance_relation(df: DataFrame, to_nan: bool = False) -> DataFrame:
352+
"""Checks that |F1001| >= |F1010|.
353+
If desired, sets the invalid points to NaN. This is only used for checking
354+
in the :func:`~optics_functions.coupling.closest_tune_approach` function,
355+
but can be invoked by the user with ``to_nan = True`` and the resulting
356+
DataFrame can then be passed to
357+
:func:`~optics_functions.coupling.closest_tune_approach`.
358+
359+
Args:
360+
df (DataFrame): Dataframe containing the coupling columns.
361+
to_nan (bool): If true, sets values where |F1001| <= |F1010| to ``NaN``.
362+
363+
Returns:
364+
A copy of the input data frame, with or without NaNs.
365+
"""
366+
df = df.copy()
367+
if F1010 not in df.columns:
368+
LOG.debug("Sum-resonance not in df, skipping resonance relation check.")
369+
return df
370+
371+
condition_not_fulfilled = df[F1001].abs() < df[F1010].abs() # comparison with NaN always yields False
372+
if any(condition_not_fulfilled):
373+
LOG.warning(f"In {sum(condition_not_fulfilled) / len(df.index) * 100}% "
374+
"of the data points |F1001| < |F1010|. Your closest tune "
375+
"approach estimates might not be accurate.")
376+
if to_nan:
377+
df.loc[condition_not_fulfilled, COUPLING_RDTS] = np.NaN
378+
return df

0 commit comments

Comments
 (0)