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 

35import lsst.afw.table as afwTable 

36import lsst.pex.config as pexConfig 

37import lsst.pex.exceptions as pexExceptions 

38import lsst.pipe.base as pipeBase 

39import lsst.pex.exceptions as pexExcept 

40import lsst.log 

41from lsst import geom 

42from lsst import sphgeom 

43from lsst.daf.base import PropertyList 

44 

45 

46def isOldFluxField(name, units): 

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

48 "old-style" reference catalog flux field. 

49 """ 

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

51 isFlux = name.endswith('_flux') 

52 isFluxSigma = name.endswith('_fluxSigma') 

53 isFluxErr = name.endswith('_fluxErr') 

54 return (isFlux or isFluxSigma or isFluxErr) and unitsCheck 

55 

56 

57def hasNanojanskyFluxUnits(schema): 

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

59 """ 

60 for field in schema: 

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

62 return False 

63 return True 

64 

65 

66def getFormatVersionFromRefCat(refCat): 

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

68 

69 Parameters 

70 ---------- 

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

72 Reference catalog to inspect. 

73 

74 Returns 

75 ------- 

76 version : `int` or `None` 

77 Format version integer, or `None` if the catalog has no metadata 

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

79 """ 

80 md = refCat.getMetadata() 

81 if md is None: 

82 return None 

83 try: 

84 return md.getScalar("REFCAT_FORMAT_VERSION") 

85 except KeyError: 

86 return None 

87 

88 

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

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

91 

92 Parameters 

93 ---------- 

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

95 The catalog to convert. 

96 log : `lsst.log.Log` 

97 Log to send messages to. 

98 doConvert : `bool`, optional 

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

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

101 

102 Returns 

103 ------- 

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

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

106 

107 Notes 

108 ----- 

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

110 release of late calendar year 2019. 

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

112 """ 

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

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

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

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

117 input_fields = [] 

118 output_fields = [] 

119 for field in catalog.schema: 

120 oldName = field.field.getName() 

121 oldUnits = field.field.getUnits() 

122 if isOldFluxField(oldName, oldUnits): 

123 units = 'nJy' 

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

125 if oldName.endswith('_fluxSigma'): 

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

127 else: 

128 name = oldName 

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

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

131 input_fields.append(field.field) 

132 output_fields.append(newField) 

133 else: 

134 mapper.addMapping(field.getKey()) 

135 

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

137 

138 if doConvert: 

139 newSchema = mapper.getOutputSchema() 

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

141 output.extend(catalog, mapper=mapper) 

142 for field in output_fields: 

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

144 log.info(f"Converted refcat flux fields to nJy (name, units): {fluxFieldsStr}") 

145 return output 

146 else: 

147 log.info(f"Found old-style refcat flux fields (name, units): {fluxFieldsStr}") 

148 return None 

149 

150 

151class _FilterCatalog: 

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

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

154 the class. 

155 

156 Parameters 

157 ---------- 

158 region : `lsst.sphgeom.Region` 

159 The spatial region which all objects should lie within 

160 """ 

161 def __init__(self, region): 

162 self.region = region 

163 

164 def __call__(self, refCat, catRegion): 

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

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

167 

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

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

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

171 

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

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

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

175 this catalog is then returned. 

176 

177 Parameters 

178 --------- 

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

180 SourceCatalog to be filtered. 

181 catRegion : `lsst.sphgeom.Region` 

182 Region in which the catalog was created 

183 """ 

184 if catRegion.isWithin(self.region): 

185 # no filtering needed, region completely contains refcat 

186 return refCat 

187 

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

189 for record in refCat: 

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

191 filteredRefCat.append(record) 

192 return filteredRefCat 

193 

194 

195class ReferenceObjectLoaderBase: 

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

197 sharing. 

198 """ 

199 def applyProperMotions(self, catalog, epoch): 

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

201 

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

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

204 modifying the catalog in place. 

205 

206 Parameters 

207 ---------- 

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

209 Catalog of positions, containing at least these fields: 

210 

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

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

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

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

215 East positive) 

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

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

218 North positive) 

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

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

221 epoch : `astropy.time.Time` 

222 Epoch to which to correct proper motion. 

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

224 ``config.requireProperMotion`` is True. 

225 

226 Raises 

227 ------ 

228 RuntimeError 

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

230 apply the proper motion correction for some reason. 

231 """ 

232 if epoch is None: 

233 if self.config.requireProperMotion: 

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

235 else: 

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

237 return 

238 

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

240 if ("pm_ra" in catalog.schema 

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

242 if self.config.requireProperMotion: 

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

244 else: 

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

246 return 

247 

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

249 if self.config.requireProperMotion: 

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

251 else: 

252 self.log.warn("Proper motion correction not available for this reference catalog.") 

253 return 

254 

255 applyProperMotionsImpl(self.log, catalog, epoch) 

256 

257 

258class ReferenceObjectLoader(ReferenceObjectLoaderBase): 

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

260 

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

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

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

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

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

266 call a corresponding method to load the reference objects. 

267 """ 

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

269 """ Constructs an instance of ReferenceObjectLoader 

270 

271 Parameters 

272 ---------- 

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

274 An iterable object of DataSetRefs which point to reference catalogs 

275 in a gen 3 repository 

276 refCats : Iterable of `lsst.daf.butler.DeferedDatasetHandle` 

277 Handles to load refCats on demand 

278 log : `lsst.log.Log` 

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

280 lsst logger will be used 

281 

282 """ 

283 self.dataIds = dataIds 

284 self.refCats = refCats 

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

286 self.config = config 

287 

288 @staticmethod 

289 def _makeBoxRegion(BBox, wcs, BBoxPadding): 

290 outerLocalBBox = geom.Box2D(BBox) 

291 innerLocalBBox = geom.Box2D(BBox) 

292 

293 # Grow the bounding box to make sure the spherical geometry bbox will contain 

294 # the same region, as non-padded boxes may contain different regions because of optical distortion. 

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

296 outerLocalBBox.grow(BBoxPadding) 

297 innerLocalBBox.grow(-1*BBoxPadding) 

298 

299 # Handle the fact that the inner bounding box shrunk to a zero sized region in at least one 

300 # dimension, in which case all reference catalogs must be checked fully against the input 

301 # bounding box 

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

303 innerSkyRegion = sphgeom.Box() 

304 else: 

305 innerBoxCorners = innerLocalBBox.getCorners() 

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

307 

308 innerSkyRegion = sphgeom.ConvexPolygon(innerSphCorners) 

309 

310 # Convert the corners of the box to sky coordinates 

311 outerBoxCorners = outerLocalBBox.getCorners() 

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

313 

314 outerSkyRegion = sphgeom.ConvexPolygon(outerSphCorners) 

315 

316 return innerSkyRegion, outerSkyRegion, innerSphCorners, outerSphCorners 

317 

318 def loadPixelBox(self, bbox, wcs, filterName=None, epoch=None, photoCalib=None, bboxPadding=100): 

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

320 

321 This algorithm works by creating a spherical box whose corners correspond 

322 to the WCS converted corners of the input bounding box (possibly padded). 

323 It then defines a filtering function which will look at a reference 

324 objects pixel position and accept objects that lie within the specified 

325 bounding box. 

326 

327 The spherical box region and filtering function are passed to the generic 

328 loadRegion method which will load and filter the reference objects from 

329 the datastore and return a single catalog containing all reference objects 

330 

331 Parameters 

332 ---------- 

333 bbox : `lsst.geom.box2I` 

334 Box which bounds a region in pixel space 

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

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

337 of pixels of the supplied bbox 

338 filterName : `str` 

339 Name of camera filter, or None or blank for the default filter 

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

341 Epoch to which to correct proper motion and parallax, 

342 or None to not apply such corrections. 

343 photoCalib : None 

344 Deprecated and ignored, only included for api compatibility 

345 bboxPadding : `int` 

346 Number describing how much to pad the input bbox by (in pixels), defaults 

347 to 100. This parameter is necessary because optical distortions in telescopes 

348 can cause a rectangular pixel grid to map into a non "rectangular" spherical 

349 region in sky coordinates. This padding is used to create a spherical 

350 "rectangle", which will for sure enclose the input box. This padding is only 

351 used to determine if the reference catalog for a sky patch will be loaded from 

352 the data store, this function will filter out objects which lie within the 

353 padded region but fall outside the input bounding box region. 

354 

355 Returns 

356 ------- 

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

358 Catalog containing reference objects inside the specified bounding box 

359 

360 Raises 

361 ------ 

362 `lsst.pex.exception.RuntimeError` 

363 Raised if no reference catalogs could be found for the specified region 

364 

365 `lsst.pex.exception.TypeError` 

366 Raised if the loaded reference catalogs do not have matching schemas 

367 """ 

368 innerSkyRegion, outerSkyRegion, _, _ = self._makeBoxRegion(bbox, wcs, bboxPadding) 

369 

370 def _filterFunction(refCat, region): 

371 # Add columns to the reference catalog relating to center positions and use afwTable 

372 # to populate those columns 

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

374 afwTable.updateRefCentroids(wcs, refCat) 

375 # no need to filter the catalog if it is sure that it is entirely contained in the region 

376 # defined by given bbox 

377 if innerSkyRegion.contains(region): 

378 return refCat 

379 # Create a new reference catalog, and populate it with records which fall inside the bbox 

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

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

382 for record in refCat: 

383 pixCoords = record[centroidKey] 

384 if bbox.contains(geom.Point2I(pixCoords)): 

385 filteredRefCat.append(record) 

386 return filteredRefCat 

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

388 

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

390 """ Load reference objects within a specified region 

391 

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

393 which intersect or are contained within the specified region. The reference 

394 catalogs which intersect but are not fully contained within the input region are 

395 further filtered by the specified filter function. This function will return a 

396 single source catalog containing all reference objects inside the specified region. 

397 

398 Parameters 

399 ---------- 

400 region : `lsst.sphgeom.Region` 

401 This can be any type that is derived from `lsst.sphgeom.region` and should 

402 define the spatial region for which reference objects are to be loaded. 

403 filtFunc : callable 

404 This optional parameter should be a callable object that takes a reference 

405 catalog and its corresponding region as parameters, filters the catalog by 

406 some criteria and returns the filtered reference catalog. If the value is 

407 left as the default (None) than an internal filter function is used which 

408 filters according to if a reference object falls within the input region. 

409 filterName : `str` 

410 Name of camera filter, or None or blank for the default filter 

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

412 Epoch to which to correct proper motion and parallax, 

413 or None to not apply such corrections. 

414 

415 Returns 

416 ------- 

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

418 Catalog containing reference objects which intersect the input region, 

419 filtered by the specified filter function 

420 

421 Raises 

422 ------ 

423 `lsst.pex.exception.RuntimeError` 

424 Raised if no reference catalogs could be found for the specified region 

425 

426 `lsst.pex.exception.TypeError` 

427 Raised if the loaded reference catalogs do not have matching schemas 

428 

429 """ 

430 regionBounding = region.getBoundingBox() 

431 self.log.info("Loading reference objects from region bounded by {}, {} lat lon".format( 

432 regionBounding.getLat(), regionBounding.getLon())) 

433 if filtFunc is None: 

434 filtFunc = _FilterCatalog(region) 

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

436 overlapList = [] 

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

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

439 # try the intersect operation in both directions 

440 try: 

441 intersects = dataId.region.intersects(region) 

442 except TypeError: 

443 intersects = region.intersects(dataId.region) 

444 

445 if intersects: 

446 overlapList.append((dataId, refCat)) 

447 

448 if len(overlapList) == 0: 

449 raise pexExceptions.RuntimeError("No reference tables could be found for input region") 

450 

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

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

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

454 

455 # Load in the remaining catalogs 

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

457 tmpCat = inputRefCat.get() 

458 

459 if tmpCat.schema != firstCat.schema: 

460 raise pexExceptions.TypeError("Reference catalogs have mismatching schemas") 

461 

462 filteredCat = filtFunc(tmpCat, dataId.region) 

463 refCat.extend(filteredCat) 

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

465 

466 self.log.debug(f"Trimmed {trimmedAmount} out of region objects, leaving {len(refCat)}") 

467 self.log.info(f"Loaded {len(refCat)} reference objects") 

468 

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

470 if not refCat.isContiguous(): 

471 refCat = refCat.copy(deep=True) 

472 

473 self.applyProperMotions(refCat, epoch) 

474 

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

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

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

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

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

480 self.log.warn("See RFC-575 for more details.") 

481 refCat = convertToNanojansky(refCat, self.log) 

482 

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

484 

485 # Add flux aliases 

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

487 

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

489 if not expandedCat.isContiguous(): 

490 expandedCat = expandedCat.copy(deep=True) 

491 

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

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

494 

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

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

497 

498 This method constructs a circular region from an input center and angular radius, 

499 loads reference catalogs which are contained in or intersect the circle, and 

500 filters reference catalogs which intersect down to objects which lie within 

501 the defined circle. 

502 

503 Parameters 

504 ---------- 

505 ctrCoord : `lsst.geom.SpherePoint` 

506 Point defining the center of the circular region 

507 radius : `lsst.geom.Angle` 

508 Defines the angular radius of the circular region 

509 filterName : `str` 

510 Name of camera filter, or None or blank for the default filter 

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

512 Epoch to which to correct proper motion and parallax, 

513 or None to not apply such corrections. 

514 

515 Returns 

516 ------- 

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

518 Catalog containing reference objects inside the specified bounding box 

519 

520 Raises 

521 ------ 

522 `lsst.pex.exception.RuntimeError` 

523 Raised if no reference catalogs could be found for the specified region 

524 

525 `lsst.pex.exception.TypeError` 

526 Raised if the loaded reference catalogs do not have matching schemas 

527 

528 """ 

529 centerVector = ctrCoord.getVector() 

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

531 circularRegion = sphgeom.Circle(centerVector, sphRadius) 

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

533 

534 def joinMatchListWithCatalog(self, matchCat, sourceCat): 

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

536 objects. 

537 

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

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

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

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

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

543 

544 Parameters 

545 ---------- 

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

547 Unpersisted packed match list. 

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

549 as returned by the astrometry tasks. 

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

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

552 by ID. 

553 

554 Returns 

555 ------- 

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

557 Match list. 

558 """ 

559 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat) 

560 

561 @classmethod 

562 def getMetadataBox(cls, bbox, wcs, filterName=None, photoCalib=None, epoch=None, bboxPadding=100): 

563 """Return metadata about the load 

564 

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

566 reconstituting a normalised match list.) 

567 

568 Parameters 

569 ---------- 

570 bbox : `lsst.geom.Box2I` 

571 Bounding bos for the pixels 

572 wcs : `lsst.afw.geom.SkyWcs 

573 WCS object 

574 filterName : `str` or None 

575 filterName of the camera filter, or None or blank for the default filter 

576 photoCalib : None 

577 Deprecated, only included for api compatibility 

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

579 Epoch to which to correct proper motion and parallax, 

580 or None to not apply such corrections. 

581 bboxPadding : `int` 

582 Number describing how much to pad the input bbox by (in pixels), defaults 

583 to 100. This parameter is necessary because optical distortions in telescopes 

584 can cause a rectangular pixel grid to map into a non "rectangular" spherical 

585 region in sky coordinates. This padding is used to create a spherical 

586 "rectangle", which will for sure enclose the input box. This padding is only 

587 used to determine if the reference catalog for a sky patch will be loaded from 

588 the data store, this function will filter out objects which lie within the 

589 padded region but fall outside the input bounding box region. 

590 Returns 

591 ------- 

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

593 """ 

594 _, _, innerCorners, outerCorners = cls._makeBoxRegion(bbox, wcs, bboxPadding) 

595 md = PropertyList() 

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

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

598 corners): 

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

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

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

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

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

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

605 return md 

606 

607 @staticmethod 

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

609 """Return metadata about the load 

610 

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

612 a normalized match list.) 

613 

614 Parameters 

615 ---------- 

616 coord : `lsst.geom.SpherePoint` 

617 ICRS center of a circle 

618 radius : `lsst.geom.angle` 

619 radius of a circle 

620 filterName : `str` or None 

621 filterName of the camera filter, or None or blank for the default filter 

622 photoCalib : None 

623 Deprecated, only included for api compatibility 

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

625 Epoch to which to correct proper motion and parallax, 

626 or None to not apply such corrections. 

627 

628 Returns 

629 ------- 

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

631 """ 

632 md = PropertyList() 

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

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

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

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

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

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

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

640 return md 

641 

642 @staticmethod 

643 def addFluxAliases(refCat, defaultFilter, filterReferenceMap): 

644 """This function creates a new catalog containing the information of the input refCat 

645 as well as added flux columns and aliases between camera and reference flux. 

646 

647 Parameters 

648 ---------- 

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

650 Catalog of reference objects 

651 defaultFilter : `str` 

652 Name of the default reference filter 

653 filterReferenceMap : `dict` of `str` 

654 Dictionary with keys corresponding to a filter name, and values which 

655 correspond to the name of the reference filter. 

656 

657 Returns 

658 ------- 

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

660 Reference catalog with columns added to track reference filters 

661 

662 Raises 

663 ------ 

664 `RuntimeError` 

665 If specified reference filter name is not a filter specifed as a key in the 

666 reference filter map. 

667 """ 

668 refCat = ReferenceObjectLoader.remapReferenceCatalogSchema(refCat, 

669 filterNameList=filterReferenceMap.keys()) 

670 aliasMap = refCat.schema.getAliasMap() 

671 if filterReferenceMap is None: 

672 filterReferenceMap = {} 

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

674 filterReferenceMap.items()): 

675 if refFilterName: 

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

677 refFluxName = refFilterName + "_flux" 

678 if refFluxName not in refCat.schema: 

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

680 aliasMap.set(camFluxName, refFluxName) 

681 

682 refFluxErrName = refFluxName + "Err" 

683 camFluxErrName = camFluxName + "Err" 

684 aliasMap.set(camFluxErrName, refFluxErrName) 

685 

686 return refCat 

687 

688 @staticmethod 

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

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

691 columns defined the remaining function arguments. 

692 

693 Parameters 

694 ---------- 

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

696 Reference catalog to map to new catalog 

697 

698 Returns 

699 ------- 

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

701 Deep copy of input reference catalog with additional columns added 

702 """ 

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

704 mapper.addMinimalSchema(refCat.schema, True) 

705 mapper.editOutputSchema().disconnectAliases() 

706 if filterNameList: 

707 for filterName in filterNameList: 

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

709 type=numpy.float64, 

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

711 units="Jy" 

712 ) 

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

714 type=numpy.float64, 

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

716 units="Jy" 

717 ) 

718 

719 if position: 

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

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

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

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

724 

725 if photometric: 

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

727 type="Flag", 

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

729 "calibration", 

730 ) 

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

732 type="Flag", 

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

734 ) 

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

736 type="Flag", 

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

738 ) 

739 

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

741 expandedCat.setMetadata(refCat.getMetadata()) 

742 expandedCat.extend(refCat, mapper=mapper) 

743 

744 return expandedCat 

745 

746 

747def getRefFluxField(schema, filterName=None): 

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

749 

750 return the alias of "anyFilterMapsToThis", if present 

751 else if filterName is specified: 

752 return "*filterName*_camFlux" if present 

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

754 matches reference filter name) 

755 else throw RuntimeError 

756 else: 

757 return "camFlux", if present, 

758 else throw RuntimeError 

759 

760 Parameters 

761 ---------- 

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

763 Reference catalog schema. 

764 filterName : `str`, optional 

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

766 set in the refcat loader config. 

767 

768 Returns 

769 ------- 

770 fluxFieldName : `str` 

771 Name of flux field. 

772 

773 Raises 

774 ------ 

775 RuntimeError 

776 If an appropriate field is not found. 

777 """ 

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

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

780 try: 

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

782 except LookupError: 

783 pass # try the filterMap next 

784 

785 if filterName: 

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

787 else: 

788 fluxFieldList = ["camFlux"] 

789 for fluxField in fluxFieldList: 

790 if fluxField in schema: 

791 return fluxField 

792 

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

794 

795 

796def getRefFluxKeys(schema, filterName=None): 

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

798 

799 Parameters 

800 ---------- 

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

802 Reference catalog schema. 

803 filterName : `str` 

804 Name of camera filter. 

805 

806 Returns 

807 ------- 

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

809 Two keys: 

810 

811 - flux key 

812 - flux error key, if present, else None 

813 

814 Raises 

815 ------ 

816 RuntimeError 

817 If flux field not found. 

818 """ 

819 fluxField = getRefFluxField(schema, filterName) 

820 fluxErrField = fluxField + "Err" 

821 fluxKey = schema[fluxField].asKey() 

822 try: 

823 fluxErrKey = schema[fluxErrField].asKey() 

824 except Exception: 

825 fluxErrKey = None 

826 return (fluxKey, fluxErrKey) 

827 

828 

829class LoadReferenceObjectsConfig(pexConfig.Config): 

830 pixelMargin = pexConfig.RangeField( 

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

832 dtype=int, 

833 default=300, 

834 min=0, 

835 ) 

836 defaultFilter = pexConfig.Field( 

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

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

839 dtype=str, 

840 default="", 

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

842 ) 

843 anyFilterMapsToThis = pexConfig.Field( 

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

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

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

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

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

849 dtype=str, 

850 default=None, 

851 optional=True 

852 ) 

853 filterMap = pexConfig.DictField( 

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

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

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

857 keytype=str, 

858 itemtype=str, 

859 default={}, 

860 ) 

861 requireProperMotion = pexConfig.Field( 

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

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

864 dtype=bool, 

865 default=False, 

866 ) 

867 

868 def validate(self): 

869 super().validate() 

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

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

872 raise pexConfig.FieldValidationError(LoadReferenceObjectsConfig.anyFilterMapsToThis, 

873 self, msg) 

874 

875 

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

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

878 """ 

879 ConfigClass = LoadReferenceObjectsConfig 

880 _DefaultName = "LoadReferenceObjects" 

881 

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

883 """Construct a LoadReferenceObjectsTask 

884 

885 Parameters 

886 ---------- 

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

888 Data butler, for access reference catalogs. 

889 """ 

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

891 self.butler = butler 

892 

893 @pipeBase.timeMethod 

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

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

896 

897 Parameters 

898 ---------- 

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

900 Bounding box for pixels. 

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

902 WCS; used to convert pixel positions to sky coordinates 

903 and vice-versa. 

904 filterName : `str` 

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

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

907 (which are not yet implemented). 

908 photoCalib : `lsst.afw.image.PhotoCalib` (optional) 

909 Calibration, or `None` if unknown. 

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

911 Epoch to which to correct proper motion and parallax, 

912 or None to not apply such corrections. 

913 

914 Returns 

915 ------- 

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

917 A Struct containing the following fields: 

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

919 A catalog of reference objects with the standard 

920 schema, as documented in the main doc string for 

921 `LoadReferenceObjects`. 

922 The catalog is guaranteed to be contiguous. 

923 fluxField : `str` 

924 Name of flux field for specified `filterName`. 

925 

926 Notes 

927 ----- 

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

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

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

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

932 """ 

933 circle = self._calculateCircle(bbox, wcs) 

934 

935 # find objects in circle 

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

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

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

939 centroids=True) 

940 refCat = loadRes.refCat 

941 numFound = len(refCat) 

942 

943 # trim objects outside bbox 

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

945 numTrimmed = numFound - len(refCat) 

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

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

948 

949 # make sure catalog is contiguous 

950 if not refCat.isContiguous(): 

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

952 

953 return loadRes 

954 

955 @abc.abstractmethod 

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

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

958 

959 Parameters 

960 ---------- 

961 ctrCoord : `lsst.geom.SpherePoint` 

962 ICRS center of search region. 

963 radius : `lsst.geom.Angle` 

964 Radius of search region. 

965 filterName : `str` (optional) 

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

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

968 (which are not yet implemented). 

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

970 Epoch to which to correct proper motion and parallax, 

971 or None to not apply such corrections. 

972 centroids : `bool` (optional) 

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

974 these fields to exist. 

975 

976 Returns 

977 ------- 

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

979 A Struct containing the following fields: 

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

981 A catalog of reference objects with the standard 

982 schema, as documented in the main doc string for 

983 `LoadReferenceObjects`. 

984 The catalog is guaranteed to be contiguous. 

985 fluxField : `str` 

986 Name of flux field for specified `filterName`. 

987 

988 Notes 

989 ----- 

990 Note that subclasses are responsible for performing the proper motion 

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

992 the catalog. 

993 """ 

994 return 

995 

996 @staticmethod 

997 def _trimToBBox(refCat, bbox, wcs): 

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

999 centroid and hasCentroid fields. 

1000 

1001 Parameters 

1002 ---------- 

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

1004 A catalog of objects. The schema must include fields 

1005 "coord", "centroid" and "hasCentroid". 

1006 The "coord" field is read. 

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

1008 bbox : `lsst.geom.Box2D` 

1009 Pixel region 

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

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

1012 

1013 Returns 

1014 ------- 

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

1016 Reference objects in the bbox, with centroid and 

1017 hasCentroid fields set. 

1018 """ 

1019 afwTable.updateRefCentroids(wcs, refCat) 

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

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

1022 for star in refCat: 

1023 point = star.get(centroidKey) 

1024 if bbox.contains(point): 

1025 retStarCat.append(star) 

1026 return retStarCat 

1027 

1028 def _addFluxAliases(self, schema): 

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

1030 

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

1032 camFlux: <defaultFilter>_flux 

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

1034 

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

1036 <camFilter>_camFlux: <refFilter>_flux 

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

1038 

1039 Parameters 

1040 ---------- 

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

1042 Schema for reference catalog. 

1043 

1044 Raises 

1045 ------ 

1046 RuntimeError 

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

1048 """ 

1049 aliasMap = schema.getAliasMap() 

1050 

1051 if self.config.anyFilterMapsToThis is not None: 

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

1053 if refFluxName not in schema: 

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

1055 raise RuntimeError(msg) 

1056 aliasMap.set("anyFilterMapsToThis", refFluxName) 

1057 return # this is mutually exclusive with filterMap 

1058 

1059 def addAliasesForOneFilter(filterName, refFilterName): 

1060 """Add aliases for a single filter 

1061 

1062 Parameters 

1063 ---------- 

1064 filterName : `str` (optional) 

1065 Camera filter name. The resulting alias name is 

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

1067 is `None` or `""`. 

1068 refFilterName : `str` 

1069 Reference catalog filter name; the field 

1070 <refFilterName>_flux must exist. 

1071 """ 

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

1073 refFluxName = refFilterName + "_flux" 

1074 if refFluxName not in schema: 

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

1076 aliasMap.set(camFluxName, refFluxName) 

1077 refFluxErrName = refFluxName + "Err" 

1078 if refFluxErrName in schema: 

1079 camFluxErrName = camFluxName + "Err" 

1080 aliasMap.set(camFluxErrName, refFluxErrName) 

1081 

1082 if self.config.defaultFilter: 

1083 addAliasesForOneFilter(None, self.config.defaultFilter) 

1084 

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

1086 addAliasesForOneFilter(filterName, refFilterName) 

1087 

1088 @staticmethod 

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

1090 addIsPhotometric=False, addIsResolved=False, 

1091 addIsVariable=False, coordErrDim=2, 

1092 addProperMotion=False, properMotionErrDim=2, 

1093 addParallax=False): 

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

1095 

1096 Parameters 

1097 ---------- 

1098 filterNameList : `list` of `str` 

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

1100 addIsPhotometric : `bool` 

1101 If True then add field "photometric". 

1102 addIsResolved : `bool` 

1103 If True then add field "resolved". 

1104 addIsVariable : `bool` 

1105 If True then add field "variable". 

1106 coordErrDim : `int` 

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

1108 

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

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

1111 addProperMotion : `bool` 

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

1113 properMotionErrDim : `int` 

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

1115 ignored if addProperMotion false: 

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

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

1118 addParallax : `bool` 

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

1120 and "parallax_flag". 

1121 

1122 Returns 

1123 ------- 

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

1125 Schema for reference catalog, an 

1126 `lsst.afw.table.SimpleCatalog`. 

1127 

1128 Notes 

1129 ----- 

1130 Reference catalogs support additional covariances, such as 

1131 covariance between RA and proper motion in declination, 

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

1133 calling this method. 

1134 """ 

1135 schema = afwTable.SimpleTable.makeMinimalSchema() 

1136 if addCentroid: 

1137 afwTable.Point2DKey.addFields( 

1138 schema, 

1139 "centroid", 

1140 "centroid on an exposure, if relevant", 

1141 "pixel", 

1142 ) 

1143 schema.addField( 

1144 field="hasCentroid", 

1145 type="Flag", 

1146 doc="is position known?", 

1147 ) 

1148 for filterName in filterNameList: 

1149 schema.addField( 

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

1151 type=numpy.float64, 

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

1153 units="nJy", 

1154 ) 

1155 for filterName in filterNameList: 

1156 schema.addField( 

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

1158 type=numpy.float64, 

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

1160 units="nJy", 

1161 ) 

1162 if addIsPhotometric: 

1163 schema.addField( 

1164 field="photometric", 

1165 type="Flag", 

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

1167 ) 

1168 if addIsResolved: 

1169 schema.addField( 

1170 field="resolved", 

1171 type="Flag", 

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

1173 ) 

1174 if addIsVariable: 

1175 schema.addField( 

1176 field="variable", 

1177 type="Flag", 

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

1179 ) 

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

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

1182 if coordErrDim > 0: 

1183 afwTable.CovarianceMatrix2fKey.addFields( 

1184 schema=schema, 

1185 prefix="coord", 

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

1187 units=["rad", "rad"], 

1188 diagonalOnly=(coordErrDim == 2), 

1189 ) 

1190 

1191 if addProperMotion or addParallax: 

1192 schema.addField( 

1193 field="epoch", 

1194 type=numpy.float64, 

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

1196 units="day", 

1197 ) 

1198 

1199 if addProperMotion: 

1200 schema.addField( 

1201 field="pm_ra", 

1202 type="Angle", 

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

1204 units="rad/year", 

1205 ) 

1206 schema.addField( 

1207 field="pm_dec", 

1208 type="Angle", 

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

1210 units="rad/year", 

1211 ) 

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

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

1214 if properMotionErrDim > 0: 

1215 afwTable.CovarianceMatrix2fKey.addFields( 

1216 schema=schema, 

1217 prefix="pm", 

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

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

1220 diagonalOnly=(properMotionErrDim == 2), 

1221 ) 

1222 schema.addField( 

1223 field="pm_flag", 

1224 type="Flag", 

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

1226 ) 

1227 

1228 if addParallax: 

1229 schema.addField( 

1230 field="parallax", 

1231 type="Angle", 

1232 doc="parallax", 

1233 units="rad", 

1234 ) 

1235 schema.addField( 

1236 field="parallaxErr", 

1237 type="Angle", 

1238 doc="uncertainty in parallax", 

1239 units="rad", 

1240 ) 

1241 schema.addField( 

1242 field="parallax_flag", 

1243 type="Flag", 

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

1245 ) 

1246 return schema 

1247 

1248 def _calculateCircle(self, bbox, wcs): 

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

1250 

1251 Parameters 

1252 ---------- 

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

1254 Pixel bounding box. 

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

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

1257 

1258 Returns 

1259 ------- 

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

1261 A Struct containing: 

1262 

1263 - coord : `lsst.geom.SpherePoint` 

1264 ICRS center of the search region. 

1265 - radius : `lsst.geom.Angle` 

1266 Radius of the search region. 

1267 - bbox : `lsst.geom.Box2D` 

1268 Bounding box used to compute the circle. 

1269 """ 

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

1271 bbox.grow(self.config.pixelMargin) 

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

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

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

1275 

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

1277 """Return metadata about the load. 

1278 

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

1280 reconstituting a normalised match list. 

1281 

1282 Parameters 

1283 ---------- 

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

1285 Pixel bounding box. 

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

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

1288 filterName : `str` 

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

1290 filter. 

1291 photoCalib : `lsst.afw.image.PhotoCalib` (optional) 

1292 Calibration, or `None` if unknown. 

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

1294 Epoch to which to correct proper motion and parallax, 

1295 or None to not apply such corrections. 

1296 

1297 Returns 

1298 ------- 

1299 metadata : lsst.daf.base.PropertyList 

1300 Metadata about the load. 

1301 """ 

1302 circle = self._calculateCircle(bbox, wcs) 

1303 return self.getMetadataCircle(circle.coord, circle.radius, filterName, photoCalib=photoCalib, 

1304 epoch=epoch) 

1305 

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

1307 """Return metadata about the load. 

1308 

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

1310 reconstituting a normalised match list. 

1311 

1312 Parameters 

1313 ---------- 

1314 coord : `lsst.geom.SpherePoint` 

1315 ICRS center of the search region. 

1316 radius : `lsst.geom.Angle` 

1317 Radius of the search region. 

1318 filterName : `str` 

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

1320 filter. 

1321 photoCalib : `lsst.afw.image.PhotoCalib` (optional) 

1322 Calibration, or `None` if unknown. 

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

1324 Epoch to which to correct proper motion and parallax, 

1325 or None to not apply such corrections. 

1326 

1327 Returns 

1328 ------- 

1329 metadata : lsst.daf.base.PropertyList 

1330 Metadata about the load 

1331 """ 

1332 md = PropertyList() 

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

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

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

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

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

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

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

1340 return md 

1341 

1342 def joinMatchListWithCatalog(self, matchCat, sourceCat): 

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

1344 objects. 

1345 

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

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

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

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

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

1351 

1352 Parameters 

1353 ---------- 

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

1355 Unperisted packed match list. 

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

1357 as returned by the astrometry tasks. 

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

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

1360 by ID. 

1361 

1362 Returns 

1363 ------- 

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

1365 Match list. 

1366 """ 

1367 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat) 

1368 

1369 

1370def joinMatchListWithCatalogImpl(refObjLoader, matchCat, sourceCat): 

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

1372 objects. 

1373 

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

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

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

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

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

1379 

1380 Parameters 

1381 ---------- 

1382 refObjLoader 

1383 Reference object loader to use in getting reference objects 

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

1385 Unperisted packed match list. 

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

1387 as returned by the astrometry tasks. 

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

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

1390 by ID. 

1391 

1392 Returns 

1393 ------- 

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

1395 Match list. 

1396 """ 

1397 matchmeta = matchCat.table.getMetadata() 

1398 version = matchmeta.getInt('SMATCHV') 

1399 if version != 1: 

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

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

1402 try: 

1403 epoch = matchmeta.getDouble('EPOCH') 

1404 except (pexExcept.NotFoundError, pexExcept.TypeError): 

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

1406 if 'RADIUS' in matchmeta: 

1407 # This is a circle style metadata, call loadSkyCircle 

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

1409 matchmeta.getDouble('DEC'), lsst.geom.degrees) 

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

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

1412 elif "INNER_UPPER_LEFT_RA" in matchmeta: 

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

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

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

1416 # by the refObjLoader 

1417 box = [] 

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

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

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

1421 lsst.geom.degrees).getVector() 

1422 box.append(coord) 

1423 outerBox = sphgeom.ConvexPolygon(box) 

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

1425 

1426 refCat.sort() 

1427 sourceCat.sort() 

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

1429 

1430 

1431def applyProperMotionsImpl(log, catalog, epoch): 

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

1433 

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

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

1436 modifying the catalog in place. 

1437 

1438 Parameters 

1439 ---------- 

1440 log : `lsst.log.Log` 

1441 Log object to write to. 

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

1443 Catalog of positions, containing: 

1444 

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

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

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

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

1449 East positive) 

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

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

1452 North positive) 

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

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

1455 epoch : `astropy.time.Time` 

1456 Epoch to which to correct proper motion. 

1457 """ 

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

1459 log.warn("Proper motion correction not available from catalog") 

1460 return 

1461 if not catalog.isContiguous(): 

1462 raise RuntimeError("Catalog must be contiguous") 

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

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

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

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

1467 coordKey = catalog.table.getCoordKey() 

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

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

1470 pmRaRad = catalog["pm_ra"] 

1471 pmDecRad = catalog["pm_dec"] 

1472 offsetsRaRad = pmRaRad*timeDiffsYears 

1473 offsetsDecRad = pmDecRad*timeDiffsYears 

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

1475 # due to proper motion, and apply the offset 

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

1477 # a reasonable scale for typical values of proper motion 

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

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

1480 # needlessly large errors for short duration 

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

1482 offsetAmountsRad = numpy.hypot(offsetsRaRad, offsetsDecRad) 

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

1484 record.set(coordKey, 

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

1486 amount=amountRad*lsst.geom.radians)) 

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

1488 if "coord_raErr" in catalog.schema: 

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

1490 catalog["pm_raErr"]*timeDiffsYears) 

1491 if "coord_decErr" in catalog.schema: 

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

1493 catalog["pm_decErr"]*timeDiffsYears)