From 1fe08daf8bc7b3530775750536e1bc06fb27e6a6 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 20 May 2026 14:25:59 +0200 Subject: [PATCH 1/6] First pass --- examples/dynamic_spectrum.py | 202 +++++++++++++++++++++ ndcube/conftest.py | 82 +++++++++ ndcube/tests/test_ndcube_dynspec.py | 178 ++++++++++++++++++ ndcube/tests/test_ndcube_slice_and_crop.py | 39 ++++ ndcube/utils/cube.py | 15 +- 5 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 examples/dynamic_spectrum.py create mode 100644 ndcube/tests/test_ndcube_dynspec.py diff --git a/examples/dynamic_spectrum.py b/examples/dynamic_spectrum.py new file mode 100644 index 000000000..00e71a5b6 --- /dev/null +++ b/examples/dynamic_spectrum.py @@ -0,0 +1,202 @@ +""" +====================================================== +Analyzing a dynamic spectrum with log-spaced frequency +====================================================== + +This example shows how to create and analyze an `~ndcube.NDCube` for a +synthetic solar radio dynamic spectrum, where the time axis has an irregular +cadence and the frequency axis is logarithmically spaced. + +The example uses a broad metric-range frequency grid and an irregular cadence +to exercise non-uniform world coordinates without needing an external data +file. +""" + +import numpy as np +from gwcs import coordinate_frames as cf +from gwcs import wcs as gwcs_wcs +from matplotlib import pyplot as plt + +import astropy.units as u +from astropy.modeling import models +from astropy.time import Time + +from ndcube import NDCube + + +def build_dynspec_wcs(time_offsets_s, frequencies_hz): + """ + Build a 2D gWCS for dynamic-spectrum data stored as (frequency, time). + """ + time_model = models.Tabular1D( + points=np.arange(len(time_offsets_s)), + lookup_table=time_offsets_s, + method='linear', + bounds_error=False, + ) + freq_model = models.Tabular1D( + points=np.arange(len(frequencies_hz)), + lookup_table=frequencies_hz, + method='linear', + bounds_error=False, + ) + + time_frame = cf.TemporalFrame( + axes_order=(0,), + unit=u.s, + reference_frame=Time("2024-03-23T00:03:23"), + ) + freq_frame = cf.SpectralFrame(axes_order=(1,), unit=u.Hz, axes_names=('frequency',)) + + transform = time_model & freq_model + output_frame = cf.CompositeFrame([time_frame, freq_frame]) + detector_frame = cf.CoordinateFrame( + name="detector", + naxes=2, + axes_order=(0, 1), + axes_type=("pixel", "pixel"), + unit=(u.pix, u.pix), + ) + + dynspec_wcs = gwcs_wcs.WCS( + forward_transform=transform, + output_frame=output_frame, + input_frame=detector_frame, + ) + dynspec_wcs.array_shape = (len(frequencies_hz), len(time_offsets_s)) + return dynspec_wcs + +############################################################################## +# Build synthetic dynamic spectrum data +# ------------------------------------- +# The frequency axis is logarithmically spaced between ~4 and ~978 MHz. +# The time axis has an irregular cadence drawn from a normal distribution +# centred on ~14 s. +# We inject a simulated Type III solar radio burst, a narrowband feature that +# drifts from high to low frequency over time, on top of a background noise +# floor. +# +# We store the data as shape ``(n_freq, n_time)`` so that frequency varies along +# rows (the Y axis) and time along columns (the X axis), matching the standard +# radio-astronomy display convention. + +rng = np.random.default_rng(42) +n_freq, n_time = 64, 100 + +# Log-spaced frequencies for this synthetic metric-range example. +freqs_hz = np.logspace(np.log10(3.992e6), np.log10(978.572e6), n_freq) + +# Irregular time offsets (seconds since observation start, median ~14 s cadence) +dt_s = np.cumsum(np.abs(rng.normal(14, 3, n_time))) +dt_s -= dt_s[0] + +# Background flux density (exponential noise floor), shape (n_freq, n_time) +data = rng.exponential(1e-15, (n_freq, n_time)) + +# Inject a Type III burst: emission that drifts from ~400 MHz to ~50 MHz +drift_rate_hz_per_s = -5e6 +burst_start_s = dt_s[35] +for t_idx in range(n_time): + f_center = 400e6 + drift_rate_hz_per_s * (dt_s[t_idx] - burst_start_s) + if freqs_hz[0] < f_center < freqs_hz[-1]: + f_idx = int(np.argmin(np.abs(freqs_hz - f_center))) + half_width = max(1, n_freq // 15) + lo, hi = max(0, f_idx - half_width), min(n_freq, f_idx + half_width) + data[lo:hi, t_idx] *= 30 + +############################################################################## +# Build the gWCS using ``Tabular1D`` lookup-table transforms +# ---------------------------------------------------------- +# Because neither axis is uniformly spaced in physical coordinates, we use +# `~astropy.modeling.models.Tabular1D` to map pixel indices to world values. +# +# Axis ordering follows the ndcube/FITS convention: pixel axis 0 maps to the +# *last* numpy array axis, so for shape ``(n_freq, n_time)``: +# +# * **pixel axis 0** -> time (array axis 1, X axis when plotted) +# * **pixel axis 1** -> frequency (array axis 0, Y axis when plotted) + +dynspec_wcs = build_dynspec_wcs(dt_s, freqs_hz) + +############################################################################## +# Create the NDCube +# ----------------- + +dynspec_cube = NDCube(data, wcs=dynspec_wcs, unit=u.W / u.m**2 / u.Hz) +print(dynspec_cube) + +############################################################################## +# The ``array_axis_physical_types`` property confirms the mapping: array axis 0 +# (rows, Y) carries frequency and array axis 1 (columns, X) carries time. + +print(dynspec_cube.array_axis_physical_types) + +############################################################################## +# Plot the full dynamic spectrum +# ------------------------------ +# Time appears on the X axis and frequency on the Y axis. + +dynspec_cube.plot() +plt.gca().set_title("Synthetic dynamic spectrum") + +############################################################################## +# Crop a useful time-frequency window +# ----------------------------------- +# ``crop_by_values`` accepts world-coordinate `~astropy.units.Quantity` objects +# in world-axis order, which is ``(time, frequency)`` for this WCS. +# Pass ``None`` to leave an axis unconstrained. +# The method returns the smallest pixel bounding box that spans the requested +# range, so the outermost channels may lie just outside the nominal bounds. + +windowed = dynspec_cube.crop_by_values( + [200 * u.s, 10e6 * u.Hz], + [800 * u.s, 500e6 * u.Hz], +) +print("Windowed shape (200-800 s, 10-500 MHz):", windowed.shape) + +windowed.plot() +plt.gca().set_title("Windowed: 200-800 s, 10-500 MHz") + +############################################################################## +# Rebin frequency channels +# ------------------------ +# ``rebin`` bins contiguous pixels together. The bin shape follows numpy +# (array) axis ordering, so ``(3, 1)`` averages triplets of frequency rows while +# leaving time samples unchanged. + +rebinned = windowed.rebin((3, 1)) +print("Frequency-rebinned shape:", rebinned.shape) + +rebinned.plot() +plt.gca().set_title("Frequency rebinned") + +############################################################################## +# Resample the time axis to a denser grid +# --------------------------------------- +# Here we use linear interpolation to add one new time sample between each pair +# of original time samples in the cropped cube. The data shape changes from +# ``(n_freq, n_time)`` to ``(n_freq, 2 * n_time - 1)`` and we build a matching +# WCS from the new lookup table. + +old_times_s = np.array([ + windowed.wcs.low_level_wcs.pixel_to_world_values(time_idx, 0)[0] + for time_idx in range(windowed.shape[1]) +]) +windowed_freqs_hz = np.array([ + windowed.wcs.low_level_wcs.pixel_to_world_values(0, freq_idx)[1] + for freq_idx in range(windowed.shape[0]) +]) +new_times_s = np.linspace(old_times_s[0], old_times_s[-1], 2 * len(old_times_s) - 1) +resampled_data = np.array([ + np.interp(new_times_s, old_times_s, frequency_row) + for frequency_row in windowed.data +]) + +resampled_wcs = build_dynspec_wcs(new_times_s, windowed_freqs_hz) +resampled = NDCube(resampled_data, wcs=resampled_wcs, unit=windowed.unit) +print("Time-resampled shape:", resampled.shape) + +resampled.plot() +plt.gca().set_title("Time resampled") + +plt.show() diff --git a/ndcube/conftest.py b/ndcube/conftest.py index 1810d0160..605c93a50 100644 --- a/ndcube/conftest.py +++ b/ndcube/conftest.py @@ -224,6 +224,72 @@ def gwcs_2d_lt_ln(): return (wcs.WCS(forward_transform=cel_model, output_frame=sky_frame, input_frame=input_frame)) + +@pytest.fixture +def gwcs_2d_t_f_linear(): + """ + 2D gWCS for a dynamic spectrum: uniform time (array axis 1 / X) and linear + frequency (array axis 0 / Y). + + Convention: array shape (n_freq, n_time) so frequency varies along rows + (Y axis) and time along columns (X axis) when plotted. + + - Pixel axis 0 -> time (14 s/pixel via Scale) + - Pixel axis 1 -> frequency (1 MHz/pixel via Scale) + """ + time_model = models.Scale(14.0) + freq_model = models.Scale(1e6) + + time_frame = cf.TemporalFrame(axes_order=(0,), unit=u.s, + reference_frame=Time("2024-03-23T00:03:23")) + freq_frame = cf.SpectralFrame(axes_order=(1,), unit=u.Hz, axes_names=('frequency',)) + + transform = time_model & freq_model + frame = cf.CompositeFrame([time_frame, freq_frame]) + detector_frame = cf.CoordinateFrame(name="detector", naxes=2, + axes_order=(0, 1), + axes_type=("pixel", "pixel"), + unit=(u.pix, u.pix)) + return wcs.WCS(forward_transform=transform, output_frame=frame, + input_frame=detector_frame) + + +@pytest.fixture +def gwcs_2d_t_f_log(): + """ + 2D gWCS for a dynamic spectrum: irregularly-spaced time (array axis 1 / X) + and log-spaced frequency (array axis 0 / Y) via Tabular1D lookup tables. + + Synthetic metric-range grid (~4-978 MHz, 16 channels, ~14 s irregular + cadence over 10 time steps). + + Convention: array shape (n_freq, n_time) so frequency varies along rows + (Y axis) and time along columns (X axis) when plotted. + + - Pixel axis 0 -> time (Tabular1D, irregular seconds since reference) + - Pixel axis 1 -> frequency (Tabular1D, log-spaced 3.992-978.572 MHz) + """ + times_s = np.array([0.0, 14.0, 27.4, 41.1, 55.2, 67.8, 82.3, 95.9, 109.1, 122.5]) + freqs_hz = np.logspace(np.log10(3.992e6), np.log10(978.572e6), 16) + + time_model = models.Tabular1D(points=np.arange(10), lookup_table=times_s, + method='linear', bounds_error=False) + freq_model = models.Tabular1D(points=np.arange(16), lookup_table=freqs_hz, + method='linear', bounds_error=False) + + time_frame = cf.TemporalFrame(axes_order=(0,), unit=u.s, + reference_frame=Time("2024-03-23T00:03:23")) + freq_frame = cf.SpectralFrame(axes_order=(1,), unit=u.Hz, axes_names=('frequency',)) + + transform = time_model & freq_model + frame = cf.CompositeFrame([time_frame, freq_frame]) + detector_frame = cf.CoordinateFrame(name="detector", naxes=2, + axes_order=(0, 1), + axes_type=("pixel", "pixel"), + unit=(u.pix, u.pix)) + return wcs.WCS(forward_transform=transform, output_frame=frame, + input_frame=detector_frame) + @pytest.fixture def wcs_4d_t_l_lt_ln(): header = { @@ -564,6 +630,20 @@ def extra_coords_sharing_axis(): # NOTE: If you add more fixtures please add to the all_ndcubes fixture ################################################################################ +@pytest.fixture +def ndcube_gwcs_2d_t_f_linear(gwcs_2d_t_f_linear): + shape = (16, 10) # (n_freq, n_time): freq on Y axis, time on X axis + gwcs_2d_t_f_linear.array_shape = shape + return NDCube(data_nd(shape), wcs=gwcs_2d_t_f_linear) + + +@pytest.fixture +def ndcube_gwcs_2d_t_f_log(gwcs_2d_t_f_log): + shape = (16, 10) # (n_freq, n_time): freq on Y axis, time on X axis + gwcs_2d_t_f_log.array_shape = shape + return NDCube(data_nd(shape), wcs=gwcs_2d_t_f_log) + + @pytest.fixture def ndcube_gwcs_4d_ln_lt_l_t(gwcs_4d_t_l_lt_ln): shape = (5, 8, 10, 12) @@ -1074,6 +1154,8 @@ def ndcube_1d_l(wcs_1d_l): @pytest.fixture(params=[ + "ndcube_gwcs_2d_t_f_linear", + "ndcube_gwcs_2d_t_f_log", "ndcube_gwcs_4d_ln_lt_l_t", "ndcube_gwcs_4d_ln_lt_l_t_unit", "ndcube_gwcs_3d_ln_lt_l", diff --git a/ndcube/tests/test_ndcube_dynspec.py b/ndcube/tests/test_ndcube_dynspec.py new file mode 100644 index 000000000..a42b1b900 --- /dev/null +++ b/ndcube/tests/test_ndcube_dynspec.py @@ -0,0 +1,178 @@ +""" +Tests for NDCube with dynamic spectrum WCS (frequency x time). + +Convention throughout: array shape (n_freq, n_time) so that when plotted +as a 2D image frequency varies along rows (Y axis) and time along columns +(X axis), matching standard radio astronomy dynamic spectrum displays. + +WCS pixel axis ordering (reversed from array): + pixel axis 0 -> time (array axis 1, X axis) + pixel axis 1 -> freq (array axis 0, Y axis) + +world_axis_units = ('s', 'Hz') and pixel_to_world_values(p_time, p_freq) +returns (time_s, freq_hz). +""" +import numpy as np +from numpy.testing import assert_allclose + +import astropy.units as u + +from ndcube.wcs.wrappers import ResampledLowLevelWCS + +# Pre-computed from fixture definitions; kept here so tests are self-documenting. +_FREQS_LOG_HZ = np.logspace(np.log10(3.992e6), np.log10(978.572e6), 16) +_TIMES_S = np.array([0.0, 14.0, 27.4, 41.1, 55.2, 67.8, 82.3, 95.9, 109.1, 122.5]) + + +class TestLinearDynspec: + """Linear Scale gWCS: 14 s/pixel time, 1 MHz/pixel freq.""" + + def test_array_axis_physical_types(self, ndcube_gwcs_2d_t_f_linear): + types = ndcube_gwcs_2d_t_f_linear.array_axis_physical_types + assert 'em.freq' in types[0] # array axis 0 = rows = Y axis + assert 'time' in types[1] # array axis 1 = cols = X axis + + def test_pixel_to_world(self, ndcube_gwcs_2d_t_f_linear): + # pixel_to_world_values(time_pixel, freq_pixel) -> (time_s, freq_hz) + time, freq = ndcube_gwcs_2d_t_f_linear.wcs.low_level_wcs.pixel_to_world_values(3, 2) + assert_allclose(time, 42.0) # 3 * 14 s/pixel + assert_allclose(freq, 2e6) # 2 * 1 MHz/pixel + + def test_world_to_pixel(self, ndcube_gwcs_2d_t_f_linear): + # world_to_pixel_values(time_s, freq_hz) -> (time_pixel, freq_pixel) + pix_t, pix_f = ndcube_gwcs_2d_t_f_linear.wcs.low_level_wcs.world_to_pixel_values(28.0, 4e6) + assert_allclose(pix_t, 2.0) + assert_allclose(pix_f, 4.0) + + def test_rebin_freq_shape(self, ndcube_gwcs_2d_t_f_linear): + # rebin(2, 1): bin 2 freq rows, keep time cols -> (8, 10) + assert ndcube_gwcs_2d_t_f_linear.rebin((2, 1)).shape == (8, 10) + + def test_rebin_freq_wcs(self, ndcube_gwcs_2d_t_f_linear): + rebinned = ndcube_gwcs_2d_t_f_linear.rebin((2, 1)) + _, freq0 = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) + assert_allclose(freq0, 0.5e6) # midpoint of freq pixels 0 and 1 + + def test_rebin_time_shape(self, ndcube_gwcs_2d_t_f_linear): + # rebin(1, 2): keep freq rows, bin 2 time cols -> (16, 5) + assert ndcube_gwcs_2d_t_f_linear.rebin((1, 2)).shape == (16, 5) + + def test_rebin_time_wcs(self, ndcube_gwcs_2d_t_f_linear): + rebinned = ndcube_gwcs_2d_t_f_linear.rebin((1, 2)) + time0, _ = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) + assert_allclose(time0, 7.0) # midpoint of 0 and 14 s + + def test_rebin_wcs_is_resampled(self, ndcube_gwcs_2d_t_f_linear): + assert isinstance(ndcube_gwcs_2d_t_f_linear.rebin((2, 2)).wcs.low_level_wcs, + ResampledLowLevelWCS) + + def test_crop_by_freq_shape(self, ndcube_gwcs_2d_t_f_linear): + # world order: (time, freq); freq crop reduces rows + # 3–7 MHz = freq pixels 3,4,5,6,7 -> 5 freq rows + cropped = ndcube_gwcs_2d_t_f_linear.crop_by_values([None, 3e6 * u.Hz], + [None, 7e6 * u.Hz]) + assert cropped.shape == (5, 10) + + def test_crop_by_time_shape(self, ndcube_gwcs_2d_t_f_linear): + # time crop reduces cols + # 14–56 s = time pixels 1,2,3,4 -> 4 time cols + cropped = ndcube_gwcs_2d_t_f_linear.crop_by_values([14 * u.s, None], + [56 * u.s, None]) + assert cropped.shape == (16, 4) + + +class TestLogDynspec: + """Log-spaced Tabular1D gWCS: synthetic metric-range frequency, irregular time.""" + + def test_array_axis_physical_types(self, ndcube_gwcs_2d_t_f_log): + types = ndcube_gwcs_2d_t_f_log.array_axis_physical_types + assert 'em.freq' in types[0] # array axis 0 = rows = Y axis + assert 'time' in types[1] # array axis 1 = cols = X axis + + def test_world_axis_units(self, ndcube_gwcs_2d_t_f_log): + units = ndcube_gwcs_2d_t_f_log.wcs.world_axis_units + assert units[0] == 's' # world axis 0 = time + assert units[1] == 'Hz' # world axis 1 = freq + + def test_pixel_to_world_origin(self, ndcube_gwcs_2d_t_f_log): + # pixel_to_world_values(time_pixel, freq_pixel) -> (time_s, freq_hz) + time, freq = ndcube_gwcs_2d_t_f_log.wcs.low_level_wcs.pixel_to_world_values(0, 0) + assert_allclose(time, _TIMES_S[0]) + assert_allclose(freq, _FREQS_LOG_HZ[0], rtol=1e-6) + + def test_pixel_to_world_last(self, ndcube_gwcs_2d_t_f_log): + time, freq = ndcube_gwcs_2d_t_f_log.wcs.low_level_wcs.pixel_to_world_values(9, 15) + assert_allclose(time, _TIMES_S[9]) + assert_allclose(freq, _FREQS_LOG_HZ[15], rtol=1e-6) + + def test_world_to_pixel_roundtrip(self, ndcube_gwcs_2d_t_f_log): + # world_to_pixel_values(time_s, freq_hz) -> (time_pixel, freq_pixel) + pix_t, pix_f = ndcube_gwcs_2d_t_f_log.wcs.low_level_wcs.world_to_pixel_values( + _TIMES_S[3], _FREQS_LOG_HZ[7]) + assert_allclose(pix_t, 3.0, atol=1e-10) + assert_allclose(pix_f, 7.0, atol=1e-10) + + def test_rebin_freq_shape(self, ndcube_gwcs_2d_t_f_log): + # rebin(2, 1): bin 2 freq rows -> (8, 10) + assert ndcube_gwcs_2d_t_f_log.rebin((2, 1)).shape == (8, 10) + + def test_rebin_freq_wcs_midpoint(self, ndcube_gwcs_2d_t_f_log): + # ResampledLowLevelWCS shifts pixel centres by 0.5; Tabular1D returns + # the linearly interpolated value at the midpoint of the binned pixels. + rebinned = ndcube_gwcs_2d_t_f_log.rebin((2, 1)) + _, freq0 = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) + expected = (_FREQS_LOG_HZ[0] + _FREQS_LOG_HZ[1]) / 2 + assert_allclose(freq0, expected, rtol=1e-6) + + def test_rebin_time_shape(self, ndcube_gwcs_2d_t_f_log): + # rebin(1, 2): bin 2 time cols -> (16, 5) + assert ndcube_gwcs_2d_t_f_log.rebin((1, 2)).shape == (16, 5) + + def test_rebin_time_wcs_midpoint(self, ndcube_gwcs_2d_t_f_log): + rebinned = ndcube_gwcs_2d_t_f_log.rebin((1, 2)) + time0, _ = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) + expected = (_TIMES_S[0] + _TIMES_S[1]) / 2 + assert_allclose(time0, expected, rtol=1e-6) + + def test_rebin_wcs_is_resampled(self, ndcube_gwcs_2d_t_f_log): + assert isinstance(ndcube_gwcs_2d_t_f_log.rebin((2, 2)).wcs.low_level_wcs, + ResampledLowLevelWCS) + + def test_crop_by_freq_shape(self, ndcube_gwcs_2d_t_f_log): + # world order: (time, freq); freq crop reduces rows + # 10–100 MHz selects 8 freq rows (bounding box including boundary pixels) + cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( + [None, 10e6 * u.Hz], [None, 100e6 * u.Hz]) + assert cropped.shape == (8, 10) + + def test_crop_by_freq_bounds(self, ndcube_gwcs_2d_t_f_log): + # Crop returns the smallest pixel bounding box spanning the world range. + # First and last pixels may fall outside [lo, hi]; the full range is covered. + lo, hi = 10e6, 100e6 + cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( + [None, lo * u.Hz], [None, hi * u.Hz]) + freqs = [cropped.wcs.low_level_wcs.pixel_to_world_values(0, i)[1] + for i in range(cropped.shape[0])] + assert freqs[0] <= lo # first channel at or below lower bound + assert freqs[-1] >= hi # last channel at or above upper bound + + def test_crop_by_time_shape(self, ndcube_gwcs_2d_t_f_log): + # time crop reduces cols; 20–80 s spans 6 time steps (14.0..82.3 s) + cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( + [20 * u.s, None], [80 * u.s, None]) + assert cropped.shape == (16, 6) + + def test_crop_by_time_bounds(self, ndcube_gwcs_2d_t_f_log): + lo, hi = 20.0, 80.0 + cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( + [lo * u.s, None], [hi * u.s, None]) + times = [cropped.wcs.low_level_wcs.pixel_to_world_values(i, 0)[0] + for i in range(cropped.shape[1])] + assert times[0] <= lo + assert times[-1] >= hi + + def test_crop_by_freq_and_time(self, ndcube_gwcs_2d_t_f_log): + # world order (time, freq) + cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( + [20 * u.s, 10e6 * u.Hz], [80 * u.s, 100e6 * u.Hz]) + assert cropped.shape == (8, 6) diff --git a/ndcube/tests/test_ndcube_slice_and_crop.py b/ndcube/tests/test_ndcube_slice_and_crop.py index cb4cb4c07..f23bfb577 100644 --- a/ndcube/tests/test_ndcube_slice_and_crop.py +++ b/ndcube/tests/test_ndcube_slice_and_crop.py @@ -615,3 +615,42 @@ def test_crop_all_points_beyond_cube_extent_error(points): with pytest.raises(ValueError, match="are outside the range of the NDCube being cropped"): cube.crop(*points, keepdims=True) + + +def test_crop_by_values_quantity_table_coordinate(): + # Regression: QuantityTableCoordinate-based WCS raised + # "High Level objects are not supported with the native API" because + # world_to_pixel_values was called with Quantity objects. + # Fixed in ndcube/utils/cube.py by stripping .value before that call. + # + # ExtraCoords adds freq on array axis 0, time on array axis 1. + # ExtraCoords.cube_wcs returns world order (Hz, s). + freqs_hz = np.logspace(np.log10(4e6), np.log10(200e6), 16) + times_s = np.linspace(0, 140, 10) + wcs2d = astropy.wcs.WCS(naxis=2) + wcs2d.wcs.ctype = ["PIXEL", "PIXEL"] + wcs2d.wcs.crpix = [1, 1] + wcs2d.wcs.cdelt = [1, 1] + wcs2d.wcs.crval = [0, 0] + data = np.arange(16 * 10).reshape(16, 10) + cube = NDCube(data, wcs=wcs2d) + cube.extra_coords.add("frequency", (0,), freqs_hz * u.Hz) + cube.extra_coords.add("time", (1,), times_s * u.s) + + # freq-only crop: world order (Hz, s) -> freq at index 0 + cropped = cube.crop_by_values([10e6 * u.Hz, None], [100e6 * u.Hz, None], + wcs=cube.extra_coords) + assert cropped.shape == (10, 10) + np.testing.assert_array_equal(cropped.data, data[3:13, :]) + + # time-only crop: time at index 1 + cropped = cube.crop_by_values([None, 20 * u.s], [None, 80 * u.s], + wcs=cube.extra_coords) + assert cropped.shape == (16, 5) + np.testing.assert_array_equal(cropped.data, data[:, 1:6]) + + # both axes + cropped = cube.crop_by_values([10e6 * u.Hz, 20 * u.s], [100e6 * u.Hz, 80 * u.s], + wcs=cube.extra_coords) + assert cropped.shape == (10, 5) + np.testing.assert_array_equal(cropped.data, data[3:13, 1:6]) diff --git a/ndcube/utils/cube.py b/ndcube/utils/cube.py index 241aae066..0b3788848 100644 --- a/ndcube/utils/cube.py +++ b/ndcube/utils/cube.py @@ -181,8 +181,19 @@ def get_crop_item_from_points(points, wcs, crop_by_values, keepdims, original_sh # Derive the pixel indices of the input point and place each index # in the list corresponding to its axis. # Use the to_pixel methods to preserve fractional indices for future rounding. - point_pixel_indices = (sliced_wcs.world_to_pixel_values(*sliced_point) if crop_by_values - else HighLevelWCSWrapper(sliced_wcs).world_to_pixel(*sliced_point)) + if crop_by_values: + # world_to_pixel_values is the APE14 low-level API and expects plain + # floats, not Quantity objects. Strip units here; the values are + # already in the correct units because _get_crop_by_values_item called + # .to(wcs.world_axis_units[j]) before reaching this point. + # Passing Quantity objects raises TypeError in gWCS when the WCS's + # declared high-level type is itself Quantity (e.g. a WCS built from + # QuantityTableCoordinate), because gWCS cannot distinguish such inputs + # from an accidental high-level API call. + stripped_point = [p.value if hasattr(p, "value") else p for p in sliced_point] + point_pixel_indices = sliced_wcs.world_to_pixel_values(*stripped_point) + else: + point_pixel_indices = HighLevelWCSWrapper(sliced_wcs).world_to_pixel(*sliced_point) # For each pixel axis associated with this point, place the pixel coords for # that pixel axis into the corresponding list within combined_points_pixel_idx. if sliced_wcs.pixel_n_dim == 1: From 32aeae92469cd04d992bccd55d1333f3206ff137 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 20 May 2026 15:33:09 +0200 Subject: [PATCH 2/6] Remove example --- examples/dynamic_spectrum.py | 202 ----------------------------------- 1 file changed, 202 deletions(-) delete mode 100644 examples/dynamic_spectrum.py diff --git a/examples/dynamic_spectrum.py b/examples/dynamic_spectrum.py deleted file mode 100644 index 00e71a5b6..000000000 --- a/examples/dynamic_spectrum.py +++ /dev/null @@ -1,202 +0,0 @@ -""" -====================================================== -Analyzing a dynamic spectrum with log-spaced frequency -====================================================== - -This example shows how to create and analyze an `~ndcube.NDCube` for a -synthetic solar radio dynamic spectrum, where the time axis has an irregular -cadence and the frequency axis is logarithmically spaced. - -The example uses a broad metric-range frequency grid and an irregular cadence -to exercise non-uniform world coordinates without needing an external data -file. -""" - -import numpy as np -from gwcs import coordinate_frames as cf -from gwcs import wcs as gwcs_wcs -from matplotlib import pyplot as plt - -import astropy.units as u -from astropy.modeling import models -from astropy.time import Time - -from ndcube import NDCube - - -def build_dynspec_wcs(time_offsets_s, frequencies_hz): - """ - Build a 2D gWCS for dynamic-spectrum data stored as (frequency, time). - """ - time_model = models.Tabular1D( - points=np.arange(len(time_offsets_s)), - lookup_table=time_offsets_s, - method='linear', - bounds_error=False, - ) - freq_model = models.Tabular1D( - points=np.arange(len(frequencies_hz)), - lookup_table=frequencies_hz, - method='linear', - bounds_error=False, - ) - - time_frame = cf.TemporalFrame( - axes_order=(0,), - unit=u.s, - reference_frame=Time("2024-03-23T00:03:23"), - ) - freq_frame = cf.SpectralFrame(axes_order=(1,), unit=u.Hz, axes_names=('frequency',)) - - transform = time_model & freq_model - output_frame = cf.CompositeFrame([time_frame, freq_frame]) - detector_frame = cf.CoordinateFrame( - name="detector", - naxes=2, - axes_order=(0, 1), - axes_type=("pixel", "pixel"), - unit=(u.pix, u.pix), - ) - - dynspec_wcs = gwcs_wcs.WCS( - forward_transform=transform, - output_frame=output_frame, - input_frame=detector_frame, - ) - dynspec_wcs.array_shape = (len(frequencies_hz), len(time_offsets_s)) - return dynspec_wcs - -############################################################################## -# Build synthetic dynamic spectrum data -# ------------------------------------- -# The frequency axis is logarithmically spaced between ~4 and ~978 MHz. -# The time axis has an irregular cadence drawn from a normal distribution -# centred on ~14 s. -# We inject a simulated Type III solar radio burst, a narrowband feature that -# drifts from high to low frequency over time, on top of a background noise -# floor. -# -# We store the data as shape ``(n_freq, n_time)`` so that frequency varies along -# rows (the Y axis) and time along columns (the X axis), matching the standard -# radio-astronomy display convention. - -rng = np.random.default_rng(42) -n_freq, n_time = 64, 100 - -# Log-spaced frequencies for this synthetic metric-range example. -freqs_hz = np.logspace(np.log10(3.992e6), np.log10(978.572e6), n_freq) - -# Irregular time offsets (seconds since observation start, median ~14 s cadence) -dt_s = np.cumsum(np.abs(rng.normal(14, 3, n_time))) -dt_s -= dt_s[0] - -# Background flux density (exponential noise floor), shape (n_freq, n_time) -data = rng.exponential(1e-15, (n_freq, n_time)) - -# Inject a Type III burst: emission that drifts from ~400 MHz to ~50 MHz -drift_rate_hz_per_s = -5e6 -burst_start_s = dt_s[35] -for t_idx in range(n_time): - f_center = 400e6 + drift_rate_hz_per_s * (dt_s[t_idx] - burst_start_s) - if freqs_hz[0] < f_center < freqs_hz[-1]: - f_idx = int(np.argmin(np.abs(freqs_hz - f_center))) - half_width = max(1, n_freq // 15) - lo, hi = max(0, f_idx - half_width), min(n_freq, f_idx + half_width) - data[lo:hi, t_idx] *= 30 - -############################################################################## -# Build the gWCS using ``Tabular1D`` lookup-table transforms -# ---------------------------------------------------------- -# Because neither axis is uniformly spaced in physical coordinates, we use -# `~astropy.modeling.models.Tabular1D` to map pixel indices to world values. -# -# Axis ordering follows the ndcube/FITS convention: pixel axis 0 maps to the -# *last* numpy array axis, so for shape ``(n_freq, n_time)``: -# -# * **pixel axis 0** -> time (array axis 1, X axis when plotted) -# * **pixel axis 1** -> frequency (array axis 0, Y axis when plotted) - -dynspec_wcs = build_dynspec_wcs(dt_s, freqs_hz) - -############################################################################## -# Create the NDCube -# ----------------- - -dynspec_cube = NDCube(data, wcs=dynspec_wcs, unit=u.W / u.m**2 / u.Hz) -print(dynspec_cube) - -############################################################################## -# The ``array_axis_physical_types`` property confirms the mapping: array axis 0 -# (rows, Y) carries frequency and array axis 1 (columns, X) carries time. - -print(dynspec_cube.array_axis_physical_types) - -############################################################################## -# Plot the full dynamic spectrum -# ------------------------------ -# Time appears on the X axis and frequency on the Y axis. - -dynspec_cube.plot() -plt.gca().set_title("Synthetic dynamic spectrum") - -############################################################################## -# Crop a useful time-frequency window -# ----------------------------------- -# ``crop_by_values`` accepts world-coordinate `~astropy.units.Quantity` objects -# in world-axis order, which is ``(time, frequency)`` for this WCS. -# Pass ``None`` to leave an axis unconstrained. -# The method returns the smallest pixel bounding box that spans the requested -# range, so the outermost channels may lie just outside the nominal bounds. - -windowed = dynspec_cube.crop_by_values( - [200 * u.s, 10e6 * u.Hz], - [800 * u.s, 500e6 * u.Hz], -) -print("Windowed shape (200-800 s, 10-500 MHz):", windowed.shape) - -windowed.plot() -plt.gca().set_title("Windowed: 200-800 s, 10-500 MHz") - -############################################################################## -# Rebin frequency channels -# ------------------------ -# ``rebin`` bins contiguous pixels together. The bin shape follows numpy -# (array) axis ordering, so ``(3, 1)`` averages triplets of frequency rows while -# leaving time samples unchanged. - -rebinned = windowed.rebin((3, 1)) -print("Frequency-rebinned shape:", rebinned.shape) - -rebinned.plot() -plt.gca().set_title("Frequency rebinned") - -############################################################################## -# Resample the time axis to a denser grid -# --------------------------------------- -# Here we use linear interpolation to add one new time sample between each pair -# of original time samples in the cropped cube. The data shape changes from -# ``(n_freq, n_time)`` to ``(n_freq, 2 * n_time - 1)`` and we build a matching -# WCS from the new lookup table. - -old_times_s = np.array([ - windowed.wcs.low_level_wcs.pixel_to_world_values(time_idx, 0)[0] - for time_idx in range(windowed.shape[1]) -]) -windowed_freqs_hz = np.array([ - windowed.wcs.low_level_wcs.pixel_to_world_values(0, freq_idx)[1] - for freq_idx in range(windowed.shape[0]) -]) -new_times_s = np.linspace(old_times_s[0], old_times_s[-1], 2 * len(old_times_s) - 1) -resampled_data = np.array([ - np.interp(new_times_s, old_times_s, frequency_row) - for frequency_row in windowed.data -]) - -resampled_wcs = build_dynspec_wcs(new_times_s, windowed_freqs_hz) -resampled = NDCube(resampled_data, wcs=resampled_wcs, unit=windowed.unit) -print("Time-resampled shape:", resampled.shape) - -resampled.plot() -plt.gca().set_title("Time resampled") - -plt.show() From a3320bf114abede8b73d658595bedea08dab687f Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 20 May 2026 16:07:17 +0200 Subject: [PATCH 3/6] Update tests to be better --- ndcube/tests/test_ndcube_dynspec.py | 329 ++++++++++++++-------------- 1 file changed, 159 insertions(+), 170 deletions(-) diff --git a/ndcube/tests/test_ndcube_dynspec.py b/ndcube/tests/test_ndcube_dynspec.py index a42b1b900..bc07de116 100644 --- a/ndcube/tests/test_ndcube_dynspec.py +++ b/ndcube/tests/test_ndcube_dynspec.py @@ -1,178 +1,167 @@ """ -Tests for NDCube with dynamic spectrum WCS (frequency x time). - -Convention throughout: array shape (n_freq, n_time) so that when plotted -as a 2D image frequency varies along rows (Y axis) and time along columns -(X axis), matching standard radio astronomy dynamic spectrum displays. - -WCS pixel axis ordering (reversed from array): - pixel axis 0 -> time (array axis 1, X axis) - pixel axis 1 -> freq (array axis 0, Y axis) - -world_axis_units = ('s', 'Hz') and pixel_to_world_values(p_time, p_freq) -returns (time_s, freq_hz). +Tests to simulate dynamic spectrum WCSes (frequency x time). """ -import numpy as np from numpy.testing import assert_allclose import astropy.units as u from ndcube.wcs.wrappers import ResampledLowLevelWCS -# Pre-computed from fixture definitions; kept here so tests are self-documenting. -_FREQS_LOG_HZ = np.logspace(np.log10(3.992e6), np.log10(978.572e6), 16) -_TIMES_S = np.array([0.0, 14.0, 27.4, 41.1, 55.2, 67.8, 82.3, 95.9, 109.1, 122.5]) - - -class TestLinearDynspec: - """Linear Scale gWCS: 14 s/pixel time, 1 MHz/pixel freq.""" - - def test_array_axis_physical_types(self, ndcube_gwcs_2d_t_f_linear): - types = ndcube_gwcs_2d_t_f_linear.array_axis_physical_types - assert 'em.freq' in types[0] # array axis 0 = rows = Y axis - assert 'time' in types[1] # array axis 1 = cols = X axis - - def test_pixel_to_world(self, ndcube_gwcs_2d_t_f_linear): - # pixel_to_world_values(time_pixel, freq_pixel) -> (time_s, freq_hz) - time, freq = ndcube_gwcs_2d_t_f_linear.wcs.low_level_wcs.pixel_to_world_values(3, 2) - assert_allclose(time, 42.0) # 3 * 14 s/pixel - assert_allclose(freq, 2e6) # 2 * 1 MHz/pixel - - def test_world_to_pixel(self, ndcube_gwcs_2d_t_f_linear): - # world_to_pixel_values(time_s, freq_hz) -> (time_pixel, freq_pixel) - pix_t, pix_f = ndcube_gwcs_2d_t_f_linear.wcs.low_level_wcs.world_to_pixel_values(28.0, 4e6) - assert_allclose(pix_t, 2.0) - assert_allclose(pix_f, 4.0) - - def test_rebin_freq_shape(self, ndcube_gwcs_2d_t_f_linear): - # rebin(2, 1): bin 2 freq rows, keep time cols -> (8, 10) - assert ndcube_gwcs_2d_t_f_linear.rebin((2, 1)).shape == (8, 10) - - def test_rebin_freq_wcs(self, ndcube_gwcs_2d_t_f_linear): - rebinned = ndcube_gwcs_2d_t_f_linear.rebin((2, 1)) - _, freq0 = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) - assert_allclose(freq0, 0.5e6) # midpoint of freq pixels 0 and 1 - - def test_rebin_time_shape(self, ndcube_gwcs_2d_t_f_linear): - # rebin(1, 2): keep freq rows, bin 2 time cols -> (16, 5) - assert ndcube_gwcs_2d_t_f_linear.rebin((1, 2)).shape == (16, 5) - - def test_rebin_time_wcs(self, ndcube_gwcs_2d_t_f_linear): - rebinned = ndcube_gwcs_2d_t_f_linear.rebin((1, 2)) - time0, _ = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) - assert_allclose(time0, 7.0) # midpoint of 0 and 14 s - - def test_rebin_wcs_is_resampled(self, ndcube_gwcs_2d_t_f_linear): - assert isinstance(ndcube_gwcs_2d_t_f_linear.rebin((2, 2)).wcs.low_level_wcs, - ResampledLowLevelWCS) - - def test_crop_by_freq_shape(self, ndcube_gwcs_2d_t_f_linear): - # world order: (time, freq); freq crop reduces rows - # 3–7 MHz = freq pixels 3,4,5,6,7 -> 5 freq rows - cropped = ndcube_gwcs_2d_t_f_linear.crop_by_values([None, 3e6 * u.Hz], - [None, 7e6 * u.Hz]) - assert cropped.shape == (5, 10) - - def test_crop_by_time_shape(self, ndcube_gwcs_2d_t_f_linear): - # time crop reduces cols - # 14–56 s = time pixels 1,2,3,4 -> 4 time cols - cropped = ndcube_gwcs_2d_t_f_linear.crop_by_values([14 * u.s, None], - [56 * u.s, None]) - assert cropped.shape == (16, 4) - - -class TestLogDynspec: - """Log-spaced Tabular1D gWCS: synthetic metric-range frequency, irregular time.""" - - def test_array_axis_physical_types(self, ndcube_gwcs_2d_t_f_log): - types = ndcube_gwcs_2d_t_f_log.array_axis_physical_types - assert 'em.freq' in types[0] # array axis 0 = rows = Y axis - assert 'time' in types[1] # array axis 1 = cols = X axis - - def test_world_axis_units(self, ndcube_gwcs_2d_t_f_log): - units = ndcube_gwcs_2d_t_f_log.wcs.world_axis_units - assert units[0] == 's' # world axis 0 = time - assert units[1] == 'Hz' # world axis 1 = freq - - def test_pixel_to_world_origin(self, ndcube_gwcs_2d_t_f_log): - # pixel_to_world_values(time_pixel, freq_pixel) -> (time_s, freq_hz) - time, freq = ndcube_gwcs_2d_t_f_log.wcs.low_level_wcs.pixel_to_world_values(0, 0) - assert_allclose(time, _TIMES_S[0]) - assert_allclose(freq, _FREQS_LOG_HZ[0], rtol=1e-6) - - def test_pixel_to_world_last(self, ndcube_gwcs_2d_t_f_log): - time, freq = ndcube_gwcs_2d_t_f_log.wcs.low_level_wcs.pixel_to_world_values(9, 15) - assert_allclose(time, _TIMES_S[9]) - assert_allclose(freq, _FREQS_LOG_HZ[15], rtol=1e-6) - - def test_world_to_pixel_roundtrip(self, ndcube_gwcs_2d_t_f_log): - # world_to_pixel_values(time_s, freq_hz) -> (time_pixel, freq_pixel) - pix_t, pix_f = ndcube_gwcs_2d_t_f_log.wcs.low_level_wcs.world_to_pixel_values( - _TIMES_S[3], _FREQS_LOG_HZ[7]) - assert_allclose(pix_t, 3.0, atol=1e-10) - assert_allclose(pix_f, 7.0, atol=1e-10) - - def test_rebin_freq_shape(self, ndcube_gwcs_2d_t_f_log): - # rebin(2, 1): bin 2 freq rows -> (8, 10) - assert ndcube_gwcs_2d_t_f_log.rebin((2, 1)).shape == (8, 10) - - def test_rebin_freq_wcs_midpoint(self, ndcube_gwcs_2d_t_f_log): - # ResampledLowLevelWCS shifts pixel centres by 0.5; Tabular1D returns - # the linearly interpolated value at the midpoint of the binned pixels. - rebinned = ndcube_gwcs_2d_t_f_log.rebin((2, 1)) - _, freq0 = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) - expected = (_FREQS_LOG_HZ[0] + _FREQS_LOG_HZ[1]) / 2 - assert_allclose(freq0, expected, rtol=1e-6) - - def test_rebin_time_shape(self, ndcube_gwcs_2d_t_f_log): - # rebin(1, 2): bin 2 time cols -> (16, 5) - assert ndcube_gwcs_2d_t_f_log.rebin((1, 2)).shape == (16, 5) - - def test_rebin_time_wcs_midpoint(self, ndcube_gwcs_2d_t_f_log): - rebinned = ndcube_gwcs_2d_t_f_log.rebin((1, 2)) - time0, _ = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) - expected = (_TIMES_S[0] + _TIMES_S[1]) / 2 - assert_allclose(time0, expected, rtol=1e-6) - - def test_rebin_wcs_is_resampled(self, ndcube_gwcs_2d_t_f_log): - assert isinstance(ndcube_gwcs_2d_t_f_log.rebin((2, 2)).wcs.low_level_wcs, - ResampledLowLevelWCS) - - def test_crop_by_freq_shape(self, ndcube_gwcs_2d_t_f_log): - # world order: (time, freq); freq crop reduces rows - # 10–100 MHz selects 8 freq rows (bounding box including boundary pixels) - cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( - [None, 10e6 * u.Hz], [None, 100e6 * u.Hz]) - assert cropped.shape == (8, 10) - - def test_crop_by_freq_bounds(self, ndcube_gwcs_2d_t_f_log): - # Crop returns the smallest pixel bounding box spanning the world range. - # First and last pixels may fall outside [lo, hi]; the full range is covered. - lo, hi = 10e6, 100e6 - cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( - [None, lo * u.Hz], [None, hi * u.Hz]) - freqs = [cropped.wcs.low_level_wcs.pixel_to_world_values(0, i)[1] - for i in range(cropped.shape[0])] - assert freqs[0] <= lo # first channel at or below lower bound - assert freqs[-1] >= hi # last channel at or above upper bound - - def test_crop_by_time_shape(self, ndcube_gwcs_2d_t_f_log): - # time crop reduces cols; 20–80 s spans 6 time steps (14.0..82.3 s) - cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( - [20 * u.s, None], [80 * u.s, None]) - assert cropped.shape == (16, 6) - - def test_crop_by_time_bounds(self, ndcube_gwcs_2d_t_f_log): - lo, hi = 20.0, 80.0 - cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( - [lo * u.s, None], [hi * u.s, None]) - times = [cropped.wcs.low_level_wcs.pixel_to_world_values(i, 0)[0] - for i in range(cropped.shape[1])] - assert times[0] <= lo - assert times[-1] >= hi - - def test_crop_by_freq_and_time(self, ndcube_gwcs_2d_t_f_log): - # world order (time, freq) - cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( - [20 * u.s, 10e6 * u.Hz], [80 * u.s, 100e6 * u.Hz]) - assert cropped.shape == (8, 6) + +def _world_at(cube, time_pixel, freq_pixel): + return cube.wcs.low_level_wcs.pixel_to_world_values(time_pixel, freq_pixel) + + +def test_linear_dynspec_array_axis_physical_types(ndcube_gwcs_2d_t_f_linear): + types = ndcube_gwcs_2d_t_f_linear.array_axis_physical_types + assert "em.freq" in types[0] + assert "time" in types[1] + + +def test_linear_dynspec_pixel_to_world(ndcube_gwcs_2d_t_f_linear): + time, freq = ndcube_gwcs_2d_t_f_linear.wcs.low_level_wcs.pixel_to_world_values(3, 2) + assert_allclose(time, 42.0) + assert_allclose(freq, 2e6) + + +def test_linear_dynspec_world_to_pixel(ndcube_gwcs_2d_t_f_linear): + pix_t, pix_f = ndcube_gwcs_2d_t_f_linear.wcs.low_level_wcs.world_to_pixel_values(28.0, 4e6) + assert_allclose(pix_t, 2.0) + assert_allclose(pix_f, 4.0) + + +def test_linear_dynspec_rebin_freq_shape(ndcube_gwcs_2d_t_f_linear): + assert ndcube_gwcs_2d_t_f_linear.rebin((2, 1)).shape == (8, 10) + + +def test_linear_dynspec_rebin_freq_wcs(ndcube_gwcs_2d_t_f_linear): + rebinned = ndcube_gwcs_2d_t_f_linear.rebin((2, 1)) + _, freq0 = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) + assert_allclose(freq0, 0.5e6) + + +def test_linear_dynspec_rebin_time_shape(ndcube_gwcs_2d_t_f_linear): + assert ndcube_gwcs_2d_t_f_linear.rebin((1, 2)).shape == (16, 5) + + +def test_linear_dynspec_rebin_time_wcs(ndcube_gwcs_2d_t_f_linear): + rebinned = ndcube_gwcs_2d_t_f_linear.rebin((1, 2)) + time0, _ = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) + assert_allclose(time0, 7.0) + + +def test_linear_dynspec_rebin_wcs_is_resampled(ndcube_gwcs_2d_t_f_linear): + assert isinstance(ndcube_gwcs_2d_t_f_linear.rebin((2, 2)).wcs.low_level_wcs, + ResampledLowLevelWCS) + + +def test_linear_dynspec_crop_by_freq_shape(ndcube_gwcs_2d_t_f_linear): + cropped = ndcube_gwcs_2d_t_f_linear.crop_by_values([None, 3e6 * u.Hz], + [None, 7e6 * u.Hz]) + + assert cropped.shape == (5, 10) + + +def test_linear_dynspec_crop_by_time_shape(ndcube_gwcs_2d_t_f_linear): + cropped = ndcube_gwcs_2d_t_f_linear.crop_by_values([14 * u.s, None], + [56 * u.s, None]) + assert cropped.shape == (16, 4) + + +def test_log_dynspec_array_axis_physical_types(ndcube_gwcs_2d_t_f_log): + types = ndcube_gwcs_2d_t_f_log.array_axis_physical_types + assert "em.freq" in types[0] + assert "time" in types[1] + + +def test_log_dynspec_world_axis_units(ndcube_gwcs_2d_t_f_log): + assert ndcube_gwcs_2d_t_f_log.wcs.world_axis_units == ("s", "Hz") + + +def test_log_dynspec_pixel_to_world_origin(ndcube_gwcs_2d_t_f_log): + time, freq = ndcube_gwcs_2d_t_f_log.wcs.low_level_wcs.pixel_to_world_values(0, 0) + assert_allclose(time, 0.0) + assert_allclose(freq, 3.992e6, rtol=1e-6) + + +def test_log_dynspec_pixel_to_world_last(ndcube_gwcs_2d_t_f_log): + time, freq = ndcube_gwcs_2d_t_f_log.wcs.low_level_wcs.pixel_to_world_values(9, 15) + assert_allclose(time, 122.5) + assert_allclose(freq, 978.572e6, rtol=1e-6) + + +def test_log_dynspec_world_to_pixel_roundtrip(ndcube_gwcs_2d_t_f_log): + time, freq = _world_at(ndcube_gwcs_2d_t_f_log, 3, 7) + pix_t, pix_f = ndcube_gwcs_2d_t_f_log.wcs.low_level_wcs.world_to_pixel_values( + time, freq) + assert_allclose(pix_t, 3.0, atol=1e-10) + assert_allclose(pix_f, 7.0, atol=1e-10) + + +def test_log_dynspec_rebin_freq_shape(ndcube_gwcs_2d_t_f_log): + assert ndcube_gwcs_2d_t_f_log.rebin((2, 1)).shape == (8, 10) + + +def test_log_dynspec_rebin_freq_wcs_midpoint(ndcube_gwcs_2d_t_f_log): + rebinned = ndcube_gwcs_2d_t_f_log.rebin((2, 1)) + _, freq0 = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) + _, freq_left = _world_at(ndcube_gwcs_2d_t_f_log, 0, 0) + _, freq_right = _world_at(ndcube_gwcs_2d_t_f_log, 0, 1) + expected = (freq_left + freq_right) / 2 + assert_allclose(freq0, expected, rtol=1e-6) + + +def test_log_dynspec_rebin_time_shape(ndcube_gwcs_2d_t_f_log): + assert ndcube_gwcs_2d_t_f_log.rebin((1, 2)).shape == (16, 5) + + +def test_log_dynspec_rebin_time_wcs_midpoint(ndcube_gwcs_2d_t_f_log): + rebinned = ndcube_gwcs_2d_t_f_log.rebin((1, 2)) + time0, _ = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) + time_left, _ = _world_at(ndcube_gwcs_2d_t_f_log, 0, 0) + time_right, _ = _world_at(ndcube_gwcs_2d_t_f_log, 1, 0) + expected = (time_left + time_right) / 2 + assert_allclose(time0, expected, rtol=1e-6) + + +def test_log_dynspec_rebin_wcs_is_resampled(ndcube_gwcs_2d_t_f_log): + assert isinstance(ndcube_gwcs_2d_t_f_log.rebin((2, 2)).wcs.low_level_wcs, + ResampledLowLevelWCS) + + +def test_log_dynspec_crop_by_freq_shape(ndcube_gwcs_2d_t_f_log): + cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( + [None, 10e6 * u.Hz], [None, 100e6 * u.Hz]) + assert cropped.shape == (8, 10) + + +def test_log_dynspec_crop_by_freq_bounds(ndcube_gwcs_2d_t_f_log): + lo, hi = 10e6, 100e6 + cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( + [None, lo * u.Hz], [None, hi * u.Hz]) + freqs = [cropped.wcs.low_level_wcs.pixel_to_world_values(0, i)[1] + for i in range(cropped.shape[0])] + assert freqs[0] <= lo + assert freqs[-1] >= hi + + +def test_log_dynspec_crop_by_time_shape(ndcube_gwcs_2d_t_f_log): + cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( + [20 * u.s, None], [80 * u.s, None]) + assert cropped.shape == (16, 6) + + +def test_log_dynspec_crop_by_time_bounds(ndcube_gwcs_2d_t_f_log): + lo, hi = 20.0, 80.0 + cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( + [lo * u.s, None], [hi * u.s, None]) + times = [cropped.wcs.low_level_wcs.pixel_to_world_values(i, 0)[0] + for i in range(cropped.shape[1])] + assert times[0] <= lo + assert times[-1] >= hi + + +def test_log_dynspec_crop_by_freq_and_time(ndcube_gwcs_2d_t_f_log): + cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( + [20 * u.s, 10e6 * u.Hz], [80 * u.s, 100e6 * u.Hz]) + assert cropped.shape == (8, 6) From c2aef6891b48d9e9eedc68e53a4d5d21fd36252d Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 20 May 2026 16:13:50 +0200 Subject: [PATCH 4/6] MORE FIXES --- ndcube/conftest.py | 15 -- ndcube/tests/test_ndcube_dynspec.py | 192 +++++++++------------ ndcube/tests/test_ndcube_slice_and_crop.py | 19 +- ndcube/utils/cube.py | 7 +- 4 files changed, 85 insertions(+), 148 deletions(-) diff --git a/ndcube/conftest.py b/ndcube/conftest.py index 605c93a50..24c86b0bb 100644 --- a/ndcube/conftest.py +++ b/ndcube/conftest.py @@ -230,12 +230,6 @@ def gwcs_2d_t_f_linear(): """ 2D gWCS for a dynamic spectrum: uniform time (array axis 1 / X) and linear frequency (array axis 0 / Y). - - Convention: array shape (n_freq, n_time) so frequency varies along rows - (Y axis) and time along columns (X axis) when plotted. - - - Pixel axis 0 -> time (14 s/pixel via Scale) - - Pixel axis 1 -> frequency (1 MHz/pixel via Scale) """ time_model = models.Scale(14.0) freq_model = models.Scale(1e6) @@ -259,15 +253,6 @@ def gwcs_2d_t_f_log(): """ 2D gWCS for a dynamic spectrum: irregularly-spaced time (array axis 1 / X) and log-spaced frequency (array axis 0 / Y) via Tabular1D lookup tables. - - Synthetic metric-range grid (~4-978 MHz, 16 channels, ~14 s irregular - cadence over 10 time steps). - - Convention: array shape (n_freq, n_time) so frequency varies along rows - (Y axis) and time along columns (X axis) when plotted. - - - Pixel axis 0 -> time (Tabular1D, irregular seconds since reference) - - Pixel axis 1 -> frequency (Tabular1D, log-spaced 3.992-978.572 MHz) """ times_s = np.array([0.0, 14.0, 27.4, 41.1, 55.2, 67.8, 82.3, 95.9, 109.1, 122.5]) freqs_hz = np.logspace(np.log10(3.992e6), np.log10(978.572e6), 16) diff --git a/ndcube/tests/test_ndcube_dynspec.py b/ndcube/tests/test_ndcube_dynspec.py index bc07de116..9de9be2f9 100644 --- a/ndcube/tests/test_ndcube_dynspec.py +++ b/ndcube/tests/test_ndcube_dynspec.py @@ -1,6 +1,7 @@ """ Tests to simulate dynamic spectrum WCSes (frequency x time). """ +import pytest from numpy.testing import assert_allclose import astropy.units as u @@ -12,8 +13,12 @@ def _world_at(cube, time_pixel, freq_pixel): return cube.wcs.low_level_wcs.pixel_to_world_values(time_pixel, freq_pixel) -def test_linear_dynspec_array_axis_physical_types(ndcube_gwcs_2d_t_f_linear): - types = ndcube_gwcs_2d_t_f_linear.array_axis_physical_types +@pytest.mark.parametrize("ndc", [ + "ndcube_gwcs_2d_t_f_linear", + "ndcube_gwcs_2d_t_f_log", +], indirect=True) +def test_dynspec_array_axis_physical_types(ndc): + types = ndc.array_axis_physical_types assert "em.freq" in types[0] assert "time" in types[1] @@ -30,64 +35,47 @@ def test_linear_dynspec_world_to_pixel(ndcube_gwcs_2d_t_f_linear): assert_allclose(pix_f, 4.0) -def test_linear_dynspec_rebin_freq_shape(ndcube_gwcs_2d_t_f_linear): - assert ndcube_gwcs_2d_t_f_linear.rebin((2, 1)).shape == (8, 10) +@pytest.mark.parametrize(("bin_shape", "expected_shape", "expected_time", "expected_freq"), [ + ((2, 1), (8, 10), 0.0, 0.5e6), + ((1, 2), (16, 5), 7.0, 0.0), +]) +def test_linear_dynspec_rebin_wcs(ndcube_gwcs_2d_t_f_linear, bin_shape, + expected_shape, expected_time, expected_freq): + rebinned = ndcube_gwcs_2d_t_f_linear.rebin(bin_shape) + time0, freq0 = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) + assert rebinned.shape == expected_shape + assert isinstance(rebinned.wcs.low_level_wcs, ResampledLowLevelWCS) + assert_allclose(time0, expected_time) + assert_allclose(freq0, expected_freq) -def test_linear_dynspec_rebin_freq_wcs(ndcube_gwcs_2d_t_f_linear): - rebinned = ndcube_gwcs_2d_t_f_linear.rebin((2, 1)) - _, freq0 = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) - assert_allclose(freq0, 0.5e6) - -def test_linear_dynspec_rebin_time_shape(ndcube_gwcs_2d_t_f_linear): - assert ndcube_gwcs_2d_t_f_linear.rebin((1, 2)).shape == (16, 5) - - -def test_linear_dynspec_rebin_time_wcs(ndcube_gwcs_2d_t_f_linear): - rebinned = ndcube_gwcs_2d_t_f_linear.rebin((1, 2)) - time0, _ = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) - assert_allclose(time0, 7.0) - - -def test_linear_dynspec_rebin_wcs_is_resampled(ndcube_gwcs_2d_t_f_linear): - assert isinstance(ndcube_gwcs_2d_t_f_linear.rebin((2, 2)).wcs.low_level_wcs, - ResampledLowLevelWCS) - - -def test_linear_dynspec_crop_by_freq_shape(ndcube_gwcs_2d_t_f_linear): - cropped = ndcube_gwcs_2d_t_f_linear.crop_by_values([None, 3e6 * u.Hz], - [None, 7e6 * u.Hz]) - - assert cropped.shape == (5, 10) - - -def test_linear_dynspec_crop_by_time_shape(ndcube_gwcs_2d_t_f_linear): - cropped = ndcube_gwcs_2d_t_f_linear.crop_by_values([14 * u.s, None], - [56 * u.s, None]) - assert cropped.shape == (16, 4) - - -def test_log_dynspec_array_axis_physical_types(ndcube_gwcs_2d_t_f_log): - types = ndcube_gwcs_2d_t_f_log.array_axis_physical_types - assert "em.freq" in types[0] - assert "time" in types[1] +@pytest.mark.parametrize(("lower_corner", "upper_corner", "expected_shape"), [ + ([None, 3e6 * u.Hz], [None, 7e6 * u.Hz], (5, 10)), + ([14 * u.s, None], [56 * u.s, None], (16, 4)), +]) +def test_linear_dynspec_crop_by_values_shape(ndcube_gwcs_2d_t_f_linear, + lower_corner, upper_corner, + expected_shape): + cropped = ndcube_gwcs_2d_t_f_linear.crop_by_values(lower_corner, upper_corner) + assert cropped.shape == expected_shape def test_log_dynspec_world_axis_units(ndcube_gwcs_2d_t_f_log): assert ndcube_gwcs_2d_t_f_log.wcs.world_axis_units == ("s", "Hz") -def test_log_dynspec_pixel_to_world_origin(ndcube_gwcs_2d_t_f_log): - time, freq = ndcube_gwcs_2d_t_f_log.wcs.low_level_wcs.pixel_to_world_values(0, 0) - assert_allclose(time, 0.0) - assert_allclose(freq, 3.992e6, rtol=1e-6) - - -def test_log_dynspec_pixel_to_world_last(ndcube_gwcs_2d_t_f_log): - time, freq = ndcube_gwcs_2d_t_f_log.wcs.low_level_wcs.pixel_to_world_values(9, 15) - assert_allclose(time, 122.5) - assert_allclose(freq, 978.572e6, rtol=1e-6) +@pytest.mark.parametrize(("time_pixel", "freq_pixel", "expected_time", "expected_freq"), [ + (0, 0, 0.0, 3.992e6), + (9, 15, 122.5, 978.572e6), +]) +def test_log_dynspec_pixel_to_world_endpoints(ndcube_gwcs_2d_t_f_log, + time_pixel, freq_pixel, + expected_time, expected_freq): + time, freq = ndcube_gwcs_2d_t_f_log.wcs.low_level_wcs.pixel_to_world_values( + time_pixel, freq_pixel) + assert_allclose(time, expected_time) + assert_allclose(freq, expected_freq, rtol=1e-6) def test_log_dynspec_world_to_pixel_roundtrip(ndcube_gwcs_2d_t_f_log): @@ -98,67 +86,47 @@ def test_log_dynspec_world_to_pixel_roundtrip(ndcube_gwcs_2d_t_f_log): assert_allclose(pix_f, 7.0, atol=1e-10) -def test_log_dynspec_rebin_freq_shape(ndcube_gwcs_2d_t_f_log): - assert ndcube_gwcs_2d_t_f_log.rebin((2, 1)).shape == (8, 10) - - -def test_log_dynspec_rebin_freq_wcs_midpoint(ndcube_gwcs_2d_t_f_log): - rebinned = ndcube_gwcs_2d_t_f_log.rebin((2, 1)) - _, freq0 = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) - _, freq_left = _world_at(ndcube_gwcs_2d_t_f_log, 0, 0) - _, freq_right = _world_at(ndcube_gwcs_2d_t_f_log, 0, 1) - expected = (freq_left + freq_right) / 2 - assert_allclose(freq0, expected, rtol=1e-6) - - -def test_log_dynspec_rebin_time_shape(ndcube_gwcs_2d_t_f_log): - assert ndcube_gwcs_2d_t_f_log.rebin((1, 2)).shape == (16, 5) - - -def test_log_dynspec_rebin_time_wcs_midpoint(ndcube_gwcs_2d_t_f_log): - rebinned = ndcube_gwcs_2d_t_f_log.rebin((1, 2)) - time0, _ = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) - time_left, _ = _world_at(ndcube_gwcs_2d_t_f_log, 0, 0) - time_right, _ = _world_at(ndcube_gwcs_2d_t_f_log, 1, 0) - expected = (time_left + time_right) / 2 - assert_allclose(time0, expected, rtol=1e-6) - - -def test_log_dynspec_rebin_wcs_is_resampled(ndcube_gwcs_2d_t_f_log): - assert isinstance(ndcube_gwcs_2d_t_f_log.rebin((2, 2)).wcs.low_level_wcs, - ResampledLowLevelWCS) - - -def test_log_dynspec_crop_by_freq_shape(ndcube_gwcs_2d_t_f_log): - cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( - [None, 10e6 * u.Hz], [None, 100e6 * u.Hz]) - assert cropped.shape == (8, 10) - - -def test_log_dynspec_crop_by_freq_bounds(ndcube_gwcs_2d_t_f_log): - lo, hi = 10e6, 100e6 - cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( - [None, lo * u.Hz], [None, hi * u.Hz]) - freqs = [cropped.wcs.low_level_wcs.pixel_to_world_values(0, i)[1] - for i in range(cropped.shape[0])] - assert freqs[0] <= lo - assert freqs[-1] >= hi - - -def test_log_dynspec_crop_by_time_shape(ndcube_gwcs_2d_t_f_log): - cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( - [20 * u.s, None], [80 * u.s, None]) - assert cropped.shape == (16, 6) - - -def test_log_dynspec_crop_by_time_bounds(ndcube_gwcs_2d_t_f_log): - lo, hi = 20.0, 80.0 - cropped = ndcube_gwcs_2d_t_f_log.crop_by_values( - [lo * u.s, None], [hi * u.s, None]) - times = [cropped.wcs.low_level_wcs.pixel_to_world_values(i, 0)[0] - for i in range(cropped.shape[1])] - assert times[0] <= lo - assert times[-1] >= hi +@pytest.mark.parametrize(("bin_shape", "expected_shape", "axis"), [ + ((2, 1), (8, 10), "freq"), + ((1, 2), (16, 5), "time"), +]) +def test_log_dynspec_rebin_wcs_midpoint(ndcube_gwcs_2d_t_f_log, bin_shape, + expected_shape, axis): + rebinned = ndcube_gwcs_2d_t_f_log.rebin(bin_shape) + time0, freq0 = rebinned.wcs.low_level_wcs.pixel_to_world_values(0, 0) + + assert rebinned.shape == expected_shape + assert isinstance(rebinned.wcs.low_level_wcs, ResampledLowLevelWCS) + if axis == "freq": + _, freq_left = _world_at(ndcube_gwcs_2d_t_f_log, 0, 0) + _, freq_right = _world_at(ndcube_gwcs_2d_t_f_log, 0, 1) + assert_allclose(freq0, (freq_left + freq_right) / 2, rtol=1e-6) + else: + time_left, _ = _world_at(ndcube_gwcs_2d_t_f_log, 0, 0) + time_right, _ = _world_at(ndcube_gwcs_2d_t_f_log, 1, 0) + assert_allclose(time0, (time_left + time_right) / 2, rtol=1e-6) + + +@pytest.mark.parametrize(("lower_corner", "upper_corner", "expected_shape", + "axis", "bounds"), [ + ([None, 10e6 * u.Hz], [None, 100e6 * u.Hz], (8, 10), "freq", (10e6, 100e6)), + ([20 * u.s, None], [80 * u.s, None], (16, 6), "time", (20.0, 80.0)), +]) +def test_log_dynspec_crop_by_values_single_axis(ndcube_gwcs_2d_t_f_log, + lower_corner, upper_corner, + expected_shape, axis, bounds): + cropped = ndcube_gwcs_2d_t_f_log.crop_by_values(lower_corner, upper_corner) + assert cropped.shape == expected_shape + + if axis == "freq": + values = [cropped.wcs.low_level_wcs.pixel_to_world_values(0, i)[1] + for i in range(cropped.shape[0])] + else: + values = [cropped.wcs.low_level_wcs.pixel_to_world_values(i, 0)[0] + for i in range(cropped.shape[1])] + + assert values[0] <= bounds[0] + assert values[-1] >= bounds[1] def test_log_dynspec_crop_by_freq_and_time(ndcube_gwcs_2d_t_f_log): diff --git a/ndcube/tests/test_ndcube_slice_and_crop.py b/ndcube/tests/test_ndcube_slice_and_crop.py index f23bfb577..56b7cd4a4 100644 --- a/ndcube/tests/test_ndcube_slice_and_crop.py +++ b/ndcube/tests/test_ndcube_slice_and_crop.py @@ -620,11 +620,7 @@ def test_crop_all_points_beyond_cube_extent_error(points): def test_crop_by_values_quantity_table_coordinate(): # Regression: QuantityTableCoordinate-based WCS raised # "High Level objects are not supported with the native API" because - # world_to_pixel_values was called with Quantity objects. - # Fixed in ndcube/utils/cube.py by stripping .value before that call. - # - # ExtraCoords adds freq on array axis 0, time on array axis 1. - # ExtraCoords.cube_wcs returns world order (Hz, s). + # world_to_pixel_values received Quantity objects instead of plain values. freqs_hz = np.logspace(np.log10(4e6), np.log10(200e6), 16) times_s = np.linspace(0, 140, 10) wcs2d = astropy.wcs.WCS(naxis=2) @@ -637,19 +633,6 @@ def test_crop_by_values_quantity_table_coordinate(): cube.extra_coords.add("frequency", (0,), freqs_hz * u.Hz) cube.extra_coords.add("time", (1,), times_s * u.s) - # freq-only crop: world order (Hz, s) -> freq at index 0 - cropped = cube.crop_by_values([10e6 * u.Hz, None], [100e6 * u.Hz, None], - wcs=cube.extra_coords) - assert cropped.shape == (10, 10) - np.testing.assert_array_equal(cropped.data, data[3:13, :]) - - # time-only crop: time at index 1 - cropped = cube.crop_by_values([None, 20 * u.s], [None, 80 * u.s], - wcs=cube.extra_coords) - assert cropped.shape == (16, 5) - np.testing.assert_array_equal(cropped.data, data[:, 1:6]) - - # both axes cropped = cube.crop_by_values([10e6 * u.Hz, 20 * u.s], [100e6 * u.Hz, 80 * u.s], wcs=cube.extra_coords) assert cropped.shape == (10, 5) diff --git a/ndcube/utils/cube.py b/ndcube/utils/cube.py index 0b3788848..d1c188a13 100644 --- a/ndcube/utils/cube.py +++ b/ndcube/utils/cube.py @@ -182,12 +182,13 @@ def get_crop_item_from_points(points, wcs, crop_by_values, keepdims, original_sh # in the list corresponding to its axis. # Use the to_pixel methods to preserve fractional indices for future rounding. if crop_by_values: - # world_to_pixel_values is the APE14 low-level API and expects plain - # floats, not Quantity objects. Strip units here; the values are + # world_to_pixel_values is APE14 low-level API and expects plain + # floats, not Quantity objects. So we need to strip units here; the values are # already in the correct units because _get_crop_by_values_item called # .to(wcs.world_axis_units[j]) before reaching this point. + # # Passing Quantity objects raises TypeError in gWCS when the WCS's - # declared high-level type is itself Quantity (e.g. a WCS built from + # declared high-level type is itself Quantity (e.g., a WCS built from # QuantityTableCoordinate), because gWCS cannot distinguish such inputs # from an accidental high-level API call. stripped_point = [p.value if hasattr(p, "value") else p for p in sliced_point] From ef02a10653522ac59cc95dfa13c95bd92e07cde3 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 20 May 2026 16:24:14 +0200 Subject: [PATCH 5/6] Add ignore for runtimewarning for astropy and angles in wcs --- pytest.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pytest.ini b/pytest.ini index 8d042b270..a1c2f7bff 100644 --- a/pytest.ini +++ b/pytest.ini @@ -58,3 +58,5 @@ filterwarnings = ignore:Animating a NDCube does not support transposing the array. The world axes may not display as expected because the array will not be transposed:UserWarning # This is raised by the Windows and mac os build for visualization.rst ignore:FigureCanvasAgg is non-interactive, and thus cannot be shown:UserWarning + # wcsaxes/formatter_locator.py hates angles + ignore:.*invalid value encountered in do_format.*:RuntimeWarning From 7bbce5b96866cff52a42957892c63fbdf4cbbfd9 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 20 May 2026 16:38:50 +0200 Subject: [PATCH 6/6] Move figure test env forward --- .circleci/config.yml | 12 ++++---- ...109_ft_261_astropy_720_animators_124.json} | 0 ...dev_ft_2143_astropy_dev_animators_dev.json | 29 +++++++++++++++++++ ..._dev_ft_261_astropy_dev_animators_dev.json | 29 ------------------- tox.ini | 4 +-- 5 files changed, 37 insertions(+), 37 deletions(-) rename ndcube/visualization/tests/{figure_hashes_mpl_3100_ft_261_astropy_710_animators_124.json => figure_hashes_mpl_3109_ft_261_astropy_720_animators_124.json} (100%) create mode 100644 ndcube/visualization/tests/figure_hashes_mpl_dev_ft_2143_astropy_dev_animators_dev.json delete mode 100644 ndcube/visualization/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json diff --git a/.circleci/config.yml b/.circleci/config.yml index 28c860eef..b3604c29f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -32,7 +32,7 @@ jobs: jobname: type: string docker: - - image: cimg/python:3.12 + - image: cimg/python:3.14 environment: TOXENV=<< parameters.jobname >> steps: @@ -57,7 +57,7 @@ jobs: jobname: type: string docker: - - image: cimg/python:3.12 + - image: cimg/python:3.14 environment: TOXENV: << parameters.jobname >> GIT_SSH_COMMAND: ssh -i ~/.ssh/id_rsa_b1c8b094a8ec67162b0f18a949a6b1db @@ -99,16 +99,16 @@ workflows: matrix: parameters: jobname: - - "py312-figure" - - "py312-figure-devdeps" + - "py314-figure" + - "py314-figure-devdeps" - deploy-reference-images: name: baseline-<< matrix.jobname >> matrix: parameters: jobname: - - "py312-figure" - - "py312-figure-devdeps" + - "py314-figure" + - "py314-figure-devdeps" requires: - << matrix.jobname >> filters: diff --git a/ndcube/visualization/tests/figure_hashes_mpl_3100_ft_261_astropy_710_animators_124.json b/ndcube/visualization/tests/figure_hashes_mpl_3109_ft_261_astropy_720_animators_124.json similarity index 100% rename from ndcube/visualization/tests/figure_hashes_mpl_3100_ft_261_astropy_710_animators_124.json rename to ndcube/visualization/tests/figure_hashes_mpl_3109_ft_261_astropy_720_animators_124.json diff --git a/ndcube/visualization/tests/figure_hashes_mpl_dev_ft_2143_astropy_dev_animators_dev.json b/ndcube/visualization/tests/figure_hashes_mpl_dev_ft_2143_astropy_dev_animators_dev.json new file mode 100644 index 000000000..0172099f3 --- /dev/null +++ b/ndcube/visualization/tests/figure_hashes_mpl_dev_ft_2143_astropy_dev_animators_dev.json @@ -0,0 +1,29 @@ +{ + "ndcube.visualization.tests.test_plotting.test_plot_1D_cube": "61b743e90986d7c624d2cbd78ca78cfdd6c46e0f7726aed10af178f9f8d7d1e3", + "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[ln_lt_l_t-cslice0-kwargs0]": "ea9da0f97ceb0538ff7acd017aa63a475a2648b575c0005cd9a38693791f0092", + "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[ln_lt_l_t-cslice1-kwargs1]": "e5922ae535968af5833b5d10ebe2ed5870e01cbeccece83212bfccf6999700c7", + "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[ln_lt_l_t-cslice2-kwargs2]": "c525c9d2e51d348dca04383444710261d9b46b71b36b6af770a0cf12972aae61", + "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[ln_lt_l_t-cslice3-kwargs3]": "d318ec88e5515e61c8e53525734b1e424f58559bc2ccc7af154e3ce3f5405387", + "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[uncertainty-cslice4-kwargs4]": "855cd9c8ec77dddd092ca6d4506f9bc5bf309869e502a0256b542b2cbbccc8dd", + "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[unit_uncertainty-cslice5-kwargs5]": "e44c87f05655e7c11aa5e1947d1506033acb4cde165616118055a063ebc5c9df", + "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[mask-cslice6-kwargs6]": "ec0b8fdb355f47945d893858abd61a1fd097e7cea210148a7699a05fd3f14e2d", + "ndcube.visualization.tests.test_plotting.test_plot_2D_cube": "8dacf131e153015efc66fccf4f475d17fc9b87d2be9ca6d2f9cd01a24a2a5a18", + "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_colorbar": "378f0b34d69ba8ad4caa76df93b9fb57d1f0a9c540b6c5afe238737970e0e854", + "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_custom_axis": "8dacf131e153015efc66fccf4f475d17fc9b87d2be9ca6d2f9cd01a24a2a5a18", + "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_custom_axis_plot_axes": "f0913c14ae37080b56bc16a1ef3129b69922932c31849102c49ae4ab91b9aaca", + "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_from_slice[ln_lt_l_t-cslice0-kwargs0]": "43534b4bf0a1d901ed410188f26e29a8067ec6c0adb621e6623c3a68b8fa3afb", + "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_from_slice[ln_lt_l_t-cslice1-kwargs1]": "24f65c026fd64b24f416db69c3fa4a7607191f3f5596268c00af5794d7123aa2", + "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_from_slice[ln_lt_l_t-cslice2-kwargs2]": "520ba0ba241cbad9b042ace302c1c25c0b5b1c6d4652ee8f76dba63fcc691929", + "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_from_slice[unit_uncertainty-cslice3-kwargs3]": "43534b4bf0a1d901ed410188f26e29a8067ec6c0adb621e6623c3a68b8fa3afb", + "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_from_slice[mask-cslice4-kwargs4]": "1b9495133636c27923ac7701dcd77fec51239a6509c0e198274e40529fbca3b8", + "ndcube.visualization.tests.test_plotting.test_animate_2D_cube": "cfad6d4794b50026abea9de223ae97f3f5dfbd28b9e75a14e955a0b45fc1ecce", + "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-cslice0-kwargs0]": "628751fc2fc5cdcacf42a88e9152bbdce69a3fb6dd2f46e1e2c00d82f051843c", + "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-cslice1-kwargs1]": "61e991fb1a45005720e60faab9f0f41adad8f8ac827d3dc018c44c8d7c422207", + "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-None-kwargs2]": "5f2a07be30fcff768f458c6a2d18e6cbbadcec605af15cf2977421556bbb5bda", + "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-None-kwargs3]": "62b6fa3af726115e7f4b1579744564fdfff4efde3f5612cf651a8fd161f7661d", + "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-None-kwargs4]": "1b24cbbe4da25b222754e96a547c9f9bd1905df30bc742033babebc093dbe2a0", + "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-cslice5-kwargs5]": "d34b83c14da1348cf1d9ba20fb0c929c061d922303465daf46f6bb67f8ee2d6e", + "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-cslice6-kwargs6]": "5f2a07be30fcff768f458c6a2d18e6cbbadcec605af15cf2977421556bbb5bda", + "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[unit_uncertainty-cslice7-kwargs7]": "d34b83c14da1348cf1d9ba20fb0c929c061d922303465daf46f6bb67f8ee2d6e", + "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[mask-cslice8-kwargs8]": "86d89a4034798c01a016ec8756b353a5b8d9d9332664fd1162c8fb2dc1f201c1" +} \ No newline at end of file diff --git a/ndcube/visualization/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json b/ndcube/visualization/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json deleted file mode 100644 index c3757929f..000000000 --- a/ndcube/visualization/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "ndcube.visualization.tests.test_plotting.test_plot_1D_cube": "040edf223f40754b7a53915da165d10dad9d4456622f1f8217269679c3b22550", - "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[ln_lt_l_t-cslice0-kwargs0]": "ea89b5d1c2fdcf34ba353dffe528f5ecd5ce364a77f43a08d00a6a7f11a869f4", - "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[ln_lt_l_t-cslice1-kwargs1]": "f5d10df37509d1ba9429deb95ee29cf37dddea1a30f4c84cf2b10034c74c31bd", - "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[ln_lt_l_t-cslice2-kwargs2]": "e48765a561ea0f43f5a80d3da27fd71654b18d276cbb7617e1918843012425f9", - "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[ln_lt_l_t-cslice3-kwargs3]": "7d9c6a470077d7ea1e2a38160d9dd27e9c82e9106d8bfdcec789055e9089c8cd", - "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[uncertainty-cslice4-kwargs4]": "04083a963e79e9aaf2d1771e9a86158ef4be51c4b8cde7b6b7b85f80e1452ea9", - "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[unit_uncertainty-cslice5-kwargs5]": "53f90627c0612aac7edcca97e108069b915bc7edaf871e62ba747732510e7cb2", - "ndcube.visualization.tests.test_plotting.test_plot_1D_cube_from_slice[mask-cslice6-kwargs6]": "b888887d05f9f7654968bd59a33fd86a12111503222904bfd369475210da7abb", - "ndcube.visualization.tests.test_plotting.test_plot_2D_cube": "4b194dbc850bfd9ae983ec5f0e07e7295f537e591e61177aadca28e41d74bd98", - "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_colorbar": "8adf60402dbd71d066547a08c81757f99db4f55b85288212420cf3a70f967c3e", - "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_custom_axis": "4b194dbc850bfd9ae983ec5f0e07e7295f537e591e61177aadca28e41d74bd98", - "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_custom_axis_plot_axes": "712809a75e7d587c064d9631ed218214071a9dd2d8017d937ae73c613e4c2ab4", - "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_from_slice[ln_lt_l_t-cslice0-kwargs0]": "77b0b9a0067897786777a78974cff240a3268ed38937047c3945473e82bc4cf0", - "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_from_slice[ln_lt_l_t-cslice1-kwargs1]": "2af86ad3672ef70d51b3637325c5e7860cad26f70032b51877d45d0a9b647a44", - "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_from_slice[ln_lt_l_t-cslice2-kwargs2]": "af99b2460f2ed4a99f159fe854cdf63285eaffb55c09932f74f15f2198a7f1b1", - "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_from_slice[unit_uncertainty-cslice3-kwargs3]": "77b0b9a0067897786777a78974cff240a3268ed38937047c3945473e82bc4cf0", - "ndcube.visualization.tests.test_plotting.test_plot_2D_cube_from_slice[mask-cslice4-kwargs4]": "490f4d37a93ab800ab2eb4fa077c448bcd6ebc30d64ab374118f5a9288246fb3", - "ndcube.visualization.tests.test_plotting.test_animate_2D_cube": "f0622456d02808c9845336e7bead8f4ac22e99d2450bf6f14bb74686a45fd5ed", - "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-cslice0-kwargs0]": "47a0279e3244139f5b7126625a1aeb2bd038b3b39b38829a6b5a9129d8bba09d", - "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-cslice1-kwargs1]": "8e9303bb77aef56991d2cf470de6840cea1994dd8ffb17bec48be9428ec5f7b8", - "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-None-kwargs2]": "4ae632474865d7df8434f66560c83205597437eadb61840266c334a836e4df7d", - "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-None-kwargs3]": "6ed647bf57c788e3f8bbdb730b1efa5c77f3b4f3cc44a149c16b62ee1fd8c6e3", - "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-None-kwargs4]": "4a983ef4f55fefde7c157f0d2799084db2bc4241e64d7a1d3b2e752bbba81518", - "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-cslice5-kwargs5]": "b6acd10439bdc945b762ef9be430b5805c735e715ac4a1621d2fb834e48e91c1", - "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[ln_lt_l_t-cslice6-kwargs6]": "4ae632474865d7df8434f66560c83205597437eadb61840266c334a836e4df7d", - "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[unit_uncertainty-cslice7-kwargs7]": "b6acd10439bdc945b762ef9be430b5805c735e715ac4a1621d2fb834e48e91c1", - "ndcube.visualization.tests.test_plotting.test_animate_cube_from_slice[mask-cslice8-kwargs8]": "27db07c262fdb78bc45e9302c9c18d60e73c840d6f26e4af156355f73c3ef6e0" -} \ No newline at end of file diff --git a/tox.ini b/tox.ini index e7a843a2f..e41473dc2 100644 --- a/tox.ini +++ b/tox.ini @@ -57,9 +57,9 @@ deps = # Oldest Dependencies oldestdeps: minimum_dependencies # Figure tests need a tightly controlled environment - figure-!devdeps: astropy==7.1.0 + figure-!devdeps: astropy==7.2.0 figure-!devdeps: dask - figure-!devdeps: matplotlib==3.10.0 + figure-!devdeps: matplotlib==3.10.9 figure-!devdeps: mpl-animators==1.2.4 figure-!devdeps: scipy # The following indicates which extras_require will be installed