class MyQuantity(Quantity): def __init__(self,coord,unit): self.q = Quantity(coord,unit) @property def value(self): return self.q.value @property def unit(self): return self.q.unit def set_value(self,value): assert isinstance(value,(float,int)) _unit = self.q.unit self.q = Quantity(value,_unit) def change_unit(self,unit): assert isinstance(unit,(str,Unit)) _newQ = self.q.to(unit) self.q = _newQ def asunit(self,unit): _q = self.q.to(unit,equivalencies=spectral()) return _q
def test_saturation_water_pressure(): args_list = [ (1.e-30, None, apu.K), (1.e-30, None, apu.hPa), ] check_astro_quantities(atm.saturation_water_pressure, args_list) temp = Quantity([100, 200, 300], apu.K) press = Quantity([900, 1000, 1100], apu.hPa) press_w = Quantity( [2.57439748e-17, 3.23857740e-03, 3.55188758e+01], apu.hPa ) assert_quantity_allclose( atm.saturation_water_pressure(temp, press), press_w ) assert_quantity_allclose( atm.saturation_water_pressure( temp.to(apu.mK), press.to(apu.Pa) ), press_w )
def test_refractive_index(): args_list = [ (1.e-30, None, apu.K), (1.e-30, None, apu.hPa), (1.e-30, None, apu.hPa), ] check_astro_quantities(atm.refractive_index, args_list) temp = Quantity([100, 200, 300], apu.K) press = Quantity([900, 1000, 1100], apu.hPa) press_w = Quantity([200, 500, 1000], apu.hPa) refr_index = Quantity( [1.0081872, 1.0050615, 1.00443253], cnv.dimless ) assert_quantity_allclose( atm.refractive_index(temp, press, press_w), refr_index ) assert_quantity_allclose( atm.refractive_index( temp.to(apu.mK), press.to(apu.Pa), press_w.to(apu.Pa) ), refr_index )
def _get_range_from_textfields(self, min_text, max_text, linelist_units, plot_units): amin = amax = None if min_text.hasAcceptableInput() and max_text.hasAcceptableInput(): amin = float(min_text.text()) amax = float(max_text.text()) amin = Quantity(amin, plot_units) amax = Quantity(amax, plot_units) amin = amin.to(linelist_units, equivalencies=u.spectral()) amax = amax.to(linelist_units, equivalencies=u.spectral()) return (amin, amax)
def clean_up(table_in): """Create a new table with exactly the columns / info we want. """ table = Table() v = Quantity(table_in['v'].data, table_in['v'].unit) energy = v.to('MeV', equivalencies=u.spectral()) table['energy'] = energy #vFv = Quantity(table['vFv'].data, table['vFv'].unit) #flux = (vFv / (energy ** 2)).to('cm^-2 s^-1 MeV^-1') table['energy_flux'] = table_in['vFv'] table['energy_flux_err_lo'] = table_in['ed_vFv'] table['energy_flux_err_hi'] = table_in['eu_vFv'] # Compute symmetrical error because most chi^2 fitters # can't handle asymmetrical Gaussian erros table['energy_flux_err'] = 0.5 * (table_in['eu_vFv'] + table_in['ed_vFv']) table['component'] = table_in['component'] table['paper'] = table_in['paper'] mask = table['energy_flux_err_hi'] == 0 return table
def evaluate(self, x): """Wrapper around the evaluate method on the Astropy model classes. Parameters ---------- x : `~gammapy.utils.energy.Energy` Evaluation point """ if self.spectral_model == 'PowerLaw': flux = models.PowerLaw1D.evaluate(x, self.parameters.norm, self.parameters.reference, self.parameters.index) elif self.spectral_model == 'LogParabola': # LogParabola evaluation does not work with arrays because # there is bug when using '**' with Quantities # see https://github.com/astropy/astropy/issues/4764 flux = Quantity([models.LogParabola1D.evaluate(xx, self.parameters.norm, self.parameters.reference, self.parameters.alpha, self.parameters.beta) for xx in x]) else: raise NotImplementedError('Not implemented for model {}.'.format(self.spectral_model)) return flux.to(self.parameters.norm.unit)
class Spectrum2D(object): """ A 2D spectrum. Parameters ---------- dispersion : `astropy.units.Quantity` or array, shape (N,) Spectral dispersion axis. data : array, shape (N, M) The spectral data. unit : `astropy.units.UnitBase` or str, optional Unit for the dispersion axis. """ def __init__(self, dispersion, data, unit=None): self.dispersion = Quantity(dispersion, unit=unit) if unit is not None: self.wavelength = self.dispersion.to(angstrom) else: self.wavelength = self.dispersion self.data = data
def solid_angle(image): """Compute the solid angle of each pixel. This will only give correct results for CAR maps! Parameters ---------- image : `~astropy.io.fits.ImageHDU` Input image Returns ------- area_image : `~astropy.units.Quantity` Solid angle image (matching the input image) in steradians. """ # Area of one pixel at the equator cdelt0 = image.header['CDELT1'] cdelt1 = image.header['CDELT2'] equator_area = Quantity(abs(cdelt0 * cdelt1), 'deg2') # Compute image with fraction of pixel area at equator glat = coordinates(image)[1] area_fraction = np.cos(np.radians(glat)) result = area_fraction * equator_area.to('sr') return result
def logspace(cls, vmin, vmax, nbins, unit=None): """Create axis with equally log-spaced nodes if no unit is given, it will be taken from vmax Parameters ---------- vmin : `~astropy.units.Quantity`, float Lowest value vmax : `~astropy.units.Quantity`, float Highest value bins : int Number of bins unit : `~astropy.units.UnitBase`, str Unit """ if unit is not None: vmin = Quantity(vmin, unit) vmax = Quantity(vmax, unit) else: vmin = Quantity(vmin) vmax = Quantity(vmax) unit = vmax.unit vmin = vmin.to(unit) x_min, x_max = np.log10([vmin.value, vmax.value]) vals = np.logspace(x_min, x_max, nbins) return cls(vals * unit, interpolation_mode='log')
def gaussian_trimmed( array: numpy.typing.ArrayLike, kernel_size: typ.Union[int, typ.Sequence[int]] = 11, kernel_width: numpy.typing.ArrayLike = 3, proportion: u.Quantity = 25 * u.percent, ): ndim = array.ndim if np.isscalar(kernel_size): kernel_size = (kernel_size, ) * ndim kernel_size = np.array(kernel_size) if np.isscalar(kernel_width): kernel_width = (kernel_width, ) * ndim kernel_width = np.array(kernel_width) kernel_left = np.ceil(kernel_size / 2 - 1).astype(int) kernel_right = np.floor(kernel_size / 2).astype(int) proportion = proportion.to(u.percent).value array_padded = np.pad( array=array, pad_width=np.stack([kernel_left, kernel_right], axis=~0), mode='constant', constant_values=np.nan, ) array_windowed = np.lib.stride_tricks.sliding_window_view( x=array_padded, window_shape=kernel_size, subok=True, ).copy() axes_kernel = tuple(~np.arange(ndim)) limit_lower = np.nanpercentile(a=array_windowed, q=proportion, axis=axes_kernel, keepdims=True) limit_upper = np.nanpercentile(a=array_windowed, q=100 - proportion, axis=axes_kernel, keepdims=True) mask_lower = limit_lower < array_windowed mask_upper = array_windowed < limit_upper mask = mask_lower & mask_upper array_windowed[~mask] = np.nan array_windowed[~mask] = np.nan kernel = 1 coordinates = np.indices(kernel_size) - kernel_size[(Ellipsis, ) + ndim * (np.newaxis, )] / 2 for d in range(ndim): kernel = kernel * np.exp( -np.square(coordinates[d] / kernel_width[d]) / 2) return np.nansum(array_windowed * kernel, axis=axes_kernel) / np.nansum( mask * kernel, axis=axes_kernel)
def __init__(self, redshift: float, radius: u.Quantity, mass: u.Quantity) -> None: Cosmic.__init__(self, redshift) CalculationDependency.__init__(self) Jsonable.__init__(self) try: self._mass = mass.to('solMass') try: self._radius = radius.to('uas') except: tmp = radius.to('m') / self.ang_diam_dist.to('m') self._radius = u.Quantity(tmp.value, 'rad').to('uas') except: from mirage.parameters import ParametersError raise ParametersError( "Quasar could not construct itself based on the provided constructor arguments." )
def fill_spectral_other_info(data, dsi): try: q = Quantity(dsi['spec']['erange']['min'], dsi['spec']['erange']['unit']) data['spec_erange_min'] = q.to('TeV').value except KeyError: data['spec_erange_min'] = NA.fill_value['number'] try: q = Quantity(dsi['spec']['erange']['max'], dsi['spec']['erange']['unit']) data['spec_erange_max'] = q.to('TeV').value except KeyError: data['spec_erange_max'] = NA.fill_value['number'] try: data['spec_theta'] = Angle(dsi['spec']['theta']).degree except KeyError: data['spec_theta'] = NA.fill_value['number']
def get_connecting_rays(self,location:Vec2D,radius:u.Quantity) -> np.ndarray: x = location.x.to('rad').value y = location.y.to('rad').value rad = radius.to('rad').value inds = self._tree.query_ball_point((x,y),rad) # print(inds.shape) # pts = list(map(lambda ind: [ind // self._canvas_dimensions.x.value, ind % self._canvas_dimensions.y.value],inds)) return np.array(inds,dtype=np.int32)
def evaluate(self, method=None, **kwargs): """Evaluate NDData Array This function provides a uniform interface to several interpolators. The evaluation nodes are given as ``kwargs``. Currently available: `~scipy.interpolate.RegularGridInterpolator`, methods: linear, nearest Parameters ---------- method : str {'linear', 'nearest'}, optional Interpolation method kwargs : dict Keys are the axis names, Values the evaluation points Returns ------- array : `~astropy.units.Quantity` Interpolated values, axis order is the same as for the NDData array """ values = [] for axis in self.axes: # Extract values for each axis, default: nodes temp = Quantity(kwargs.pop(axis.name, axis.nodes)) # Transform to correct unit temp = temp.to(axis.unit).value # Transform to match interpolation behaviour of axis values.append(np.atleast_1d(axis._interp_values(temp))) # This is to catch e.g. typos in axis names if kwargs != {}: raise ValueError("Input given for unknown axis: {}".format(kwargs)) # This is necessary since np.append does not support the 1D case if self.dim > 1: shapes = np.concatenate([np.shape(_) for _ in values]) else: shapes = values[0].shape # Flatten in order to support 2D array input values = [_.flatten() for _ in values] points = list(itertools.product(*values)) if self._regular_grid_interp is None: self._add_regular_grid_interp() method = method or self.default_interp_kwargs.get('method', None) res = self._regular_grid_interp(points, method=method, **kwargs) out = np.reshape(res, shapes).squeeze() # Clip interpolated values to be non-negative np.clip(out, 0, None, out=out) # Attach units to the output out = out * self.data.unit return out
def energy_to_channel(energy: Quantity) -> int: """ Converts an astropy energy quantity into an XMM channel. :param energy: """ energy = energy.to("eV").value chan = int(energy) return chan
def create(cls, start, stop, reference_time="2000-01-01"): """Creates a GTI table from start and stop times. Parameters ---------- start : `~astropy.units.Quantity` start times w.r.t. reference time stop : `~astropy.units.Quantity` stop times w.r.t. reference time reference_time : `~astropy.time.Time` the reference time to use in GTI definition """ start = Quantity(start, ndmin=1) stop = Quantity(stop, ndmin=1) reference_time = Time(reference_time) meta = time_ref_to_dict(reference_time) table = Table({"START": start.to("s"), "STOP": stop.to("s")}, meta=meta) return cls(table)
def set_timestep(self, dt: u.Quantity = None): """ Set the time step for the next non-equilibrium ionization time advance. Parameters ---------- dt: astropy.units.Quantity, optional The time step to be used for the next time advance. Notes ----- If ``dt`` is not `None`, then the time step will be set to ``dt``. If ``dt`` is not set and the ``adapt_dt`` attribute of an `~plasmapy_nei.nei.NEI` instance is `True`, then this method will calculate the time step corresponding to how long it will be until the temperature rises or drops into the next temperature bin. If this time step is between ``dtmin`` and ``dtmax``, then If ``dt`` is not set and the ``adapt_dt`` attribute is `False`, then this method will set the time step as what was inputted to the `~plasmapy_nei.nei.NEI` class upon instantiation in the ``dt`` argument or through the `~plasmapy_nei.nei.NEI` class's ``dt_input`` attribute. Raises ------ ~plasmapy_nei.nei.NEIError If the time step cannot be set, for example if the ``dt`` argument is invalid or the time step cannot be adapted. """ if dt is not None: # Allow the time step to set as an argument to this method. try: dt = dt.to(u.s) except Exception as exc: raise NEIError(f"{dt} is not a valid time step.") from exc finally: self._dt = dt elif self.adapt_dt: try: self._set_adaptive_timestep() except Exception as exc: raise NEIError("Unable to adapt the time step.") from exc elif self.dt_input is not None: self._dt = self.dt_input else: raise NEIError("Unable to set the time step.") self._old_time = self._new_time self._new_time = self._old_time + self._dt if self._new_time > self.time_max: self._new_time = self.time_max self._dt = self._new_time - self._old_time
def solve_field( image_files: Union[str, list], base_filename: str = "astrometry", overwrite: bool = True, tweak: bool = True, search_radius: units.Quantity = 1 * units.degree, centre: SkyCoord = None, guess_scale: bool = True, time_limit: units.Quantity = None, verify: bool = True, odds_to_tune_up: float = 1e6, odds_to_solve: float = 1e9, *flags, **params): """ Returns True if successful (by checking whether the corrected file is generated); False if not. :param image_files: :param base_filename: :param overwrite: :param flags: :param params: :return: """ params["o"] = base_filename params["odds-to-tune-up"] = odds_to_tune_up params["odds-to-solve"] = odds_to_solve if time_limit is not None: params["l"] = check_quantity(time_limit, units.second).value if search_radius is not None: params["radius"] = search_radius.to(units.deg).value if centre is not None: params["ra"] = centre.ra.to(units.deg).value params["dec"] = centre.dec.to(units.deg).value debug_print(1, "solve_field(): tweak ==", tweak) flags = list(flags) if overwrite: flags.append("O") if guess_scale: flags.append("g") if not tweak: flags.append("T") if not verify: flags.append("y") system_command("solve-field", image_files, False, True, False, *flags, **params) if isinstance(image_files, list): image_path = image_files[0] else: image_path = image_files check_dir = os.path.split(image_path)[0] check_path = os.path.join(check_dir, f"{base_filename}.new") print(f"Checking for result file at {check_path}...") return os.path.isfile(check_path)
def inverse_abel(self, x: Quantity, use_par_dist: bool = False, method='analytical') -> Quantity: """ This overrides the inverse abel method of the model superclass, as there is an analytical solution to the inverse abel transform of the single beta model. The form of the inverse abel transform is that of the king profile, but with an extra transformation applied to the normalising parameter. This method can either return a single value calculated using the current model parameters, or a distribution of values using the parameter distributions (assuming that this model has had a fit run on it). :param Quantity x: The x location(s) at which to calculate the value of the inverse abel transform. :param bool use_par_dist: Should the parameter distributions be used to calculate a inverse abel transform distribution; this can only be used if a fit has been performed using the model instance. Default is False, in which case the current parameters will be used to calculate a single value. :param str method: The method that should be used to calculate the values of this inverse abel transform. Default for this overriding method is 'analytical', in which case the analytical solution is used. You may pass 'direct', 'basex', 'hansenlaw', 'onion_bordas', 'onion_peeling', 'two_point', or 'three_point' to calculate the transform numerically. :return: The inverse abel transform result. :rtype: Quantity """ def transform(x_val: Quantity, beta: Quantity, r_core: Quantity, norm: Quantity): """ The function that calculates the inverse abel transform of this beta profile. :param Quantity x_val: The x location(s) at which to calculate the value of the inverse abel transform. :param Quantity beta: The beta parameter of the beta profile. :param Quantity r_core: The core radius parameter of the beta profile. :param Quantity norm: The normalisation of the beta profile. :return: """ # We calculate the new normalisation parameter new_norm = norm / ((gamma((3 * beta) - 0.5) * np.sqrt(np.pi) * r_core) / gamma(3 * beta)) # Then return the value of the transformed beta profile return new_norm * np.power((1 + (np.power(x_val / r_core, 2))), (-3 * beta)) # Checking x units to make sure that they are valid if not x.unit.is_equivalent(self._x_unit): raise UnitConversionError("The input x coordinates cannot be converted to units of " "{}".format(self._x_unit.to_string())) else: x = x.to(self._x_unit) if method == 'analytical': # The way the calculation is called depends on whether the user wants to use the parameter distributions # or just the current model parameter values to calculate the inverse abel transform. if not use_par_dist: transform_res = transform(x, *self.model_pars) elif use_par_dist and len(self._par_dists[0]) != 0: transform_res = transform(x[..., None], *self.par_dists) elif use_par_dist and len(self._par_dists[0]) == 0: raise XGAFitError("No fit has been performed with this model, so there are no parameter distributions" " available.") else: transform_res = super().inverse_abel(x, use_par_dist, method) return transform_res
def _compute_wcs(center: SkyCoord, resolution: u.Quantity, radius: u.Quantity = None): """ """ dangle = 0.675 scale = int(dangle/resolution.to(u.deg).value) scale = 1 if scale <= 1 else scale ra_dim = 480*scale dec_dim = 240*scale if radius is not None: resol = dangle/scale ra_dim = int(2 * radius.to(u.deg).value / resol) dec_dim = ra_dim #raauto = False wcs = WCS(naxis=2) wcs.wcs.crpix = [ra_dim/2 + 0.5, dec_dim/2 + 0.5] wcs.wcs.cdelt = np.array([-dangle/scale, dangle/scale]) wcs.wcs.crval = [center.ra.deg, center.dec.deg] wcs.wcs.ctype = ['RA---AIT', 'DEC--AIT'] return wcs, (dec_dim, ra_dim)
def __init__(self,IMF:IMF_broken_powerlaw,velocity:u.Quantity,sigma:u.Quantity,dt:u.Quantity,seed:int=None) -> None: self._stationaryFunc = StationaryMassFunction(IMF,seed) self._velocity_characteristics = (velocity,sigma.to(velocity.unit)) self._region_info = None self._time = dt*0.0 self._dt = dt self._stars = [] self._velocity = None # self._next_injection = 0 self._ret_stars = None self._ret_vel = None
def test_LogEnergyAxis(): from scipy.stats import gmean energy = Quantity([1, 10, 100], 'TeV') energy_axis = LogEnergyAxis(energy) energy = Quantity(gmean([1, 10]), 'TeV') pix = energy_axis.wcs_world2pix(energy.to('MeV')) assert_allclose(pix, 0.5) world = energy_axis.wcs_pix2world(pix) assert_quantity_allclose(world, energy)
def electron_temperature(self, time: u.Quantity) -> u.Quantity: try: if not self.in_time_interval(time): raise NEIError("Not in simulation time interval.") T_e = self._electron_temperature(time.to(u.s)) if np.isnan(T_e) or np.isinf(T_e) or T_e < 0 * u.K: raise NEIError(f"T_e = {T_e} at time = {time}.") return T_e except Exception as exc: raise NEIError(f"Unable to calculate a valid electron temperature " f"for time {time}") from exc
def test_LogEnergyAxis(): from scipy.stats import gmean energy = Quantity([1, 10, 100], 'TeV') energy_axis = LogEnergyAxis(energy) energy = Quantity(gmean([1, 10]), 'TeV') pix = energy_axis.wcs_world2pix(energy.to('MeV')) assert_allclose(pix, 0.5) world = energy_axis.wcs_pix2world(pix) assert_quantity_allclose(world, energy)
def get_exoplanet_class(mass: u.Quantity, radius: u.Quantity) -> str: """ (Approximately) classify an exoplanet based on its mass and radius. The classification scheme used here is based on the table from: http://phl.upr.edu/library/notes/ amassclassificationforbothsolarandextrasolarplanets Args: mass: The mass of the exoplanet as a astropy.units.Quantity. radius: The radius of the exoplanet as a astropy.units.Quantity. Returns: The approximate planet class, which is one of the following: ["Asteroidan", "Mercurian", "Subterran", "Terran", "Superterran", "Neptunian", "Jovian", "N/A", "Other"] """ # Convert mass and radius to Earth units and cast to float mass = mass.to(u.earthMass).to_value() radius = radius.to(u.earthRad).to_value() # Classify the planet based on its mass and radius if (mass == 0) or (radius == 0): return 'N/A' elif (0 < mass <= 0.00001) and (0 < radius <= 0.03): return 'Asteroidan' elif (0.00001 <= mass <= 0.1) and (0.03 <= radius <= 0.7): return 'Mercurian' elif (0.1 <= mass <= 0.5) and (0.5 <= radius <= 1.2): return 'Subterran' elif (0.5 <= mass <= 2) and (0.8 <= radius <= 1.9): return 'Terran' elif (2 <= mass <= 10) and (1.3 <= radius <= 3.3): return 'Superterran' elif (10 <= mass <= 50) and (2.1 <= radius <= 5.7): return 'Neptunian' elif (50 <= mass < 5000) and (3.5 <= radius <= 27): return 'Jovian' else: return 'Other'
def wrapper(self, value): if self._param.unit: value = Quantity(value) if not value.unit.to_string(): value = value * Unit(self._param.unit) else: value = value.to(self._param.unit) if isinstance(value, Quantity): value = value.value return func(self, value)
def __call__(self, frequencies: u.Quantity, effective: bool = False) -> u.Quantity: # Note: don't use to_value, because that can return a value, and we're # going to modify x. x = frequencies.to(self._frequency_unit).value x[(frequencies < self.min_frequency) | (frequencies > self.max_frequency)] = np.nan pol_sefd = np.polynomial.polynomial.polyval( x, self._coeffs.value.T, tensor=True) << self._coeffs.unit # Take quadratic mean of individual polarizations sefd = np.sqrt(np.mean(np.square(pol_sefd), axis=0)) if effective: sefd /= self.correlator_efficiency return sefd
def pix_deg_scale(coord: Quantity, input_wcs: WCS, small_offset: Quantity = Quantity(1, 'arcmin')) -> Quantity: """ Very heavily inspired by the regions module version of this function, just tweaked to work better for my use case. Perturbs the given coordinates with the small_offset value, converts the changed ra-dec coordinates to pixel, then calculates the difference between the new and original coordinates in pixel. Then small_offset is converted to degrees and divided by the pixel distance to calculate a pixel to degree factor. :param Quantity coord: The starting coordinates. :param WCS input_wcs: The world coordinate system used to calculate the pixel to degree scale :param Quantity small_offset: The amount you wish to perturb the original coordinates :return: Factor that can be used to convert pixel distances to degree distances, returned as an astropy quantity with units of deg/pix. :rtype: Quantity """ if coord.unit != pix and coord.unit != deg: raise UnitConversionError("This function can only be used with radec or pixel coordinates as input") elif coord.shape != (2,): raise ValueError("coord input must only contain 1 pair.") elif not small_offset.unit.is_equivalent("deg"): raise UnitConversionError("small_offset must be convertible to degrees") if coord.unit == deg: pix_coord = Quantity(input_wcs.all_world2pix(*coord.value, 0), pix) deg_coord = coord elif coord.unit == pix: deg_coord = Quantity(input_wcs.all_pix2world(*coord.value, 0), deg) pix_coord = coord else: raise UnitConversionError('{} is not a recognised position unit'.format(coord.unit.to_string())) perturbed_coord = deg_coord + Quantity([0, small_offset.to("deg").value], 'deg') perturbed_pix_coord = Quantity(input_wcs.all_world2pix(*perturbed_coord.value, 0), pix) diff = abs(perturbed_pix_coord - pix_coord) pix_dist = np.hypot(*diff) scale = small_offset.to('deg').value / pix_dist.value return Quantity(scale, 'deg/pix')
def electron_temperature(self, time: u.Quantity) -> u.Quantity: try: if not self.in_time_interval(time): warnings.warn(f"{time} is not in the simulation time interval:" f"[{self.time_start}, {self.time_max}]. " f"May be extrapolating temperature.") T_e = self._electron_temperature(time.to(u.s)) if np.isnan(T_e) or np.isinf(T_e) or T_e < 0 * u.K: raise NEIError(f"T_e = {T_e} at time = {time}.") return T_e except Exception as exc: raise NEIError(f"Unable to calculate a valid electron temperature " f"for time {time}") from exc
def ang_to_rad(ang: Quantity, z: float, cosmo=Planck15) -> Quantity: """ The counterpart to rad_to_ang, this converts from an angle to a radius in kpc. :param Quantity ang: Angle to be converted to radius. :param Cosmology cosmo: An instance of an astropy cosmology, the default is Planck15. :param float z: The _redshift of the source. :return: The radius in kpc. :rtype: Quantity """ d_a = cosmo.angular_diameter_distance(z) rad = (ang.to("deg").value * (pi / 180) * d_a).to("kpc") return rad
def rad_to_ang(rad: Quantity, z: float, cosmo=Planck15) -> Quantity: """ Converts radius in length units to radius on sky in degrees. :param Quantity rad: Radius for conversion. :param Cosmology cosmo: An instance of an astropy cosmology, the default is Planck15. :param float z: The _redshift of the source. :return: The radius in degrees. :rtype: Quantity """ d_a = cosmo.angular_diameter_distance(z) ang_rad = (rad.to("Mpc") / d_a).to('').value * (180 / pi) return Quantity(ang_rad, 'deg')
def __call__( self, field: kgpy.vectors.Vector2D, frequency: kgpy.vectors.Vector2D, wavelength: u.Quantity, velocity_los: u.Quantity = 0 * u.km / u.s, ): field = field.to(self.unit_field) frequency = frequency.to(self.unit_frequency) wavelength = wavelength.to(self.unit_wavelength) velocity_los = velocity_los.to(self.unit_velocity_los) points = [None] * self.axis.ndim points[self.axis.field_x] = field.x.value points[self.axis.field_y] = field.y.value points[self.axis.frequency_x] = frequency.x.value points[self.axis.frequency_y] = frequency.y.value points[self.axis.wavelength] = wavelength.value points[self.axis.velocity_los] = velocity_los.value del points[self.axis.velocity_los] return self.interpolator(*points) * u.dimensionless_unscaled
def _sample_grid_altaz_jones( self, l: np.ndarray, m: np.ndarray, frequency: u.Quantity, *, out: Optional[np.ndarray] = None) -> np.ndarray: """Partial implementation of :meth:`sample_grid`. It takes `l` and `m` in AltAz frame and produces Jones HV matrices. """ assert l.ndim == 1 assert l.shape == m.shape assert l.dtype == np.dtype(np.float32) assert m.dtype == np.dtype(np.float32) x_m = _asarray(self.x.to_value(u.m), np.float32) y_m = _asarray(self.y.to_value(u.m), np.float32) wavenumber = frequency.to('m^-1', equivalencies=u.spectral()) wavenumber_m = _asarray(wavenumber.value, np.float32) samples = self._prepare_samples(frequency) out_shape = frequency.shape + m.shape + l.shape + (2, 2) if out is None: out = np.empty(out_shape, np.complex64) # Pre-fault the memory. This seems to speed up matrix # multiplications that write out to this memory. See # https://github.com/numpy/numpy/issues/18669 out.ravel()[::(4096 // 8)].fill(0) elif out.shape != out_shape: raise ValueError( f'out must have shape {out_shape}, not {out.shape}') self._sample_grid_impl(x_m, y_m, l, m, wavenumber_m, samples, out) # Check if there are any points that may lie outside the valid l/m # region. If not (common case) we can avoid computing masks. max_l = np.max(np.abs(l)) max_m = np.max(np.abs(m)) max_wavenumber = np.max(wavenumber) limit_l = 0.5 / abs(self.x_step) limit_m = 0.5 / abs(self.y_step) if max_l * max_wavenumber > limit_l: invalid = np.multiply.outer(wavenumber, np.abs(l)) > limit_l # Insert axes for m and Jones terms invalid = invalid[..., np.newaxis, :, np.newaxis, np.newaxis] np.copyto(out, np.nan, where=invalid) if max_m * max_wavenumber > limit_m: invalid = np.multiply.outer(wavenumber, np.abs(m)) > limit_m # Insert axes for l and Jones terms invalid = invalid[..., np.newaxis, np.newaxis, np.newaxis] np.copyto(out, np.nan, where=invalid) return out
def peak(self, new_peak: Quantity): """ Allows the user to update the peak value used during analyses manually. :param Quantity new_peak: A new RA-DEC peak coordinate, in degrees. """ if not new_peak.unit.is_equivalent("deg"): raise UnitConversionError( "The new peak value must be in RA and DEC coordinates") elif len(new_peak) != 2: raise ValueError( "Please pass an astropy Quantity, in units of degrees, with two entries - " "one for RA and one for DEC.") self._peaks["combined"] = new_peak.to("deg")
def animate_channel(self, images: u.Quantity, image_names: typ.List[str], ax: typ.Optional[plt.Axes] = None, thresh_min: u.Quantity = 0.01 * u.percent, thresh_max: u.Quantity = 99.9 * u.percent, norm_gamma: float = 1, norm_vmin: typ.Optional[u.Quantity] = None, norm_vmax: typ.Optional[u.Quantity] = None, frame_interval: u.Quantity = 1 * u.s, colormap: typ.Optional[str] = None): if ax is None: fig, ax = plt.subplots() else: fig = ax.figure vmin, vmax = norm_vmin, norm_vmax if vmin is None: vmin = np.nanpercentile(images[0], thresh_min.value) if vmax is None: vmax = np.nanpercentile(images[0], thresh_max.value) img = ax.imshow( X=images[0].value, cmap=colormap, norm=matplotlib.colors.PowerNorm( gamma=norm_gamma, vmin=vmin.value, vmax=vmax.value, ), origin='lower', ) title = ax.set_title(image_names[0]) ax.set_xlabel('detector $x$ (pix)') ax.set_ylabel('detector $y$ (pix)') fig.colorbar(img, ax=ax, label=images.unit, fraction=0.05) def func(i: int): img.set_data(images[i].value) title.set_text(image_names[i]) img.set_clim(vmin=vmin.value, vmax=vmax.value) # fig.set_constrained_layout(False) return matplotlib.animation.FuncAnimation( fig=fig, func=func, frames=images.shape[0], interval=frame_interval.to(u.ms).value, )
def evaluate(self, method=None, **kwargs): """Evaluate NDData Array This function provides a uniform interface to several interpolators. The evaluation nodes are given as ``kwargs``. Currently available: `~scipy.interpolate.RegularGridInterpolator`, methods: linear, nearest Parameters ---------- method : str {'linear', 'nearest'}, optional Interpolation method kwargs : dict Keys are the axis names, Values the evaluation points Returns ------- array : `~astropy.units.Quantity` Interpolated values, axis order is the same as for the NDData array """ values = [] for axis in self.axes: # Extract values for each axis, default: nodes temp = Quantity(kwargs.pop(axis.name, axis.nodes)) # Transform to correct unit temp = temp.to(axis.unit).value # Transform to match interpolation behaviour of axis values.append(np.atleast_1d(axis._interp_values(temp))) # This is to catch e.g. typos in axis names if kwargs != {}: raise ValueError("Input given for unknown axis: {}".format(kwargs)) if method is None: out = self._eval_regular_grid_interp(values) elif method == 'linear': out = self._eval_regular_grid_interp(values, method='linear') elif method == 'nearest': out = self._eval_regular_grid_interp(values, method='nearest') else: raise ValueError('Interpolator {} not available'.format(method)) # Clip interpolated values to be non-negative np.clip(out, 0, None, out=out) # Attach units to the output out = out * self.data.unit return out
def sky_deg_scale(im_prod: Union[Image, RateMap, ExpMap], coord: Quantity, small_offset: Quantity = Quantity(1, 'arcmin')) -> Quantity: """ This is equivelant to pix_deg_scale, but instead calculates the conversion factor between XMM's XY sky coordinate system and degrees. :param Image/Ratemap/ExpMap im_prod: The image product to calculate the conversion factor for. :param Quantity coord: The starting coordinates. :param Quantity small_offset: The amount you wish to perturb the original coordinates :return: A scaling factor to convert sky distances to degree distances, returned as an astropy quantity with units of deg/xmm_sky. :rtype: Quantity """ # Now really this function probably isn't necessary at, because there is a fixed scaling from degrees # to this coordinate system, but I do like to be general if coord.shape != (2,): raise ValueError("coord input must only contain 1 pair.") elif not small_offset.unit.is_equivalent("deg"): raise UnitConversionError("small_offset must be convertible to degrees") # Seeing as we're taking an image product input on this one, I can leave the checking # of inputs to coord_conv # We need the degree and xmm_sky original coordinates deg_coord = im_prod.coord_conv(coord, deg) sky_coord = im_prod.coord_conv(coord, xmm_sky) perturbed_coord = deg_coord + Quantity([0, small_offset.to("deg").value], 'deg') perturbed_sky_coord = im_prod.coord_conv(perturbed_coord, xmm_sky) diff = abs(perturbed_sky_coord - sky_coord) sky_dist = np.hypot(*diff) scale = small_offset.to('deg').value / sky_dist.value return Quantity(scale, deg/xmm_sky)
def __init__(self,redshift:float, velocity_dispersion:u.Quantity, shear: PolarVec, ellipticity:PolarVec) -> None: Cosmic.__init__(self,redshift) CalculationDependency.__init__(self) Jsonable.__init__(self) try: assert isinstance(shear,PolarVec) assert isinstance(ellipticity, PolarVec) assert isinstance(redshift,float) self._velocity_dispersion = velocity_dispersion.to('km/s') self._ellipticity = ellipticity self._shear = shear except: from mirage.parameters import ParametersError raise ParametersError("Could not construct a Lens instance from the supplied constructor arguments.")
def fit_flare(self, init_guess_amplitude: u.Quantity, init_guess_rise_time: u.Quantity, init_guess_decay_const: u.Quantity, init_guess_flare_time: u.Quantity): initial_guesses = np.array((init_guess_amplitude.to(u.ct).value, init_guess_rise_time.to(u.s).value, init_guess_decay_const.to(u.s).value, init_guess_flare_time.to(u.s).value)) result = optimize.minimize(self.fitting_cost_function_PRED, x0=initial_guesses, bounds=self._bounds) params = result.x amp = u.Quantity(params[0], 'ct') rise = u.Quantity(params[1], 's') decay = u.Quantity(params[2], 's') flare_time = u.Quantity(params[3], 's') result_dict = { 'amplitude': amp, 'rise_time': rise, 'decay_const': decay, 'flare_time': flare_time, 'cost': result.fun } return result_dict
def parse_value(value, default_units, equivalence=None): if isinstance(value, string_types): v = value.split(",") if len(v) == 2: value = (float(v[0]), v[1]) else: value = float(v[0]) if hasattr(value, "to_astropy"): value = value.to_astropy() if isinstance(value, Quantity): q = Quantity(value.value, value.unit) elif iterable(value): q = Quantity(value[0], value[1]) else: q = Quantity(value, default_units) return q.to(default_units, equivalencies=equivalence).value
def parse_value(value, default_units, equivalence=None): if isinstance(value, string_types): v = value.split(",") if len(v) == 2: value = (float(v[0]), v[1]) else: value = float(v[0]) if hasattr(value, "to_astropy"): value = value.to_astropy() if isinstance(value, Quantity): q = Quantity(value.value, value.unit) elif iterable(value): q = Quantity(value[0], value[1]) else: q = Quantity(value, default_units) return q.to(default_units, equivalencies=equivalence).value
def __str__(self): """Summary report (`str`). """ ss = '*** Observation summary ***\n' ss += 'Target position: {}\n'.format(self.target_pos) ss += 'Number of observations: {}\n'.format(len(self.obs_table)) livetime = Quantity(sum(self.obs_table['LIVETIME']), 'second') ss += 'Livetime: {:.2f}\n'.format(livetime.to('hour')) zenith = self.obs_table['ZEN_PNT'] ss += 'Zenith angle: (mean={:.2f}, std={:.2f})\n'.format( zenith.mean(), zenith.std()) offset = self.offset ss += 'Offset: (mean={:.2f}, std={:.2f})\n'.format( offset.mean(), offset.std()) return ss
def solid_angle_image(self): """Solid angle image. TODO: currently uses CDELT1 x CDELT2, which only works for cartesian images near the equator. Returns ------- solid_angle_image : `~astropy.units.Quantity` Solid angle image (steradian) """ cdelt = self.wcs.wcs.cdelt solid_angle = np.abs(cdelt[0]) * np.abs(cdelt[1]) shape = self.data.shape[:2] solid_angle = solid_angle * np.ones(shape, dtype=float) solid_angle = Quantity(solid_angle, 'deg^2') return solid_angle.to('steradian')
def find_node(self, val): """Find next node Parameters ---------- val : `~astropy.units.Quantity` Lookup value """ val = Quantity(val) if not val.unit.is_equivalent(self.unit): raise ValueError("Units {} and {} do not match".format(val.unit, self.unit)) val = val.to(self.data.unit) val = np.atleast_1d(val) x1 = np.array([val] * self.nbins).transpose() x2 = np.array([self.nodes] * len(val)) temp = np.abs(x1 - x2) idx = np.argmin(temp, axis=1) return idx
class BinnedDataAxis(DataAxis): """Data axis for binned data Parameters ---------- lo : `~astropy.units.Quantity` Lower bin edges hi : `~astropy.units.Quantity` Upper bin edges name : str, optional Axis name, default: 'Default' interpolation_mode : str {'linear', 'log'} Interpolation behaviour, default: 'linear' """ def __init__(self, lo, hi, **kwargs): self.lo = Quantity(lo) self.hi = Quantity(hi) super(BinnedDataAxis, self).__init__(None, **kwargs) @classmethod def logspace(cls, emin, emax, nbins, unit=None, **kwargs): # TODO: splitout log space into a helper function vals = DataAxis.logspace(emin, emax, nbins + 1, unit)._data return cls(vals[:-1], vals[1:], **kwargs) def __str__(self): ss = super(BinnedDataAxis, self).__str__() ss += '\nLower bounds {}'.format(self.lo) ss += '\nUpper bounds {}'.format(self.hi) return ss @property def bins(self): """Bin edges""" unit = self.lo.unit val = np.append(self.lo.value, self.hi.to(unit).value[-1]) return val * unit @property def bin_width(self): """Bin width""" return self.hi - self.lo @property def nodes(self): """Evaluation nodes. Depending on the interpolation mode, either log or lin center are returned """ if self.interpolation_mode == 'log': return self.log_center() else: return self.lin_center() def lin_center(self): """Linear bin centers""" return (self.lo + self.hi) / 2 def log_center(self): """Logarithmic bin centers""" return np.sqrt(self.lo * self.hi)
def _arithmetic(self, operand, propagate_uncertainties, operation, reverse=False): """ {description} Parameters ---------- operand : `igmtools.data.Data`, `astropy.units.Quantity`, float or int Either a or b in the operation a {operator} b propagate_uncertainties : str The name of one of the propagation rules defined by the `igmtools.data.Uncertainty` class. operation : `numpy.ufunc` The function that performs the operation. reverse : bool, optional (default=False) Sets the operand order for the operation. Set to True to place `self` after the operation. Returns ------- result : `igmtools.data.Data` or `astropy.units.Quantity` The data or quantity resulting from the arithmetic. Notes ----- Uncertainties are propagated, although correlated errors are not supported. """ if isinstance(operand, (int, float)): operand = Quantity(operand) if reverse: try: result = operation(operand.value * operand.unit, self.value * self.unit) except: raise UnitsError('operand units do not match') else: try: result = operation(self.value * self.unit, operand.value * operand.unit) except: raise UnitsError('operand units do not match') # If we are not propagating uncertainties, we should just return the # result here: if not propagate_uncertainties: return result unit = result.unit value = result.value # If the operation is addition or subtraction then we need to ensure # that the operand is in the same units as the result: if (operation in (operator.add, operator.sub) and unit != operand.unit): operand = operand.to(unit) method = getattr(self.uncertainty, propagate_uncertainties) if propagate_uncertainties in ('propagate_add', 'propagate_subtract'): uncertainty = method(operand) else: uncertainty = method(operand, result) result = self.__class__(value, uncertainty.value, unit) return result
def make_test_eventlist( observation_table, obs_id, sigma=Angle(5.0, "deg"), spectral_index=2.7, random_state="random-seed" ): """ Make a test event list for a specified observation. The observation can be specified with an observation table object and the observation ID pointing to the correct observation in the table. For now, only a very rudimentary event list is generated, containing only detector X, Y coordinates (a.k.a. nominal system) and energy columns for the events. And the livetime of the observations stored in the header. The model used to simulate events is also very simple. Only dummy background is created (no signal). The background is created following a 2D symmetric gaussian model for the spatial coordinates (X, Y) and a power-law in energy. The gaussian width varies in energy from sigma/2 to sigma. The number of events generated depends linearly on the livetime and on the altitude angle of the observation. The model can be tuned via the sigma and spectral_index parameters. In addition, an effective area table is produced. For the moment only the low energy threshold is filled. See also :ref:`datasets_obssim`. Parameters ---------- observation_table : `~gammapy.data.ObservationTable` Observation table containing the observation to fake. obs_id : int Observation ID of the observation to fake inside the observation table. sigma : `~astropy.coordinates.Angle`, optional Width of the gaussian model used for the spatial coordinates. spectral_index : double, optional Index for the power-law model used for the energy coordinate. random_state : {int, 'random-seed', 'global-rng', `~numpy.random.RandomState`}, optional Defines random number generator initialisation. Passed to `~gammapy.utils.random.get_random_state`. Returns ------- event_list : `~gammapy.data.EventList` Event list. aeff_hdu : `~astropy.io.fits.BinTableHDU` Effective area table. """ from ..data import EventList random_state = get_random_state(random_state) # find obs row in obs table obs_ids = observation_table["OBS_ID"].data obs_index = np.where(obs_ids == obs_id) row = obs_index[0][0] # get observation information alt = Angle(observation_table["ALT"])[row] livetime = Quantity(observation_table["LIVETIME"])[row] # number of events to simulate # it is linearly dependent on the livetime, taking as reference # a trigger rate of 300 Hz # it is linearly dependent on the zenith angle (90 deg - altitude) # it is n_events_max at alt = 90 deg and n_events_max/2 at alt = 0 deg n_events_max = Quantity(300.0, "Hz") * livetime alt_min = Angle(0.0, "deg") alt_max = Angle(90.0, "deg") slope = (n_events_max - n_events_max / 2) / (alt_max - alt_min) free_term = n_events_max / 2 - slope * alt_min n_events = alt * slope + free_term # simulate energy # the index of `~numpy.random.RandomState.power` has to be # positive defined, so it is necessary to translate the (0, 1) # interval of the random variable to (emax, e_min) in order to # have a decreasing power-law e_min = Quantity(0.1, "TeV") e_max = Quantity(100.0, "TeV") energy = sample_powerlaw(e_min.value, e_max.value, spectral_index, size=n_events, random_state=random_state) energy = Quantity(energy, "TeV") E_0 = Quantity(1.0, "TeV") # reference energy for the model # define E dependent sigma # it is defined via a PL, in order to be log-linear # it is equal to the parameter sigma at E max # and sigma/2. at E min sigma_min = sigma / 2.0 # at E min sigma_max = sigma # at E max s_index = np.log(sigma_max / sigma_min) s_index /= np.log(e_max / e_min) s_norm = sigma_min * ((e_min / E_0) ** -s_index) sigma = s_norm * ((energy / E_0) ** s_index) # simulate detx, dety detx = Angle(random_state.normal(loc=0, scale=sigma.deg, size=n_events), "deg") dety = Angle(random_state.normal(loc=0, scale=sigma.deg, size=n_events), "deg") # fill events in an event list event_list = EventList() event_list["DETX"] = detx event_list["DETY"] = dety event_list["ENERGY"] = energy # store important info in header event_list.meta["LIVETIME"] = livetime.to("second").value event_list.meta["EUNIT"] = str(energy.unit) # effective area table aeff_table = Table() # fill threshold, for now, a default 100 GeV will be set # independently of observation parameters energy_threshold = Quantity(0.1, "TeV") aeff_table.meta["LO_THRES"] = energy_threshold.value aeff_table.meta["name"] = "EFFECTIVE AREA" # convert to BinTableHDU and add necessary comment for the units aeff_hdu = table_to_fits_table(aeff_table) aeff_hdu.header.comments["LO_THRES"] = "[" + str(energy_threshold.unit) + "]" return event_list, aeff_hdu
def test_solid_angle_image(self): actual = self.spectral_cube.solid_angle_image[10][30] expected = Quantity(self.spectral_cube.wcs.wcs.cdelt[:-1].prod(), 'deg2') assert_quantity(actual, expected.to('sr'), rtol=1e-4)
class Spectrum1D(object): """ A 1D spectrum. Assumes wavelength units unless otherwise specified. Parameters ---------- dispersion : `astropy.units.Quantity` or array Spectral dispersion axis. flux : `igmtools.data.Data`, `astropy.units.Quantity` or array Spectral flux. Should have the same length as `dispersion`. error : `astropy.units.Quantity` or array, optional Error on each flux value. continuum : `astropy.units.Quantity` or array, optional An estimate of the continuum flux. mask : array, optional Mask for the spectrum. The values must be False where valid and True where not. unit : `astropy.units.UnitBase` or str, optional Spectral unit. dispersion_unit : `astropy.units.UnitBase` or str, optional Unit for the dispersion axis. meta : dict, optional Meta data for the spectrum. """ def __init__(self, dispersion, flux, error=None, continuum=None, mask=None, unit=None, dispersion_unit=None, meta=None): _unit = flux.unit if unit is None and hasattr(flux, 'unit') else unit if isinstance(error, (Quantity, Data)): if error.unit != _unit: raise UnitsError('The error unit must be the same as the ' 'flux unit.') error = error.value elif isinstance(error, Column): if error.unit != _unit: raise UnitsError('The error unit must be the same as the ' 'flux unit.') error = error.data # Set zero error elements to NaN: if error is not None: zero = error == 0 error[zero] = np.nan # Mask these elements: if mask is not None: self.mask = mask | np.isnan(error) else: self.mask = np.isnan(error) # If dispersion is a `Quantity`, `Data`, or `Column` instance with the # unit attribute set, that unit is preserved if `dispersion_unit` is # None, but overriden otherwise self.dispersion = Quantity(dispersion, unit=dispersion_unit) if dispersion_unit is not None: self.wavelength = self.dispersion.to(angstrom) else: # Assume wavelength units: self.wavelength = self.dispersion self.flux = Data(flux, error, unit) if continuum is not None: self.continuum = Quantity(continuum, unit=unit) else: self.continuum = None self.meta = meta @classmethod def from_table(cls, table, dispersion_column, flux_column, error_column=None, continuum_column=None, unit=None, dispersion_unit=None): """ Initialises a `Spectrum1D` object from an `astropy.table.Table` instance. Parameters ---------- table : `astropy.table.Table` Contains information used to construct the spectrum. Must have columns for the dispersion axis and the spectral flux. dispersion_column : str Name for the dispersion column. flux_column : str Name for the flux column. error_column : str, optional Name for the error column. continuum_column : str, optional Name for the continuum column. unit : `astropy.units.UnitBase` or str, optional Spectral unit. dispersion_unit : `astropy.units.UnitBase` or str, optional Unit for the dispersion axis. """ dispersion = Quantity(table[dispersion_column]) flux = Quantity(table[flux_column]) if error_column is not None: error = Quantity(table[error_column]) else: error = None if continuum_column is not None: continuum = Quantity(table[continuum_column]) else: continuum = None meta = table.meta mask = table.mask return cls(dispersion, flux, error, continuum, mask, unit, dispersion_unit, meta) def write(self, *args, **kwargs): """ Write the spectrum to a file. Accepts the same arguments as `astropy.table.Table.write` """ if self.dispersion.unit is None: label_string = 'WAVELENGTH' else: if self.dispersion.unit.physical_type == 'length': label_string = 'WAVELENGTH' elif self.dispersion.unit.physical_type == 'frequency': label_string = 'FREQUENCY' elif self.dispersion.unit.physical_type == 'energy': label_string = 'ENERGY' else: raise ValueError('unrecognised unit type') t = Table([self.dispersion, self.flux, self.flux.uncertainty.value], names=[label_string, 'FLUX', 'ERROR']) t['ERROR'].unit = t['FLUX'].unit if self.continuum is not None: t['CONTINUUM'] = self.continuum t.write(*args, **kwargs) def plot(self, **kwargs): """ Plot the spectrum. Accepts the same arguments as `igmtools.plot.Plot`. """ from ..plot import Plot p = Plot(1, 1, 1, **kwargs) p.axes[0].plot(self.dispersion.value, self.flux.value, drawstyle='steps-mid') if self.flux.uncertainty is not None: p.axes[0].plot(self.dispersion.value, self.flux.uncertainty.value, drawstyle='steps-mid') p.tidy() p.display() def normalise_to_magnitude(self, magnitude, band): """ Normalises the spectrum to match the flux equivalent to the given AB magnitude in the given passband. Parameters ---------- magnitude : float AB magnitude. band : `igmtools.photometry.Passband` The passband. """ from ..photometry import mag2flux mag_flux = mag2flux(magnitude, band) spec_flux = self.calculate_flux(band) norm = mag_flux / spec_flux self.flux *= norm def calculate_flux(self, band): """ Calculate the mean flux for a passband, weighted by the response and wavelength in the given passband. Parameters ---------- band : `igmtools.photometry.Passband` The passband. Returns ------- flux : `astropy.units.Quantity` The mean flux in erg / s / cm^2 / Angstrom. Notes ----- This function does not calculate an uncertainty. """ if (self.wavelength[0] > band.wavelength[0] or self.wavelength[-1] < band.wavelength[-1]): warn('Spectrum does not cover the whole bandpass, ' 'extrapolating...') dw = np.median(np.diff(self.wavelength.value)) spec_wavelength = np.arange( band.wavelength.value[0], band.wavelength.value[-1] + dw, dw) * angstrom spec_flux = np.interp(spec_wavelength, self.wavelength, self.flux.value) else: spec_wavelength = self.wavelength spec_flux = self.flux.value i, j = spec_wavelength.searchsorted( Quantity([band.wavelength[0], band.wavelength[-1]])) wavelength = spec_wavelength[i:j] flux = spec_flux[i:j] dw_band = np.median(np.diff(band.wavelength)) dw_spec = np.median(np.diff(wavelength)) if dw_spec.value > dw_band.value > 20: warn('Spectrum wavelength sampling interval {0:.2f}, but bandpass' 'sampling interval {1:.2f}'.format(dw_spec, dw_band)) # Interpolate the spectrum to the passband wavelengths: flux = np.interp(band.wavelength, wavelength, flux) band_transmission = band.transmission wavelength = band.wavelength else: # Interpolate the band transmission to the spectrum wavelengths: band_transmission = np.interp( wavelength, band.wavelength, band.transmission) # Weight by the response and wavelength, appropriate when we're # counting the number of photons within the band: flux = (np.trapz(band_transmission * flux * wavelength, wavelength) / np.trapz(band_transmission * wavelength, wavelength)) flux *= erg / s / cm ** 2 / angstrom return flux def calculate_magnitude(self, band, system='AB'): """ Calculates the magnitude in a given passband. band : `igmtools.photometry.Passband` The passband. system : {`AB`, `Vega`} Magnitude system. Returns ------- magnitude : float Magnitude in the given system. """ if system not in ('AB', 'Vega'): raise ValueError('`system` must be one of `AB` or `Vega`') f1 = self.calculate_flux(band) if f1 > 0: magnitude = -2.5 * log10(f1 / band.flux[system]) if system == 'Vega': # Add 0.026 because Vega has V = 0.026: magnitude += 0.026 else: magnitude = np.inf return magnitude def apply_extinction(self, EBmV): """ Apply Milky Way extinction. Parameters ---------- EBmV : float Colour excess. """ from astro.extinction import MWCardelli89 tau = MWCardelli89(self.wavelength, EBmV=EBmV).tau self.flux *= np.exp(-tau) if self.continuum is not None: self.continuum *= np.exp(-tau) def rebin(self, dispersion): """ Rebin the spectrum onto a new dispersion axis. Parameters ---------- dispersion : float, `astropy.units.Quantity` or array The dispersion for the rebinned spectrum. If a float, assumes a linear scale with that bin size. """ if isinstance(dispersion, float): dispersion = np.arange( self.dispersion.value[0], self.dispersion.value[-1], dispersion) old_bins = find_bin_edges(self.dispersion.value) new_bins = find_bin_edges(dispersion) widths = np.diff(old_bins) old_length = len(self.dispersion) new_length = len(dispersion) i = 0 # index of old array j = 0 # index of new array # Variables used for rebinning: df = 0.0 de2 = 0.0 nbins = 0.0 flux = np.zeros_like(dispersion) error = np.zeros_like(dispersion) # Sanity check: if old_bins[-1] < new_bins[0] or new_bins[-1] < old_bins[0]: raise ValueError('Dispersion scales do not overlap!') # Find the first contributing old pixel to the rebinned spectrum: if old_bins[i + 1] < new_bins[0]: # Old dispersion scale extends lower than the new one. Find the # first old bin that overlaps with the new scale: while old_bins[i + 1] < new_bins[0]: i += 1 i -= 1 elif old_bins[0] > new_bins[j + 1]: # New dispersion scale extends lower than the old one. Find the # first new bin that overlaps with the old scale: while old_bins[0] > new_bins[j + 1]: flux = np.nan error = np.nan j += 1 j -= 1 l0 = old_bins[i] # lower edge of contributing old bin while True: h0 = old_bins[i + 1] # upper edge of contributing old bin h1 = new_bins[j + 1] # upper edge of jth new bin if h0 < h1: # Count up the decimal number of old bins that contribute to # the new one and start adding up fractional flux values: if self.flux.uncertainty.value[i] > 0: bin_fraction = (h0 - l0) / widths[i] nbins += bin_fraction # We don't let `Data` handle the error propagation here # because a sum of squares will not give us what we # want, i.e. 0.25**2 + 0.75**2 != 0.5**2 + 0.5**2 != 1**2 df += self.flux.value[i] * bin_fraction de2 += self.flux.uncertainty.value[i] ** 2 * bin_fraction l0 = h0 i += 1 if i == old_length: break else: # We have all but one of the old bins that contribute to the # new one, so now just add the remaining fraction of the new # bin to the decimal bin count and add the remaining # fractional flux value to the sum: if self.flux.uncertainty.value[i] > 0: bin_fraction = (h1 - l0) / widths[i] nbins += bin_fraction df += self.flux.value[i] * bin_fraction de2 += self.flux.uncertainty.value[i] ** 2 * bin_fraction if nbins > 0: # Divide by the decimal bin count to conserve flux density: flux[j] = df / nbins error[j] = sqrt(de2) / nbins else: flux[j] = 0.0 error[j] = 0.0 df = 0.0 de2 = 0.0 nbins = 0.0 l0 = h1 j += 1 if j == new_length: break if hasattr(self.dispersion, 'unit'): dispersion = Quantity(dispersion, self.dispersion.unit) if hasattr(self.flux, 'unit'): flux = Data(flux, error, self.flux.unit) # Linearly interpolate the continuum onto the new dispersion scale: if self.continuum is not None: continuum = np.interp(dispersion, self.dispersion, self.continuum) else: continuum = None return self.__class__(dispersion, flux, continuum=continuum)
class EnergyDependentMultiGaussPSF: """ Triple Gauss analytical PSF depending on energy and theta. To evaluate the PSF call the ``to_energy_dependent_table_psf`` or ``psf_at_energy_and_theta`` methods. Parameters ---------- energy_lo : `~astropy.units.Quantity` Lower energy boundary of the energy bin. energy_hi : `~astropy.units.Quantity` Upper energy boundary of the energy bin. theta : `~astropy.units.Quantity` Center values of the theta bins. sigmas : list of 'numpy.ndarray' Triple Gauss sigma parameters, where every entry is a two dimensional 'numpy.ndarray' containing the sigma value for every given energy and theta. norms : list of 'numpy.ndarray' Triple Gauss norm parameters, where every entry is a two dimensional 'numpy.ndarray' containing the norm value for every given energy and theta. Norm corresponds to the value of the Gaussian at theta = 0. energy_thresh_lo : `~astropy.units.Quantity` Lower save energy threshold of the psf. energy_thresh_hi : `~astropy.units.Quantity` Upper save energy threshold of the psf. Examples -------- Plot R68 of the PSF vs. theta and energy: .. plot:: :include-source: import matplotlib.pyplot as plt from gammapy.irf import EnergyDependentMultiGaussPSF filename = '$GAMMAPY_DATA/tests/unbundled/irfs/psf.fits' psf = EnergyDependentMultiGaussPSF.read(filename, hdu='POINT SPREAD FUNCTION') psf.plot_containment(0.68, show_safe_energy=False) plt.show() """ def __init__( self, energy_lo, energy_hi, theta, sigmas, norms, energy_thresh_lo="0.1 TeV", energy_thresh_hi="100 TeV", ): self.energy_lo = Quantity(energy_lo, "TeV") self.energy_hi = Quantity(energy_hi, "TeV") ebounds = EnergyBounds.from_lower_and_upper_bounds( self.energy_lo, self.energy_hi ) self.energy = ebounds.log_centers self.theta = Quantity(theta, "deg") sigmas[0][sigmas[0] == 0] = 1 sigmas[1][sigmas[1] == 0] = 1 sigmas[2][sigmas[2] == 0] = 1 self.sigmas = sigmas self.norms = norms self.energy_thresh_lo = Quantity(energy_thresh_lo, "TeV") self.energy_thresh_hi = Quantity(energy_thresh_hi, "TeV") self._interp_norms = self._setup_interpolators(self.norms) self._interp_sigmas = self._setup_interpolators(self.sigmas) def _setup_interpolators(self, values_list): interps = [] for values in values_list: interp = ScaledRegularGridInterpolator( points=(self.theta, self.energy), values=values ) interps.append(interp) return interps @classmethod def read(cls, filename, hdu="PSF_2D_GAUSS"): """Create `EnergyDependentMultiGaussPSF` from FITS file. Parameters ---------- filename : str File name """ filename = make_path(filename) with fits.open(str(filename), memmap=False) as hdulist: psf = cls.from_fits(hdulist[hdu]) return psf @classmethod def from_fits(cls, hdu): """Create `EnergyDependentMultiGaussPSF` from HDU list. Parameters ---------- hdu : `~astropy.io.fits.BintableHDU` HDU """ energy_lo = Quantity(hdu.data["ENERG_LO"][0], "TeV") energy_hi = Quantity(hdu.data["ENERG_HI"][0], "TeV") theta = Angle(hdu.data["THETA_LO"][0], "deg") # Get sigmas shape = (len(theta), len(energy_hi)) sigmas = [] for key in ["SIGMA_1", "SIGMA_2", "SIGMA_3"]: sigma = hdu.data[key].reshape(shape).copy() sigmas.append(sigma) # Get amplitudes norms = [] for key in ["SCALE", "AMPL_2", "AMPL_3"]: norm = hdu.data[key].reshape(shape).copy() norms.append(norm) opts = {} try: opts["energy_thresh_lo"] = Quantity(hdu.header["LO_THRES"], "TeV") opts["energy_thresh_hi"] = Quantity(hdu.header["HI_THRES"], "TeV") except KeyError: pass return cls(energy_lo, energy_hi, theta, sigmas, norms, **opts) def to_fits(self): """ Convert psf table data to FITS hdu list. Returns ------- hdu_list : `~astropy.io.fits.HDUList` PSF in HDU list format. """ # Set up data names = [ "ENERG_LO", "ENERG_HI", "THETA_LO", "THETA_HI", "SCALE", "SIGMA_1", "AMPL_2", "SIGMA_2", "AMPL_3", "SIGMA_3", ] units = ["TeV", "TeV", "deg", "deg", "", "deg", "", "deg", "", "deg"] data = [ self.energy_lo, self.energy_hi, self.theta, self.theta, self.norms[0], self.sigmas[0], self.norms[1], self.sigmas[1], self.norms[2], self.sigmas[2], ] table = Table() for name_, data_, unit_ in zip(names, data, units): table[name_] = [data_] table[name_].unit = unit_ # Create hdu and hdu list hdu = fits.BinTableHDU(table) hdu.header["LO_THRES"] = self.energy_thresh_lo.value hdu.header["HI_THRES"] = self.energy_thresh_hi.value return fits.HDUList([fits.PrimaryHDU(), hdu]) def write(self, filename, *args, **kwargs): """Write PSF to FITS file. Calls `~astropy.io.fits.HDUList.writeto`, forwarding all arguments. """ self.to_fits().writeto(filename, *args, **kwargs) def psf_at_energy_and_theta(self, energy, theta): """ Get `~gammapy.image.models.MultiGauss2D` model for given energy and theta. No interpolation is used. Parameters ---------- energy : `~astropy.units.Quantity` Energy at which a PSF is requested. theta : `~astropy.coordinates.Angle` Offset angle at which a PSF is requested. Returns ------- psf : `~gammapy.morphology.MultiGauss2D` Multigauss PSF object. """ energy = Energy(energy) theta = Quantity(theta) pars = {} for name, interp_norm in zip(["scale", "A_2", "A_3"], self._interp_norms): pars[name] = interp_norm((theta, energy)) for idx, interp_sigma in enumerate(self._interp_sigmas): pars["sigma_{}".format(idx + 1)] = interp_sigma((theta, energy)) psf = HESSMultiGaussPSF(pars) return psf.to_MultiGauss2D(normalize=True) def containment_radius(self, energy, theta, fraction=0.68): """Compute containment for all energy and theta values""" # This is a false positive from pylint # See https://github.com/PyCQA/pylint/issues/2435 energies = Energy(energy).flatten() # pylint:disable=assignment-from-no-return thetas = Angle(theta).flatten() radius = np.empty((theta.size, energy.size)) for idx, energy in enumerate(energies): for jdx, theta in enumerate(thetas): try: psf = self.psf_at_energy_and_theta(energy, theta) radius[jdx, idx] = psf.containment_radius(fraction) except ValueError: log.debug( "Computing containment failed for E = {:.2f}" " and Theta={:.2f}".format(energy, theta) ) log.debug("Sigmas: {} Norms: {}".format(psf.sigmas, psf.norms)) radius[jdx, idx] = np.nan return Angle(radius, "deg") def plot_containment( self, fraction=0.68, ax=None, show_safe_energy=False, add_cbar=True, **kwargs ): """ Plot containment image with energy and theta axes. Parameters ---------- fraction : float Containment fraction between 0 and 1. add_cbar : bool Add a colorbar """ import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax energy = self.energy_hi offset = self.theta # Set up and compute data containment = self.containment_radius(energy, offset, fraction) # plotting defaults kwargs.setdefault("cmap", "GnBu") kwargs.setdefault("vmin", np.nanmin(containment.value)) kwargs.setdefault("vmax", np.nanmax(containment.value)) # Plotting x = energy.value y = offset.value caxes = ax.pcolormesh(x, y, containment.value, **kwargs) # Axes labels and ticks, colobar ax.semilogx() ax.set_ylabel("Offset ({unit})".format(unit=offset.unit)) ax.set_xlabel("Energy ({unit})".format(unit=energy.unit)) ax.set_xlim(x.min(), x.max()) ax.set_ylim(y.min(), y.max()) if show_safe_energy: self._plot_safe_energy_range(ax) if add_cbar: label = "Containment radius R{:.0f} ({})".format( 100 * fraction, containment.unit ) ax.figure.colorbar(caxes, ax=ax, label=label) return ax def _plot_safe_energy_range(self, ax): """add safe energy range lines to the plot""" esafe = self.energy_thresh_lo omin = self.offset.value.min() omax = self.offset.value.max() ax.hlines(y=esafe.value, xmin=omin, xmax=omax) label = "Safe energy threshold: {:3.2f}".format(esafe) ax.text(x=0.1, y=0.9 * esafe.value, s=label, va="top") def plot_containment_vs_energy( self, fractions=[0.68, 0.95], thetas=Angle([0, 1], "deg"), ax=None, **kwargs ): """Plot containment fraction as a function of energy. """ import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax energy = Energy.equal_log_spacing(self.energy_lo[0], self.energy_hi[-1], 100) for theta in thetas: for fraction in fractions: radius = self.containment_radius(energy, theta, fraction).squeeze() label = "{} deg, {:.1f}%".format(theta.deg, 100 * fraction) ax.plot(energy.value, radius.value, label=label) ax.semilogx() ax.legend(loc="best") ax.set_xlabel("Energy (TeV)") ax.set_ylabel("Containment radius (deg)") def peek(self, figsize=(15, 5)): """Quick-look summary plots.""" import matplotlib.pyplot as plt fig, axes = plt.subplots(nrows=1, ncols=3, figsize=figsize) self.plot_containment(fraction=0.68, ax=axes[0]) self.plot_containment(fraction=0.95, ax=axes[1]) self.plot_containment_vs_energy(ax=axes[2]) # TODO: implement this plot # psf = self.psf_at_energy_and_theta(energy='1 TeV', theta='1 deg') # psf.plot_components(ax=axes[2]) plt.tight_layout() def info( self, fractions=[0.68, 0.95], energies=Quantity([1.0, 10.0], "TeV"), thetas=Quantity([0.0], "deg"), ): """ Print PSF summary info. The containment radius for given fraction, energies and thetas is computed and printed on the command line. Parameters ---------- fractions : list Containment fraction to compute containment radius for. energies : `~astropy.units.Quantity` Energies to compute containment radius for. thetas : `~astropy.units.Quantity` Thetas to compute containment radius for. Returns ------- ss : string Formatted string containing the summary info. """ ss = "\nSummary PSF info\n" ss += "----------------\n" # Summarise data members ss += array_stats_str(self.theta.to("deg"), "Theta") ss += array_stats_str(self.energy_hi, "Energy hi") ss += array_stats_str(self.energy_lo, "Energy lo") ss += "Safe energy threshold lo: {:6.3f}\n".format(self.energy_thresh_lo) ss += "Safe energy threshold hi: {:6.3f}\n".format(self.energy_thresh_hi) for fraction in fractions: containment = self.containment_radius(energies, thetas, fraction) for i, energy in enumerate(energies): for j, theta in enumerate(thetas): radius = containment[j, i] ss += ( "{:2.0f}% containment radius at theta = {} and " "E = {:4.1f}: {:5.8f}\n" "".format(100 * fraction, theta, energy, radius) ) return ss def to_energy_dependent_table_psf(self, theta=None, rad=None, exposure=None): """ Convert triple Gaussian PSF ot table PSF. Parameters ---------- theta : `~astropy.coordinates.Angle` Offset in the field of view. Default theta = 0 deg rad : `~astropy.coordinates.Angle` Offset from PSF center used for evaluating the PSF on a grid. Default offset = [0, 0.005, ..., 1.495, 1.5] deg. exposure : `~astropy.units.Quantity` Energy dependent exposure. Should be in units equivalent to 'cm^2 s'. Default exposure = 1. Returns ------- tabe_psf : `~gammapy.irf.EnergyDependentTablePSF` Instance of `EnergyDependentTablePSF`. """ # Convert energies to log center energies = self.energy # Defaults and input handling if theta is None: theta = Angle(0, "deg") else: theta = Angle(theta) if rad is None: rad = Angle(np.arange(0, 1.5, 0.005), "deg") else: rad = Angle(rad).to("deg") psf_value = Quantity(np.zeros((energies.size, rad.size)), "deg^-2") for idx, energy in enumerate(energies): psf_gauss = self.psf_at_energy_and_theta(energy, theta) psf_value[idx] = Quantity(psf_gauss(rad), "deg^-2") return EnergyDependentTablePSF( energy=energies, rad=rad, exposure=exposure, psf_value=psf_value ) def to_psf3d(self, rad): """Create a PSF3D from an analytical PSF. Parameters ---------- rad : `~astropy.units.Quantity` or `~astropy.coordinates.Angle` the array of position errors (rad) on which the PSF3D will be defined Returns ------- psf3d : `~gammapy.irf.PSF3D` the PSF3D. It will be defined on the same energy and offset values than the input psf. """ offsets = self.theta energy = self.energy energy_lo = self.energy_lo energy_hi = self.energy_hi rad_lo = rad[:-1] rad_hi = rad[1:] psf_values = np.zeros( (rad_lo.shape[0], offsets.shape[0], energy_lo.shape[0]) ) * Unit("sr-1") for i, offset in enumerate(offsets): psftable = self.to_energy_dependent_table_psf(offset) psf_values[:, i, :] = psftable.evaluate(energy, 0.5 * (rad_lo + rad_hi)).T return PSF3D( energy_lo, energy_hi, offsets, rad_lo, rad_hi, psf_values, self.energy_thresh_lo, self.energy_thresh_hi, )
class EnergyDependentTablePSF(object): """Energy-dependent radially-symmetric table PSF (``gtpsf`` format). TODO: add references and explanations. Parameters ---------- energy : `~astropy.units.Quantity` Energy (1-dim) offset : `~astropy.units.Quantity` with angle units Offset angle (1-dim) exposure : `~astropy.units.Quantity` Exposure (1-dim) psf_value : `~astropy.units.Quantity` PSF (2-dim with axes: psf[energy_index, offset_index] """ def __init__(self, energy, offset, exposure=None, psf_value=None): self.energy = Quantity(energy).to("GeV") self.offset = Quantity(offset).to("radian") if not exposure: self.exposure = Quantity(np.ones(len(energy)), "cm^2 s") else: self.exposure = Quantity(exposure).to("cm^2 s") if not psf_value: self.psf_value = Quantity(np.zeros(len(energy), len(offset)), "sr^-1") else: self.psf_value = Quantity(psf_value).to("sr^-1") # Cache for TablePSF at each energy ... only computed when needed self._table_psf_cache = [None] * len(self.energy) @classmethod def from_fits(cls, hdu_list): """Create `EnergyDependentTablePSF` from ``gtpsf`` format HDU list. Parameters ---------- hdu_list : `~astropy.io.fits.HDUList` HDU list with ``THETA`` and ``PSF`` extensions. """ offset = Angle(hdu_list["THETA"].data["Theta"], "deg") energy = Quantity(hdu_list["PSF"].data["Energy"], "MeV") exposure = Quantity(hdu_list["PSF"].data["Exposure"], "cm^2 s") psf_value = Quantity(hdu_list["PSF"].data["PSF"], "sr^-1") return cls(energy, offset, exposure, psf_value) def to_fits(self): """Convert to FITS HDU list format. Returns ------- hdu_list : `~astropy.io.fits.HDUList` PSF in HDU list format. """ # TODO: write HEADER keywords as gtpsf data = self.offset theta_hdu = fits.BinTableHDU(data=data, name="Theta") data = [self.energy, self.exposure, self.psf_value] psf_hdu = fits.BinTableHDU(data=data, name="PSF") hdu_list = fits.HDUList([theta_hdu, psf_hdu]) return hdu_list @classmethod def read(cls, filename): """Create `EnergyDependentTablePSF` from ``gtpsf``-format FITS file. Parameters ---------- filename : str File name """ hdu_list = fits.open(filename) return cls.from_fits(hdu_list) def write(self, *args, **kwargs): """Write to FITS file. Calls `~astropy.io.fits.HDUList.writeto`, forwarding all arguments. """ self.to_fits().writeto(*args, **kwargs) def evaluate(self, energy=None, offset=None, interp_kwargs=None): """Interpolate the value of the `EnergyOffsetArray` at a given offset and Energy. Parameters ---------- energy : `~astropy.units.Quantity` energy value offset : `~astropy.coordinates.Angle` offset value interp_kwargs : dict option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- values : `~astropy.units.Quantity` Interpolated value """ if not interp_kwargs: interp_kwargs = dict(bounds_error=False, fill_value=None) from scipy.interpolate import RegularGridInterpolator if energy is None: energy = self.energy if offset is None: offset = self.offset energy = Energy(energy).to("TeV") offset = Angle(offset).to("deg") energy_bin = self.energy.to("TeV") offset_bin = self.offset.to("deg") points = (energy_bin, offset_bin) interpolator = RegularGridInterpolator(points, self.psf_value, **interp_kwargs) ee, off = np.meshgrid(energy.value, offset.value, indexing="ij") shape = ee.shape pix_coords = np.column_stack([ee.flat, off.flat]) data_interp = interpolator(pix_coords) return Quantity(data_interp.reshape(shape), self.psf_value.unit) def table_psf_at_energy(self, energy, interp_kwargs=None, **kwargs): """Evaluate the `EnergyOffsetArray` at one given energy. Parameters ---------- energy : `~astropy.units.Quantity` Energy interp_kwargs : dict Option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- table : `~astropy.table.Table` Table with two columns: offset, value """ psf_value = self.evaluate(energy, None, interp_kwargs)[0, :] table_psf = TablePSF(self.offset, psf_value, **kwargs) return table_psf def table_psf_in_energy_band(self, energy_band, spectral_index=2, spectrum=None, **kwargs): """Average PSF in a given energy band. Expected counts in sub energy bands given the given exposure and spectrum are used as weights. Parameters ---------- energy_band : `~astropy.units.Quantity` Energy band spectral_index : float Power law spectral index (used if spectrum=None). spectrum : callable Spectrum (callable with energy as parameter). Returns ------- psf : `TablePSF` Table PSF """ if spectrum is None: def spectrum(energy): return (energy / energy_band[0]) ** (-spectral_index) # TODO: warn if `energy_band` is outside available data. energy_idx_min, energy_idx_max = self._energy_index(energy_band) # TODO: extract this into a utility function `npred_weighted_mean()` # Compute weights for energy bins weights = np.zeros_like(self.energy.value, dtype=np.float64) for idx in range(energy_idx_min, energy_idx_max - 1): energy_min = self.energy[idx] energy_max = self.energy[idx + 1] exposure = self.exposure[idx] flux = spectrum(energy_min) weights[idx] = (exposure * flux * (energy_max - energy_min)).value # Normalize weights to sum to 1 weights = weights / weights.sum() # Compute weighted PSF value array total_psf_value = np.zeros_like(self._get_1d_psf_values(0), dtype=np.float64) for idx in range(energy_idx_min, energy_idx_max - 1): psf_value = self._get_1d_psf_values(idx) total_psf_value += weights[idx] * psf_value # TODO: add version that returns `total_psf_value` without # making a `TablePSF`. return TablePSF(self.offset, total_psf_value, **kwargs) def containment_radius(self, energy, fraction, interp_kwargs=None): """Containment radius. Parameters ---------- energy : `~astropy.units.Quantity` Energy fraction : float Containment fraction in % Returns ------- radius : `~astropy.units.Quantity` Containment radius in deg """ # TODO: useless at the moment ... support array inputs or remove! psf = self.table_psf_at_energy(energy, interp_kwargs) return psf.containment_radius(fraction) def integral(self, energy, offset_min, offset_max): """Containment fraction. Parameters ---------- energy : `~astropy.units.Quantity` Energy offset_min, offset_max : `~astropy.coordinates.Angle` Offset Returns ------- fraction : array_like Containment fraction (in range 0 .. 1) """ # TODO: useless at the moment ... support array inputs or remove! psf = self.table_psf_at_energy(energy) return psf.integral(offset_min, offset_max) def info(self): """Print basic info.""" # Summarise data members ss = array_stats_str(self.offset.to("deg"), "offset") ss += array_stats_str(self.energy, "energy") ss += array_stats_str(self.exposure, "exposure") # ss += 'integral = {0}\n'.format(self.integral()) # Print some example containment radii fractions = [0.68, 0.95] energies = Quantity([10, 100], "GeV") for energy in energies: for fraction in fractions: radius = self.containment_radius(energy=energy, fraction=fraction) ss += "{0}% containment radius at {1}: {2}\n".format(100 * fraction, energy, radius) return ss def plot_psf_vs_theta(self, filename=None, energies=[1e4, 1e5, 1e6]): """Plot PSF vs theta. Parameters ---------- TODO """ import matplotlib.pyplot as plt plt.figure(figsize=(6, 4)) for energy in energies: energy_index = self._energy_index(energy) psf = self.psf_value[energy_index, :] label = "{0} GeV".format(1e-3 * energy) x = np.hstack([-self.theta[::-1], self.theta]) y = 1e-6 * np.hstack([psf[::-1], psf]) plt.plot(x, y, lw=2, label=label) # plt.semilogy() # plt.loglog() plt.legend() plt.xlim(-0.2, 0.5) plt.xlabel("Offset (deg)") plt.ylabel("PSF (1e-6 sr^-1)") plt.tight_layout() if filename != None: plt.savefig(filename) def plot_containment_vs_energy(self, filename=None): """Plot containment versus energy.""" raise NotImplementedError import matplotlib.pyplot as plt plt.clf() if filename != None: plt.savefig(filename) def plot_exposure_vs_energy(self, filename=None): """Plot exposure versus energy.""" import matplotlib.pyplot as plt plt.figure(figsize=(4, 3)) plt.plot(self.energy, self.exposure, color="black", lw=3) plt.semilogx() plt.xlabel("Energy (MeV)") plt.ylabel("Exposure (cm^2 s)") plt.xlim(1e4 / 1.3, 1.3 * 1e6) plt.ylim(0, 1.5e11) plt.tight_layout() if filename != None: plt.savefig(filename) def _energy_index(self, energy): """Find energy array index. """ # TODO: test with array input return np.searchsorted(self.energy, energy) def _get_1d_psf_values(self, energy_index): """Get 1-dim PSF value array. Parameters ---------- energy_index : int Energy index Returns ------- psf_values : `~astropy.units.Quantity` PSF value array """ psf_values = self.psf_value[energy_index, :].flatten().copy() return psf_values def _get_1d_table_psf(self, energy_index, **kwargs): """Get 1-dim TablePSF (cached). Parameters ---------- energy_index : int Energy index Returns ------- table_psf : `TablePSF` Table PSF """ # TODO: support array_like `energy_index` here? if self._table_psf_cache[energy_index] is None: psf_value = self._get_1d_psf_values(energy_index) table_psf = TablePSF(self.offset, psf_value, **kwargs) self._table_psf_cache[energy_index] = table_psf return self._table_psf_cache[energy_index]
class EnergyDependentTablePSF(object): """Energy-dependent radially-symmetric table PSF (``gtpsf`` format). TODO: add references and explanations. Parameters ---------- energy : `~astropy.units.Quantity` Energy (1-dim) rad : `~astropy.units.Quantity` with angle units Offset angle wrt source position (1-dim) exposure : `~astropy.units.Quantity` Exposure (1-dim) psf_value : `~astropy.units.Quantity` PSF (2-dim with axes: psf[energy_index, offset_index] """ def __init__(self, energy, rad, exposure=None, psf_value=None): self.energy = Quantity(energy).to('GeV') self.rad = Quantity(rad).to('radian') if exposure is None: self.exposure = Quantity(np.ones(len(energy)), 'cm^2 s') else: self.exposure = Quantity(exposure).to('cm^2 s') if psf_value is None: self.psf_value = Quantity(np.zeros(len(energy), len(rad)), 'sr^-1') else: self.psf_value = Quantity(psf_value).to('sr^-1') # Cache for TablePSF at each energy ... only computed when needed self._table_psf_cache = [None] * len(self.energy) def __str__(self): ss = 'EnergyDependentTablePSF\n' ss += '-----------------------\n' ss += '\nAxis info:\n' ss += ' ' + array_stats_str(self.rad.to('deg'), 'rad') ss += ' ' + array_stats_str(self.energy, 'energy') # ss += ' ' + array_stats_str(self.exposure, 'exposure') # ss += 'integral = {}\n'.format(self.integral()) ss += '\nContainment info:\n' # Print some example containment radii fractions = [0.68, 0.95] energies = Quantity([10, 100], 'GeV') for fraction in fractions: rads = self.containment_radius(energies=energies, fraction=fraction) for energy, rad in zip(energies, rads): ss += ' ' + '{}% containment radius at {:3.0f}: {:.2f}\n'.format(100 * fraction, energy, rad) return ss @classmethod def from_fits(cls, hdu_list): """Create `EnergyDependentTablePSF` from ``gtpsf`` format HDU list. Parameters ---------- hdu_list : `~astropy.io.fits.HDUList` HDU list with ``THETA`` and ``PSF`` extensions. """ rad = Angle(hdu_list['THETA'].data['Theta'], 'deg') energy = Quantity(hdu_list['PSF'].data['Energy'], 'MeV') exposure = Quantity(hdu_list['PSF'].data['Exposure'], 'cm^2 s') psf_value = Quantity(hdu_list['PSF'].data['PSF'], 'sr^-1') return cls(energy, rad, exposure, psf_value) def to_fits(self): """Convert to FITS HDU list format. Returns ------- hdu_list : `~astropy.io.fits.HDUList` PSF in HDU list format. """ # TODO: write HEADER keywords as gtpsf data = self.rad theta_hdu = fits.BinTableHDU(data=data, name='Theta') data = [self.energy, self.exposure, self.psf_value] psf_hdu = fits.BinTableHDU(data=data, name='PSF') hdu_list = fits.HDUList([theta_hdu, psf_hdu]) return hdu_list @classmethod def read(cls, filename): """Create `EnergyDependentTablePSF` from ``gtpsf``-format FITS file. Parameters ---------- filename : str File name """ hdu_list = fits.open(filename) return cls.from_fits(hdu_list) def write(self, *args, **kwargs): """Write to FITS file. Calls `~astropy.io.fits.HDUList.writeto`, forwarding all arguments. """ self.to_fits().writeto(*args, **kwargs) def evaluate(self, energy=None, rad=None, interp_kwargs=None): """Evaluate the PSF at a given energy and offset Parameters ---------- energy : `~astropy.units.Quantity` energy value rad : `~astropy.coordinates.Angle` Offset wrt source position interp_kwargs : dict option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- values : `~astropy.units.Quantity` Interpolated value """ if interp_kwargs is None: interp_kwargs = dict(bounds_error=False, fill_value=None) from scipy.interpolate import RegularGridInterpolator if energy is None: energy = self.energy if rad is None: rad = self.rad energy = Energy(energy).to('TeV') rad = Angle(rad).to('deg') energy_bin = self.energy.to('TeV') rad_bin = self.rad.to('deg') points = (energy_bin, rad_bin) interpolator = RegularGridInterpolator(points, self.psf_value, **interp_kwargs) energy_grid, rad_grid = np.meshgrid(energy.value, rad.value, indexing='ij') shape = energy_grid.shape pix_coords = np.column_stack([energy_grid.flat, rad_grid.flat]) data_interp = interpolator(pix_coords) return Quantity(data_interp.reshape(shape), self.psf_value.unit) def table_psf_at_energy(self, energy, interp_kwargs=None, **kwargs): """Evaluate the `EnergyOffsetArray` at one given energy. Parameters ---------- energy : `~astropy.units.Quantity` Energy interp_kwargs : dict Option for interpolation for `~scipy.interpolate.RegularGridInterpolator` Returns ------- table : `~astropy.table.Table` Table with two columns: offset, value """ psf_value = self.evaluate(energy, None, interp_kwargs)[0, :] table_psf = TablePSF(self.rad, psf_value, **kwargs) return table_psf def kernels(self, cube, rad_max, **kwargs): """ Make a set of 2D kernel images, representing the PSF at different energies. The kernel image is evaluated on the spatial and energy grid defined by the reference sky cube. Parameters ---------- cube : `~gammapy.cube.SkyCube` Reference sky cube. rad_max `~astropy.coordinates.Angle` PSF kernel size kwargs : dict Keyword arguments passed to `EnergyDependentTablePSF.table_psf_in_energy_band()`. Returns ------- kernels : list of `~numpy.ndarray` List of 2D convolution kernels. """ energies = cube.energies(mode='edges') kernels = [] for emin, emax in zip(energies[:-1], energies[1:]): energy_band = Quantity([emin, emax]) try: psf = self.table_psf_in_energy_band(energy_band, **kwargs) kernel = psf.kernel(cube.sky_image_ref, rad_max=rad_max) except ValueError: kernel = np.nan * np.ones((1, 1)) # Dummy, means "no kernel available" kernels.append(kernel) return kernels def table_psf_in_energy_band(self, energy_band, spectral_index=2, spectrum=None, **kwargs): """Average PSF in a given energy band. Expected counts in sub energy bands given the given exposure and spectrum are used as weights. Parameters ---------- energy_band : `~astropy.units.Quantity` Energy band spectral_index : float Power law spectral index (used if spectrum=None). spectrum : callable Spectrum (callable with energy as parameter). Returns ------- psf : `TablePSF` Table PSF """ if spectrum is None: def spectrum(energy): return (energy / energy_band[0]) ** (-spectral_index) # TODO: warn if `energy_band` is outside available data. energy_idx_min, energy_idx_max = self._energy_index(energy_band) # TODO: improve this, probably by evaluating the PSF (i.e. interpolating in energy) onto a new energy grid # This is a bit of a hack, but makes sure that a PSF is given, by forcing at least one slice: if energy_idx_max - energy_idx_min < 2: # log.warning('Dubious case of PSF energy binning') # Note that below always range stop of `energy_idx_max - 1` is used! # That's why we put +2 here to make sure we have at least one bin. energy_idx_max = max(energy_idx_min + 2, energy_idx_max) # Make sure we don't step out of the energy array (doesn't help much) energy_idx_max = min(energy_idx_max, len(self.energy)) # TODO: extract this into a utility function `npred_weighted_mean()` # Compute weights for energy bins weights = np.zeros_like(self.energy.value, dtype=np.float64) for idx in range(energy_idx_min, energy_idx_max - 1): energy_min = self.energy[idx] energy_max = self.energy[idx + 1] exposure = self.exposure[idx] flux = spectrum(energy_min) weights[idx] = (exposure * flux * (energy_max - energy_min)).value # Normalize weights to sum to 1 weights = weights / weights.sum() # Compute weighted PSF value array total_psf_value = np.zeros_like(self._get_1d_psf_values(0), dtype=np.float64) for idx in range(energy_idx_min, energy_idx_max - 1): psf_value = self._get_1d_psf_values(idx) total_psf_value += weights[idx] * psf_value # TODO: add version that returns `total_psf_value` without # making a `TablePSF`. return TablePSF(self.rad, total_psf_value, **kwargs) def containment_radius(self, energies, fraction, interp_kwargs=None): """Containment radius. Parameters ---------- energies : `~astropy.units.Quantity` Energy fraction : float Containment fraction in % Returns ------- rad : `~astropy.units.Quantity` Containment radius in deg """ # TODO: figure out if there's a more efficient implementation to support # arrays of energy energies = np.atleast_1d(energies) psfs = [self.table_psf_at_energy(energy, interp_kwargs) for energy in energies] rad = [psf.containment_radius(fraction) for psf in psfs] return Quantity(rad) def integral(self, energy, rad_min, rad_max): """Containment fraction. Parameters ---------- energy : `~astropy.units.Quantity` Energy rad_min, rad_max : `~astropy.coordinates.Angle` Offset Returns ------- fraction : array_like Containment fraction (in range 0 .. 1) """ # TODO: useless at the moment ... support array inputs or remove! psf = self.table_psf_at_energy(energy) return psf.integral(rad_min, rad_max) def info(self): """Print basic info""" print(self.__str__) def plot_psf_vs_rad(self, energies=[1e4, 1e5, 1e6]): """Plot PSF vs radius. Parameters ---------- TODO """ import matplotlib.pyplot as plt plt.figure(figsize=(6, 4)) for energy in energies: energy_index = self._energy_index(energy) psf = self.psf_value[energy_index, :] label = '{} GeV'.format(1e-3 * energy) x = np.hstack([-self.rad[::-1], self.rad]) y = 1e-6 * np.hstack([psf[::-1], psf]) plt.plot(x, y, lw=2, label=label) # plt.semilogy() # plt.loglog() plt.legend() plt.xlim(-0.2, 0.5) plt.xlabel('Offset (deg)') plt.ylabel('PSF (1e-6 sr^-1)') plt.tight_layout() def plot_containment_vs_energy(self, ax=None, fractions=[0.63, 0.8, 0.95], **kwargs): """Plot containment versus energy.""" import matplotlib.pyplot as plt ax = plt.gca() if ax is None else ax energy = Energy.equal_log_spacing( self.energy.min(), self.energy.max(), 10) for fraction in fractions: rad = self.containment_radius(energy, fraction) label = '{:.1f}% Containment'.format(100 * fraction) ax.plot(energy.value, rad.value, label=label, **kwargs) ax.semilogx() ax.legend(loc='best') ax.set_xlabel('Energy (GeV)') ax.set_ylabel('Containment radius (deg)') def plot_exposure_vs_energy(self): """Plot exposure versus energy.""" import matplotlib.pyplot as plt plt.figure(figsize=(4, 3)) plt.plot(self.energy, self.exposure, color='black', lw=3) plt.semilogx() plt.xlabel('Energy (MeV)') plt.ylabel('Exposure (cm^2 s)') plt.xlim(1e4 / 1.3, 1.3 * 1e6) plt.ylim(0, 1.5e11) plt.tight_layout() def _energy_index(self, energy): """Find energy array index. """ # TODO: test with array input return np.searchsorted(self.energy, energy) def _get_1d_psf_values(self, energy_index): """Get 1-dim PSF value array. Parameters ---------- energy_index : int Energy index Returns ------- psf_values : `~astropy.units.Quantity` PSF value array """ psf_values = self.psf_value[energy_index, :].flatten().copy() where_are_NaNs = np.isnan(psf_values) # When the PSF Table is not filled (with nan), the psf estimation at a given energy crashes psf_values[where_are_NaNs] = 0 return psf_values def _get_1d_table_psf(self, energy_index, **kwargs): """Get 1-dim TablePSF (cached). Parameters ---------- energy_index : int Energy index Returns ------- table_psf : `TablePSF` Table PSF """ # TODO: support array_like `energy_index` here? if self._table_psf_cache[energy_index] is None: psf_value = self._get_1d_psf_values(energy_index) table_psf = TablePSF(self.rad, psf_value, **kwargs) self._table_psf_cache[energy_index] = table_psf return self._table_psf_cache[energy_index]