Source code for stixpy.calibration.visibility

from types import SimpleNamespace
from typing import Union
from pathlib import Path

import astropy.units as u
import numpy as np
from astropy.coordinates import SkyCoord
from astropy.table import Table
from astropy.time import Time
from astropy.units import Quantity
from sunpy.coordinates import HeliographicStonyhurst
from sunpy.time import TimeRange
from xrayvision.visibility import Visibilities, VisMeta

from stixpy.calibration.energy import get_elut
from stixpy.calibration.grid import get_grid_transmission
from stixpy.calibration.livetime import get_livetime_fraction
from stixpy.config.instrument import STIX_INSTRUMENT, _get_uv_points_data
from stixpy.coordinates.frames import STIXImaging
from stixpy.coordinates.transforms import get_hpc_info
from stixpy.product.sources import CompressedPixelData, RawPixelData, SummedCompressedPixelData

__all__ = [
    "get_subcollimator_info",
    "create_meta_pixels",
    "create_visibility",
    "get_uv_points_data",
    "calibrate_visibility",
]

_PIXEL_SLICES = {
    "top": slice(0, 4),
    "bot": slice(4, 8),
    "top+bot": slice(0, 8),
    "all": slice(None),
    "small": slice(8, None),
}


[docs] @u.quantity_input def get_uv_points_data(d_det: u.Quantity[u.mm] = 47.78 * u.mm, d_sep: u.Quantity[u.mm] = 545.30 * u.mm): r""" Return the STIX (u,v) points coordinates defined in [1], ordered with respect to the detector index. It will return the precalculated cached data if the default values for `d_det` and `d_sep` are used. Parameters ---------- d_det: `u.Quantity[u.mm]` optional Distance between the rear grid and the detector plane (in mm). Default, 47.78 * u.mm d_sep: `u.Quantity[u.mm]` optional Distance between the front and the rear grid (in mm). Default, 545.30 * u.mm Returns ------- A dictionary containing sub-collimator indices, sub-collimator labels and coordinates of the STIX (u,v) points (defined in arcsec :sup:`-1`) References ---------- [1] Massa et al., 2023, The STIX Imaging Concept, Solar Physics, 298, https://doi.org/10.1007/s11207-023-02205-7 """ if d_det == 47.78 * u.mm and d_sep == 545.30 * u.mm: # If the default values are used, return the cached data return STIX_INSTRUMENT.vis_info return _get_uv_points_data(d_det=d_det, d_sep=d_sep)
[docs] def get_subcollimator_info(): r""" Return resolutions, orientations, and labels for sub-collimators. Returns ------- """ grids = { 9: np.array([3, 20, 22]) - 1, 8: np.array([16, 14, 32]) - 1, 7: np.array([21, 26, 4]) - 1, 6: np.array([24, 8, 28]) - 1, 5: np.array([15, 27, 31]) - 1, 4: np.array([6, 30, 2]) - 1, 3: np.array([25, 5, 23]) - 1, 2: np.array([7, 29, 1]) - 1, 1: np.array([12, 19, 17]) - 1, 0: np.array([11, 13, 18]) - 1, } labels = {i: [str(i + 1) + "a", str(i) + "b", str(i) + "c"] for i in range(10)} res32 = np.zeros(32) res32[grids[9]] = 178.6 res32[grids[8]] = 124.9 res32[grids[7]] = 87.3 res32[grids[6]] = 61.0 res32[grids[5]] = 42.7 res32[grids[4]] = 29.8 res32[grids[3]] = 20.9 res32[grids[2]] = 14.6 res32[grids[1]] = 10.2 res32[grids[0]] = 7.1 res10 = [7.1, 10.2, 14.6, 20.9, 29.8, 42.7, 61.0, 87.3, 124.9, 178.6] o32 = np.zeros(32) o32[grids[9]] = [150, 90, 30] o32[grids[8]] = [170, 110, 50] o32[grids[7]] = [10, 130, 70] o32[grids[6]] = [30, 150, 90] o32[grids[5]] = [50, 170, 110] o32[grids[4]] = [70, 10, 130] o32[grids[3]] = [90, 30, 150] o32[grids[2]] = [110, 50, 170] o32[grids[1]] = [130, 70, 10] o32[grids[0]] = [150, 90, 30] # g03_10 = [g03, g04, g05, g06, g07, g08, g09, g10] # g01_10 = [g01, g02, g03, g04, g05, g06, g07, g08, g09, g10] # g_plot = [g10, g05, g09, g04, g08, g03, g07, g02, g06, g01] # l_plot = [l10, l05, l09, l04, l08, l03, l07, l02, l06, l01] res = SimpleNamespace(res32=res32, o32=o32, res10=res10, label=labels) return res
[docs] @u.quantity_input def create_meta_pixels( pixel_data: Union[RawPixelData, CompressedPixelData, SummedCompressedPixelData], time_range: Time, energy_range: Quantity["energy"], # noqa: F821 flare_location: SkyCoord | None = None, pixels: str = "top+bot", no_shadowing: bool = False, ): r""" Create meta-pixels by summing data with in given time and energy range. Parameters ---------- pixel_data Input pixel data time_range : Start and end times energy_range Start and end energies flare_location The coordinates of flare used to calculate grid transmission pixels : The set of pixels to use to create the meta pixels. Allowed values are 'all', 'big' (default), 'small', 'top', 'bottom'. no_shadowing : bool optional If set to True turn grid shadowing correction off Returns ------- `dict` Visibility data and sub-collimator information """ # checks if a time bin fully overlaps, is fully within, starts within, or ends within the specified time range. pixel_starts = pixel_data.times - pixel_data.duration / 2 pixel_ends = pixel_data.times + pixel_data.duration / 2 time_range_start = Time(time_range[0]) time_range_end = Time(time_range[1]) t_mask = ( (pixel_starts >= time_range_start) & (pixel_ends <= time_range_end) # fully within the time range | (time_range_start <= pixel_starts) & (time_range_end >= pixel_ends) # fully overlaps the time range | (pixel_starts <= time_range_start) & (pixel_ends >= time_range_start) # starts within the time range | (pixel_starts <= time_range_end) & (pixel_ends >= time_range_end) # ends within the time range ) e_mask = (pixel_data.energies["e_low"] >= energy_range[0]) & (pixel_data.energies["e_high"] <= energy_range[1]) t_ind = np.argwhere(t_mask).ravel() e_ind = np.argwhere(e_mask).ravel() time_range = TimeRange( pixel_data.times[t_ind[0]] - pixel_data.duration[t_ind[0]] / 2, pixel_data.times[t_ind[-1]] + pixel_data.duration[t_ind[-1]] / 2, ) changed = [] for column in ["rcr", "pixel_masks", "detector_masks"]: if np.unique(pixel_data.data[column][t_ind], axis=0).shape[0] != 1: changed.append(column) if len(changed) > 0: raise ValueError( f"The following: {', '.join(changed)} changed in the selected time interval " f"please select a time interval where these are constant." ) trigger_to_detector = STIX_INSTRUMENT.subcol_adc_mapping # Map the triggers to all 32 detectors triggers = pixel_data.data["triggers"][:, trigger_to_detector].astype(float)[...] livefrac, *_ = get_livetime_fraction(triggers / pixel_data.data["timedel"].to("s").reshape(-1, 1)) pixel_data.data["livefrac"] = livefrac e_cor_high, e_cor_low = get_elut_correction(e_ind, pixel_data) # Get counts and other data. idx_pix = _PIXEL_SLICES.get(pixels.lower(), None) if idx_pix is None: raise ValueError(f"Unrecognised input for 'pixels': {pixels}. Supported values: {list(_PIXEL_SLICES.keys())}") counts = pixel_data.data["counts"].astype(float) count_errors = np.sqrt(pixel_data.data["counts_comp_err"].astype(float).value ** 2 + counts.value) * u.ct ct = counts[t_ind][..., idx_pix, e_ind] ct[..., 0] = ct[..., 0] * e_cor_low[..., idx_pix] ct[..., -1] = ct[..., -1] * e_cor_high[..., idx_pix] ct_error = count_errors[t_ind][..., idx_pix, e_ind] ct_error[..., 0] = ct_error[..., 0] * e_cor_low[..., idx_pix] ct_error[..., -1] = ct_error[..., -1] * e_cor_high[..., idx_pix] lt = (livefrac * pixel_data.data["timedel"].reshape(-1, 1).to("s"))[t_ind].sum(axis=0) ct_summed = ct.sum(axis=(0, 3)) # .astype(float) ct_error_summed = np.sqrt(np.sum(ct_error**2, axis=(0, 3))) if not no_shadowing: if flare_location is None or not isinstance(flare_location, SkyCoord): raise ValueError("flare_location must be a SkyCoord object if using grid shadowing correction.") if not isinstance(flare_location.frame, STIXImaging) and flare_location.obstime != time_range.center: roll, solo_heeq, stix_pointing = get_hpc_info(time_range.start, time_range.end) flare_location = flare_location.transform_to(STIXImaging(obstime=time_range.center, observer=solo_heeq)) grid_shadowing = get_grid_transmission(flare_location) ct_summed = ct_summed / grid_shadowing.reshape(-1, 1) / 4 # transmission grid ~ 0.5*0.5 = .25 ct_error_summed = ct_error_summed / grid_shadowing.reshape(-1, 1) / 4 abcd_counts = ct_summed.reshape(ct_summed.shape[0], -1, 4).sum(axis=1) abcd_count_errors = np.sqrt((ct_error_summed.reshape(ct_error_summed.shape[0], -1, 4) ** 2).sum(axis=1)) abcd_rate = abcd_counts / lt.reshape(-1, 1) abcd_rate_error = abcd_count_errors / lt.reshape(-1, 1) e_bin = pixel_data.energies[e_ind][-1]["e_high"] - pixel_data.energies[e_ind][0]["e_low"] abcd_rate_kev = abcd_rate / e_bin abcd_rate_error_kev = abcd_rate_error / e_bin pixel_areas = STIX_INSTRUMENT.pixel_config["Area"].to("cm2") areas = pixel_areas[idx_pix].reshape(-1, 4).sum(axis=0) meta_pixels = { "abcd_rate_kev": abcd_rate_kev, "abcd_rate_kev_cm": abcd_rate_kev / areas, "abcd_rate_error_kev_cm": abcd_rate_error_kev / areas, "time_range": time_range, "energy_range": energy_range, "pixels": pixels, "areas": areas, } return meta_pixels
def get_elut_correction(e_ind, pixel_data): r""" Get ELUT correction factors Only correct the low and high energy edges as internal bins are contiguous. Parameters ---------- e_ind : `np.ndarray` Energy channel indices pixel_data : `~stixpy.products.sources.CompressedPixelData` Pixel data Returns ------- """ energy_mask = pixel_data.energy_masks.energy_mask.astype(bool) elut = get_elut(pixel_data.time_range.center) ebin_edges_low = np.zeros((32, 12, 32), dtype=float) ebin_edges_low[..., 1:] = elut.e_actual ebin_edges_low = ebin_edges_low[..., energy_mask] ebin_edges_high = np.zeros((32, 12, 32), dtype=float) ebin_edges_high[..., 0:-1] = elut.e_actual ebin_edges_high[..., -1] = np.nan ebin_edges_high = ebin_edges_high[..., energy_mask] ebin_widths = ebin_edges_high - ebin_edges_low ebin_sci_edges_low = elut.e[..., 0:-1].value ebin_sci_edges_low = ebin_sci_edges_low[..., energy_mask] ebin_sci_edges_high = elut.e[..., 1:].value ebin_sci_edges_high = ebin_sci_edges_high[..., energy_mask] e_cor_low = (ebin_edges_high[..., e_ind[0]] - ebin_sci_edges_low[..., e_ind[0]]) / ebin_widths[..., e_ind[0]] e_cor_high = (ebin_sci_edges_high[..., e_ind[-1]] - ebin_edges_low[..., e_ind[-1]]) / ebin_widths[..., e_ind[-1]] return e_cor_high, e_cor_low
[docs] def create_visibility(meta_pixels): r""" Create visibilities from meta-pixels Parameters ---------- meta_pixels Returns ------- """ abcd_rate_kev_cm = meta_pixels["abcd_rate_kev_cm"] abcd_rate_error_kev_cm = meta_pixels["abcd_rate_error_kev_cm"] real = abcd_rate_kev_cm[:, 2] - abcd_rate_kev_cm[:, 0] imag = abcd_rate_kev_cm[:, 3] - abcd_rate_kev_cm[:, 1] real_err = np.sqrt(abcd_rate_error_kev_cm[:, 2] ** 2 + abcd_rate_error_kev_cm[:, 0] ** 2).value * real.unit imag_err = np.sqrt(abcd_rate_error_kev_cm[:, 3] ** 2 + abcd_rate_error_kev_cm[:, 1] ** 2).value * real.unit vis_info = get_uv_points_data() # Compute raw phases phase = np.arctan2(imag, real) # Compute amplitudes observed_amplitude = np.sqrt(real**2 + imag**2) # Compute error on amplitudes amplitude_error = np.sqrt((real / observed_amplitude * real_err) ** 2 + (imag / observed_amplitude * imag_err) ** 2) # Apply pixel correction pix_set = meta_pixels.get("pixels", None) if pix_set in {"top", "bot", "top+bot"}: phase += 46.1 * u.deg # Center of large pixel in terms morie pattern phase elif pix_set == "all": phase += 45 * u.deg elif pix_set == "small": phase += 22.5 * u.deg else: raise ValueError( f"Set of pixels used to make meta pixels not recognised, {pix_set} should be one of {list(_PIXEL_SLICES.keys())}" ) obsvis = (np.cos(phase) + np.sin(phase) * 1j) * observed_amplitude imaging_detector_indices = vis_info["isc"] - 1 vis_meta = VisMeta( instrumet="STIX", spectral_range=meta_pixels["energy_range"], time_range=Time([meta_pixels["time_range"].start, meta_pixels["time_range"].end]), vis_labels=vis_info["label"].tolist(), isc=vis_info["isc"].value, calibrated=False, ) vis = Visibilities( obsvis[imaging_detector_indices], u=vis_info["u"], v=vis_info["v"], amplitude=observed_amplitude[imaging_detector_indices], amplitude_uncertainty=amplitude_error[imaging_detector_indices], meta=vis_meta, ) return vis
[docs] def calibrate_visibility( vis: Visibilities, flare_location: SkyCoord = SkyCoord(0 * u.arcsec, 0 * u.arcsec, frame=STIXImaging) ): """ Calibrate visibility phase and amplitudes. Parameters ---------- vis : Uncalibrated Visibilities flare_location The location of the flare the visibility are shifted to have the phase center at this location if not given will default to sun center `Helioprojective(0,0)` Returns ------- """ modulation_efficiency = np.pi**3 / (8 * np.sqrt(2)) # Grid correction factors grid_cal_file = Path(__file__).parent.parent / "config" / "data" / "grid" / "GridCorrection.csv" grid_cal = Table.read(grid_cal_file, header_start=2, data_start=3) grid_corr = grid_cal["Phase correction factor"][vis.meta["isc"] - 1] * u.deg # Phase correction phase_cal_file = Path(__file__).parent.parent / "config" / "data" / "grid" / "PhaseCorrFactors.csv" phase_cal = Table.read(phase_cal_file, header_start=3, data_start=4) phase_corr = phase_cal["Phase correction factor"][vis.meta["isc"] - 1] * u.deg tr = TimeRange(vis.meta.time_range) if not isinstance(flare_location, SkyCoord): raise ValueError("'flare_location' is not a SkyCoord object") if not isinstance(flare_location.frame, STIXImaging) or flare_location.obstime != tr.center: roll, solo_heeq, stix_pointing = get_hpc_info(vis.meta.time_range[0], vis.meta.time_range[1]) solo_coord = HeliographicStonyhurst(solo_heeq, representation_type="cartesian", obstime=tr.center) flare_location = flare_location.transform_to(STIXImaging(obstime=tr.center, observer=solo_coord)) phase_mapcenter_corr = -2 * np.pi * (flare_location.Tx * vis.u + flare_location.Ty * vis.v) * u.rad ovis = vis.visibilities[:] ovis_real = np.real(ovis) ovis_imag = np.imag(ovis) ovis_amp = np.sqrt(ovis_real**2 + ovis_imag**2) ovis_phase = np.arctan2(ovis_imag, ovis_real).to("deg") calibrated_amplitude = ovis_amp * modulation_efficiency calibrated_phase = ovis_phase + grid_corr + phase_corr + phase_mapcenter_corr # Compute error on amplitudes systematic_error = 5 * u.percent # from G. Hurford 5% systematics calibrated_amplitude_error = np.sqrt( (vis.amplitude_uncertainty * modulation_efficiency) ** 2 + (systematic_error * calibrated_amplitude).to(ovis_amp.unit) ** 2 ) calibrated_visibility = (np.cos(calibrated_phase) + np.sin(calibrated_phase) * 1j) * calibrated_amplitude vis.meta["calibrated"] = True vis.meta["offset"] = flare_location cal_vis = Visibilities( calibrated_visibility, u=vis.u, v=vis.v, amplitude=calibrated_amplitude, amplitude_uncertainty=calibrated_amplitude_error, meta=vis.meta, ) return cal_vis