LSST Applications g04e9c324dd+8c5ae1fdc5,g134cb467dc+1b3060144d,g18429d2f64+f642bf4753,g199a45376c+0ba108daf9,g1fd858c14a+2dcf163641,g262e1987ae+7b8c96d2ca,g29ae962dfc+3bd6ecb08a,g2cef7863aa+aef1011c0b,g35bb328faa+8c5ae1fdc5,g3fd5ace14f+53e1a9e7c5,g4595892280+fef73a337f,g47891489e3+2efcf17695,g4d44eb3520+642b70b07e,g53246c7159+8c5ae1fdc5,g67b6fd64d1+2efcf17695,g67fd3c3899+b70e05ef52,g74acd417e5+317eb4c7d4,g786e29fd12+668abc6043,g87389fa792+8856018cbb,g89139ef638+2efcf17695,g8d7436a09f+3be3c13596,g8ea07a8fe4+9f5ccc88ac,g90f42f885a+a4e7b16d9b,g97be763408+ad77d7208f,g9dd6db0277+b70e05ef52,ga681d05dcb+a3f46e7fff,gabf8522325+735880ea63,gac2eed3f23+2efcf17695,gb89ab40317+2efcf17695,gbf99507273+8c5ae1fdc5,gd8ff7fe66e+b70e05ef52,gdab6d2f7ff+317eb4c7d4,gdc713202bf+b70e05ef52,gdfd2d52018+b10e285e0f,ge365c994fd+310e8507c4,ge410e46f29+2efcf17695,geaed405ab2+562b3308c0,gffca2db377+8c5ae1fdc5,w.2025.35
LSST Data Management Base Package
Loading...
Searching...
No Matches
ssoAssociation.py
Go to the documentation of this file.
1# This file is part of ap_association.
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/>.
21
22"""Spatial association for Solar System Objects."""
23
24__all__ = ["SolarSystemAssociationConfig", "SolarSystemAssociationTask"]
25
26from astropy import units as u
27from astropy.table import Table, Column
28from astropy.coordinates import SkyCoord
29import healpy as hp
30import numpy as np
31from numpy.polynomial.chebyshev import Chebyshev, chebval
32from scipy.spatial import cKDTree
33
34from lsst.afw.image.exposure.exposureUtils import bbox_contains_sky_coords
35import lsst.pex.config as pexConfig
36import lsst.pipe.base as pipeBase
37from lsst.utils.timer import timeMethod
38
39
40class SolarSystemAssociationConfig(pexConfig.Config):
41 """Config class for SolarSystemAssociationTask.
42 """
43 maxDistArcSeconds = pexConfig.Field(
44 dtype=float,
45 doc='Maximum distance in arcseconds to test for a DIASource to be a '
46 'match to a SSObject.',
47 default=1.0,
48 )
49 maxPixelMargin = pexConfig.RangeField(
50 doc="Maximum padding to add to the ccd bounding box before masking "
51 "SolarSystem objects to the ccd footprint. The bounding box will "
52 "be padded by the minimum of this number or the max uncertainty "
53 "of the SolarSystemObjects in pixels.",
54 dtype=int,
55 default=100,
56 min=0,
57 )
58
59
60class SolarSystemAssociationTask(pipeBase.Task):
61 """Associate DIASources into existing SolarSystem Objects.
62
63 This task performs the association of detected DIASources in a visit
64 with known solar system objects.
65 """
66 ConfigClass = SolarSystemAssociationConfig
67 _DefaultName = "ssoAssociation"
68
69 @timeMethod
70 def run(self, diaSourceCatalog, solarSystemObjects, visitInfo, bbox, wcs):
71 """Create a searchable tree of unassociated DiaSources and match
72 to the nearest ssoObject.
73
74 Parameters
75 ----------
76 diaSourceCatalog : `astropy.table.Table`
77 Catalog of DiaSources. Modified in place to add ssObjectId to
78 successfully associated DiaSources.
79 solarSystemObjects : `astropy.table.Table`
80 Set of solar system objects that should be within the footprint
81 of the current visit.
82 visitInfo : `lsst.afw.image.VisitInfo`
83 visitInfo of exposure used for exposure time
84 bbox : `lsst.geom.Box2I`
85 bbox of exposure used for masking
86 wcs : `lsst.afw.geom.SkyWcs`
87 wcs of exposure used for masking
88
89 Returns
90 -------
91 resultsStruct : `lsst.pipe.base.Struct`
92
93 - ``ssoAssocDiaSources`` : DiaSources that were associated with
94 solar system objects in this visit. (`astropy.table.Table`)
95 - ``unAssocDiaSources`` : Set of DiaSources that were not
96 associated with any solar system object. (`astropy.table.Table`)
97 - ``nTotalSsObjects`` : Total number of SolarSystemObjects
98 contained in the CCD footprint. (`int`)
99 - ``nAssociatedSsObjects`` : Number of SolarSystemObjects
100 that were associated with DiaSources. (`int`)
101 - ``ssSourceData`` : ssSource table data. (`Astropy.table.Table`)
102 """
103
104 nSolarSystemObjects = len(solarSystemObjects)
105 if nSolarSystemObjects <= 0:
106 return self._return_empty(diaSourceCatalog, solarSystemObjects)
107
108 mjd_midpoint = visitInfo.date.toAstropy().tai.mjd
109 ref_time = mjd_midpoint - solarSystemObjects["tmin"].value[0] # all tmin should be identical
110
111 solarSystemObjects['obs_position'] = [
112 np.array([chebval(ref_time, row['obs_x_poly']),
113 chebval(ref_time, row['obs_y_poly']),
114 chebval(ref_time, row['obs_z_poly'])])
115 for row in solarSystemObjects]
116 solarSystemObjects['obs_velocity'] = [
117 np.array([chebval(ref_time, Chebyshev(row['obs_x_poly']).deriv().coef),
118 chebval(ref_time, Chebyshev(row['obs_y_poly']).deriv().coef),
119 chebval(ref_time, Chebyshev(row['obs_z_poly']).deriv().coef)])
120 for row in solarSystemObjects]
121 solarSystemObjects['obj_position'] = [
122 np.array([chebval(ref_time, row['obj_x_poly']),
123 chebval(ref_time, row['obj_y_poly']),
124 chebval(ref_time, row['obj_z_poly'])])
125 for row in solarSystemObjects]
126 solarSystemObjects['obj_velocity'] = [
127 np.array([chebval(ref_time, Chebyshev(row['obj_x_poly']).deriv().coef),
128 chebval(ref_time, Chebyshev(row['obj_y_poly']).deriv().coef),
129 chebval(ref_time, Chebyshev(row['obj_z_poly']).deriv().coef)])
130 for row in solarSystemObjects]
131 vector = np.vstack(solarSystemObjects['obj_position'].value
132 - solarSystemObjects['obs_position'].value)
133 ras, decs = np.vstack(hp.vec2ang(vector, lonlat=True))
134 solarSystemObjects['ra'] = ras
135 solarSystemObjects['dec'] = decs
136 solarSystemObjects['obs_position_x'], solarSystemObjects['obs_position_y'], \
137 solarSystemObjects['obs_position_z'] = solarSystemObjects['obs_position'].value.T
138 solarSystemObjects['heliocentricX'], solarSystemObjects['heliocentricY'], \
139 solarSystemObjects['heliocentricZ'] = solarSystemObjects['obj_position'].value.T
140 solarSystemObjects['obs_velocity_x'], solarSystemObjects['obs_velocity_y'], \
141 solarSystemObjects['obs_velocity_z'] = solarSystemObjects['obs_velocity'].value.T
142 solarSystemObjects['heliocentricVX'], solarSystemObjects['heliocentricVY'], \
143 solarSystemObjects['heliocentricVZ'] = solarSystemObjects['obj_velocity'].value.T
144 solarSystemObjects['topocentric_position'], solarSystemObjects['topocentric_velocity'] = (
145 solarSystemObjects['obj_position'] - solarSystemObjects['obs_position'],
146 solarSystemObjects['obj_velocity'] - solarSystemObjects['obs_velocity'],
147 )
148 solarSystemObjects['topocentricX'], solarSystemObjects['topocentricY'], \
149 solarSystemObjects['topocentricZ'] = (
150 np.array(list(solarSystemObjects['topocentric_position'].value)).T
151 )
152 solarSystemObjects['topocentricVX'], solarSystemObjects['topocentricVY'], \
153 solarSystemObjects['topocentricVZ'] = (
154 np.array(list(solarSystemObjects['topocentric_velocity'].value)).T
155 )
156 solarSystemObjects['heliocentricVX'], solarSystemObjects['heliocentricVY'], \
157 solarSystemObjects['heliocentricVZ'] = np.array(list(solarSystemObjects['obj_velocity'].value)).T
158 solarSystemObjects['heliocentricDist'] = np.linalg.norm(solarSystemObjects['obj_position'], axis=1)
159 solarSystemObjects['topocentricDist'] = np.linalg.norm(solarSystemObjects['topocentric_position'],
160 axis=1)
161 solarSystemObjects['phaseAngle'] = np.degrees(np.arccos(np.sum(
162 solarSystemObjects['obj_position'].T * solarSystemObjects['topocentric_position'].T
163 / solarSystemObjects['heliocentricDist'] / solarSystemObjects['topocentricDist'], axis=0
164 )))
165
166 stateVectorColumns = ['heliocentricX', 'heliocentricY', 'heliocentricZ', 'heliocentricVX',
167 'heliocentricVY', 'heliocentricVZ', 'topocentricX', 'topocentricY',
168 'topocentricZ', 'topocentricVX', 'topocentricVY', 'topocentricVZ']
169
170 mpcorbColumns = [col for col in solarSystemObjects.columns if col[:7] == 'MPCORB_']
171
172 maskedObjects = self._maskToCcdRegion(
173 solarSystemObjects,
174 bbox,
175 wcs,
176 solarSystemObjects["Err(arcsec)"].max()).copy()
177 nSolarSystemObjects = len(maskedObjects)
178 if nSolarSystemObjects <= 0:
179 return self._return_empty(diaSourceCatalog, maskedObjects)
180
181 maxRadius = np.deg2rad(self.config.maxDistArcSeconds / 3600)
182
183 # Transform DIA RADEC coordinates to unit sphere xyz for tree building.
184 vectors = self._radec_to_xyz(diaSourceCatalog["ra"],
185 diaSourceCatalog["dec"])
186
187 # Create KDTree of DIA sources
188 tree = cKDTree(vectors)
189
190 nFound = 0
191 # Query the KDtree for DIA nearest neighbors to SSOs. Currently only
192 # picks the DiaSource with the shortest distance. We can do something
193 # fancier later.
194 ssSourceData, ssObjectIds = [], []
195 ras, decs, residual_ras, residual_decs, dia_ids = [], [], [], [], []
196 diaSourceCatalog["ssObjectId"] = 0
197 source_column = 'id'
198 maskedObjects['associated'] = False
199 if 'diaSourceId' in diaSourceCatalog.columns:
200 source_column = 'diaSourceId'
201 for ssObject in maskedObjects:
202 index = ssObject.index
203 ssoVect = self._radec_to_xyz(ssObject["ra"], ssObject["dec"])
204 # Which DIA Sources fall within r?
205 dist, idx = tree.query(ssoVect, distance_upper_bound=maxRadius)
206 if len(idx) == 1 and np.isfinite(dist[0]):
207 nFound += 1
208 diaSourceCatalog[idx[0]]["ssObjectId"] = ssObject["ssObjectId"]
209 ssObjectIds.append(ssObject["ssObjectId"])
210 all_cols = ["phaseAngle", "heliocentricDist",
211 "topocentricDist"] + stateVectorColumns + mpcorbColumns
212 ssSourceData.append(list(ssObject[all_cols].values()))
213 dia_ra = diaSourceCatalog[idx[0]]["ra"]
214 dia_dec = diaSourceCatalog[idx[0]]["dec"]
215 dia_id = diaSourceCatalog[idx[0]][source_column]
216 ras.append(dia_ra)
217 decs.append(dia_dec)
218 dia_ids.append(dia_id)
219 residual_ras.append(dia_ra - ssObject["ra"])
220 residual_decs.append(dia_dec - ssObject["dec"])
221 maskedObjects['associated'][index] = True
222 else:
223 maskedObjects['associated'][index] = False
224
225 self.log.info("Successfully associated %d / %d SolarSystemObjects.", nFound, nSolarSystemObjects)
226 self.metadata['nAssociatedSsObjects'] = nFound
227 self.metadata['nExpectedSsObjects'] = nSolarSystemObjects
228 assocSourceMask = diaSourceCatalog["ssObjectId"] != 0
229 unAssocObjectMask = np.logical_not(maskedObjects['associated'].value)
230 ssSourceData = np.array(ssSourceData)
231 ssSourceData = Table(ssSourceData,
232 names=[
233 "phaseAngle", "heliocentricDist", "topocentricDist"
234 ] + stateVectorColumns + mpcorbColumns)
235 ssSourceData['ssObjectId'] = Column(data=ssObjectIds, dtype=int)
236 ssSourceData["ra"] = ras
237 ssSourceData["dec"] = decs
238 ssSourceData["residualRa"] = residual_ras
239 ssSourceData["residualDec"] = residual_decs
240 ssSourceData[source_column] = dia_ids
241 coords = SkyCoord(ra=ssSourceData['ra'].value * u.deg, dec=ssSourceData['dec'].value * u.deg)
242 ssSourceData['galacticL'] = coords.galactic.l.deg
243 ssSourceData['galacticB'] = coords.galactic.b.deg
244 ssSourceData['eclipticLambda'] = coords.barycentrictrueecliptic.lon.deg
245 ssSourceData['eclipticBeta'] = coords.barycentrictrueecliptic.lat.deg
246 unassociatedObjects = maskedObjects[unAssocObjectMask]
247 columns_to_drop = [
248 "obs_position", "obs_velocity", "obj_position", "obj_velocity", "topocentric_position",
249 "topocentric_velocity", "obs_x_poly", "obs_y_poly", "obs_z_poly", "obj_x_poly", "obj_y_poly",
250 "obj_z_poly", "associated"
251 ]
252 unassociatedObjects.remove_columns(columns_to_drop)
253 return pipeBase.Struct(
254 ssoAssocDiaSources=diaSourceCatalog[assocSourceMask],
255 unAssocDiaSources=diaSourceCatalog[~assocSourceMask],
256 nTotalSsObjects=nSolarSystemObjects,
257 nAssociatedSsObjects=nFound,
258 associatedSsSources=ssSourceData,
259 unassociatedSsObjects=unassociatedObjects)
260
261 def _maskToCcdRegion(self, solarSystemObjects, bbox, wcs, marginArcsec):
262 """Mask the input SolarSystemObjects to only those in the exposure
263 bounding box.
264
265 Parameters
266 ----------
267 solarSystemObjects : `astropy.table.Table`
268 SolarSystemObjects to mask to ``exposure``.
269 bbox :
270 Exposure bbox used for masking
271 wcs :
272 Exposure wcs used for masking
273 marginArcsec : `float`
274 Maximum possible matching radius to pad onto the exposure bounding
275 box. If greater than ``maxPixelMargin``, ``maxPixelMargin`` will
276 be used.
277
278 Returns
279 -------
280 maskedSolarSystemObjects : `astropy.table.Table`
281 Set of SolarSystemObjects contained within the exposure bounds.
282 """
283 if len(solarSystemObjects) == 0:
284 return solarSystemObjects
285 padding = min(
286 int(np.ceil(marginArcsec / wcs.getPixelScale(bbox.getCenter()).asArcseconds())),
287 self.config.maxPixelMargin)
288
289 return solarSystemObjects[bbox_contains_sky_coords(
290 bbox,
291 wcs,
292 solarSystemObjects['ra'].value * u.degree,
293 solarSystemObjects['dec'].value * u.degree,
294 padding)]
295
296 def _radec_to_xyz(self, ras, decs):
297 """Convert input ra/dec coordinates to spherical unit-vectors.
298
299 Parameters
300 ----------
301 ras : `array-like`
302 RA coordinates of objects in degrees.
303 decs : `array-like`
304 DEC coordinates of objects in degrees.
305
306 Returns
307 -------
308 vectors : `numpy.ndarray`, (N, 3)
309 Output unit-vectors
310 """
311 ras = np.radians(ras)
312 decs = np.radians(decs)
313 try:
314 vectors = np.empty((len(ras), 3))
315 except TypeError:
316 vectors = np.empty((1, 3))
317
318 sin_dec = np.sin(np.pi / 2 - decs)
319 vectors[:, 0] = sin_dec * np.cos(ras)
320 vectors[:, 1] = sin_dec * np.sin(ras)
321 vectors[:, 2] = np.cos(np.pi / 2 - decs)
322
323 return vectors
324
325 def _return_empty(self, diaSourceCatalog, emptySolarSystemObjects):
326 """Return a struct with all appropriate empty values for no SSO associations.
327
328 Parameters
329 ----------
330 diaSourceCatalog : `astropy.table.Table`
331 Used for column names
332 emptySolarSystemObjects : `astropy.table.Table`
333 Used for column names.
334 Returns
335 -------
336 results : `lsst.pipe.base.Struct`
337 Results struct with components.
338 - ``ssoAssocDiaSources`` : Empty. (`astropy.table.Table`)
339 - ``unAssocDiaSources`` : Input DiaSources. (`astropy.table.Table`)
340 - ``nTotalSsObjects`` : Zero. (`int`)
341 - ``nAssociatedSsObjects`` : Zero.
342 - ``associatedSsSources`` : Empty. (`Astropy.table.Table`)
343 - ``unassociatedSsObjects`` : Empty. (`Astropy.table.Table`)
344
345
346 Raises
347 ------
348 RuntimeError
349 Raised if duplicate DiaObjects or duplicate DiaSources are found.
350 """
351 self.log.info("No SolarSystemObjects found in detector bounding box.")
352 return pipeBase.Struct(
353 ssoAssocDiaSources=Table(names=diaSourceCatalog.columns),
354 unAssocDiaSources=diaSourceCatalog,
355 nTotalSsObjects=0,
356 nAssociatedSsObjects=0,
357 associatedSsSources=Table(names=['phaseAngle', 'heliocentricDist', 'topocentricDist',
358 'heliocentricX', 'heliocentricY', 'heliocentricZ',
359 'heliocentricVX', 'heliocentricVY', 'heliocentricVZ',
360 'topocentricX', 'topocentricY', 'topocentricZ',
361 'topocentricVX', 'topocentricVY', 'topocentricVZ',
362 'residualRa', 'residualDec', 'eclipticLambda', 'eclipticBeta',
363 'galacticL', 'galacticB', 'ssObjectId', 'diaSourceId'],
364 dtype=[float] * 21 + [int] * 2),
365 unassociatedSsObjects=Table(names=emptySolarSystemObjects.columns)
366 )
_return_empty(self, diaSourceCatalog, emptySolarSystemObjects)
_maskToCcdRegion(self, solarSystemObjects, bbox, wcs, marginArcsec)
run(self, diaSourceCatalog, solarSystemObjects, visitInfo, bbox, wcs)