Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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.
182 changes: 182 additions & 0 deletions radiospectra/spectrogram/meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import abc

from ndcube.meta import NDMeta, NDMetaABC

__all__ = ["SpectrogramMetaABC", "SpectrogramMeta"]


class SpectrogramMetaABC(NDMetaABC):
"""
Abstract base class for all spectrogram metadata.
"""

# Identification
@property
@abc.abstractmethod
def instrument(self):

@samaloney samaloney Jun 29, 2026

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 a short description and the type info should be encode here for each property e.g.

def instrument(self) -> str:
    """
    Name of the instrument
    """
    pass

and then here if we only return str then the default south be "" if that better or worse then returning a str or none e.g. -> str|None

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.

for properties like date_start, frequency_rangeor data_units, what should i write for them??

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.

"Start of the observation" Time
"Frequency range of observation" Quantity
"Unit for the data" Unit

Maybe we don't need the docstrings for them all but they types would be good

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.

One more doubt, what would you prefer for observer_location and data_mask?

pass

@property
@abc.abstractmethod
def observatory(self):
pass

@property
@abc.abstractmethod
def detector(self):
pass

@property
@abc.abstractmethod
def processing_level(self):
"""The level to which the data has been processed."""
pass

@property
@abc.abstractmethod
def version(self):
"""The data version."""
pass

@property
@abc.abstractmethod
def source_filename(self):
"""The source filename."""
pass

# Time
@property
@abc.abstractmethod
def date_start(self):
"""The start time of the observation."""
pass

@property
@abc.abstractmethod
def date_end(self):
"""The end time of the observation."""
pass

@property
@abc.abstractmethod
def temporal_resolution(self):
"""Temporal resolution, both raw and current."""
pass

# Frequency
@property
@abc.abstractmethod
def frequency_range(self):
"""Start and end frequencies of the observation."""
pass

@property
@abc.abstractmethod
def frequency_resolution(self):
"""Frequency resolution, both raw and current."""
pass

# Calibration and signal
@property
@abc.abstractmethod
def data_units(self):
"""Units of the data (e.g. arbitrary, V^2/Hz, sfu, dB)."""
pass

@property
@abc.abstractmethod
def calibration_state(self):
"""Calibration state."""
pass

@property
@abc.abstractmethod
def polarisation(self):
"""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):
return self["instrument"]

@property
def observatory(self):
return self["observatory"]

@property
def detector(self):
return self["detector"]

@property
def date_start(self):
return self["start_time"]

@property
def date_end(self):
return self["end_time"]

@property
def processing_level(self):
return self.get("processing_level")

@property
def version(self):
return self.get("version")

@property
def source_filename(self):
return self.get("source_filename")

@property
def temporal_resolution(self):
return self.get("temporal_resolution")

@property
def frequency_range(self):
return self.get("wavelength")

@property
def frequency_resolution(self):
return self.get("frequency_resolution")

@property
def data_units(self):
return self.get("data_units")

@property
def calibration_state(self):
return self.get("calibration_state")

@property
def polarisation(self):
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