Coverage for python/lsst/meas/algorithms/loadReferenceObjects.py: 11%

435 statements  

« prev     ^ index     » next       coverage.py v7.1.0, created at 2023-02-05 18:13 -0800

1# 

2# LSST Data Management System 

3# 

4# Copyright 2008-2017 AURA/LSST. 

5# 

6# This product includes software developed by the 

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

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 LSST License Statement and 

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

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

22# 

23 

24__all__ = ["getRefFluxField", "getRefFluxKeys", "LoadReferenceObjectsTask", "LoadReferenceObjectsConfig", 

25 "ReferenceObjectLoader"] 

26 

27import abc 

28import itertools 

29 

30import astropy.time 

31import astropy.units 

32import numpy 

33 

34import lsst.geom as geom 

35import lsst.afw.table as afwTable 

36import lsst.pex.config as pexConfig 

37import lsst.pipe.base as pipeBase 

38import lsst.log 

39from lsst import sphgeom 

40from lsst.daf.base import PropertyList 

41 

42 

43def isOldFluxField(name, units): 

44 """Return True if this name/units combination corresponds to an 

45 "old-style" reference catalog flux field. 

46 """ 

47 unitsCheck = units != 'nJy' # (units == 'Jy' or units == '' or units == '?') 

48 isFlux = name.endswith('_flux') 

49 isFluxSigma = name.endswith('_fluxSigma') 

50 isFluxErr = name.endswith('_fluxErr') 

51 return (isFlux or isFluxSigma or isFluxErr) and unitsCheck 

52 

53 

54def hasNanojanskyFluxUnits(schema): 

55 """Return True if the units of all flux and fluxErr are correct (nJy). 

56 """ 

57 for field in schema: 

58 if isOldFluxField(field.field.getName(), field.field.getUnits()): 

59 return False 

60 return True 

61 

62 

63def getFormatVersionFromRefCat(refCat): 

64 """"Return the format version stored in a reference catalog header. 

65 

66 Parameters 

67 ---------- 

68 refCat : `lsst.afw.table.SimpleCatalog` 

69 Reference catalog to inspect. 

70 

71 Returns 

72 ------- 

73 version : `int` 

74 Format verison integer. Returns `0` if the catalog has no metadata 

75 or the metadata does not include a "REFCAT_FORMAT_VERSION" key. 

76 """ 

77 md = refCat.getMetadata() 

78 if md is None: 

79 return 0 

80 try: 

81 return md.getScalar("REFCAT_FORMAT_VERSION") 

82 except KeyError: 

83 return 0 

84 

85 

86def convertToNanojansky(catalog, log, doConvert=True): 

87 """Convert fluxes in a catalog from jansky to nanojansky. 

88 

89 Parameters 

90 ---------- 

91 catalog : `lsst.afw.table.SimpleCatalog` 

92 The catalog to convert. 

93 log : `lsst.log.Log` 

94 Log to send messages to. 

95 doConvert : `bool`, optional 

96 Return a converted catalog, or just identify the fields that need to be converted? 

97 This supports the "write=False" mode of `bin/convert_to_nJy.py`. 

98 

99 Returns 

100 ------- 

101 catalog : `lsst.afw.table.SimpleCatalog` or None 

102 The converted catalog, or None if ``doConvert`` is False. 

103 

104 Notes 

105 ----- 

106 Support for old units in reference catalogs will be removed after the 

107 release of late calendar year 2019. 

108 Use `meas_algorithms/bin/convert_to_nJy.py` to update your reference catalog. 

109 """ 

110 # Do not share the AliasMap: for refcats, that gets created when the 

111 # catalog is read from disk and should not be propagated. 

112 mapper = lsst.afw.table.SchemaMapper(catalog.schema, shareAliasMap=False) 

113 mapper.addMinimalSchema(lsst.afw.table.SimpleTable.makeMinimalSchema()) 

114 input_fields = [] 

115 output_fields = [] 

116 for field in catalog.schema: 

117 oldName = field.field.getName() 

118 oldUnits = field.field.getUnits() 

119 if isOldFluxField(oldName, oldUnits): 

120 units = 'nJy' 

121 # remap Sigma flux fields to Err, so we can drop the alias 

122 if oldName.endswith('_fluxSigma'): 

123 name = oldName.replace('_fluxSigma', '_fluxErr') 

124 else: 

125 name = oldName 

126 newField = lsst.afw.table.Field[field.dtype](name, field.field.getDoc(), units) 

127 mapper.addMapping(field.getKey(), newField) 

128 input_fields.append(field.field) 

129 output_fields.append(newField) 

130 else: 

131 mapper.addMapping(field.getKey()) 

132 

133 fluxFieldsStr = '; '.join("(%s, '%s')" % (field.getName(), field.getUnits()) for field in input_fields) 

134 

135 if doConvert: 

136 newSchema = mapper.getOutputSchema() 

137 output = lsst.afw.table.SimpleCatalog(newSchema) 

138 output.extend(catalog, mapper=mapper) 

139 for field in output_fields: 

140 output[field.getName()] *= 1e9 

141 log.info("Converted refcat flux fields to nJy (name, units): %s", fluxFieldsStr) 

142 return output 

143 else: 

144 log.info("Found old-style refcat flux fields (name, units): %s", fluxFieldsStr) 

145 return None 

146 

147 

148class _FilterCatalog: 

149 """This is a private helper class which filters catalogs by 

150 row based on the row being inside the region used to initialize 

151 the class. 

152 

153 Parameters 

154 ---------- 

155 region : `lsst.sphgeom.Region` 

156 The spatial region which all objects should lie within 

157 """ 

158 def __init__(self, region): 

159 self.region = region 

160 

161 def __call__(self, refCat, catRegion): 

162 """This call method on an instance of this class takes in a reference 

163 catalog, and the region from which the catalog was generated. 

164 

165 If the catalog region is entirely contained within the region used to 

166 initialize this class, then all the entries in the catalog must be 

167 within the region and so the whole catalog is returned. 

168 

169 If the catalog region is not entirely contained, then the location for 

170 each record is tested against the region used to initialize the class. 

171 Records which fall inside this region are added to a new catalog, and 

172 this catalog is then returned. 

173 

174 Parameters 

175 --------- 

176 refCat : `lsst.afw.table.SourceCatalog` 

177 SourceCatalog to be filtered. 

178 catRegion : `lsst.sphgeom.Region` 

179 Region in which the catalog was created 

180 """ 

181 if catRegion.isWithin(self.region): 

182 # no filtering needed, region completely contains refcat 

183 return refCat 

184 

185 filteredRefCat = type(refCat)(refCat.table) 

186 for record in refCat: 

187 if self.region.contains(record.getCoord().getVector()): 

188 filteredRefCat.append(record) 

189 return filteredRefCat 

190 

191 

192class ReferenceObjectLoaderBase: 

193 """Base class for reference object loaders, to facilitate gen2/gen3 code 

194 sharing. 

195 """ 

196 def applyProperMotions(self, catalog, epoch): 

197 """Apply proper motion correction to a reference catalog. 

198 

199 Adjust position and position error in the ``catalog`` 

200 for proper motion to the specified ``epoch``, 

201 modifying the catalog in place. 

202 

203 Parameters 

204 ---------- 

205 catalog : `lsst.afw.table.SimpleCatalog` 

206 Catalog of positions, containing at least these fields: 

207 

208 - Coordinates, retrieved by the table's coordinate key. 

209 - ``coord_raErr`` : Error in Right Ascension (rad). 

210 - ``coord_decErr`` : Error in Declination (rad). 

211 - ``pm_ra`` : Proper motion in Right Ascension (rad/yr, 

212 East positive) 

213 - ``pm_raErr`` : Error in ``pm_ra`` (rad/yr), optional. 

214 - ``pm_dec`` : Proper motion in Declination (rad/yr, 

215 North positive) 

216 - ``pm_decErr`` : Error in ``pm_dec`` (rad/yr), optional. 

217 - ``epoch`` : Mean epoch of object (an astropy.time.Time) 

218 epoch : `astropy.time.Time` 

219 Epoch to which to correct proper motion. 

220 If None, do not apply PM corrections or raise if 

221 ``config.requireProperMotion`` is True. 

222 

223 Raises 

224 ------ 

225 RuntimeError 

226 Raised if ``config.requireProperMotion`` is set but we cannot 

227 apply the proper motion correction for some reason. 

228 """ 

229 if epoch is None: 

230 if self.config.requireProperMotion: 

231 raise RuntimeError("requireProperMotion=True but epoch not provided to loader.") 

232 else: 

233 self.log.debug("No epoch provided: not applying proper motion corrections to refcat.") 

234 return 

235 

236 # Warn/raise for a catalog in an incorrect format, if epoch was specified. 

237 if ("pm_ra" in catalog.schema 

238 and not isinstance(catalog.schema["pm_ra"].asKey(), lsst.afw.table.KeyAngle)): 

239 if self.config.requireProperMotion: 

240 raise RuntimeError("requireProperMotion=True but refcat pm_ra field is not an Angle.") 

241 else: 

242 self.log.warning("Reference catalog pm_ra field is not an Angle; cannot apply proper motion.") 

243 return 

244 

245 if ("epoch" not in catalog.schema or "pm_ra" not in catalog.schema): 

246 if self.config.requireProperMotion: 

247 raise RuntimeError("requireProperMotion=True but PM data not available from catalog.") 

248 else: 

249 self.log.warning("Proper motion correction not available for this reference catalog.") 

250 return 

251 

252 applyProperMotionsImpl(self.log, catalog, epoch) 

253 

254 

255class ReferenceObjectLoader(ReferenceObjectLoaderBase): 

256 """This class facilitates loading reference catalogs with gen 3 middleware 

257 

258 The middleware preflight solver will create a list of datarefs that may 

259 possibly overlap a given region. These datarefs are then used to construct 

260 and instance of this class. The class instance should then be passed into 

261 a task which needs reference catalogs. These tasks should then determine 

262 the exact region of the sky reference catalogs will be loaded for, and 

263 call a corresponding method to load the reference objects. 

264 """ 

265 def __init__(self, dataIds, refCats, config, log=None): 

266 """ Constructs an instance of ReferenceObjectLoader 

267 

268 Parameters 

269 ---------- 

270 dataIds : iterable of `lsst.daf.butler.DataIds` 

271 An iterable object of DataSetRefs which point to reference catalogs 

272 in a gen 3 repository. 

273 refCats : iterable of `lsst.daf.butler.DeferedDatasetHandle` 

274 Handles to load refCats on demand 

275 config : `lsst.pex.config.configurableField` 

276 Configuration for the loader. 

277 log : `lsst.log.Log` or `None`, optional 

278 Logger object used to write out messages. If `None` the default 

279 lsst logger will be used. 

280 """ 

281 self.dataIds = dataIds 

282 self.refCats = refCats 

283 self.log = log or lsst.log.Log.getDefaultLogger() 

284 self.config = config 

285 

286 @staticmethod 

287 def _makeBoxRegion(BBox, wcs, BBoxPadding): 

288 outerLocalBBox = geom.Box2D(BBox) 

289 innerLocalBBox = geom.Box2D(BBox) 

290 

291 # Grow the bounding box to allow for effects not fully captured by the 

292 # wcs provided (which represents the current best-guess wcs solution 

293 # associated with the dataset for which the calibration is to be 

294 # computed using the loaded and trimmed reference catalog being defined 

295 # here). These effects could include pointing errors and/or an 

296 # insufficient optical distorition model for the instrument. The idea 

297 # is to ensure the spherical geometric region created contains the 

298 # entire region covered by the bbox. 

299 # Also create an inner region that is sure to be inside the bbox. 

300 outerLocalBBox.grow(BBoxPadding) 

301 innerLocalBBox.grow(-1*BBoxPadding) 

302 

303 # Handle the case where the inner bounding box shrank to a zero sized 

304 # region (which will be the case if the shrunken size of either 

305 # dimension is less than or equal to zero). In this case, the inner 

306 # bounding box is set to the original input bounding box. This is 

307 # probably not the best way to handle an empty inner bounding box, but 

308 # it is what the calling code currently expects. 

309 if innerLocalBBox.getDimensions() == geom.Extent2D(0, 0): 

310 innerLocalBBox = geom.Box2D(BBox) 

311 

312 # Convert the corners of the bounding boxes to sky coordinates. 

313 innerBoxCorners = innerLocalBBox.getCorners() 

314 innerSphCorners = [wcs.pixelToSky(corner).getVector() for corner in innerBoxCorners] 

315 innerSkyRegion = sphgeom.ConvexPolygon(innerSphCorners) 

316 

317 outerBoxCorners = outerLocalBBox.getCorners() 

318 outerSphCorners = [wcs.pixelToSky(corner).getVector() for corner in outerBoxCorners] 

319 outerSkyRegion = sphgeom.ConvexPolygon(outerSphCorners) 

320 

321 return innerSkyRegion, outerSkyRegion, innerSphCorners, outerSphCorners 

322 

323 def loadPixelBox(self, bbox, wcs, filterName=None, epoch=None, photoCalib=None, 

324 bboxToSpherePadding=100): 

325 """Load reference objects that are within a pixel-based rectangular 

326 region. 

327 

328 This algorithm works by creating a spherical box whose corners 

329 correspond to the WCS converted corners of the input bounding box 

330 (possibly padded). It then defines a filtering function which looks at 

331 the pixel position of the reference objects and accepts only those that 

332 lie within the specified bounding box. 

333 

334 The spherical box region and filtering function are passed to the 

335 generic loadRegion method which loads and filters the reference objects 

336 from the datastore and returns a single catalog containing the filtered 

337 set of reference objects. 

338 

339 Parameters 

340 ---------- 

341 bbox : `lsst.geom.Box2I` or `lsst.geom.Box2D` 

342 Box which bounds a region in pixel space. 

343 wcs : `lsst.afw.geom.SkyWcs` 

344 Wcs object defining the pixel to sky (and inverse) transform for 

345 the supplied ``bbox``. 

346 filterName : `str` or `None`, optional 

347 Name of camera filter, or `None` or blank for the default filter. 

348 epoch : `astropy.time.Time` or `None`, optional 

349 Epoch to which to correct proper motion and parallax, or `None` 

350 to not apply such corrections. 

351 photoCalib : `None` 

352 Deprecated and ignored, only included for api compatibility. 

353 bboxToSpherePadding : `int`, optional 

354 Padding to account for translating a set of corners into a 

355 spherical (convex) boundary that is certain to encompase the 

356 enitre area covered by the box. 

357 

358 Returns 

359 ------- 

360 referenceCatalog : `lsst.afw.table.SimpleCatalog` 

361 Catalog containing reference objects inside the specified bounding 

362 box (padded by self.config.pixelMargin). 

363 

364 Raises 

365 ------ 

366 RuntimeError 

367 Raised if no reference catalogs could be found for the specified 

368 region. 

369 TypeError 

370 Raised if the loaded reference catalogs do not have matching 

371 schemas. 

372 """ 

373 paddedBbox = geom.Box2D(bbox) 

374 paddedBbox.grow(self.config.pixelMargin) 

375 innerSkyRegion, outerSkyRegion, _, _ = self._makeBoxRegion(paddedBbox, wcs, bboxToSpherePadding) 

376 

377 def _filterFunction(refCat, region): 

378 # Perform an initial "pre filter" step based on the refCat coords 

379 # and the outerSkyRegion created from the self.config.pixelMargin- 

380 # paddedBbox plus an "extra" padding of bboxToSpherePadding and the 

381 # raw wcs. This should ensure a large enough projected area on the 

382 # sky that accounts for any projection/distortion issues, but small 

383 # enough to filter out loaded reference objects that lie well 

384 # beyond the projected detector of interest. This step is required 

385 # due to the very local nature of the wcs available for the 

386 # sky <--> pixel conversions. 

387 preFiltFunc = _FilterCatalog(outerSkyRegion) 

388 refCat = preFiltFunc(refCat, region) 

389 

390 # Add columns to the pre-filtered reference catalog relating their 

391 # coordinates to equivalent pixel positions for the wcs provided 

392 # and use to populate those columns. 

393 refCat = self.remapReferenceCatalogSchema(refCat, position=True) 

394 afwTable.updateRefCentroids(wcs, refCat) 

395 # No need to filter the catalog if it is entirely contained in the 

396 # region defined by the inner sky region. 

397 if innerSkyRegion.contains(region): 

398 return refCat 

399 # Create a new reference catalog, and populate it only with records 

400 # that fall inside the padded bbox. 

401 filteredRefCat = type(refCat)(refCat.table) 

402 centroidKey = afwTable.Point2DKey(refCat.schema['centroid']) 

403 for record in refCat: 

404 pixCoords = record[centroidKey] 

405 if paddedBbox.contains(geom.Point2D(pixCoords)): 

406 filteredRefCat.append(record) 

407 return filteredRefCat 

408 return self.loadRegion(outerSkyRegion, filtFunc=_filterFunction, epoch=epoch, filterName=filterName) 

409 

410 def loadRegion(self, region, filtFunc=None, filterName=None, epoch=None): 

411 """Load reference objects within a specified region. 

412 

413 This function loads the DataIds used to construct an instance of this 

414 class which intersect or are contained within the specified region. The 

415 reference catalogs which intersect but are not fully contained within 

416 the input region are further filtered by the specified filter function. 

417 This function returns a single source catalog containing all reference 

418 objects inside the specified region. 

419 

420 Parameters 

421 ---------- 

422 region : `lsst.sphgeom.Region` 

423 This can be any type that is derived from `lsst.sphgeom.Region` and 

424 should define the spatial region for which reference objects are to 

425 be loaded. 

426 filtFunc : callable or `None`, optional 

427 This optional parameter should be a callable object that takes a 

428 reference catalog and its corresponding region as parameters, 

429 filters the catalog by some criteria and returns the filtered 

430 reference catalog. If `None`, an internal filter function is used 

431 which filters according to if a reference object falls within the 

432 input region. 

433 filterName : `str` or `None`, optional 

434 Name of camera filter, or `None` or blank for the default filter. 

435 epoch : `astropy.time.Time` or `None`, optional 

436 Epoch to which to correct proper motion and parallax, or `None` to 

437 not apply such corrections. 

438 

439 Returns 

440 ------- 

441 referenceCatalog : `lsst.afw.table.SourceCatalog` 

442 Catalog containing reference objects which intersect the input region, 

443 filtered by the specified filter function. 

444 

445 Raises 

446 ------ 

447 RuntimeError 

448 Raised if no reference catalogs could be found for the specified 

449 region. 

450 TypeError 

451 Raised if the loaded reference catalogs do not have matching 

452 schemas. 

453 """ 

454 regionLat = region.getBoundingBox().getLat() 

455 regionLon = region.getBoundingBox().getLon() 

456 self.log.info("Loading reference objects from region bounded by " 

457 "[%.8f, %.8f], [%.8f, %.8f] RA Dec", 

458 regionLon.getA().asDegrees(), regionLon.getB().asDegrees(), 

459 regionLat.getA().asDegrees(), regionLat.getB().asDegrees()) 

460 if filtFunc is None: 

461 filtFunc = _FilterCatalog(region) 

462 # filter out all the regions supplied by the constructor that do not overlap 

463 overlapList = [] 

464 for dataId, refCat in zip(self.dataIds, self.refCats): 

465 # SphGeom supports some objects intersecting others, but is not symmetric, 

466 # try the intersect operation in both directions 

467 try: 

468 intersects = dataId.region.intersects(region) 

469 except TypeError: 

470 intersects = region.intersects(dataId.region) 

471 

472 if intersects: 

473 overlapList.append((dataId, refCat)) 

474 

475 if len(overlapList) == 0: 

476 raise RuntimeError("No reference tables could be found for input region") 

477 

478 firstCat = overlapList[0][1].get() 

479 refCat = filtFunc(firstCat, overlapList[0][0].region) 

480 trimmedAmount = len(firstCat) - len(refCat) 

481 

482 # Load in the remaining catalogs 

483 for dataId, inputRefCat in overlapList[1:]: 

484 tmpCat = inputRefCat.get() 

485 

486 if tmpCat.schema != firstCat.schema: 

487 raise TypeError("Reference catalogs have mismatching schemas") 

488 

489 filteredCat = filtFunc(tmpCat, dataId.region) 

490 refCat.extend(filteredCat) 

491 trimmedAmount += len(tmpCat) - len(filteredCat) 

492 

493 self.log.debug("Trimmed %d refCat objects lying outside padded region, leaving %d", 

494 trimmedAmount, len(refCat)) 

495 self.log.info("Loaded %d reference objects", len(refCat)) 

496 

497 # Ensure that the loaded reference catalog is continuous in memory 

498 if not refCat.isContiguous(): 

499 refCat = refCat.copy(deep=True) 

500 

501 self.applyProperMotions(refCat, epoch) 

502 

503 # Verify the schema is in the correct units and has the correct version; automatically convert 

504 # it with a warning if this is not the case. 

505 if not hasNanojanskyFluxUnits(refCat.schema) or not getFormatVersionFromRefCat(refCat) >= 1: 

506 self.log.warning("Found version 0 reference catalog with old style units in schema.") 

507 self.log.warning("run `meas_algorithms/bin/convert_refcat_to_nJy.py` to convert fluxes to nJy.") 

508 self.log.warning("See RFC-575 for more details.") 

509 refCat = convertToNanojansky(refCat, self.log) 

510 

511 expandedCat = self.remapReferenceCatalogSchema(refCat, position=True) 

512 

513 # Add flux aliases 

514 expandedCat = self.addFluxAliases(expandedCat, self.config.defaultFilter, self.config.filterMap) 

515 

516 # Ensure that the returned reference catalog is continuous in memory 

517 if not expandedCat.isContiguous(): 

518 expandedCat = expandedCat.copy(deep=True) 

519 

520 fluxField = getRefFluxField(schema=expandedCat.schema, filterName=filterName) 

521 return pipeBase.Struct(refCat=expandedCat, fluxField=fluxField) 

522 

523 def loadSkyCircle(self, ctrCoord, radius, filterName=None, epoch=None): 

524 """Load reference objects that lie within a circular region on the sky. 

525 

526 This method constructs a circular region from an input center and 

527 angular radius, loads reference catalogs which are contained in or 

528 intersect the circle, and filters reference catalogs which intersect 

529 down to objects which lie within the defined circle. 

530 

531 Parameters 

532 ---------- 

533 ctrCoord : `lsst.geom.SpherePoint` 

534 Point defining the center of the circular region. 

535 radius : `lsst.geom.Angle` 

536 Defines the angular radius of the circular region. 

537 filterName : `str` or `None`, optional 

538 Name of camera filter, or `None` or blank for the default filter. 

539 epoch : `astropy.time.Time` or `None`, optional 

540 Epoch to which to correct proper motion and parallax, or `None` to 

541 not apply such corrections. 

542 

543 Returns 

544 ------- 

545 referenceCatalog : `lsst.afw.table.SourceCatalog` 

546 Catalog containing reference objects inside the specified search 

547 circle. 

548 """ 

549 centerVector = ctrCoord.getVector() 

550 sphRadius = sphgeom.Angle(radius.asRadians()) 

551 circularRegion = sphgeom.Circle(centerVector, sphRadius) 

552 return self.loadRegion(circularRegion, filterName=filterName, epoch=epoch) 

553 

554 def joinMatchListWithCatalog(self, matchCat, sourceCat): 

555 """Relink an unpersisted match list to sources and reference objects. 

556 

557 A match list is persisted and unpersisted as a catalog of IDs 

558 produced by afw.table.packMatches(), with match metadata 

559 (as returned by the astrometry tasks) in the catalog's metadata 

560 attribute. This method converts such a match catalog into a match 

561 list, with links to source records and reference object records. 

562 

563 Parameters 

564 ---------- 

565 matchCat : `lsst.afw.table.BaseCatalog` 

566 Unpersisted packed match list. 

567 ``matchCat.table.getMetadata()`` must contain match metadata, 

568 as returned by the astrometry tasks. 

569 sourceCat : `lsst.afw.table.SourceCatalog` 

570 Source catalog. As a side effect, the catalog will be sorted 

571 by ID. 

572 

573 Returns 

574 ------- 

575 matchList : `lsst.afw.table.ReferenceMatchVector` 

576 Match list. 

577 """ 

578 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat) 

579 

580 def getMetadataBox(self, bbox, wcs, filterName=None, photoCalib=None, epoch=None, 

581 bboxToSpherePadding=100): 

582 """Return metadata about the load 

583 

584 This metadata is used for reloading the catalog (e.g., for 

585 reconstituting a normalised match list.) 

586 

587 Parameters 

588 ---------- 

589 bbox : `lsst.geom.Box2I` or `lsst.geom.Box2D` 

590 Bounding box for the pixels. 

591 wcs : `lsst.afw.geom.SkyWcs` 

592 The WCS object associated with ``bbox``. 

593 filterName : `str` or `None`, optional 

594 Name of the camera filter, or `None` or blank for the default 

595 filter. 

596 photoCalib : `None` 

597 Deprecated, only included for api compatibility. 

598 epoch : `astropy.time.Time` or `None`, optional 

599 Epoch to which to correct proper motion and parallax, or `None` to 

600 not apply such corrections. 

601 bboxToSpherePadding : `int`, optional 

602 Padding to account for translating a set of corners into a 

603 spherical (convex) boundary that is certain to encompase the 

604 enitre area covered by the box. 

605 

606 Returns 

607 ------- 

608 md : `lsst.daf.base.PropertyList` 

609 The metadata detailing the search parameters used for this 

610 dataset. 

611 """ 

612 paddedBbox = geom.Box2D(bbox) 

613 paddedBbox.grow(self.config.pixelMargin) 

614 _, _, innerCorners, outerCorners = self._makeBoxRegion(paddedBbox, wcs, bboxToSpherePadding) 

615 md = PropertyList() 

616 for box, corners in zip(("INNER", "OUTER"), (innerCorners, outerCorners)): 

617 for (name, corner) in zip(("UPPER_LEFT", "UPPER_RIGHT", "LOWER_LEFT", "LOWER_RIGHT"), 

618 corners): 

619 md.add(f"{box}_{name}_RA", geom.SpherePoint(corner).getRa().asDegrees(), f"{box}_corner") 

620 md.add(f"{box}_{name}_DEC", geom.SpherePoint(corner).getDec().asDegrees(), f"{box}_corner") 

621 md.add("SMATCHV", 1, 'SourceMatchVector version number') 

622 filterName = "UNKNOWN" if filterName is None else str(filterName) 

623 md.add('FILTER', filterName, 'filter name for photometric data') 

624 md.add('EPOCH', "NONE" if epoch is None else epoch.mjd, 'Epoch (TAI MJD) for catalog') 

625 return md 

626 

627 @staticmethod 

628 def getMetadataCircle(coord, radius, filterName, photoCalib=None, epoch=None): 

629 """Return metadata about the load. 

630 

631 This metadata is used for reloading the catalog (e.g. for 

632 reconstituting a normalized match list.) 

633 

634 Parameters 

635 ---------- 

636 coord : `lsst.geom.SpherePoint` 

637 ICRS center of the search region. 

638 radius : `lsst.geom.Angle` 

639 Radius of the search region. 

640 filterName : `str` or `None` 

641 Name of the camera filter, or `None` or blank for the default 

642 filter. 

643 photoCalib : `None` 

644 Deprecated, only included for api compatibility. 

645 epoch : `astropy.time.Time` or `None`, optional 

646 Epoch to which to correct proper motion and parallax, or `None` to 

647 not apply such corrections. 

648 

649 Returns 

650 ------- 

651 md : `lsst.daf.base.PropertyList` 

652 """ 

653 md = PropertyList() 

654 md.add('RA', coord.getRa().asDegrees(), 'field center in degrees') 

655 md.add('DEC', coord.getDec().asDegrees(), 'field center in degrees') 

656 md.add('RADIUS', radius.asDegrees(), 'field radius in degrees, minimum') 

657 md.add('SMATCHV', 1, 'SourceMatchVector version number') 

658 filterName = "UNKNOWN" if filterName is None else str(filterName) 

659 md.add('FILTER', filterName, 'filter name for photometric data') 

660 md.add('EPOCH', "NONE" if epoch is None else epoch.mjd, 'Epoch (TAI MJD) for catalog') 

661 return md 

662 

663 @staticmethod 

664 def addFluxAliases(refCat, defaultFilter, filterReferenceMap): 

665 """Add flux columns and aliases for camera to reference mapping. 

666 

667 Creates a new catalog containing the information of the input refCat 

668 as well as added flux columns and aliases between camera and reference 

669 fluxes. 

670 

671 Parameters 

672 ---------- 

673 refCat : `lsst.afw.table.SimpleCatalog` 

674 Catalog of reference objects 

675 defaultFilter : `str` 

676 Name of the default reference filter 

677 filterReferenceMap : `dict` of `str` 

678 Dictionary with keys corresponding to a filter name and values 

679 which correspond to the name of the reference filter. 

680 

681 Returns 

682 ------- 

683 refCat : `lsst.afw.table.SimpleCatalog` 

684 Reference catalog with columns added to track reference filters. 

685 

686 Raises 

687 ------ 

688 `RuntimeError` 

689 If the specified reference filter name is not specifed as a 

690 key in the reference filter map. 

691 """ 

692 refCat = ReferenceObjectLoader.remapReferenceCatalogSchema(refCat, 

693 filterNameList=filterReferenceMap.keys()) 

694 aliasMap = refCat.schema.getAliasMap() 

695 if filterReferenceMap is None: 

696 filterReferenceMap = {} 

697 for filterName, refFilterName in itertools.chain([(None, defaultFilter)], 

698 filterReferenceMap.items()): 

699 if refFilterName: 

700 camFluxName = filterName + "_camFlux" if filterName is not None else "camFlux" 

701 refFluxName = refFilterName + "_flux" 

702 if refFluxName not in refCat.schema: 

703 raise RuntimeError("Unknown reference filter %s" % (refFluxName,)) 

704 aliasMap.set(camFluxName, refFluxName) 

705 

706 refFluxErrName = refFluxName + "Err" 

707 camFluxErrName = camFluxName + "Err" 

708 aliasMap.set(camFluxErrName, refFluxErrName) 

709 

710 return refCat 

711 

712 @staticmethod 

713 def remapReferenceCatalogSchema(refCat, *, filterNameList=None, position=False, photometric=False): 

714 """This function takes in a reference catalog and creates a new catalog with additional 

715 columns defined the remaining function arguments. 

716 

717 Parameters 

718 ---------- 

719 refCat : `lsst.afw.table.SimpleCatalog` 

720 Reference catalog to map to new catalog 

721 

722 Returns 

723 ------- 

724 expandedCat : `lsst.afw.table.SimpleCatalog` 

725 Deep copy of input reference catalog with additional columns added 

726 """ 

727 mapper = afwTable.SchemaMapper(refCat.schema, True) 

728 mapper.addMinimalSchema(refCat.schema, True) 

729 mapper.editOutputSchema().disconnectAliases() 

730 if filterNameList: 

731 for filterName in filterNameList: 

732 mapper.editOutputSchema().addField(f"{filterName}_flux", 

733 type=numpy.float64, 

734 doc=f"flux in filter {filterName}", 

735 units="Jy" 

736 ) 

737 mapper.editOutputSchema().addField(f"{filterName}_fluxErr", 

738 type=numpy.float64, 

739 doc=f"flux uncertanty in filter {filterName}", 

740 units="Jy" 

741 ) 

742 

743 if position: 

744 mapper.editOutputSchema().addField("centroid_x", type=float, doReplace=True) 

745 mapper.editOutputSchema().addField("centroid_y", type=float, doReplace=True) 

746 mapper.editOutputSchema().addField("hasCentroid", type="Flag", doReplace=True) 

747 mapper.editOutputSchema().getAliasMap().set("slot_Centroid", "centroid") 

748 

749 if photometric: 

750 mapper.editOutputSchema().addField("photometric", 

751 type="Flag", 

752 doc="set if the object can be used for photometric" 

753 "calibration", 

754 ) 

755 mapper.editOutputSchema().addField("resolved", 

756 type="Flag", 

757 doc="set if the object is spatially resolved" 

758 ) 

759 mapper.editOutputSchema().addField("variable", 

760 type="Flag", 

761 doc="set if the object has variable brightness" 

762 ) 

763 

764 expandedCat = afwTable.SimpleCatalog(mapper.getOutputSchema()) 

765 expandedCat.setMetadata(refCat.getMetadata()) 

766 expandedCat.extend(refCat, mapper=mapper) 

767 

768 return expandedCat 

769 

770 

771def getRefFluxField(schema, filterName=None): 

772 """Get the name of a flux field from a schema. 

773 

774 return the alias of "anyFilterMapsToThis", if present 

775 else if filterName is specified: 

776 return "*filterName*_camFlux" if present 

777 else return "*filterName*_flux" if present (camera filter name 

778 matches reference filter name) 

779 else throw RuntimeError 

780 else: 

781 return "camFlux", if present, 

782 else throw RuntimeError 

783 

784 Parameters 

785 ---------- 

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

787 Reference catalog schema. 

788 filterName : `str`, optional 

789 Name of camera filter. If not specified, ``defaultFilter`` needs to be 

790 set in the refcat loader config. 

791 

792 Returns 

793 ------- 

794 fluxFieldName : `str` 

795 Name of flux field. 

796 

797 Raises 

798 ------ 

799 RuntimeError 

800 If an appropriate field is not found. 

801 """ 

802 if not isinstance(schema, afwTable.Schema): 

803 raise RuntimeError("schema=%s is not a schema" % (schema,)) 

804 try: 

805 return schema.getAliasMap().get("anyFilterMapsToThis") 

806 except LookupError: 

807 pass # try the filterMap next 

808 

809 if filterName: 

810 fluxFieldList = [filterName + "_camFlux", filterName + "_flux"] 

811 else: 

812 fluxFieldList = ["camFlux"] 

813 for fluxField in fluxFieldList: 

814 if fluxField in schema: 

815 return fluxField 

816 

817 raise RuntimeError("Could not find flux field(s) %s" % (", ".join(fluxFieldList))) 

818 

819 

820def getRefFluxKeys(schema, filterName=None): 

821 """Return keys for flux and flux error. 

822 

823 Parameters 

824 ---------- 

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

826 Reference catalog schema. 

827 filterName : `str` 

828 Name of camera filter. 

829 

830 Returns 

831 ------- 

832 keys : `tuple` of (`lsst.afw.table.Key`, `lsst.afw.table.Key`) 

833 Two keys: 

834 

835 - flux key 

836 - flux error key, if present, else None 

837 

838 Raises 

839 ------ 

840 RuntimeError 

841 If flux field not found. 

842 """ 

843 fluxField = getRefFluxField(schema, filterName) 

844 fluxErrField = fluxField + "Err" 

845 fluxKey = schema[fluxField].asKey() 

846 try: 

847 fluxErrKey = schema[fluxErrField].asKey() 

848 except Exception: 

849 fluxErrKey = None 

850 return (fluxKey, fluxErrKey) 

851 

852 

853class LoadReferenceObjectsConfig(pexConfig.Config): 

854 pixelMargin = pexConfig.RangeField( 

855 doc="Padding to add to 4 all edges of the bounding box (pixels)", 

856 dtype=int, 

857 default=250, 

858 min=0, 

859 ) 

860 defaultFilter = pexConfig.Field( 

861 doc=("Default reference catalog filter to use if filter not specified in exposure;" 

862 " if blank then filter must be specified in exposure."), 

863 dtype=str, 

864 default="", 

865 deprecated="defaultFilter is deprecated by RFC-716. Will be removed after v22." 

866 ) 

867 anyFilterMapsToThis = pexConfig.Field( 

868 doc=("Always use this reference catalog filter, no matter whether or what filter name is " 

869 "supplied to the loader. Effectively a trivial filterMap: map all filter names to this filter." 

870 " This can be set for purely-astrometric catalogs (e.g. Gaia DR2) where there is only one " 

871 "reasonable choice for every camera filter->refcat mapping, but not for refcats used for " 

872 "photometry, which need a filterMap and/or colorterms/transmission corrections."), 

873 dtype=str, 

874 default=None, 

875 optional=True 

876 ) 

877 filterMap = pexConfig.DictField( 

878 doc=("Mapping of camera filter name: reference catalog filter name; " 

879 "each reference filter must exist in the refcat." 

880 " Note that this does not perform any bandpass corrections: it is just a lookup."), 

881 keytype=str, 

882 itemtype=str, 

883 default={}, 

884 ) 

885 requireProperMotion = pexConfig.Field( 

886 doc="Require that the fields needed to correct proper motion " 

887 "(epoch, pm_ra and pm_dec) are present?", 

888 dtype=bool, 

889 default=False, 

890 ) 

891 

892 def validate(self): 

893 super().validate() 

894 if self.filterMap != {} and self.anyFilterMapsToThis is not None: 

895 msg = "`filterMap` and `anyFilterMapsToThis` are mutually exclusive" 

896 raise pexConfig.FieldValidationError(LoadReferenceObjectsConfig.anyFilterMapsToThis, 

897 self, msg) 

898 

899 

900class LoadReferenceObjectsTask(pipeBase.Task, ReferenceObjectLoaderBase, metaclass=abc.ABCMeta): 

901 """Abstract base class to load objects from reference catalogs. 

902 """ 

903 ConfigClass = LoadReferenceObjectsConfig 

904 _DefaultName = "LoadReferenceObjects" 

905 

906 def __init__(self, butler=None, *args, **kwargs): 

907 """Construct a LoadReferenceObjectsTask 

908 

909 Parameters 

910 ---------- 

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

912 Data butler, for access reference catalogs. 

913 """ 

914 pipeBase.Task.__init__(self, *args, **kwargs) 

915 self.butler = butler 

916 

917 @pipeBase.timeMethod 

918 def loadPixelBox(self, bbox, wcs, filterName=None, photoCalib=None, epoch=None): 

919 """Load reference objects that overlap a rectangular pixel region. 

920 

921 Parameters 

922 ---------- 

923 bbox : `lsst.geom.Box2I` or `lsst.geom.Box2D` 

924 Bounding box for pixels. 

925 wcs : `lsst.afw.geom.SkyWcs` 

926 WCS; used to convert pixel positions to sky coordinates 

927 and vice-versa. 

928 filterName : `str` or `None`, optional 

929 Name of filter, or `None` or `""` for the default filter. 

930 This is used for flux values in case we have flux limits 

931 (which are not yet implemented). 

932 photoCalib : `None` 

933 Deprecated, only included for api compatibility. 

934 epoch : `astropy.time.Time` or `None`, optional 

935 Epoch to which to correct proper motion and parallax, or `None` to 

936 not apply such corrections. 

937 

938 Returns 

939 ------- 

940 results : `lsst.pipe.base.Struct` 

941 A Struct containing the following fields: 

942 refCat : `lsst.afw.catalog.SimpleCatalog` 

943 A catalog of reference objects with the standard 

944 schema, as documented in the main doc string for 

945 `LoadReferenceObjects`. 

946 The catalog is guaranteed to be contiguous. 

947 fluxField : `str` 

948 Name of flux field for specified `filterName`. 

949 

950 Notes 

951 ----- 

952 The search algorithm works by searching in a region in sky 

953 coordinates whose center is the center of the bbox and radius 

954 is large enough to just include all 4 corners of the bbox. 

955 Stars that lie outside the bbox are then trimmed from the list. 

956 """ 

957 circle = self._calculateCircle(bbox, wcs) 

958 

959 # find objects in circle 

960 self.log.info("Loading reference objects using center %s and radius %s deg", 

961 circle.coord, circle.radius.asDegrees()) 

962 loadRes = self.loadSkyCircle(circle.coord, circle.radius, filterName=filterName, epoch=epoch, 

963 centroids=True) 

964 refCat = loadRes.refCat 

965 numFound = len(refCat) 

966 

967 # trim objects outside bbox 

968 refCat = self._trimToBBox(refCat=refCat, bbox=circle.bbox, wcs=wcs) 

969 numTrimmed = numFound - len(refCat) 

970 self.log.debug("trimmed %d out-of-bbox objects, leaving %d", numTrimmed, len(refCat)) 

971 self.log.info("Loaded %d reference objects", len(refCat)) 

972 

973 # make sure catalog is contiguous 

974 if not refCat.isContiguous(): 

975 loadRes.refCat = refCat.copy(deep=True) 

976 

977 return loadRes 

978 

979 @abc.abstractmethod 

980 def loadSkyCircle(self, ctrCoord, radius, filterName=None, epoch=None, centroids=False): 

981 """Load reference objects that overlap a circular sky region. 

982 

983 Parameters 

984 ---------- 

985 ctrCoord : `lsst.geom.SpherePoint` 

986 ICRS center of search region. 

987 radius : `lsst.geom.Angle` 

988 Radius of search region. 

989 filterName : `str` or `None`, optional 

990 Name of filter, or `None` or `""` for the default filter. 

991 This is used for flux values in case we have flux limits 

992 (which are not yet implemented). 

993 epoch : `astropy.time.Time` or `None`, optional 

994 Epoch to which to correct proper motion and parallax, or `None` to 

995 not apply such corrections. 

996 centroids : `bool`, optional 

997 Add centroid fields to the loaded Schema. ``loadPixelBox`` expects 

998 these fields to exist. 

999 

1000 Returns 

1001 ------- 

1002 results : `lsst.pipe.base.Struct` 

1003 A Struct containing the following fields: 

1004 refCat : `lsst.afw.catalog.SimpleCatalog` 

1005 A catalog of reference objects with the standard 

1006 schema, as documented in the main doc string for 

1007 `LoadReferenceObjects`. 

1008 The catalog is guaranteed to be contiguous. 

1009 fluxField : `str` 

1010 Name of flux field for specified `filterName`. 

1011 

1012 Notes 

1013 ----- 

1014 Note that subclasses are responsible for performing the proper motion 

1015 correction, since this is the lowest-level interface for retrieving 

1016 the catalog. 

1017 """ 

1018 return 

1019 

1020 @staticmethod 

1021 def _trimToBBox(refCat, bbox, wcs): 

1022 """Remove objects outside a given pixel bounding box and set 

1023 centroid and hasCentroid fields. 

1024 

1025 Parameters 

1026 ---------- 

1027 refCat : `lsst.afw.table.SimpleCatalog` 

1028 A catalog of objects. The schema must include fields 

1029 "coord", "centroid" and "hasCentroid". 

1030 The "coord" field is read. 

1031 The "centroid" and "hasCentroid" fields are set. 

1032 bbox : `lsst.geom.Box2D` 

1033 Pixel region 

1034 wcs : `lsst.afw.geom.SkyWcs` 

1035 WCS; used to convert sky coordinates to pixel positions. 

1036 

1037 Returns 

1038 ------- 

1039 catalog : `lsst.afw.table.SimpleCatalog` 

1040 Reference objects in the bbox, with centroid and 

1041 hasCentroid fields set. 

1042 """ 

1043 afwTable.updateRefCentroids(wcs, refCat) 

1044 centroidKey = afwTable.Point2DKey(refCat.schema["centroid"]) 

1045 retStarCat = type(refCat)(refCat.table) 

1046 for star in refCat: 

1047 point = star.get(centroidKey) 

1048 if bbox.contains(point): 

1049 retStarCat.append(star) 

1050 return retStarCat 

1051 

1052 def _addFluxAliases(self, schema): 

1053 """Add aliases for camera filter fluxes to the schema. 

1054 

1055 If self.config.defaultFilter then adds these aliases: 

1056 camFlux: <defaultFilter>_flux 

1057 camFluxErr: <defaultFilter>_fluxErr, if the latter exists 

1058 

1059 For each camFilter: refFilter in self.config.filterMap adds these aliases: 

1060 <camFilter>_camFlux: <refFilter>_flux 

1061 <camFilter>_camFluxErr: <refFilter>_fluxErr, if the latter exists 

1062 

1063 Parameters 

1064 ---------- 

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

1066 Schema for reference catalog. 

1067 

1068 Raises 

1069 ------ 

1070 RuntimeError 

1071 If any reference flux field is missing from the schema. 

1072 """ 

1073 aliasMap = schema.getAliasMap() 

1074 

1075 if self.config.anyFilterMapsToThis is not None: 

1076 refFluxName = self.config.anyFilterMapsToThis + "_flux" 

1077 if refFluxName not in schema: 

1078 msg = f"Unknown reference filter for anyFilterMapsToThis='{refFluxName}'" 

1079 raise RuntimeError(msg) 

1080 aliasMap.set("anyFilterMapsToThis", refFluxName) 

1081 return # this is mutually exclusive with filterMap 

1082 

1083 def addAliasesForOneFilter(filterName, refFilterName): 

1084 """Add aliases for a single filter 

1085 

1086 Parameters 

1087 ---------- 

1088 filterName : `str` (optional) 

1089 Camera filter name. The resulting alias name is 

1090 <filterName>_camFlux, or simply "camFlux" if `filterName` 

1091 is `None` or `""`. 

1092 refFilterName : `str` 

1093 Reference catalog filter name; the field 

1094 <refFilterName>_flux must exist. 

1095 """ 

1096 camFluxName = filterName + "_camFlux" if filterName is not None else "camFlux" 

1097 refFluxName = refFilterName + "_flux" 

1098 if refFluxName not in schema: 

1099 raise RuntimeError("Unknown reference filter %s" % (refFluxName,)) 

1100 aliasMap.set(camFluxName, refFluxName) 

1101 refFluxErrName = refFluxName + "Err" 

1102 if refFluxErrName in schema: 

1103 camFluxErrName = camFluxName + "Err" 

1104 aliasMap.set(camFluxErrName, refFluxErrName) 

1105 

1106 if self.config.defaultFilter: 

1107 addAliasesForOneFilter(None, self.config.defaultFilter) 

1108 

1109 for filterName, refFilterName in self.config.filterMap.items(): 

1110 addAliasesForOneFilter(filterName, refFilterName) 

1111 

1112 @staticmethod 

1113 def makeMinimalSchema(filterNameList, *, addCentroid=False, 

1114 addIsPhotometric=False, addIsResolved=False, 

1115 addIsVariable=False, coordErrDim=2, 

1116 addProperMotion=False, properMotionErrDim=2, 

1117 addParallax=False): 

1118 """Make a standard schema for reference object catalogs. 

1119 

1120 Parameters 

1121 ---------- 

1122 filterNameList : `list` of `str` 

1123 List of filter names. Used to create <filterName>_flux fields. 

1124 addIsPhotometric : `bool` 

1125 If True then add field "photometric". 

1126 addIsResolved : `bool` 

1127 If True then add field "resolved". 

1128 addIsVariable : `bool` 

1129 If True then add field "variable". 

1130 coordErrDim : `int` 

1131 Number of coord error fields; must be one of 0, 2, 3: 

1132 

1133 - If 2 or 3: add fields "coord_raErr" and "coord_decErr". 

1134 - If 3: also add field "coord_radecErr". 

1135 addProperMotion : `bool` 

1136 If True add fields "epoch", "pm_ra", "pm_dec" and "pm_flag". 

1137 properMotionErrDim : `int` 

1138 Number of proper motion error fields; must be one of 0, 2, 3; 

1139 ignored if addProperMotion false: 

1140 - If 2 or 3: add fields "pm_raErr" and "pm_decErr". 

1141 - If 3: also add field "pm_radecErr". 

1142 addParallax : `bool` 

1143 If True add fields "epoch", "parallax", "parallaxErr" 

1144 and "parallax_flag". 

1145 

1146 Returns 

1147 ------- 

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

1149 Schema for reference catalog, an 

1150 `lsst.afw.table.SimpleCatalog`. 

1151 

1152 Notes 

1153 ----- 

1154 Reference catalogs support additional covariances, such as 

1155 covariance between RA and proper motion in declination, 

1156 that are not supported by this method, but can be added after 

1157 calling this method. 

1158 """ 

1159 schema = afwTable.SimpleTable.makeMinimalSchema() 

1160 if addCentroid: 

1161 afwTable.Point2DKey.addFields( 

1162 schema, 

1163 "centroid", 

1164 "centroid on an exposure, if relevant", 

1165 "pixel", 

1166 ) 

1167 schema.addField( 

1168 field="hasCentroid", 

1169 type="Flag", 

1170 doc="is position known?", 

1171 ) 

1172 for filterName in filterNameList: 

1173 schema.addField( 

1174 field="%s_flux" % (filterName,), 

1175 type=numpy.float64, 

1176 doc="flux in filter %s" % (filterName,), 

1177 units="nJy", 

1178 ) 

1179 for filterName in filterNameList: 

1180 schema.addField( 

1181 field="%s_fluxErr" % (filterName,), 

1182 type=numpy.float64, 

1183 doc="flux uncertainty in filter %s" % (filterName,), 

1184 units="nJy", 

1185 ) 

1186 if addIsPhotometric: 

1187 schema.addField( 

1188 field="photometric", 

1189 type="Flag", 

1190 doc="set if the object can be used for photometric calibration", 

1191 ) 

1192 if addIsResolved: 

1193 schema.addField( 

1194 field="resolved", 

1195 type="Flag", 

1196 doc="set if the object is spatially resolved", 

1197 ) 

1198 if addIsVariable: 

1199 schema.addField( 

1200 field="variable", 

1201 type="Flag", 

1202 doc="set if the object has variable brightness", 

1203 ) 

1204 if coordErrDim not in (0, 2, 3): 

1205 raise ValueError("coordErrDim={}; must be (0, 2, 3)".format(coordErrDim)) 

1206 if coordErrDim > 0: 

1207 afwTable.CovarianceMatrix2fKey.addFields( 

1208 schema=schema, 

1209 prefix="coord", 

1210 names=["ra", "dec"], 

1211 units=["rad", "rad"], 

1212 diagonalOnly=(coordErrDim == 2), 

1213 ) 

1214 

1215 if addProperMotion or addParallax: 

1216 schema.addField( 

1217 field="epoch", 

1218 type=numpy.float64, 

1219 doc="date of observation (TAI, MJD)", 

1220 units="day", 

1221 ) 

1222 

1223 if addProperMotion: 

1224 schema.addField( 

1225 field="pm_ra", 

1226 type="Angle", 

1227 doc="proper motion in the right ascension direction = dra/dt * cos(dec)", 

1228 units="rad/year", 

1229 ) 

1230 schema.addField( 

1231 field="pm_dec", 

1232 type="Angle", 

1233 doc="proper motion in the declination direction", 

1234 units="rad/year", 

1235 ) 

1236 if properMotionErrDim not in (0, 2, 3): 

1237 raise ValueError("properMotionErrDim={}; must be (0, 2, 3)".format(properMotionErrDim)) 

1238 if properMotionErrDim > 0: 

1239 afwTable.CovarianceMatrix2fKey.addFields( 

1240 schema=schema, 

1241 prefix="pm", 

1242 names=["ra", "dec"], 

1243 units=["rad/year", "rad/year"], 

1244 diagonalOnly=(properMotionErrDim == 2), 

1245 ) 

1246 schema.addField( 

1247 field="pm_flag", 

1248 type="Flag", 

1249 doc="Set if proper motion or proper motion error is bad", 

1250 ) 

1251 

1252 if addParallax: 

1253 schema.addField( 

1254 field="parallax", 

1255 type="Angle", 

1256 doc="parallax", 

1257 units="rad", 

1258 ) 

1259 schema.addField( 

1260 field="parallaxErr", 

1261 type="Angle", 

1262 doc="uncertainty in parallax", 

1263 units="rad", 

1264 ) 

1265 schema.addField( 

1266 field="parallax_flag", 

1267 type="Flag", 

1268 doc="Set if parallax or parallax error is bad", 

1269 ) 

1270 return schema 

1271 

1272 def _calculateCircle(self, bbox, wcs): 

1273 """Compute on-sky center and radius of search region. 

1274 

1275 Parameters 

1276 ---------- 

1277 bbox : `lsst.geom.Box2I` or `lsst.geom.Box2D` 

1278 Pixel bounding box. 

1279 wcs : `lsst.afw.geom.SkyWcs` 

1280 WCS; used to convert pixel positions to sky coordinates. 

1281 

1282 Returns 

1283 ------- 

1284 results : `lsst.pipe.base.Struct` 

1285 A Struct containing: 

1286 

1287 - coord : `lsst.geom.SpherePoint` 

1288 ICRS center of the search region. 

1289 - radius : `lsst.geom.Angle` 

1290 Radius of the search region. 

1291 - bbox : `lsst.geom.Box2D` 

1292 Bounding box used to compute the circle. 

1293 """ 

1294 bbox = geom.Box2D(bbox) # make sure bbox is double and that we have a copy 

1295 bbox.grow(self.config.pixelMargin) 

1296 coord = wcs.pixelToSky(bbox.getCenter()) 

1297 radius = max(coord.separation(wcs.pixelToSky(pp)) for pp in bbox.getCorners()) 

1298 return pipeBase.Struct(coord=coord, radius=radius, bbox=bbox) 

1299 

1300 def getMetadataBox(self, bbox, wcs, filterName=None, photoCalib=None, epoch=None): 

1301 """Return metadata about the load. 

1302 

1303 This metadata is used for reloading the catalog (e.g., for 

1304 reconstituting a normalised match list. 

1305 

1306 Parameters 

1307 ---------- 

1308 bbox : `lsst.geom.Box2I` or `lsst.geom.Box2D` 

1309 Pixel bounding box. 

1310 wcs : `lsst.afw.geom.SkyWcs` 

1311 WCS; used to convert pixel positions to sky coordinates. 

1312 filterName : `str` or `None`, optional 

1313 Name of camera filter, or `None` or `""` for the default 

1314 filter. 

1315 photoCalib : `None` 

1316 Deprecated, only included for api compatibility. 

1317 epoch : `astropy.time.Time` or `None`, optional 

1318 Epoch to which to correct proper motion and parallax, 

1319 or None to not apply such corrections. 

1320 

1321 Returns 

1322 ------- 

1323 metadata : `lsst.daf.base.PropertyList` 

1324 Metadata about the load. 

1325 """ 

1326 circle = self._calculateCircle(bbox, wcs) 

1327 return self.getMetadataCircle(circle.coord, circle.radius, filterName, epoch=epoch) 

1328 

1329 def getMetadataCircle(self, coord, radius, filterName, photoCalib=None, epoch=None): 

1330 """Return metadata about the load. 

1331 

1332 This metadata is used for reloading the catalog (e.g., for 

1333 reconstituting a normalised match list. 

1334 

1335 Parameters 

1336 ---------- 

1337 coord : `lsst.geom.SpherePoint` 

1338 ICRS center of the search region. 

1339 radius : `lsst.geom.Angle` 

1340 Radius of the search region. 

1341 filterName : `str` 

1342 Name of camera filter, or `None` or `""` for the default 

1343 filter. 

1344 photoCalib : `None` 

1345 Deprecated, only included for api compatibility. 

1346 epoch : `astropy.time.Time` (optional) 

1347 Epoch to which to correct proper motion and parallax, or `None` to 

1348 not apply such corrections. 

1349 

1350 Returns 

1351 ------- 

1352 metadata : lsst.daf.base.PropertyList 

1353 Metadata about the load 

1354 """ 

1355 md = PropertyList() 

1356 md.add('RA', coord.getRa().asDegrees(), 'field center in degrees') 

1357 md.add('DEC', coord.getDec().asDegrees(), 'field center in degrees') 

1358 md.add('RADIUS', radius.asDegrees(), 'field radius in degrees, minimum') 

1359 md.add('SMATCHV', 1, 'SourceMatchVector version number') 

1360 filterName = "UNKNOWN" if filterName is None else str(filterName) 

1361 md.add('FILTER', filterName, 'filter name for photometric data') 

1362 md.add('EPOCH', "NONE" if epoch is None else epoch.mjd, 'Epoch (TAI MJD) for catalog') 

1363 return md 

1364 

1365 def joinMatchListWithCatalog(self, matchCat, sourceCat): 

1366 """Relink an unpersisted match list to sources and reference 

1367 objects. 

1368 

1369 A match list is persisted and unpersisted as a catalog of IDs 

1370 produced by afw.table.packMatches(), with match metadata 

1371 (as returned by the astrometry tasks) in the catalog's metadata 

1372 attribute. This method converts such a match catalog into a match 

1373 list, with links to source records and reference object records. 

1374 

1375 Parameters 

1376 ---------- 

1377 matchCat : `lsst.afw.table.BaseCatalog` 

1378 Unperisted packed match list. 

1379 ``matchCat.table.getMetadata()`` must contain match metadata, 

1380 as returned by the astrometry tasks. 

1381 sourceCat : `lsst.afw.table.SourceCatalog` 

1382 Source catalog. As a side effect, the catalog will be sorted 

1383 by ID. 

1384 

1385 Returns 

1386 ------- 

1387 matchList : `lsst.afw.table.ReferenceMatchVector` 

1388 Match list. 

1389 """ 

1390 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat) 

1391 

1392 

1393def joinMatchListWithCatalogImpl(refObjLoader, matchCat, sourceCat): 

1394 """Relink an unpersisted match list to sources and reference 

1395 objects. 

1396 

1397 A match list is persisted and unpersisted as a catalog of IDs 

1398 produced by afw.table.packMatches(), with match metadata 

1399 (as returned by the astrometry tasks) in the catalog's metadata 

1400 attribute. This method converts such a match catalog into a match 

1401 list, with links to source records and reference object records. 

1402 

1403 Parameters 

1404 ---------- 

1405 refObjLoader 

1406 Reference object loader to use in getting reference objects 

1407 matchCat : `lsst.afw.table.BaseCatalog` 

1408 Unperisted packed match list. 

1409 ``matchCat.table.getMetadata()`` must contain match metadata, 

1410 as returned by the astrometry tasks. 

1411 sourceCat : `lsst.afw.table.SourceCatalog` 

1412 Source catalog. As a side effect, the catalog will be sorted 

1413 by ID. 

1414 

1415 Returns 

1416 ------- 

1417 matchList : `lsst.afw.table.ReferenceMatchVector` 

1418 Match list. 

1419 """ 

1420 matchmeta = matchCat.table.getMetadata() 

1421 version = matchmeta.getInt('SMATCHV') 

1422 if version != 1: 

1423 raise ValueError('SourceMatchVector version number is %i, not 1.' % version) 

1424 filterName = matchmeta.getString('FILTER').strip() 

1425 try: 

1426 epoch = matchmeta.getDouble('EPOCH') 

1427 except (LookupError, TypeError): 

1428 epoch = None # Not present, or not correct type means it's not set 

1429 if 'RADIUS' in matchmeta: 

1430 # This is a circle style metadata, call loadSkyCircle 

1431 ctrCoord = geom.SpherePoint(matchmeta.getDouble('RA'), 

1432 matchmeta.getDouble('DEC'), geom.degrees) 

1433 rad = matchmeta.getDouble('RADIUS')*geom.degrees 

1434 refCat = refObjLoader.loadSkyCircle(ctrCoord, rad, filterName, epoch=epoch).refCat 

1435 elif "INNER_UPPER_LEFT_RA" in matchmeta: 

1436 # This is the sky box type (only triggers in the LoadReferenceObject class, not task) 

1437 # Only the outer box is required to be loaded to get the maximum region, all filtering 

1438 # will be done by the unpackMatches function, and no spatial filtering needs to be done 

1439 # by the refObjLoader 

1440 box = [] 

1441 for place in ("UPPER_LEFT", "UPPER_RIGHT", "LOWER_LEFT", "LOWER_RIGHT"): 

1442 coord = geom.SpherePoint(matchmeta.getDouble(f"OUTER_{place}_RA"), 

1443 matchmeta.getDouble(f"OUTER_{place}_DEC"), 

1444 geom.degrees).getVector() 

1445 box.append(coord) 

1446 outerBox = sphgeom.ConvexPolygon(box) 

1447 refCat = refObjLoader.loadRegion(outerBox, filterName=filterName, epoch=epoch).refCat 

1448 

1449 refCat.sort() 

1450 sourceCat.sort() 

1451 return afwTable.unpackMatches(matchCat, refCat, sourceCat) 

1452 

1453 

1454def applyProperMotionsImpl(log, catalog, epoch): 

1455 """Apply proper motion correction to a reference catalog. 

1456 

1457 Adjust position and position error in the ``catalog`` 

1458 for proper motion to the specified ``epoch``, 

1459 modifying the catalog in place. 

1460 

1461 Parameters 

1462 ---------- 

1463 log : `lsst.log.Log` 

1464 Log object to write to. 

1465 catalog : `lsst.afw.table.SimpleCatalog` 

1466 Catalog of positions, containing: 

1467 

1468 - Coordinates, retrieved by the table's coordinate key. 

1469 - ``coord_raErr`` : Error in Right Ascension (rad). 

1470 - ``coord_decErr`` : Error in Declination (rad). 

1471 - ``pm_ra`` : Proper motion in Right Ascension (rad/yr, 

1472 East positive) 

1473 - ``pm_raErr`` : Error in ``pm_ra`` (rad/yr), optional. 

1474 - ``pm_dec`` : Proper motion in Declination (rad/yr, 

1475 North positive) 

1476 - ``pm_decErr`` : Error in ``pm_dec`` (rad/yr), optional. 

1477 - ``epoch`` : Mean epoch of object (an astropy.time.Time) 

1478 epoch : `astropy.time.Time` 

1479 Epoch to which to correct proper motion. 

1480 """ 

1481 if "epoch" not in catalog.schema or "pm_ra" not in catalog.schema or "pm_dec" not in catalog.schema: 

1482 log.warning("Proper motion correction not available from catalog") 

1483 return 

1484 if not catalog.isContiguous(): 

1485 raise RuntimeError("Catalog must be contiguous") 

1486 catEpoch = astropy.time.Time(catalog["epoch"], scale="tai", format="mjd") 

1487 log.info("Correcting reference catalog for proper motion to %r", epoch) 

1488 # Use `epoch.tai` to make sure the time difference is in TAI 

1489 timeDiffsYears = (epoch.tai - catEpoch).to(astropy.units.yr).value 

1490 coordKey = catalog.table.getCoordKey() 

1491 # Compute the offset of each object due to proper motion 

1492 # as components of the arc of a great circle along RA and Dec 

1493 pmRaRad = catalog["pm_ra"] 

1494 pmDecRad = catalog["pm_dec"] 

1495 offsetsRaRad = pmRaRad*timeDiffsYears 

1496 offsetsDecRad = pmDecRad*timeDiffsYears 

1497 # Compute the corresponding bearing and arc length of each offset 

1498 # due to proper motion, and apply the offset 

1499 # The factor of 1e6 for computing bearing is intended as 

1500 # a reasonable scale for typical values of proper motion 

1501 # in order to avoid large errors for small values of proper motion; 

1502 # using the offsets is another option, but it can give 

1503 # needlessly large errors for short duration 

1504 offsetBearingsRad = numpy.arctan2(pmDecRad*1e6, pmRaRad*1e6) 

1505 offsetAmountsRad = numpy.hypot(offsetsRaRad, offsetsDecRad) 

1506 for record, bearingRad, amountRad in zip(catalog, offsetBearingsRad, offsetAmountsRad): 

1507 record.set(coordKey, 

1508 record.get(coordKey).offset(bearing=bearingRad*geom.radians, 

1509 amount=amountRad*geom.radians)) 

1510 # Increase error in RA and Dec based on error in proper motion 

1511 if "coord_raErr" in catalog.schema: 

1512 catalog["coord_raErr"] = numpy.hypot(catalog["coord_raErr"], 

1513 catalog["pm_raErr"]*timeDiffsYears) 

1514 if "coord_decErr" in catalog.schema: 

1515 catalog["coord_decErr"] = numpy.hypot(catalog["coord_decErr"], 

1516 catalog["pm_decErr"]*timeDiffsYears)