"""
Set detector constants
"""
import warnings
import numpy as np
import cupy as cp
import yaml
from collections import defaultdict
from larpix.packet import Packet_v2, Packet_v3
# LArPix settings for Packet data structure
LARPIX_REGISTRY = {
2: {"packet_version": Packet_v2,
"hdf5_version": "2.4",
"packet_type": 0,
},
3: {"packet_version": Packet_v3,
"hdf5_version": "3.0",
"packet_type": 1,
},
}
from .units import mm, cm, mV, V, kV, e
###################
# LArTPC drift
###################
#: Detector temperature in K
TEMPERATURE = 87.17
#: Liquid argon density in :math:`g/cm^3`
LAR_DENSITY = 1.38 # g/cm^3
#: Electric field magnitude in :math:`kV/cm`
E_FIELD = 0.50 # kV/cm
#: Drift velocity in :math:`cm/\mu s`
V_DRIFT = 0.1648 # cm / us,
#: Electron mobility constants
ELECTRON_MOBILITY_PARAMS = 551.6, 7158.3, 4440.43, 4.29, 43.63, 0.2053
#: Electron lifetime in :math:`\mu s`
ELECTRON_LIFETIME = 2.2e3 # us,
#: Longitudinal diffusion coefficient in :math:`cm^2/\mu s`
LONG_DIFF = 4.0e-6 # cm * cm / us
#: Transverse diffusion coefficient in :math:`cm^2/\mu s`
TRAN_DIFF = 8.8e-6 # cm * cm / us
###################
# TPC geometry
###################
#: TPC drift length in :math:`cm`
DRIFT_LENGTH = 0
#: Time for the full drift calculated from the nominal drift velocity
DRIFT_MAX_TIME = 0
#: Borders of each TPC volume in :math:`cm`
TPC_BORDERS = np.zeros((0, 3, 2))
#: TPC offsets wrt the origin in :math:`cm`
TPC_OFFSETS = np.zeros((0, 3, 2))
#: Pixel tile borders in :math:`cm`
TILE_BORDERS = np.zeros((2,2))
###################
# LArPix
###################
#: Time sampling in :math:`\mu s`
TIME_SAMPLING = 0.1 # us
#: Time sampling in the pixel response file in :math:`\mu s`
RESPONSE_SAMPLING = 0.05
#: Spatial sampling in the pixel response file in :math:`cm`
RESPONSE_BIN_SIZE = 0.04434
#: The longest cathode charge response in time :math:`\mu s`
RESPONSE_MAX_TIME = 0.0
#: The maximum radius to consider the neighbouring charge response
MAX_RADIUS = 4
#: The step size to chop up segments :math:`cm`
#: MIN_STEP_SIZE should be comparable to the smallest bin size in x,y,t of the response file
#: The bin size in x, y is ~0.04 cm (1/10 of a pixel size),
#: and the bin size in t is 50 ns, which is roughly 0.007 cm
MIN_STEP_SIZE = 0.001 # allow per response bin to have a "reasonable" diffusion distribution
#: Default value for pixel_plane, to indicate out-of-bounds edep
DEFAULT_PLANE_INDEX = 0x0000BEEF
#: Total number of pixels
N_PIXELS = 0, 0
#: Number of pixels in each tile
N_PIXELS_PER_TILE = 0, 0
#: Dictionary between pixel ID and its position in the pixel array
PIXEL_CONNECTION_DICT = {}
#: Pixel pitch in :math:`cm`
PIXEL_PITCH = 0.4434
#: Tile position wrt the center of the anode in :math:`cm`
TILE_POSITIONS = {}
#: Tile orientations in each anode
TILE_ORIENTATIONS = {}
#: Map of tiles in each anode
TILE_MAP = ()
#: Association between chips and io channels
TILE_CHIP_TO_IO = {}
#: Association between modules and io groups
MODULE_TO_IO_GROUPS = {}
#: Association between modules and tpcs
MODULE_TO_TPCS = {}
TPC_TO_MODULE = {}
###################
# LArPix FEE
###################
#: Discrimination threshold in e-
DISCRIMINATION_THRESHOLD = 5e3 # e-
#: ADC hold delay in clock cycles
ADC_HOLD_DELAY = 15
#: ADC busy delay in clock cycles
ADC_BUSY_DELAY = 9
#: periodic reset cycles in clock cycles
PERIODIC_RESET_CYCLES = -1
#: Reset time in clock cycles
RESET_CYCLES = 1
#: Clock cycle time in :math:`\mu s`
CLOCK_CYCLE = 0.1
#: Clock rollover / reset time in larpix clock ticks (32-digit clock)
ROLLOVER_CYCLES = 2**31
#: PPS reset time
PPS_CYCLES = 10**6 / CLOCK_CYCLE
#: True if using PPS reset / false for clock rollover
USE_PPS_ROLLOVER = True # leaving True as default
#: Clock reset, either ROLLOVER_CYCLES or PPS_CYCLES
if USE_PPS_ROLLOVER:
CLOCK_RESET_PERIOD = int(PPS_CYCLES)
else:
CLOCK_RESET_PERIOD = int(ROLLOVER_CYCLES)
#: Front-end gain in :math:`mV/e-`
GAIN = 4 / 1e3 # mV/e
#: Buffer risetime in :math:`\mu s` (set >0 to include buffer response simulation)
BUFFER_RISETIME = 0.100
#: Common-mode voltage in :math:`mV`
V_CM = 478.1 # mV
#: Reference voltage in :math:`mV`
V_REF = 1568 # mV
#: Pedestal voltage in :math:`mV`
V_PEDESTAL = 580 # mV
#: Number of ADC counts
ADC_COUNTS = 2**8
#: Scale factor to adjust the ADC range
ADC_SCALE_FACTOR = 1 # integer >= 1
#: Reset noise in e-
RESET_NOISE_CHARGE = 900 # e
#: Uncorrelated noise in e-
UNCORRELATED_NOISE_CHARGE = 500 # e
#: Discriminator noise in e-
DISCRIMINATOR_NOISE = 650 # e
#: LArPix version (default v2)
LARPIX_VERSION = 2
#: LArPix packet class version
PACKET_VERSION = 2
#: Data packet type
PACKET_TYPE = 0 # 0: LArPix-v2; 1: LArPix-v3
#: LArPix HDF5 dataset version
LARPIX_HDF5_VERSION = 2.4 # see larpix-control docs for version descriptions
#: Average time between events in microseconds
EVENT_RATE = 100000 # 10Hz
#: Offset of the non-beam event time in microseconds
NON_BEAM_EVENT_GAP = 0 # us
#: Pad the signal range to allow N sigmas of diffusion
DIFF_N_SIGMAS = 5
#: Distances of the neighboring pixels to the center pixel
NEIGHBORING_PIX_DIST = []
[docs]
def electron_mobility(efield, temperature):
"""
Calculation of the electron mobility w.r.t temperature and electric
field.
References:
- https://lar.bnl.gov/properties/trans.html (summary)
- https://doi.org/10.1016/j.nima.2016.01.073 (parameterization)
Args:
efield (float): electric field in kV/cm
temperature (float): temperature
Returns:
float: electron mobility in cm^2/kV/us
"""
a0, a1, a2, a3, a4, a5 = ELECTRON_MOBILITY_PARAMS
num = a0 + a1 * efield + a2 * pow(efield, 1.5) + a3 * pow(efield, 2.5)
denom = 1 + (a1 / a0) * efield + a4 * pow(efield, 2) + a5 * pow(efield, 3)
temp_corr = pow(temperature / 89, -1.5)
mu = num / denom * temp_corr * V / kV
return mu
[docs]
def load_detector_properties(config_keyword):
from ..config import get_config
cfg = get_config(config_keyword)
set_detector_properties(cfg['DET_PROPERTIES'],cfg['PIXEL_LAYOUT'])
[docs]
def get_n_modules(detprop_file):
"""
The function loads the global detector properties (not subject to the module variations)
stores the constants as global variables
Args:
detprop_file (str): detector properties YAML
filename
"""
with open(detprop_file) as df:
detprop = yaml.load(df, Loader=yaml.FullLoader)
return list(detprop['module_to_tpcs'].keys())
[docs]
def set_multi_properties(bucket, n_mod, i_module, message=""):
if isinstance(bucket, list) and (len(bucket) != n_mod and len(bucket) != 1):
raise KeyError(f'The length of provided {message} configuration file is unexpected. Please check again.')
if not isinstance(bucket, list):
prop = float(bucket)
elif i_module < 0:
prop = float(bucket[0])
if len(bucket) > 1:
warnings.warn(f'Module variation seems to be not activated, but the {message} is provided as a list. Taking the first given value.')
elif i_module > len(bucket):
prop = float(bucket[0])
warnings.warn(f'Module variation seems to be activated, but the {message} is not specified per module. Taking the first given value.')
else:
prop = float(bucket[i_module-1])
return prop
[docs]
def set_detector_properties(detprop_file, pixel_file, response_file=None, i_module=-1, geo_only=False):
"""
The function loads the detector properties and
the pixel geometry YAML files and stores the constants
as global variables
Args:
detprop_file (str): detector properties YAML filename
It is acceptable to provide a single value or a list with one element
for electric field and electron lifetime.
pixel_file (str): pixel layout YAML filename
i_module (int): module id, default value i_module = -1.
i_module < 0 means all module share the same detector configuration.
"""
global PIXEL_PITCH
global TPC_BORDERS
global PIXEL_CONNECTION_DICT
global N_PIXELS
global N_PIXELS_PER_TILE
global V_DRIFT
global E_FIELD
global TEMPERATURE
global ELECTRON_LIFETIME
global LONG_DIFF
global TRAN_DIFF
global TILE_POSITIONS
global TILE_ORIENTATIONS
global TILE_MAP
global TILE_CHIP_TO_IO
global DRIFT_LENGTH
global DRIFT_MAX_TIME
global MODULE_TO_IO_GROUPS
global MODULE_TO_TPCS
global TPC_TO_MODULE
global RESPONSE_SAMPLING
global RESPONSE_BIN_SIZE
global MAX_RADIUS
global MIN_STEP_SIZE
global TPC_OFFSETS
global MOD_IDS
global DISCRIMINATION_THRESHOLD
global ADC_HOLD_DELAY
global PERIODIC_RESET_CYCLES
global ADC_BUSY_DELAY
global RESET_CYCLES
global CLOCK_CYCLE
global ROLLOVER_CYCLES
global PPS_CYCLES
global USE_PPS_ROLLOVER
global CLOCK_RESET_PERIOD
global GAIN
global BUFFER_RISETIME
global V_CM
global V_REF
global V_PEDESTAL
global ADC_COUNTS
global ADC_SCALE_FACTOR
global RESET_NOISE_CHARGE
global UNCORRELATED_NOISE_CHARGE
global DISCRIMINATOR_NOISE
global LARPIX_VERSION
global LARPIX_HDF5_VERSION
global PACKET_VERSION
global PACKET_TYPE
global EVENT_RATE
global NON_BEAM_EVENT_GAP
global DIFF_N_SIGMAS
global NEIGHBORING_PIX_DIST
with open(detprop_file) as df:
detprop = yaml.load(df, Loader=yaml.FullLoader)
MOD_IDS = get_n_modules(detprop_file)
n_mod = len(MOD_IDS)
TPC_OFFSETS = np.array(detprop['tpc_offsets'])
# Inverting x and z axes
TPC_OFFSETS[:, [2, 0]] = TPC_OFFSETS[:, [0, 2]]
# if module variation for pixel layout file exist, "pixel_file" is a list of pixel layout file with the length of module number
if isinstance(pixel_file, list):
if i_module < 0:
pixel_file = pixel_file[0]
else:
pixel_file = pixel_file[i_module-1]
with open(pixel_file, 'r') as pf:
tile_layout = yaml.load(pf, Loader=yaml.FullLoader)
PIXEL_PITCH = tile_layout['pixel_pitch'] * mm / cm
chip_channel_to_position = tile_layout['chip_channel_to_position']
PIXEL_CONNECTION_DICT = {tuple(pix): (chip_channel//1000,chip_channel%1000) for chip_channel, pix in chip_channel_to_position.items()}
TILE_CHIP_TO_IO = tile_layout['tile_chip_to_io']
xs = np.array(list(chip_channel_to_position.values()))[:,0] * PIXEL_PITCH
ys = np.array(list(chip_channel_to_position.values()))[:,1] * PIXEL_PITCH
TILE_BORDERS[0] = [-(max(xs) + PIXEL_PITCH)/2, (max(xs) + PIXEL_PITCH)/2]
TILE_BORDERS[1] = [-(max(ys) + PIXEL_PITCH)/2, (max(ys) + PIXEL_PITCH)/2]
tile_indeces = tile_layout['tile_indeces']
TILE_ORIENTATIONS = tile_layout['tile_orientations']
TILE_POSITIONS = tile_layout['tile_positions']
tpc_ids = np.unique(np.array(list(tile_indeces.values()))[:,0], axis=0)
anodes = defaultdict(list)
cathode_directions = dict() # What direction the cathode is relative to anode
for tpc_id in tpc_ids:
tile_cathode_directions = []
for tile in tile_indeces:
if tile_indeces[tile][0] == tpc_id:
anodes[tpc_id].append(TILE_POSITIONS[tile])
tile_cathode_directions.append(TILE_ORIENTATIONS[tile][0])
if len(set(tile_cathode_directions)) != 1:
raise ValueError("Tiles in same anode plane have different drift directions.")
if tile_cathode_directions[0] not in [1, -1]:
raise ValueError("Cathode direction should be either 1 or -1.")
cathode_directions[tpc_id] = tile_cathode_directions[0]
try:
DRIFT_LENGTH = tile_layout['drift_length'] * mm / cm
except:
mod_anodes = np.array(list(TILE_POSITIONS.values()))[:, 0]
DRIFT_LENGTH = 0.5 * (max(mod_anodes) - min(mod_anodes)) * mm / cm
DRIFT_MAX_TIME = DRIFT_LENGTH / V_DRIFT
TPC_OFFSETS = np.array(detprop['tpc_offsets'])
TPC_OFFSETS[:, [2, 0]] = TPC_OFFSETS[:, [0, 2]]
TPC_BORDERS = np.empty((TPC_OFFSETS.shape[0] * tpc_ids.shape[0], 3, 2))
for it, tpc_offset in enumerate(TPC_OFFSETS):
for ia, anode in enumerate(anodes):
tiles = np.vstack(anodes[anode])
tiles *= mm / cm
cathode_direction = cathode_directions[anode]
x_border = min(tiles[:,2]) + TILE_BORDERS[0][0] + tpc_offset[0], \
max(tiles[:,2]) + TILE_BORDERS[0][1] + tpc_offset[0]
y_border = min(tiles[:,1]) + TILE_BORDERS[1][0] + tpc_offset[1], \
max(tiles[:,1]) + TILE_BORDERS[1][1] + tpc_offset[1]
z_border = min(tiles[:,0]) + tpc_offset[2], \
max(tiles[:,0]) + DRIFT_LENGTH * cathode_direction + tpc_offset[2]
TPC_BORDERS[it*2+ia] = (x_border, y_border, z_border)
TILE_MAP = detprop['tile_map']
ntiles_x = len(TILE_MAP[0])
ntiles_y = len(TILE_MAP[0][0])
N_PIXELS = len(np.unique(xs))*ntiles_x, len(np.unique(ys))*ntiles_y
N_PIXELS_PER_TILE = len(np.unique(xs)), len(np.unique(ys))
MODULE_TO_IO_GROUPS = detprop['module_to_io_groups']
MODULE_TO_TPCS = detprop['module_to_tpcs']
TPC_TO_MODULE = dict([(tpc, mod) for mod,tpcs in MODULE_TO_TPCS.items() for tpc in tpcs])
DIFF_N_SIGMAS = detprop.get('diff_n_sigmas', DIFF_N_SIGMAS)
if not geo_only:
# Get response sampling and bin size
if response_file is not None:
# if module variation for response file exist, "response_file" is a list of response file with the length of module number
if isinstance(response_file, list):
if i_module < 0:
response_file = response_file[0]
else:
response_file = response_file[i_module-1]
response = cp.load(response_file)
RESPONSE_SAMPLING = float(response['time_tick'])
RESPONSE_BIN_SIZE = float(response['bin_size'])
MAX_RADIUS = int(response['response'].shape[0] * RESPONSE_BIN_SIZE // PIXEL_PITCH) # assuming y,z in the response file has the symmetry
else:
warnings.warn(f'Charge response not set!')
dis_threshold_bucket = detprop.get('discrimination_threshold', DISCRIMINATION_THRESHOLD)
DISCRIMINATION_THRESHOLD = set_multi_properties(dis_threshold_bucket, n_mod, i_module, message="larpix discrimination threshold")
PERIODIC_RESET_CYCLES = detprop.get('periodic_reset_cycles', PERIODIC_RESET_CYCLES)
ADC_HOLD_DELAY = detprop.get('adc_hold_delay', ADC_HOLD_DELAY)
ADC_BUSY_DELAY = detprop.get('adc_busy_delay', ADC_BUSY_DELAY)
RESET_CYCLES = detprop.get('reset_cycles', RESET_CYCLES)
CLOCK_CYCLE = detprop.get('clock_cycle', CLOCK_CYCLE)
ROLLOVER_CYCLES = detprop.get('rollover_cycles', ROLLOVER_CYCLES)
PPS_CYCLES = detprop.get('pps_cycles', PPS_CYCLES)
USE_PPS_ROLLOVER = detprop.get('use_pps_rollover', USE_PPS_ROLLOVER)
CLOCK_RESET_PERIOD = detprop.get('clock_reset_period', CLOCK_RESET_PERIOD)
GAIN = detprop.get('larpix_gain', GAIN)
BUFFER_RISETIME = detprop.get('buffer_risetime', BUFFER_RISETIME)
V_CM = detprop.get('v_cm', V_CM)
V_REF = detprop.get('v_ref', V_REF)
V_PEDESTAL = detprop.get('v_pedestal', V_PEDESTAL)
ADC_COUNTS = detprop.get('adc_counts', ADC_COUNTS)
ADC_SCALE_FACTOR = detprop.get('adc_scale_factor', ADC_SCALE_FACTOR)
RESET_NOISE_CHARGE = detprop.get('reset_noise_charge', RESET_NOISE_CHARGE)
UNCORRELATED_NOISE_CHARGE = detprop.get('uncorrelated_noise_charge', UNCORRELATED_NOISE_CHARGE)
DISCRIMINATOR_NOISE = detprop.get('discriminator_noise', DISCRIMINATOR_NOISE)
LARPIX_VERSION = detprop.get('larpix_version', LARPIX_VERSION)
EVENT_RATE = detprop.get('event_rate', EVENT_RATE)
NON_BEAM_EVENT_GAP = detprop.get('non_beam_event_gap', NON_BEAM_EVENT_GAP)
if LARPIX_VERSION not in LARPIX_REGISTRY:
raise ValueError(f"Unknown/invalid larpix_version: {LARPIX_VERSION!r}")
else:
print(f"Using LArPix-v{LARPIX_VERSION} settings:")
print(LARPIX_REGISTRY[LARPIX_VERSION])
PACKET_VERSION = LARPIX_REGISTRY[LARPIX_VERSION]['packet_version']
PACKET_TYPE = LARPIX_REGISTRY[LARPIX_VERSION]['packet_type']
LARPIX_HDF5_VERSION = LARPIX_REGISTRY[LARPIX_VERSION]['hdf5_version']
e_field_bucket = detprop.get('e_field', E_FIELD)
E_FIELD = set_multi_properties(e_field_bucket, n_mod, i_module, message="electric field")
TEMPERATURE = detprop.get('temperature', TEMPERATURE)
V_DRIFT = E_FIELD * electron_mobility(E_FIELD, TEMPERATURE)
lifetime_bucket = detprop.get('lifetime', ELECTRON_LIFETIME)
ELECTRON_LIFETIME = set_multi_properties(lifetime_bucket, n_mod, i_module, message="electron lifetime")
LONG_DIFF = float(detprop.get('long_diff', LONG_DIFF))
TRAN_DIFF = float(detprop.get('tran_diff', TRAN_DIFF))
MAX_RADIUS = int(detprop.get('max_radius', MAX_RADIUS))
# Prepare neighbouring pixel distance for backtracking
# Currently backtracking range is used to convert from segment base to pixel base
NEIGHBORING_PIX_DIST = []
for i in range(MAX_RADIUS + 1):
for j in range(MAX_RADIUS + 1):
NEIGHBORING_PIX_DIST.append(np.sqrt(i*i + j*j))
NEIGHBORING_PIX_DIST = np.unique(np.array(NEIGHBORING_PIX_DIST, dtype=np.float32))
[docs]
def load_response(response_file):
global RESPONSE_MAX_TIME
# load the charge response for the full drift length
# shape it to be consistent with detector drift length
# by either pad or chop the values at the beginning (the side further away from the cathode)
f_res = cp.load(response_file)
response = f_res['response']
res_drift_length = float(f_res['drift_length'])
drift_ticks_diff = round((res_drift_length - DRIFT_LENGTH) / V_DRIFT / RESPONSE_SAMPLING) # time difference of the response file vs. the TPC in terms of response time ticks
if drift_ticks_diff > 0: # response is too long, chop
response = response[:, :, drift_ticks_diff:]
warnings.warn(f'The TPC drift_length is {DRIFT_LENGTH} cm and the charge response simulation uses drift_length of {res_drift_length} cm; The response drift_length is too long, chop {drift_ticks_diff} values at the beginning (close to the readout).')
elif drift_ticks_diff < 0: # response is too short, pad
response = cp.pad(response, ((0, 0), (0, 0), (abs(drift_ticks_diff), 0)))
warnings.warn(f'The TPC drift_length is {DRIFT_LENGTH} cm and the charge response simulation uses a drift_length of {res_drift_length} cm; The response drift_length is too short, pad {abs(drift_ticks_diff)} 0s at the beginning (close to the readout).')
RESPONSE_MAX_TIME = float(cp.max(cp.nonzero(response)[2]) * RESPONSE_SAMPLING) # axis 0,1 are pixel plane bins, and axis 2 is the time axis
return response