diff --git a/changelog/245.feature.rst b/changelog/245.feature.rst new file mode 100644 index 0000000..01ed97d --- /dev/null +++ b/changelog/245.feature.rst @@ -0,0 +1 @@ +Add ``SpectrogramMetaABC`` and ``SpectrogramMeta`` metadata classes backed by ``ndcube.meta.NDMeta`` with instrument specific subclasses for all supported instruments (``WAVESMeta``, ``CALISTOMeta``, ``EOVSAMeta``, ``RFSMeta``,etc) providing typed property access to spectrogram metadata. diff --git a/radiospectra/spectrogram/meta.py b/radiospectra/spectrogram/meta.py new file mode 100644 index 0000000..0ec3da9 --- /dev/null +++ b/radiospectra/spectrogram/meta.py @@ -0,0 +1,177 @@ +import abc + +from ndcube.meta import NDMeta, NDMetaABC + +from astropy.coordinates import SkyCoord +from astropy.time import Time +from astropy.units import Quantity + +__all__ = ["SpectrogramMetaABC", "SpectrogramMeta"] + + +class SpectrogramMetaABC(NDMetaABC): + """ + Abstract base class for all spectrogram metadata. + """ + + # Identification + @property + @abc.abstractmethod + def instrument(self) -> str: + """Name of the instrument.""" + pass + + @property + @abc.abstractmethod + def observatory(self) -> str: + """Name of the observatory.""" + pass + + @property + @abc.abstractmethod + def detector(self) -> str: + """Name of the detector.""" + pass + + @property + @abc.abstractmethod + def processing_level(self) -> str | None: + """The level to which the data has been processed.""" + pass + + @property + @abc.abstractmethod + def version(self) -> str | None: + """The data version.""" + pass + + @property + @abc.abstractmethod + def source_filename(self) -> str | None: + """The source filename.""" + pass + + # Time + @property + @abc.abstractmethod + def date_start(self) -> Time: + """Start of the observation""" + pass + + @property + @abc.abstractmethod + def date_end(self) -> Time: + """End of the observation""" + pass + + @property + @abc.abstractmethod + def temporal_resolution(self) -> Quantity: + pass + + # Frequency + @property + @abc.abstractmethod + def frequency_range(self) -> Quantity: + """Frequency range of observation""" + pass + + @property + @abc.abstractmethod + def frequency_resolution(self) -> Quantity: + pass + + # Calibration and signal + @property + @abc.abstractmethod + def calibration_state(self) -> str | None: + """Calibration state.""" + pass + + @property + @abc.abstractmethod + def polarisation(self) -> str | None: + """Stokes parameter convention or polarization.""" + pass + + # Quality + @property + @abc.abstractmethod + def data_mask(self): + """Data mask.""" + pass + + # Position + @property + @abc.abstractmethod + def observer_coordinate(self) -> SkyCoord | None: + """Observer coordinate.""" + pass + + +class SpectrogramMeta(NDMeta, SpectrogramMetaABC): + """ + Base class for radio spectrogram metadata. + + Backed by `ndcube.meta.NDMeta` (a `dict` subclass). + """ + + @property + def instrument(self) -> str: + return self["instrument"] + + @property + def observatory(self) -> str: + return self["observatory"] + + @property + def detector(self) -> str: + return self["detector"] + + @property + def date_start(self) -> Time: + return self["start_time"] + + @property + def date_end(self) -> Time: + return self["end_time"] + + @property + def processing_level(self) -> str | None: + return self.get("processing_level") + + @property + def version(self) -> str | None: + return self.get("version") + + @property + def source_filename(self) -> str | None: + return self.get("source_filename") + + @property + def temporal_resolution(self) -> Quantity: + return self.get("temporal_resolution") + + @property + def frequency_range(self) -> Quantity: + return self.get("wavelength") + + @property + def frequency_resolution(self) -> Quantity: + return self.get("frequency_resolution") + + @property + def calibration_state(self) -> str | None: + return self.get("calibration_state") + + @property + def polarisation(self) -> str | None: + return self.get("polarisation") + + @property + def data_mask(self): + return self.get("data_mask") + + @property + def observer_coordinate(self) -> SkyCoord | None: + return self.get("observer_coordinate") diff --git a/radiospectra/spectrogram/sources/callisto.py b/radiospectra/spectrogram/sources/callisto.py index d8cf028..d8f03b0 100644 --- a/radiospectra/spectrogram/sources/callisto.py +++ b/radiospectra/spectrogram/sources/callisto.py @@ -1,9 +1,25 @@ import astropy.units as u -from astropy.coordinates.earth import EarthLocation +from astropy.coordinates import EarthLocation, SkyCoord +from radiospectra.spectrogram.meta import SpectrogramMeta from radiospectra.spectrogram.spectrogrambase import GenericSpectrogram -__all__ = ["CALISTOSpectrogram"] +__all__ = ["CALISTOSpectrogram", "CALISTOMeta"] + + +class CALISTOMeta(SpectrogramMeta): + """Metadata for e-CALLISTO spectrograms.""" + + @property + def observer_coordinate(self) -> SkyCoord | None: + """The coordinate of the observatory.""" + fits_meta = self.get("fits_meta") + if fits_meta: + lat = fits_meta.get("OBS_LAT", 0) * u.deg * (1.0 if fits_meta.get("OBS_LAC") == "N" else -1.0) + lon = fits_meta.get("OBS_LON", 0) * u.deg * (1.0 if fits_meta.get("OBS_LOC") == "E" else -1.0) + height = fits_meta.get("OBS_ALT", 0) * u.m + return EarthLocation(lat=lat, lon=lon, height=height) + return None class CALISTOSpectrogram(GenericSpectrogram): @@ -27,14 +43,13 @@ class CALISTOSpectrogram(GenericSpectrogram): """ def __init__(self, data, meta, **kwargs): + if not isinstance(meta, CALISTOMeta): + meta = CALISTOMeta(meta) super().__init__(meta=meta, data=data, **kwargs) @property def observatory_location(self): - lat = self.meta["fits_meta"]["OBS_LAT"] * u.deg * (1.0 if self.meta["fits_meta"]["OBS_LAC"] == "N" else -1.0) - lon = self.meta["fits_meta"]["OBS_LON"] * u.deg * (1.0 if self.meta["fits_meta"]["OBS_LOC"] == "E" else -1.0) - height = self.meta["fits_meta"]["OBS_ALT"] * u.m - return EarthLocation(lat=lat, lon=lon, height=height) + return self.meta.observer_coordinate @classmethod def is_datasource_for(cls, data, meta, **kwargs): diff --git a/radiospectra/spectrogram/sources/eovsa.py b/radiospectra/spectrogram/sources/eovsa.py index 48abb96..ef9dd6a 100644 --- a/radiospectra/spectrogram/sources/eovsa.py +++ b/radiospectra/spectrogram/sources/eovsa.py @@ -1,6 +1,19 @@ +from radiospectra.spectrogram.meta import SpectrogramMeta from radiospectra.spectrogram.spectrogrambase import GenericSpectrogram -__all__ = ["EOVSASpectrogram"] +__all__ = ["EOVSASpectrogram", "EOVSAMeta"] + + +class EOVSAMeta(SpectrogramMeta): + """Metadata for EOVSA spectrograms.""" + + @property + def polarisation(self) -> str | None: + """The polarisation of the observation.""" + fits_meta = self.get("fits_meta") + if fits_meta: + return fits_meta.get("POLARIZA") + return None class EOVSASpectrogram(GenericSpectrogram): @@ -22,11 +35,13 @@ class EOVSASpectrogram(GenericSpectrogram): """ def __init__(self, data, meta, **kwargs): + if not isinstance(meta, EOVSAMeta): + meta = EOVSAMeta(meta) super().__init__(meta=meta, data=data, **kwargs) @property def polarisation(self): - return self.meta["fits_meta"]["POLARIZA"] + return self.meta.polarisation @classmethod def is_datasource_for(cls, data, meta, **kwargs): diff --git a/radiospectra/spectrogram/sources/ilofar357.py b/radiospectra/spectrogram/sources/ilofar357.py index 1dd2273..b277efb 100644 --- a/radiospectra/spectrogram/sources/ilofar357.py +++ b/radiospectra/spectrogram/sources/ilofar357.py @@ -1,22 +1,43 @@ +from radiospectra.spectrogram.meta import SpectrogramMeta from radiospectra.spectrogram.spectrogrambase import GenericSpectrogram __all__ = [ "ILOFARMode357Spectrogram", + "ILOFARMeta", ] +class ILOFARMeta(SpectrogramMeta): + """Metadata for Irish LOFAR Station spectrograms.""" + + @property + def mode(self) -> int | None: + """The observation mode.""" + return self.get("mode") + + @property + def polarisation(self) -> str | None: + """The polarisation of the observation.""" + return self.get("polarisation") + + class ILOFARMode357Spectrogram(GenericSpectrogram): """ Irish LOFAR Station mode 357 Spectrogram """ + def __init__(self, data, meta, **kwargs): + if not isinstance(meta, ILOFARMeta): + meta = ILOFARMeta(meta) + super().__init__(meta=meta, data=data, **kwargs) + @property def mode(self): - return self.meta.get("mode") + return self.meta.mode @property def polarisation(self): - return self.meta.get("polarisation") + return self.meta.polarisation @classmethod def is_datasource_for(cls, data, meta, **kwargs): diff --git a/radiospectra/spectrogram/sources/psp_rfs.py b/radiospectra/spectrogram/sources/psp_rfs.py index d2c4775..84fffc4 100644 --- a/radiospectra/spectrogram/sources/psp_rfs.py +++ b/radiospectra/spectrogram/sources/psp_rfs.py @@ -1,6 +1,37 @@ +from radiospectra.spectrogram.meta import SpectrogramMeta from radiospectra.spectrogram.spectrogrambase import GenericSpectrogram -__all__ = ["RFSSpectrogram"] +__all__ = ["RFSSpectrogram", "RFSMeta"] + + +class RFSMeta(SpectrogramMeta): + """Metadata for PSP FIELDS/RFS spectrograms.""" + + @property + def level(self) -> str | None: + """The data processing level.""" + cdf_globals = self.get("cdf_globals") + if cdf_globals: + data_type = cdf_globals.get("Data_type") + if data_type is None: + return None + if isinstance(data_type, list) or hasattr(data_type, "tolist"): + data_type = data_type[0] + return data_type.split(">")[0] + return None + + @property + def version(self) -> int | None: + """The data version.""" + cdf_globals = self.get("cdf_globals") + if cdf_globals: + data_version = cdf_globals.get("Data_version") + if data_version is None: + return None + if isinstance(data_version, list) or hasattr(data_version, "tolist"): + data_version = data_version[0] + return int(data_version) + return None class RFSSpectrogram(GenericSpectrogram): @@ -22,21 +53,17 @@ class RFSSpectrogram(GenericSpectrogram): """ def __init__(self, data, meta, **kwargs): + if not isinstance(meta, RFSMeta): + meta = RFSMeta(meta) super().__init__(meta=meta, data=data, **kwargs) @property def level(self): - data_type = self.meta["cdf_globals"]["Data_type"] - if isinstance(data_type, list) or hasattr(data_type, "tolist"): - data_type = data_type[0] - return data_type.split(">")[0] + return self.meta.level @property def version(self): - data_version = self.meta["cdf_globals"]["Data_version"] - if isinstance(data_version, list) or hasattr(data_version, "tolist"): - data_version = data_version[0] - return int(data_version) + return self.meta.version @classmethod def is_datasource_for(cls, data, meta, **kwargs): diff --git a/radiospectra/spectrogram/sources/rpw.py b/radiospectra/spectrogram/sources/rpw.py index e6c828d..4e04721 100644 --- a/radiospectra/spectrogram/sources/rpw.py +++ b/radiospectra/spectrogram/sources/rpw.py @@ -1,6 +1,13 @@ +from radiospectra.spectrogram.meta import SpectrogramMeta from radiospectra.spectrogram.spectrogrambase import GenericSpectrogram -__all__ = ["RPWSpectrogram"] +__all__ = ["RPWSpectrogram", "RPWMeta"] + + +class RPWMeta(SpectrogramMeta): + """Metadata for Solar Orbiter RPW spectrograms.""" + + pass class RPWSpectrogram(GenericSpectrogram): @@ -54,6 +61,11 @@ class RPWSpectrogram(GenericSpectrogram): """ + def __init__(self, data, meta, **kwargs): + if not isinstance(meta, RPWMeta): + meta = RPWMeta(meta) + super().__init__(meta=meta, data=data, **kwargs) + @classmethod def is_datasource_for(cls, data, meta, **kwargs): return meta["instrument"] == "RPW" diff --git a/radiospectra/spectrogram/sources/rstn.py b/radiospectra/spectrogram/sources/rstn.py index 6180665..0574406 100644 --- a/radiospectra/spectrogram/sources/rstn.py +++ b/radiospectra/spectrogram/sources/rstn.py @@ -1,6 +1,13 @@ +from radiospectra.spectrogram.meta import SpectrogramMeta from radiospectra.spectrogram.spectrogrambase import GenericSpectrogram -__all__ = ["RSTNSpectrogram"] +__all__ = ["RSTNSpectrogram", "RSTNMeta"] + + +class RSTNMeta(SpectrogramMeta): + """Metadata for RSTN spectrograms.""" + + pass class RSTNSpectrogram(GenericSpectrogram): @@ -21,6 +28,11 @@ class RSTNSpectrogram(GenericSpectrogram): """ + def __init__(self, data, meta, **kwargs): + if not isinstance(meta, RSTNMeta): + meta = RSTNMeta(meta) + super().__init__(meta=meta, data=data, **kwargs) + @classmethod def is_datasource_for(cls, data, meta, **kwargs): return meta["instrument"] == "RSTN" diff --git a/radiospectra/spectrogram/sources/swaves.py b/radiospectra/spectrogram/sources/swaves.py index 1dcdfe9..1a7fc21 100644 --- a/radiospectra/spectrogram/sources/swaves.py +++ b/radiospectra/spectrogram/sources/swaves.py @@ -1,6 +1,16 @@ +from radiospectra.spectrogram.meta import SpectrogramMeta from radiospectra.spectrogram.spectrogrambase import GenericSpectrogram -__all__ = ["SWAVESSpectrogram"] +__all__ = ["SWAVESSpectrogram", "SWAVESMeta"] + + +class SWAVESMeta(SpectrogramMeta): + """Metadata for STEREO/SWAVES spectrograms.""" + + @property + def receiver(self) -> str | None: + """The name of the receiver.""" + return self.get("detector") class SWAVESSpectrogram(GenericSpectrogram): @@ -24,14 +34,14 @@ class SWAVESSpectrogram(GenericSpectrogram): """ def __init__(self, data, meta, **kwargs): + if not isinstance(meta, SWAVESMeta): + meta = SWAVESMeta(meta) super().__init__(meta=meta, data=data, **kwargs) @property def receiver(self): - """ - The name of the receiver. - """ - return self.meta["receiver"] + """The name of the receiver.""" + return self.meta.receiver @classmethod def is_datasource_for(cls, data, meta, **kwargs): diff --git a/radiospectra/spectrogram/sources/tests/test_callisto.py b/radiospectra/spectrogram/sources/tests/test_callisto.py index 8930eb3..652c2d8 100644 --- a/radiospectra/spectrogram/sources/tests/test_callisto.py +++ b/radiospectra/spectrogram/sources/tests/test_callisto.py @@ -7,6 +7,7 @@ import pytest import astropy.units as u +from astropy.coordinates import EarthLocation from astropy.tests.helper import assert_quantity_allclose from astropy.time import Time @@ -14,6 +15,7 @@ from radiospectra.spectrogram import Spectrogram from radiospectra.spectrogram.sources import CALISTOSpectrogram +from radiospectra.spectrogram.sources.callisto import CALISTOMeta @mock.patch("radiospectra.spectrogram.spectrogram_factory.parse_path") @@ -509,3 +511,22 @@ def test_ecallisto_spectrogram_online(): assert_quantity_allclose(spec.frequencies[0], 92.938 * u.MHz, rtol=1e-3) assert spec.frequencies[-1] == 45.0 * u.MHz assert spec.data.shape == (200, 3600) + + +def test_callisto_meta(): + meta = CALISTOMeta( + { + "fits_meta": { + "OBS_LAC": "N", + "OBS_LAT": 53.0941390991211, + "OBS_LOC": "E", + "OBS_LON": 7.92012977600098, + "OBS_ALT": 416.5, + } + } + ) + coord = meta.observer_coordinate + assert isinstance(coord, EarthLocation) + assert np.isclose(coord.lon.value, 7.92012977600098) + assert np.isclose(coord.lat.value, 53.0941390991211) + assert np.isclose(coord.height.value, 416.5) diff --git a/radiospectra/spectrogram/sources/tests/test_waves.py b/radiospectra/spectrogram/sources/tests/test_waves.py index 97e9c2a..c912bcd 100644 --- a/radiospectra/spectrogram/sources/tests/test_waves.py +++ b/radiospectra/spectrogram/sources/tests/test_waves.py @@ -12,6 +12,7 @@ from radiospectra.spectrogram import Spectrogram from radiospectra.spectrogram.sources import WAVESSpectrogram +from radiospectra.spectrogram.sources.waves import WAVESMeta from radiospectra.spectrogram.spectrogram_factory import SpectrogramFactory @@ -88,3 +89,16 @@ def test_waves_spectrogram_online(): assert spec.frequencies[0] == 20.0 * u.kHz assert spec.frequencies[-1] == 1040.0 * u.kHz assert spec.data.shape == (256, 1440) + + +def test_waves_meta(): + meta = WAVESMeta( + { + "instrument": "WAVES", + "background": "test_bg", + "detector": "rad1", + } + ) + assert meta.instrument == "WAVES" + assert meta.background == "test_bg" + assert meta.receiver == "rad1" diff --git a/radiospectra/spectrogram/sources/waves.py b/radiospectra/spectrogram/sources/waves.py index a4715b0..44e8d58 100644 --- a/radiospectra/spectrogram/sources/waves.py +++ b/radiospectra/spectrogram/sources/waves.py @@ -1,6 +1,21 @@ +from radiospectra.spectrogram.meta import SpectrogramMeta from radiospectra.spectrogram.spectrogrambase import GenericSpectrogram -__all__ = ["WAVESSpectrogram"] +__all__ = ["WAVESSpectrogram", "WAVESMeta"] + + +class WAVESMeta(SpectrogramMeta): + """Metadata for WIND/WAVES spectrograms.""" + + @property + def background(self): + """The background subtracted from the data.""" + return self.get("background") + + @property + def receiver(self): + """The name of the receiver.""" + return self.get("detector") class WAVESSpectrogram(GenericSpectrogram): @@ -24,6 +39,8 @@ class WAVESSpectrogram(GenericSpectrogram): """ def __init__(self, data, meta, **kwargs): + if not isinstance(meta, WAVESMeta): + meta = WAVESMeta(meta) super().__init__(meta=meta, data=data, **kwargs) @property @@ -31,14 +48,14 @@ def receiver(self): """ The name of the receiver. """ - return self.meta["receiver"] + return getattr(self.meta, "receiver", self.meta.get("receiver")) @property def background(self): """ The background subtracted from the data. """ - return self.meta.bg + return getattr(self.meta, "background", self.meta.get("bg")) @classmethod def is_datasource_for(cls, data, meta, **kwargs): diff --git a/radiospectra/spectrogram/spectrogrambase.py b/radiospectra/spectrogram/spectrogrambase.py index 5f00d85..c297731 100644 --- a/radiospectra/spectrogram/spectrogrambase.py +++ b/radiospectra/spectrogram/spectrogrambase.py @@ -5,6 +5,7 @@ from radiospectra.exceptions import SpectraMetaValidationError from radiospectra.mixins import NonUniformImagePlotMixin, PcolormeshPlotMixin +from radiospectra.spectrogram.meta import SpectrogramMeta from radiospectra.utils import build_spectrogram_wcs __all__ = ["GenericSpectrogram"] @@ -30,6 +31,9 @@ def __init_subclass__(cls, **kwargs): cls._registry[cls] = cls.is_datasource_for def __init__(self, data, meta, wcs=None, **kwargs): + if not isinstance(meta, SpectrogramMeta): + meta = SpectrogramMeta(meta) + if wcs is None: self._validate_meta(meta) wcs = build_spectrogram_wcs(self._time_axis_from_meta(meta), meta["freqs"]).wcs @@ -40,42 +44,45 @@ def observatory(self): """ The name of the observatory which recorded the spectrogram. """ - return self.meta["observatory"].upper() + val = getattr(self.meta, "observatory", self.meta.get("observatory")) + return val.upper() if val else None @property def instrument(self): """ The name of the instrument which recorded the spectrogram. """ - return self.meta["instrument"].upper() + val = getattr(self.meta, "instrument", self.meta.get("instrument")) + return val.upper() if val else None @property def detector(self): """ The detector which recorded the spectrogram. """ - return self.meta["detector"].upper() + val = getattr(self.meta, "detector", self.meta.get("detector")) + return val.upper() if val else None @property def start_time(self): """ The start time of the spectrogram. """ - return self.meta["start_time"] + return getattr(self.meta, "date_start", self.meta.get("start_time")) @property def end_time(self): """ The end time of the spectrogram. """ - return self.meta["end_time"] + return getattr(self.meta, "date_end", self.meta.get("end_time")) @property def wavelength(self): """ The wavelength range of the spectrogram. """ - return self.meta["wavelength"] + return getattr(self.meta, "frequency_range", self.meta.get("wavelength")) @property def times(self): diff --git a/radiospectra/spectrogram/tests/test_meta.py b/radiospectra/spectrogram/tests/test_meta.py new file mode 100644 index 0000000..0895fb8 --- /dev/null +++ b/radiospectra/spectrogram/tests/test_meta.py @@ -0,0 +1,42 @@ +import pytest + +from radiospectra.spectrogram.meta import SpectrogramMeta + + +def test_spectrogram_meta_required_keys(): + meta = SpectrogramMeta( + { + "instrument": "test_inst", + "observatory": "test_obs", + "detector": "test_det", + "start_time": "2020-01-01", + "end_time": "2020-01-02", + } + ) + + assert meta.instrument == "test_inst" + assert meta.observatory == "test_obs" + assert meta.detector == "test_det" + assert meta.date_start == "2020-01-01" + assert meta.date_end == "2020-01-02" + + +def test_spectrogram_meta_missing_required(): + meta = SpectrogramMeta({}) + with pytest.raises(KeyError): + _ = meta.instrument + + +def test_spectrogram_meta_optional_keys(): + meta = SpectrogramMeta( + { + "processing_level": "L2", + "version": "1.0", + "wavelength": "radio", + } + ) + + assert meta.processing_level == "L2" + assert meta.version == "1.0" + assert meta.frequency_range == "radio" + assert meta.temporal_resolution is None