Coverage for python/lsst/ap/association/diaPipe.py: 85%

495 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-10 09:53 +0000

1# 

2# LSST Data Management System 

3# Copyright 2008-2016 AURA/LSST. 

4# 

5# This product includes software developed by the 

6# LSST Project (http://www.lsst.org/). 

7# 

8# This program is free software: you can redistribute it and/or modify 

9# it under the terms of the GNU General Public License as published by 

10# the Free Software Foundation, either version 3 of the License, or 

11# (at your option) any later version. 

12# 

13# This program is distributed in the hope that it will be useful, 

14# but WITHOUT ANY WARRANTY; without even the implied warranty of 

15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

16# GNU General Public License for more details. 

17# 

18# You should have received a copy of the LSST License Statement and 

19# the GNU General Public License along with this program. If not, 

20# see <https://www.lsstcorp.org/LegalNotices/>. 

21# 

22 

23"""PipelineTask for associating DiaSources with previous DiaObjects. 

24 

25Additionally performs forced photometry on the calibrated and difference 

26images at the updated locations of DiaObjects. 

27""" 

28 

29__all__ = ("DiaPipelineConfig", 

30 "DiaPipelineTask", 

31 "DiaPipelineConnections") 

32 

33 

34import dataclasses 

35 

36import lsst.dax.apdb as daxApdb 

37import lsst.pex.config as pexConfig 

38import lsst.pipe.base as pipeBase 

39import lsst.pipe.base.connectionTypes as connTypes 

40import lsst.sphgeom 

41 

42from astropy.table import Table 

43import numpy as np 

44import pandas as pd 

45from lsst.ap.association import ( 

46 AssociationTask, 

47 DiaForcedSourceTask, 

48 PackageAlertsTask) 

49 

50from lsst.ap.association.loadDiaCatalogs import loadDiaObjectsFromApdb, loadDiaSourcesFromApdb, \ 

51 loadDiaForcedSourcesFromApdb 

52from lsst.ap.association.utils import makeEmptyForcedSourceTable, getRegion, paddedRegion, readSchemaFromApdb 

53from lsst.daf.base import DateTime 

54from lsst.meas.base import DetectorVisitIdGeneratorConfig, \ 

55 DiaObjectCalculationTask 

56from lsst.pipe.tasks.schemaUtils import convertDataFrameToSdmSchema, checkSdmSchemaColumns, \ 

57 dropEmptyColumns, make_empty_catalog 

58from lsst.pipe.tasks.ssoAssociation import SolarSystemAssociationTask 

59from lsst.utils.timer import timeMethod, duration_from_timeMethod 

60 

61 

62class TooManyDiaObjectsError(pipeBase.AlgorithmError): 

63 """Raised if there are an unusually large number of unassociated DiaSources. 

64 This is usually indicative of an image subtraction error, and needs to be 

65 caught before updating the APDB with a large number of spurious entries. 

66 """ 

67 def __init__(self, *, nNewDiaObjects, threshold): 

68 msg = ("Aborting processing before writing to the APDB." 

69 f" {nNewDiaObjects} new DiaObjects would be created, which exceeds the" 

70 f" configured maximum of {threshold}") 

71 super().__init__(msg) 

72 self.nNewDiaObjects = nNewDiaObjects 

73 self.threshold = threshold 

74 

75 @property 

76 def metadata(self): 

77 return {"nNewDiaObjects": self.nNewDiaObjects, 

78 "threshold": self.threshold 

79 } 

80 

81 

82class PostApdbUpdateError(pipeBase.AlgorithmError): 

83 """Raised for any error that occurs after the APDB has been updated. 

84 This allows partial outputs to be written, and signals that processing 

85 can't be retried. 

86 """ 

87 def __init__(self, *, errorMsg): 

88 msg = ("Aborting processing after writing to the APDB. " 

89 "This image cannot be retried! " 

90 "The original error was:\n" 

91 f"{errorMsg}") 

92 super().__init__(msg) 

93 

94 @property 

95 def metadata(self): 

96 return {} 

97 

98 

99class DiaPipelineConnections( 

100 pipeBase.PipelineTaskConnections, 

101 dimensions=("instrument", "visit", "detector"), 

102 defaultTemplates={"coaddName": "deep", "fakesType": ""}): 

103 """Butler connections for DiaPipelineTask. 

104 """ 

105 diaSourceTable = connTypes.Input( 

106 doc="Catalog of calibrated DiaSources.", 

107 name="{fakesType}{coaddName}Diff_diaSrcTable", 

108 storageClass="DataFrame", 

109 dimensions=("instrument", "visit", "detector"), 

110 ) 

111 solarSystemObjectTable = connTypes.Input( 

112 doc="Catalog of SolarSolarSystem objects expected to be observable in " 

113 "this detectorVisit.", 

114 name="preloaded_SsObjects", 

115 storageClass="ArrowAstropy", 

116 dimensions=("instrument", "group", "detector"), 

117 minimum=0, 

118 ) 

119 diffIm = connTypes.Input( 

120 doc="Difference image on which the DiaSources were detected.", 

121 name="{fakesType}{coaddName}Diff_differenceExp", 

122 storageClass="ExposureF", 

123 dimensions=("instrument", "visit", "detector"), 

124 ) 

125 exposure = connTypes.Input( 

126 doc="Calibrated exposure differenced with a template image during " 

127 "image differencing.", 

128 name="{fakesType}calexp", 

129 storageClass="ExposureF", 

130 dimensions=("instrument", "visit", "detector"), 

131 ) 

132 template = connTypes.Input( 

133 doc="Warped template used to create `subtractedExposure`. Not PSF " 

134 "matched.", 

135 dimensions=("instrument", "visit", "detector"), 

136 storageClass="ExposureF", 

137 name="{fakesType}{coaddName}Diff_templateExp", 

138 ) 

139 preloadedDiaObjects = connTypes.Input( 

140 doc="DiaObjects preloaded from the APDB.", 

141 name="preloaded_diaObjects", 

142 storageClass="DataFrame", 

143 dimensions=("instrument", "group", "detector"), 

144 ) 

145 preloadedDiaSources = connTypes.Input( 

146 doc="DiaSources preloaded from the APDB.", 

147 name="preloaded_diaSources", 

148 storageClass="DataFrame", 

149 dimensions=("instrument", "group", "detector"), 

150 ) 

151 preloadedDiaForcedSources = connTypes.Input( 

152 doc="DiaForcedSources preloaded from the APDB.", 

153 name="preloaded_diaForcedSources", 

154 storageClass="DataFrame", 

155 dimensions=("instrument", "group", "detector"), 

156 ) 

157 apdbMarker = connTypes.Output( 

158 doc="Marker dataset storing the configuration of the Apdb for each " 

159 "visit/detector. Used to signal the completion of the pipeline.", 

160 name="apdb_marker", 

161 storageClass="Config", 

162 dimensions=("instrument", "visit", "detector"), 

163 ) 

164 associatedDiaSources = connTypes.Output( 

165 doc="Optional output storing the DiaSource catalog after matching, " 

166 "calibration, and standardization for insertion into the Apdb.", 

167 name="{fakesType}{coaddName}Diff_assocDiaSrc", 

168 storageClass="ArrowAstropy", 

169 dimensions=("instrument", "visit", "detector"), 

170 ) 

171 associatedSsSources = connTypes.Output( 

172 doc="Optional output storing ssSource data computed during association.", 

173 name="{fakesType}{coaddName}Diff_associatedSsSources", 

174 storageClass="ArrowAstropy", 

175 dimensions=("instrument", "visit", "detector"), 

176 ) 

177 unassociatedSsObjects = connTypes.Output( 

178 doc="Expected locations of an ssObject with no source", 

179 name="ssUnassociatedObjects", 

180 storageClass="ArrowAstropy", 

181 dimensions=("instrument", "visit", "detector"), 

182 ) 

183 

184 diaForcedSources = connTypes.Output( 

185 doc="Optional output storing the forced sources computed at the diaObject positions.", 

186 name="{fakesType}{coaddName}Diff_diaForcedSrc", 

187 storageClass="ArrowAstropy", 

188 dimensions=("instrument", "visit", "detector"), 

189 ) 

190 diaObjects = connTypes.Output( 

191 doc="Optional output storing the updated diaObjects associated to these sources.", 

192 name="{fakesType}{coaddName}Diff_diaObject", 

193 storageClass="ArrowAstropy", 

194 dimensions=("instrument", "visit", "detector"), 

195 ) 

196 newDiaSources = connTypes.Output( 

197 doc="New diaSources not associated with an existing diaObject that" 

198 " were used to create a new diaObject", 

199 name="{fakesType}new_dia_source", 

200 storageClass="ArrowAstropy", 

201 dimensions=("instrument", "visit", "detector"), 

202 ) 

203 marginalDiaSources = connTypes.Output( 

204 doc="Low SNR diaSources not associated with an existing diaObject that" 

205 " were rejected instead of creating a new diaObject", 

206 name="{fakesType}marginal_new_dia_source", 

207 storageClass="ArrowAstropy", 

208 dimensions=("instrument", "visit", "detector"), 

209 ) 

210 

211 def __init__(self, *, config=None): 

212 super().__init__(config=config) 

213 

214 if config.input_image_type == "future": 

215 self.diffIm = dataclasses.replace(self.diffIm, storageClass="DifferenceImage") 

216 self.exposure = dataclasses.replace(self.exposure, storageClass="VisitImage") 

217 self.template = dataclasses.replace(self.template, storageClass="DifferenceImage") 

218 

219 if not config.doWriteAssociatedSources: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true

220 self.outputs.remove("associatedDiaSources") 

221 self.outputs.remove("diaForcedSources") 

222 self.outputs.remove("diaObjects") 

223 self.outputs.remove("newDiaSources") 

224 self.outputs.remove("marginalDiaSources") 

225 else: 

226 if not config.doRunForcedMeasurement: 

227 self.outputs.remove("diaForcedSources") 

228 if not config.filterUnAssociatedSources: 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true

229 self.outputs.remove("newDiaSources") 

230 self.outputs.remove("marginalDiaSources") 

231 if not config.doSolarSystemAssociation: 

232 self.inputs.remove("solarSystemObjectTable") 

233 if (not config.doWriteAssociatedSources) or (not config.doSolarSystemAssociation): 

234 self.outputs.remove("associatedSsSources") 

235 self.outputs.remove("unassociatedSsObjects") 

236 if config.doReloadAllApdbCatalogs: 

237 # If this is set, the complete history will be read from the APDB 

238 # during association and the preloaded catalogs will not be used. 

239 self.inputs.remove("preloadedDiaObjects") 

240 self.inputs.remove("preloadedDiaSources") 

241 self.inputs.remove("preloadedDiaForcedSources") 

242 

243 def adjustQuantum(self, inputs, outputs, label, dataId): 

244 """Override to make adjustments to `lsst.daf.butler.DatasetRef` objects 

245 in the `lsst.daf.butler.core.Quantum` during the graph generation stage 

246 of the activator. 

247 

248 This implementation checks to make sure that the filters in the dataset 

249 are compatible with AP processing as set by the Apdb/DPDD schema. 

250 

251 Parameters 

252 ---------- 

253 inputs : `dict` 

254 Dictionary whose keys are an input (regular or prerequisite) 

255 connection name and whose values are a tuple of the connection 

256 instance and a collection of associated `DatasetRef` objects. 

257 The exact type of the nested collections is unspecified; it can be 

258 assumed to be multi-pass iterable and support `len` and ``in``, but 

259 it should not be mutated in place. In contrast, the outer 

260 dictionaries are guaranteed to be temporary copies that are true 

261 `dict` instances, and hence may be modified and even returned; this 

262 is especially useful for delegating to `super` (see notes below). 

263 outputs : `dict` 

264 Dict of output datasets, with the same structure as ``inputs``. 

265 label : `str` 

266 Label for this task in the pipeline (should be used in all 

267 diagnostic messages). 

268 data_id : `lsst.daf.butler.DataCoordinate` 

269 Data ID for this quantum in the pipeline (should be used in all 

270 diagnostic messages). 

271 

272 Returns 

273 ------- 

274 adjusted_inputs : `dict` 

275 Dict of the same form as ``inputs`` with updated containers of 

276 input `DatasetRef` objects. Connections that are not changed 

277 should not be returned at all. Datasets may only be removed, not 

278 added. Nested collections may be of any multi-pass iterable type, 

279 and the order of iteration will set the order of iteration within 

280 `PipelineTask.runQuantum`. 

281 adjusted_outputs : `dict` 

282 Dict of updated output datasets, with the same structure and 

283 interpretation as ``adjusted_inputs``. 

284 

285 Raises 

286 ------ 

287 ScalarError 

288 Raised if any `Input` or `PrerequisiteInput` connection has 

289 ``multiple`` set to `False`, but multiple datasets. 

290 NoWorkFound 

291 Raised to indicate that this quantum should not be run; not enough 

292 datasets were found for a regular `Input` connection, and the 

293 quantum should be pruned or skipped. 

294 FileNotFoundError 

295 Raised to cause QuantumGraph generation to fail (with the message 

296 included in this exception); not enough datasets were found for a 

297 `PrerequisiteInput` connection. 

298 """ 

299 _, refs = inputs["diffIm"] 

300 for ref in refs: 

301 if ref.dataId["band"] not in self.config.validBands: 

302 raise ValueError( 

303 f"Requested '{ref.dataId['band']}' not in " 

304 "DiaPipelineConfig.validBands. To process bands not in " 

305 "the standard Rubin set (ugrizy) you must add the band to " 

306 "the validBands list in DiaPipelineConfig and add the " 

307 "appropriate columns to the Apdb schema.") 

308 return super().adjustQuantum(inputs, outputs, label, dataId) 

309 

310 

311class DiaPipelineConfig(pipeBase.PipelineTaskConfig, 

312 pipelineConnections=DiaPipelineConnections): 

313 """Config for DiaPipelineTask. 

314 """ 

315 input_image_type = pexConfig.ChoiceField[str]( 

316 doc="Type of the input images. run() takes lsst.afw.image.ExposureF" 

317 " either way.", 

318 allowed={ 

319 "legacy": "Read `lsst.afw.image.ExposureF`.", 

320 "future": "Read `lsst.images.VisitImage` for the science image and" 

321 " `lsst.images.DifferenceImage` for the difference image and" 

322 " template.", 

323 }, 

324 optional=False, 

325 default="legacy", 

326 ) 

327 coaddName = pexConfig.Field( 

328 doc="coadd name: typically one of deep, goodSeeing, or dcr", 

329 dtype=str, 

330 default="deep", 

331 ) 

332 apdb_config_url = pexConfig.Field( 

333 dtype=str, 

334 default=None, 

335 optional=False, 

336 doc="A config file specifying the APDB and its connection parameters, " 

337 "typically written by the apdb-cli command-line utility. " 

338 "The database must already be initialized.", 

339 ) 

340 validBands = pexConfig.ListField( 

341 dtype=str, 

342 default=["u", "g", "r", "i", "z", "y"], 

343 doc="List of bands that are valid for AP processing. To process a " 

344 "band not on this list, the appropriate band specific columns " 

345 "must be added to the Apdb schema in dax_apdb.", 

346 ) 

347 associator = pexConfig.ConfigurableField( 

348 target=AssociationTask, 

349 doc="Task used to associate DiaSources with DiaObjects.", 

350 ) 

351 doSolarSystemAssociation = pexConfig.Field( 

352 dtype=bool, 

353 default=True, 

354 doc="Process SolarSystem objects through the pipeline.", 

355 ) 

356 solarSystemAssociator = pexConfig.ConfigurableField( 

357 target=SolarSystemAssociationTask, 

358 doc="Task used to associate DiaSources with SolarSystemObjects.", 

359 ) 

360 diaCalculation = pexConfig.ConfigurableField( 

361 target=DiaObjectCalculationTask, 

362 doc="Task to compute summary statistics for DiaObjects.", 

363 ) 

364 doReloadDiaObjects = pexConfig.Field( 

365 dtype=bool, 

366 default=True, 

367 doc="Drop preloaded DiaObjects and reload them from the APDB?" 

368 "Used in production when the very latest objects from the APDB " 

369 "are needed. Ignored and superceded if `doReloadAllApdbCatalogs` " 

370 "is set.", 

371 ) 

372 doReloadAllApdbCatalogs = pexConfig.Field( 

373 dtype=bool, 

374 default=False, 

375 doc="Read the complete DiaObject, DiaSource, and DiaForcedSource " 

376 "history from the APDB during association? Use only during " 

377 "reprocessing, and never in Prompt Processing.", 

378 ) 

379 angleMargin = pexConfig.RangeField( 

380 doc="Padding to add when reloading catalogs from the APDB. " 

381 "Smaller than the padding used for ``LoadDiaCatalogsTask`` because " 

382 "this task is after astrometric calibration.", 

383 dtype=float, 

384 default=2, 

385 min=0, 

386 ) 

387 doRunForcedMeasurement = pexConfig.Field( 

388 dtype=bool, 

389 default=True, 

390 deprecated="Added to allow disabling forced sources for performance " 

391 "reasons during the ops rehearsal. " 

392 "It is expected to be removed.", 

393 doc="Run forced measurement on all of the diaObjects? " 

394 "This should only be turned off for debugging purposes.", 

395 ) 

396 diaForcedSource = pexConfig.ConfigurableField( 

397 target=DiaForcedSourceTask, 

398 doc="Task used for force photometer DiaObject locations in direct and " 

399 "difference images.", 

400 ) 

401 forcedReliabilityThreshold = pexConfig.Field( 

402 dtype=float, 

403 default=0.5, 

404 doc="Minimum reliability score of constituent diaSources to use when " 

405 "determining whether to calculate forced photometry for a " 

406 "diaObject.", 

407 ) 

408 forcedTrailLengthThreshold = pexConfig.Field( 

409 dtype=float, 

410 default=1.4, 

411 doc="Maximum trail length (arseconds) of constituent diaSources to use " 

412 "when determining whether to calculate forced photometry for a " 

413 "diaObject.", 

414 ) 

415 forcedBadFlags = pexConfig.ListField( 

416 dtype=str, 

417 default=["glint_trail", "isDipole"], 

418 doc="Flags of constituent diaSources to exclude when determining " 

419 "whether to calculate forced photometry for a diaObject.", 

420 ) 

421 alertPackager = pexConfig.ConfigurableField( 

422 target=PackageAlertsTask, 

423 doc="Subtask for packaging Ap data into alerts.", 

424 ) 

425 doPackageAlerts = pexConfig.Field( 

426 dtype=bool, 

427 default=False, 

428 doc="Package Dia-data into serialized alerts for distribution and " 

429 "write them to disk.", 

430 ) 

431 doWriteAssociatedSources = pexConfig.Field( 

432 dtype=bool, 

433 default=True, 

434 doc="Write out associated DiaSources, DiaForcedSources, and DiaObjects, " 

435 "formatted following the Science Data Model.", 

436 ) 

437 imagePixelMargin = pexConfig.RangeField( 

438 dtype=int, 

439 default=10, 

440 min=0, 

441 doc="Pad the image by this many pixels before removing off-image " 

442 "diaObjects for association.", 

443 ) 

444 filterUnAssociatedSources = pexConfig.Field( 

445 dtype=bool, 

446 default=True, 

447 doc="Check unassociated diaSources for quality before creating new ." 

448 "diaObjects. DiaSources that are associated with existing diaObjects " 

449 "will not be affected." 

450 ) 

451 newObjectBadFlags = pexConfig.ListField( 

452 dtype=str, 

453 default=("centroid_flag", 

454 "pixelFlags_crCenter", 

455 "pixelFlags_nodataCenter", 

456 "pixelFlags_interpolatedCenter", 

457 "pixelFlags_saturatedCenter", 

458 "pixelFlags_suspectCenter", 

459 "pixelFlags_streakCenter", 

460 "glint_trail"), 

461 doc="If `filterUnAssociatedSources` is set, exclude diaSources with " 

462 "these flags set before creating new diaObjects.", 

463 ) 

464 maxNewDiaObjects = pexConfig.RangeField( 

465 dtype=float, 

466 default=0, 

467 min=0, 

468 doc="Maximum number of new DiaObjects to create before raising an error." 

469 "Set to zero to disable.", 

470 ) 

471 newObjectSnrThreshold = pexConfig.RangeField( 

472 dtype=float, 

473 default=0, 

474 min=0, 

475 doc="If `filterUnAssociatedSources` is set, exclude diaSources with " 

476 "Abs(flux/fluxErr) less than this threshold before creating new " 

477 "diaObjects." 

478 "Set to zero to disable.", 

479 ) 

480 newObjectLowReliabilitySnrThreshold = pexConfig.RangeField( 

481 dtype=float, 

482 default=0, 

483 min=0, 

484 doc="If `filterUnAssociatedSources` is set, exclude diaSources with " 

485 "signal-to-noise ratios less than this threshold if they have" 

486 " low reliability scores before creating new diaObjects." 

487 "Set to zero to disable.", 

488 ) 

489 newObjectReliabilityThreshold = pexConfig.RangeField( 

490 dtype=float, 

491 default=0.1, 

492 min=0, 

493 max=1, 

494 doc="If `filterUnAssociatedSources` is set, exclude diaSources with " 

495 "reliability scores less than this threshold before creating new " 

496 "diaObjects." 

497 "Set to zero to disable.", 

498 ) 

499 newObjectLowSnrReliabilityThreshold = pexConfig.RangeField( 

500 dtype=float, 

501 default=0.1, 

502 min=0, 

503 max=1, 

504 doc="If `filterUnAssociatedSources` is set, exclude diaSources with " 

505 "low signal-to-noise and reliability scores below this threshold " 

506 "before creating new diaObjects." 

507 "Set to zero to disable.", 

508 ) 

509 newObjectFluxField = pexConfig.Field( 

510 dtype=str, 

511 default="apFlux", 

512 doc="Name of the flux field in the standardized diaSource catalog to " 

513 "use for checking the signal-to-noise before creating new diaObjects.", 

514 ) 

515 idGenerator = DetectorVisitIdGeneratorConfig.make_field() 

516 

517 def setDefaults(self): 

518 self.diaCalculation.plugins = ["ap_meanPosition", 

519 "ap_firstLastDiaSourceTime", 

520 "ap_nDiaSources", 

521 "ap_meanFlux", 

522 "ap_sigmaFlux", 

523 "ap_minMaxFlux", 

524 "ap_maxSlopeFlux", 

525 "ap_meanErrFlux", 

526 "ap_meanTotFlux"] 

527 

528 

529class DiaPipelineTask(pipeBase.PipelineTask): 

530 """Task for loading, associating and storing Difference Image Analysis 

531 (DIA) Objects and Sources. 

532 """ 

533 ConfigClass = DiaPipelineConfig 

534 _DefaultName = "diaPipe" 

535 

536 def __init__(self, initInputs=None, **kwargs): 

537 super().__init__(**kwargs) 

538 self.apdb = daxApdb.Apdb.from_uri(self.config.apdb_config_url) 

539 self.schema = readSchemaFromApdb(self.apdb) 

540 self.makeSubtask("associator") 

541 self.makeSubtask("diaCalculation") 

542 if self.config.doRunForcedMeasurement: 

543 self.makeSubtask("diaForcedSource") 

544 if self.config.doPackageAlerts: 

545 self.makeSubtask("alertPackager") 

546 if self.config.doSolarSystemAssociation: 

547 self.makeSubtask("solarSystemAssociator") 

548 if self.config.filterUnAssociatedSources: 

549 columns = [self.config.newObjectFluxField, 

550 self.config.newObjectFluxField + "Err", 

551 "reliability"] 

552 columns += self.config.newObjectBadFlags 

553 

554 missing = checkSdmSchemaColumns(self.schema, columns, "DiaSource") 

555 if missing: 

556 raise pipeBase.InvalidQuantumError("Field %s not in the DiaSource schema" % missing) 

557 

558 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

559 inputs = butlerQC.get(inputRefs) 

560 if self.config.input_image_type == "future": 

561 for name in ("diffIm", "exposure", "template"): 

562 inputs[name] = inputs[name].to_legacy() 

563 inputs["idGenerator"] = self.config.idGenerator.apply(butlerQC.quantum.dataId) 

564 inputs["band"] = butlerQC.quantum.dataId["band"] 

565 inputs["legacySolarSystemTable"] = None 

566 if not self.config.doSolarSystemAssociation: 566 ↛ 567line 566 didn't jump to line 567 because the condition on line 566 was never true

567 inputs["solarSystemObjectTable"] = None 

568 

569 associationResults = pipeBase.Struct( 

570 apdbMarker=None, 

571 associatedDiaSources=None, 

572 diaForcedSources=None, 

573 diaObjects=None, 

574 associatedSsSources=None, 

575 unassociatedSsObjects=None, 

576 newDiaSources=None, 

577 marginalDiaSources=None, 

578 ) 

579 try: 

580 self.run(**inputs, associationResults=associationResults) 

581 except pipeBase.AlgorithmError as e: 

582 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

583 e, 

584 self, 

585 log=self.log 

586 ) 

587 butlerQC.put(associationResults, outputRefs) 

588 raise error from e 

589 butlerQC.put(associationResults, outputRefs) 

590 

591 @timeMethod 

592 def run(self, 

593 diaSourceTable, 

594 legacySolarSystemTable, 

595 diffIm, 

596 exposure, 

597 template, 

598 preloadedDiaObjects=None, 

599 preloadedDiaSources=None, 

600 preloadedDiaForcedSources=None, 

601 band=None, 

602 idGenerator=None, 

603 solarSystemObjectTable=None, 

604 associationResults=None): 

605 """Process DiaSources and DiaObjects. 

606 

607 Load previous DiaObjects and their DiaSource history. Calibrate the 

608 values in the diaSourceCat. Associate new DiaSources with previous 

609 DiaObjects. Run forced photometry at the updated DiaObject locations. 

610 Store the results in the Alert Production Database (Apdb). 

611 

612 Parameters 

613 ---------- 

614 diaSourceTable : `pandas.DataFrame` 

615 Newly detected DiaSources. 

616 legacySolarSystemTable : `pandas.DataFrame` 

617 Not used 

618 diffIm : `lsst.afw.image.ExposureF` 

619 Difference image exposure in which the sources in ``diaSourceCat`` 

620 were detected. 

621 exposure : `lsst.afw.image.ExposureF` 

622 Calibrated exposure differenced with a template to create 

623 ``diffIm``. 

624 template : `lsst.afw.image.ExposureF` 

625 Template exposure used to create diffIm. 

626 preloadedDiaObjects : `pandas.DataFrame`, optional 

627 Previously detected DiaObjects, loaded from the APDB. 

628 `None` if ``doReloadAllApdbCatalogs`` is set. 

629 preloadedDiaSources : `pandas.DataFrame`, optional 

630 Previously detected DiaSources, loaded from the APDB. 

631 `None` if ``doReloadAllApdbCatalogs`` is set. 

632 preloadedDiaForcedSources : `pandas.DataFrame`, optional 

633 Catalog of previously detected forced DiaSources, from the APDB. 

634 `None` if ``doReloadAllApdbCatalogs`` is set. 

635 band : `str` 

636 The band in which the new DiaSources were detected. Required, 

637 despite the `None` default. 

638 idGenerator : `lsst.meas.base.IdGenerator` 

639 Object that generates source IDs and random number generator seeds. 

640 Required, despite the `None` default. 

641 solarSystemObjectTable : `astropy.table.Table`, optional 

642 Preloaded Solar System objects expected to be visible in the image. 

643 associationResults : `lsst.pipe.base.Struct`, optional 

644 Result struct that is modified to allow saving of partial outputs 

645 for some failure conditions. If the task completes successfully, 

646 this is also returned. 

647 

648 Returns 

649 ------- 

650 associationResults : `lsst.pipe.base.Struct` 

651 Results struct with components. 

652 

653 - ``apdbMarker`` : Marker dataset to store in the Butler indicating 

654 that this ccdVisit has completed successfully. 

655 (`lsst.dax.apdb.ApdbConfig`) 

656 - ``associatedDiaSources`` : Catalog of newly associated 

657 DiaSources. (`pandas.DataFrame`) 

658 - ``diaForcedSources`` : Catalog of new and previously detected 

659 forced DiaSources. (`pandas.DataFrame`) 

660 - ``diaObjects`` : Updated table of DiaObjects. (`pandas.DataFrame`) 

661 - ``associatedSsSources`` : Catalog of ssSource records. 

662 (`pandas.DataFrame`) 

663 

664 Raises 

665 ------ 

666 RuntimeError 

667 Raised if duplicate DiaObjects or duplicate DiaSources are found. 

668 ValueError 

669 Raised if ``band`` or ``idGenerator`` is `None`. 

670 """ 

671 # These default to `None` only so that the preloaded catalogs, which 

672 # precede them, can be omitted. 

673 if band is None: 

674 raise ValueError("band is required.") 

675 if idGenerator is None: 

676 raise ValueError("idGenerator is required.") 

677 

678 if associationResults is None: 678 ↛ 680line 678 didn't jump to line 680 because the condition on line 678 was always true

679 associationResults = pipeBase.Struct() 

680 self._validateExposure(exposure, "Science") 

681 self._validateExposure(diffIm, "Difference") 

682 self._validateExposure(template, "Template") 

683 

684 # Accept either legacySolarSystemTable or optional solarSystemObjectTable. 

685 if legacySolarSystemTable is not None and solarSystemObjectTable is None: 685 ↛ 686line 685 didn't jump to line 686 because the condition on line 685 was never true

686 solarSystemObjectTable = Table.from_pandas(legacySolarSystemTable) 

687 region = getRegion(exposure) 

688 if self.config.doReloadDiaObjects or self.config.doReloadAllApdbCatalogs: 

689 try: 

690 preloadedDiaObjects = self.loadRefreshedDiaObjects(region, preloadedDiaObjects) 

691 except Exception as e: 

692 if self.config.doReloadAllApdbCatalogs: 

693 # We can't continue if the diaObjects were not loaded and 

694 # ``doReloadAllApdbCatalogs`` is set, because there will be 

695 # no preloaded diaObjects to fall back on. 

696 raise 

697 self.log.warning("Error encountered while attempting to load " 

698 "the latest diaObjects from the APDB. Processing " 

699 "will continue with only the diaObjects from " 

700 "preload.", exc_info=e) 

701 finally: 

702 self.metadata["loadDiaObjectsDuration"] = duration_from_timeMethod( 

703 self.metadata, "loadRefreshedDiaObjects", clock="Utc" 

704 ) 

705 self.log.verbose("Re-loading DiaObjects: Took %.4f seconds", 

706 self.metadata["loadDiaObjectsDuration"]) 

707 

708 else: 

709 self.metadata["loadDiaObjectsDuration"] = -1 

710 

711 if self.config.doReloadAllApdbCatalogs: 

712 # The metadata time records must use the same names as 

713 # LoadDiaCatalogsTask to keep the metrics upload consistent. 

714 visitTime = exposure.visitInfo.date.toAstropy() 

715 try: 

716 preloadedDiaSources = self.loadRefreshedDiaSources(region, preloadedDiaObjects, visitTime) 

717 finally: 

718 self.metadata["loadDiaSourcesDuration"] = duration_from_timeMethod( 

719 self.metadata, "loadRefreshedDiaSources", clock="Utc" 

720 ) 

721 self.log.verbose("Re-loading DiaSources: Took %.4f seconds", 

722 self.metadata["loadDiaSourcesDuration"]) 

723 try: 

724 preloadedDiaForcedSources = self.loadRefreshedDiaForcedSources( 

725 region, preloadedDiaObjects, visitTime) 

726 finally: 

727 self.metadata["loadDiaForcedSourcesDuration"] = duration_from_timeMethod( 

728 self.metadata, "loadRefreshedDiaForcedSources", clock="Utc" 

729 ) 

730 self.log.verbose("Re-loading DiaForcedSources: Took %.4f seconds", 

731 self.metadata["loadDiaForcedSourcesDuration"]) 

732 else: 

733 self.metadata["loadDiaSourcesDuration"] = -1 

734 self.metadata["loadDiaForcedSourcesDuration"] = -1 

735 

736 self.checkTableIndex(preloadedDiaSources, index=["diaObjectId", "band", "diaSourceId"]) 

737 self.checkTableIndex(preloadedDiaObjects, index="diaObjectId") 

738 self.checkTableIndex(preloadedDiaForcedSources, index=["diaObjectId", "diaForcedSourceId"]) 

739 

740 # Alerts include the DiaSource history, so check that it has every 

741 # column they require while the image can still be retried. 

742 if self.config.doPackageAlerts and not preloadedDiaSources.empty: 

743 missing = self.alertPackager.findMissingDiaSourceColumns(preloadedDiaSources) 

744 if missing: 

745 raise ValueError( 

746 "Aborting before writing to the APDB: the DiaSource history lacks columns that " 

747 f"alerts require: {', '.join(missing)}. It may predate these columns in the APDB schema." 

748 ) 

749 

750 if not preloadedDiaObjects.empty: 750 ↛ 755line 750 didn't jump to line 755 because the condition on line 750 was always true

751 # Include a small buffer outside the image so that we can associate sources near the edge 

752 diaObjects, _ = self.purgeDiaObjects(diffIm.getBBox(), diffIm.getWcs(), preloadedDiaObjects, 

753 buffer=self.config.imagePixelMargin) 

754 else: 

755 self.log.info("Preloaded DiaObject table is empty.") 

756 diaObjects = preloadedDiaObjects 

757 

758 # Associate DiaSources with DiaObjects 

759 assocResults = self.associateDiaSources(diaSourceTable, solarSystemObjectTable, diffIm, diaObjects) 

760 

761 # Set unassociated diaObjectIds and ssObjectIds to NULL, and convert to SDM schema format 

762 standardizedAssociatedDiaSources = self.standardizeDataFrame( 

763 assocResults.associatedDiaSources, 

764 "DiaSource", 

765 nullColumns=["diaObjectId", "ssObjectId"] 

766 ) 

767 

768 # Merge associated diaSources 

769 mergedDiaSourceHistory, mergedDiaObjects, updatedDiaObjectIds = self.mergeAssociatedCatalogs( 

770 preloadedDiaSources, 

771 assocResults.associatedDiaSources, 

772 diaObjects, 

773 assocResults.newDiaObjects, 

774 diffIm 

775 ) 

776 

777 # Compute DiaObject Summary statistics from their full DiaSource 

778 # history. 

779 diaCalResult = self.diaCalculation.run( 

780 mergedDiaObjects, 

781 mergedDiaSourceHistory, 

782 updatedDiaObjectIds, 

783 self.config.validBands) 

784 updatedDiaObjects = convertDataFrameToSdmSchema(self.schema, diaCalResult.updatedDiaObjects, 

785 tableName="DiaObject", skipIndex=True) 

786 

787 # Test for duplication in the updated DiaObjects. 

788 if self.testDataFrameIndex(diaCalResult.diaObjectCat): 788 ↛ 789line 788 didn't jump to line 789 because the condition on line 788 was never true

789 raise RuntimeError( 

790 "Duplicate DiaObjects (loaded + updated) created after " 

791 "DiaCalculation. This is unexpected behavior and should be " 

792 "reported. Exiting.") 

793 if self.testDataFrameIndex(updatedDiaObjects): 793 ↛ 794line 793 didn't jump to line 794 because the condition on line 793 was never true

794 raise RuntimeError( 

795 "Duplicate DiaObjects (updated) created after " 

796 "DiaCalculation. This is unexpected behavior and should be " 

797 "reported. Exiting.") 

798 

799 # Forced source measurement 

800 if self.config.doRunForcedMeasurement: 

801 diaObjectsForced = self._selectGoodDiaObjects(diaCalResult.diaObjectCat, mergedDiaSourceHistory) 

802 diaForcedSources = self.runForcedMeasurement( 

803 diaObjectsForced, updatedDiaObjects, exposure, diffIm, idGenerator 

804 ) 

805 forcedSourceHistoryThreshold = self.diaForcedSource.config.historyThreshold 

806 else: 

807 # alertPackager needs correct columns 

808 diaForcedSources = makeEmptyForcedSourceTable(self.schema) 

809 forcedSourceHistoryThreshold = 0 

810 

811 # Write results to Alert Production Database (APDB) 

812 try: 

813 validityStart = self.writeToApdb(updatedDiaObjects, standardizedAssociatedDiaSources, 

814 diaForcedSources) 

815 finally: 

816 self.metadata["writeToApdbDuration"] = duration_from_timeMethod(self.metadata, "writeToApdb", 

817 clock="Utc") 

818 # A single log message is easier for Loki to parse than timeMethod's start+end pairs. 

819 self.log.verbose("writeToApdb: Took %.4f seconds", self.metadata["writeToApdbDuration"]) 

820 

821 # For historical reasons, apdbMarker is a Config even if it's not meant to be read. 

822 # A default Config is the cheapest way to satisfy the storage class. 

823 associationResults.apdbMarker = pexConfig.Config() 

824 associationResults.associatedDiaSources = assocResults.associatedDiaSources 

825 associationResults.diaForcedSources = diaForcedSources 

826 associationResults.diaObjects = diaCalResult.diaObjectCat 

827 associationResults.unassociatedSsObjects = assocResults.unassociatedSsObjects 

828 associationResults.newDiaSources = assocResults.newDiaSources 

829 associationResults.marginalDiaSources = assocResults.marginalDiaSources 

830 

831 # Catch *any* error after we have updated the APDB 

832 try: 

833 associatedSsSources = assocResults.associatedSsSources 

834 associatedSsSourcesPlusMpcorb = None 

835 if associatedSsSources is not None: 

836 mpcorbColumns = [col for col in associatedSsSources.columns if col[:7] == 'MPCORB_'] 

837 associatedSsSourceMpcorb = associatedSsSources[mpcorbColumns].copy() 

838 associatedSsSources = self.standardizeTable(associatedSsSources, "SSSource", nullColumns=[]) 

839 associatedSsSourcesPlusMpcorb = associatedSsSources.copy() 

840 for mpcorbColumn in mpcorbColumns: 840 ↛ 841line 840 didn't jump to line 841 because the loop on line 840 never started

841 associatedSsSourcesPlusMpcorb[mpcorbColumn] = associatedSsSourceMpcorb[mpcorbColumn] 

842 associationResults.associatedSsSources = associatedSsSources 

843 

844 # patch the otherwise-empty validityStart field for the alerts 

845 updatedDiaObjects['validityStartMjdTai'] = validityStart.get(system=DateTime.MJD, 

846 scale=DateTime.TAI) 

847 

848 # Package alerts 

849 if self.config.doPackageAlerts: 

850 # Append new forced sources to the full history 

851 diaForcedSourcesFull = self.mergeCatalogs(preloadedDiaForcedSources, diaForcedSources, 

852 tableName="DiaForcedSource") 

853 if self.testDataFrameIndex(diaForcedSourcesFull): 853 ↛ 854line 853 didn't jump to line 854 because the condition on line 853 was never true

854 self.log.warning( 

855 "Duplicate DiaForcedSources created after merge with " 

856 "history and new sources. This may cause downstream " 

857 "problems. Dropping duplicates.") 

858 # Drop duplicates via index and keep the first appearance. 

859 diaForcedSourcesFull = diaForcedSourcesFull[ 

860 ~diaForcedSourcesFull.index.duplicated(keep="first")] 

861 

862 self.alertPackager.run(assocResults.associatedDiaSources, 

863 updatedDiaObjects, 

864 preloadedDiaSources, 

865 diaForcedSourcesFull, 

866 diffIm, 

867 exposure, 

868 template, 

869 ssSrc=associatedSsSourcesPlusMpcorb, 

870 doRunForcedMeasurement=self.config.doRunForcedMeasurement, 

871 forcedSourceHistoryThreshold=forcedSourceHistoryThreshold, 

872 ) 

873 except Exception as e: 

874 # Catch *any* error after we have updated the APDB 

875 raise PostApdbUpdateError(errorMsg=repr(e)) from e 

876 

877 return associationResults 

878 

879 def _validateExposure(self, exposure, label): 

880 """Validate exposure metadata early to avoid failures after updating 

881 the APDB. 

882 

883 Parameters 

884 ---------- 

885 exposure : `lsst.afw.image.ExposureF` 

886 Calibrated science image that was subtracted. Corresponds to the 

887 ``exposure`` Butler connection (``{fakesType}calexp``). 

888 label : `str` 

889 Specification of the image being validated, for logging messages. 

890 

891 Raises 

892 ------ 

893 ValueError 

894 Raised when any required metadata field is missing or invalid. 

895 """ 

896 errors = [] 

897 

898 photo_calib = exposure.getPhotoCalib() 

899 if photo_calib is None: 899 ↛ 900line 899 didn't jump to line 900 because the condition on line 899 was never true

900 errors.append( 

901 f"{label}: PhotoCalib is None; flux-to-nJy calibration " 

902 "in alert packaging will fail." 

903 ) 

904 else: 

905 mean = photo_calib.getCalibrationMean() 

906 if not np.isfinite(mean) or mean <= 0.0: 906 ↛ 907line 906 didn't jump to line 907 because the condition on line 906 was never true

907 errors.append( 

908 f"{label}: PhotoCalib calibration mean is {mean!r}; " 

909 "must be a finite, positive value." 

910 ) 

911 

912 if exposure.getWcs() is None: 912 ↛ 913line 912 didn't jump to line 913 because the condition on line 912 was never true

913 errors.append( 

914 f"{label}: WCS is None; sky-to-pixel projection and " 

915 "DIA Object association will fail." 

916 ) 

917 

918 # Skip visitInfo checks for the template 

919 if label != "Template": 

920 visit_info = exposure.getInfo().getVisitInfo() 

921 if visit_info is None: 921 ↛ 922line 921 didn't jump to line 922 because the condition on line 921 was never true

922 errors.append( 

923 f"{label}: VisitInfo is None; observation time, pointing, " 

924 "rotation angle, and exposure duration are unavailable." 

925 ) 

926 else: 

927 obs_date = visit_info.getDate() 

928 if not obs_date.isValid(): 928 ↛ 929line 928 didn't jump to line 929 because the condition on line 928 was never true

929 errors.append( 

930 f"{label}: VisitInfo.date is invalid." 

931 ) 

932 

933 # boresightRaDec — written to APDB rows and alert payloads. 

934 boresight = visit_info.getBoresightRaDec() 

935 ra_deg = boresight.getRa().asDegrees() 

936 dec_deg = boresight.getDec().asDegrees() 

937 if not np.isfinite(ra_deg) or not np.isfinite(dec_deg): 937 ↛ 938line 937 didn't jump to line 938 because the condition on line 937 was never true

938 errors.append( 

939 f"{label}: VisitInfo.boresightRaDec contains NaN " 

940 f"(RA={ra_deg}, Dec={dec_deg}); pointing metadata " 

941 "is missing or corrupt." 

942 ) 

943 

944 rot_deg = visit_info.getBoresightRotAngle().asDegrees() 

945 if not np.isfinite(rot_deg): 945 ↛ 946line 945 didn't jump to line 946 because the condition on line 945 was never true

946 errors.append( 

947 f"{label}: VisitInfo.boresightRotAngle is NaN; the " 

948 "ROTPA keyword will be corrupt in all alert cutout " 

949 "FITS headers." 

950 ) 

951 

952 filter_label = exposure.getFilter() 

953 if filter_label is None: 953 ↛ 954line 953 didn't jump to line 954 because the condition on line 953 was never true

954 errors.append( 

955 f"{label}: filter label is None." 

956 ) 

957 else: 

958 band = filter_label.bandLabel if filter_label.hasBandLabel() else "" 

959 if not band: 959 ↛ 960line 959 didn't jump to line 960 because the condition on line 959 was never true

960 errors.append( 

961 f"{label}: filter label carries no band name." 

962 ) 

963 

964 bbox = exposure.getBBox() 

965 if bbox.getWidth() <= 0 or bbox.getHeight() <= 0: 965 ↛ 966line 965 didn't jump to line 966 because the condition on line 965 was never true

966 errors.append( 

967 f"{label}: bounding box is empty " 

968 f"({bbox.getWidth()}×{bbox.getHeight()} px); the " 

969 "readout or ISR step may have failed." 

970 ) 

971 

972 if exposure.getPsf() is None: 972 ↛ 973line 972 didn't jump to line 973 because the condition on line 972 was never true

973 errors.append( 

974 f"{label}: PSF model is None." 

975 ) 

976 

977 if errors: 977 ↛ 978line 977 didn't jump to line 978 because the condition on line 977 was never true

978 detail = "\n ".join(errors) 

979 raise ValueError( 

980 "Input exposure metadata is invalid; aborting before any " 

981 "APDB writes to prevent non-recoverable data corruption:\n " 

982 + detail 

983 ) 

984 

985 def _selectGoodDiaObjects(self, diaObjects, diaSources): 

986 """ 

987 Extract a subset of diaObjects with multiple associated diaSources that 

988 pass selection requirements. 

989 """ 

990 n_history = self.diaForcedSource.config.historyThreshold 

991 required_columns = ["reliability", "trailLength"] 

992 for col in required_columns: 

993 if col not in diaSources.columns: 

994 raise RuntimeError("Required column '%s' missing from diaSource table." % col) 

995 

996 rel_ok = diaSources["reliability"] >= self.config.forcedReliabilityThreshold 

997 trailed_ok = diaSources["trailLength"] <= self.config.forcedTrailLengthThreshold 

998 badFlags = [f for f in self.config.forcedBadFlags if f in diaSources.columns] 

999 if badFlags: 

1000 self.log.info("Excluding diaSources with %s flags set when calculating" 

1001 " diaObject history for forced photometry." % badFlags) 

1002 # Exclude rows where ANY bad flag is True. 

1003 # If bad flag columns can contain NA, treat NA as True (i.e., bad) via fillna(True). 

1004 bad_any = diaSources[badFlags].fillna(True).any(axis=1) 

1005 mask = rel_ok & trailed_ok & ~bad_any 

1006 ids = diaSources.index.get_level_values("diaObjectId")[mask] 

1007 

1008 # Count occurrences in diaSource 

1009 counts = pd.Series(ids, copy=False).value_counts() 

1010 

1011 # IDs that exceed threshold 

1012 keep_ids = counts[counts >= n_history].index 

1013 

1014 # Since diaObject index is unique, direct index filtering is safe and efficient 

1015 return diaObjects.loc[diaObjects.index.intersection(keep_ids)] 

1016 

1017 def createNewDiaObjects(self, unassociatedDiaSources): 

1018 """Loop through the set of DiaSources and create new DiaObjects 

1019 for unassociated DiaSources. 

1020 

1021 Parameters 

1022 ---------- 

1023 unassociatedDiaSources : `pandas.DataFrame` 

1024 Set of DiaSources to create new DiaObjects from. 

1025 

1026 Returns 

1027 ------- 

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

1029 Results struct containing: 

1030 

1031 - diaSources : `pandas.DataFrame` 

1032 DiaSource catalog with updated DiaObject ids. 

1033 - newDiaObjects : `pandas.DataFrame` 

1034 Newly created DiaObjects from the unassociated DiaSources. 

1035 - nNewDiaObjects : `int` 

1036 Number of newly created diaObjects. 

1037 - marginalDiaSources : `pandas.DataFrame` 

1038 Unassociated diaSources with low signal-to-noise and/or 

1039 reliability, which are excluded from the new DiaObjects. 

1040 """ 

1041 marginalDiaSources = make_empty_catalog(self.schema, tableName="DiaSource") 

1042 if len(unassociatedDiaSources) == 0: 

1043 newDiaObjects = make_empty_catalog(self.schema, tableName="DiaObject") 

1044 else: 

1045 if self.config.filterUnAssociatedSources: 

1046 results = self.filterSources(unassociatedDiaSources) 

1047 unassociatedDiaSources = results.goodSources 

1048 marginalDiaSources = results.badSources 

1049 unassociatedDiaSources["diaObjectId"] = unassociatedDiaSources["diaSourceId"] 

1050 newDiaObjects = convertDataFrameToSdmSchema(self.schema, unassociatedDiaSources, 

1051 tableName="DiaObject", skipIndex=True) 

1052 self.metadata["nRejectedNewDiaObjects"] = len(marginalDiaSources) 

1053 return pipeBase.Struct(diaSources=unassociatedDiaSources, 

1054 newDiaObjects=newDiaObjects, 

1055 nNewDiaObjects=len(newDiaObjects), 

1056 marginalDiaSources=marginalDiaSources) 

1057 

1058 def filterSources(self, sources, snrThreshold=None, lowReliabilitySnrThreshold=None, 

1059 reliabilityThreshold=None, lowSnrReliabilityThreshold=None, badFlags=None): 

1060 """Select good sources out of a catalog. 

1061 

1062 Parameters 

1063 ---------- 

1064 sources : `pandas.DataFrame` 

1065 Set of DiaSources to check. 

1066 snrThreshold : `float`, optional 

1067 The minimum signal to noise diaSource to make a new diaObject. 

1068 Included for unit tests. Uses the task config value if not set. 

1069 lowReliabilitySnrThreshold : `float`, optional 

1070 Use ``lowSnrReliabilityThreshold`` as the reliability threshold for 

1071 diaSources with ``snrThreshold`` < SNR < ``lowReliabilitySnrThreshold`` 

1072 Included for unit tests. Uses the task config value if not set. 

1073 reliabilityThreshold : `float`, optional 

1074 The minimum reliability score diaSource to make a new diaObject 

1075 Included for unit tests. Uses the task config value if not set. 

1076 lowSnrReliabilityThreshold : `float`, optional 

1077 Use ``lowSnrReliabilityThreshold`` as the reliability threshold for 

1078 diaSources with ``snrThreshold`` < SNR < ``lowReliabilitySnrThreshold`` 

1079 Included for unit tests. Uses the task config value if not set. 

1080 badFlags : `list` of `str`, optional 

1081 Do not create new diaObjects for any diaSource with any of these 

1082 flags set. 

1083 Included for unit tests. Uses the task config value if not set. 

1084 

1085 Returns 

1086 ------- 

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

1088 Results struct containing: 

1089 

1090 - goodSources : `pandas.DataFrame` 

1091 Subset of the input sources that pass all checks. 

1092 - badSources : `pandas.DataFrame` 

1093 Subset of the input sources that fail any check. 

1094 """ 

1095 if snrThreshold is None: 

1096 snrThreshold = self.config.newObjectSnrThreshold 

1097 if lowReliabilitySnrThreshold is None: 

1098 lowReliabilitySnrThreshold = self.config.newObjectLowReliabilitySnrThreshold 

1099 if reliabilityThreshold is None: 

1100 reliabilityThreshold = self.config.newObjectReliabilityThreshold 

1101 if lowSnrReliabilityThreshold is None: 

1102 lowSnrReliabilityThreshold = self.config.newObjectLowSnrReliabilityThreshold 

1103 if badFlags is None: 

1104 badFlags = self.config.newObjectBadFlags 

1105 flagged = sources[badFlags].fillna(False).any(axis=1) 

1106 fluxField = self.config.newObjectFluxField 

1107 fluxErrField = fluxField + "Err" 

1108 signalToNoise = np.abs(np.array(sources[fluxField]/sources[fluxErrField])) 

1109 reliability = np.array(sources['reliability']) 

1110 nFlagged = np.count_nonzero(flagged) 

1111 if nFlagged > 0: 

1112 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to flags", nFlagged) 

1113 

1114 if snrThreshold > 0: 

1115 snr_flag = signalToNoise < snrThreshold 

1116 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to %sFlux" 

1117 " signal to noise < %f", 

1118 np.sum(snr_flag), fluxField, snrThreshold) 

1119 flagged |= snr_flag 

1120 if reliabilityThreshold > 0: 

1121 reliability_flag = reliability < reliabilityThreshold 

1122 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to " 

1123 "reliability<%f", 

1124 np.sum(reliability_flag), reliabilityThreshold) 

1125 flagged |= reliability_flag 

1126 if min(lowReliabilitySnrThreshold, lowSnrReliabilityThreshold) > 0: 

1127 # Only run the combined test if both thresholds are greater than zero 

1128 lowSnrReliability_flag = ((signalToNoise < lowReliabilitySnrThreshold) 

1129 & (reliability < lowSnrReliabilityThreshold)) 

1130 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to %sFlux" 

1131 " signal to noise < %f combined with reliability< %f", 

1132 np.sum(lowSnrReliability_flag), 

1133 fluxField, 

1134 lowReliabilitySnrThreshold, 

1135 lowSnrReliabilityThreshold) 

1136 flagged |= lowSnrReliability_flag 

1137 

1138 if np.count_nonzero(~flagged) > 0: 1138 ↛ 1141line 1138 didn't jump to line 1141 because the condition on line 1138 was always true

1139 goodSources = sources[~flagged].copy(deep=True) 

1140 else: 

1141 goodSources = make_empty_catalog(self.schema, tableName="DiaSource") 

1142 if np.count_nonzero(flagged) > 0: 

1143 badSources = sources[flagged].copy(deep=True) 

1144 else: 

1145 badSources = make_empty_catalog(self.schema, tableName="DiaSource") 

1146 return pipeBase.Struct(goodSources=goodSources, 

1147 badSources=badSources 

1148 ) 

1149 

1150 @timeMethod 

1151 def associateDiaSources(self, diaSourceTable, solarSystemObjectTable, diffIm, diaObjects): 

1152 """Associate DiaSources with DiaObjects. 

1153 

1154 Associate new DiaSources with existing DiaObjects. Create new 

1155 DiaObjects fron unassociated DiaSources. Index DiaSource catalogue 

1156 after associations. Append new DiaObjects and DiaSources to their 

1157 previous history. Test for DiaSource and DiaObject duplications. 

1158 Compute DiaObject Summary statistics from their full DiaSource 

1159 history. Test for duplication in the updated DiaObjects. 

1160 

1161 Parameters 

1162 ---------- 

1163 diaSourceTable : `pandas.DataFrame` 

1164 Newly detected DiaSources. 

1165 solarSystemObjectTable : `astropy.table.Table` 

1166 Preloaded Solar System objects expected to be visible in the image. 

1167 diffIm : `lsst.afw.image.ExposureF` 

1168 Difference image exposure in which the sources in ``diaSourceCat`` 

1169 were detected. 

1170 diaObjects : `pandas.DataFrame` 

1171 Table of DiaObjects from preloaded DiaObjects. 

1172 

1173 Returns 

1174 ------- 

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

1176 Results struct containing: 

1177 

1178 - associatedDiaSources : `pandas.DataFrame` 

1179 Associated DiaSources with DiaObjects. 

1180 - newDiaObjects : `pandas.DataFrame` 

1181 Table of new DiaObjects after association. 

1182 - associatedSsSources : `astropy.table.Table` 

1183 Table of new ssSources after association. 

1184 - unassociatedSsObjects : `astropy.table.Table` 

1185 Table of expected ssSources that were not associated with a 

1186 diaSource. 

1187 - newDiaSources : `pandas.DataFrame` 

1188 Subset of `associatedDiaSources` consisting of only the 

1189 unassociated diaSources that were added as new diaObjects. 

1190 - marginalDiaSources : `pandas.DataFrame` 

1191 Unassociated diaSources with marginal detections, which were 

1192 removed from `associatedDiaSources` and were not added as new 

1193 diaObjects. 

1194 """ 

1195 associatedCatalogs = [] 

1196 # First associate diaSources with known asteroids 

1197 if self.config.doSolarSystemAssociation and solarSystemObjectTable is not None: 

1198 ssoAssocResult = self.solarSystemAssociator.run( 

1199 Table.from_pandas(diaSourceTable), 

1200 solarSystemObjectTable, 

1201 diffIm.visitInfo, 

1202 diffIm.getBBox(), 

1203 diffIm.wcs 

1204 ) 

1205 nTotalSsObjects = ssoAssocResult.nTotalSsObjects 

1206 nAssociatedSsObjects = ssoAssocResult.nAssociatedSsObjects 

1207 associatedSsSources = ssoAssocResult.associatedSsSources 

1208 unassociatedSsObjects = ssoAssocResult.unassociatedSsObjects 

1209 if len(ssoAssocResult.ssoAssocDiaSources) > 0: 1209 ↛ 1210line 1209 didn't jump to line 1210 because the condition on line 1209 was never true

1210 associatedCatalogs.append(ssoAssocResult.ssoAssocDiaSources.to_pandas()) 

1211 if len(ssoAssocResult.unAssocDiaSources) > 0: 1211 ↛ 1214line 1211 didn't jump to line 1214 because the condition on line 1211 was never true

1212 # If the table is empty then converting time fields to pandas 

1213 # will raise an error. Pass in an empty Dataframe in that case. 

1214 unAssocSSDiaSources = ssoAssocResult.unAssocDiaSources.to_pandas() 

1215 else: 

1216 unAssocSSDiaSources = make_empty_catalog(self.schema, tableName="DiaSource") 

1217 else: 

1218 unAssocSSDiaSources = diaSourceTable 

1219 nTotalSsObjects = 0 

1220 nAssociatedSsObjects = 0 

1221 associatedSsSources = None 

1222 unassociatedSsObjects = None 

1223 # Associate new DiaSources with existing DiaObjects. 

1224 assocResults = self.associator.run(unAssocSSDiaSources, diaObjects, schema=self.schema) 

1225 createResults = self.createNewDiaObjects(assocResults.unAssocDiaSources) 

1226 

1227 if not assocResults.matchedDiaSources.empty: 

1228 associatedCatalogs.append(assocResults.matchedDiaSources) 

1229 if not createResults.diaSources.empty: 

1230 associatedCatalogs.append(createResults.diaSources) 

1231 if len(associatedCatalogs) == 0: 

1232 associatedDiaSources = make_empty_catalog(self.schema, tableName="DiaSource") 

1233 else: 

1234 # Standardize each component to the schema before concatinating, so 

1235 # that type mis-matches are fixed as early as possible. 

1236 associatedDiaSources = pd.concat( 

1237 [convertDataFrameToSdmSchema(self.schema, df, tableName="DiaSource", skipIndex=True) 

1238 for df in associatedCatalogs]) 

1239 

1240 self._add_association_meta_data(assocResults.nUpdatedDiaObjects, 

1241 assocResults.nUnassociatedDiaObjects, 

1242 createResults.nNewDiaObjects, 

1243 nTotalSsObjects, 

1244 nAssociatedSsObjects) 

1245 self.log.info("%i updated and %i unassociated diaObjects. Creating %i new diaObjects" 

1246 " and dropping %i marginal diaSources.", 

1247 assocResults.nUpdatedDiaObjects, 

1248 assocResults.nUnassociatedDiaObjects, 

1249 createResults.nNewDiaObjects, 

1250 len(createResults.marginalDiaSources), 

1251 ) 

1252 if createResults.nNewDiaObjects > self.config.maxNewDiaObjects > 0: 

1253 raise TooManyDiaObjectsError(nNewDiaObjects=createResults.nNewDiaObjects, 

1254 threshold=self.config.maxNewDiaObjects) 

1255 return pipeBase.Struct(associatedDiaSources=associatedDiaSources, 

1256 newDiaObjects=createResults.newDiaObjects, 

1257 associatedSsSources=associatedSsSources, 

1258 unassociatedSsObjects=unassociatedSsObjects, 

1259 newDiaSources=createResults.diaSources, 

1260 marginalDiaSources=createResults.marginalDiaSources 

1261 ) 

1262 

1263 def standardizeDataFrame(self, dataFrame, tableName, nullColumns=None): 

1264 """Convert a catalog to SDM schema format with NULL ID values 

1265 

1266 Parameters 

1267 ---------- 

1268 dataFrame : `pandas.DataFrame` 

1269 The catalog to standardize/convert 

1270 tableName : `str` 

1271 Schema name of table to which this dataFrame should be standardized 

1272 nullColumns : `list` of `str`, optional 

1273 List of column names to check for default values of 0 that should be 

1274 replaced with `NULL`. 

1275 

1276 

1277 Returns 

1278 ------- 

1279 standardizedAssociatedDiaSources : pandas.DataFrame 

1280 The standardized DiaSource catalog 

1281 """ 

1282 standardizedDataFrame = convertDataFrameToSdmSchema(self.schema, 

1283 dataFrame, 

1284 tableName=tableName, 

1285 skipIndex=True) 

1286 

1287 def _setNullColumn(dataframe, colName): 

1288 """Set specified columns with default values of 0 to NULL.""" 

1289 dataframe.loc[dataframe[colName] == 0, colName] = pd.NA 

1290 

1291 if nullColumns is not None: 1291 ↛ 1294line 1291 didn't jump to line 1294 because the condition on line 1291 was always true

1292 for colName in nullColumns: 

1293 _setNullColumn(standardizedDataFrame, colName) 

1294 return standardizedDataFrame 

1295 

1296 def standardizeTable(self, table, tableName, nullColumns=None): 

1297 """Convert a catalog to SDM schema format with NULL ID values 

1298 

1299 Parameters 

1300 ---------- 

1301 table : `astropy.table.Table` 

1302 The catalog to standardize/convert 

1303 tableName : `str` 

1304 Schema name of table to which this dataFrame should be standardized 

1305 nullColumns : `list` of `str`, optional 

1306 List of column names to check for default values of 0 that should be 

1307 replaced with `NULL`. 

1308 

1309 

1310 Returns 

1311 ------- 

1312 standardizedAssociatedDiaSources : pandas.DataFrame 

1313 The standardized DiaSource catalog 

1314 """ 

1315 dataFrame = table.to_pandas() 

1316 standardizedDataFrame = self.standardizeDataFrame(dataFrame, tableName, nullColumns=nullColumns) 

1317 return standardizedDataFrame 

1318 

1319 @timeMethod 

1320 def mergeAssociatedCatalogs(self, preloadedDiaSources, associatedDiaSources, diaObjects, newDiaObjects, 

1321 diffIm): 

1322 """Merge the associated diaSource and diaObjects to their previous history. 

1323 

1324 Parameters 

1325 ---------- 

1326 preloadedDiaSources : `pandas.DataFrame` 

1327 Previously detected DiaSources, loaded from the APDB. 

1328 associatedDiaSources : `pandas.DataFrame` 

1329 Associated DiaSources with DiaObjects. 

1330 diaObjects : `pandas.DataFrame` 

1331 Table of DiaObjects from preloaded DiaObjects. 

1332 newDiaObjects : `pandas.DataFrame` 

1333 Table of new DiaObjects after association. 

1334 

1335 Raises 

1336 ------ 

1337 RuntimeError 

1338 Raised if duplicate DiaObjects or duplicate DiaSources are found. 

1339 

1340 Returns 

1341 ------- 

1342 mergedDiaSourceHistory : `pandas.DataFrame` 

1343 The combined catalog, with all of the rows from preloadedDiaSources 

1344 catalog ordered before the rows of associatedDiaSources catalog. 

1345 mergedDiaObjects : `pandas.DataFrame` 

1346 Table of new DiaObjects merged with their history. 

1347 updatedDiaObjectIds : `numpy.Array` 

1348 Object Id's from associated diaSources. 

1349 """ 

1350 # Index the DiaSource catalog for this visit after all associations 

1351 # have been made. 

1352 updatedDiaObjectIds = associatedDiaSources["diaObjectId"][ 

1353 associatedDiaSources["diaObjectId"] != 0].to_numpy() 

1354 associatedDiaSources.set_index(["diaObjectId", 

1355 "band", 

1356 "diaSourceId"], 

1357 drop=False, 

1358 inplace=True) 

1359 

1360 # Append new DiaObjects and DiaSources to their previous history. 

1361 if diaObjects.empty: 1361 ↛ 1362line 1361 didn't jump to line 1362 because the condition on line 1361 was never true

1362 mergedDiaObjects = newDiaObjects.set_index("diaObjectId", drop=False) 

1363 elif not newDiaObjects.empty: 1363 ↛ 1364line 1363 didn't jump to line 1364 because the condition on line 1363 was never true

1364 mergedDiaObjects = pd.concat( 

1365 [diaObjects, 

1366 newDiaObjects.set_index("diaObjectId", drop=False)], 

1367 sort=True) 

1368 else: 

1369 mergedDiaObjects = diaObjects 

1370 

1371 # Exclude any objects that are off the image after association. 

1372 mergedDiaObjects, updatedDiaObjectIds = self.purgeDiaObjects(diffIm.getBBox(), diffIm.getWcs(), 

1373 mergedDiaObjects, 

1374 diaObjectIds=updatedDiaObjectIds, 

1375 buffer=-1) 

1376 if self.testDataFrameIndex(mergedDiaObjects): 1376 ↛ 1377line 1376 didn't jump to line 1377 because the condition on line 1376 was never true

1377 raise RuntimeError( 

1378 "Duplicate DiaObjects created after association. This is " 

1379 "likely due to re-running data with an already populated " 

1380 "Apdb. If this was not the case then there was an unexpected " 

1381 "failure in Association while matching and creating new " 

1382 "DiaObjects and should be reported. Exiting.") 

1383 

1384 mergedDiaSourceHistory = self.mergeCatalogs(preloadedDiaSources, associatedDiaSources, 

1385 tableName="DiaSource") 

1386 

1387 # Test for DiaSource duplication first. If duplicates are found, 

1388 # this likely means this is duplicate data being processed and sent 

1389 # to the Apdb. 

1390 if self.testDataFrameIndex(mergedDiaSourceHistory): 1390 ↛ 1391line 1390 didn't jump to line 1391 because the condition on line 1390 was never true

1391 raise RuntimeError( 

1392 "Duplicate DiaSources found after association and merging " 

1393 "with history. This is likely due to re-running data with an " 

1394 "already populated Apdb. If this was not the case then there " 

1395 "was an unexpected failure in Association while matching " 

1396 "sources to objects, and should be reported. Exiting.") 

1397 # Finally, update the diaObject table with the number of associated diaSources 

1398 mergedUpdatedDiaObjects = self.updateObjectTable(mergedDiaObjects, mergedDiaSourceHistory) 

1399 return (mergedDiaSourceHistory, mergedUpdatedDiaObjects, updatedDiaObjectIds) 

1400 

1401 @timeMethod 

1402 def runForcedMeasurement(self, diaObjects, updatedDiaObjects, exposure, diffIm, idGenerator): 

1403 """Forced Source Measurement 

1404 

1405 Forced photometry on the difference and calibrated exposures using the 

1406 new and updated DiaObject locations. 

1407 

1408 Parameters 

1409 ---------- 

1410 diaObjects : `pandas.DataFrame` 

1411 Catalog of DiaObjects. 

1412 updatedDiaObjects : `pandas.DataFrame` 

1413 Catalog of updated DiaObjects. 

1414 exposure : `lsst.afw.image.ExposureF` 

1415 Calibrated exposure differenced with a template to create 

1416 ``diffIm``. 

1417 diffIm : `lsst.afw.image.ExposureF` 

1418 Difference image exposure in which the sources in ``diaSourceCat`` 

1419 were detected. 

1420 idGenerator : `lsst.meas.base.IdGenerator` 

1421 Object that generates source IDs and random number generator seeds. 

1422 

1423 Returns 

1424 ------- 

1425 diaForcedSources : `pandas.DataFrame` 

1426 Catalog of calibrated forced photometered fluxes on both the 

1427 difference and direct images at DiaObject locations. 

1428 """ 

1429 # Force photometer on the Difference and Calibrated exposures using 

1430 # the new and updated DiaObject locations. 

1431 diaForcedSources = self.diaForcedSource.run( 

1432 diaObjects, 

1433 updatedDiaObjects.loc[:, "diaObjectId"].to_numpy(), 

1434 exposure, 

1435 diffIm, 

1436 idGenerator=idGenerator) 

1437 self.log.info(f"Updating {len(diaForcedSources)} diaForcedSources in the APDB") 

1438 diaForcedSources = convertDataFrameToSdmSchema(self.schema, diaForcedSources, 

1439 tableName="DiaForcedSource", skipIndex=True) 

1440 return diaForcedSources 

1441 

1442 @timeMethod 

1443 def loadRefreshedDiaObjects(self, region, preloadedDiaObjects=None): 

1444 """Load DiaObjects from the Apdb based on their HTM location. 

1445 

1446 Parameters 

1447 ---------- 

1448 region : `sphgeom.Region` 

1449 Region containing the current exposure to load diaObjects from the 

1450 APDB. 

1451 preloadedDiaObjects : `pandas.DataFrame`, optional 

1452 Previously detected DiaObjects, loaded from the APDB. `None` if 

1453 the preloaded catalogs are not available. 

1454 

1455 Returns 

1456 ------- 

1457 diaObjects : `pandas.DataFrame` 

1458 DiaObjects loaded from the Apdb that are within the area defined 

1459 by ``region``. 

1460 """ 

1461 refreshedDiaObjects = loadDiaObjectsFromApdb(self.apdb, self._paddedRegion(region), self.schema, 

1462 self.log) 

1463 if preloadedDiaObjects is None: 

1464 return refreshedDiaObjects 

1465 

1466 refreshedIsInPreloaded = refreshedDiaObjects.index.isin(preloadedDiaObjects.index) 

1467 preloadedIsInRefreshed = preloadedDiaObjects.index.isin(refreshedDiaObjects.index) 

1468 nUniqueRefreshed = (~refreshedIsInPreloaded).sum() 

1469 nUniquePreloaded = (~preloadedIsInRefreshed).sum() 

1470 if nUniqueRefreshed > 0: 1470 ↛ 1471line 1470 didn't jump to line 1471 because the condition on line 1470 was never true

1471 self.log.info("Reloading the diaObject table during association yielded " 

1472 "an additional %d objects over the %d preloaded diaObjects.", 

1473 nUniqueRefreshed, len(preloadedDiaObjects)) 

1474 if nUniquePreloaded == 0: 1474 ↛ 1475line 1474 didn't jump to line 1475 because the condition on line 1474 was never true

1475 return refreshedDiaObjects 

1476 # ap_verify CI datasets ship a preloaded diaObject catalog 

1477 # alongside an empty APDB, so some preloaded objects may not 

1478 # appear in the refresh. Combine the two, giving precedence to 

1479 # refreshed entries on overlap so that updated column values are 

1480 # not silently discarded when the refresh adds no new ids. 

1481 return pd.concat([refreshedDiaObjects, preloadedDiaObjects.loc[~preloadedIsInRefreshed]]) 

1482 

1483 @timeMethod 

1484 def loadRefreshedDiaSources(self, region, diaObjects, visitTime): 

1485 """Reload the DiaSource history from the Apdb. 

1486 

1487 Used only in non-time-critical environments where it is more important 

1488 to load the most recent DiaSources than to save time by preloading 

1489 catalogs. 

1490 

1491 Parameters 

1492 ---------- 

1493 region : `sphgeom.Region` 

1494 Region containing the current exposure to load the history from 

1495 the APDB. 

1496 diaObjects : `pandas.DataFrame` 

1497 DiaObjects to load the history for, indexed by ``diaObjectId``. 

1498 visitTime : `astropy.time.Time` 

1499 Time of the current visit. 

1500 

1501 Returns 

1502 ------- 

1503 diaSources : `pandas.DataFrame` 

1504 DiaSource history loaded from the Apdb, indexed by 

1505 ``diaObjectId``, ``band``, and ``diaSourceId``. 

1506 """ 

1507 return loadDiaSourcesFromApdb(self.apdb, self._paddedRegion(region), 

1508 diaObjects.loc[:, "diaObjectId"], visitTime, self.schema, self.log) 

1509 

1510 @timeMethod 

1511 def loadRefreshedDiaForcedSources(self, region, diaObjects, visitTime): 

1512 """Reload the DiaForcedSource history from the Apdb. 

1513 

1514 Used only in non-time-critical environments where it is more important 

1515 to load the most recent DiaForcedSources than to save time by preloading 

1516 catalogs. 

1517 

1518 Parameters 

1519 ---------- 

1520 region : `sphgeom.Region` 

1521 Region containing the current exposure to load the history from 

1522 the APDB. 

1523 diaObjects : `pandas.DataFrame` 

1524 DiaObjects to load the history for, indexed by ``diaObjectId``. 

1525 visitTime : `astropy.time.Time` 

1526 Time of the current visit. 

1527 

1528 Returns 

1529 ------- 

1530 diaForcedSources : `pandas.DataFrame` 

1531 DiaForcedSource history loaded from the Apdb, indexed by 

1532 ``diaObjectId`` and ``diaForcedSourceId``. 

1533 """ 

1534 return loadDiaForcedSourcesFromApdb(self.apdb, self._paddedRegion(region), 

1535 diaObjects.loc[:, "diaObjectId"], visitTime, self.schema, 

1536 self.log) 

1537 

1538 def _paddedRegion(self, region): 

1539 """Pad a region by the configured margin. 

1540 

1541 Parameters 

1542 ---------- 

1543 region : `sphgeom.Region` 

1544 Region containing the current exposure. 

1545 

1546 Returns 

1547 ------- 

1548 region : `sphgeom.Region` 

1549 The region, expanded by ``config.angleMargin``. 

1550 """ 

1551 return paddedRegion(region, lsst.sphgeom.Angle.fromDegrees(self.config.angleMargin/3600.)) 

1552 

1553 @timeMethod 

1554 def writeToApdb(self, updatedDiaObjects, associatedDiaSources, diaForcedSources): 

1555 """Write to the Alert Production Database (Apdb). 

1556 

1557 Store DiaSources, updated DiaObjects, and DiaForcedSources in the 

1558 Alert Production Database (Apdb). 

1559 

1560 Parameters 

1561 ---------- 

1562 updatedDiaObjects : `pandas.DataFrame` 

1563 Catalog of updated DiaObjects. 

1564 associatedDiaSources : `pandas.DataFrame` 

1565 Associated DiaSources with DiaObjects. 

1566 diaForcedSources : `pandas.DataFrame` 

1567 Catalog of calibrated forced photometered fluxes on both the 

1568 difference and direct images at DiaObject locations. 

1569 

1570 Returns 

1571 ------- 

1572 validityStart : `lsst.daf.base.DateTime` 

1573 Time at which the APDB was updated. 

1574 """ 

1575 # Store DiaSources, updated DiaObjects, and DiaForcedSources in the 

1576 # Apdb. 

1577 # Drop empty columns that are nullable in the APDB. 

1578 diaObjectStore = dropEmptyColumns(self.schema, updatedDiaObjects, tableName="DiaObject") 

1579 diaSourceStore = dropEmptyColumns(self.schema, associatedDiaSources, tableName="DiaSource") 

1580 diaForcedSourceStore = dropEmptyColumns(self.schema, diaForcedSources, tableName="DiaForcedSource") 

1581 

1582 validityStart = DateTime.now() 

1583 self.apdb.store( 

1584 validityStart.toAstropy(), 

1585 diaObjectStore, 

1586 diaSourceStore, 

1587 diaForcedSourceStore) 

1588 self.log.info("APDB updated.") 

1589 

1590 return validityStart 

1591 

1592 def testDataFrameIndex(self, df): 

1593 """Test the sorted DataFrame index for duplicates. 

1594 

1595 Wrapped as a separate function to allow for mocking of the this task 

1596 in unittesting. Default of a mock return for this test is True. 

1597 

1598 Parameters 

1599 ---------- 

1600 df : `pandas.DataFrame` 

1601 DataFrame to text. 

1602 

1603 Returns 

1604 ------- 

1605 `bool` 

1606 True if DataFrame contains duplicate rows. 

1607 """ 

1608 return df.index.has_duplicates 

1609 

1610 def _add_association_meta_data(self, 

1611 nUpdatedDiaObjects, 

1612 nUnassociatedDiaObjects, 

1613 nNewDiaObjects, 

1614 nTotalSsObjects, 

1615 nAssociatedSsObjects): 

1616 """Store summaries of the association step in the task metadata. 

1617 

1618 Parameters 

1619 ---------- 

1620 nUpdatedDiaObjects : `int` 

1621 Number of previous DiaObjects associated and updated in this 

1622 ccdVisit. 

1623 nUnassociatedDiaObjects : `int` 

1624 Number of previous DiaObjects that were not associated or updated 

1625 in this ccdVisit. 

1626 nNewDiaObjects : `int` 

1627 Number of newly created DiaObjects for this ccdVisit. 

1628 nTotalSsObjects : `int` 

1629 Number of SolarSystemObjects within the observable detector 

1630 area. 

1631 nAssociatedSsObjects : `int` 

1632 Number of successfully associated SolarSystemObjects. 

1633 """ 

1634 self.metadata['numUpdatedDiaObjects'] = nUpdatedDiaObjects 

1635 self.metadata['numUnassociatedDiaObjects'] = nUnassociatedDiaObjects 

1636 self.metadata['numNewDiaObjects'] = nNewDiaObjects 

1637 self.metadata['numTotalSolarSystemObjects'] = nTotalSsObjects 

1638 self.metadata['numAssociatedSsObjects'] = nAssociatedSsObjects 

1639 

1640 def purgeDiaObjects(self, bbox, wcs, diaObjCat, diaObjectIds=None, buffer=0): 

1641 """Drop diaObjects that are outside the exposure bounding box. 

1642 

1643 Parameters 

1644 ---------- 

1645 bbox : `lsst.geom.Box2I` 

1646 Bounding box of the exposure. 

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

1648 Coordinate system definition (wcs) for the exposure. 

1649 diaObjCat : `pandas.DataFrame` 

1650 DiaObjects loaded from the Apdb. 

1651 buffer : `int`, optional 

1652 Width, in pixels, to pad the exposure bounding box. 

1653 

1654 Returns 

1655 ------- 

1656 diaObjCat : `pandas.DataFrame` 

1657 DiaObjects loaded from the Apdb, restricted to the exposure 

1658 bounding box. 

1659 """ 

1660 # Copy the bbox so the caller's box is not mutated by grow(). 

1661 bbox = type(bbox)(bbox) 

1662 try: 

1663 bbox.grow(buffer) 

1664 raVals = diaObjCat.ra.to_numpy() 

1665 decVals = diaObjCat.dec.to_numpy() 

1666 xVals, yVals = wcs.skyToPixelArray(raVals, decVals, degrees=True) 

1667 selector = bbox.contains(xVals, yVals) 

1668 nPurged = np.sum(~selector) 

1669 if nPurged > 0: 

1670 if diaObjectIds is not None: 1670 ↛ 1678line 1670 didn't jump to line 1678 because the condition on line 1670 was always true

1671 # We also need to drop any of the associated IDs if this runs after association 

1672 purgedIds = diaObjCat[~selector].diaObjectId 

1673 diaObjectIds = diaObjectIds[~np.isin(diaObjectIds, purgedIds)] 

1674 self.log.info("Dropped %i diaObjects that were outside the bbox " 

1675 "after association, leaving %i in the catalog", 

1676 nPurged, len(diaObjCat) - nPurged) 

1677 else: 

1678 self.log.info("Dropped %i diaObjects that were outside the padded bbox " 

1679 "before association, leaving %i in the catalog", 

1680 nPurged, len(diaObjCat) - nPurged) 

1681 diaObjCat = diaObjCat[selector].copy() 

1682 except (AttributeError, KeyError, ValueError) as e: 

1683 self.log.warning("Error attempting to check diaObject history: %s", e, exc_info=e) 

1684 return diaObjCat, diaObjectIds 

1685 

1686 def mergeCatalogs(self, originalCatalog, newCatalog, tableName): 

1687 """Combine two catalogs, ensuring that the new catalog conforms to the schema. 

1688 

1689 Parameters 

1690 ---------- 

1691 originalCatalog : `pandas.DataFrame` 

1692 The original catalog to be added to. 

1693 newCatalog : `pandas.DataFrame` 

1694 The new catalog to append to `originalCatalog` 

1695 tableName : `str` 

1696 Name of the table in the schema to use. 

1697 

1698 Returns 

1699 ------- 

1700 mergedCatalog : `pandas.DataFrame` 

1701 The combined catalog, with all of the rows from ``originalCatalog`` 

1702 ordered before the rows of ``newCatalog`` 

1703 """ 

1704 if len(newCatalog) > 0: 

1705 catalog = convertDataFrameToSdmSchema(self.schema, newCatalog, 

1706 tableName=tableName, skipIndex=True) 

1707 

1708 mergedCatalog = pd.concat([originalCatalog, catalog], sort=True) 

1709 else: 

1710 mergedCatalog = pd.concat([originalCatalog], sort=True) 

1711 return mergedCatalog.loc[:, originalCatalog.columns] 

1712 

1713 def updateObjectTable(self, diaObjects, diaSources): 

1714 """Update the diaObject table with the new diaSource records. 

1715 

1716 Parameters 

1717 ---------- 

1718 diaObjects : `pandas.DataFrame` 

1719 Table of new DiaObjects merged with their history. 

1720 diaSources : `pandas.DataFrame` 

1721 The combined preloaded and associated diaSource catalog. 

1722 

1723 Returns 

1724 ------- 

1725 updatedDiaObjects : `pandas.DataFrame` 

1726 Table of DiaObjects updated with the number of associated DiaSources 

1727 """ 

1728 # Group on the index level explicitly since diaObjectId is both an 

1729 # index and a (duplicated) column 

1730 nDiaSources = diaSources.groupby(level="diaObjectId").size().rename("nDiaSources") 

1731 diaObjects = diaObjects.drop(columns="nDiaSources", errors="ignore") 

1732 updatedDiaObjects = diaObjects.join(nDiaSources, how="left") 

1733 return updatedDiaObjects 

1734 

1735 @staticmethod 

1736 def checkTableIndex(dataFrame, index): 

1737 if dataFrame.index.name is None: 

1738 # The expected index may or may not be set, depending on whether 

1739 # the table was written originally as a DataFrame or something else 

1740 # Parquet-friendly. 

1741 dataFrame.set_index(index, drop=False, inplace=True)