Source code for scanning.camera

import math
from math import cos, sin, radians, degrees, sqrt
import json
import warnings
from functools import wraps

import numpy as np
import pandas as pd
from scipy.constants import speed_of_light
import astropy.units as u

#####################
#   CAMERA MODULE
#####################

[docs]class Module(): """ Class for a single camera module (e.g. EoRSpec, CMBPol, SFH). Each module consists of three wafers/detector arrays. Each wafter contains three rhombuses. """ _stored_units = {'ang_res': u.deg, 'x': u.deg, 'y': u.deg, 'pol': u.deg, 'freq': u.Hz*10**9} # other attributes (for development): # _stored_units # _data: x, y, pol, pixel_num, rhombus, wafer # _ang_res # _freq # INITIALIZATION
[docs] def __init__(self, data=None, units=None, **kwargs) -> None: """ Create a camera module either through: | option1 : Module(data, units) | option2 : Module(freq=, F_lambda=) | option3 : Module(wavelength=, F_lambda=) Parameters -------------------- data : str, DataFrame or [dict of str -> sequence] If `str`, a file path to a csv file. If `dict` or `DataFrame`, column names map to their values. Must have columns: x, y. Recommended to have columns: pol, rhombus, and wafer. units : [dict of str -> str or Unit] or None; default None Mapping columns in `data` with their units. All columns do not need to be mapped. If not provided, all applicable units are assumed to be in degrees. Keyword Arguments ------------------- freq : float, Quantity or str; default unit GHz Center of frequency band. Must be positive. If each wafer is different (such as EoRSpec), pass a three-length list like [freq1, freq2, freq3]. wavelength : float, Quantity or str; default unit micron Intended wavelength of light. Must be positive. If each wafer is different (such as EoRSpec), pass a three-length list like [wavelength1, wavelength2, wavelength3]. F_lambda : float; default 1.2 Factor for spacing between individual detectors. Raises ------------------- ValueError `data` could not be properly interpreted. TypeError Missing parameters or unneccesary keywords. Examples -------------------------- >>> Module('file.csv', units={'x': 'arcsec'}) # specify x is in arcsec >>> Module(freq=280, F_lambda=1.2) >>> Module(wavelength='350 micron') """ # PASS IN DATA if not data is None: try: if isinstance(data, str): self._data = pd.read_csv(data, index_col=False) else: self._data = pd.DataFrame(data) except (ValueError, TypeError): raise ValueError('"data" could not be properly interpreted') # check for certain columns inside for col in ['pol', 'rhombus', 'wafer']: if not col in self._data.columns: warnings.warn(f'column {col} not in data, marking all column values for {col} as 0') self._data[col] = 0 if not 'pixel_num' in self._data.columns: self._data['pixel_num'] = self._data.index data_columns = ['x', 'y', 'pixel_num', 'pol', 'rhombus', 'wafer'] self._data = self._data[data_columns] # convert to specified units if not units is None: for col, unit in units.items(): self._data[col] = self._data[col]*u.Unit(unit).to(self._stored_units[col]) self._data = self._data.astype({'pixel_num': int, 'pol': np.int16, 'rhombus': np.uint8, 'wafer': np.uint8}) # find angular resolution and freq self._ang_res = self._find_ang_res() self._freq = self._find_freq() # GENERATE MODULE else: # check F_lambda F_lambda = kwargs.pop('F_lambda', 1.2) # get freq if 'freq' in kwargs.keys(): freq = u.Quantity(kwargs.pop('freq'), self._stored_units['freq']).value elif 'wavelength' in kwargs.keys(): wavelength = u.Quantity(kwargs.pop('wavelength'), u.micron).to(u.m).value freq = speed_of_light/wavelength/10**9 else: raise TypeError('cannot create Module without one of file, freq, or wavelength') if kwargs: raise TypeError(f'unneccary keywords passed: {kwargs}') self._freq = freq self._ang_res, self._data = self._generate_module(freq, F_lambda)
def _find_ang_res(self): ang_res = [] for wafer in np.unique(self.wafer): temp_dict = self._data[self.wafer == wafer].iloc[0:2].loc[:, ['x', 'y']].to_dict('list') ang_res.append( math.sqrt( (temp_dict['x'][0] - temp_dict['x'][1])**2 + (temp_dict['y'][0] - temp_dict['y'][1])**2 ) ) if np.allclose([ang_res[0]]*len(ang_res), ang_res): return ang_res[0] else: return ang_res def _find_freq(self): freq = [] for wafer in np.unique(self.wafer): num_wafer_pixels = np.count_nonzero(self.wafer == wafer) freq0 = 280*math.sqrt(num_wafer_pixels/3)/24 freq.append(freq0) if np.allclose([freq[0]]*len(freq), freq): return freq[0] else: return freq def _waferpixelpos(self, p, numrows, numcols, numrhombus, centeroffset): """Obtains pixel positions in a wafer, origin centered at wafer center""" theta_axes = 2*np.pi/numrhombus # 120 degree offset between the row and column axes of each rhombus numpixels = numrows*numcols*numrhombus # number of total pixels on a detector wafer pixelpos = np.zeros((numpixels, 4)) # array for storing all pixel x and y coordinates, polarizations, and rhombus count = 0 # counter for pixel assignment to rows in the pixelpos array rhombusaxisarr = np.arange(0, 2*np.pi, theta_axes) # the 3 rhombus axes angles when drawn from the center of detector # Calculate pixel positions individually in a nested loop # Note there are more efficient ways of doing this, but nested for loops is probably most flexible for changes for i, pol in zip( range(numrhombus), ((45, 0), (-45, 60), (45, 30)) ): # convert polarizations to deg pm = pol[0] pc = pol[1] # For each rhombus, determine the angle that it is rotated about the origin, # and the position of the pixel nearest to the origin rhombusaxis = rhombusaxisarr[i] x0 = centeroffset*np.cos(rhombusaxis) y0 = centeroffset*np.sin(rhombusaxis) # Inside each rhombus iterate through each pixel by deterimining the row position, # and then counting 24 pixels by column along each row for row in np.arange(numrows): xrowstart = x0 + row * p*np.cos(rhombusaxis-theta_axes/2) yrowstart = y0 + row * p*np.sin(rhombusaxis-theta_axes/2) for col in np.arange(numcols): x = xrowstart + col * p*np.cos(rhombusaxis+theta_axes/2) y = yrowstart + col * p*np.sin(rhombusaxis+theta_axes/2) pixelpos[count, :] = x, y, (count%2)*pm + pc, i count = count + 1 return pixelpos def _generate_module(self, center_freq, F_lambda=1.2): """ Generates the pixel positions of a module centered at a particular frequency, based off of code from the eor_spec_models package. The numerical values in the code are based off of the parameters for a detector array centered at 280 GHz. """ # --- SETUP --- # λ/D in rad (D = 6m, diameter of the telescope) if isinstance(center_freq, int) or isinstance(center_freq, float): center1 = center2 = center3 = center_freq ang_res = ang_res1 = ang_res2 = ang_res3 = F_lambda*math.degrees((speed_of_light)/(center_freq*10**9)/6) elif len(center_freq) == 3: center1 = center_freq[0] center2 = center_freq[1] center3 = center_freq[2] ang_res1 = F_lambda*math.degrees((speed_of_light)/(center1*10**9)/6) ang_res2 = F_lambda*math.degrees((speed_of_light)/(center2*10**9)/6) ang_res3 = F_lambda*math.degrees((speed_of_light)/(center3*10**9)/6) ang_res = [ang_res1, ang_res2, ang_res3] else: raise ValueError(f'freq {center_freq} is invalid') # Each detector wafer is 72mm from the center of the focal plane # Let wafer 1 be shifted in +x by 72mm, # and wafer 2 and 3 be rotated to 120, 240 degrees from the x-axis respectively waferoffset = 72*10**-3 # distance from focal plane center to wafer center waferangles = [0, 2*np.pi/3, 4*np.pi/3] # the wafer angle offset from the x-axis # Wafer 1 ratio1 = 280/center1 p1 = 2.75*10**-3 * ratio1 # pixel spacing (pitch) numrows1 = int(24 /ratio1) # number of rows in a rhombus numcols1 = int(24 /ratio1) # number of columns in a rhombus numrhombus1 = 3 # number of rhombuses on a detector wafer pixeloffset1 = 1.5*2.75*10**-3 # distance of nearest pixel from center of the wafer numpixels1 = numrows1*numcols1*numrhombus1 # number of total pixels on a detector wafer # Wafer 2 ratio2 = 280/center2 p2 = 2.75*10**-3 *ratio2 numrows2 = int(24 /ratio2) numcols2 = int(24 /ratio2) numrhombus2 = 3 pixeloffset2 = 1.5*2.75*10**-3 numpixels2 = numrows2*numcols2*numrhombus2 # Wafer 3 ratio3 = 280/center3 p3 = 2.75*10**-3 *ratio3 numrows3 = int(24 /ratio3) numcols3 = int(24 /ratio3) numrhombus3 = 3 pixeloffset3 = 1.5*2.75*10**-3 numpixels3 = numrows3*numcols3*numrhombus3 # --- DETECTOR PLANE PIXEL POSITIONS --- # Obtain pixel coordinates for a wafer with above parameters pixelcoords1 = self._waferpixelpos(p1,numrows1,numcols1,numrhombus1,pixeloffset1) pixelcoords2 = self._waferpixelpos(p2,numrows2,numcols2,numrhombus2,pixeloffset2) pixelcoords3 = self._waferpixelpos(p3,numrows3,numcols3,numrhombus3,pixeloffset3) ### Detector Array 1 # wafer center coordinate, relative to center of the detector offsetarray1 = [waferoffset*np.cos(waferangles[0]), waferoffset*np.sin(waferangles[0])] # pixel coordinates, relative to center of the detector pixelcoords1[:,0] = pixelcoords1[:,0] + offsetarray1[0] pixelcoords1[:,1] = pixelcoords1[:,1] + offsetarray1[1] ### Detector Array 2 offsetarray2 = [waferoffset*np.cos(waferangles[1]), waferoffset*np.sin(waferangles[1])] pixelcoords2[:,0] = pixelcoords2[:,0] + offsetarray2[0] pixelcoords2[:,1] = pixelcoords2[:,1] + offsetarray2[1] ### Detector Array 3 offsetarray3 = [waferoffset*np.cos(waferangles[2]), waferoffset*np.sin(waferangles[2])] pixelcoords3[:,0] = pixelcoords3[:,0] + offsetarray3[0] pixelcoords3[:,1] = pixelcoords3[:,1] + offsetarray3[1] # --- CLEAN UP --- # turn to deg pixelcoords1[: , 0] = pixelcoords1[: , 0]/p1*ang_res1 pixelcoords2[: , 0] = pixelcoords2[: , 0]/p2*ang_res2 pixelcoords3[: , 0] = pixelcoords3[: , 0]/p3*ang_res3 pixelcoords1[: , 1] = pixelcoords1[: , 1]/p1*ang_res1 pixelcoords2[: , 1] = pixelcoords2[: , 1]/p2*ang_res2 pixelcoords3[: , 1] = pixelcoords3[: , 1]/p3*ang_res3 # mark rhombuses pixelcoords2[:, 3] = pixelcoords2[:, 3] + 3 pixelcoords3[:, 3] = pixelcoords3[:, 3] + 6 # mark wafers pixelcoords1 = np.hstack( (pixelcoords1, [[0]]*numpixels1) ) pixelcoords2 = np.hstack( (pixelcoords2, [[1]]*numpixels2) ) pixelcoords3 = np.hstack( (pixelcoords3, [[2]]*numpixels3) ) # save to data frame data = pd.DataFrame( np.append(np.append(pixelcoords1, pixelcoords2, axis=0), pixelcoords3, axis=0), columns=['x', 'y', 'pol', 'rhombus', 'wafer'] ).astype({'pol': np.int16, 'rhombus': np.uint8, 'wafer': np.uint8}) data['pixel_num'] = data.index return ang_res, data # METHODS
[docs] def save_data(self, path_or_buf=None, columns='all'): """ Write Module object to csv file. Parameters ---------------------- path_or_buf : str, file handle or None; default None File path or object, if `None` is provided the result is returned as a dictionary. columns : sequence, str or [dict of str -> str/Unit/None]; default 'all' Columns to write. If `dict`, map column names to their desired unit and use `None` if column is unit-less. 'all' is ['x', 'y', 'pol', 'pixel_num', 'rhombus', 'wafer'] Returns ---------------------- None or [dict of str -> array] If `path_or_buf` is `None`, returns the data as a dictionary mapping column name to values. Otherwise returns `None`. Examples --------------------- >>> # saves x, y, and rhombus >>> module.save_data(path_or_buf='file.csv', columns={'x': 'arcsec', 'y': 'rad', 'rhombus': None}) """ if columns == 'all': columns = ['x', 'y', 'pol', 'pixel_num', 'rhombus', 'wafer'] # unit conversions if isinstance(columns, dict): data = pd.DataFrame() for col, unit in columns.items(): if not unit is None: data[col] = getattr(self, col).to(unit).value else: data[col] = getattr(self, col) else: data = self._data[columns] # returning if path_or_buf is None: return data.to_dict('list') else: data.to_csv(path_or_buf, index=False)
# ATTRIBUTES def __getattr__(self, attr): # for easy access of properties without unit conversions if attr.startswith('_'): prop = getattr(self, attr[1:]) if type(prop) is u.Quantity: return prop.value else: return prop else: raise AttributeError(f'type object "{type(self)}" has no attribute "{attr}"') @property def F_lambda(self): """ Quantity: F lambda. """ return self._ang_res/math.degrees(speed_of_light/self._freq.to(u.Hz)/6) @property def ang_res(self): """ Quantity or Quantity array: Angular resolution. If multiple frequencies or wavelengths were provided, this will be a three-length sequence. """ return self._ang_res*self._stored_units['ang_res'] @property def freq(self): """ Quantity or Quantity array: Center of frequency band. If multiple frequencies or wavelengths were provided, this will be a three-length sequence. """ return self._freq*self._stored_units['freq'] @property def x(self): """ Quantity array: x offset of detector pixels. """ return self._data['x'].to_numpy()*self._stored_units['x'] @property def y(self): """ Quantity array: y offset of detector pixels """ return self._data['y'].to_numpy()*self._stored_units['y'] @property def pol(self): """ Quantity array: Polarization geometry. """ return self._data['pol'].to_numpy()*self._stored_units['pol'] @property def pixel_num(self): """ int array: Pixel mumber. """ return self._data['pixel_num'].to_numpy() @property def rhombus(self): """ int array: Rhombus number. """ return self._data['rhombus'].to_numpy() @property def wafer(self): """ int array: Wafer number. """ return self._data['wafer'].to_numpy()
# some standard modules CMBPol = Module(freq=350) SFH = Module(freq=860) EoRSpec = Module(freq=[262.5, 262.5, 367.5]) Mod280 = Module(freq=280) ####################### # INSTRUMENT #######################
[docs]class Instrument(): """ A configurable instrument that holds modules. """ # other attributes (for development) # _modules: mod_rot, distance, theta, module # _slots # _instr_rot # _instr_offset _slots = dict()
[docs] def __init__(self, data=None, instr_offset=(0, 0), instr_rot=0) -> None: """ Initialize a filled Instrument: option 1: Instrument(data) or an empty Intrument: option 2: Instrument(instr_offset, instr_rot) Parameters ----------------------------- data : str or dict File path to json file or dict object. Overwrites `instr_offset` and `instr_rot`. Applicable values are in degrees unit. instr_offset : (float/Quantity/str, float/Quantity/str); default (0, 0), default unit deg Offset of the instrument from the boresight. instr_rot : float, Quantity or str; default 0, default unit deg CCW rotation of the instrument. Raises ------------------------------- ValueError could not parse "data" Examples ------------------------------ >>> import astropy.units as u >>> Instrument(inst_offset=(0, '100 arcsec'), instr_rot=3.14*u.rad) >>> Instrument('file.json') """ # config file is passed if not data is None: if isinstance(data, str): with open(data, 'r') as f: config = json.load(f) elif isinstance(data, dict): config = data else: raise TypeError('cannot parse "data"') try: # populate modules self._modules = dict() for identifier in config['modules'].keys(): self._modules[identifier] = {prop: config['modules'][identifier].pop(prop) for prop in ('dist', 'theta', 'mod_rot')} self._modules[identifier]['module'] = Module(config['modules'][identifier]) except (KeyError, ValueError): raise ValueError('could not parse "data"') self.instr_offset = config['instr_offset'] self.instr_rot = config['instr_rot'] # empty instrument else: self.instr_rot = instr_rot self.instr_offset = instr_offset self._modules = dict()
def __repr__(self) -> str: instr_repr = f'instrument: offset {self.instr_offset}, rotation {self.instr_rot}\n------------------------------------' if len(self._modules) == 0: instr_repr += '\nempty' else: for identifier in self._modules.keys(): instr_repr += "\n" + f"{identifier} \n (r, theta) = {(self._modules[identifier]['dist'], self._modules[identifier]['theta'])}, rotation = {self._modules[identifier]['mod_rot']}" return instr_repr def _check_identifier(func): @wraps(func) def wrapper(self, identifier, *args, **kwargs): if not identifier in self._modules.keys(): raise ValueError(f'identifier {identifier} is not valid') return func(self, identifier, *args, **kwargs) return wrapper # CHANGING INSTRUMENT CONFIGURATION
[docs] def add_module(self, module, location, mod_rot=0, identifier=None) -> None: """ Add a module to the instrument. Parameters ------------------------- module : Module or str A `Module` object or one of the default options ['CMBPol', 'SFH', 'EoRSpec', 'Mod280'] location : (float/Quantity/str, float/Quantity/str) or str; default unit deg A `tuple` containing the (distance, theta) from the center of the instrument in polar coordinates. Or a `str` of one of the default slot options. identifier : str or None Name of the module. If user chose a default `module` option and this is `None`, then `identifier` will be its corresponding name. mod_rot : float/Quantity/str; default 0, default unit deg CCW rotation of the module. """ # if a default option of module was chosen if isinstance(module, str): if identifier is None: identifier = module if module == 'CMBPol': module = CMBPol elif module == 'SFH': module = SFH elif module == 'EoRSpec': module = EoRSpec elif module == 'Mod280': module = Mod280 else: raise ValueError(f'module {module} is not a valid option') elif not isinstance(identifier, str): raise ValueError('Identifier string must be passed') # if a default option for location was chosen if isinstance(location, str): location = self.slots[location].value else: location = u.Quantity(location, u.deg).value # change module rotation mod_rot = u.Quantity(mod_rot, u.deg).value # if identifier is already a module that's saved if identifier in self._modules.keys(): warnings.warn(f'Module {identifier} already exists. Overwriting it...') self._modules[identifier] = {'module': module, 'dist': location[0], 'theta': location[1], 'mod_rot': mod_rot}
[docs] @_check_identifier def change_module(self, identifier, new_location=None, new_mod_rot=None, new_identifier=None) -> None: """ Change a module in the instrument. Parameters ------------------------- identifier : str Name of the module to change. new_location : (float/Quantity/str, float/Quantity/str) or str; default unit deg, optional A `tuple` containing the (distance, theta) from the center of the instrument in polar coordinates. Or a `str` of one of the default slot options. new_mod_rot : float/Quantity/str; default 0, default unit deg, optional CCW rotation of the module. new_identifier : str, optional, optional New name of the module. Raises --------------------------- ValueError "identifier" is not valid """ if new_identifier is None and new_location is None and new_mod_rot is None: warnings.warn(f'Nothing has changed for {identifier}.') return # rename identifier if not new_identifier is None: self._modules[new_identifier] = self._modules.pop(identifier) identifier = new_identifier # change location if not new_location is None: if isinstance(new_location, str): new_location = self.slots[new_location].value else: if new_location[0] is None: new_location[0] = self._modules[identifier]['dist'] if new_location[1] is None: new_location[1] = self._modules[identifier]['theta'] new_location = u.Quantity(new_location, u.deg).value self._modules[identifier]['dist'] = new_location[0] self._modules[identifier]['theta'] = new_location[1] # change module rotation if not new_mod_rot is None: self._modules[identifier]['mod_rot'] = u.Quantity(new_mod_rot, u.deg).value
[docs] @_check_identifier def delete_module(self, identifier) -> None: """ Delete a module in the instrument. Parameters ------------------------- identifier : str Name of the module. Raises --------------------------- ValueError "identifier" is not valid """ self._modules.pop(identifier)
# GETTERS
[docs] def save_data(self, path_or_buf=None): """ Saves as a dictionary like in a json format. Parameters ------------- path_or_buf : str or file handle, default None File path or object, if `None` is provided the result is returned as a `dict`. Returns ---------------------- None or dict If `path_or_buf` is `None`, returns the resulting json format as a dictionary. Otherwise returns `None`. """ # organize the configuration config = {'instr_offset': list(self.instr_offset.value), 'instr_rot': self.instr_rot.value, 'modules': dict()} for identifier in self._modules.keys(): config['modules'][identifier] = { 'dist': self._modules[identifier]['dist'], 'theta': self._modules[identifier]['theta'], 'mod_rot': self._modules[identifier]['mod_rot'], 'x': self._modules[identifier]['module'].x.value.tolist(), 'y': self._modules[identifier]['module'].y.value.tolist(), 'pol': self._modules[identifier]['module'].pol.value.tolist(), 'pixel_num': self._modules[identifier]['module'].pixel_num.tolist(), 'rhombus': self._modules[identifier]['module'].rhombus.tolist(), 'wafer': self._modules[identifier]['module'].wafer.tolist() } # push configuration if path_or_buf is None: return config else: with open(path_or_buf, 'w') as f: json.dump(config, f)
[docs] @_check_identifier def get_module(self, identifier, with_mod_rot=False, with_instr_rot=False, with_instr_offset=False, with_mod_offset=False) -> Module: """ Parameters ------------------------- identifier : str Name of the module. with_mod_rot, with_instr_rot, with_instr_offset, with_mod_offset : bool; default False Whether to apply instrument and module rotation and offset to the returned `Module`. Returns -------------------------- Module `Module` object. Raises --------------------------- ValueError "identifier" is not valid """ if not np.any([with_mod_rot, with_instr_rot, with_instr_offset, with_mod_offset]): return self._modules[identifier]['module'] else: mod_rot_rad = self.get_module_rot(identifier).to(u.rad).value if with_mod_rot else 0 instr_rot_rad = self.instr_rot.to(u.rad).value if with_instr_rot else 0 mod_dist = self.get_module_dist(identifier).value if with_mod_offset else 0 mod_theta_rad = self.get_module_theta(identifier).to(u.rad).value if with_mod_offset else 0 mod_x = mod_dist*cos(mod_theta_rad) mod_y = mod_dist*sin(mod_theta_rad) instr_offset = self.instr_offset.value if with_instr_offset else (0, 0) data = self._modules[identifier]['module'].save_data() x = np.array(data['x']) y = np.array(data['y']) data['x'] = x*cos(mod_rot_rad + instr_rot_rad) - y*sin(mod_rot_rad + instr_rot_rad) + mod_x + instr_offset[0] data['y'] = x*sin(mod_rot_rad + instr_rot_rad) + y*cos(mod_rot_rad + instr_rot_rad) + mod_y + instr_offset[1] return Module(data)
[docs] @_check_identifier def get_module_dist(self, identifier, from_boresight=False): """ Parameters ------------------------- identifier : str Name of the module. from_boresight : bool; default False From the boresight (`True`) or from the center of the instrument (`False`). Returns ------------------------- Quantity Distance away of the module in polar coordinates. Raises --------------------------- ValueError "identifier" is not valid """ return self.get_module_location(identifier, from_boresight)[0]
[docs] @_check_identifier def get_module_theta(self, identifier, from_boresight=False): """ Parameters ------------------------- identifier : str Name of the module. from_boresight : bool; default False From the boresight (`True`) or from the center of the instrument (`False`). Returns ------------------------- Quantity Angle of the module in polar coordinates. Raises --------------------------- ValueError "identifier" is not valid """ return self.get_module_location(identifier, from_boresight)[1]
[docs] @_check_identifier def get_module_rot(self, identifier, with_instr_rot=False): """ Get rotation of module from the center of the instrument. Parameters ------------------------- identifier : str Name of the module. with_instr_rot : bool; default False Whether to include instrument rotation. Returns -------------------------- Quantity Rotation of the module Raises --------------------------- ValueError "identifier" is not valid """ if with_instr_rot: return self._modules[identifier]['mod_rot']*u.deg + self.instr_rot else: return self._modules[identifier]['mod_rot']*u.deg
[docs] @_check_identifier def get_module_location(self, identifier, from_boresight=False, polar=True): """ Parameters ------------------------- identifier : str Name of the module. from_boresight : bool; default False From the boresight (`True`) or from the center of the instrument (`False`). polar : bool; default True In polar coordinates (`True`) or in cartesian coordinates (`False`) Returns --------------------------- Quantity two-tuple (distance, theta) location of module. Raises --------------------------- ValueError "identifier" is not valid """ # get module location from center of instrument in x and y dist = self._modules[identifier]['dist'] theta = self._modules[identifier]['theta'] if from_boresight: theta_rad = radians(theta) mod_x = dist*cos(theta_rad) mod_y = dist*sin(theta_rad) # get rotation and offset instr_x = self.instr_offset[0].value instr_y = self.instr_offset[1].value instr_rot_rad = self.instr_rot.to(u.rad).value mod_rot_rad = radians(self._modules[identifier]['mod_rot']) # get module location from boresight in x and y new_mod_x = mod_x*cos(instr_rot_rad + mod_rot_rad) - mod_y*sin(instr_rot_rad + mod_rot_rad) + instr_x new_mod_y = mod_x*sin(instr_rot_rad + mod_rot_rad) + mod_y*cos(instr_rot_rad + mod_rot_rad) + instr_y new_dist = sqrt(new_mod_x**2 + new_mod_y**2) new_theta = degrees(math.atan2(new_mod_y, new_mod_x)) dist = new_dist theta = new_theta if polar: return (dist, theta)*u.deg else: return (dist*cos(radians(theta)), dist*sin(radians(theta)))*u.deg
[docs] def get_slot_location(self, slot_name, from_boresight=False, polar=True): """ Parameters ------------------------- slot_name : str Name of slot. from_boresight : bool; default False From the boresight (`True`) or from the center of the instrument (`False`). polar : bool; default True In polar coordinates (`True`) or in cartesian coordinates (`False`) Returns --------------------------- Quantity two-tuple (distance, theta) location of slot. Raises --------------------------- ValueError "slot_name" is not valid """ # get slot location from center of instrument in x and y try: dist = self.slots[slot_name][0].value theta = self.slots[slot_name][1].value except KeyError: raise ValueError(f'"slot_name" {slot_name} is not valid') if from_boresight: return self.location_from_boresight(dist, theta, polar) if polar: return (dist, theta)*u.deg else: return (dist*cos(radians(theta)), dist*sin(radians(theta)))*u.deg
[docs] def location_from_boresight(self, dist, theta, polar=True): """ Given a location relative to the center of the instrument, find the location relative to the boresight. Parameters ------------------- dist : float, Quantity, or str; default unit deg Distance away from the center of the instrument. theta : float, Quantity, or str; default unit deg Angle away from the center of the instrument in polar coordinates. polar : bool; default True In polar coordinates (`True`) or in cartesian coordinates (`False`) Returns --------------------------- Quantity two-tuple (distance, theta) location of slot. """ dist = u.Quantity(dist, u.deg).value theta_rad = u.Quantity(theta, u.deg).to(u.rad).value instr_x = self.instr_offset[0].value instr_y = self.instr_offset[1].value instr_rot_rad = self.instr_rot.to(u.rad).value # get true module location in terms of x and y orignal_x = dist*cos(theta_rad) original_y = dist*sin(theta_rad) x_offset = orignal_x*cos(instr_rot_rad) - original_y*sin(instr_rot_rad) + instr_x y_offset = orignal_x*sin(instr_rot_rad) + original_y*cos(instr_rot_rad) + instr_y new_dist = sqrt(x_offset**2 + y_offset**2) new_theta = math.degrees(math.atan2(y_offset, x_offset)) if polar: return (new_dist, new_theta)*u.deg else: return (new_dist*cos(radians(new_theta)), new_dist*sin(radians(new_theta)))*u.deg
# ATTRIBUTES @property def instr_offset(self): """Quantity two-tuple: The x and y offsets of the center of the instrument from the boresight. """ return self._instr_offset*u.deg @property def instr_rot(self): """Quantity: The CCW rotation of the instrument.""" return self._instr_rot*u.deg @property def slots(self): """dict of str -> Quantity two-tuple: Map of slot name to their (distance, theta) from the center of the instrument.""" return {slot_name: slot_loc*u.deg for slot_name, slot_loc in self._slots.items()} @property def modules(self): """list of str: list of module identifiers inside the instrument.""" return [module_name for module_name in self._modules.keys()] # SETTERS @instr_offset.setter def instr_offset(self, value): self._instr_offset = (u.Quantity(value[0], u.deg).value, u.Quantity(value[1], u.deg).value) @instr_rot.setter def instr_rot(self, value): self._instr_rot = u.Quantity(value, u.deg).value
[docs]class ModCam(Instrument): _slots = {'c': (0, 0)}
[docs]class PrimeCam(Instrument): # default configuration of optics tubes at 0 deg elevation # in terms of (radius from center [deg], angle [deg]) _default_ir = 1.78 _slots = { 'c': (0, 0), 'i1': (_default_ir, -90), 'i2': (_default_ir, -30), 'i3': (_default_ir, 30), 'i4': (_default_ir, 90), 'i5': (_default_ir, 150), 'i6': (_default_ir, -150), }