Recipes

Short code for common tasks. Each recipe runs on its own, on the simulation that ships with the repository and is executed when this documentation is built. The Tutorial notebooks work through the same tasks at length.

Every recipe starts from the sample folder:

from pathlib import Path

import numpy as np
import grand

SAMPLE = (Path(grand.__file__).parents[1]
          / "sim2root/Common/sim_Xiaodushan_20221026_000000_RUN1_CD_ZHAireS_0000")

Every event of a folder

EventList joins the run, shower and trace trees into one event object:

from grand.aoi.event_list import EventList

for event in EventList(str(SAMPLE)):
    shower = event.simshower
    print("event %5d: zenith %5.2f deg, energy %.2g GeV, %2d antennas"
          % (event.event_number, shower.zenith, shower.energy_primary,
             len(event.antennas)))
event 13790: zenith 79.43 deg, energy 3.9e+09 GeV, 44 antennas
event  1618: zenith 51.64 deg, energy 3.8e+09 GeV,  5 antennas

EventList reuses one Event object for every event. To keep values across iterations, copy them out, as above, rather than storing event.

All the trees of a folder

DataDirectory opens every file of a folder and gives each tree as an attribute, by type and analysis level:

from grand.dataio import DataDirectory

folder = DataDirectory(str(SAMPLE))
tshower = folder.tshower_l0
tshower.get_entry(0)
print("primary %s (PDG code), Xmax %.1f g/cm2" % (tshower.primary_type, tshower.xmax_grams))
primary 2212 (PDG code), Xmax 794.6 g/cm2

A bare attribute, such as folder.tefield, gives the highest level present.

Many files without memory growth

Release each tree when you are done with it. The with form does it even if the loop raises. Here, every ADC file of the simulations in the repository:

from grand.dataio import TADC

n_events = 0
for path in sorted(SAMPLE.parent.glob("sim_*/adc_*.root")):
    with TADC(str(path)) as tadc:
        for event, run in tadc.get_list_of_events():
            tadc.get_event(event, run)
            n_events += 1               # your analysis of one event goes here
print(n_events, "events read")
15 events read

The antennas in latitude and longitude

Antenna positions are stored in meters in the array frame (x north, y west, z up) around the run’s origin. Convert them to geodetic coordinates:

from grand import Geodetic, GRANDCS
from grand.dataio import TRun

with TRun(str(SAMPLE / "run_1_L0_0000.root")) as trun:
    trun.get_entry(0)
    xyz = np.asarray(trun.du_xyz)                  # (units, 3), in m
    lat0, lon0, h0 = np.asarray(trun.origin_geoid)

origin = Geodetic(latitude=lat0, longitude=lon0, height=h0)
antennas = Geodetic(GRANDCS(x=xyz[:, 0], y=xyz[:, 1], z=xyz[:, 2], location=origin))
lat, lon, height = np.asarray(antennas)
print("%d antennas, latitude %.3f to %.3f, longitude %.3f to %.3f"
      % (len(lat), lat.min(), lat.max(), lon.min(), lon.max()))
49 antennas, latitude 40.975 to 41.060, longitude 93.857 to 94.005

The geomagnetic field and the geoid at a site

from grand.geo.geomagnet import field
from grand.geo.topography import geoid_undulation

b = np.asarray(field(origin)).ravel()              # east, north, up, in T
inclination = np.degrees(np.arctan2(-b[2], np.hypot(b[0], b[1])))
print("|B| = %.1f µT, inclination %.1f deg" % (np.linalg.norm(b) * 1e6, inclination))
print("geoid - ellipsoid: %.1f m" % geoid_undulation(latitude=lat0, longitude=lon0))
|B| = 56.4 µT, inclination 61.8 deg
geoid - ellipsoid: -61.1 m

The field is evaluated on 1 January 2020 unless the frame says otherwise; see The geomagnetic model ends in 2025 for later dates.

The RF chain’s response

import matplotlib.pyplot as plt
from grand.sim.detector.rf_chain import RFChain

freqs_mhz = np.arange(30.0, 251.0)
chain = RFChain()
chain.compute_for_freqs(freqs_mhz)
tf = np.abs(chain.get_tf())                        # (arms, frequencies)

fig, ax = plt.subplots(figsize=(7, 3))
for arm, name in enumerate("XYZ"):
    ax.plot(freqs_mhz, tf[arm], label="arm " + name)
ax.set_xlabel("frequency [MHz]")
ax.set_ylabel(r"$|V_{\rm out} / V_{\rm oc}|$")
ax.legend()
plt.show()
_images/recipes_6_0.png

Galactic noise at a sidereal time

One realization of the noise spectrum at the open-circuit terminals, for four units at 6 h local sidereal time:

from grand.sim.noise.galaxy import galactic_noise

spectrum = galactic_noise(6.0, 2048, freqs_mhz, nb_ant=4, seed=0)
print("shape (units, arms, frequencies):", spectrum.shape)
shape (units, arms, frequencies): (4, 3, 221)

du_type selects the antenna model ('GP300', 'GP300_nec', 'GP300_mat').

The T1 trigger

Apply the offline T1 trigger to the ADC traces of an event:

from grand.dataio import TADC
from grand.sim.detector.trigger import t1_du_triggers

with TADC(str(SAMPLE / "adc_1618-13790_L1_0000.root")) as tadc:
    tadc.get_entry(0)
    counts = np.asarray(tadc.trace_ch)             # (units, channels, samples)

passed = t1_du_triggers(counts)
print("%d of %d units pass T1" % (passed.sum(), len(passed)))
2 of 44 units pass T1

Parameters are passed as a dictionary, for example t1_du_triggers(counts, {"th1": 120}). On noise-free simulations the defaults pass few units; see The offline T1 trigger passes few units on clean simulations.

The arrival direction from peak times

A plane-wave fit to the times at which the field peaks at each antenna:

from scipy.signal import hilbert
from grand.analysis.fitting.plane_wave import PWF_semianalytical
from grand.dataio import TEfield, TShower

with TRun(str(SAMPLE / "run_1_L0_0000.root")) as trun:
    trun.get_entry(0)
    position = dict(zip(trun.du_id, np.asarray(trun.du_xyz)))

with TEfield(str(SAMPLE / "efield_1618-13790_L0_0000.root")) as tefield:
    tefield.get_entry(0)
    du_id = list(tefield.du_id)
    traces = np.asarray(tefield.trace)              # (units, 3, samples), µV/m
    t0_s = (np.asarray(tefield.du_seconds, dtype=float) - tefield.du_seconds[0]
            + np.asarray(tefield.du_nanoseconds) * 1e-9)

envelope = np.abs(hilbert(traces, axis=-1)).max(axis=1)
t_peak_s = t0_s + 0.5e-9 * envelope.argmax(axis=1)  # 0.5 ns per sample
xants = np.array([position[d] for d in du_id])

theta, phi = PWF_semianalytical(xants, t_peak_s)    # radians
with TShower(str(SAMPLE / "shower_1618-13790_L0_0000.root")) as tshower:
    tshower.get_entry(0)
    print("fit:        zenith %.2f deg, azimuth %.2f deg" % (np.degrees(theta), np.degrees(phi)))
    print("simulated:  zenith %.2f deg, azimuth %.2f deg" % (tshower.zenith, tshower.azimuth))
fit:        zenith 79.70 deg, azimuth 311.53 deg
simulated:  zenith 79.43 deg, azimuth 310.00 deg

The reconstruction works in seconds and radians. Notebook 11 continues with the spherical-wave fit, the amplitude fit and the energy estimate.

Where next