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

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

393 statements  

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", "ReferenceObjectLoaderBase"] 

26 

27import abc 

28import logging 

29 

30import astropy.time 

31import astropy.units 

32import numpy 

33 

34import lsst.geom as geom 

35import lsst.afw.table as afwTable 

36import lsst.pex.config as pexConfig 

37import lsst.pipe.base as pipeBase 

38from lsst import sphgeom 

39from lsst.daf.base import PropertyList 

40from lsst.utils.timer import timeMethod 

41 

42 

43def isOldFluxField(name, units): 

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

45 "old-style" reference catalog flux field. 

46 """ 

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

48 isFlux = name.endswith('_flux') 

49 isFluxSigma = name.endswith('_fluxSigma') 

50 isFluxErr = name.endswith('_fluxErr') 

51 return (isFlux or isFluxSigma or isFluxErr) and unitsCheck 

52 

53 

54def hasNanojanskyFluxUnits(schema): 

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

56 """ 

57 for field in schema: 

58 if isOldFluxField(field.field.getName(), field.field.getUnits()): 58 ↛ 59line 58 didn't jump to line 59, because the condition on line 58 was never true

59 return False 

60 return True 

61 

62 

63def getFormatVersionFromRefCat(refCat): 

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

65 

66 Parameters 

67 ---------- 

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

69 Reference catalog to inspect. 

70 

71 Returns 

72 ------- 

73 version : `int` 

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

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

76 """ 

77 md = refCat.getMetadata() 

78 if md is None: 78 ↛ 79line 78 didn't jump to line 79, because the condition on line 78 was never true

79 return 0 

80 try: 

81 return md.getScalar("REFCAT_FORMAT_VERSION") 

82 except KeyError: 

83 return 0 

84 

85 

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

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

88 

89 Parameters 

90 ---------- 

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

92 The catalog to convert. 

93 log : `lsst.log.Log` or `logging.Logger` 

94 Log to send messages to. 

95 doConvert : `bool`, optional 

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

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

98 

99 Returns 

100 ------- 

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

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

103 

104 Notes 

105 ----- 

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

107 release of late calendar year 2019. 

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

109 """ 

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

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

112 mapper = afwTable.SchemaMapper(catalog.schema, shareAliasMap=False) 

113 mapper.addMinimalSchema(afwTable.SimpleTable.makeMinimalSchema()) 

114 input_fields = [] 

115 output_fields = [] 

116 for field in catalog.schema: 

117 oldName = field.field.getName() 

118 oldUnits = field.field.getUnits() 

119 if isOldFluxField(oldName, oldUnits): 

120 units = 'nJy' 

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

122 if oldName.endswith('_fluxSigma'): 

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

124 else: 

125 name = oldName 

126 newField = afwTable.Field[field.dtype](name, field.field.getDoc(), units) 

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

128 input_fields.append(field.field) 

129 output_fields.append(newField) 

130 else: 

131 mapper.addMapping(field.getKey()) 

132 

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

134 

135 if doConvert: 

136 newSchema = mapper.getOutputSchema() 

137 output = afwTable.SimpleCatalog(newSchema) 

138 output.extend(catalog, mapper=mapper) 

139 for field in output_fields: 

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

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

142 return output 

143 else: 

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

145 return None 

146 

147 

148class _FilterCatalog: 

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

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

151 the class. 

152 

153 Parameters 

154 ---------- 

155 region : `lsst.sphgeom.Region` 

156 The spatial region which all objects should lie within 

157 """ 

158 def __init__(self, region): 

159 self.region = region 

160 

161 def __call__(self, refCat, catRegion): 

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

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

164 

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

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

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

168 

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

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

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

172 this catalog is then returned. 

173 

174 Parameters 

175 --------- 

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

177 SourceCatalog to be filtered. 

178 catRegion : `lsst.sphgeom.Region` 

179 Region in which the catalog was created 

180 """ 

181 if catRegion.isWithin(self.region): 181 ↛ 183line 181 didn't jump to line 183, because the condition on line 181 was never true

182 # no filtering needed, region completely contains refcat 

183 return refCat 

184 

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

186 for record in refCat: 

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

188 filteredRefCat.append(record) 

189 return filteredRefCat 

190 

191 

192class LoadReferenceObjectsConfig(pexConfig.Config): 

193 pixelMargin = pexConfig.RangeField( 

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

195 dtype=int, 

196 default=250, 

197 min=0, 

198 ) 

199 anyFilterMapsToThis = pexConfig.Field( 

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

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

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

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

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

205 dtype=str, 

206 default=None, 

207 optional=True 

208 ) 

209 filterMap = pexConfig.DictField( 

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

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

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

213 keytype=str, 

214 itemtype=str, 

215 default={}, 

216 ) 

217 requireProperMotion = pexConfig.Field( 

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

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

220 dtype=bool, 

221 default=False, 

222 ) 

223 

224 def validate(self): 

225 super().validate() 

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

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

228 raise pexConfig.FieldValidationError(LoadReferenceObjectsConfig.anyFilterMapsToThis, 

229 self, msg) 

230 

231 

232class ReferenceObjectLoaderBase: 

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

234 sharing. 

235 

236 Parameters 

237 ---------- 

238 config : `lsst.pex.config.Config` 

239 Configuration for the loader. 

240 """ 

241 ConfigClass = LoadReferenceObjectsConfig 

242 

243 def __init__(self, config=None, *args, **kwargs): 

244 self.config = config 

245 

246 def applyProperMotions(self, catalog, epoch): 

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

248 

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

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

251 modifying the catalog in place. 

252 

253 Parameters 

254 ---------- 

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

256 Catalog of positions, containing at least these fields: 

257 

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

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

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

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

262 East positive) 

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

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

265 North positive) 

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

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

268 epoch : `astropy.time.Time` 

269 Epoch to which to correct proper motion. 

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

271 ``config.requireProperMotion`` is True. 

272 

273 Raises 

274 ------ 

275 RuntimeError 

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

277 apply the proper motion correction for some reason. 

278 """ 

279 if epoch is None: 279 ↛ 287line 279 didn't jump to line 287, because the condition on line 279 was never false

280 if self.config.requireProperMotion: 280 ↛ 281line 280 didn't jump to line 281, because the condition on line 280 was never true

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

282 else: 

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

284 return 

285 

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

287 if ("pm_ra" in catalog.schema 

288 and not isinstance(catalog.schema["pm_ra"].asKey(), afwTable.KeyAngle)): 

289 if self.config.requireProperMotion: 

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

291 else: 

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

293 return 

294 

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

296 if self.config.requireProperMotion: 

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

298 else: 

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

300 return 

301 

302 applyProperMotionsImpl(self.log, catalog, epoch) 

303 

304 @staticmethod 

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

306 addIsPhotometric=False, addIsResolved=False, 

307 addIsVariable=False, coordErrDim=2, 

308 addProperMotion=False, properMotionErrDim=2, 

309 addParallax=False): 

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

311 

312 Parameters 

313 ---------- 

314 filterNameList : `list` of `str` 

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

316 addIsPhotometric : `bool` 

317 If True then add field "photometric". 

318 addIsResolved : `bool` 

319 If True then add field "resolved". 

320 addIsVariable : `bool` 

321 If True then add field "variable". 

322 coordErrDim : `int` 

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

324 

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

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

327 addProperMotion : `bool` 

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

329 properMotionErrDim : `int` 

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

331 ignored if addProperMotion false: 

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

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

334 addParallax : `bool` 

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

336 and "parallax_flag". 

337 

338 Returns 

339 ------- 

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

341 Schema for reference catalog, an 

342 `lsst.afw.table.SimpleCatalog`. 

343 

344 Notes 

345 ----- 

346 Reference catalogs support additional covariances, such as 

347 covariance between RA and proper motion in declination, 

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

349 calling this method. 

350 """ 

351 schema = afwTable.SimpleTable.makeMinimalSchema() 

352 if addCentroid: 352 ↛ 353line 352 didn't jump to line 353, because the condition on line 352 was never true

353 afwTable.Point2DKey.addFields( 

354 schema, 

355 "centroid", 

356 "centroid on an exposure, if relevant", 

357 "pixel", 

358 ) 

359 schema.addField( 

360 field="hasCentroid", 

361 type="Flag", 

362 doc="is position known?", 

363 ) 

364 for filterName in filterNameList: 

365 schema.addField( 

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

367 type=numpy.float64, 

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

369 units="nJy", 

370 ) 

371 for filterName in filterNameList: 

372 schema.addField( 

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

374 type=numpy.float64, 

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

376 units="nJy", 

377 ) 

378 if addIsPhotometric: 378 ↛ 379line 378 didn't jump to line 379, because the condition on line 378 was never true

379 schema.addField( 

380 field="photometric", 

381 type="Flag", 

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

383 ) 

384 if addIsResolved: 384 ↛ 385line 384 didn't jump to line 385, because the condition on line 384 was never true

385 schema.addField( 

386 field="resolved", 

387 type="Flag", 

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

389 ) 

390 if addIsVariable: 390 ↛ 391line 390 didn't jump to line 391, because the condition on line 390 was never true

391 schema.addField( 

392 field="variable", 

393 type="Flag", 

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

395 ) 

396 if coordErrDim not in (0, 2, 3): 396 ↛ 397line 396 didn't jump to line 397, because the condition on line 396 was never true

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

398 if coordErrDim > 0: 

399 afwTable.CovarianceMatrix2fKey.addFields( 

400 schema=schema, 

401 prefix="coord", 

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

403 units=["rad", "rad"], 

404 diagonalOnly=(coordErrDim == 2), 

405 ) 

406 

407 if addProperMotion or addParallax: 

408 schema.addField( 

409 field="epoch", 

410 type=numpy.float64, 

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

412 units="day", 

413 ) 

414 

415 if addProperMotion: 

416 schema.addField( 

417 field="pm_ra", 

418 type="Angle", 

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

420 units="rad/year", 

421 ) 

422 schema.addField( 

423 field="pm_dec", 

424 type="Angle", 

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

426 units="rad/year", 

427 ) 

428 if properMotionErrDim not in (0, 2, 3): 428 ↛ 429line 428 didn't jump to line 429, because the condition on line 428 was never true

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

430 if properMotionErrDim > 0: 430 ↛ 438line 430 didn't jump to line 438, because the condition on line 430 was never false

431 afwTable.CovarianceMatrix2fKey.addFields( 

432 schema=schema, 

433 prefix="pm", 

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

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

436 diagonalOnly=(properMotionErrDim == 2), 

437 ) 

438 schema.addField( 

439 field="pm_flag", 

440 type="Flag", 

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

442 ) 

443 

444 if addParallax: 444 ↛ 445line 444 didn't jump to line 445, because the condition on line 444 was never true

445 schema.addField( 

446 field="parallax", 

447 type="Angle", 

448 doc="parallax", 

449 units="rad", 

450 ) 

451 schema.addField( 

452 field="parallaxErr", 

453 type="Angle", 

454 doc="uncertainty in parallax", 

455 units="rad", 

456 ) 

457 schema.addField( 

458 field="parallax_flag", 

459 type="Flag", 

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

461 ) 

462 return schema 

463 

464 @staticmethod 

465 def _remapReferenceCatalogSchema(refCat, *, anyFilterMapsToThis=None, 

466 filterMap=None, centroids=False): 

467 """This function takes in a reference catalog and returns a new catalog 

468 with additional columns defined from the remaining function arguments. 

469 

470 Parameters 

471 ---------- 

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

473 Reference catalog to map to new catalog 

474 anyFilterMapsToThis : `str`, optional 

475 Always use this reference catalog filter. 

476 Mutually exclusive with `filterMap` 

477 filterMap : `dict` [`str`,`str`], optional 

478 Mapping of camera filter name: reference catalog filter name. 

479 centroids : `bool`, optional 

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

481 these fields to exist. 

482 

483 Returns 

484 ------- 

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

486 Deep copy of input reference catalog with additional columns added 

487 """ 

488 if anyFilterMapsToThis or filterMap: 488 ↛ 489line 488 didn't jump to line 489, because the condition on line 488 was never true

489 ReferenceObjectLoaderBase._addFluxAliases(refCat.schema, anyFilterMapsToThis, filterMap) 

490 

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

492 mapper.addMinimalSchema(refCat.schema, True) 

493 mapper.editOutputSchema().disconnectAliases() 

494 

495 if centroids: 495 ↛ 500line 495 didn't jump to line 500, because the condition on line 495 was never true

496 # Add and initialize centroid and hasCentroid fields (these are 

497 # added after loading to avoid wasting space in the saved catalogs). 

498 # The new fields are automatically initialized to (nan, nan) and 

499 # False so no need to set them explicitly. 

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

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

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

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

504 

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

506 expandedCat.setMetadata(refCat.getMetadata()) 

507 expandedCat.extend(refCat, mapper=mapper) 

508 

509 return expandedCat 

510 

511 @staticmethod 

512 def _addFluxAliases(schema, anyFilterMapsToThis=None, filterMap=None): 

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

514 

515 For each camFilter: refFilter in filterMap, adds these aliases: 

516 <camFilter>_camFlux: <refFilter>_flux 

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

518 or sets `anyFilterMapsToThis` in the schema. 

519 

520 Parameters 

521 ---------- 

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

523 Schema for reference catalog. 

524 anyFilterMapsToThis : `str`, optional 

525 Always use this reference catalog filter. 

526 Mutually exclusive with `filterMap`. 

527 filterMap : `dict` [`str`,`str`], optional 

528 Mapping of camera filter name: reference catalog filter name. 

529 Mutually exclusive with `anyFilterMapsToThis`. 

530 

531 Raises 

532 ------ 

533 RuntimeError 

534 Raised if any required reference flux field is missing from the 

535 schema. 

536 """ 

537 # Fail on any truthy value for either of these. 

538 if anyFilterMapsToThis and filterMap: 

539 raise ValueError("anyFilterMapsToThis and filterMap are mutually exclusive!") 

540 

541 aliasMap = schema.getAliasMap() 

542 

543 if anyFilterMapsToThis is not None: 

544 refFluxName = anyFilterMapsToThis + "_flux" 

545 if refFluxName not in schema: 

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

547 raise RuntimeError(msg) 

548 aliasMap.set("anyFilterMapsToThis", refFluxName) 

549 return # this is mutually exclusive with filterMap 

550 

551 def addAliasesForOneFilter(filterName, refFilterName): 

552 """Add aliases for a single filter 

553 

554 Parameters 

555 ---------- 

556 filterName : `str` (optional) 

557 Camera filter name. The resulting alias name is 

558 <filterName>_camFlux 

559 refFilterName : `str` 

560 Reference catalog filter name; the field 

561 <refFilterName>_flux must exist. 

562 """ 

563 camFluxName = filterName + "_camFlux" 

564 refFluxName = refFilterName + "_flux" 

565 if refFluxName not in schema: 

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

567 aliasMap.set(camFluxName, refFluxName) 

568 refFluxErrName = refFluxName + "Err" 

569 if refFluxErrName in schema: 

570 camFluxErrName = camFluxName + "Err" 

571 aliasMap.set(camFluxErrName, refFluxErrName) 

572 

573 if filterMap is not None: 

574 for filterName, refFilterName in filterMap.items(): 

575 addAliasesForOneFilter(filterName, refFilterName) 

576 

577 @staticmethod 

578 def _makeBoxRegion(BBox, wcs, BBoxPadding): 

579 outerLocalBBox = geom.Box2D(BBox) 

580 innerLocalBBox = geom.Box2D(BBox) 

581 

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

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

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

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

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

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

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

589 # entire region covered by the bbox. 

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

591 outerLocalBBox.grow(BBoxPadding) 

592 innerLocalBBox.grow(-1*BBoxPadding) 

593 

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

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

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

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

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

599 # it is what the calling code currently expects. 

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

601 innerLocalBBox = geom.Box2D(BBox) 

602 

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

604 innerBoxCorners = innerLocalBBox.getCorners() 

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

606 innerSkyRegion = sphgeom.ConvexPolygon(innerSphCorners) 

607 

608 outerBoxCorners = outerLocalBBox.getCorners() 

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

610 outerSkyRegion = sphgeom.ConvexPolygon(outerSphCorners) 

611 

612 return innerSkyRegion, outerSkyRegion, innerSphCorners, outerSphCorners 

613 

614 @staticmethod 

615 def _calculateCircle(bbox, wcs, pixelMargin): 

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

617 

618 Parameters 

619 ---------- 

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

621 Pixel bounding box. 

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

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

624 pixelMargin : `int` 

625 Padding to add to 4 all edges of the bounding box (pixels). 

626 

627 Returns 

628 ------- 

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

630 A Struct containing: 

631 

632 - coord : `lsst.geom.SpherePoint` 

633 ICRS center of the search region. 

634 - radius : `lsst.geom.Angle` 

635 Radius of the search region. 

636 - bbox : `lsst.geom.Box2D` 

637 Bounding box used to compute the circle. 

638 """ 

639 bbox = geom.Box2D(bbox) # we modify the box, so use a copy 

640 bbox.grow(pixelMargin) 

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

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

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

644 

645 @staticmethod 

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

647 """Return metadata about the reference catalog being loaded. 

648 

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

650 reconstituting a normalized match list). 

651 

652 Parameters 

653 ---------- 

654 coord : `lsst.geom.SpherePoint` 

655 ICRS center of the search region. 

656 radius : `lsst.geom.Angle` 

657 Radius of the search region. 

658 filterName : `str` 

659 Name of the camera filter. 

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

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

662 not apply such corrections. 

663 

664 Returns 

665 ------- 

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

667 Metadata about the catalog. 

668 """ 

669 md = PropertyList() 

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

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

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

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

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

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

676 return md 

677 

678 def getMetadataBox(self, bbox, wcs, filterName, epoch=None, 

679 bboxToSpherePadding=100): 

680 """Return metadata about the load 

681 

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

683 reconstituting a normalised match list). 

684 

685 Parameters 

686 ---------- 

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

688 Bounding box for the pixels. 

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

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

691 filterName : `str` 

692 Name of the camera filter. 

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

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

695 not apply such corrections. 

696 bboxToSpherePadding : `int`, optional 

697 Padding in pixels to account for translating a set of corners into 

698 a spherical (convex) boundary that is certain to encompass the 

699 enitre area covered by the box. 

700 

701 Returns 

702 ------- 

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

704 The metadata detailing the search parameters used for this 

705 dataset. 

706 """ 

707 circle = self._calculateCircle(bbox, wcs, self.config.pixelMargin) 

708 md = self.getMetadataCircle(circle.coord, circle.radius, filterName, epoch=epoch) 

709 

710 paddedBbox = circle.bbox 

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

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

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

714 corners): 

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

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

717 return md 

718 

719 def joinMatchListWithCatalog(self, matchCat, sourceCat): 

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

721 

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

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

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

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

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

727 

728 Parameters 

729 ---------- 

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

731 Unpersisted packed match list. 

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

733 as returned by the astrometry tasks. 

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

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

736 by ID. 

737 

738 Returns 

739 ------- 

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

741 Match list. 

742 """ 

743 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat) 

744 

745 

746class ReferenceObjectLoader(ReferenceObjectLoaderBase): 

747 """This class facilitates loading reference catalogs with gen 3 middleware. 

748 

749 The QuantumGraph generation will create a list of datasets that may 

750 possibly overlap a given region. These datasets are then used to construct 

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

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

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

754 call a corresponding method to load the reference objects. 

755 

756 Parameters 

757 ---------- 

758 dataIds : iterable of `lsst.daf.butler.DataCoordinate` 

759 An iterable object of data IDs that point to reference catalogs 

760 in a gen 3 repository. 

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

762 Handles to load refCats on demand 

763 log : `lsst.log.Log`, `logging.Logger` or `None`, optional 

764 Logger object used to write out messages. If `None` a default 

765 logger will be used. 

766 """ 

767 def __init__(self, dataIds, refCats, log=None, **kwargs): 

768 super().__init__(**kwargs) 

769 self.dataIds = dataIds 

770 self.refCats = refCats 

771 self.log = log or logging.getLogger(__name__).getChild("ReferenceObjectLoader") 

772 

773 def loadPixelBox(self, bbox, wcs, filterName, epoch=None, 

774 bboxToSpherePadding=100): 

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

776 region. 

777 

778 This algorithm works by creating a spherical box whose corners 

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

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

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

782 lie within the specified bounding box. 

783 

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

785 generic loadRegion method which loads and filters the reference objects 

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

787 set of reference objects. 

788 

789 Parameters 

790 ---------- 

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

792 Box which bounds a region in pixel space. 

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

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

795 the supplied ``bbox``. 

796 filterName : `str` 

797 Name of camera filter. 

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

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

800 to not apply such corrections. 

801 bboxToSpherePadding : `int`, optional 

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

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

804 enitre area covered by the box. 

805 

806 Returns 

807 ------- 

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

809 Catalog containing reference objects inside the specified bounding 

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

811 

812 Raises 

813 ------ 

814 RuntimeError 

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

816 region. 

817 TypeError 

818 Raised if the loaded reference catalogs do not have matching 

819 schemas. 

820 """ 

821 paddedBbox = geom.Box2D(bbox) 

822 paddedBbox.grow(self.config.pixelMargin) 

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

824 

825 def _filterFunction(refCat, region): 

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

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

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

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

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

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

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

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

834 # sky <--> pixel conversions. 

835 preFiltFunc = _FilterCatalog(outerSkyRegion) 

836 refCat = preFiltFunc(refCat, region) 

837 

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

839 # coordinates to equivalent pixel positions for the wcs provided 

840 # and use to populate those columns. 

841 refCat = self._remapReferenceCatalogSchema(refCat, centroids=True) 

842 afwTable.updateRefCentroids(wcs, refCat) 

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

844 # region defined by the inner sky region. 

845 if innerSkyRegion.contains(region): 

846 return refCat 

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

848 # that fall inside the padded bbox. 

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

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

851 for record in refCat: 

852 pixCoords = record[centroidKey] 

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

854 filteredRefCat.append(record) 

855 return filteredRefCat 

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

857 

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

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

860 

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

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

863 reference catalogs which intersect but are not fully contained within 

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

865 This function returns a single source catalog containing all reference 

866 objects inside the specified region. 

867 

868 Parameters 

869 ---------- 

870 region : `lsst.sphgeom.Region` 

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

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

873 be loaded. 

874 filtFunc : callable or `None`, optional 

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

876 reference catalog and its corresponding region as parameters, 

877 filters the catalog by some criteria and returns the filtered 

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

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

880 input region. 

881 filterName : `str` 

882 Name of camera filter. 

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

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

885 not apply such corrections. 

886 

887 Returns 

888 ------- 

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

890 Catalog containing reference objects which intersect the input region, 

891 filtered by the specified filter function. 

892 

893 Raises 

894 ------ 

895 RuntimeError 

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

897 region. 

898 TypeError 

899 Raised if the loaded reference catalogs do not have matching 

900 schemas. 

901 """ 

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

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

904 self.log.info("Loading reference objects from %s in region bounded by " 

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

906 # Name of refcat we're loading from is the datasetType. 

907 self.refCats[0].ref.datasetType.name, 

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

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

910 if filtFunc is None: 910 ↛ 913line 910 didn't jump to line 913, because the condition on line 910 was never false

911 filtFunc = _FilterCatalog(region) 

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

913 overlapList = [] 

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

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

916 # try the intersect operation in both directions 

917 try: 

918 intersects = dataId.region.intersects(region) 

919 except TypeError: 

920 intersects = region.intersects(dataId.region) 

921 

922 if intersects: 

923 overlapList.append((dataId, refCat)) 

924 

925 if len(overlapList) == 0: 925 ↛ 926line 925 didn't jump to line 926, because the condition on line 925 was never true

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

927 

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

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

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

931 

932 # Load in the remaining catalogs 

933 for dataId, inputRefCat in overlapList[1:]: 933 ↛ 934line 933 didn't jump to line 934, because the loop on line 933 never started

934 tmpCat = inputRefCat.get() 

935 

936 if tmpCat.schema != firstCat.schema: 

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

938 

939 filteredCat = filtFunc(tmpCat, dataId.region) 

940 refCat.extend(filteredCat) 

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

942 

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

944 trimmedAmount, len(refCat)) 

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

946 

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

948 if not refCat.isContiguous(): 948 ↛ 949line 948 didn't jump to line 949, because the condition on line 948 was never true

949 refCat = refCat.copy(deep=True) 

950 

951 self.applyProperMotions(refCat, epoch) 

952 

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

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

955 if not hasNanojanskyFluxUnits(refCat.schema) or not getFormatVersionFromRefCat(refCat) >= 1: 955 ↛ 956line 955 didn't jump to line 956, because the condition on line 955 was never true

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

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

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

959 refCat = convertToNanojansky(refCat, self.log) 

960 

961 expandedCat = self._remapReferenceCatalogSchema(refCat, 

962 anyFilterMapsToThis=self.config.anyFilterMapsToThis, 

963 filterMap=self.config.filterMap) 

964 

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

966 if not expandedCat.isContiguous(): 966 ↛ 967line 966 didn't jump to line 967, because the condition on line 966 was never true

967 expandedCat = expandedCat.copy(deep=True) 

968 

969 fluxField = getRefFluxField(expandedCat.schema, filterName) 

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

971 

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

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

974 

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

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

977 intersect the circle, and filters reference catalogs which intersect 

978 down to objects which lie within the defined circle. 

979 

980 Parameters 

981 ---------- 

982 ctrCoord : `lsst.geom.SpherePoint` 

983 Point defining the center of the circular region. 

984 radius : `lsst.geom.Angle` 

985 Defines the angular radius of the circular region. 

986 filterName : `str` 

987 Name of camera filter. 

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

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

990 not apply such corrections. 

991 

992 Returns 

993 ------- 

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

995 Catalog containing reference objects inside the specified search 

996 circle. 

997 """ 

998 centerVector = ctrCoord.getVector() 

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

1000 circularRegion = sphgeom.Circle(centerVector, sphRadius) 

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

1002 

1003 

1004def getRefFluxField(schema, filterName): 

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

1006 

1007 return the alias of "anyFilterMapsToThis", if present 

1008 else: 

1009 return "*filterName*_camFlux" if present 

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

1011 matches reference filter name) 

1012 else throw RuntimeError 

1013 

1014 Parameters 

1015 ---------- 

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

1017 Reference catalog schema. 

1018 filterName : `str` 

1019 Name of camera filter. 

1020 

1021 Returns 

1022 ------- 

1023 fluxFieldName : `str` 

1024 Name of flux field. 

1025 

1026 Raises 

1027 ------ 

1028 RuntimeError 

1029 If an appropriate field is not found. 

1030 """ 

1031 if not isinstance(schema, afwTable.Schema): 1031 ↛ 1032line 1031 didn't jump to line 1032, because the condition on line 1031 was never true

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

1033 try: 

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

1035 except LookupError: 

1036 pass # try the filterMap next 

1037 

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

1039 for fluxField in fluxFieldList: 1039 ↛ 1043line 1039 didn't jump to line 1043, because the loop on line 1039 didn't complete

1040 if fluxField in schema: 

1041 return fluxField 

1042 

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

1044 

1045 

1046def getRefFluxKeys(schema, filterName): 

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

1048 

1049 Parameters 

1050 ---------- 

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

1052 Reference catalog schema. 

1053 filterName : `str` 

1054 Name of camera filter. 

1055 

1056 Returns 

1057 ------- 

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

1059 Two keys: 

1060 

1061 - flux key 

1062 - flux error key, if present, else None 

1063 

1064 Raises 

1065 ------ 

1066 RuntimeError 

1067 If flux field not found. 

1068 """ 

1069 fluxField = getRefFluxField(schema, filterName) 

1070 fluxErrField = fluxField + "Err" 

1071 fluxKey = schema[fluxField].asKey() 

1072 try: 

1073 fluxErrKey = schema[fluxErrField].asKey() 

1074 except Exception: 

1075 fluxErrKey = None 

1076 return (fluxKey, fluxErrKey) 

1077 

1078 

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

1080 """Abstract gen2 base class to load objects from reference catalogs. 

1081 """ 

1082 _DefaultName = "LoadReferenceObjects" 

1083 

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

1085 """Construct a LoadReferenceObjectsTask 

1086 

1087 Parameters 

1088 ---------- 

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

1090 Data butler, for access reference catalogs. 

1091 """ 

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

1093 self.butler = butler 

1094 

1095 @timeMethod 

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

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

1098 

1099 Parameters 

1100 ---------- 

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

1102 Bounding box for pixels. 

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

1104 WCS; used to convert pixel positions to sky coordinates 

1105 and vice-versa. 

1106 filterName : `str` 

1107 Name of filter. This can be used for flux limit comparisons. 

1108 photoCalib : `None` 

1109 Deprecated, only included for api compatibility. 

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

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

1112 not apply such corrections. 

1113 

1114 Returns 

1115 ------- 

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

1117 A Struct containing the following fields: 

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

1119 A catalog of reference objects with the standard 

1120 schema, as documented in the main doc string for 

1121 `LoadReferenceObjects`. 

1122 The catalog is guaranteed to be contiguous. 

1123 fluxField : `str` 

1124 Name of flux field for specified `filterName`. 

1125 

1126 Notes 

1127 ----- 

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

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

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

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

1132 """ 

1133 circle = self._calculateCircle(bbox, wcs, self.config.pixelMargin) 

1134 

1135 # find objects in circle 

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

1137 self.config.ref_dataset_name, circle.coord, circle.radius.asDegrees()) 

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

1139 centroids=True) 

1140 refCat = loadRes.refCat 

1141 numFound = len(refCat) 

1142 

1143 # trim objects outside bbox 

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

1145 numTrimmed = numFound - len(refCat) 

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

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

1148 

1149 # make sure catalog is contiguous 

1150 if not refCat.isContiguous(): 

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

1152 

1153 return loadRes 

1154 

1155 @abc.abstractmethod 

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

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

1158 

1159 Parameters 

1160 ---------- 

1161 ctrCoord : `lsst.geom.SpherePoint` 

1162 ICRS center of search region. 

1163 radius : `lsst.geom.Angle` 

1164 Radius of search region. 

1165 filterName : `str` 

1166 Name of filter. This can be used for flux limit comparisons. 

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

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

1169 not apply such corrections. 

1170 centroids : `bool`, optional 

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

1172 these fields to exist. 

1173 

1174 Returns 

1175 ------- 

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

1177 A Struct containing the following fields: 

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

1179 A catalog of reference objects with the standard 

1180 schema, as documented in the main doc string for 

1181 `LoadReferenceObjects`. 

1182 The catalog is guaranteed to be contiguous. 

1183 fluxField : `str` 

1184 Name of flux field for specified `filterName`. 

1185 

1186 Notes 

1187 ----- 

1188 Note that subclasses are responsible for performing the proper motion 

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

1190 the catalog. 

1191 """ 

1192 return 

1193 

1194 @staticmethod 

1195 def _trimToBBox(refCat, bbox, wcs): 

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

1197 centroid and hasCentroid fields. 

1198 

1199 Parameters 

1200 ---------- 

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

1202 A catalog of objects. The schema must include fields 

1203 "coord", "centroid" and "hasCentroid". 

1204 The "coord" field is read. 

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

1206 bbox : `lsst.geom.Box2D` 

1207 Pixel region 

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

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

1210 

1211 Returns 

1212 ------- 

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

1214 Reference objects in the bbox, with centroid and 

1215 hasCentroid fields set. 

1216 """ 

1217 afwTable.updateRefCentroids(wcs, refCat) 

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

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

1220 for star in refCat: 

1221 point = star.get(centroidKey) 

1222 if bbox.contains(point): 

1223 retStarCat.append(star) 

1224 return retStarCat 

1225 

1226 

1227def joinMatchListWithCatalogImpl(refObjLoader, matchCat, sourceCat): 

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

1229 objects. 

1230 

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

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

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

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

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

1236 

1237 Parameters 

1238 ---------- 

1239 refObjLoader 

1240 Reference object loader to use in getting reference objects 

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

1242 Unperisted packed match list. 

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

1244 as returned by the astrometry tasks. 

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

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

1247 by ID. 

1248 

1249 Returns 

1250 ------- 

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

1252 Match list. 

1253 """ 

1254 matchmeta = matchCat.table.getMetadata() 

1255 version = matchmeta.getInt('SMATCHV') 

1256 if version != 1: 

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

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

1259 try: 

1260 epoch = matchmeta.getDouble('EPOCH') 

1261 except (LookupError, TypeError): 

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

1263 if 'RADIUS' in matchmeta: 

1264 # This is a circle style metadata, call loadSkyCircle 

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

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

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

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

1269 elif "INNER_UPPER_LEFT_RA" in matchmeta: 

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

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

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

1273 # by the refObjLoader 

1274 box = [] 

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

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

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

1278 geom.degrees).getVector() 

1279 box.append(coord) 

1280 outerBox = sphgeom.ConvexPolygon(box) 

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

1282 

1283 refCat.sort() 

1284 sourceCat.sort() 

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

1286 

1287 

1288def applyProperMotionsImpl(log, catalog, epoch): 

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

1290 

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

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

1293 modifying the catalog in place. 

1294 

1295 Parameters 

1296 ---------- 

1297 log : `lsst.log.Log` or `logging.getLogger` 

1298 Log object to write to. 

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

1300 Catalog of positions, containing: 

1301 

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

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

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

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

1306 East positive) 

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

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

1309 North positive) 

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

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

1312 epoch : `astropy.time.Time` 

1313 Epoch to which to correct proper motion. 

1314 """ 

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

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

1317 return 

1318 if not catalog.isContiguous(): 

1319 raise RuntimeError("Catalog must be contiguous") 

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

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

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

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

1324 coordKey = catalog.table.getCoordKey() 

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

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

1327 pmRaRad = catalog["pm_ra"] 

1328 pmDecRad = catalog["pm_dec"] 

1329 offsetsRaRad = pmRaRad*timeDiffsYears 

1330 offsetsDecRad = pmDecRad*timeDiffsYears 

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

1332 # due to proper motion, and apply the offset 

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

1334 # a reasonable scale for typical values of proper motion 

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

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

1337 # needlessly large errors for short duration 

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

1339 offsetAmountsRad = numpy.hypot(offsetsRaRad, offsetsDecRad) 

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

1341 record.set(coordKey, 

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

1343 amount=amountRad*geom.radians)) 

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

1345 if "coord_raErr" in catalog.schema: 

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

1347 catalog["pm_raErr"]*timeDiffsYears) 

1348 if "coord_decErr" in catalog.schema: 

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

1350 catalog["pm_decErr"]*timeDiffsYears)