Coverage for python/lsst/validate/drp/matchreduce.py: 8%

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

187 statements  

1# LSST Data Management System 

2# Copyright 2016-2019 AURA/LSST. 

3# 

4# This product includes software developed by the 

5# LSST Project (http://www.lsst.org/). 

6# 

7# This program is free software: you can redistribute it and/or modify 

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

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

10# (at your option) any later version. 

11# 

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

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

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

15# GNU General Public License for more details. 

16# 

17# You should have received a copy of the LSST License Statement and 

18# the GNU General Public License along with this program. If not, 

19# see <https://www.lsstcorp.org/LegalNotices/>. 

20"""Blob classes that reduce a multi-visit dataset and encapsulate data 

21for measurement classes, plotting functions, and JSON persistence. 

22""" 

23 

24__all__ = ['build_matched_dataset', 'getKeysFilter', 'filterSources', 'summarizeSources'] 

25 

26import numpy as np 

27import astropy.units as u 

28from sqlalchemy.exc import OperationalError 

29import sqlite3 

30 

31import lsst.geom as geom 

32import lsst.daf.persistence as dafPersist 

33from lsst.afw.table import (SourceCatalog, SchemaMapper, Field, 

34 MultiMatch, SimpleRecord, GroupView, 

35 SOURCE_IO_NO_FOOTPRINTS) 

36import lsst.afw.table as afwTable 

37from lsst.afw.fits import FitsError 

38import lsst.pipe.base as pipeBase 

39from lsst.verify import Blob, Datum 

40 

41from .util import (getCcdKeyName, raftSensorToInt, positionRmsFromCat, 

42 ellipticity_from_cat) 

43 

44 

45def build_matched_dataset(repo, dataIds, matchRadius=None, brightSnrMin=None, brightSnrMax=None, 

46 faintSnrMin=None, faintSnrMax=None, 

47 doApplyExternalPhotoCalib=False, externalPhotoCalibName=None, 

48 doApplyExternalSkyWcs=False, externalSkyWcsName=None, 

49 skipTEx=False, skipNonSrd=False): 

50 """Construct a container for matched star catalogs from multple visits, with filtering, 

51 summary statistics, and modelling. 

52 

53 `lsst.verify.Blob` instances are serializable to JSON. 

54 

55 Parameters 

56 ---------- 

57 repo : `str` or `lsst.daf.persistence.Butler` 

58 A Butler instance or a repository URL that can be used to construct 

59 one. 

60 dataIds : `list` of `dict` 

61 List of `butler` data IDs of Image catalogs to compare to reference. 

62 The `calexp` cpixel image is needed for the photometric calibration. 

63 matchRadius : `lsst.geom.Angle`, optional 

64 Radius for matching. Default is 1 arcsecond. 

65 brightSnrMin : `float`, optional 

66 Minimum median SNR for a source to be considered bright; passed to `filterSources`. 

67 brightSnrMax : `float`, optional 

68 Maximum median SNR for a source to be considered bright; passed to `filterSources`. 

69 faintSnrMin : `float`, optional 

70 Minimum median SNR for a source to be considered faint; passed to `filterSources`. 

71 faintSnrMax : `float`, optional 

72 Maximum median SNR for a source to be considered faint; passed to `filterSources`. 

73 doApplyExternalPhotoCalib : bool, optional 

74 Apply external photoCalib to calibrate fluxes. 

75 externalPhotoCalibName : str, optional 

76 Type of external `PhotoCalib` to apply. Currently supported are jointcal, 

77 fgcm, and fgcm_tract. Must be set if "doApplyExternalPhotoCalib" is True. 

78 doApplyExternalSkyWcs : bool, optional 

79 Apply external wcs to calibrate positions. 

80 externalSkyWcsName : str, optional: 

81 Type of external `wcs` to apply. Currently supported is jointcal. 

82 Must be set if "doApplyExternalSkyWcs" is True. 

83 skipTEx : `bool`, optional 

84 Skip TEx calculations (useful for older catalogs that don't have 

85 PsfShape measurements). 

86 skipNonSrd : `bool`, optional 

87 Skip any metrics not defined in the LSST SRD; default False. 

88 

89 Attributes of returned Blob 

90 ---------- 

91 filterName : `str` 

92 Name of filter used for all observations. 

93 mag : `astropy.units.Quantity` 

94 Mean PSF magnitudes of stars over multiple visits (magnitudes). 

95 magerr : `astropy.units.Quantity` 

96 Median 1-sigma uncertainty of PSF magnitudes over multiple visits 

97 (magnitudes). 

98 magrms : `astropy.units.Quantity` 

99 RMS of PSF magnitudes over multiple visits (magnitudes). 

100 snr : `astropy.units.Quantity` 

101 Median signal-to-noise ratio of PSF magnitudes over multiple visits 

102 (dimensionless). 

103 dist : `astropy.units.Quantity` 

104 RMS of sky coordinates of stars over multiple visits (milliarcseconds). 

105 

106 *Not serialized.* 

107 matchesFaint : `afw.table.GroupView` 

108 Faint matches containing only objects that have: 

109 

110 1. A PSF Flux measurement with sufficient S/N. 

111 2. A finite (non-nan) PSF magnitude. This separate check is largely 

112 to reject failed zeropoints. 

113 3. No flags set for bad, cosmic ray, edge or saturated. 

114 4. Extendedness consistent with a point source. 

115 

116 *Not serialized.* 

117 matchesBright : `afw.table.GroupView` 

118 Bright matches matching a higher S/N threshold than matchesFaint. 

119 

120 *Not serialized.* 

121 magKey 

122 Key for `"base_PsfFlux_mag"` in the `matchesFaint` and `matchesBright` 

123 catalog tables. 

124 

125 *Not serialized.* 

126 

127 Raises 

128 ------ 

129 RuntimeError: 

130 Raised if "doApplyExternalPhotoCalib" is True and "externalPhotoCalibName" 

131 is None, or if "doApplyExternalSkyWcs" is True and "externalSkyWcsName" is 

132 None. 

133 """ 

134 if doApplyExternalPhotoCalib and externalPhotoCalibName is None: 

135 raise RuntimeError("Must set externalPhotoCalibName if doApplyExternalPhotoCalib is True.") 

136 if doApplyExternalSkyWcs and externalSkyWcsName is None: 

137 raise RuntimeError("Must set externalSkyWcsName if doApplyExternalSkyWcs is True.") 

138 

139 blob = Blob('MatchedMultiVisitDataset') 

140 

141 if not matchRadius: 

142 matchRadius = geom.Angle(1, geom.arcseconds) 

143 

144 # Extract single filter 

145 blob['filterName'] = Datum(quantity=set([dId['filter'] for dId in dataIds]).pop(), 

146 description='Filter name') 

147 

148 # Record important configuration 

149 blob['doApplyExternalPhotoCalib'] = Datum(quantity=doApplyExternalPhotoCalib, 

150 description=('Whether external photometric ' 

151 'calibrations were used.')) 

152 blob['externalPhotoCalibName'] = Datum(quantity=externalPhotoCalibName, 

153 description='Name of external PhotoCalib dataset used.') 

154 blob['doApplyExternalSkyWcs'] = Datum(quantity=doApplyExternalSkyWcs, 

155 description='Whether external wcs calibrations were used.') 

156 blob['externalSkyWcsName'] = Datum(quantity=externalSkyWcsName, 

157 description='Name of external wcs dataset used.') 

158 

159 # Match catalogs across visits 

160 blob._catalog, blob._matchedCatalog = \ 

161 _loadAndMatchCatalogs(repo, dataIds, matchRadius, 

162 doApplyExternalPhotoCalib=doApplyExternalPhotoCalib, 

163 externalPhotoCalibName=externalPhotoCalibName, 

164 doApplyExternalSkyWcs=doApplyExternalSkyWcs, 

165 externalSkyWcsName=externalSkyWcsName, 

166 skipTEx=skipTEx, skipNonSrd=skipNonSrd) 

167 

168 blob.magKey = blob._matchedCatalog.schema.find("base_PsfFlux_mag").key 

169 # Reduce catalogs into summary statistics. 

170 # These are the serializable attributes of this class. 

171 filterResult = filterSources( 

172 blob._matchedCatalog, brightSnrMin=brightSnrMin, brightSnrMax=brightSnrMax, 

173 faintSnrMin=faintSnrMin, faintSnrMax=faintSnrMax, 

174 ) 

175 blob['brightSnrMin'] = Datum(quantity=filterResult.brightSnrMin * u.Unit(''), 

176 label='Bright SNR Min', 

177 description='Minimum median SNR for a source to be considered bright') 

178 blob['brightSnrMax'] = Datum(quantity=filterResult.brightSnrMax * u.Unit(''), 

179 label='Bright SNR Max', 

180 description='Maximum median SNR for a source to be considered bright') 

181 summarizeSources(blob, filterResult) 

182 return blob 

183 

184 

185def _loadAndMatchCatalogs(repo, dataIds, matchRadius, 

186 doApplyExternalPhotoCalib=False, externalPhotoCalibName=None, 

187 doApplyExternalSkyWcs=False, externalSkyWcsName=None, 

188 skipTEx=False, skipNonSrd=False): 

189 """Load data from specific visits and returned a calibrated catalog matched 

190 with a reference. 

191 

192 Parameters 

193 ---------- 

194 repo : `str` or `lsst.daf.persistence.Butler` 

195 A Butler or a repository URL that can be used to construct one. 

196 dataIds : list of dict 

197 List of butler data IDs of Image catalogs to compare to 

198 reference. The calexp cpixel image is needed for the photometric 

199 calibration. 

200 matchRadius : `lsst.geom.Angle`, optional 

201 Radius for matching. Default is 1 arcsecond. 

202 doApplyExternalPhotoCalib : bool, optional 

203 Apply external photoCalib to calibrate fluxes. 

204 externalPhotoCalibName : str, optional 

205 Type of external `PhotoCalib` to apply. Currently supported are jointcal, 

206 fgcm, and fgcm_tract. Must be set if doApplyExternalPhotoCalib is True. 

207 doApplyExternalSkyWcs : bool, optional 

208 Apply external wcs to calibrate positions. 

209 externalSkyWcsName : str, optional 

210 Type of external `wcs` to apply. Currently supported is jointcal. 

211 Must be set if "doApplyExternalWcs" is True. 

212 skipTEx : `bool`, optional 

213 Skip TEx calculations (useful for older catalogs that don't have 

214 PsfShape measurements). 

215 skipNonSrd : `bool`, optional 

216 Skip any metrics not defined in the LSST SRD; default False. 

217 

218 Returns 

219 ------- 

220 catalog : `lsst.afw.table.SourceCatalog` 

221 A new calibrated SourceCatalog. 

222 matches : `lsst.afw.table.GroupView` 

223 A GroupView of the matched sources. 

224 

225 Raises 

226 ------ 

227 RuntimeError: 

228 Raised if "doApplyExternalPhotoCalib" is True and "externalPhotoCalibName" 

229 is None, or if "doApplyExternalSkyWcs" is True and "externalSkyWcsName" is 

230 None. 

231 """ 

232 

233 if doApplyExternalPhotoCalib and externalPhotoCalibName is None: 

234 raise RuntimeError("Must set externalPhotoCalibName if doApplyExternalPhotoCalib is True.") 

235 if doApplyExternalSkyWcs and externalSkyWcsName is None: 

236 raise RuntimeError("Must set externalSkyWcsName if doApplyExternalSkyWcs is True.") 

237 

238 # Following 

239 # https://github.com/lsst/afw/blob/tickets/DM-3896/examples/repeatability.ipynb 

240 if isinstance(repo, dafPersist.Butler): 

241 butler = repo 

242 else: 

243 butler = dafPersist.Butler(repo) 

244 dataset = 'src' 

245 

246 # 2016-02-08 MWV: 

247 # I feel like I could be doing something more efficient with 

248 # something along the lines of the following: 

249 # dataRefs = [dafPersist.ButlerDataRef(butler, vId) for vId in dataIds] 

250 

251 ccdKeyName = getCcdKeyName(dataIds[0]) 

252 

253 # Hack to support raft and sensor 0,1 IDs as ints for multimatch 

254 if ccdKeyName == 'sensor': 

255 ccdKeyName = 'raft_sensor_int' 

256 for vId in dataIds: 

257 vId[ccdKeyName] = raftSensorToInt(vId) 

258 

259 schema = butler.get(dataset + "_schema").schema 

260 mapper = SchemaMapper(schema) 

261 mapper.addMinimalSchema(schema) 

262 mapper.addOutputField(Field[float]('base_PsfFlux_snr', 

263 'PSF flux SNR')) 

264 mapper.addOutputField(Field[float]('base_PsfFlux_mag', 

265 'PSF magnitude')) 

266 mapper.addOutputField(Field[float]('base_PsfFlux_magErr', 

267 'PSF magnitude uncertainty')) 

268 if not skipNonSrd: 

269 # Needed because addOutputField(... 'slot_ModelFlux_mag') will add a field with that literal name 

270 aliasMap = schema.getAliasMap() 

271 # Possibly not needed since base_GaussianFlux is the default, but this ought to be safe 

272 modelName = aliasMap['slot_ModelFlux'] if 'slot_ModelFlux' in aliasMap.keys() else 'base_GaussianFlux' 

273 mapper.addOutputField(Field[float](f'{modelName}_mag', 

274 'Model magnitude')) 

275 mapper.addOutputField(Field[float](f'{modelName}_magErr', 

276 'Model magnitude uncertainty')) 

277 mapper.addOutputField(Field[float](f'{modelName}_snr', 

278 'Model flux snr')) 

279 mapper.addOutputField(Field[float]('e1', 

280 'Source Ellipticity 1')) 

281 mapper.addOutputField(Field[float]('e2', 

282 'Source Ellipticity 1')) 

283 mapper.addOutputField(Field[float]('psf_e1', 

284 'PSF Ellipticity 1')) 

285 mapper.addOutputField(Field[float]('psf_e2', 

286 'PSF Ellipticity 1')) 

287 newSchema = mapper.getOutputSchema() 

288 newSchema.setAliasMap(schema.getAliasMap()) 

289 

290 # Create an object that matches multiple catalogs with same schema 

291 mmatch = MultiMatch(newSchema, 

292 dataIdFormat={'visit': np.int32, ccdKeyName: np.int32}, 

293 radius=matchRadius, 

294 RecordClass=SimpleRecord) 

295 

296 # create the new extented source catalog 

297 srcVis = SourceCatalog(newSchema) 

298 

299 for vId in dataIds: 

300 if not butler.datasetExists('src', vId): 

301 print(f'Could not find source catalog for {vId}; skipping.') 

302 continue 

303 

304 photoCalib = _loadPhotoCalib(butler, vId, 

305 doApplyExternalPhotoCalib, externalPhotoCalibName) 

306 if photoCalib is None: 

307 continue 

308 

309 if doApplyExternalSkyWcs: 

310 wcs = _loadExternalSkyWcs(butler, vId, externalSkyWcsName) 

311 if wcs is None: 

312 continue 

313 

314 # We don't want to put this above the first _loadPhotoCalib call 

315 # because we need to use the first `butler.get` in there to quickly 

316 # catch dataIDs with no usable outputs. 

317 try: 

318 # HSC supports these flags, which dramatically improve I/O 

319 # performance; support for other cameras is DM-6927. 

320 oldSrc = butler.get('src', vId, flags=SOURCE_IO_NO_FOOTPRINTS) 

321 except (OperationalError, sqlite3.OperationalError): 

322 oldSrc = butler.get('src', vId) 

323 

324 print(len(oldSrc), "sources in ccd %s visit %s" % 

325 (vId[ccdKeyName], vId["visit"])) 

326 

327 # create temporary catalog 

328 tmpCat = SourceCatalog(SourceCatalog(newSchema).table) 

329 tmpCat.extend(oldSrc, mapper=mapper) 

330 tmpCat['base_PsfFlux_snr'][:] = tmpCat['base_PsfFlux_instFlux'] \ 

331 / tmpCat['base_PsfFlux_instFluxErr'] 

332 

333 if doApplyExternalSkyWcs: 

334 afwTable.updateSourceCoords(wcs, tmpCat) 

335 photoCalib.instFluxToMagnitude(tmpCat, "base_PsfFlux", "base_PsfFlux") 

336 if not skipNonSrd: 

337 tmpCat['slot_ModelFlux_snr'][:] = (tmpCat['slot_ModelFlux_instFlux'] 

338 / tmpCat['slot_ModelFlux_instFluxErr']) 

339 photoCalib.instFluxToMagnitude(tmpCat, "slot_ModelFlux", "slot_ModelFlux") 

340 

341 if not skipTEx: 

342 _, psf_e1, psf_e2 = ellipticity_from_cat(oldSrc, slot_shape='slot_PsfShape') 

343 _, star_e1, star_e2 = ellipticity_from_cat(oldSrc, slot_shape='slot_Shape') 

344 tmpCat['e1'][:] = star_e1 

345 tmpCat['e2'][:] = star_e2 

346 tmpCat['psf_e1'][:] = psf_e1 

347 tmpCat['psf_e2'][:] = psf_e2 

348 

349 srcVis.extend(tmpCat, False) 

350 mmatch.add(catalog=tmpCat, dataId=vId) 

351 

352 # Complete the match, returning a catalog that includes 

353 # all matched sources with object IDs that can be used to group them. 

354 matchCat = mmatch.finish() 

355 

356 # Create a mapping object that allows the matches to be manipulated 

357 # as a mapping of object ID to catalog of sources. 

358 allMatches = GroupView.build(matchCat) 

359 

360 return srcVis, allMatches 

361 

362 

363def getKeysFilter(schema, nameFluxKey=None): 

364 """ Get schema keys for filtering sources. 

365 

366 schema : `lsst.afw.table.Schema` 

367 A table schema to retrieve keys from. 

368 nameFluxKey : `str` 

369 The name of a flux field to retrieve 

370 

371 Returns 

372 ------- 

373 keys : `lsst.pipe.base.Struct` 

374 A struct storing schema keys to aggregate over. 

375 """ 

376 if nameFluxKey is None: 

377 nameFluxKey = "base_PsfFlux" 

378 # Filter down to matches with at least 2 sources and good flags 

379 

380 return pipeBase.Struct( 

381 flags=[schema.find("base_PixelFlags_flag_%s" % flag).key 

382 for flag in ("saturated", "cr", "bad", "edge")], 

383 snr=schema.find(f"{nameFluxKey}_snr").key, 

384 mag=schema.find(f"{nameFluxKey}_mag").key, 

385 magErr=schema.find(f"{nameFluxKey}_magErr").key, 

386 extended=schema.find("base_ClassificationExtendedness_value").key, 

387 ) 

388 

389 

390def filterSources(allMatches, keys=None, faintSnrMin=None, brightSnrMin=None, safeExtendedness=None, 

391 extended=False, faintSnrMax=None, brightSnrMax=None): 

392 """Filter matched sources on flags and SNR. 

393 

394 Parameters 

395 ---------- 

396 allMatches : `lsst.afw.table.GroupView` 

397 GroupView object with matches. 

398 keys : `lsst.pipe.base.Struct` 

399 A struct storing schema keys to aggregate over. 

400 faintSnrMin : float, optional 

401 Minimum median SNR for a faint source match; default 5. 

402 brightSnrMin : float, optional 

403 Minimum median SNR for a bright source match; default 50. 

404 safeExtendedness: float, optional 

405 Maximum (exclusive) extendedness for sources or minimum (inclusive) if extended==True; default 1. 

406 extended: bool, optional 

407 Whether to select extended sources, i.e. galaxies. 

408 faintSnrMax : float, optional 

409 Maximum median SNR for a faint source match; default `numpy.Inf`. 

410 brightSnrMax : float, optional 

411 Maximum median SNR for a bright source match; default `numpy.Inf`. 

412 

413 Returns 

414 ------- 

415 filterResult : `lsst.pipe.base.Struct` 

416 A struct containing good and safe matches and the necessary keys to use them. 

417 """ 

418 if brightSnrMin is None: 

419 brightSnrMin = 50 

420 if brightSnrMax is None: 

421 brightSnrMax = np.Inf 

422 if faintSnrMin is None: 

423 faintSnrMin = 5 

424 if faintSnrMax is None: 

425 faintSnrMax = np.Inf 

426 if safeExtendedness is None: 

427 safeExtendedness = 1.0 

428 if keys is None: 

429 keys = getKeysFilter(allMatches.schema, "slot_ModelFlux" if extended else "base_PsfFlux") 

430 nMatchesRequired = 2 

431 snrMin, snrMax = faintSnrMin, faintSnrMax 

432 

433 def extendedFilter(cat): 

434 if len(cat) < nMatchesRequired: 

435 return False 

436 for flagKey in keys.flags: 

437 if cat.get(flagKey).any(): 

438 return False 

439 if not np.isfinite(cat.get(keys.mag)).all(): 

440 return False 

441 extendedness = cat.get(keys.extended) 

442 return np.min(extendedness) >= safeExtendedness if extended else \ 

443 np.max(extendedness) < safeExtendedness 

444 

445 def snrFilter(cat): 

446 # Note that this also implicitly checks for psfSnr being non-nan. 

447 snr = np.median(cat.get(keys.snr)) 

448 return snrMax >= snr >= snrMin 

449 

450 def fullFilter(cat): 

451 return extendedFilter(cat) and snrFilter(cat) 

452 

453 # If brightSnrMin range is a subset of faintSnrMin, it's safe to only filter on snr again 

454 # Otherwise, filter on flags/extendedness first, then snr 

455 isSafeSubset = faintSnrMax >= brightSnrMax and faintSnrMin <= brightSnrMin 

456 matchesFaint = allMatches.where(fullFilter) if isSafeSubset else allMatches.where(extendedFilter) 

457 snrMin, snrMax = brightSnrMin, brightSnrMax 

458 matchesBright = matchesFaint.where(snrFilter) 

459 # This means that matchesFaint has had extendedFilter but not snrFilter applied 

460 if not isSafeSubset: 

461 snrMin, snrMax = faintSnrMin, faintSnrMax 

462 matchesFaint = matchesFaint.where(snrFilter) 

463 

464 return pipeBase.Struct( 

465 extended=extended, keys=keys, matchesFaint=matchesFaint, matchesBright=matchesBright, 

466 brightSnrMin=brightSnrMin, brightSnrMax=brightSnrMax, 

467 faintSnrMin=faintSnrMin, faintSnrMax=faintSnrMax, 

468 ) 

469 

470 

471def summarizeSources(blob, filterResult): 

472 """Calculate summary statistics for each source. These are persisted 

473 as object attributes. 

474 

475 Parameters 

476 ---------- 

477 blob : `lsst.verify.blob.Blob` 

478 A verification blob to store Datums in. 

479 filterResult : `lsst.pipe.base.Struct` 

480 A struct containing bright and faint filter matches, as returned by `filterSources`. 

481 """ 

482 # Pass field=psfMagKey so np.mean just gets that as its input 

483 typeMag = "model" if filterResult.extended else "PSF" 

484 filter_name = blob['filterName'] 

485 source_type = f'{"extended" if filterResult.extended else "point"} sources"' 

486 matches = filterResult.matchesFaint 

487 keys = filterResult.keys 

488 blob['snr'] = Datum(quantity=matches.aggregate(np.median, field=keys.snr) * u.Unit(''), 

489 label='SNR({band})'.format(band=filter_name), 

490 description=f'Median signal-to-noise ratio of {typeMag} magnitudes for {source_type}' 

491 f' over multiple visits') 

492 blob['mag'] = Datum(quantity=matches.aggregate(np.mean, field=keys.mag) * u.mag, 

493 label='{band}'.format(band=filter_name), 

494 description=f'Mean of {typeMag} magnitudes for {source_type} over multiple visits') 

495 blob['magrms'] = Datum(quantity=matches.aggregate(np.std, field=keys.mag) * u.mag, 

496 label='RMS({band})'.format(band=filter_name), 

497 description=f'RMS of {typeMag} magnitudes for {source_type} over multiple visits') 

498 blob['magerr'] = Datum(quantity=matches.aggregate(np.median, field=keys.magErr) * u.mag, 

499 label='sigma({band})'.format(band=filter_name), 

500 description=f'Median 1-sigma uncertainty of {typeMag} magnitudes for {source_type}' 

501 f' over multiple visits') 

502 # positionRmsFromCat knows how to query a group 

503 # so we give it the whole thing by going with the default `field=None`. 

504 blob['dist'] = Datum(quantity=matches.aggregate(positionRmsFromCat) * u.milliarcsecond, 

505 label='d', 

506 description=f'RMS of sky coordinates of {source_type} over multiple visits') 

507 

508 # These attributes are not serialized 

509 blob.matchesFaint = filterResult.matchesFaint 

510 blob.matchesBright = filterResult.matchesBright 

511 

512 

513def _loadPhotoCalib(butler, dataId, doApplyExternalPhotoCalib, externalPhotoCalibName): 

514 """ 

515 Load a photoCalib object. 

516 

517 Parameters 

518 ---------- 

519 butler: `lsst.daf.persistence.Butler` 

520 dataId: Butler dataId `dict` 

521 doApplyExternalPhotoCalib: `bool` 

522 Apply external photoCalib to calibrate fluxes. 

523 externalPhotoCalibName: `str` 

524 Type of external `PhotoCalib` to apply. Currently supported are jointcal, 

525 fgcm, and fgcm_tract. Must be set if "doApplyExternalPhotoCalib" is True. 

526 

527 Returns 

528 ------- 

529 photoCalib: `lsst.afw.image.PhotoCalib` or None 

530 photoCalib to apply. None if a suitable one was not found. 

531 """ 

532 

533 photoCalib = None 

534 

535 if doApplyExternalPhotoCalib: 

536 try: 

537 photoCalib = butler.get(f"{externalPhotoCalibName}_photoCalib", dataId) 

538 except (FitsError, dafPersist.NoResults) as e: 

539 print(e) 

540 print(f'Could not open external photometric calib for {dataId}; skipping.') 

541 photoCalib = None 

542 else: 

543 try: 

544 photoCalib = butler.get('calexp_photoCalib', dataId) 

545 except (FitsError, dafPersist.NoResults) as e: 

546 print(e) 

547 print(f'Could not open calibrated image file for {dataId}; skipping.') 

548 except TypeError as te: 

549 # DECam images that haven't been properly reformatted 

550 # can trigger a TypeError because of a residual FITS header 

551 # LTV2 which is a float instead of the expected integer. 

552 # This generates an error of the form: 

553 # 

554 # lsst::pex::exceptions::TypeError: 'LTV2 has mismatched type' 

555 # 

556 # See, e.g., DM-2957 for details. 

557 print(te) 

558 print(f'Calibration image header information malformed for {dataId}; skipping.') 

559 photoCalib = None 

560 

561 return photoCalib 

562 

563 

564def _loadExternalSkyWcs(butler, dataId, externalSkyWcsName): 

565 """ 

566 Load a SkyWcs object. 

567 

568 Parameters 

569 ---------- 

570 butler: `lsst.daf.persistence.Butler` 

571 dataId: Butler dataId `dict` 

572 externalSkyWcsName: `str` 

573 Type of external `SkyWcs` to apply. Currently supported is jointcal. 

574 Must be not None if "doApplyExternalSkyWcs" is True. 

575 

576 Returns 

577 ------- 

578 SkyWcs: `lsst.afw.geom.SkyWcs` or None 

579 SkyWcs to apply. None if a suitable one was not found. 

580 """ 

581 

582 try: 

583 wcs = butler.get(f"{externalSkyWcsName}_wcs", dataId) 

584 except (FitsError, dafPersist.NoResults) as e: 

585 print(e) 

586 print(f'Could not open external WCS for {dataId}; skipping.') 

587 wcs = None 

588 

589 return wcs