Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/245.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ``SpectrogramMetaABC`` and ``SpectrogramMeta`` metadata classes backed by ``ndcube.meta.NDMeta`` with instrument specific subclasses ``WAVESMeta`` and ``CALISTOMeta`` providing typed property access to spectrogram metadata.
186 changes: 186 additions & 0 deletions radiospectra/spectrogram/meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import abc

from ndcube.meta import NDMeta, NDMetaABC

from astropy.time import Time
from astropy.units import Quantity, Unit

__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 data_units(self) -> Unit:
"""Unit for the data"""
pass

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is aleady cover by the unit in NDCube


@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_location(self):
"""Observer location."""
pass

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe observe_coordinate would be better and this should be a SkyCoord | None

@Amityush-lgtm Amityush-lgtm Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, I'm working on it



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 data_units(self) -> Unit:
return self.get("data_units")

@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_location(self):
return self.get("observer_location")
25 changes: 20 additions & 5 deletions radiospectra/spectrogram/sources/callisto.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import astropy.units as u
from astropy.coordinates.earth import EarthLocation

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_location(self):
"""The location 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):
Expand All @@ -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_location

@classmethod
def is_datasource_for(cls, data, meta, **kwargs):
Expand Down
23 changes: 20 additions & 3 deletions radiospectra/spectrogram/sources/waves.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -24,21 +39,23 @@ 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
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):
Expand Down
19 changes: 13 additions & 6 deletions radiospectra/spectrogram/spectrogrambase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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
Expand All @@ -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):
Expand Down
42 changes: 42 additions & 0 deletions radiospectra/spectrogram/tests/test_meta.py
Original file line number Diff line number Diff line change
@@ -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
Loading