Skip to content
Open
Show file tree
Hide file tree
Changes from all 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/181.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixes time gap rendering in `~radiospectra.mixins.PcolormeshPlotMixin` spectrogram plots by inserting ``NaN`` rows at detected gaps so ``pcolormesh`` renders them as empty space instead of stretching data across them.
72 changes: 68 additions & 4 deletions radiospectra/mixins.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import numpy as np
from matplotlib import pyplot as plt
from matplotlib.image import NonUniformImage

from astropy.time import Time
from astropy.visualization import quantity_support, time_support


Expand Down Expand Up @@ -42,14 +44,71 @@ class PcolormeshPlotMixin:
Class provides plotting functions using `~pcolormesh`.
"""

def plot(self, axes=None, **kwargs):
@staticmethod
def _insert_time_gaps(times, data, gap_threshold=None):
"""
Identify gaps in the time axis and insert NaNs to prevent pcolormesh
from stretching data across the gaps.

Parameters
----------
times : `astropy.time.Time`
The timestamps of the observations.
data : `numpy.ndarray`
The intensity data.
gap_threshold : `float`, optional
The threshold in seconds above which a time difference is
considered a gap. If not provided, it defaults to 2.5 times
the minimum difference between consecutive timestamps.

Returns
-------
new_times : `astropy.time.Time`
Modified timestamps with gap-fillers.
new_data : `numpy.ndarray`
Modified data with NaN rows inserted at gaps.
"""
times_numeric = times.to_value("unix")
diffs = np.diff(times_numeric)
if len(diffs) == 0:
return times, data

if gap_threshold is None:
gap_threshold = 2.5 * np.min(diffs[diffs > 0]) if np.any(diffs > 0) else 1e9

gap_indices = np.where(diffs > gap_threshold)[0]

if gap_indices.size == 0:
return times, data

if not np.issubdtype(data.dtype, np.floating):
new_data = data.astype(np.float64)
else:
new_data = data.copy()

new_times_numeric = times_numeric.tolist()
for idx in reversed(gap_indices):
gap_time = (times_numeric[idx] + times_numeric[idx + 1]) / 2.0
new_times_numeric.insert(idx + 1, gap_time)

null_row = np.full(data.shape[1], np.nan)
new_data = np.insert(new_data, idx + 1, null_row, axis=0)

return Time(new_times_numeric, format="unix"), new_data

def plot(self, axes=None, handle_gaps=True, gap_threshold=None, **kwargs):
"""
Plot the spectrogram.

Parameters
----------
axes : `matplotlib.axis.Axes`, optional
The axes where the plot will be added.
handle_gaps : `bool`, optional
If True, automatically detect large time gaps and render
them as empty space by inserting NaNs. Defaults to True.
gap_threshold : `float`, optional
Optional manual threshold in seconds for gap detection.
kwargs :
Arguments pass to the plot call `pcolormesh`.

Expand Down Expand Up @@ -85,10 +144,15 @@ def plot(self, axes=None, **kwargs):
_set_axis_converter(axes.xaxis, converter_x)

axes.plot(self.times[[0, -1]], self.frequencies[[0, -1]], linestyle="None", marker="None")
if self.times.shape[0] == self.data.shape[0] and self.frequencies.shape[0] == self.data.shape[1]:
ret = axes.pcolormesh(self.times, self.frequencies, data, shading="auto", **kwargs)

times, data_to_plot = self.times, data
if handle_gaps:
times, data_to_plot = self._insert_time_gaps(times, data_to_plot, gap_threshold=gap_threshold)

if times.shape[0] == data_to_plot.shape[0] and self.frequencies.shape[0] == data_to_plot.shape[1]:
ret = axes.pcolormesh(times, self.frequencies, data_to_plot, shading="auto", **kwargs)
else:
ret = axes.pcolormesh(self.times, self.frequencies, data[:-1, :-1], shading="auto", **kwargs)
ret = axes.pcolormesh(times, self.frequencies, data_to_plot[:-1, :-1], shading="auto", **kwargs)
axes.set_xlim(self.times[0], self.times[-1])
fig.autofmt_xdate()

Expand Down
3 changes: 0 additions & 3 deletions radiospectra/spectrogram/sources/eovsa.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,3 @@ def polarisation(self):
@classmethod
def is_datasource_for(cls, data, meta, **kwargs):
return meta["instrument"] == "EOVSA" or meta["detector"] == "EOVSA"

# TODO fix time gaps for plots need to render them as gaps
# can prob do when generateing proper pcolormesh grid but then prob doesn't belong here
90 changes: 90 additions & 0 deletions radiospectra/tests/test_mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import numpy as np

from astropy.time import Time

from radiospectra.mixins import PcolormeshPlotMixin


class MockSpectrogram(PcolormeshPlotMixin):
def __init__(self, times, data):
self.times = times
self.data = data


def test_insert_time_gaps_no_gaps():
times = Time(["2023-01-01T00:00:00", "2023-01-01T00:00:01", "2023-01-01T00:00:02"])
data = np.ones((3, 5))
spec = MockSpectrogram(times, data)

new_times, new_data = spec._insert_time_gaps(times, data)

assert len(new_times) == 3
assert new_data.shape == (3, 5)
assert not np.isnan(new_data).any()


def test_insert_time_gaps_with_gap():
times = Time(["2023-01-01T00:00:00", "2023-01-01T00:00:01", "2023-01-01T00:00:11"])
data = np.array([[1, 1], [2, 2], [3, 3]])
spec = MockSpectrogram(times, data)

new_times, new_data = spec._insert_time_gaps(times, data)

assert len(new_times) == 4
assert new_data.shape == (4, 2)

assert new_times[2].isot == "2023-01-01T00:00:06.000"
assert np.isnan(new_data[2]).all()

np.testing.assert_array_equal(new_data[0], [1.0, 1.0])
np.testing.assert_array_equal(new_data[1], [2.0, 2.0])
np.testing.assert_array_equal(new_data[3], [3.0, 3.0])


def test_insert_time_gaps_multiple_gaps():
times = Time(
[
"2023-01-01T00:00:00",
"2023-01-01T00:00:01",
"2023-01-01T00:00:10",
"2023-01-01T00:00:11",
"2023-01-01T00:00:20",
]
)
data = np.ones((5, 2))
spec = MockSpectrogram(times, data)

new_times, new_data = spec._insert_time_gaps(times, data)

assert len(new_times) == 7
assert new_data.shape == (7, 2)
assert np.isnan(new_data[2]).all()
assert np.isnan(new_data[5]).all()


def test_insert_time_gaps_float_input():
"""Verify that float data is copied, not cast again."""
times = Time(["2023-01-01T00:00:00", "2023-01-01T00:00:01", "2023-01-01T00:00:11"])
data = np.array([[1.5, 2.5], [3.5, 4.5], [5.5, 6.5]])
spec = MockSpectrogram(times, data)

new_times, new_data = spec._insert_time_gaps(times, data)

assert len(new_times) == 4
assert np.isnan(new_data[2]).all()
np.testing.assert_array_equal(new_data[0], [1.5, 2.5])
np.testing.assert_array_equal(new_data[3], [5.5, 6.5])


def test_insert_time_gaps_manual_threshold():
"""Verify that a manual gap_threshold overrides the automatic detection."""
times = Time(["2023-01-01T00:00:00", "2023-01-01T00:00:01", "2023-01-01T00:00:04"])
data = np.ones((3, 2))
spec = MockSpectrogram(times, data)

new_times, _ = spec._insert_time_gaps(times, data)
assert len(new_times) == 4

new_times_manual, new_data_manual = spec._insert_time_gaps(times, data, gap_threshold=5.0)
assert len(new_times_manual) == 3
assert not np.isnan(new_data_manual).any()
Loading