Coverage for python / lsst / ap / association / mpSkyEphemerisQuery.py: 24%

106 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-18 05:01 -0800

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"""Solar System Object Query to MPSky in place of a internal Rubin solar 

23system object caching/retrieval code. 

24 

25Will compute the location for of known SSObjects within a visit-detector combination. 

26""" 

27 

28__all__ = ["MPSkyEphemerisQueryConfig", "MPSkyEphemerisQueryTask"] 

29 

30 

31import numpy as np 

32import os 

33import pandas as pd 

34import pyarrow as pa 

35import requests 

36 

37from lsst.ap.association.utils import getMidpointFromTimespan 

38from lsst.geom import SpherePoint 

39import lsst.pex.config as pexConfig 

40from lsst.pipe.tasks.associationUtils import obj_id_to_ss_object_id 

41from lsst.utils.timer import timeMethod 

42 

43from lsst.pipe.base import connectionTypes, NoWorkFound, PipelineTask, \ 

44 PipelineTaskConfig, PipelineTaskConnections, Struct 

45 

46ELEMENTS_LIST = "q,e,inc,node,argPeri,t_p_MJD_TDB,epochMJD_TDB".split(',') 

47ELEMENTS_FORMAT = " ".join(["{:> 11f}"] * len(ELEMENTS_LIST)) 

48NAMING_MAP = {'inc': 'incl', 'argPeri': 'peri', 't_p_MJD_TDB': 't_p', 'epochMJD_TDB': 'epoch', 

49 'q': 'q', 'e': 'e', 'node': 'node'} 

50 

51ELEMENTS_LIST_MPC_ORBITS = ['designation', 'id', 'packed_primary_provisional_designation', 

52 'unpacked_primary_provisional_designation', 'mpc_orb_jsonb', 

53 'created_at', 'updated_at', 'orbit_type_int', 'u_param', 'nopp', 

54 'arc_length_total', 'arc_length_sel', 'nobs_total', 'nobs_total_sel', 

55 'a', 'q', 'e', 'i', 'node', 'argperi', 'peri_time', 'yarkovsky', 'srp', 

56 'a1', 'a2', 'a3', 'dt', 'mean_anomaly', 'period', 'mean_motion', 'a_unc', 

57 'q_unc', 'e_unc', 'i_unc', 'node_unc', 'argperi_unc', 'peri_time_unc', 

58 'yarkovsky_unc', 'srp_unc', 'a1_unc', 'a2_unc', 'a3_unc', 'dt_unc', 

59 'mean_anomaly_unc', 'period_unc', 'mean_motion_unc', 'epoch_mjd', 'h', 'g', 

60 'not_normalized_rms', 'normalized_rms', 'earth_moid', 'fitting_datetime'] 

61 

62 

63class MPSkyEphemerisQueryConnections(PipelineTaskConnections, 

64 dimensions=("instrument", 

65 "group", "detector")): 

66 

67 predictedRegionTime = connectionTypes.Input( 

68 doc="The predicted exposure region and time", 

69 name="regionTimeInfo", 

70 storageClass="RegionTimeInfo", 

71 dimensions={"instrument", "group", "detector"}, 

72 ) 

73 

74 ssObjects = connectionTypes.Output( 

75 doc="MPSky-provided Solar System objects observable in this detector-visit", 

76 name="preloaded_SsObjects", 

77 storageClass="ArrowAstropy", 

78 dimensions=("instrument", "group", "detector"), 

79 ) 

80 

81 

82class MPSkyEphemerisQueryConfig( 

83 PipelineTaskConfig, 

84 pipelineConnections=MPSkyEphemerisQueryConnections): 

85 observerCode = pexConfig.Field( 

86 dtype=str, 

87 doc="IAU Minor Planet Center observer code for queries " 

88 "(default is X05 for Rubin Obs./LSST)", 

89 default='X05' 

90 ) 

91 queryBufferRadiusDegrees = pexConfig.Field( 

92 dtype=float, 

93 doc="Buffer radius in degrees added to detector bounding circle for ephemeris " 

94 "cone search. Defaults to 10 deg/day * 30 minutes", 

95 default=0.208 

96 ) 

97 mpSkyRequestTimeoutSeconds = pexConfig.Field( 

98 dtype=float, 

99 doc="Time in seconds to wait for mpSky request before failing ", 

100 default=10.0 

101 ) 

102 mpSkyFallbackURL = pexConfig.Field( 

103 dtype=str, 

104 doc="mpSky default URL if MP_SKY_URL environment variable unset", 

105 default="http://sdfiana014.sdf.slac.stanford.edu:3666/ephemerides/" 

106 ) 

107 mpcorb_prefix = pexConfig.Field( 

108 dtype=str, 

109 doc="Column prefix to indicate MPCORB columns for downstream tasks.", 

110 default="MPCORB_" 

111 ) 

112 

113 

114class MPSkyEphemerisQueryTask(PipelineTask): 

115 """Task to query the MPSky service and retrieve the solar system objects 

116 that are observable within the input visit. 

117 """ 

118 ConfigClass = MPSkyEphemerisQueryConfig 

119 _DefaultName = "mpSkyEphemerisQuery" 

120 

121 @timeMethod 

122 def run(self, predictedRegionTime): 

123 """Parse the information on the current visit and retrieve the 

124 observable solar system objects from MPSky. 

125 

126 Parameters 

127 ---------- 

128 predictedRegionTime : `lsst.pipe.base.utils.RegionTimeInfo` 

129 Predicted footprint and timespan of the exposure 

130 

131 Returns 

132 ------- 

133 result : `lsst.pipe.base.Struct` 

134 Results struct with components: 

135 

136 - ``ssObjects`` : `pandas.DataFrame` 

137 DataFrame containing Solar System Objects near the detector 

138 footprint as retrieved by MPSky. The columns are as follows: 

139 

140 ``Name`` 

141 object name (`str`) 

142 ``ra`` 

143 RA in decimal degrees (`float`) 

144 ``dec`` 

145 DEC in decimal degrees (`float`) 

146 ``obj_X_poly``, ``obj_Y_poly``, ``obj_Z_poly`` 

147 Chebyshev coefficients for object path 

148 ``obs_X_poly``, ``obs_Y_poly``, ``obs_Z_poly`` 

149 Chebyshev coefficients for observer path 

150 ``t_min`` 

151 Lower time bound for polynomials 

152 ``t_max`` 

153 Upper time bound for polynomials 

154 """ 

155 # Get detector center and timespan midpoint from predictedRegionTime. 

156 region = predictedRegionTime.region 

157 timespan = predictedRegionTime.timespan 

158 expCenter = SpherePoint(region.getBoundingCircle().getCenter()) 

159 expRadius = region.getBoundingCircle().getOpeningAngle().asDegrees() 

160 expMidPointEPOCH = getMidpointFromTimespan(timespan, allowUnbounded=False).mjd 

161 

162 # MPSky service query 

163 try: 

164 mpSkyURL = os.environ['MP_SKY_URL'] 

165 except KeyError: 

166 self.log.warning("MP_SKY_URL is not defined. Falling back to %s.", self.config.mpSkyFallbackURL) 

167 mpSkyURL = self.config.mpSkyFallbackURL 

168 mpSkySsObjects, elements = self._mpSkyConeSearch(expCenter, expMidPointEPOCH, 

169 expRadius + self.config.queryBufferRadiusDegrees, 

170 mpSkyURL) 

171 if elements is not None: 

172 if 'mpc_orb_jsonb' in elements.columns: 

173 for element in ELEMENTS_LIST_MPC_ORBITS: 

174 mpSkySsObjects[self.config.mpcorb_prefix + element] = elements[element] 

175 else: 

176 for element in ELEMENTS_LIST: 

177 mpSkySsObjects[self.config.mpcorb_prefix + NAMING_MAP[element]] = elements[element] 

178 mpSkySsObjects = mpSkySsObjects.fillna(value=np.nan) 

179 # ssObjectId and mpcDesignation are also in the MPCORB schema so let's add them here. 

180 return Struct( 

181 ssObjects=mpSkySsObjects, 

182 ) 

183 

184 def _mpSkyConeSearch(self, expCenter, epochMJD, queryRadius, mpSkyURL): 

185 """Query MPSky ephemeris service for objects near the expected detector position 

186 

187 Parameters 

188 ---------- 

189 expCenter : `lsst.geom.SpherePoint` 

190 Center of search cone 

191 epochMJD : `float` 

192 Epoch of cone search, (MJD in UTC). 

193 queryRadius : `float` 

194 Radius of the cone search in degrees. 

195 mpSkyURL : `str` 

196 URL to query for MPSky. 

197 

198 Returns 

199 ------- 

200 mpSkySsObjects : `pandas.DataFrame` 

201 DataFrame with Solar System Object information and RA/DEC position 

202 within the visit. 

203 elements : `pandas.DataFrame` or `None` 

204 Orbital elements of ssObjects (to pass to alerts) 

205 """ 

206 fieldRA = expCenter.getRa().asDegrees() 

207 fieldDec = expCenter.getDec().asDegrees() 

208 

209 params = { 

210 "t": epochMJD, 

211 "ra": fieldRA, 

212 "dec": fieldDec, 

213 "radius": queryRadius, 

214 "return_elements": "extended" 

215 } 

216 

217 response = None 

218 try: 

219 response = requests.get(mpSkyURL, params=params, timeout=self.config.mpSkyRequestTimeoutSeconds) 

220 response.raise_for_status() 

221 with pa.input_stream(memoryview(response.content)) as fp: 

222 fp.seek(0) 

223 p = pa.ipc.read_tensor(fp) 

224 op = pa.ipc.read_tensor(fp) 

225 with pa.ipc.open_stream(fp) as reader: 

226 schema = reader.schema 

227 r = next(reader) 

228 

229 # construct an elements pandas dataframe 

230 if "q" in schema.names: 

231 if "mpc_orb_jsonb" in schema.names: 

232 colnames = ELEMENTS_LIST_MPC_ORBITS 

233 else: 

234 colnames = ELEMENTS_LIST 

235 cols = {col: r[col].to_numpy(zero_copy_only=False) for col in colnames} 

236 elements = pd.DataFrame(cols) 

237 for c in cols: 

238 if c in ['created_at', 'fitting_datetime', 'updated_at']: 

239 elements[c] = None 

240 # TODO (DM-53952): Handle properly, probably as a datetime 

241 else: 

242 elements = None 

243 

244 mpSkySsObjects = pd.DataFrame() 

245 mpSkySsObjects['ObjID'] = r["name"].to_numpy(zero_copy_only=False) 

246 mpSkySsObjects['ra'] = r["ra"].to_numpy() 

247 mpSkySsObjects['dec'] = r["dec"].to_numpy() 

248 mpSkySsObjects['tmin'] = r["tmin"].to_numpy() 

249 mpSkySsObjects['tmax'] = r["tmax"].to_numpy() 

250 

251 mpSkySsObjects['trailedSourceMagTrue'] = np.nan 

252 if 'Vmag' in r: 

253 mpSkySsObjects['trailedSourceMagTrue'] = r["Vmag"].to_numpy() 

254 

255 object_polynomial = p.to_numpy() 

256 observer_polynomial = op.to_numpy() 

257 

258 mpSkySsObjects['obj_x_poly'] = [poly[0] for poly in object_polynomial.T] 

259 mpSkySsObjects['obj_y_poly'] = [poly[1] for poly in object_polynomial.T] 

260 mpSkySsObjects['obj_z_poly'] = [poly[2] for poly in object_polynomial.T] 

261 mpSkySsObjects['obs_x_poly'] = [observer_polynomial.T[0] for 

262 i in range(len(mpSkySsObjects))] 

263 mpSkySsObjects['obs_y_poly'] = [observer_polynomial.T[1] for 

264 i in range(len(mpSkySsObjects))] 

265 mpSkySsObjects['obs_z_poly'] = [observer_polynomial.T[2] for 

266 i in range(len(mpSkySsObjects))] 

267 mpSkySsObjects['Err(arcsec)'] = 2 

268 if 'packed_primary_provisional_designation' in elements.columns: 

269 desigs = elements['packed_primary_provisional_designation'].values 

270 else: 

271 desigs = mpSkySsObjects['ObjID'].values 

272 mpSkySsObjects['ssObjectId'] = [obj_id_to_ss_object_id(v) for v in desigs] 

273 

274 nFound = len(mpSkySsObjects) 

275 

276 if nFound == 0: 

277 self.log.info("No Solar System objects found for visit.") 

278 else: 

279 self.log.info("%d Solar System Objects in visit", nFound) 

280 except requests.RequestException as e: 

281 body = getattr(response, "text", "<No response>") 

282 message = f"Query to the remote ephemerides service failed. Got response {body}" 

283 self.log.error(message) 

284 raise NoWorkFound(f"{message}: {e}") from e 

285 

286 return mpSkySsObjects, elements