pueoAnalysisTools
Loading...
Searching...
No Matches
calibration.py
Go to the documentation of this file.
1r"""!
2@file calibration.py
3@brief `main()` shows how to
4 [minimize()](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html)
5 the [objective function()](#calibration.objective_function) for antenna phase center
6 calibration.
7"""
8import ROOT
9import re
10import numpy as np
11import polars as pl
12from pathlib import Path
13from initialise import load_pueoEvent_Dataset
14
19
20def generate_antenna_phase_center_pairs_with_placeholders(qrh_dot_dat: Path, offset_inSIM:Path | None = None, ant_type='MI') -> pl.DataFrame:
21 r"""!Creates [antenna pairs](#antenna_pairs.generate_MI_antenna_pairs) with placeholder
22 integers as antenna phase centers.
23@ingroup CALPC
24@param[in] qrh_dot_dat Path to `qrh.dat`
25@retval nominal_phase_center_pairs
26
27* Output schema:
28```
29$ A1_AntNum <enum>
30$ A1_Ring <u8>
31$ A1_Rho (placeholder) <u16>
32$ A1_Phi (placeholder) <u16>
33$ A1_Z (placeholder) <u16>
34$ A1_Rho <f64>
35$ A1_Phi <f64>
36$ A1_Z <f64>
37$ A2_AntNum <enum>
38$ A2_Ring <u8>
39$ A2_Rho (placeholder) <u16>
40$ A2_Phi (placeholder) <u16>
41$ A2_Z (placeholder) <u16>
42$ A2_Rho <f64>
43$ A2_Phi <f64>
44$ A2_Z <f64>
45```
46* Details:
47 -# Calls #antenna_attributes.read_MI_antenna_geometry() to read `qrh.dat` to obtain
48 antenna face center positions
49 -# Calls #antenna_attributes.get_MI_nominal_phase_center() to convert face centers to
50 nominal phase centers
51 -# Adds three columns to store placeholder values of the phase centers; these integer placeholders
52 serve as dictionary keys.
53```
54shape: (96, 9)
55┌────────┬────────┬──────┬────────────────┬────────────────┬───────────────┬──────────┬───────────┬───────┐
56│ AntNum ┆ AntIdx ┆ Ring ┆ Rho ┆ Phi ┆ Z ┆ Rho ┆ Phi ┆ Z │
57│ --- ┆ --- ┆ --- ┆ (placeholder) ┆ (placeholder) ┆ (placeholder) ┆ --- ┆ --- ┆ --- │
58│ enum ┆ u32 ┆ u8 ┆ --- ┆ --- ┆ --- ┆ f64 ┆ f64 ┆ f64 │
59│ ┆ ┆ ┆ u16 ┆ u16 ┆ u16 ┆ ┆ ┆ │
60╞════════╪════════╪══════╪════════════════╪════════════════╪═══════════════╪══════════╪═══════════╪═══════╡
61│ 101 ┆ 0 ┆ 1 ┆ 0 ┆ 96 ┆ 192 ┆ 1.188 ┆ 0.0 ┆ 5.114 │
62│ 201 ┆ 1 ┆ 2 ┆ 1 ┆ 97 ┆ 193 ┆ 2.296 ┆ 0.0 ┆ 1.457 │
63│ 301 ┆ 2 ┆ 3 ┆ 2 ┆ 98 ┆ 194 ┆ 2.296 ┆ 0.0 ┆ 0.728 │
64│ 401 ┆ 3 ┆ 4 ┆ 3 ┆ 99 ┆ 195 ┆ 2.296 ┆ 0.0 ┆ 0.0 │
65│ 102 ┆ 4 ┆ 1 ┆ 4 ┆ 100 ┆ 196 ┆ 1.076821 ┆ 0.261538 ┆ 4.476 │
66│ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … │
67│ 423 ┆ 91 ┆ 4 ┆ 91 ┆ 187 ┆ 283 ┆ 2.295825 ┆ -0.523638 ┆ 0.0 │
68│ 124 ┆ 92 ┆ 1 ┆ 92 ┆ 188 ┆ 284 ┆ 1.07708 ┆ -0.26224 ┆ 4.476 │
69│ 224 ┆ 93 ┆ 2 ┆ 93 ┆ 189 ┆ 285 ┆ 2.295502 ┆ -0.261893 ┆ 1.457 │
70│ 324 ┆ 94 ┆ 3 ┆ 94 ┆ 190 ┆ 286 ┆ 2.295502 ┆ -0.261893 ┆ 0.728 │
71│ 424 ┆ 95 ┆ 4 ┆ 95 ┆ 191 ┆ 287 ┆ 2.295502 ┆ -0.261893 ┆ 0.0 │
72└────────┴────────┴──────┴────────────────┴────────────────┴───────────────┴──────────┴───────────┴───────┘
73```
74 -# Calls #antenna_pairs.generate_MI_antenna_pairs with the above dataframe as the input.
75 """
76 from antenna_pairs import generate_MI_antenna_pairs, generate_LF_antenna_pairs
77 from antenna_attributes import read_antenna_geometry, get_nominal_phase_center
78
79 fc: pl.DataFrame = read_antenna_geometry(qrh_dot_dat=qrh_dot_dat, offset_inSIM=offset_inSIM, ant_type=ant_type)
80 nominal_and_placeholders: pl.DataFrame = (
81 get_nominal_phase_center(face_centers=fc, coordinates="cylindrical", ant_type=ant_type)
82 .with_columns(
83 pl.arange(pl.len()).alias("Rho (placeholder)").cast(pl.UInt16),
84 pl.arange(pl.len(), pl.len() * 2).alias("Phi (placeholder)").cast(pl.UInt16),
85 pl.arange(pl.len() * 2, pl.len() * 3).alias("Z (placeholder)").cast(pl.UInt16),
86 pl.arange(pl.len() * 3, pl.len() * 4).alias("CableDelay (placeholder)").cast(pl.UInt16),
87 pl.lit(0.0).alias("CableDelay"), # fills whole column with 0.0
88 )
89 .select(
90 "AntNum", "AntIdx", "Ring", "Rho (placeholder)", "Phi (placeholder)", "Z (placeholder)", "CableDelay (placeholder)",
91 pl.col("Rho[m]").alias("Rho"), pl.col("Phi[rad]").alias("Phi"), pl.col("Z[m]").alias("Z"), "CableDelay"
92 )
93 )
94 if ant_type=='MI':
95 nominal_phase_center_pairs: pl.DataFrame = (
96 generate_MI_antenna_pairs(antennas=nominal_and_placeholders)
97 .select(pl.all().exclude(r"^A[12]_AntIdx$"))
98 )
99
100 elif ant_type=='LF':
101 nominal_phase_center_pairs: pl.DataFrame = (
102 generate_LF_antenna_pairs(antennas=nominal_and_placeholders)
103 .select(pl.all().exclude(r"^A[12]_AntIdx$"))
104 )
105 else:
106 print("Ant type is not supported!")
107
108 return nominal_phase_center_pairs
109
110
111def __get_all_run_numbers(pueo_data: Path) -> [int]:
112 """! A temporary helper function of #load_dataset_and_measure_time_delays()
113 @ingroup CALPC
114 @param[in] pueo_data See the parameter of #initialise.load_pueoEvent_Dataset
115
116 * This function retrieves the run numbers (negative sign allowed) of the `run/` subdirectories
117 in `pueo_data`
118
119 * The runs are returned as a list of integers.
120
121 * The function is here only for testing purposes,
122 as it makes little sense to use every single run for antenna calibration.
123 """
124
125 # regular expression, allowing positive and negative signs in run numbers
126 run_number_pattern_ = re.compile(r'^run([+\-]?\d+)$')
127 return [int(run_number_pattern_.match(run_directories_.name).group(1))
128 for run_directories_ in pueo_data.rglob("run*")]
129
130
131def load_dataset_and_measure_time_delays(dataset: ROOT.pueo.Dataset,
132 phase_center_pairs: pl.DataFrame, pulser_station: str='taylor_dome', MC=False, AntType='MI', IcefinalFileDir:str='', singlerun:int='', event_list:list = None,UPSAMPLE=1) -> pl.DataFrame:
133 r"""! Uses [SciPy's correlate()](https://docs.scipy.org/doc//scipy-1.16.2/reference/generated/scipy.signal.correlate.html)
134 to "measure" the delays of [waveforms](#waveform_plots.load_waveforms) between antenna pairs
135 @ingroup CALPC
136 @param[in] dataset The output of #initialise.load_pueoEvent_Dataset
137 @param[in] phase_center_pairs The output of #generate_antenna_phase_center_pairs_with_placeholders
138 @param[in] pulser_station For data, which pulser station the data is from, taylor_dome, ldb, s200
139 @param[in] MC This is MC or not
140 @param[in] AntType This is for MI or LF
141 @param[in] IcefinalFileDir Temporary input, only for the LF + MC combination, the dataformat is not ready, so we read waveform from root file diretly. Path to the IceFinal Root file
142 @param[in] singlerun If specified, only use one run instead of all runs in the data directory
143 @param[in] UPSAMPPLE Upsample factor
144 @retval pulser_directions_and_measured_time_delays
145
146 * The input `dataset` should have been initialized with a (valid) run number,
147 but that is not the only run that gets loaded.
148 For now, [all runs](#__get_all_run_numbers) are used.
149 * Output schema:
150```
151$ measured time delays (ns) <f64>
152$ max correlation <f64>
153$ A1_AntNum <enum>
154$ A1_Ring <u8>
155$ A1_Rho (placeholder) <u16>
156$ A1_Phi (placeholder) <u16>
157$ A1_Z (placeholder) <u16>
158$ A1_Rho <f64>
159$ A1_Phi <f64>
160$ A1_Z <f64>
161$ A2_AntNum <enum>
162$ A2_Ring <u8>
163$ A2_Rho (placeholder) <u16>
164$ A2_Phi (placeholder) <u16>
165$ A2_Z (placeholder) <u16>
166$ A2_Rho <f64>
167$ A2_Phi <f64>
168$ A2_Z <f64>
169$ incoming pulse direction (Phi Theta) <array[f64, 2]>
170```
171 * The `dataset` is also used to load the directions of the incoming pulses
172 (\f$^\circ\phi\f$ and \f$^\circ\theta\f$ in payload coordinates).
173 * `measured time delays (ns)` is computed by the helper function
174 #_scipy_cross_correlate_all_pairs_for_one_event().
175 """
176
177 # Note: eventually will have to get pulser direction somehow, instead of from the truth file
178 from locate_signal import _get_true_direction, _get_true_pulser_direction, _get_true_pulser_direction_tmp
179 from waveform_plots import load_waveforms, upsample_waveforms, load_waveforms_from_root
180
181 # initialize an empty dataframe that will be in-place updated
182 pulser_directions_and_measured_time_delays = pl.DataFrame()
183
184 # Note: probably doesn't make sense to use all runs, for now it's okay
185 if MC==True:
186 all_runs: [int] = __get_all_run_numbers(
187 pueo_data=Path(
188 dataset.getDataDir(ROOT.pueo.Dataset.PUEO_MC_DATA)
189 )
190 )
191 else:
192 all_runs: [int] = __get_all_run_numbers(
193 pueo_data=Path(
194 dataset.getDataDir(ROOT.pueo.Dataset.PUEO_ROOT_DATA)
195 )
196 )
197 if singlerun!='':
198 all_runs=[singlerun]
199 for run in all_runs:
200 # load the run
201 if MC==True:
202 dataset.loadRun(run, ROOT.pueo.Dataset.PUEO_MC_DATA)
203 else:
204 dataset.loadRun(run, ROOT.pueo.Dataset.PUEO_ROOT_DATA)
205
206 if MC==True:
207 for i in range(dataset.N()): # loop through all events in the run, Todo: for data, only loop through an event number list, yuchieh's check point
208 dataset.getEntry(i)
209 if AntType=='MI':
210
211 wf: pl.DataFrame = (
212 load_waveforms(run, event=i, anttype=AntType, upsample_factor=UPSAMPLE)
213 .filter(pl.col("Pol") == "V")
214 .select("AntNum", "Pol", "waveforms (volts)", "step size (nanoseconds)")
215 )
216
217 elif AntType=='LF':
218 IcefinalFile=IcefinalFileDir+f'run{run}/IceFinal_{run}_allTree.root'
219 wf: pl.DataFrame = (
220 load_waveforms_from_root(IcefinalFile, ev=i)
221 .filter(pl.col("Pol") == "V")
222 .select("AntNum", "Pol", "waveforms (volts)", "step size (nanoseconds)")
223 )
224 wf: pl.DataFrame = upsample_waveforms(waveforms=wf, upsample_factor=UPSAMPLE)
225
226 # measure time delay by cross correlate the waveforms
227 pulser_directions_and_measured_time_delays.vstack(
228 _scipy_cross_correlate_all_pairs_for_one_event(
229 phase_center_pairs=phase_center_pairs,
230 waveforms=wf,
231 ).with_columns(
232 (pl.lit(_get_true_direction(dataset=dataset)) * np.pi / 180)
233 .cast(pl.Array(pl.Float64, 2))
234 .alias("incoming pulse direction (Phi Theta)")
235 ),
236 in_place=True
237 )
238
239 elif MC==False: # for data
240 for eventnum in event_list: # loop through a list of events in this run
241 dataset.getEvent(eventnum);
242 wf: pl.DataFrame = (
243 load_waveforms(run, eventNumber=eventnum, anttype=AntType, upsample_factor=UPSAMPLE)
244 .filter(pl.col("Pol") == "V")
245 .select("AntNum", "Pol", "waveforms (volts)", "step size (nanoseconds)")
246 )
247 # for real data position calibration
248 pulser_directions_and_measured_time_delays.vstack(
249 _scipy_cross_correlate_all_pairs_for_one_event(
250 phase_center_pairs=phase_center_pairs,
251 waveforms=wf,
252 ).with_columns(
253 (pl.lit(_get_true_pulser_direction_tmp(dataset=dataset, pulser_station=pulser_station)) * np.pi / 180)
254 .cast(pl.Array(pl.Float64, 2))
255 .alias("incoming pulse direction (Phi Theta)")
256 ),
257 in_place=True
258 )
259
260 return pulser_directions_and_measured_time_delays
261
262
263def _scipy_cross_correlate_all_pairs_for_one_event(phase_center_pairs: pl.DataFrame,
264 waveforms: pl.DataFrame
265 ) -> pl.DataFrame:
266 r"""! A helper function of #load_dataset_and_measure_time_delays()
267 @ingroup CALPC
268 @param[in] phase_center_pairs The output of #generate_antenna_phase_center_pairs_with_placeholders
269 @param[in] waveforms The output of #waveform_plots.load_waveforms
270 @retval measured_time_delays
271
272 * Output schema:
273```
274$ measured time delays (ns) <f64>
275$ max correlation <f64>
276$ A1_AntNum <enum>
277$ A1_Ring <u8>
278$ A1_Rho (placeholder) <u16>
279$ A1_Phi (placeholder) <u16>
280$ A1_Z (placeholder) <u16>
281$ A1_Rho <f64>
282$ A1_Phi <f64>
283$ A1_Z <f64>
284$ A2_AntNum <enum>
285$ A2_Ring <u8>
286$ A2_Rho (placeholder) <u16>
287$ A2_Phi (placeholder) <u16>
288$ A2_Z (placeholder) <u16>
289$ A2_Rho <f64>
290$ A2_Phi <f64>
291$ A2_Z <f64>
292```
293 * Details:
294 -# [Load the waveforms](#waveform_plots.load_waveforms) of both antennas in the antenna pair
295 -# Cross correlate the waveform pair
296 -# Figure out by how much one needs to shift the second waveform against the first
297 in order to achieve the maximum correlation.
298 -# The above is called `measured time delays (ns)` in the output dataframe.
299 -# For more details, see [Explanation](#scipy_corr_expl).
300 """
301 from scipy.signal import correlate, correlation_lags
302
303 # the fact that the waveforms are in the same column guarantees they have the same length,
304 # which is probably an integer multiple of 1024 (if upsampled)
305 signal_length = len(waveforms["waveforms (volts)"][0])
306 CORRLAGS: np.ndarray = correlation_lags(signal_length, signal_length)
307
308 STEP_SIZE = waveforms["step size (nanoseconds)"][0]
309 # For peace of mind, check that all signals were sampled at the same step size :)
310 assert np.mean(waveforms["step size (nanoseconds)"].to_numpy()) - STEP_SIZE < 1e-10
311
312 corr_func = zncc_pairwise_correlation
313
314 measured_time_delays = (
315 phase_center_pairs
316 .join(waveforms, left_on="A1_AntNum", right_on="AntNum")
317 .join(waveforms, left_on=["A2_AntNum", "Pol"], right_on=["AntNum", "Pol"])
318 .with_columns(
319 pl.struct("waveforms (volts)", "waveforms (volts)_right")
320 .map_batches(corr_func).alias("correlation"),
321 )
322 .with_columns(
323 pl.col("correlation").arr.max().alias("max correlation"),
324 pl.col("correlation").arr.arg_max().alias("max correlation idx"),
325 )
326 .with_columns( # Find the time where the maximum correlation occurs and convert to [ns]
327 pl.col("max correlation idx").map_batches(lambda idx: CORRLAGS[idx] * STEP_SIZE)
328 .alias("measured time delays (ns)")
329 )
330 .select(
331 "measured time delays (ns)", "max correlation",
332 r"^A[12]_(?:AntNum|Ring|Rho|Phi|Z|CableDelay)(?: \‍(placeholder\‍))?$",
333 )
334
335 )
336 return measured_time_delays
337
338def zncc_pairwise_correlation(s): # zero-center usefrontlobeized cross-correlation (ZNCC), bounded b/w [-1, 1], Todo: should use the same std across the channels. see how pueoTALON does this.
339 from scipy.signal import correlate
340 return np.array([
341 correlate(
342 (wf1 - np.mean(wf1)) / np.std(wf1),
343 (wf2 - np.mean(wf2)) / np.std(wf2),
344 method='fft'
345 ) / len(wf1)
346 for wf2, wf1 in s.to_numpy()
347 ])
348
349def retrieve_relevant_antennas_from_all_pairs(phase_center_pairs: pl.DataFrame):
350 r"""! Retrieves the (unique) antennas, given a bunch of antenna pairs.
351 @ingroup CALPC
352 @param[in] phase_center_pairs The output of #load_dataset_and_measure_time_delays
353 @retval relevant_antennas
354
355 * This step is not trivial, as explained below.
356 * It is conceivable that not all antennas receive signals clear enough for calibration purposes
357 (resulting in weak correlations).
358 * As such, the weakly correlated antenna pairs should be filtered out.
359 For example,
360```python3
361 pulser_directions_and_pairwise_time_delays = (
362 load_dataset_and_measure_time_delays(dataset=ds, phase_center_pairs=nominal_pairs)
363 .filter(pl.col("max correlation") > 0.8)
364 )
365```
366 * It is possible that some antennas end up getting filtered out.
367 * Sample output (note that only 76 out of 96 MI antennas survived the filter in this case):
368```
369shape: (76, 8)
370┌────────┬──────┬───────────────────┬───────────────────┬─────────────────┬──────────┬───────────┬───────┐
371│ AntNum ┆ Ring ┆ Rho (placeholder) ┆ Phi (placeholder) ┆ Z (placeholder) ┆ Rho ┆ Phi ┆ Z │
372│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
373│ enum ┆ u8 ┆ u16 ┆ u16 ┆ u16 ┆ f64 ┆ f64 ┆ f64 │
374╞════════╪══════╪═══════════════════╪═══════════════════╪═════════════════╪══════════╪═══════════╪═══════╡
375│ 101 ┆ 1 ┆ 0 ┆ 96 ┆ 192 ┆ 1.188 ┆ 0.0 ┆ 5.114 │
376│ 201 ┆ 2 ┆ 1 ┆ 97 ┆ 193 ┆ 2.296 ┆ 0.0 ┆ 1.457 │
377│ 301 ┆ 3 ┆ 2 ┆ 98 ┆ 194 ┆ 2.296 ┆ 0.0 ┆ 0.728 │
378│ 401 ┆ 4 ┆ 3 ┆ 99 ┆ 195 ┆ 2.296 ┆ 0.0 ┆ 0.0 │
379│ 102 ┆ 1 ┆ 4 ┆ 100 ┆ 196 ┆ 1.076821 ┆ 0.261538 ┆ 4.476 │
380│ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … │
381│ 423 ┆ 4 ┆ 91 ┆ 187 ┆ 283 ┆ 2.295825 ┆ -0.523638 ┆ 0.0 │
382│ 124 ┆ 1 ┆ 92 ┆ 188 ┆ 284 ┆ 1.07708 ┆ -0.26224 ┆ 4.476 │
383│ 224 ┆ 2 ┆ 93 ┆ 189 ┆ 285 ┆ 2.295502 ┆ -0.261893 ┆ 1.457 │
384│ 324 ┆ 3 ┆ 94 ┆ 190 ┆ 286 ┆ 2.295502 ┆ -0.261893 ┆ 0.728 │
385│ 424 ┆ 4 ┆ 95 ┆ 191 ┆ 287 ┆ 2.295502 ┆ -0.261893 ┆ 0.0 │
386└────────┴──────┴───────────────────┴───────────────────┴─────────────────┴──────────┴───────────┴───────┘
387```
388 * If we still pass these filtered antennas to the #objective_function,
389 SciPy's minimizer would act as if these antennas are relevant and try to still modify their
390 phase centers during the minization.
391 """
392 a1 = (
393 phase_center_pairs
394 .select(pl.col(r"^A1_(?:AntNum|Ring|Rho|Phi|Z|CableDelay)(?: \‍(placeholder\‍))?$"))
395 .rename(lambda name: re.sub(r"^A1_(.*)$", r"\1", name))
396 )
397 a2 = (
398 phase_center_pairs
399 .select(pl.col(r"^A2_(?:AntNum|Ring|Rho|Phi|Z|CableDelay)(?: \‍(placeholder\‍))?$"))
400 .rename(lambda name: re.sub(r"^A2_(.*)$", r"\1", name))
401 )
402
403 relevant_antennas = (
404 a1.vstack(a2).unique().sort("AntNum")
405 )
406 return relevant_antennas
407
408# Minimization options, l2_loss is default
409def l2_loss(residuals):
410 return np.sum(residuals**2)
411
412def l1_loss(residuals):
413 return np.sum(np.abs(residuals))
414
415def huber_loss(residuals, delta=0.3):
416 abs_r = np.abs(residuals)
417 quadratic = 0.5 * residuals**2
418 linear = delta * (abs_r - 0.5 * delta)
419 return np.sum(np.where(abs_r <= delta, quadratic, linear))
420
421def objective_function(phase_center_guess: pl.Series,
422 phase_center_placeholders: pl.Series,
423 pulser_directions_and_measured_time_delays: pl.DataFrame,
424 axis: str,
425 loss_type: str = "l2",
426 delta: float = 0.3
427 ) -> float:
428 r"""!Computes the expected time delays and compares them with the measured time delays,
429 designed to be run by SciPy's minimizer.
430 @ingroup CALPC
431 @param[in] phase_center_guess The output of #retrieve_relevant_antennas_from_all_pairs
432 @param[in] phase_center_placeholders The output of #retrieve_relevant_antennas_from_all_pairs
433 @param[in] pulser_directions_and_measured_time_delays The output of #load_dataset_and_measure_time_delays
434 @param[in] axis Axis to fit: \f$\rho\f$, \f$\phi\f$, or \f$z\f$
435 @param[in] loss_type The loss function type to minimize, "l2" is sum(residual square), "l1" is sum(abs(residual)), "huber_loss" is a combined of l1 and l2. l2 is default.
436 @param[in] delta paramter if you use "huber_loss"
437 @retval total_difference_bw_expected_time_delay_and_measured_time_delay A scalar
438
439 * In #load_dataset_and_measure_time_delays(), the `measured_time_delay` has already been computed.
440 * The job of this function is to
441 -# Given `phase_center_guess`, replace the placeholder antenna phase centers in
442 `pulser_directions_and_pairwise_time_delays`
443 -# Compute the pairwise antenna displacement vectors (call these \f$\bf{d}\f$),
444 using the updated antenna positions.
445 -# Compute the `expected time delay` based on the dot product between \f$\bf{d}\f$ and
446 `incoming pulse direction (Phi Theta)`,
447 see [this figure](@ref timedelaybydotproduct) for an illustration.
448 ```
449 ┌───────────────────────────┬───────────────────────────┐
450 │ measured time delays (ns) ┆ expected time delays (ns) │
451 │ --- ┆ --- │
452 │ f64 ┆ f64 │
453 ╞═══════════════════════════╪═══════════════════════════╡
454 │ -0.866667 ┆ -0.853384 │
455 │ -0.866667 ┆ -0.853384 │
456 │ -7.466667 ┆ -7.487111 │
457 │ 0.6 ┆ 0.611216 │
458 │ -9.466667 ┆ -9.48138 │
459 │ … ┆ … │
460 │ 8.333333 ┆ 8.33698 │
461 │ 0.2 ┆ 0.15054 │
462 │ -0.866667 ┆ -0.855732 │
463 │ 0.8 ┆ 0.760589 │
464 │ 1.466667 ┆ 1.463433 │
465 └───────────────────────────┴───────────────────────────┘
466 ```
467 -# Quantify the difference between the expectation and the measurement; this is done by
468 summing the difference over all rows above.
469 * SciPy's minimizer tries to minimize the total difference by updating `phase_center_guess`.
470 """
471 from scipy.constants import c as speed_of_light
472
473 phi_p = pl.col("incoming pulse direction (Phi Theta)").arr.get(0)
474 theta_p = pl.col("incoming pulse direction (Phi Theta)").arr.get(1)
475 x_p = phi_p.cos() * theta_p.sin()
476 y_p = phi_p.sin() * theta_p.sin()
477 z_p = theta_p.cos()
478 r_1 = pl.col("A1_Rho")
479 r_2 = pl.col("A2_Rho")
480 z_1 = pl.col("A1_Z")
481 z_2 = pl.col("A2_Z")
482 phi_1 = pl.col("A1_Phi")
483 phi_2 = pl.col("A2_Phi")
484 cabledelay_1 = pl.col("A1_CableDelay")
485 cabledelay_2 = pl.col("A2_CableDelay")
486 A1minusA2_dotProduct_signalDir = (
487 x_p * (r_1 * phi_1.cos() - r_2 * phi_2.cos()) +
488 y_p * (r_1 * phi_1.sin() - r_2 * phi_2.sin()) + z_p * (z_1 - z_2)
489 )
490
491
492 total_difference_bw_expected_time_delay_and_measured_time_delay = (
493 pulser_directions_and_measured_time_delays
494 .with_columns(
495 pl.col(f"A1_{axis} (placeholder)").replace_strict(phase_center_placeholders, phase_center_guess, default=pl.col(f"A1_{axis}")).alias(f"A1_{axis}"),
496 pl.col(f"A2_{axis} (placeholder)").replace_strict(phase_center_placeholders, phase_center_guess, default=pl.col(f"A2_{axis}")).alias(f"A2_{axis}")
497 )
498 .with_columns(
499 (A1minusA2_dotProduct_signalDir / speed_of_light * 1e9 + cabledelay_1-cabledelay_2).alias("expected time delays (ns)")
500 )
501 .with_columns(
502 (pl.col("measured time delays (ns)")
503 - pl.col("expected time delays (ns)")
504 ).alias("residual")
505 )
506 )
507
508 if loss_type == "l2":
509 loss = total_difference_bw_expected_time_delay_and_measured_time_delay.select(
510 (pl.col("residual")**2).sum()
511 ).item()
512 elif loss_type == "l1":
513 loss = total_difference_bw_expected_time_delay_and_measured_time_delay.select(
514 pl.col("residual").abs().sum()
515 ).item()
516 elif loss_type == "huber":
517 loss = total_difference_bw_expected_time_delay_and_measured_time_delay.select(
518 pl.when(pl.col("residual").abs() <= delta)
519 .then(0.5 * pl.col("residual")**2)
520 .otherwise(delta * (pl.col("residual").abs() - 0.5 * delta))
521 .sum()
522 ).item()
523
524 return loss
525
526def compute_time_delay_residuals(
527 phase_center_guess: np.ndarray,
528 phase_center_placeholders: pl.Series,
529 pulser_directions_and_measured_time_delays: pl.DataFrame,
530 axis: str,
531 )->pl.DataFrame:
532 r"""!Computes the time delays and residuals, given a solution. This can be used to track residuals evolution during minimization
533 @param[in] phase_center_guess The assumed solution to fill in placeholder
534 @param[in] phase_center_placeholders The output of #retrieve_relevant_antennas_from_all_pairs
535 @param[in] pulser_directions_and_measured_time_delays The output of #load_dataset_and_measure_time_delays
536 @param[in] axis Axis to fit: \f$\rho\f$, \f$\phi\f$, or \f$z\f$
537 @param[in] loss_type The loss function type to minimize, "l2" is sum(residual square), "l1" is sum(abs(residual)), "huber_loss" is a combined of l1 and l2. l2 is default.
538 @param[in] delta paramter if you use "huber_loss"
539 @retval pulser_directions_and_measured_time_delays with an additional column of pairwise delta t residual
540 """
541 from scipy.constants import c as speed_of_light
542 phi_p = pl.col("incoming pulse direction (Phi Theta)").arr.get(0)
543 theta_p = pl.col("incoming pulse direction (Phi Theta)").arr.get(1)
544
545 x_p = phi_p.cos() * theta_p.sin()
546 y_p = phi_p.sin() * theta_p.sin()
547 z_p = theta_p.cos()
548
549 r_1 = pl.col("A1_Rho")
550 r_2 = pl.col("A2_Rho")
551 z_1 = pl.col("A1_Z")
552 z_2 = pl.col("A2_Z")
553 phi_1 = pl.col("A1_Phi")
554 phi_2 = pl.col("A2_Phi")
555 cabledelay_1 = pl.col("A1_CableDelay")
556 cabledelay_2 = pl.col("A2_CableDelay")
557
558 A1minusA2_dotProduct_signalDir = (
559 x_p * (r_1 * phi_1.cos() - r_2 * phi_2.cos()) +
560 y_p * (r_1 * phi_1.sin() - r_2 * phi_2.sin()) +
561 z_p * (z_1 - z_2)
562 )
563
564 df_resid = (
565 pulser_directions_and_measured_time_delays
566 .with_columns(
567 pl.col(f"A1_{axis} (placeholder)")
568 .replace_strict(phase_center_placeholders, phase_center_guess, default=pl.col(f"A1_{axis}"))
569 .alias(f"A1_{axis}"),
570
571 pl.col(f"A2_{axis} (placeholder)")
572 .replace_strict(phase_center_placeholders, phase_center_guess, default=pl.col(f"A2_{axis}"))
573 .alias(f"A2_{axis}")
574 )
575 .with_columns(
576 (A1minusA2_dotProduct_signalDir / speed_of_light * 1e9 + cabledelay_1 - cabledelay_2)
577 .alias("expected time delays (ns)")
578 )
579 .with_columns(
580 (pl.col("measured time delays (ns)") - pl.col("expected time delays (ns)"))
581 .alias("residual (ns)")
582 )
583 )
584
585 return df_resid
586
587def _plot_calibration_result(axis: str, before: pl.DataFrame, after: np.ndarray, anttype='MI'):
588 """!
589 @param[in] axis Axis to fit: \f$\rho\f$, \f$\phi\f$, or \f$z\f$
590 @param[in] before The output of of #retrieve_relevant_antennas_from_all_pairs
591 @param[in] after The `restul.x` of running `scipy.optimize.minimize` on #objective_function
592 @ingroup CALPC
593 Nothing crazy here!
594
595 ![](example_phase_center_fit_result.png)
596 """
597 import matplotlib.pyplot as plt
598 import matplotlib
599 matplotlib.use("Agg")
600
601 plt.rcParams.update({'font.size': 13})
602 fig1, ax1 = plt.subplots(nrows=4, figsize=(10, 15), tight_layout=True)
603
604 before_and_after = (
605 before.with_columns(
606 pl.Series(after).alias(f"{axis} minimization result")
607 )
608 )
609
610 for i in range(before['Ring'].unique().len()):
611 if anttype=='MI':
612 ring = before_and_after.filter(pl.col("Ring") == (i + 1))
613 elif anttype=='LF':
614 ring = before_and_after.filter(pl.col("Ring") == (i + 5))
615 #ax1[i].scatter(ring['AntNum'], ring[f"{axis}"] * 1e2,
616 # label=f"Simulated geometry")
617 #ax1[i].scatter(ring['AntNum'], ring[f"{axis} minimization result"] * 1e2,
618 # label=f"minimization result")
619 if axis=='Rho' or axis=='Z':
620 ax1[i].scatter(ring['AntNum'], ring[f"{axis} minimization result"] * 1e2 - ring[f"{axis}"] * 1e2,
621 label=f"Minimized - Simulated geometry", s=60)
622 elif axis=='Phi':
623 diff = (ring[f"{axis} minimization result"] - ring[f"{axis}"]) * 180/np.pi
624 diff = (diff + 180) % 360 - 180
625 ax1[i].scatter(ring['AntNum'], diff,
626 label=f"Minimized - Simulated geometry", s=60)
627 elif axis=='CableDelay':
628 ax1[i].scatter(ring['AntNum'], ring[f"{axis} minimization result"] - ring[f"{axis}"],
629 label=f"Minimized - Simulated Cable Delay", s=60)
630
631 x_ticks_to_show = [val for val in sorted(set(ring['AntNum']))]
632 ax1[i].set_xticks(x_ticks_to_show)
633 ax1[i].set_xticklabels([str(int(val)) for val in x_ticks_to_show])
634
635 if anttype=='LF':
636 ax1[i].set_title(f"Ring {i + 5}", fontsize=20)
637 #ax1[i].set_ylim(150,165)
638 else:
639 ax1[i].set_title(f"Ring {i + 1}", fontsize=20)
640 ax1[i].tick_params(axis='x', labelrotation=45, labelright=True, labelsize=20)
641 ax1[i].tick_params(axis='y', labelright=True, labelsize=16)
642 ax1[i].grid(alpha=.3)
643
644 if anttype=='LF':
645 ax1[0].set_xlim(500,525)
646 ax1[1].set_xlim(600,625)
647 ax1[2].set_xlim(700, 725)
648 ax1[3].set_xlim(800,825)
649
650 ax1[0].legend(fontsize=20)
651
652 if axis=='Rho' or axis=='Z':
653 fig1.supylabel(f"$\Delta$ {axis} [cm]", fontsize=20)
654 elif axis=='Phi':
655 fig1.supylabel(f"$\Delta$ {axis} [deg]", fontsize=20)
656 elif axis=='CableDelay':
657 fig1.supylabel(f"$\Delta$ {axis} [ns]", fontsize=20)
658
659
660 fig1.supxlabel('Antenna Label', fontsize=20)
661 fig1.suptitle('Antenna Phase Centers (Payload Coordinates)\n', fontsize=24)
662 fig1.savefig(f"plot/{anttype}_phase_center_fit_result_{axis}_tmp.png")
663
664
665if __name__ == "__main__":
666 import os
667 from scipy.optimize import minimize
668 import matplotlib.pyplot as plt
669 import matplotlib
670 matplotlib.use("Agg")
671 ant_type='LF'
672 MC=False
673 run = 797
674 event_list = [2518]
675
676 # use nominal phase center as the initial guesss of the minimzation
677 if ant_type=='MI':
678 j25: Path = os.environ.get("PUEO_UTIL_INSTALL_DIR") / Path("share/pueo/geometry/jun25/qrh.dat")
679 a25: Path = os.environ.get("PUEO_UTIL_INSTALL_DIR") / Path("share/pueo/geometry/aug25/qrh.dat")
680 j26: Path = os.environ.get("PUEO_UTIL_INSTALL_DIR") / Path("share/pueo/geometry/jan26/qrh.dat")
681 offset_inSIM: Path = os.environ.get("PUEO_UTIL_INSTALL_DIR") / Path("share/pueo/geometry/jun25/phase_center_offset_MI.dat")
682 elif ant_type=='LF':
683 j25: Path = os.environ.get("PUEO_UTIL_INSTALL_DIR") / Path("share/pueo/geometry/jun25/lf_cal.dat")#lf_cal.dat
684 a25: Path = os.environ.get("PUEO_UTIL_INSTALL_DIR") / Path("share/pueo/geometry/aug25/lf.dat")
685 j26: Path = os.environ.get("PUEO_UTIL_INSTALL_DIR") / Path("share/pueo/geometry/jan26/lf.dat")
686 offset_inSIM: Path = os.environ.get("PUEO_UTIL_INSTALL_DIR") / Path("share/pueo/geometry/jan26/phase_center_offset_LF.dat")
687 else:
688 print("Ant type doesn't exist!")
689
690 nominal_pairs = generate_antenna_phase_center_pairs_with_placeholders(qrh_dot_dat=j26, offset_inSIM=offset_inSIM, ant_type=ant_type)
691
692 # start with some run number to initialize the Dataset instance.
693 ds: ROOT.pueo.Dataset = load_pueoEvent_Dataset(run_number = run, MC=MC)
694
695 if ant_type=='LF':
696 Icefinalpath=f'your_path_to_ice_final'
697 else:
698 Icefinalpath=''
699
700 pulser_directions_and_pairwise_time_delays = (
701 load_dataset_and_measure_time_delays(dataset=ds, phase_center_pairs=nominal_pairs, MC=MC, AntType=ant_type, singlerun=run,IcefinalFileDir=Icefinalpath, event_list=event_list)
702 )
703
704 pulser_directions_and_pairwise_time_delays = pulser_directions_and_pairwise_time_delays.filter(pl.col("max correlation")> 0.80) # cut on the correlation value, this is a parameter to tune
705
706 relevant_antennas = (
707 retrieve_relevant_antennas_from_all_pairs(
708 phase_center_pairs=pulser_directions_and_pairwise_time_delays
709 )
710 )
711
712 AXIS = "Rho" # "Rho", "Phi", or "Z"
713 result = minimize(fun=objective_function,
714 x0=relevant_antennas[AXIS],
715 args=(relevant_antennas[f"{AXIS} (placeholder)"],
716 pulser_directions_and_pairwise_time_delays, AXIS),
717 method='Nelder-Mead',
718 options={'maxiter': 500_000})
719
720 print(result)
721 _plot_calibration_result(axis=AXIS, before=relevant_antennas, after=result.x)