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