PyREx

How to Use PyREx

This section describes in detail how to use a majority of the functions and classes included in the base PyREx package, along with short example code segments. The code in each section is designed to run sequentially, and the code examples all assume these imports:

import numpy as np
import matplotlib.pyplot as plt
import scipy.fft
import scipy.signal
import pyrex

All of the following examples can also be found (and easily run) in the Code Examples python notebook found in the examples directory.

Working with Signal Objects

The base Signal class consists of an array of times and an array of corresponding signal values, and is instantiated with these two arrays. The times array is assumed to be in units of seconds, but there are no general units for the values array. It is worth noting that the Signal object stores shallow copies of the passed arrays, so changing the original arrays will not affect the Signal object.

time_array = np.linspace(0, 10)
value_array = np.sin(time_array)
my_signal = pyrex.Signal(times=time_array, values=value_array)

Plotting the Signal object is as simple as plotting the times vs the values:

plt.plot(my_signal.times, my_signal.values)
plt.show()
_images/signal_1.png

While there are no specified units for Signal.values, there is the option to specify the value_type of the values. This is done using the Signal.Type enum. By default, a Signal object has value_type=Type.unknown. However, if the signal represents a voltage, electric field, or power; value_type can be set to Signal.Type.voltage, Signal.Type.field, or Signal.Type.power respectively:

my_voltage_signal = pyrex.Signal(times=time_array, values=value_array,
                                 value_type=pyrex.Signal.Type.voltage)

Signal objects can be added as long as they have the same time array and value_type. Signal objects can also be multiplied by numeric types, which will multiply the values attribute of the signal.

time_array = np.linspace(0, 10)
values1 = np.sin(time_array)
values2 = np.cos(time_array)
signal1 = pyrex.Signal(time_array, values1)
plt.plot(signal1.times, signal1.values,
         label="signal1 = sin(t)")
signal2 = pyrex.Signal(time_array, values2)
plt.plot(signal2.times, signal2.values,
         label="signal2 = cos(t)")
signal3 = signal1 + signal2
plt.plot(signal3.times, signal3.values,
         label="signal3 = sin(t)+cos(t)")
signal4 = 2 * signal3
plt.plot(signal4.times, signal4.values,
         label="signal4 = 2*(sin(t)+cos(t))")
all_signals = [signal1, signal2, signal3]
signal5 = sum(all_signals)
plt.plot(signal5.times, signal5.values, '--',
         label="signal5 = 2*(sin(t)+cos(t))")
plt.legend()
plt.show()
_images/signal_2.png

The Signal class provides many convenience attributes for dealing with signals:

my_signal.dt == my_signal.times[1] - my_signal.times[0]
my_signal.spectrum == scipy.fft.fft(my_signal.values)
my_signal.frequencies == scipy.fft.fftfreq(n=len(my_signal.values),
                                           d=my_signal.dt)
my_signal.envelope == np.abs(scipy.signal.hilbert(my_signal.values))

The Signal class also provides methods for manipulating the signal. The Signal.resample() method will resample the times and values arrays to the given number of points (with the same endpoints). This method operates “in-place” on the signal, but we can use the Signal.copy() method to make a duplicate object first so that further uses of the original signal object are unaffected:

signal_copy = my_signal.copy()
signal_copy.resample(1001)
len(signal_copy.times) == len(signal_copy.values) == 1001
signal_copy.times[0] == 0
signal_copy.times[-1] == 10
plt.plot(signal_copy.times, signal_copy.values)
plt.show()
_images/signal_3.png

The Signal.with_times() method will interpolate/extrapolate the signal’s values onto a new times array:

new_times = np.linspace(-5, 15)
new_signal = my_signal.with_times(new_times)
plt.plot(new_signal.times, new_signal.values, label="new signal")
plt.plot(my_signal.times, my_signal.values, label="original signal")
plt.legend()
plt.show()
_images/signal_4.png

The Signal.shift() method will shift the signal in time by a specified value (in seconds):

my_signal.shift(2)
plt.plot(my_signal.times, my_signal.values)
plt.show()
_images/signal_5.png

The Signal.filter_frequencies() method will apply a frequency-domain filter to the values array based on the frequency response function provided. In cases where the filter is designed for only positive frequencies (as below) the filtered frequency may exhibit strange behavior, including potentially having an imaginary part. To resolve that issue, pass force_real=True to the Signal.filter_frequencies() method, which will extrapolate the given filter to negative frequencies and ensure a real-valued filtered signal.

def lowpass_filter(frequency):
    if frequency < 1:
        return 1
    else:
        return 0

time_array = np.linspace(0, 10, 1001)
value_array = np.sin(0.1*2*np.pi*time_array) + np.sin(2*2*np.pi*time_array)
my_signal = pyrex.Signal(times=time_array, values=value_array)

plt.plot(my_signal.times, my_signal.values, label="original")
my_signal.filter_frequencies(lowpass_filter, force_real=True)
plt.plot(my_signal.times, my_signal.values, label="filtered")
plt.legend()
plt.show()
_images/signal_6.png

A number of classes which inherit from the Signal class are included in PyREx: EmptySignal, FunctionSignal, AskaryanSignal, and ThermalNoise.

EmptySignal is simply a signal whose values are all zero:

time_array = np.linspace(0,10)
empty = pyrex.EmptySignal(times=time_array)
plt.plot(empty.times, empty.values)
plt.show()
_images/signal_7.png

FunctionSignal takes a function of time and creates a signal based on that function:

time_array = np.linspace(0, 10, num=101)
def square_wave(time):
    if int(time)%2==0:
        return 1
    else:
        return -1
square_signal = pyrex.FunctionSignal(times=time_array, function=square_wave)
plt.plot(square_signal.times, square_signal.values)
plt.show()
_images/signal_8.png

Additionally, FunctionSignal leverages its knowledge of the function to more accurately interpolate and extrapolate values for the Signal.with_times() method:

new_times = np.linspace(0, 20, num=201)
long_square_signal = square_signal.with_times(new_times)
plt.plot(long_square_signal.times, long_square_signal.values, label="extrapolated")
plt.plot(square_signal.times, square_signal.values, label="original")
plt.legend()
plt.show()
_images/signal_9.png

AskaryanSignal produces an Askaryan pulse (in V/m) on a time array resulting from a given neutrino observed at a given angle from the shower axis and at a given distance from the shower vertex. For more about using the Particle class, see Particle Generation.

time_array = np.linspace(-10e-9, 40e-9, 1001)
neutrino_energy = 1e8 # GeV
neutrino = pyrex.Particle("nu_e", vertex=(0, 0, -1000), direction=(0, 0, -1),
                          energy=neutrino_energy)
neutrino.interaction.em_frac = 1
neutrino.interaction.had_frac = 0
observation_angle = 65 * np.pi/180 # radians
observation_distance = 2000 # meters
askaryan = pyrex.AskaryanSignal(times=time_array, particle=neutrino,
                                viewing_angle=observation_angle,
                                viewing_distance=observation_distance)
print(askaryan.value_type)
plt.plot(askaryan.times, askaryan.values)
plt.show()
_images/signal_10.png

ThermalNoise produces Rayleigh-distributed noise (in V) at a given temperature and resistance, within a given frequency range:

time_array = np.linspace(-10e-9, 40e-9, 1001)
noise_temp = 300 # K
system_resistance = 1000 # ohm
frequency_range = (550e6, 750e6) # Hz
noise = pyrex.ThermalNoise(times=time_array, temperature=noise_temp,
                           resistance=system_resistance,
                           f_band=frequency_range)
print(noise.value_type)
plt.plot(noise.times, noise.values)
plt.show()
_images/signal_11.png

Note that since ThermalNoise inherits from FunctionSignal, it can be extrapolated nicely to new times. It may be highly periodic outside of its original time range however, but this can be tuned using the uniqueness_factor parameter.

short_noise = pyrex.ThermalNoise(times=time_array, temperature=noise_temp,
                                 resistance=system_resistance,
                                 f_band=(100e6, 400e6))
long_noise = short_noise.with_times(np.linspace(-10e-9, 90e-9, 2001))

plt.plot(short_noise.times, short_noise.values)
plt.show()
plt.plot(long_noise.times, long_noise.values)
plt.axvline(40e-9, ls=':', c='k')
plt.show()
_images/signal_12.png _images/signal_13.png

Antenna Class and Subclasses

The base Antenna class provided by PyREx is designed to be subclassed in order to match various antenna models. At its core, an Antenna object is initialized with a position and a number of optional parameters, including a temperature, resistance, and frequency range (for noise calculations) and a boolean dictating whether or not noise should be added to the antenna’s signals.

# Please note that some values are unrealistic for demonstration purposes
position = (0, 0, -100) # m
temperature = 300 # K
resistance = 1e17 # ohm
frequency_range = (0, 5) # Hz
basic_antenna = pyrex.Antenna(position=position, temperature=temperature,
                              resistance=resistance,
                              freq_range=frequency_range)
noiseless_antenna = pyrex.Antenna(position=position, noisy=False)

The basic useful properties of an Antenna object are is_hit and waveforms. The is_hit property specifies whether or not the antenna has been triggered by an event. waveforms is a list of all the waveforms which have triggered the antenna. The antenna also defines a signals attribute, which is a list of all signals the antenna has received (without noise), and all_waveforms which is a list of all waveforms (signal plus noise) the antenna has received including those which didn’t trigger. Finally, the antenna has an is_hit_mc property which is similar to is_hit, but does not count triggers where noise alone would have triggered the antenna.

basic_antenna.is_hit == False
basic_antenna.waveforms == []

The Antenna class contains two attributes and three methods which represent characteristics of the antenna as they relate to signal processing. The attributes are efficiency and antenna_factor, and the methods are Antenna.frequency_response(), Antenna.directional_gain(), and Antenna.polarization_gain(). The attributes are to be set and the methods overwritten in order to customize the way the antenna responds to incoming signals. efficiency is simply a scalar which multiplies the signal the antenna receives (default value is 1). antenna_factor is a factor used in converting received electric fields into voltages (antenna_factor = E / V; default value is 1). Antenna.frequency_response() takes a frequency or list of frequencies (in Hz) and returns the frequency response of the antenna at each frequency given (default always returns 1). Antenna.directional_gain() takes angles theta and phi in the antenna’s coordinates and returns the antenna’s gain for a signal coming from that direction (default always returns 1). Antenna.directional_gain() is dependent on the antenna’s orientation, which is defined by its z_axis and x_axis attributes. To change the antenna’s orientation, use the Antenna.set_orientation() method which takes z_axis and x_axis arguments. Finally, Antenna.polarization_gain() takes a polarization vector and returns the antenna’s gain for a signal with that polarization (default always returns 1).

basic_antenna.efficiency == 1
basic_antenna.antenna_factor == 1
freqs = [1, 2, 3, 4, 5]
basic_antenna.frequency_response(freqs) == [1, 1, 1, 1, 1]
basic_antenna.directional_gain(theta=np.pi/2, phi=0) == 1
basic_antenna.polarization_gain([0,0,1]) == 1

The Antenna class defines an Antenna.trigger() method which is also expected to be overwritten. Antenna.trigger() takes a Signal object as an argument and returns a boolean of whether or not the antenna would trigger on that signal (default always returns True).

basic_antenna.trigger(pyrex.Signal([0],[0])) == True

The Antenna class also defines an Antenna.receive() method which takes a Signal object and processes the signal according to the antenna’s attributes (efficiency, antenna_factor, response, directional_gain, and polarization_gain as described above). To use the Antenna.receive() method, simply pass it the Signal object the antenna sees, and the Antenna class will handle the rest. You can also optionally specify the direction of travel of the signal (used in the Antenna.directional_gain() calculation) and the polarization direction of the signal (used in the Antenna.polarization_gain() calculation). If either of these is unspecified, the corresponding gain will simply be set to 1.

def limited_sin(times, min_time, max_time):
    values = np.zeros(len(times))
    in_range = (times>=min_time) & (times<max_time)
    values[in_range] = np.sin(times[in_range])
    return values

incoming_signal_1 = pyrex.FunctionSignal(np.linspace(0,2*np.pi), lambda t: limited_sin(t,0,2*np.pi),
                                        value_type=pyrex.Signal.Type.voltage)
incoming_signal_2 = pyrex.FunctionSignal(np.linspace(4*np.pi,6*np.pi), lambda t: limited_sin(t,4*np.pi,6*np.pi),
                                        value_type=pyrex.Signal.Type.voltage)
basic_antenna.receive(incoming_signal_1)
basic_antenna.receive(incoming_signal_2, direction=[0,0,1], polarization=[1,0,0])
basic_antenna.is_hit == True
for waveform, pure_signal in zip(basic_antenna.waveforms, basic_antenna.signals):
    plt.figure()
    plt.plot(waveform.times, waveform.values, label="Waveform")
    plt.plot(pure_signal.times, pure_signal.values, label="Pure Signal")
    plt.legend()
    plt.show()
_images/antenna_1.png _images/antenna_2.png

Beyond Antenna.waveforms, the Antenna object also provides methods for checking the waveform and trigger status for arbitrary times: Antenna.full_waveform() and Antenna.is_hit_during(). Both of these methods take a time array as an argument and return either the waveform Signal object for those times or whether said waveform triggered the antenna, respectively.

total_waveform = basic_antenna.full_waveform(np.linspace(0,20))
plt.plot(total_waveform.times, total_waveform.values, label="Total Waveform")
plt.plot(incoming_signal_1.times, incoming_signal_1.values, label="Pure Signals")
plt.plot(incoming_signal_2.times, incoming_signal_2.values, color="C1")
plt.legend()
plt.show()

basic_antenna.is_hit_during(np.linspace(0,6)) == True
_images/antenna_3.png

Finally, the Antenna class defines an Antenna.clear() method which will reset the antenna to a state of having received no signals:

basic_antenna.clear()
basic_antenna.is_hit == False
len(basic_antenna.waveforms) == 0

The Antenna.clear() method can also optionally reset the source of noise waveforms by passing reset_noise=True so that if the same signals are given after the antenna is cleared, the noise waveforms will be different:

noise_before = basic_antenna.make_noise(np.linspace(0, 20))
plt.plot(noise_before.times, noise_before.values, label="Noise Before Clear")
basic_antenna.clear(reset_noise=True)
noise_after = basic_antenna.make_noise(np.linspace(0, 20))
plt.plot(noise_after.times, noise_after.values, label="Noise After Clear")
plt.legend()
plt.show()
_images/antenna_4.png

To create a custom antenna, simply inherit from the Antenna class:

class NoiselessThresholdAntenna(pyrex.Antenna):
    def __init__(self, position, threshold):
        super().__init__(position=position, noisy=False)
        self.threshold = threshold

    def trigger(self, signal):
        if max(np.abs(signal.values)) > self.threshold:
            return True
        else:
            return False

Our custom NoiselessThresholdAntenna should only trigger when the amplitude of a signal exceeds its threshold value:

my_antenna = NoiselessThresholdAntenna(position=(0, 0, 0), threshold=2)

incoming_signal = pyrex.FunctionSignal(np.linspace(0,10), np.sin,
                                       value_type=pyrex.Signal.Type.voltage)
my_antenna.receive(incoming_signal)
my_antenna.is_hit == False
len(my_antenna.waveforms) == 0
len(my_antenna.all_waveforms) == 1

incoming_signal = pyrex.Signal(incoming_signal.times,
                               5*incoming_signal.values,
                               incoming_signal.value_type)
my_antenna.receive(incoming_signal)
my_antenna.is_hit == True
len(my_antenna.waveforms) == 1
len(my_antenna.all_waveforms) == 2

for wave in my_antenna.waveforms:
    plt.figure()
    plt.plot(wave.times, wave.values)
    plt.show()
_images/antenna_5.png

For more on customizing PyREx, see the Custom Sub-Package section.

PyREx also defines DipoleAntenna, a subclass of Antenna which provides a basic threshold trigger, a basic bandpass filter frequency response, a sine-function directional gain, and a typical dot-product polarization effect. A DipoleAntenna object can be created as follows:

antenna_identifier = "antenna 1"
position = (0, 0, -100)
center_frequency = 250e6 # Hz
bandwidth = 300e6 # Hz
temperature = 300 # K
resistance = 100 # ohm
antenna_length = 3e8/center_frequency/2 # m
polarization_direction = (0, 0, 1)
trigger_threshold = 1e-5 # V
dipole = pyrex.DipoleAntenna(name=antenna_identifier,position=position,
                             center_frequency=center_frequency,
                             bandwidth=bandwidth,
                             temperature=temperature, resistance=resistance,
                             effective_height=antenna_length,
                             orientation=polarization_direction,
                             trigger_threshold=trigger_threshold)

AntennaSystem and Detector Classes

The AntennaSystem class is designed to bridge the gap between the basic antenna classes and realistic antenna systems that include front-end processing of the antenna’s signals. It is designed to be subclassed, but by default it takes as an argument the Antenna class or subclass it is extending, or an object of that class. It provides an interface nearly identical to that of the Antenna class, but where an AntennaSystem.front_end() method (which by default does nothing) is applied to the extended antenna’s signals.

To extend an Antenna class or subclass into a full antenna system, inherit from the AntennaSystem class and define the AntennaSystem.front_end() method. If the front end of the antenna system requires some time to equilibrate to noise signals, that can be specified in the AntennaSystem.lead_in_time attribute, adding that amount of time before any waveforms to be processed. A different trigger also optionally can be defined for the antenna system (by default it uses the antenna’s trigger):

class PowerAntennaSystem(pyrex.AntennaSystem):
    """Antenna system whose signals and waveforms are powers instead of
    voltages."""
    def __init__(self, position, temperature, resistance, frequency_range):
        super().__init__(pyrex.Antenna)
        # The setup_antenna method simply passes all arguments on to the
        # antenna class passed to super.__init__() and stores the resulting
        # antenna to self.antenna
        self.setup_antenna(position=position, temperature=temperature,
                           resistance=resistance,
                           freq_range=frequency_range)

    def front_end(self, signal):
        return pyrex.Signal(signal.times, signal.values**2,
                            value_type=pyrex.Signal.Type.power)

Objects of this class can then, for the most part, be interacted with as though they were regular antenna objects:

position = (0, 0, -100) # m
temperature = 300 # K
resistance = 1e17 # ohm
frequency_range = (0, 5) # Hz

basic_antenna_system = PowerAntennaSystem(position=position,
                                          temperature=temperature,
                                          resistance=resistance,
                                          frequency_range=frequency_range)

basic_antenna_system.trigger(pyrex.Signal([0],[0])) == True

def limited_sin(times, min_time, max_time):
    values = np.zeros(len(times))
    in_range = (times>=min_time) & (times<max_time)
    values[in_range] = np.sin(times[in_range])
    return values

incoming_signal_1 = pyrex.FunctionSignal(np.linspace(0,2*np.pi), lambda t: limited_sin(t,0,2*np.pi),
                                         value_type=pyrex.Signal.Type.voltage)
incoming_signal_2 = pyrex.FunctionSignal(np.linspace(4*np.pi,6*np.pi), lambda t: limited_sin(t,4*np.pi,6*np.pi),
                                         value_type=pyrex.Signal.Type.voltage)
basic_antenna_system.receive(incoming_signal_1)
basic_antenna_system.receive(incoming_signal_2, direction=[0,0,1],
                             polarization=[1,0,0])
basic_antenna_system.is_hit == True
for waveform, pure_signal in zip(basic_antenna_system.waveforms,
                                 basic_antenna_system.signals):
    plt.figure()
    plt.plot(waveform.times, waveform.values, label="Waveform")
    plt.plot(pure_signal.times, pure_signal.values, label="Pure Signal")
    plt.legend()
    plt.show()

total_waveform = basic_antenna_system.full_waveform(np.linspace(0,20))
plt.plot(total_waveform.times, total_waveform.values, label="Total Waveform")
plt.plot(incoming_signal_1.times, incoming_signal_1.values, label="Pure Signals")
plt.plot(incoming_signal_2.times, incoming_signal_2.values, color="C1")
plt.legend()
plt.show()

basic_antenna_system.is_hit_during(np.linspace(0,6)) == True

basic_antenna_system.clear()
basic_antenna_system.is_hit == False
len(basic_antenna_system.waveforms) == 0
_images/detector_1.png _images/detector_2.png _images/detector_3.png

The Detector class is another convenience class meant to be subclassed. It is useful for automatically generating many antennas (as would be used in a detector). Subclasses must define a Detector.set_positions() method to assign vector positions to the antenna_positions attribute. By default Detector.set_positions() will raise a NotImplementedError. Additionally subclasses may extend the default Detector.build_antennas() method which by default simply builds antennas of a passed antenna class using any keyword arguments passed to the method. In addition to simply generating many antennas at desired positions, another convenience of the Detector class is that once the Detector.build_antennas() method is run, it can be iterated directly as though the object were a list of the antennas it generated. And finally, the Detector.triggered() method will check whether any of the antennas have been triggered, and can be overridden in subclasses to define a more complicated detector trigger. An example of subclassing the Detector class is shown below:

class AntennaGrid(pyrex.Detector):
    """A detector composed of a plane of antennas in a rectangular grid layout
    some distance below the ice."""
    def set_positions(self, number, separation=10, depth=-50):
        self.antenna_positions = []
        n_x = int(np.sqrt(number))
        n_y = int(number/n_x)
        dx = separation
        dy = separation
        for i in range(n_x):
            x = -dx*n_x/2 + dx/2 + dx*i
            for j in range(n_y):
                y = -dy*n_y/2 + dy/2 + dy*j
                self.antenna_positions.append((x, y, depth))

grid_detector = AntennaGrid(9)

# Build the antennas
temperature = 300 # K
resistance = 1e17 # ohm
frequency_range = (0, 5) # Hz
grid_detector.build_antennas(pyrex.Antenna, temperature=temperature,
                             resistance=resistance,
                             freq_range=frequency_range)

plt.figure(figsize=(6,6))
for antenna in grid_detector:
    x = antenna.position[0]
    y = antenna.position[1]
    plt.plot(x, y, "kD")
plt.ylim(plt.xlim())
plt.show()
_images/detector_4.png

Due to the parallels between Antenna and AntennaSystem, an antenna system may also be used in the custom detector class. Note however, that the antenna positions must be accessed as antenna.antenna.position since we didn’t define a position attribute for the PowerAntennaSystem:

grid_detector = AntennaGrid(12)

# Build the antennas
temperature = 300 # K
resistance = 1e17 # ohm
frequency_range = (0, 5) # Hz
grid_detector.build_antennas(PowerAntennaSystem, temperature=temperature,
                            resistance=resistance,
                            frequency_range=frequency_range)

for antenna in grid_detector:
    x = antenna.antenna.position[0]
    y = antenna.antenna.position[1]
    plt.plot(x, y, "kD")
plt.show()
_images/detector_5.png

For convenience, objects derived from the Detector class can be added into a CombinedDetector object, which behaves similarly. The CombinedDetector.build_antennas() method should work seamlessly if the sub-detectors have the same build_antennas() method, otherwise it will do its best to dispatch keyword arguments between the sub-detectors. Similarly the CombinedDetector.triggered() method will return True if either sub-detector was triggered, with arguments to the method dispatched to the proper sub-triggers.

Ice and Earth Models

PyREx provides an ice model object ice, which is an instance of whichever ice model class is preferred (currently pyrex.ice_model.AntarcticIce). The ice object provides a number of (hopefully self-explanatory) methods for calculating characteristics of the ice at different depths and frequencies as below:

depth = -1000 # m
pyrex.ice.temperature(depth)
pyrex.ice.index(depth)
pyrex.ice.gradient(depth)
frequency = 1e8 # Hz
pyrex.ice.attenuation_length(depth, frequency)

PyREx also provides an Earth model object earth, which is similarly an instance of whichever Earth model class is preferred (currently pyrex.earth_model.PREM). This model provides two methods: density() and slant_depth(). density() calculates the density in grams per cubic centimeter of the Earth at a given radius, and slant_depth() calculates the material thickness in grams per square centimeter of a chord cutting through the Earth in a given direction, starting from a given point:

radius = 6360000 # m
pyrex.earth.density(radius)
angle = 60 * np.pi/180 # radians
direction = (np.sin(angle), 0, -np.cos(angle))
endpoint = (0, 0, -1000) # m
pyrex.earth.slant_depth(endpoint, direction)

Ray Tracing

PyREx provides ray tracing in the RayTracer and RayTracePath classes. RayTracer takes a launch point and receiving point as arguments (and optionally an ice model and z-step), and will solve for the paths between the points (as RayTracePath objects).

start = (0, 0, -250) # m
finish = (750, 0, -100) # m
my_ray_tracer = pyrex.RayTracer(from_point=start, to_point=finish)

The two most useful properties of RayTracer are exists and solutions. The exists property is a boolean value of whether or not path solutions exist between the launch and receiving points. solutions is the list of (zero or two) RayTracePath objects which exist between the launch and receiving points. There are many other properties available in RayTracer, outlined in the PyREx API section, which are mostly used internally and maybe not interesting otherwise.

my_ray_tracer.exists
my_ray_tracer.solutions

The RayTracePath class contains the attributes of the paths between points. The most useful properties of RayTracePath are tof, path_length, emitted_direction, and received_direction. These properties provide the time of flight, path length, and direction of rays at the launch and receiving points respectively.

my_path = my_ray_tracer.solutions[0]
my_path.tof
my_path.path_length
my_path.emitted_direction
my_path.received_direction

RayTracePath also provides a RayTracePath.attenuation() method which gives the attenuation of the signal at a given frequency (or frequencies), and a RayTracePath.coordinates property which gives the x, y, and z coordinates of the path (useful mostly for plotting, and not guaranteed to be accurate enough for other purposes).

frequency = 100e6 # Hz
my_path.attenuation(frequency)
my_path.attenuation(np.linspace(1e8, 1e9, 11))
plt.plot(my_path.coordinates[0], my_path.coordinates[2])
plt.show()
_images/ray_tracing_1.png

Finally, RayTracePath.propagate() propagates a Signal object from the launch point to the receiving point of the path by applying the frequency-dependent attenuation from RayTracePath.attenuation(), and shifting the signal times by RayTracePath.tof. Note that it does not apply a 1/R factor to the signal amplitude based on the path length. If needed, this effect should be added in manually. RayTracePath.propagate() returns the Signal objects and polarization vectors of the s-polarized and p-polarized portions of the signal.

time_array = np.linspace(0, 5e-9, 1001)
launch_signal = (
    pyrex.FunctionSignal(time_array, lambda t: np.sin(1e9*2*np.pi*t))
    + pyrex.FunctionSignal(time_array, lambda t: np.sin(1e10*2*np.pi*t))
)
plt.plot(launch_signal.times*1e9, launch_signal.values)
plt.show()
# Polarize perpendicular to the path in the x-z plane
launch_pol = np.cross(my_path.emitted_direction, (0, 1, 0))
print(launch_pol)

rec_signals, rec_pols = my_path.propagate(launch_signal, polarization=launch_pol)
plt.plot(rec_signals[0].times*1e9, rec_signals[0].values, label="s-pol signal")
plt.plot(rec_signals[1].times*1e9, rec_signals[1].values, label="p-pol signal")
plt.legend()
plt.show()
print(rec_pols)
_images/ray_tracing_2.png _images/ray_tracing_3.png

Particle Generation

PyREx includes the Particle class as a container for information about neutrinos which are generated to produce Askaryan pulses. A Particle contains an id, a vertex, a direction, an energy, an interaction, and a weight:

particle_type = pyrex.Particle.Type.electron_neutrino
initial_position = (0,0,0) # m
direction_vector = (0,0,-1)
particle_energy = 1e8 # GeV
particle = pyrex.Particle(particle_id=particle_type, vertex=initial_position,
                          direction=direction_vector, energy=particle_energy)

The interaction attribute is an instance of an Interaction class (NeutrinoInteraction by default) which is a model for how the neutrino interacts in the ice. It has a kind denoting whether the interaction will be charged-current or neutral-current, an inelasticity, em_frac and had_frac describing the resulting particle shower(s), and cross_section and interaction_length in the ice at the energy of the parent Particle object:

type(particle.interaction)
particle.interaction.kind
particle.interaction.inelasticity
particle.interaction.em_frac
particle.interaction.had_frac
particle.interaction.cross_section
particle.interaction.interaction_length

PyREx also includes a number of classes for generating random neutrinos in various ice volumes. The CylindricalGenerator and RectangularGenerator classes generate neutrinos uniformly in cylindrical or rectangular volumes respectively. These generator classes take as arguments the necessary dimensions and an energy (which can be a scalar value or a function returning scalar values). Additional arguments include whether to reject events shadowed by the Earth, as well as a desired flavor ratio:

volume_radius = 1000 # m
volume_depth = 500 # m
flavor_ratio = (1, 1, 1) # even distribution of neutrino flavors
source = 'astrophysical' # could also be cosmogenic, changes neutrino:antineutrino ratios
my_generator = pyrex.CylindricalGenerator(dr=volume_radius,
                                          dz=volume_depth,
                                          energy=particle_energy,
                                          shadow=False,
                                          flavor_ratio=flavor_ratio,
                                          source=source)
my_generator.create_event()

The create_event() method of the generator returns an Event object, which contains a tree of Particle objects representing the event. Currently this tree will only contain a single neutrino, but could be expanded in the future in order to describe more exotic events. The neutrino is available as the only element in the list Event.roots. It can also be accessed by iterating the Event object.

Lastly, PyREx includes ListGenerator and FileGenerator classes which can be used to reproduce pre-generated events from either a list or from simulation output files, respectively. For example, to continuously re-throw our Particle object from above:

repetitive_generator = pyrex.ListGenerator([pyrex.Event(particle)])
repetitive_generator.create_event()
repetitive_generator.create_event()

Full Simulation

PyREx provides the EventKernel class to control a basic simulation including the creation of neutrinos and their respective signals, the propagation of their pulses to the antennas, and the triggering of the antennas. The EventKernel is designed to be modular and can use a specific ice model, ray tracer, output file writer, and signal times array as specified in optional arguments, along with some basic parameters used to speed up the simulation (the defaults are explicitly specified below).

The EventKernel.event() method handles the full simulation of a single event: generating a random neutrino event with a corresponding Askaryan signal, propagating the signal to each antenna in the detector, and processing the response of the antennas to the incoming signal(s). The method returns the Event object simulated and optionally may return whether the detector was triggered by the event.

particle_generator = pyrex.CylindricalGenerator(dr=1000, dz=1000, energy=1e8)
detector = []
for i, z in enumerate([-100, -150, -200, -250]):
    detector.append(
        pyrex.DipoleAntenna(name="antenna_"+str(i), position=(0, 0, z),
                            center_frequency=250e6, bandwidth=300e6,
                            temperature=300, resistance=0, effective_height=0.6,
                            trigger_threshold=1e-4, noisy=False)
    )
kernel = pyrex.EventKernel(generator=particle_generator,
                           antennas=detector,
                           ice_model=pyrex.ice,
                           ray_tracer=pyrex.RayTracer,
                           signal_times=np.linspace(-50e-9, 50e-9, 2000,
                                                    endpoint=False),
                           event_writer=None, triggers=None,
                           offcone_max=40, weight_min=None,
                           attenuation_interpolation=0.1)

triggered = False
while not triggered:
    for antenna in detector:
        antenna.clear()
    event = kernel.event()
    for antenna in detector:
        if antenna.is_hit:
            triggered = True
            break

particle = event.roots[0]
print("Particle type:   ", particle.id)
print("Shower vertex:   ", particle.vertex)
print("Shower axis:     ", particle.direction)
print("Particle energy: ", particle.energy)
print("Interaction type:", particle.interaction.kind)
print("Electromagnetic shower fraction:", particle.interaction.em_frac)
print("Hadronic shower fraction:       ", particle.interaction.had_frac)
print("Event weight:", particle.weight)

for antenna in detector:
    for i, wave in enumerate(antenna.waveforms):
        plt.plot(wave.times * 1e9, wave.values)
        plt.xlabel("Time (ns)")
        plt.ylabel("Voltage (V)")
        plt.title(antenna.name + " - waveform "+str(i))
_images/full_sim_1.png _images/full_sim_2.png

Data File I/O

The File class controls the reading and writing of data files for simulation. At the most basic it takes a filename and mode in which to open the file, and if the file type is supported the object will be the appropriate file handler. Like python’s open() function, the File class works as a context manager and should preferably be used in with statements. Currently the only data file type supported by PyREx is HDF5. Depending on whether an HDF5 file is being read or written there are additional keyword arguments that may be provided to File. HDF5 files support the following modes: ‘r’ for read-only, ‘w’ for write (overwrites existing file), ‘a’/’r+’ for append (doesn’t overwrite existing file), and ‘x’ for write (fails if file exists already).

If writing an HDF5 file, the optional arguments specify which event data to write. The available write options are write_particles, write_triggers, write_antenna_triggers, write_rays, write_noise, and write_waveforms. Most of these are self-explanatory, but write_antenna_triggers will write triggering information for each antenna in the detector and write_noise will write the frequency data required to replicate noise waveforms. The last optional argument is require_trigger which specifies which data should only be written when the detector is triggered. If a boolean value, requires trigger or not for all data with the exception of particle and trigger data, which is always written. If a list of strings, the listed data will require triggers and any other data will always be written.

The most straightforward way to write data files is to pass a File object to the EventKernel object handling the simulation. In such a case, a global trigger condition should be passed to the EventKernel as well, either as a function which acts on a detector object, or as the “global” key in a dictionary of functions representing various trigger conditions:

particle_generator = pyrex.CylindricalGenerator(dr=1000, dz=1000, energy=1e8)
detector = []
for i, z in enumerate([-100, -150, -200, -250]):
    detector.append(
        pyrex.DipoleAntenna(name="antenna_"+str(i), position=(0, 0, z),
                            center_frequency=250e6, bandwidth=300e6,
                            temperature=300, resistance=0, effective_height=0.6,
                            trigger_threshold=1e-8, noisy=False)
    )

def global_trigger_condition(det):
    for ant in det:
        if ant.is_hit:
            return True
    return False

def even_antenna_trigger(det):
    for i, ant in enumerate(det):
        if i%2==0 and ant.is_hit:
            return True
    return False

trigger_conditions = {
    "global": global_trigger_condition,
    "evens": even_antenna_trigger,
    "ant1": lambda det: det[1].is_hit
}

with pyrex.File('my_data_file.h5', 'w') as f:
    kernel = pyrex.EventKernel(generator=particle_generator,
                               antennas=detector,
                               event_writer=f,
                               triggers=trigger_conditions)

    for _ in range(10):
        for antenna in detector:
            antenna.clear()
        event, triggered = kernel.event()
        print(triggered)

If you want to manually write the data file, then the File.set_detector() and File.add() methods are necessary. File.set_detector() associates the given antennas with the file object (and writes their data) and File.add() adds the data from the given event to the file. Here we also manually open and close the file object with File.open() and File.close(), and add some metadata to the file with File.add_file_metadata():

f = pyrex.File('my_data_file_2.h5', 'w')
f.open()

f.add_file_metadata({"write_style": "manual", "number_of_events": 10})

f.set_detector(detector)

kernel = pyrex.EventKernel(generator=particle_generator,
                           antennas=detector)

for _ in range(10):
    for antenna in detector:
        antenna.clear()
    event = kernel.event()
    triggered = False
    for antenna in detector:
        if antenna.is_hit:
            triggered = True
            break
    f.add(event, triggered=triggered)

f.close()

The File objects also support writing miscellaneous analysis data to the file. File.create_analysis_dataset() creates and returns a basic HDF5 dataset. File.create_analysis_metadataset() creates a joined set of tables for string and float data, which can be written to with File.add_analysis_metadata(). And finally, File.add_analysis_indices() allows for linking event indices to specific rows of analysis data.

with pyrex.File('my_data_file.h5', 'a') as f:
    f.create_analysis_metadataset("effective_volume")
    gen_vol = (np.pi*1000**2)*1000
    # Just set an arbitrary number of triggers for now. We'll get into reading
    # files in the examples below.
    n_triggers = 5
    data = {
        "generation_volume": gen_vol,
        "veff": n_triggers/10*gen_vol,
        "error": np.sqrt(n_triggers)/10*gen_vol,
        "unit": "m^3"
    }
    f.add_analysis_metadata("effective_volume", data)

    other = f.create_analysis_dataset("meaningless_data",
                                      data=np.ones((20, 5)))
    other.attrs['rows_per_event'] = 2
    for i in range(10):
        f.add_analysis_indices("meaningless_data", global_index=i,
                               start_index=2*i, length=2)

If reading an HDF5 file, the slice_range argument specifies the size of event slices to load into memory at once when iterating over events. In general, increasing the slice_range will improve the speed of iteration at the cost of greater memory consumption. By default, the whole file is read at once.

with pyrex.File('my_data_file.h5', 'r', slice_range=100) as f:
    pass

When reading HDF5 files, there are a number of methods and attributes available to access the data. With the File object alone, File.file_metadata contains a dictionary of the file’s metadata and File.antenna_info contains a list of dictionaries with data for each antenna in the detector the file was run with. If waveform data is available, File.get_waveforms() can be used to get all waveforms in the file or a specific subset based on event_id, antenna_id, and waveform_type arguments. Finally, direct access to the contents of the HDF5 file is supported through either the proper paths or nicknames.

with pyrex.File('my_data_file.h5', 'r') as f:
    print(f.file_metadata)
    print(f.antenna_info[0])

    # No waveform data was stored above, so these will fail if run
    # All waveforms:
    # wfs = f.get_waveforms()
    # Waveforms from event 0
    # wfs = f.get_waveforms(event_id=0)
    # Waveforms in antenna 1 from all events
    # wfs = f.get_waveforms(antenna_id=1)
    # Direct waveform in antenna 4 from event 5
    # wf = f.get_waveforms(event_id=5, antenna_id=4, waveform_type=0)

    # Using full file path
    triggers = f['data/triggers']
    # Using dataset nickname
    particle_string_metadata = f['particles_meta_str']
    # Using analysis dataset nickname
    other = f['meaningless_data']

HDF5 files opened in read-only mode can also be iterated over, which allows access to the data for each event in turn. When iterating, the event objects have the following methods for accessing data. get_particle_info() and get_rays_info() return a list of dictionaries or attribute values for the event’s particles or rays, respectively. The is_neutrino, is_nubar, and flavor attributes also contain the associated basic information about the base particle of the event. get_waveforms() returns the waveforms for the event, or a specific subset based on antenna_id and waveform_type (as above). The triggered attribute contains whether the event triggered the detector and the get_triggered_components() method returns a list of the trigger conditions of the detector which were met (as specified when writing the file). And finally, if noise data is recorded for the event it is contained in the noise_bases attribute. Iteration of the HDF5 files supports slicing as long as the step size is positive-valued, and individual events can also be reached by indexing the File object.

with pyrex.File('my_data_file.h5', 'r') as f:
    for event in f:
        print(event.is_neutrino, event.is_nubar, event.flavor)
        print(event.triggered, event.get_triggered_components())
    print()

    for event in f[2:6:2]:
        print(event.get_particle_info('particle_name'),
              event.get_particle_info('vertex'))
        print(np.degrees(event.get_rays_info('receiving_angle')))
    print()

    print(f[4].get_rays_info('tof'))

    # No waveform data was stored above, so this will fail if run
    # wfs = f[4].get_waveforms(antenna_id=2)

More Examples

For more code examples, see the Example Code section and the python notebooks and scripts in the examples directory.