Coverage for python / lsst / ap / association / diaPipe.py: 19%
416 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-26 01:07 -0700
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-26 01:07 -0700
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#
23"""PipelineTask for associating DiaSources with previous DiaObjects.
25Additionally performs forced photometry on the calibrated and difference
26images at the updated locations of DiaObjects.
27"""
29__all__ = ("DiaPipelineConfig",
30 "DiaPipelineTask",
31 "DiaPipelineConnections")
34import lsst.dax.apdb as daxApdb
35import lsst.pex.config as pexConfig
36import lsst.pipe.base as pipeBase
37import lsst.pipe.base.connectionTypes as connTypes
38import lsst.sphgeom
40from astropy.table import Table
41import numpy as np
42import pandas as pd
43from lsst.ap.association import (
44 AssociationTask,
45 DiaForcedSourceTask,
46 PackageAlertsTask)
48from lsst.ap.association.utils import makeEmptyForcedSourceTable, getRegion, paddedRegion, readSchemaFromApdb
49from lsst.daf.base import DateTime
50from lsst.meas.base import DetectorVisitIdGeneratorConfig, \
51 DiaObjectCalculationTask
52from lsst.pipe.tasks.schemaUtils import convertDataFrameToSdmSchema, checkSdmSchemaColumns, \
53 dropEmptyColumns, make_empty_catalog
54from lsst.pipe.tasks.ssoAssociation import SolarSystemAssociationTask
55from lsst.utils.timer import timeMethod, duration_from_timeMethod
58class TooManyDiaObjectsError(pipeBase.AlgorithmError):
59 """Raised if there are an unusually large number of unassociated DiaSources.
60 This is usually indicative of an image subtraction error, and needs to be
61 caught before updating the APDB with a large number of spurious entries.
62 """
63 def __init__(self, *, nNewDiaObjects, threshold):
64 msg = ("Aborting processing before writing to the APDB."
65 f" {nNewDiaObjects} new DiaObjects would be created, which exceeds the"
66 f" configured maximum of {threshold}")
67 super().__init__(msg)
68 self.nNewDiaObjects = nNewDiaObjects
69 self.threshold = threshold
71 @property
72 def metadata(self):
73 return {"nNewDiaObjects": self.nNewDiaObjects,
74 "threshold": self.threshold
75 }
78class PostApdbUpdateError(pipeBase.AlgorithmError):
79 """Raised for any error that occurs after the APDB has been updated.
80 This allows partial outputs to be written, and signals that processing
81 can't be retried.
82 """
83 def __init__(self, *, errorMsg):
84 msg = ("Aborting processing after writing to the APDB. "
85 "This image cannot be retried! "
86 "The original error was:\n"
87 f"{errorMsg}")
88 super().__init__(msg)
90 @property
91 def metadata(self):
92 return {}
95class DiaPipelineConnections(
96 pipeBase.PipelineTaskConnections,
97 dimensions=("instrument", "visit", "detector"),
98 defaultTemplates={"coaddName": "deep", "fakesType": ""}):
99 """Butler connections for DiaPipelineTask.
100 """
101 diaSourceTable = connTypes.Input(
102 doc="Catalog of calibrated DiaSources.",
103 name="{fakesType}{coaddName}Diff_diaSrcTable",
104 storageClass="DataFrame",
105 dimensions=("instrument", "visit", "detector"),
106 )
107 solarSystemObjectTable = connTypes.Input(
108 doc="Catalog of SolarSolarSystem objects expected to be observable in "
109 "this detectorVisit.",
110 name="preloaded_SsObjects",
111 storageClass="ArrowAstropy",
112 dimensions=("instrument", "group", "detector"),
113 minimum=0,
114 )
115 diffIm = connTypes.Input(
116 doc="Difference image on which the DiaSources were detected.",
117 name="{fakesType}{coaddName}Diff_differenceExp",
118 storageClass="ExposureF",
119 dimensions=("instrument", "visit", "detector"),
120 )
121 exposure = connTypes.Input(
122 doc="Calibrated exposure differenced with a template image during "
123 "image differencing.",
124 name="{fakesType}calexp",
125 storageClass="ExposureF",
126 dimensions=("instrument", "visit", "detector"),
127 )
128 template = connTypes.Input(
129 doc="Warped template used to create `subtractedExposure`. Not PSF "
130 "matched.",
131 dimensions=("instrument", "visit", "detector"),
132 storageClass="ExposureF",
133 name="{fakesType}{coaddName}Diff_templateExp",
134 )
135 preloadedDiaObjects = connTypes.Input(
136 doc="DiaObjects preloaded from the APDB.",
137 name="preloaded_diaObjects",
138 storageClass="DataFrame",
139 dimensions=("instrument", "group", "detector"),
140 )
141 preloadedDiaSources = connTypes.Input(
142 doc="DiaSources preloaded from the APDB.",
143 name="preloaded_diaSources",
144 storageClass="DataFrame",
145 dimensions=("instrument", "group", "detector"),
146 )
147 preloadedDiaForcedSources = connTypes.Input(
148 doc="DiaForcedSources preloaded from the APDB.",
149 name="preloaded_diaForcedSources",
150 storageClass="DataFrame",
151 dimensions=("instrument", "group", "detector"),
152 )
153 apdbMarker = connTypes.Output(
154 doc="Marker dataset storing the configuration of the Apdb for each "
155 "visit/detector. Used to signal the completion of the pipeline.",
156 name="apdb_marker",
157 storageClass="Config",
158 dimensions=("instrument", "visit", "detector"),
159 )
160 associatedDiaSources = connTypes.Output(
161 doc="Optional output storing the DiaSource catalog after matching, "
162 "calibration, and standardization for insertion into the Apdb.",
163 name="{fakesType}{coaddName}Diff_assocDiaSrc",
164 storageClass="ArrowAstropy",
165 dimensions=("instrument", "visit", "detector"),
166 )
167 associatedSsSources = connTypes.Output(
168 doc="Optional output storing ssSource data computed during association.",
169 name="{fakesType}{coaddName}Diff_associatedSsSources",
170 storageClass="ArrowAstropy",
171 dimensions=("instrument", "visit", "detector"),
172 )
173 unassociatedSsObjects = connTypes.Output(
174 doc="Expected locations of an ssObject with no source",
175 name="ssUnassociatedObjects",
176 storageClass="ArrowAstropy",
177 dimensions=("instrument", "visit", "detector"),
178 )
180 diaForcedSources = connTypes.Output(
181 doc="Optional output storing the forced sources computed at the diaObject positions.",
182 name="{fakesType}{coaddName}Diff_diaForcedSrc",
183 storageClass="ArrowAstropy",
184 dimensions=("instrument", "visit", "detector"),
185 )
186 diaObjects = connTypes.Output(
187 doc="Optional output storing the updated diaObjects associated to these sources.",
188 name="{fakesType}{coaddName}Diff_diaObject",
189 storageClass="ArrowAstropy",
190 dimensions=("instrument", "visit", "detector"),
191 )
192 newDiaSources = connTypes.Output(
193 doc="New diaSources not associated with an existing diaObject that"
194 " were used to create a new diaObject",
195 name="{fakesType}new_dia_source",
196 storageClass="ArrowAstropy",
197 dimensions=("instrument", "visit", "detector"),
198 )
199 marginalDiaSources = connTypes.Output(
200 doc="Low SNR diaSources not associated with an existing diaObject that"
201 " were rejected instead of creating a new diaObject",
202 name="{fakesType}marginal_new_dia_source",
203 storageClass="ArrowAstropy",
204 dimensions=("instrument", "visit", "detector"),
205 )
207 def __init__(self, *, config=None):
208 super().__init__(config=config)
210 if not config.doWriteAssociatedSources:
211 self.outputs.remove("associatedDiaSources")
212 self.outputs.remove("diaForcedSources")
213 self.outputs.remove("diaObjects")
214 self.outputs.remove("newDiaSources")
215 self.outputs.remove("marginalDiaSources")
216 else:
217 if not config.doRunForcedMeasurement:
218 self.outputs.remove("diaForcedSources")
219 if not config.filterUnAssociatedSources:
220 self.outputs.remove("newDiaSources")
221 self.outputs.remove("marginalDiaSources")
222 if not config.doSolarSystemAssociation:
223 self.inputs.remove("solarSystemObjectTable")
224 if (not config.doWriteAssociatedSources) or (not config.doSolarSystemAssociation):
225 self.outputs.remove("associatedSsSources")
226 self.outputs.remove("unassociatedSsObjects")
228 def adjustQuantum(self, inputs, outputs, label, dataId):
229 """Override to make adjustments to `lsst.daf.butler.DatasetRef` objects
230 in the `lsst.daf.butler.core.Quantum` during the graph generation stage
231 of the activator.
233 This implementation checks to make sure that the filters in the dataset
234 are compatible with AP processing as set by the Apdb/DPDD schema.
236 Parameters
237 ----------
238 inputs : `dict`
239 Dictionary whose keys are an input (regular or prerequisite)
240 connection name and whose values are a tuple of the connection
241 instance and a collection of associated `DatasetRef` objects.
242 The exact type of the nested collections is unspecified; it can be
243 assumed to be multi-pass iterable and support `len` and ``in``, but
244 it should not be mutated in place. In contrast, the outer
245 dictionaries are guaranteed to be temporary copies that are true
246 `dict` instances, and hence may be modified and even returned; this
247 is especially useful for delegating to `super` (see notes below).
248 outputs : `dict`
249 Dict of output datasets, with the same structure as ``inputs``.
250 label : `str`
251 Label for this task in the pipeline (should be used in all
252 diagnostic messages).
253 data_id : `lsst.daf.butler.DataCoordinate`
254 Data ID for this quantum in the pipeline (should be used in all
255 diagnostic messages).
257 Returns
258 -------
259 adjusted_inputs : `dict`
260 Dict of the same form as ``inputs`` with updated containers of
261 input `DatasetRef` objects. Connections that are not changed
262 should not be returned at all. Datasets may only be removed, not
263 added. Nested collections may be of any multi-pass iterable type,
264 and the order of iteration will set the order of iteration within
265 `PipelineTask.runQuantum`.
266 adjusted_outputs : `dict`
267 Dict of updated output datasets, with the same structure and
268 interpretation as ``adjusted_inputs``.
270 Raises
271 ------
272 ScalarError
273 Raised if any `Input` or `PrerequisiteInput` connection has
274 ``multiple`` set to `False`, but multiple datasets.
275 NoWorkFound
276 Raised to indicate that this quantum should not be run; not enough
277 datasets were found for a regular `Input` connection, and the
278 quantum should be pruned or skipped.
279 FileNotFoundError
280 Raised to cause QuantumGraph generation to fail (with the message
281 included in this exception); not enough datasets were found for a
282 `PrerequisiteInput` connection.
283 """
284 _, refs = inputs["diffIm"]
285 for ref in refs:
286 if ref.dataId["band"] not in self.config.validBands:
287 raise ValueError(
288 f"Requested '{ref.dataId['band']}' not in "
289 "DiaPipelineConfig.validBands. To process bands not in "
290 "the standard Rubin set (ugrizy) you must add the band to "
291 "the validBands list in DiaPipelineConfig and add the "
292 "appropriate columns to the Apdb schema.")
293 return super().adjustQuantum(inputs, outputs, label, dataId)
296class DiaPipelineConfig(pipeBase.PipelineTaskConfig,
297 pipelineConnections=DiaPipelineConnections):
298 """Config for DiaPipelineTask.
299 """
300 coaddName = pexConfig.Field(
301 doc="coadd name: typically one of deep, goodSeeing, or dcr",
302 dtype=str,
303 default="deep",
304 )
305 apdb_config_url = pexConfig.Field(
306 dtype=str,
307 default=None,
308 optional=False,
309 doc="A config file specifying the APDB and its connection parameters, "
310 "typically written by the apdb-cli command-line utility. "
311 "The database must already be initialized.",
312 )
313 validBands = pexConfig.ListField(
314 dtype=str,
315 default=["u", "g", "r", "i", "z", "y"],
316 doc="List of bands that are valid for AP processing. To process a "
317 "band not on this list, the appropriate band specific columns "
318 "must be added to the Apdb schema in dax_apdb.",
319 )
320 associator = pexConfig.ConfigurableField(
321 target=AssociationTask,
322 doc="Task used to associate DiaSources with DiaObjects.",
323 )
324 doSolarSystemAssociation = pexConfig.Field(
325 dtype=bool,
326 default=True,
327 doc="Process SolarSystem objects through the pipeline.",
328 )
329 solarSystemAssociator = pexConfig.ConfigurableField(
330 target=SolarSystemAssociationTask,
331 doc="Task used to associate DiaSources with SolarSystemObjects.",
332 )
333 diaCalculation = pexConfig.ConfigurableField(
334 target=DiaObjectCalculationTask,
335 doc="Task to compute summary statistics for DiaObjects.",
336 )
337 doReloadDiaObjects = pexConfig.Field(
338 dtype=bool,
339 default=True,
340 doc="Drop preloaded DiaObjects and reload them from the APDB?"
341 "Used in production when the very latest objects from the APDB "
342 "are needed.",
343 )
344 angleMargin = pexConfig.RangeField(
345 doc="Padding to add when loading diaObjects if `doReloadDiaObjects=True`",
346 dtype=float,
347 default=2,
348 min=0,
349 )
350 doRunForcedMeasurement = pexConfig.Field(
351 dtype=bool,
352 default=True,
353 deprecated="Added to allow disabling forced sources for performance "
354 "reasons during the ops rehearsal. "
355 "It is expected to be removed.",
356 doc="Run forced measurement on all of the diaObjects? "
357 "This should only be turned off for debugging purposes.",
358 )
359 diaForcedSource = pexConfig.ConfigurableField(
360 target=DiaForcedSourceTask,
361 doc="Task used for force photometer DiaObject locations in direct and "
362 "difference images.",
363 )
364 forcedReliabilityThreshold = pexConfig.Field(
365 dtype=float,
366 default=0.5,
367 doc="Minimum reliability score of constituent diaSources to use when "
368 "determining whether to calculate forced photometry for a "
369 "diaObject.",
370 )
371 forcedTrailLengthThreshold = pexConfig.Field(
372 dtype=float,
373 default=1.4,
374 doc="Maximum trail length (arseconds) of constituent diaSources to use "
375 "when determining whether to calculate forced photometry for a "
376 "diaObject.",
377 )
378 forcedBadFlags = pexConfig.ListField(
379 dtype=str,
380 default=["glint_trail", "isDipole"],
381 doc="Flags of constituent diaSources to exclude when determining "
382 "whether to calculate forced photometry for a diaObject.",
383 )
384 alertPackager = pexConfig.ConfigurableField(
385 target=PackageAlertsTask,
386 doc="Subtask for packaging Ap data into alerts.",
387 )
388 doPackageAlerts = pexConfig.Field(
389 dtype=bool,
390 default=False,
391 doc="Package Dia-data into serialized alerts for distribution and "
392 "write them to disk.",
393 )
394 doWriteAssociatedSources = pexConfig.Field(
395 dtype=bool,
396 default=True,
397 doc="Write out associated DiaSources, DiaForcedSources, and DiaObjects, "
398 "formatted following the Science Data Model.",
399 )
400 imagePixelMargin = pexConfig.RangeField(
401 dtype=int,
402 default=10,
403 min=0,
404 doc="Pad the image by this many pixels before removing off-image "
405 "diaObjects for association.",
406 )
407 filterUnAssociatedSources = pexConfig.Field(
408 dtype=bool,
409 default=True,
410 doc="Check unassociated diaSources for quality before creating new ."
411 "diaObjects. DiaSources that are associated with existing diaObjects "
412 "will not be affected."
413 )
414 newObjectBadFlags = pexConfig.ListField(
415 dtype=str,
416 default=("centroid_flag",
417 "pixelFlags_crCenter",
418 "pixelFlags_nodataCenter",
419 "pixelFlags_interpolatedCenter",
420 "pixelFlags_saturatedCenter",
421 "pixelFlags_suspectCenter",
422 "pixelFlags_streakCenter",
423 "glint_trail"),
424 doc="If `filterUnAssociatedSources` is set, exclude diaSources with "
425 "these flags set before creating new diaObjects.",
426 )
427 maxNewDiaObjects = pexConfig.RangeField(
428 dtype=float,
429 default=0,
430 min=0,
431 doc="Maximum number of new DiaObjects to create before raising an error."
432 "Set to zero to disable.",
433 )
434 newObjectSnrThreshold = pexConfig.RangeField(
435 dtype=float,
436 default=0,
437 min=0,
438 doc="If `filterUnAssociatedSources` is set, exclude diaSources with "
439 "Abs(flux/fluxErr) less than this threshold before creating new "
440 "diaObjects."
441 "Set to zero to disable.",
442 )
443 newObjectLowReliabilitySnrThreshold = pexConfig.RangeField(
444 dtype=float,
445 default=0,
446 min=0,
447 doc="If `filterUnAssociatedSources` is set, exclude diaSources with "
448 "signal-to-noise ratios less than this threshold if they have"
449 " low reliability scores before creating new diaObjects."
450 "Set to zero to disable.",
451 )
452 newObjectReliabilityThreshold = pexConfig.RangeField(
453 dtype=float,
454 default=0.1,
455 min=0,
456 max=1,
457 doc="If `filterUnAssociatedSources` is set, exclude diaSources with "
458 "reliability scores less than this threshold before creating new "
459 "diaObjects."
460 "Set to zero to disable.",
461 )
462 newObjectLowSnrReliabilityThreshold = pexConfig.RangeField(
463 dtype=float,
464 default=0.1,
465 min=0,
466 max=1,
467 doc="If `filterUnAssociatedSources` is set, exclude diaSources with "
468 "low signal-to-noise and reliability scores below this threshold "
469 "before creating new diaObjects."
470 "Set to zero to disable.",
471 )
472 newObjectFluxField = pexConfig.Field(
473 dtype=str,
474 default="apFlux",
475 doc="Name of the flux field in the standardized diaSource catalog to "
476 "use for checking the signal-to-noise before creating new diaObjects.",
477 )
478 idGenerator = DetectorVisitIdGeneratorConfig.make_field()
480 def setDefaults(self):
481 self.diaCalculation.plugins = ["ap_meanPosition",
482 "ap_nDiaSources",
483 "ap_meanFlux",
484 "ap_sigmaFlux",
485 "ap_minMaxFlux",
486 "ap_maxSlopeFlux",
487 "ap_meanErrFlux",
488 "ap_meanTotFlux"]
491class DiaPipelineTask(pipeBase.PipelineTask):
492 """Task for loading, associating and storing Difference Image Analysis
493 (DIA) Objects and Sources.
494 """
495 ConfigClass = DiaPipelineConfig
496 _DefaultName = "diaPipe"
498 def __init__(self, initInputs=None, **kwargs):
499 super().__init__(**kwargs)
500 self.apdb = daxApdb.Apdb.from_uri(self.config.apdb_config_url)
501 self.schema = readSchemaFromApdb(self.apdb)
502 self.makeSubtask("associator")
503 self.makeSubtask("diaCalculation")
504 if self.config.doRunForcedMeasurement:
505 self.makeSubtask("diaForcedSource")
506 if self.config.doPackageAlerts:
507 self.makeSubtask("alertPackager")
508 if self.config.doSolarSystemAssociation:
509 self.makeSubtask("solarSystemAssociator")
510 if self.config.filterUnAssociatedSources:
511 columns = [self.config.newObjectFluxField,
512 self.config.newObjectFluxField + "Err",
513 "reliability"]
514 columns += self.config.newObjectBadFlags
516 missing = checkSdmSchemaColumns(self.schema, columns, "DiaSource")
517 if missing:
518 raise pipeBase.InvalidQuantumError("Field %s not in the DiaSource schema" % missing)
520 def runQuantum(self, butlerQC, inputRefs, outputRefs):
521 inputs = butlerQC.get(inputRefs)
522 inputs["idGenerator"] = self.config.idGenerator.apply(butlerQC.quantum.dataId)
523 inputs["band"] = butlerQC.quantum.dataId["band"]
524 inputs["legacySolarSystemTable"] = None
525 if not self.config.doSolarSystemAssociation:
526 inputs["solarSystemObjectTable"] = None
528 associationResults = pipeBase.Struct(
529 apdbMarker=None,
530 associatedDiaSources=None,
531 diaForcedSources=None,
532 diaObjects=None,
533 associatedSsSources=None,
534 unassociatedSsObjects=None,
535 newDiaSources=None,
536 marginalDiaSources=None,
537 )
538 try:
539 self.run(**inputs, associationResults=associationResults)
540 except pipeBase.AlgorithmError as e:
541 error = pipeBase.AnnotatedPartialOutputsError.annotate(
542 e,
543 self,
544 log=self.log
545 )
546 butlerQC.put(associationResults, outputRefs)
547 raise error from e
548 butlerQC.put(associationResults, outputRefs)
550 @timeMethod
551 def run(self,
552 diaSourceTable,
553 legacySolarSystemTable,
554 diffIm,
555 exposure,
556 template,
557 preloadedDiaObjects,
558 preloadedDiaSources,
559 preloadedDiaForcedSources,
560 band,
561 idGenerator,
562 solarSystemObjectTable=None,
563 associationResults=None):
564 """Process DiaSources and DiaObjects.
566 Load previous DiaObjects and their DiaSource history. Calibrate the
567 values in the diaSourceCat. Associate new DiaSources with previous
568 DiaObjects. Run forced photometry at the updated DiaObject locations.
569 Store the results in the Alert Production Database (Apdb).
571 Parameters
572 ----------
573 diaSourceTable : `pandas.DataFrame`
574 Newly detected DiaSources.
575 legacySolarSystemTable : `pandas.DataFrame`
576 Not used
577 diffIm : `lsst.afw.image.ExposureF`
578 Difference image exposure in which the sources in ``diaSourceCat``
579 were detected.
580 exposure : `lsst.afw.image.ExposureF`
581 Calibrated exposure differenced with a template to create
582 ``diffIm``.
583 template : `lsst.afw.image.ExposureF`
584 Template exposure used to create diffIm.
585 preloadedDiaObjects : `pandas.DataFrame`
586 Previously detected DiaObjects, loaded from the APDB.
587 preloadedDiaSources : `pandas.DataFrame`
588 Previously detected DiaSources, loaded from the APDB.
589 preloadedDiaForcedSources : `pandas.DataFrame`
590 Catalog of previously detected forced DiaSources, from the APDB
591 band : `str`
592 The band in which the new DiaSources were detected.
593 idGenerator : `lsst.meas.base.IdGenerator`
594 Object that generates source IDs and random number generator seeds.
595 solarSystemObjectTable : `astropy.table.Table`
596 Preloaded Solar System objects expected to be visible in the image.
597 associationResults : `lsst.pipe.base.Struct`, optional
598 Result struct that is modified to allow saving of partial outputs
599 for some failure conditions. If the task completes successfully,
600 this is also returned.
602 Returns
603 -------
604 associationResults : `lsst.pipe.base.Struct`
605 Results struct with components.
607 - ``apdbMarker`` : Marker dataset to store in the Butler indicating
608 that this ccdVisit has completed successfully.
609 (`lsst.dax.apdb.ApdbConfig`)
610 - ``associatedDiaSources`` : Catalog of newly associated
611 DiaSources. (`pandas.DataFrame`)
612 - ``diaForcedSources`` : Catalog of new and previously detected
613 forced DiaSources. (`pandas.DataFrame`)
614 - ``diaObjects`` : Updated table of DiaObjects. (`pandas.DataFrame`)
615 - ``associatedSsSources`` : Catalog of ssSource records.
616 (`pandas.DataFrame`)
618 Raises
619 ------
620 RuntimeError
621 Raised if duplicate DiaObjects or duplicate DiaSources are found.
622 """
624 if associationResults is None:
625 associationResults = pipeBase.Struct()
626 # Accept either legacySolarSystemTable or optional solarSystemObjectTable.
627 if legacySolarSystemTable is not None and solarSystemObjectTable is None:
628 solarSystemObjectTable = Table.from_pandas(legacySolarSystemTable)
629 if self.config.doReloadDiaObjects:
630 try:
631 preloadedDiaObjects = self.loadRefreshedDiaObjects(getRegion(exposure), preloadedDiaObjects)
632 except Exception as e:
633 self.log.warning("Error encountered while attempting to load "
634 "the latest diaObjects from the APDB. Processing "
635 "will continue with only the diaObjects from "
636 "preload.", exc_info=e)
637 finally:
638 self.metadata["loadDiaObjectsDuration"] = duration_from_timeMethod(
639 self.metadata, "loadRefreshedDiaObjects", clock="Utc"
640 )
641 self.log.verbose("Re-loading DiaObjects: Took %.4f seconds",
642 self.metadata["loadDiaObjectsDuration"])
644 else:
645 self.metadata["loadDiaObjectsDuration"] = -1
647 self.checkTableIndex(preloadedDiaSources, index=["diaObjectId", "band", "diaSourceId"])
648 self.checkTableIndex(preloadedDiaObjects, index="diaObjectId")
649 self.checkTableIndex(preloadedDiaForcedSources, index=["diaObjectId", "diaForcedSourceId"])
651 if not preloadedDiaObjects.empty:
652 # Include a small buffer outside the image so that we can associate sources near the edge
653 diaObjects, _ = self.purgeDiaObjects(diffIm.getBBox(), diffIm.getWcs(), preloadedDiaObjects,
654 buffer=self.config.imagePixelMargin)
655 else:
656 self.log.info("Preloaded DiaObject table is empty.")
657 diaObjects = preloadedDiaObjects
659 # Associate DiaSources with DiaObjects
660 assocResults = self.associateDiaSources(diaSourceTable, solarSystemObjectTable, diffIm, diaObjects)
662 # Set unassociated diaObjectIds and ssObjectIds to NULL, and convert to SDM schema format
663 standardizedAssociatedDiaSources = self.standardizeDataFrame(
664 assocResults.associatedDiaSources,
665 "DiaSource",
666 nullColumns=["diaObjectId", "ssObjectId"]
667 )
669 # Merge associated diaSources
670 mergedDiaSourceHistory, mergedDiaObjects, updatedDiaObjectIds = self.mergeAssociatedCatalogs(
671 preloadedDiaSources,
672 assocResults.associatedDiaSources,
673 diaObjects,
674 assocResults.newDiaObjects,
675 diffIm
676 )
678 # Compute DiaObject Summary statistics from their full DiaSource
679 # history.
680 diaCalResult = self.diaCalculation.run(
681 mergedDiaObjects,
682 mergedDiaSourceHistory,
683 updatedDiaObjectIds,
684 [band])
685 updatedDiaObjects = convertDataFrameToSdmSchema(self.schema, diaCalResult.updatedDiaObjects,
686 tableName="DiaObject", skipIndex=True)
688 # Test for duplication in the updated DiaObjects.
689 if self.testDataFrameIndex(diaCalResult.diaObjectCat):
690 raise RuntimeError(
691 "Duplicate DiaObjects (loaded + updated) created after "
692 "DiaCalculation. This is unexpected behavior and should be "
693 "reported. Exiting.")
694 if self.testDataFrameIndex(updatedDiaObjects):
695 raise RuntimeError(
696 "Duplicate DiaObjects (updated) created after "
697 "DiaCalculation. This is unexpected behavior and should be "
698 "reported. Exiting.")
700 # Forced source measurement
701 if self.config.doRunForcedMeasurement:
702 diaObjectsForced = self._selectGoodDiaObjects(diaCalResult.diaObjectCat, mergedDiaSourceHistory)
703 diaForcedSources = self.runForcedMeasurement(
704 diaObjectsForced, updatedDiaObjects, exposure, diffIm, idGenerator
705 )
706 forcedSourceHistoryThreshold = self.diaForcedSource.config.historyThreshold
707 else:
708 # alertPackager needs correct columns
709 diaForcedSources = makeEmptyForcedSourceTable(self.schema)
710 forcedSourceHistoryThreshold = 0
712 # Write results to Alert Production Database (APDB)
713 try:
714 validityStart = self.writeToApdb(updatedDiaObjects, standardizedAssociatedDiaSources,
715 diaForcedSources)
716 finally:
717 self.metadata["writeToApdbDuration"] = duration_from_timeMethod(self.metadata, "writeToApdb",
718 clock="Utc")
719 # A single log message is easier for Loki to parse than timeMethod's start+end pairs.
720 self.log.verbose("writeToApdb: Took %.4f seconds", self.metadata["writeToApdbDuration"])
722 # For historical reasons, apdbMarker is a Config even if it's not meant to be read.
723 # A default Config is the cheapest way to satisfy the storage class.
724 associationResults.apdbMarker = pexConfig.Config()
725 associationResults.associatedDiaSources = assocResults.associatedDiaSources
726 associationResults.diaForcedSources = diaForcedSources
727 associationResults.diaObjects = diaCalResult.diaObjectCat
728 associationResults.unassociatedSsObjects = assocResults.unassociatedSsObjects
729 associationResults.newDiaSources = assocResults.newDiaSources
730 associationResults.marginalDiaSources = assocResults.marginalDiaSources
732 # Catch *any* error after we have updated the APDB
733 try:
734 associatedSsSources = assocResults.associatedSsSources
735 associatedSsSourcesPlusMpcorb = None
736 if associatedSsSources is not None:
737 mpcorbColumns = [col for col in associatedSsSources.columns if col[:7] == 'MPCORB_']
738 associatedSsSourceMpcorb = associatedSsSources[mpcorbColumns].copy()
739 associatedSsSources = self.standardizeTable(associatedSsSources, "SSSource", nullColumns=[])
740 associatedSsSourcesPlusMpcorb = associatedSsSources.copy()
741 for mpcorbColumn in mpcorbColumns:
742 associatedSsSourcesPlusMpcorb[mpcorbColumn] = associatedSsSourceMpcorb[mpcorbColumn]
743 associationResults.associatedSsSources = associatedSsSources
745 # patch the otherwise-empty validityStart field for the alerts
746 updatedDiaObjects['validityStartMjdTai'] = validityStart.get(system=DateTime.MJD,
747 scale=DateTime.TAI)
749 # Package alerts
750 if self.config.doPackageAlerts:
751 # Append new forced sources to the full history
752 diaForcedSourcesFull = self.mergeCatalogs(preloadedDiaForcedSources, diaForcedSources,
753 tableName="DiaForcedSource")
754 if self.testDataFrameIndex(diaForcedSources):
755 self.log.warning(
756 "Duplicate DiaForcedSources created after merge with "
757 "history and new sources. This may cause downstream "
758 "problems. Dropping duplicates.")
759 # Drop duplicates via index and keep the first appearance.
760 # Reset due to the index shape being slight different than
761 # expected.
762 diaForcedSourcesFull = diaForcedSourcesFull.groupby(
763 diaForcedSourcesFull.index).first()
764 diaForcedSourcesFull.reset_index(drop=True, inplace=True)
765 diaForcedSourcesFull.set_index(
766 ["diaObjectId", "diaForcedSourceId"],
767 drop=False,
768 inplace=True)
770 self.alertPackager.run(assocResults.associatedDiaSources,
771 updatedDiaObjects,
772 preloadedDiaSources,
773 diaForcedSourcesFull,
774 diffIm,
775 exposure,
776 template,
777 ssSrc=associatedSsSourcesPlusMpcorb,
778 doRunForcedMeasurement=self.config.doRunForcedMeasurement,
779 forcedSourceHistoryThreshold=forcedSourceHistoryThreshold,
780 )
781 except Exception as e:
782 # Catch *any* error after we have updated the APDB
783 raise PostApdbUpdateError(errorMsg=repr(e))
785 return associationResults
787 def _selectGoodDiaObjects(self, diaObjects, diaSources):
788 """
789 Extract a subset of diaObjects with multiple associated diaSources that
790 pass selection requirements.
791 """
792 n_history = self.diaForcedSource.config.historyThreshold
793 required_columns = ["reliability", "trailLength"]
794 for col in required_columns:
795 if col not in diaSources.columns:
796 raise RuntimeError("Required column '%s' missing from diaSource table." % col)
798 rel_ok = diaSources["reliability"] >= self.config.forcedReliabilityThreshold
799 trailed_ok = diaSources["trailLength"] <= self.config.forcedTrailLengthThreshold
800 badFlags = [f for f in self.config.forcedBadFlags if f in diaSources.columns]
801 if badFlags:
802 self.log.info("Excluding diaSources with %s flags set when calculating"
803 " diaObject history for forced photometry." % badFlags)
804 # Exclude rows where ANY bad flag is True.
805 # If bad flag columns can contain NA, treat NA as True (i.e., bad) via fillna(True).
806 bad_any = diaSources[badFlags].fillna(True).any(axis=1)
807 mask = rel_ok & trailed_ok & ~bad_any
808 ids = diaSources.index.get_level_values("diaObjectId")[mask]
810 # Count occurrences in diaSource
811 counts = pd.Series(ids, copy=False).value_counts()
813 # IDs that exceed threshold
814 keep_ids = counts[counts >= n_history].index
816 # Since diaObject index is unique, direct index filtering is safe and efficient
817 return diaObjects.loc[diaObjects.index.intersection(keep_ids)]
819 def createNewDiaObjects(self, unassociatedDiaSources):
820 """Loop through the set of DiaSources and create new DiaObjects
821 for unassociated DiaSources.
823 Parameters
824 ----------
825 unassociatedDiaSources : `pandas.DataFrame`
826 Set of DiaSources to create new DiaObjects from.
828 Returns
829 -------
830 results : `lsst.pipe.base.Struct`
831 Results struct containing:
833 - diaSources : `pandas.DataFrame`
834 DiaSource catalog with updated DiaObject ids.
835 - newDiaObjects : `pandas.DataFrame`
836 Newly created DiaObjects from the unassociated DiaSources.
837 - nNewDiaObjects : `int`
838 Number of newly created diaObjects.
839 - marginalDiaSources : `pandas.DataFrame`
840 Unassociated diaSources with low signal-to-noise and/or
841 reliability, which are excluded from the new DiaObjects.
842 """
843 marginalDiaSources = make_empty_catalog(self.schema, tableName="DiaSource")
844 if len(unassociatedDiaSources) == 0:
845 newDiaObjects = make_empty_catalog(self.schema, tableName="DiaObject")
846 else:
847 if self.config.filterUnAssociatedSources:
848 results = self.filterSources(unassociatedDiaSources)
849 unassociatedDiaSources = results.goodSources
850 marginalDiaSources = results.badSources
851 unassociatedDiaSources["diaObjectId"] = unassociatedDiaSources["diaSourceId"]
852 newDiaObjects = convertDataFrameToSdmSchema(self.schema, unassociatedDiaSources,
853 tableName="DiaObject", skipIndex=True)
854 self.metadata["nRejectedNewDiaObjects"] = len(marginalDiaSources)
855 return pipeBase.Struct(diaSources=unassociatedDiaSources,
856 newDiaObjects=newDiaObjects,
857 nNewDiaObjects=len(newDiaObjects),
858 marginalDiaSources=marginalDiaSources)
860 def filterSources(self, sources, snrThreshold=None, lowReliabilitySnrThreshold=None,
861 reliabilityThreshold=None, lowSnrReliabilityThreshold=None, badFlags=None):
862 """Select good sources out of a catalog.
864 Parameters
865 ----------
866 sources : `pandas.DataFrame`
867 Set of DiaSources to check.
868 snrThreshold : `float`, optional
869 The minimum signal to noise diaSource to make a new diaObject.
870 Included for unit tests. Uses the task config value if not set.
871 lowReliabilitySnrThreshold : `float`, optional
872 Use ``lowSnrReliabilityThreshold`` as the reliability threshold for
873 diaSources with ``snrThreshold`` < SNR < ``lowReliabilitySnrThreshold``
874 Included for unit tests. Uses the task config value if not set.
875 reliabilityThreshold : `float`, optional
876 The minimum reliability score diaSource to make a new diaObject
877 Included for unit tests. Uses the task config value if not set.
878 lowSnrReliabilityThreshold : `float`, optional
879 Use ``lowSnrReliabilityThreshold`` as the reliability threshold for
880 diaSources with ``snrThreshold`` < SNR < ``lowReliabilitySnrThreshold``
881 Included for unit tests. Uses the task config value if not set.
882 badFlags : `list` of `str`, optional
883 Do not create new diaObjects for any diaSource with any of these
884 flags set.
885 Included for unit tests. Uses the task config value if not set.
887 Returns
888 -------
889 results : `lsst.pipe.base.Struct`
890 Results struct containing:
892 - goodSources : `pandas.DataFrame`
893 Subset of the input sources that pass all checks.
894 - badSources : `pandas.DataFrame`
895 Subset of the input sources that fail any check.
896 """
897 if snrThreshold is None:
898 snrThreshold = self.config.newObjectSnrThreshold
899 if lowReliabilitySnrThreshold is None:
900 lowReliabilitySnrThreshold = self.config.newObjectLowReliabilitySnrThreshold
901 if reliabilityThreshold is None:
902 reliabilityThreshold = self.config.newObjectReliabilityThreshold
903 if lowSnrReliabilityThreshold is None:
904 lowSnrReliabilityThreshold = self.config.newObjectLowSnrReliabilityThreshold
905 if badFlags is None:
906 badFlags = self.config.newObjectBadFlags
907 flagged = sources[badFlags].fillna(False).any(axis=1)
908 fluxField = self.config.newObjectFluxField
909 fluxErrField = fluxField + "Err"
910 signalToNoise = np.abs(np.array(sources[fluxField]/sources[fluxErrField]))
911 reliability = np.array(sources['reliability'])
912 nFlagged = np.count_nonzero(flagged)
913 if nFlagged > 0:
914 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to flags", nFlagged)
916 if snrThreshold > 0:
917 snr_flag = signalToNoise < snrThreshold
918 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to %sFlux"
919 " signal to noise < %f",
920 np.sum(snr_flag), fluxField, snrThreshold)
921 flagged += snr_flag
922 if reliabilityThreshold > 0:
923 reliability_flag = reliability < reliabilityThreshold
924 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to "
925 "reliability<%f",
926 np.sum(reliability_flag), reliabilityThreshold)
927 flagged += reliability_flag
928 if min(lowReliabilitySnrThreshold, lowSnrReliabilityThreshold) > 0:
929 # Only run the combined test if both thresholds are greater than zero
930 lowSnrReliability_flag = ((signalToNoise < lowReliabilitySnrThreshold)
931 & (reliability < lowSnrReliabilityThreshold))
932 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to %sFlux"
933 " signal to noise < %f combined with reliability< %f",
934 np.sum(lowSnrReliability_flag),
935 fluxField,
936 lowReliabilitySnrThreshold,
937 lowSnrReliabilityThreshold)
938 flagged += lowSnrReliability_flag
940 if np.count_nonzero(~flagged) > 0:
941 goodSources = sources[~flagged].copy(deep=True)
942 else:
943 goodSources = make_empty_catalog(self.schema, tableName="DiaSource")
944 if np.count_nonzero(flagged) > 0:
945 badSources = sources[flagged].copy(deep=True)
946 else:
947 badSources = make_empty_catalog(self.schema, tableName="DiaSource")
948 return pipeBase.Struct(goodSources=goodSources,
949 badSources=badSources
950 )
952 @timeMethod
953 def associateDiaSources(self, diaSourceTable, solarSystemObjectTable, diffIm, diaObjects):
954 """Associate DiaSources with DiaObjects.
956 Associate new DiaSources with existing DiaObjects. Create new
957 DiaObjects fron unassociated DiaSources. Index DiaSource catalogue
958 after associations. Append new DiaObjects and DiaSources to their
959 previous history. Test for DiaSource and DiaObject duplications.
960 Compute DiaObject Summary statistics from their full DiaSource
961 history. Test for duplication in the updated DiaObjects.
963 Parameters
964 ----------
965 diaSourceTable : `pandas.DataFrame`
966 Newly detected DiaSources.
967 solarSystemObjectTable : `astropy.table.Table`
968 Preloaded Solar System objects expected to be visible in the image.
969 diffIm : `lsst.afw.image.ExposureF`
970 Difference image exposure in which the sources in ``diaSourceCat``
971 were detected.
972 diaObjects : `pandas.DataFrame`
973 Table of DiaObjects from preloaded DiaObjects.
975 Returns
976 -------
977 results : `lsst.pipe.base.Struct`
978 Results struct containing:
980 - associatedDiaSources : `pandas.DataFrame`
981 Associated DiaSources with DiaObjects.
982 - newDiaObjects : `pandas.DataFrame`
983 Table of new DiaObjects after association.
984 - associatedSsSources : `astropy.table.Table`
985 Table of new ssSources after association.
986 - unassociatedSsObjects : `astropy.table.Table`
987 Table of expected ssSources that were not associated with a
988 diaSource.
989 - newDiaSources : `pandas.DataFrame`
990 Subset of `associatedDiaSources` consisting of only the
991 unassociated diaSources that were added as new diaObjects.
992 - marginalDiaSources : `pandas.DataFrame`
993 Unassociated diaSources with marginal detections, which were
994 removed from `associatedDiaSources` and were not added as new
995 diaObjects.
996 """
997 associatedCatalogs = []
998 # First associate diaSources with known asteroids
999 if self.config.doSolarSystemAssociation and solarSystemObjectTable is not None:
1000 ssoAssocResult = self.solarSystemAssociator.run(
1001 Table.from_pandas(diaSourceTable),
1002 solarSystemObjectTable,
1003 diffIm.visitInfo,
1004 diffIm.getBBox(),
1005 diffIm.wcs
1006 )
1007 nTotalSsObjects = ssoAssocResult.nTotalSsObjects
1008 nAssociatedSsObjects = ssoAssocResult.nAssociatedSsObjects
1009 associatedSsSources = ssoAssocResult.associatedSsSources
1010 unassociatedSsObjects = ssoAssocResult.unassociatedSsObjects
1011 if len(ssoAssocResult.ssoAssocDiaSources) > 0:
1012 associatedCatalogs.append(ssoAssocResult.ssoAssocDiaSources.to_pandas())
1013 if len(ssoAssocResult.unAssocDiaSources) > 0:
1014 # If the table is empty then converting time fields to pandas
1015 # will raise an error. Pass in an empty Dataframe in that case.
1016 unAssocSSDiaSources = ssoAssocResult.unAssocDiaSources.to_pandas()
1017 else:
1018 unAssocSSDiaSources = make_empty_catalog(self.schema, tableName="DiaSource")
1019 else:
1020 unAssocSSDiaSources = diaSourceTable
1021 nTotalSsObjects = 0
1022 nAssociatedSsObjects = 0
1023 associatedSsSources = None
1024 unassociatedSsObjects = None
1025 # Associate new DiaSources with existing DiaObjects.
1026 assocResults = self.associator.run(unAssocSSDiaSources, diaObjects)
1027 createResults = self.createNewDiaObjects(assocResults.unAssocDiaSources)
1029 if not assocResults.matchedDiaSources.empty:
1030 associatedCatalogs.append(assocResults.matchedDiaSources)
1031 if not createResults.diaSources.empty:
1032 associatedCatalogs.append(createResults.diaSources)
1033 if len(associatedCatalogs) == 0:
1034 associatedDiaSources = make_empty_catalog(self.schema, tableName="DiaSource")
1035 else:
1036 associatedDiaSources = pd.concat(associatedCatalogs)
1038 self._add_association_meta_data(assocResults.nUpdatedDiaObjects,
1039 assocResults.nUnassociatedDiaObjects,
1040 createResults.nNewDiaObjects,
1041 nTotalSsObjects,
1042 nAssociatedSsObjects)
1043 self.log.info("%i updated and %i unassociated diaObjects. Creating %i new diaObjects"
1044 " and dropping %i marginal diaSources.",
1045 assocResults.nUpdatedDiaObjects,
1046 assocResults.nUnassociatedDiaObjects,
1047 createResults.nNewDiaObjects,
1048 len(createResults.marginalDiaSources),
1049 )
1050 if createResults.nNewDiaObjects > self.config.maxNewDiaObjects > 0:
1051 raise TooManyDiaObjectsError(nNewDiaObjects=createResults.nNewDiaObjects,
1052 threshold=self.config.maxNewDiaObjects)
1053 return pipeBase.Struct(associatedDiaSources=associatedDiaSources,
1054 newDiaObjects=createResults.newDiaObjects,
1055 associatedSsSources=associatedSsSources,
1056 unassociatedSsObjects=unassociatedSsObjects,
1057 newDiaSources=createResults.diaSources,
1058 marginalDiaSources=createResults.marginalDiaSources
1059 )
1061 def standardizeDataFrame(self, dataFrame, tableName, nullColumns=None):
1062 """Convert a catalog to SDM schema format with NULL ID values
1064 Parameters
1065 ----------
1066 dataFrame : `pandas.DataFrame`
1067 The catalog to standardize/convert
1068 tableName : `str`
1069 Schema name of table to which this dataFrame should be standardized
1070 nullColumns : `list` of `str`, optional
1071 List of column names to check for default values of 0 that should be
1072 replaced with `NULL`.
1075 Returns
1076 -------
1077 standardizedAssociatedDiaSources : pandas.DataFrame
1078 The standardized DiaSource catalog
1079 """
1080 standardizedDataFrame = convertDataFrameToSdmSchema(self.schema,
1081 dataFrame,
1082 tableName=tableName,
1083 skipIndex=True)
1085 def _setNullColumn(dataframe, colName):
1086 """Set specified columns with default values of 0 to NULL."""
1087 dataframe.loc[dataframe[colName] == 0, colName] = pd.NA
1089 if nullColumns is not None:
1090 for colName in nullColumns:
1091 _setNullColumn(standardizedDataFrame, colName)
1092 return standardizedDataFrame
1094 def standardizeTable(self, table, tableName, nullColumns=None):
1095 """Convert a catalog to SDM schema format with NULL ID values
1097 Parameters
1098 ----------
1099 table : `astropy.table.Table`
1100 The catalog to standardize/convert
1101 tableName : `str`
1102 Schema name of table to which this dataFrame should be standardized
1103 nullColumns : `list` of `str`, optional
1104 List of column names to check for default values of 0 that should be
1105 replaced with `NULL`.
1108 Returns
1109 -------
1110 standardizedAssociatedDiaSources : pandas.DataFrame
1111 The standardized DiaSource catalog
1112 """
1113 dataFrame = table.to_pandas()
1114 standardizedDataFrame = self.standardizeDataFrame(dataFrame, tableName, nullColumns=nullColumns)
1115 return standardizedDataFrame
1117 @timeMethod
1118 def mergeAssociatedCatalogs(self, preloadedDiaSources, associatedDiaSources, diaObjects, newDiaObjects,
1119 diffIm):
1120 """Merge the associated diaSource and diaObjects to their previous history.
1122 Parameters
1123 ----------
1124 preloadedDiaSources : `pandas.DataFrame`
1125 Previously detected DiaSources, loaded from the APDB.
1126 associatedDiaSources : `pandas.DataFrame`
1127 Associated DiaSources with DiaObjects.
1128 diaObjects : `pandas.DataFrame`
1129 Table of DiaObjects from preloaded DiaObjects.
1130 newDiaObjects : `pandas.DataFrame`
1131 Table of new DiaObjects after association.
1133 Raises
1134 ------
1135 RuntimeError
1136 Raised if duplicate DiaObjects or duplicate DiaSources are found.
1138 Returns
1139 -------
1140 mergedDiaSourceHistory : `pandas.DataFrame`
1141 The combined catalog, with all of the rows from preloadedDiaSources
1142 catalog ordered before the rows of associatedDiaSources catalog.
1143 mergedDiaObjects : `pandas.DataFrame`
1144 Table of new DiaObjects merged with their history.
1145 updatedDiaObjectIds : `numpy.Array`
1146 Object Id's from associated diaSources.
1147 """
1148 # Index the DiaSource catalog for this visit after all associations
1149 # have been made.
1150 updatedDiaObjectIds = associatedDiaSources["diaObjectId"][
1151 associatedDiaSources["diaObjectId"] != 0].to_numpy()
1152 associatedDiaSources.set_index(["diaObjectId",
1153 "band",
1154 "diaSourceId"],
1155 drop=False,
1156 inplace=True)
1158 # Append new DiaObjects and DiaSources to their previous history.
1159 if diaObjects.empty:
1160 mergedDiaObjects = newDiaObjects.set_index("diaObjectId", drop=False)
1161 elif not newDiaObjects.empty:
1162 mergedDiaObjects = pd.concat(
1163 [diaObjects,
1164 newDiaObjects.set_index("diaObjectId", drop=False)],
1165 sort=True)
1166 else:
1167 mergedDiaObjects = diaObjects
1169 # Exclude any objects that are off the image after association.
1170 mergedDiaObjects, updatedDiaObjectIds = self.purgeDiaObjects(diffIm.getBBox(), diffIm.getWcs(),
1171 mergedDiaObjects,
1172 diaObjectIds=updatedDiaObjectIds,
1173 buffer=-1)
1174 if self.testDataFrameIndex(mergedDiaObjects):
1175 raise RuntimeError(
1176 "Duplicate DiaObjects created after association. This is "
1177 "likely due to re-running data with an already populated "
1178 "Apdb. If this was not the case then there was an unexpected "
1179 "failure in Association while matching and creating new "
1180 "DiaObjects and should be reported. Exiting.")
1182 mergedDiaSourceHistory = self.mergeCatalogs(preloadedDiaSources, associatedDiaSources,
1183 tableName="DiaSource")
1185 # Test for DiaSource duplication first. If duplicates are found,
1186 # this likely means this is duplicate data being processed and sent
1187 # to the Apdb.
1188 if self.testDataFrameIndex(mergedDiaSourceHistory):
1189 raise RuntimeError(
1190 "Duplicate DiaSources found after association and merging "
1191 "with history. This is likely due to re-running data with an "
1192 "already populated Apdb. If this was not the case then there "
1193 "was an unexpected failure in Association while matching "
1194 "sources to objects, and should be reported. Exiting.")
1195 # Finally, update the diaObject table with the number of associated diaSources
1196 mergedUpdatedDiaObjects = self.updateObjectTable(mergedDiaObjects, mergedDiaSourceHistory)
1197 return (mergedDiaSourceHistory, mergedUpdatedDiaObjects, updatedDiaObjectIds)
1199 @timeMethod
1200 def runForcedMeasurement(self, diaObjects, updatedDiaObjects, exposure, diffIm, idGenerator):
1201 """Forced Source Measurement
1203 Forced photometry on the difference and calibrated exposures using the
1204 new and updated DiaObject locations.
1206 Parameters
1207 ----------
1208 diaObjects : `pandas.DataFrame`
1209 Catalog of DiaObjects.
1210 updatedDiaObjects : `pandas.DataFrame`
1211 Catalog of updated DiaObjects.
1212 exposure : `lsst.afw.image.ExposureF`
1213 Calibrated exposure differenced with a template to create
1214 ``diffIm``.
1215 diffIm : `lsst.afw.image.ExposureF`
1216 Difference image exposure in which the sources in ``diaSourceCat``
1217 were detected.
1218 idGenerator : `lsst.meas.base.IdGenerator`
1219 Object that generates source IDs and random number generator seeds.
1221 Returns
1222 -------
1223 diaForcedSources : `pandas.DataFrame`
1224 Catalog of calibrated forced photometered fluxes on both the
1225 difference and direct images at DiaObject locations.
1226 """
1227 # Force photometer on the Difference and Calibrated exposures using
1228 # the new and updated DiaObject locations.
1229 diaForcedSources = self.diaForcedSource.run(
1230 diaObjects,
1231 updatedDiaObjects.loc[:, "diaObjectId"].to_numpy(),
1232 exposure,
1233 diffIm,
1234 idGenerator=idGenerator)
1235 self.log.info(f"Updating {len(diaForcedSources)} diaForcedSources in the APDB")
1236 diaForcedSources = convertDataFrameToSdmSchema(self.schema, diaForcedSources,
1237 tableName="DiaForcedSource", skipIndex=True)
1238 return diaForcedSources
1240 @timeMethod
1241 def loadRefreshedDiaObjects(self, region, preloadedDiaObjects):
1242 """Load DiaObjects from the Apdb based on their HTM location.
1244 Parameters
1245 ----------
1246 region : `sphgeom.Region`
1247 Region containing the current exposure to load diaObjects from the
1248 APDB.
1249 preloadedDiaObjects : `pandas.DataFrame`
1250 Previously detected DiaObjects, loaded from the APDB.
1252 Returns
1253 -------
1254 diaObjects : `pandas.DataFrame`
1255 DiaObjects loaded from the Apdb that are within the area defined
1256 by ``pixelRanges``.
1257 """
1258 angleMargin = lsst.sphgeom.Angle.fromDegrees(self.config.angleMargin/3600.)
1259 diaObjects = self.apdb.getDiaObjects(paddedRegion(region, angleMargin))
1261 diaObjects.set_index("diaObjectId", drop=False, inplace=True)
1262 if diaObjects.index.has_duplicates:
1263 self.log.warning(
1264 "Duplicate DiaObjects loaded from the Apdb. This may cause "
1265 "downstream pipeline issues. Dropping duplicated rows")
1266 # Drop duplicates via index and keep the first appearance.
1267 diaObjects = diaObjects.groupby(diaObjects.index).first()
1268 self.log.info("Loaded %d DiaObjects", len(diaObjects))
1269 refreshedDiaObjects = convertDataFrameToSdmSchema(self.schema, diaObjects, tableName="DiaObject",
1270 skipIndex=True)
1272 refreshedIsInPreloaded = refreshedDiaObjects.index.isin(preloadedDiaObjects.index)
1273 preloadedIsInRefreshed = preloadedDiaObjects.index.isin(refreshedDiaObjects.index)
1274 nUniqueRefreshed = (~refreshedIsInPreloaded).sum()
1275 nUniquePreloaded = (~preloadedIsInRefreshed).sum()
1276 if nUniqueRefreshed > 0:
1277 self.log.info("Reloading the diaObject table during association yielded "
1278 "an additional %d objects over the %d preloaded diaObjects.",
1279 nUniqueRefreshed, len(preloadedDiaObjects))
1280 if nUniquePreloaded == 0:
1281 return refreshedDiaObjects
1282 elif nUniqueRefreshed == 0:
1283 return preloadedDiaObjects
1284 else:
1285 # We can get "preloaded" diaObjects that don't appear in the APDB with
1286 # the CI datasets in ap_verify, since those are stored as a dataset and
1287 # an empty APDB is created. In that case, combine the two catalogs.
1288 # Precedence is given to the refreshed diaObject catalog.
1289 return pd.concat([refreshedDiaObjects, preloadedDiaObjects.loc[~preloadedIsInRefreshed]])
1291 @timeMethod
1292 def writeToApdb(self, updatedDiaObjects, associatedDiaSources, diaForcedSources):
1293 """Write to the Alert Production Database (Apdb).
1295 Store DiaSources, updated DiaObjects, and DiaForcedSources in the
1296 Alert Production Database (Apdb).
1298 Parameters
1299 ----------
1300 updatedDiaObjects : `pandas.DataFrame`
1301 Catalog of updated DiaObjects.
1302 associatedDiaSources : `pandas.DataFrame`
1303 Associated DiaSources with DiaObjects.
1304 diaForcedSources : `pandas.DataFrame`
1305 Catalog of calibrated forced photometered fluxes on both the
1306 difference and direct images at DiaObject locations.
1308 Returns
1309 -------
1310 validityStart : `lsst.daf.base.DateTime`
1311 Time at which the APDB was updated.
1312 """
1313 # Store DiaSources, updated DiaObjects, and DiaForcedSources in the
1314 # Apdb.
1315 # Drop empty columns that are nullable in the APDB.
1316 diaObjectStore = dropEmptyColumns(self.schema, updatedDiaObjects, tableName="DiaObject")
1317 diaSourceStore = dropEmptyColumns(self.schema, associatedDiaSources, tableName="DiaSource")
1318 diaForcedSourceStore = dropEmptyColumns(self.schema, diaForcedSources, tableName="DiaForcedSource")
1320 validityStart = DateTime.now()
1321 self.apdb.store(
1322 validityStart.toAstropy(),
1323 diaObjectStore,
1324 diaSourceStore,
1325 diaForcedSourceStore)
1326 self.log.info("APDB updated.")
1328 return validityStart
1330 def testDataFrameIndex(self, df):
1331 """Test the sorted DataFrame index for duplicates.
1333 Wrapped as a separate function to allow for mocking of the this task
1334 in unittesting. Default of a mock return for this test is True.
1336 Parameters
1337 ----------
1338 df : `pandas.DataFrame`
1339 DataFrame to text.
1341 Returns
1342 -------
1343 `bool`
1344 True if DataFrame contains duplicate rows.
1345 """
1346 return df.index.has_duplicates
1348 def _add_association_meta_data(self,
1349 nUpdatedDiaObjects,
1350 nUnassociatedDiaObjects,
1351 nNewDiaObjects,
1352 nTotalSsObjects,
1353 nAssociatedSsObjects):
1354 """Store summaries of the association step in the task metadata.
1356 Parameters
1357 ----------
1358 nUpdatedDiaObjects : `int`
1359 Number of previous DiaObjects associated and updated in this
1360 ccdVisit.
1361 nUnassociatedDiaObjects : `int`
1362 Number of previous DiaObjects that were not associated or updated
1363 in this ccdVisit.
1364 nNewDiaObjects : `int`
1365 Number of newly created DiaObjects for this ccdVisit.
1366 nTotalSsObjects : `int`
1367 Number of SolarSystemObjects within the observable detector
1368 area.
1369 nAssociatedSsObjects : `int`
1370 Number of successfully associated SolarSystemObjects.
1371 """
1372 self.metadata['numUpdatedDiaObjects'] = nUpdatedDiaObjects
1373 self.metadata['numUnassociatedDiaObjects'] = nUnassociatedDiaObjects
1374 self.metadata['numNewDiaObjects'] = nNewDiaObjects
1375 self.metadata['numTotalSolarSystemObjects'] = nTotalSsObjects
1376 self.metadata['numAssociatedSsObjects'] = nAssociatedSsObjects
1378 def purgeDiaObjects(self, bbox, wcs, diaObjCat, diaObjectIds=None, buffer=0):
1379 """Drop diaObjects that are outside the exposure bounding box.
1381 Parameters
1382 ----------
1383 bbox : `lsst.geom.Box2I`
1384 Bounding box of the exposure.
1385 wcs : `lsst.afw.geom.SkyWcs`
1386 Coordinate system definition (wcs) for the exposure.
1387 diaObjCat : `pandas.DataFrame`
1388 DiaObjects loaded from the Apdb.
1389 buffer : `int`, optional
1390 Width, in pixels, to pad the exposure bounding box.
1392 Returns
1393 -------
1394 diaObjCat : `pandas.DataFrame`
1395 DiaObjects loaded from the Apdb, restricted to the exposure
1396 bounding box.
1397 """
1398 try:
1399 bbox.grow(buffer)
1400 raVals = diaObjCat.ra.to_numpy()
1401 decVals = diaObjCat.dec.to_numpy()
1402 xVals, yVals = wcs.skyToPixelArray(raVals, decVals, degrees=True)
1403 selector = bbox.contains(xVals, yVals)
1404 nPurged = np.sum(~selector)
1405 if nPurged > 0:
1406 if diaObjectIds is not None:
1407 # We also need to drop any of the associated IDs if this runs after association
1408 purgedIds = diaObjCat[~selector].diaObjectId
1409 diaObjectIds = diaObjectIds[~np.isin(diaObjectIds, purgedIds)]
1410 self.log.info("Dropped %i diaObjects that were outside the bbox "
1411 "after association, leaving %i in the catalog",
1412 nPurged, len(diaObjCat) - nPurged)
1413 else:
1414 self.log.info("Dropped %i diaObjects that were outside the padded bbox "
1415 "before association, leaving %i in the catalog",
1416 nPurged, len(diaObjCat) - nPurged)
1417 diaObjCat = diaObjCat[selector].copy()
1418 except Exception as e:
1419 self.log.warning("Error attempting to check diaObject history: %s", e)
1420 return diaObjCat, diaObjectIds
1422 def mergeCatalogs(self, originalCatalog, newCatalog, tableName):
1423 """Combine two catalogs, ensuring that the new catalog conforms to the schema.
1425 Parameters
1426 ----------
1427 originalCatalog : `pandas.DataFrame`
1428 The original catalog to be added to.
1429 newCatalog : `pandas.DataFrame`
1430 The new catalog to append to `originalCatalog`
1431 tableName : `str`
1432 Name of the table in the schema to use.
1434 Returns
1435 -------
1436 mergedCatalog : `pandas.DataFrame`
1437 The combined catalog, with all of the rows from ``originalCatalog``
1438 ordered before the rows of ``newCatalog``
1439 """
1440 if len(newCatalog) > 0:
1441 catalog = convertDataFrameToSdmSchema(self.schema, newCatalog,
1442 tableName=tableName, skipIndex=True)
1444 mergedCatalog = pd.concat([originalCatalog, catalog], sort=True)
1445 else:
1446 mergedCatalog = pd.concat([originalCatalog], sort=True)
1447 return mergedCatalog.loc[:, originalCatalog.columns]
1449 def updateObjectTable(self, diaObjects, diaSources):
1450 """Update the diaObject table with the new diaSource records.
1452 Parameters
1453 ----------
1454 diaObjects : `pandas.DataFrame`
1455 Table of new DiaObjects merged with their history.
1456 diaSources : `pandas.DataFrame`
1457 The combined preloaded and associated diaSource catalog.
1459 Returns
1460 -------
1461 updatedDiaObjects : `pandas.DataFrame`
1462 Table of DiaObjects updated with the number of associated DiaSources
1463 """
1464 nDiaSources = diaSources[["diaSourceId"]].groupby("diaObjectId").agg(len)
1465 nDiaSources.rename({"diaSourceId": "nDiaSources"}, errors="raise", axis="columns", inplace=True)
1466 del diaObjects["nDiaSources"]
1467 updatedDiaObjects = diaObjects.join(nDiaSources, how="left")
1468 return updatedDiaObjects
1470 @staticmethod
1471 def checkTableIndex(dataFrame, index):
1472 if dataFrame.index.name is None:
1473 # The expected index may or may not be set, depending on whether
1474 # the table was written originally as a DataFrame or something else
1475 # Parquet-friendly.
1476 dataFrame.set_index(index, drop=False, inplace=True)