Coverage for python / lsst / pipe / tasks / ssp / moid.py: 14%

152 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-17 09:20 +0000

1import numpy as np 

2from astropy import units as u 

3from astropy.constants import au as AU_CONST, GM_sun 

4from collections import namedtuple 

5 

6# Convert AU to km from astropy 

7AU_KM = AU_CONST.to_value(u.km) 

8 

9 

10EarthElements = namedtuple( 

11 "EarthElements", 

12 ["a_AU", "e", "inc_deg", "Omega_deg", "omega_deg"], 

13) 

14 

15# Named result type 

16MOIDResult = namedtuple( 

17 "MOIDResult", 

18 [ 

19 "MOID_AU", 

20 "DeltaV_kms", 

21 "EclipticLongitude_deg", 

22 "TrueAnomaly1_deg", 

23 "TrueAnomaly2_deg", 

24 ], 

25) 

26 

27 

28def earth_orbit_J2000(): 

29 """ 

30 Return canonical heliocentric Keplerian orbital elements for the Earth, 

31 suitable for MOID computations, in the J2000 mean ecliptic and equinox 

32 reference frame. 

33 

34 The elements describe a *fixed Keplerian ellipse* that approximates the 

35 Earth's mean orbit at epoch J2000. This is the standard convention used 

36 in MOID literature and software (e.g., Milani & Gronchi, NEODyS, JPL) 

37 when computing the MOID between small bodies and the Earth. 

38 

39 Returns 

40 ------- 

41 EarthElements 

42 A namedtuple with fields: 

43 

44 - a_AU : float 

45 Semi-major axis in astronomical units (AU). 

46 Canonical value: 1.00000011 AU 

47 

48 - e : float 

49 Eccentricity (dimensionless). 

50 Canonical value: 0.01671022 

51 

52 - inc_deg : float 

53 Inclination to the ecliptic in degrees. 

54 Canonical value: 0.00005 deg 

55 

56 Note: by definition the ecliptic plane is the Earth's mean orbital 

57 plane, so the true inclination is ~0. A very small non-zero value 

58 is used in practice to avoid singularities in rotation matrices 

59 and angle definitions. 

60 

61 - Omega_deg : float 

62 Longitude of the ascending node in degrees, measured in the 

63 ecliptic plane from the J2000 vernal equinox. 

64 Canonical value: -11.26064 deg (equivalent to 348.73936 deg) 

65 

66 - omega_deg : float 

67 Argument of perihelion in degrees, measured from the ascending 

68 node along the Earth's orbital plane. 

69 Canonical value: 102.94719 deg 

70 

71 Notes 

72 ----- 

73 * These values are the widely used J2000 "mean elements" of the Earth’s 

74 heliocentric orbit, compatible with VSOP87-style low-order models and 

75 with the orbital elements quoted in many celestial mechanics references. 

76 

77 * They are intended for *geometric* computations such as MOID, where one 

78 wants a fixed Keplerian ellipse representing the Earth's orbit, rather 

79 than the true, time-varying perturbed Earth orbit. 

80 

81 * If you are using the MOIDSolver defined above, you can plug this 

82 directly into the solver as the Earth orbit, e.g.: 

83 

84 earth = earth_orbit_J2000() 

85 result = solver.compute(asteroid_elements, earth) 

86 

87 or reverse the order depending on which way you want to label the 

88 "orbit 1" quantities. 

89 

90 * The epoch associated with these elements is J2000.0; MOID is a purely 

91 geometric quantity, so there is no mean anomaly / phase dependence. 

92 

93 References 

94 ---------- 

95 These values (or very close variants) are quoted in many sources, e.g.: 

96 

97 - Explanatory Supplement to the Astronomical Almanac 

98 - VSOP87/IAU-style mean orbital element lists for planets 

99 - Milani, A., & Gronchi, G. F., "Theory of Orbit Determination" 

100 - Online resources summarizing J2000 planetary elements 

101 """ 

102 return EarthElements( 

103 a_AU=1.00000011, 

104 e=0.01671022, 

105 inc_deg=0.00005, 

106 Omega_deg=-11.26064, 

107 omega_deg=102.94719, 

108 ) 

109 

110 

111class MOIDSolver: 

112 """ 

113 MOID solver using an adaptive 2D grid in (f1, f2). 

114 

115 Units: 

116 - Semi-major axes a: AU 

117 - Distances / MOID: AU 

118 - Velocities: km/s 

119 - μ: astropy quantity (km^3/s^2) or float 

120 - Public angles: degrees 

121 - Internal angles: radians 

122 """ 

123 

124 def __init__( 

125 self, 

126 mu=GM_sun, 

127 n_samples=128, 

128 refine_factor=5.0, 

129 tol_MOID_abs=1e-7 * u.AU, 

130 tol_MOID_rel=1e-6, 

131 max_refine=10, 

132 ): 

133 # Convert mu to float in km^3/s^2 

134 self.mu = mu.to_value(u.km**3 / u.s**2) if hasattr(mu, "unit") else float(mu) 

135 

136 self.n_samples = int(n_samples) 

137 self.refine_factor = float(refine_factor) 

138 

139 # Absolute tolerance expressed in AU 

140 self.tol_MOID_abs = tol_MOID_abs.to_value(u.AU) 

141 self.tol_MOID_rel = float(tol_MOID_rel) 

142 

143 self.max_refine = int(max_refine) 

144 

145 # ------------------------------------------------------------ 

146 # Geometry helpers 

147 # ------------------------------------------------------------ 

148 

149 @staticmethod 

150 def _make_rotation_matrix(inc, Om, om): 

151 """Rz(Omega) * Rx(inc) * Rz(omega).""" 

152 cO = np.cos(Om) 

153 sO = np.sin(Om) 

154 ci = np.cos(inc) 

155 si = np.sin(inc) 

156 cw = np.cos(om) 

157 sw = np.sin(om) 

158 

159 A00 = cw 

160 A01 = -sw 

161 A02 = 0.0 

162 

163 A10 = ci * sw 

164 A11 = ci * cw 

165 A12 = -si 

166 

167 A20 = si * sw 

168 A21 = si * cw 

169 A22 = ci 

170 

171 Q = np.empty((3, 3), float) 

172 

173 Q[0, 0] = cO * A00 - sO * A10 

174 Q[0, 1] = cO * A01 - sO * A11 

175 Q[0, 2] = cO * A02 - sO * A12 

176 

177 Q[1, 0] = sO * A00 + cO * A10 

178 Q[1, 1] = sO * A01 + cO * A11 

179 Q[1, 2] = sO * A02 + cO * A12 

180 

181 Q[2, 0] = A20 

182 Q[2, 1] = A21 

183 Q[2, 2] = A22 

184 

185 return Q 

186 

187 @classmethod 

188 def _make_orbit_params(cls, a_AU, e, inc, Om, om): 

189 """Return p in AU, rotation matrix Q.""" 

190 p = a_AU * (1 - e * e) # AU 

191 Q = cls._make_rotation_matrix(inc, Om, om) 

192 return p, e, Q 

193 

194 @staticmethod 

195 def _orbit_positions(p_AU, e, Q, f): 

196 """Vectorized positions in AU.""" 

197 f = np.asarray(f) 

198 cf = np.cos(f) 

199 sf = np.sin(f) 

200 

201 r = p_AU / (1 + e * cf) # AU 

202 

203 x_pf = r * cf 

204 y_pf = r * sf 

205 

206 X = Q[0, 0] * x_pf + Q[0, 1] * y_pf 

207 Y = Q[1, 0] * x_pf + Q[1, 1] * y_pf 

208 Z = Q[2, 0] * x_pf + Q[2, 1] * y_pf 

209 return np.column_stack((X, Y, Z)) # AU 

210 

211 @staticmethod 

212 def _rv_from_params(p_AU, e, Q, f, mu_km3_s2): 

213 """Return (r in AU, v in km/s).""" 

214 cf = np.cos(f) 

215 sf = np.sin(f) 

216 

217 # Position (AU) 

218 r_AU = p_AU / (1 + e * cf) 

219 x_pf = r_AU * cf 

220 y_pf = r_AU * sf 

221 

222 X = Q[0, 0] * x_pf + Q[0, 1] * y_pf 

223 Y = Q[1, 0] * x_pf + Q[1, 1] * y_pf 

224 Z = Q[2, 0] * x_pf + Q[2, 1] * y_pf 

225 r_vec_AU = np.array([X, Y, Z], float) 

226 

227 # Velocity (km/s): convert p (AU) to km 

228 p_km = p_AU * AU_KM 

229 fac = np.sqrt(mu_km3_s2 / p_km) 

230 

231 vx_pf = -fac * sf 

232 vy_pf = fac * (e + cf) 

233 

234 VX = Q[0, 0] * vx_pf + Q[0, 1] * vy_pf 

235 VY = Q[1, 0] * vx_pf + Q[1, 1] * vy_pf 

236 VZ = Q[2, 0] * vx_pf + Q[2, 1] * vy_pf 

237 v_vec_kms = np.array([VX, VY, VZ], float) 

238 

239 return r_vec_AU, v_vec_kms 

240 

241 # ------------------------------------------------------------ 

242 # Adaptive 2D grid MOID 

243 # ------------------------------------------------------------ 

244 

245 def _moid_grid_search(self, p1, e1, Q1, p2, e2, Q2): 

246 """ 

247 Adaptive search in AU. 

248 Returns: (MOID_AU, f1_best, f2_best) 

249 """ 

250 n = self.n_samples 

251 rf = self.refine_factor 

252 tol_abs = self.tol_MOID_abs 

253 tol_rel = self.tol_MOID_rel 

254 max_ref = self.max_refine 

255 

256 two_pi = 2 * np.pi 

257 

258 center1 = np.pi 

259 center2 = np.pi 

260 width1 = two_pi 

261 width2 = two_pi 

262 

263 best_d2 = np.inf 

264 best_f1 = 0.0 

265 best_f2 = 0.0 

266 prev_moid = np.inf 

267 

268 for level in range(max_ref + 1): 

269 f1_lo = center1 - width1 / 2 

270 f1_hi = center1 + width1 / 2 

271 f2_lo = center2 - width2 / 2 

272 f2_hi = center2 + width2 / 2 

273 

274 f1_grid = (f1_lo + (np.arange(n) + 0.5) * (f1_hi - f1_lo) / n) % two_pi 

275 f2_grid = (f2_lo + (np.arange(n) + 0.5) * (f2_hi - f2_lo) / n) % two_pi 

276 

277 r1 = self._orbit_positions(p1, e1, Q1, f1_grid) 

278 r2 = self._orbit_positions(p2, e2, Q2, f2_grid) 

279 

280 diff = r1[:, None, :] - r2[None, :, :] 

281 d2 = np.einsum("ijk,ijk->ij", diff, diff) 

282 

283 i0, j0 = np.unravel_index(np.argmin(d2), d2.shape) 

284 d2_local = d2[i0, j0] 

285 f1_local, f2_local = f1_grid[i0], f2_grid[j0] 

286 

287 if d2_local < best_d2: 

288 best_d2 = d2_local 

289 best_f1 = f1_local 

290 best_f2 = f2_local 

291 

292 moid = float(np.sqrt(best_d2)) # AU 

293 

294 if level > 0: 

295 delta = abs(moid - prev_moid) 

296 scale = max(abs(moid), abs(prev_moid), 1.0) 

297 if delta <= tol_abs + tol_rel * scale: 

298 break 

299 

300 prev_moid = moid 

301 center1 = f1_local 

302 center2 = f2_local 

303 width1 /= rf 

304 width2 /= rf 

305 

306 return moid, best_f1, best_f2 

307 

308 # ------------------------------------------------------------ 

309 # Public API 

310 # ------------------------------------------------------------ 

311 

312 def compute(self, el1, el2): 

313 """ 

314 Compute MOID and related quantities. 

315 

316 el = (a_AU, e, inc_deg, Ω_deg, ω_deg) 

317 Returns MOIDResult namedtuple. 

318 """ 

319 a1, e1, i1d, O1d, w1d = el1 

320 a2, e2, i2d, O2d, w2d = el2 

321 

322 i1 = np.deg2rad(i1d) 

323 O1 = np.deg2rad(O1d) 

324 w1 = np.deg2rad(w1d) 

325 

326 i2 = np.deg2rad(i2d) 

327 O2 = np.deg2rad(O2d) 

328 w2 = np.deg2rad(w2d) 

329 

330 p1, e1, Q1 = self._make_orbit_params(a1, e1, i1, O1, w1) 

331 p2, e2, Q2 = self._make_orbit_params(a2, e2, i2, O2, w2) 

332 

333 MOID_AU, f1_best, f2_best = self._moid_grid_search(p1, e1, Q1, p2, e2, Q2) 

334 

335 r1, v1 = self._rv_from_params(p1, e1, Q1, f1_best, self.mu) 

336 r2, v2 = self._rv_from_params(p2, e2, Q2, f2_best, self.mu) 

337 

338 dv_kms = float(np.linalg.norm(v1 - v2)) 

339 

340 lon = np.degrees(np.arctan2(r1[1], r1[0])) % 360 

341 f1_deg = np.degrees(f1_best) % 360 

342 f2_deg = np.degrees(f2_best) % 360 

343 

344 return MOIDResult( 

345 MOID_AU=MOID_AU, 

346 DeltaV_kms=dv_kms, 

347 EclipticLongitude_deg=lon, 

348 TrueAnomaly1_deg=f1_deg, 

349 TrueAnomaly2_deg=f2_deg, 

350 ) 

351 

352 

353# ------------------------------------------------------------------- 

354# Example 

355# ------------------------------------------------------------------- 

356if __name__ == "__main__": 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true

357 solver = MOIDSolver( 

358 mu=GM_sun, 

359 n_samples=128, 

360 refine_factor=5.0, 

361 tol_MOID_abs=1e-8 * u.AU, 

362 tol_MOID_rel=1e-8, 

363 max_refine=12, 

364 ) 

365 

366 el1 = (1.0, 0.1, 5.0, 30.0, 45.0) 

367 el2 = (1.5, 0.2, 15.0, 60.0, 10.0) 

368 

369 result = solver.compute(el1, el2) 

370 print(result)