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-18 11:18 +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",) 

22 

23import astropy.units as u 

24import numpy as np 

25import pandas as pd 

26from astropy.coordinates import SkyCoord 

27from lsst.pex.config import Field 

28 

29from ...interfaces import KeyedData, KeyedDataAction, KeyedDataSchema, Vector 

30 

31 

32class CalcRelativeDistances(KeyedDataAction): 

33 """Calculate relative distances in a matched catalog. 

34 

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 """ 

42 

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 ) 

53 

54 def getInputSchema(self) -> KeyedDataSchema: 

55 return ( 

56 (self.groupKey, Vector), 

57 (self.raKey, Vector), 

58 (self.decKey, Vector), 

59 (self.visitKey, Vector), 

60 ) 

61 

62 def __call__(self, data: KeyedData, **kwargs) -> KeyedData: 

63 """Run the calculation. 

64 

65 Parameters 

66 ---------- 

67 data : KeyedData 

68 Catalog of data including coordinate, visit, and object group 

69 information. 

70 

71 Returns 

72 ------- 

73 distanceParams : `dict` 

74 Dictionary of the calculated arrays and metrics with the following 

75 keys: 

76 

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) 

87 

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 ) 

96 

97 meanRa = df.groupby("groupKey")["coord_ra"].aggregate("mean") 

98 meanDec = df.groupby("groupKey")["coord_dec"].aggregate("mean") 

99 

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] 

105 

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 

113 

114 object_srcs = df.loc[df["groupKey"] == meanRa.index[id]] 

115 

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) 

123 

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 

129 

130 separations = matchVisitComputeDistance( 

131 object_visits, object_ras, object_decs, match_visits, match_ras, match_decs 

132 ) 

133 

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)) 

139 

140 if len(rmsDistances) == 0: 

141 AMx = np.nan * u.marcsec 

142 else: 

143 AMx = (np.median(rmsDistances) * u.radian).to(u.marcsec) 

144 

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 

155 

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 } 

163 

164 return distanceParams 

165 

166 

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. 

170 

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. 

174 

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 

207 

208 

209def sphDist(ra_mean, dec_mean, ra, dec): 

210 """Calculate distance on the surface of a unit sphere. 

211 

212 This function was borrowed from faro. 

213 

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. 

224 

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)) 

236 

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)) 

240 

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() 

246 

247 return dist