Coverage for tests/test_verify_rewrite.py: 54%
187 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 01:36 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 01:36 +0000
1# This file is part of lsst-images.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12from __future__ import annotations
14import os
15import random
16from pathlib import Path
17from typing import Any
19import numpy as np
20import pytest
21from click.testing import CliRunner
23from lsst.images import YX, Background, BackgroundMap, Box, DifferenceImage
24from lsst.images.cli._main import main
25from lsst.images.fields import ChebyshevField
26from lsst.images.tests import (
27 DP2_COADD_DATA_ID,
28 DP2_VISIT_DETECTOR_DATA_ID,
29 get_dp2_exposure_record,
30 reset_afw_mask_planes, # noqa: F401
31)
32from lsst.images.tests.verify_rewrite import (
33 RewriteVerifier,
34 _check_backgrounds,
35 _check_kernel,
36 _check_templates,
37 _spot_check_points,
38)
41def _bbox() -> Box:
42 return Box.factory[0:10, 0:10]
45def _constant_background(name: str = "subtracted") -> Background:
46 field = ChebyshevField(_bbox(), np.array([[5.0]]))
47 return Background(name=name, field=field, description="test")
50def test_check_backgrounds_present_and_finite() -> None:
51 """Check that present and finite backgrounds pass."""
52 backgrounds = BackgroundMap(
53 [_constant_background("subtracted"), _constant_background("skyCorr")],
54 subtracted="subtracted",
55 )
56 _check_backgrounds(backgrounds, _bbox(), expected=("subtracted", "skyCorr"))
59def test_check_backgrounds_missing_expected_fails() -> None:
60 """Check that a missing expected background name fails."""
61 backgrounds = BackgroundMap([_constant_background("subtracted")], subtracted="subtracted")
62 with pytest.raises(AssertionError, match="expected background 'skyCorr' not attached"):
63 _check_backgrounds(backgrounds, _bbox(), expected=("subtracted", "skyCorr"))
66def test_check_backgrounds_non_finite_fails() -> None:
67 """Check that a non-finite background field fails."""
68 field = ChebyshevField(_bbox(), np.array([[np.nan]]))
69 backgrounds = BackgroundMap([Background(name="subtracted", field=field)], subtracted="subtracted")
70 with pytest.raises(AssertionError, match="non-finite"):
71 _check_backgrounds(backgrounds, _bbox())
74def test_check_backgrounds_no_background_ok() -> None:
75 """Check that an empty background map with no expectations passes."""
76 _check_backgrounds(BackgroundMap(), _bbox(), expected=())
79def test_check_kernel_none_fails() -> None:
80 """Check that a None kernel fails."""
81 with pytest.raises(AssertionError):
82 _check_kernel(None)
85def test_check_templates_none_fails() -> None:
86 """Check that a None templates list fails."""
87 with pytest.raises(AssertionError):
88 _check_templates(None, _bbox())
91def test_check_templates_empty_fails() -> None:
92 """Check that an empty templates list fails."""
93 with pytest.raises(AssertionError):
94 _check_templates([], _bbox())
97def test_rewrite_verifier_reports_problems() -> None:
98 """Assert that print_error increments the problem count."""
99 verifier = RewriteVerifier(None, "difference_image", old_prefix="legacy_", new_prefix="")
100 assert verifier.n_problems == 0
101 verifier.print_error("data_id", AssertionError("boom"))
102 assert verifier.n_problems == 1
105def test_print_error_no_note(capsys: pytest.CaptureFixture[str]) -> None:
106 """An error without a component note is printed on one line."""
107 verifier = RewriteVerifier(None, "difference_image", old_prefix="legacy_", new_prefix="")
108 verifier.print_error("data_id", AssertionError("boom"))
109 assert capsys.readouterr().out == "data_id: boom (AssertionError)\n"
112def test_print_error_component_note(capsys: pytest.CaptureFixture[str]) -> None:
113 """A component note is printed as a prefix on its own indented line."""
114 verifier = RewriteVerifier(None, "difference_image", old_prefix="legacy_", new_prefix="")
115 err = AssertionError("81/6642 values differ; max abs diff 1e-9 at index (np.int64(0),)")
116 err.add_note("sky_projection")
117 verifier.print_error("data_id", err)
118 assert capsys.readouterr().out == (
119 "data_id\n sky_projection: 81/6642 values differ; max abs diff 1e-9 at index"
120 " (np.int64(0),) (AssertionError)\n"
121 )
124def test_print_error_nested_notes(capsys: pytest.CaptureFixture[str]) -> None:
125 """Nested component notes are rendered as a path, not repeated messages."""
126 verifier = RewriteVerifier(None, "difference_image", old_prefix="legacy_", new_prefix="")
127 err = AssertionError("boom")
128 err.add_note("visit_image")
129 err.add_note("sky_projection")
130 verifier.print_error("data_id", err)
131 assert capsys.readouterr().out == "data_id\n visit_image -> sky_projection: boom (AssertionError)\n"
134def test_spot_check_points_subset() -> None:
135 """Check that the sample has the requested size, no repeats, and stays
136 within the input points.
137 """
138 points = YX(y=np.arange(10.0), x=100.0 * np.arange(10.0))
139 result = _spot_check_points(points, 4, random.Random("test"))
140 assert len(result.y) == 4
141 assert len(set(result.y.tolist())) == 4
142 assert set(result.y.tolist()) <= set(points.y.tolist())
143 assert set(result.x.tolist()) <= set(points.x.tolist())
144 # x/y pairs must stay together.
145 np.testing.assert_array_equal(result.x, 100.0 * result.y)
148def test_spot_check_points_deterministic() -> None:
149 """Check that a seeded random.Random gives reproducible samples."""
150 points = YX(y=np.arange(20.0), x=np.arange(20.0) ** 2)
151 first = _spot_check_points(points, 5, random.Random("seed"))
152 second = _spot_check_points(points, 5, random.Random("seed"))
153 np.testing.assert_array_equal(first.y, second.y)
154 np.testing.assert_array_equal(first.x, second.x)
157def test_spot_check_points_more_requested_than_available() -> None:
158 """Check that requesting at least as many points as available returns
159 all of them.
160 """
161 points = YX(y=np.arange(3.0), x=2.0 * np.arange(3.0))
162 for n in (3, 10):
163 result = _spot_check_points(points, n, random.Random("all"))
164 assert len(result.y) == 3
165 assert set(result.y.tolist()) == set(points.y.tolist())
166 np.testing.assert_array_equal(result.x, 2.0 * result.y)
169def test_rewrite_verifier_stores_coadd_options() -> None:
170 """Check that the constructor stores the CellCoadd-check options."""
171 verifier = RewriteVerifier(
172 None,
173 "template_coadd",
174 old_prefix="legacy_",
175 new_prefix="",
176 pixel_atol_factor=0.25,
177 coadd_check_points=7,
178 )
179 assert verifier.pixel_atol_factor == 0.25
180 assert verifier.coadd_check_points == 7
181 default = RewriteVerifier(None, "template_coadd", old_prefix="legacy_", new_prefix="")
182 assert default.pixel_atol_factor == 0.5
183 assert default.coadd_check_points == 4
186@pytest.fixture(scope="module")
187def testdata_dir() -> str:
188 """Return the external test-data directory, skipping if unset."""
189 if (result := os.environ.get("TESTDATA_IMAGES_DIR")) is None: 189 ↛ 191line 189 didn't jump to line 191 because the condition on line 189 was always true
190 pytest.skip("TESTDATA_IMAGES_DIR is not set.")
191 return result
194def _make_dp2_rewrite_repo(repo: str, src: str) -> None:
195 """Build a repo holding a legacy DP2 difference image and its rewritten
196 counterpart, ready for ``verify-rewrite``.
198 The rewritten dataset is converted from the source file with the given
199 (or the true DP2) exposure record; passing a tampered record produces a
200 product that ``verify-rewrite``'s observation-info check should reject.
201 """
202 from lsst.afw.image import ExposureF
203 from lsst.daf.butler import Butler, DataCoordinate, DatasetType
205 Butler.makeRepo(repo)
206 with Butler.from_config(repo, run="run1") as butler:
207 exposure_record = get_dp2_exposure_record(butler.dimensions)
208 reg = butler.registry
209 reg.insertDimensionData("instrument", {"instrument": exposure_record.instrument})
210 reg.insertDimensionData(
211 "day_obs", {"instrument": exposure_record.instrument, "day_obs": exposure_record.day_obs}
212 )
213 reg.insertDimensionData(
214 "physical_filter",
215 {
216 "physical_filter": exposure_record.physical_filter,
217 "band": DP2_VISIT_DETECTOR_DATA_ID["band"],
218 "instrument": exposure_record.instrument,
219 },
220 )
221 reg.insertDimensionData(
222 "detector",
223 {
224 "instrument": exposure_record.instrument,
225 "id": DP2_VISIT_DETECTOR_DATA_ID["detector"],
226 "full_name": "R21_S11",
227 },
228 )
229 reg.insertDimensionData(
230 "visit",
231 {
232 "instrument": exposure_record.instrument,
233 "id": exposure_record.id,
234 "physical_filter": exposure_record.physical_filter,
235 "name": str(exposure_record.id),
236 "day_obs": exposure_record.day_obs,
237 },
238 )
239 reg.insertDimensionData(
240 "visit_detector_region",
241 {
242 "instrument": exposure_record.instrument,
243 "visit": exposure_record.id,
244 "detector": DP2_VISIT_DETECTOR_DATA_ID["detector"],
245 "region": None,
246 },
247 )
248 reg.insertDimensionData(
249 "group", {"instrument": exposure_record.instrument, "name": exposure_record.group}
250 )
251 reg.insertDimensionData("exposure", exposure_record)
252 reg.insertDimensionData(
253 "visit_definition",
254 {
255 "instrument": exposure_record.instrument,
256 "visit": exposure_record.id,
257 "exposure": exposure_record.id,
258 },
259 )
261 dims = ("instrument", "visit", "detector")
262 data_id = DataCoordinate.standardize(
263 {
264 "instrument": exposure_record.instrument,
265 "visit": exposure_record.id,
266 "detector": DP2_VISIT_DETECTOR_DATA_ID["detector"],
267 },
268 universe=butler.dimensions,
269 )
270 legacy_dt = DatasetType("legacy_difference_image", dims, "ExposureF", universe=butler.dimensions)
271 new_dt = DatasetType("difference_image", dims, "DifferenceImage", universe=butler.dimensions)
272 reg.registerDatasetType(legacy_dt)
273 reg.registerDatasetType(new_dt)
275 butler.put(ExposureF(src), legacy_dt, data_id)
276 # Convert with a second read of the file, not from_legacy() on the
277 # exposure above: the conversion shares pixel data with, and mutates
278 # the metadata of, the exposure it is given.
279 new = DifferenceImage.read_legacy(src, exposure_record=exposure_record)
280 butler.put(new, new_dt, data_id)
283def _invoke_verify_rewrite(repo: str) -> Any:
284 return CliRunner().invoke(
285 main,
286 [
287 "verify-rewrite",
288 repo,
289 "difference_image",
290 "run1",
291 "--no-check-kernel",
292 "--no-check-templates",
293 "--no-require-compressed",
294 ],
295 )
298def test_verify_rewrite_end_to_end(tmp_path: Path, testdata_dir: str, reset_afw_mask_planes) -> None: # noqa: F811
299 """Run verify-rewrite on a real difference image."""
300 try:
301 import lsst.afw.image
302 import lsst.daf.butler # noqa: F401
303 except ImportError:
304 pytest.skip("lsst.daf.butler and lsst.afw could not be imported.")
306 src = os.path.join(testdata_dir, "dp2", "legacy", "difference_image.fits")
307 repo = str(tmp_path / "repo")
308 _make_dp2_rewrite_repo(repo, src)
309 result = _invoke_verify_rewrite(repo)
310 assert result.exit_code == 0, result.output
313def _make_dp2_coadd_rewrite_repo(repo: str, testdata_dir: str, *, tamper: bool = False) -> None:
314 """Build a repo holding a legacy DP2 ``template_coadd`` (a patch-level
315 lazy-PSF ``ExposureF``) and its CellCoadd conversion, ready for
316 ``verify-rewrite``.
318 With ``tamper``, the converted coadd's image gets a large constant
319 offset over a sub-box, which ``verify-rewrite``'s coadd comparison
320 should reject.
321 """
322 import pickle
324 from lsst.afw.image import ExposureFitsReader
325 from lsst.daf.butler import Butler, DataCoordinate, DatasetType
326 from lsst.images.cells import CellCoadd
328 Butler.makeRepo(repo)
329 with Butler.from_config(repo, run="run1") as butler:
330 with open(os.path.join(testdata_dir, "dp2", "legacy", "skyMap.pickle"), "rb") as stream:
331 skymap = pickle.load(stream)
332 tract_info = skymap[DP2_COADD_DATA_ID["tract"]]
333 patch_index = tract_info[DP2_COADD_DATA_ID["patch"]].getIndex()
334 reg = butler.registry
335 # band has no records of its own; it is populated by physical_filter.
336 reg.insertDimensionData("instrument", {"instrument": "LSSTCam"})
337 reg.insertDimensionData(
338 "physical_filter",
339 {
340 "instrument": "LSSTCam",
341 "physical_filter": "r_57",
342 "band": DP2_COADD_DATA_ID["band"],
343 },
344 )
345 # The skymap hash and metadata and the tract/patch regions are never
346 # consulted by verify-rewrite, so insert placeholders for them (the
347 # same region=None trick _make_dp2_rewrite_repo uses for
348 # visit_detector_region).
349 reg.insertDimensionData(
350 "skymap",
351 {"name": DP2_COADD_DATA_ID["skymap"], "hash": (50).to_bytes(8, byteorder="little")},
352 )
353 reg.insertDimensionData(
354 "tract",
355 {"skymap": DP2_COADD_DATA_ID["skymap"], "id": DP2_COADD_DATA_ID["tract"], "region": None},
356 )
357 reg.insertDimensionData(
358 "patch",
359 {
360 "skymap": DP2_COADD_DATA_ID["skymap"],
361 "tract": DP2_COADD_DATA_ID["tract"],
362 "id": DP2_COADD_DATA_ID["patch"],
363 "cell_x": patch_index.x,
364 "cell_y": patch_index.y,
365 "region": None,
366 },
367 )
369 dims = ("band", "skymap", "tract", "patch")
370 data_id = DataCoordinate.standardize(DP2_COADD_DATA_ID, universe=butler.dimensions)
371 legacy_dt = DatasetType("legacy_template_coadd", dims, "ExposureF", universe=butler.dimensions)
372 new_dt = DatasetType("template_coadd", dims, "CellCoadd", universe=butler.dimensions)
373 reg.registerDatasetType(legacy_dt)
374 reg.registerDatasetType(new_dt)
376 src = os.path.join(testdata_dir, "dp2", "legacy", "template_coadd.fits")
377 butler.put(ExposureFitsReader(src).read(), legacy_dt, data_id)
378 # Convert with a second read of the file, not from the exposure
379 # above: the conversion shares pixel data with, and mutates the
380 # metadata of, the exposure it is given.
381 new = CellCoadd.from_legacy_lazy_coadd(
382 ExposureFitsReader(src).read(),
383 instrument="LSSTCam",
384 tract_info=tract_info,
385 skymap=DP2_COADD_DATA_ID["skymap"],
386 patch=DP2_COADD_DATA_ID["patch"],
387 band=DP2_COADD_DATA_ID["band"],
388 )
389 if tamper:
390 # Offset far larger than verify-rewrite's pixel atol
391 # (pixel_atol_factor times the legacy image's standard deviation).
392 new.image.array[64:128, 64:128] += 1e4
393 butler.put(new, new_dt, data_id)
396def _invoke_verify_coadd_rewrite(repo: str) -> Any:
397 return CliRunner().invoke(
398 main,
399 [
400 "verify-rewrite",
401 repo,
402 "template_coadd",
403 "run1",
404 "--coadd-check-points",
405 "2",
406 # The test datastore writes plain GZIP-compressed (rather than
407 # lossily RICE-compressed) FITS, so skip that leg, as in the
408 # difference-image e2e test.
409 "--no-require-compressed",
410 ],
411 )
414def test_verify_rewrite_coadd_end_to_end(
415 tmp_path: Path,
416 testdata_dir: str,
417 reset_afw_mask_planes, # noqa: F811
418) -> None:
419 """Run verify-rewrite on a real legacy lazy-PSF coadd and its CellCoadd
420 conversion.
421 """
422 try:
423 import lsst.afw.image
424 import lsst.daf.butler
425 import lsst.skymap # noqa: F401
426 except ImportError:
427 pytest.skip("lsst.daf.butler, lsst.skymap, and lsst.afw could not be imported.")
429 repo = str(tmp_path / "repo")
430 _make_dp2_coadd_rewrite_repo(repo, testdata_dir)
431 result = _invoke_verify_coadd_rewrite(repo)
432 assert result.exit_code == 0, result.output
435def test_verify_rewrite_coadd_detects_tampering(
436 tmp_path: Path,
437 testdata_dir: str,
438 reset_afw_mask_planes, # noqa: F811
439) -> None:
440 """Check that verify-rewrite rejects a CellCoadd whose image was
441 corrupted in the datastore.
442 """
443 try:
444 import lsst.afw.image
445 import lsst.daf.butler
446 import lsst.skymap # noqa: F401
447 except ImportError:
448 pytest.skip("lsst.daf.butler, lsst.skymap, and lsst.afw could not be imported.")
450 repo = str(tmp_path / "repo")
451 _make_dp2_coadd_rewrite_repo(repo, testdata_dir, tamper=True)
452 result = _invoke_verify_coadd_rewrite(repo)
453 assert result.exit_code == 1, result.output