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 ReferenceObjectLoader: 

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

197 

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

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

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

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

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

203 call a corresponding method to load the reference objects. 

204 """ 

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

206 """ Constructs an instance of ReferenceObjectLoader 

207 

208 Parameters 

209 ---------- 

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

211 An iterable object of DataSetRefs which point to reference catalogs 

212 in a gen 3 repository 

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

214 Handles to load refCats on demand 

215 log : `lsst.log.Log` 

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

217 lsst logger will be used 

218 

219 """ 

220 self.dataIds = dataIds 

221 self.refCats = refCats 

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

223 self.config = config 

224 

225 @staticmethod 

226 def _makeBoxRegion(BBox, wcs, BBoxPadding): 

227 outerLocalBBox = geom.Box2D(BBox) 

228 innerLocalBBox = geom.Box2D(BBox) 

229 

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

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

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

233 outerLocalBBox.grow(BBoxPadding) 

234 innerLocalBBox.grow(-1*BBoxPadding) 

235 

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

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

238 # bounding box 

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

240 innerSkyRegion = sphgeom.Box() 

241 else: 

242 innerBoxCorners = innerLocalBBox.getCorners() 

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

244 

245 innerSkyRegion = sphgeom.ConvexPolygon(innerSphCorners) 

246 

247 # Convert the corners of the box to sky coordinates 

248 outerBoxCorners = outerLocalBBox.getCorners() 

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

250 

251 outerSkyRegion = sphgeom.ConvexPolygon(outerSphCorners) 

252 

253 return innerSkyRegion, outerSkyRegion, innerSphCorners, outerSphCorners 

254 

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

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

257 

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

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

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

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

262 bounding box. 

263 

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

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

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

267 

268 Parameters 

269 ---------- 

270 bbox : `lsst.geom.box2I` 

271 Box which bounds a region in pixel space 

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

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

274 of pixels of the supplied bbox 

275 filterName : `str` 

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

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

278 Epoch to which to correct proper motion and parallax, 

279 or None to not apply such corrections. 

280 photoCalib : None 

281 Deprecated and ignored, only included for api compatibility 

282 bboxPadding : `int` 

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

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

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

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

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

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

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

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

291 

292 Returns 

293 ------- 

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

295 Catalog containing reference objects inside the specified bounding box 

296 

297 Raises 

298 ------ 

299 `lsst.pex.exception.RuntimeError` 

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

301 

302 `lsst.pex.exception.TypeError` 

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

304 """ 

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

306 

307 def _filterFunction(refCat, region): 

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

309 # to populate those columns 

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

311 afwTable.updateRefCentroids(wcs, refCat) 

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

313 # defined by given bbox 

314 if innerSkyRegion.contains(region): 

315 return refCat 

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

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

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

319 for record in refCat: 

320 pixCoords = record[centroidKey] 

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

322 filteredRefCat.append(record) 

323 return filteredRefCat 

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

325 

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

327 """ Load reference objects within a specified region 

328 

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

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

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

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

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

334 

335 Parameters 

336 ---------- 

337 region : `lsst.sphgeom.Region` 

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

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

340 filtFunc : callable 

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

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

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

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

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

346 filterName : `str` 

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

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

349 Epoch to which to correct proper motion and parallax, 

350 or None to not apply such corrections. 

351 

352 Returns 

353 ------- 

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

355 Catalog containing reference objects which intersect the input region, 

356 filtered by the specified filter function 

357 

358 Raises 

359 ------ 

360 `lsst.pex.exception.RuntimeError` 

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

362 

363 `lsst.pex.exception.TypeError` 

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

365 

366 """ 

367 regionBounding = region.getBoundingBox() 

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

369 regionBounding.getLat(), regionBounding.getLon())) 

370 if filtFunc is None: 

371 filtFunc = _FilterCatalog(region) 

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

373 overlapList = [] 

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

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

376 # try the intersect operation in both directions 

377 try: 

378 intersects = dataId.region.intersects(region) 

379 except TypeError: 

380 intersects = region.intersects(dataId.region) 

381 

382 if intersects: 

383 overlapList.append((dataId, refCat)) 

384 

385 if len(overlapList) == 0: 

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

387 

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

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

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

391 

392 # Load in the remaining catalogs 

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

394 tmpCat = inputRefCat.get() 

395 

396 if tmpCat.schema != firstCat.schema: 

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

398 

399 filteredCat = filtFunc(tmpCat, dataId.region) 

400 refCat.extend(filteredCat) 

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

402 

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

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

405 

406 if epoch is not None and "pm_ra" in refCat.schema: 

407 # check for a catalog in a non-standard format 

408 if isinstance(refCat.schema["pm_ra"].asKey(), lsst.afw.table.KeyAngle): 

409 applyProperMotionsImpl(self.log, refCat, epoch) 

410 else: 

411 self.log.warn("Catalog pm_ra field is not an Angle; not applying proper motion") 

412 

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

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

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

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

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

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

419 refCat = convertToNanojansky(refCat, self.log) 

420 

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

422 

423 # Add flux aliases 

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

425 

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

427 if not expandedCat.isContiguous(): 

428 expandedCat = refCat.copy(deep=True) 

429 

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

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

432 

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

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

435 

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

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

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

439 the defined circle. 

440 

441 Parameters 

442 ---------- 

443 ctrCoord : `lsst.geom.SpherePoint` 

444 Point defining the center of the circular region 

445 radius : `lsst.geom.Angle` 

446 Defines the angular radius of the circular region 

447 filterName : `str` 

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

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

450 Epoch to which to correct proper motion and parallax, 

451 or None to not apply such corrections. 

452 

453 Returns 

454 ------- 

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

456 Catalog containing reference objects inside the specified bounding box 

457 

458 Raises 

459 ------ 

460 `lsst.pex.exception.RuntimeError` 

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

462 

463 `lsst.pex.exception.TypeError` 

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

465 

466 """ 

467 centerVector = ctrCoord.getVector() 

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

469 circularRegion = sphgeom.Circle(centerVector, sphRadius) 

470 return self.loadRegion(circularRegion, filterName=filterName, epoch=None) 

471 

472 def joinMatchListWithCatalog(self, matchCat, sourceCat): 

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

474 objects. 

475 

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

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

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

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

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

481 

482 Parameters 

483 ---------- 

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

485 Unpersisted packed match list. 

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

487 as returned by the astrometry tasks. 

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

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

490 by ID. 

491 

492 Returns 

493 ------- 

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

495 Match list. 

496 """ 

497 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat) 

498 

499 @classmethod 

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

501 """Return metadata about the load 

502 

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

504 reconstituting a normalised match list.) 

505 

506 Parameters 

507 ---------- 

508 bbox : `lsst.geom.Box2I` 

509 Bounding bos for the pixels 

510 wcs : `lsst.afw.geom.SkyWcs 

511 WCS object 

512 filterName : `str` or None 

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

514 photoCalib : None 

515 Deprecated, only included for api compatibility 

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

517 Epoch to which to correct proper motion and parallax, 

518 or None to not apply such corrections. 

519 bboxPadding : `int` 

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

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

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

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

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

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

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

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

528 Returns 

529 ------- 

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

531 """ 

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

533 md = PropertyList() 

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

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

536 corners): 

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

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

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

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

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

542 # Calling str on the epoch is a workaround for code that never worked anyway and was not used 

543 # see DM-17438 

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

545 return md 

546 

547 @staticmethod 

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

549 """Return metadata about the load 

550 

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

552 a normalized match list.) 

553 

554 Parameters 

555 ---------- 

556 coord : `lsst.geom.SpherePoint` 

557 ICRS center of a circle 

558 radius : `lsst.geom.angle` 

559 radius of a circle 

560 filterName : `str` or None 

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

562 photoCalib : None 

563 Deprecated, only included for api compatibility 

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

565 Epoch to which to correct proper motion and parallax, 

566 or None to not apply such corrections. 

567 

568 Returns 

569 ------- 

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

571 """ 

572 md = PropertyList() 

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

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

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

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

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

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

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

580 return md 

581 

582 @staticmethod 

583 def addFluxAliases(refCat, defaultFilter, filterReferenceMap): 

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

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

586 

587 Parameters 

588 ---------- 

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

590 Catalog of reference objects 

591 defaultFilter : `str` 

592 Name of the default reference filter 

593 filterReferenceMap : `dict` of `str` 

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

595 correspond to the name of the reference filter. 

596 

597 Returns 

598 ------- 

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

600 Reference catalog with columns added to track reference filters 

601 

602 Raises 

603 ------ 

604 `RuntimeError` 

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

606 reference filter map. 

607 """ 

608 refCat = ReferenceObjectLoader.remapReferenceCatalogSchema(refCat, 

609 filterNameList=filterReferenceMap.keys()) 

610 aliasMap = refCat.schema.getAliasMap() 

611 if filterReferenceMap is None: 

612 filterReferenceMap = {} 

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

614 filterReferenceMap.items()): 

615 if refFilterName: 

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

617 refFluxName = refFilterName + "_flux" 

618 if refFluxName not in refCat.schema: 

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

620 aliasMap.set(camFluxName, refFluxName) 

621 

622 refFluxErrName = refFluxName + "Err" 

623 camFluxErrName = camFluxName + "Err" 

624 aliasMap.set(camFluxErrName, refFluxErrName) 

625 

626 return refCat 

627 

628 @staticmethod 

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

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

631 columns defined the remaining function arguments. 

632 

633 Parameters 

634 ---------- 

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

636 Reference catalog to map to new catalog 

637 

638 Returns 

639 ------- 

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

641 Deep copy of input reference catalog with additional columns added 

642 """ 

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

644 mapper.addMinimalSchema(refCat.schema, True) 

645 mapper.editOutputSchema().disconnectAliases() 

646 if filterNameList: 

647 for filterName in filterNameList: 

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

649 type=numpy.float64, 

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

651 units="Jy" 

652 ) 

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

654 type=numpy.float64, 

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

656 units="Jy" 

657 ) 

658 

659 if position: 

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

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

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

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

664 

665 if photometric: 

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

667 type="Flag", 

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

669 "calibration", 

670 ) 

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

672 type="Flag", 

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

674 ) 

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

676 type="Flag", 

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

678 ) 

679 

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

681 expandedCat.setMetadata(refCat.getMetadata()) 

682 expandedCat.extend(refCat, mapper=mapper) 

683 

684 return expandedCat 

685 

686 

687def getRefFluxField(schema, filterName=None): 

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

689 

690 if filterName is specified: 

691 return *filterName*_camFlux if present 

692 else return *filterName*_flux if present (camera filter name matches reference filter name) 

693 else throw RuntimeError 

694 else: 

695 return camFlux, if present, 

696 else throw RuntimeError 

697 

698 Parameters 

699 ---------- 

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

701 Reference catalog schema. 

702 filterName : `str` 

703 Name of camera filter. 

704 

705 Returns 

706 ------- 

707 fluxFieldName : `str` 

708 Name of flux field. 

709 

710 Raises 

711 ------ 

712 RuntimeError 

713 If an appropriate field is not found. 

714 """ 

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

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

717 if filterName: 

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

719 else: 

720 fluxFieldList = ["camFlux"] 

721 for fluxField in fluxFieldList: 

722 if fluxField in schema: 

723 return fluxField 

724 

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

726 

727 

728def getRefFluxKeys(schema, filterName=None): 

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

730 

731 Parameters 

732 ---------- 

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

734 Reference catalog schema. 

735 filterName : `str` 

736 Name of camera filter. 

737 

738 Returns 

739 ------- 

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

741 Two keys: 

742 

743 - flux key 

744 - flux error key, if present, else None 

745 

746 Raises 

747 ------ 

748 RuntimeError 

749 If flux field not found. 

750 """ 

751 fluxField = getRefFluxField(schema, filterName) 

752 fluxErrField = fluxField + "Err" 

753 fluxKey = schema[fluxField].asKey() 

754 try: 

755 fluxErrKey = schema[fluxErrField].asKey() 

756 except Exception: 

757 fluxErrKey = None 

758 return (fluxKey, fluxErrKey) 

759 

760 

761class LoadReferenceObjectsConfig(pexConfig.Config): 

762 pixelMargin = pexConfig.RangeField( 

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

764 dtype=int, 

765 default=300, 

766 min=0, 

767 ) 

768 defaultFilter = pexConfig.Field( 

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

770 "if blank then filter must be specified in exposure", 

771 dtype=str, 

772 default="", 

773 ) 

774 filterMap = pexConfig.DictField( 

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

776 "each reference filter must exist", 

777 keytype=str, 

778 itemtype=str, 

779 default={}, 

780 ) 

781 requireProperMotion = pexConfig.Field( 

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

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

784 dtype=bool, 

785 default=False, 

786 ) 

787 

788# The following comment block adds a link to this task from the Task Documentation page. 

789## @addtogroup LSST_task_documentation 

790## @{ 

791## @page LoadReferenceObjectsTask 

792## @ref LoadReferenceObjectsTask_ "LoadReferenceObjectsTask" 

793## @copybrief LoadReferenceObjectsTask 

794## @} 

795 

796 

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

798 r"""!Abstract base class to load objects from reference catalogs 

799 

800 @anchor LoadReferenceObjectsTask_ 

801 

802 @section meas_algorithms_loadReferenceObjects_Contents Contents 

803 

804 - @ref meas_algorithms_loadReferenceObjects_Purpose 

805 - @ref meas_algorithms_loadReferenceObjects_Initialize 

806 - @ref meas_algorithms_loadReferenceObjects_IO 

807 - @ref meas_algorithms_loadReferenceObjects_Schema 

808 - @ref meas_algorithms_loadReferenceObjects_Config 

809 

810 @section meas_algorithms_loadReferenceObjects_Purpose Description 

811 

812 Abstract base class for tasks that load objects from a reference catalog 

813 in a particular region of the sky. 

814 

815 Implementations must subclass this class, override the loadSkyCircle method, 

816 and will typically override the value of ConfigClass with a task-specific config class. 

817 

818 @section meas_algorithms_loadReferenceObjects_Initialize Task initialisation 

819 

820 @copydoc \_\_init\_\_ 

821 

822 @section meas_algorithms_loadReferenceObjects_IO Invoking the Task 

823 

824 @copydoc loadPixelBox 

825 

826 @section meas_algorithms_loadReferenceObjects_Schema Schema of the reference object catalog 

827 

828 Reference object catalogs are instances of lsst.afw.table.SimpleCatalog with the following schema 

829 (other fields may also be present). 

830 The units use astropy quantity conventions, so a 2 suffix means squared. 

831 See also makeMinimalSchema. 

832 - coord: ICRS position of star on sky (an lsst.geom.SpherePoint) 

833 - centroid: position of star on an exposure, if relevant (an lsst.afw.Point2D) 

834 - hasCentroid: is centroid usable? (a Flag) 

835 - *referenceFilterName*_flux: brightness in the specified reference catalog filter (nJy) 

836 Note: you can use astropy.units to convert from AB Magnitude to nJy: 

837 `u.Magnitude(value, u.ABmag).to_value(u.nJy)` 

838 - *referenceFilterName*_fluxErr (optional): brightness standard deviation (nJy); 

839 omitted if no data is available; possibly nan if data is available for some objects but not others 

840 - camFlux: brightness in default camera filter (nJy); omitted if defaultFilter not specified 

841 - camFluxErr: brightness standard deviation for default camera filter; 

842 omitted if defaultFilter not specified or standard deviation not available that filter 

843 - *cameraFilterName*_camFlux: brightness in specified camera filter (nJy) 

844 - *cameraFilterName*_camFluxErr (optional): brightness standard deviation 

845 in specified camera filter (nJy); omitted if no data is available; 

846 possibly nan if data is available for some objects but not others 

847 - photometric (optional): is the object usable for photometric calibration? (a Flag) 

848 - resolved (optional): is the object spatially resolved? (a Flag) 

849 - variable (optional): does the object have variable brightness? (a Flag) 

850 - coord_raErr: uncertainty in `coord` along the direction of right ascension (radian, an Angle) 

851 = uncertainty in ra * cos(dec); nan if unknown 

852 - coord_decErr: uncertainty in `coord` along the direction of declination (radian, an Angle); 

853 nan if unknown 

854 

855 The following are optional; fields should only be present if the 

856 information is available for at least some objects. 

857 Numeric values are `nan` if unknown: 

858 - epoch: date of observation as TAI MJD (day) 

859 

860 - pm_ra: proper motion along the direction of right ascension (rad/year, an Angle) = dra/dt * cos(dec) 

861 - pm_dec: proper motion along the direction of declination (rad/year, and Angle) 

862 - pm_raErr: uncertainty in `pm_ra` (rad/year) 

863 - pm_decErr: uncertainty in `pm_dec` (rad/year) 

864 - pm_ra_dec_Cov: covariance between pm_ra and pm_dec (rad2/year2) 

865 - pm_flag: set if proper motion, error or covariance is bad 

866 

867 - parallax: parallax (rad, an Angle) 

868 - parallaxErr: uncertainty in `parallax` (rad) 

869 - parallax_flag: set if parallax value or parallaxErr is bad 

870 

871 - coord_ra_pm_ra_Cov: covariance between coord_ra and pm_ra (rad2/year) 

872 - coord_ra_pm_dec_Cov: covariance between coord_ra and pm_dec (rad2/year) 

873 - coord_ra_parallax_Cov: covariance between coord_ra and parallax (rad2/year) 

874 - coord_dec_pm_ra_Cov: covariance between coord_dec and pm_ra (rad2/year) 

875 - coord_dec_pm_dec_Cov: covariance between coord_dec and pm_dec (rad2/year) 

876 - coord_dec_parallax_Cov: covariance between coord_dec and parallax (rad2/year) 

877 - pm_ra_parallax_Cov: covariance between pm_ra and parallax (rad2/year) 

878 - pm_dec_parallax_Cov: covariance between pm_dec and parallax (rad2/year) 

879 

880 @section meas_algorithms_loadReferenceObjects_Config Configuration parameters 

881 

882 See @ref LoadReferenceObjectsConfig for a base set of configuration parameters. 

883 Most subclasses will add configuration variables. 

884 """ 

885 ConfigClass = LoadReferenceObjectsConfig 

886 _DefaultName = "LoadReferenceObjects" 

887 

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

889 """Construct a LoadReferenceObjectsTask 

890 

891 Parameters 

892 ---------- 

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

894 Data butler, for access reference catalogs. 

895 """ 

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

897 self.butler = butler 

898 

899 @pipeBase.timeMethod 

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

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

902 

903 Parameters 

904 ---------- 

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

906 Bounding box for pixels. 

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

908 WCS; used to convert pixel positions to sky coordinates 

909 and vice-versa. 

910 filterName : `str` 

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

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

913 (which are not yet implemented). 

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

915 Calibration, or `None` if unknown. 

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

917 Epoch to which to correct proper motion and parallax, 

918 or None to not apply such corrections. 

919 

920 Returns 

921 ------- 

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

923 A Struct containing the following fields: 

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

925 A catalog of reference objects with the standard 

926 schema, as documented in the main doc string for 

927 `LoadReferenceObjects`. 

928 The catalog is guaranteed to be contiguous. 

929 fluxField : `str` 

930 Name of flux field for specified `filterName`. 

931 

932 Notes 

933 ----- 

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

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

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

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

938 """ 

939 circle = self._calculateCircle(bbox, wcs) 

940 

941 # find objects in circle 

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

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

944 loadRes = self.loadSkyCircle(circle.coord, circle.radius, filterName, centroids=True) 

945 refCat = loadRes.refCat 

946 numFound = len(refCat) 

947 

948 # trim objects outside bbox 

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

950 numTrimmed = numFound - len(refCat) 

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

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

953 

954 # make sure catalog is contiguous 

955 if not refCat.isContiguous(): 

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

957 

958 return loadRes 

959 

960 @abc.abstractmethod 

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

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

963 

964 Parameters 

965 ---------- 

966 ctrCoord : `lsst.geom.SpherePoint` 

967 ICRS center of search region. 

968 radius : `lsst.geom.Angle` 

969 Radius of search region. 

970 filterName : `str` (optional) 

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

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

973 (which are not yet implemented). 

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

975 Epoch to which to correct proper motion and parallax, 

976 or None to not apply such corrections. 

977 centroids : `bool` (optional) 

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

979 these fields to exist. 

980 

981 Returns 

982 ------- 

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

984 A Struct containing the following fields: 

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

986 A catalog of reference objects with the standard 

987 schema, as documented in the main doc string for 

988 `LoadReferenceObjects`. 

989 The catalog is guaranteed to be contiguous. 

990 fluxField : `str` 

991 Name of flux field for specified `filterName`. 

992 

993 Notes 

994 ----- 

995 Note that subclasses are responsible for performing the proper motion 

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

997 the catalog. 

998 """ 

999 return 

1000 

1001 @staticmethod 

1002 def _trimToBBox(refCat, bbox, wcs): 

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

1004 centroid and hasCentroid fields. 

1005 

1006 Parameters 

1007 ---------- 

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

1009 A catalog of objects. The schema must include fields 

1010 "coord", "centroid" and "hasCentroid". 

1011 The "coord" field is read. 

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

1013 bbox : `lsst.geom.Box2D` 

1014 Pixel region 

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

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

1017 

1018 @return a catalog of reference objects in bbox, with centroid and hasCentroid fields set 

1019 """ 

1020 afwTable.updateRefCentroids(wcs, refCat) 

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

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

1023 for star in refCat: 

1024 point = star.get(centroidKey) 

1025 if bbox.contains(point): 

1026 retStarCat.append(star) 

1027 return retStarCat 

1028 

1029 def _addFluxAliases(self, schema): 

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

1031 

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

1033 camFlux: <defaultFilter>_flux 

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

1035 

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

1037 <camFilter>_camFlux: <refFilter>_flux 

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

1039 

1040 Parameters 

1041 ---------- 

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

1043 Schema for reference catalog. 

1044 

1045 Throws 

1046 ------ 

1047 RuntimeError 

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

1049 """ 

1050 aliasMap = schema.getAliasMap() 

1051 

1052 def addAliasesForOneFilter(filterName, refFilterName): 

1053 """Add aliases for a single filter 

1054 

1055 Parameters 

1056 ---------- 

1057 filterName : `str` (optional) 

1058 Camera filter name. The resulting alias name is 

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

1060 is `None` or `""`. 

1061 refFilterName : `str` 

1062 Reference catalog filter name; the field 

1063 <refFilterName>_flux must exist. 

1064 """ 

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

1066 refFluxName = refFilterName + "_flux" 

1067 if refFluxName not in schema: 

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

1069 aliasMap.set(camFluxName, refFluxName) 

1070 refFluxErrName = refFluxName + "Err" 

1071 if refFluxErrName in schema: 

1072 camFluxErrName = camFluxName + "Err" 

1073 aliasMap.set(camFluxErrName, refFluxErrName) 

1074 

1075 if self.config.defaultFilter: 

1076 addAliasesForOneFilter(None, self.config.defaultFilter) 

1077 

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

1079 addAliasesForOneFilter(filterName, refFilterName) 

1080 

1081 @staticmethod 

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

1083 addIsPhotometric=False, addIsResolved=False, 

1084 addIsVariable=False, coordErrDim=2, 

1085 addProperMotion=False, properMotionErrDim=2, 

1086 addParallax=False): 

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

1088 

1089 Parameters 

1090 ---------- 

1091 filterNameList : `list` of `str` 

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

1093 addIsPhotometric : `bool` 

1094 If True then add field "photometric". 

1095 addIsResolved : `bool` 

1096 If True then add field "resolved". 

1097 addIsVariable : `bool` 

1098 If True then add field "variable". 

1099 coordErrDim : `int` 

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

1101 

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

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

1104 addProperMotion : `bool` 

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

1106 properMotionErrDim : `int` 

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

1108 ignored if addProperMotion false: 

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

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

1111 addParallax : `bool` 

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

1113 and "parallax_flag". 

1114 

1115 Returns 

1116 ------- 

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

1118 Schema for reference catalog, an 

1119 `lsst.afw.table.SimpleCatalog`. 

1120 

1121 Notes 

1122 ----- 

1123 Reference catalogs support additional covariances, such as 

1124 covariance between RA and proper motion in declination, 

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

1126 calling this method. 

1127 """ 

1128 schema = afwTable.SimpleTable.makeMinimalSchema() 

1129 if addCentroid: 

1130 afwTable.Point2DKey.addFields( 

1131 schema, 

1132 "centroid", 

1133 "centroid on an exposure, if relevant", 

1134 "pixel", 

1135 ) 

1136 schema.addField( 

1137 field="hasCentroid", 

1138 type="Flag", 

1139 doc="is position known?", 

1140 ) 

1141 for filterName in filterNameList: 

1142 schema.addField( 

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

1144 type=numpy.float64, 

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

1146 units="nJy", 

1147 ) 

1148 for filterName in filterNameList: 

1149 schema.addField( 

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

1151 type=numpy.float64, 

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

1153 units="nJy", 

1154 ) 

1155 if addIsPhotometric: 

1156 schema.addField( 

1157 field="photometric", 

1158 type="Flag", 

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

1160 ) 

1161 if addIsResolved: 

1162 schema.addField( 

1163 field="resolved", 

1164 type="Flag", 

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

1166 ) 

1167 if addIsVariable: 

1168 schema.addField( 

1169 field="variable", 

1170 type="Flag", 

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

1172 ) 

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

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

1175 if coordErrDim > 0: 

1176 afwTable.CovarianceMatrix2fKey.addFields( 

1177 schema=schema, 

1178 prefix="coord", 

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

1180 units=["rad", "rad"], 

1181 diagonalOnly=(coordErrDim == 2), 

1182 ) 

1183 

1184 if addProperMotion or addParallax: 

1185 schema.addField( 

1186 field="epoch", 

1187 type=numpy.float64, 

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

1189 units="day", 

1190 ) 

1191 

1192 if addProperMotion: 

1193 schema.addField( 

1194 field="pm_ra", 

1195 type="Angle", 

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

1197 units="rad/year", 

1198 ) 

1199 schema.addField( 

1200 field="pm_dec", 

1201 type="Angle", 

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

1203 units="rad/year", 

1204 ) 

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

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

1207 if properMotionErrDim > 0: 

1208 afwTable.CovarianceMatrix2fKey.addFields( 

1209 schema=schema, 

1210 prefix="pm", 

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

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

1213 diagonalOnly=(properMotionErrDim == 2), 

1214 ) 

1215 schema.addField( 

1216 field="pm_flag", 

1217 type="Flag", 

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

1219 ) 

1220 

1221 if addParallax: 

1222 schema.addField( 

1223 field="parallax", 

1224 type="Angle", 

1225 doc="parallax", 

1226 units="rad", 

1227 ) 

1228 schema.addField( 

1229 field="parallaxErr", 

1230 type="Angle", 

1231 doc="uncertainty in parallax", 

1232 units="rad", 

1233 ) 

1234 schema.addField( 

1235 field="parallax_flag", 

1236 type="Flag", 

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

1238 ) 

1239 return schema 

1240 

1241 def _calculateCircle(self, bbox, wcs): 

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

1243 

1244 Parameters 

1245 ---------- 

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

1247 Pixel bounding box. 

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

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

1250 

1251 Returns 

1252 ------- 

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

1254 A Struct containing: 

1255 

1256 - coord : `lsst.geom.SpherePoint` 

1257 ICRS center of the search region. 

1258 - radius : `lsst.geom.Angle` 

1259 Radius of the search region. 

1260 - bbox : `lsst.geom.Box2D` 

1261 Bounding box used to compute the circle. 

1262 """ 

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

1264 bbox.grow(self.config.pixelMargin) 

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

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

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

1268 

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

1270 """Return metadata about the load. 

1271 

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

1273 reconstituting a normalised match list. 

1274 

1275 Parameters 

1276 ---------- 

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

1278 Pixel bounding box. 

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

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

1281 filterName : `str` 

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

1283 filter. 

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

1285 Calibration, or `None` if unknown. 

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

1287 Epoch to which to correct proper motion and parallax, 

1288 or None to not apply such corrections. 

1289 

1290 Returns 

1291 ------- 

1292 metadata : lsst.daf.base.PropertyList 

1293 Metadata about the load. 

1294 """ 

1295 circle = self._calculateCircle(bbox, wcs) 

1296 return self.getMetadataCircle(circle.coord, circle.radius, filterName, photoCalib) 

1297 

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

1299 """Return metadata about the load. 

1300 

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

1302 reconstituting a normalised match list. 

1303 

1304 Parameters 

1305 ---------- 

1306 coord : `lsst.geom.SpherePoint` 

1307 ICRS center of the search region. 

1308 radius : `lsst.geom.Angle` 

1309 Radius of the search region. 

1310 filterName : `str` 

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

1312 filter. 

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

1314 Calibration, or `None` if unknown. 

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

1316 Epoch to which to correct proper motion and parallax, 

1317 or None to not apply such corrections. 

1318 

1319 Returns 

1320 ------- 

1321 metadata : lsst.daf.base.PropertyList 

1322 Metadata about the load 

1323 """ 

1324 md = PropertyList() 

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

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

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

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

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

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

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

1332 return md 

1333 

1334 def joinMatchListWithCatalog(self, matchCat, sourceCat): 

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

1336 objects. 

1337 

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

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

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

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

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

1343 

1344 Parameters 

1345 ---------- 

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

1347 Unperisted packed match list. 

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

1349 as returned by the astrometry tasks. 

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

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

1352 by ID. 

1353 

1354 Returns 

1355 ------- 

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

1357 Match list. 

1358 """ 

1359 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat) 

1360 

1361 def applyProperMotions(self, catalog, epoch): 

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

1363 

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

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

1366 modifying the catalong in place. 

1367 

1368 Parameters 

1369 ---------- 

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

1371 Catalog of positions, containing: 

1372 

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

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

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

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

1377 East positive) 

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

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

1380 North positive) 

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

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

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

1384 Epoch to which to correct proper motion and parallax, 

1385 or None to not apply such corrections. 

1386 """ 

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

1388 if self.config.requireProperMotion: 

1389 raise RuntimeError("Proper motion correction required but not available from catalog") 

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

1391 return 

1392 applyProperMotionsImpl(self.log, catalog, epoch) 

1393 

1394 

1395def joinMatchListWithCatalogImpl(refObjLoader, matchCat, sourceCat): 

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

1397 objects. 

1398 

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

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

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

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

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

1404 

1405 Parameters 

1406 ---------- 

1407 refObjLoader 

1408 Reference object loader to use in getting reference objects 

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

1410 Unperisted packed match list. 

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

1412 as returned by the astrometry tasks. 

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

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

1415 by ID. 

1416 

1417 Returns 

1418 ------- 

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

1420 Match list. 

1421 """ 

1422 matchmeta = matchCat.table.getMetadata() 

1423 version = matchmeta.getInt('SMATCHV') 

1424 if version != 1: 

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

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

1427 try: 

1428 epoch = matchmeta.getDouble('EPOCH') 

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

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

1431 if 'RADIUS' in matchmeta: 

1432 # This is a circle style metadata, call loadSkyCircle 

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

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

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

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

1437 elif "INNER_UPPER_LEFT_RA" in matchmeta: 

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

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

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

1441 # by the refObjLoader 

1442 box = [] 

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

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

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

1446 lsst.geom.degrees).getVector() 

1447 box.append(coord) 

1448 outerBox = sphgeom.ConvexPolygon(box) 

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

1450 

1451 refCat.sort() 

1452 sourceCat.sort() 

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

1454 

1455 

1456def applyProperMotionsImpl(log, catalog, epoch): 

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

1458 

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

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

1461 modifying the catalong in place. 

1462 

1463 Parameters 

1464 ---------- 

1465 log : `lsst.log.log` 

1466 log object to write to 

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

1468 Catalog of positions, containing: 

1469 

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

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

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

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

1474 East positive) 

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

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

1477 North positive) 

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

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

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

1481 Epoch to which to correct proper motion and parallax, 

1482 or None to not apply such corrections. 

1483 """ 

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

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

1486 return 

1487 if not catalog.isContiguous(): 

1488 raise RuntimeError("Catalog must be contiguous") 

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

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

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

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

1493 coordKey = catalog.table.getCoordKey() 

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

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

1496 pmRaRad = catalog["pm_ra"] 

1497 pmDecRad = catalog["pm_dec"] 

1498 offsetsRaRad = pmRaRad*timeDiffsYears 

1499 offsetsDecRad = pmDecRad*timeDiffsYears 

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

1501 # due to proper motion, and apply the offset 

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

1503 # a reasonable scale for typical values of proper motion 

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

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

1506 # needlessly large errors for short duration 

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

1508 offsetAmountsRad = numpy.hypot(offsetsRaRad, offsetsDecRad) 

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

1510 record.set(coordKey, 

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

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

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

1514 if "coord_raErr" in catalog.schema: 

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

1516 catalog["pm_raErr"]*timeDiffsYears) 

1517 if "coord_decErr" in catalog.schema: 

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

1519 catalog["pm_decErr"]*timeDiffsYears)