Coverage for tests/test_difference_image_extras.py: 54%

207 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-20 09:29 +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 dataclasses 

15import logging 

16import math 

17import os 

18import uuid 

19from pathlib import Path 

20from typing import Any 

21 

22import astropy.units as u 

23import numpy as np 

24import pytest 

25from astro_metadata_translator import ObservationInfo 

26 

27from lsst.daf.butler import DimensionUniverse 

28from lsst.images import ( 

29 Box, 

30 DetectorFrame, 

31 DifferenceImage, 

32 DifferenceImageTemplateInfo, 

33 Image, 

34 MaskPlane, 

35 MaskSchema, 

36 Polygon, 

37) 

38from lsst.images.cameras import Detector 

39from lsst.images.convolution_kernels import ConvolutionKernel, ImageBasisConvolutionKernel 

40from lsst.images.fields import ChebyshevField 

41from lsst.images.psfs import GaussianPointSpreadFunction 

42from lsst.images.serialization import read_archive 

43from lsst.images.tests import ( 

44 DP2_TEMPLATE_COADD_DATASETS, 

45 DP2_VISIT_DETECTOR_DATA_ID, 

46 RoundtripFits, 

47 assert_values_equal, 

48 current_fixture_path, 

49 get_dp2_exposure_record, 

50 make_random_sky_projection, 

51 reset_afw_mask_planes, # noqa: F401 

52) 

53 

54try: 

55 from lsst.afw.image import Exposure as LegacyExposure 

56 from lsst.afw.image import ImageD as LegacyImageD 

57 from lsst.afw.math import Kernel as LegacyKernel 

58 from lsst.afw.table import ExposureCatalog as LegacyExposureCatalog 

59 from lsst.daf.base import PropertyList as LegacyPropertyList 

60 from lsst.geom import Extent2I as LegacyExtent2I 

61 from lsst.meas.algorithms import CoaddPsf as LegacyCoaddPsf 

62except ImportError: 

63 type LegacyExposure = Any # type: ignore[no-redef] 

64 type LegacyImageD = Any # type: ignore[no-redef] 

65 type LegacyKernel = Any # type: ignore[no-redef] 

66 type LegacyPropertyList = Any # type: ignore[no-redef] 

67 type LegacyCoaddPsf = Any # type: ignore[no-redef] 

68 type LegacyExposureCatalog = Any # type: ignore[no-redef] 

69 type LegacyExtent2I = Any # type: ignore[no-redef] 

70 

71 

72EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None) 

73 

74 

75@dataclasses.dataclass 

76class _LegacyTestData: 

77 kernel: LegacyKernel 

78 template_metadata: LegacyPropertyList 

79 template_psf: LegacyCoaddPsf 

80 exposure: LegacyExposure 

81 detector_frame: DetectorFrame 

82 exposure_record: Any = None 

83 

84 

85@pytest.fixture 

86def legacy_test_data(reset_afw_mask_planes: None) -> _LegacyTestData: # noqa: F811 

87 """Return a struct of legacy test objects loaded from EXTERNAL_DATA_DIR. 

88 

89 Skips if TESTDATA_IMAGES_DIR is unset or afw is unavailable. 

90 """ 

91 # reset_afw_mask_planes will have already skipped if afw is not available. 

92 from lsst.afw.image import ExposureFitsReader 

93 

94 if EXTERNAL_DATA_DIR is None: 94 ↛ 96line 94 didn't jump to line 96 because the condition on line 94 was always true

95 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.") 

96 kernel_filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "difference_kernel.fits") 

97 template_filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "template_detector.fits") 

98 exposure_filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "difference_image.fits") 

99 kernel = LegacyKernel.readFits(kernel_filename) 

100 template_reader = ExposureFitsReader(template_filename) 

101 template_metadata = template_reader.readMetadata() 

102 template_psf = template_reader.readPsf() 

103 exposure = ExposureFitsReader(exposure_filename).read() 

104 exposure_record = get_dp2_exposure_record(DimensionUniverse()) 

105 detector_frame = DetectorFrame( 

106 **DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.from_legacy(exposure.getDetector().getBBox()) 

107 ) 

108 return _LegacyTestData( 

109 kernel=kernel, 

110 template_metadata=template_metadata, 

111 template_psf=template_psf, 

112 exposure=exposure, 

113 detector_frame=detector_frame, 

114 exposure_record=exposure_record, 

115 ) 

116 

117 

118def compare_kernel_to_legacy(kernel: ConvolutionKernel, legacy_kernel: LegacyKernel) -> None: 

119 """Assert that a ConvolutionKernel matches a legacy Kernel at sampled 

120 points. 

121 """ 

122 xy_array = kernel.bounds.bbox.meshgrid(3) 

123 legacy_im = LegacyImageD(kernel.kernel_bbox.to_legacy()) 

124 for x, y in zip(xy_array.x.flat, xy_array.y.flat): 

125 x = round(x) 

126 y = round(y) 

127 im = kernel.compute_kernel_image(x=x, y=y) 

128 legacy_im.array[...] = 0.0 

129 legacy_kernel.computeImage(legacy_im, doNormalize=False, x=x, y=y) 

130 assert_values_equal(im.array, legacy_im.array, rtol=1e-15, atol=1e-15) 

131 

132 

133def _sanity_check_template_info( 

134 template_info: list[DifferenceImageTemplateInfo], detector_frame: DetectorFrame 

135) -> None: 

136 """Check that a list of DifferenceImageTemplateInfo looks plausible.""" 

137 assert len(template_info) == 9 

138 assert {info.dataset_id for info in template_info} == set(DP2_TEMPLATE_COADD_DATASETS.keys()) 

139 assert { 

140 frozenset({"skymap": info.skymap, "tract": info.tract, "patch": info.patch, "band": "r"}.items()) 

141 for info in template_info 

142 } == {frozenset(v.items()) for v in DP2_TEMPLATE_COADD_DATASETS.values()} 

143 assert not any(info.psf_shape_flag for info in template_info) 

144 assert not any(math.isnan(info.psf_shape_xx) for info in template_info) 

145 assert not any(math.isnan(info.psf_shape_yy) for info in template_info) 

146 assert not any(math.isnan(info.psf_shape_xy) for info in template_info) 

147 assert all(detector_frame.bbox.contains(info.bounds.bbox) for info in template_info) 

148 # Patches overlap, so total area is a bit more than detector area. 

149 assert sum(info.bounds.area for info in template_info) < 1.5 * detector_frame.bbox.area 

150 

151 

152def _make_difference_image(legacy_test_data: _LegacyTestData) -> DifferenceImage: 

153 """Return a DifferenceImage with kernel and template components 

154 attached. 

155 """ 

156 difference_image = DifferenceImage.from_legacy( 

157 legacy_test_data.exposure, exposure_record=legacy_test_data.exposure_record 

158 ) 

159 difference_image.kernel = ImageBasisConvolutionKernel.from_legacy(legacy_test_data.kernel) 

160 difference_image.templates = DifferenceImageTemplateInfo.from_legacy( 

161 legacy_test_data.detector_frame, 

162 legacy_test_data.template_psf, 

163 legacy_test_data.template_metadata, 

164 DP2_TEMPLATE_COADD_DATASETS, 

165 instrument="LSSTCam", 

166 ) 

167 return difference_image 

168 

169 

170def test_roundtrip(legacy_test_data: _LegacyTestData) -> None: 

171 """Test round-tripping a DifferenceImage with extra components through 

172 FITS. 

173 """ 

174 difference_image = _make_difference_image(legacy_test_data) 

175 with RoundtripFits(difference_image, storage_class="DifferenceImage") as roundtrip: 

176 pass 

177 compare_kernel_to_legacy(roundtrip.result.kernel, legacy_test_data.kernel) 

178 _sanity_check_template_info(roundtrip.result.templates, legacy_test_data.detector_frame) 

179 assert all(info.instrument == "LSSTCam" for info in roundtrip.result.templates) 

180 

181 

182def test_kernel_component_read(legacy_test_data: _LegacyTestData) -> None: 

183 """Verify the kernel component of a DifferenceImage can be read on its 

184 own. 

185 

186 Requires a butler; skips when `lsst.daf.butler` is absent. Butler-free 

187 assertions live in `test_roundtrip`. 

188 """ 

189 difference_image = _make_difference_image(legacy_test_data) 

190 with RoundtripFits(difference_image, storage_class="DifferenceImage") as roundtrip: 

191 compare_kernel_to_legacy(roundtrip.get("kernel"), legacy_test_data.kernel) 

192 

193 

194def test_difference_kernel(legacy_test_data: _LegacyTestData) -> None: 

195 """Test converting a legacy difference kernel to and from the new type.""" 

196 kernel = ImageBasisConvolutionKernel.from_legacy(legacy_test_data.kernel) 

197 compare_kernel_to_legacy(kernel, legacy_test_data.kernel) 

198 legacy_kernel_2 = kernel.to_legacy() 

199 compare_kernel_to_legacy(kernel, legacy_kernel_2) 

200 

201 

202def test_template_info(legacy_test_data: _LegacyTestData) -> None: 

203 """Test extracting template information from legacy template_detector 

204 components. 

205 """ 

206 template_info = DifferenceImageTemplateInfo.from_legacy( 

207 legacy_test_data.detector_frame, 

208 legacy_test_data.template_psf, 

209 legacy_test_data.template_metadata, 

210 DP2_TEMPLATE_COADD_DATASETS, 

211 ) 

212 _sanity_check_template_info(template_info, legacy_test_data.detector_frame) 

213 assert all(info.instrument == "UNKNOWN" for info in template_info) 

214 

215 

216def test_template_info_instrument(legacy_test_data: _LegacyTestData) -> None: 

217 """Test that the instrument passed to from_legacy is recorded on every 

218 template. 

219 """ 

220 template_info = DifferenceImageTemplateInfo.from_legacy( 

221 legacy_test_data.detector_frame, 

222 legacy_test_data.template_psf, 

223 legacy_test_data.template_metadata, 

224 DP2_TEMPLATE_COADD_DATASETS, 

225 instrument="LSSTCam", 

226 ) 

227 assert template_info 

228 assert all(info.instrument == "LSSTCam" for info in template_info) 

229 

230 

231def test_template_info_no_overlap_is_skipped( 

232 legacy_test_data: _LegacyTestData, caplog: pytest.LogCaptureFixture 

233) -> None: 

234 """Test that template coadd whose patch does not overlap the detector is 

235 skipped in DifferenceImageTemplateInfo.from_legacy, instead of raising 

236 NoOverlapError. 

237 """ 

238 psf = legacy_test_data.template_psf 

239 n_components = psf.getComponentCount() 

240 assert n_components > 1 

241 

242 # Rebuild the CoaddPsf from its per-component getters, shifting component 

243 # 0's patch bbox far from the detector so the bbox intersection in 

244 # from_legacy is empty (NoOverlapError). Keep the tract/patch columns so 

245 # from_legacy can still resolve its butler info. 

246 schema = LegacyExposureCatalog.Table.makeMinimalSchema() 

247 schema.addField("weight", type="D") 

248 schema.addField("tract", type="I") 

249 schema.addField("patch", type="I") 

250 catalog = LegacyExposureCatalog(schema) 

251 shift = LegacyExtent2I(50000, 50000) 

252 for n in range(n_components): 

253 record = catalog.addNew() 

254 record.setId(psf.getId(n)) 

255 record.setWcs(psf.getWcs(n)) 

256 record.setPsf(psf.getPsf(n)) 

257 record.setValidPolygon(psf.getValidPolygon(n)) 

258 bbox = psf.getBBox(n) 

259 if n == 0: 

260 bbox = bbox.shiftedBy(shift) 

261 record.setBBox(bbox) 

262 record.set("weight", psf.getWeight(n)) 

263 record.set("tract", psf.getTract(n)) 

264 record.set("patch", psf.getPatch(n)) 

265 shifted_psf = LegacyCoaddPsf(catalog, psf.getCoaddWcs(), psf.getAveragePosition()) 

266 

267 skipped_tract = psf.getTract(0) 

268 skipped_patch = psf.getPatch(0) 

269 with caplog.at_level(logging.ERROR): 

270 template_info = DifferenceImageTemplateInfo.from_legacy( 

271 legacy_test_data.detector_frame, 

272 shifted_psf, 

273 legacy_test_data.template_metadata, 

274 DP2_TEMPLATE_COADD_DATASETS, 

275 log=logging.getLogger("test_template_info_no_overlap"), 

276 ) 

277 assert len(template_info) == n_components - 1 

278 assert all((info.tract, info.patch) != (skipped_tract, skipped_patch) for info in template_info) 

279 assert not any(info.psf_shape_flag for info in template_info) 

280 assert all(legacy_test_data.detector_frame.bbox.contains(info.bounds.bbox) for info in template_info) 

281 assert any( 

282 f"No overlap with tract={skipped_tract}, patch={skipped_patch}" in record.message 

283 for record in caplog.records 

284 ) 

285 

286 

287LOCAL_DATA_DIR = os.path.join(os.path.dirname(__file__), "data") 

288 

289 

290def test_difference_image_repr_str_pinned() -> None: 

291 """Pin the exact str and repr output of a DifferenceImage.""" 

292 rng = np.random.default_rng(500) 

293 det_frame = DetectorFrame(instrument="Inst", visit=1234, detector=1, bbox=Box.factory[1:4096, 1:4096]) 

294 mask_schema = MaskSchema([MaskPlane("M1", "D1")]) 

295 obs_info = ObservationInfo(instrument="LSSTCam", detector_num=4, physical_filter="r1") 

296 detector = read_archive(os.path.join(LOCAL_DATA_DIR, "detector.json"), Detector) 

297 image = Image(42, shape=(1024, 1024), unit=u.nJy) 

298 sky_projection = make_random_sky_projection(rng, det_frame, det_frame.bbox) 

299 di = DifferenceImage( 

300 image, 

301 psf=GaussianPointSpreadFunction(2.5, stamp_size=33, bounds=Box.factory[-10:10, -12:13]), 

302 mask_schema=mask_schema, 

303 sky_projection=sky_projection, 

304 detector=detector, 

305 obs_info=obs_info, 

306 band="r", 

307 ) 

308 assert str(di) == "DifferenceImage(Image([y=0:1024, x=0:1024], int64), ['M1'])" 

309 assert repr(di) == ( 

310 "DifferenceImage(Image(..., bbox=Box(y=Interval(start=0, stop=1024), x=Interval(start=0, stop=1024))," 

311 " dtype=dtype('int64')), mask_schema=MaskSchema([MaskPlane(name='M1', description='D1')]," 

312 " dtype=dtype('uint8')))" 

313 ) 

314 

315 

316FIXTURE_DIR = Path(__file__).parent / "data" / "schemas" 

317 

318 

319def _make_template( 

320 tract: int = 1, 

321 patch: int = 2, 

322 *, 

323 skymap: str = "sky", 

324 dataset_run: str = "run", 

325 psf_shape_xx: float = 4.0, 

326 psf_shape_yy: float = 4.0, 

327 psf_shape_xy: float = 0.0, 

328 psf_shape_flag: bool = False, 

329) -> DifferenceImageTemplateInfo: 

330 """Return a template info struct with defaults for everything a test does 

331 not care about. 

332 """ 

333 return DifferenceImageTemplateInfo( 

334 skymap=skymap, 

335 tract=tract, 

336 patch=patch, 

337 dataset_id=uuid.uuid4(), 

338 dataset_run=dataset_run, 

339 bounds=Polygon(x_vertices=[-0.5, 3.5, -0.5], y_vertices=[-0.5, -0.5, 3.5]), 

340 psf_shape_xx=psf_shape_xx, 

341 psf_shape_yy=psf_shape_yy, 

342 psf_shape_xy=psf_shape_xy, 

343 psf_shape_flag=psf_shape_flag, 

344 ) 

345 

346 

347def test_template_info_instrument_defaults_to_unknown() -> None: 

348 """A template without a recorded instrument, such as one read from a file 

349 written before the field existed, has the instrument ``"UNKNOWN"``. 

350 """ 

351 assert _make_template().instrument == "UNKNOWN" 

352 

353 

354def test_describe_templates_hoists_shared_values() -> None: 

355 """A value every template shares becomes a field instead of a column.""" 

356 templates = [_make_template(1, 2), _make_template(1, 3)] 

357 fields, table = DifferenceImageTemplateInfo._describe_templates(templates) 

358 assert {field.label: field.value for field in fields} == {"skymap": "sky", "template run": "run"} 

359 assert table.title == "Templates" 

360 assert table.columns == ["Tract", "Patch", "PSF \N{GREEK SMALL LETTER SIGMA}", "Dataset ID"] 

361 assert [row[:3] for row in table.rows] == [[1, 2, "2.000"], [1, 3, "2.000"]] 

362 

363 

364def test_describe_templates_keeps_varying_values_as_columns() -> None: 

365 """A value that differs between templates stays in the table.""" 

366 templates = [_make_template(skymap="a", dataset_run="r1"), _make_template(skymap="b", dataset_run="r1")] 

367 fields, table = DifferenceImageTemplateInfo._describe_templates(templates) 

368 # Only the run is shared, so only the run is hoisted. 

369 assert [field.label for field in fields] == ["template run"] 

370 assert table.columns[0] == "Skymap" 

371 assert [row[0] for row in table.rows] == ["a", "b"] 

372 

373 

374def test_describe_templates_marks_unusable_psf_shapes() -> None: 

375 """A flagged or degenerate PSF shape reports no radius, and the flag 

376 column appears only when some template carries it. 

377 """ 

378 good = _make_template(psf_shape_xx=9.0, psf_shape_yy=9.0) 

379 flagged = _make_template(psf_shape_flag=True) 

380 degenerate = _make_template(psf_shape_xx=1.0, psf_shape_yy=1.0, psf_shape_xy=1.0) 

381 _, table = DifferenceImageTemplateInfo._describe_templates([good, degenerate]) 

382 assert "PSF flag" not in table.columns 

383 assert [row[2] for row in table.rows] == ["3.000", "n/a"] 

384 _, table = DifferenceImageTemplateInfo._describe_templates([good, flagged]) 

385 flag_column = table.columns.index("PSF flag") 

386 assert [row[2] for row in table.rows] == ["3.000", "n/a"] 

387 assert [row[flag_column] for row in table.rows] == ["", "set"] 

388 

389 

390def test_difference_image_describe_reports_templates_and_kernel() -> None: 

391 """A deserialized difference image describes both of the parts it adds to 

392 a visit image. 

393 """ 

394 difference_image = read_archive(str(current_fixture_path(FIXTURE_DIR, "difference_image", variant="dp2"))) 

395 report = difference_image.describe() 

396 tables = {table.title: table for table in report.tables} 

397 assert len(tables["Templates"].rows) == len(difference_image.templates) 

398 assert "skymap" in {field.label for field in report.fields} 

399 assert report.children["kernel"].type_name == "ImageBasisConvolutionKernel" 

400 # Both renderers run over the whole thing without error. 

401 assert isinstance(report._repr_html_(), str) 

402 report.__rich__() 

403 

404 

405def test_difference_image_describe_brief_skips_templates_and_kernel() -> None: 

406 """Brief reports feed repr and str, which name neither part.""" 

407 difference_image = read_archive(str(current_fixture_path(FIXTURE_DIR, "difference_image", variant="dp2"))) 

408 report = difference_image.describe(brief=True) 

409 assert report.tables == [] 

410 assert "kernel" not in report.children 

411 

412 

413def test_convolution_kernel_describes_itself() -> None: 

414 """A kernel summarizes its basis rather than listing it.""" 

415 spatial = [ChebyshevField(Box.factory[0:10, 0:20], np.array([[1.0]])) for _ in range(3)] 

416 kernel = ImageBasisConvolutionKernel(np.ones((3, 5, 5)), spatial) 

417 report = kernel.describe() 

418 values = {field.label: field.value for field in report.fields} 

419 assert values["basis images"] == 3 

420 assert values["spatial variation"] == "ChebyshevField" 

421 assert report.children == {} 

422 assert str(kernel) == "ImageBasisConvolutionKernel with 3 basis images over [y=0:10, x=0:20]"