fpfind 3.3.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.
fpfind/freqservo.py ADDED
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env python3
2
+ """Reads costream output and provides frequency compensation updates.
3
+
4
+ It reads the costream logging output (either written to '-n' argument, or to stdout otherwise),
5
+ and performs a linear estimate of the timing drift (frequency offset). These frequency offsets are compatible
6
+ with the formats read by 'freqcd'. See the documentation for 'freqservo:evaluate_freqshift' on how to
7
+ tune this estimation. Example scenario:
8
+
9
+ "freqcd -F freq.pipe -f freqdiff -o raws &
10
+ chopper2 -i raws -D t1dir -U &
11
+ costream -D t1dir -n log.pipe -V 5 &
12
+ freqservo -n log.pipe -V 5 -F freq.pipe -f freqdiff &".
13
+
14
+ """
15
+
16
+ import logging
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+ import configargparse
22
+ import numpy as np
23
+
24
+ from fpfind.lib import parse_epochs as eparser
25
+ from fpfind.lib.constants import EPOCH_DURATION
26
+ from fpfind.lib.utils import ArgparseCustomFormatter, parse_docstring_description
27
+
28
+ _LOGGING_FMT = "{asctime}\t{levelname:<7s}\t{funcName}:{lineno}\t| {message}"
29
+ logger = logging.getLogger(__name__)
30
+ if not logger.handlers:
31
+ handler = logging.StreamHandler(stream=sys.stderr)
32
+ handler.setFormatter(
33
+ logging.Formatter(fmt=_LOGGING_FMT, datefmt="%Y%m%d_%H%M%S", style="{")
34
+ )
35
+ logger.addHandler(handler)
36
+ logger.propagate = False
37
+
38
+ # Conversion factors for frequency units, i.e. 2^-34 <-> 1e-10
39
+ FCORR_DTOB = (1 << 34) / 10_000_000_000
40
+ FCORR_BTOD = 1 / FCORR_DTOB
41
+
42
+ # Container to hold previously servoed time differences
43
+ DT_HISTORY = []
44
+
45
+
46
+ def parse(line, format=None):
47
+ """Reads costream stdout and returns epoch and servoed time.
48
+
49
+ The returned time is in units of ns. If 'format' is not supplied, 'line' is
50
+ expected to be of the form '<EPOCH:str> <DT:float>\n', rather than a costream
51
+ format.
52
+
53
+ Default format (space-delimited):
54
+ bead38d4 -77845
55
+
56
+ costream V=4 format (space-delimited):
57
+ epoch: bead378b, 2-evnts: 86180, 4-evnts: 2997, new bw4: 7, ft: 346, acc: 2876, true: 2997, 1-events: 86324
58
+
59
+ costream V=5 format (tab-delimited):
60
+ Epoch Raw Sent Cmpr Track Acc Coinc Singles (headers, not in output)
61
+ bead38d4 116271 4144 7 -77845 3897 4144 111068
62
+ """
63
+ if format is None:
64
+ epoch, dt = line.strip().split(" ")
65
+ dt = float(dt)
66
+ elif format == 5:
67
+ tokens = line.split("\t")
68
+ epoch = tokens[0]
69
+ dt = int(tokens[4]) / 8 # convert 1/8ns -> 1ns
70
+ elif format == 4:
71
+ tokens = line.split(" ")
72
+ epoch = tokens[1][:-1]
73
+ dt = int(tokens[10][:-1]) / 8
74
+ else:
75
+ raise ValueError(f"Unrecognised costream format: '{format}'")
76
+ logger.debug("Successfully parsed epoch = %s, dt = %.3f ns", epoch, dt)
77
+ return epoch, dt
78
+
79
+
80
+ def evaluate_freqshift(epoch, dt, ignore=5, average=3, separation=12, cap=100e-9):
81
+ """Returns desired clock skew correction, in absolute units.
82
+
83
+ This function accumulates the dt values, and performs some degree of
84
+ averaging (low-pass) to smoothen the frequency correction adjustment.
85
+
86
+ Example:
87
+ Suppose the dt history contains the set of time differences (in ns),
88
+ with parameters (ignore=5, average=3, separation=12, cap=10e-9):
89
+
90
+ dt_history = (
91
+ { 43.250, 99.250, 148.625, 176.500, 197.375,} # ignored
92
+ {220.250, 229.750, 247.750,} 255.125, 268.000, # initial sample
93
+ 271.875, 277.625, 281.625, 278.125, 282.250,
94
+ 292.625, 300.000, {304.000, 315.000, 314.125,} # final sample
95
+ )
96
+
97
+ The averaged servoed timediff in the initial and final samples are
98
+ 232.58333 and 311.04166 ns, which in span of 12 epochs corresponds to
99
+ a frequency drift of 12.178 ppb. The compensation frequency is thus
100
+ -12.2 ppb, to the nearest ppb, and then capped to -10.0 ppb.
101
+
102
+ Note:
103
+ Assuming a bimodal clock skew, the algorithm used should not be a
104
+ sliding mean, since the coincidence matching requires an accurate
105
+ frequency compensation value. We instead collect a set of samples,
106
+ then calculate the frequency difference.
107
+
108
+ Race condition may be possible - no guarantees on the continuity
109
+ of epochs. This is resolved with an epoch continuity check at every
110
+ function call.
111
+
112
+ This function was ported over from QKDServer.S15qkd.readevents[1].
113
+
114
+ References:
115
+ [1]: <https://github.com/s-fifteen-instruments/QKDServer/blob/master/S15qkd/readevents.py>
116
+ """
117
+ # Verify epochs are contiguous
118
+ # Needed because costream may terminate prematurely, and no calls to
119
+ # flush the dt history is made, resulting in large time gaps.
120
+ if len(DT_HISTORY) > 0:
121
+ # DT_HISTORY format: [(epoch:str, dt:float),...]
122
+ prev_epoch = DT_HISTORY[-1][0]
123
+ if eparser.epoch2int(prev_epoch) + 1 != eparser.epoch2int(epoch):
124
+ DT_HISTORY.clear()
125
+
126
+ # Collect epochs
127
+ DT_HISTORY.append((epoch, dt))
128
+ required = ignore + average + separation
129
+ if len(DT_HISTORY) < required:
130
+ return
131
+
132
+ # Ignore first few epochs to allow freqcorr to propagate
133
+ history = DT_HISTORY[ignore:required] # make a copy
134
+ epochs, dts = list(zip(*history))
135
+ logger.debug("Active frequency correction triggered, measurements: %s", dts)
136
+
137
+ # Calculate averaged frequency difference
138
+ dt_early = np.mean(dts[:average])
139
+ dt_late = np.mean(dts[-average:])
140
+ dt_change = (dt_late - dt_early) * 1e-9 # convert to seconds
141
+ df = dt_change / (separation * EPOCH_DURATION)
142
+ df_toapply = 1 / (1 + df) - 1 # note: equal to -df ~ 1e-10 if df**2 < 0.5e-10
143
+ DT_HISTORY.clear() # restart collection
144
+
145
+ # Cap correction if positive value supplied
146
+ df_applied = df_toapply
147
+ if cap > 0:
148
+ df_applied = max(-cap, min(cap, df_toapply))
149
+
150
+ logger.info(
151
+ "Correction: %5.1f ppb (%5.1f ppb @ %s)",
152
+ df_applied * 1e9,
153
+ df_toapply * 1e9,
154
+ epochs[-1],
155
+ )
156
+ return df_applied
157
+
158
+
159
+ def main():
160
+ script_name = Path(sys.argv[0]).name
161
+ parser = configargparse.ArgumentParser(
162
+ add_config_file_help=False,
163
+ default_config_files=[f"{script_name}.default.conf"],
164
+ description=parse_docstring_description(__doc__),
165
+ formatter_class=ArgparseCustomFormatter,
166
+ add_help=False,
167
+ )
168
+
169
+ # fmt: off
170
+ # Boilerplate
171
+ pgroup_config = parser.add_argument_group("display/configuration")
172
+ pgroup_config.add_argument(
173
+ "-h", "--help", action="store_true",
174
+ help="Show this help message and exit")
175
+ pgroup_config.add_argument(
176
+ "-v", "--verbosity", action="count", default=0,
177
+ help="Specify debug verbosity, e.g. -vv for more verbosity")
178
+ pgroup_config.add_argument(
179
+ "-L", "--logging", metavar="",
180
+ help="Log to file, if specified. Log level follows verbosity.")
181
+ pgroup_config.add_argument(
182
+ "--config", metavar="file", is_config_file_arg=True,
183
+ help="Path to configuration file")
184
+ pgroup_config.add_argument(
185
+ "--save", metavar="", is_write_out_config_file_arg=True,
186
+ help="Path to configuration file for saving, then immediately exit")
187
+
188
+ # freqcalc parameters
189
+ pgroup = parser.add_argument_group("freqservo parameters")
190
+ pgroup.add_argument(
191
+ "-i", metavar="ignore", type=int, default=5,
192
+ help="Ignore initial epochs during calculation (default: %(default)d)")
193
+ pgroup.add_argument(
194
+ "-a", metavar="average", type=int, default=3,
195
+ help="Number of epochs to average servoed time over (default: %(default)d)")
196
+ pgroup.add_argument(
197
+ "-s", metavar="separation", type=int, default=12,
198
+ help="Separation width for frequency calculation, epoch units (default: %(default)d)")
199
+ pgroup.add_argument(
200
+ "-c", metavar="cap", type=float, default=100e-9,
201
+ help="Correction limit/cap per frequency update, disable with 0 (default: 100e-9)")
202
+
203
+ # costream parameters
204
+ pgroup = parser.add_argument_group("costream parameters")
205
+ pgroup.add_argument(
206
+ "-n", metavar="logfile5",
207
+ help="Optional path to pipe of costream/freq outputs (default: stdin)")
208
+ pgroup.add_argument(
209
+ "-V", metavar="level", type=int, choices=(4,5),
210
+ help="Output format of costream (default: '<epoch:str> <dt:float_ns>\\n')")
211
+ pgroup.add_argument(
212
+ "-e", action="store_true",
213
+ help="Echo costream output back into stderr")
214
+
215
+ # freqcd parameters
216
+ pgroup = parser.add_argument_group("freqcd parameters")
217
+ pgroup.add_argument(
218
+ "-F", metavar="freqfilename",
219
+ help="Path to freqcd frequency correction pipe (default: stdout)")
220
+ pgroup.add_argument(
221
+ "-f", metavar="freqcorr", type=int, default=0,
222
+ help="freqcd initial frequency offset, used if '-u' not supplied (default: %(default)d)")
223
+ pgroup.add_argument(
224
+ "-d", action="store_true",
225
+ help="freqcd in 'decimal mode' (default: False)")
226
+ pgroup.add_argument(
227
+ "-u", action="store_true",
228
+ help="freqcd in 'update mode' (default: False)")
229
+ # fmt: on
230
+
231
+ # Parse arguments
232
+ args = parser.parse_args()
233
+
234
+ # Check whether options have been supplied, and print help otherwise
235
+ if args.help:
236
+ parser.print_help(sys.stderr)
237
+ sys.exit(1)
238
+
239
+ # Set logging level and log arguments
240
+ if args.logging is not None:
241
+ handler = logging.FileHandler(filename=args.logging, mode="w")
242
+ handler.setFormatter(
243
+ logging.Formatter(
244
+ fmt=_LOGGING_FMT,
245
+ datefmt="%Y%m%d_%H%M%S",
246
+ style="{",
247
+ )
248
+ )
249
+ logger.addHandler(handler)
250
+
251
+ # Set logging level
252
+ levels = [logging.WARNING, logging.INFO, logging.DEBUG]
253
+ verbosity = min(args.verbosity, len(levels) - 1)
254
+ logger.setLevel(levels[verbosity])
255
+ logger.debug("%s", args)
256
+
257
+ # Check arguments
258
+ assert args.i > 0
259
+ assert args.a > 0
260
+ assert args.s > 0
261
+
262
+ # Open relevant I/O pipes
263
+ cin = sys.stdin
264
+ if args.n is not None:
265
+ cin = open(args.n, "r")
266
+
267
+ cout = sys.stdout
268
+ if args.F is not None:
269
+ cout = open(args.F, "w")
270
+
271
+ # Mainloop
272
+ try:
273
+ # Set initial frequency difference
274
+ df = args.f
275
+ if not args.d:
276
+ df = args.f * FCORR_BTOD # convert 2^-34 -> 0.1ppb
277
+ df = df * 1e-10 # convert 0.1ppb -> 1
278
+
279
+ # Restarts read if mainloop terminates, e.g. no data on read
280
+ while True:
281
+ line = cin.readline()
282
+ if line == "":
283
+ logger.debug("No new data, waiting...")
284
+ time.sleep(0.5)
285
+ continue
286
+
287
+ # Derive frequency shift from costream
288
+ epoch, dt = parse(line, format=args.V)
289
+ if args.e:
290
+ print(line, end="", file=sys.stderr)
291
+ df1 = evaluate_freqshift(
292
+ epoch,
293
+ dt,
294
+ ignore=args.i,
295
+ average=args.a,
296
+ separation=args.s,
297
+ cap=args.c,
298
+ )
299
+ if df1 is None:
300
+ logger.debug("No changes to frequency submitted.")
301
+ continue
302
+
303
+ # If 'update mode' is enabled on freqcd, we simply pass the correction
304
+ # directly. Otherwise, we manually calculate the new absolute frequency.
305
+ # Note: This is effectively the inverse of freqcd 'update mode'.
306
+ if args.u:
307
+ df = df1
308
+ else:
309
+ df = (1 + df) * (1 + df1) - 1
310
+
311
+ # Convert and write new frequency
312
+ _df = df * 1e10 # convert 1 -> 0.1ppb
313
+ if not args.d:
314
+ _df = _df * FCORR_DTOB # convert 0.1ppb -> 2^-34
315
+ _df = round(_df)
316
+ logger.debug("Wrote '%d' to output", _df)
317
+ cout.write(f"{_df}\n")
318
+
319
+ # Alternatively, use 'open(pipe, "wb", 0)'
320
+ cout.flush() # necessary to avoid trapping df in buffer
321
+
322
+ except KeyboardInterrupt:
323
+ logger.debug("User interrupted.")
324
+
325
+ finally:
326
+ if cin is not sys.stdin:
327
+ cin.close()
328
+ if cout is not sys.stdout:
329
+ cout.close()
330
+
331
+
332
+ if __name__ == "__main__":
333
+ main()
fpfind/lib/_logging.py ADDED
@@ -0,0 +1,177 @@
1
+ import logging
2
+ import sys
3
+ import traceback
4
+ from types import SimpleNamespace
5
+
6
+
7
+ class LoggingOverrideFormatter(logging.Formatter):
8
+ """Supports injection of overrides during logging.
9
+
10
+ The following attributes are injectable: '_funcname', '_filename',
11
+ '_lineno', 'details'. The first three are for higher-level stack trace
12
+ information, adapted from [1].
13
+
14
+ Note that multiline logging is generally discouraged. For legacy and
15
+ convenience reasons, multiline logging is enabled by passing
16
+ 'human_readable=True' to the Formatter, then furnishing each logger call
17
+ with the 'details' argument, either as a list or a dict. This relies on
18
+ the presence of the '| ' delimiter to separate debugging information from
19
+ text. This delimiter can be redefined.
20
+
21
+ Examples:
22
+
23
+ # Usual logging setup
24
+ >>> logger = logging.getLogger(__name__)
25
+ >>> handler = logging.StreamHandler(stream=sys.stderr)
26
+ >>> handler.setFormatter(LoggingOverrideFormatter())
27
+ >>> logger.addHandler(handler)
28
+
29
+ # Injection of stack trace information
30
+ >>> caller = inspect.getframeinfo(inspect.stack()[1][0])
31
+ >>> extras = {
32
+ ... "_funcname": f"[{f.__name__}]",
33
+ ... "_filename": os.path.basename(caller.filename),
34
+ ... "_lineno": caller.lineno,
35
+ ... }
36
+ >>> logger.warning("callme", stacklevel=2, extra=extras)
37
+
38
+ # Append details (machine-parseable format)
39
+ >>> handler.setFormatter(
40
+ ... LoggingOverrideFormatter(
41
+ ... fmt="{asctime}\t{levelname:<7s}\t| {message}", style="{",
42
+ ... )
43
+ ... )
44
+ >>> logger.warning(
45
+ ... "call!", extras={"details": {
46
+ ... "value1": 2,
47
+ ... }
48
+ ... )
49
+ 2024-01-23 07:59:24,743 WARNING | call! {"value1": 2}
50
+
51
+ # Append details (human-readable format)
52
+ >>> handler.setFormatter(
53
+ ... LoggingOverrideFormatter(
54
+ ... fmt="{asctime}\t{levelname:<7s}\t| {message}", style="{",
55
+ ... human_readable=True, delimiter="| ",
56
+ ... )
57
+ ... )
58
+ >>> logger.warning(
59
+ ... "call!", extras={"details": [
60
+ ... "value1 is 2ns",
61
+ ... "value2 has been read",
62
+ ... ]
63
+ ... )
64
+ 2024-01-23 07:59:24,743 WARNING | call!
65
+ | value1 is 2ns
66
+ | value2 has been read
67
+
68
+ References:
69
+ [1]: <https://stackoverflow.com/a/71228329>
70
+ """
71
+
72
+ def __init__(self, *args, human_readable=False, delimiter="| ", **kwargs):
73
+ # For multiline logging
74
+ self.human_readable = human_readable
75
+ self.delim = delimiter
76
+ super().__init__(*args, **kwargs)
77
+
78
+ def format(self, record):
79
+ # Override attributes with debugging-relevant information
80
+ if hasattr(record, "_funcname"):
81
+ record.funcName = record._funcname # type: ignore
82
+ if hasattr(record, "_filename"):
83
+ record.filename = record._filename # type: ignore
84
+ if hasattr(record, "_lineno"):
85
+ record.lineno = record._lineno # type: ignore
86
+ message = super().format(record)
87
+
88
+ # Append additional debugging info
89
+ details = getattr(record, "details", None)
90
+ if details is None:
91
+ pass # ignore
92
+ elif not self.human_readable:
93
+ message = f"{message}\t{details}" # append details to the back
94
+ else:
95
+ # Enable multiline logging for humans to read
96
+ if isinstance(details, dict): # parse into array
97
+ details = [f"{k}: {v}" for k, v in details.items()]
98
+ if isinstance(details, (list, tuple)) and len(details) > 0:
99
+ pre, _, text = message.partition(self.delim)
100
+ pad = " " * (len(text) - len(text.lstrip(" ")))
101
+ # preserve tabs, alternatively use 'str.expandtabs'
102
+ _pre = "".join([c if c.isspace() else " " for c in pre])
103
+ text = text.lstrip(" ")
104
+
105
+ # Collect all messages before concatenating
106
+ messages = [message]
107
+ if text == "": # replace first line if empty
108
+ text, details = details[0], details[1:]
109
+ messages = [f"{pre}{self.delim}{pad} {text}"]
110
+ messages.extend(
111
+ [f"{_pre}{self.delim}{pad} {text}" for text in details]
112
+ )
113
+ message = "\n".join(messages)
114
+
115
+ return message
116
+
117
+
118
+ def get_logger(name):
119
+ logger = logging.getLogger(name)
120
+ logger.setLevel(logging.WARNING) # default logging level
121
+ if not logger.handlers:
122
+ handler = logging.StreamHandler(stream=sys.stderr)
123
+ handler.setFormatter(DEFAULT_FORMATTER)
124
+ logger.addHandler(handler)
125
+ logger.propagate = False
126
+
127
+ _indentation = 0
128
+
129
+ def log(n=0):
130
+ nonlocal _indentation
131
+ _indentation = n
132
+ return namespace
133
+
134
+ def create_logfunc(f):
135
+ def logfunc(message, *details):
136
+ nonlocal _indentation
137
+ msg = " " * _indentation + message
138
+ caller = traceback.extract_stack(limit=2)[0]
139
+ extras = {
140
+ "_funcname": caller.name,
141
+ "_filename": caller.filename,
142
+ "_lineno": caller.lineno,
143
+ "details": details,
144
+ }
145
+ f(msg, extra=extras)
146
+ _indentation = 0
147
+
148
+ return logfunc
149
+
150
+ namespace = SimpleNamespace(
151
+ debug=create_logfunc(logger.debug),
152
+ info=create_logfunc(logger.info),
153
+ warning=create_logfunc(logger.warning),
154
+ error=create_logfunc(logger.error),
155
+ )
156
+
157
+ return logger, log
158
+
159
+
160
+ def set_logfile(logger, path):
161
+ handler = logging.FileHandler(filename=path, mode="w")
162
+ handler.setFormatter(DEFAULT_FORMATTER)
163
+ logger.addHandler(handler)
164
+
165
+
166
+ def set_verbosity(logger, verbosity):
167
+ levels = [logging.WARNING, logging.INFO, logging.DEBUG]
168
+ verbosity = min(verbosity, len(levels) - 1)
169
+ logger.setLevel(levels[verbosity])
170
+
171
+
172
+ DEFAULT_FORMATTER = LoggingOverrideFormatter(
173
+ fmt="{asctime}\t{levelname:<7s}\t{funcName}:{lineno}\t| {message}",
174
+ datefmt="%Y%m%d_%H%M%S",
175
+ style="{",
176
+ human_readable=True,
177
+ )
@@ -0,0 +1,98 @@
1
+ import enum
2
+ import warnings
3
+
4
+ import numpy as np
5
+
6
+
7
+ class TSRES(enum.Enum):
8
+ """Stores timestamp resolution information.
9
+
10
+ Values assigned correspond to the number of units within a
11
+ span of 1 nanosecond.
12
+ """
13
+
14
+ NS2 = 0.5 # S-Fifteen TDC1 timestamp
15
+ NS1 = 1
16
+ PS125 = 8 # CQT Red timestamp
17
+ PS4 = 256 # S-Fifteen TDC2 timestamp
18
+
19
+
20
+ class FrequencyCompensation(enum.Enum):
21
+ FORCE = enum.auto() # never disable frequency compensation
22
+ ENABLE = enum.auto() # disables when no frequency detected
23
+ DISABLE = enum.auto()
24
+
25
+
26
+ EPOCH_LENGTH = 1 << 29 # from filespec
27
+ FCORR_AMAXBITS = -13 # from 'freqcd.c'
28
+ NTP_MAXDELAY_NS = 200e6 # for very *very* asymmetric channels
29
+
30
+ # Derived constants
31
+ EPOCH_DURATION = EPOCH_LENGTH * 1e-9 # in seconds
32
+ MAX_FCORR = 2**FCORR_AMAXBITS
33
+
34
+ # Maximum timing resolution [ns] during timing doubling
35
+ MAX_TIMING_RESOLUTION_NS = 1e5
36
+
37
+ # Compilations of numpy that do not include support for 128-bit floats will not
38
+ # expose 'np.float128'. We map such instances directly into a 64-bit float instead.
39
+ # Note that some variants implicitly map 'np.float128' to 'np.float64' as well.
40
+ #
41
+ # The availability of this alias depends on the available build flags for numpy,
42
+ # and is platform-dependent. This is usually extended-precision (80-bits) padded
43
+ # to 128-bits for efficiency.
44
+ NP_PRECISEFLOAT = np.float64
45
+ if hasattr(np, "float128"):
46
+ NP_PRECISEFLOAT = np.float128
47
+ else:
48
+ warnings.warn(
49
+ "Extended-precision floats unsupported in current numpy build on this "
50
+ "platform: falling back to 64-bit floats instead.\n\n"
51
+ "Hint: To avoid precision loss when using time taggers with <125ps "
52
+ "precision, read timestamps as 64-bit integers instead, e.g. "
53
+ "'read_a1(..., fractional=False)'."
54
+ )
55
+
56
+
57
+ class PeakFindingFailed(ValueError):
58
+ def __init__(
59
+ self,
60
+ message,
61
+ significance=None,
62
+ resolution=None,
63
+ dt1=None,
64
+ dt2=None,
65
+ dt=None,
66
+ df=None,
67
+ ):
68
+ self.message = message
69
+ self.s = significance
70
+ self.r = resolution
71
+ self.dt1 = dt1
72
+ self.dt2 = dt2
73
+ self.dt = dt
74
+ self.df = df
75
+ super().__init__(message)
76
+
77
+ def __str__(self):
78
+ text = self.message
79
+ suppl = []
80
+ if self.s is not None:
81
+ suppl.append(f"S={self.s:8.3f}")
82
+ if self.r is not None:
83
+ suppl.append(f"r={self.r:5.0f}")
84
+ if self.dt1 is not None:
85
+ suppl.append(f"dt1={self.dt1:11.0f}")
86
+ if self.dt2 is not None:
87
+ suppl.append(f"dt2={self.dt2:11.0f}")
88
+ if self.dt is not None:
89
+ suppl.append(f"dt={self.dt:11.0f}")
90
+ if self.df is not None:
91
+ suppl.append(f"df={self.df * 1e6:.4f}ppm")
92
+ if suppl:
93
+ text = f"{text} ({', '.join(suppl)})"
94
+ return text
95
+
96
+
97
+ class PeakFindingTerminated(RuntimeError):
98
+ pass