Coverage for python/lsst/ap/association/diaPipe.py: 84%
482 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-28 09:56 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-28 09:56 +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#
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.loadDiaCatalogs import loadDiaObjectsFromApdb, loadDiaSourcesFromApdb, \
49 loadDiaForcedSourcesFromApdb
50from lsst.ap.association.utils import makeEmptyForcedSourceTable, getRegion, paddedRegion, readSchemaFromApdb
51from lsst.daf.base import DateTime
52from lsst.meas.base import DetectorVisitIdGeneratorConfig, \
53 DiaObjectCalculationTask
54from lsst.pipe.tasks.schemaUtils import convertDataFrameToSdmSchema, checkSdmSchemaColumns, \
55 dropEmptyColumns, make_empty_catalog
56from lsst.pipe.tasks.ssoAssociation import SolarSystemAssociationTask
57from lsst.utils.timer import timeMethod, duration_from_timeMethod
60class TooManyDiaObjectsError(pipeBase.AlgorithmError):
61 """Raised if there are an unusually large number of unassociated DiaSources.
62 This is usually indicative of an image subtraction error, and needs to be
63 caught before updating the APDB with a large number of spurious entries.
64 """
65 def __init__(self, *, nNewDiaObjects, threshold):
66 msg = ("Aborting processing before writing to the APDB."
67 f" {nNewDiaObjects} new DiaObjects would be created, which exceeds the"
68 f" configured maximum of {threshold}")
69 super().__init__(msg)
70 self.nNewDiaObjects = nNewDiaObjects
71 self.threshold = threshold
73 @property
74 def metadata(self):
75 return {"nNewDiaObjects": self.nNewDiaObjects,
76 "threshold": self.threshold
77 }
80class PostApdbUpdateError(pipeBase.AlgorithmError):
81 """Raised for any error that occurs after the APDB has been updated.
82 This allows partial outputs to be written, and signals that processing
83 can't be retried.
84 """
85 def __init__(self, *, errorMsg):
86 msg = ("Aborting processing after writing to the APDB. "
87 "This image cannot be retried! "
88 "The original error was:\n"
89 f"{errorMsg}")
90 super().__init__(msg)
92 @property
93 def metadata(self):
94 return {}
97class DiaPipelineConnections(
98 pipeBase.PipelineTaskConnections,
99 dimensions=("instrument", "visit", "detector"),
100 defaultTemplates={"coaddName": "deep", "fakesType": ""}):
101 """Butler connections for DiaPipelineTask.
102 """
103 diaSourceTable = connTypes.Input(
104 doc="Catalog of calibrated DiaSources.",
105 name="{fakesType}{coaddName}Diff_diaSrcTable",
106 storageClass="DataFrame",
107 dimensions=("instrument", "visit", "detector"),
108 )
109 solarSystemObjectTable = connTypes.Input(
110 doc="Catalog of SolarSolarSystem objects expected to be observable in "
111 "this detectorVisit.",
112 name="preloaded_SsObjects",
113 storageClass="ArrowAstropy",
114 dimensions=("instrument", "group", "detector"),
115 minimum=0,
116 )
117 diffIm = connTypes.Input(
118 doc="Difference image on which the DiaSources were detected.",
119 name="{fakesType}{coaddName}Diff_differenceExp",
120 storageClass="ExposureF",
121 dimensions=("instrument", "visit", "detector"),
122 )
123 exposure = connTypes.Input(
124 doc="Calibrated exposure differenced with a template image during "
125 "image differencing.",
126 name="{fakesType}calexp",
127 storageClass="ExposureF",
128 dimensions=("instrument", "visit", "detector"),
129 )
130 template = connTypes.Input(
131 doc="Warped template used to create `subtractedExposure`. Not PSF "
132 "matched.",
133 dimensions=("instrument", "visit", "detector"),
134 storageClass="ExposureF",
135 name="{fakesType}{coaddName}Diff_templateExp",
136 )
137 preloadedDiaObjects = connTypes.Input(
138 doc="DiaObjects preloaded from the APDB.",
139 name="preloaded_diaObjects",
140 storageClass="DataFrame",
141 dimensions=("instrument", "group", "detector"),
142 )
143 preloadedDiaSources = connTypes.Input(
144 doc="DiaSources preloaded from the APDB.",
145 name="preloaded_diaSources",
146 storageClass="DataFrame",
147 dimensions=("instrument", "group", "detector"),
148 )
149 preloadedDiaForcedSources = connTypes.Input(
150 doc="DiaForcedSources preloaded from the APDB.",
151 name="preloaded_diaForcedSources",
152 storageClass="DataFrame",
153 dimensions=("instrument", "group", "detector"),
154 )
155 apdbMarker = connTypes.Output(
156 doc="Marker dataset storing the configuration of the Apdb for each "
157 "visit/detector. Used to signal the completion of the pipeline.",
158 name="apdb_marker",
159 storageClass="Config",
160 dimensions=("instrument", "visit", "detector"),
161 )
162 associatedDiaSources = connTypes.Output(
163 doc="Optional output storing the DiaSource catalog after matching, "
164 "calibration, and standardization for insertion into the Apdb.",
165 name="{fakesType}{coaddName}Diff_assocDiaSrc",
166 storageClass="ArrowAstropy",
167 dimensions=("instrument", "visit", "detector"),
168 )
169 associatedSsSources = connTypes.Output(
170 doc="Optional output storing ssSource data computed during association.",
171 name="{fakesType}{coaddName}Diff_associatedSsSources",
172 storageClass="ArrowAstropy",
173 dimensions=("instrument", "visit", "detector"),
174 )
175 unassociatedSsObjects = connTypes.Output(
176 doc="Expected locations of an ssObject with no source",
177 name="ssUnassociatedObjects",
178 storageClass="ArrowAstropy",
179 dimensions=("instrument", "visit", "detector"),
180 )
182 diaForcedSources = connTypes.Output(
183 doc="Optional output storing the forced sources computed at the diaObject positions.",
184 name="{fakesType}{coaddName}Diff_diaForcedSrc",
185 storageClass="ArrowAstropy",
186 dimensions=("instrument", "visit", "detector"),
187 )
188 diaObjects = connTypes.Output(
189 doc="Optional output storing the updated diaObjects associated to these sources.",
190 name="{fakesType}{coaddName}Diff_diaObject",
191 storageClass="ArrowAstropy",
192 dimensions=("instrument", "visit", "detector"),
193 )
194 newDiaSources = connTypes.Output(
195 doc="New diaSources not associated with an existing diaObject that"
196 " were used to create a new diaObject",
197 name="{fakesType}new_dia_source",
198 storageClass="ArrowAstropy",
199 dimensions=("instrument", "visit", "detector"),
200 )
201 marginalDiaSources = connTypes.Output(
202 doc="Low SNR diaSources not associated with an existing diaObject that"
203 " were rejected instead of creating a new diaObject",
204 name="{fakesType}marginal_new_dia_source",
205 storageClass="ArrowAstropy",
206 dimensions=("instrument", "visit", "detector"),
207 )
209 def __init__(self, *, config=None):
210 super().__init__(config=config)
212 if not config.doWriteAssociatedSources: 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true
213 self.outputs.remove("associatedDiaSources")
214 self.outputs.remove("diaForcedSources")
215 self.outputs.remove("diaObjects")
216 self.outputs.remove("newDiaSources")
217 self.outputs.remove("marginalDiaSources")
218 else:
219 if not config.doRunForcedMeasurement:
220 self.outputs.remove("diaForcedSources")
221 if not config.filterUnAssociatedSources: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true
222 self.outputs.remove("newDiaSources")
223 self.outputs.remove("marginalDiaSources")
224 if not config.doSolarSystemAssociation:
225 self.inputs.remove("solarSystemObjectTable")
226 if (not config.doWriteAssociatedSources) or (not config.doSolarSystemAssociation):
227 self.outputs.remove("associatedSsSources")
228 self.outputs.remove("unassociatedSsObjects")
229 if config.doReloadAllApdbCatalogs:
230 # If this is set, the complete history will be read from the APDB
231 # during association and the preloaded catalogs will not be used.
232 self.inputs.remove("preloadedDiaObjects")
233 self.inputs.remove("preloadedDiaSources")
234 self.inputs.remove("preloadedDiaForcedSources")
236 def adjustQuantum(self, inputs, outputs, label, dataId):
237 """Override to make adjustments to `lsst.daf.butler.DatasetRef` objects
238 in the `lsst.daf.butler.core.Quantum` during the graph generation stage
239 of the activator.
241 This implementation checks to make sure that the filters in the dataset
242 are compatible with AP processing as set by the Apdb/DPDD schema.
244 Parameters
245 ----------
246 inputs : `dict`
247 Dictionary whose keys are an input (regular or prerequisite)
248 connection name and whose values are a tuple of the connection
249 instance and a collection of associated `DatasetRef` objects.
250 The exact type of the nested collections is unspecified; it can be
251 assumed to be multi-pass iterable and support `len` and ``in``, but
252 it should not be mutated in place. In contrast, the outer
253 dictionaries are guaranteed to be temporary copies that are true
254 `dict` instances, and hence may be modified and even returned; this
255 is especially useful for delegating to `super` (see notes below).
256 outputs : `dict`
257 Dict of output datasets, with the same structure as ``inputs``.
258 label : `str`
259 Label for this task in the pipeline (should be used in all
260 diagnostic messages).
261 data_id : `lsst.daf.butler.DataCoordinate`
262 Data ID for this quantum in the pipeline (should be used in all
263 diagnostic messages).
265 Returns
266 -------
267 adjusted_inputs : `dict`
268 Dict of the same form as ``inputs`` with updated containers of
269 input `DatasetRef` objects. Connections that are not changed
270 should not be returned at all. Datasets may only be removed, not
271 added. Nested collections may be of any multi-pass iterable type,
272 and the order of iteration will set the order of iteration within
273 `PipelineTask.runQuantum`.
274 adjusted_outputs : `dict`
275 Dict of updated output datasets, with the same structure and
276 interpretation as ``adjusted_inputs``.
278 Raises
279 ------
280 ScalarError
281 Raised if any `Input` or `PrerequisiteInput` connection has
282 ``multiple`` set to `False`, but multiple datasets.
283 NoWorkFound
284 Raised to indicate that this quantum should not be run; not enough
285 datasets were found for a regular `Input` connection, and the
286 quantum should be pruned or skipped.
287 FileNotFoundError
288 Raised to cause QuantumGraph generation to fail (with the message
289 included in this exception); not enough datasets were found for a
290 `PrerequisiteInput` connection.
291 """
292 _, refs = inputs["diffIm"]
293 for ref in refs:
294 if ref.dataId["band"] not in self.config.validBands:
295 raise ValueError(
296 f"Requested '{ref.dataId['band']}' not in "
297 "DiaPipelineConfig.validBands. To process bands not in "
298 "the standard Rubin set (ugrizy) you must add the band to "
299 "the validBands list in DiaPipelineConfig and add the "
300 "appropriate columns to the Apdb schema.")
301 return super().adjustQuantum(inputs, outputs, label, dataId)
304class DiaPipelineConfig(pipeBase.PipelineTaskConfig,
305 pipelineConnections=DiaPipelineConnections):
306 """Config for DiaPipelineTask.
307 """
308 coaddName = pexConfig.Field(
309 doc="coadd name: typically one of deep, goodSeeing, or dcr",
310 dtype=str,
311 default="deep",
312 )
313 apdb_config_url = pexConfig.Field(
314 dtype=str,
315 default=None,
316 optional=False,
317 doc="A config file specifying the APDB and its connection parameters, "
318 "typically written by the apdb-cli command-line utility. "
319 "The database must already be initialized.",
320 )
321 validBands = pexConfig.ListField(
322 dtype=str,
323 default=["u", "g", "r", "i", "z", "y"],
324 doc="List of bands that are valid for AP processing. To process a "
325 "band not on this list, the appropriate band specific columns "
326 "must be added to the Apdb schema in dax_apdb.",
327 )
328 associator = pexConfig.ConfigurableField(
329 target=AssociationTask,
330 doc="Task used to associate DiaSources with DiaObjects.",
331 )
332 doSolarSystemAssociation = pexConfig.Field(
333 dtype=bool,
334 default=True,
335 doc="Process SolarSystem objects through the pipeline.",
336 )
337 solarSystemAssociator = pexConfig.ConfigurableField(
338 target=SolarSystemAssociationTask,
339 doc="Task used to associate DiaSources with SolarSystemObjects.",
340 )
341 diaCalculation = pexConfig.ConfigurableField(
342 target=DiaObjectCalculationTask,
343 doc="Task to compute summary statistics for DiaObjects.",
344 )
345 doReloadDiaObjects = pexConfig.Field(
346 dtype=bool,
347 default=True,
348 doc="Drop preloaded DiaObjects and reload them from the APDB?"
349 "Used in production when the very latest objects from the APDB "
350 "are needed. Ignored and superceded if `doReloadAllApdbCatalogs` "
351 "is set.",
352 )
353 doReloadAllApdbCatalogs = pexConfig.Field(
354 dtype=bool,
355 default=False,
356 doc="Read the complete DiaObject, DiaSource, and DiaForcedSource "
357 "history from the APDB during association? Use only during "
358 "reprocessing, and never in Prompt Processing.",
359 )
360 angleMargin = pexConfig.RangeField(
361 doc="Padding to add when reloading catalogs from the APDB. "
362 "Smaller than the padding used for ``LoadDiaCatalogsTask`` because "
363 "this task is after astrometric calibration.",
364 dtype=float,
365 default=2,
366 min=0,
367 )
368 doRunForcedMeasurement = pexConfig.Field(
369 dtype=bool,
370 default=True,
371 deprecated="Added to allow disabling forced sources for performance "
372 "reasons during the ops rehearsal. "
373 "It is expected to be removed.",
374 doc="Run forced measurement on all of the diaObjects? "
375 "This should only be turned off for debugging purposes.",
376 )
377 diaForcedSource = pexConfig.ConfigurableField(
378 target=DiaForcedSourceTask,
379 doc="Task used for force photometer DiaObject locations in direct and "
380 "difference images.",
381 )
382 forcedReliabilityThreshold = pexConfig.Field(
383 dtype=float,
384 default=0.5,
385 doc="Minimum reliability score of constituent diaSources to use when "
386 "determining whether to calculate forced photometry for a "
387 "diaObject.",
388 )
389 forcedTrailLengthThreshold = pexConfig.Field(
390 dtype=float,
391 default=1.4,
392 doc="Maximum trail length (arseconds) of constituent diaSources to use "
393 "when determining whether to calculate forced photometry for a "
394 "diaObject.",
395 )
396 forcedBadFlags = pexConfig.ListField(
397 dtype=str,
398 default=["glint_trail", "isDipole"],
399 doc="Flags of constituent diaSources to exclude when determining "
400 "whether to calculate forced photometry for a diaObject.",
401 )
402 alertPackager = pexConfig.ConfigurableField(
403 target=PackageAlertsTask,
404 doc="Subtask for packaging Ap data into alerts.",
405 )
406 doPackageAlerts = pexConfig.Field(
407 dtype=bool,
408 default=False,
409 doc="Package Dia-data into serialized alerts for distribution and "
410 "write them to disk.",
411 )
412 doWriteAssociatedSources = pexConfig.Field(
413 dtype=bool,
414 default=True,
415 doc="Write out associated DiaSources, DiaForcedSources, and DiaObjects, "
416 "formatted following the Science Data Model.",
417 )
418 imagePixelMargin = pexConfig.RangeField(
419 dtype=int,
420 default=10,
421 min=0,
422 doc="Pad the image by this many pixels before removing off-image "
423 "diaObjects for association.",
424 )
425 filterUnAssociatedSources = pexConfig.Field(
426 dtype=bool,
427 default=True,
428 doc="Check unassociated diaSources for quality before creating new ."
429 "diaObjects. DiaSources that are associated with existing diaObjects "
430 "will not be affected."
431 )
432 newObjectBadFlags = pexConfig.ListField(
433 dtype=str,
434 default=("centroid_flag",
435 "pixelFlags_crCenter",
436 "pixelFlags_nodataCenter",
437 "pixelFlags_interpolatedCenter",
438 "pixelFlags_saturatedCenter",
439 "pixelFlags_suspectCenter",
440 "pixelFlags_streakCenter",
441 "glint_trail"),
442 doc="If `filterUnAssociatedSources` is set, exclude diaSources with "
443 "these flags set before creating new diaObjects.",
444 )
445 maxNewDiaObjects = pexConfig.RangeField(
446 dtype=float,
447 default=0,
448 min=0,
449 doc="Maximum number of new DiaObjects to create before raising an error."
450 "Set to zero to disable.",
451 )
452 newObjectSnrThreshold = pexConfig.RangeField(
453 dtype=float,
454 default=0,
455 min=0,
456 doc="If `filterUnAssociatedSources` is set, exclude diaSources with "
457 "Abs(flux/fluxErr) less than this threshold before creating new "
458 "diaObjects."
459 "Set to zero to disable.",
460 )
461 newObjectLowReliabilitySnrThreshold = pexConfig.RangeField(
462 dtype=float,
463 default=0,
464 min=0,
465 doc="If `filterUnAssociatedSources` is set, exclude diaSources with "
466 "signal-to-noise ratios less than this threshold if they have"
467 " low reliability scores before creating new diaObjects."
468 "Set to zero to disable.",
469 )
470 newObjectReliabilityThreshold = pexConfig.RangeField(
471 dtype=float,
472 default=0.1,
473 min=0,
474 max=1,
475 doc="If `filterUnAssociatedSources` is set, exclude diaSources with "
476 "reliability scores less than this threshold before creating new "
477 "diaObjects."
478 "Set to zero to disable.",
479 )
480 newObjectLowSnrReliabilityThreshold = pexConfig.RangeField(
481 dtype=float,
482 default=0.1,
483 min=0,
484 max=1,
485 doc="If `filterUnAssociatedSources` is set, exclude diaSources with "
486 "low signal-to-noise and reliability scores below this threshold "
487 "before creating new diaObjects."
488 "Set to zero to disable.",
489 )
490 newObjectFluxField = pexConfig.Field(
491 dtype=str,
492 default="apFlux",
493 doc="Name of the flux field in the standardized diaSource catalog to "
494 "use for checking the signal-to-noise before creating new diaObjects.",
495 )
496 idGenerator = DetectorVisitIdGeneratorConfig.make_field()
498 def setDefaults(self):
499 self.diaCalculation.plugins = ["ap_meanPosition",
500 "ap_nDiaSources",
501 "ap_meanFlux",
502 "ap_sigmaFlux",
503 "ap_minMaxFlux",
504 "ap_maxSlopeFlux",
505 "ap_meanErrFlux",
506 "ap_meanTotFlux"]
509class DiaPipelineTask(pipeBase.PipelineTask):
510 """Task for loading, associating and storing Difference Image Analysis
511 (DIA) Objects and Sources.
512 """
513 ConfigClass = DiaPipelineConfig
514 _DefaultName = "diaPipe"
516 def __init__(self, initInputs=None, **kwargs):
517 super().__init__(**kwargs)
518 self.apdb = daxApdb.Apdb.from_uri(self.config.apdb_config_url)
519 self.schema = readSchemaFromApdb(self.apdb)
520 self.makeSubtask("associator")
521 self.makeSubtask("diaCalculation")
522 if self.config.doRunForcedMeasurement:
523 self.makeSubtask("diaForcedSource")
524 if self.config.doPackageAlerts:
525 self.makeSubtask("alertPackager")
526 if self.config.doSolarSystemAssociation:
527 self.makeSubtask("solarSystemAssociator")
528 if self.config.filterUnAssociatedSources:
529 columns = [self.config.newObjectFluxField,
530 self.config.newObjectFluxField + "Err",
531 "reliability"]
532 columns += self.config.newObjectBadFlags
534 missing = checkSdmSchemaColumns(self.schema, columns, "DiaSource")
535 if missing:
536 raise pipeBase.InvalidQuantumError("Field %s not in the DiaSource schema" % missing)
538 def runQuantum(self, butlerQC, inputRefs, outputRefs):
539 inputs = butlerQC.get(inputRefs)
540 inputs["idGenerator"] = self.config.idGenerator.apply(butlerQC.quantum.dataId)
541 inputs["band"] = butlerQC.quantum.dataId["band"]
542 inputs["legacySolarSystemTable"] = None
543 if not self.config.doSolarSystemAssociation: 543 ↛ 544line 543 didn't jump to line 544 because the condition on line 543 was never true
544 inputs["solarSystemObjectTable"] = None
546 associationResults = pipeBase.Struct(
547 apdbMarker=None,
548 associatedDiaSources=None,
549 diaForcedSources=None,
550 diaObjects=None,
551 associatedSsSources=None,
552 unassociatedSsObjects=None,
553 newDiaSources=None,
554 marginalDiaSources=None,
555 )
556 try:
557 self.run(**inputs, associationResults=associationResults)
558 except pipeBase.AlgorithmError as e:
559 error = pipeBase.AnnotatedPartialOutputsError.annotate(
560 e,
561 self,
562 log=self.log
563 )
564 butlerQC.put(associationResults, outputRefs)
565 raise error from e
566 butlerQC.put(associationResults, outputRefs)
568 @timeMethod
569 def run(self,
570 diaSourceTable,
571 legacySolarSystemTable,
572 diffIm,
573 exposure,
574 template,
575 preloadedDiaObjects=None,
576 preloadedDiaSources=None,
577 preloadedDiaForcedSources=None,
578 band=None,
579 idGenerator=None,
580 solarSystemObjectTable=None,
581 associationResults=None):
582 """Process DiaSources and DiaObjects.
584 Load previous DiaObjects and their DiaSource history. Calibrate the
585 values in the diaSourceCat. Associate new DiaSources with previous
586 DiaObjects. Run forced photometry at the updated DiaObject locations.
587 Store the results in the Alert Production Database (Apdb).
589 Parameters
590 ----------
591 diaSourceTable : `pandas.DataFrame`
592 Newly detected DiaSources.
593 legacySolarSystemTable : `pandas.DataFrame`
594 Not used
595 diffIm : `lsst.afw.image.ExposureF`
596 Difference image exposure in which the sources in ``diaSourceCat``
597 were detected.
598 exposure : `lsst.afw.image.ExposureF`
599 Calibrated exposure differenced with a template to create
600 ``diffIm``.
601 template : `lsst.afw.image.ExposureF`
602 Template exposure used to create diffIm.
603 preloadedDiaObjects : `pandas.DataFrame`, optional
604 Previously detected DiaObjects, loaded from the APDB.
605 `None` if ``doReloadAllApdbCatalogs`` is set.
606 preloadedDiaSources : `pandas.DataFrame`, optional
607 Previously detected DiaSources, loaded from the APDB.
608 `None` if ``doReloadAllApdbCatalogs`` is set.
609 preloadedDiaForcedSources : `pandas.DataFrame`, optional
610 Catalog of previously detected forced DiaSources, from the APDB.
611 `None` if ``doReloadAllApdbCatalogs`` is set.
612 band : `str`
613 The band in which the new DiaSources were detected. Required,
614 despite the `None` default.
615 idGenerator : `lsst.meas.base.IdGenerator`
616 Object that generates source IDs and random number generator seeds.
617 Required, despite the `None` default.
618 solarSystemObjectTable : `astropy.table.Table`, optional
619 Preloaded Solar System objects expected to be visible in the image.
620 associationResults : `lsst.pipe.base.Struct`, optional
621 Result struct that is modified to allow saving of partial outputs
622 for some failure conditions. If the task completes successfully,
623 this is also returned.
625 Returns
626 -------
627 associationResults : `lsst.pipe.base.Struct`
628 Results struct with components.
630 - ``apdbMarker`` : Marker dataset to store in the Butler indicating
631 that this ccdVisit has completed successfully.
632 (`lsst.dax.apdb.ApdbConfig`)
633 - ``associatedDiaSources`` : Catalog of newly associated
634 DiaSources. (`pandas.DataFrame`)
635 - ``diaForcedSources`` : Catalog of new and previously detected
636 forced DiaSources. (`pandas.DataFrame`)
637 - ``diaObjects`` : Updated table of DiaObjects. (`pandas.DataFrame`)
638 - ``associatedSsSources`` : Catalog of ssSource records.
639 (`pandas.DataFrame`)
641 Raises
642 ------
643 RuntimeError
644 Raised if duplicate DiaObjects or duplicate DiaSources are found.
645 ValueError
646 Raised if ``band`` or ``idGenerator`` is `None`.
647 """
648 # These default to `None` only so that the preloaded catalogs, which
649 # precede them, can be omitted.
650 if band is None:
651 raise ValueError("band is required.")
652 if idGenerator is None:
653 raise ValueError("idGenerator is required.")
655 if associationResults is None: 655 ↛ 657line 655 didn't jump to line 657 because the condition on line 655 was always true
656 associationResults = pipeBase.Struct()
657 self._validateExposure(exposure, "Science")
658 self._validateExposure(diffIm, "Difference")
659 self._validateExposure(template, "Template")
661 # Accept either legacySolarSystemTable or optional solarSystemObjectTable.
662 if legacySolarSystemTable is not None and solarSystemObjectTable is None: 662 ↛ 663line 662 didn't jump to line 663 because the condition on line 662 was never true
663 solarSystemObjectTable = Table.from_pandas(legacySolarSystemTable)
664 region = getRegion(exposure)
665 if self.config.doReloadDiaObjects or self.config.doReloadAllApdbCatalogs:
666 try:
667 preloadedDiaObjects = self.loadRefreshedDiaObjects(region, preloadedDiaObjects)
668 except Exception as e:
669 if self.config.doReloadAllApdbCatalogs:
670 # We can't continue if the diaObjects were not loaded and
671 # ``doReloadAllApdbCatalogs`` is set, because there will be
672 # no preloaded diaObjects to fall back on.
673 raise
674 self.log.warning("Error encountered while attempting to load "
675 "the latest diaObjects from the APDB. Processing "
676 "will continue with only the diaObjects from "
677 "preload.", exc_info=e)
678 finally:
679 self.metadata["loadDiaObjectsDuration"] = duration_from_timeMethod(
680 self.metadata, "loadRefreshedDiaObjects", clock="Utc"
681 )
682 self.log.verbose("Re-loading DiaObjects: Took %.4f seconds",
683 self.metadata["loadDiaObjectsDuration"])
685 else:
686 self.metadata["loadDiaObjectsDuration"] = -1
688 if self.config.doReloadAllApdbCatalogs:
689 # The metadata time records must use the same names as
690 # LoadDiaCatalogsTask to keep the metrics upload consistent.
691 visitTime = exposure.visitInfo.date.toAstropy()
692 try:
693 preloadedDiaSources = self.loadRefreshedDiaSources(region, preloadedDiaObjects, visitTime)
694 finally:
695 self.metadata["loadDiaSourcesDuration"] = duration_from_timeMethod(
696 self.metadata, "loadRefreshedDiaSources", clock="Utc"
697 )
698 self.log.verbose("Re-loading DiaSources: Took %.4f seconds",
699 self.metadata["loadDiaSourcesDuration"])
700 try:
701 preloadedDiaForcedSources = self.loadRefreshedDiaForcedSources(
702 region, preloadedDiaObjects, visitTime)
703 finally:
704 self.metadata["loadDiaForcedSourcesDuration"] = duration_from_timeMethod(
705 self.metadata, "loadRefreshedDiaForcedSources", clock="Utc"
706 )
707 self.log.verbose("Re-loading DiaForcedSources: Took %.4f seconds",
708 self.metadata["loadDiaForcedSourcesDuration"])
709 else:
710 self.metadata["loadDiaSourcesDuration"] = -1
711 self.metadata["loadDiaForcedSourcesDuration"] = -1
713 self.checkTableIndex(preloadedDiaSources, index=["diaObjectId", "band", "diaSourceId"])
714 self.checkTableIndex(preloadedDiaObjects, index="diaObjectId")
715 self.checkTableIndex(preloadedDiaForcedSources, index=["diaObjectId", "diaForcedSourceId"])
717 if not preloadedDiaObjects.empty: 717 ↛ 722line 717 didn't jump to line 722 because the condition on line 717 was always true
718 # Include a small buffer outside the image so that we can associate sources near the edge
719 diaObjects, _ = self.purgeDiaObjects(diffIm.getBBox(), diffIm.getWcs(), preloadedDiaObjects,
720 buffer=self.config.imagePixelMargin)
721 else:
722 self.log.info("Preloaded DiaObject table is empty.")
723 diaObjects = preloadedDiaObjects
725 # Associate DiaSources with DiaObjects
726 assocResults = self.associateDiaSources(diaSourceTable, solarSystemObjectTable, diffIm, diaObjects)
728 # Set unassociated diaObjectIds and ssObjectIds to NULL, and convert to SDM schema format
729 standardizedAssociatedDiaSources = self.standardizeDataFrame(
730 assocResults.associatedDiaSources,
731 "DiaSource",
732 nullColumns=["diaObjectId", "ssObjectId"]
733 )
735 # Merge associated diaSources
736 mergedDiaSourceHistory, mergedDiaObjects, updatedDiaObjectIds = self.mergeAssociatedCatalogs(
737 preloadedDiaSources,
738 assocResults.associatedDiaSources,
739 diaObjects,
740 assocResults.newDiaObjects,
741 diffIm
742 )
744 # Compute DiaObject Summary statistics from their full DiaSource
745 # history.
746 diaCalResult = self.diaCalculation.run(
747 mergedDiaObjects,
748 mergedDiaSourceHistory,
749 updatedDiaObjectIds,
750 self.config.validBands)
751 updatedDiaObjects = convertDataFrameToSdmSchema(self.schema, diaCalResult.updatedDiaObjects,
752 tableName="DiaObject", skipIndex=True)
754 # Test for duplication in the updated DiaObjects.
755 if self.testDataFrameIndex(diaCalResult.diaObjectCat): 755 ↛ 756line 755 didn't jump to line 756 because the condition on line 755 was never true
756 raise RuntimeError(
757 "Duplicate DiaObjects (loaded + updated) created after "
758 "DiaCalculation. This is unexpected behavior and should be "
759 "reported. Exiting.")
760 if self.testDataFrameIndex(updatedDiaObjects): 760 ↛ 761line 760 didn't jump to line 761 because the condition on line 760 was never true
761 raise RuntimeError(
762 "Duplicate DiaObjects (updated) created after "
763 "DiaCalculation. This is unexpected behavior and should be "
764 "reported. Exiting.")
766 # Forced source measurement
767 if self.config.doRunForcedMeasurement:
768 diaObjectsForced = self._selectGoodDiaObjects(diaCalResult.diaObjectCat, mergedDiaSourceHistory)
769 diaForcedSources = self.runForcedMeasurement(
770 diaObjectsForced, updatedDiaObjects, exposure, diffIm, idGenerator
771 )
772 forcedSourceHistoryThreshold = self.diaForcedSource.config.historyThreshold
773 else:
774 # alertPackager needs correct columns
775 diaForcedSources = makeEmptyForcedSourceTable(self.schema)
776 forcedSourceHistoryThreshold = 0
778 # Write results to Alert Production Database (APDB)
779 try:
780 validityStart = self.writeToApdb(updatedDiaObjects, standardizedAssociatedDiaSources,
781 diaForcedSources)
782 finally:
783 self.metadata["writeToApdbDuration"] = duration_from_timeMethod(self.metadata, "writeToApdb",
784 clock="Utc")
785 # A single log message is easier for Loki to parse than timeMethod's start+end pairs.
786 self.log.verbose("writeToApdb: Took %.4f seconds", self.metadata["writeToApdbDuration"])
788 # For historical reasons, apdbMarker is a Config even if it's not meant to be read.
789 # A default Config is the cheapest way to satisfy the storage class.
790 associationResults.apdbMarker = pexConfig.Config()
791 associationResults.associatedDiaSources = assocResults.associatedDiaSources
792 associationResults.diaForcedSources = diaForcedSources
793 associationResults.diaObjects = diaCalResult.diaObjectCat
794 associationResults.unassociatedSsObjects = assocResults.unassociatedSsObjects
795 associationResults.newDiaSources = assocResults.newDiaSources
796 associationResults.marginalDiaSources = assocResults.marginalDiaSources
798 # Catch *any* error after we have updated the APDB
799 try:
800 associatedSsSources = assocResults.associatedSsSources
801 associatedSsSourcesPlusMpcorb = None
802 if associatedSsSources is not None:
803 mpcorbColumns = [col for col in associatedSsSources.columns if col[:7] == 'MPCORB_']
804 associatedSsSourceMpcorb = associatedSsSources[mpcorbColumns].copy()
805 associatedSsSources = self.standardizeTable(associatedSsSources, "SSSource", nullColumns=[])
806 associatedSsSourcesPlusMpcorb = associatedSsSources.copy()
807 for mpcorbColumn in mpcorbColumns: 807 ↛ 808line 807 didn't jump to line 808 because the loop on line 807 never started
808 associatedSsSourcesPlusMpcorb[mpcorbColumn] = associatedSsSourceMpcorb[mpcorbColumn]
809 associationResults.associatedSsSources = associatedSsSources
811 # patch the otherwise-empty validityStart field for the alerts
812 updatedDiaObjects['validityStartMjdTai'] = validityStart.get(system=DateTime.MJD,
813 scale=DateTime.TAI)
815 # Package alerts
816 if self.config.doPackageAlerts:
817 # Append new forced sources to the full history
818 diaForcedSourcesFull = self.mergeCatalogs(preloadedDiaForcedSources, diaForcedSources,
819 tableName="DiaForcedSource")
820 if self.testDataFrameIndex(diaForcedSourcesFull): 820 ↛ 821line 820 didn't jump to line 821 because the condition on line 820 was never true
821 self.log.warning(
822 "Duplicate DiaForcedSources created after merge with "
823 "history and new sources. This may cause downstream "
824 "problems. Dropping duplicates.")
825 # Drop duplicates via index and keep the first appearance.
826 diaForcedSourcesFull = diaForcedSourcesFull[
827 ~diaForcedSourcesFull.index.duplicated(keep="first")]
829 self.alertPackager.run(assocResults.associatedDiaSources,
830 updatedDiaObjects,
831 preloadedDiaSources,
832 diaForcedSourcesFull,
833 diffIm,
834 exposure,
835 template,
836 ssSrc=associatedSsSourcesPlusMpcorb,
837 doRunForcedMeasurement=self.config.doRunForcedMeasurement,
838 forcedSourceHistoryThreshold=forcedSourceHistoryThreshold,
839 )
840 except Exception as e:
841 # Catch *any* error after we have updated the APDB
842 raise PostApdbUpdateError(errorMsg=repr(e)) from e
844 return associationResults
846 def _validateExposure(self, exposure, label):
847 """Validate exposure metadata early to avoid failures after updating
848 the APDB.
850 Parameters
851 ----------
852 exposure : `lsst.afw.image.ExposureF`
853 Calibrated science image that was subtracted. Corresponds to the
854 ``exposure`` Butler connection (``{fakesType}calexp``).
855 label : `str`
856 Specification of the image being validated, for logging messages.
858 Raises
859 ------
860 ValueError
861 Raised when any required metadata field is missing or invalid.
862 """
863 errors = []
865 photo_calib = exposure.getPhotoCalib()
866 if photo_calib is None: 866 ↛ 867line 866 didn't jump to line 867 because the condition on line 866 was never true
867 errors.append(
868 f"{label}: PhotoCalib is None; flux-to-nJy calibration "
869 "in alert packaging will fail."
870 )
871 else:
872 mean = photo_calib.getCalibrationMean()
873 if not np.isfinite(mean) or mean <= 0.0: 873 ↛ 874line 873 didn't jump to line 874 because the condition on line 873 was never true
874 errors.append(
875 f"{label}: PhotoCalib calibration mean is {mean!r}; "
876 "must be a finite, positive value."
877 )
879 if exposure.getWcs() is None: 879 ↛ 880line 879 didn't jump to line 880 because the condition on line 879 was never true
880 errors.append(
881 f"{label}: WCS is None; sky-to-pixel projection and "
882 "DIA Object association will fail."
883 )
885 # Skip visitInfo checks for the template
886 if label != "Template":
887 visit_info = exposure.getInfo().getVisitInfo()
888 if visit_info 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}: VisitInfo is None; observation time, pointing, "
891 "rotation angle, and exposure duration are unavailable."
892 )
893 else:
894 obs_date = visit_info.getDate()
895 if not obs_date.isValid(): 895 ↛ 896line 895 didn't jump to line 896 because the condition on line 895 was never true
896 errors.append(
897 f"{label}: VisitInfo.date is invalid."
898 )
900 # boresightRaDec — written to APDB rows and alert payloads.
901 boresight = visit_info.getBoresightRaDec()
902 ra_deg = boresight.getRa().asDegrees()
903 dec_deg = boresight.getDec().asDegrees()
904 if not np.isfinite(ra_deg) or not np.isfinite(dec_deg): 904 ↛ 905line 904 didn't jump to line 905 because the condition on line 904 was never true
905 errors.append(
906 f"{label}: VisitInfo.boresightRaDec contains NaN "
907 f"(RA={ra_deg}, Dec={dec_deg}); pointing metadata "
908 "is missing or corrupt."
909 )
911 rot_deg = visit_info.getBoresightRotAngle().asDegrees()
912 if not np.isfinite(rot_deg): 912 ↛ 913line 912 didn't jump to line 913 because the condition on line 912 was never true
913 errors.append(
914 f"{label}: VisitInfo.boresightRotAngle is NaN; the "
915 "ROTPA keyword will be corrupt in all alert cutout "
916 "FITS headers."
917 )
919 filter_label = exposure.getFilter()
920 if filter_label is None: 920 ↛ 921line 920 didn't jump to line 921 because the condition on line 920 was never true
921 errors.append(
922 f"{label}: filter label is None."
923 )
924 else:
925 band = filter_label.bandLabel if filter_label.hasBandLabel() else ""
926 if not band: 926 ↛ 927line 926 didn't jump to line 927 because the condition on line 926 was never true
927 errors.append(
928 f"{label}: filter label carries no band name."
929 )
931 bbox = exposure.getBBox()
932 if bbox.getWidth() <= 0 or bbox.getHeight() <= 0: 932 ↛ 933line 932 didn't jump to line 933 because the condition on line 932 was never true
933 errors.append(
934 f"{label}: bounding box is empty "
935 f"({bbox.getWidth()}×{bbox.getHeight()} px); the "
936 "readout or ISR step may have failed."
937 )
939 if exposure.getPsf() is None: 939 ↛ 940line 939 didn't jump to line 940 because the condition on line 939 was never true
940 errors.append(
941 f"{label}: PSF model is None."
942 )
944 if errors: 944 ↛ 945line 944 didn't jump to line 945 because the condition on line 944 was never true
945 detail = "\n ".join(errors)
946 raise ValueError(
947 "Input exposure metadata is invalid; aborting before any "
948 "APDB writes to prevent non-recoverable data corruption:\n "
949 + detail
950 )
952 def _selectGoodDiaObjects(self, diaObjects, diaSources):
953 """
954 Extract a subset of diaObjects with multiple associated diaSources that
955 pass selection requirements.
956 """
957 n_history = self.diaForcedSource.config.historyThreshold
958 required_columns = ["reliability", "trailLength"]
959 for col in required_columns:
960 if col not in diaSources.columns:
961 raise RuntimeError("Required column '%s' missing from diaSource table." % col)
963 rel_ok = diaSources["reliability"] >= self.config.forcedReliabilityThreshold
964 trailed_ok = diaSources["trailLength"] <= self.config.forcedTrailLengthThreshold
965 badFlags = [f for f in self.config.forcedBadFlags if f in diaSources.columns]
966 if badFlags:
967 self.log.info("Excluding diaSources with %s flags set when calculating"
968 " diaObject history for forced photometry." % badFlags)
969 # Exclude rows where ANY bad flag is True.
970 # If bad flag columns can contain NA, treat NA as True (i.e., bad) via fillna(True).
971 bad_any = diaSources[badFlags].fillna(True).any(axis=1)
972 mask = rel_ok & trailed_ok & ~bad_any
973 ids = diaSources.index.get_level_values("diaObjectId")[mask]
975 # Count occurrences in diaSource
976 counts = pd.Series(ids, copy=False).value_counts()
978 # IDs that exceed threshold
979 keep_ids = counts[counts >= n_history].index
981 # Since diaObject index is unique, direct index filtering is safe and efficient
982 return diaObjects.loc[diaObjects.index.intersection(keep_ids)]
984 def createNewDiaObjects(self, unassociatedDiaSources):
985 """Loop through the set of DiaSources and create new DiaObjects
986 for unassociated DiaSources.
988 Parameters
989 ----------
990 unassociatedDiaSources : `pandas.DataFrame`
991 Set of DiaSources to create new DiaObjects from.
993 Returns
994 -------
995 results : `lsst.pipe.base.Struct`
996 Results struct containing:
998 - diaSources : `pandas.DataFrame`
999 DiaSource catalog with updated DiaObject ids.
1000 - newDiaObjects : `pandas.DataFrame`
1001 Newly created DiaObjects from the unassociated DiaSources.
1002 - nNewDiaObjects : `int`
1003 Number of newly created diaObjects.
1004 - marginalDiaSources : `pandas.DataFrame`
1005 Unassociated diaSources with low signal-to-noise and/or
1006 reliability, which are excluded from the new DiaObjects.
1007 """
1008 marginalDiaSources = make_empty_catalog(self.schema, tableName="DiaSource")
1009 if len(unassociatedDiaSources) == 0:
1010 newDiaObjects = make_empty_catalog(self.schema, tableName="DiaObject")
1011 else:
1012 if self.config.filterUnAssociatedSources:
1013 results = self.filterSources(unassociatedDiaSources)
1014 unassociatedDiaSources = results.goodSources
1015 marginalDiaSources = results.badSources
1016 unassociatedDiaSources["diaObjectId"] = unassociatedDiaSources["diaSourceId"]
1017 newDiaObjects = convertDataFrameToSdmSchema(self.schema, unassociatedDiaSources,
1018 tableName="DiaObject", skipIndex=True)
1019 self.metadata["nRejectedNewDiaObjects"] = len(marginalDiaSources)
1020 return pipeBase.Struct(diaSources=unassociatedDiaSources,
1021 newDiaObjects=newDiaObjects,
1022 nNewDiaObjects=len(newDiaObjects),
1023 marginalDiaSources=marginalDiaSources)
1025 def filterSources(self, sources, snrThreshold=None, lowReliabilitySnrThreshold=None,
1026 reliabilityThreshold=None, lowSnrReliabilityThreshold=None, badFlags=None):
1027 """Select good sources out of a catalog.
1029 Parameters
1030 ----------
1031 sources : `pandas.DataFrame`
1032 Set of DiaSources to check.
1033 snrThreshold : `float`, optional
1034 The minimum signal to noise diaSource to make a new diaObject.
1035 Included for unit tests. Uses the task config value if not set.
1036 lowReliabilitySnrThreshold : `float`, optional
1037 Use ``lowSnrReliabilityThreshold`` as the reliability threshold for
1038 diaSources with ``snrThreshold`` < SNR < ``lowReliabilitySnrThreshold``
1039 Included for unit tests. Uses the task config value if not set.
1040 reliabilityThreshold : `float`, optional
1041 The minimum reliability score diaSource to make a new diaObject
1042 Included for unit tests. Uses the task config value if not set.
1043 lowSnrReliabilityThreshold : `float`, optional
1044 Use ``lowSnrReliabilityThreshold`` as the reliability threshold for
1045 diaSources with ``snrThreshold`` < SNR < ``lowReliabilitySnrThreshold``
1046 Included for unit tests. Uses the task config value if not set.
1047 badFlags : `list` of `str`, optional
1048 Do not create new diaObjects for any diaSource with any of these
1049 flags set.
1050 Included for unit tests. Uses the task config value if not set.
1052 Returns
1053 -------
1054 results : `lsst.pipe.base.Struct`
1055 Results struct containing:
1057 - goodSources : `pandas.DataFrame`
1058 Subset of the input sources that pass all checks.
1059 - badSources : `pandas.DataFrame`
1060 Subset of the input sources that fail any check.
1061 """
1062 if snrThreshold is None:
1063 snrThreshold = self.config.newObjectSnrThreshold
1064 if lowReliabilitySnrThreshold is None:
1065 lowReliabilitySnrThreshold = self.config.newObjectLowReliabilitySnrThreshold
1066 if reliabilityThreshold is None:
1067 reliabilityThreshold = self.config.newObjectReliabilityThreshold
1068 if lowSnrReliabilityThreshold is None:
1069 lowSnrReliabilityThreshold = self.config.newObjectLowSnrReliabilityThreshold
1070 if badFlags is None:
1071 badFlags = self.config.newObjectBadFlags
1072 flagged = sources[badFlags].fillna(False).any(axis=1)
1073 fluxField = self.config.newObjectFluxField
1074 fluxErrField = fluxField + "Err"
1075 signalToNoise = np.abs(np.array(sources[fluxField]/sources[fluxErrField]))
1076 reliability = np.array(sources['reliability'])
1077 nFlagged = np.count_nonzero(flagged)
1078 if nFlagged > 0:
1079 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to flags", nFlagged)
1081 if snrThreshold > 0:
1082 snr_flag = signalToNoise < snrThreshold
1083 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to %sFlux"
1084 " signal to noise < %f",
1085 np.sum(snr_flag), fluxField, snrThreshold)
1086 flagged |= snr_flag
1087 if reliabilityThreshold > 0:
1088 reliability_flag = reliability < reliabilityThreshold
1089 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to "
1090 "reliability<%f",
1091 np.sum(reliability_flag), reliabilityThreshold)
1092 flagged |= reliability_flag
1093 if min(lowReliabilitySnrThreshold, lowSnrReliabilityThreshold) > 0:
1094 # Only run the combined test if both thresholds are greater than zero
1095 lowSnrReliability_flag = ((signalToNoise < lowReliabilitySnrThreshold)
1096 & (reliability < lowSnrReliabilityThreshold))
1097 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to %sFlux"
1098 " signal to noise < %f combined with reliability< %f",
1099 np.sum(lowSnrReliability_flag),
1100 fluxField,
1101 lowReliabilitySnrThreshold,
1102 lowSnrReliabilityThreshold)
1103 flagged |= lowSnrReliability_flag
1105 if np.count_nonzero(~flagged) > 0: 1105 ↛ 1108line 1105 didn't jump to line 1108 because the condition on line 1105 was always true
1106 goodSources = sources[~flagged].copy(deep=True)
1107 else:
1108 goodSources = make_empty_catalog(self.schema, tableName="DiaSource")
1109 if np.count_nonzero(flagged) > 0:
1110 badSources = sources[flagged].copy(deep=True)
1111 else:
1112 badSources = make_empty_catalog(self.schema, tableName="DiaSource")
1113 return pipeBase.Struct(goodSources=goodSources,
1114 badSources=badSources
1115 )
1117 @timeMethod
1118 def associateDiaSources(self, diaSourceTable, solarSystemObjectTable, diffIm, diaObjects):
1119 """Associate DiaSources with DiaObjects.
1121 Associate new DiaSources with existing DiaObjects. Create new
1122 DiaObjects fron unassociated DiaSources. Index DiaSource catalogue
1123 after associations. Append new DiaObjects and DiaSources to their
1124 previous history. Test for DiaSource and DiaObject duplications.
1125 Compute DiaObject Summary statistics from their full DiaSource
1126 history. Test for duplication in the updated DiaObjects.
1128 Parameters
1129 ----------
1130 diaSourceTable : `pandas.DataFrame`
1131 Newly detected DiaSources.
1132 solarSystemObjectTable : `astropy.table.Table`
1133 Preloaded Solar System objects expected to be visible in the image.
1134 diffIm : `lsst.afw.image.ExposureF`
1135 Difference image exposure in which the sources in ``diaSourceCat``
1136 were detected.
1137 diaObjects : `pandas.DataFrame`
1138 Table of DiaObjects from preloaded DiaObjects.
1140 Returns
1141 -------
1142 results : `lsst.pipe.base.Struct`
1143 Results struct containing:
1145 - associatedDiaSources : `pandas.DataFrame`
1146 Associated DiaSources with DiaObjects.
1147 - newDiaObjects : `pandas.DataFrame`
1148 Table of new DiaObjects after association.
1149 - associatedSsSources : `astropy.table.Table`
1150 Table of new ssSources after association.
1151 - unassociatedSsObjects : `astropy.table.Table`
1152 Table of expected ssSources that were not associated with a
1153 diaSource.
1154 - newDiaSources : `pandas.DataFrame`
1155 Subset of `associatedDiaSources` consisting of only the
1156 unassociated diaSources that were added as new diaObjects.
1157 - marginalDiaSources : `pandas.DataFrame`
1158 Unassociated diaSources with marginal detections, which were
1159 removed from `associatedDiaSources` and were not added as new
1160 diaObjects.
1161 """
1162 associatedCatalogs = []
1163 # First associate diaSources with known asteroids
1164 if self.config.doSolarSystemAssociation and solarSystemObjectTable is not None:
1165 ssoAssocResult = self.solarSystemAssociator.run(
1166 Table.from_pandas(diaSourceTable),
1167 solarSystemObjectTable,
1168 diffIm.visitInfo,
1169 diffIm.getBBox(),
1170 diffIm.wcs
1171 )
1172 nTotalSsObjects = ssoAssocResult.nTotalSsObjects
1173 nAssociatedSsObjects = ssoAssocResult.nAssociatedSsObjects
1174 associatedSsSources = ssoAssocResult.associatedSsSources
1175 unassociatedSsObjects = ssoAssocResult.unassociatedSsObjects
1176 if len(ssoAssocResult.ssoAssocDiaSources) > 0: 1176 ↛ 1177line 1176 didn't jump to line 1177 because the condition on line 1176 was never true
1177 associatedCatalogs.append(ssoAssocResult.ssoAssocDiaSources.to_pandas())
1178 if len(ssoAssocResult.unAssocDiaSources) > 0: 1178 ↛ 1181line 1178 didn't jump to line 1181 because the condition on line 1178 was never true
1179 # If the table is empty then converting time fields to pandas
1180 # will raise an error. Pass in an empty Dataframe in that case.
1181 unAssocSSDiaSources = ssoAssocResult.unAssocDiaSources.to_pandas()
1182 else:
1183 unAssocSSDiaSources = make_empty_catalog(self.schema, tableName="DiaSource")
1184 else:
1185 unAssocSSDiaSources = diaSourceTable
1186 nTotalSsObjects = 0
1187 nAssociatedSsObjects = 0
1188 associatedSsSources = None
1189 unassociatedSsObjects = None
1190 # Associate new DiaSources with existing DiaObjects.
1191 assocResults = self.associator.run(unAssocSSDiaSources, diaObjects, schema=self.schema)
1192 createResults = self.createNewDiaObjects(assocResults.unAssocDiaSources)
1194 if not assocResults.matchedDiaSources.empty:
1195 associatedCatalogs.append(assocResults.matchedDiaSources)
1196 if not createResults.diaSources.empty:
1197 associatedCatalogs.append(createResults.diaSources)
1198 if len(associatedCatalogs) == 0:
1199 associatedDiaSources = make_empty_catalog(self.schema, tableName="DiaSource")
1200 else:
1201 # Standardize each component to the schema before concatinating, so
1202 # that type mis-matches are fixed as early as possible.
1203 associatedDiaSources = pd.concat(
1204 [convertDataFrameToSdmSchema(self.schema, df, tableName="DiaSource", skipIndex=True)
1205 for df in associatedCatalogs])
1207 self._add_association_meta_data(assocResults.nUpdatedDiaObjects,
1208 assocResults.nUnassociatedDiaObjects,
1209 createResults.nNewDiaObjects,
1210 nTotalSsObjects,
1211 nAssociatedSsObjects)
1212 self.log.info("%i updated and %i unassociated diaObjects. Creating %i new diaObjects"
1213 " and dropping %i marginal diaSources.",
1214 assocResults.nUpdatedDiaObjects,
1215 assocResults.nUnassociatedDiaObjects,
1216 createResults.nNewDiaObjects,
1217 len(createResults.marginalDiaSources),
1218 )
1219 if createResults.nNewDiaObjects > self.config.maxNewDiaObjects > 0:
1220 raise TooManyDiaObjectsError(nNewDiaObjects=createResults.nNewDiaObjects,
1221 threshold=self.config.maxNewDiaObjects)
1222 return pipeBase.Struct(associatedDiaSources=associatedDiaSources,
1223 newDiaObjects=createResults.newDiaObjects,
1224 associatedSsSources=associatedSsSources,
1225 unassociatedSsObjects=unassociatedSsObjects,
1226 newDiaSources=createResults.diaSources,
1227 marginalDiaSources=createResults.marginalDiaSources
1228 )
1230 def standardizeDataFrame(self, dataFrame, tableName, nullColumns=None):
1231 """Convert a catalog to SDM schema format with NULL ID values
1233 Parameters
1234 ----------
1235 dataFrame : `pandas.DataFrame`
1236 The catalog to standardize/convert
1237 tableName : `str`
1238 Schema name of table to which this dataFrame should be standardized
1239 nullColumns : `list` of `str`, optional
1240 List of column names to check for default values of 0 that should be
1241 replaced with `NULL`.
1244 Returns
1245 -------
1246 standardizedAssociatedDiaSources : pandas.DataFrame
1247 The standardized DiaSource catalog
1248 """
1249 standardizedDataFrame = convertDataFrameToSdmSchema(self.schema,
1250 dataFrame,
1251 tableName=tableName,
1252 skipIndex=True)
1254 def _setNullColumn(dataframe, colName):
1255 """Set specified columns with default values of 0 to NULL."""
1256 dataframe.loc[dataframe[colName] == 0, colName] = pd.NA
1258 if nullColumns is not None: 1258 ↛ 1261line 1258 didn't jump to line 1261 because the condition on line 1258 was always true
1259 for colName in nullColumns:
1260 _setNullColumn(standardizedDataFrame, colName)
1261 return standardizedDataFrame
1263 def standardizeTable(self, table, tableName, nullColumns=None):
1264 """Convert a catalog to SDM schema format with NULL ID values
1266 Parameters
1267 ----------
1268 table : `astropy.table.Table`
1269 The catalog to standardize/convert
1270 tableName : `str`
1271 Schema name of table to which this dataFrame should be standardized
1272 nullColumns : `list` of `str`, optional
1273 List of column names to check for default values of 0 that should be
1274 replaced with `NULL`.
1277 Returns
1278 -------
1279 standardizedAssociatedDiaSources : pandas.DataFrame
1280 The standardized DiaSource catalog
1281 """
1282 dataFrame = table.to_pandas()
1283 standardizedDataFrame = self.standardizeDataFrame(dataFrame, tableName, nullColumns=nullColumns)
1284 return standardizedDataFrame
1286 @timeMethod
1287 def mergeAssociatedCatalogs(self, preloadedDiaSources, associatedDiaSources, diaObjects, newDiaObjects,
1288 diffIm):
1289 """Merge the associated diaSource and diaObjects to their previous history.
1291 Parameters
1292 ----------
1293 preloadedDiaSources : `pandas.DataFrame`
1294 Previously detected DiaSources, loaded from the APDB.
1295 associatedDiaSources : `pandas.DataFrame`
1296 Associated DiaSources with DiaObjects.
1297 diaObjects : `pandas.DataFrame`
1298 Table of DiaObjects from preloaded DiaObjects.
1299 newDiaObjects : `pandas.DataFrame`
1300 Table of new DiaObjects after association.
1302 Raises
1303 ------
1304 RuntimeError
1305 Raised if duplicate DiaObjects or duplicate DiaSources are found.
1307 Returns
1308 -------
1309 mergedDiaSourceHistory : `pandas.DataFrame`
1310 The combined catalog, with all of the rows from preloadedDiaSources
1311 catalog ordered before the rows of associatedDiaSources catalog.
1312 mergedDiaObjects : `pandas.DataFrame`
1313 Table of new DiaObjects merged with their history.
1314 updatedDiaObjectIds : `numpy.Array`
1315 Object Id's from associated diaSources.
1316 """
1317 # Index the DiaSource catalog for this visit after all associations
1318 # have been made.
1319 updatedDiaObjectIds = associatedDiaSources["diaObjectId"][
1320 associatedDiaSources["diaObjectId"] != 0].to_numpy()
1321 associatedDiaSources.set_index(["diaObjectId",
1322 "band",
1323 "diaSourceId"],
1324 drop=False,
1325 inplace=True)
1327 # Append new DiaObjects and DiaSources to their previous history.
1328 if diaObjects.empty: 1328 ↛ 1329line 1328 didn't jump to line 1329 because the condition on line 1328 was never true
1329 mergedDiaObjects = newDiaObjects.set_index("diaObjectId", drop=False)
1330 elif not newDiaObjects.empty: 1330 ↛ 1331line 1330 didn't jump to line 1331 because the condition on line 1330 was never true
1331 mergedDiaObjects = pd.concat(
1332 [diaObjects,
1333 newDiaObjects.set_index("diaObjectId", drop=False)],
1334 sort=True)
1335 else:
1336 mergedDiaObjects = diaObjects
1338 # Exclude any objects that are off the image after association.
1339 mergedDiaObjects, updatedDiaObjectIds = self.purgeDiaObjects(diffIm.getBBox(), diffIm.getWcs(),
1340 mergedDiaObjects,
1341 diaObjectIds=updatedDiaObjectIds,
1342 buffer=-1)
1343 if self.testDataFrameIndex(mergedDiaObjects): 1343 ↛ 1344line 1343 didn't jump to line 1344 because the condition on line 1343 was never true
1344 raise RuntimeError(
1345 "Duplicate DiaObjects created after association. This is "
1346 "likely due to re-running data with an already populated "
1347 "Apdb. If this was not the case then there was an unexpected "
1348 "failure in Association while matching and creating new "
1349 "DiaObjects and should be reported. Exiting.")
1351 mergedDiaSourceHistory = self.mergeCatalogs(preloadedDiaSources, associatedDiaSources,
1352 tableName="DiaSource")
1354 # Test for DiaSource duplication first. If duplicates are found,
1355 # this likely means this is duplicate data being processed and sent
1356 # to the Apdb.
1357 if self.testDataFrameIndex(mergedDiaSourceHistory): 1357 ↛ 1358line 1357 didn't jump to line 1358 because the condition on line 1357 was never true
1358 raise RuntimeError(
1359 "Duplicate DiaSources found after association and merging "
1360 "with history. This is likely due to re-running data with an "
1361 "already populated Apdb. If this was not the case then there "
1362 "was an unexpected failure in Association while matching "
1363 "sources to objects, and should be reported. Exiting.")
1364 # Finally, update the diaObject table with the number of associated diaSources
1365 mergedUpdatedDiaObjects = self.updateObjectTable(mergedDiaObjects, mergedDiaSourceHistory)
1366 return (mergedDiaSourceHistory, mergedUpdatedDiaObjects, updatedDiaObjectIds)
1368 @timeMethod
1369 def runForcedMeasurement(self, diaObjects, updatedDiaObjects, exposure, diffIm, idGenerator):
1370 """Forced Source Measurement
1372 Forced photometry on the difference and calibrated exposures using the
1373 new and updated DiaObject locations.
1375 Parameters
1376 ----------
1377 diaObjects : `pandas.DataFrame`
1378 Catalog of DiaObjects.
1379 updatedDiaObjects : `pandas.DataFrame`
1380 Catalog of updated DiaObjects.
1381 exposure : `lsst.afw.image.ExposureF`
1382 Calibrated exposure differenced with a template to create
1383 ``diffIm``.
1384 diffIm : `lsst.afw.image.ExposureF`
1385 Difference image exposure in which the sources in ``diaSourceCat``
1386 were detected.
1387 idGenerator : `lsst.meas.base.IdGenerator`
1388 Object that generates source IDs and random number generator seeds.
1390 Returns
1391 -------
1392 diaForcedSources : `pandas.DataFrame`
1393 Catalog of calibrated forced photometered fluxes on both the
1394 difference and direct images at DiaObject locations.
1395 """
1396 # Force photometer on the Difference and Calibrated exposures using
1397 # the new and updated DiaObject locations.
1398 diaForcedSources = self.diaForcedSource.run(
1399 diaObjects,
1400 updatedDiaObjects.loc[:, "diaObjectId"].to_numpy(),
1401 exposure,
1402 diffIm,
1403 idGenerator=idGenerator)
1404 self.log.info(f"Updating {len(diaForcedSources)} diaForcedSources in the APDB")
1405 diaForcedSources = convertDataFrameToSdmSchema(self.schema, diaForcedSources,
1406 tableName="DiaForcedSource", skipIndex=True)
1407 return diaForcedSources
1409 @timeMethod
1410 def loadRefreshedDiaObjects(self, region, preloadedDiaObjects=None):
1411 """Load DiaObjects from the Apdb based on their HTM location.
1413 Parameters
1414 ----------
1415 region : `sphgeom.Region`
1416 Region containing the current exposure to load diaObjects from the
1417 APDB.
1418 preloadedDiaObjects : `pandas.DataFrame`, optional
1419 Previously detected DiaObjects, loaded from the APDB. `None` if
1420 the preloaded catalogs are not available.
1422 Returns
1423 -------
1424 diaObjects : `pandas.DataFrame`
1425 DiaObjects loaded from the Apdb that are within the area defined
1426 by ``region``.
1427 """
1428 refreshedDiaObjects = loadDiaObjectsFromApdb(self.apdb, self._paddedRegion(region), self.schema,
1429 self.log)
1430 if preloadedDiaObjects is None:
1431 return refreshedDiaObjects
1433 refreshedIsInPreloaded = refreshedDiaObjects.index.isin(preloadedDiaObjects.index)
1434 preloadedIsInRefreshed = preloadedDiaObjects.index.isin(refreshedDiaObjects.index)
1435 nUniqueRefreshed = (~refreshedIsInPreloaded).sum()
1436 nUniquePreloaded = (~preloadedIsInRefreshed).sum()
1437 if nUniqueRefreshed > 0: 1437 ↛ 1438line 1437 didn't jump to line 1438 because the condition on line 1437 was never true
1438 self.log.info("Reloading the diaObject table during association yielded "
1439 "an additional %d objects over the %d preloaded diaObjects.",
1440 nUniqueRefreshed, len(preloadedDiaObjects))
1441 if nUniquePreloaded == 0: 1441 ↛ 1442line 1441 didn't jump to line 1442 because the condition on line 1441 was never true
1442 return refreshedDiaObjects
1443 # ap_verify CI datasets ship a preloaded diaObject catalog
1444 # alongside an empty APDB, so some preloaded objects may not
1445 # appear in the refresh. Combine the two, giving precedence to
1446 # refreshed entries on overlap so that updated column values are
1447 # not silently discarded when the refresh adds no new ids.
1448 return pd.concat([refreshedDiaObjects, preloadedDiaObjects.loc[~preloadedIsInRefreshed]])
1450 @timeMethod
1451 def loadRefreshedDiaSources(self, region, diaObjects, visitTime):
1452 """Reload the DiaSource history from the Apdb.
1454 Used only in non-time-critical environments where it is more important
1455 to load the most recent DiaSources than to save time by preloading
1456 catalogs.
1458 Parameters
1459 ----------
1460 region : `sphgeom.Region`
1461 Region containing the current exposure to load the history from
1462 the APDB.
1463 diaObjects : `pandas.DataFrame`
1464 DiaObjects to load the history for, indexed by ``diaObjectId``.
1465 visitTime : `astropy.time.Time`
1466 Time of the current visit.
1468 Returns
1469 -------
1470 diaSources : `pandas.DataFrame`
1471 DiaSource history loaded from the Apdb, indexed by
1472 ``diaObjectId``, ``band``, and ``diaSourceId``.
1473 """
1474 return loadDiaSourcesFromApdb(self.apdb, self._paddedRegion(region),
1475 diaObjects.loc[:, "diaObjectId"], visitTime, self.schema, self.log)
1477 @timeMethod
1478 def loadRefreshedDiaForcedSources(self, region, diaObjects, visitTime):
1479 """Reload the DiaForcedSource history from the Apdb.
1481 Used only in non-time-critical environments where it is more important
1482 to load the most recent DiaForcedSources than to save time by preloading
1483 catalogs.
1485 Parameters
1486 ----------
1487 region : `sphgeom.Region`
1488 Region containing the current exposure to load the history from
1489 the APDB.
1490 diaObjects : `pandas.DataFrame`
1491 DiaObjects to load the history for, indexed by ``diaObjectId``.
1492 visitTime : `astropy.time.Time`
1493 Time of the current visit.
1495 Returns
1496 -------
1497 diaForcedSources : `pandas.DataFrame`
1498 DiaForcedSource history loaded from the Apdb, indexed by
1499 ``diaObjectId`` and ``diaForcedSourceId``.
1500 """
1501 return loadDiaForcedSourcesFromApdb(self.apdb, self._paddedRegion(region),
1502 diaObjects.loc[:, "diaObjectId"], visitTime, self.schema,
1503 self.log)
1505 def _paddedRegion(self, region):
1506 """Pad a region by the configured margin.
1508 Parameters
1509 ----------
1510 region : `sphgeom.Region`
1511 Region containing the current exposure.
1513 Returns
1514 -------
1515 region : `sphgeom.Region`
1516 The region, expanded by ``config.angleMargin``.
1517 """
1518 return paddedRegion(region, lsst.sphgeom.Angle.fromDegrees(self.config.angleMargin/3600.))
1520 @timeMethod
1521 def writeToApdb(self, updatedDiaObjects, associatedDiaSources, diaForcedSources):
1522 """Write to the Alert Production Database (Apdb).
1524 Store DiaSources, updated DiaObjects, and DiaForcedSources in the
1525 Alert Production Database (Apdb).
1527 Parameters
1528 ----------
1529 updatedDiaObjects : `pandas.DataFrame`
1530 Catalog of updated DiaObjects.
1531 associatedDiaSources : `pandas.DataFrame`
1532 Associated DiaSources with DiaObjects.
1533 diaForcedSources : `pandas.DataFrame`
1534 Catalog of calibrated forced photometered fluxes on both the
1535 difference and direct images at DiaObject locations.
1537 Returns
1538 -------
1539 validityStart : `lsst.daf.base.DateTime`
1540 Time at which the APDB was updated.
1541 """
1542 # Store DiaSources, updated DiaObjects, and DiaForcedSources in the
1543 # Apdb.
1544 # Drop empty columns that are nullable in the APDB.
1545 diaObjectStore = dropEmptyColumns(self.schema, updatedDiaObjects, tableName="DiaObject")
1546 diaSourceStore = dropEmptyColumns(self.schema, associatedDiaSources, tableName="DiaSource")
1547 diaForcedSourceStore = dropEmptyColumns(self.schema, diaForcedSources, tableName="DiaForcedSource")
1549 validityStart = DateTime.now()
1550 self.apdb.store(
1551 validityStart.toAstropy(),
1552 diaObjectStore,
1553 diaSourceStore,
1554 diaForcedSourceStore)
1555 self.log.info("APDB updated.")
1557 return validityStart
1559 def testDataFrameIndex(self, df):
1560 """Test the sorted DataFrame index for duplicates.
1562 Wrapped as a separate function to allow for mocking of the this task
1563 in unittesting. Default of a mock return for this test is True.
1565 Parameters
1566 ----------
1567 df : `pandas.DataFrame`
1568 DataFrame to text.
1570 Returns
1571 -------
1572 `bool`
1573 True if DataFrame contains duplicate rows.
1574 """
1575 return df.index.has_duplicates
1577 def _add_association_meta_data(self,
1578 nUpdatedDiaObjects,
1579 nUnassociatedDiaObjects,
1580 nNewDiaObjects,
1581 nTotalSsObjects,
1582 nAssociatedSsObjects):
1583 """Store summaries of the association step in the task metadata.
1585 Parameters
1586 ----------
1587 nUpdatedDiaObjects : `int`
1588 Number of previous DiaObjects associated and updated in this
1589 ccdVisit.
1590 nUnassociatedDiaObjects : `int`
1591 Number of previous DiaObjects that were not associated or updated
1592 in this ccdVisit.
1593 nNewDiaObjects : `int`
1594 Number of newly created DiaObjects for this ccdVisit.
1595 nTotalSsObjects : `int`
1596 Number of SolarSystemObjects within the observable detector
1597 area.
1598 nAssociatedSsObjects : `int`
1599 Number of successfully associated SolarSystemObjects.
1600 """
1601 self.metadata['numUpdatedDiaObjects'] = nUpdatedDiaObjects
1602 self.metadata['numUnassociatedDiaObjects'] = nUnassociatedDiaObjects
1603 self.metadata['numNewDiaObjects'] = nNewDiaObjects
1604 self.metadata['numTotalSolarSystemObjects'] = nTotalSsObjects
1605 self.metadata['numAssociatedSsObjects'] = nAssociatedSsObjects
1607 def purgeDiaObjects(self, bbox, wcs, diaObjCat, diaObjectIds=None, buffer=0):
1608 """Drop diaObjects that are outside the exposure bounding box.
1610 Parameters
1611 ----------
1612 bbox : `lsst.geom.Box2I`
1613 Bounding box of the exposure.
1614 wcs : `lsst.afw.geom.SkyWcs`
1615 Coordinate system definition (wcs) for the exposure.
1616 diaObjCat : `pandas.DataFrame`
1617 DiaObjects loaded from the Apdb.
1618 buffer : `int`, optional
1619 Width, in pixels, to pad the exposure bounding box.
1621 Returns
1622 -------
1623 diaObjCat : `pandas.DataFrame`
1624 DiaObjects loaded from the Apdb, restricted to the exposure
1625 bounding box.
1626 """
1627 # Copy the bbox so the caller's box is not mutated by grow().
1628 bbox = type(bbox)(bbox)
1629 try:
1630 bbox.grow(buffer)
1631 raVals = diaObjCat.ra.to_numpy()
1632 decVals = diaObjCat.dec.to_numpy()
1633 xVals, yVals = wcs.skyToPixelArray(raVals, decVals, degrees=True)
1634 selector = bbox.contains(xVals, yVals)
1635 nPurged = np.sum(~selector)
1636 if nPurged > 0:
1637 if diaObjectIds is not None: 1637 ↛ 1645line 1637 didn't jump to line 1645 because the condition on line 1637 was always true
1638 # We also need to drop any of the associated IDs if this runs after association
1639 purgedIds = diaObjCat[~selector].diaObjectId
1640 diaObjectIds = diaObjectIds[~np.isin(diaObjectIds, purgedIds)]
1641 self.log.info("Dropped %i diaObjects that were outside the bbox "
1642 "after association, leaving %i in the catalog",
1643 nPurged, len(diaObjCat) - nPurged)
1644 else:
1645 self.log.info("Dropped %i diaObjects that were outside the padded bbox "
1646 "before association, leaving %i in the catalog",
1647 nPurged, len(diaObjCat) - nPurged)
1648 diaObjCat = diaObjCat[selector].copy()
1649 except (AttributeError, KeyError, ValueError) as e:
1650 self.log.warning("Error attempting to check diaObject history: %s", e, exc_info=e)
1651 return diaObjCat, diaObjectIds
1653 def mergeCatalogs(self, originalCatalog, newCatalog, tableName):
1654 """Combine two catalogs, ensuring that the new catalog conforms to the schema.
1656 Parameters
1657 ----------
1658 originalCatalog : `pandas.DataFrame`
1659 The original catalog to be added to.
1660 newCatalog : `pandas.DataFrame`
1661 The new catalog to append to `originalCatalog`
1662 tableName : `str`
1663 Name of the table in the schema to use.
1665 Returns
1666 -------
1667 mergedCatalog : `pandas.DataFrame`
1668 The combined catalog, with all of the rows from ``originalCatalog``
1669 ordered before the rows of ``newCatalog``
1670 """
1671 if len(newCatalog) > 0:
1672 catalog = convertDataFrameToSdmSchema(self.schema, newCatalog,
1673 tableName=tableName, skipIndex=True)
1675 mergedCatalog = pd.concat([originalCatalog, catalog], sort=True)
1676 else:
1677 mergedCatalog = pd.concat([originalCatalog], sort=True)
1678 return mergedCatalog.loc[:, originalCatalog.columns]
1680 def updateObjectTable(self, diaObjects, diaSources):
1681 """Update the diaObject table with the new diaSource records.
1683 Parameters
1684 ----------
1685 diaObjects : `pandas.DataFrame`
1686 Table of new DiaObjects merged with their history.
1687 diaSources : `pandas.DataFrame`
1688 The combined preloaded and associated diaSource catalog.
1690 Returns
1691 -------
1692 updatedDiaObjects : `pandas.DataFrame`
1693 Table of DiaObjects updated with the number of associated DiaSources
1694 """
1695 # Group on the index level explicitly since diaObjectId is both an
1696 # index and a (duplicated) column
1697 nDiaSources = diaSources.groupby(level="diaObjectId").size().rename("nDiaSources")
1698 diaObjects = diaObjects.drop(columns="nDiaSources", errors="ignore")
1699 updatedDiaObjects = diaObjects.join(nDiaSources, how="left")
1700 return updatedDiaObjects
1702 @staticmethod
1703 def checkTableIndex(dataFrame, index):
1704 if dataFrame.index.name is None:
1705 # The expected index may or may not be set, depending on whether
1706 # the table was written originally as a DataFrame or something else
1707 # Parquet-friendly.
1708 dataFrame.set_index(index, drop=False, inplace=True)