Source code for scopesim.optics.fov

"""Defines FieldOfView class."""

from copy import deepcopy
from itertools import chain
from collections.abc import Iterable

import numpy as np
from scipy.interpolate import interp1d

from astropy import units as u
from astropy.io import fits
from astropy.table import Table
from synphot import Empirical1D, SourceSpectrum
from synphot.units import PHOTLAM

from . import fov_utils as fu
from . import image_plane_utils as imp_utils

from ..base_classes import SourceBase, FieldOfViewBase
from ..utils import from_currsys, quantify, has_needed_keywords, get_logger


logger = get_logger(__name__)


[docs]class FieldOfView(FieldOfViewBase): """ A FOV is spectro-spatial volume cut out of a Source object. Flux units after extracting the fields from the Source are in ph/s/pixel The initial header should contain an on-sky WCS description: - CDELT-, CUNIT-, NAXIS- : for pixel scale and size (assumed CUNIT in deg) - CRVAL-, CRPIX- : for positioning the final image - CTYPE- : is assumed to be "RA---TAN", "DEC--TAN" and an image-plane WCS description - CDELT-D, CUNIT-D, NAXISn : for pixel scale and size (assumed CUNIT in mm) - CRVAL-D, CRPIX-D : for positioning the final image - CTYPE-D : is assumed to be "LINEAR", "LINEAR" The wavelength range is given by waverange """ def __init__(self, header, waverange, detector_header=None, cmds=None, **kwargs): self.meta = { "id": None, "wave_min": quantify(waverange[0], u.um), "wave_max": quantify(waverange[1], u.um), "wave_bin_n": 1, "wave_bin_type": "linear", "area": 0 * u.m**2, "pixel_area": None, # [arcsec] "sub_pixel": "!SIM.sub_pixel.flag", "distortion": {"scale": [1, 1], "offset": [0, 0], "shear": [1, 1], "rotation": 0, "radius_of_curvature": None}, "conserve_image": True, "trace_id": None, "aperture_id": None, } self.meta.update(kwargs) self.cmds = cmds if not any((has_needed_keywords(header, s) for s in ["", "S"])): raise ValueError( f"Header must contain a valid sky-plane WCS: {dict(header)}") if not has_needed_keywords(header, "D"): raise ValueError( f"Header must contain a valid image-plane WCS: {dict(header)}") self.header = fits.Header() self.header["NAXIS"] = 2 self.header["NAXIS1"] = header["NAXIS1"] self.header["NAXIS2"] = header["NAXIS2"] self.header.update(header) self._ensure_deg_header() self.detector_header = detector_header self.hdu = None self.image_plane_id = 0 self.fields = [] self.spectra = {} self.cube = None # 3D array for IFU, long-lit, Slicer-MOS self.image = None # 2D array for Imagers self.spectrum = None # SourceSpectrum for Fibre-fed MOS self._waverange = None self._wavelength = None self._volume = None def _pixarea(self, hdr): return (hdr["CDELT1"] * u.Unit(hdr["CUNIT1"]) * hdr["CDELT2"] * u.Unit(hdr["CUNIT2"])).to(u.arcsec ** 2) @property def trace_id(self): """Return the name of the trace.""" return self.meta["trace_id"] @property def pixel_area(self): if self.meta["pixel_area"] is None: # [arcsec] (really?) self.meta["pixel_area"] = self._pixarea(self.header).value return self.meta["pixel_area"]
[docs] def extract_from(self, src): """..assumption: Bandpass has been applied. .. note:: Spectra are cut and copied from the original Source object. They are in original units. ph/s/pix comes in the make_**** methods """ if not isinstance(src, SourceBase): raise ValueError(f"source must be a Source object: {type(src)}") fields_in_fov = [field for field in src.fields if fu.is_field_in_fov(self.header, field)] if not fields_in_fov: logger.warning("No fields in FOV.") spec_refs = set() volume = self.volume() for ifld, fld in enumerate(fields_in_fov): if isinstance(fld, Table): extracted_field = fu.extract_area_from_table(fld, volume) spec_refs.update(extracted_field["ref"]) fields_in_fov[ifld] = extracted_field elif isinstance(fld, fits.ImageHDU): if fld.header["NAXIS"] in (2, 3): fields_in_fov[ifld] = fu.extract_area_from_imagehdu(fld, volume) if fld.header["NAXIS"] == 2 or fld.header.get("BG_SRC"): ref = fld.header.get("SPEC_REF") if ref is not None: spec_refs.add(ref) waves = volume["waves"] * u.Unit(volume["wave_unit"]) spectra = {ref: fu.extract_range_from_spectrum(src.spectra[ref], waves) for ref in spec_refs} self.fields = fields_in_fov self.spectra = spectra
[docs] def view(self, hdu_type="image", sub_pixel=None, use_photlam=None): """ Force the self.fields to be viewed as a single object. Parameters ---------- sub_pixel : bool hdu_type : str ["cube", "image", "spectrum"] Returns ------- self.hdu : fits.ImageHDU, synphot.SourceSpectrum """ if sub_pixel is not None: self.meta["sub_pixel"] = sub_pixel if hdu_type == "image": # FIXME: why not just make False the default value?? use_photlam = False if use_photlam is None else use_photlam self.hdu = self.make_image_hdu(use_photlam=use_photlam) elif hdu_type == "cube": self.hdu = self.make_cube_hdu() elif hdu_type == "spectrum": self.hdu = self.make_spectrum() return self.hdu
[docs] def flatten(self): """If cube, collapse along first axis.""" if self.hdu and self.hdu.header["NAXIS"] == 3: image = np.sum(self.hdu.data, axis=0) self.hdu.data = image
def _evaluate_spectrum_with_weight(self, ref, waveset, weight): return self.spectra[ref](waveset).value * weight def _calc_area_factor(self, field): bg_solid_angle = u.Unit(field.header["SOLIDANG"]).to(u.arcsec**-2) # arcsec**2 * arcsec**-2 area_factor = self.pixel_area * bg_solid_angle return area_factor def _make_spectrum_cubefields(self, fov_waveset): """ Find Cube fields. * collapse cube along spatial dimensions --> spectrum vector * convert vector to PHOTLAM * interpolate at waveset * yield scaled flux to be added to canvas flux """ for field in self.cube_fields: hdu_waveset = fu.get_cube_waveset(field.header, return_quantity=True) fluxes = field.data.sum(axis=2).sum(axis=1) fov_waveset_fluxes = np.interp(fov_waveset, hdu_waveset, fluxes) field_unit = field.header.get("BUNIT", PHOTLAM) flux_scale_factor = u.Unit(field_unit).to(PHOTLAM) yield fov_waveset_fluxes * flux_scale_factor def _make_spectrum_imagefields(self, waveset): """ Find Image fields. * sum image over both dimensions * evaluate SPEC_REF spectum at waveset * yield spectrum multiply by sum to be added to canvas flux """ for field in self.image_fields: weight = np.sum(field.data) yield self._evaluate_spectrum_with_weight(field.header["SPEC_REF"], waveset, weight) def _make_spectrum_tablefields(self, waveset): """ Find Table fields. * evaluate all spectra at waveset * for each unique ref, sum the weights * yield each spectrum * sum of weights to be added to canvas flux """ for field in self.table_fields: refs = np.array(field["ref"]) weights = np.array(field["weight"]) # TODO: could do grouping of table with both columns?? for ref in set(refs): weight = np.sum(weights, where=refs == ref) yield self._evaluate_spectrum_with_weight(ref, waveset, weight) def _make_spectrum_backfields(self, waveset): for field in self.background_fields: yield self._evaluate_spectrum_with_weight( field.header["SPEC_REF"], waveset, self._calc_area_factor(field))
[docs] def make_spectrum(self): """ TBA. This is needed for when we do incoherent MOS instruments. Each fibre doesn't care about the spatial information. Returns ------- spec : SourceSpectrum [PHOTLAM] """ fov_waveset = self.waveset # Start with zero flux no ensure correct array shape even if none of # the sub-functions yield anything. canvas_flux = sum(chain( self._make_spectrum_cubefields(fov_waveset), self._make_spectrum_imagefields(fov_waveset), self._make_spectrum_tablefields(fov_waveset), self._make_spectrum_backfields(fov_waveset), ), start=np.zeros_like(fov_waveset.value)) spectrum = SourceSpectrum(Empirical1D, points=fov_waveset, lookup_table=canvas_flux) return spectrum
def _make_image_cubefields(self, area): """ Find Cube fields. * collapse cube along wavelength axis * rescale and reorient image * yield cube image to be added to canvas image """ for field in self.cube_fields: # cube_fields come in with units of photlam/arcsec2, # need to convert to ph/s # We need to the voxel volume (spectral and solid angle) for that. # ..todo: implement branch for use_photlam is True spectral_bin_width = (field.header["CDELT3"] * u.Unit(field.header["CUNIT3"]) ).to(u.Angstrom) # First collapse to image, then convert units image = np.sum(field.data, axis=0) * PHOTLAM/u.arcsec**2 image = (image * self._pixarea(field.header) * area * spectral_bin_width).to(u.ph/u.s) yield fits.ImageHDU(data=image, header=field.header) def _make_image_imagefields(self, fluxes): """ Find Image fields. * sum spectra between wavelength edges * multiply image by summed spectrum * yield image to be added to canvas image """ for field in self.image_fields: image = deepcopy(field.data) image *= fluxes[field.header["SPEC_REF"]] # ph / s yield fits.ImageHDU(data=image, header=field.header) def _make_image_tablefields(self, fluxes): """ Find Table fields. * sum spectra between wavelength edges * yield summed flux at x,y position to be added to canvas image """ for field in self.table_fields: # x, y are ALWAYS in arcsec - crval is in deg xpix, ypix = imp_utils.val2pix(self.header, field["x"] / 3600, field["y"] / 3600) if from_currsys(self.meta["sub_pixel"], self.cmds): for idx, row in enumerate(field): xs, ys, fracs = imp_utils.sub_pixel_fractions(xpix[idx], ypix[idx]) for x, y, frac in zip(xs, ys, fracs): yield fluxes[row["ref"]] * frac, row["weight"], x, y else: # Note: these had x/ypix+0.5 until a06ab75 # TODO: could these be something more numpythonic grid-ish? x = np.array(xpix).astype(int) y = np.array(ypix).astype(int) # quickest way to round flux = np.array([fluxes[ref] for ref in field["ref"]]) yield flux, np.array(field["weight"]), x, y def _make_image_backfields(self, fluxes): for field in self.background_fields: flux = fluxes[field.header["SPEC_REF"]] yield flux * self._calc_area_factor(field)
[docs] def make_image_hdu(self, use_photlam=False): """ TBA. Used for imaging. Output image units are ph s-1 pixel-1 .. note:: ``self.make_image()`` does NOT store anything in ``self.image`` See make_cube for an explanation Make canvas image from NAXIS1,2 from fov.header Parameters ---------- use_photlam : bool Default False. Defines the flux units of the image pixels Returns ------- image_hdu : fits.ImageHDU [ph s-1 pixel-1] or PHOTLAM (if use_photlam=True) """ spline_order = from_currsys("!SIM.computing.spline_order") # Make waveset and canvas image fov_waveset = self.waveset bin_widths = np.diff(fov_waveset) # u.um bin_widths = 0.5 * (np.r_[0, bin_widths] + np.r_[bin_widths, 0]) area = from_currsys(self.meta["area"], self.cmds) # u.m2 # PHOTLAM * u.um * u.m2 --> ph / s specs = {ref: spec(fov_waveset) if use_photlam else (spec(fov_waveset) * bin_widths * area).to(u.ph / u.s) for ref, spec in self.spectra.items()} fluxes = {ref: np.sum(spec.value) for ref, spec in specs.items()} canvas_image_hdu = fits.ImageHDU( data=np.zeros((self.header["NAXIS2"], self.header["NAXIS1"])), header=self.header) for tmp_hdu in chain(self._make_image_cubefields(area), self._make_image_imagefields(fluxes)): canvas_image_hdu = imp_utils.add_imagehdu_to_imagehdu( tmp_hdu, canvas_image_hdu, conserve_flux=True, spline_order=spline_order) for flux, weight, x, y in self._make_image_tablefields(fluxes): if from_currsys(self.meta["sub_pixel"]): # These x and y should not be arrays when sub_pixel is # enabled, it is therefore not necessary to deploy the fix # below in the else-branch. assert not isinstance(x, Iterable), "x must be an integer" canvas_image_hdu.data[y, x] += flux * weight else: # Mask out any stars that were pushed out of the fov by rounding mask = ((x < canvas_image_hdu.data.shape[1]) * (y < canvas_image_hdu.data.shape[0])) # This used to contain this line: # canvas_image_hdu.data[y[mask], x[mask]] += flux[mask] * weight[mask] # However, that is wrong when there are duplicate (x, y) pairs. # In those cases, only the last source flux is added to the # pixel. Therefor it is necessary to iterate over the sources. # The stacking of stars is tested by TestStackedStars in # test_flux_is_conserved_through_full_system.py for yi, xi, fluxi, weighti in zip( y[mask], x[mask], flux[mask], weight[mask]): canvas_image_hdu.data[yi, xi] += fluxi * weighti canvas_image_hdu.data = sum(self._make_image_backfields(fluxes), start=canvas_image_hdu.data) return canvas_image_hdu # [ph s-1 pixel-1]
def _make_cube_cubefields(self, fov_waveset): """ Find Cube fields. * rescale and reorient cubes * interp1d smaller cubes with waveset * yield cubes to be added to cavas cube """ for field in self.cube_fields: # Cube should be in PHOTLAM arcsec-2 for SpectralTrace mapping # Assumption is that ImageHDUs have units of PHOTLAM arcsec-2 field_waveset = fu.get_cube_waveset(field.header, return_quantity=True) # ..todo: Deal with this bounds_error in a more elegant way field_interp = interp1d(field_waveset.to(u.um).value, field.data, axis=0, kind="linear", bounds_error=False, fill_value=0) field_data = field_interp(fov_waveset.value) # Pixel scale conversion field_data *= self._pixarea(field.header).value / self.pixel_area field_hdu = fits.ImageHDU(data=field_data, header=field.header) yield field_hdu def _make_cube_imagefields(self, specs, spline_order): """ Find Image fields. * rescale and reorient images * evaluate spectra at waveset * expand image by spectra to 3D form * yield image cubes to be added to cavas cube """ for field in self.image_fields: # Cube should be in PHOTLAM arcsec-2 for SpectralTrace mapping # Assumption is that ImageHDUs have units of PHOTLAM arcsec-2 # ImageHDUs have photons/second/pixel. # ..todo: Add a catch to get ImageHDU with BUNITs canvas_image_hdu = fits.ImageHDU( data=np.zeros((self.header["NAXIS2"], self.header["NAXIS1"])), header=self.header) # FIXME: Why here not "Pixel scale conversion" as above? field.data /= self.pixel_area canvas_image_hdu = imp_utils.add_imagehdu_to_imagehdu( field, canvas_image_hdu, spline_order=spline_order) spec = specs[field.header["SPEC_REF"]] # 2D * 1D -> 3D field_cube = canvas_image_hdu.data[None, :, :] * spec[:, None, None] yield field_cube.value def _make_cube_tablefields(self, specs): """ Find Table fields. * evaluate spectra at waveset * yield spectrum at x,y position to be added cavas to cube """ for field in self.table_fields: # Cube should be in PHOTLAM arcsec-2 for SpectralTrace mapping # Point sources are in PHOTLAM per pixel # Point sources need to be scaled up by inverse pixel_area for row in field: xsky, ysky = row["x"] / 3600, row["y"] / 3600 # x, y are ALWAYS in arcsec - crval is in deg xpix, ypix = imp_utils.val2pix(self.header, xsky, ysky) flux_vector = specs[row["ref"]].value * row["weight"] / self.pixel_area if from_currsys(self.meta["sub_pixel"]): xs, ys, fracs = imp_utils.sub_pixel_fractions(xpix, ypix) for i, j, k in zip(xs, ys, fracs): yield flux_vector * k, i, j else: yield flux_vector, int(xpix), int(ypix) def _make_cube_backfields(self, specs): for field in self.background_fields: # TODO: The following would have been identical to the other two # make methods, but was commented out. Why? # bg_solid_angle = u.Unit(field.header["SOLIDANG"]).to(u.arcsec**-2) # float [arcsec-2] # pixel_area = from_currsys(self.meta["pixel_scale"]) ** 2 # float [arcsec2] # area_factor = pixel_area * bg_solid_angle # float [arcsec2 * arcsec-2] # Cube should be in PHOTLAM arcsec-2 for SpectralTrace mapping # spec = specs[field.header["SPEC_REF"]] * area_factor spec = specs[field.header["SPEC_REF"]] yield spec[:, None, None].value
[docs] def make_cube_hdu(self): """ TBA. Used for IFUs, slit spectrographs, and coherent MOSs (e.g.KMOS) Returned cube units are ``ph s-1 voxel-1`` .. note:: ``self.make_cube()`` does NOT store anything in ``self.cube`` self.cube and self.make_cube() are deliberately kept seperately so that self.cube will not be accidently overwritten by a rogue call from an Effect object. All Effect objects should specifically test whether ``self.cube is None`` before assigning a new cube it The cube is made with these steps: 1. Make waveset and canvas cube:: if at least one cube: set waveset to equal largest cube waveset else: make waveset from self.meta values make canvas cube based on waveset of largest cube and NAXIS1,2 from fov.header 2. Find Cube fields (see ``FieldOfView._make_cube_cubefields()``). 3. Find Image fields (see ``FieldOfView._make_cube_imagefields()``). 4. Find Table fields (see ``FieldOfView._make_cube_tablefields()``). ``PHOTLAM = ph / (s * m2 * um)``. Original source fields are in units of: - tables: (PHOTLAM in spectrum) - images: arcsec-2 (PHOTLAM in spectrum) - cubes: PHOTLAM arcsec-2 .. warning:: Input Images and Cubes should have units of PHOTLAM arcsec-2 Returns ------- canvas_cube_hdu : fits.ImageHDU [ph s-1 AA-1 arcsec-2] # as needed by SpectralTrace """ spline_order = from_currsys("!SIM.computing.spline_order") # 1. Make waveset and canvas cube (area, bin_width are applied at end) # TODO: Why is this not self.waveset? What's different? wave_unit = u.Unit(from_currsys("!SIM.spectral.wave_unit")) fov_waveset = np.arange( self.meta["wave_min"].value, self.meta["wave_max"].value, from_currsys("!SIM.spectral.spectral_bin_width")) * wave_unit fov_waveset = fov_waveset.to(u.um) # TODO: what's with this code?? # fov_waveset = self.waveset # wave_bin_n = len(fov_waveset) # if "lin" in self.meta["wave_bin_type"]: # fov_waveset = np.linspace(wave_min, wave_max, wave_bin_n) # elif "log" in self.meta["wave_bin_type"]: # wmin, wmax = wave_min.to(u.um).value, wave_max.to(u.um).value # fov_waveset = np.logspace(wmin, wmax, wave_bin_n) specs = {ref: spec(fov_waveset) # PHOTLAM = ph/s/cm2/AA for ref, spec in self.spectra.items()} # make canvas cube based on waveset of largest cube and NAXIS1,2 # from fov.header canvas_cube_hdu = fits.ImageHDU( data=np.zeros((len(fov_waveset), self.header["NAXIS2"], self.header["NAXIS1"])), header=self.header) canvas_cube_hdu.header["BUNIT"] = "ph s-1 cm-2 AA-1" for field_hdu in self._make_cube_cubefields(fov_waveset): canvas_cube_hdu = imp_utils.add_imagehdu_to_imagehdu( field_hdu, canvas_cube_hdu, spline_order=spline_order) canvas_cube_hdu.data = sum(self._make_cube_imagefields(specs, spline_order), start=canvas_cube_hdu.data) for flux, x, y in self._make_cube_tablefields(specs): # To prevent adding array values in this manner. assert not isinstance(x, Iterable), "x should be integer" canvas_cube_hdu.data[:, y, x] += flux canvas_cube_hdu.data = sum(self._make_cube_backfields(specs), start=canvas_cube_hdu.data) # TODO: what's with this code?? # 6. Convert from PHOTLAM to ph/s/voxel # PHOTLAM = ph/s/cm-2/AA # area = m2, fov_waveset = um # SpectralTrace wants ph/s/um/arcsec2 --> get rid of m2, leave um area = from_currsys(self.meta["area"]) # u.m2 canvas_cube_hdu.data *= area.to(u.cm ** 2).value canvas_cube_hdu.data *= 1e4 # ph/s/AA/arcsec2 --> ph/s/um/arcsec2 # TODO: what's with this code?? # bin_widths = np.diff(fov_waveset).to(u.AA).value # bin_widths = 0.5 * (np.r_[0, bin_widths] + np.r_[bin_widths, 0]) # canvas_cube_hdu.data *= bin_widths[:, None, None] cdelt3 = np.diff(fov_waveset[:2])[0] canvas_cube_hdu.header.update({"CDELT3": cdelt3.to(u.um).value, "CRVAL3": fov_waveset[0].value, "CRPIX3": 1, "CUNIT3": "um", "CTYPE3": "WAVE"}) # ..todo: Add the log wavelength keyword here, if log scale is needed return canvas_cube_hdu # [ph s-1 AA-1 (arcsec-2)]
[docs] def volume(self, wcs_prefix=""): xy = imp_utils.calc_footprint(self.header, wcs_suffix=wcs_prefix) unit = self.header[f"CUNIT1{wcs_prefix}"].lower() # FIXME: This is unused!! # wave_corners = self.waverange minmax = np.array((xy.min(axis=0), xy.max(axis=0))) self._volume = {"xs": minmax[:, 0], "ys": minmax[:, 1], "waves": self.waverange, "xy_unit": unit, "wave_unit": "um"} return self._volume
@property def data(self): """Return either hdu.data, image, cube, spectrum or None.""" if self.hdu is not None: return self.hdu.data if self.image is not None: return self.image if self.cube is not None: return self.cube if self.spectrum is not None: return self.spectrum return None @property def corners(self): """Return sky footprint, image plane footprint.""" # Couldn't find where this is ever used, put warning here just in case logger.warning("calc_footprint has been updated, any code that " "relies on this .corners property must likely be " "adapted as well.") sky_corners = imp_utils.calc_footprint(self.header) imp_corners = imp_utils.calc_footprint(self.header, "D") return sky_corners, imp_corners @property def waverange(self): """Return wavelength range in um [wave_min, wave_max].""" if self._waverange is None: wave_min = quantify(self.meta["wave_min"], u.um).value wave_max = quantify(self.meta["wave_max"], u.um).value self._waverange = [wave_min, wave_max] return self._waverange @property def wavelength(self): """Return central wavelength in um.""" if self._wavelength is None: self._wavelength = np.average(self.waverange) return quantify(self._wavelength, u.um) @property def waveset(self): """Return a wavelength vector in um.""" if field_cubes := self.cube_fields: naxis3_max = np.argmax([cube.header["NAXIS3"] for cube in field_cubes]) _waveset = fu.get_cube_waveset(field_cubes[naxis3_max].header, return_quantity=True) elif self.spectra: wavesets = [spec.waveset for spec in self.spectra.values()] _waveset = np.concatenate(wavesets) else: _waveset = self.waverange * u.um # TODO: tie the round to a global precision setting (this is defined # better in another TODO somewhere...) # The rounding is necessary to not end up with things like: # 0.7 um # 0.7000000000000001 um # 0.7000000000000002 um # and yes, that actually happend... _waveset = np.unique(_waveset.to(u.um).round(10)) return _waveset @property def cube_fields(self): """Return list of non-BG_SRC ImageHDU fields with NAXIS=3.""" return [field for field in self.fields if isinstance(field, fits.ImageHDU) and field.header["NAXIS"] == 3 and not field.header.get("BG_SRC", False)] @property def image_fields(self): """Return list of non-BG_SRC ImageHDU fields with NAXIS=2.""" return [field for field in self.fields if isinstance(field, fits.ImageHDU) and field.header["NAXIS"] == 2 and not field.header.get("BG_SRC", False)] @property def table_fields(self): """Return list of Table fields.""" return [field for field in self.fields if isinstance(field, Table)] @property def background_fields(self): """Return list of BG_SRC ImageHDU fields.""" return [field for field in self.fields if isinstance(field, fits.ImageHDU) and field.header.get("BG_SRC", False)] def _ensure_deg_header(self): cunit = u.Unit(self.header["CUNIT1"].lower()) convf = cunit.to(u.deg) self.header["CDELT1"] *= convf self.header["CDELT2"] *= convf self.header["CRVAL1"] *= convf self.header["CRVAL2"] *= convf self.header["CUNIT1"] = "deg" self.header["CUNIT2"] = "deg" def __repr__(self): waverange = [self.meta["wave_min"].value, self.meta["wave_max"].value] msg = (f"{self.__class__.__name__}({self.header!r}, {waverange!r}, " f"{self.detector_header!r}, **{self.meta!r})") return msg def __str__(self): msg = (f"FOV id: {self.meta['id']}, with dimensions " f"({self.header['NAXIS1']}, {self.header['NAXIS2']})\n" f"Sky centre: ({self.header['CRVAL1']}, " f"{self.header['CRVAL2']})\n" f"Image centre: ({self.header['CRVAL1D']}, " f"{self.header['CRVAL2D']})\n" f"Wavelength range: ({self.meta['wave_min']}, " f"{self.meta['wave_max']})um\n") return msg