Coverage for tests/test_calibrateImage.py: 98%

618 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 09:53 +0000

1# This file is part of pipe_tasks. 

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# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22import unittest 

23from unittest import mock 

24import tempfile 

25 

26import astropy.units as u 

27from astropy.coordinates import SkyCoord 

28import copy 

29import numpy as np 

30import esutil 

31import os 

32import requests 

33 

34import lsst.afw.image as afwImage 

35import lsst.afw.math as afwMath 

36import lsst.afw.table as afwTable 

37from lsst.afw.cameraGeom.testUtils import CameraWrapper 

38import lsst.daf.base 

39import lsst.daf.butler 

40import lsst.daf.butler.tests as butlerTests 

41import lsst.geom 

42import lsst.images 

43import lsst.meas.algorithms 

44from lsst.meas.algorithms import testUtils 

45import lsst.meas.extensions.psfex 

46import lsst.meas.base 

47import lsst.meas.base.tests 

48import lsst.pipe.base as pipeBase 

49import lsst.pipe.base.testUtils 

50from lsst.pipe.tasks.calibrateImage import CalibrateImageTask, \ 

51 NoPsfStarsToStarsMatchError, AllCentroidsFlaggedError 

52import lsst.pex.config as pexConfig 

53import lsst.utils.tests 

54 

55from utils import makeTestVisitInfo, make_exposure_record 

56 

57 

58class CalibrateImageTaskTests(lsst.utils.tests.TestCase): 

59 

60 def setUp(self): 

61 # Different x/y dimensions so they're easy to distinguish in a plot, 

62 # and non-zero minimum, to help catch xy0 errors. 

63 bbox = lsst.geom.Box2I(lsst.geom.Point2I(5, 4), lsst.geom.Point2I(205, 184)) 

64 self.sky_center = lsst.geom.SpherePoint(245.0, -45.0, lsst.geom.degrees) 

65 self.photo_calib = 12.3 

66 dataset = lsst.meas.base.tests.TestDataset(bbox, crval=self.sky_center, calibration=self.photo_calib) 

67 # sqrt of area of a normalized 2d gaussian 

68 psf_scale = np.sqrt(4*np.pi*(dataset.psfShape.getDeterminantRadius())**2) 

69 noise = 10.0 # stddev of noise per pixel 

70 # Sources ordered from faintest to brightest. 

71 self.fluxes = np.array((6*noise*psf_scale, 

72 12*noise*psf_scale, 

73 45*noise*psf_scale, 

74 150*noise*psf_scale, 

75 400*noise*psf_scale, 

76 1000*noise*psf_scale)) 

77 self.centroids = np.array(((162, 22), 

78 (40, 70), 

79 (100, 160), 

80 (50, 120), 

81 (92, 35), 

82 (175, 154)), dtype=np.float32) 

83 for flux, centroid in zip(self.fluxes, self.centroids): 

84 dataset.addSource(instFlux=flux, centroid=lsst.geom.Point2D(centroid[0], centroid[1])) 

85 

86 # Bright extended source in the center of the image: should not appear 

87 # in any of the output catalogs. 

88 self.extended_source = lsst.geom.Point2D(100, 100) 

89 shape = lsst.afw.geom.Quadrupole(8, 9, 3) 

90 dataset.addSource(instFlux=500*noise*psf_scale, centroid=self.extended_source, shape=shape) 

91 

92 schema = dataset.makeMinimalSchema() 

93 self.truth_exposure, self.truth_cat = dataset.realize(noise=noise, schema=schema) 

94 # Add in a significant background, so we can test that the output 

95 # background is self-consistent with the calibrated exposure. 

96 self.background_level = 500.0 

97 self.truth_exposure.image += self.background_level 

98 # To make it look like a version=1 (nJy fluxes) refcat 

99 self.truth_cat = self.truth_exposure.photoCalib.calibrateCatalog(self.truth_cat) 

100 self.ref_loader = testUtils.MockReferenceObjectLoaderFromMemory([self.truth_cat]) 

101 metadata = lsst.daf.base.PropertyList() 

102 metadata.set("REFCAT_FORMAT_VERSION", 1) 

103 self.truth_cat.setMetadata(metadata) 

104 

105 # TODO: a cosmic ray (need to figure out how to insert a fake-CR) 

106 # self.truth_exposure.image.array[10, 10] = 100000 

107 # self.truth_exposure.variance.array[10, 10] = 100000/noise 

108 

109 # Copy the truth exposure, because CalibrateImage modifies the input. 

110 # Post-ISR images only contain: initial WCS, VisitInfo, filter 

111 self.exposure = afwImage.ExposureF(self.truth_exposure, deep=True) 

112 self.exposure.setWcs(self.truth_exposure.wcs) 

113 self.exposure.info.setVisitInfo(self.truth_exposure.visitInfo) 

114 self.exposure.info.id = 12345 

115 # "truth" filter, to match the "truth" refcat. 

116 self.exposure.setFilter(lsst.afw.image.FilterLabel(physical='truth', band="truth")) 

117 self.exposure.metadata["LSST ISR FLAT APPLIED"] = True 

118 

119 # Set up a basic results struct to hold exposure attribute data 

120 self.attributes = pipeBase.Struct() 

121 self.attributes.exposure = self.exposure 

122 self.attributes.background = None 

123 self.attributes.background_to_photometric_ratio = None 

124 

125 # Test-specific configuration: 

126 self.config = CalibrateImageTask.ConfigClass() 

127 # We don't have many sources, so have to fit simpler models. 

128 self.config.compute_shapelet_decomposition.min_n_stars = 4 

129 self.config.psf_detection.background.approxOrderX = 1 

130 self.config.star_detection.background.approxOrderX = 1 

131 # Only insert 2 sky sources, for simplicity. 

132 self.config.star_sky_sources.nSources = 2 

133 # Use PCA psf fitter, as psfex fails if there are only 4 stars. 

134 self.config.psf_measure_psf.psfDeterminer = 'pca' 

135 # We don't have many test points, so can't match on complicated shapes. 

136 self.config.astrometry.sourceSelector["science"].flags.good = [] 

137 self.config.astrometry.matcher.numPointsForShape = 3 

138 self.config.run_sattle = False 

139 # Maintain original, no adaptive threshold detection, configs values. 

140 self.config.do_adaptive_threshold_detection = False 

141 self.config.psf_detection.reEstimateBackground = True 

142 self.config.star_detection.reEstimateBackground = True 

143 # ApFlux has more noise than PsfFlux (the latter unrealistically small 

144 # in this test data), so we need to do magnitude rejection at higher 

145 # sigma, otherwise we can lose otherwise good sources. 

146 # TODO DM-39203: Once we are using Compensated Gaussian Fluxes, we 

147 # will use those fluxes here, and hopefully can remove this. 

148 self.config.astrometry.magnitudeOutlierRejectionNSigma = 9.0 

149 

150 # Make a realistic id generator so that output catalog ids are useful. 

151 # NOTE: The id generator is used to seed the noise replacer during 

152 # measurement, so changes to values here can have subtle effects on 

153 # the centroids and fluxes measured on the image, which might cause 

154 # tests to fail. 

155 data_id = lsst.daf.butler.DataCoordinate.standardize( 

156 instrument="I", 

157 visit=self.truth_exposure.visitInfo.id, 

158 detector=12, 

159 universe=lsst.daf.butler.DimensionUniverse(), 

160 ) 

161 self.config.id_generator.packer.name = "observation" 

162 self.config.id_generator.packer["observation"].n_observations = 10000 

163 self.config.id_generator.packer["observation"].n_detectors = 99 

164 self.config.id_generator.n_releases = 8 

165 self.config.id_generator.release_id = 2 

166 self.id_generator = self.config.id_generator.apply(data_id) 

167 

168 # Something about this test dataset prefers a larger threshold here. 

169 self.config.star_selector["science"].unresolved.maximum = 0.2 

170 

171 def _check_run(self, calibrate, result, expect_calibrated_pixels: bool = True, 

172 expect_n_background: int = 4, expect_n_background_equal_or_greater_than: int = -1, 

173 do_shapelet_check: bool = False): 

174 """Test the result of CalibrateImage.run(). 

175 

176 Parameters 

177 ---------- 

178 calibrate : `lsst.pipe.tasks.calibrateImage.CalibrateImageTask` 

179 Configured task that had `run` called on it. 

180 result : `lsst.pipe.base.Struct` 

181 Result of calling calibrate.run(). 

182 expect_calibrated_pixels : `bool`, optional 

183 Whether to expect image and background pixels to be calibrated. 

184 """ 

185 # Background should have 4 elements: 3 from compute_psf and one from 

186 # re-estimation during source detection. 

187 if expect_n_background is not None: 

188 self.assertEqual(len(result.background), expect_n_background) 

189 if expect_n_background_equal_or_greater_than is not None: 189 ↛ 193line 189 didn't jump to line 193 because the condition on line 189 was always true

190 self.assertTrue(len(result.background) >= expect_n_background_equal_or_greater_than) 

191 

192 # Both afw and astropy psf_stars catalogs should be populated. 

193 self.assertEqual(result.psf_stars["calib_psf_used"].sum(), 3) 

194 self.assertEqual(result.psf_stars_footprints["calib_psf_used"].sum(), 3) 

195 

196 # Check that the summary statistics are reasonable. 

197 summary = result.exposure.info.getSummaryStats() 

198 # Values below are only correct for the base run test. 

199 if do_shapelet_check: 

200 self.assertFloatsAlmostEqual(summary.shapeletsOnlyIqScore, 8.104235164021247e-06, rtol=1e-4) 

201 self.assertFloatsAlmostEqual(summary.shapeletsIqScore, 6.651368381893731e-05, rtol=1e-4) 

202 shapeletsCoeffs = [ 

203 0.28358882665634155, -0.00037077977926209773, -0.002802249127080195, 

204 3.439385909587145e-05, -0.0012995795522691303, -0.0018000953572589512, 

205 4.4449956901327674e-05, -2.5397295072102522e-05, 3.0203139976223994e-05, 

206 0.000617273102631627, -4.456396106385861e-05, 8.730505214771256e-05, 

207 0.00011167120828758925, 0.00017521437985124066, -1.5413010141236832e-05, 

208 -0.00015419833136474156, -8.099463936604213e-05, -0.00017129145577976686, 

209 -9.799635877346543e-05, 2.7090113467370116e-05, 0.0004025635973896668, 

210 -6.346980774284659e-05, 0.00013586174545049867, -0.00011295261583971951, 

211 0.0003724458365468307, 1.5024524355547979e-05, 0.00012497524707246052, 

212 0.00041057434072064747 

213 ] 

214 for iShapelet in range(len(shapeletsCoeffs)): 

215 self.assertFloatsAlmostEqual( 

216 summary.shapeletsCoeffs[iShapelet], shapeletsCoeffs[iShapelet], rtol=2e-3) 

217 self.assertEqual(summary.nShapeletsStar, 4) 

218 self.assertFloatsAlmostEqual( 

219 summary.centroidDiffShapeletsVsSlotMedian, 0.10838090573, rtol=1e-3) 

220 

221 self.assertFloatsAlmostEqual(summary.refCatSourceDensity, 39470.0430204, rtol=1e-3) 

222 self.assertFloatsAlmostEqual(summary.psfSigma, 2.0, rtol=1e-2) 

223 self.assertFloatsAlmostEqual(summary.ra, self.sky_center.getRa().asDegrees(), rtol=1e-7) 

224 self.assertFloatsAlmostEqual(summary.dec, self.sky_center.getDec().asDegrees(), rtol=1e-7) 

225 

226 # Should have finite sky coordinates in the afw and astropy catalogs. 

227 self.assertTrue(np.isfinite(result.stars_footprints["coord_ra"]).all()) 

228 self.assertTrue(np.isfinite(result.stars["coord_ra"]).all()) 

229 

230 if expect_calibrated_pixels: 

231 # Fit photoCalib should be the applied value if we calibrated 

232 # pixels, not the ==1 one on the exposure. 

233 photo_calib = result.applied_photo_calib 

234 self.assertEqual(result.exposure.photoCalib.getCalibrationMean(), 1.0) 

235 else: 

236 self.assertIsNone(result.applied_photo_calib) 

237 photo_calib = result.exposure.photoCalib 

238 # PhotoCalib comparison is very approximate because we are basing this 

239 # comparison on just 2-3 stars. 

240 self.assertFloatsAlmostEqual(photo_calib.getCalibrationMean(), self.photo_calib, rtol=1e-2) 

241 # Should have calibrated flux/mags in the afw and astropy catalogs 

242 self.assertIn("slot_PsfFlux_flux", result.stars_footprints.schema) 

243 self.assertIn("slot_PsfFlux_mag", result.stars_footprints.schema) 

244 self.assertEqual(result.stars["slot_PsfFlux_flux"].unit, u.nJy) 

245 self.assertEqual(result.stars["slot_PsfFlux_mag"].unit, u.ABmag) 

246 

247 # Should have detected all S/N >= 10 sources plus 2 sky sources, 

248 # whether 1 or 2 snaps. 

249 self.assertEqual(len(result.stars), 6) 

250 # Did the psf flags get propagated from the psf_stars catalog? 

251 self.assertEqual(result.stars["calib_psf_used"].sum(), 3) 

252 

253 # Check that all necessary fields are in the output. 

254 lsst.pipe.base.testUtils.assertValidOutput(calibrate, result) 

255 

256 # Check metadata. 

257 key = "LSST CALIB ILLUMCORR APPLIED" 

258 self.assertIn(key, result.exposure.metadata) 

259 self.assertEqual(result.exposure.metadata[key], False) 

260 key = "REF_CAT_SOURCE_DENSITY" 

261 self.assertIn(key, result.exposure.metadata) 

262 

263 # Check that the psf_stars cross match worked correctly. 

264 matches = esutil.numpy_util.match(result.psf_stars["id"], result.stars["psf_id"]) 

265 self.assertFloatsAlmostEqual(result.psf_stars["slot_Centroid_x"][matches[0]], 

266 result.stars["slot_Centroid_x"][matches[1]], atol=3e-4) 

267 if "astrometry_matches" in self.config.optional_outputs: 

268 matches = esutil.numpy_util.match(result.astrometry_matches["src_id"], 

269 result.photometry_matches["src_psf_id"]) 

270 self.assertFloatsAlmostEqual(result.astrometry_matches["src_slot_Centroid_x"][matches[0]], 

271 result.photometry_matches["src_slot_Centroid_x"][matches[1]], 

272 atol=3e-4) 

273 

274 def _run_legacy_for_future(self, config): 

275 """Run the task configured for the `lsst.images` output type, and 

276 return what it produced, without converting it. 

277 

278 Parameters 

279 ---------- 

280 config : `lsst.pipe.tasks.calibrateImage.CalibrateImageConfig` 

281 Configuration to run with; ``output_image_type`` is set to 

282 "future" here. 

283 

284 Returns 

285 ------- 

286 calibrate : `lsst.pipe.tasks.calibrateImage.CalibrateImageTask` 

287 The task that was run, to convert the result with. 

288 result : `lsst.pipe.base.Struct` 

289 The legacy output struct. 

290 """ 

291 # lsst.images needs raw amplifier geometry and a FIELD_ANGLE 

292 # transform, which the trivial test detectors do not have. 

293 self.exposure.setDetector(list(CameraWrapper().camera)[0]) 

294 config.output_image_type = "future" 

295 calibrate = CalibrateImageTask(config=config) 

296 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

297 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

298 result = calibrate.run(exposures=self.exposure) 

299 # run() always produces legacy types, whatever output_image_type is. 

300 self.assertIsInstance(result.exposure, afwImage.Exposure) 

301 return calibrate, result 

302 

303 def _run_future(self, config): 

304 """Run the task with an `lsst.images` output type, and convert the 

305 result. 

306 

307 Parameters 

308 ---------- 

309 config : `lsst.pipe.tasks.calibrateImage.CalibrateImageConfig` 

310 Configuration to run with; ``output_image_type`` is set to 

311 "future" here. 

312 

313 Returns 

314 ------- 

315 result : `lsst.pipe.base.Struct` 

316 The converted output struct. 

317 

318 Notes 

319 ----- 

320 The conversion removes ``applied_photo_calib`` from the struct, so 

321 the value `run` produced is saved as ``self.applied_photo_calib`` 

322 for tests that need to compare against it. 

323 """ 

324 calibrate, result = self._run_legacy_for_future(config) 

325 self.applied_photo_calib = result.applied_photo_calib 

326 calibrate.convert_outputs_to_future(result, make_exposure_record(self.exposure)) 

327 return result 

328 

329 def test_convert_outputs_to_future(self): 

330 """Test that the output exposure is converted to a VisitImage with 

331 the photometric calibration and background attached. 

332 """ 

333 config = copy.copy(self.config) 

334 result = self._run_future(config) 

335 

336 self.assertIsInstance(result.exposure, lsst.images.VisitImage) 

337 # Pixels were calibrated, so both image and background are in nJy. 

338 self.assertEqual(result.exposure.unit, u.nJy) 

339 self.assertEqual(result.exposure.backgrounds["subtracted"].field.unit, u.nJy) 

340 self.assertEqual(result.exposure.backgrounds.subtracted.name, "subtracted") 

341 

342 # photometric_scaling is the calibration that was applied, not the 

343 # identity that is left attached to the calibrated exposure. 

344 self.assertIsNotNone(result.exposure.photometric_scaling) 

345 self.assertEqual(result.exposure.photometric_scaling.unit, u.nJy/u.electron) 

346 round_tripped = result.exposure.photometric_scaling.to_legacy_photo_calib(u.electron) 

347 self.assertFloatsAlmostEqual(round_tripped.getCalibrationMean(), 

348 self.applied_photo_calib.getCalibrationMean(), 

349 rtol=1e-14) 

350 

351 # The background and the applied calibration were moved onto the 

352 # image, so the separate outputs are gone; both connections are 

353 # deleted in this mode. 

354 self.assertFalse(hasattr(result, "background")) 

355 self.assertFalse(hasattr(result, "applied_photo_calib")) 

356 

357 def test_convert_outputs_to_future_no_calibrate_pixels(self): 

358 """Test the conversion when the pixels are left in instrumental 

359 units, so that the photometric calibration has not been applied. 

360 """ 

361 config = copy.copy(self.config) 

362 config.do_calibrate_pixels = False 

363 result = self._run_future(config) 

364 

365 self.assertIsNone(self.applied_photo_calib) 

366 instrumental_unit = u.Unit(config.instrumental_unit) 

367 self.assertEqual(result.exposure.unit, instrumental_unit) 

368 self.assertEqual(result.exposure.backgrounds["subtracted"].field.unit, instrumental_unit) 

369 self.assertEqual(result.exposure.photometric_scaling.unit, u.nJy/instrumental_unit) 

370 self.assertFalse(hasattr(result, "background")) 

371 self.assertFalse(hasattr(result, "applied_photo_calib")) 

372 

373 def test_convert_outputs_to_future_partial_outputs_struct(self): 

374 """Test conversion of a struct with only the fields ``runQuantum`` 

375 seeds before calling ``run``: no ``background``, and 

376 ``applied_photo_calib`` set to `None`. 

377 """ 

378 config = copy.copy(self.config) 

379 # Leave the pixels uncalibrated, to match applied_photo_calib=None. 

380 config.do_calibrate_pixels = False 

381 calibrate, run_result = self._run_legacy_for_future(config) 

382 

383 # Same fields, in the same state, as the struct runQuantum seeds. 

384 partial = pipeBase.Struct( 

385 exposure=run_result.exposure, 

386 stars_footprints=None, 

387 psf_stars_footprints=None, 

388 background_to_photometric_ratio=None, 

389 applied_photo_calib=None, 

390 ) 

391 calibrate.convert_outputs_to_future(partial, make_exposure_record(self.exposure)) 

392 self.assertIsInstance(partial.exposure, lsst.images.VisitImage) 

393 

394 def test_run(self): 

395 """Test that run() returns reasonable values to be butler put. 

396 """ 

397 calibrate = CalibrateImageTask(config=self.config) 

398 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

399 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

400 result = calibrate.run(exposures=self.exposure) 

401 

402 self._check_run(calibrate, result, do_shapelet_check=True) 

403 

404 def test_run_adaptive_threshold_detection(self): 

405 """Test that run() runs with adaptive threshold detection turned on. 

406 """ 

407 config = copy.copy(self.config) 

408 # Set the adaptive threshold detection, config values... 

409 config.do_adaptive_threshold_detection = True 

410 config.psf_adaptive_threshold_detection.minFootprint = 4 

411 config.psf_adaptive_threshold_detection.minIsolated = 4 

412 config.psf_adaptive_threshold_detection.sufficientIsolated = 4 

413 config.psf_detection.reEstimateBackground = False 

414 config.star_detection.reEstimateBackground = False 

415 

416 calibrate = CalibrateImageTask(config=config) 

417 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

418 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

419 with self.assertLogs("lsst.calibrateImage", level="INFO") as cm: 

420 result = calibrate.run(exposures=self.exposure) 

421 subString = "Using adaptive threshold detection " 

422 self.assertTrue(any(subString in s for s in cm.output)) 

423 

424 # Number of backgrounds in list is only guaranteed to have at least 2 

425 # entries in the adaptive threshold code path. 

426 self._check_run(calibrate, result, expect_n_background=None, 

427 expect_n_background_equal_or_greater_than=2) 

428 

429 def test_run_downsample(self): 

430 """Test that run() runs with downsample. 

431 """ 

432 config = copy.copy(self.config) 

433 config.do_downsample_footprints = True 

434 config.downsample_max_footprints = 5 

435 

436 calibrate = CalibrateImageTask(config=config) 

437 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

438 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

439 with self.assertLogs("lsst.calibrateImage", level="INFO") as cm: 

440 result = calibrate.run(exposures=self.exposure) 

441 self.assertIn( 

442 "INFO:lsst.calibrateImage:Downsampling from 8 to 7 non-sky-source footprints.", 

443 cm.output, 

444 ) 

445 

446 self._check_run(calibrate, result) 

447 

448 def test_run_2_snaps(self): 

449 """Test that run() returns reasonable values to be butler put, when 

450 passed two exposures to combine as snaps. 

451 """ 

452 calibrate = CalibrateImageTask(config=self.config) 

453 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

454 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

455 # Halve the flux in each exposure to get the expected visit sum. 

456 self.exposure.image /= 2 

457 self.exposure.variance /= 2 

458 result = calibrate.run(exposures=[self.exposure, self.exposure]) 

459 

460 self._check_run(calibrate, result) 

461 

462 def test_run_no_optionals(self): 

463 """Test that disabling optional outputs removes them from the output 

464 struct, as appropriate. 

465 """ 

466 self.config.optional_outputs = [] 

467 calibrate = CalibrateImageTask(config=self.config) 

468 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

469 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

470 result = calibrate.run(exposures=self.exposure) 

471 

472 self._check_run(calibrate, result) 

473 # These are the only optional outputs that require extra computation, 

474 # the others are included in the output struct regardless. 

475 self.assertNotIn("astrometry_matches", result.getDict()) 

476 self.assertNotIn("photometry_matches", result.getDict()) 

477 

478 def test_run_no_calibrate_pixels(self): 

479 """Test that run() returns reasonable values when 

480 do_calibrate_pixels=False. 

481 """ 

482 self.config.do_calibrate_pixels = False 

483 calibrate = CalibrateImageTask(config=self.config) 

484 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

485 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

486 result = calibrate.run(exposures=self.exposure) 

487 

488 self._check_run(calibrate, result, expect_calibrated_pixels=False) 

489 

490 def test_run_no_astrom_errors(self): 

491 """Test that run() returns reasonable values when 

492 do_calibrate_pixels=False. 

493 """ 

494 self.config.do_include_astrometric_errors = False 

495 calibrate = CalibrateImageTask(config=self.config) 

496 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

497 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

498 result = calibrate.run(exposures=self.exposure) 

499 

500 self._check_run(calibrate, result) 

501 

502 def test_compute_psf(self): 

503 """Test that our brightest sources are found by _compute_psf(), 

504 that a PSF is assigned to the exposure. 

505 """ 

506 calibrate = CalibrateImageTask(config=self.config) 

507 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator) 

508 psf_stars = compute_psf_struct.detections_sources 

509 

510 # Catalog ids should be very large from this id generator. 

511 self.assertTrue(all(psf_stars['id'] > 1000000000)) 

512 

513 # Background should have 3 elements: initial subtraction, and two from 

514 # re-estimation during the two detection passes. 

515 self.assertEqual(len(self.attributes.background), 3) 

516 

517 # Only the point-sources with S/N > 50 should be in this output. 

518 self.assertEqual(psf_stars["calib_psf_used"].sum(), 3) 

519 # Sort in brightness order, to easily compare with expected positions. 

520 psf_stars.sort(psf_stars.getPsfFluxSlot().getMeasKey()) 

521 for record, flux, center in zip(psf_stars[::-1], self.fluxes, self.centroids[self.fluxes > 50]): 521 ↛ 522line 521 didn't jump to line 522 because the loop on line 521 never started

522 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01) 

523 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01) 

524 # PsfFlux should match the values inserted. 

525 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01) 

526 

527 # TODO: While debugging DM-32701, we're using PCA instead of psfex. 

528 # Check that we got a useable PSF. 

529 # self.assertIsInstance(self.exposure.psf, lsst.meas.extensions.psfex.PsfexPsf) 

530 self.assertIsInstance(self.exposure.psf, lsst.meas.algorithms.PcaPsf) 

531 # TestDataset sources have PSF radius=2 pixels. 

532 radius = self.exposure.psf.computeShape(self.exposure.psf.getAveragePosition()).getDeterminantRadius() 

533 self.assertFloatsAlmostEqual(radius, 2.0, rtol=1e-2) 

534 

535 # To look at images for debugging (`setup display_ds9` and run ds9): 

536 # import lsst.afw.display 

537 # display = lsst.afw.display.getDisplay() 

538 # display.mtv(self.exposure) 

539 

540 def test_compute_psf_bad_centroids(self): 

541 """Test that we raise an appropriate error if all measured centroids 

542 are bad in compute_psf. 

543 The root cause of this is likely an interaction between the PSF fit 

544 and cosmic ray repair. An ugly true PSF can result in the centers of 

545 sources being masked and repaired as CRs, thus removing the center of 

546 the sources and causing them all to be flagged. 

547 """ 

548 # make the sources have a (not too deep) hole in the middle 

549 for point in self.centroids: 

550 for i in (-1, 0, 1): 

551 for j in (-1, 0, 1): 

552 self.exposure.image[point[0]+i, point[1]+j] -= \ 

553 (self.exposure.image[point[0]+i, point[1]+j] - self.background_level)/1.1 

554 # and the extended central source 

555 point = self.extended_source 

556 self.exposure.image[point[0], point[1]] -= \ 

557 (self.exposure.image[point[0], point[1]] - self.background_level) 

558 

559 # add a diagonal gradient to each source 

560 size = 5 

561 for point, flux in zip(self.centroids, self.fluxes): 

562 box = lsst.geom.Box2I.makeCenteredBox(lsst.geom.Point2D(point[0], point[1]), 

563 lsst.geom.Extent2I(size*2, size*2)) 

564 X, Y = np.ogrid[point[0] - size:point[0] + size, point[1] - size:point[1] + size] 

565 distance = np.sqrt((X - point[0])**2 + (Y - point[1])**2) 

566 gradient = (X - point[0] + size)*10 + (Y - point[1] + size)*10 

567 gradient[distance > size] = 0 

568 cutout = self.exposure.image.subset(box) 

569 cutout.array += gradient / flux 

570 

571 calibrate = CalibrateImageTask(config=self.config) 

572 with self.assertRaisesRegex(AllCentroidsFlaggedError, r"source centroids \(out of 4\) flagged"): 

573 calibrate.run(exposures=[self.exposure], id_generator=self.id_generator) 

574 

575 def test_measure_aperture_correction(self): 

576 """Test that _measure_aperture_correction() assigns an ApCorrMap to the 

577 exposure. 

578 """ 

579 calibrate = CalibrateImageTask(config=self.config) 

580 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator) 

581 psf_stars = compute_psf_struct.detections_sources 

582 

583 # First check that the exposure doesn't have an ApCorrMap. 

584 self.assertIsNone(self.exposure.apCorrMap) 

585 calibrate._measure_aperture_correction(self.exposure, psf_stars) 

586 self.assertIsInstance(self.exposure.apCorrMap, afwImage.ApCorrMap) 

587 # We know that there are 2 fields from the normalization, plus more 

588 # from other configured plugins. 

589 self.assertGreater(len(self.exposure.apCorrMap), 2) 

590 

591 def test_find_stars(self): 

592 """Test that _find_stars() correctly identifies the S/N>10 stars 

593 in the image and returns them in the output catalog. 

594 """ 

595 calibrate = CalibrateImageTask(config=self.config) 

596 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator) 

597 psf_stars = compute_psf_struct.detections_sources 

598 calibrate._measure_aperture_correction(self.exposure, psf_stars) 

599 

600 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator) 

601 

602 # Catalog ids should be very large from this id generator. 

603 self.assertTrue(all(stars['id'] > 1000000000)) 

604 

605 # Background should have 4 elements: 3 from compute_psf and one from 

606 # re-estimation during source detection. 

607 self.assertEqual(len(self.attributes.background), 4) 

608 

609 # Only 5 psf-like sources with S/N>10 should be in the output catalog, 

610 # plus two sky sources. 

611 self.assertEqual(len(stars), 6) 

612 self.assertTrue(stars.isContiguous()) 

613 # Sort in brightness order, to easily compare with expected positions. 

614 stars.sort(stars.getPsfFluxSlot().getMeasKey()) 

615 for record, flux, center in zip(stars[::-1], self.fluxes, self.centroids[self.fluxes > 50]): 615 ↛ 616line 615 didn't jump to line 616 because the loop on line 615 never started

616 self.assertFloatsAlmostEqual(record.getX(), center[0], rtol=0.01) 

617 self.assertFloatsAlmostEqual(record.getY(), center[1], rtol=0.01) 

618 self.assertFloatsAlmostEqual(record["slot_PsfFlux_instFlux"], flux, rtol=0.01) 

619 

620 def test_astrometry(self): 

621 """Test that the fitted WCS gives good catalog coordinates. 

622 """ 

623 calibrate = CalibrateImageTask(config=self.config) 

624 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

625 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator) 

626 psf_stars = compute_psf_struct.detections_sources 

627 calibrate._measure_aperture_correction(self.exposure, psf_stars) 

628 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator) 

629 

630 calibrate._fit_astrometry(self.exposure, stars) 

631 

632 # Check that we got reliable matches with the truth coordinates. 

633 sky = stars["sky_source"] 

634 fitted = SkyCoord(stars[~sky]['coord_ra'], stars[~sky]['coord_dec'], unit="radian") 

635 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian") 

636 idx, d2d, _ = fitted.match_to_catalog_sky(truth) 

637 np.testing.assert_array_less(d2d.to_value(u.milliarcsecond), 35.0) 

638 

639 def test_photometry(self): 

640 """Test that the fitted photoCalib matches the one we generated, 

641 and that the exposure is calibrated. 

642 """ 

643 calibrate = CalibrateImageTask(config=self.config) 

644 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

645 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

646 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator) 

647 psf_stars = compute_psf_struct.detections_sources 

648 calibrate._measure_aperture_correction(self.exposure, psf_stars) 

649 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator) 

650 calibrate._fit_astrometry(self.exposure, stars) 

651 

652 stars, matches, meta, photoCalib = calibrate._fit_photometry(self.exposure, stars) 

653 calibrate._apply_photometry(self.exposure, self.attributes.background) 

654 

655 # NOTE: With this test data, PhotoCalTask returns calibrationErr==0, 

656 # so we can't check that the photoCal error has been set. 

657 self.assertFloatsAlmostEqual(photoCalib.getCalibrationMean(), self.photo_calib, rtol=1e-2) 

658 # The exposure should be calibrated by the applied photoCalib, 

659 # and the background should be calibrated to match. 

660 uncalibrated = self.exposure.image.clone() 

661 uncalibrated += self.attributes.background.getImage() 

662 uncalibrated /= self.photo_calib 

663 self.assertFloatsAlmostEqual(uncalibrated.array, self.truth_exposure.image.array, rtol=1e-2) 

664 # PhotoCalib on the exposure must be identically 1. 

665 self.assertEqual(self.exposure.photoCalib.getCalibrationMean(), 1.0) 

666 

667 # Check that we got reliable magnitudes and fluxes vs. truth, ignoring 

668 # sky sources. 

669 sky = stars["sky_source"] 

670 fitted = SkyCoord(stars[~sky]['coord_ra'], stars[~sky]['coord_dec'], unit="radian") 

671 truth = SkyCoord(self.truth_cat['coord_ra'], self.truth_cat['coord_dec'], unit="radian") 

672 idx, _, _ = fitted.match_to_catalog_sky(truth) 

673 # Because the input variance image does not include contributions from 

674 # the sources, we can't use fluxErr as a bound on the measurement 

675 # quality here. 

676 self.assertFloatsAlmostEqual(stars[~sky]['slot_PsfFlux_flux'], 

677 self.truth_cat['truth_flux'][idx], 

678 rtol=0.1) 

679 self.assertFloatsAlmostEqual(stars[~sky]['slot_PsfFlux_mag'], 

680 self.truth_cat['truth_mag'][idx], 

681 rtol=0.01) 

682 

683 def test_match_psf_stars(self): 

684 """Test that _match_psf_stars() flags the correct stars as psf stars 

685 and candidates. 

686 """ 

687 calibrate = CalibrateImageTask(config=self.config) 

688 compute_psf_struct = calibrate._compute_psf(self.attributes, self.id_generator) 

689 psf_stars = compute_psf_struct.detections_sources 

690 calibrate._measure_aperture_correction(self.exposure, psf_stars) 

691 stars = calibrate._find_stars(self.exposure, self.attributes.background, self.id_generator) 

692 

693 # There should be no psf-related flags set at first. 

694 self.assertEqual(stars["calib_psf_candidate"].sum(), 0) 

695 self.assertEqual(stars["calib_psf_used"].sum(), 0) 

696 self.assertEqual(stars["calib_psf_reserved"].sum(), 0) 

697 

698 # Reorder stars to be out of order with psf_stars (putting the sky 

699 # sources in front); this tests that I get the indexing right. 

700 stars.sort(stars.getCentroidSlot().getMeasKey().getX()) 

701 stars = stars.copy(deep=True) 

702 # Re-number the ids: the matcher requires sorted ids: this is always 

703 # true in the code itself, but we've permuted them by sorting on 

704 # flux. We don't care what the actual ids themselves are here. 

705 stars["id"] = np.arange(len(stars)) 

706 

707 calibrate._match_psf_stars(psf_stars, stars) 

708 

709 # Check that the three brightest stars have the psf flags transferred 

710 # from the psf_stars catalog by sorting in order of brightness. 

711 stars.sort(stars.getPsfFluxSlot().getMeasKey()) 

712 # sort() above leaves the catalog non-contiguous. 

713 stars = stars.copy(deep=True) 

714 np.testing.assert_array_equal(stars["calib_psf_candidate"], 

715 [False, False, False, True, True, True]) 

716 np.testing.assert_array_equal(stars["calib_psf_used"], 

717 [False, False, False, True, True, True]) 

718 # Too few sources to reserve any in these tests. 

719 self.assertEqual(stars["calib_psf_reserved"].sum(), 0) 

720 

721 def test_match_psf_stars_no_matches(self): 

722 """Check that _match_psf_stars handles the case of no cross-matches. 

723 """ 

724 calibrate = CalibrateImageTask(config=self.config) 

725 # Make two catalogs that cannot have matches. 

726 stars = self.truth_cat[2:].copy(deep=True) 

727 psf_stars = self.truth_cat[:2].copy(deep=True) 

728 

729 with self.assertRaisesRegex(NoPsfStarsToStarsMatchError, 

730 "No psf stars out of 2 matched 5 calib stars") as cm: 

731 calibrate._match_psf_stars(psf_stars, stars) 

732 self.assertEqual(cm.exception.metadata["n_psf_stars"], 2) 

733 self.assertEqual(cm.exception.metadata["n_stars"], 5) 

734 

735 def test_calibrate_image_illumcorr(self): 

736 """Test running through with an illumination correction.""" 

737 config = copy.copy(self.config) 

738 config.do_illumination_correction = True 

739 config.psf_subtract_background.doApplyFlatBackgroundRatio = True 

740 config.psf_detection.doApplyFlatBackgroundRatio = True 

741 config.star_background.doApplyFlatBackgroundRatio = True 

742 config.star_detection.doApplyFlatBackgroundRatio = True 

743 

744 calibrate = CalibrateImageTask(config=config) 

745 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

746 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

747 

748 # Assume that the exposure has been flattened by a flat-flat. 

749 background_flat = self.exposure.clone() 

750 background_flat.image.array[:, :] = 1.0 

751 background_flat.mask.array[:, :] = 0 

752 background_flat.variance.array[:, :] = 0.0 

753 

754 # And create an illumination correction of 1.1. 

755 illum_corr_value = 1.1 

756 illumination_correction = self.exposure.clone() 

757 illumination_correction.image.array[:, :] = illum_corr_value 

758 illumination_correction.mask.array[:, :] = 0 

759 illumination_correction.variance.array[:, :] = 0.0 

760 

761 result = calibrate.run( 

762 exposures=self.exposure, 

763 id_generator=self.id_generator, 

764 background_flat=background_flat, 

765 illumination_correction=illumination_correction, 

766 ) 

767 

768 # We divide the image by the illumination correction, but the reference 

769 # sources stay the same, so the applied photocalib will increase by 

770 # the illum_corr_value. 

771 # Tolerance is the same as the direct test with no illumination 

772 # correction. 

773 self.assertFloatsAlmostEqual( 

774 result.applied_photo_calib.getCalibrationMean(), 

775 self.photo_calib * illum_corr_value, 

776 rtol=1e-2, 

777 ) 

778 

779 self.assertEqual(len(result.background), 4) 

780 self.assertFloatsAlmostEqual( 

781 np.median(result.background.getImage().array), 

782 result.applied_photo_calib.getCalibrationMean() * self.background_level, 

783 rtol=1e-3, 

784 ) 

785 

786 # Check metadata. 

787 key = "LSST CALIB ILLUMCORR APPLIED" 

788 self.assertIn(key, result.exposure.metadata) 

789 self.assertEqual(result.exposure.metadata[key], True) 

790 

791 def test_run_with_diffraction_spike_mask(self): 

792 """Test that the diffraction spike mask subtask runs. 

793 """ 

794 config = self.config 

795 config.doMaskDiffractionSpikes = True 

796 config.diffractionSpikeMask.magnitudeThreshold = 19 

797 # Define a fake SATURATED mask plane for use by the diffraction spike 

798 # task, so that it does not affect the rest of calibrate 

799 config.diffractionSpikeMask.saturatedMaskPlane = "FAKESATURATED" 

800 calibrate = CalibrateImageTask(config=config) 

801 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

802 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

803 calibrate.diffractionSpikeMask.setRefObjLoader(self.ref_loader) 

804 

805 exposure = self.exposure.clone() 

806 

807 exposure.info.setVisitInfo(makeTestVisitInfo()) 

808 exposure.mask.addMaskPlane(config.diffractionSpikeMask.saturatedMaskPlane) 

809 

810 # Set the saturated mask plane in half of the image 

811 saturatedMaskBit = exposure.mask.getPlaneBitMask(config.diffractionSpikeMask.saturatedMaskPlane) 

812 bbox = exposure.getBBox() 

813 bbox.grow(-lsst.geom.Extent2I(0, bbox.height//4)) 

814 exposure[bbox].mask.array |= saturatedMaskBit 

815 

816 result = calibrate.run(exposures=exposure) 

817 

818 self._check_run(calibrate, result) 

819 

820 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": ""}) 

821 def test_fail_on_sattle_misconfiguration(self): 

822 """Test for failure if sattle is requested without appropriate 

823 configurations. 

824 """ 

825 self.config.run_sattle = True 

826 with self.assertRaises(pexConfig.FieldValidationError): 

827 CalibrateImageTask(config=self.config) 

828 

829 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"}) 

830 def test_continue_on_sattle_failure(self): 

831 """Processing should continue when sattle returns status codes other 

832 than 200. 

833 """ 

834 response = MockResponse({}, 500, "internal sattle error") 

835 

836 self.config.run_sattle = True 

837 calibrate = CalibrateImageTask(config=self.config) 

838 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

839 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

840 with mock.patch('requests.put', return_value=response) as mock_put: 

841 calibrate.run(exposures=self.exposure) 

842 mock_put.assert_called_once() 

843 

844 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"}) 

845 def test_sattle(self): 

846 """Test for successful completion when sattle call returns 

847 successfully. 

848 """ 

849 response = MockResponse({}, 200, "success") 

850 

851 self.config.run_sattle = True 

852 calibrate = CalibrateImageTask(config=self.config) 

853 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

854 calibrate.photometry.match.setRefObjLoader(self.ref_loader) 

855 with mock.patch('requests.put', return_value=response) as mock_put: 

856 calibrate.run(exposures=self.exposure) 

857 mock_put.assert_called_once() 

858 

859 

860class CalibrateImageTaskRunQuantumTests(lsst.utils.tests.TestCase): 

861 """Tests of ``CalibrateImageTask.runQuantum``, which need a test butler, 

862 but do not need real images. 

863 """ 

864 def setUp(self): 

865 instrument = "testCam" 

866 exposure0 = 101 

867 exposure1 = 102 

868 visit = 100101 

869 detector = 42 

870 physical_filter = "r" 

871 

872 # Create a and populate a test butler for runQuantum tests. 

873 self.repo_path = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) 

874 self.repo = butlerTests.makeTestRepo(self.repo_path.name) 

875 self.enterContext(self.repo) 

876 

877 # A complete instrument record is necessary for the id generator. 

878 instrumentRecord = self.repo.dimensions["instrument"].RecordClass( 

879 name=instrument, visit_max=1e6, exposure_max=1e6, detector_max=128, 

880 class_name="lsst.obs.base.instrument_tests.DummyCam", 

881 ) 

882 self.repo.registry.syncDimensionData("instrument", instrumentRecord) 

883 

884 # dataIds for fake data 

885 butlerTests.addDataIdValue(self.repo, "detector", detector) 

886 butlerTests.addDataIdValue(self.repo, "exposure", exposure0) 

887 butlerTests.addDataIdValue(self.repo, "exposure", exposure1) 

888 butlerTests.addDataIdValue(self.repo, "visit", visit) 

889 butlerTests.addDataIdValue(self.repo, "physical_filter", physical_filter) 

890 

891 # inputs 

892 butlerTests.addDatasetType(self.repo, "postISRCCD", {"instrument", "exposure", "detector"}, 

893 "ExposureF") 

894 butlerTests.addDatasetType(self.repo, "gaia_dr3_20230707", {"htm7"}, "SimpleCatalog") 

895 butlerTests.addDatasetType(self.repo, "ps1_pv3_3pi_20170110", {"htm7"}, "SimpleCatalog") 

896 butlerTests.addDatasetType(self.repo, "flat", {"instrument", "detector", "physical_filter"}, 

897 "Exposure") 

898 butlerTests.addDatasetType(self.repo, 

899 "illuminationCorrection", 

900 {"instrument", "detector", "physical_filter"}, 

901 "Exposure") 

902 

903 # outputs 

904 butlerTests.addDatasetType(self.repo, "initial_pvi", {"instrument", "visit", "detector"}, 

905 "ExposureF") 

906 butlerTests.addDatasetType(self.repo, "initial_stars_footprints_detector", 

907 {"instrument", "visit", "detector"}, 

908 "SourceCatalog") 

909 butlerTests.addDatasetType(self.repo, "initial_stars_detector", 

910 {"instrument", "visit", "detector"}, 

911 "ArrowAstropy") 

912 butlerTests.addDatasetType(self.repo, "initial_photoCalib_detector", 

913 {"instrument", "visit", "detector"}, 

914 "PhotoCalib") 

915 butlerTests.addDatasetType(self.repo, "background_to_photometric_ratio", 

916 {"instrument", "visit", "detector"}, 

917 "Image") 

918 # optional outputs 

919 butlerTests.addDatasetType(self.repo, "initial_pvi_background", {"instrument", "visit", "detector"}, 

920 "Background") 

921 butlerTests.addDatasetType(self.repo, "initial_psf_stars_footprints_detector", 

922 {"instrument", "visit", "detector"}, 

923 "SourceCatalog") 

924 butlerTests.addDatasetType(self.repo, "initial_psf_stars_detector", 

925 {"instrument", "visit", "detector"}, 

926 "ArrowAstropy") 

927 butlerTests.addDatasetType(self.repo, 

928 "initial_astrometry_match_detector", 

929 {"instrument", "visit", "detector"}, 

930 "Catalog") 

931 butlerTests.addDatasetType(self.repo, 

932 "initial_photometry_match_detector", 

933 {"instrument", "visit", "detector"}, 

934 "Catalog") 

935 butlerTests.addDatasetType(self.repo, 

936 "preliminary_visit_mask", 

937 {"instrument", "visit", "detector"}, 

938 "Mask") 

939 

940 # dataIds 

941 self.exposure0_id = self.repo.registry.expandDataId( 

942 {"instrument": instrument, "exposure": exposure0, "detector": detector}) 

943 self.exposure1_id = self.repo.registry.expandDataId( 

944 {"instrument": instrument, "exposure": exposure1, "detector": detector}) 

945 self.visit_id = self.repo.registry.expandDataId( 

946 {"instrument": instrument, "visit": visit, "detector": detector}) 

947 self.htm_id = self.repo.registry.expandDataId({"htm7": 42}) 

948 self.flat_id = self.repo.registry.expandDataId( 

949 {"instrument": instrument, "detector": detector, "physical_filter": physical_filter}) 

950 

951 # put empty data 

952 self.butler = butlerTests.makeTestCollection(self.repo) 

953 self.butler.put(afwImage.ExposureF(), "postISRCCD", self.exposure0_id) 

954 self.butler.put(afwImage.ExposureF(), "postISRCCD", self.exposure1_id) 

955 self.butler.put(afwTable.SimpleCatalog(), "gaia_dr3_20230707", self.htm_id) 

956 self.butler.put(afwTable.SimpleCatalog(), "ps1_pv3_3pi_20170110", self.htm_id) 

957 self.butler.put(afwImage.ExposureF(), "flat", self.flat_id) 

958 self.butler.put(afwImage.ExposureF(), "illuminationCorrection", self.flat_id) 

959 

960 def tearDown(self): 

961 self.repo_path.cleanup() 

962 

963 def test_runQuantum(self): 

964 task = CalibrateImageTask() 

965 lsst.pipe.base.testUtils.assertValidInitOutput(task) 

966 

967 quantum = lsst.pipe.base.testUtils.makeQuantum( 

968 task, self.butler, self.visit_id, 

969 {"exposures": [self.exposure0_id], 

970 "astrometry_ref_cat": [self.htm_id], 

971 "photometry_ref_cat": [self.htm_id], 

972 "background_flat": self.flat_id, 

973 "illumination_correction": self.flat_id, 

974 # outputs 

975 "exposure": self.visit_id, 

976 "stars": self.visit_id, 

977 "stars_footprints": self.visit_id, 

978 "background": self.visit_id, 

979 "psf_stars": self.visit_id, 

980 "psf_stars_footprints": self.visit_id, 

981 "applied_photo_calib": self.visit_id, 

982 "initial_pvi_background": self.visit_id, 

983 "astrometry_matches": self.visit_id, 

984 "photometry_matches": self.visit_id, 

985 "mask": self.visit_id, 

986 }) 

987 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum) 

988 

989 # Ensure the reference loaders have been configured. 

990 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707") 

991 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110") 

992 # Check that the proper kwargs are passed to run(). 

993 self.assertEqual( 

994 mock_run.call_args.kwargs.keys(), 

995 {"exposures", 

996 "result", 

997 "id_generator", 

998 "background_flat", 

999 "illumination_correction", 

1000 "camera_model", 

1001 "exposure_record", 

1002 "exposure_region", 

1003 }, 

1004 ) 

1005 

1006 def test_runQuantum_illumination_correction(self): 

1007 config = CalibrateImageTask.ConfigClass() 

1008 config.do_illumination_correction = True 

1009 config.psf_subtract_background.doApplyFlatBackgroundRatio = True 

1010 config.psf_detection.doApplyFlatBackgroundRatio = True 

1011 config.star_background.doApplyFlatBackgroundRatio = True 

1012 config.star_detection.doApplyFlatBackgroundRatio = True 

1013 task = CalibrateImageTask(config=config) 

1014 lsst.pipe.base.testUtils.assertValidInitOutput(task) 

1015 

1016 quantum = lsst.pipe.base.testUtils.makeQuantum( 

1017 task, self.butler, self.visit_id, 

1018 {"exposures": [self.exposure0_id], 

1019 "astrometry_ref_cat": [self.htm_id], 

1020 "photometry_ref_cat": [self.htm_id], 

1021 "background_flat": self.flat_id, 

1022 "illumination_correction": self.flat_id, 

1023 # outputs 

1024 "exposure": self.visit_id, 

1025 "stars": self.visit_id, 

1026 "stars_footprints": self.visit_id, 

1027 "background": self.visit_id, 

1028 "background_to_photometric_ratio": self.visit_id, 

1029 "psf_stars": self.visit_id, 

1030 "psf_stars_footprints": self.visit_id, 

1031 "applied_photo_calib": self.visit_id, 

1032 "initial_pvi_background": self.visit_id, 

1033 "astrometry_matches": self.visit_id, 

1034 "photometry_matches": self.visit_id, 

1035 "mask": self.visit_id, 

1036 }) 

1037 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum) 

1038 

1039 # Ensure the reference loaders have been configured. 

1040 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707") 

1041 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110") 

1042 # Check that the proper kwargs are passed to run(). 

1043 self.assertEqual( 

1044 mock_run.call_args.kwargs.keys(), 

1045 {"exposures", 

1046 "result", 

1047 "id_generator", 

1048 "background_flat", 

1049 "illumination_correction", 

1050 "camera_model", 

1051 "exposure_record", 

1052 "exposure_region", 

1053 }, 

1054 ) 

1055 

1056 def test_runQuantum_2_snaps(self): 

1057 task = CalibrateImageTask() 

1058 lsst.pipe.base.testUtils.assertValidInitOutput(task) 

1059 

1060 quantum = lsst.pipe.base.testUtils.makeQuantum( 

1061 task, self.butler, self.visit_id, 

1062 {"exposures": [self.exposure0_id, self.exposure1_id], 

1063 "astrometry_ref_cat": [self.htm_id], 

1064 "photometry_ref_cat": [self.htm_id], 

1065 "background_flat": self.flat_id, 

1066 "illumination_correction": self.flat_id, 

1067 # outputs 

1068 "exposure": self.visit_id, 

1069 "stars": self.visit_id, 

1070 "stars_footprints": self.visit_id, 

1071 "background": self.visit_id, 

1072 "psf_stars": self.visit_id, 

1073 "psf_stars_footprints": self.visit_id, 

1074 "applied_photo_calib": self.visit_id, 

1075 "initial_pvi_background": self.visit_id, 

1076 "astrometry_matches": self.visit_id, 

1077 "photometry_matches": self.visit_id, 

1078 "mask": self.visit_id, 

1079 }) 

1080 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum) 

1081 

1082 # Ensure the reference loaders have been configured. 

1083 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707") 

1084 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110") 

1085 # Check that the proper kwargs are passed to run(). 

1086 self.assertEqual( 

1087 mock_run.call_args.kwargs.keys(), 

1088 {"exposures", 

1089 "result", 

1090 "id_generator", 

1091 "background_flat", 

1092 "illumination_correction", 

1093 "camera_model", 

1094 "exposure_record", 

1095 "exposure_region", 

1096 }, 

1097 ) 

1098 

1099 def test_runQuantum_no_optional_outputs(self): 

1100 # All the possible connections: we modify this to test each one by 

1101 # popping off the removed connection, then re-setting it. 

1102 connections = {"exposures": [self.exposure0_id, self.exposure1_id], 

1103 "astrometry_ref_cat": [self.htm_id], 

1104 "photometry_ref_cat": [self.htm_id], 

1105 "background_flat": self.flat_id, 

1106 "illumination_correction": self.flat_id, 

1107 # outputs 

1108 "exposure": self.visit_id, 

1109 "stars": self.visit_id, 

1110 "stars_footprints": self.visit_id, 

1111 "background": self.visit_id, 

1112 "psf_stars": self.visit_id, 

1113 "psf_stars_footprints": self.visit_id, 

1114 "applied_photo_calib": self.visit_id, 

1115 "initial_pvi_background": self.visit_id, 

1116 "astrometry_matches": self.visit_id, 

1117 "photometry_matches": self.visit_id, 

1118 "mask": self.visit_id, 

1119 } 

1120 

1121 # Check that we can turn off one output at a time. 

1122 for optional in ["psf_stars", "psf_stars_footprints", "astrometry_matches", "photometry_matches", 

1123 "mask"]: 

1124 config = CalibrateImageTask.ConfigClass() 

1125 config.optional_outputs.remove(optional) 

1126 task = CalibrateImageTask(config=config) 

1127 lsst.pipe.base.testUtils.assertValidInitOutput(task) 

1128 # Save the removed one for the next test. 

1129 temp = connections.pop(optional) 

1130 # This will fail with "Error in connection ..." if we don't pop 

1131 # the optional item from the connections list just above. 

1132 quantum = lsst.pipe.base.testUtils.makeQuantum(task, self.butler, self.visit_id, connections) 

1133 # This confirms that the outputs did skip the removed one. 

1134 self.assertNotIn(optional, quantum.outputs) 

1135 # Restore the one we removed for the next test. 

1136 connections[optional] = temp 

1137 

1138 def test_runQuantum_no_calibrate_pixels(self): 

1139 """Test that the the task runs when calibrating pixels is disabled, 

1140 and that this results in the ``applied_photo_calib`` output being 

1141 removed. 

1142 """ 

1143 config = CalibrateImageTask.ConfigClass() 

1144 config.do_calibrate_pixels = False 

1145 task = CalibrateImageTask(config=config) 

1146 lsst.pipe.base.testUtils.assertValidInitOutput(task) 

1147 

1148 quantum = lsst.pipe.base.testUtils.makeQuantum( 

1149 task, self.butler, self.visit_id, 

1150 {"exposures": [self.exposure0_id], 

1151 "astrometry_ref_cat": [self.htm_id], 

1152 "photometry_ref_cat": [self.htm_id], 

1153 "background_flat": self.flat_id, 

1154 "illumination_correction": self.flat_id, 

1155 # outputs 

1156 "exposure": self.visit_id, 

1157 "stars": self.visit_id, 

1158 "stars_footprints": self.visit_id, 

1159 "background": self.visit_id, 

1160 "psf_stars": self.visit_id, 

1161 "psf_stars_footprints": self.visit_id, 

1162 "initial_pvi_background": self.visit_id, 

1163 "astrometry_matches": self.visit_id, 

1164 "photometry_matches": self.visit_id, 

1165 "mask": self.visit_id, 

1166 }) 

1167 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum) 

1168 

1169 # Ensure the reference loaders have been configured. 

1170 self.assertEqual(task.astrometry.refObjLoader.name, "gaia_dr3_20230707") 

1171 self.assertEqual(task.photometry.match.refObjLoader.name, "ps1_pv3_3pi_20170110") 

1172 # Check that the proper kwargs are passed to run(). 

1173 self.assertEqual( 

1174 mock_run.call_args.kwargs.keys(), 

1175 {"exposures", 

1176 "result", 

1177 "id_generator", 

1178 "background_flat", 

1179 "illumination_correction", 

1180 "camera_model", 

1181 "exposure_record", 

1182 "exposure_region", 

1183 }, 

1184 ) 

1185 

1186 def test_lintConnections(self): 

1187 """Check that the connections are self-consistent. 

1188 """ 

1189 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass 

1190 lsst.pipe.base.testUtils.lintConnections(Connections) 

1191 

1192 def test_connections_future_image_type(self): 

1193 """Test that the future image type changes the exposure storage class 

1194 and removes the separate background output, which is attached to the 

1195 image instead. 

1196 """ 

1197 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass 

1198 config = CalibrateImageTask.ConfigClass() 

1199 

1200 connections = Connections(config=config) 

1201 self.assertEqual(connections.exposure.storageClass, "ExposureF") 

1202 self.assertIn("background", connections.outputs) 

1203 self.assertIn("applied_photo_calib", connections.outputs) 

1204 

1205 config.output_image_type = "future" 

1206 connections = Connections(config=config) 

1207 self.assertEqual(connections.exposure.storageClass, "VisitImage") 

1208 # The dataset name is unchanged; only its storage class differs. 

1209 self.assertEqual(connections.exposure.name, "initial_pvi") 

1210 # Both of these are carried on the image itself in this mode. 

1211 self.assertNotIn("background", connections.outputs) 

1212 self.assertNotIn("applied_photo_calib", connections.outputs) 

1213 

1214 def test_runQuantum_partial_outputs_struct(self): 

1215 """Test that the result struct runQuantum passes to run() has 

1216 ``applied_photo_calib`` seeded as `None`. 

1217 """ 

1218 task = CalibrateImageTask() 

1219 

1220 quantum = lsst.pipe.base.testUtils.makeQuantum( 

1221 task, self.butler, self.visit_id, 

1222 {"exposures": [self.exposure0_id], 

1223 "astrometry_ref_cat": [self.htm_id], 

1224 "photometry_ref_cat": [self.htm_id], 

1225 "background_flat": self.flat_id, 

1226 "illumination_correction": self.flat_id, 

1227 # outputs 

1228 "exposure": self.visit_id, 

1229 "stars": self.visit_id, 

1230 "stars_footprints": self.visit_id, 

1231 "background": self.visit_id, 

1232 "psf_stars": self.visit_id, 

1233 "psf_stars_footprints": self.visit_id, 

1234 "applied_photo_calib": self.visit_id, 

1235 "initial_pvi_background": self.visit_id, 

1236 "astrometry_matches": self.visit_id, 

1237 "photometry_matches": self.visit_id, 

1238 "mask": self.visit_id, 

1239 }) 

1240 mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum) 

1241 

1242 result = mock_run.call_args.kwargs["result"] 

1243 self.assertIsNone(result.applied_photo_calib) 

1244 

1245 def test_runQuantum_exception(self): 

1246 """Test exception handling in runQuantum. 

1247 """ 

1248 task = CalibrateImageTask() 

1249 lsst.pipe.base.testUtils.assertValidInitOutput(task) 

1250 

1251 quantum = lsst.pipe.base.testUtils.makeQuantum( 

1252 task, self.butler, self.visit_id, 

1253 {"exposures": [self.exposure0_id], 

1254 "astrometry_ref_cat": [self.htm_id], 

1255 "photometry_ref_cat": [self.htm_id], 

1256 "background_flat": self.flat_id, 

1257 "illuminationCorrection": self.flat_id, 

1258 # outputs 

1259 "exposure": self.visit_id, 

1260 "stars": self.visit_id, 

1261 "stars_footprints": self.visit_id, 

1262 "background": self.visit_id, 

1263 "psf_stars": self.visit_id, 

1264 "psf_stars_footprints": self.visit_id, 

1265 "applied_photo_calib": self.visit_id, 

1266 "initial_pvi_background": self.visit_id, 

1267 "astrometry_matches": self.visit_id, 

1268 "photometry_matches": self.visit_id, 

1269 "mask": self.visit_id, 

1270 }) 

1271 

1272 # A generic exception should raise directly. 

1273 msg = "mocked run exception" 

1274 with ( 

1275 mock.patch.object(task, "run", side_effect=ValueError(msg)), 

1276 self.assertRaisesRegex(ValueError, "mocked run exception") 

1277 ): 

1278 lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum, mockRun=False) 

1279 

1280 # An AlgorithmError should write annotated partial outputs. 

1281 error = lsst.meas.algorithms.MeasureApCorrError(name="test", nSources=100, ndof=101) 

1282 

1283 def mock_run( 

1284 exposures, 

1285 result=None, 

1286 id_generator=None, 

1287 background_flat=None, 

1288 illumination_correction=None, 

1289 camera_model=None, 

1290 exposure_record=None, 

1291 exposure_region=None, 

1292 ): 

1293 """Mock success through compute_psf, but failure after. 

1294 """ 

1295 result.exposure = afwImage.ExposureF(10, 10) 

1296 result.psf_stars_footprints = afwTable.SourceCatalog() 

1297 result.psf_stars = afwTable.SourceCatalog().asAstropy() 

1298 result.background = afwMath.BackgroundList() 

1299 raise error 

1300 

1301 with ( 

1302 mock.patch.object(task, "run", side_effect=mock_run), 

1303 self.assertRaises(lsst.pipe.base.AnnotatedPartialOutputsError), 

1304 ): 

1305 with self.assertLogs("lsst.calibrateImage", level="DEBUG") as cm: 

1306 lsst.pipe.base.testUtils.runTestQuantum(task, 

1307 self.butler, 

1308 quantum, 

1309 mockRun=False) 

1310 

1311 logged = "\n".join(cm.output) 

1312 self.assertIn("Task failed with only partial outputs", logged) 

1313 self.assertIn("MeasureApCorrError", logged) 

1314 

1315 # NOTE: This is an integration test of afw Exposure & SourceCatalog 

1316 # metadata with the error annotation system in pipe_base. 

1317 # Check that we did get the annotated partial outputs... 

1318 pvi = self.butler.get("initial_pvi", self.visit_id) 

1319 self.assertIn("Unable to measure aperture correction", pvi.metadata["failure.message"]) 

1320 self.assertIn("MeasureApCorrError", pvi.metadata["failure.type"]) 

1321 self.assertEqual(pvi.metadata["failure.metadata.ndof"], 101) 

1322 stars = self.butler.get("initial_psf_stars_footprints_detector", self.visit_id) 

1323 self.assertIn("Unable to measure aperture correction", stars.metadata["failure.message"]) 

1324 self.assertIn("MeasureApCorrError", stars.metadata["failure.type"]) 

1325 self.assertEqual(stars.metadata["failure.metadata.ndof"], 101) 

1326 # ... but not the un-produced outputs. 

1327 with self.assertRaises(FileNotFoundError): 

1328 self.butler.get("initial_stars_footprints_detector", self.visit_id) 

1329 

1330 def test_runQuantum_partial_outputs_future(self): 

1331 """Test that a failed quantum in 'future' mode skips the image and 

1332 still writes the rest of the annotated partial outputs. 

1333 """ 

1334 config = CalibrateImageTask.ConfigClass() 

1335 config.output_image_type = "future" 

1336 task = CalibrateImageTask(config=config) 

1337 

1338 quantum = lsst.pipe.base.testUtils.makeQuantum( 

1339 task, self.butler, self.visit_id, 

1340 {"exposures": [self.exposure0_id], 

1341 "astrometry_ref_cat": [self.htm_id], 

1342 "photometry_ref_cat": [self.htm_id], 

1343 "background_flat": self.flat_id, 

1344 "illumination_correction": self.flat_id, 

1345 # outputs 

1346 "exposure": self.visit_id, 

1347 "stars": self.visit_id, 

1348 "stars_footprints": self.visit_id, 

1349 "psf_stars": self.visit_id, 

1350 "psf_stars_footprints": self.visit_id, 

1351 "astrometry_matches": self.visit_id, 

1352 "photometry_matches": self.visit_id, 

1353 "mask": self.visit_id, 

1354 }) 

1355 

1356 error = lsst.meas.algorithms.MeasureApCorrError(name="test", nSources=100, ndof=101) 

1357 

1358 def mock_run( 

1359 exposures, 

1360 result=None, 

1361 id_generator=None, 

1362 background_flat=None, 

1363 illumination_correction=None, 

1364 camera_model=None, 

1365 exposure_record=None, 

1366 exposure_region=None, 

1367 ): 

1368 """Mock a failure of the aperture correction fit, after `run` 

1369 has set the exposure and the PSF star catalog. 

1370 """ 

1371 exposure = afwImage.ExposureF(10, 10) 

1372 # `run` sets these from the post-ISR image before any fit can 

1373 # fail. 

1374 exposure.setDetector(list(CameraWrapper().camera)[0]) 

1375 exposure.setFilter(afwImage.FilterLabel(physical="test-r", band="r")) 

1376 exposure.info.setVisitInfo(makeTestVisitInfo(id=self.visit_id["visit"])) 

1377 result.exposure = exposure 

1378 result.psf_stars_footprints = afwTable.SourceCatalog() 

1379 result.psf_stars = afwTable.SourceCatalog().asAstropy() 

1380 result.background = afwMath.BackgroundList() 

1381 raise error 

1382 

1383 with ( 

1384 mock.patch.object(task, "run", side_effect=mock_run), 

1385 self.assertRaises(lsst.pipe.base.AnnotatedPartialOutputsError), 

1386 self.assertLogs("lsst.calibrateImage", level="WARNING") as cm, 

1387 ): 

1388 lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum, mockRun=False) 

1389 

1390 self.assertIn("Cannot write a partial-outputs image in 'future' mode", "\n".join(cm.output)) 

1391 # The image was skipped ... 

1392 with self.assertRaises(FileNotFoundError): 

1393 self.butler.get("initial_pvi", self.visit_id) 

1394 # ... but the catalog that `run` did produce was written, with the 

1395 # failure annotation on it. 

1396 stars = self.butler.get("initial_psf_stars_footprints_detector", self.visit_id) 

1397 self.assertIn("Unable to measure aperture correction", stars.metadata["failure.message"]) 

1398 

1399 

1400class MockResponse: 

1401 """Provide a mock for requests.put calls""" 

1402 def __init__(self, json_data, status_code, text): 

1403 self.json_data = json_data 

1404 self.status_code = status_code 

1405 self.text = text 

1406 

1407 def json(self): 

1408 return self.json_data 

1409 

1410 def raise_for_status(self): 

1411 if self.status_code != 200: 

1412 raise requests.exceptions.HTTPError 

1413 

1414 

1415def setup_module(module): 

1416 lsst.utils.tests.init() 

1417 

1418 

1419class MemoryTestCase(lsst.utils.tests.MemoryTestCase): 

1420 pass 

1421 

1422 

1423if __name__ == "__main__": 1423 ↛ 1424line 1423 didn't jump to line 1424 because the condition on line 1423 was never true

1424 lsst.utils.tests.init() 

1425 unittest.main()