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