Hide keyboard shortcuts

Hot-keys 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

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 

41from lsst.utils.timer import timeMethod 

42 

43 

44def isOldFluxField(name, units): 

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

46 "old-style" reference catalog flux field. 

47 """ 

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

49 isFlux = name.endswith('_flux') 

50 isFluxSigma = name.endswith('_fluxSigma') 

51 isFluxErr = name.endswith('_fluxErr') 

52 return (isFlux or isFluxSigma or isFluxErr) and unitsCheck 

53 

54 

55def hasNanojanskyFluxUnits(schema): 

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

57 """ 

58 for field in schema: 

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

60 return False 

61 return True 

62 

63 

64def getFormatVersionFromRefCat(refCat): 

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

66 

67 Parameters 

68 ---------- 

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

70 Reference catalog to inspect. 

71 

72 Returns 

73 ------- 

74 version : `int` 

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

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

77 """ 

78 md = refCat.getMetadata() 

79 if md is None: 

80 return 0 

81 try: 

82 return md.getScalar("REFCAT_FORMAT_VERSION") 

83 except KeyError: 

84 return 0 

85 

86 

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

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

89 

90 Parameters 

91 ---------- 

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

93 The catalog to convert. 

94 log : `lsst.log.Log` 

95 Log to send messages to. 

96 doConvert : `bool`, optional 

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

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

99 

100 Returns 

101 ------- 

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

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

104 

105 Notes 

106 ----- 

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

108 release of late calendar year 2019. 

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

110 """ 

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

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

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

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

115 input_fields = [] 

116 output_fields = [] 

117 for field in catalog.schema: 

118 oldName = field.field.getName() 

119 oldUnits = field.field.getUnits() 

120 if isOldFluxField(oldName, oldUnits): 

121 units = 'nJy' 

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

123 if oldName.endswith('_fluxSigma'): 

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

125 else: 

126 name = oldName 

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

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

129 input_fields.append(field.field) 

130 output_fields.append(newField) 

131 else: 

132 mapper.addMapping(field.getKey()) 

133 

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

135 

136 if doConvert: 

137 newSchema = mapper.getOutputSchema() 

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

139 output.extend(catalog, mapper=mapper) 

140 for field in output_fields: 

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

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

143 return output 

144 else: 

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

146 return None 

147 

148 

149class _FilterCatalog: 

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

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

152 the class. 

153 

154 Parameters 

155 ---------- 

156 region : `lsst.sphgeom.Region` 

157 The spatial region which all objects should lie within 

158 """ 

159 def __init__(self, region): 

160 self.region = region 

161 

162 def __call__(self, refCat, catRegion): 

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

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

165 

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

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

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

169 

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

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

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

173 this catalog is then returned. 

174 

175 Parameters 

176 --------- 

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

178 SourceCatalog to be filtered. 

179 catRegion : `lsst.sphgeom.Region` 

180 Region in which the catalog was created 

181 """ 

182 if catRegion.isWithin(self.region): 

183 # no filtering needed, region completely contains refcat 

184 return refCat 

185 

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

187 for record in refCat: 

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

189 filteredRefCat.append(record) 

190 return filteredRefCat 

191 

192 

193class ReferenceObjectLoaderBase: 

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

195 sharing. 

196 """ 

197 def applyProperMotions(self, catalog, epoch): 

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

199 

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

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

202 modifying the catalog in place. 

203 

204 Parameters 

205 ---------- 

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

207 Catalog of positions, containing at least these fields: 

208 

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

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

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

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

213 East positive) 

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

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

216 North positive) 

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

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

219 epoch : `astropy.time.Time` 

220 Epoch to which to correct proper motion. 

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

222 ``config.requireProperMotion`` is True. 

223 

224 Raises 

225 ------ 

226 RuntimeError 

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

228 apply the proper motion correction for some reason. 

229 """ 

230 if epoch is None: 

231 if self.config.requireProperMotion: 

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

233 else: 

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

235 return 

236 

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

238 if ("pm_ra" in catalog.schema 

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

240 if self.config.requireProperMotion: 

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

242 else: 

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

244 return 

245 

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

247 if self.config.requireProperMotion: 

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

249 else: 

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

251 return 

252 

253 applyProperMotionsImpl(self.log, catalog, epoch) 

254 

255 

256class ReferenceObjectLoader(ReferenceObjectLoaderBase): 

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

258 

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

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

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

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

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

264 call a corresponding method to load the reference objects. 

265 """ 

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

267 """ Constructs an instance of ReferenceObjectLoader 

268 

269 Parameters 

270 ---------- 

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

272 An iterable object of DataSetRefs which point to reference catalogs 

273 in a gen 3 repository. 

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

275 Handles to load refCats on demand 

276 config : `lsst.pex.config.configurableField` 

277 Configuration for the loader. 

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

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

280 lsst logger will be used. 

281 """ 

282 self.dataIds = dataIds 

283 self.refCats = refCats 

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

285 self.config = config 

286 

287 @staticmethod 

288 def _makeBoxRegion(BBox, wcs, BBoxPadding): 

289 outerLocalBBox = geom.Box2D(BBox) 

290 innerLocalBBox = geom.Box2D(BBox) 

291 

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

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

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

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

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

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

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

299 # entire region covered by the bbox. 

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

301 outerLocalBBox.grow(BBoxPadding) 

302 innerLocalBBox.grow(-1*BBoxPadding) 

303 

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

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

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

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

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

309 # it is what the calling code currently expects. 

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

311 innerLocalBBox = geom.Box2D(BBox) 

312 

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

314 innerBoxCorners = innerLocalBBox.getCorners() 

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

316 innerSkyRegion = sphgeom.ConvexPolygon(innerSphCorners) 

317 

318 outerBoxCorners = outerLocalBBox.getCorners() 

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

320 outerSkyRegion = sphgeom.ConvexPolygon(outerSphCorners) 

321 

322 return innerSkyRegion, outerSkyRegion, innerSphCorners, outerSphCorners 

323 

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

325 bboxToSpherePadding=100): 

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

327 region. 

328 

329 This algorithm works by creating a spherical box whose corners 

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

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

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

333 lie within the specified bounding box. 

334 

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

336 generic loadRegion method which loads and filters the reference objects 

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

338 set of reference objects. 

339 

340 Parameters 

341 ---------- 

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

343 Box which bounds a region in pixel space. 

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

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

346 the supplied ``bbox``. 

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

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

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

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

351 to not apply such corrections. 

352 photoCalib : `None` 

353 Deprecated and ignored, only included for api compatibility. 

354 bboxToSpherePadding : `int`, optional 

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

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

357 enitre area covered by the box. 

358 

359 Returns 

360 ------- 

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

362 Catalog containing reference objects inside the specified bounding 

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

364 

365 Raises 

366 ------ 

367 RuntimeError 

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

369 region. 

370 TypeError 

371 Raised if the loaded reference catalogs do not have matching 

372 schemas. 

373 """ 

374 paddedBbox = geom.Box2D(bbox) 

375 paddedBbox.grow(self.config.pixelMargin) 

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

377 

378 def _filterFunction(refCat, region): 

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

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

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

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

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

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

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

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

387 # sky <--> pixel conversions. 

388 preFiltFunc = _FilterCatalog(outerSkyRegion) 

389 refCat = preFiltFunc(refCat, region) 

390 

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

392 # coordinates to equivalent pixel positions for the wcs provided 

393 # and use to populate those columns. 

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

395 afwTable.updateRefCentroids(wcs, refCat) 

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

397 # region defined by the inner sky region. 

398 if innerSkyRegion.contains(region): 

399 return refCat 

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

401 # that fall inside the padded bbox. 

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

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

404 for record in refCat: 

405 pixCoords = record[centroidKey] 

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

407 filteredRefCat.append(record) 

408 return filteredRefCat 

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

410 

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

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

413 

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

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

416 reference catalogs which intersect but are not fully contained within 

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

418 This function returns a single source catalog containing all reference 

419 objects inside the specified region. 

420 

421 Parameters 

422 ---------- 

423 region : `lsst.sphgeom.Region` 

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

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

426 be loaded. 

427 filtFunc : callable or `None`, optional 

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

429 reference catalog and its corresponding region as parameters, 

430 filters the catalog by some criteria and returns the filtered 

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

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

433 input region. 

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

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

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

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

438 not apply such corrections. 

439 

440 Returns 

441 ------- 

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

443 Catalog containing reference objects which intersect the input region, 

444 filtered by the specified filter function. 

445 

446 Raises 

447 ------ 

448 RuntimeError 

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

450 region. 

451 TypeError 

452 Raised if the loaded reference catalogs do not have matching 

453 schemas. 

454 """ 

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

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

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

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

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

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

461 if filtFunc is None: 

462 filtFunc = _FilterCatalog(region) 

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

464 overlapList = [] 

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

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

467 # try the intersect operation in both directions 

468 try: 

469 intersects = dataId.region.intersects(region) 

470 except TypeError: 

471 intersects = region.intersects(dataId.region) 

472 

473 if intersects: 

474 overlapList.append((dataId, refCat)) 

475 

476 if len(overlapList) == 0: 

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

478 

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

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

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

482 

483 # Load in the remaining catalogs 

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

485 tmpCat = inputRefCat.get() 

486 

487 if tmpCat.schema != firstCat.schema: 

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

489 

490 filteredCat = filtFunc(tmpCat, dataId.region) 

491 refCat.extend(filteredCat) 

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

493 

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

495 trimmedAmount, len(refCat)) 

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

497 

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

499 if not refCat.isContiguous(): 

500 refCat = refCat.copy(deep=True) 

501 

502 self.applyProperMotions(refCat, epoch) 

503 

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

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

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

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

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

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

510 refCat = convertToNanojansky(refCat, self.log) 

511 

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

513 

514 # Add flux aliases 

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

516 

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

518 if not expandedCat.isContiguous(): 

519 expandedCat = expandedCat.copy(deep=True) 

520 

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

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

523 

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

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

526 

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

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

529 intersect the circle, and filters reference catalogs which intersect 

530 down to objects which lie within the defined circle. 

531 

532 Parameters 

533 ---------- 

534 ctrCoord : `lsst.geom.SpherePoint` 

535 Point defining the center of the circular region. 

536 radius : `lsst.geom.Angle` 

537 Defines the angular radius of the circular region. 

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

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

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

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

542 not apply such corrections. 

543 

544 Returns 

545 ------- 

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

547 Catalog containing reference objects inside the specified search 

548 circle. 

549 """ 

550 centerVector = ctrCoord.getVector() 

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

552 circularRegion = sphgeom.Circle(centerVector, sphRadius) 

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

554 

555 def joinMatchListWithCatalog(self, matchCat, sourceCat): 

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

557 

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

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

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

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

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

563 

564 Parameters 

565 ---------- 

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

567 Unpersisted packed match list. 

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

569 as returned by the astrometry tasks. 

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

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

572 by ID. 

573 

574 Returns 

575 ------- 

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

577 Match list. 

578 """ 

579 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat) 

580 

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

582 bboxToSpherePadding=100): 

583 """Return metadata about the load 

584 

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

586 reconstituting a normalised match list.) 

587 

588 Parameters 

589 ---------- 

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

591 Bounding box for the pixels. 

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

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

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

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

596 filter. 

597 photoCalib : `None` 

598 Deprecated, only included for api compatibility. 

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

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

601 not apply such corrections. 

602 bboxToSpherePadding : `int`, optional 

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

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

605 enitre area covered by the box. 

606 

607 Returns 

608 ------- 

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

610 The metadata detailing the search parameters used for this 

611 dataset. 

612 """ 

613 paddedBbox = geom.Box2D(bbox) 

614 paddedBbox.grow(self.config.pixelMargin) 

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

616 md = PropertyList() 

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

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

619 corners): 

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

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

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

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

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

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

626 return md 

627 

628 @staticmethod 

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

630 """Return metadata about the load. 

631 

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

633 reconstituting a normalized match list.) 

634 

635 Parameters 

636 ---------- 

637 coord : `lsst.geom.SpherePoint` 

638 ICRS center of the search region. 

639 radius : `lsst.geom.Angle` 

640 Radius of the search region. 

641 filterName : `str` or `None` 

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

643 filter. 

644 photoCalib : `None` 

645 Deprecated, only included for api compatibility. 

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

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

648 not apply such corrections. 

649 

650 Returns 

651 ------- 

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

653 """ 

654 md = PropertyList() 

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

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

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

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

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

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

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

662 return md 

663 

664 @staticmethod 

665 def addFluxAliases(refCat, defaultFilter, filterReferenceMap): 

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

667 

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

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

670 fluxes. 

671 

672 Parameters 

673 ---------- 

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

675 Catalog of reference objects 

676 defaultFilter : `str` 

677 Name of the default reference filter 

678 filterReferenceMap : `dict` of `str` 

679 Dictionary with keys corresponding to a filter name and values 

680 which correspond to the name of the reference filter. 

681 

682 Returns 

683 ------- 

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

685 Reference catalog with columns added to track reference filters. 

686 

687 Raises 

688 ------ 

689 `RuntimeError` 

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

691 key in the reference filter map. 

692 """ 

693 refCat = ReferenceObjectLoader.remapReferenceCatalogSchema(refCat, 

694 filterNameList=filterReferenceMap.keys()) 

695 aliasMap = refCat.schema.getAliasMap() 

696 if filterReferenceMap is None: 

697 filterReferenceMap = {} 

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

699 filterReferenceMap.items()): 

700 if refFilterName: 

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

702 refFluxName = refFilterName + "_flux" 

703 if refFluxName not in refCat.schema: 

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

705 aliasMap.set(camFluxName, refFluxName) 

706 

707 refFluxErrName = refFluxName + "Err" 

708 camFluxErrName = camFluxName + "Err" 

709 aliasMap.set(camFluxErrName, refFluxErrName) 

710 

711 return refCat 

712 

713 @staticmethod 

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

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

716 columns defined the remaining function arguments. 

717 

718 Parameters 

719 ---------- 

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

721 Reference catalog to map to new catalog 

722 

723 Returns 

724 ------- 

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

726 Deep copy of input reference catalog with additional columns added 

727 """ 

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

729 mapper.addMinimalSchema(refCat.schema, True) 

730 mapper.editOutputSchema().disconnectAliases() 

731 if filterNameList: 

732 for filterName in filterNameList: 

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

734 type=numpy.float64, 

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

736 units="Jy" 

737 ) 

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

739 type=numpy.float64, 

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

741 units="Jy" 

742 ) 

743 

744 if position: 

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

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

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

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

749 

750 if photometric: 

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

752 type="Flag", 

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

754 "calibration", 

755 ) 

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

757 type="Flag", 

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

759 ) 

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

761 type="Flag", 

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

763 ) 

764 

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

766 expandedCat.setMetadata(refCat.getMetadata()) 

767 expandedCat.extend(refCat, mapper=mapper) 

768 

769 return expandedCat 

770 

771 

772def getRefFluxField(schema, filterName=None): 

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

774 

775 return the alias of "anyFilterMapsToThis", if present 

776 else if filterName is specified: 

777 return "*filterName*_camFlux" if present 

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

779 matches reference filter name) 

780 else throw RuntimeError 

781 else: 

782 return "camFlux", if present, 

783 else throw RuntimeError 

784 

785 Parameters 

786 ---------- 

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

788 Reference catalog schema. 

789 filterName : `str`, optional 

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

791 set in the refcat loader config. 

792 

793 Returns 

794 ------- 

795 fluxFieldName : `str` 

796 Name of flux field. 

797 

798 Raises 

799 ------ 

800 RuntimeError 

801 If an appropriate field is not found. 

802 """ 

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

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

805 try: 

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

807 except LookupError: 

808 pass # try the filterMap next 

809 

810 if filterName: 

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

812 else: 

813 fluxFieldList = ["camFlux"] 

814 for fluxField in fluxFieldList: 

815 if fluxField in schema: 

816 return fluxField 

817 

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

819 

820 

821def getRefFluxKeys(schema, filterName=None): 

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

823 

824 Parameters 

825 ---------- 

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

827 Reference catalog schema. 

828 filterName : `str` 

829 Name of camera filter. 

830 

831 Returns 

832 ------- 

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

834 Two keys: 

835 

836 - flux key 

837 - flux error key, if present, else None 

838 

839 Raises 

840 ------ 

841 RuntimeError 

842 If flux field not found. 

843 """ 

844 fluxField = getRefFluxField(schema, filterName) 

845 fluxErrField = fluxField + "Err" 

846 fluxKey = schema[fluxField].asKey() 

847 try: 

848 fluxErrKey = schema[fluxErrField].asKey() 

849 except Exception: 

850 fluxErrKey = None 

851 return (fluxKey, fluxErrKey) 

852 

853 

854class LoadReferenceObjectsConfig(pexConfig.Config): 

855 pixelMargin = pexConfig.RangeField( 

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

857 dtype=int, 

858 default=250, 

859 min=0, 

860 ) 

861 defaultFilter = pexConfig.Field( 

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

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

864 dtype=str, 

865 default="", 

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

867 ) 

868 anyFilterMapsToThis = pexConfig.Field( 

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

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

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

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

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

874 dtype=str, 

875 default=None, 

876 optional=True 

877 ) 

878 filterMap = pexConfig.DictField( 

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

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

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

882 keytype=str, 

883 itemtype=str, 

884 default={}, 

885 ) 

886 requireProperMotion = pexConfig.Field( 

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

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

889 dtype=bool, 

890 default=False, 

891 ) 

892 

893 def validate(self): 

894 super().validate() 

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

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

897 raise pexConfig.FieldValidationError(LoadReferenceObjectsConfig.anyFilterMapsToThis, 

898 self, msg) 

899 

900 

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

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

903 """ 

904 ConfigClass = LoadReferenceObjectsConfig 

905 _DefaultName = "LoadReferenceObjects" 

906 

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

908 """Construct a LoadReferenceObjectsTask 

909 

910 Parameters 

911 ---------- 

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

913 Data butler, for access reference catalogs. 

914 """ 

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

916 self.butler = butler 

917 

918 @timeMethod 

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

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

921 

922 Parameters 

923 ---------- 

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

925 Bounding box for pixels. 

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

927 WCS; used to convert pixel positions to sky coordinates 

928 and vice-versa. 

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

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

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

932 (which are not yet implemented). 

933 photoCalib : `None` 

934 Deprecated, only included for api compatibility. 

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

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

937 not apply such corrections. 

938 

939 Returns 

940 ------- 

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

942 A Struct containing the following fields: 

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

944 A catalog of reference objects with the standard 

945 schema, as documented in the main doc string for 

946 `LoadReferenceObjects`. 

947 The catalog is guaranteed to be contiguous. 

948 fluxField : `str` 

949 Name of flux field for specified `filterName`. 

950 

951 Notes 

952 ----- 

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

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

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

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

957 """ 

958 circle = self._calculateCircle(bbox, wcs) 

959 

960 # find objects in circle 

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

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

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

964 centroids=True) 

965 refCat = loadRes.refCat 

966 numFound = len(refCat) 

967 

968 # trim objects outside bbox 

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

970 numTrimmed = numFound - len(refCat) 

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

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

973 

974 # make sure catalog is contiguous 

975 if not refCat.isContiguous(): 

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

977 

978 return loadRes 

979 

980 @abc.abstractmethod 

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

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

983 

984 Parameters 

985 ---------- 

986 ctrCoord : `lsst.geom.SpherePoint` 

987 ICRS center of search region. 

988 radius : `lsst.geom.Angle` 

989 Radius of search region. 

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

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

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

993 (which are not yet implemented). 

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

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

996 not apply such corrections. 

997 centroids : `bool`, optional 

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

999 these fields to exist. 

1000 

1001 Returns 

1002 ------- 

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

1004 A Struct containing the following fields: 

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

1006 A catalog of reference objects with the standard 

1007 schema, as documented in the main doc string for 

1008 `LoadReferenceObjects`. 

1009 The catalog is guaranteed to be contiguous. 

1010 fluxField : `str` 

1011 Name of flux field for specified `filterName`. 

1012 

1013 Notes 

1014 ----- 

1015 Note that subclasses are responsible for performing the proper motion 

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

1017 the catalog. 

1018 """ 

1019 return 

1020 

1021 @staticmethod 

1022 def _trimToBBox(refCat, bbox, wcs): 

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

1024 centroid and hasCentroid fields. 

1025 

1026 Parameters 

1027 ---------- 

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

1029 A catalog of objects. The schema must include fields 

1030 "coord", "centroid" and "hasCentroid". 

1031 The "coord" field is read. 

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

1033 bbox : `lsst.geom.Box2D` 

1034 Pixel region 

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

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

1037 

1038 Returns 

1039 ------- 

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

1041 Reference objects in the bbox, with centroid and 

1042 hasCentroid fields set. 

1043 """ 

1044 afwTable.updateRefCentroids(wcs, refCat) 

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

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

1047 for star in refCat: 

1048 point = star.get(centroidKey) 

1049 if bbox.contains(point): 

1050 retStarCat.append(star) 

1051 return retStarCat 

1052 

1053 def _addFluxAliases(self, schema): 

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

1055 

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

1057 camFlux: <defaultFilter>_flux 

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

1059 

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

1061 <camFilter>_camFlux: <refFilter>_flux 

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

1063 

1064 Parameters 

1065 ---------- 

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

1067 Schema for reference catalog. 

1068 

1069 Raises 

1070 ------ 

1071 RuntimeError 

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

1073 """ 

1074 aliasMap = schema.getAliasMap() 

1075 

1076 if self.config.anyFilterMapsToThis is not None: 

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

1078 if refFluxName not in schema: 

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

1080 raise RuntimeError(msg) 

1081 aliasMap.set("anyFilterMapsToThis", refFluxName) 

1082 return # this is mutually exclusive with filterMap 

1083 

1084 def addAliasesForOneFilter(filterName, refFilterName): 

1085 """Add aliases for a single filter 

1086 

1087 Parameters 

1088 ---------- 

1089 filterName : `str` (optional) 

1090 Camera filter name. The resulting alias name is 

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

1092 is `None` or `""`. 

1093 refFilterName : `str` 

1094 Reference catalog filter name; the field 

1095 <refFilterName>_flux must exist. 

1096 """ 

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

1098 refFluxName = refFilterName + "_flux" 

1099 if refFluxName not in schema: 

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

1101 aliasMap.set(camFluxName, refFluxName) 

1102 refFluxErrName = refFluxName + "Err" 

1103 if refFluxErrName in schema: 

1104 camFluxErrName = camFluxName + "Err" 

1105 aliasMap.set(camFluxErrName, refFluxErrName) 

1106 

1107 if self.config.defaultFilter: 

1108 addAliasesForOneFilter(None, self.config.defaultFilter) 

1109 

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

1111 addAliasesForOneFilter(filterName, refFilterName) 

1112 

1113 @staticmethod 

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

1115 addIsPhotometric=False, addIsResolved=False, 

1116 addIsVariable=False, coordErrDim=2, 

1117 addProperMotion=False, properMotionErrDim=2, 

1118 addParallax=False): 

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

1120 

1121 Parameters 

1122 ---------- 

1123 filterNameList : `list` of `str` 

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

1125 addIsPhotometric : `bool` 

1126 If True then add field "photometric". 

1127 addIsResolved : `bool` 

1128 If True then add field "resolved". 

1129 addIsVariable : `bool` 

1130 If True then add field "variable". 

1131 coordErrDim : `int` 

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

1133 

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

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

1136 addProperMotion : `bool` 

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

1138 properMotionErrDim : `int` 

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

1140 ignored if addProperMotion false: 

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

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

1143 addParallax : `bool` 

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

1145 and "parallax_flag". 

1146 

1147 Returns 

1148 ------- 

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

1150 Schema for reference catalog, an 

1151 `lsst.afw.table.SimpleCatalog`. 

1152 

1153 Notes 

1154 ----- 

1155 Reference catalogs support additional covariances, such as 

1156 covariance between RA and proper motion in declination, 

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

1158 calling this method. 

1159 """ 

1160 schema = afwTable.SimpleTable.makeMinimalSchema() 

1161 if addCentroid: 

1162 afwTable.Point2DKey.addFields( 

1163 schema, 

1164 "centroid", 

1165 "centroid on an exposure, if relevant", 

1166 "pixel", 

1167 ) 

1168 schema.addField( 

1169 field="hasCentroid", 

1170 type="Flag", 

1171 doc="is position known?", 

1172 ) 

1173 for filterName in filterNameList: 

1174 schema.addField( 

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

1176 type=numpy.float64, 

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

1178 units="nJy", 

1179 ) 

1180 for filterName in filterNameList: 

1181 schema.addField( 

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

1183 type=numpy.float64, 

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

1185 units="nJy", 

1186 ) 

1187 if addIsPhotometric: 

1188 schema.addField( 

1189 field="photometric", 

1190 type="Flag", 

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

1192 ) 

1193 if addIsResolved: 

1194 schema.addField( 

1195 field="resolved", 

1196 type="Flag", 

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

1198 ) 

1199 if addIsVariable: 

1200 schema.addField( 

1201 field="variable", 

1202 type="Flag", 

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

1204 ) 

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

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

1207 if coordErrDim > 0: 

1208 afwTable.CovarianceMatrix2fKey.addFields( 

1209 schema=schema, 

1210 prefix="coord", 

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

1212 units=["rad", "rad"], 

1213 diagonalOnly=(coordErrDim == 2), 

1214 ) 

1215 

1216 if addProperMotion or addParallax: 

1217 schema.addField( 

1218 field="epoch", 

1219 type=numpy.float64, 

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

1221 units="day", 

1222 ) 

1223 

1224 if addProperMotion: 

1225 schema.addField( 

1226 field="pm_ra", 

1227 type="Angle", 

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

1229 units="rad/year", 

1230 ) 

1231 schema.addField( 

1232 field="pm_dec", 

1233 type="Angle", 

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

1235 units="rad/year", 

1236 ) 

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

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

1239 if properMotionErrDim > 0: 

1240 afwTable.CovarianceMatrix2fKey.addFields( 

1241 schema=schema, 

1242 prefix="pm", 

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

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

1245 diagonalOnly=(properMotionErrDim == 2), 

1246 ) 

1247 schema.addField( 

1248 field="pm_flag", 

1249 type="Flag", 

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

1251 ) 

1252 

1253 if addParallax: 

1254 schema.addField( 

1255 field="parallax", 

1256 type="Angle", 

1257 doc="parallax", 

1258 units="rad", 

1259 ) 

1260 schema.addField( 

1261 field="parallaxErr", 

1262 type="Angle", 

1263 doc="uncertainty in parallax", 

1264 units="rad", 

1265 ) 

1266 schema.addField( 

1267 field="parallax_flag", 

1268 type="Flag", 

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

1270 ) 

1271 return schema 

1272 

1273 def _calculateCircle(self, bbox, wcs): 

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

1275 

1276 Parameters 

1277 ---------- 

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

1279 Pixel bounding box. 

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

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

1282 

1283 Returns 

1284 ------- 

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

1286 A Struct containing: 

1287 

1288 - coord : `lsst.geom.SpherePoint` 

1289 ICRS center of the search region. 

1290 - radius : `lsst.geom.Angle` 

1291 Radius of the search region. 

1292 - bbox : `lsst.geom.Box2D` 

1293 Bounding box used to compute the circle. 

1294 """ 

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

1296 bbox.grow(self.config.pixelMargin) 

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

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

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

1300 

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

1302 """Return metadata about the load. 

1303 

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

1305 reconstituting a normalised match list. 

1306 

1307 Parameters 

1308 ---------- 

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

1310 Pixel bounding box. 

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

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

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

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

1315 filter. 

1316 photoCalib : `None` 

1317 Deprecated, only included for api compatibility. 

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

1319 Epoch to which to correct proper motion and parallax, 

1320 or None to not apply such corrections. 

1321 

1322 Returns 

1323 ------- 

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

1325 Metadata about the load. 

1326 """ 

1327 circle = self._calculateCircle(bbox, wcs) 

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

1329 

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

1331 """Return metadata about the load. 

1332 

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

1334 reconstituting a normalised match list. 

1335 

1336 Parameters 

1337 ---------- 

1338 coord : `lsst.geom.SpherePoint` 

1339 ICRS center of the search region. 

1340 radius : `lsst.geom.Angle` 

1341 Radius of the search region. 

1342 filterName : `str` 

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

1344 filter. 

1345 photoCalib : `None` 

1346 Deprecated, only included for api compatibility. 

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

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

1349 not apply such corrections. 

1350 

1351 Returns 

1352 ------- 

1353 metadata : lsst.daf.base.PropertyList 

1354 Metadata about the load 

1355 """ 

1356 md = PropertyList() 

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

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

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

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

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

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

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

1364 return md 

1365 

1366 def joinMatchListWithCatalog(self, matchCat, sourceCat): 

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

1368 objects. 

1369 

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

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

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

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

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

1375 

1376 Parameters 

1377 ---------- 

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

1379 Unperisted packed match list. 

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

1381 as returned by the astrometry tasks. 

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

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

1384 by ID. 

1385 

1386 Returns 

1387 ------- 

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

1389 Match list. 

1390 """ 

1391 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat) 

1392 

1393 

1394def joinMatchListWithCatalogImpl(refObjLoader, matchCat, sourceCat): 

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

1396 objects. 

1397 

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

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

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

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

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

1403 

1404 Parameters 

1405 ---------- 

1406 refObjLoader 

1407 Reference object loader to use in getting reference objects 

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

1409 Unperisted packed match list. 

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

1411 as returned by the astrometry tasks. 

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

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

1414 by ID. 

1415 

1416 Returns 

1417 ------- 

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

1419 Match list. 

1420 """ 

1421 matchmeta = matchCat.table.getMetadata() 

1422 version = matchmeta.getInt('SMATCHV') 

1423 if version != 1: 

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

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

1426 try: 

1427 epoch = matchmeta.getDouble('EPOCH') 

1428 except (LookupError, TypeError): 

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

1430 if 'RADIUS' in matchmeta: 

1431 # This is a circle style metadata, call loadSkyCircle 

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

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

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

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

1436 elif "INNER_UPPER_LEFT_RA" in matchmeta: 

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

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

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

1440 # by the refObjLoader 

1441 box = [] 

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

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

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

1445 geom.degrees).getVector() 

1446 box.append(coord) 

1447 outerBox = sphgeom.ConvexPolygon(box) 

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

1449 

1450 refCat.sort() 

1451 sourceCat.sort() 

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

1453 

1454 

1455def applyProperMotionsImpl(log, catalog, epoch): 

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

1457 

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

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

1460 modifying the catalog in place. 

1461 

1462 Parameters 

1463 ---------- 

1464 log : `lsst.log.Log` 

1465 Log object to write to. 

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

1467 Catalog of positions, containing: 

1468 

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

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

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

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

1473 East positive) 

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

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

1476 North positive) 

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

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

1479 epoch : `astropy.time.Time` 

1480 Epoch to which to correct proper motion. 

1481 """ 

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

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

1484 return 

1485 if not catalog.isContiguous(): 

1486 raise RuntimeError("Catalog must be contiguous") 

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

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

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

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

1491 coordKey = catalog.table.getCoordKey() 

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

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

1494 pmRaRad = catalog["pm_ra"] 

1495 pmDecRad = catalog["pm_dec"] 

1496 offsetsRaRad = pmRaRad*timeDiffsYears 

1497 offsetsDecRad = pmDecRad*timeDiffsYears 

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

1499 # due to proper motion, and apply the offset 

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

1501 # a reasonable scale for typical values of proper motion 

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

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

1504 # needlessly large errors for short duration 

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

1506 offsetAmountsRad = numpy.hypot(offsetsRaRad, offsetsDecRad) 

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

1508 record.set(coordKey, 

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

1510 amount=amountRad*geom.radians)) 

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

1512 if "coord_raErr" in catalog.schema: 

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

1514 catalog["pm_raErr"]*timeDiffsYears) 

1515 if "coord_decErr" in catalog.schema: 

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

1517 catalog["pm_decErr"]*timeDiffsYears)