def set_orientation(self, z_axis=(0, 0, 1), x_axis=(1, 0, 0)): """ Sets the orientation of the antenna. Sets up the z-axis and the x-axis of the antenna according to the given parameters. Fails if the z-axis and x-axis aren't perpendicular. Parameters ---------- z_axis : array_like, optional Vector direction of the z-axis of the antenna. x_axis : array_like, optional Vector direction of the x-axis of the antenna. Raises ------ ValueError If the z-axis and x-axis aren't perpendicular. """ self.z_axis = normalize(z_axis) self.x_axis = normalize(x_axis) if not np.isclose(np.dot(self.z_axis, self.x_axis), 0, rtol=0): raise ValueError("Antenna's x_axis must be perpendicular to its " + "z_axis")
def receive(self, signal, direction=None, polarization=None, force_real=True): """Process incoming signal according to the filter function and store it to the signals list. Subclasses may extend this fuction, but should end with super().receive(signal).""" copy = Signal(signal.times, signal.values, value_type=Signal.ValueTypes.voltage) copy.filter_frequencies(self.response, force_real=force_real) if direction is not None: # Calculate theta and phi relative to the orientation origin = self.position - normalize(direction) r, theta, phi = self._convert_to_antenna_coordinates(origin) freq_data, gain_data, phase_data = self.generate_directionality_gains( theta, phi) def interpolate_directionality(frequencies): interp_gains = np.interp(frequencies, freq_data, gain_data, left=0, right=0) interp_phases = np.interp(frequencies, freq_data, phase_data, left=0, right=0) return interp_gains * np.exp(1j * interp_phases) copy.filter_frequencies(interpolate_directionality, force_real=force_real) if polarization is None: p_gain = 1 else: p_gain = self.polarization_gain(normalize(polarization)) signal_factor = p_gain * self.efficiency if signal.value_type == Signal.ValueTypes.voltage: pass elif signal.value_type == Signal.ValueTypes.field: signal_factor /= self.antenna_factor else: raise ValueError("Signal's value type must be either " + "voltage or field. Given " + str(signal.value_type)) copy.values *= signal_factor self.signals.append(copy)
def receive(self, signal, origin=None, polarization=None): """Process incoming signal according to the filter function and store it to the signals list. Subclasses may extend this fuction, but should end with super().receive(signal).""" copy = Signal(signal.times, signal.values, value_type=Signal.ValueTypes.voltage) copy.filter_frequencies(self.response) if origin is None: d_gain = 1 else: # Calculate theta and phi relative to the orientation r, theta, phi = self._convert_to_antenna_coordinates(origin) d_gain = self.directional_gain(theta=theta, phi=phi) if polarization is None: p_gain = 1 else: p_gain = self.polarization_gain(normalize(polarization)) signal_factor = d_gain * p_gain * self.efficiency if signal.value_type==Signal.ValueTypes.voltage: pass elif signal.value_type==Signal.ValueTypes.field: signal_factor /= self.antenna_factor else: raise ValueError("Signal's value type must be either " +"voltage or field. Given "+str(signal.value_type)) copy.values *= signal_factor self.signals.append(copy)
def slant_depth(self, endpoint, direction, step=500): """ Calculates the column density of a chord cutting through Earth. Integrates the Earth's density along the chord, resulting in a column density (or material thickness) with units of mass per area. Parameters ---------- endpoint : array_like Vector position (m) of the chord endpoint, in a coordinate system centered on the surface of the Earth (e.g. a negative third coordinate represents the depth below the surface). direction : array_like Vector direction of the chord, in a coordinate system centered on the surface of the Earth (e.g. a negative third coordinate represents the chord pointing into the Earth). step : float, optional Step size (m) for the density integration. Returns ------- float Column density (g/cm^2) along the chord starting at `endpoint` and passing through the Earth at the given `direction`. See Also -------- PREM.density : Calculates the Earth's density at a given radius. """ # Convert to Earth-centric coordinate system (e.g. center of the Earth # is at (0, 0, 0)) endpoint = np.array( [endpoint[0], endpoint[1], endpoint[2] + self.earth_radius]) direction = normalize(direction) dot_prod = np.dot(endpoint, direction) # Check for intersection of line and sphere discriminant = dot_prod**2 - np.sum(endpoint**2) + self.earth_radius**2 if discriminant <= 0: return 0 # Calculate the distance at which the line intersects the sphere distance = -dot_prod + np.sqrt(discriminant) if distance <= 0: return 0 # Parameterize line integral with ts from 0 to 1, with steps just under # the given step size (in meters) n_steps = int(distance / step) if distance % step: n_steps += 1 ts = np.linspace(0, 1, n_steps) xs = endpoint[0] + ts * distance * direction[0] ys = endpoint[1] + ts * distance * direction[1] zs = endpoint[2] + ts * distance * direction[2] rs = np.sqrt(xs**2 + ys**2 + zs**2) rhos = self.density(rs) # Integrate the density times the distance along the chord return 100 * np.trapz(rhos * distance, ts)
def _convert_local_to_global(position, local_coords): """ Convert local "station" coordinates into global "array" coordinates. Parameters ---------- position : array_like Cartesian position in local "station" coordinates to be transformed. local_coords : array_like Matrix of local coordinate system axes in global coordinates. Returns ------- global_position : ndarray Cartesian position transformed to the global "array" coordinates. """ local_x = normalize(local_coords[0]) global_x = (1, 0, 0) # Find angle between x-axes and rotation axis perpendicular to both x-axes angle = np.arccos(np.dot(local_x, global_x)) axis = normalize(np.cross(global_x, local_x)) # Form rotation matrix cos = np.cos(angle) sin = np.sin(angle) ux, uy, uz = axis rot = np.array([ [ cos + ux**2 * (1 - cos), ux * uy * (1 - cos) - uz * sin, ux * uz * (1 - cos) + uy * sin ], [ uy * ux * (1 - cos) + uz * sin, cos + uy**2 * (1 - cos), uy * uz * (1 - cos) - ux * sin ], [ uz * ux * (1 - cos) - uy * sin, uz * uy * (1 - cos) + ux * sin, cos + uz**2 * (1 - cos) ], ]) # Rotate position to new axes return np.dot(rot, position)
def test_normalization(self): """Test that vectors are successfully normalized""" np.random.seed(SEED) for _ in range(1000): vector = np.random.normal(size=3) if np.array_equal(vector, [0, 0, 0]): continue unit = normalize(vector) quotient = vector / unit assert np.linalg.norm(unit) == pytest.approx(1) assert quotient[0] == pytest.approx(quotient[1]) assert quotient[0] == pytest.approx(quotient[2])
def get_bounce_point(self): """Calculation of point at which signal is reflected by the ice surface (z=0).""" z0 = self.from_point[2] z1 = self.to_point[2] u = self.to_point - self.from_point # x-y distance between points rho = np.sqrt(u[0]**2 + u[1]**2) # x-y distance to bounce point based on geometric arguments distance = z0 * rho / (z0 + z1) # x-y direction vector u_xy = np.array([u[0], u[1], 0]) direction = normalize(u_xy) bounce_point = self.from_point + distance * direction bounce_point[2] = 0 return bounce_point
def __init__(self, particle_id, vertex, direction, energy, interaction_model=NeutrinoInteraction, interaction_type=None, weight=None): self.id = particle_id self.vertex = np.array(vertex) self.direction = normalize(direction) self.energy = energy if inspect.isclass(interaction_model): self.interaction = interaction_model(self, kind=interaction_type) else: raise ValueError( "Particle class interaction_model must be a class") self.survival_weight = None self.interaction_weight = None self._forced_weight = weight
def event(self): """Generate particle, propagate signal through ice to antennas, process signal at antennas, and return the original particle.""" p = self.gen.create_particle() n = self.ice.index(p.vertex[2]) for ant in self.ant_array: pf = PathFinder(self.ice, p.vertex, ant.position) rpf = ReflectedPathFinder(self.ice, p.vertex, ant.position) for path in [pf, rpf]: # If path is invalid, skip it if not (path.exists): continue # p.direction and k should both be unit vectors # epol is (negative) vector rejection of k onto p.direction k = path.received_ray epol = normalize(np.vdot(k, p.direction) * k - p.direction) # In case k and p.direction are equal # (antenna directly on shower axis), just let epol be all zeros psi = np.arccos(np.vdot(p.direction, path.emitted_ray)) # TODO: Support angles larger than pi/2 if psi > np.pi / 2: continue times = np.linspace(-20e-9, 80e-9, 2048, endpoint=False) pulse = AskaryanSignal(times=times, energy=p.energy, theta=psi, n=n) path.propagate(pulse) # Dividing by path length scales Askaryan pulse properly pulse.values /= path.path_length ant.receive(pulse, origin=p.vertex, polarization=epol) return p
def propagate(self, signal=None, polarization=None): """ Propagate the signal with optional polarization along the ray path. Applies the frequency-dependent signal attenuation along the ray path and shifts the times according to the ray time of flight. Additionally provides the s and p polarization directions. Parameters ---------- signal : Signal, optional ``Signal`` object to propagate. polarization : array_like, optional Vector representing the linear polarization of the `signal`. Returns ------- tuple of Signal Tuple of ``Signal`` objects representing the s and p polarizations of the original `signal` attenuated along the ray path. Only returned if `signal` was not ``None``. tuple of ndarray Tuple of polarization vectors representing the s and p polarization directions of the `signal` at the end of the ray path. Only returned if `polarization` was not ``None``. See Also -------- pyrex.Signal : Base class for time-domain signals. """ if polarization is None: if signal is None: return else: copy = Signal(signal.times+self.tof, signal.values, value_type=signal.value_type) copy.filter_frequencies(self.attenuation) return copy else: # Unit vectors perpendicular and parallel to plane of incidence # at the launching point u_s0 = normalize(np.cross(self.emitted_direction, [0, 0, 1])) u_p0 = normalize(np.cross(u_s0, self.emitted_direction)) # Unit vector parallel to plane of incidence at the receiving point # (perpendicular vector stays the same) u_p1 = normalize(np.cross(u_s0, self.received_direction)) if signal is None: return (u_s0, u_p1) else: # Amplitudes of s and p components pol_s = np.dot(polarization, u_s0) pol_p = np.dot(polarization, u_p0) # Fresnel reflectances of s and p components f_s, f_p = self.fresnel # Apply fresnel s and p coefficients in addition to attenuation attenuation_s = lambda freqs: self.attenuation(freqs) * f_s attenuation_p = lambda freqs: self.attenuation(freqs) * f_p signal_s = Signal(signal.times+self.tof, signal.values*pol_s, value_type=signal.value_type) signal_p = Signal(signal.times+self.tof, signal.values*pol_p, value_type=signal.value_type) signal_s.filter_frequencies(attenuation_s, force_real=True) signal_p.filter_frequencies(attenuation_p, force_real=True) return (signal_s, signal_p), (u_s0, u_p1)
def event(self): """ Create a neutrino event and run it through the simulation chain. Creates a particle using the ``generator``, produces a signal from that event, propagates that signal through the ice according to the ``ice_model`` and the ``ray_tracer``, and passes it into the ``antennas`` for processing. Returns ------- event : Event The neutrino event generated which is responsible for the waveforms on the antennas. triggered : bool, optional If the ``triggers`` parameter was specified, contains whether the global trigger condition of the detector was met. See Also -------- pyrex.Event : Class for storing a tree of `Particle` objects representing an event. pyrex.Particle : Class for storing particle attributes. """ event = self.gen.create_event() ray_paths = [] polarizations = [] for i in range(len(self.antennas)): ray_paths.append([]) polarizations.append([]) for particle in event: logger.info("Processing event for %s", particle) if isinstance(self.weight_min, Sequence): if ((particle.survival_weight is not None and particle.survival_weight < self.weight_min[0]) or (particle.interaction_weight is not None and particle.interaction_weight < self.weight_min[1])): logger.debug("Skipping particle with weight below %s", self.weight_min) continue elif particle.weight < self.weight_min: logger.debug("Skipping particle with weight below %s", self.weight_min) continue for i, ant in enumerate(self.antennas): rt = self.ray_tracer(particle.vertex, ant.position, ice_model=self.ice) # If no path(s) between the points, skip ahead if not rt.exists: logger.debug("Ray paths to %s do not exist", ant) continue theta_c = np.arccos(1 / self.ice.index(particle.vertex[2])) ray_paths[i].extend(rt.solutions) for path in rt.solutions: # nu_pol is the signal polarization at the neutrino vertex # It's calculated as the (negative) vector rejection of # path.emitted_direction onto particle.direction, making # epol orthogonal to path.emitted_direction in the same # plane as particle.direction and path.emitted_direction # This is equivalent to the vector triple product # (particle.direction x path.emitted_direction) x # path.emitted_direction # In the case when path.emitted_direction and # particle.direction are equal, just let nu_pol be zeros nu_pol = normalize( np.vdot(path.emitted_direction, particle.direction) * path.emitted_direction - particle.direction) polarizations[i].append(nu_pol) psi = np.arccos( np.vdot(particle.direction, path.emitted_direction)) logger.debug("Angle to %s is %f degrees", ant, np.degrees(psi)) try: if np.abs(psi - theta_c) > self.offcone_max: raise ValueError("Viewing angle is larger than " + "offcone limit " + str(np.degrees(self.offcone_max))) pulse = self.signal_model( times=self.signal_times, particle=particle, viewing_angle=psi, viewing_distance=path.path_length, ice_model=self.ice) except ValueError as err: logger.debug("Eliminating invalid Askaryan signal: %s", err) ant.receive( EmptySignal(self.signal_times + path.tof, value_type=EmptySignal.Type.field)) else: ant_pulses, ant_pols = path.propagate( signal=pulse, polarization=nu_pol, attenuation_interpolation=self. attenuation_interpolation) ant.receive(ant_pulses, direction=path.received_direction, polarization=ant_pols) if self.triggers is None: triggered = None elif isinstance(self.triggers, dict): triggered = { key: trigger_func(self.antennas) for key, trigger_func in self.triggers.items() } else: triggered = self.triggers(self.antennas) if self.writer is not None: self.writer.add(event=event, triggered=triggered, ray_paths=ray_paths, polarizations=polarizations, events_thrown=self.gen.count - self._gen_count) self._gen_count = self.gen.count if triggered is None: return event elif isinstance(self.triggers, dict): return event, triggered['global'] else: return event, triggered
def apply_response(self, signal, direction=None, polarization=None, force_real=False): """ Process the complete antenna response for an incoming signal. Processes the incoming signal according to the frequency response of the antenna, the efficiency, and the antenna factor. May also apply the directionality and the polarization gain depending on the provided parameters. Subclasses may wish to overwrite this function if the full antenna response cannot be divided nicely into the described pieces. Parameters ---------- signal : Signal Incoming ``Signal`` object to process. direction : array_like, optional Vector denoting the direction of travel of the signal as it reaches the antenna (in the global coordinate frame). If ``None`` no directional response will be applied. polarization : array_like, optional Vector denoting the signal's polarization direction (in the global coordinate frame). If ``None`` no polarization gain will be applied. force_real : boolean, optional Whether or not the frequency response should be redefined in the negative-frequency domain to keep the values of the filtered signal real. Returns ------- Signal Processed ``Signal`` object after the complete antenna response has been applied. Should have a ``value_type`` of ``voltage``. Raises ------ ValueError If the given `signal` does not have a ``value_type`` of ``voltage`` or ``field``. See Also -------- pyrex.Signal : Base class for time-domain signals. """ new_signal = signal.copy() new_signal.value_type = Signal.Type.voltage new_signal.filter_frequencies(self.frequency_response, force_real=force_real) if direction is None: d_gain = 1 else: # Calculate theta and phi relative to the antenna's orientation origin = self.position - normalize(direction) r, theta, phi = self._convert_to_antenna_coordinates(origin) d_gain = self.directional_gain(theta=theta, phi=phi) if polarization is None: p_gain = 1 else: p_gain = self.polarization_gain(normalize(polarization)) signal_factor = d_gain * p_gain * self.efficiency if signal.value_type == Signal.Type.voltage: pass elif signal.value_type == Signal.Type.field: signal_factor /= self.antenna_factor else: raise ValueError("Signal's value type must be either " + "voltage or field. Given " + str(signal.value_type)) new_signal *= signal_factor return new_signal
def received_ray(self): """Direction from which ray is received.""" return normalize(self.to_point - self.bounce_point)
def apply_response(self, signal, direction=None, polarization=None, force_real=True): """ Process the complete antenna response for an incoming signal. Processes the incoming signal according to the frequency response of the antenna, the efficiency, and the antenna factor. May also apply the directionality and the polarization gain depending on the provided parameters. Subclasses may wish to overwrite this function if the full antenna response cannot be divided nicely into the described pieces. Parameters ---------- signal : Signal Incoming ``Signal`` object to process. direction : array_like, optional Vector denoting the direction of travel of the signal as it reaches the antenna (in the global coordinate frame). If ``None`` no directional response will be applied. polarization : array_like, optional Vector denoting the signal's polarization direction (in the global coordinate frame). If ``None`` no polarization gain will be applied. force_real : boolean, optional Whether or not the frequency response should be redefined in the negative-frequency domain to keep the values of the filtered signal real. Returns ------- Signal Processed ``Signal`` object after the complete antenna response has been applied. Should have a ``value_type`` of ``voltage``. Raises ------ ValueError If the given `signal` does not have a ``value_type`` of ``voltage`` or ``field``. Or if only one of `direction` and `polarization` is specified. See Also -------- pyrex.Signal : Base class for time-domain signals. """ new_signal = signal.copy() new_signal.value_type = Signal.Type.voltage freq_response = self.frequency_response if direction is not None and polarization is not None: # Calculate theta and phi relative to the orientation origin = self.position - normalize(direction) r, theta, phi = self._convert_to_antenna_coordinates(origin) # Calculate polarization vector in the antenna coordinates y_axis = np.cross(self.z_axis, self.x_axis) transformation = np.array([self.x_axis, y_axis, self.z_axis]) ant_pol = np.dot(transformation, normalize(polarization)) # Calculate directional response as a function of frequency directive_response = self.directional_response(theta, phi, ant_pol) freq_response = lambda f: (self.frequency_response(f) * directive_response(f)) elif (direction is not None and polarization is None or direction is None and polarization is not None): raise ValueError( "Direction and polarization must be specified together") # Apply (combined) frequency response new_signal.filter_frequencies(freq_response, force_real=force_real) signal_factor = self.efficiency if signal.value_type == Signal.Type.voltage: pass elif signal.value_type == Signal.Type.field: signal_factor /= self.antenna_factor else: raise ValueError("Signal's value type must be either " + "voltage or field. Given " + str(signal.value_type)) new_signal *= signal_factor return new_signal
def emitted_ray(self): """Direction in which ray is emitted.""" return normalize(self.to_point - self.from_point)
def test_zero(self): """Test that the zero vector doesn't cause problems""" unit = normalize([0, 0, 0]) assert np.array_equal(unit, [0, 0, 0])
def __init__(self, vertex, direction, energy): self.vertex = np.array(vertex) self.direction = normalize(direction) self.energy = energy
def event(self): """ Create a neutrino event and run it through the simulation chain. Creates a particle using the ``generator``, produces a signal from that event, propagates that signal through the ice according to the ``ice_model`` and the ``ray_tracer``, and passes it into the ``antennas`` for processing. Returns ------- event : Event The neutrino event generated which is responsible for the waveforms on the antennas. triggered : bool, optional If the ``triggers`` parameter was specified, contains whether the global trigger condition of the detector was met. See Also -------- pyrex.Event : Class for storing a tree of `Particle` objects representing an event. pyrex.Particle : Class for storing particle attributes. """ event = self.gen.create_event() ray_paths = [] polarizations = [] for i in range(len(self.antennas)): ray_paths.append([]) polarizations.append([]) for particle in event: logger.info("Processing event for %s", particle) for i, ant in enumerate(self.antennas): rt = self.ray_tracer(particle.vertex, ant.position, ice_model=self.ice) # If no path(s) between the points, skip ahead if not rt.exists: logger.debug("Ray paths to %s do not exist", ant) continue ray_paths[i].extend(rt.solutions) for path in rt.solutions: # nu_pol is the signal polarization at the neutrino vertex # It's calculated as the (negative) vector rejection of # path.emitted_direction onto particle.direction, making # epol orthogonal to path.emitted_direction in the same # plane as particle.direction and path.emitted_direction # This is equivalent to the vector triple product # (particle.direction x path.emitted_direction) x # path.emitted_direction # In the case when path.emitted_direction and # particle.direction are equal, just let nu_pol be zeros nu_pol = normalize( np.vdot(path.emitted_direction, particle.direction) * path.emitted_direction - particle.direction) polarizations[i].append(nu_pol) psi = np.arccos( np.vdot(particle.direction, path.emitted_direction)) logger.debug("Angle to %s is %f degrees", ant, np.degrees(psi)) # TODO: Support angles larger than pi/2 # (low priority since these angles are far from the # cherenkov cone) if psi > np.pi / 2: continue pulse = self.signal_model( times=self.signal_times, particle=particle, viewing_angle=psi, viewing_distance=path.path_length, ice_model=self.ice) ant_pulses, ant_pols = path.propagate(signal=pulse, polarization=nu_pol) ant.receive(ant_pulses, direction=path.received_direction, polarization=ant_pols) if self.triggers is None: triggered = None elif isinstance(self.triggers, dict): triggered = { key: trigger_func(self.antennas) for key, trigger_func in self.triggers.items() } else: triggered = self.triggers(self.antennas) if self.writer is not None: self.writer.add(event=event, triggered=triggered, ray_paths=ray_paths, polarizations=polarizations, events_thrown=self.gen.count - self._gen_count) self._gen_count = self.gen.count if triggered is None: return event elif isinstance(self.triggers, dict): return event, triggered['global'] else: return event, triggered
def set_orientation(self, z_axis=[0,0,1], x_axis=[1,0,0]): self.z_axis = normalize(z_axis) self.x_axis = normalize(x_axis) if np.dot(self.z_axis, self.x_axis)!=0: raise ValueError("Antenna's x_axis must be perpendicular to its " +"z_axis")