Coverage for python/lsst/analysis/tools/tasks/associatedSourcesTractAnalysis.py: 21%
188 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-11 09:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-11 09:02 +0000
1# This file is part of analysis_tools.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21from __future__ import annotations
23__all__ = (
24 "AssociatedSourcesTractAnalysisConfig",
25 "AssociatedSourcesTractAnalysisTask",
26 "AssociatedSourcesHealpix3AnalysisTask",
27)
29import astropy.time
30import astropy.units as u
31import numpy as np
32from astropy.coordinates import SkyCoord
33from astropy.table import Table, hstack, vstack
35import lsst.pex.config as pexConfig
36from lsst.daf.butler import DatasetProvenance
37from lsst.drp.tasks.gbdesAstrometricFit import calculate_apparent_motion
38from lsst.pipe.base import NoWorkFound
39from lsst.pipe.base import connectionTypes as ct
40from lsst.skymap import BaseSkyMap
41from lsst.sphgeom import HealpixPixelization
43from ..interfaces import AnalysisBaseConfig, AnalysisBaseConnections, AnalysisPipelineTask
46class AssociatedSourcesTractAnalysisConnections(
47 AnalysisBaseConnections,
48 dimensions=("skymap", "tract", "instrument"),
49 defaultTemplates={
50 "outputName": "isolated_star_presources",
51 "associatedSourcesInputName": "isolated_star_presources",
52 "associatedSourceIdsInputName": "isolated_star_presource_associations",
53 },
54):
55 sourceCatalogs = ct.Input(
56 doc="Visit based source table to load from the butler",
57 name="sourceTable_visit",
58 storageClass="ArrowAstropy",
59 deferLoad=True,
60 dimensions=("visit", "band"),
61 multiple=True,
62 )
64 associatedSources = ct.Input(
65 doc="Table of associated sources",
66 name="{associatedSourcesInputName}",
67 storageClass="ArrowAstropy",
68 deferLoad=True,
69 dimensions=("instrument", "skymap", "tract"),
70 )
72 associatedSourceIds = ct.Input(
73 doc="Table containing unique ids for the associated sources",
74 name="{associatedSourceIdsInputName}",
75 storageClass="ArrowAstropy",
76 deferLoad=True,
77 dimensions=("instrument", "skymap", "tract"),
78 )
80 skyMap = ct.Input(
81 doc="Input definition of geometry/bbox and projection/wcs for warped exposures",
82 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
83 storageClass="SkyMap",
84 dimensions=("skymap",),
85 )
87 camera = ct.PrerequisiteInput(
88 doc="Input camera to use for focal plane geometry.",
89 name="camera",
90 storageClass="Camera",
91 dimensions=("instrument",),
92 isCalibration=True,
93 )
94 astrometricCorrectionCatalog = ct.Input(
95 doc="Catalog with proper motion and parallax information.",
96 name="isolated_star_stellar_motions",
97 storageClass="ArrowAstropy",
98 deferLoad=True,
99 dimensions=("instrument", "skymap", "tract"),
100 )
102 visitTable = ct.Input(
103 doc="Catalog containing visit information.",
104 name="visitTable",
105 storageClass="DataFrame",
106 dimensions=("instrument",),
107 )
109 def __init__(self, *, config=None):
110 super().__init__(config=config)
112 if not config.applyAstrometricCorrections:
113 self.inputs.remove("astrometricCorrectionCatalog")
114 self.inputs.remove("visitTable")
117class AssociatedSourcesTractAnalysisConfig(
118 AnalysisBaseConfig, pipelineConnections=AssociatedSourcesTractAnalysisConnections
119):
120 applyAstrometricCorrections = pexConfig.Field(
121 dtype=bool,
122 default=True,
123 doc="Apply proper motion and parallax corrections to source positions.",
124 )
125 astrometricCorrectionParameters = pexConfig.DictField(
126 keytype=str,
127 itemtype=str,
128 default={
129 "ra": "ra",
130 "dec": "dec",
131 "pmRA": "raPM",
132 "pmDec": "decPM",
133 "parallax": "parallax",
134 "isolated_star_id": "isolated_star_id",
135 },
136 doc="Column names for position and motion parameters in the astrometric correction catalogs.",
137 )
138 maxVisitCount = pexConfig.Field(
139 dtype=int,
140 default=None,
141 doc="Maximum number of visits to use in calculating the metrics.",
142 optional=True,
143 )
144 maxObjects = pexConfig.Field(
145 dtype=int,
146 default=None,
147 doc="Maximum number of associated objects to use in calculating the metrics.",
148 optional=True,
149 )
152class AssociatedSourcesTractAnalysisTask(AnalysisPipelineTask):
153 ConfigClass = AssociatedSourcesTractAnalysisConfig
154 _DefaultName = "associatedSourcesTractAnalysis"
156 @staticmethod
157 def getBoxWcs(skymap, tract):
158 """Get box that defines tract boundaries."""
159 tractInfo = skymap.generateTract(tract)
160 wcs = tractInfo.getWcs()
161 tractBox = tractInfo.getBBox()
162 return tractBox, wcs
164 def callback(self, inputs, dataId):
165 """Callback function to be used with reconstructor."""
166 return self.prepareAssociatedSources(
167 inputs["sourceCatalogs"],
168 inputs["associatedSources"],
169 inputs["associatedSourceIds"],
170 inputs["astrometricCorrectionCatalog"],
171 inputs["visitTable"],
172 )
174 def prepareAssociatedSources(
175 self,
176 sourceCatalogs,
177 associatedSources,
178 associatedSourceIds,
179 astrometricCorrectionCatalog=None,
180 visitTable=None,
181 ):
182 """Concatenate source catalogs and join on associated source IDs."""
183 rng = np.random.default_rng()
185 # Strip any provenance from tables before merging to prevent
186 # warnings from conflicts being issued by astropy.utils.merge.
187 DatasetProvenance.strip_provenance_from_flat_dict(associatedSources.meta)
188 DatasetProvenance.strip_provenance_from_flat_dict(associatedSourceIds.meta)
190 # associatedSource["obj_index"] refers to the corresponding index (row)
191 # in associatedSourceIds.
192 index = associatedSources["obj_index"]
193 associatedSources["isolated_star_id"] = associatedSourceIds["isolated_star_id"][index]
195 if self.config.maxObjects:
196 objectChoice = rng.permutation(associatedSourceIds["isolated_star_id"])[: self.config.maxObjects]
197 objectChoice.sort()
198 sub1 = np.clip(
199 np.searchsorted(objectChoice, associatedSources["isolated_star_id"]),
200 0,
201 len(objectChoice) - 1,
202 )
203 matched = objectChoice[sub1] == associatedSources["isolated_star_id"]
204 associatedSources = associatedSources[matched]
206 trimmedSourceCatalogs = []
207 fullCatLen = 0
208 # It would be preferable to use astropy's built in functions
209 # but they are too slow so instead we use a numpy searchsorted
210 # maneuver.
211 sortedAssocSources = associatedSources["sourceId"].copy()
212 assocSourcesSort = associatedSources["sourceId"].argsort()
213 sortedAssocSources.sort()
214 nAssocSources = len(sortedAssocSources)
215 colsNeeded = list(self.collectInputNames())
216 # Only get the columns needed for the source catalogues.
217 # The isolated_star_id and the obj_index are added later
218 # from other tables so remove these from the list. Also
219 # add the coord_ra and coord_dec as well because this bit
220 # of code needs it even if it isn't requested by a
221 # downstream atool.
222 if "isolated_star_id" in colsNeeded:
223 colsNeeded.remove("isolated_star_id")
224 if "obj_index" in colsNeeded:
225 colsNeeded.remove("obj_index")
226 colsNeeded += ["sourceId", "coord_ra", "coord_dec"]
228 if self.config.maxVisitCount:
229 sourceCatalogs = rng.permutation(sourceCatalogs)[: self.config.maxVisitCount]
231 for sourceCatalogRef in sourceCatalogs:
232 sourceCatalog = sourceCatalogRef.get(parameters={"columns": set(colsNeeded)})
233 DatasetProvenance.strip_provenance_from_flat_dict(sourceCatalog.meta)
235 sub = np.clip(
236 np.searchsorted(sortedAssocSources, sourceCatalog["sourceId"]), 0, nAssocSources - 1
237 )
238 sourceCatalogInds = sortedAssocSources[sub] == sourceCatalog["sourceId"]
239 assocCatalogInds = sub[sourceCatalogInds]
241 # Keep only the sources in groups that are fully contained within
242 # the tract by matching to the associated sources table
243 trimmedSourceCatalogs.append(
244 hstack(
245 [associatedSources[assocSourcesSort][assocCatalogInds], sourceCatalog[sourceCatalogInds]]
246 )
247 )
248 fullCatLen += np.sum(sourceCatalogInds)
250 columns = trimmedSourceCatalogs[0].columns
251 dtypes = trimmedSourceCatalogs[0].dtype
252 zeros = np.zeros((fullCatLen, len(columns)))
253 fullCat = Table(data=zeros, names=columns, dtype=dtypes)
254 n = 0
255 for trimmedSourceCatalog in trimmedSourceCatalogs:
256 fullCat[n : n + len(trimmedSourceCatalog)] = trimmedSourceCatalog
257 n += len(trimmedSourceCatalog)
259 if (astrometricCorrectionCatalog is not None) and (len(fullCat) != 0):
260 self.applyAstrometricCorrections(fullCat, astrometricCorrectionCatalog, visitTable)
262 # Keep only finite ras and decs
263 keep = np.isfinite(fullCat["coord_ra"]) & np.isfinite(fullCat["coord_dec"])
264 return fullCat[keep]
266 def applyAstrometricCorrections(self, dataJoined, astrometricCorrectionCatalog, visitTable):
267 """Use proper motion/parallax catalogs to shift positions to median
268 epoch of the visits.
270 Parameters
271 ----------
272 dataJoined : `astropy.table.Table`
273 Table containing source positions, which will be modified in place.
274 astrometricCorrectionCatalog : `astropy.table.Table`
275 Proper motion and parallax catalog.
276 visitTable : `pd.DataFrame`
277 Table containing the MJDs of the visits.
278 """
279 if visitTable.index.name is None:
280 # The expected index may or may not be set, depending on whether
281 # the table was written originally as a DataFrame or something else
282 # Parquet-friendly.
283 visitTable.set_index("visitId", inplace=True)
285 # Get the stellar motion catalog into the right format:
286 for key, value in self.config.astrometricCorrectionParameters.items():
287 astrometricCorrectionCatalog.rename_column(value, key)
288 astrometricCorrectionCatalog["ra"] *= u.degree
289 astrometricCorrectionCatalog["dec"] *= u.degree
290 astrometricCorrectionCatalog["pmRA"] *= u.mas / u.yr
291 astrometricCorrectionCatalog["pmDec"] *= u.mas / u.yr
292 astrometricCorrectionCatalog["parallax"] *= u.mas
294 # Join the dataJoined catalog with the astrometricCorrectionCatalog.
295 sourceIds = dataJoined["isolated_star_id"].copy()
296 sortOrder = np.argsort(sourceIds)
297 sub = np.clip(
298 np.searchsorted(sourceIds, astrometricCorrectionCatalog["isolated_star_id"], sorter=sortOrder),
299 0,
300 len(sourceIds) - 1,
301 )
302 correctionsCatalogInds = sourceIds[sortOrder[sub]] == astrometricCorrectionCatalog["isolated_star_id"]
303 sourceIdsInds = sortOrder[sub[correctionsCatalogInds]]
305 dataWithPM = hstack([dataJoined[sourceIdsInds], astrometricCorrectionCatalog[correctionsCatalogInds]])
307 mjds = visitTable.loc[dataWithPM["visit"]]["expMidptMJD"]
308 times = astropy.time.Time(mjds, format="mjd", scale="tai")
309 dataWithPM["MJD"] = times
310 medianMJD = astropy.time.Time(np.median(mjds), format="mjd", scale="tai")
312 raCorrection, decCorrection = calculate_apparent_motion(dataWithPM, medianMJD)
313 dataJoined["coord_ra"][sourceIdsInds] = dataWithPM["coord_ra"] - raCorrection.value
314 dataJoined["coord_dec"][sourceIdsInds] = dataWithPM["coord_dec"] - decCorrection.value
316 def runQuantum(self, butlerQC, inputRefs, outputRefs):
317 inputs = butlerQC.get(inputRefs)
319 # Load specified columns from source catalogs
320 names = self.collectInputNames()
321 names |= {"sourceId", "coord_ra", "coord_dec"}
322 for item in ["obj_index", "isolated_star_id"]:
323 if item in names:
324 names.remove(item)
326 if self.config.applyAstrometricCorrections:
327 astrometricCorrections = inputs["astrometricCorrectionCatalog"].get(
328 parameters={"columns": self.config.astrometricCorrectionParameters.values()}
329 )
330 inputs["astrometricCorrectionCatalog"] = astrometricCorrections
331 else:
332 inputs["astrometricCorrectionCatalog"] = None
333 inputs["visitTable"] = None
335 dataId = butlerQC.quantum.dataId
336 plotInfo = self.parsePlotInfo(inputs, dataId, connectionName="associatedSources")
338 # TODO: make key used for object index configurable
339 inputs["associatedSources"] = self.loadData(inputs["associatedSources"], ["obj_index", "sourceId"])
340 inputs["associatedSourceIds"] = self.loadData(inputs["associatedSourceIds"], ["isolated_star_id"])
342 if len(inputs["associatedSources"]) == 0:
343 raise NoWorkFound(f"No associated sources in tract {dataId.tract.id}")
345 data = self.callback(inputs, dataId)
347 kwargs = {"data": data, "plotInfo": plotInfo, "skymap": inputs["skyMap"], "camera": inputs["camera"]}
348 outputs = self.run(**kwargs)
349 self.putByBand(butlerQC, outputs, outputRefs)
352class AssociatedSourcesHealpix3AnalysisConnections(
353 AssociatedSourcesTractAnalysisConnections,
354 dimensions=("healpix3", "instrument"),
355):
356 associatedSources = ct.Input(
357 doc="Table of associated sources",
358 name="{associatedSourcesInputName}",
359 storageClass="ArrowAstropy",
360 deferLoad=True,
361 dimensions=("instrument", "skymap", "tract"),
362 multiple=True,
363 )
365 associatedSourceIds = ct.Input(
366 doc="Table containing unique ids for the associated sources",
367 name="{associatedSourceIdsInputName}",
368 storageClass="ArrowAstropy",
369 deferLoad=True,
370 dimensions=("instrument", "skymap", "tract"),
371 multiple=True,
372 )
373 astrometricCorrectionCatalog = ct.Input(
374 doc="Catalog with proper motion and parallax information.",
375 name="isolated_star_stellar_motions",
376 storageClass="ArrowAstropy",
377 deferLoad=True,
378 dimensions=("instrument", "skymap", "tract"),
379 multiple=True,
380 )
383class AssociatedSourcesHealpix3AnalysisConfig(
384 AssociatedSourcesTractAnalysisConfig, pipelineConnections=AssociatedSourcesHealpix3AnalysisConnections
385):
386 pass
389class AssociatedSourcesHealpix3AnalysisTask(AssociatedSourcesTractAnalysisTask):
390 ConfigClass = AssociatedSourcesHealpix3AnalysisConfig
391 _DefaultName = "associatedSourcesHealpix3Analysis"
393 def getHealpixOverlap(self, sources, sourceIds, pixelId, astrometricCorrections=None):
395 pixelization = HealpixPixelization(3)
396 pixelRegion = pixelization.pixel(pixelId)
398 sourceCoords = SkyCoord(sourceIds["ra"] * u.degree, sourceIds["dec"] * u.degree).cartesian.xyz
400 inPixel = pixelRegion.contains(*sourceCoords.value)
401 if not inPixel.any():
402 return sources[:0]
403 pixelIds = sourceIds[inPixel]["isolated_star_id"]
405 sub1 = np.clip(np.searchsorted(pixelIds, sources["isolated_star_id"]), 0, len(pixelIds) - 1)
406 matched = pixelIds[sub1] == sources["isolated_star_id"]
408 return sources[matched]
410 def runQuantum(self, butlerQC, inputRefs, outputRefs):
411 inputs = butlerQC.get(inputRefs)
413 # Load specified columns from source catalogs
414 names = self.collectInputNames()
415 names |= {"sourceId", "coord_ra", "coord_dec"}
416 for item in ["obj_index", "isolated_star_id"]:
417 if item in names:
418 names.remove(item)
420 dataId = butlerQC.quantum.dataId
421 plotInfo = self.parsePlotInfo(
422 {"associatedSources": inputs["associatedSources"][0]}, dataId, connectionName="associatedSources"
423 )
425 # Loop over tract inputs, keeping only objects that in this healpix,
426 # then stack in one big table.
427 pixelId = dataId["healpix3"]
428 associatedSourceRefs = {
429 assocRef.dataId["tract"]: assocRef for assocRef in inputs["associatedSources"]
430 }
431 associatedSourceIdRefs = {
432 assocRef.dataId["tract"]: assocRef for assocRef in inputs["associatedSourceIds"]
433 }
434 astrometricCorrectionRefs = {
435 assocRef.dataId["tract"]: assocRef for assocRef in inputs["astrometricCorrectionCatalog"]
436 }
437 data = []
438 for tract in associatedSourceRefs:
439 tractAssociatedSources = self.loadData(associatedSourceRefs[tract], ["obj_index", "sourceId"])
440 tractAssociatedSourceIds = self.loadData(
441 associatedSourceIdRefs[tract], ["isolated_star_id", "ra", "dec"]
442 )
443 if self.config.applyAstrometricCorrections:
444 astromCorrections = astrometricCorrectionRefs[tract].get(
445 parameters={"columns": self.config.astrometricCorrectionParameters.values()}
446 )
447 else:
448 astromCorrections = None
449 tractInput = {
450 "associatedSources": tractAssociatedSources,
451 "associatedSourceIds": tractAssociatedSourceIds,
452 "astrometricCorrectionCatalog": astromCorrections,
453 "sourceCatalogs": inputs["sourceCatalogs"],
454 "visitTable": inputs["visitTable"],
455 }
456 tractData = self.callback(tractInput, dataId)
457 if len(tractData) == 0:
458 continue
459 trimmedData = self.getHealpixOverlap(tractData, tractAssociatedSourceIds, pixelId)
460 trimmedData["tract"] = tract
461 data.append(trimmedData)
462 data = vstack(data)
464 if len(data["associatedSources"]) == 0:
465 raise NoWorkFound(f"No associated sources in healpix {dataId.healpix3.id}")
467 kwargs = {
468 "data": data,
469 "plotInfo": plotInfo,
470 "camera": inputs["camera"],
471 }
472 outputs = self.run(**kwargs)
473 self.putByBand(butlerQC, outputs, outputRefs)