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

491 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-24 09:43 +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_nDiaSources", 

520 "ap_meanFlux", 

521 "ap_sigmaFlux", 

522 "ap_minMaxFlux", 

523 "ap_maxSlopeFlux", 

524 "ap_meanErrFlux", 

525 "ap_meanTotFlux"] 

526 

527 

528class DiaPipelineTask(pipeBase.PipelineTask): 

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

530 (DIA) Objects and Sources. 

531 """ 

532 ConfigClass = DiaPipelineConfig 

533 _DefaultName = "diaPipe" 

534 

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

536 super().__init__(**kwargs) 

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

538 self.schema = readSchemaFromApdb(self.apdb) 

539 self.makeSubtask("associator") 

540 self.makeSubtask("diaCalculation") 

541 if self.config.doRunForcedMeasurement: 

542 self.makeSubtask("diaForcedSource") 

543 if self.config.doPackageAlerts: 

544 self.makeSubtask("alertPackager") 

545 if self.config.doSolarSystemAssociation: 

546 self.makeSubtask("solarSystemAssociator") 

547 if self.config.filterUnAssociatedSources: 

548 columns = [self.config.newObjectFluxField, 

549 self.config.newObjectFluxField + "Err", 

550 "reliability"] 

551 columns += self.config.newObjectBadFlags 

552 

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

554 if missing: 

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

556 

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

558 inputs = butlerQC.get(inputRefs) 

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

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

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

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

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

564 inputs["legacySolarSystemTable"] = None 

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

566 inputs["solarSystemObjectTable"] = None 

567 

568 associationResults = pipeBase.Struct( 

569 apdbMarker=None, 

570 associatedDiaSources=None, 

571 diaForcedSources=None, 

572 diaObjects=None, 

573 associatedSsSources=None, 

574 unassociatedSsObjects=None, 

575 newDiaSources=None, 

576 marginalDiaSources=None, 

577 ) 

578 try: 

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

580 except pipeBase.AlgorithmError as e: 

581 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

582 e, 

583 self, 

584 log=self.log 

585 ) 

586 butlerQC.put(associationResults, outputRefs) 

587 raise error from e 

588 butlerQC.put(associationResults, outputRefs) 

589 

590 @timeMethod 

591 def run(self, 

592 diaSourceTable, 

593 legacySolarSystemTable, 

594 diffIm, 

595 exposure, 

596 template, 

597 preloadedDiaObjects=None, 

598 preloadedDiaSources=None, 

599 preloadedDiaForcedSources=None, 

600 band=None, 

601 idGenerator=None, 

602 solarSystemObjectTable=None, 

603 associationResults=None): 

604 """Process DiaSources and DiaObjects. 

605 

606 Load previous DiaObjects and their DiaSource history. Calibrate the 

607 values in the diaSourceCat. Associate new DiaSources with previous 

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

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

610 

611 Parameters 

612 ---------- 

613 diaSourceTable : `pandas.DataFrame` 

614 Newly detected DiaSources. 

615 legacySolarSystemTable : `pandas.DataFrame` 

616 Not used 

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

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

619 were detected. 

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

621 Calibrated exposure differenced with a template to create 

622 ``diffIm``. 

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

624 Template exposure used to create diffIm. 

625 preloadedDiaObjects : `pandas.DataFrame`, optional 

626 Previously detected DiaObjects, loaded from the APDB. 

627 `None` if ``doReloadAllApdbCatalogs`` is set. 

628 preloadedDiaSources : `pandas.DataFrame`, optional 

629 Previously detected DiaSources, loaded from the APDB. 

630 `None` if ``doReloadAllApdbCatalogs`` is set. 

631 preloadedDiaForcedSources : `pandas.DataFrame`, optional 

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

633 `None` if ``doReloadAllApdbCatalogs`` is set. 

634 band : `str` 

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

636 despite the `None` default. 

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

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

639 Required, despite the `None` default. 

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

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

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

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

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

645 this is also returned. 

646 

647 Returns 

648 ------- 

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

650 Results struct with components. 

651 

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

653 that this ccdVisit has completed successfully. 

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

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

656 DiaSources. (`pandas.DataFrame`) 

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

658 forced DiaSources. (`pandas.DataFrame`) 

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

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

661 (`pandas.DataFrame`) 

662 

663 Raises 

664 ------ 

665 RuntimeError 

666 Raised if duplicate DiaObjects or duplicate DiaSources are found. 

667 ValueError 

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

669 """ 

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

671 # precede them, can be omitted. 

672 if band is None: 

673 raise ValueError("band is required.") 

674 if idGenerator is None: 

675 raise ValueError("idGenerator is required.") 

676 

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

678 associationResults = pipeBase.Struct() 

679 self._validateExposure(exposure, "Science") 

680 self._validateExposure(diffIm, "Difference") 

681 self._validateExposure(template, "Template") 

682 

683 # Accept either legacySolarSystemTable or optional solarSystemObjectTable. 

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

685 solarSystemObjectTable = Table.from_pandas(legacySolarSystemTable) 

686 region = getRegion(exposure) 

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

688 try: 

689 preloadedDiaObjects = self.loadRefreshedDiaObjects(region, preloadedDiaObjects) 

690 except Exception as e: 

691 if self.config.doReloadAllApdbCatalogs: 

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

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

694 # no preloaded diaObjects to fall back on. 

695 raise 

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

697 "the latest diaObjects from the APDB. Processing " 

698 "will continue with only the diaObjects from " 

699 "preload.", exc_info=e) 

700 finally: 

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

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

703 ) 

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

705 self.metadata["loadDiaObjectsDuration"]) 

706 

707 else: 

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

709 

710 if self.config.doReloadAllApdbCatalogs: 

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

712 # LoadDiaCatalogsTask to keep the metrics upload consistent. 

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

714 try: 

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

716 finally: 

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

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

719 ) 

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

721 self.metadata["loadDiaSourcesDuration"]) 

722 try: 

723 preloadedDiaForcedSources = self.loadRefreshedDiaForcedSources( 

724 region, preloadedDiaObjects, visitTime) 

725 finally: 

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

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

728 ) 

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

730 self.metadata["loadDiaForcedSourcesDuration"]) 

731 else: 

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

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

734 

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

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

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

738 

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

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

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

742 buffer=self.config.imagePixelMargin) 

743 else: 

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

745 diaObjects = preloadedDiaObjects 

746 

747 # Associate DiaSources with DiaObjects 

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

749 

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

751 standardizedAssociatedDiaSources = self.standardizeDataFrame( 

752 assocResults.associatedDiaSources, 

753 "DiaSource", 

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

755 ) 

756 

757 # Merge associated diaSources 

758 mergedDiaSourceHistory, mergedDiaObjects, updatedDiaObjectIds = self.mergeAssociatedCatalogs( 

759 preloadedDiaSources, 

760 assocResults.associatedDiaSources, 

761 diaObjects, 

762 assocResults.newDiaObjects, 

763 diffIm 

764 ) 

765 

766 # Compute DiaObject Summary statistics from their full DiaSource 

767 # history. 

768 diaCalResult = self.diaCalculation.run( 

769 mergedDiaObjects, 

770 mergedDiaSourceHistory, 

771 updatedDiaObjectIds, 

772 self.config.validBands) 

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

774 tableName="DiaObject", skipIndex=True) 

775 

776 # Test for duplication in the updated DiaObjects. 

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

778 raise RuntimeError( 

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

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

781 "reported. Exiting.") 

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

783 raise RuntimeError( 

784 "Duplicate DiaObjects (updated) created after " 

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

786 "reported. Exiting.") 

787 

788 # Forced source measurement 

789 if self.config.doRunForcedMeasurement: 

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

791 diaForcedSources = self.runForcedMeasurement( 

792 diaObjectsForced, updatedDiaObjects, exposure, diffIm, idGenerator 

793 ) 

794 forcedSourceHistoryThreshold = self.diaForcedSource.config.historyThreshold 

795 else: 

796 # alertPackager needs correct columns 

797 diaForcedSources = makeEmptyForcedSourceTable(self.schema) 

798 forcedSourceHistoryThreshold = 0 

799 

800 # Write results to Alert Production Database (APDB) 

801 try: 

802 validityStart = self.writeToApdb(updatedDiaObjects, standardizedAssociatedDiaSources, 

803 diaForcedSources) 

804 finally: 

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

806 clock="Utc") 

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

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

809 

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

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

812 associationResults.apdbMarker = pexConfig.Config() 

813 associationResults.associatedDiaSources = assocResults.associatedDiaSources 

814 associationResults.diaForcedSources = diaForcedSources 

815 associationResults.diaObjects = diaCalResult.diaObjectCat 

816 associationResults.unassociatedSsObjects = assocResults.unassociatedSsObjects 

817 associationResults.newDiaSources = assocResults.newDiaSources 

818 associationResults.marginalDiaSources = assocResults.marginalDiaSources 

819 

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

821 try: 

822 associatedSsSources = assocResults.associatedSsSources 

823 associatedSsSourcesPlusMpcorb = None 

824 if associatedSsSources is not None: 

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

826 associatedSsSourceMpcorb = associatedSsSources[mpcorbColumns].copy() 

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

828 associatedSsSourcesPlusMpcorb = associatedSsSources.copy() 

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

830 associatedSsSourcesPlusMpcorb[mpcorbColumn] = associatedSsSourceMpcorb[mpcorbColumn] 

831 associationResults.associatedSsSources = associatedSsSources 

832 

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

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

835 scale=DateTime.TAI) 

836 

837 # Package alerts 

838 if self.config.doPackageAlerts: 

839 # Append new forced sources to the full history 

840 diaForcedSourcesFull = self.mergeCatalogs(preloadedDiaForcedSources, diaForcedSources, 

841 tableName="DiaForcedSource") 

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

843 self.log.warning( 

844 "Duplicate DiaForcedSources created after merge with " 

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

846 "problems. Dropping duplicates.") 

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

848 diaForcedSourcesFull = diaForcedSourcesFull[ 

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

850 

851 self.alertPackager.run(assocResults.associatedDiaSources, 

852 updatedDiaObjects, 

853 preloadedDiaSources, 

854 diaForcedSourcesFull, 

855 diffIm, 

856 exposure, 

857 template, 

858 ssSrc=associatedSsSourcesPlusMpcorb, 

859 doRunForcedMeasurement=self.config.doRunForcedMeasurement, 

860 forcedSourceHistoryThreshold=forcedSourceHistoryThreshold, 

861 ) 

862 except Exception as e: 

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

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

865 

866 return associationResults 

867 

868 def _validateExposure(self, exposure, label): 

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

870 the APDB. 

871 

872 Parameters 

873 ---------- 

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

875 Calibrated science image that was subtracted. Corresponds to the 

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

877 label : `str` 

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

879 

880 Raises 

881 ------ 

882 ValueError 

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

884 """ 

885 errors = [] 

886 

887 photo_calib = exposure.getPhotoCalib() 

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

889 errors.append( 

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

891 "in alert packaging will fail." 

892 ) 

893 else: 

894 mean = photo_calib.getCalibrationMean() 

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

896 errors.append( 

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

898 "must be a finite, positive value." 

899 ) 

900 

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

902 errors.append( 

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

904 "DIA Object association will fail." 

905 ) 

906 

907 # Skip visitInfo checks for the template 

908 if label != "Template": 

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

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

911 errors.append( 

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

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

914 ) 

915 else: 

916 obs_date = visit_info.getDate() 

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

918 errors.append( 

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

920 ) 

921 

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

923 boresight = visit_info.getBoresightRaDec() 

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

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

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

927 errors.append( 

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

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

930 "is missing or corrupt." 

931 ) 

932 

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

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

935 errors.append( 

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

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

938 "FITS headers." 

939 ) 

940 

941 filter_label = exposure.getFilter() 

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

943 errors.append( 

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

945 ) 

946 else: 

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

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

949 errors.append( 

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

951 ) 

952 

953 bbox = exposure.getBBox() 

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

955 errors.append( 

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

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

958 "readout or ISR step may have failed." 

959 ) 

960 

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

962 errors.append( 

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

964 ) 

965 

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

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

968 raise ValueError( 

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

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

971 + detail 

972 ) 

973 

974 def _selectGoodDiaObjects(self, diaObjects, diaSources): 

975 """ 

976 Extract a subset of diaObjects with multiple associated diaSources that 

977 pass selection requirements. 

978 """ 

979 n_history = self.diaForcedSource.config.historyThreshold 

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

981 for col in required_columns: 

982 if col not in diaSources.columns: 

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

984 

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

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

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

988 if badFlags: 

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

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

991 # Exclude rows where ANY bad flag is True. 

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

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

994 mask = rel_ok & trailed_ok & ~bad_any 

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

996 

997 # Count occurrences in diaSource 

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

999 

1000 # IDs that exceed threshold 

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

1002 

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

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

1005 

1006 def createNewDiaObjects(self, unassociatedDiaSources): 

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

1008 for unassociated DiaSources. 

1009 

1010 Parameters 

1011 ---------- 

1012 unassociatedDiaSources : `pandas.DataFrame` 

1013 Set of DiaSources to create new DiaObjects from. 

1014 

1015 Returns 

1016 ------- 

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

1018 Results struct containing: 

1019 

1020 - diaSources : `pandas.DataFrame` 

1021 DiaSource catalog with updated DiaObject ids. 

1022 - newDiaObjects : `pandas.DataFrame` 

1023 Newly created DiaObjects from the unassociated DiaSources. 

1024 - nNewDiaObjects : `int` 

1025 Number of newly created diaObjects. 

1026 - marginalDiaSources : `pandas.DataFrame` 

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

1028 reliability, which are excluded from the new DiaObjects. 

1029 """ 

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

1031 if len(unassociatedDiaSources) == 0: 

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

1033 else: 

1034 if self.config.filterUnAssociatedSources: 

1035 results = self.filterSources(unassociatedDiaSources) 

1036 unassociatedDiaSources = results.goodSources 

1037 marginalDiaSources = results.badSources 

1038 unassociatedDiaSources["diaObjectId"] = unassociatedDiaSources["diaSourceId"] 

1039 newDiaObjects = convertDataFrameToSdmSchema(self.schema, unassociatedDiaSources, 

1040 tableName="DiaObject", skipIndex=True) 

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

1042 return pipeBase.Struct(diaSources=unassociatedDiaSources, 

1043 newDiaObjects=newDiaObjects, 

1044 nNewDiaObjects=len(newDiaObjects), 

1045 marginalDiaSources=marginalDiaSources) 

1046 

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

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

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

1050 

1051 Parameters 

1052 ---------- 

1053 sources : `pandas.DataFrame` 

1054 Set of DiaSources to check. 

1055 snrThreshold : `float`, optional 

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

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

1058 lowReliabilitySnrThreshold : `float`, optional 

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

1060 diaSources with ``snrThreshold`` < SNR < ``lowReliabilitySnrThreshold`` 

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

1062 reliabilityThreshold : `float`, optional 

1063 The minimum reliability score diaSource to make a new diaObject 

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

1065 lowSnrReliabilityThreshold : `float`, optional 

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

1067 diaSources with ``snrThreshold`` < SNR < ``lowReliabilitySnrThreshold`` 

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

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

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

1071 flags set. 

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

1073 

1074 Returns 

1075 ------- 

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

1077 Results struct containing: 

1078 

1079 - goodSources : `pandas.DataFrame` 

1080 Subset of the input sources that pass all checks. 

1081 - badSources : `pandas.DataFrame` 

1082 Subset of the input sources that fail any check. 

1083 """ 

1084 if snrThreshold is None: 

1085 snrThreshold = self.config.newObjectSnrThreshold 

1086 if lowReliabilitySnrThreshold is None: 

1087 lowReliabilitySnrThreshold = self.config.newObjectLowReliabilitySnrThreshold 

1088 if reliabilityThreshold is None: 

1089 reliabilityThreshold = self.config.newObjectReliabilityThreshold 

1090 if lowSnrReliabilityThreshold is None: 

1091 lowSnrReliabilityThreshold = self.config.newObjectLowSnrReliabilityThreshold 

1092 if badFlags is None: 

1093 badFlags = self.config.newObjectBadFlags 

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

1095 fluxField = self.config.newObjectFluxField 

1096 fluxErrField = fluxField + "Err" 

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

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

1099 nFlagged = np.count_nonzero(flagged) 

1100 if nFlagged > 0: 

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

1102 

1103 if snrThreshold > 0: 

1104 snr_flag = signalToNoise < snrThreshold 

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

1106 " signal to noise < %f", 

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

1108 flagged |= snr_flag 

1109 if reliabilityThreshold > 0: 

1110 reliability_flag = reliability < reliabilityThreshold 

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

1112 "reliability<%f", 

1113 np.sum(reliability_flag), reliabilityThreshold) 

1114 flagged |= reliability_flag 

1115 if min(lowReliabilitySnrThreshold, lowSnrReliabilityThreshold) > 0: 

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

1117 lowSnrReliability_flag = ((signalToNoise < lowReliabilitySnrThreshold) 

1118 & (reliability < lowSnrReliabilityThreshold)) 

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

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

1121 np.sum(lowSnrReliability_flag), 

1122 fluxField, 

1123 lowReliabilitySnrThreshold, 

1124 lowSnrReliabilityThreshold) 

1125 flagged |= lowSnrReliability_flag 

1126 

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

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

1129 else: 

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

1131 if np.count_nonzero(flagged) > 0: 

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

1133 else: 

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

1135 return pipeBase.Struct(goodSources=goodSources, 

1136 badSources=badSources 

1137 ) 

1138 

1139 @timeMethod 

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

1141 """Associate DiaSources with DiaObjects. 

1142 

1143 Associate new DiaSources with existing DiaObjects. Create new 

1144 DiaObjects fron unassociated DiaSources. Index DiaSource catalogue 

1145 after associations. Append new DiaObjects and DiaSources to their 

1146 previous history. Test for DiaSource and DiaObject duplications. 

1147 Compute DiaObject Summary statistics from their full DiaSource 

1148 history. Test for duplication in the updated DiaObjects. 

1149 

1150 Parameters 

1151 ---------- 

1152 diaSourceTable : `pandas.DataFrame` 

1153 Newly detected DiaSources. 

1154 solarSystemObjectTable : `astropy.table.Table` 

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

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

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

1158 were detected. 

1159 diaObjects : `pandas.DataFrame` 

1160 Table of DiaObjects from preloaded DiaObjects. 

1161 

1162 Returns 

1163 ------- 

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

1165 Results struct containing: 

1166 

1167 - associatedDiaSources : `pandas.DataFrame` 

1168 Associated DiaSources with DiaObjects. 

1169 - newDiaObjects : `pandas.DataFrame` 

1170 Table of new DiaObjects after association. 

1171 - associatedSsSources : `astropy.table.Table` 

1172 Table of new ssSources after association. 

1173 - unassociatedSsObjects : `astropy.table.Table` 

1174 Table of expected ssSources that were not associated with a 

1175 diaSource. 

1176 - newDiaSources : `pandas.DataFrame` 

1177 Subset of `associatedDiaSources` consisting of only the 

1178 unassociated diaSources that were added as new diaObjects. 

1179 - marginalDiaSources : `pandas.DataFrame` 

1180 Unassociated diaSources with marginal detections, which were 

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

1182 diaObjects. 

1183 """ 

1184 associatedCatalogs = [] 

1185 # First associate diaSources with known asteroids 

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

1187 ssoAssocResult = self.solarSystemAssociator.run( 

1188 Table.from_pandas(diaSourceTable), 

1189 solarSystemObjectTable, 

1190 diffIm.visitInfo, 

1191 diffIm.getBBox(), 

1192 diffIm.wcs 

1193 ) 

1194 nTotalSsObjects = ssoAssocResult.nTotalSsObjects 

1195 nAssociatedSsObjects = ssoAssocResult.nAssociatedSsObjects 

1196 associatedSsSources = ssoAssocResult.associatedSsSources 

1197 unassociatedSsObjects = ssoAssocResult.unassociatedSsObjects 

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

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

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

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

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

1203 unAssocSSDiaSources = ssoAssocResult.unAssocDiaSources.to_pandas() 

1204 else: 

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

1206 else: 

1207 unAssocSSDiaSources = diaSourceTable 

1208 nTotalSsObjects = 0 

1209 nAssociatedSsObjects = 0 

1210 associatedSsSources = None 

1211 unassociatedSsObjects = None 

1212 # Associate new DiaSources with existing DiaObjects. 

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

1214 createResults = self.createNewDiaObjects(assocResults.unAssocDiaSources) 

1215 

1216 if not assocResults.matchedDiaSources.empty: 

1217 associatedCatalogs.append(assocResults.matchedDiaSources) 

1218 if not createResults.diaSources.empty: 

1219 associatedCatalogs.append(createResults.diaSources) 

1220 if len(associatedCatalogs) == 0: 

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

1222 else: 

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

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

1225 associatedDiaSources = pd.concat( 

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

1227 for df in associatedCatalogs]) 

1228 

1229 self._add_association_meta_data(assocResults.nUpdatedDiaObjects, 

1230 assocResults.nUnassociatedDiaObjects, 

1231 createResults.nNewDiaObjects, 

1232 nTotalSsObjects, 

1233 nAssociatedSsObjects) 

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

1235 " and dropping %i marginal diaSources.", 

1236 assocResults.nUpdatedDiaObjects, 

1237 assocResults.nUnassociatedDiaObjects, 

1238 createResults.nNewDiaObjects, 

1239 len(createResults.marginalDiaSources), 

1240 ) 

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

1242 raise TooManyDiaObjectsError(nNewDiaObjects=createResults.nNewDiaObjects, 

1243 threshold=self.config.maxNewDiaObjects) 

1244 return pipeBase.Struct(associatedDiaSources=associatedDiaSources, 

1245 newDiaObjects=createResults.newDiaObjects, 

1246 associatedSsSources=associatedSsSources, 

1247 unassociatedSsObjects=unassociatedSsObjects, 

1248 newDiaSources=createResults.diaSources, 

1249 marginalDiaSources=createResults.marginalDiaSources 

1250 ) 

1251 

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

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

1254 

1255 Parameters 

1256 ---------- 

1257 dataFrame : `pandas.DataFrame` 

1258 The catalog to standardize/convert 

1259 tableName : `str` 

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

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

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

1263 replaced with `NULL`. 

1264 

1265 

1266 Returns 

1267 ------- 

1268 standardizedAssociatedDiaSources : pandas.DataFrame 

1269 The standardized DiaSource catalog 

1270 """ 

1271 standardizedDataFrame = convertDataFrameToSdmSchema(self.schema, 

1272 dataFrame, 

1273 tableName=tableName, 

1274 skipIndex=True) 

1275 

1276 def _setNullColumn(dataframe, colName): 

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

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

1279 

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

1281 for colName in nullColumns: 

1282 _setNullColumn(standardizedDataFrame, colName) 

1283 return standardizedDataFrame 

1284 

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

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

1287 

1288 Parameters 

1289 ---------- 

1290 table : `astropy.table.Table` 

1291 The catalog to standardize/convert 

1292 tableName : `str` 

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

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

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

1296 replaced with `NULL`. 

1297 

1298 

1299 Returns 

1300 ------- 

1301 standardizedAssociatedDiaSources : pandas.DataFrame 

1302 The standardized DiaSource catalog 

1303 """ 

1304 dataFrame = table.to_pandas() 

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

1306 return standardizedDataFrame 

1307 

1308 @timeMethod 

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

1310 diffIm): 

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

1312 

1313 Parameters 

1314 ---------- 

1315 preloadedDiaSources : `pandas.DataFrame` 

1316 Previously detected DiaSources, loaded from the APDB. 

1317 associatedDiaSources : `pandas.DataFrame` 

1318 Associated DiaSources with DiaObjects. 

1319 diaObjects : `pandas.DataFrame` 

1320 Table of DiaObjects from preloaded DiaObjects. 

1321 newDiaObjects : `pandas.DataFrame` 

1322 Table of new DiaObjects after association. 

1323 

1324 Raises 

1325 ------ 

1326 RuntimeError 

1327 Raised if duplicate DiaObjects or duplicate DiaSources are found. 

1328 

1329 Returns 

1330 ------- 

1331 mergedDiaSourceHistory : `pandas.DataFrame` 

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

1333 catalog ordered before the rows of associatedDiaSources catalog. 

1334 mergedDiaObjects : `pandas.DataFrame` 

1335 Table of new DiaObjects merged with their history. 

1336 updatedDiaObjectIds : `numpy.Array` 

1337 Object Id's from associated diaSources. 

1338 """ 

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

1340 # have been made. 

1341 updatedDiaObjectIds = associatedDiaSources["diaObjectId"][ 

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

1343 associatedDiaSources.set_index(["diaObjectId", 

1344 "band", 

1345 "diaSourceId"], 

1346 drop=False, 

1347 inplace=True) 

1348 

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

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

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

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

1353 mergedDiaObjects = pd.concat( 

1354 [diaObjects, 

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

1356 sort=True) 

1357 else: 

1358 mergedDiaObjects = diaObjects 

1359 

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

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

1362 mergedDiaObjects, 

1363 diaObjectIds=updatedDiaObjectIds, 

1364 buffer=-1) 

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

1366 raise RuntimeError( 

1367 "Duplicate DiaObjects created after association. This is " 

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

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

1370 "failure in Association while matching and creating new " 

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

1372 

1373 mergedDiaSourceHistory = self.mergeCatalogs(preloadedDiaSources, associatedDiaSources, 

1374 tableName="DiaSource") 

1375 

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

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

1378 # to the Apdb. 

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

1380 raise RuntimeError( 

1381 "Duplicate DiaSources found after association and merging " 

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

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

1384 "was an unexpected failure in Association while matching " 

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

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

1387 mergedUpdatedDiaObjects = self.updateObjectTable(mergedDiaObjects, mergedDiaSourceHistory) 

1388 return (mergedDiaSourceHistory, mergedUpdatedDiaObjects, updatedDiaObjectIds) 

1389 

1390 @timeMethod 

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

1392 """Forced Source Measurement 

1393 

1394 Forced photometry on the difference and calibrated exposures using the 

1395 new and updated DiaObject locations. 

1396 

1397 Parameters 

1398 ---------- 

1399 diaObjects : `pandas.DataFrame` 

1400 Catalog of DiaObjects. 

1401 updatedDiaObjects : `pandas.DataFrame` 

1402 Catalog of updated DiaObjects. 

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

1404 Calibrated exposure differenced with a template to create 

1405 ``diffIm``. 

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

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

1408 were detected. 

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

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

1411 

1412 Returns 

1413 ------- 

1414 diaForcedSources : `pandas.DataFrame` 

1415 Catalog of calibrated forced photometered fluxes on both the 

1416 difference and direct images at DiaObject locations. 

1417 """ 

1418 # Force photometer on the Difference and Calibrated exposures using 

1419 # the new and updated DiaObject locations. 

1420 diaForcedSources = self.diaForcedSource.run( 

1421 diaObjects, 

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

1423 exposure, 

1424 diffIm, 

1425 idGenerator=idGenerator) 

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

1427 diaForcedSources = convertDataFrameToSdmSchema(self.schema, diaForcedSources, 

1428 tableName="DiaForcedSource", skipIndex=True) 

1429 return diaForcedSources 

1430 

1431 @timeMethod 

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

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

1434 

1435 Parameters 

1436 ---------- 

1437 region : `sphgeom.Region` 

1438 Region containing the current exposure to load diaObjects from the 

1439 APDB. 

1440 preloadedDiaObjects : `pandas.DataFrame`, optional 

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

1442 the preloaded catalogs are not available. 

1443 

1444 Returns 

1445 ------- 

1446 diaObjects : `pandas.DataFrame` 

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

1448 by ``region``. 

1449 """ 

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

1451 self.log) 

1452 if preloadedDiaObjects is None: 

1453 return refreshedDiaObjects 

1454 

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

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

1457 nUniqueRefreshed = (~refreshedIsInPreloaded).sum() 

1458 nUniquePreloaded = (~preloadedIsInRefreshed).sum() 

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

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

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

1462 nUniqueRefreshed, len(preloadedDiaObjects)) 

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

1464 return refreshedDiaObjects 

1465 # ap_verify CI datasets ship a preloaded diaObject catalog 

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

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

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

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

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

1471 

1472 @timeMethod 

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

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

1475 

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

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

1478 catalogs. 

1479 

1480 Parameters 

1481 ---------- 

1482 region : `sphgeom.Region` 

1483 Region containing the current exposure to load the history from 

1484 the APDB. 

1485 diaObjects : `pandas.DataFrame` 

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

1487 visitTime : `astropy.time.Time` 

1488 Time of the current visit. 

1489 

1490 Returns 

1491 ------- 

1492 diaSources : `pandas.DataFrame` 

1493 DiaSource history loaded from the Apdb, indexed by 

1494 ``diaObjectId``, ``band``, and ``diaSourceId``. 

1495 """ 

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

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

1498 

1499 @timeMethod 

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

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

1502 

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

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

1505 catalogs. 

1506 

1507 Parameters 

1508 ---------- 

1509 region : `sphgeom.Region` 

1510 Region containing the current exposure to load the history from 

1511 the APDB. 

1512 diaObjects : `pandas.DataFrame` 

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

1514 visitTime : `astropy.time.Time` 

1515 Time of the current visit. 

1516 

1517 Returns 

1518 ------- 

1519 diaForcedSources : `pandas.DataFrame` 

1520 DiaForcedSource history loaded from the Apdb, indexed by 

1521 ``diaObjectId`` and ``diaForcedSourceId``. 

1522 """ 

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

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

1525 self.log) 

1526 

1527 def _paddedRegion(self, region): 

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

1529 

1530 Parameters 

1531 ---------- 

1532 region : `sphgeom.Region` 

1533 Region containing the current exposure. 

1534 

1535 Returns 

1536 ------- 

1537 region : `sphgeom.Region` 

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

1539 """ 

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

1541 

1542 @timeMethod 

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

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

1545 

1546 Store DiaSources, updated DiaObjects, and DiaForcedSources in the 

1547 Alert Production Database (Apdb). 

1548 

1549 Parameters 

1550 ---------- 

1551 updatedDiaObjects : `pandas.DataFrame` 

1552 Catalog of updated DiaObjects. 

1553 associatedDiaSources : `pandas.DataFrame` 

1554 Associated DiaSources with DiaObjects. 

1555 diaForcedSources : `pandas.DataFrame` 

1556 Catalog of calibrated forced photometered fluxes on both the 

1557 difference and direct images at DiaObject locations. 

1558 

1559 Returns 

1560 ------- 

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

1562 Time at which the APDB was updated. 

1563 """ 

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

1565 # Apdb. 

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

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

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

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

1570 

1571 validityStart = DateTime.now() 

1572 self.apdb.store( 

1573 validityStart.toAstropy(), 

1574 diaObjectStore, 

1575 diaSourceStore, 

1576 diaForcedSourceStore) 

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

1578 

1579 return validityStart 

1580 

1581 def testDataFrameIndex(self, df): 

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

1583 

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

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

1586 

1587 Parameters 

1588 ---------- 

1589 df : `pandas.DataFrame` 

1590 DataFrame to text. 

1591 

1592 Returns 

1593 ------- 

1594 `bool` 

1595 True if DataFrame contains duplicate rows. 

1596 """ 

1597 return df.index.has_duplicates 

1598 

1599 def _add_association_meta_data(self, 

1600 nUpdatedDiaObjects, 

1601 nUnassociatedDiaObjects, 

1602 nNewDiaObjects, 

1603 nTotalSsObjects, 

1604 nAssociatedSsObjects): 

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

1606 

1607 Parameters 

1608 ---------- 

1609 nUpdatedDiaObjects : `int` 

1610 Number of previous DiaObjects associated and updated in this 

1611 ccdVisit. 

1612 nUnassociatedDiaObjects : `int` 

1613 Number of previous DiaObjects that were not associated or updated 

1614 in this ccdVisit. 

1615 nNewDiaObjects : `int` 

1616 Number of newly created DiaObjects for this ccdVisit. 

1617 nTotalSsObjects : `int` 

1618 Number of SolarSystemObjects within the observable detector 

1619 area. 

1620 nAssociatedSsObjects : `int` 

1621 Number of successfully associated SolarSystemObjects. 

1622 """ 

1623 self.metadata['numUpdatedDiaObjects'] = nUpdatedDiaObjects 

1624 self.metadata['numUnassociatedDiaObjects'] = nUnassociatedDiaObjects 

1625 self.metadata['numNewDiaObjects'] = nNewDiaObjects 

1626 self.metadata['numTotalSolarSystemObjects'] = nTotalSsObjects 

1627 self.metadata['numAssociatedSsObjects'] = nAssociatedSsObjects 

1628 

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

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

1631 

1632 Parameters 

1633 ---------- 

1634 bbox : `lsst.geom.Box2I` 

1635 Bounding box of the exposure. 

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

1637 Coordinate system definition (wcs) for the exposure. 

1638 diaObjCat : `pandas.DataFrame` 

1639 DiaObjects loaded from the Apdb. 

1640 buffer : `int`, optional 

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

1642 

1643 Returns 

1644 ------- 

1645 diaObjCat : `pandas.DataFrame` 

1646 DiaObjects loaded from the Apdb, restricted to the exposure 

1647 bounding box. 

1648 """ 

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

1650 bbox = type(bbox)(bbox) 

1651 try: 

1652 bbox.grow(buffer) 

1653 raVals = diaObjCat.ra.to_numpy() 

1654 decVals = diaObjCat.dec.to_numpy() 

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

1656 selector = bbox.contains(xVals, yVals) 

1657 nPurged = np.sum(~selector) 

1658 if nPurged > 0: 

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

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

1661 purgedIds = diaObjCat[~selector].diaObjectId 

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

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

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

1665 nPurged, len(diaObjCat) - nPurged) 

1666 else: 

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

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

1669 nPurged, len(diaObjCat) - nPurged) 

1670 diaObjCat = diaObjCat[selector].copy() 

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

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

1673 return diaObjCat, diaObjectIds 

1674 

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

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

1677 

1678 Parameters 

1679 ---------- 

1680 originalCatalog : `pandas.DataFrame` 

1681 The original catalog to be added to. 

1682 newCatalog : `pandas.DataFrame` 

1683 The new catalog to append to `originalCatalog` 

1684 tableName : `str` 

1685 Name of the table in the schema to use. 

1686 

1687 Returns 

1688 ------- 

1689 mergedCatalog : `pandas.DataFrame` 

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

1691 ordered before the rows of ``newCatalog`` 

1692 """ 

1693 if len(newCatalog) > 0: 

1694 catalog = convertDataFrameToSdmSchema(self.schema, newCatalog, 

1695 tableName=tableName, skipIndex=True) 

1696 

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

1698 else: 

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

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

1701 

1702 def updateObjectTable(self, diaObjects, diaSources): 

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

1704 

1705 Parameters 

1706 ---------- 

1707 diaObjects : `pandas.DataFrame` 

1708 Table of new DiaObjects merged with their history. 

1709 diaSources : `pandas.DataFrame` 

1710 The combined preloaded and associated diaSource catalog. 

1711 

1712 Returns 

1713 ------- 

1714 updatedDiaObjects : `pandas.DataFrame` 

1715 Table of DiaObjects updated with the number of associated DiaSources 

1716 """ 

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

1718 # index and a (duplicated) column 

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

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

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

1722 return updatedDiaObjects 

1723 

1724 @staticmethod 

1725 def checkTableIndex(dataFrame, index): 

1726 if dataFrame.index.name is None: 

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

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

1729 # Parquet-friendly. 

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