Coverage for python/lsst/ap/association/diaPipe.py: 85%
491 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-07 01:46 -0800
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-07 01:46 -0800
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_firstLastDiaSourceTime",
520 "ap_nDiaSources",
521 "ap_meanFlux",
522 "ap_sigmaFlux",
523 "ap_minMaxFlux",
524 "ap_maxSlopeFlux",
525 "ap_meanErrFlux",
526 "ap_meanTotFlux"]
529class DiaPipelineTask(pipeBase.PipelineTask):
530 """Task for loading, associating and storing Difference Image Analysis
531 (DIA) Objects and Sources.
532 """
533 ConfigClass = DiaPipelineConfig
534 _DefaultName = "diaPipe"
536 def __init__(self, initInputs=None, **kwargs):
537 super().__init__(**kwargs)
538 self.apdb = daxApdb.Apdb.from_uri(self.config.apdb_config_url)
539 self.schema = readSchemaFromApdb(self.apdb)
540 self.makeSubtask("associator")
541 self.makeSubtask("diaCalculation")
542 if self.config.doRunForcedMeasurement:
543 self.makeSubtask("diaForcedSource")
544 if self.config.doPackageAlerts:
545 self.makeSubtask("alertPackager")
546 if self.config.doSolarSystemAssociation:
547 self.makeSubtask("solarSystemAssociator")
548 if self.config.filterUnAssociatedSources:
549 columns = [self.config.newObjectFluxField,
550 self.config.newObjectFluxField + "Err",
551 "reliability"]
552 columns += self.config.newObjectBadFlags
554 missing = checkSdmSchemaColumns(self.schema, columns, "DiaSource")
555 if missing:
556 raise pipeBase.InvalidQuantumError("Field %s not in the DiaSource schema" % missing)
558 def runQuantum(self, butlerQC, inputRefs, outputRefs):
559 inputs = butlerQC.get(inputRefs)
560 if self.config.input_image_type == "future":
561 for name in ("diffIm", "exposure", "template"):
562 inputs[name] = inputs[name].to_legacy()
563 inputs["idGenerator"] = self.config.idGenerator.apply(butlerQC.quantum.dataId)
564 inputs["band"] = butlerQC.quantum.dataId["band"]
565 inputs["legacySolarSystemTable"] = None
566 if not self.config.doSolarSystemAssociation: 566 ↛ 567line 566 didn't jump to line 567 because the condition on line 566 was never true
567 inputs["solarSystemObjectTable"] = None
569 associationResults = pipeBase.Struct(
570 apdbMarker=None,
571 associatedDiaSources=None,
572 diaForcedSources=None,
573 diaObjects=None,
574 associatedSsSources=None,
575 unassociatedSsObjects=None,
576 newDiaSources=None,
577 marginalDiaSources=None,
578 )
579 try:
580 self.run(**inputs, associationResults=associationResults)
581 except pipeBase.AlgorithmError as e:
582 error = pipeBase.AnnotatedPartialOutputsError.annotate(
583 e,
584 self,
585 log=self.log
586 )
587 butlerQC.put(associationResults, outputRefs)
588 raise error from e
589 butlerQC.put(associationResults, outputRefs)
591 @timeMethod
592 def run(self,
593 diaSourceTable,
594 legacySolarSystemTable,
595 diffIm,
596 exposure,
597 template,
598 preloadedDiaObjects=None,
599 preloadedDiaSources=None,
600 preloadedDiaForcedSources=None,
601 band=None,
602 idGenerator=None,
603 solarSystemObjectTable=None,
604 associationResults=None):
605 """Process DiaSources and DiaObjects.
607 Load previous DiaObjects and their DiaSource history. Calibrate the
608 values in the diaSourceCat. Associate new DiaSources with previous
609 DiaObjects. Run forced photometry at the updated DiaObject locations.
610 Store the results in the Alert Production Database (Apdb).
612 Parameters
613 ----------
614 diaSourceTable : `pandas.DataFrame`
615 Newly detected DiaSources.
616 legacySolarSystemTable : `pandas.DataFrame`
617 Not used
618 diffIm : `lsst.afw.image.ExposureF`
619 Difference image exposure in which the sources in ``diaSourceCat``
620 were detected.
621 exposure : `lsst.afw.image.ExposureF`
622 Calibrated exposure differenced with a template to create
623 ``diffIm``.
624 template : `lsst.afw.image.ExposureF`
625 Template exposure used to create diffIm.
626 preloadedDiaObjects : `pandas.DataFrame`, optional
627 Previously detected DiaObjects, loaded from the APDB.
628 `None` if ``doReloadAllApdbCatalogs`` is set.
629 preloadedDiaSources : `pandas.DataFrame`, optional
630 Previously detected DiaSources, loaded from the APDB.
631 `None` if ``doReloadAllApdbCatalogs`` is set.
632 preloadedDiaForcedSources : `pandas.DataFrame`, optional
633 Catalog of previously detected forced DiaSources, from the APDB.
634 `None` if ``doReloadAllApdbCatalogs`` is set.
635 band : `str`
636 The band in which the new DiaSources were detected. Required,
637 despite the `None` default.
638 idGenerator : `lsst.meas.base.IdGenerator`
639 Object that generates source IDs and random number generator seeds.
640 Required, despite the `None` default.
641 solarSystemObjectTable : `astropy.table.Table`, optional
642 Preloaded Solar System objects expected to be visible in the image.
643 associationResults : `lsst.pipe.base.Struct`, optional
644 Result struct that is modified to allow saving of partial outputs
645 for some failure conditions. If the task completes successfully,
646 this is also returned.
648 Returns
649 -------
650 associationResults : `lsst.pipe.base.Struct`
651 Results struct with components.
653 - ``apdbMarker`` : Marker dataset to store in the Butler indicating
654 that this ccdVisit has completed successfully.
655 (`lsst.dax.apdb.ApdbConfig`)
656 - ``associatedDiaSources`` : Catalog of newly associated
657 DiaSources. (`pandas.DataFrame`)
658 - ``diaForcedSources`` : Catalog of new and previously detected
659 forced DiaSources. (`pandas.DataFrame`)
660 - ``diaObjects`` : Updated table of DiaObjects. (`pandas.DataFrame`)
661 - ``associatedSsSources`` : Catalog of ssSource records.
662 (`pandas.DataFrame`)
664 Raises
665 ------
666 RuntimeError
667 Raised if duplicate DiaObjects or duplicate DiaSources are found.
668 ValueError
669 Raised if ``band`` or ``idGenerator`` is `None`.
670 """
671 # These default to `None` only so that the preloaded catalogs, which
672 # precede them, can be omitted.
673 if band is None:
674 raise ValueError("band is required.")
675 if idGenerator is None:
676 raise ValueError("idGenerator is required.")
678 if associationResults is None: 678 ↛ 680line 678 didn't jump to line 680 because the condition on line 678 was always true
679 associationResults = pipeBase.Struct()
680 self._validateExposure(exposure, "Science")
681 self._validateExposure(diffIm, "Difference")
682 self._validateExposure(template, "Template")
684 # Accept either legacySolarSystemTable or optional solarSystemObjectTable.
685 if legacySolarSystemTable is not None and solarSystemObjectTable is None: 685 ↛ 686line 685 didn't jump to line 686 because the condition on line 685 was never true
686 solarSystemObjectTable = Table.from_pandas(legacySolarSystemTable)
687 region = getRegion(exposure)
688 if self.config.doReloadDiaObjects or self.config.doReloadAllApdbCatalogs:
689 try:
690 preloadedDiaObjects = self.loadRefreshedDiaObjects(region, preloadedDiaObjects)
691 except Exception as e:
692 if self.config.doReloadAllApdbCatalogs:
693 # We can't continue if the diaObjects were not loaded and
694 # ``doReloadAllApdbCatalogs`` is set, because there will be
695 # no preloaded diaObjects to fall back on.
696 raise
697 self.log.warning("Error encountered while attempting to load "
698 "the latest diaObjects from the APDB. Processing "
699 "will continue with only the diaObjects from "
700 "preload.", exc_info=e)
701 finally:
702 self.metadata["loadDiaObjectsDuration"] = duration_from_timeMethod(
703 self.metadata, "loadRefreshedDiaObjects", clock="Utc"
704 )
705 self.log.verbose("Re-loading DiaObjects: Took %.4f seconds",
706 self.metadata["loadDiaObjectsDuration"])
708 else:
709 self.metadata["loadDiaObjectsDuration"] = -1
711 if self.config.doReloadAllApdbCatalogs:
712 # The metadata time records must use the same names as
713 # LoadDiaCatalogsTask to keep the metrics upload consistent.
714 visitTime = exposure.visitInfo.date.toAstropy()
715 try:
716 preloadedDiaSources = self.loadRefreshedDiaSources(region, preloadedDiaObjects, visitTime)
717 finally:
718 self.metadata["loadDiaSourcesDuration"] = duration_from_timeMethod(
719 self.metadata, "loadRefreshedDiaSources", clock="Utc"
720 )
721 self.log.verbose("Re-loading DiaSources: Took %.4f seconds",
722 self.metadata["loadDiaSourcesDuration"])
723 try:
724 preloadedDiaForcedSources = self.loadRefreshedDiaForcedSources(
725 region, preloadedDiaObjects, visitTime)
726 finally:
727 self.metadata["loadDiaForcedSourcesDuration"] = duration_from_timeMethod(
728 self.metadata, "loadRefreshedDiaForcedSources", clock="Utc"
729 )
730 self.log.verbose("Re-loading DiaForcedSources: Took %.4f seconds",
731 self.metadata["loadDiaForcedSourcesDuration"])
732 else:
733 self.metadata["loadDiaSourcesDuration"] = -1
734 self.metadata["loadDiaForcedSourcesDuration"] = -1
736 self.checkTableIndex(preloadedDiaSources, index=["diaObjectId", "band", "diaSourceId"])
737 self.checkTableIndex(preloadedDiaObjects, index="diaObjectId")
738 self.checkTableIndex(preloadedDiaForcedSources, index=["diaObjectId", "diaForcedSourceId"])
740 if not preloadedDiaObjects.empty: 740 ↛ 745line 740 didn't jump to line 745 because the condition on line 740 was always true
741 # Include a small buffer outside the image so that we can associate sources near the edge
742 diaObjects, _ = self.purgeDiaObjects(diffIm.getBBox(), diffIm.getWcs(), preloadedDiaObjects,
743 buffer=self.config.imagePixelMargin)
744 else:
745 self.log.info("Preloaded DiaObject table is empty.")
746 diaObjects = preloadedDiaObjects
748 # Associate DiaSources with DiaObjects
749 assocResults = self.associateDiaSources(diaSourceTable, solarSystemObjectTable, diffIm, diaObjects)
751 # Set unassociated diaObjectIds and ssObjectIds to NULL, and convert to SDM schema format
752 standardizedAssociatedDiaSources = self.standardizeDataFrame(
753 assocResults.associatedDiaSources,
754 "DiaSource",
755 nullColumns=["diaObjectId", "ssObjectId"]
756 )
758 # Merge associated diaSources
759 mergedDiaSourceHistory, mergedDiaObjects, updatedDiaObjectIds = self.mergeAssociatedCatalogs(
760 preloadedDiaSources,
761 assocResults.associatedDiaSources,
762 diaObjects,
763 assocResults.newDiaObjects,
764 diffIm
765 )
767 # Compute DiaObject Summary statistics from their full DiaSource
768 # history.
769 diaCalResult = self.diaCalculation.run(
770 mergedDiaObjects,
771 mergedDiaSourceHistory,
772 updatedDiaObjectIds,
773 self.config.validBands)
774 updatedDiaObjects = convertDataFrameToSdmSchema(self.schema, diaCalResult.updatedDiaObjects,
775 tableName="DiaObject", skipIndex=True)
777 # Test for duplication in the updated DiaObjects.
778 if self.testDataFrameIndex(diaCalResult.diaObjectCat): 778 ↛ 779line 778 didn't jump to line 779 because the condition on line 778 was never true
779 raise RuntimeError(
780 "Duplicate DiaObjects (loaded + updated) created after "
781 "DiaCalculation. This is unexpected behavior and should be "
782 "reported. Exiting.")
783 if self.testDataFrameIndex(updatedDiaObjects): 783 ↛ 784line 783 didn't jump to line 784 because the condition on line 783 was never true
784 raise RuntimeError(
785 "Duplicate DiaObjects (updated) created after "
786 "DiaCalculation. This is unexpected behavior and should be "
787 "reported. Exiting.")
789 # Forced source measurement
790 if self.config.doRunForcedMeasurement:
791 diaObjectsForced = self._selectGoodDiaObjects(diaCalResult.diaObjectCat, mergedDiaSourceHistory)
792 diaForcedSources = self.runForcedMeasurement(
793 diaObjectsForced, updatedDiaObjects, exposure, diffIm, idGenerator
794 )
795 forcedSourceHistoryThreshold = self.diaForcedSource.config.historyThreshold
796 else:
797 # alertPackager needs correct columns
798 diaForcedSources = makeEmptyForcedSourceTable(self.schema)
799 forcedSourceHistoryThreshold = 0
801 # Write results to Alert Production Database (APDB)
802 try:
803 validityStart = self.writeToApdb(updatedDiaObjects, standardizedAssociatedDiaSources,
804 diaForcedSources)
805 finally:
806 self.metadata["writeToApdbDuration"] = duration_from_timeMethod(self.metadata, "writeToApdb",
807 clock="Utc")
808 # A single log message is easier for Loki to parse than timeMethod's start+end pairs.
809 self.log.verbose("writeToApdb: Took %.4f seconds", self.metadata["writeToApdbDuration"])
811 # For historical reasons, apdbMarker is a Config even if it's not meant to be read.
812 # A default Config is the cheapest way to satisfy the storage class.
813 associationResults.apdbMarker = pexConfig.Config()
814 associationResults.associatedDiaSources = assocResults.associatedDiaSources
815 associationResults.diaForcedSources = diaForcedSources
816 associationResults.diaObjects = diaCalResult.diaObjectCat
817 associationResults.unassociatedSsObjects = assocResults.unassociatedSsObjects
818 associationResults.newDiaSources = assocResults.newDiaSources
819 associationResults.marginalDiaSources = assocResults.marginalDiaSources
821 # Catch *any* error after we have updated the APDB
822 try:
823 associatedSsSources = assocResults.associatedSsSources
824 associatedSsSourcesPlusMpcorb = None
825 if associatedSsSources is not None:
826 mpcorbColumns = [col for col in associatedSsSources.columns if col[:7] == 'MPCORB_']
827 associatedSsSourceMpcorb = associatedSsSources[mpcorbColumns].copy()
828 associatedSsSources = self.standardizeTable(associatedSsSources, "SSSource", nullColumns=[])
829 associatedSsSourcesPlusMpcorb = associatedSsSources.copy()
830 for mpcorbColumn in mpcorbColumns: 830 ↛ 831line 830 didn't jump to line 831 because the loop on line 830 never started
831 associatedSsSourcesPlusMpcorb[mpcorbColumn] = associatedSsSourceMpcorb[mpcorbColumn]
832 associationResults.associatedSsSources = associatedSsSources
834 # patch the otherwise-empty validityStart field for the alerts
835 updatedDiaObjects['validityStartMjdTai'] = validityStart.get(system=DateTime.MJD,
836 scale=DateTime.TAI)
838 # Package alerts
839 if self.config.doPackageAlerts:
840 # Append new forced sources to the full history
841 diaForcedSourcesFull = self.mergeCatalogs(preloadedDiaForcedSources, diaForcedSources,
842 tableName="DiaForcedSource")
843 if self.testDataFrameIndex(diaForcedSourcesFull): 843 ↛ 844line 843 didn't jump to line 844 because the condition on line 843 was never true
844 self.log.warning(
845 "Duplicate DiaForcedSources created after merge with "
846 "history and new sources. This may cause downstream "
847 "problems. Dropping duplicates.")
848 # Drop duplicates via index and keep the first appearance.
849 diaForcedSourcesFull = diaForcedSourcesFull[
850 ~diaForcedSourcesFull.index.duplicated(keep="first")]
852 self.alertPackager.run(assocResults.associatedDiaSources,
853 updatedDiaObjects,
854 preloadedDiaSources,
855 diaForcedSourcesFull,
856 diffIm,
857 exposure,
858 template,
859 ssSrc=associatedSsSourcesPlusMpcorb,
860 doRunForcedMeasurement=self.config.doRunForcedMeasurement,
861 forcedSourceHistoryThreshold=forcedSourceHistoryThreshold,
862 )
863 except Exception as e:
864 # Catch *any* error after we have updated the APDB
865 raise PostApdbUpdateError(errorMsg=repr(e)) from e
867 return associationResults
869 def _validateExposure(self, exposure, label):
870 """Validate exposure metadata early to avoid failures after updating
871 the APDB.
873 Parameters
874 ----------
875 exposure : `lsst.afw.image.ExposureF`
876 Calibrated science image that was subtracted. Corresponds to the
877 ``exposure`` Butler connection (``{fakesType}calexp``).
878 label : `str`
879 Specification of the image being validated, for logging messages.
881 Raises
882 ------
883 ValueError
884 Raised when any required metadata field is missing or invalid.
885 """
886 errors = []
888 photo_calib = exposure.getPhotoCalib()
889 if photo_calib is None: 889 ↛ 890line 889 didn't jump to line 890 because the condition on line 889 was never true
890 errors.append(
891 f"{label}: PhotoCalib is None; flux-to-nJy calibration "
892 "in alert packaging will fail."
893 )
894 else:
895 mean = photo_calib.getCalibrationMean()
896 if not np.isfinite(mean) or mean <= 0.0: 896 ↛ 897line 896 didn't jump to line 897 because the condition on line 896 was never true
897 errors.append(
898 f"{label}: PhotoCalib calibration mean is {mean!r}; "
899 "must be a finite, positive value."
900 )
902 if exposure.getWcs() is None: 902 ↛ 903line 902 didn't jump to line 903 because the condition on line 902 was never true
903 errors.append(
904 f"{label}: WCS is None; sky-to-pixel projection and "
905 "DIA Object association will fail."
906 )
908 # Skip visitInfo checks for the template
909 if label != "Template":
910 visit_info = exposure.getInfo().getVisitInfo()
911 if visit_info is None: 911 ↛ 912line 911 didn't jump to line 912 because the condition on line 911 was never true
912 errors.append(
913 f"{label}: VisitInfo is None; observation time, pointing, "
914 "rotation angle, and exposure duration are unavailable."
915 )
916 else:
917 obs_date = visit_info.getDate()
918 if not obs_date.isValid(): 918 ↛ 919line 918 didn't jump to line 919 because the condition on line 918 was never true
919 errors.append(
920 f"{label}: VisitInfo.date is invalid."
921 )
923 # boresightRaDec — written to APDB rows and alert payloads.
924 boresight = visit_info.getBoresightRaDec()
925 ra_deg = boresight.getRa().asDegrees()
926 dec_deg = boresight.getDec().asDegrees()
927 if not np.isfinite(ra_deg) or not np.isfinite(dec_deg): 927 ↛ 928line 927 didn't jump to line 928 because the condition on line 927 was never true
928 errors.append(
929 f"{label}: VisitInfo.boresightRaDec contains NaN "
930 f"(RA={ra_deg}, Dec={dec_deg}); pointing metadata "
931 "is missing or corrupt."
932 )
934 rot_deg = visit_info.getBoresightRotAngle().asDegrees()
935 if not np.isfinite(rot_deg): 935 ↛ 936line 935 didn't jump to line 936 because the condition on line 935 was never true
936 errors.append(
937 f"{label}: VisitInfo.boresightRotAngle is NaN; the "
938 "ROTPA keyword will be corrupt in all alert cutout "
939 "FITS headers."
940 )
942 filter_label = exposure.getFilter()
943 if filter_label is None: 943 ↛ 944line 943 didn't jump to line 944 because the condition on line 943 was never true
944 errors.append(
945 f"{label}: filter label is None."
946 )
947 else:
948 band = filter_label.bandLabel if filter_label.hasBandLabel() else ""
949 if not band: 949 ↛ 950line 949 didn't jump to line 950 because the condition on line 949 was never true
950 errors.append(
951 f"{label}: filter label carries no band name."
952 )
954 bbox = exposure.getBBox()
955 if bbox.getWidth() <= 0 or bbox.getHeight() <= 0: 955 ↛ 956line 955 didn't jump to line 956 because the condition on line 955 was never true
956 errors.append(
957 f"{label}: bounding box is empty "
958 f"({bbox.getWidth()}×{bbox.getHeight()} px); the "
959 "readout or ISR step may have failed."
960 )
962 if exposure.getPsf() is None: 962 ↛ 963line 962 didn't jump to line 963 because the condition on line 962 was never true
963 errors.append(
964 f"{label}: PSF model is None."
965 )
967 if errors: 967 ↛ 968line 967 didn't jump to line 968 because the condition on line 967 was never true
968 detail = "\n ".join(errors)
969 raise ValueError(
970 "Input exposure metadata is invalid; aborting before any "
971 "APDB writes to prevent non-recoverable data corruption:\n "
972 + detail
973 )
975 def _selectGoodDiaObjects(self, diaObjects, diaSources):
976 """
977 Extract a subset of diaObjects with multiple associated diaSources that
978 pass selection requirements.
979 """
980 n_history = self.diaForcedSource.config.historyThreshold
981 required_columns = ["reliability", "trailLength"]
982 for col in required_columns:
983 if col not in diaSources.columns:
984 raise RuntimeError("Required column '%s' missing from diaSource table." % col)
986 rel_ok = diaSources["reliability"] >= self.config.forcedReliabilityThreshold
987 trailed_ok = diaSources["trailLength"] <= self.config.forcedTrailLengthThreshold
988 badFlags = [f for f in self.config.forcedBadFlags if f in diaSources.columns]
989 if badFlags:
990 self.log.info("Excluding diaSources with %s flags set when calculating"
991 " diaObject history for forced photometry." % badFlags)
992 # Exclude rows where ANY bad flag is True.
993 # If bad flag columns can contain NA, treat NA as True (i.e., bad) via fillna(True).
994 bad_any = diaSources[badFlags].fillna(True).any(axis=1)
995 mask = rel_ok & trailed_ok & ~bad_any
996 ids = diaSources.index.get_level_values("diaObjectId")[mask]
998 # Count occurrences in diaSource
999 counts = pd.Series(ids, copy=False).value_counts()
1001 # IDs that exceed threshold
1002 keep_ids = counts[counts >= n_history].index
1004 # Since diaObject index is unique, direct index filtering is safe and efficient
1005 return diaObjects.loc[diaObjects.index.intersection(keep_ids)]
1007 def createNewDiaObjects(self, unassociatedDiaSources):
1008 """Loop through the set of DiaSources and create new DiaObjects
1009 for unassociated DiaSources.
1011 Parameters
1012 ----------
1013 unassociatedDiaSources : `pandas.DataFrame`
1014 Set of DiaSources to create new DiaObjects from.
1016 Returns
1017 -------
1018 results : `lsst.pipe.base.Struct`
1019 Results struct containing:
1021 - diaSources : `pandas.DataFrame`
1022 DiaSource catalog with updated DiaObject ids.
1023 - newDiaObjects : `pandas.DataFrame`
1024 Newly created DiaObjects from the unassociated DiaSources.
1025 - nNewDiaObjects : `int`
1026 Number of newly created diaObjects.
1027 - marginalDiaSources : `pandas.DataFrame`
1028 Unassociated diaSources with low signal-to-noise and/or
1029 reliability, which are excluded from the new DiaObjects.
1030 """
1031 marginalDiaSources = make_empty_catalog(self.schema, tableName="DiaSource")
1032 if len(unassociatedDiaSources) == 0:
1033 newDiaObjects = make_empty_catalog(self.schema, tableName="DiaObject")
1034 else:
1035 if self.config.filterUnAssociatedSources:
1036 results = self.filterSources(unassociatedDiaSources)
1037 unassociatedDiaSources = results.goodSources
1038 marginalDiaSources = results.badSources
1039 unassociatedDiaSources["diaObjectId"] = unassociatedDiaSources["diaSourceId"]
1040 newDiaObjects = convertDataFrameToSdmSchema(self.schema, unassociatedDiaSources,
1041 tableName="DiaObject", skipIndex=True)
1042 self.metadata["nRejectedNewDiaObjects"] = len(marginalDiaSources)
1043 return pipeBase.Struct(diaSources=unassociatedDiaSources,
1044 newDiaObjects=newDiaObjects,
1045 nNewDiaObjects=len(newDiaObjects),
1046 marginalDiaSources=marginalDiaSources)
1048 def filterSources(self, sources, snrThreshold=None, lowReliabilitySnrThreshold=None,
1049 reliabilityThreshold=None, lowSnrReliabilityThreshold=None, badFlags=None):
1050 """Select good sources out of a catalog.
1052 Parameters
1053 ----------
1054 sources : `pandas.DataFrame`
1055 Set of DiaSources to check.
1056 snrThreshold : `float`, optional
1057 The minimum signal to noise diaSource to make a new diaObject.
1058 Included for unit tests. Uses the task config value if not set.
1059 lowReliabilitySnrThreshold : `float`, optional
1060 Use ``lowSnrReliabilityThreshold`` as the reliability threshold for
1061 diaSources with ``snrThreshold`` < SNR < ``lowReliabilitySnrThreshold``
1062 Included for unit tests. Uses the task config value if not set.
1063 reliabilityThreshold : `float`, optional
1064 The minimum reliability score diaSource to make a new diaObject
1065 Included for unit tests. Uses the task config value if not set.
1066 lowSnrReliabilityThreshold : `float`, optional
1067 Use ``lowSnrReliabilityThreshold`` as the reliability threshold for
1068 diaSources with ``snrThreshold`` < SNR < ``lowReliabilitySnrThreshold``
1069 Included for unit tests. Uses the task config value if not set.
1070 badFlags : `list` of `str`, optional
1071 Do not create new diaObjects for any diaSource with any of these
1072 flags set.
1073 Included for unit tests. Uses the task config value if not set.
1075 Returns
1076 -------
1077 results : `lsst.pipe.base.Struct`
1078 Results struct containing:
1080 - goodSources : `pandas.DataFrame`
1081 Subset of the input sources that pass all checks.
1082 - badSources : `pandas.DataFrame`
1083 Subset of the input sources that fail any check.
1084 """
1085 if snrThreshold is None:
1086 snrThreshold = self.config.newObjectSnrThreshold
1087 if lowReliabilitySnrThreshold is None:
1088 lowReliabilitySnrThreshold = self.config.newObjectLowReliabilitySnrThreshold
1089 if reliabilityThreshold is None:
1090 reliabilityThreshold = self.config.newObjectReliabilityThreshold
1091 if lowSnrReliabilityThreshold is None:
1092 lowSnrReliabilityThreshold = self.config.newObjectLowSnrReliabilityThreshold
1093 if badFlags is None:
1094 badFlags = self.config.newObjectBadFlags
1095 flagged = sources[badFlags].fillna(False).any(axis=1)
1096 fluxField = self.config.newObjectFluxField
1097 fluxErrField = fluxField + "Err"
1098 signalToNoise = np.abs(np.array(sources[fluxField]/sources[fluxErrField]))
1099 reliability = np.array(sources['reliability'])
1100 nFlagged = np.count_nonzero(flagged)
1101 if nFlagged > 0:
1102 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to flags", nFlagged)
1104 if snrThreshold > 0:
1105 snr_flag = signalToNoise < snrThreshold
1106 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to %sFlux"
1107 " signal to noise < %f",
1108 np.sum(snr_flag), fluxField, snrThreshold)
1109 flagged |= snr_flag
1110 if reliabilityThreshold > 0:
1111 reliability_flag = reliability < reliabilityThreshold
1112 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to "
1113 "reliability<%f",
1114 np.sum(reliability_flag), reliabilityThreshold)
1115 flagged |= reliability_flag
1116 if min(lowReliabilitySnrThreshold, lowSnrReliabilityThreshold) > 0:
1117 # Only run the combined test if both thresholds are greater than zero
1118 lowSnrReliability_flag = ((signalToNoise < lowReliabilitySnrThreshold)
1119 & (reliability < lowSnrReliabilityThreshold))
1120 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to %sFlux"
1121 " signal to noise < %f combined with reliability< %f",
1122 np.sum(lowSnrReliability_flag),
1123 fluxField,
1124 lowReliabilitySnrThreshold,
1125 lowSnrReliabilityThreshold)
1126 flagged |= lowSnrReliability_flag
1128 if np.count_nonzero(~flagged) > 0: 1128 ↛ 1131line 1128 didn't jump to line 1131 because the condition on line 1128 was always true
1129 goodSources = sources[~flagged].copy(deep=True)
1130 else:
1131 goodSources = make_empty_catalog(self.schema, tableName="DiaSource")
1132 if np.count_nonzero(flagged) > 0:
1133 badSources = sources[flagged].copy(deep=True)
1134 else:
1135 badSources = make_empty_catalog(self.schema, tableName="DiaSource")
1136 return pipeBase.Struct(goodSources=goodSources,
1137 badSources=badSources
1138 )
1140 @timeMethod
1141 def associateDiaSources(self, diaSourceTable, solarSystemObjectTable, diffIm, diaObjects):
1142 """Associate DiaSources with DiaObjects.
1144 Associate new DiaSources with existing DiaObjects. Create new
1145 DiaObjects fron unassociated DiaSources. Index DiaSource catalogue
1146 after associations. Append new DiaObjects and DiaSources to their
1147 previous history. Test for DiaSource and DiaObject duplications.
1148 Compute DiaObject Summary statistics from their full DiaSource
1149 history. Test for duplication in the updated DiaObjects.
1151 Parameters
1152 ----------
1153 diaSourceTable : `pandas.DataFrame`
1154 Newly detected DiaSources.
1155 solarSystemObjectTable : `astropy.table.Table`
1156 Preloaded Solar System objects expected to be visible in the image.
1157 diffIm : `lsst.afw.image.ExposureF`
1158 Difference image exposure in which the sources in ``diaSourceCat``
1159 were detected.
1160 diaObjects : `pandas.DataFrame`
1161 Table of DiaObjects from preloaded DiaObjects.
1163 Returns
1164 -------
1165 results : `lsst.pipe.base.Struct`
1166 Results struct containing:
1168 - associatedDiaSources : `pandas.DataFrame`
1169 Associated DiaSources with DiaObjects.
1170 - newDiaObjects : `pandas.DataFrame`
1171 Table of new DiaObjects after association.
1172 - associatedSsSources : `astropy.table.Table`
1173 Table of new ssSources after association.
1174 - unassociatedSsObjects : `astropy.table.Table`
1175 Table of expected ssSources that were not associated with a
1176 diaSource.
1177 - newDiaSources : `pandas.DataFrame`
1178 Subset of `associatedDiaSources` consisting of only the
1179 unassociated diaSources that were added as new diaObjects.
1180 - marginalDiaSources : `pandas.DataFrame`
1181 Unassociated diaSources with marginal detections, which were
1182 removed from `associatedDiaSources` and were not added as new
1183 diaObjects.
1184 """
1185 associatedCatalogs = []
1186 # First associate diaSources with known asteroids
1187 if self.config.doSolarSystemAssociation and solarSystemObjectTable is not None:
1188 ssoAssocResult = self.solarSystemAssociator.run(
1189 Table.from_pandas(diaSourceTable),
1190 solarSystemObjectTable,
1191 diffIm.visitInfo,
1192 diffIm.getBBox(),
1193 diffIm.wcs
1194 )
1195 nTotalSsObjects = ssoAssocResult.nTotalSsObjects
1196 nAssociatedSsObjects = ssoAssocResult.nAssociatedSsObjects
1197 associatedSsSources = ssoAssocResult.associatedSsSources
1198 unassociatedSsObjects = ssoAssocResult.unassociatedSsObjects
1199 if len(ssoAssocResult.ssoAssocDiaSources) > 0: 1199 ↛ 1200line 1199 didn't jump to line 1200 because the condition on line 1199 was never true
1200 associatedCatalogs.append(ssoAssocResult.ssoAssocDiaSources.to_pandas())
1201 if len(ssoAssocResult.unAssocDiaSources) > 0: 1201 ↛ 1204line 1201 didn't jump to line 1204 because the condition on line 1201 was never true
1202 # If the table is empty then converting time fields to pandas
1203 # will raise an error. Pass in an empty Dataframe in that case.
1204 unAssocSSDiaSources = ssoAssocResult.unAssocDiaSources.to_pandas()
1205 else:
1206 unAssocSSDiaSources = make_empty_catalog(self.schema, tableName="DiaSource")
1207 else:
1208 unAssocSSDiaSources = diaSourceTable
1209 nTotalSsObjects = 0
1210 nAssociatedSsObjects = 0
1211 associatedSsSources = None
1212 unassociatedSsObjects = None
1213 # Associate new DiaSources with existing DiaObjects.
1214 assocResults = self.associator.run(unAssocSSDiaSources, diaObjects, schema=self.schema)
1215 createResults = self.createNewDiaObjects(assocResults.unAssocDiaSources)
1217 if not assocResults.matchedDiaSources.empty:
1218 associatedCatalogs.append(assocResults.matchedDiaSources)
1219 if not createResults.diaSources.empty:
1220 associatedCatalogs.append(createResults.diaSources)
1221 if len(associatedCatalogs) == 0:
1222 associatedDiaSources = make_empty_catalog(self.schema, tableName="DiaSource")
1223 else:
1224 # Standardize each component to the schema before concatinating, so
1225 # that type mis-matches are fixed as early as possible.
1226 associatedDiaSources = pd.concat(
1227 [convertDataFrameToSdmSchema(self.schema, df, tableName="DiaSource", skipIndex=True)
1228 for df in associatedCatalogs])
1230 self._add_association_meta_data(assocResults.nUpdatedDiaObjects,
1231 assocResults.nUnassociatedDiaObjects,
1232 createResults.nNewDiaObjects,
1233 nTotalSsObjects,
1234 nAssociatedSsObjects)
1235 self.log.info("%i updated and %i unassociated diaObjects. Creating %i new diaObjects"
1236 " and dropping %i marginal diaSources.",
1237 assocResults.nUpdatedDiaObjects,
1238 assocResults.nUnassociatedDiaObjects,
1239 createResults.nNewDiaObjects,
1240 len(createResults.marginalDiaSources),
1241 )
1242 if createResults.nNewDiaObjects > self.config.maxNewDiaObjects > 0:
1243 raise TooManyDiaObjectsError(nNewDiaObjects=createResults.nNewDiaObjects,
1244 threshold=self.config.maxNewDiaObjects)
1245 return pipeBase.Struct(associatedDiaSources=associatedDiaSources,
1246 newDiaObjects=createResults.newDiaObjects,
1247 associatedSsSources=associatedSsSources,
1248 unassociatedSsObjects=unassociatedSsObjects,
1249 newDiaSources=createResults.diaSources,
1250 marginalDiaSources=createResults.marginalDiaSources
1251 )
1253 def standardizeDataFrame(self, dataFrame, tableName, nullColumns=None):
1254 """Convert a catalog to SDM schema format with NULL ID values
1256 Parameters
1257 ----------
1258 dataFrame : `pandas.DataFrame`
1259 The catalog to standardize/convert
1260 tableName : `str`
1261 Schema name of table to which this dataFrame should be standardized
1262 nullColumns : `list` of `str`, optional
1263 List of column names to check for default values of 0 that should be
1264 replaced with `NULL`.
1267 Returns
1268 -------
1269 standardizedAssociatedDiaSources : pandas.DataFrame
1270 The standardized DiaSource catalog
1271 """
1272 standardizedDataFrame = convertDataFrameToSdmSchema(self.schema,
1273 dataFrame,
1274 tableName=tableName,
1275 skipIndex=True)
1277 def _setNullColumn(dataframe, colName):
1278 """Set specified columns with default values of 0 to NULL."""
1279 dataframe.loc[dataframe[colName] == 0, colName] = pd.NA
1281 if nullColumns is not None: 1281 ↛ 1284line 1281 didn't jump to line 1284 because the condition on line 1281 was always true
1282 for colName in nullColumns:
1283 _setNullColumn(standardizedDataFrame, colName)
1284 return standardizedDataFrame
1286 def standardizeTable(self, table, tableName, nullColumns=None):
1287 """Convert a catalog to SDM schema format with NULL ID values
1289 Parameters
1290 ----------
1291 table : `astropy.table.Table`
1292 The catalog to standardize/convert
1293 tableName : `str`
1294 Schema name of table to which this dataFrame should be standardized
1295 nullColumns : `list` of `str`, optional
1296 List of column names to check for default values of 0 that should be
1297 replaced with `NULL`.
1300 Returns
1301 -------
1302 standardizedAssociatedDiaSources : pandas.DataFrame
1303 The standardized DiaSource catalog
1304 """
1305 dataFrame = table.to_pandas()
1306 standardizedDataFrame = self.standardizeDataFrame(dataFrame, tableName, nullColumns=nullColumns)
1307 return standardizedDataFrame
1309 @timeMethod
1310 def mergeAssociatedCatalogs(self, preloadedDiaSources, associatedDiaSources, diaObjects, newDiaObjects,
1311 diffIm):
1312 """Merge the associated diaSource and diaObjects to their previous history.
1314 Parameters
1315 ----------
1316 preloadedDiaSources : `pandas.DataFrame`
1317 Previously detected DiaSources, loaded from the APDB.
1318 associatedDiaSources : `pandas.DataFrame`
1319 Associated DiaSources with DiaObjects.
1320 diaObjects : `pandas.DataFrame`
1321 Table of DiaObjects from preloaded DiaObjects.
1322 newDiaObjects : `pandas.DataFrame`
1323 Table of new DiaObjects after association.
1325 Raises
1326 ------
1327 RuntimeError
1328 Raised if duplicate DiaObjects or duplicate DiaSources are found.
1330 Returns
1331 -------
1332 mergedDiaSourceHistory : `pandas.DataFrame`
1333 The combined catalog, with all of the rows from preloadedDiaSources
1334 catalog ordered before the rows of associatedDiaSources catalog.
1335 mergedDiaObjects : `pandas.DataFrame`
1336 Table of new DiaObjects merged with their history.
1337 updatedDiaObjectIds : `numpy.Array`
1338 Object Id's from associated diaSources.
1339 """
1340 # Index the DiaSource catalog for this visit after all associations
1341 # have been made.
1342 updatedDiaObjectIds = associatedDiaSources["diaObjectId"][
1343 associatedDiaSources["diaObjectId"] != 0].to_numpy()
1344 associatedDiaSources.set_index(["diaObjectId",
1345 "band",
1346 "diaSourceId"],
1347 drop=False,
1348 inplace=True)
1350 # Append new DiaObjects and DiaSources to their previous history.
1351 if diaObjects.empty: 1351 ↛ 1352line 1351 didn't jump to line 1352 because the condition on line 1351 was never true
1352 mergedDiaObjects = newDiaObjects.set_index("diaObjectId", drop=False)
1353 elif not newDiaObjects.empty: 1353 ↛ 1354line 1353 didn't jump to line 1354 because the condition on line 1353 was never true
1354 mergedDiaObjects = pd.concat(
1355 [diaObjects,
1356 newDiaObjects.set_index("diaObjectId", drop=False)],
1357 sort=True)
1358 else:
1359 mergedDiaObjects = diaObjects
1361 # Exclude any objects that are off the image after association.
1362 mergedDiaObjects, updatedDiaObjectIds = self.purgeDiaObjects(diffIm.getBBox(), diffIm.getWcs(),
1363 mergedDiaObjects,
1364 diaObjectIds=updatedDiaObjectIds,
1365 buffer=-1)
1366 if self.testDataFrameIndex(mergedDiaObjects): 1366 ↛ 1367line 1366 didn't jump to line 1367 because the condition on line 1366 was never true
1367 raise RuntimeError(
1368 "Duplicate DiaObjects created after association. This is "
1369 "likely due to re-running data with an already populated "
1370 "Apdb. If this was not the case then there was an unexpected "
1371 "failure in Association while matching and creating new "
1372 "DiaObjects and should be reported. Exiting.")
1374 mergedDiaSourceHistory = self.mergeCatalogs(preloadedDiaSources, associatedDiaSources,
1375 tableName="DiaSource")
1377 # Test for DiaSource duplication first. If duplicates are found,
1378 # this likely means this is duplicate data being processed and sent
1379 # to the Apdb.
1380 if self.testDataFrameIndex(mergedDiaSourceHistory): 1380 ↛ 1381line 1380 didn't jump to line 1381 because the condition on line 1380 was never true
1381 raise RuntimeError(
1382 "Duplicate DiaSources found after association and merging "
1383 "with history. This is likely due to re-running data with an "
1384 "already populated Apdb. If this was not the case then there "
1385 "was an unexpected failure in Association while matching "
1386 "sources to objects, and should be reported. Exiting.")
1387 # Finally, update the diaObject table with the number of associated diaSources
1388 mergedUpdatedDiaObjects = self.updateObjectTable(mergedDiaObjects, mergedDiaSourceHistory)
1389 return (mergedDiaSourceHistory, mergedUpdatedDiaObjects, updatedDiaObjectIds)
1391 @timeMethod
1392 def runForcedMeasurement(self, diaObjects, updatedDiaObjects, exposure, diffIm, idGenerator):
1393 """Forced Source Measurement
1395 Forced photometry on the difference and calibrated exposures using the
1396 new and updated DiaObject locations.
1398 Parameters
1399 ----------
1400 diaObjects : `pandas.DataFrame`
1401 Catalog of DiaObjects.
1402 updatedDiaObjects : `pandas.DataFrame`
1403 Catalog of updated DiaObjects.
1404 exposure : `lsst.afw.image.ExposureF`
1405 Calibrated exposure differenced with a template to create
1406 ``diffIm``.
1407 diffIm : `lsst.afw.image.ExposureF`
1408 Difference image exposure in which the sources in ``diaSourceCat``
1409 were detected.
1410 idGenerator : `lsst.meas.base.IdGenerator`
1411 Object that generates source IDs and random number generator seeds.
1413 Returns
1414 -------
1415 diaForcedSources : `pandas.DataFrame`
1416 Catalog of calibrated forced photometered fluxes on both the
1417 difference and direct images at DiaObject locations.
1418 """
1419 # Force photometer on the Difference and Calibrated exposures using
1420 # the new and updated DiaObject locations.
1421 diaForcedSources = self.diaForcedSource.run(
1422 diaObjects,
1423 updatedDiaObjects.loc[:, "diaObjectId"].to_numpy(),
1424 exposure,
1425 diffIm,
1426 idGenerator=idGenerator)
1427 self.log.info(f"Updating {len(diaForcedSources)} diaForcedSources in the APDB")
1428 diaForcedSources = convertDataFrameToSdmSchema(self.schema, diaForcedSources,
1429 tableName="DiaForcedSource", skipIndex=True)
1430 return diaForcedSources
1432 @timeMethod
1433 def loadRefreshedDiaObjects(self, region, preloadedDiaObjects=None):
1434 """Load DiaObjects from the Apdb based on their HTM location.
1436 Parameters
1437 ----------
1438 region : `sphgeom.Region`
1439 Region containing the current exposure to load diaObjects from the
1440 APDB.
1441 preloadedDiaObjects : `pandas.DataFrame`, optional
1442 Previously detected DiaObjects, loaded from the APDB. `None` if
1443 the preloaded catalogs are not available.
1445 Returns
1446 -------
1447 diaObjects : `pandas.DataFrame`
1448 DiaObjects loaded from the Apdb that are within the area defined
1449 by ``region``.
1450 """
1451 refreshedDiaObjects = loadDiaObjectsFromApdb(self.apdb, self._paddedRegion(region), self.schema,
1452 self.log)
1453 if preloadedDiaObjects is None:
1454 return refreshedDiaObjects
1456 refreshedIsInPreloaded = refreshedDiaObjects.index.isin(preloadedDiaObjects.index)
1457 preloadedIsInRefreshed = preloadedDiaObjects.index.isin(refreshedDiaObjects.index)
1458 nUniqueRefreshed = (~refreshedIsInPreloaded).sum()
1459 nUniquePreloaded = (~preloadedIsInRefreshed).sum()
1460 if nUniqueRefreshed > 0: 1460 ↛ 1461line 1460 didn't jump to line 1461 because the condition on line 1460 was never true
1461 self.log.info("Reloading the diaObject table during association yielded "
1462 "an additional %d objects over the %d preloaded diaObjects.",
1463 nUniqueRefreshed, len(preloadedDiaObjects))
1464 if nUniquePreloaded == 0: 1464 ↛ 1465line 1464 didn't jump to line 1465 because the condition on line 1464 was never true
1465 return refreshedDiaObjects
1466 # ap_verify CI datasets ship a preloaded diaObject catalog
1467 # alongside an empty APDB, so some preloaded objects may not
1468 # appear in the refresh. Combine the two, giving precedence to
1469 # refreshed entries on overlap so that updated column values are
1470 # not silently discarded when the refresh adds no new ids.
1471 return pd.concat([refreshedDiaObjects, preloadedDiaObjects.loc[~preloadedIsInRefreshed]])
1473 @timeMethod
1474 def loadRefreshedDiaSources(self, region, diaObjects, visitTime):
1475 """Reload the DiaSource history from the Apdb.
1477 Used only in non-time-critical environments where it is more important
1478 to load the most recent DiaSources than to save time by preloading
1479 catalogs.
1481 Parameters
1482 ----------
1483 region : `sphgeom.Region`
1484 Region containing the current exposure to load the history from
1485 the APDB.
1486 diaObjects : `pandas.DataFrame`
1487 DiaObjects to load the history for, indexed by ``diaObjectId``.
1488 visitTime : `astropy.time.Time`
1489 Time of the current visit.
1491 Returns
1492 -------
1493 diaSources : `pandas.DataFrame`
1494 DiaSource history loaded from the Apdb, indexed by
1495 ``diaObjectId``, ``band``, and ``diaSourceId``.
1496 """
1497 return loadDiaSourcesFromApdb(self.apdb, self._paddedRegion(region),
1498 diaObjects.loc[:, "diaObjectId"], visitTime, self.schema, self.log)
1500 @timeMethod
1501 def loadRefreshedDiaForcedSources(self, region, diaObjects, visitTime):
1502 """Reload the DiaForcedSource history from the Apdb.
1504 Used only in non-time-critical environments where it is more important
1505 to load the most recent DiaForcedSources than to save time by preloading
1506 catalogs.
1508 Parameters
1509 ----------
1510 region : `sphgeom.Region`
1511 Region containing the current exposure to load the history from
1512 the APDB.
1513 diaObjects : `pandas.DataFrame`
1514 DiaObjects to load the history for, indexed by ``diaObjectId``.
1515 visitTime : `astropy.time.Time`
1516 Time of the current visit.
1518 Returns
1519 -------
1520 diaForcedSources : `pandas.DataFrame`
1521 DiaForcedSource history loaded from the Apdb, indexed by
1522 ``diaObjectId`` and ``diaForcedSourceId``.
1523 """
1524 return loadDiaForcedSourcesFromApdb(self.apdb, self._paddedRegion(region),
1525 diaObjects.loc[:, "diaObjectId"], visitTime, self.schema,
1526 self.log)
1528 def _paddedRegion(self, region):
1529 """Pad a region by the configured margin.
1531 Parameters
1532 ----------
1533 region : `sphgeom.Region`
1534 Region containing the current exposure.
1536 Returns
1537 -------
1538 region : `sphgeom.Region`
1539 The region, expanded by ``config.angleMargin``.
1540 """
1541 return paddedRegion(region, lsst.sphgeom.Angle.fromDegrees(self.config.angleMargin/3600.))
1543 @timeMethod
1544 def writeToApdb(self, updatedDiaObjects, associatedDiaSources, diaForcedSources):
1545 """Write to the Alert Production Database (Apdb).
1547 Store DiaSources, updated DiaObjects, and DiaForcedSources in the
1548 Alert Production Database (Apdb).
1550 Parameters
1551 ----------
1552 updatedDiaObjects : `pandas.DataFrame`
1553 Catalog of updated DiaObjects.
1554 associatedDiaSources : `pandas.DataFrame`
1555 Associated DiaSources with DiaObjects.
1556 diaForcedSources : `pandas.DataFrame`
1557 Catalog of calibrated forced photometered fluxes on both the
1558 difference and direct images at DiaObject locations.
1560 Returns
1561 -------
1562 validityStart : `lsst.daf.base.DateTime`
1563 Time at which the APDB was updated.
1564 """
1565 # Store DiaSources, updated DiaObjects, and DiaForcedSources in the
1566 # Apdb.
1567 # Drop empty columns that are nullable in the APDB.
1568 diaObjectStore = dropEmptyColumns(self.schema, updatedDiaObjects, tableName="DiaObject")
1569 diaSourceStore = dropEmptyColumns(self.schema, associatedDiaSources, tableName="DiaSource")
1570 diaForcedSourceStore = dropEmptyColumns(self.schema, diaForcedSources, tableName="DiaForcedSource")
1572 validityStart = DateTime.now()
1573 self.apdb.store(
1574 validityStart.toAstropy(),
1575 diaObjectStore,
1576 diaSourceStore,
1577 diaForcedSourceStore)
1578 self.log.info("APDB updated.")
1580 return validityStart
1582 def testDataFrameIndex(self, df):
1583 """Test the sorted DataFrame index for duplicates.
1585 Wrapped as a separate function to allow for mocking of the this task
1586 in unittesting. Default of a mock return for this test is True.
1588 Parameters
1589 ----------
1590 df : `pandas.DataFrame`
1591 DataFrame to text.
1593 Returns
1594 -------
1595 `bool`
1596 True if DataFrame contains duplicate rows.
1597 """
1598 return df.index.has_duplicates
1600 def _add_association_meta_data(self,
1601 nUpdatedDiaObjects,
1602 nUnassociatedDiaObjects,
1603 nNewDiaObjects,
1604 nTotalSsObjects,
1605 nAssociatedSsObjects):
1606 """Store summaries of the association step in the task metadata.
1608 Parameters
1609 ----------
1610 nUpdatedDiaObjects : `int`
1611 Number of previous DiaObjects associated and updated in this
1612 ccdVisit.
1613 nUnassociatedDiaObjects : `int`
1614 Number of previous DiaObjects that were not associated or updated
1615 in this ccdVisit.
1616 nNewDiaObjects : `int`
1617 Number of newly created DiaObjects for this ccdVisit.
1618 nTotalSsObjects : `int`
1619 Number of SolarSystemObjects within the observable detector
1620 area.
1621 nAssociatedSsObjects : `int`
1622 Number of successfully associated SolarSystemObjects.
1623 """
1624 self.metadata['numUpdatedDiaObjects'] = nUpdatedDiaObjects
1625 self.metadata['numUnassociatedDiaObjects'] = nUnassociatedDiaObjects
1626 self.metadata['numNewDiaObjects'] = nNewDiaObjects
1627 self.metadata['numTotalSolarSystemObjects'] = nTotalSsObjects
1628 self.metadata['numAssociatedSsObjects'] = nAssociatedSsObjects
1630 def purgeDiaObjects(self, bbox, wcs, diaObjCat, diaObjectIds=None, buffer=0):
1631 """Drop diaObjects that are outside the exposure bounding box.
1633 Parameters
1634 ----------
1635 bbox : `lsst.geom.Box2I`
1636 Bounding box of the exposure.
1637 wcs : `lsst.afw.geom.SkyWcs`
1638 Coordinate system definition (wcs) for the exposure.
1639 diaObjCat : `pandas.DataFrame`
1640 DiaObjects loaded from the Apdb.
1641 buffer : `int`, optional
1642 Width, in pixels, to pad the exposure bounding box.
1644 Returns
1645 -------
1646 diaObjCat : `pandas.DataFrame`
1647 DiaObjects loaded from the Apdb, restricted to the exposure
1648 bounding box.
1649 """
1650 # Copy the bbox so the caller's box is not mutated by grow().
1651 bbox = type(bbox)(bbox)
1652 try:
1653 bbox.grow(buffer)
1654 raVals = diaObjCat.ra.to_numpy()
1655 decVals = diaObjCat.dec.to_numpy()
1656 xVals, yVals = wcs.skyToPixelArray(raVals, decVals, degrees=True)
1657 selector = bbox.contains(xVals, yVals)
1658 nPurged = np.sum(~selector)
1659 if nPurged > 0:
1660 if diaObjectIds is not None: 1660 ↛ 1668line 1660 didn't jump to line 1668 because the condition on line 1660 was always true
1661 # We also need to drop any of the associated IDs if this runs after association
1662 purgedIds = diaObjCat[~selector].diaObjectId
1663 diaObjectIds = diaObjectIds[~np.isin(diaObjectIds, purgedIds)]
1664 self.log.info("Dropped %i diaObjects that were outside the bbox "
1665 "after association, leaving %i in the catalog",
1666 nPurged, len(diaObjCat) - nPurged)
1667 else:
1668 self.log.info("Dropped %i diaObjects that were outside the padded bbox "
1669 "before association, leaving %i in the catalog",
1670 nPurged, len(diaObjCat) - nPurged)
1671 diaObjCat = diaObjCat[selector].copy()
1672 except (AttributeError, KeyError, ValueError) as e:
1673 self.log.warning("Error attempting to check diaObject history: %s", e, exc_info=e)
1674 return diaObjCat, diaObjectIds
1676 def mergeCatalogs(self, originalCatalog, newCatalog, tableName):
1677 """Combine two catalogs, ensuring that the new catalog conforms to the schema.
1679 Parameters
1680 ----------
1681 originalCatalog : `pandas.DataFrame`
1682 The original catalog to be added to.
1683 newCatalog : `pandas.DataFrame`
1684 The new catalog to append to `originalCatalog`
1685 tableName : `str`
1686 Name of the table in the schema to use.
1688 Returns
1689 -------
1690 mergedCatalog : `pandas.DataFrame`
1691 The combined catalog, with all of the rows from ``originalCatalog``
1692 ordered before the rows of ``newCatalog``
1693 """
1694 if len(newCatalog) > 0:
1695 catalog = convertDataFrameToSdmSchema(self.schema, newCatalog,
1696 tableName=tableName, skipIndex=True)
1698 mergedCatalog = pd.concat([originalCatalog, catalog], sort=True)
1699 else:
1700 mergedCatalog = pd.concat([originalCatalog], sort=True)
1701 return mergedCatalog.loc[:, originalCatalog.columns]
1703 def updateObjectTable(self, diaObjects, diaSources):
1704 """Update the diaObject table with the new diaSource records.
1706 Parameters
1707 ----------
1708 diaObjects : `pandas.DataFrame`
1709 Table of new DiaObjects merged with their history.
1710 diaSources : `pandas.DataFrame`
1711 The combined preloaded and associated diaSource catalog.
1713 Returns
1714 -------
1715 updatedDiaObjects : `pandas.DataFrame`
1716 Table of DiaObjects updated with the number of associated DiaSources
1717 """
1718 # Group on the index level explicitly since diaObjectId is both an
1719 # index and a (duplicated) column
1720 nDiaSources = diaSources.groupby(level="diaObjectId").size().rename("nDiaSources")
1721 diaObjects = diaObjects.drop(columns="nDiaSources", errors="ignore")
1722 updatedDiaObjects = diaObjects.join(nDiaSources, how="left")
1723 return updatedDiaObjects
1725 @staticmethod
1726 def checkTableIndex(dataFrame, index):
1727 if dataFrame.index.name is None:
1728 # The expected index may or may not be set, depending on whether
1729 # the table was written originally as a DataFrame or something else
1730 # Parquet-friendly.
1731 dataFrame.set_index(index, drop=False, inplace=True)