Coverage for tests/test_verify_rewrite.py: 54%

187 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-10 09:42 +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. 

11 

12from __future__ import annotations 

13 

14import os 

15import random 

16from pathlib import Path 

17from typing import Any 

18 

19import numpy as np 

20import pytest 

21from click.testing import CliRunner 

22 

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) 

39 

40 

41def _bbox() -> Box: 

42 return Box.factory[0:10, 0:10] 

43 

44 

45def _constant_background(name: str = "subtracted") -> Background: 

46 field = ChebyshevField(_bbox(), np.array([[5.0]])) 

47 return Background(name=name, field=field, description="test") 

48 

49 

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")) 

57 

58 

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")) 

64 

65 

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()) 

72 

73 

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=()) 

77 

78 

79def test_check_kernel_none_fails() -> None: 

80 """Check that a None kernel fails.""" 

81 with pytest.raises(AssertionError): 

82 _check_kernel(None) 

83 

84 

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()) 

89 

90 

91def test_check_templates_empty_fails() -> None: 

92 """Check that an empty templates list fails.""" 

93 with pytest.raises(AssertionError): 

94 _check_templates([], _bbox()) 

95 

96 

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 

103 

104 

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" 

110 

111 

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 ) 

122 

123 

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" 

132 

133 

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) 

146 

147 

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) 

155 

156 

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) 

167 

168 

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 

184 

185 

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 

192 

193 

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``. 

197 

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 

204 

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 ) 

260 

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) 

274 

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) 

281 

282 

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 ) 

296 

297 

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.") 

305 

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 

311 

312 

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``. 

317 

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 

323 

324 from lsst.afw.image import ExposureFitsReader 

325 from lsst.daf.butler import Butler, DataCoordinate, DatasetType 

326 from lsst.images.cells import CellCoadd 

327 

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 ) 

368 

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) 

375 

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) 

394 

395 

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 ) 

412 

413 

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.") 

428 

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 

433 

434 

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.") 

449 

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