pueoAnalysisTools
Loading...
Searching...
No Matches
locate_signal.py
Go to the documentation of this file.
1r"""!
2@file locate_signal.py
3@brief `main()` shows how to use [cross_correlate()](#locate_signal.cross_correlate)
4and [plot_correlation_map()](#locate_signal.plot_correlation_map).
5
6\anchor example_one_pair_corr_map
7![Unmasked and Masked Correlation Maps: one antenna pair](example_correlation_map_one_pair.png)
8
9Summing over all antenna pairs, we have
10
11\anchor example_corr_map
12![Unmasked and Masked Total Correlation Maps](example_correlation_map_2D.png)
13
14We can see that by masking the individual correlation maps, the total correlation map is less
15noisy.
16
17"""
18
19import ROOT
20import numpy as np
21import polars as pl
22import sys
23from time_delays import _make_grid
24from pathlib import Path
25from initialise import load_pueoEvent_Dataset
26
27
33
34
35def supply_azimuthal_angle_masks(skymaps: pl.DataFrame) -> pl.DataFrame:
36 r"""!Supplies azimuth masks to any sky map
37 (eg [time delay maps](#time_delays.make_time_delay_skymap) or
38 [correlation maps](#cross_correlate))
39 @ingroup CC
40 @param[in] skymaps columns `A1_PhiSector` and `A2_PhiSector` are **required**
41 @retval phi_masks see the schema of the example output below
42
43* Parameters:
44 * Input: required columns are the \f$\phi\f$-sectors of the antenna pairs,
45 `A1_PhiSector` and `A2_PhiSector`.
46 * Output schema: one column (`masks`) will be attached to the input dataframe,
47```
48$ A1_PhiSector <u8>
49$ A2_PhiSector <u8>
50$ masks <array[bool, (1, 360)]>
51```
52
53* Explanation:
54
55 * \f$\phi\f$-sector 1 is centered around 0 degrees azimuth, \f$\phi\f$-sector 2 around 15 degrees,
56 and so on and so forth; the last \f$\phi\f$ sector (24) is centered around 345 degrees.
57 * Based on the antenna's [field of view](#antenna_attributes.ASSUMED_PHI_SECTOR_APERTURE_WIDTH),
58 the values outside a certain range are dropped.
59 * In the figure below, we show what this would look like for antennas from the first three and
60 the last \f$\phi\f$-sectors (white means masked).
61
62 ![Example masks of a few phi-sectors](azimuthal_angle_masks_illustration.svg)
63
64 * The chosen masking behavior for now is that **only the data within the overlapping unmasked
65 range would be kept**.
66 * For instance, suppose an antenna from \f$\phi\f$-sector 1 is paired with an antenna from
67 \f$\phi\f$-sector 2, then:
68
69 ![Example superimposed masks](azimuthal_angle_mask_overlap_illustration.svg)
70
71 @warning
72 * Thus, if the antenna pair's
73 [fields of view](#antenna_attributes.ASSUMED_PHI_SECTOR_APERTURE_WIDTH)
74 do not overlap, then everything will be masked.
75 """
76 from antenna_attributes import NUM_PHI_SECTORS, ASSUMED_PHI_SECTOR_APERTURE_WIDTH
77 phi, _ = _make_grid(sparse=True)
78 phi = np.degrees(phi)
79
80 # eg. PhiSector 1 is centered around 0, so with aperture width 50 degrees,
81 # its bound would be (-25, 25)
82 aperture_bounds = (
83 pl.DataFrame(
84 {"PhiSector": np.arange(1, 25)}, schema={"PhiSector": pl.UInt8}
85 )
86 .with_columns
87 (
88 ((pl.col("PhiSector") - 1) * (360 / NUM_PHI_SECTORS)).alias("aperture center [deg]")
89 )
90 .with_columns
91 (
92 pl.col("aperture center [deg]"),
93 (
94 (pl.col("aperture center [deg]") - ASSUMED_PHI_SECTOR_APERTURE_WIDTH / 2)
95 ).alias("left bound [deg]"),
96 (
97 (pl.col("aperture center [deg]") + ASSUMED_PHI_SECTOR_APERTURE_WIDTH / 2)
98 ).alias("right bound [deg]"),
99 )
100 )
101
102 phi_masks = (
103 skymaps
104 .join(
105 aperture_bounds.select(
106 pl.col("PhiSector"),
107 pl.col("left bound [deg]").alias("A1 left"),
108 pl.col("right bound [deg]").alias("A1 right"),
109 ), left_on="A1_PhiSector", right_on="PhiSector"
110 ).join(
111 aperture_bounds.select(
112 pl.col("PhiSector"),
113 pl.col("left bound [deg]").alias("A2 left"),
114 pl.col("right bound [deg]").alias("A2 right"),
115 ), left_on="A2_PhiSector", right_on="PhiSector"
116 ).with_columns(
117 pl.struct(pl.col("A1 left"), pl.col("A1 right"))
118 .map_batches
119 (
120 lambda s: np.array([
121 ((phi - left) % 360) < ((right - left) % 360)
122 for left, right
123 in s.to_numpy()
124 ])
125 ).alias("A1 masks"),
126 pl.struct(pl.col("A2 left"), pl.col("A2 right"))
127 .map_batches
128 (
129 lambda s: np.array([
130 ((phi - left) % 360) < ((right - left) % 360)
131 for left, right
132 in s.to_numpy()
133 ])
134 ).alias("A2 masks")
135 )
136 .select(
137 pl.all().exclude(
138 "A1 masks", "A2 masks", "A1 left", "A2 left", "A1 right", "A2 right"
139 ),
140 pl.struct("A1 masks", "A2 masks").map_batches(
141 lambda s: np.array([ # note: only overlapping part is kept, hence the element-wise &
142 ~(m1 & m2) for m1, m2 in s.to_numpy()
143 ]) # the not (~) is used since True means masked in NumPy.
144 ).alias("masks")
145 )
146 )
147 return phi_masks
148
149
150def combine_time_delay_maps_and_waveforms(masks_and_skymaps: pl.DataFrame,
151 waveforms: pl.DataFrame) -> pl.DataFrame:
152 r"""!Prepares a big table that has all the column needed by #cross_correlate.
153 @ingroup CC
154 @param[in] masks_and_skymaps The output of #supply_azimuthal_angle_masks
155 @param[in] waveforms The output of #waveform_plots.load_waveforms
156 @retval big_frame See sample output schema below.
157
158 * Parameters:
159 * The following columns are required in `waveforms`:
160 -# `AntNum`
161 -# `waveforms (volts)`
162 -# `step size (nanoseconds)`
163 -# `Pol`
164 * The following columns are required in `masks_and_skymaps`:
165 -# `A1_AntNum`
166 -# `A2_AntNum`
167 -# `time delays [sec]`
168 -# `masks`
169 * The output schema:
170```
171$ A1_AntNum <enum>
172$ A2_AntNum <enum>
173$ Pol <enum>
174$ A1_waveforms (volts) <array[f64, 3072]>
175$ A2_waveforms (volts) <array[f64, 3072]>
176$ time delays [samples] <array[i64, (180, 360)]>
177$ masks <array[bool, (1, 360)]>
178```
179 * Explanation:
180 * `time delays [samples]` refers to the [time delay skymaps](#time_delays.make_time_delay_skymap),
181 with the units converted from seconds to "samples"
182 * That is, the units are in "steps" (`step size (nanoseconds)`)
183 * The values in these time delay skymaps are therefore integers, serving as indices.
184 * These indices are then used later in #cross_correlate when creating the
185 correlation skymaps based on the time delay maps (via "fancy-indexing").
186 * Qualitatively, the time delay maps have not changed. For example:
187 \image html time_delay_map_in_seconds.svg Time Delay Map in Seconds width=40%
188 \image html time_delay_map_in_samples.svg Time Delay Map in Samples width=40%
189 """
190
191 signal_length = len(waveforms["waveforms (volts)"][0])
192 step_size = waveforms["step size (nanoseconds)"][0] * 1e-9 # ns -> seconds
193
194 big_frame = (
195 masks_and_skymaps
196 .join(waveforms, left_on="A1_AntNum", right_on="AntNum")
197 .join(waveforms, left_on=["A2_AntNum", "Pol"], right_on=["AntNum", "Pol"])
198 .with_columns(
199 ( # Convert [sec] to [number of samples]
200 pl.col("time delays [sec]") / step_size + signal_length - 1
201 # This offset is related to scipy.signal.correlate's behavior.
202 ) # Next, round the time delay maps to integers.
203 .map_batches(lambda s: np.rint(s.to_numpy()).astype(int))
204 .alias("time delays [samples]")
205 )
206 .with_columns(
207 pl.col("waveforms (volts)").alias("A1_waveforms (volts)"),
208 pl.col("waveforms (volts)_right").alias("A2_waveforms (volts)")
209 )
210 .select(
211 r"^A[12]_AntNum$", "Pol", r"^A[12]_waveforms \‍(volts\‍)$", "time delays [samples]", "masks"
212 )
213 )
214
215 return big_frame
216
217
218def cross_correlate(big_frame: pl.DataFrame) -> pl.DataFrame:
219 r"""!Compute the zero-centered normalized cross correlation (ZNCC) and makes correlation skymaps
220 @ingroup CC
221 @param[in] big_frame The output of #combine_time_delay_maps_and_waveforms()
222 @retval correlation_maps See the schema of the example output below
223
224 * Parameters: columns `correlation` and `correlation maps` will be added to the input,
225 so the output schema looks like
226```
227$ A1_AntNum <enum>
228$ A2_AntNum <enum>
229$ Pol <enum>
230$ A1_waveforms (volts) <array[f64, 3072]>
231$ A2_waveforms (volts) <array[f64, 3072]>
232$ time delays [samples] <array[i64, (180, 360)]>
233$ masks <array[bool, (1, 360)]>
234$ correlation <array[f64, 6143]>
235$ correlation maps <array[f64, (180, 360)]>
236```
237 * `correlation maps` contain correlation skymaps.
238 These are matrices with the same dimensions as the time delay maps of `time delays [samples]`,
239 as the former are made based on the latter via "fancy-indexing"
240
241 * Each matrix element of a correlation skymap is the correlation score between two waveforms,
242 given some particular [time delay](#time_delays.make_time_delay_skymap), ie. phase shift.
243 \anchor effectOfMask
244 ![Unmasked and Masked Correlation Maps](example_correlation_map_one_pair.png)
245
246 * `masks` can be used to mask the correlation maps, as shown in the bottom subplot in the
247 Figure above. The masks are defined by #supply_azimuthal_angle_masks.
248
249 \anchor scipy_corr_expl
250 * Explanation:
251 * Consider two waveforms,
252 \anchor maxcorrachieved
253 \image html shift_by_472_samples.png
254 * Each row in the `correlation` column is an array of correlation scores.
255 * By shifting the waveforms we may be able to get them to align perfectly,
256 at which point maximum correlation is achieved.
257 * The correlation score tells us how "aligned" the two waveforms are after we phase shift
258 `A1_waveforms (volts)` against `A2_waveforms (volts)` by a certain amount of time.
259 * The waveforms are zero-centered and normalized such that the cross-correlation is
260 bounded between [-1,1]. Zero means the waveforms are not aligned at all.
261 * See [cross_correlation_and_time_delay.pdf](cross_correlation_and_time_delay.pdf) or,
262 for details, [scipy_correlate_behavior.pdf](scipy_correlate_behavior.pdf).
263
264 """
265 from scipy.signal import correlate
266
267 correlation_maps: pl.DataFrame = (
268 big_frame
269 .with_columns
270 (
271 pl.struct("A1_waveforms (volts)", "A2_waveforms (volts)")
272 .map_batches # pass the waveform pair to a lambda function
273 ( # the lambda function runs scipy's correlate
274 lambda s: np.array([
275 correlate( # compute the zero-center normalized cross-correlation (ZNCC)
276 (wf1 - np.mean(wf1)) / np.std(wf1), # so that CC is bounded b/w [-1, 1]
277 (wf2 - np.mean(wf2)) / np.std(wf2), # zero-center and normalize the signals
278 method='fft'
279 ) / len(wf1)
280 for wf1, wf2 in s.to_numpy()
281 ])
282 )
283 .alias("correlation"),
284 )
285 .with_columns # make correlation maps via "fancy-indexing" inside the lambda function
286 (
287 pl.struct(pl.col("time delays [samples]"), pl.col("correlation")).map_batches(
288 lambda s: np.array(
289 [correlation_array[time_delay_matrix]
290 for time_delay_matrix, correlation_array
291 in s.to_numpy()]
292 )
293 ).alias("correlation maps")
294 )
295 )
296
297 return correlation_maps
298
299
300def _get_true_direction(dataset: ROOT.pueo.Dataset) -> [float, float]:
301 r"""!
302 @brief Returns the true signal direction
303 @ingroup CC
304 @param[in] dataset The output of #initialise.load_pueoEvent_Dataset
305
306 * Note that as stored in the `.root` files,
307 the variable `RFdir_payload` is the direction the signal is travelling **to**.
308 * Therefore, to obtain the direction that the signal is coming **from**,
309 we need the opposite vector.
310 * Thus, \f$\phi_{\rm true} = (\phi_{\rm rfdir} + 180 ^\circ) \% 360^\circ\f$,
311 and \f$\theta_{\rm true} = 180^\circ - \theta_{\rm rfdir}\f$
312 """
313
314 truePhi = (dataset.truth().payloadPhi + 180) % 360
315 trueTheta = 180 - dataset.truth().payloadTheta
316 #print("truePhi, trueTheta=", truePhi, trueTheta)
317 return truePhi, trueTheta
318
319
320def _get_true_pulser_direction(dataset: ROOT.pueo.Dataset, pulser_station:str) -> [float, float]:
321
322 sys.path.append(str(Path(__file__).resolve().parent.parent))
323 import geographyUtils.locations
324 import geographyUtils.geometry
325
326 geometry_helper = geographyUtils.geometry.GeometryHelper()
327 locations_helper = geographyUtils.locations.LocationsHelper()
328
329 pulser_position = locations_helper.get_ground_pulser_position(pulser_station)
330
331 pueo_position = np.zeros(3)
332
333 pueo_position[0] = dataset.gps().longitude
334 pueo_position[1] = dataset.gps().latitude
335 pueo_position[2] = dataset.gps().altitude
336
337 pueo_heading = dataset.gps().heading
338
339 expected_el_az = geometry_helper.get_direction_of_position(
340 pulser_position,
341 pueo_position
342 )
343 # correct for different direction definitions # check if this match the difinition used in calibration.py
344 expected_signal_direction = np.zeros(2)
345
346 # zenith
347 expected_signal_direction[0] = 180 - expected_el_az[0]
348
349 #azimuth
350 expected_signal_direction[1] = (-expected_el_az[1] + 180 + pueo_heading)
351
352 while expected_signal_direction[1] < 0:
353 expected_signal_direction[1] += 360
354 while expected_signal_direction[1] > 360:
355 expected_signal_direction[1] -= 360
356
357 truePhi = expected_signal_direction[1]
358 trueTheta = expected_signal_direction[0]
359
360 return truePhi, trueTheta
361
362def plot_correlation_map(correlation_frame: pl.DataFrame, plot_name: str,
363 true_phi=None, true_theta=None) -> None:
364 r"""!
365 @brief Plots the reult of #cross_correlate.
366 @ingroup CC
367 @param[in] correlation_frame The output of #cross_correlate
368 @param[in] plot_name Remember to specify file type
369 @param[in] true_phi (optional) from #_get_true_direction
370 @param[in] true_theta (optional) from #_get_true_direction
371
372 * Required columns in `correlation_frame`:
373 -# `correlation maps`
374 -# `masks`
375 -# `Pol`
376
377 * Using only one antenna pair, one can find a band of peak correlation scores,
378 see the [plot in the file description](@ref example_one_pair_corr_map).
379
380 * If we then sum over all antenna pairs, we would be able to identify a single peak:
381
382 ![Correlation Skymaps (summed over all antenna pairs)](img/example_correlation_map_two_pols.png)
383
384 """
385 import matplotlib.pyplot as plt
386 import numpy.ma as ma
387 plt.rcParams.update({'font.size': 15})
388
389 phi, theta = _make_grid(sparse=True)
390 phi = np.degrees(phi)
391 theta = np.degrees(theta)
392
393 fig, axes = plt.subplots(2, 1, figsize=(20, 10), sharex=True)
394 for i, df in enumerate(correlation_frame.partition_by("Pol")):
395 corr_map = df["correlation maps"].to_numpy()
396 # repeat each mask (row vector) 300 times (ie. the dimension of theta),
397 # so that each mask (now a matrix) has the same dimension as the corresponding correlation map
398 masks = np.repeat(df["masks"].to_numpy(), repeats=np.shape(corr_map)[1], axis=1)
399 masks_sum = np.sum(~masks, axis=0) # for normalizing `total_masked`
400 # Since True means masked in numpy's masked array, a `not` (~) is needed in the sum above.
401 corr_map_masked = ma.array(corr_map, mask=masks)
402
403 # sum the correlation maps
404 total_masked = np.sum(corr_map_masked, axis=0) / masks_sum
405
406 axes[i].set_ylabel(r"$\theta$")
407 axes[i].set_xlabel(r"$\phi$")
408 axes[i].set_title(f"{df["Pol"][0]}Pol correlation maps")
409 fig.colorbar(axes[i].pcolormesh(phi, theta, total_masked))
410
411 if true_phi is not None and true_theta is not None:
412 axes[i].scatter(true_phi, true_theta, marker='x', color="red", label='True Direction')
413 axes[i].legend()
414
415 fig.savefig(plot_name)
416
417
418# note: internal function for creating an example plot used by the documentation; not documented.
419def __plot_example_correlation_map_for_one_antenna_pair(input_frame: pl.DataFrame):
420 import matplotlib.pyplot as plt
421 import numpy.ma as ma
422 plt.rcParams.update({'font.size': 15})
423
424 pl.Config().set_tbl_cols(input_frame.width)
425 pl.Config().set_fmt_table_cell_list_len(0)
426 print(input_frame)
427
428 # retrieve the correlation map from the dataframe, and its corresponding mask
429 mask = input_frame["masks"].to_numpy().squeeze()
430 corr_map = input_frame["correlation maps"].to_numpy().squeeze()
431
432 # applying mask to the correlation map
433 mask = np.vstack([mask] * corr_map.shape[0])
434 corr_map_masked = ma.array(corr_map, mask=mask)
435
436 # plots
437 phi, theta = _make_grid(sparse=True)
438
439 fig, axes = plt.subplots(2, 1, sharex=True, figsize=(20, 10))
440 axes[0].set_title(
441 f"Antennas {input_frame["A1_AntNum"][0]} and {input_frame["A2_AntNum"][0]} Correlation Map"
442 )
443 for ax in axes:
444 ax.set_ylabel(r"$\theta$")
445 ax.set_yticks([np.pi / 2, np.pi], labels=[r"$\pi / 2$", r"$\pi$"])
446
447 axes[1].set_xlabel(r"$\phi$")
448 axes[1].set_xticks([0, np.pi, 2 * np.pi], labels=["0", r"$\pi$", r"$2\pi$"])
449 image = axes[0].pcolormesh(phi, theta, corr_map, vmin=corr_map.min(), vmax=corr_map.max())
450 image = axes[1].pcolormesh(phi, theta, corr_map_masked, vmin=corr_map.min(), vmax=corr_map.max())
451 fig.colorbar(image, ax=axes.ravel().tolist(), label="Correlation Score")
452 plt.savefig("img/example_correlation_map_one_pair.png")
453
454
455if __name__ == "__main__":
456 import os
457 from antenna_attributes import read_antenna_geometry, get_nominal_phase_center
458 from antenna_pairs import generate_MI_antenna_pairs
459 from time_delays import make_time_delay_skymap
460 from waveform_plots import load_waveforms, upsample_waveforms
461
462 # ----------------------- step 1: read in antenna geometry ----------------------- #
463
464 _jun25: Path = os.environ.get("PUEO_UTIL_INSTALL_DIR") / Path("share/pueo/geometry/jun25/qrh.dat")
465 _face_centers: pl.DataFrame = read_antenna_geometry(qrh_dot_dat=_jun25)
466 # keep only the necessary columns for the task at hand
467 _phase_centers: pl.DataFrame = (
468 get_nominal_phase_center(face_centers=_face_centers)
469 .select("AntNum", "PhiSector", "AntIdx", "X[m]", "Y[m]", "Z[m]")
470 )
471
472 # ------------------- step 2: pair up antennas and compute the time delays ------------------- #
473
474 _antenna_pairs: pl.DataFrame = generate_MI_antenna_pairs(antennas=_phase_centers)
475 _time_delays: pl.DataFrame = (
476 make_time_delay_skymap(antenna_pairs=_antenna_pairs)
477 .select(pl.col(r"^A[12]_AntNum$", r"^A[12]_PhiSector$", "time delays [sec]"))
478 )
479
480 # ----------------------- step 3: supply phi masks ----------------------- #
481
482 masked: pl.DataFrame = (
483 supply_azimuthal_angle_masks(skymaps=_time_delays)
484 .select(pl.col(r"^A[12]_AntNum$", "time delays [sec]", "masks"))
485 )
486
487 # ----------------------- step 4: get the waveforms ----------------------- #
488
489 _run_zero_data: ROOT.pueo.Dataset = load_pueoEvent_Dataset(pueo_mc_data=Path("/tmp"), run_number=0)
490 _run_zero_data.last() # suppose we want to analyze the last event in run0/
491
492 _wf: pl.DataFrame = (
493 load_waveforms(dataset=_run_zero_data)
494 .select("AntNum", "Pol", "waveforms (volts)", "step size (nanoseconds)")
495 )
496 up: pl.DataFrame = upsample_waveforms(waveforms=_wf, upsample_factor=3)
497
498 # ----------------------- step 5: apply cross correlation ----------------------- #
499
500 big_frame = combine_time_delay_maps_and_waveforms(masks_and_skymaps=masked, waveforms=up)
501
502 correlation_frame: pl.DataFrame = cross_correlate(big_frame)
503
504 # ----------------------- step 6: plots ----------------------- #
505
506 tp, tt = _get_true_direction(dataset=_run_zero_data)
507 plot_correlation_map(
508 correlation_frame=correlation_frame, true_phi=tp, true_theta=tt,
509 plot_name='img/example_correlation_map_two_pols.png'
510 )
511
512 # make the correlation map for one pair of antennas for the example plot in the documentation
513 # __plot_example_correlation_map_for_one_antenna_pair(
514 # correlation_frame
515 # .filter(
516 # (pl.col("A1_AntNum") == "319"), pl.col("A2_AntNum") == "221", pl.col("Pol") == "V"
517 # )
518 # )