Coverage for python/lsst/analysis/tools/actions/keyedData/calcDistances.py: 18%
90 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-09 13:16 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-09 13:16 +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/>.
21__all__ = ("CalcRelativeDistances",)
23import astropy.units as u
24import numpy as np
25import pandas as pd
26from astropy.coordinates import SkyCoord
27from lsst.pex.config import Field
29from ...interfaces import KeyedData, KeyedDataAction, KeyedDataSchema, Vector
32class CalcRelativeDistances(KeyedDataAction):
33 """Calculate relative distances in a matched catalog.
35 Given a catalog of matched sources from multiple visits, this finds all
36 pairs of objects at a given separation, then calculates the separation of
37 their component source measurements from the individual visits. The RMS of
38 these is used to calculate the astrometric relative repeatability metric,
39 AMx, while the overall distribution of separations is used to compute the
40 ADx and AFx metrics.
41 """
43 groupKey = Field[str](doc="Column key to use for forming groups", default="obj_index")
44 visitKey = Field[str](doc="Column key to use for matching visits", default="visit")
45 raKey = Field[str](doc="RA column key", default="coord_ra")
46 decKey = Field[str](doc="Dec column key", default="coord_dec")
47 annulus = Field[float](doc="Radial distance of the annulus in arcmin", default=5.0)
48 width = Field[float](doc="Width of annulus in arcmin", default=2.0)
49 threshAD = Field[float](doc="Threshold in mas for AFx calculation.", default=20.0)
50 threshAF = Field[float](
51 doc="Percentile of differences that can vary by more than threshAD.", default=10.0
52 )
54 def getInputSchema(self) -> KeyedDataSchema:
55 return (
56 (self.groupKey, Vector),
57 (self.raKey, Vector),
58 (self.decKey, Vector),
59 (self.visitKey, Vector),
60 )
62 def __call__(self, data: KeyedData, **kwargs) -> KeyedData:
63 """Run the calculation.
65 Parameters
66 ----------
67 data : KeyedData
68 Catalog of data including coordinate, visit, and object group
69 information.
71 Returns
72 -------
73 distanceParams : `dict`
74 Dictionary of the calculated arrays and metrics with the following
75 keys:
77 - ``rmsDistances`` : Per-object rms of separations (`np.array`).
78 - ``separationResiduals`` : All separations minus per-object median
79 (`np.array`)
80 - ``AMx`` : AMx metric (`float`).
81 - ``ADx`` : ADx metric (`float`).
82 - ``AFx`` : AFx metric (`float`).
83 """
84 D = self.annulus * u.arcmin
85 width = self.width * u.arcmin
86 annulus = (D + (width / 2) * np.array([-1, +1])).to(u.radian)
88 df = pd.DataFrame(
89 {
90 "groupKey": data[self.groupKey],
91 "coord_ra": data[self.raKey],
92 "coord_dec": data[self.decKey],
93 "visit": data[self.visitKey],
94 }
95 )
97 meanRa = df.groupby("groupKey")["coord_ra"].aggregate("mean")
98 meanDec = df.groupby("groupKey")["coord_dec"].aggregate("mean")
100 catalog = SkyCoord(meanRa.to_numpy() * u.degree, meanDec.to_numpy() * u.degree)
101 idx, idxCatalog, d2d, d3d = catalog.search_around_sky(catalog, annulus[1])
102 inAnnulus = d2d > annulus[0]
103 idx = idx[inAnnulus]
104 idxCatalog = idxCatalog[inAnnulus]
106 rmsDistances = []
107 sepResiduals = []
108 for id in range(len(meanRa)):
109 match_inds = idx == id
110 match_ids = idxCatalog[match_inds & (idxCatalog != id)]
111 if match_ids.sum() == 0:
112 continue
114 object_srcs = df.loc[df["groupKey"] == meanRa.index[id]]
116 object_visits = object_srcs["visit"].to_numpy()
117 object_ras = (object_srcs["coord_ra"].to_numpy() * u.degree).to(u.radian).value
118 object_decs = (object_srcs["coord_dec"].to_numpy() * u.degree).to(u.radian).value
119 if len(object_srcs) <= 1:
120 continue
121 object_srcs = object_srcs.set_index("visit")
122 object_srcs.sort_index(inplace=True)
124 for id2 in match_ids:
125 match_srcs = df.loc[df["groupKey"] == meanRa.index[id2]]
126 match_visits = match_srcs["visit"].to_numpy()
127 match_ras = (match_srcs["coord_ra"].to_numpy() * u.degree).to(u.radian).value
128 match_decs = (match_srcs["coord_dec"].to_numpy() * u.degree).to(u.radian).value
130 separations = matchVisitComputeDistance(
131 object_visits, object_ras, object_decs, match_visits, match_ras, match_decs
132 )
134 if len(separations) > 1:
135 rmsDist = np.std(separations, ddof=1)
136 rmsDistances.append(rmsDist)
137 if len(separations) > 2:
138 sepResiduals.append(separations - np.median(separations))
140 if len(rmsDistances) == 0:
141 AMx = np.nan * u.marcsec
142 else:
143 AMx = (np.median(rmsDistances) * u.radian).to(u.marcsec)
145 if len(sepResiduals) <= 1:
146 AFx = np.nan * u.percent
147 ADx = np.nan * u.marcsec
148 absDiffSeparations = np.array([]) * u.marcsec
149 else:
150 sepResiduals = np.concatenate(sepResiduals)
151 absDiffSeparations = (abs(sepResiduals - np.median(sepResiduals)) * u.radian).to(u.marcsec)
152 afThreshhold = 100.0 - self.threshAF
153 ADx = np.percentile(absDiffSeparations, afThreshhold)
154 AFx = 100 * np.mean(np.abs(absDiffSeparations) > self.threshAD * u.marcsec) * u.percent
156 distanceParams = {
157 "rmsDistances": (np.array(rmsDistances) * u.radian).to(u.marcsec).value,
158 "separationResiduals": absDiffSeparations.value,
159 "AMx": AMx.value,
160 "ADx": ADx.value,
161 "AFx": AFx.value,
162 }
164 return distanceParams
167def matchVisitComputeDistance(visit_obj1, ra_obj1, dec_obj1, visit_obj2, ra_obj2, dec_obj2):
168 """Calculate obj1-obj2 distance for each visit in which both objects are
169 seen.
171 For each visit shared between visit_obj1 and visit_obj2, calculate the
172 spherical distance between the obj1 and obj2. visit_obj1 and visit_obj2 are
173 assumed to be unsorted. This function was borrowed from faro.
175 Parameters
176 ----------
177 visit_obj1 : scalar, list, or numpy.array of int or str
178 List of visits for object 1.
179 ra_obj1 : scalar, list, or numpy.array of float
180 List of RA in each visit for object 1. [radians]
181 dec_obj1 : scalar, list or numpy.array of float
182 List of Dec in each visit for object 1. [radians]
183 visit_obj2 : list or numpy.array of int or str
184 List of visits for object 2.
185 ra_obj2 : list or numpy.array of float
186 List of RA in each visit for object 2. [radians]
187 dec_obj2 : list or numpy.array of float
188 List of Dec in each visit for object 2. [radians]
189 Results
190 -------
191 list of float
192 spherical distances (in radians) for matching visits.
193 """
194 distances = []
195 visit_obj1_idx = np.argsort(visit_obj1)
196 visit_obj2_idx = np.argsort(visit_obj2)
197 j_raw = 0
198 j = visit_obj2_idx[j_raw]
199 for i in visit_obj1_idx:
200 while (visit_obj2[j] < visit_obj1[i]) and (j_raw < len(visit_obj2_idx) - 1):
201 j_raw += 1
202 j = visit_obj2_idx[j_raw]
203 if visit_obj2[j] == visit_obj1[i]:
204 if np.isfinite([ra_obj1[i], dec_obj1[i], ra_obj2[j], dec_obj2[j]]).all():
205 distances.append(sphDist(ra_obj1[i], dec_obj1[i], ra_obj2[j], dec_obj2[j]))
206 return distances
209def sphDist(ra_mean, dec_mean, ra, dec):
210 """Calculate distance on the surface of a unit sphere.
212 This function was borrowed from faro.
214 Parameters
215 ----------
216 ra_mean : `float`
217 Mean RA in radians.
218 dec_mean : `float`
219 Mean Dec in radians.
220 ra : `numpy.array` [`float`]
221 Array of RA in radians.
222 dec : `numpy.array` [`float`]
223 Array of Dec in radians.
225 Notes
226 -----
227 Uses the Haversine formula to preserve accuracy at small angles.
228 Law of cosines approach doesn't work well for the typically very small
229 differences that we're looking at here.
230 """
231 # Haversine
232 dra = ra - ra_mean
233 ddec = dec - dec_mean
234 a = np.square(np.sin(ddec / 2)) + np.cos(dec_mean) * np.cos(dec) * np.square(np.sin(dra / 2))
235 dist = 2 * np.arcsin(np.sqrt(a))
237 # This is what the law of cosines would look like
238 # dist = np.arccos(np.sin(dec1)*np.sin(dec2) +
239 # np.cos(dec1)*np.cos(dec2)*np.cos(ra1 - ra2))
241 # This will also work, but must run separately for each element
242 # whereas the numpy version will run on either scalars or arrays:
243 # sp1 = geom.SpherePoint(ra1, dec1, geom.radians)
244 # sp2 = geom.SpherePoint(ra2, dec2, geom.radians)
245 # return sp1.separation(sp2).asRadians()
247 return dist