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

402 statements  

« prev     ^ index     » next       coverage.py v6.4.2, created at 2022-07-14 16:25 -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 

42from lsst.utils.timer import timeMethod 

43 

44 

45# TODO DM-34793: remove this function 

46def isOldFluxField(name, units): 

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

48 "old-style" reference catalog flux field. 

49 """ 

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

51 isFlux = name.endswith('_flux') 

52 isFluxSigma = name.endswith('_fluxSigma') 

53 isFluxErr = name.endswith('_fluxErr') 

54 return (isFlux or isFluxSigma or isFluxErr) and unitsCheck 

55 

56 

57# TODO DM-34793: remove this function 

58def hasNanojanskyFluxUnits(schema): 

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

60 """ 

61 for field in schema: 

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

63 return False 

64 return True 

65 

66 

67def getFormatVersionFromRefCat(refCat): 

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

69 

70 Parameters 

71 ---------- 

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

73 Reference catalog to inspect. 

74 

75 Returns 

76 ------- 

77 version : `int` 

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

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

80 """ 

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

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

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

84 md = refCat.getMetadata() 

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

86 warnings.warn(deprecation_msg) 

87 return 0 

88 try: 

89 return md.getScalar("REFCAT_FORMAT_VERSION") 

90 except KeyError: 

91 warnings.warn(deprecation_msg) 

92 return 0 

93 

94 

95# TODO DM-34793: remove this function 

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

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

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

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

100 

101 Parameters 

102 ---------- 

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

104 The catalog to convert. 

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

106 Log to send messages to. 

107 doConvert : `bool`, optional 

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

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

110 

111 Returns 

112 ------- 

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

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

115 

116 Notes 

117 ----- 

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

119 release of late calendar year 2019. 

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

121 """ 

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

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

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

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

126 input_fields = [] 

127 output_fields = [] 

128 for field in catalog.schema: 

129 oldName = field.field.getName() 

130 oldUnits = field.field.getUnits() 

131 if isOldFluxField(oldName, oldUnits): 

132 units = 'nJy' 

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

134 if oldName.endswith('_fluxSigma'): 

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

136 else: 

137 name = oldName 

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

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

140 input_fields.append(field.field) 

141 output_fields.append(newField) 

142 else: 

143 mapper.addMapping(field.getKey()) 

144 

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

146 

147 if doConvert: 

148 newSchema = mapper.getOutputSchema() 

149 output = afwTable.SimpleCatalog(newSchema) 

150 output.reserve(len(catalog)) 

151 output.extend(catalog, mapper=mapper) 

152 for field in output_fields: 

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

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

155 return output 

156 else: 

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

158 return None 

159 

160 

161class _FilterCatalog: 

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

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

164 the class. 

165 

166 Parameters 

167 ---------- 

168 region : `lsst.sphgeom.Region` 

169 The spatial region which all objects should lie within 

170 """ 

171 def __init__(self, region): 

172 self.region = region 

173 

174 def __call__(self, refCat, catRegion): 

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

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

177 

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

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

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

181 

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

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

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

185 this catalog is then returned. 

186 

187 Parameters 

188 --------- 

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

190 SourceCatalog to be filtered. 

191 catRegion : `lsst.sphgeom.Region` 

192 Region in which the catalog was created 

193 """ 

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

195 # no filtering needed, region completely contains refcat 

196 return refCat 

197 

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

199 for record in refCat: 

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

201 filteredRefCat.append(record) 

202 return filteredRefCat 

203 

204 

205class LoadReferenceObjectsConfig(pexConfig.Config): 

206 pixelMargin = pexConfig.RangeField( 

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

208 dtype=int, 

209 default=250, 

210 min=0, 

211 ) 

212 anyFilterMapsToThis = pexConfig.Field( 

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

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

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

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

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

218 dtype=str, 

219 default=None, 

220 optional=True 

221 ) 

222 filterMap = pexConfig.DictField( 

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

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

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

226 keytype=str, 

227 itemtype=str, 

228 default={}, 

229 ) 

230 requireProperMotion = pexConfig.Field( 

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

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

233 dtype=bool, 

234 default=False, 

235 ) 

236 

237 def validate(self): 

238 super().validate() 

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

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

241 raise pexConfig.FieldValidationError(LoadReferenceObjectsConfig.anyFilterMapsToThis, 

242 self, msg) 

243 

244 

245class ReferenceObjectLoaderBase: 

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

247 sharing. 

248 

249 Parameters 

250 ---------- 

251 config : `lsst.pex.config.Config` 

252 Configuration for the loader. 

253 """ 

254 ConfigClass = LoadReferenceObjectsConfig 

255 

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

257 self.config = config 

258 

259 def applyProperMotions(self, catalog, epoch): 

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

261 

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

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

264 modifying the catalog in place. 

265 

266 Parameters 

267 ---------- 

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

269 Catalog of positions, containing at least these fields: 

270 

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

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

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

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

275 East positive) 

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

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

278 North positive) 

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

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

281 epoch : `astropy.time.Time` 

282 Epoch to which to correct proper motion. 

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

284 ``config.requireProperMotion`` is True. 

285 

286 Raises 

287 ------ 

288 RuntimeError 

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

290 apply the proper motion correction for some reason. 

291 """ 

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

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

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

295 else: 

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

297 return 

298 

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

300 if ("pm_ra" in catalog.schema 

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

302 if self.config.requireProperMotion: 

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

304 else: 

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

306 return 

307 

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

309 if self.config.requireProperMotion: 

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

311 else: 

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

313 return 

314 

315 applyProperMotionsImpl(self.log, catalog, epoch) 

316 

317 @staticmethod 

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

319 addIsPhotometric=False, addIsResolved=False, 

320 addIsVariable=False, coordErrDim=2, 

321 addProperMotion=False, properMotionErrDim=2, 

322 addParallax=False): 

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

324 

325 Parameters 

326 ---------- 

327 filterNameList : `list` of `str` 

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

329 addIsPhotometric : `bool` 

330 If True then add field "photometric". 

331 addIsResolved : `bool` 

332 If True then add field "resolved". 

333 addIsVariable : `bool` 

334 If True then add field "variable". 

335 coordErrDim : `int` 

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

337 

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

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

340 addProperMotion : `bool` 

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

342 properMotionErrDim : `int` 

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

344 ignored if addProperMotion false: 

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

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

347 addParallax : `bool` 

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

349 and "parallax_flag". 

350 

351 Returns 

352 ------- 

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

354 Schema for reference catalog, an 

355 `lsst.afw.table.SimpleCatalog`. 

356 

357 Notes 

358 ----- 

359 Reference catalogs support additional covariances, such as 

360 covariance between RA and proper motion in declination, 

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

362 calling this method. 

363 """ 

364 schema = afwTable.SimpleTable.makeMinimalSchema() 

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

366 afwTable.Point2DKey.addFields( 

367 schema, 

368 "centroid", 

369 "centroid on an exposure, if relevant", 

370 "pixel", 

371 ) 

372 schema.addField( 

373 field="hasCentroid", 

374 type="Flag", 

375 doc="is position known?", 

376 ) 

377 for filterName in filterNameList: 

378 schema.addField( 

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

380 type=numpy.float64, 

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

382 units="nJy", 

383 ) 

384 for filterName in filterNameList: 

385 schema.addField( 

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

387 type=numpy.float64, 

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

389 units="nJy", 

390 ) 

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

392 schema.addField( 

393 field="photometric", 

394 type="Flag", 

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

396 ) 

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

398 schema.addField( 

399 field="resolved", 

400 type="Flag", 

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

402 ) 

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

404 schema.addField( 

405 field="variable", 

406 type="Flag", 

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

408 ) 

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

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

411 if coordErrDim > 0: 

412 afwTable.CovarianceMatrix2fKey.addFields( 

413 schema=schema, 

414 prefix="coord", 

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

416 units=["rad", "rad"], 

417 diagonalOnly=(coordErrDim == 2), 

418 ) 

419 

420 if addProperMotion or addParallax: 

421 schema.addField( 

422 field="epoch", 

423 type=numpy.float64, 

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

425 units="day", 

426 ) 

427 

428 if addProperMotion: 

429 schema.addField( 

430 field="pm_ra", 

431 type="Angle", 

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

433 units="rad/year", 

434 ) 

435 schema.addField( 

436 field="pm_dec", 

437 type="Angle", 

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

439 units="rad/year", 

440 ) 

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

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

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

444 afwTable.CovarianceMatrix2fKey.addFields( 

445 schema=schema, 

446 prefix="pm", 

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

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

449 diagonalOnly=(properMotionErrDim == 2), 

450 ) 

451 schema.addField( 

452 field="pm_flag", 

453 type="Flag", 

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

455 ) 

456 

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

458 schema.addField( 

459 field="parallax", 

460 type="Angle", 

461 doc="parallax", 

462 units="rad", 

463 ) 

464 schema.addField( 

465 field="parallaxErr", 

466 type="Angle", 

467 doc="uncertainty in parallax", 

468 units="rad", 

469 ) 

470 schema.addField( 

471 field="parallax_flag", 

472 type="Flag", 

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

474 ) 

475 return schema 

476 

477 @staticmethod 

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

479 filterMap=None, centroids=False): 

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

481 with additional columns defined from the remaining function arguments. 

482 

483 Parameters 

484 ---------- 

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

486 Reference catalog to map to new catalog 

487 anyFilterMapsToThis : `str`, optional 

488 Always use this reference catalog filter. 

489 Mutually exclusive with `filterMap` 

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

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

492 centroids : `bool`, optional 

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

494 these fields to exist. 

495 

496 Returns 

497 ------- 

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

499 Deep copy of input reference catalog with additional columns added 

500 """ 

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

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

503 

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

505 mapper.addMinimalSchema(refCat.schema, True) 

506 mapper.editOutputSchema().disconnectAliases() 

507 

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

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

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

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

512 # False so no need to set them explicitly. 

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

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

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

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

517 

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

519 expandedCat.setMetadata(refCat.getMetadata()) 

520 expandedCat.extend(refCat, mapper=mapper) 

521 

522 return expandedCat 

523 

524 @staticmethod 

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

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

527 

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

529 <camFilter>_camFlux: <refFilter>_flux 

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

531 or sets `anyFilterMapsToThis` in the schema. 

532 

533 Parameters 

534 ---------- 

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

536 Schema for reference catalog. 

537 anyFilterMapsToThis : `str`, optional 

538 Always use this reference catalog filter. 

539 Mutually exclusive with `filterMap`. 

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

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

542 Mutually exclusive with `anyFilterMapsToThis`. 

543 

544 Raises 

545 ------ 

546 RuntimeError 

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

548 schema. 

549 """ 

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

551 if anyFilterMapsToThis and filterMap: 

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

553 

554 aliasMap = schema.getAliasMap() 

555 

556 if anyFilterMapsToThis is not None: 

557 refFluxName = anyFilterMapsToThis + "_flux" 

558 if refFluxName not in schema: 

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

560 raise RuntimeError(msg) 

561 aliasMap.set("anyFilterMapsToThis", refFluxName) 

562 return # this is mutually exclusive with filterMap 

563 

564 def addAliasesForOneFilter(filterName, refFilterName): 

565 """Add aliases for a single filter 

566 

567 Parameters 

568 ---------- 

569 filterName : `str` (optional) 

570 Camera filter name. The resulting alias name is 

571 <filterName>_camFlux 

572 refFilterName : `str` 

573 Reference catalog filter name; the field 

574 <refFilterName>_flux must exist. 

575 """ 

576 camFluxName = filterName + "_camFlux" 

577 refFluxName = refFilterName + "_flux" 

578 if refFluxName not in schema: 

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

580 aliasMap.set(camFluxName, refFluxName) 

581 refFluxErrName = refFluxName + "Err" 

582 if refFluxErrName in schema: 

583 camFluxErrName = camFluxName + "Err" 

584 aliasMap.set(camFluxErrName, refFluxErrName) 

585 

586 if filterMap is not None: 

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

588 addAliasesForOneFilter(filterName, refFilterName) 

589 

590 @staticmethod 

591 def _makeBoxRegion(BBox, wcs, BBoxPadding): 

592 outerLocalBBox = geom.Box2D(BBox) 

593 innerLocalBBox = geom.Box2D(BBox) 

594 

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

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

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

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

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

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

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

602 # entire region covered by the bbox. 

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

604 outerLocalBBox.grow(BBoxPadding) 

605 innerLocalBBox.grow(-1*BBoxPadding) 

606 

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

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

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

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

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

612 # it is what the calling code currently expects. 

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

614 innerLocalBBox = geom.Box2D(BBox) 

615 

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

617 innerBoxCorners = innerLocalBBox.getCorners() 

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

619 innerSkyRegion = sphgeom.ConvexPolygon(innerSphCorners) 

620 

621 outerBoxCorners = outerLocalBBox.getCorners() 

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

623 outerSkyRegion = sphgeom.ConvexPolygon(outerSphCorners) 

624 

625 return innerSkyRegion, outerSkyRegion, innerSphCorners, outerSphCorners 

626 

627 @staticmethod 

628 def _calculateCircle(bbox, wcs, pixelMargin): 

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

630 

631 Parameters 

632 ---------- 

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

634 Pixel bounding box. 

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

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

637 pixelMargin : `int` 

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

639 

640 Returns 

641 ------- 

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

643 A Struct containing: 

644 

645 - coord : `lsst.geom.SpherePoint` 

646 ICRS center of the search region. 

647 - radius : `lsst.geom.Angle` 

648 Radius of the search region. 

649 - bbox : `lsst.geom.Box2D` 

650 Bounding box used to compute the circle. 

651 """ 

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

653 bbox.grow(pixelMargin) 

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

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

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

657 

658 @staticmethod 

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

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

661 

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

663 reconstituting a normalized match list). 

664 

665 Parameters 

666 ---------- 

667 coord : `lsst.geom.SpherePoint` 

668 ICRS center of the search region. 

669 radius : `lsst.geom.Angle` 

670 Radius of the search region. 

671 filterName : `str` 

672 Name of the camera filter. 

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

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

675 not apply such corrections. 

676 

677 Returns 

678 ------- 

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

680 Metadata about the catalog. 

681 """ 

682 md = PropertyList() 

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

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

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

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

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

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

689 return md 

690 

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

692 bboxToSpherePadding=100): 

693 """Return metadata about the load 

694 

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

696 reconstituting a normalised match list). 

697 

698 Parameters 

699 ---------- 

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

701 Bounding box for the pixels. 

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

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

704 filterName : `str` 

705 Name of the camera filter. 

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

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

708 not apply such corrections. 

709 bboxToSpherePadding : `int`, optional 

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

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

712 enitre area covered by the box. 

713 

714 Returns 

715 ------- 

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

717 The metadata detailing the search parameters used for this 

718 dataset. 

719 """ 

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

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

722 

723 paddedBbox = circle.bbox 

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

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

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

727 corners): 

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

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

730 return md 

731 

732 def joinMatchListWithCatalog(self, matchCat, sourceCat): 

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

734 

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

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

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

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

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

740 

741 Parameters 

742 ---------- 

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

744 Unpersisted packed match list. 

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

746 as returned by the astrometry tasks. 

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

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

749 by ID. 

750 

751 Returns 

752 ------- 

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

754 Match list. 

755 """ 

756 return joinMatchListWithCatalogImpl(self, matchCat, sourceCat) 

757 

758 

759class ReferenceObjectLoader(ReferenceObjectLoaderBase): 

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

761 

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

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

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

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

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

767 call a corresponding method to load the reference objects. 

768 

769 Parameters 

770 ---------- 

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

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

773 in a gen 3 repository. 

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

775 Handles to load refCats on demand 

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

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

778 logger will be used. 

779 """ 

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

781 if config is None: 781 ↛ 782line 781 didn't jump to line 782, because the condition on line 781 was never true

782 config = self.ConfigClass() 

783 super().__init__(config=config, **kwargs) 

784 self.dataIds = dataIds 

785 self.refCats = refCats 

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

787 

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

789 bboxToSpherePadding=100): 

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

791 region. 

792 

793 This algorithm works by creating a spherical box whose corners 

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

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

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

797 lie within the specified bounding box. 

798 

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

800 generic loadRegion method which loads and filters the reference objects 

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

802 set of reference objects. 

803 

804 Parameters 

805 ---------- 

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

807 Box which bounds a region in pixel space. 

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

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

810 the supplied ``bbox``. 

811 filterName : `str` 

812 Name of camera filter. 

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

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

815 to not apply such corrections. 

816 bboxToSpherePadding : `int`, optional 

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

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

819 enitre area covered by the box. 

820 

821 Returns 

822 ------- 

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

824 Results struct with attributes: 

825 

826 ``refCat`` 

827 Catalog containing reference objects inside the specified 

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

829 ``fluxField`` 

830 Name of the field containing the flux associated with 

831 ``filterName``. 

832 

833 Raises 

834 ------ 

835 RuntimeError 

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

837 region. 

838 TypeError 

839 Raised if the loaded reference catalogs do not have matching 

840 schemas. 

841 """ 

842 paddedBbox = geom.Box2D(bbox) 

843 paddedBbox.grow(self.config.pixelMargin) 

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

845 

846 def _filterFunction(refCat, region): 

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

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

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

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

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

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

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

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

855 # sky <--> pixel conversions. 

856 preFiltFunc = _FilterCatalog(outerSkyRegion) 

857 refCat = preFiltFunc(refCat, region) 

858 

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

860 # coordinates to equivalent pixel positions for the wcs provided 

861 # and use to populate those columns. 

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

863 afwTable.updateRefCentroids(wcs, refCat) 

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

865 # region defined by the inner sky region. 

866 if innerSkyRegion.contains(region): 

867 return refCat 

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

869 # that fall inside the padded bbox. 

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

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

872 for record in refCat: 

873 pixCoords = record[centroidKey] 

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

875 filteredRefCat.append(record) 

876 return filteredRefCat 

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

878 

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

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

881 

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

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

884 reference catalogs which intersect but are not fully contained within 

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

886 This function returns a single source catalog containing all reference 

887 objects inside the specified region. 

888 

889 Parameters 

890 ---------- 

891 region : `lsst.sphgeom.Region` 

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

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

894 be loaded. 

895 filtFunc : callable or `None`, optional 

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

897 reference catalog and its corresponding region as parameters, 

898 filters the catalog by some criteria and returns the filtered 

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

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

901 input region. 

902 filterName : `str` 

903 Name of camera filter. 

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

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

906 not apply such corrections. 

907 

908 Returns 

909 ------- 

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

911 Results struct with attributes: 

912 

913 ``refCat`` 

914 Catalog containing reference objects which intersect the 

915 input region, filtered by the specified filter function. 

916 ``fluxField`` 

917 Name of the field containing the flux associated with 

918 ``filterName``. 

919 

920 Raises 

921 ------ 

922 RuntimeError 

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

924 region. 

925 TypeError 

926 Raised if the loaded reference catalogs do not have matching 

927 schemas. 

928 """ 

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

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

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

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

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

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

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

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

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

938 filtFunc = _FilterCatalog(region) 

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

940 overlapList = [] 

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

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

943 # try the intersect operation in both directions 

944 try: 

945 intersects = dataId.region.intersects(region) 

946 except TypeError: 

947 intersects = region.intersects(dataId.region) 

948 

949 if intersects: 

950 overlapList.append((dataId, refCat)) 

951 

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

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

954 

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

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

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

958 

959 # Load in the remaining catalogs 

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

961 tmpCat = inputRefCat.get() 

962 

963 if tmpCat.schema != firstCat.schema: 

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

965 

966 filteredCat = filtFunc(tmpCat, dataId.region) 

967 refCat.extend(filteredCat) 

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

969 

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

971 trimmedAmount, len(refCat)) 

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

973 

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

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

976 refCat = refCat.copy(deep=True) 

977 

978 self.applyProperMotions(refCat, epoch) 

979 

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

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

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

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

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

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

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

987 refCat = convertToNanojansky(refCat, self.log) 

988 

989 expandedCat = self._remapReferenceCatalogSchema(refCat, 

990 anyFilterMapsToThis=self.config.anyFilterMapsToThis, 

991 filterMap=self.config.filterMap) 

992 

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

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

995 expandedCat = expandedCat.copy(deep=True) 

996 

997 fluxField = getRefFluxField(expandedCat.schema, filterName) 

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

999 

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

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

1002 

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

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

1005 intersect the circle, and filters reference catalogs which intersect 

1006 down to objects which lie within the defined circle. 

1007 

1008 Parameters 

1009 ---------- 

1010 ctrCoord : `lsst.geom.SpherePoint` 

1011 Point defining the center of the circular region. 

1012 radius : `lsst.geom.Angle` 

1013 Defines the angular radius of the circular region. 

1014 filterName : `str` 

1015 Name of camera filter. 

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

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

1018 not apply such corrections. 

1019 

1020 Returns 

1021 ------- 

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

1023 Results struct with attributes: 

1024 

1025 ``refCat`` 

1026 Catalog containing reference objects inside the specified 

1027 search circle. 

1028 ``fluxField`` 

1029 Name of the field containing the flux associated with 

1030 ``filterName``. 

1031 """ 

1032 centerVector = ctrCoord.getVector() 

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

1034 circularRegion = sphgeom.Circle(centerVector, sphRadius) 

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

1036 

1037 

1038def getRefFluxField(schema, filterName): 

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

1040 

1041 return the alias of "anyFilterMapsToThis", if present 

1042 else: 

1043 return "*filterName*_camFlux" if present 

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

1045 matches reference filter name) 

1046 else throw RuntimeError 

1047 

1048 Parameters 

1049 ---------- 

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

1051 Reference catalog schema. 

1052 filterName : `str` 

1053 Name of camera filter. 

1054 

1055 Returns 

1056 ------- 

1057 fluxFieldName : `str` 

1058 Name of flux field. 

1059 

1060 Raises 

1061 ------ 

1062 RuntimeError 

1063 If an appropriate field is not found. 

1064 """ 

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

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

1067 try: 

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

1069 except LookupError: 

1070 pass # try the filterMap next 

1071 

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

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

1074 if fluxField in schema: 

1075 return fluxField 

1076 

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

1078 

1079 

1080def getRefFluxKeys(schema, filterName): 

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

1082 

1083 Parameters 

1084 ---------- 

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

1086 Reference catalog schema. 

1087 filterName : `str` 

1088 Name of camera filter. 

1089 

1090 Returns 

1091 ------- 

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

1093 Two keys: 

1094 

1095 - flux key 

1096 - flux error key, if present, else None 

1097 

1098 Raises 

1099 ------ 

1100 RuntimeError 

1101 If flux field not found. 

1102 """ 

1103 fluxField = getRefFluxField(schema, filterName) 

1104 fluxErrField = fluxField + "Err" 

1105 fluxKey = schema[fluxField].asKey() 

1106 try: 

1107 fluxErrKey = schema[fluxErrField].asKey() 

1108 except Exception: 

1109 fluxErrKey = None 

1110 return (fluxKey, fluxErrKey) 

1111 

1112 

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

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

1115 """ 

1116 _DefaultName = "LoadReferenceObjects" 

1117 

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

1119 """Construct a LoadReferenceObjectsTask 

1120 

1121 Parameters 

1122 ---------- 

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

1124 Data butler, for access reference catalogs. 

1125 """ 

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

1127 self.butler = butler 

1128 

1129 @timeMethod 

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

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

1132 

1133 Parameters 

1134 ---------- 

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

1136 Bounding box for pixels. 

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

1138 WCS; used to convert pixel positions to sky coordinates 

1139 and vice-versa. 

1140 filterName : `str` 

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

1142 photoCalib : `None` 

1143 Deprecated, only included for api compatibility. 

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

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

1146 not apply such corrections. 

1147 

1148 Returns 

1149 ------- 

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

1151 A Struct containing the following fields: 

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

1153 A catalog of reference objects with the standard 

1154 schema, as documented in the main doc string for 

1155 `LoadReferenceObjects`. 

1156 The catalog is guaranteed to be contiguous. 

1157 fluxField : `str` 

1158 Name of flux field for specified `filterName`. 

1159 

1160 Notes 

1161 ----- 

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

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

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

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

1166 """ 

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

1168 

1169 # find objects in circle 

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

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

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

1173 centroids=True) 

1174 refCat = loadRes.refCat 

1175 numFound = len(refCat) 

1176 

1177 # trim objects outside bbox 

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

1179 numTrimmed = numFound - len(refCat) 

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

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

1182 

1183 # make sure catalog is contiguous 

1184 if not refCat.isContiguous(): 

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

1186 

1187 return loadRes 

1188 

1189 @abc.abstractmethod 

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

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

1192 

1193 Parameters 

1194 ---------- 

1195 ctrCoord : `lsst.geom.SpherePoint` 

1196 ICRS center of search region. 

1197 radius : `lsst.geom.Angle` 

1198 Radius of search region. 

1199 filterName : `str` 

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

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

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

1203 not apply such corrections. 

1204 centroids : `bool`, optional 

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

1206 these fields to exist. 

1207 

1208 Returns 

1209 ------- 

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

1211 A Struct containing the following fields: 

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

1213 A catalog of reference objects with the standard 

1214 schema, as documented in the main doc string for 

1215 `LoadReferenceObjects`. 

1216 The catalog is guaranteed to be contiguous. 

1217 fluxField : `str` 

1218 Name of flux field for specified `filterName`. 

1219 

1220 Notes 

1221 ----- 

1222 Note that subclasses are responsible for performing the proper motion 

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

1224 the catalog. 

1225 """ 

1226 return 

1227 

1228 @staticmethod 

1229 def _trimToBBox(refCat, bbox, wcs): 

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

1231 centroid and hasCentroid fields. 

1232 

1233 Parameters 

1234 ---------- 

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

1236 A catalog of objects. The schema must include fields 

1237 "coord", "centroid" and "hasCentroid". 

1238 The "coord" field is read. 

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

1240 bbox : `lsst.geom.Box2D` 

1241 Pixel region 

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

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

1244 

1245 Returns 

1246 ------- 

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

1248 Reference objects in the bbox, with centroid and 

1249 hasCentroid fields set. 

1250 """ 

1251 afwTable.updateRefCentroids(wcs, refCat) 

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

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

1254 for star in refCat: 

1255 point = star.get(centroidKey) 

1256 if bbox.contains(point): 

1257 retStarCat.append(star) 

1258 return retStarCat 

1259 

1260 

1261def joinMatchListWithCatalogImpl(refObjLoader, matchCat, sourceCat): 

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

1263 objects. 

1264 

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

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

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

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

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

1270 

1271 Parameters 

1272 ---------- 

1273 refObjLoader 

1274 Reference object loader to use in getting reference objects 

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

1276 Unperisted packed match list. 

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

1278 as returned by the astrometry tasks. 

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

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

1281 by ID. 

1282 

1283 Returns 

1284 ------- 

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

1286 Match list. 

1287 """ 

1288 matchmeta = matchCat.table.getMetadata() 

1289 version = matchmeta.getInt('SMATCHV') 

1290 if version != 1: 

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

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

1293 try: 

1294 epoch = matchmeta.getDouble('EPOCH') 

1295 except (LookupError, TypeError): 

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

1297 if 'RADIUS' in matchmeta: 

1298 # This is a circle style metadata, call loadSkyCircle 

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

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

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

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

1303 elif "INNER_UPPER_LEFT_RA" in matchmeta: 

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

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

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

1307 # by the refObjLoader 

1308 box = [] 

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

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

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

1312 geom.degrees).getVector() 

1313 box.append(coord) 

1314 outerBox = sphgeom.ConvexPolygon(box) 

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

1316 

1317 refCat.sort() 

1318 sourceCat.sort() 

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

1320 

1321 

1322def applyProperMotionsImpl(log, catalog, epoch): 

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

1324 

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

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

1327 modifying the catalog in place. 

1328 

1329 Parameters 

1330 ---------- 

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

1332 Log object to write to. 

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

1334 Catalog of positions, containing: 

1335 

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

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

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

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

1340 East positive) 

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

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

1343 North positive) 

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

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

1346 epoch : `astropy.time.Time` 

1347 Epoch to which to correct proper motion. 

1348 """ 

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

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

1351 return 

1352 if not catalog.isContiguous(): 

1353 raise RuntimeError("Catalog must be contiguous") 

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

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

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

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

1358 coordKey = catalog.table.getCoordKey() 

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

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

1361 pmRaRad = catalog["pm_ra"] 

1362 pmDecRad = catalog["pm_dec"] 

1363 offsetsRaRad = pmRaRad*timeDiffsYears 

1364 offsetsDecRad = pmDecRad*timeDiffsYears 

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

1366 # due to proper motion, and apply the offset 

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

1368 # a reasonable scale for typical values of proper motion 

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

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

1371 # needlessly large errors for short duration 

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

1373 offsetAmountsRad = numpy.hypot(offsetsRaRad, offsetsDecRad) 

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

1375 record.set(coordKey, 

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

1377 amount=amountRad*geom.radians)) 

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

1379 if "coord_raErr" in catalog.schema: 

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

1381 catalog["pm_raErr"]*timeDiffsYears) 

1382 if "coord_decErr" in catalog.schema: 

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

1384 catalog["pm_decErr"]*timeDiffsYears)