Coverage for tests/test_difference_image_extras.py: 59%

269 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 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 DifferenceImageTemplateInput, 

34 Image, 

35 MaskPlane, 

36 MaskSchema, 

37 Polygon, 

38) 

39from lsst.images.cameras import Detector 

40from lsst.images.convolution_kernels import ConvolutionKernel, ImageBasisConvolutionKernel 

41from lsst.images.fields import ChebyshevField 

42from lsst.images.psfs import GaussianPointSpreadFunction 

43from lsst.images.serialization import read_archive 

44from lsst.images.tests import ( 

45 DP2_TEMPLATE_COADD_DATASETS, 

46 DP2_VISIT_DETECTOR_DATA_ID, 

47 RoundtripFits, 

48 assert_values_equal, 

49 current_fixture_path, 

50 get_dp2_exposure_record, 

51 make_random_sky_projection, 

52 reset_afw_mask_planes, # noqa: F401 

53) 

54 

55try: 

56 import astshim 

57 from lsst.afw.detection import GaussianPsf as LegacyGaussianPsf 

58 from lsst.afw.geom import Polygon as LegacyPolygon 

59 from lsst.afw.geom import TransformPoint2ToPoint2 as LegacyTransform 

60 from lsst.afw.geom import makeCdMatrix, makeModifiedWcs, makeSkyWcs 

61 from lsst.afw.image import Exposure as LegacyExposure 

62 from lsst.afw.image import ImageD as LegacyImageD 

63 from lsst.afw.math import Kernel as LegacyKernel 

64 from lsst.afw.table import ExposureCatalog as LegacyExposureCatalog 

65 from lsst.daf.base import PropertyList as LegacyPropertyList 

66 from lsst.geom import Box2D as LegacyBox2D 

67 from lsst.geom import Box2I as LegacyBox2I 

68 from lsst.geom import Extent2I as LegacyExtent2I 

69 from lsst.geom import Point2D as LegacyPoint2D 

70 from lsst.geom import Point2I as LegacyPoint2I 

71 from lsst.geom import SpherePoint as LegacySpherePoint 

72 from lsst.geom import arcseconds, degrees 

73 from lsst.meas.algorithms import CoaddPsf as LegacyCoaddPsf 

74except ImportError: 

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

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

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

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

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

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

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

82 

83 

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

85 

86 

87@dataclasses.dataclass 

88class _LegacyTestData: 

89 kernel: LegacyKernel 

90 template_metadata: LegacyPropertyList 

91 template_psf: LegacyCoaddPsf 

92 exposure: LegacyExposure 

93 detector_frame: DetectorFrame 

94 exposure_record: Any = None 

95 

96 

97@pytest.fixture 

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

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

100 

101 Skips if TESTDATA_IMAGES_DIR is unset or afw is unavailable. 

102 """ 

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

104 from lsst.afw.image import ExposureFitsReader 

105 

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

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

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

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

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

111 kernel = LegacyKernel.readFits(kernel_filename) 

112 template_reader = ExposureFitsReader(template_filename) 

113 template_metadata = template_reader.readMetadata() 

114 template_psf = template_reader.readPsf() 

115 exposure = ExposureFitsReader(exposure_filename).read() 

116 exposure_record = get_dp2_exposure_record(DimensionUniverse()) 

117 detector_frame = DetectorFrame( 

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

119 ) 

120 return _LegacyTestData( 

121 kernel=kernel, 

122 template_metadata=template_metadata, 

123 template_psf=template_psf, 

124 exposure=exposure, 

125 detector_frame=detector_frame, 

126 exposure_record=exposure_record, 

127 ) 

128 

129 

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

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

132 points. 

133 """ 

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

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

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

137 x = round(x) 

138 y = round(y) 

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

140 legacy_im.array[...] = 0.0 

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

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

143 

144 

145def _sanity_check_template_info( 

146 template_info: list[DifferenceImageTemplateInfo], detector_frame: DetectorFrame 

147) -> None: 

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

149 assert len(template_info) == 9 

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

151 assert { 

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

153 for info in template_info 

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

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

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

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

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

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

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

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

162 

163 

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

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

166 attached. 

167 """ 

168 difference_image = DifferenceImage.from_legacy( 

169 legacy_test_data.exposure, exposure_record=legacy_test_data.exposure_record 

170 ) 

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

172 difference_image.templates = DifferenceImageTemplateInfo.from_legacy( 

173 legacy_test_data.detector_frame, 

174 legacy_test_data.template_psf, 

175 legacy_test_data.template_metadata, 

176 DP2_TEMPLATE_COADD_DATASETS, 

177 instrument="LSSTCam", 

178 ) 

179 return difference_image 

180 

181 

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

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

184 FITS. 

185 """ 

186 difference_image = _make_difference_image(legacy_test_data) 

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

188 pass 

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

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

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

192 assert all(info.inputs == [] for info in roundtrip.result.templates) 

193 

194 

195def test_roundtrip_template_inputs(legacy_test_data: _LegacyTestData) -> None: 

196 """Test that the input images recorded for each template round-trip 

197 through FITS. 

198 """ 

199 difference_image = _make_difference_image(legacy_test_data) 

200 for n, info in enumerate(difference_image.templates): 

201 info.inputs = [ 

202 DifferenceImageTemplateInput( 

203 visit=2025061200123 + n, 

204 detector=94, 

205 mid_mjd_tai=60838.13125 + n, 

206 weight=0.75, 

207 bounds=info.bounds, 

208 ) 

209 ] 

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

211 pass 

212 for original, result in zip(difference_image.templates, roundtrip.result.templates, strict=True): 

213 assert len(result.inputs) == 1 

214 assert result.inputs[0].visit == original.inputs[0].visit 

215 assert result.inputs[0].detector == 94 

216 assert result.inputs[0].mid_mjd_tai == original.inputs[0].mid_mjd_tai 

217 assert result.inputs[0].weight == 0.75 

218 np.testing.assert_array_equal(result.inputs[0].bounds.x_vertices, original.bounds.x_vertices) 

219 np.testing.assert_array_equal(result.inputs[0].bounds.y_vertices, original.bounds.y_vertices) 

220 

221 

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

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

224 own. 

225 

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

227 assertions live in `test_roundtrip`. 

228 """ 

229 difference_image = _make_difference_image(legacy_test_data) 

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

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

232 

233 

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

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

236 kernel = ImageBasisConvolutionKernel.from_legacy(legacy_test_data.kernel) 

237 compare_kernel_to_legacy(kernel, legacy_test_data.kernel) 

238 legacy_kernel_2 = kernel.to_legacy() 

239 compare_kernel_to_legacy(kernel, legacy_kernel_2) 

240 

241 

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

243 """Test extracting template information from legacy template_detector 

244 components. 

245 """ 

246 template_info = DifferenceImageTemplateInfo.from_legacy( 

247 legacy_test_data.detector_frame, 

248 legacy_test_data.template_psf, 

249 legacy_test_data.template_metadata, 

250 DP2_TEMPLATE_COADD_DATASETS, 

251 ) 

252 _sanity_check_template_info(template_info, legacy_test_data.detector_frame) 

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

254 

255 

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

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

258 template. 

259 """ 

260 template_info = DifferenceImageTemplateInfo.from_legacy( 

261 legacy_test_data.detector_frame, 

262 legacy_test_data.template_psf, 

263 legacy_test_data.template_metadata, 

264 DP2_TEMPLATE_COADD_DATASETS, 

265 instrument="LSSTCam", 

266 ) 

267 assert template_info 

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

269 

270 

271def test_template_info_no_overlap_is_skipped( 

272 legacy_test_data: _LegacyTestData, caplog: pytest.LogCaptureFixture 

273) -> None: 

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

275 skipped in DifferenceImageTemplateInfo.from_legacy, instead of raising 

276 NoOverlapError. 

277 """ 

278 psf = legacy_test_data.template_psf 

279 n_components = psf.getComponentCount() 

280 assert n_components > 1 

281 

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

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

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

285 # from_legacy can still resolve its butler info. 

286 schema = LegacyExposureCatalog.Table.makeMinimalSchema() 

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

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

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

290 catalog = LegacyExposureCatalog(schema) 

291 shift = LegacyExtent2I(50000, 50000) 

292 for n in range(n_components): 

293 record = catalog.addNew() 

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

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

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

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

298 bbox = psf.getBBox(n) 

299 if n == 0: 

300 bbox = bbox.shiftedBy(shift) 

301 record.setBBox(bbox) 

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

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

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

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

306 

307 skipped_tract = psf.getTract(0) 

308 skipped_patch = psf.getPatch(0) 

309 with caplog.at_level(logging.ERROR): 

310 template_info = DifferenceImageTemplateInfo.from_legacy( 

311 legacy_test_data.detector_frame, 

312 shifted_psf, 

313 legacy_test_data.template_metadata, 

314 DP2_TEMPLATE_COADD_DATASETS, 

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

316 ) 

317 assert len(template_info) == n_components - 1 

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

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

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

321 assert any( 

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

323 for record in caplog.records 

324 ) 

325 

326 

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

328 

329 

330def test_difference_image_repr_str_pinned() -> None: 

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

332 rng = np.random.default_rng(500) 

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

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

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

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

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

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

339 di = DifferenceImage( 

340 image, 

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

342 mask_schema=mask_schema, 

343 sky_projection=sky_projection, 

344 detector=detector, 

345 obs_info=obs_info, 

346 band="r", 

347 ) 

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

349 assert repr(di) == ( 

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

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

352 " dtype=dtype('uint8')))" 

353 ) 

354 

355 

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

357 

358 

359def _make_template( 

360 tract: int = 1, 

361 patch: int = 2, 

362 *, 

363 skymap: str = "sky", 

364 dataset_run: str = "run", 

365 psf_shape_xx: float = 4.0, 

366 psf_shape_yy: float = 4.0, 

367 psf_shape_xy: float = 0.0, 

368 psf_shape_flag: bool = False, 

369) -> DifferenceImageTemplateInfo: 

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

371 not care about. 

372 """ 

373 return DifferenceImageTemplateInfo( 

374 skymap=skymap, 

375 tract=tract, 

376 patch=patch, 

377 dataset_id=uuid.uuid4(), 

378 dataset_run=dataset_run, 

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

380 psf_shape_xx=psf_shape_xx, 

381 psf_shape_yy=psf_shape_yy, 

382 psf_shape_xy=psf_shape_xy, 

383 psf_shape_flag=psf_shape_flag, 

384 ) 

385 

386 

387def test_template_info_instrument_defaults_to_unknown() -> None: 

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

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

390 """ 

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

392 

393 

394@pytest.mark.parametrize("curvature", [-5e-7, 5e-7]) 

395def test_template_info_bounds_cover_distorted_detector( 

396 reset_afw_mask_planes: None, # noqa: F811 

397 curvature: float, 

398) -> None: 

399 """Test that the bounds of two overlapping patches together cover a 

400 distorted detector whose edges their shared boundary crosses. 

401 

402 The detector edges are distorted in the coadd frame by about 2 pixels, so 

403 clipping each patch to the detector's corner-only footprint in the coadd 

404 frame would leave a sliver along one edge outside both patches' bounds. 

405 """ 

406 crval = LegacySpherePoint(150.0, 2.0, degrees) 

407 coadd_wcs = makeSkyWcs(LegacyPoint2D(1023.5, 2047.5), crval, makeCdMatrix(0.2 * arcseconds)) 

408 # x' = x + curvature*(y - yc)**2 and y' = y, with its exact inverse. 

409 yc = 2047.5 

410 forward = np.array( 

411 [ 

412 [1.0, 1, 1, 0], 

413 [curvature, 1, 0, 2], 

414 [-2 * curvature * yc, 1, 0, 1], 

415 [curvature * yc**2, 1, 0, 0], 

416 [1.0, 2, 0, 1], 

417 ] 

418 ) 

419 inverse = np.array( 

420 [ 

421 [1.0, 1, 1, 0], 

422 [-curvature, 1, 0, 2], 

423 [2 * curvature * yc, 1, 0, 1], 

424 [-curvature * yc**2, 1, 0, 0], 

425 [1.0, 2, 0, 1], 

426 ] 

427 ) 

428 detector_wcs = makeModifiedWcs(LegacyTransform(astshim.PolyMap(forward, inverse)), coadd_wcs, False) 

429 schema = LegacyExposureCatalog.Table.makeMinimalSchema() 

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

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

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

433 catalog = LegacyExposureCatalog(schema) 

434 patches = { 

435 0: LegacyBox2I(LegacyPoint2I(-500, -500), LegacyPoint2I(2600, 2100)), 

436 1: LegacyBox2I(LegacyPoint2I(-500, 2000), LegacyPoint2I(2600, 4600)), 

437 } 

438 for patch, patch_bbox in patches.items(): 

439 record = catalog.addNew() 

440 record.setWcs(coadd_wcs) 

441 record.setPsf(LegacyGaussianPsf(21, 21, 2.0)) 

442 record.setBBox(patch_bbox) 

443 record.setValidPolygon(LegacyPolygon(LegacyBox2D(patch_bbox))) 

444 record.set("weight", 1.0) 

445 record.set("tract", 7) 

446 record.set("patch", patch) 

447 psf = LegacyCoaddPsf(catalog, detector_wcs, LegacyPoint2D(1023.5, 2047.5)) 

448 detector_frame = DetectorFrame(instrument="Inst", visit=1, detector=1, bbox=Box.factory[0:4096, 0:2048]) 

449 

450 template_info = DifferenceImageTemplateInfo.from_legacy_psf( 

451 detector_frame, psf, "skymap", {(7, patch): (uuid.uuid4(), "run") for patch in patches} 

452 ) 

453 

454 assert len(template_info) == 2 

455 # The centers of the pixels along all four detector edges. 

456 ys = np.arange(4096, dtype=float) 

457 xs = np.arange(2048, dtype=float) 

458 x = np.concatenate([np.zeros_like(ys), np.full_like(ys, 2047.0), xs, xs]) 

459 y = np.concatenate([ys, ys, np.zeros_like(xs), np.full_like(xs, 4095.0)]) 

460 covered = np.zeros(x.size, dtype=bool) 

461 for info in template_info: 

462 assert detector_frame.bbox.contains(info.bounds.bbox) 

463 covered |= info.bounds.contains(x=x, y=y) 

464 assert covered.all() 

465 

466 

467def test_describe_templates_hoists_shared_values() -> None: 

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

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

470 fields, table = DifferenceImageTemplateInfo._describe_templates(templates) 

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

472 assert table.title == "Templates" 

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

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

475 

476 

477def test_describe_templates_keeps_varying_values_as_columns() -> None: 

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

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

480 fields, table = DifferenceImageTemplateInfo._describe_templates(templates) 

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

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

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

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

485 

486 

487def test_describe_templates_marks_unusable_psf_shapes() -> None: 

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

489 column appears only when some template carries it. 

490 """ 

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

492 flagged = _make_template(psf_shape_flag=True) 

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

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

495 assert "PSF flag" not in table.columns 

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

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

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

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

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

501 

502 

503def test_difference_image_describe_reports_templates_and_kernel() -> None: 

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

505 a visit image. 

506 """ 

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

508 report = difference_image.describe() 

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

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

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

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

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

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

515 report.__rich__() 

516 

517 

518def test_difference_image_describe_brief_skips_templates_and_kernel() -> None: 

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

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

521 report = difference_image.describe(brief=True) 

522 assert report.tables == [] 

523 assert "kernel" not in report.children 

524 

525 

526def test_convolution_kernel_describes_itself() -> None: 

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

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

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

530 report = kernel.describe() 

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

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

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

534 assert report.children == {} 

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