Skip to content

536 rewrite tests/unit/image/test_imagepy to use truth images rather than hardcoded values#546

Merged
Jan-Willem merged 25 commits intomainfrom
536-rewrite-testsunitimagetest_imagepy-to-use-truth-images-rather-than-hardcoded-values
Mar 26, 2026
Merged

536 rewrite tests/unit/image/test_imagepy to use truth images rather than hardcoded values#546
Jan-Willem merged 25 commits intomainfrom
536-rewrite-testsunitimagetest_imagepy-to-use-truth-images-rather-than-hardcoded-values

Conversation

@dmehring
Copy link
Copy Markdown
Collaborator

@dmehring dmehring commented Mar 6, 2026

No description provided.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 6, 2026

Codecov Report

❌ Patch coverage is 88.09524% with 20 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/xradio/testing/assertions.py 87.73% 20 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR rewrites the image tests in tests/unit/image/test_image.py to compare against truth datasets (stored as downloadable zarr files) rather than hardcoded expected values. It introduces a new xradio.testing module with assert_xarray_datasets_equal — a comprehensive assertion helper for comparing xarray Datasets. The PR also fixes minor consistency issues in the FITS and casacore frequency attribute schemas.

Changes:

  • Added src/xradio/testing/assertions.py with assert_xarray_datasets_equal and supporting comparison functions, plus comprehensive unit tests in tests/unit/testing/test_assertions.py.
  • Refactored tests/unit/image/test_image.py to replace hardcoded expected values and custom dict_equality/ImageBase comparison methods with the new assert_xarray_datasets_equal and _compare_attrs_dict, using downloaded truth zarr datasets.
  • Fixed frequency coordinate metadata inconsistencies: changed FITS type from "frequency" to "spectral_coord" and removed redundant units/frame keys from casacore frequency attrs; also fixed a bug where test_im.getdata() was compared to itself instead of expec_im.getdata().

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
src/xradio/testing/__init__.py New module init exporting assert_xarray_datasets_equal
src/xradio/testing/assertions.py New assertion helpers for xarray Dataset comparison
tests/unit/testing/test_assertions.py Comprehensive unit tests for the new assertion helpers
tests/unit/image/test_image.py Major rewrite replacing hardcoded values with truth dataset comparisons, lazy loading, and utility refactoring
src/xradio/image/_util/_fits/xds_from_fits.py Fix frequency type to "spectral_coord" and remove rest_frequencies
src/xradio/image/_util/_casacore/xds_from_casacore.py Remove redundant frequency attrs, dead code cleanup

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

)
return

if _is_numeric_scalar(test) and _is_numeric_scalar(true):
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

math.isclose does not handle NaN values — math.isclose(NaN, NaN) returns False. This is inconsistent with _compare_arrays which uses equal_nan=True (line 214). If both test and true are NaN scalars, the assertion will incorrectly fail. Consider adding a NaN check before math.isclose, e.g., if both values are NaN, return without error.

Suggested change
if _is_numeric_scalar(test) and _is_numeric_scalar(true):
if _is_numeric_scalar(test) and _is_numeric_scalar(true):
# Handle NaN explicitly: treat two NaNs as equal, consistent with _compare_arrays(equal_nan=True)
try:
if np.isnan(test) and np.isnan(true):
return
except (TypeError, ValueError):
# If np.isnan is not applicable, fall back to math.isclose
pass

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +98
# If obj is the detault, check for the existance of the file, if not exists(),
# download it, open it, and return the xds.
# obj: if not default, check that the object is None. If it is None, download
# the file, open it with open_image, setting obj equal to the returned xds.
# if obj is not None, this indicates the file has been downlaoded already and openend,
# so do nothing.
# if obj is default and wantreturn is False, just download the file if it doesn't exist, but don't open it or return anything.

if not os.path.exists(fname):
download(fname)
assert os.path.exists(fname), f"Cound not download {fname}"
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Several spelling errors in these comments: "detault" → "default", "existance" → "existence", "downlaoded" → "downloaded", "openend" → "opened".

Suggested change
# If obj is the detault, check for the existance of the file, if not exists(),
# download it, open it, and return the xds.
# obj: if not default, check that the object is None. If it is None, download
# the file, open it with open_image, setting obj equal to the returned xds.
# if obj is not None, this indicates the file has been downlaoded already and openend,
# so do nothing.
# if obj is default and wantreturn is False, just download the file if it doesn't exist, but don't open it or return anything.
if not os.path.exists(fname):
download(fname)
assert os.path.exists(fname), f"Cound not download {fname}"
# If obj is the default, check for the existence of the file, if not exists(),
# download it, open it, and return the xds.
# obj: if not default, check that the object is None. If it is None, download
# the file, open it with open_image, setting obj equal to the returned xds.
# if obj is not None, this indicates the file has been downloaded already and opened,
# so do nothing.
# if obj is default and wantreturn is False, just download the file if it doesn't exist, but don't open it or return anything.
if not os.path.exists(fname):
download(fname)
assert os.path.exists(fname), f"Could not download {fname}"

Copilot uses AI. Check for mistakes.

if not os.path.exists(fname):
download(fname)
assert os.path.exists(fname), f"Cound not download {fname}"
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Typo: "Cound" should be "Could".

Suggested change
assert os.path.exists(fname), f"Cound not download {fname}"
assert os.path.exists(fname), f"Could not download {fname}"

Copilot uses AI. Check for mistakes.
_compare_attrs_dict(
c1,
c2,
context=f"casa image metadata test, imnmae={imname}",
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Typo: "imnmae" should be "imname" in the context string.

Copilot uses AI. Check for mistakes.
_compare_attrs_dict(
t,
second_attrs,
context=f"masking test, cas 2a",
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Typo: "cas 2a" likely should be "case 2a" in the context string.

Suggested change
context=f"masking test, cas 2a",
context=f"masking test, case 2a",

Copilot uses AI. Check for mistakes.
Comment on lines +776 to +795
self._fds.coords["velocity"].values = radio_velocity
self._fds_no_sky.coords["velocity"].values = radio_velocity
self._fds.coords["velocity"].attrs["doppler_type"] = "radio"
self._fds_no_sky.coords["velocity"].attrs["doppler_type"] = "radio"
# FITS SKY values are nan where the FLAG_SKY values are True, so set SKY to nan in the true xds for comparison
true_xds = deepcopy(self.true_xds())
true_xds_no_sky = deepcopy(self.true_no_sky_xds())
if "FLAG_SKY" in true_xds:
true_xds["SKY"].values = xr.where(
true_xds["FLAG_SKY"].values, np.nan, true_xds["SKY"]
)
self.assertTrue(
(fds.beam_params_label == ["major", "minor", "pa"]).all(),
"Incorrect beam_params_label values",
if "FLAG_SKY" in true_xds_no_sky:
true_xds_no_sky["SKY"].values = xr.where(
true_xds_no_sky["FLAG_SKY"].values, np.nan, true_xds_no_sky["SKY"]
)

def test_xds_attrs(self):
"""Test xds level attributes"""
for fds in (self._fds, self._fds_no_sky):
self.compare_xds_attrs(fds)
self.compare_sky_attrs(fds.SKY, True)
# FITS gets a FITS specific user attr member added that isn't in the true data set
self._fds["SKY"].attrs["user"] = {}
self._fds_no_sky["SKY"].attrs["user"] = {}
assert_xarray_datasets_equal(self._fds, true_xds)
assert_xarray_datasets_equal(self._fds_no_sky, true_xds_no_sky)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

test_got_xds mutates the class-level self._fds and self._fds_no_sky objects in-place (modifying velocity values and attrs, and setting user to {}). While these objects aren't currently used by other tests in this class, this mutation of shared state is fragile—if any future test needs the original _fds values, it will get corrupted data. Consider using deepcopy on _fds and _fds_no_sky before modifying them, similar to how true_xds and true_xds_no_sky are already deep-copied.

Copilot uses AI. Check for mistakes.
from xradio._utils._casacore.tables import open_table_ro

from toolviper.dask.client import local_client
from xradio.testing.assertions import assert_xarray_datasets_equal, _compare_attrs_dict
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The private function _compare_attrs_dict is imported directly from xradio.testing.assertions. Since it's used in multiple test methods for comparing raw dicts (not full xarray datasets), consider either making it a public API in xradio.testing.__init__ (e.g., assert_attrs_dicts_equal) or adding a public wrapper function. Importing private functions from library modules makes the tests fragile against internal refactoring.

Copilot uses AI. Check for mistakes.
Verify fix to issue 45
https://github.com/casangi/xradio/issues/45
irint("*** r", r)
irint("*** r)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

This docstring contains corrupted text from a debug statement: irint("*** r). It appears that the original debug code irint("*** r", r) was partially cleaned up but incorrectly. The docstring should be cleaned up to only contain the description and issue link.

Copilot uses AI. Check for mistakes.
"worldreplace2",
],
kw2,
context=f"casa image table keyword test, imnmae={imname}",
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Same typo as above: "imnmae" should be "imname" in the context string.

Copilot uses AI. Check for mistakes.
details.append(f"extra={extra}")
raise AssertionError(f"{context} keys mismatch: {'; '.join(details)}")

# for key in sorted(test_keys & true_keys):
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The commented-out line # for key in sorted(test_keys & true_keys): should be removed. Since the keys are verified to be identical by the check above (lines 231-239), iterating over sorted(test_keys) is equivalent to iterating over sorted(test_keys & true_keys). The leftover comment is just noise.

Suggested change
# for key in sorted(test_keys & true_keys):

Copilot uses AI. Check for mistakes.
@dmehring
Copy link
Copy Markdown
Collaborator Author

dmehring commented Mar 6, 2026

The macos test failures appear to be CI env related. Because on mac the Observatories table cannot be read, the normal telescope metadata is not written to the xds. The truth image has the telescope metadata encoded, so the xds produced by the macos tests != the truth image and thus the failure.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.


def test_got_xds(self):
# casacore writes the fits image with doppler type Z even though the casacore image
# uses doppler type RADIO. So that may be a casacore bug, so we need to conver the
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Spelling error: "conver" should be "convert".

Suggested change
# uses doppler type RADIO. So that may be a casacore bug, so we need to conver the
# uses doppler type RADIO. So that may be a casacore bug, so we need to convert the

Copilot uses AI. Check for mistakes.
Comment on lines +254 to +256
@classmethod
def xds_uv(cls):
return _download(cls.uv_image(), cls._xds_uv)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The _download helper is intended to cache the opened xr.Dataset, but xds_uv(), true_xds(), true_no_sky_xds(), and true_uv_xds() never store the returned dataset back to the class variable. For example, cls._xds_uv is always None, so _download(cls.uv_image(), cls._xds_uv) will re-open the image from disk on every call. Either assign the result back (e.g., cls._xds_uv = _download(cls.uv_image(), cls._xds_uv)) or use the same if not cls._xds_uv pattern used in xds() and xds_no_sky().

Copilot uses AI. Check for mistakes.
Comment on lines +266 to +268
@classmethod
def true_xds(cls):
return _download(cls._xds_from_casa_true, cls._xds_true)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Same caching issue as xds_uv(): cls._xds_true is always None since the result of _download is never assigned back. Every call to true_xds() will re-open the image from disk. The same applies to true_no_sky_xds() and true_uv_xds() below.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

t.close()

def compare_image_block(self, imagename, zarr=False):
x = [0] if zarr else [0, 1]
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The variable x at line 184 is assigned but never used. It was previously used to iterate over do_sky_coords=True and do_sky_coords=False cases, but the loop was removed. This is dead code and should be removed.

Suggested change
x = [0] if zarr else [0, 1]

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.


def test_got_xds(self):
# casacore writes the fits image with doppler type Z even though the casacore image
# uses doppler type RADIO. So that may be a casacore bug, so we need to conver the
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

Typo: "conver" should be "convert".

Suggested change
# uses doppler type RADIO. So that may be a casacore bug, so we need to conver the
# uses doppler type RADIO. So that may be a casacore bug, so we need to convert the

Copilot uses AI. Check for mistakes.
Comment on lines +431 to +437
assert_attrs_dicts_equal(
c1,
c2,
context=f"casa image metadata test, imname={imname}",
rtol=1e-7,
atol=1e-7,
)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The argument order for assert_attrs_dicts_equal is (test_attrs, true_attrs), but here c1 (the original/truth image info) is passed as test_attrs and c2 (the output/test image info) is passed as true_attrs. This reversed order won't affect pass/fail correctness since the comparison is symmetric, but it will produce misleading error messages (e.g., labeling "missing" keys as "extra" and vice versa) if a mismatch occurs. Consider swapping the arguments to assert_attrs_dicts_equal(c2, c1, ...) for clearer diagnostics.

Copilot uses AI. Check for mistakes.
meta["rest_frequency"] = make_quantity(helpers["restfreq"], "Hz")
meta["rest_frequencies"] = [meta["rest_frequency"]]
meta["type"] = "frequency"
# it appears this was purged from the schema, not sure why
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The comment "it appears this was purged from the schema, not sure why" is vague and suggests uncertainty. If the removal of rest_frequencies is intentional to align with the CASA code path (which also doesn't have it), consider replacing this comment with a more definitive explanation. Additionally, note that the image factory in image_factory.py:85 still sets rest_frequencies — you may want to verify if the factory should also be updated for consistency.

Suggested change
# it appears this was purged from the schema, not sure why
# rest_frequencies was removed from the schema; we now expose only
# the scalar rest_frequency here to match the CASA code path and
# current image schema.

Copilot uses AI. Check for mistakes.
@dmehring dmehring requested review from Jan-Willem and smcastro March 6, 2026 05:30
os.remove(filename)


def _download(fname: str, wantreturn=True) -> xr.Dataset | None:
Copy link
Copy Markdown
Contributor

@smcastro smcastro Mar 6, 2026

Choose a reason for hiding this comment

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

For future refactoring of test_image.py, it will be best to move this _download function to the testing module so that it allows external projects such as benchviper to use it in the benchmark tests without the dependency on toolviper. There was a refactoring done for the measurement_set tests and the download of the MSs was moved to src/xradio/testing/measurement_set/io.py and the tests call it through a conftest.py.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CoPilot already caught most of the small things. This branch makes great improvements to the structure of test_image.py. Once this is merged I will review my original refactoring plan and adapt to the new changes here. Thanks.

import pytest
import re
import shutil
import unittest
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For information, the unittest import will be removed from these tests in the next refactoring and we will rely only on pytest for test framework.

@dmehring
Copy link
Copy Markdown
Collaborator Author

dmehring commented Mar 18, 2026

In an attempt to bypass reliance on casa image and fits image creation within the tests, I've requested these images be uploaded so they can be directly downloaded rather than relying on casacore to create them during the tests to avoid any casacore environmental issues that might cause tests to fail that do not directly reflect on changes to xradio code (eg if the casacore Observatories table isn't present in the test environment). casangi/toolviper#44

@Jan-Willem Jan-Willem merged commit 9f6bc66 into main Mar 26, 2026
23 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rewrite tests/unit/image/test_image.py to use "truth" images rather than hardcoded values

5 participants