Coverage for python/lsst/ap/association/diaPipe.py: 29%
136 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-04 15:25 -0700
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-04 15:25 -0700
1#
2# LSST Data Management System
3# Copyright 2008-2016 AURA/LSST.
4#
5# This product includes software developed by the
6# LSST Project (http://www.lsst.org/).
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the LSST License Statement and
19# the GNU General Public License along with this program. If not,
20# see <https://www.lsstcorp.org/LegalNotices/>.
21#
23"""PipelineTask for associating DiaSources with previous DiaObjects.
25Additionally performs forced photometry on the calibrated and difference
26images at the updated locations of DiaObjects.
28Currently loads directly from the Apdb rather than pre-loading.
29"""
31import pandas as pd
33import lsst.dax.apdb as daxApdb
34from lsst.meas.base import DetectorVisitIdGeneratorConfig, DiaObjectCalculationTask
35import lsst.pex.config as pexConfig
36import lsst.pipe.base as pipeBase
37import lsst.pipe.base.connectionTypes as connTypes
38from lsst.utils.timer import timeMethod
40from lsst.ap.association import (
41 AssociationTask,
42 DiaForcedSourceTask,
43 LoadDiaCatalogsTask,
44 PackageAlertsTask)
45from lsst.ap.association.ssoAssociation import SolarSystemAssociationTask
47__all__ = ("DiaPipelineConfig",
48 "DiaPipelineTask",
49 "DiaPipelineConnections")
52class DiaPipelineConnections(
53 pipeBase.PipelineTaskConnections,
54 dimensions=("instrument", "visit", "detector"),
55 defaultTemplates={"coaddName": "deep", "fakesType": ""}):
56 """Butler connections for DiaPipelineTask.
57 """
58 diaSourceTable = connTypes.Input(
59 doc="Catalog of calibrated DiaSources.",
60 name="{fakesType}{coaddName}Diff_diaSrcTable",
61 storageClass="DataFrame",
62 dimensions=("instrument", "visit", "detector"),
63 )
64 solarSystemObjectTable = connTypes.Input(
65 doc="Catalog of SolarSolarSystem objects expected to be observable in "
66 "this detectorVisit.",
67 name="visitSsObjects",
68 storageClass="DataFrame",
69 dimensions=("instrument", "visit"),
70 )
71 diffIm = connTypes.Input(
72 doc="Difference image on which the DiaSources were detected.",
73 name="{fakesType}{coaddName}Diff_differenceExp",
74 storageClass="ExposureF",
75 dimensions=("instrument", "visit", "detector"),
76 )
77 exposure = connTypes.Input(
78 doc="Calibrated exposure differenced with a template image during "
79 "image differencing.",
80 name="{fakesType}calexp",
81 storageClass="ExposureF",
82 dimensions=("instrument", "visit", "detector"),
83 )
84 template = connTypes.Input(
85 doc="Warped template used to create `subtractedExposure`. Not PSF "
86 "matched.",
87 dimensions=("instrument", "visit", "detector"),
88 storageClass="ExposureF",
89 name="{fakesType}{coaddName}Diff_templateExp",
90 )
91 apdbMarker = connTypes.Output(
92 doc="Marker dataset storing the configuration of the Apdb for each "
93 "visit/detector. Used to signal the completion of the pipeline.",
94 name="apdb_marker",
95 storageClass="Config",
96 dimensions=("instrument", "visit", "detector"),
97 )
98 associatedDiaSources = connTypes.Output(
99 doc="Optional output storing the DiaSource catalog after matching, "
100 "calibration, and standardization for insertion into the Apdb.",
101 name="{fakesType}{coaddName}Diff_assocDiaSrc",
102 storageClass="DataFrame",
103 dimensions=("instrument", "visit", "detector"),
104 )
105 diaForcedSources = connTypes.Output(
106 doc="Optional output storing the forced sources computed at the diaObject positions.",
107 name="{fakesType}{coaddName}Diff_diaForcedSrc",
108 storageClass="DataFrame",
109 dimensions=("instrument", "visit", "detector"),
110 )
111 diaObjects = connTypes.Output(
112 doc="Optional output storing the updated diaObjects associated to these sources.",
113 name="{fakesType}{coaddName}Diff_diaObject",
114 storageClass="DataFrame",
115 dimensions=("instrument", "visit", "detector"),
116 )
118 def __init__(self, *, config=None):
119 super().__init__(config=config)
121 if not config.doWriteAssociatedSources:
122 self.outputs.remove("associatedDiaSources")
123 self.outputs.remove("diaForcedSources")
124 self.outputs.remove("diaObjects")
125 if not config.doSolarSystemAssociation:
126 self.inputs.remove("solarSystemObjectTable")
128 def adjustQuantum(self, inputs, outputs, label, dataId):
129 """Override to make adjustments to `lsst.daf.butler.DatasetRef` objects
130 in the `lsst.daf.butler.core.Quantum` during the graph generation stage
131 of the activator.
133 This implementation checks to make sure that the filters in the dataset
134 are compatible with AP processing as set by the Apdb/DPDD schema.
136 Parameters
137 ----------
138 inputs : `dict`
139 Dictionary whose keys are an input (regular or prerequisite)
140 connection name and whose values are a tuple of the connection
141 instance and a collection of associated `DatasetRef` objects.
142 The exact type of the nested collections is unspecified; it can be
143 assumed to be multi-pass iterable and support `len` and ``in``, but
144 it should not be mutated in place. In contrast, the outer
145 dictionaries are guaranteed to be temporary copies that are true
146 `dict` instances, and hence may be modified and even returned; this
147 is especially useful for delegating to `super` (see notes below).
148 outputs : `dict`
149 Dict of output datasets, with the same structure as ``inputs``.
150 label : `str`
151 Label for this task in the pipeline (should be used in all
152 diagnostic messages).
153 data_id : `lsst.daf.butler.DataCoordinate`
154 Data ID for this quantum in the pipeline (should be used in all
155 diagnostic messages).
157 Returns
158 -------
159 adjusted_inputs : `dict`
160 Dict of the same form as ``inputs`` with updated containers of
161 input `DatasetRef` objects. Connections that are not changed
162 should not be returned at all. Datasets may only be removed, not
163 added. Nested collections may be of any multi-pass iterable type,
164 and the order of iteration will set the order of iteration within
165 `PipelineTask.runQuantum`.
166 adjusted_outputs : `dict`
167 Dict of updated output datasets, with the same structure and
168 interpretation as ``adjusted_inputs``.
170 Raises
171 ------
172 ScalarError
173 Raised if any `Input` or `PrerequisiteInput` connection has
174 ``multiple`` set to `False`, but multiple datasets.
175 NoWorkFound
176 Raised to indicate that this quantum should not be run; not enough
177 datasets were found for a regular `Input` connection, and the
178 quantum should be pruned or skipped.
179 FileNotFoundError
180 Raised to cause QuantumGraph generation to fail (with the message
181 included in this exception); not enough datasets were found for a
182 `PrerequisiteInput` connection.
183 """
184 _, refs = inputs["diffIm"]
185 for ref in refs:
186 if ref.dataId["band"] not in self.config.validBands:
187 raise ValueError(
188 f"Requested '{ref.dataId['band']}' not in "
189 "DiaPipelineConfig.validBands. To process bands not in "
190 "the standard Rubin set (ugrizy) you must add the band to "
191 "the validBands list in DiaPipelineConfig and add the "
192 "appropriate columns to the Apdb schema.")
193 return super().adjustQuantum(inputs, outputs, label, dataId)
196class DiaPipelineConfig(pipeBase.PipelineTaskConfig,
197 pipelineConnections=DiaPipelineConnections):
198 """Config for DiaPipelineTask.
199 """
200 coaddName = pexConfig.Field(
201 doc="coadd name: typically one of deep, goodSeeing, or dcr",
202 dtype=str,
203 default="deep",
204 )
205 apdb = daxApdb.ApdbSql.makeField(
206 doc="Database connection for storing associated DiaSources and "
207 "DiaObjects. Must already be initialized.",
208 )
209 validBands = pexConfig.ListField(
210 dtype=str,
211 default=["u", "g", "r", "i", "z", "y"],
212 doc="List of bands that are valid for AP processing. To process a "
213 "band not on this list, the appropriate band specific columns "
214 "must be added to the Apdb schema in dax_apdb.",
215 )
216 diaCatalogLoader = pexConfig.ConfigurableField(
217 target=LoadDiaCatalogsTask,
218 doc="Task to load DiaObjects and DiaSources from the Apdb.",
219 )
220 associator = pexConfig.ConfigurableField(
221 target=AssociationTask,
222 doc="Task used to associate DiaSources with DiaObjects.",
223 )
224 doSolarSystemAssociation = pexConfig.Field(
225 dtype=bool,
226 default=False,
227 doc="Process SolarSystem objects through the pipeline.",
228 )
229 solarSystemAssociator = pexConfig.ConfigurableField(
230 target=SolarSystemAssociationTask,
231 doc="Task used to associate DiaSources with SolarSystemObjects.",
232 )
233 diaCalculation = pexConfig.ConfigurableField(
234 target=DiaObjectCalculationTask,
235 doc="Task to compute summary statistics for DiaObjects.",
236 )
237 diaForcedSource = pexConfig.ConfigurableField(
238 target=DiaForcedSourceTask,
239 doc="Task used for force photometer DiaObject locations in direct and "
240 "difference images.",
241 )
242 alertPackager = pexConfig.ConfigurableField(
243 target=PackageAlertsTask,
244 doc="Subtask for packaging Ap data into alerts.",
245 )
246 doPackageAlerts = pexConfig.Field(
247 dtype=bool,
248 default=False,
249 doc="Package Dia-data into serialized alerts for distribution and "
250 "write them to disk.",
251 )
252 doWriteAssociatedSources = pexConfig.Field(
253 dtype=bool,
254 default=False,
255 doc="Write out associated DiaSources, DiaForcedSources, and DiaObjects, "
256 "formatted following the Science Data Model.",
257 )
258 idGenerator = DetectorVisitIdGeneratorConfig.make_field()
260 def setDefaults(self):
261 self.apdb.dia_object_index = "baseline"
262 self.apdb.dia_object_columns = []
263 self.diaCalculation.plugins = ["ap_meanPosition",
264 "ap_nDiaSources",
265 "ap_diaObjectFlag",
266 "ap_meanFlux",
267 "ap_percentileFlux",
268 "ap_sigmaFlux",
269 "ap_chi2Flux",
270 "ap_madFlux",
271 "ap_skewFlux",
272 "ap_minMaxFlux",
273 "ap_maxSlopeFlux",
274 "ap_meanErrFlux",
275 "ap_linearFit",
276 "ap_stetsonJ",
277 "ap_meanTotFlux",
278 "ap_sigmaTotFlux"]
281class DiaPipelineTask(pipeBase.PipelineTask):
282 """Task for loading, associating and storing Difference Image Analysis
283 (DIA) Objects and Sources.
284 """
285 ConfigClass = DiaPipelineConfig
286 _DefaultName = "diaPipe"
288 def __init__(self, initInputs=None, **kwargs):
289 super().__init__(**kwargs)
290 self.apdb = self.config.apdb.apply()
291 self.makeSubtask("diaCatalogLoader")
292 self.makeSubtask("associator")
293 self.makeSubtask("diaCalculation")
294 self.makeSubtask("diaForcedSource")
295 if self.config.doPackageAlerts:
296 self.makeSubtask("alertPackager")
297 if self.config.doSolarSystemAssociation:
298 self.makeSubtask("solarSystemAssociator")
300 def runQuantum(self, butlerQC, inputRefs, outputRefs):
301 inputs = butlerQC.get(inputRefs)
302 inputs["idGenerator"] = self.config.idGenerator.apply(butlerQC.quantum.dataId)
303 # Need to set ccdExposureIdBits (now deprecated) to None and pass it,
304 # since there are non-optional positional arguments after it.
305 inputs["ccdExposureIdBits"] = None
306 inputs["band"] = butlerQC.quantum.dataId["band"]
307 if not self.config.doSolarSystemAssociation:
308 inputs["solarSystemObjectTable"] = None
310 outputs = self.run(**inputs)
312 butlerQC.put(outputs, outputRefs)
314 @timeMethod
315 def run(self,
316 diaSourceTable,
317 solarSystemObjectTable,
318 diffIm,
319 exposure,
320 template,
321 ccdExposureIdBits, # TODO: remove on DM-38687.
322 band,
323 idGenerator=None):
324 """Process DiaSources and DiaObjects.
326 Load previous DiaObjects and their DiaSource history. Calibrate the
327 values in the diaSourceCat. Associate new DiaSources with previous
328 DiaObjects. Run forced photometry at the updated DiaObject locations.
329 Store the results in the Alert Production Database (Apdb).
331 Parameters
332 ----------
333 diaSourceTable : `pandas.DataFrame`
334 Newly detected DiaSources.
335 diffIm : `lsst.afw.image.ExposureF`
336 Difference image exposure in which the sources in ``diaSourceCat``
337 were detected.
338 exposure : `lsst.afw.image.ExposureF`
339 Calibrated exposure differenced with a template to create
340 ``diffIm``.
341 template : `lsst.afw.image.ExposureF`
342 Template exposure used to create diffIm.
343 ccdExposureIdBits : `int`
344 Number of bits used for a unique ``ccdVisitId``. Deprecated in
345 favor of ``idGenerator``, and ignored if that is present. Pass
346 `None` explicitly to avoid a deprecation warning (a default is
347 impossible given that later positional arguments are not
348 defaulted).
349 band : `str`
350 The band in which the new DiaSources were detected.
351 idGenerator : `lsst.meas.base.IdGenerator`, optional
352 Object that generates source IDs and random number generator seeds.
353 Will be required after ``ccdExposureIdBits`` is removed.
355 Returns
356 -------
357 results : `lsst.pipe.base.Struct`
358 Results struct with components.
360 - ``apdbMaker`` : Marker dataset to store in the Butler indicating
361 that this ccdVisit has completed successfully.
362 (`lsst.dax.apdb.ApdbConfig`)
363 - ``associatedDiaSources`` : Catalog of newly associated
364 DiaSources. (`pandas.DataFrame`)
365 """
366 # Load the DiaObjects and DiaSource history.
367 loaderResult = self.diaCatalogLoader.run(diffIm, self.apdb)
369 # Associate new DiaSources with existing DiaObjects.
370 assocResults = self.associator.run(diaSourceTable,
371 loaderResult.diaObjects)
372 if self.config.doSolarSystemAssociation:
373 ssoAssocResult = self.solarSystemAssociator.run(
374 assocResults.unAssocDiaSources,
375 solarSystemObjectTable,
376 diffIm)
377 createResults = self.createNewDiaObjects(
378 ssoAssocResult.unAssocDiaSources)
379 associatedDiaSources = pd.concat(
380 [assocResults.matchedDiaSources,
381 ssoAssocResult.ssoAssocDiaSources,
382 createResults.diaSources])
383 nTotalSsObjects = ssoAssocResult.nTotalSsObjects
384 nAssociatedSsObjects = ssoAssocResult.nAssociatedSsObjects
385 else:
386 createResults = self.createNewDiaObjects(
387 assocResults.unAssocDiaSources)
388 associatedDiaSources = pd.concat(
389 [assocResults.matchedDiaSources,
390 createResults.diaSources])
391 nTotalSsObjects = 0
392 nAssociatedSsObjects = 0
394 # Create new DiaObjects from unassociated diaSources.
395 self._add_association_meta_data(assocResults.nUpdatedDiaObjects,
396 assocResults.nUnassociatedDiaObjects,
397 createResults.nNewDiaObjects,
398 nTotalSsObjects,
399 nAssociatedSsObjects)
400 # Index the DiaSource catalog for this visit after all associations
401 # have been made.
402 updatedDiaObjectIds = associatedDiaSources["diaObjectId"][
403 associatedDiaSources["diaObjectId"] != 0].to_numpy()
404 associatedDiaSources.set_index(["diaObjectId",
405 "band",
406 "diaSourceId"],
407 drop=False,
408 inplace=True)
410 # Append new DiaObjects and DiaSources to their previous history.
411 diaObjects = pd.concat(
412 [loaderResult.diaObjects,
413 createResults.newDiaObjects.set_index("diaObjectId", drop=False)],
414 sort=True)
415 if self.testDataFrameIndex(diaObjects):
416 raise RuntimeError(
417 "Duplicate DiaObjects created after association. This is "
418 "likely due to re-running data with an already populated "
419 "Apdb. If this was not the case then there was an unexpected "
420 "failure in Association while matching and creating new "
421 "DiaObjects and should be reported. Exiting.")
422 mergedDiaSourceHistory = pd.concat(
423 [loaderResult.diaSources, associatedDiaSources],
424 sort=True)
425 # Test for DiaSource duplication first. If duplicates are found,
426 # this likely means this is duplicate data being processed and sent
427 # to the Apdb.
428 if self.testDataFrameIndex(mergedDiaSourceHistory):
429 raise RuntimeError(
430 "Duplicate DiaSources found after association and merging "
431 "with history. This is likely due to re-running data with an "
432 "already populated Apdb. If this was not the case then there "
433 "was an unexpected failure in Association while matching "
434 "sources to objects, and should be reported. Exiting.")
436 # Compute DiaObject Summary statistics from their full DiaSource
437 # history.
438 diaCalResult = self.diaCalculation.run(
439 diaObjects,
440 mergedDiaSourceHistory,
441 updatedDiaObjectIds,
442 [band])
443 # Test for duplication in the updated DiaObjects.
444 if self.testDataFrameIndex(diaCalResult.diaObjectCat):
445 raise RuntimeError(
446 "Duplicate DiaObjects (loaded + updated) created after "
447 "DiaCalculation. This is unexpected behavior and should be "
448 "reported. Exiting.")
449 if self.testDataFrameIndex(diaCalResult.updatedDiaObjects):
450 raise RuntimeError(
451 "Duplicate DiaObjects (updated) created after "
452 "DiaCalculation. This is unexpected behavior and should be "
453 "reported. Exiting.")
455 # Force photometer on the Difference and Calibrated exposures using
456 # the new and updated DiaObject locations.
457 diaForcedSources = self.diaForcedSource.run(
458 diaCalResult.diaObjectCat,
459 diaCalResult.updatedDiaObjects.loc[:, "diaObjectId"].to_numpy(),
460 # Passing a ccdExposureIdBits here that isn't None will make
461 # diaForcedSource emit a deprecation warning, so we don't have to
462 # emit our own.
463 ccdExposureIdBits,
464 exposure,
465 diffIm,
466 idGenerator=idGenerator)
468 # Store DiaSources, updated DiaObjects, and DiaForcedSources in the
469 # Apdb.
470 self.apdb.store(
471 exposure.visitInfo.date,
472 diaCalResult.updatedDiaObjects,
473 associatedDiaSources,
474 diaForcedSources)
476 if self.config.doPackageAlerts:
477 if len(loaderResult.diaForcedSources) > 1:
478 diaForcedSources = pd.concat(
479 [diaForcedSources, loaderResult.diaForcedSources],
480 sort=True)
481 if self.testDataFrameIndex(diaForcedSources):
482 self.log.warning(
483 "Duplicate DiaForcedSources created after merge with "
484 "history and new sources. This may cause downstream "
485 "problems. Dropping duplicates.")
486 # Drop duplicates via index and keep the first appearance.
487 # Reset due to the index shape being slight different than
488 # expected.
489 diaForcedSources = diaForcedSources.groupby(
490 diaForcedSources.index).first()
491 diaForcedSources.reset_index(drop=True, inplace=True)
492 diaForcedSources.set_index(
493 ["diaObjectId", "diaForcedSourceId"],
494 drop=False,
495 inplace=True)
496 self.alertPackager.run(associatedDiaSources,
497 diaCalResult.diaObjectCat,
498 loaderResult.diaSources,
499 diaForcedSources,
500 diffIm,
501 template)
503 return pipeBase.Struct(apdbMarker=self.config.apdb.value,
504 associatedDiaSources=associatedDiaSources,
505 diaForcedSources=diaForcedSources,
506 diaObjects=diaObjects,
507 )
509 def createNewDiaObjects(self, unAssocDiaSources):
510 """Loop through the set of DiaSources and create new DiaObjects
511 for unassociated DiaSources.
513 Parameters
514 ----------
515 unAssocDiaSources : `pandas.DataFrame`
516 Set of DiaSources to create new DiaObjects from.
518 Returns
519 -------
520 results : `lsst.pipe.base.Struct`
521 Results struct containing:
523 - ``diaSources`` : DiaSource catalog with updated DiaObject ids.
524 (`pandas.DataFrame`)
525 - ``newDiaObjects`` : Newly created DiaObjects from the
526 unassociated DiaSources. (`pandas.DataFrame`)
527 - ``nNewDiaObjects`` : Number of newly created diaObjects.(`int`)
528 """
529 if len(unAssocDiaSources) == 0:
530 tmpObj = self._initialize_dia_object(0)
531 newDiaObjects = pd.DataFrame(data=[],
532 columns=tmpObj.keys())
533 else:
534 newDiaObjects = unAssocDiaSources["diaSourceId"].apply(
535 self._initialize_dia_object)
536 unAssocDiaSources["diaObjectId"] = unAssocDiaSources["diaSourceId"]
537 return pipeBase.Struct(diaSources=unAssocDiaSources,
538 newDiaObjects=newDiaObjects,
539 nNewDiaObjects=len(newDiaObjects))
541 def _initialize_dia_object(self, objId):
542 """Create a new DiaObject with values required to be initialized by the
543 Ppdb.
545 Parameters
546 ----------
547 objid : `int`
548 ``diaObjectId`` value for the of the new DiaObject.
550 Returns
551 -------
552 diaObject : `dict`
553 Newly created DiaObject with keys:
555 ``diaObjectId``
556 Unique DiaObjectId (`int`).
557 ``pmParallaxNdata``
558 Number of data points used for parallax calculation (`int`).
559 ``nearbyObj1``
560 Id of the a nearbyObject in the Object table (`int`).
561 ``nearbyObj2``
562 Id of the a nearbyObject in the Object table (`int`).
563 ``nearbyObj3``
564 Id of the a nearbyObject in the Object table (`int`).
565 ``?_psfFluxNdata``
566 Number of data points used to calculate point source flux
567 summary statistics in each bandpass (`int`).
568 """
569 new_dia_object = {"diaObjectId": objId,
570 "pmParallaxNdata": 0,
571 "nearbyObj1": 0,
572 "nearbyObj2": 0,
573 "nearbyObj3": 0,
574 "flags": 0}
575 for f in ["u", "g", "r", "i", "z", "y"]:
576 new_dia_object["%s_psfFluxNdata" % f] = 0
577 return pd.Series(data=new_dia_object)
579 def testDataFrameIndex(self, df):
580 """Test the sorted DataFrame index for duplicates.
582 Wrapped as a separate function to allow for mocking of the this task
583 in unittesting. Default of a mock return for this test is True.
585 Parameters
586 ----------
587 df : `pandas.DataFrame`
588 DataFrame to text.
590 Returns
591 -------
592 `bool`
593 True if DataFrame contains duplicate rows.
594 """
595 return df.index.has_duplicates
597 def _add_association_meta_data(self,
598 nUpdatedDiaObjects,
599 nUnassociatedDiaObjects,
600 nNewDiaObjects,
601 nTotalSsObjects,
602 nAssociatedSsObjects):
603 """Store summaries of the association step in the task metadata.
605 Parameters
606 ----------
607 nUpdatedDiaObjects : `int`
608 Number of previous DiaObjects associated and updated in this
609 ccdVisit.
610 nUnassociatedDiaObjects : `int`
611 Number of previous DiaObjects that were not associated or updated
612 in this ccdVisit.
613 nNewDiaObjects : `int`
614 Number of newly created DiaObjects for this ccdVisit.
615 nTotalSsObjects : `int`
616 Number of SolarSystemObjects within the observable detector
617 area.
618 nAssociatedSsObjects : `int`
619 Number of successfully associated SolarSystemObjects.
620 """
621 self.metadata.add('numUpdatedDiaObjects', nUpdatedDiaObjects)
622 self.metadata.add('numUnassociatedDiaObjects', nUnassociatedDiaObjects)
623 self.metadata.add('numNewDiaObjects', nNewDiaObjects)
624 self.metadata.add('numTotalSolarSystemObjects', nTotalSsObjects)
625 self.metadata.add('numAssociatedSsObjects', nAssociatedSsObjects)