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

379 statements  

« prev     ^ index     » next       coverage.py v6.4.4, created at 2022-08-18 12:10 -0700

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 

29import warnings 

30from deprecated.sphinx import deprecated 

31 

32import astropy.time 

33import astropy.units 

34import numpy 

35 

36import lsst.geom as geom 

37import lsst.afw.table as afwTable 

38import lsst.pex.config as pexConfig 

39import lsst.pipe.base as pipeBase 

40from lsst import sphgeom 

41from lsst.daf.base import PropertyList 

42 

43 

44# TODO DM-34793: remove this function 

45def isOldFluxField(name, units): 

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

47 "old-style" reference catalog flux field. 

48 """ 

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

50 isFlux = name.endswith('_flux') 

51 isFluxSigma = name.endswith('_fluxSigma') 

52 isFluxErr = name.endswith('_fluxErr') 

53 return (isFlux or isFluxSigma or isFluxErr) and unitsCheck 

54 

55 

56# TODO DM-34793: remove this function 

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` 

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

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

79 """ 

80 # TODO DM-34793: change to "Version 0 refcats are no longer supported: refcat fluxes must have nJy units." 

81 # TODO DM-34793: and raise an exception instead of returning 0. 

82 deprecation_msg = "Support for version 0 refcats (pre-nJy fluxes) will be removed after v25." 

83 md = refCat.getMetadata() 

84 if md is None: 

85 warnings.warn(deprecation_msg) 

86 return 0 

87 try: 

88 return md.getScalar("REFCAT_FORMAT_VERSION") 

89 except KeyError: 

90 warnings.warn(deprecation_msg) 

91 return 0 

92 

93 

94# TODO DM-34793: remove this function 

95@deprecated(reason="Support for version 0 refcats (pre-nJy fluxes) will be removed after v25.", 

96 version="v24.0", category=FutureWarning) 

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

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

99 

100 Parameters 

101 ---------- 

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

103 The catalog to convert. 

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

105 Log to send messages to. 

106 doConvert : `bool`, optional 

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

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

109 

110 Returns 

111 ------- 

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

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

114 

115 Notes 

116 ----- 

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

118 release of late calendar year 2019. 

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

120 """ 

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

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

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

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

125 input_fields = [] 

126 output_fields = [] 

127 for field in catalog.schema: 

128 oldName = field.field.getName() 

129 oldUnits = field.field.getUnits() 

130 if isOldFluxField(oldName, oldUnits): 

131 units = 'nJy' 

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

133 if oldName.endswith('_fluxSigma'): 

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

135 else: 

136 name = oldName 

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

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

139 input_fields.append(field.field) 

140 output_fields.append(newField) 

141 else: 

142 mapper.addMapping(field.getKey()) 

143 

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

145 

146 if doConvert: 

147 newSchema = mapper.getOutputSchema() 

148 output = afwTable.SimpleCatalog(newSchema) 

149 output.reserve(len(catalog)) 

150 output.extend(catalog, mapper=mapper) 

151 for field in output_fields: 

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

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

154 return output 

155 else: 

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

157 return None 

158 

159 

160class _FilterCatalog: 

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

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

163 the class. 

164 

165 Parameters 

166 ---------- 

167 region : `lsst.sphgeom.Region` 

168 The spatial region which all objects should lie within 

169 """ 

170 def __init__(self, region): 

171 self.region = region 

172 

173 def __call__(self, refCat, catRegion): 

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

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

176 

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

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

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

180 

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

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

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

184 this catalog is then returned. 

185 

186 Parameters 

187 --------- 

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

189 SourceCatalog to be filtered. 

190 catRegion : `lsst.sphgeom.Region` 

191 Region in which the catalog was created 

192 """ 

193 if catRegion.isWithin(self.region): 

194 # no filtering needed, region completely contains refcat 

195 return refCat 

196 

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

198 for record in refCat: 

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

200 filteredRefCat.append(record) 

201 return filteredRefCat 

202 

203 

204class LoadReferenceObjectsConfig(pexConfig.Config): 

205 pixelMargin = pexConfig.RangeField( 

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

207 dtype=int, 

208 default=250, 

209 min=0, 

210 ) 

211 anyFilterMapsToThis = pexConfig.Field( 

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

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

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

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

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

217 dtype=str, 

218 default=None, 

219 optional=True 

220 ) 

221 filterMap = pexConfig.DictField( 

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

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

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

225 keytype=str, 

226 itemtype=str, 

227 default={}, 

228 ) 

229 requireProperMotion = pexConfig.Field( 

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

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

232 dtype=bool, 

233 default=False, 

234 ) 

235 ref_dataset_name = pexConfig.Field( 

236 doc="Deprecated; do not use. Added for easier transition from LoadIndexedReferenceObjectsConfig to " 

237 "LoadReferenceObjectsConfig", 

238 dtype=str, 

239 default='', 

240 deprecated='This field is not used. It will be removed after v25.', 

241 ) 

242 

243 def validate(self): 

244 super().validate() 

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

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

247 raise pexConfig.FieldValidationError(LoadReferenceObjectsConfig.anyFilterMapsToThis, 

248 self, msg) 

249 

250 

251class ReferenceObjectLoader: 

252 """This class facilitates loading reference catalogs. 

253 

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

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

256 an instance of this class. The class instance should then be passed into 

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

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

259 call a corresponding method to load the reference objects. 

260 

261 Parameters 

262 ---------- 

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

264 An iterable object of data IDs that point to reference catalogs. 

265 refCats : iterable of `lsst.daf.butler.DeferredDatasetHandle` 

266 Handles to load refCats on demand. 

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

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

269 logger will be used. 

270 """ 

271 ConfigClass = LoadReferenceObjectsConfig 

272 

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

274 if kwargs: 

275 warnings.warn("Instantiating ReferenceObjectLoader with additional kwargs is deprecated " 

276 "and will be removed after v25.0", FutureWarning, stacklevel=2) 

277 

278 if config is None: 

279 config = self.ConfigClass() 

280 self.config = config 

281 self.dataIds = dataIds 

282 self.refCats = refCats 

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

284 

285 def applyProperMotions(self, catalog, epoch): 

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

287 

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

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

290 modifying the catalog in place. 

291 

292 Parameters 

293 ---------- 

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

295 Catalog of positions, containing at least these fields: 

296 

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

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

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

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

301 East positive) 

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

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

304 North positive) 

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

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

307 epoch : `astropy.time.Time` 

308 Epoch to which to correct proper motion. 

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

310 ``config.requireProperMotion`` is True. 

311 

312 Raises 

313 ------ 

314 RuntimeError 

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

316 apply the proper motion correction for some reason. 

317 """ 

318 if epoch is None: 

319 if self.config.requireProperMotion: 

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

321 else: 

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

323 return 

324 

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

326 if ("pm_ra" in catalog.schema 

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

328 if self.config.requireProperMotion: 

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

330 else: 

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

332 return 

333 

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

335 if self.config.requireProperMotion: 

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

337 else: 

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

339 return 

340 

341 applyProperMotionsImpl(self.log, catalog, epoch) 

342 

343 @staticmethod 

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

345 addIsPhotometric=False, addIsResolved=False, 

346 addIsVariable=False, coordErrDim=2, 

347 addProperMotion=False, properMotionErrDim=2, 

348 addParallax=False): 

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

350 

351 Parameters 

352 ---------- 

353 filterNameList : `list` of `str` 

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

355 addIsPhotometric : `bool` 

356 If True then add field "photometric". 

357 addIsResolved : `bool` 

358 If True then add field "resolved". 

359 addIsVariable : `bool` 

360 If True then add field "variable". 

361 coordErrDim : `int` 

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

363 

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

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

366 addProperMotion : `bool` 

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

368 properMotionErrDim : `int` 

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

370 ignored if addProperMotion false: 

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

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

373 addParallax : `bool` 

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

375 and "parallax_flag". 

376 

377 Returns 

378 ------- 

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

380 Schema for reference catalog, an 

381 `lsst.afw.table.SimpleCatalog`. 

382 

383 Notes 

384 ----- 

385 Reference catalogs support additional covariances, such as 

386 covariance between RA and proper motion in declination, 

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

388 calling this method. 

389 """ 

390 schema = afwTable.SimpleTable.makeMinimalSchema() 

391 if addCentroid: 

392 afwTable.Point2DKey.addFields( 

393 schema, 

394 "centroid", 

395 "centroid on an exposure, if relevant", 

396 "pixel", 

397 ) 

398 schema.addField( 

399 field="hasCentroid", 

400 type="Flag", 

401 doc="is position known?", 

402 ) 

403 for filterName in filterNameList: 

404 schema.addField( 

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

406 type=numpy.float64, 

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

408 units="nJy", 

409 ) 

410 for filterName in filterNameList: 

411 schema.addField( 

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

413 type=numpy.float64, 

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

415 units="nJy", 

416 ) 

417 if addIsPhotometric: 

418 schema.addField( 

419 field="photometric", 

420 type="Flag", 

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

422 ) 

423 if addIsResolved: 

424 schema.addField( 

425 field="resolved", 

426 type="Flag", 

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

428 ) 

429 if addIsVariable: 

430 schema.addField( 

431 field="variable", 

432 type="Flag", 

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

434 ) 

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

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

437 if coordErrDim > 0: 

438 afwTable.CovarianceMatrix2fKey.addFields( 

439 schema=schema, 

440 prefix="coord", 

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

442 units=["rad", "rad"], 

443 diagonalOnly=(coordErrDim == 2), 

444 ) 

445 

446 if addProperMotion or addParallax: 

447 schema.addField( 

448 field="epoch", 

449 type=numpy.float64, 

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

451 units="day", 

452 ) 

453 

454 if addProperMotion: 

455 schema.addField( 

456 field="pm_ra", 

457 type="Angle", 

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

459 units="rad/year", 

460 ) 

461 schema.addField( 

462 field="pm_dec", 

463 type="Angle", 

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

465 units="rad/year", 

466 ) 

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

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

469 if properMotionErrDim > 0: 

470 afwTable.CovarianceMatrix2fKey.addFields( 

471 schema=schema, 

472 prefix="pm", 

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

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

475 diagonalOnly=(properMotionErrDim == 2), 

476 ) 

477 schema.addField( 

478 field="pm_flag", 

479 type="Flag", 

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

481 ) 

482 

483 if addParallax: 

484 schema.addField( 

485 field="parallax", 

486 type="Angle", 

487 doc="parallax", 

488 units="rad", 

489 ) 

490 schema.addField( 

491 field="parallaxErr", 

492 type="Angle", 

493 doc="uncertainty in parallax", 

494 units="rad", 

495 ) 

496 schema.addField( 

497 field="parallax_flag", 

498 type="Flag", 

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

500 ) 

501 return schema 

502 

503 @staticmethod 

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

505 filterMap=None, centroids=False): 

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

507 with additional columns defined from the remaining function arguments. 

508 

509 Parameters 

510 ---------- 

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

512 Reference catalog to map to new catalog 

513 anyFilterMapsToThis : `str`, optional 

514 Always use this reference catalog filter. 

515 Mutually exclusive with `filterMap` 

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

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

518 centroids : `bool`, optional 

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

520 these fields to exist. 

521 

522 Returns 

523 ------- 

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

525 Deep copy of input reference catalog with additional columns added 

526 """ 

527 if anyFilterMapsToThis or filterMap: 

528 ReferenceObjectLoader._addFluxAliases(refCat.schema, anyFilterMapsToThis, filterMap) 

529 

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

531 mapper.addMinimalSchema(refCat.schema, True) 

532 mapper.editOutputSchema().disconnectAliases() 

533 

534 if centroids: 

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

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

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

538 # False so no need to set them explicitly. 

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

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

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

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

543 

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

545 expandedCat.setMetadata(refCat.getMetadata()) 

546 expandedCat.extend(refCat, mapper=mapper) 

547 

548 return expandedCat 

549 

550 @staticmethod 

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

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

553 

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

555 <camFilter>_camFlux: <refFilter>_flux 

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

557 or sets `anyFilterMapsToThis` in the schema. 

558 

559 Parameters 

560 ---------- 

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

562 Schema for reference catalog. 

563 anyFilterMapsToThis : `str`, optional 

564 Always use this reference catalog filter. 

565 Mutually exclusive with `filterMap`. 

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

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

568 Mutually exclusive with `anyFilterMapsToThis`. 

569 

570 Raises 

571 ------ 

572 RuntimeError 

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

574 schema. 

575 """ 

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

577 if anyFilterMapsToThis and filterMap: 

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

579 

580 aliasMap = schema.getAliasMap() 

581 

582 if anyFilterMapsToThis is not None: 

583 refFluxName = anyFilterMapsToThis + "_flux" 

584 if refFluxName not in schema: 

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

586 raise RuntimeError(msg) 

587 aliasMap.set("anyFilterMapsToThis", refFluxName) 

588 return # this is mutually exclusive with filterMap 

589 

590 def addAliasesForOneFilter(filterName, refFilterName): 

591 """Add aliases for a single filter 

592 

593 Parameters 

594 ---------- 

595 filterName : `str` (optional) 

596 Camera filter name. The resulting alias name is 

597 <filterName>_camFlux 

598 refFilterName : `str` 

599 Reference catalog filter name; the field 

600 <refFilterName>_flux must exist. 

601 """ 

602 camFluxName = filterName + "_camFlux" 

603 refFluxName = refFilterName + "_flux" 

604 if refFluxName not in schema: 

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

606 aliasMap.set(camFluxName, refFluxName) 

607 refFluxErrName = refFluxName + "Err" 

608 if refFluxErrName in schema: 

609 camFluxErrName = camFluxName + "Err" 

610 aliasMap.set(camFluxErrName, refFluxErrName) 

611 

612 if filterMap is not None: 

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

614 addAliasesForOneFilter(filterName, refFilterName) 

615 

616 @staticmethod 

617 def _makeBoxRegion(BBox, wcs, BBoxPadding): 

618 outerLocalBBox = geom.Box2D(BBox) 

619 innerLocalBBox = geom.Box2D(BBox) 

620 

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

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

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

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

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

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

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

628 # entire region covered by the bbox. 

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

630 outerLocalBBox.grow(BBoxPadding) 

631 innerLocalBBox.grow(-1*BBoxPadding) 

632 

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

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

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

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

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

638 # it is what the calling code currently expects. 

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

640 innerLocalBBox = geom.Box2D(BBox) 

641 

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

643 innerBoxCorners = innerLocalBBox.getCorners() 

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

645 innerSkyRegion = sphgeom.ConvexPolygon(innerSphCorners) 

646 

647 outerBoxCorners = outerLocalBBox.getCorners() 

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

649 outerSkyRegion = sphgeom.ConvexPolygon(outerSphCorners) 

650 

651 return innerSkyRegion, outerSkyRegion, innerSphCorners, outerSphCorners 

652 

653 @staticmethod 

654 def _calculateCircle(bbox, wcs, pixelMargin): 

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

656 

657 Parameters 

658 ---------- 

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

660 Pixel bounding box. 

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

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

663 pixelMargin : `int` 

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

665 

666 Returns 

667 ------- 

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

669 A Struct containing: 

670 

671 - coord : `lsst.geom.SpherePoint` 

672 ICRS center of the search region. 

673 - radius : `lsst.geom.Angle` 

674 Radius of the search region. 

675 - bbox : `lsst.geom.Box2D` 

676 Bounding box used to compute the circle. 

677 """ 

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

679 bbox.grow(pixelMargin) 

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

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

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

683 

684 @staticmethod 

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

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

687 

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

689 reconstituting a normalized match list). 

690 

691 Parameters 

692 ---------- 

693 coord : `lsst.geom.SpherePoint` 

694 ICRS center of the search region. 

695 radius : `lsst.geom.Angle` 

696 Radius of the search region. 

697 filterName : `str` 

698 Name of the camera filter. 

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

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

701 not apply such corrections. 

702 

703 Returns 

704 ------- 

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

706 Metadata about the catalog. 

707 """ 

708 md = PropertyList() 

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

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

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

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

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

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

715 return md 

716 

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

718 bboxToSpherePadding=100): 

719 """Return metadata about the load 

720 

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

722 reconstituting a normalised match list). 

723 

724 Parameters 

725 ---------- 

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

727 Bounding box for the pixels. 

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

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

730 filterName : `str` 

731 Name of the camera filter. 

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

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

734 not apply such corrections. 

735 bboxToSpherePadding : `int`, optional 

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

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

738 enitre area covered by the box. 

739 

740 Returns 

741 ------- 

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

743 The metadata detailing the search parameters used for this 

744 dataset. 

745 """ 

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

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

748 

749 paddedBbox = circle.bbox 

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

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

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

753 corners): 

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

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

756 return md 

757 

758 def joinMatchListWithCatalog(self, matchCat, sourceCat): 

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

760 

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

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

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

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

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

766 

767 Parameters 

768 ---------- 

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

770 Unpersisted packed match list. 

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

772 as returned by the astrometry tasks. 

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

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

775 by ID. 

776 

777 Returns 

778 ------- 

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

780 Match list. 

781 """ 

782 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat) 

783 

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

785 bboxToSpherePadding=100): 

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

787 region. 

788 

789 This algorithm works by creating a spherical box whose corners 

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

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

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

793 lie within the specified bounding box. 

794 

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

796 generic loadRegion method which loads and filters the reference objects 

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

798 set of reference objects. 

799 

800 Parameters 

801 ---------- 

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

803 Box which bounds a region in pixel space. 

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

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

806 the supplied ``bbox``. 

807 filterName : `str` 

808 Name of camera filter. 

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

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

811 to not apply such corrections. 

812 bboxToSpherePadding : `int`, optional 

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

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

815 enitre area covered by the box. 

816 

817 Returns 

818 ------- 

819 output : `lsst.pipe.base.Struct` 

820 Results struct with attributes: 

821 

822 ``refCat`` 

823 Catalog containing reference objects inside the specified 

824 bounding box (padded by self.config.pixelMargin). 

825 ``fluxField`` 

826 Name of the field containing the flux associated with 

827 ``filterName``. 

828 

829 Raises 

830 ------ 

831 RuntimeError 

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

833 region. 

834 TypeError 

835 Raised if the loaded reference catalogs do not have matching 

836 schemas. 

837 """ 

838 paddedBbox = geom.Box2D(bbox) 

839 paddedBbox.grow(self.config.pixelMargin) 

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

841 

842 def _filterFunction(refCat, region): 

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

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

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

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

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

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

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

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

851 # sky <--> pixel conversions. 

852 preFiltFunc = _FilterCatalog(outerSkyRegion) 

853 refCat = preFiltFunc(refCat, region) 

854 

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

856 # coordinates to equivalent pixel positions for the wcs provided 

857 # and use to populate those columns. 

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

859 afwTable.updateRefCentroids(wcs, refCat) 

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

861 # region defined by the inner sky region. 

862 if innerSkyRegion.contains(region): 

863 return refCat 

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

865 # that fall inside the padded bbox. 

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

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

868 for record in refCat: 

869 pixCoords = record[centroidKey] 

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

871 filteredRefCat.append(record) 

872 return filteredRefCat 

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

874 

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

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

877 

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

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

880 reference catalogs which intersect but are not fully contained within 

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

882 This function returns a single source catalog containing all reference 

883 objects inside the specified region. 

884 

885 Parameters 

886 ---------- 

887 region : `lsst.sphgeom.Region` 

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

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

890 be loaded. 

891 filtFunc : callable or `None`, optional 

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

893 reference catalog and its corresponding region as parameters, 

894 filters the catalog by some criteria and returns the filtered 

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

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

897 input region. 

898 filterName : `str` 

899 Name of camera filter. 

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

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

902 not apply such corrections. 

903 

904 Returns 

905 ------- 

906 output : `lsst.pipe.base.Struct` 

907 Results struct with attributes: 

908 

909 ``refCat`` 

910 Catalog containing reference objects which intersect the 

911 input region, filtered by the specified filter function. 

912 ``fluxField`` 

913 Name of the field containing the flux associated with 

914 ``filterName``. 

915 

916 Raises 

917 ------ 

918 RuntimeError 

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

920 region. 

921 TypeError 

922 Raised if the loaded reference catalogs do not have matching 

923 schemas. 

924 """ 

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

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

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

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

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

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

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

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

933 if filtFunc is None: 

934 filtFunc = _FilterCatalog(region) 

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

936 overlapList = [] 

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

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

939 # try the intersect operation in both directions 

940 try: 

941 intersects = dataId.region.intersects(region) 

942 except TypeError: 

943 intersects = region.intersects(dataId.region) 

944 

945 if intersects: 

946 overlapList.append((dataId, refCat)) 

947 

948 if len(overlapList) == 0: 

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

950 

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

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

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

954 

955 # Load in the remaining catalogs 

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

957 tmpCat = inputRefCat.get() 

958 

959 if tmpCat.schema != firstCat.schema: 

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

961 

962 filteredCat = filtFunc(tmpCat, dataId.region) 

963 refCat.extend(filteredCat) 

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

965 

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

967 trimmedAmount, len(refCat)) 

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

969 

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

971 if not refCat.isContiguous(): 

972 refCat = refCat.copy(deep=True) 

973 

974 self.applyProperMotions(refCat, epoch) 

975 

976 # TODO DM-34793: remove this entire if block. 

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

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

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

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

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

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

983 refCat = convertToNanojansky(refCat, self.log) 

984 

985 expandedCat = self._remapReferenceCatalogSchema(refCat, 

986 anyFilterMapsToThis=self.config.anyFilterMapsToThis, 

987 filterMap=self.config.filterMap) 

988 

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

990 if not expandedCat.isContiguous(): 

991 expandedCat = expandedCat.copy(deep=True) 

992 

993 fluxField = getRefFluxField(expandedCat.schema, filterName) 

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

995 

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

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

998 

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

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

1001 intersect the circle, and filters reference catalogs which intersect 

1002 down to objects which lie within the defined circle. 

1003 

1004 Parameters 

1005 ---------- 

1006 ctrCoord : `lsst.geom.SpherePoint` 

1007 Point defining the center of the circular region. 

1008 radius : `lsst.geom.Angle` 

1009 Defines the angular radius of the circular region. 

1010 filterName : `str` 

1011 Name of camera filter. 

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

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

1014 not apply such corrections. 

1015 

1016 Returns 

1017 ------- 

1018 output : `lsst.pipe.base.Struct` 

1019 Results struct with attributes: 

1020 

1021 ``refCat`` 

1022 Catalog containing reference objects inside the specified 

1023 search circle. 

1024 ``fluxField`` 

1025 Name of the field containing the flux associated with 

1026 ``filterName``. 

1027 """ 

1028 centerVector = ctrCoord.getVector() 

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

1030 circularRegion = sphgeom.Circle(centerVector, sphRadius) 

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

1032 

1033 

1034def getRefFluxField(schema, filterName): 

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

1036 

1037 return the alias of "anyFilterMapsToThis", if present 

1038 else: 

1039 return "*filterName*_camFlux" if present 

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

1041 matches reference filter name) 

1042 else throw RuntimeError 

1043 

1044 Parameters 

1045 ---------- 

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

1047 Reference catalog schema. 

1048 filterName : `str` 

1049 Name of camera filter. 

1050 

1051 Returns 

1052 ------- 

1053 fluxFieldName : `str` 

1054 Name of flux field. 

1055 

1056 Raises 

1057 ------ 

1058 RuntimeError 

1059 If an appropriate field is not found. 

1060 """ 

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

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

1063 try: 

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

1065 except LookupError: 

1066 pass # try the filterMap next 

1067 

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

1069 for fluxField in fluxFieldList: 

1070 if fluxField in schema: 

1071 return fluxField 

1072 

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

1074 

1075 

1076def getRefFluxKeys(schema, filterName): 

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

1078 

1079 Parameters 

1080 ---------- 

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

1082 Reference catalog schema. 

1083 filterName : `str` 

1084 Name of camera filter. 

1085 

1086 Returns 

1087 ------- 

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

1089 Two keys: 

1090 

1091 - flux key 

1092 - flux error key, if present, else None 

1093 

1094 Raises 

1095 ------ 

1096 RuntimeError 

1097 If flux field not found. 

1098 """ 

1099 fluxField = getRefFluxField(schema, filterName) 

1100 fluxErrField = fluxField + "Err" 

1101 fluxKey = schema[fluxField].asKey() 

1102 try: 

1103 fluxErrKey = schema[fluxErrField].asKey() 

1104 except Exception: 

1105 fluxErrKey = None 

1106 return (fluxKey, fluxErrKey) 

1107 

1108 

1109@deprecated(reason=("This task is used in gen2 only; it will be removed after v25. " 

1110 "See DM-35671 for details on updating code to avoid this warning."), 

1111 version="v25.0", category=FutureWarning) 

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

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

1114 """ 

1115 _DefaultName = "LoadReferenceObjects" 

1116 

1117 @abc.abstractmethod 

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

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

1120 

1121 Parameters 

1122 ---------- 

1123 ctrCoord : `lsst.geom.SpherePoint` 

1124 ICRS center of search region. 

1125 radius : `lsst.geom.Angle` 

1126 Radius of search region. 

1127 filterName : `str` 

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

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

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

1131 not apply such corrections. 

1132 centroids : `bool`, optional 

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

1134 these fields to exist. 

1135 

1136 Returns 

1137 ------- 

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

1139 A Struct containing the following fields: 

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

1141 A catalog of reference objects with the standard 

1142 schema, as documented in the main doc string for 

1143 `LoadReferenceObjects`. 

1144 The catalog is guaranteed to be contiguous. 

1145 fluxField : `str` 

1146 Name of flux field for specified `filterName`. 

1147 

1148 Notes 

1149 ----- 

1150 Note that subclasses are responsible for performing the proper motion 

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

1152 the catalog. 

1153 """ 

1154 return 

1155 

1156 

1157def joinMatchListWithCatalogImpl(refObjLoader, matchCat, sourceCat): 

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

1159 objects. 

1160 

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

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

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

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

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

1166 

1167 Parameters 

1168 ---------- 

1169 refObjLoader 

1170 Reference object loader to use in getting reference objects 

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

1172 Unperisted packed match list. 

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

1174 as returned by the astrometry tasks. 

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

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

1177 by ID. 

1178 

1179 Returns 

1180 ------- 

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

1182 Match list. 

1183 """ 

1184 matchmeta = matchCat.table.getMetadata() 

1185 version = matchmeta.getInt('SMATCHV') 

1186 if version != 1: 

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

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

1189 try: 

1190 epoch = matchmeta.getDouble('EPOCH') 

1191 except (LookupError, TypeError): 

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

1193 if 'RADIUS' in matchmeta: 

1194 # This is a circle style metadata, call loadSkyCircle 

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

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

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

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

1199 elif "INNER_UPPER_LEFT_RA" in matchmeta: 

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

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

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

1203 # by the refObjLoader 

1204 box = [] 

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

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

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

1208 geom.degrees).getVector() 

1209 box.append(coord) 

1210 outerBox = sphgeom.ConvexPolygon(box) 

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

1212 

1213 refCat.sort() 

1214 sourceCat.sort() 

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

1216 

1217 

1218@deprecated(reason="Base class only used for gen2 interface, and will be removed after v25.0. " 

1219 "Please use ReferenceObjectLoader directly.", 

1220 version="v25.0", category=FutureWarning) 

1221class ReferenceObjectLoaderBase(ReferenceObjectLoader): 

1222 """Stub of a deprecated class. 

1223 

1224 Parameters 

1225 ---------- 

1226 config : `lsst.pex.config.Config` 

1227 Configuration for the loader. 

1228 """ 

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

1230 pass 

1231 

1232 

1233def applyProperMotionsImpl(log, catalog, epoch): 

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

1235 

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

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

1238 modifying the catalog in place. 

1239 

1240 Parameters 

1241 ---------- 

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

1243 Log object to write to. 

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

1245 Catalog of positions, containing: 

1246 

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

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

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

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

1251 East positive) 

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

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

1254 North positive) 

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

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

1257 epoch : `astropy.time.Time` 

1258 Epoch to which to correct proper motion. 

1259 """ 

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

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

1262 return 

1263 if not catalog.isContiguous(): 

1264 raise RuntimeError("Catalog must be contiguous") 

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

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

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

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

1269 coordKey = catalog.table.getCoordKey() 

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

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

1272 pmRaRad = catalog["pm_ra"] 

1273 pmDecRad = catalog["pm_dec"] 

1274 offsetsRaRad = pmRaRad*timeDiffsYears 

1275 offsetsDecRad = pmDecRad*timeDiffsYears 

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

1277 # due to proper motion, and apply the offset 

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

1279 # a reasonable scale for typical values of proper motion 

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

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

1282 # needlessly large errors for short duration 

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

1284 offsetAmountsRad = numpy.hypot(offsetsRaRad, offsetsDecRad) 

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

1286 record.set(coordKey, 

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

1288 amount=amountRad*geom.radians)) 

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

1290 if "coord_raErr" in catalog.schema: 

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

1292 catalog["pm_raErr"]*timeDiffsYears) 

1293 if "coord_decErr" in catalog.schema: 

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

1295 catalog["pm_decErr"]*timeDiffsYears)