Coverage for tests / test_calibrateImage.py: 13%

526 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-13 01:56 -0700

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 

37import lsst.daf.base 

38import lsst.daf.butler 

39import lsst.daf.butler.tests as butlerTests 

40import lsst.geom 

41import lsst.meas.algorithms 

42from lsst.meas.algorithms import testUtils 

43import lsst.meas.extensions.psfex 

44import lsst.meas.base 

45import lsst.meas.base.tests 

46import lsst.pipe.base as pipeBase 

47import lsst.pipe.base.testUtils 

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

49 NoPsfStarsToStarsMatchError, AllCentroidsFlaggedError 

50import lsst.pex.config as pexConfig 

51import lsst.utils.tests 

52 

53from utils import makeTestVisitInfo 

54 

55 

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

57 

58 def setUp(self): 

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

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

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

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

63 self.photo_calib = 12.3 

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

65 # sqrt of area of a normalized 2d gaussian 

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

67 noise = 10.0 # stddev of noise per pixel 

68 # Sources ordered from faintest to brightest. 

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

70 12*noise*psf_scale, 

71 45*noise*psf_scale, 

72 150*noise*psf_scale, 

73 400*noise*psf_scale, 

74 1000*noise*psf_scale)) 

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

76 (40, 70), 

77 (100, 160), 

78 (50, 120), 

79 (92, 35), 

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

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

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

83 

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

85 # in any of the output catalogs. 

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

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

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

89 

90 schema = dataset.makeMinimalSchema() 

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

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

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

94 self.background_level = 500.0 

95 self.truth_exposure.image += self.background_level 

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

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

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

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

100 metadata.set("REFCAT_FORMAT_VERSION", 1) 

101 self.truth_cat.setMetadata(metadata) 

102 

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

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

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

106 

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

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

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

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

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

112 self.exposure.info.id = 12345 

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

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

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

116 

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

118 self.attributes = pipeBase.Struct() 

119 self.attributes.exposure = self.exposure 

120 self.attributes.background = None 

121 self.attributes.background_to_photometric_ratio = None 

122 

123 # Test-specific configuration: 

124 self.config = CalibrateImageTask.ConfigClass() 

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

126 self.config.compute_shapelet_decomposition.min_n_stars = 4 

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

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

129 # Only insert 2 sky sources, for simplicity. 

130 self.config.star_sky_sources.nSources = 2 

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

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

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

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

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

136 self.config.run_sattle = False 

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

138 self.config.do_adaptive_threshold_detection = False 

139 self.config.psf_detection.reEstimateBackground = True 

140 self.config.star_detection.reEstimateBackground = True 

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

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

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

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

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

146 self.config.astrometry.magnitudeOutlierRejectionNSigma = 9.0 

147 

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

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

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

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

152 # tests to fail. 

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

154 instrument="I", 

155 visit=self.truth_exposure.visitInfo.id, 

156 detector=12, 

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

158 ) 

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

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

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

162 self.config.id_generator.n_releases = 8 

163 self.config.id_generator.release_id = 2 

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

165 

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

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

168 

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

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

171 do_shapelet_check: bool = False): 

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

173 

174 Parameters 

175 ---------- 

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

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

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

179 Result of calling calibrate.run(). 

180 expect_calibrated_pixels : `bool`, optional 

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

182 """ 

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

184 # re-estimation during source detection. 

185 if expect_n_background is not None: 

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

187 if expect_n_background_equal_or_greater_than is not None: 

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

189 

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

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

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

193 

194 # Check that the summary statistics are reasonable. 

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

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

197 if do_shapelet_check: 

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

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

200 shapeletsCoeffs = [ 

201 0.28358882665634155, -0.00037077977926209773, -0.002802249127080195, 

202 3.439385909587145e-05, -0.0012995795522691303, -0.0018000953572589512, 

203 4.4449956901327674e-05, -2.5397295072102522e-05, 3.0203139976223994e-05, 

204 0.000617273102631627, -4.456396106385861e-05, 8.730505214771256e-05, 

205 0.00011167120828758925, 0.00017521437985124066, -1.5413010141236832e-05, 

206 -0.00015419833136474156, -8.099463936604213e-05, -0.00017129145577976686, 

207 -9.799635877346543e-05, 2.7090113467370116e-05, 0.0004025635973896668, 

208 -6.346980774284659e-05, 0.00013586174545049867, -0.00011295261583971951, 

209 0.0003724458365468307, 1.5024524355547979e-05, 0.00012497524707246052, 

210 0.00041057434072064747 

211 ] 

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

213 self.assertFloatsAlmostEqual( 

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

215 self.assertEqual(summary.nShapeletsStar, 4) 

216 self.assertFloatsAlmostEqual( 

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

218 

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

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

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

222 

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

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

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

226 

227 if expect_calibrated_pixels: 

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

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

230 photo_calib = result.applied_photo_calib 

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

232 else: 

233 self.assertIsNone(result.applied_photo_calib) 

234 photo_calib = result.exposure.photoCalib 

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

236 # comparison on just 2-3 stars. 

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

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

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

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

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

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

243 

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

245 # whether 1 or 2 snaps. 

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

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

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

249 

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

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

252 

253 # Check metadata. 

254 key = "LSST CALIB ILLUMCORR APPLIED" 

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

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

257 

258 # Check that the psf_stars cross match worked correctly. 

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

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

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

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

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

264 result.photometry_matches["src_psf_id"]) 

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

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

267 atol=3e-4) 

268 

269 def test_run(self): 

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

271 """ 

272 calibrate = CalibrateImageTask(config=self.config) 

273 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

276 

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

278 

279 def test_run_adaptive_threshold_detection(self): 

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

281 """ 

282 config = copy.copy(self.config) 

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

284 config.do_adaptive_threshold_detection = True 

285 config.psf_adaptive_threshold_detection.minFootprint = 4 

286 config.psf_adaptive_threshold_detection.minIsolated = 4 

287 config.psf_adaptive_threshold_detection.sufficientIsolated = 4 

288 config.psf_detection.reEstimateBackground = False 

289 config.star_detection.reEstimateBackground = False 

290 

291 calibrate = CalibrateImageTask(config=config) 

292 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

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

296 subString = "Using adaptive threshold detection " 

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

298 

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

300 # entries in the adaptive threshold code path. 

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

302 expect_n_background_equal_or_greater_than=2) 

303 

304 def test_run_downsample(self): 

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

306 """ 

307 config = copy.copy(self.config) 

308 config.do_downsample_footprints = True 

309 config.downsample_max_footprints = 5 

310 

311 calibrate = CalibrateImageTask(config=config) 

312 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

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

316 self.assertIn( 

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

318 cm.output, 

319 ) 

320 

321 self._check_run(calibrate, result) 

322 

323 def test_run_2_snaps(self): 

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

325 passed two exposures to combine as snaps. 

326 """ 

327 calibrate = CalibrateImageTask(config=self.config) 

328 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

331 self.exposure.image /= 2 

332 self.exposure.variance /= 2 

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

334 

335 self._check_run(calibrate, result) 

336 

337 def test_run_no_optionals(self): 

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

339 struct, as appropriate. 

340 """ 

341 self.config.optional_outputs = [] 

342 calibrate = CalibrateImageTask(config=self.config) 

343 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

346 

347 self._check_run(calibrate, result) 

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

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

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

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

352 

353 def test_run_no_calibrate_pixels(self): 

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

355 do_calibrate_pixels=False. 

356 """ 

357 self.config.do_calibrate_pixels = False 

358 calibrate = CalibrateImageTask(config=self.config) 

359 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

362 

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

364 

365 def test_run_no_astrom_errors(self): 

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

367 do_calibrate_pixels=False. 

368 """ 

369 self.config.do_include_astrometric_errors = False 

370 calibrate = CalibrateImageTask(config=self.config) 

371 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

374 

375 self._check_run(calibrate, result) 

376 

377 def test_compute_psf(self): 

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

379 that a PSF is assigned to the exposure. 

380 """ 

381 calibrate = CalibrateImageTask(config=self.config) 

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

383 psf_stars = compute_psf_struct.detections_sources 

384 

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

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

387 

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

389 # re-estimation during the two detection passes. 

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

391 

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

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

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

395 psf_stars.sort(psf_stars.getPsfFluxSlot().getMeasKey()) 

396 for record, flux, center in zip(psf_stars[::-1], self.fluxes, self.centroids[self.fluxes > 50]): 

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

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

399 # PsfFlux should match the values inserted. 

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

401 

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

403 # Check that we got a useable PSF. 

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

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

406 # TestDataset sources have PSF radius=2 pixels. 

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

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

409 

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

411 # import lsst.afw.display 

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

413 # display.mtv(self.exposure) 

414 

415 def test_compute_psf_bad_centroids(self): 

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

417 are bad in compute_psf. 

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

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

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

421 the sources and causing them all to be flagged. 

422 """ 

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

424 for point in self.centroids: 

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

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

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

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

429 # and the extended central source 

430 point = self.extended_source 

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

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

433 

434 # add a diagonal gradient to each source 

435 size = 5 

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

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

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

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

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

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

442 gradient[distance > size] = 0 

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

444 cutout.array += gradient / flux 

445 

446 calibrate = CalibrateImageTask(config=self.config) 

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

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

449 

450 def test_measure_aperture_correction(self): 

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

452 exposure. 

453 """ 

454 calibrate = CalibrateImageTask(config=self.config) 

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

456 psf_stars = compute_psf_struct.detections_sources 

457 

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

459 self.assertIsNone(self.exposure.apCorrMap) 

460 calibrate._measure_aperture_correction(self.exposure, psf_stars) 

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

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

463 # from other configured plugins. 

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

465 

466 def test_find_stars(self): 

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

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

469 """ 

470 calibrate = CalibrateImageTask(config=self.config) 

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

472 psf_stars = compute_psf_struct.detections_sources 

473 calibrate._measure_aperture_correction(self.exposure, psf_stars) 

474 

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

476 

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

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

479 

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

481 # re-estimation during source detection. 

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

483 

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

485 # plus two sky sources. 

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

487 self.assertTrue(stars.isContiguous()) 

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

489 stars.sort(stars.getPsfFluxSlot().getMeasKey()) 

490 for record, flux, center in zip(stars[::-1], self.fluxes, self.centroids[self.fluxes > 50]): 

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

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

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

494 

495 def test_astrometry(self): 

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

497 """ 

498 calibrate = CalibrateImageTask(config=self.config) 

499 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

501 psf_stars = compute_psf_struct.detections_sources 

502 calibrate._measure_aperture_correction(self.exposure, psf_stars) 

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

504 

505 calibrate._fit_astrometry(self.exposure, stars) 

506 

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

508 sky = stars["sky_source"] 

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

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

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

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

513 

514 def test_photometry(self): 

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

516 and that the exposure is calibrated. 

517 """ 

518 calibrate = CalibrateImageTask(config=self.config) 

519 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

522 psf_stars = compute_psf_struct.detections_sources 

523 calibrate._measure_aperture_correction(self.exposure, psf_stars) 

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

525 calibrate._fit_astrometry(self.exposure, stars) 

526 

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

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

529 

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

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

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

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

534 # and the background should be calibrated to match. 

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

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

537 uncalibrated /= self.photo_calib 

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

539 # PhotoCalib on the exposure must be identically 1. 

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

541 

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

543 # sky sources. 

544 sky = stars["sky_source"] 

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

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

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

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

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

550 # quality here. 

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

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

553 rtol=0.1) 

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

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

556 rtol=0.01) 

557 

558 def test_match_psf_stars(self): 

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

560 and candidates. 

561 """ 

562 calibrate = CalibrateImageTask(config=self.config) 

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

564 psf_stars = compute_psf_struct.detections_sources 

565 calibrate._measure_aperture_correction(self.exposure, psf_stars) 

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

567 

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

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

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

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

572 

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

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

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

576 stars = stars.copy(deep=True) 

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

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

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

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

581 

582 calibrate._match_psf_stars(psf_stars, stars) 

583 

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

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

586 stars.sort(stars.getPsfFluxSlot().getMeasKey()) 

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

588 stars = stars.copy(deep=True) 

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

590 [False, False, False, True, True, True]) 

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

592 [False, False, False, True, True, True]) 

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

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

595 

596 def test_match_psf_stars_no_matches(self): 

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

598 """ 

599 calibrate = CalibrateImageTask(config=self.config) 

600 # Make two catalogs that cannot have matches. 

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

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

603 

604 with self.assertRaisesRegex(NoPsfStarsToStarsMatchError, 

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

606 calibrate._match_psf_stars(psf_stars, stars) 

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

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

609 

610 def test_calibrate_image_illumcorr(self): 

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

612 config = copy.copy(self.config) 

613 config.do_illumination_correction = True 

614 config.psf_subtract_background.doApplyFlatBackgroundRatio = True 

615 config.psf_detection.doApplyFlatBackgroundRatio = True 

616 config.star_background.doApplyFlatBackgroundRatio = True 

617 config.star_detection.doApplyFlatBackgroundRatio = True 

618 

619 calibrate = CalibrateImageTask(config=config) 

620 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

622 

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

624 background_flat = self.exposure.clone() 

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

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

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

628 

629 # And create an illumination correction of 1.1. 

630 illum_corr_value = 1.1 

631 illumination_correction = self.exposure.clone() 

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

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

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

635 

636 result = calibrate.run( 

637 exposures=self.exposure, 

638 id_generator=self.id_generator, 

639 background_flat=background_flat, 

640 illumination_correction=illumination_correction, 

641 ) 

642 

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

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

645 # the illum_corr_value. 

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

647 # correction. 

648 self.assertFloatsAlmostEqual( 

649 result.applied_photo_calib.getCalibrationMean(), 

650 self.photo_calib * illum_corr_value, 

651 rtol=1e-2, 

652 ) 

653 

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

655 self.assertFloatsAlmostEqual( 

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

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

658 rtol=1e-3, 

659 ) 

660 

661 # Check metadata. 

662 key = "LSST CALIB ILLUMCORR APPLIED" 

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

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

665 

666 def test_run_with_diffraction_spike_mask(self): 

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

668 """ 

669 config = self.config 

670 config.doMaskDiffractionSpikes = True 

671 config.diffractionSpikeMask.magnitudeThreshold = 19 

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

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

674 config.diffractionSpikeMask.saturatedMaskPlane = "FAKESATURATED" 

675 calibrate = CalibrateImageTask(config=config) 

676 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

678 calibrate.diffractionSpikeMask.setRefObjLoader(self.ref_loader) 

679 

680 exposure = self.exposure.clone() 

681 

682 exposure.info.setVisitInfo(makeTestVisitInfo()) 

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

684 

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

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

687 bbox = exposure.getBBox() 

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

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

690 

691 result = calibrate.run(exposures=exposure) 

692 

693 self._check_run(calibrate, result) 

694 

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

696 def test_fail_on_sattle_misconfiguration(self): 

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

698 configurations. 

699 """ 

700 self.config.run_sattle = True 

701 with self.assertRaises(pexConfig.FieldValidationError): 

702 CalibrateImageTask(config=self.config) 

703 

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

705 def test_continue_on_sattle_failure(self): 

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

707 than 200. 

708 """ 

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

710 

711 self.config.run_sattle = True 

712 calibrate = CalibrateImageTask(config=self.config) 

713 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

716 calibrate.run(exposures=self.exposure) 

717 mock_put.assert_called_once() 

718 

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

720 def test_sattle(self): 

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

722 successfully. 

723 """ 

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

725 

726 self.config.run_sattle = True 

727 calibrate = CalibrateImageTask(config=self.config) 

728 calibrate.astrometry.setRefObjLoader(self.ref_loader) 

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

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

731 calibrate.run(exposures=self.exposure) 

732 mock_put.assert_called_once() 

733 

734 

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

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

737 but do not need real images. 

738 """ 

739 def setUp(self): 

740 instrument = "testCam" 

741 exposure0 = 101 

742 exposure1 = 102 

743 visit = 100101 

744 detector = 42 

745 physical_filter = "r" 

746 

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

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

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

750 self.enterContext(self.repo) 

751 

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

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

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

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

756 ) 

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

758 

759 # dataIds for fake data 

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

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

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

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

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

765 

766 # inputs 

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

768 "ExposureF") 

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

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

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

772 "Exposure") 

773 butlerTests.addDatasetType(self.repo, 

774 "illuminationCorrection", 

775 {"instrument", "detector", "physical_filter"}, 

776 "Exposure") 

777 

778 # outputs 

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

780 "ExposureF") 

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

782 {"instrument", "visit", "detector"}, 

783 "SourceCatalog") 

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

785 {"instrument", "visit", "detector"}, 

786 "ArrowAstropy") 

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

788 {"instrument", "visit", "detector"}, 

789 "PhotoCalib") 

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

791 {"instrument", "visit", "detector"}, 

792 "Image") 

793 # optional outputs 

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

795 "Background") 

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

797 {"instrument", "visit", "detector"}, 

798 "SourceCatalog") 

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

800 {"instrument", "visit", "detector"}, 

801 "ArrowAstropy") 

802 butlerTests.addDatasetType(self.repo, 

803 "initial_astrometry_match_detector", 

804 {"instrument", "visit", "detector"}, 

805 "Catalog") 

806 butlerTests.addDatasetType(self.repo, 

807 "initial_photometry_match_detector", 

808 {"instrument", "visit", "detector"}, 

809 "Catalog") 

810 butlerTests.addDatasetType(self.repo, 

811 "preliminary_visit_mask", 

812 {"instrument", "visit", "detector"}, 

813 "Mask") 

814 

815 # dataIds 

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

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

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

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

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

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

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

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

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

825 

826 # put empty data 

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

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

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

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

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

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

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

834 

835 def tearDown(self): 

836 self.repo_path.cleanup() 

837 

838 def test_runQuantum(self): 

839 task = CalibrateImageTask() 

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

841 

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

843 task, self.butler, self.visit_id, 

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

845 "astrometry_ref_cat": [self.htm_id], 

846 "photometry_ref_cat": [self.htm_id], 

847 "background_flat": self.flat_id, 

848 "illumination_correction": self.flat_id, 

849 # outputs 

850 "exposure": self.visit_id, 

851 "stars": self.visit_id, 

852 "stars_footprints": self.visit_id, 

853 "background": self.visit_id, 

854 "psf_stars": self.visit_id, 

855 "psf_stars_footprints": self.visit_id, 

856 "applied_photo_calib": self.visit_id, 

857 "initial_pvi_background": self.visit_id, 

858 "astrometry_matches": self.visit_id, 

859 "photometry_matches": self.visit_id, 

860 "mask": self.visit_id, 

861 }) 

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

863 

864 # Ensure the reference loaders have been configured. 

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

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

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

868 self.assertEqual( 

869 mock_run.call_args.kwargs.keys(), 

870 {"exposures", 

871 "result", 

872 "id_generator", 

873 "background_flat", 

874 "illumination_correction", 

875 "camera_model", 

876 "exposure_record", 

877 "exposure_region", 

878 }, 

879 ) 

880 

881 def test_runQuantum_illumination_correction(self): 

882 config = CalibrateImageTask.ConfigClass() 

883 config.do_illumination_correction = True 

884 config.psf_subtract_background.doApplyFlatBackgroundRatio = True 

885 config.psf_detection.doApplyFlatBackgroundRatio = True 

886 config.star_background.doApplyFlatBackgroundRatio = True 

887 config.star_detection.doApplyFlatBackgroundRatio = True 

888 task = CalibrateImageTask(config=config) 

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

890 

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

892 task, self.butler, self.visit_id, 

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

894 "astrometry_ref_cat": [self.htm_id], 

895 "photometry_ref_cat": [self.htm_id], 

896 "background_flat": self.flat_id, 

897 "illumination_correction": self.flat_id, 

898 # outputs 

899 "exposure": self.visit_id, 

900 "stars": self.visit_id, 

901 "stars_footprints": self.visit_id, 

902 "background": self.visit_id, 

903 "background_to_photometric_ratio": self.visit_id, 

904 "psf_stars": self.visit_id, 

905 "psf_stars_footprints": self.visit_id, 

906 "applied_photo_calib": self.visit_id, 

907 "initial_pvi_background": self.visit_id, 

908 "astrometry_matches": self.visit_id, 

909 "photometry_matches": self.visit_id, 

910 "mask": self.visit_id, 

911 }) 

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

913 

914 # Ensure the reference loaders have been configured. 

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

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

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

918 self.assertEqual( 

919 mock_run.call_args.kwargs.keys(), 

920 {"exposures", 

921 "result", 

922 "id_generator", 

923 "background_flat", 

924 "illumination_correction", 

925 "camera_model", 

926 "exposure_record", 

927 "exposure_region", 

928 }, 

929 ) 

930 

931 def test_runQuantum_2_snaps(self): 

932 task = CalibrateImageTask() 

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

934 

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

936 task, self.butler, self.visit_id, 

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

938 "astrometry_ref_cat": [self.htm_id], 

939 "photometry_ref_cat": [self.htm_id], 

940 "background_flat": self.flat_id, 

941 "illumination_correction": self.flat_id, 

942 # outputs 

943 "exposure": self.visit_id, 

944 "stars": self.visit_id, 

945 "stars_footprints": self.visit_id, 

946 "background": self.visit_id, 

947 "psf_stars": self.visit_id, 

948 "psf_stars_footprints": self.visit_id, 

949 "applied_photo_calib": self.visit_id, 

950 "initial_pvi_background": self.visit_id, 

951 "astrometry_matches": self.visit_id, 

952 "photometry_matches": self.visit_id, 

953 "mask": self.visit_id, 

954 }) 

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

956 

957 # Ensure the reference loaders have been configured. 

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

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

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

961 self.assertEqual( 

962 mock_run.call_args.kwargs.keys(), 

963 {"exposures", 

964 "result", 

965 "id_generator", 

966 "background_flat", 

967 "illumination_correction", 

968 "camera_model", 

969 "exposure_record", 

970 "exposure_region", 

971 }, 

972 ) 

973 

974 def test_runQuantum_no_optional_outputs(self): 

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

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

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

978 "astrometry_ref_cat": [self.htm_id], 

979 "photometry_ref_cat": [self.htm_id], 

980 "background_flat": self.flat_id, 

981 "illumination_correction": self.flat_id, 

982 # outputs 

983 "exposure": self.visit_id, 

984 "stars": self.visit_id, 

985 "stars_footprints": self.visit_id, 

986 "background": self.visit_id, 

987 "psf_stars": self.visit_id, 

988 "psf_stars_footprints": self.visit_id, 

989 "applied_photo_calib": self.visit_id, 

990 "initial_pvi_background": self.visit_id, 

991 "astrometry_matches": self.visit_id, 

992 "photometry_matches": self.visit_id, 

993 "mask": self.visit_id, 

994 } 

995 

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

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

998 "mask"]: 

999 config = CalibrateImageTask.ConfigClass() 

1000 config.optional_outputs.remove(optional) 

1001 task = CalibrateImageTask(config=config) 

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

1003 # Save the removed one for the next test. 

1004 temp = connections.pop(optional) 

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

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

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

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

1009 self.assertNotIn(optional, quantum.outputs) 

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

1011 connections[optional] = temp 

1012 

1013 def test_runQuantum_no_calibrate_pixels(self): 

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

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

1016 removed. 

1017 """ 

1018 config = CalibrateImageTask.ConfigClass() 

1019 config.do_calibrate_pixels = False 

1020 task = CalibrateImageTask(config=config) 

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

1022 

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

1024 task, self.butler, self.visit_id, 

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

1026 "astrometry_ref_cat": [self.htm_id], 

1027 "photometry_ref_cat": [self.htm_id], 

1028 "background_flat": self.flat_id, 

1029 "illumination_correction": self.flat_id, 

1030 # outputs 

1031 "exposure": self.visit_id, 

1032 "stars": self.visit_id, 

1033 "stars_footprints": self.visit_id, 

1034 "background": self.visit_id, 

1035 "psf_stars": self.visit_id, 

1036 "psf_stars_footprints": self.visit_id, 

1037 "initial_pvi_background": self.visit_id, 

1038 "astrometry_matches": self.visit_id, 

1039 "photometry_matches": self.visit_id, 

1040 "mask": self.visit_id, 

1041 }) 

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

1043 

1044 # Ensure the reference loaders have been configured. 

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

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

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

1048 self.assertEqual( 

1049 mock_run.call_args.kwargs.keys(), 

1050 {"exposures", 

1051 "result", 

1052 "id_generator", 

1053 "background_flat", 

1054 "illumination_correction", 

1055 "camera_model", 

1056 "exposure_record", 

1057 "exposure_region", 

1058 }, 

1059 ) 

1060 

1061 def test_lintConnections(self): 

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

1063 """ 

1064 Connections = CalibrateImageTask.ConfigClass.ConnectionsClass 

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

1066 

1067 def test_runQuantum_exception(self): 

1068 """Test exception handling in runQuantum. 

1069 """ 

1070 task = CalibrateImageTask() 

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

1072 

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

1074 task, self.butler, self.visit_id, 

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

1076 "astrometry_ref_cat": [self.htm_id], 

1077 "photometry_ref_cat": [self.htm_id], 

1078 "background_flat": self.flat_id, 

1079 "illuminationCorrection": self.flat_id, 

1080 # outputs 

1081 "exposure": self.visit_id, 

1082 "stars": self.visit_id, 

1083 "stars_footprints": self.visit_id, 

1084 "background": self.visit_id, 

1085 "psf_stars": self.visit_id, 

1086 "psf_stars_footprints": self.visit_id, 

1087 "applied_photo_calib": self.visit_id, 

1088 "initial_pvi_background": self.visit_id, 

1089 "astrometry_matches": self.visit_id, 

1090 "photometry_matches": self.visit_id, 

1091 "mask": self.visit_id, 

1092 }) 

1093 

1094 # A generic exception should raise directly. 

1095 msg = "mocked run exception" 

1096 with ( 

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

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

1099 ): 

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

1101 

1102 # An AlgorithmError should write annotated partial outputs. 

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

1104 

1105 def mock_run( 

1106 exposures, 

1107 result=None, 

1108 id_generator=None, 

1109 background_flat=None, 

1110 illumination_correction=None, 

1111 camera_model=None, 

1112 exposure_record=None, 

1113 exposure_region=None, 

1114 ): 

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

1116 """ 

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

1118 result.psf_stars_footprints = afwTable.SourceCatalog() 

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

1120 result.background = afwMath.BackgroundList() 

1121 raise error 

1122 

1123 with ( 

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

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

1126 ): 

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

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

1129 self.butler, 

1130 quantum, 

1131 mockRun=False) 

1132 

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

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

1135 self.assertIn("MeasureApCorrError", logged) 

1136 

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

1138 # metadata with the error annotation system in pipe_base. 

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

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

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

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

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

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

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

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

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

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

1149 with self.assertRaises(FileNotFoundError): 

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

1151 

1152 

1153class MockResponse: 

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

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

1156 self.json_data = json_data 

1157 self.status_code = status_code 

1158 self.text = text 

1159 

1160 def json(self): 

1161 return self.json_data 

1162 

1163 def raise_for_status(self): 

1164 if self.status_code != 200: 

1165 raise requests.exceptions.HTTPError 

1166 

1167 

1168def setup_module(module): 

1169 lsst.utils.tests.init() 

1170 

1171 

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

1173 pass 

1174 

1175 

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

1177 lsst.utils.tests.init() 

1178 unittest.main()