Coverage for python/lsst/ap/association/diaPipe.py: 85%
495 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-09 18:03 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-09 18:03 +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_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 # Alerts include the DiaSource history, so check that it has every
741 # column they require while the image can still be retried.
742 if self.config.doPackageAlerts and not preloadedDiaSources.empty:
743 missing = self.alertPackager.findMissingDiaSourceColumns(preloadedDiaSources)
744 if missing:
745 raise ValueError(
746 "Aborting before writing to the APDB: the DiaSource history lacks columns that "
747 f"alerts require: {', '.join(missing)}. It may predate these columns in the APDB schema."
748 )
750 if not preloadedDiaObjects.empty: 750 ↛ 755line 750 didn't jump to line 755 because the condition on line 750 was always true
751 # Include a small buffer outside the image so that we can associate sources near the edge
752 diaObjects, _ = self.purgeDiaObjects(diffIm.getBBox(), diffIm.getWcs(), preloadedDiaObjects,
753 buffer=self.config.imagePixelMargin)
754 else:
755 self.log.info("Preloaded DiaObject table is empty.")
756 diaObjects = preloadedDiaObjects
758 # Associate DiaSources with DiaObjects
759 assocResults = self.associateDiaSources(diaSourceTable, solarSystemObjectTable, diffIm, diaObjects)
761 # Set unassociated diaObjectIds and ssObjectIds to NULL, and convert to SDM schema format
762 standardizedAssociatedDiaSources = self.standardizeDataFrame(
763 assocResults.associatedDiaSources,
764 "DiaSource",
765 nullColumns=["diaObjectId", "ssObjectId"]
766 )
768 # Merge associated diaSources
769 mergedDiaSourceHistory, mergedDiaObjects, updatedDiaObjectIds = self.mergeAssociatedCatalogs(
770 preloadedDiaSources,
771 assocResults.associatedDiaSources,
772 diaObjects,
773 assocResults.newDiaObjects,
774 diffIm
775 )
777 # Compute DiaObject Summary statistics from their full DiaSource
778 # history.
779 diaCalResult = self.diaCalculation.run(
780 mergedDiaObjects,
781 mergedDiaSourceHistory,
782 updatedDiaObjectIds,
783 self.config.validBands)
784 updatedDiaObjects = convertDataFrameToSdmSchema(self.schema, diaCalResult.updatedDiaObjects,
785 tableName="DiaObject", skipIndex=True)
787 # Test for duplication in the updated DiaObjects.
788 if self.testDataFrameIndex(diaCalResult.diaObjectCat): 788 ↛ 789line 788 didn't jump to line 789 because the condition on line 788 was never true
789 raise RuntimeError(
790 "Duplicate DiaObjects (loaded + updated) created after "
791 "DiaCalculation. This is unexpected behavior and should be "
792 "reported. Exiting.")
793 if self.testDataFrameIndex(updatedDiaObjects): 793 ↛ 794line 793 didn't jump to line 794 because the condition on line 793 was never true
794 raise RuntimeError(
795 "Duplicate DiaObjects (updated) created after "
796 "DiaCalculation. This is unexpected behavior and should be "
797 "reported. Exiting.")
799 # Forced source measurement
800 if self.config.doRunForcedMeasurement:
801 diaObjectsForced = self._selectGoodDiaObjects(diaCalResult.diaObjectCat, mergedDiaSourceHistory)
802 diaForcedSources = self.runForcedMeasurement(
803 diaObjectsForced, updatedDiaObjects, exposure, diffIm, idGenerator
804 )
805 forcedSourceHistoryThreshold = self.diaForcedSource.config.historyThreshold
806 else:
807 # alertPackager needs correct columns
808 diaForcedSources = makeEmptyForcedSourceTable(self.schema)
809 forcedSourceHistoryThreshold = 0
811 # Write results to Alert Production Database (APDB)
812 try:
813 validityStart = self.writeToApdb(updatedDiaObjects, standardizedAssociatedDiaSources,
814 diaForcedSources)
815 finally:
816 self.metadata["writeToApdbDuration"] = duration_from_timeMethod(self.metadata, "writeToApdb",
817 clock="Utc")
818 # A single log message is easier for Loki to parse than timeMethod's start+end pairs.
819 self.log.verbose("writeToApdb: Took %.4f seconds", self.metadata["writeToApdbDuration"])
821 # For historical reasons, apdbMarker is a Config even if it's not meant to be read.
822 # A default Config is the cheapest way to satisfy the storage class.
823 associationResults.apdbMarker = pexConfig.Config()
824 associationResults.associatedDiaSources = assocResults.associatedDiaSources
825 associationResults.diaForcedSources = diaForcedSources
826 associationResults.diaObjects = diaCalResult.diaObjectCat
827 associationResults.unassociatedSsObjects = assocResults.unassociatedSsObjects
828 associationResults.newDiaSources = assocResults.newDiaSources
829 associationResults.marginalDiaSources = assocResults.marginalDiaSources
831 # Catch *any* error after we have updated the APDB
832 try:
833 associatedSsSources = assocResults.associatedSsSources
834 associatedSsSourcesPlusMpcorb = None
835 if associatedSsSources is not None:
836 mpcorbColumns = [col for col in associatedSsSources.columns if col[:7] == 'MPCORB_']
837 associatedSsSourceMpcorb = associatedSsSources[mpcorbColumns].copy()
838 associatedSsSources = self.standardizeTable(associatedSsSources, "SSSource", nullColumns=[])
839 associatedSsSourcesPlusMpcorb = associatedSsSources.copy()
840 for mpcorbColumn in mpcorbColumns: 840 ↛ 841line 840 didn't jump to line 841 because the loop on line 840 never started
841 associatedSsSourcesPlusMpcorb[mpcorbColumn] = associatedSsSourceMpcorb[mpcorbColumn]
842 associationResults.associatedSsSources = associatedSsSources
844 # patch the otherwise-empty validityStart field for the alerts
845 updatedDiaObjects['validityStartMjdTai'] = validityStart.get(system=DateTime.MJD,
846 scale=DateTime.TAI)
848 # Package alerts
849 if self.config.doPackageAlerts:
850 # Append new forced sources to the full history
851 diaForcedSourcesFull = self.mergeCatalogs(preloadedDiaForcedSources, diaForcedSources,
852 tableName="DiaForcedSource")
853 if self.testDataFrameIndex(diaForcedSourcesFull): 853 ↛ 854line 853 didn't jump to line 854 because the condition on line 853 was never true
854 self.log.warning(
855 "Duplicate DiaForcedSources created after merge with "
856 "history and new sources. This may cause downstream "
857 "problems. Dropping duplicates.")
858 # Drop duplicates via index and keep the first appearance.
859 diaForcedSourcesFull = diaForcedSourcesFull[
860 ~diaForcedSourcesFull.index.duplicated(keep="first")]
862 self.alertPackager.run(assocResults.associatedDiaSources,
863 updatedDiaObjects,
864 preloadedDiaSources,
865 diaForcedSourcesFull,
866 diffIm,
867 exposure,
868 template,
869 ssSrc=associatedSsSourcesPlusMpcorb,
870 doRunForcedMeasurement=self.config.doRunForcedMeasurement,
871 forcedSourceHistoryThreshold=forcedSourceHistoryThreshold,
872 )
873 except Exception as e:
874 # Catch *any* error after we have updated the APDB
875 raise PostApdbUpdateError(errorMsg=repr(e)) from e
877 return associationResults
879 def _validateExposure(self, exposure, label):
880 """Validate exposure metadata early to avoid failures after updating
881 the APDB.
883 Parameters
884 ----------
885 exposure : `lsst.afw.image.ExposureF`
886 Calibrated science image that was subtracted. Corresponds to the
887 ``exposure`` Butler connection (``{fakesType}calexp``).
888 label : `str`
889 Specification of the image being validated, for logging messages.
891 Raises
892 ------
893 ValueError
894 Raised when any required metadata field is missing or invalid.
895 """
896 errors = []
898 photo_calib = exposure.getPhotoCalib()
899 if photo_calib is None: 899 ↛ 900line 899 didn't jump to line 900 because the condition on line 899 was never true
900 errors.append(
901 f"{label}: PhotoCalib is None; flux-to-nJy calibration "
902 "in alert packaging will fail."
903 )
904 else:
905 mean = photo_calib.getCalibrationMean()
906 if not np.isfinite(mean) or mean <= 0.0: 906 ↛ 907line 906 didn't jump to line 907 because the condition on line 906 was never true
907 errors.append(
908 f"{label}: PhotoCalib calibration mean is {mean!r}; "
909 "must be a finite, positive value."
910 )
912 if exposure.getWcs() is None: 912 ↛ 913line 912 didn't jump to line 913 because the condition on line 912 was never true
913 errors.append(
914 f"{label}: WCS is None; sky-to-pixel projection and "
915 "DIA Object association will fail."
916 )
918 # Skip visitInfo checks for the template
919 if label != "Template":
920 visit_info = exposure.getInfo().getVisitInfo()
921 if visit_info is None: 921 ↛ 922line 921 didn't jump to line 922 because the condition on line 921 was never true
922 errors.append(
923 f"{label}: VisitInfo is None; observation time, pointing, "
924 "rotation angle, and exposure duration are unavailable."
925 )
926 else:
927 obs_date = visit_info.getDate()
928 if not obs_date.isValid(): 928 ↛ 929line 928 didn't jump to line 929 because the condition on line 928 was never true
929 errors.append(
930 f"{label}: VisitInfo.date is invalid."
931 )
933 # boresightRaDec — written to APDB rows and alert payloads.
934 boresight = visit_info.getBoresightRaDec()
935 ra_deg = boresight.getRa().asDegrees()
936 dec_deg = boresight.getDec().asDegrees()
937 if not np.isfinite(ra_deg) or not np.isfinite(dec_deg): 937 ↛ 938line 937 didn't jump to line 938 because the condition on line 937 was never true
938 errors.append(
939 f"{label}: VisitInfo.boresightRaDec contains NaN "
940 f"(RA={ra_deg}, Dec={dec_deg}); pointing metadata "
941 "is missing or corrupt."
942 )
944 rot_deg = visit_info.getBoresightRotAngle().asDegrees()
945 if not np.isfinite(rot_deg): 945 ↛ 946line 945 didn't jump to line 946 because the condition on line 945 was never true
946 errors.append(
947 f"{label}: VisitInfo.boresightRotAngle is NaN; the "
948 "ROTPA keyword will be corrupt in all alert cutout "
949 "FITS headers."
950 )
952 filter_label = exposure.getFilter()
953 if filter_label is None: 953 ↛ 954line 953 didn't jump to line 954 because the condition on line 953 was never true
954 errors.append(
955 f"{label}: filter label is None."
956 )
957 else:
958 band = filter_label.bandLabel if filter_label.hasBandLabel() else ""
959 if not band: 959 ↛ 960line 959 didn't jump to line 960 because the condition on line 959 was never true
960 errors.append(
961 f"{label}: filter label carries no band name."
962 )
964 bbox = exposure.getBBox()
965 if bbox.getWidth() <= 0 or bbox.getHeight() <= 0: 965 ↛ 966line 965 didn't jump to line 966 because the condition on line 965 was never true
966 errors.append(
967 f"{label}: bounding box is empty "
968 f"({bbox.getWidth()}×{bbox.getHeight()} px); the "
969 "readout or ISR step may have failed."
970 )
972 if exposure.getPsf() is None: 972 ↛ 973line 972 didn't jump to line 973 because the condition on line 972 was never true
973 errors.append(
974 f"{label}: PSF model is None."
975 )
977 if errors: 977 ↛ 978line 977 didn't jump to line 978 because the condition on line 977 was never true
978 detail = "\n ".join(errors)
979 raise ValueError(
980 "Input exposure metadata is invalid; aborting before any "
981 "APDB writes to prevent non-recoverable data corruption:\n "
982 + detail
983 )
985 def _selectGoodDiaObjects(self, diaObjects, diaSources):
986 """
987 Extract a subset of diaObjects with multiple associated diaSources that
988 pass selection requirements.
989 """
990 n_history = self.diaForcedSource.config.historyThreshold
991 required_columns = ["reliability", "trailLength"]
992 for col in required_columns:
993 if col not in diaSources.columns:
994 raise RuntimeError("Required column '%s' missing from diaSource table." % col)
996 rel_ok = diaSources["reliability"] >= self.config.forcedReliabilityThreshold
997 trailed_ok = diaSources["trailLength"] <= self.config.forcedTrailLengthThreshold
998 badFlags = [f for f in self.config.forcedBadFlags if f in diaSources.columns]
999 if badFlags:
1000 self.log.info("Excluding diaSources with %s flags set when calculating"
1001 " diaObject history for forced photometry." % badFlags)
1002 # Exclude rows where ANY bad flag is True.
1003 # If bad flag columns can contain NA, treat NA as True (i.e., bad) via fillna(True).
1004 bad_any = diaSources[badFlags].fillna(True).any(axis=1)
1005 mask = rel_ok & trailed_ok & ~bad_any
1006 ids = diaSources.index.get_level_values("diaObjectId")[mask]
1008 # Count occurrences in diaSource
1009 counts = pd.Series(ids, copy=False).value_counts()
1011 # IDs that exceed threshold
1012 keep_ids = counts[counts >= n_history].index
1014 # Since diaObject index is unique, direct index filtering is safe and efficient
1015 return diaObjects.loc[diaObjects.index.intersection(keep_ids)]
1017 def createNewDiaObjects(self, unassociatedDiaSources):
1018 """Loop through the set of DiaSources and create new DiaObjects
1019 for unassociated DiaSources.
1021 Parameters
1022 ----------
1023 unassociatedDiaSources : `pandas.DataFrame`
1024 Set of DiaSources to create new DiaObjects from.
1026 Returns
1027 -------
1028 results : `lsst.pipe.base.Struct`
1029 Results struct containing:
1031 - diaSources : `pandas.DataFrame`
1032 DiaSource catalog with updated DiaObject ids.
1033 - newDiaObjects : `pandas.DataFrame`
1034 Newly created DiaObjects from the unassociated DiaSources.
1035 - nNewDiaObjects : `int`
1036 Number of newly created diaObjects.
1037 - marginalDiaSources : `pandas.DataFrame`
1038 Unassociated diaSources with low signal-to-noise and/or
1039 reliability, which are excluded from the new DiaObjects.
1040 """
1041 marginalDiaSources = make_empty_catalog(self.schema, tableName="DiaSource")
1042 if len(unassociatedDiaSources) == 0:
1043 newDiaObjects = make_empty_catalog(self.schema, tableName="DiaObject")
1044 else:
1045 if self.config.filterUnAssociatedSources:
1046 results = self.filterSources(unassociatedDiaSources)
1047 unassociatedDiaSources = results.goodSources
1048 marginalDiaSources = results.badSources
1049 unassociatedDiaSources["diaObjectId"] = unassociatedDiaSources["diaSourceId"]
1050 newDiaObjects = convertDataFrameToSdmSchema(self.schema, unassociatedDiaSources,
1051 tableName="DiaObject", skipIndex=True)
1052 self.metadata["nRejectedNewDiaObjects"] = len(marginalDiaSources)
1053 return pipeBase.Struct(diaSources=unassociatedDiaSources,
1054 newDiaObjects=newDiaObjects,
1055 nNewDiaObjects=len(newDiaObjects),
1056 marginalDiaSources=marginalDiaSources)
1058 def filterSources(self, sources, snrThreshold=None, lowReliabilitySnrThreshold=None,
1059 reliabilityThreshold=None, lowSnrReliabilityThreshold=None, badFlags=None):
1060 """Select good sources out of a catalog.
1062 Parameters
1063 ----------
1064 sources : `pandas.DataFrame`
1065 Set of DiaSources to check.
1066 snrThreshold : `float`, optional
1067 The minimum signal to noise diaSource to make a new diaObject.
1068 Included for unit tests. Uses the task config value if not set.
1069 lowReliabilitySnrThreshold : `float`, optional
1070 Use ``lowSnrReliabilityThreshold`` as the reliability threshold for
1071 diaSources with ``snrThreshold`` < SNR < ``lowReliabilitySnrThreshold``
1072 Included for unit tests. Uses the task config value if not set.
1073 reliabilityThreshold : `float`, optional
1074 The minimum reliability score diaSource to make a new diaObject
1075 Included for unit tests. Uses the task config value if not set.
1076 lowSnrReliabilityThreshold : `float`, optional
1077 Use ``lowSnrReliabilityThreshold`` as the reliability threshold for
1078 diaSources with ``snrThreshold`` < SNR < ``lowReliabilitySnrThreshold``
1079 Included for unit tests. Uses the task config value if not set.
1080 badFlags : `list` of `str`, optional
1081 Do not create new diaObjects for any diaSource with any of these
1082 flags set.
1083 Included for unit tests. Uses the task config value if not set.
1085 Returns
1086 -------
1087 results : `lsst.pipe.base.Struct`
1088 Results struct containing:
1090 - goodSources : `pandas.DataFrame`
1091 Subset of the input sources that pass all checks.
1092 - badSources : `pandas.DataFrame`
1093 Subset of the input sources that fail any check.
1094 """
1095 if snrThreshold is None:
1096 snrThreshold = self.config.newObjectSnrThreshold
1097 if lowReliabilitySnrThreshold is None:
1098 lowReliabilitySnrThreshold = self.config.newObjectLowReliabilitySnrThreshold
1099 if reliabilityThreshold is None:
1100 reliabilityThreshold = self.config.newObjectReliabilityThreshold
1101 if lowSnrReliabilityThreshold is None:
1102 lowSnrReliabilityThreshold = self.config.newObjectLowSnrReliabilityThreshold
1103 if badFlags is None:
1104 badFlags = self.config.newObjectBadFlags
1105 flagged = sources[badFlags].fillna(False).any(axis=1)
1106 fluxField = self.config.newObjectFluxField
1107 fluxErrField = fluxField + "Err"
1108 signalToNoise = np.abs(np.array(sources[fluxField]/sources[fluxErrField]))
1109 reliability = np.array(sources['reliability'])
1110 nFlagged = np.count_nonzero(flagged)
1111 if nFlagged > 0:
1112 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to flags", nFlagged)
1114 if snrThreshold > 0:
1115 snr_flag = signalToNoise < snrThreshold
1116 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to %sFlux"
1117 " signal to noise < %f",
1118 np.sum(snr_flag), fluxField, snrThreshold)
1119 flagged |= snr_flag
1120 if reliabilityThreshold > 0:
1121 reliability_flag = reliability < reliabilityThreshold
1122 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to "
1123 "reliability<%f",
1124 np.sum(reliability_flag), reliabilityThreshold)
1125 flagged |= reliability_flag
1126 if min(lowReliabilitySnrThreshold, lowSnrReliabilityThreshold) > 0:
1127 # Only run the combined test if both thresholds are greater than zero
1128 lowSnrReliability_flag = ((signalToNoise < lowReliabilitySnrThreshold)
1129 & (reliability < lowSnrReliabilityThreshold))
1130 self.log.info("Not creating new diaObjects for %i unassociated diaSources due to %sFlux"
1131 " signal to noise < %f combined with reliability< %f",
1132 np.sum(lowSnrReliability_flag),
1133 fluxField,
1134 lowReliabilitySnrThreshold,
1135 lowSnrReliabilityThreshold)
1136 flagged |= lowSnrReliability_flag
1138 if np.count_nonzero(~flagged) > 0: 1138 ↛ 1141line 1138 didn't jump to line 1141 because the condition on line 1138 was always true
1139 goodSources = sources[~flagged].copy(deep=True)
1140 else:
1141 goodSources = make_empty_catalog(self.schema, tableName="DiaSource")
1142 if np.count_nonzero(flagged) > 0:
1143 badSources = sources[flagged].copy(deep=True)
1144 else:
1145 badSources = make_empty_catalog(self.schema, tableName="DiaSource")
1146 return pipeBase.Struct(goodSources=goodSources,
1147 badSources=badSources
1148 )
1150 @timeMethod
1151 def associateDiaSources(self, diaSourceTable, solarSystemObjectTable, diffIm, diaObjects):
1152 """Associate DiaSources with DiaObjects.
1154 Associate new DiaSources with existing DiaObjects. Create new
1155 DiaObjects fron unassociated DiaSources. Index DiaSource catalogue
1156 after associations. Append new DiaObjects and DiaSources to their
1157 previous history. Test for DiaSource and DiaObject duplications.
1158 Compute DiaObject Summary statistics from their full DiaSource
1159 history. Test for duplication in the updated DiaObjects.
1161 Parameters
1162 ----------
1163 diaSourceTable : `pandas.DataFrame`
1164 Newly detected DiaSources.
1165 solarSystemObjectTable : `astropy.table.Table`
1166 Preloaded Solar System objects expected to be visible in the image.
1167 diffIm : `lsst.afw.image.ExposureF`
1168 Difference image exposure in which the sources in ``diaSourceCat``
1169 were detected.
1170 diaObjects : `pandas.DataFrame`
1171 Table of DiaObjects from preloaded DiaObjects.
1173 Returns
1174 -------
1175 results : `lsst.pipe.base.Struct`
1176 Results struct containing:
1178 - associatedDiaSources : `pandas.DataFrame`
1179 Associated DiaSources with DiaObjects.
1180 - newDiaObjects : `pandas.DataFrame`
1181 Table of new DiaObjects after association.
1182 - associatedSsSources : `astropy.table.Table`
1183 Table of new ssSources after association.
1184 - unassociatedSsObjects : `astropy.table.Table`
1185 Table of expected ssSources that were not associated with a
1186 diaSource.
1187 - newDiaSources : `pandas.DataFrame`
1188 Subset of `associatedDiaSources` consisting of only the
1189 unassociated diaSources that were added as new diaObjects.
1190 - marginalDiaSources : `pandas.DataFrame`
1191 Unassociated diaSources with marginal detections, which were
1192 removed from `associatedDiaSources` and were not added as new
1193 diaObjects.
1194 """
1195 associatedCatalogs = []
1196 # First associate diaSources with known asteroids
1197 if self.config.doSolarSystemAssociation and solarSystemObjectTable is not None:
1198 ssoAssocResult = self.solarSystemAssociator.run(
1199 Table.from_pandas(diaSourceTable),
1200 solarSystemObjectTable,
1201 diffIm.visitInfo,
1202 diffIm.getBBox(),
1203 diffIm.wcs
1204 )
1205 nTotalSsObjects = ssoAssocResult.nTotalSsObjects
1206 nAssociatedSsObjects = ssoAssocResult.nAssociatedSsObjects
1207 associatedSsSources = ssoAssocResult.associatedSsSources
1208 unassociatedSsObjects = ssoAssocResult.unassociatedSsObjects
1209 if len(ssoAssocResult.ssoAssocDiaSources) > 0: 1209 ↛ 1210line 1209 didn't jump to line 1210 because the condition on line 1209 was never true
1210 associatedCatalogs.append(ssoAssocResult.ssoAssocDiaSources.to_pandas())
1211 if len(ssoAssocResult.unAssocDiaSources) > 0: 1211 ↛ 1214line 1211 didn't jump to line 1214 because the condition on line 1211 was never true
1212 # If the table is empty then converting time fields to pandas
1213 # will raise an error. Pass in an empty Dataframe in that case.
1214 unAssocSSDiaSources = ssoAssocResult.unAssocDiaSources.to_pandas()
1215 else:
1216 unAssocSSDiaSources = make_empty_catalog(self.schema, tableName="DiaSource")
1217 else:
1218 unAssocSSDiaSources = diaSourceTable
1219 nTotalSsObjects = 0
1220 nAssociatedSsObjects = 0
1221 associatedSsSources = None
1222 unassociatedSsObjects = None
1223 # Associate new DiaSources with existing DiaObjects.
1224 assocResults = self.associator.run(unAssocSSDiaSources, diaObjects, schema=self.schema)
1225 createResults = self.createNewDiaObjects(assocResults.unAssocDiaSources)
1227 if not assocResults.matchedDiaSources.empty:
1228 associatedCatalogs.append(assocResults.matchedDiaSources)
1229 if not createResults.diaSources.empty:
1230 associatedCatalogs.append(createResults.diaSources)
1231 if len(associatedCatalogs) == 0:
1232 associatedDiaSources = make_empty_catalog(self.schema, tableName="DiaSource")
1233 else:
1234 # Standardize each component to the schema before concatinating, so
1235 # that type mis-matches are fixed as early as possible.
1236 associatedDiaSources = pd.concat(
1237 [convertDataFrameToSdmSchema(self.schema, df, tableName="DiaSource", skipIndex=True)
1238 for df in associatedCatalogs])
1240 self._add_association_meta_data(assocResults.nUpdatedDiaObjects,
1241 assocResults.nUnassociatedDiaObjects,
1242 createResults.nNewDiaObjects,
1243 nTotalSsObjects,
1244 nAssociatedSsObjects)
1245 self.log.info("%i updated and %i unassociated diaObjects. Creating %i new diaObjects"
1246 " and dropping %i marginal diaSources.",
1247 assocResults.nUpdatedDiaObjects,
1248 assocResults.nUnassociatedDiaObjects,
1249 createResults.nNewDiaObjects,
1250 len(createResults.marginalDiaSources),
1251 )
1252 if createResults.nNewDiaObjects > self.config.maxNewDiaObjects > 0:
1253 raise TooManyDiaObjectsError(nNewDiaObjects=createResults.nNewDiaObjects,
1254 threshold=self.config.maxNewDiaObjects)
1255 return pipeBase.Struct(associatedDiaSources=associatedDiaSources,
1256 newDiaObjects=createResults.newDiaObjects,
1257 associatedSsSources=associatedSsSources,
1258 unassociatedSsObjects=unassociatedSsObjects,
1259 newDiaSources=createResults.diaSources,
1260 marginalDiaSources=createResults.marginalDiaSources
1261 )
1263 def standardizeDataFrame(self, dataFrame, tableName, nullColumns=None):
1264 """Convert a catalog to SDM schema format with NULL ID values
1266 Parameters
1267 ----------
1268 dataFrame : `pandas.DataFrame`
1269 The catalog to standardize/convert
1270 tableName : `str`
1271 Schema name of table to which this dataFrame should be standardized
1272 nullColumns : `list` of `str`, optional
1273 List of column names to check for default values of 0 that should be
1274 replaced with `NULL`.
1277 Returns
1278 -------
1279 standardizedAssociatedDiaSources : pandas.DataFrame
1280 The standardized DiaSource catalog
1281 """
1282 standardizedDataFrame = convertDataFrameToSdmSchema(self.schema,
1283 dataFrame,
1284 tableName=tableName,
1285 skipIndex=True)
1287 def _setNullColumn(dataframe, colName):
1288 """Set specified columns with default values of 0 to NULL."""
1289 dataframe.loc[dataframe[colName] == 0, colName] = pd.NA
1291 if nullColumns is not None: 1291 ↛ 1294line 1291 didn't jump to line 1294 because the condition on line 1291 was always true
1292 for colName in nullColumns:
1293 _setNullColumn(standardizedDataFrame, colName)
1294 return standardizedDataFrame
1296 def standardizeTable(self, table, tableName, nullColumns=None):
1297 """Convert a catalog to SDM schema format with NULL ID values
1299 Parameters
1300 ----------
1301 table : `astropy.table.Table`
1302 The catalog to standardize/convert
1303 tableName : `str`
1304 Schema name of table to which this dataFrame should be standardized
1305 nullColumns : `list` of `str`, optional
1306 List of column names to check for default values of 0 that should be
1307 replaced with `NULL`.
1310 Returns
1311 -------
1312 standardizedAssociatedDiaSources : pandas.DataFrame
1313 The standardized DiaSource catalog
1314 """
1315 dataFrame = table.to_pandas()
1316 standardizedDataFrame = self.standardizeDataFrame(dataFrame, tableName, nullColumns=nullColumns)
1317 return standardizedDataFrame
1319 @timeMethod
1320 def mergeAssociatedCatalogs(self, preloadedDiaSources, associatedDiaSources, diaObjects, newDiaObjects,
1321 diffIm):
1322 """Merge the associated diaSource and diaObjects to their previous history.
1324 Parameters
1325 ----------
1326 preloadedDiaSources : `pandas.DataFrame`
1327 Previously detected DiaSources, loaded from the APDB.
1328 associatedDiaSources : `pandas.DataFrame`
1329 Associated DiaSources with DiaObjects.
1330 diaObjects : `pandas.DataFrame`
1331 Table of DiaObjects from preloaded DiaObjects.
1332 newDiaObjects : `pandas.DataFrame`
1333 Table of new DiaObjects after association.
1335 Raises
1336 ------
1337 RuntimeError
1338 Raised if duplicate DiaObjects or duplicate DiaSources are found.
1340 Returns
1341 -------
1342 mergedDiaSourceHistory : `pandas.DataFrame`
1343 The combined catalog, with all of the rows from preloadedDiaSources
1344 catalog ordered before the rows of associatedDiaSources catalog.
1345 mergedDiaObjects : `pandas.DataFrame`
1346 Table of new DiaObjects merged with their history.
1347 updatedDiaObjectIds : `numpy.Array`
1348 Object Id's from associated diaSources.
1349 """
1350 # Index the DiaSource catalog for this visit after all associations
1351 # have been made.
1352 updatedDiaObjectIds = associatedDiaSources["diaObjectId"][
1353 associatedDiaSources["diaObjectId"] != 0].to_numpy()
1354 associatedDiaSources.set_index(["diaObjectId",
1355 "band",
1356 "diaSourceId"],
1357 drop=False,
1358 inplace=True)
1360 # Append new DiaObjects and DiaSources to their previous history.
1361 if diaObjects.empty: 1361 ↛ 1362line 1361 didn't jump to line 1362 because the condition on line 1361 was never true
1362 mergedDiaObjects = newDiaObjects.set_index("diaObjectId", drop=False)
1363 elif not newDiaObjects.empty: 1363 ↛ 1364line 1363 didn't jump to line 1364 because the condition on line 1363 was never true
1364 mergedDiaObjects = pd.concat(
1365 [diaObjects,
1366 newDiaObjects.set_index("diaObjectId", drop=False)],
1367 sort=True)
1368 else:
1369 mergedDiaObjects = diaObjects
1371 # Exclude any objects that are off the image after association.
1372 mergedDiaObjects, updatedDiaObjectIds = self.purgeDiaObjects(diffIm.getBBox(), diffIm.getWcs(),
1373 mergedDiaObjects,
1374 diaObjectIds=updatedDiaObjectIds,
1375 buffer=-1)
1376 if self.testDataFrameIndex(mergedDiaObjects): 1376 ↛ 1377line 1376 didn't jump to line 1377 because the condition on line 1376 was never true
1377 raise RuntimeError(
1378 "Duplicate DiaObjects created after association. This is "
1379 "likely due to re-running data with an already populated "
1380 "Apdb. If this was not the case then there was an unexpected "
1381 "failure in Association while matching and creating new "
1382 "DiaObjects and should be reported. Exiting.")
1384 mergedDiaSourceHistory = self.mergeCatalogs(preloadedDiaSources, associatedDiaSources,
1385 tableName="DiaSource")
1387 # Test for DiaSource duplication first. If duplicates are found,
1388 # this likely means this is duplicate data being processed and sent
1389 # to the Apdb.
1390 if self.testDataFrameIndex(mergedDiaSourceHistory): 1390 ↛ 1391line 1390 didn't jump to line 1391 because the condition on line 1390 was never true
1391 raise RuntimeError(
1392 "Duplicate DiaSources found after association and merging "
1393 "with history. This is likely due to re-running data with an "
1394 "already populated Apdb. If this was not the case then there "
1395 "was an unexpected failure in Association while matching "
1396 "sources to objects, and should be reported. Exiting.")
1397 # Finally, update the diaObject table with the number of associated diaSources
1398 mergedUpdatedDiaObjects = self.updateObjectTable(mergedDiaObjects, mergedDiaSourceHistory)
1399 return (mergedDiaSourceHistory, mergedUpdatedDiaObjects, updatedDiaObjectIds)
1401 @timeMethod
1402 def runForcedMeasurement(self, diaObjects, updatedDiaObjects, exposure, diffIm, idGenerator):
1403 """Forced Source Measurement
1405 Forced photometry on the difference and calibrated exposures using the
1406 new and updated DiaObject locations.
1408 Parameters
1409 ----------
1410 diaObjects : `pandas.DataFrame`
1411 Catalog of DiaObjects.
1412 updatedDiaObjects : `pandas.DataFrame`
1413 Catalog of updated DiaObjects.
1414 exposure : `lsst.afw.image.ExposureF`
1415 Calibrated exposure differenced with a template to create
1416 ``diffIm``.
1417 diffIm : `lsst.afw.image.ExposureF`
1418 Difference image exposure in which the sources in ``diaSourceCat``
1419 were detected.
1420 idGenerator : `lsst.meas.base.IdGenerator`
1421 Object that generates source IDs and random number generator seeds.
1423 Returns
1424 -------
1425 diaForcedSources : `pandas.DataFrame`
1426 Catalog of calibrated forced photometered fluxes on both the
1427 difference and direct images at DiaObject locations.
1428 """
1429 # Force photometer on the Difference and Calibrated exposures using
1430 # the new and updated DiaObject locations.
1431 diaForcedSources = self.diaForcedSource.run(
1432 diaObjects,
1433 updatedDiaObjects.loc[:, "diaObjectId"].to_numpy(),
1434 exposure,
1435 diffIm,
1436 idGenerator=idGenerator)
1437 self.log.info(f"Updating {len(diaForcedSources)} diaForcedSources in the APDB")
1438 diaForcedSources = convertDataFrameToSdmSchema(self.schema, diaForcedSources,
1439 tableName="DiaForcedSource", skipIndex=True)
1440 return diaForcedSources
1442 @timeMethod
1443 def loadRefreshedDiaObjects(self, region, preloadedDiaObjects=None):
1444 """Load DiaObjects from the Apdb based on their HTM location.
1446 Parameters
1447 ----------
1448 region : `sphgeom.Region`
1449 Region containing the current exposure to load diaObjects from the
1450 APDB.
1451 preloadedDiaObjects : `pandas.DataFrame`, optional
1452 Previously detected DiaObjects, loaded from the APDB. `None` if
1453 the preloaded catalogs are not available.
1455 Returns
1456 -------
1457 diaObjects : `pandas.DataFrame`
1458 DiaObjects loaded from the Apdb that are within the area defined
1459 by ``region``.
1460 """
1461 refreshedDiaObjects = loadDiaObjectsFromApdb(self.apdb, self._paddedRegion(region), self.schema,
1462 self.log)
1463 if preloadedDiaObjects is None:
1464 return refreshedDiaObjects
1466 refreshedIsInPreloaded = refreshedDiaObjects.index.isin(preloadedDiaObjects.index)
1467 preloadedIsInRefreshed = preloadedDiaObjects.index.isin(refreshedDiaObjects.index)
1468 nUniqueRefreshed = (~refreshedIsInPreloaded).sum()
1469 nUniquePreloaded = (~preloadedIsInRefreshed).sum()
1470 if nUniqueRefreshed > 0: 1470 ↛ 1471line 1470 didn't jump to line 1471 because the condition on line 1470 was never true
1471 self.log.info("Reloading the diaObject table during association yielded "
1472 "an additional %d objects over the %d preloaded diaObjects.",
1473 nUniqueRefreshed, len(preloadedDiaObjects))
1474 if nUniquePreloaded == 0: 1474 ↛ 1475line 1474 didn't jump to line 1475 because the condition on line 1474 was never true
1475 return refreshedDiaObjects
1476 # ap_verify CI datasets ship a preloaded diaObject catalog
1477 # alongside an empty APDB, so some preloaded objects may not
1478 # appear in the refresh. Combine the two, giving precedence to
1479 # refreshed entries on overlap so that updated column values are
1480 # not silently discarded when the refresh adds no new ids.
1481 return pd.concat([refreshedDiaObjects, preloadedDiaObjects.loc[~preloadedIsInRefreshed]])
1483 @timeMethod
1484 def loadRefreshedDiaSources(self, region, diaObjects, visitTime):
1485 """Reload the DiaSource history from the Apdb.
1487 Used only in non-time-critical environments where it is more important
1488 to load the most recent DiaSources than to save time by preloading
1489 catalogs.
1491 Parameters
1492 ----------
1493 region : `sphgeom.Region`
1494 Region containing the current exposure to load the history from
1495 the APDB.
1496 diaObjects : `pandas.DataFrame`
1497 DiaObjects to load the history for, indexed by ``diaObjectId``.
1498 visitTime : `astropy.time.Time`
1499 Time of the current visit.
1501 Returns
1502 -------
1503 diaSources : `pandas.DataFrame`
1504 DiaSource history loaded from the Apdb, indexed by
1505 ``diaObjectId``, ``band``, and ``diaSourceId``.
1506 """
1507 return loadDiaSourcesFromApdb(self.apdb, self._paddedRegion(region),
1508 diaObjects.loc[:, "diaObjectId"], visitTime, self.schema, self.log)
1510 @timeMethod
1511 def loadRefreshedDiaForcedSources(self, region, diaObjects, visitTime):
1512 """Reload the DiaForcedSource history from the Apdb.
1514 Used only in non-time-critical environments where it is more important
1515 to load the most recent DiaForcedSources than to save time by preloading
1516 catalogs.
1518 Parameters
1519 ----------
1520 region : `sphgeom.Region`
1521 Region containing the current exposure to load the history from
1522 the APDB.
1523 diaObjects : `pandas.DataFrame`
1524 DiaObjects to load the history for, indexed by ``diaObjectId``.
1525 visitTime : `astropy.time.Time`
1526 Time of the current visit.
1528 Returns
1529 -------
1530 diaForcedSources : `pandas.DataFrame`
1531 DiaForcedSource history loaded from the Apdb, indexed by
1532 ``diaObjectId`` and ``diaForcedSourceId``.
1533 """
1534 return loadDiaForcedSourcesFromApdb(self.apdb, self._paddedRegion(region),
1535 diaObjects.loc[:, "diaObjectId"], visitTime, self.schema,
1536 self.log)
1538 def _paddedRegion(self, region):
1539 """Pad a region by the configured margin.
1541 Parameters
1542 ----------
1543 region : `sphgeom.Region`
1544 Region containing the current exposure.
1546 Returns
1547 -------
1548 region : `sphgeom.Region`
1549 The region, expanded by ``config.angleMargin``.
1550 """
1551 return paddedRegion(region, lsst.sphgeom.Angle.fromDegrees(self.config.angleMargin/3600.))
1553 @timeMethod
1554 def writeToApdb(self, updatedDiaObjects, associatedDiaSources, diaForcedSources):
1555 """Write to the Alert Production Database (Apdb).
1557 Store DiaSources, updated DiaObjects, and DiaForcedSources in the
1558 Alert Production Database (Apdb).
1560 Parameters
1561 ----------
1562 updatedDiaObjects : `pandas.DataFrame`
1563 Catalog of updated DiaObjects.
1564 associatedDiaSources : `pandas.DataFrame`
1565 Associated DiaSources with DiaObjects.
1566 diaForcedSources : `pandas.DataFrame`
1567 Catalog of calibrated forced photometered fluxes on both the
1568 difference and direct images at DiaObject locations.
1570 Returns
1571 -------
1572 validityStart : `lsst.daf.base.DateTime`
1573 Time at which the APDB was updated.
1574 """
1575 # Store DiaSources, updated DiaObjects, and DiaForcedSources in the
1576 # Apdb.
1577 # Drop empty columns that are nullable in the APDB.
1578 diaObjectStore = dropEmptyColumns(self.schema, updatedDiaObjects, tableName="DiaObject")
1579 diaSourceStore = dropEmptyColumns(self.schema, associatedDiaSources, tableName="DiaSource")
1580 diaForcedSourceStore = dropEmptyColumns(self.schema, diaForcedSources, tableName="DiaForcedSource")
1582 validityStart = DateTime.now()
1583 self.apdb.store(
1584 validityStart.toAstropy(),
1585 diaObjectStore,
1586 diaSourceStore,
1587 diaForcedSourceStore)
1588 self.log.info("APDB updated.")
1590 return validityStart
1592 def testDataFrameIndex(self, df):
1593 """Test the sorted DataFrame index for duplicates.
1595 Wrapped as a separate function to allow for mocking of the this task
1596 in unittesting. Default of a mock return for this test is True.
1598 Parameters
1599 ----------
1600 df : `pandas.DataFrame`
1601 DataFrame to text.
1603 Returns
1604 -------
1605 `bool`
1606 True if DataFrame contains duplicate rows.
1607 """
1608 return df.index.has_duplicates
1610 def _add_association_meta_data(self,
1611 nUpdatedDiaObjects,
1612 nUnassociatedDiaObjects,
1613 nNewDiaObjects,
1614 nTotalSsObjects,
1615 nAssociatedSsObjects):
1616 """Store summaries of the association step in the task metadata.
1618 Parameters
1619 ----------
1620 nUpdatedDiaObjects : `int`
1621 Number of previous DiaObjects associated and updated in this
1622 ccdVisit.
1623 nUnassociatedDiaObjects : `int`
1624 Number of previous DiaObjects that were not associated or updated
1625 in this ccdVisit.
1626 nNewDiaObjects : `int`
1627 Number of newly created DiaObjects for this ccdVisit.
1628 nTotalSsObjects : `int`
1629 Number of SolarSystemObjects within the observable detector
1630 area.
1631 nAssociatedSsObjects : `int`
1632 Number of successfully associated SolarSystemObjects.
1633 """
1634 self.metadata['numUpdatedDiaObjects'] = nUpdatedDiaObjects
1635 self.metadata['numUnassociatedDiaObjects'] = nUnassociatedDiaObjects
1636 self.metadata['numNewDiaObjects'] = nNewDiaObjects
1637 self.metadata['numTotalSolarSystemObjects'] = nTotalSsObjects
1638 self.metadata['numAssociatedSsObjects'] = nAssociatedSsObjects
1640 def purgeDiaObjects(self, bbox, wcs, diaObjCat, diaObjectIds=None, buffer=0):
1641 """Drop diaObjects that are outside the exposure bounding box.
1643 Parameters
1644 ----------
1645 bbox : `lsst.geom.Box2I`
1646 Bounding box of the exposure.
1647 wcs : `lsst.afw.geom.SkyWcs`
1648 Coordinate system definition (wcs) for the exposure.
1649 diaObjCat : `pandas.DataFrame`
1650 DiaObjects loaded from the Apdb.
1651 buffer : `int`, optional
1652 Width, in pixels, to pad the exposure bounding box.
1654 Returns
1655 -------
1656 diaObjCat : `pandas.DataFrame`
1657 DiaObjects loaded from the Apdb, restricted to the exposure
1658 bounding box.
1659 """
1660 # Copy the bbox so the caller's box is not mutated by grow().
1661 bbox = type(bbox)(bbox)
1662 try:
1663 bbox.grow(buffer)
1664 raVals = diaObjCat.ra.to_numpy()
1665 decVals = diaObjCat.dec.to_numpy()
1666 xVals, yVals = wcs.skyToPixelArray(raVals, decVals, degrees=True)
1667 selector = bbox.contains(xVals, yVals)
1668 nPurged = np.sum(~selector)
1669 if nPurged > 0:
1670 if diaObjectIds is not None: 1670 ↛ 1678line 1670 didn't jump to line 1678 because the condition on line 1670 was always true
1671 # We also need to drop any of the associated IDs if this runs after association
1672 purgedIds = diaObjCat[~selector].diaObjectId
1673 diaObjectIds = diaObjectIds[~np.isin(diaObjectIds, purgedIds)]
1674 self.log.info("Dropped %i diaObjects that were outside the bbox "
1675 "after association, leaving %i in the catalog",
1676 nPurged, len(diaObjCat) - nPurged)
1677 else:
1678 self.log.info("Dropped %i diaObjects that were outside the padded bbox "
1679 "before association, leaving %i in the catalog",
1680 nPurged, len(diaObjCat) - nPurged)
1681 diaObjCat = diaObjCat[selector].copy()
1682 except (AttributeError, KeyError, ValueError) as e:
1683 self.log.warning("Error attempting to check diaObject history: %s", e, exc_info=e)
1684 return diaObjCat, diaObjectIds
1686 def mergeCatalogs(self, originalCatalog, newCatalog, tableName):
1687 """Combine two catalogs, ensuring that the new catalog conforms to the schema.
1689 Parameters
1690 ----------
1691 originalCatalog : `pandas.DataFrame`
1692 The original catalog to be added to.
1693 newCatalog : `pandas.DataFrame`
1694 The new catalog to append to `originalCatalog`
1695 tableName : `str`
1696 Name of the table in the schema to use.
1698 Returns
1699 -------
1700 mergedCatalog : `pandas.DataFrame`
1701 The combined catalog, with all of the rows from ``originalCatalog``
1702 ordered before the rows of ``newCatalog``
1703 """
1704 if len(newCatalog) > 0:
1705 catalog = convertDataFrameToSdmSchema(self.schema, newCatalog,
1706 tableName=tableName, skipIndex=True)
1708 mergedCatalog = pd.concat([originalCatalog, catalog], sort=True)
1709 else:
1710 mergedCatalog = pd.concat([originalCatalog], sort=True)
1711 return mergedCatalog.loc[:, originalCatalog.columns]
1713 def updateObjectTable(self, diaObjects, diaSources):
1714 """Update the diaObject table with the new diaSource records.
1716 Parameters
1717 ----------
1718 diaObjects : `pandas.DataFrame`
1719 Table of new DiaObjects merged with their history.
1720 diaSources : `pandas.DataFrame`
1721 The combined preloaded and associated diaSource catalog.
1723 Returns
1724 -------
1725 updatedDiaObjects : `pandas.DataFrame`
1726 Table of DiaObjects updated with the number of associated DiaSources
1727 """
1728 # Group on the index level explicitly since diaObjectId is both an
1729 # index and a (duplicated) column
1730 nDiaSources = diaSources.groupby(level="diaObjectId").size().rename("nDiaSources")
1731 diaObjects = diaObjects.drop(columns="nDiaSources", errors="ignore")
1732 updatedDiaObjects = diaObjects.join(nDiaSources, how="left")
1733 return updatedDiaObjects
1735 @staticmethod
1736 def checkTableIndex(dataFrame, index):
1737 if dataFrame.index.name is None:
1738 # The expected index may or may not be set, depending on whether
1739 # the table was written originally as a DataFrame or something else
1740 # Parquet-friendly.
1741 dataFrame.set_index(index, drop=False, inplace=True)