Ejemplo n.º 1
0
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
Ejemplo n.º 2
0
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
        )
Ejemplo n.º 3
0
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
        )
Ejemplo n.º 4
0
    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)
Ejemplo n.º 5
0
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
Ejemplo n.º 6
0
    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)
Ejemplo n.º 7
0
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
Ejemplo n.º 8
0
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
Ejemplo n.º 9
0
    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')
Ejemplo n.º 10
0
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)
Ejemplo n.º 11
0
 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."
         )
Ejemplo n.º 12
0
 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']
Ejemplo n.º 13
0
 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)
Ejemplo n.º 14
0
    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
Ejemplo n.º 15
0
Archivo: utils.py Proyecto: DavidT3/XGA
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
Ejemplo n.º 16
0
    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)
Ejemplo n.º 17
0
    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
Ejemplo n.º 18
0
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)
Ejemplo n.º 19
0
Archivo: sb.py Proyecto: DavidT3/XGA
    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
Ejemplo n.º 20
0
    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)
Ejemplo n.º 21
0
 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
Ejemplo n.º 22
0
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)
Ejemplo n.º 23
0
 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
Ejemplo n.º 24
0
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'
Ejemplo n.º 26
0
    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)
Ejemplo n.º 27
0
 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
Ejemplo n.º 28
0
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')
Ejemplo n.º 29
0
 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
Ejemplo n.º 30
0
Archivo: misc.py Proyecto: DavidT3/XGA
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
Ejemplo n.º 31
0
Archivo: misc.py Proyecto: DavidT3/XGA
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')
Ejemplo n.º 32
0
    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
Ejemplo n.º 33
0
    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
Ejemplo n.º 34
0
    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")
Ejemplo n.º 35
0
    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,
        )
Ejemplo n.º 36
0
    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
Ejemplo n.º 37
0
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)
Ejemplo n.º 38
0
 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.")
Ejemplo n.º 39
0
 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
Ejemplo n.º 40
0
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
Ejemplo n.º 41
0
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
Ejemplo n.º 42
0
    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
Ejemplo n.º 43
0
    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')
Ejemplo n.º 44
0
    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
Ejemplo n.º 45
0
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)
Ejemplo n.º 46
0
    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
Ejemplo n.º 47
0
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
Ejemplo n.º 48
0
 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)
Ejemplo n.º 49
0
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)
Ejemplo n.º 50
0
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,
        )
Ejemplo n.º 51
0
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]
Ejemplo n.º 52
0
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]