Coverage for python / lsst / summit / extras / slewTimingAuxTel.py: 0%

118 statements  

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

1# This file is part of summit_extras. 

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 

22import itertools 

23 

24import astropy 

25import matplotlib 

26import matplotlib.pyplot as plt 

27import pandas as pd 

28from astropy.time import TimeDelta 

29from lsst_efd_client import EfdClient 

30from lsst_efd_client import merge_packed_time_series as mpts 

31from matplotlib.lines import Line2D 

32from matplotlib.patches import Patch 

33 

34import lsst.daf.butler as dafButler 

35import lsst.summit.utils.butlerUtils as butlerUtils 

36from lsst.summit.utils.efdUtils import getCommands, getEfdData 

37 

38__all__ = ["plotExposureTiming"] 

39 

40READOUT_TIME = TimeDelta(2.3, format="sec") 

41 

42COMMANDS_TO_QUERY = [ 

43 # at the time of writing this was all the commands that existed for ATPtg 

44 # and ATMCS. We explicitly exclude the 20Hz ATMCS.command_trackTarget 

45 # command, and include all others. Perhaps this should be done dynamically 

46 # by using the findTopics function and removing command_trackTarget from 

47 # the list? 

48 "lsst.sal.ATPtg.command_azElTarget", 

49 "lsst.sal.ATPtg.command_disable", 

50 "lsst.sal.ATPtg.command_enable", 

51 "lsst.sal.ATPtg.command_exitControl", 

52 "lsst.sal.ATPtg.command_offsetAbsorb", 

53 "lsst.sal.ATPtg.command_offsetAzEl", 

54 "lsst.sal.ATPtg.command_offsetClear", 

55 "lsst.sal.ATPtg.command_offsetPA", 

56 "lsst.sal.ATPtg.command_offsetRADec", 

57 "lsst.sal.ATPtg.command_pointAddData", 

58 "lsst.sal.ATPtg.command_pointLoadModel", 

59 "lsst.sal.ATPtg.command_pointNewFile", 

60 "lsst.sal.ATPtg.command_poriginAbsorb", 

61 "lsst.sal.ATPtg.command_poriginClear", 

62 "lsst.sal.ATPtg.command_poriginOffset", 

63 "lsst.sal.ATPtg.command_poriginXY", 

64 "lsst.sal.ATPtg.command_raDecTarget", 

65 "lsst.sal.ATPtg.command_rotOffset", 

66 "lsst.sal.ATPtg.command_standby", 

67 "lsst.sal.ATPtg.command_start", 

68 "lsst.sal.ATPtg.command_startTracking", 

69 "lsst.sal.ATPtg.command_stopTracking", 

70 "lsst.sal.ATMCS.command_disable", 

71 "lsst.sal.ATMCS.command_enable", 

72 "lsst.sal.ATMCS.command_exitControl", 

73 "lsst.sal.ATMCS.command_setInstrumentPort", 

74 "lsst.sal.ATMCS.command_standby", 

75 "lsst.sal.ATMCS.command_start", 

76 "lsst.sal.ATMCS.command_startTracking", 

77 "lsst.sal.ATMCS.command_stopTracking", 

78 # 'lsst.sal.ATMCS.command_trackTarget', # exclude the 20Hz data 

79] 

80 

81 

82def getMountPositionData( 

83 client: EfdClient, 

84 begin: astropy.time.Time, 

85 end: astropy.time.Time, 

86 prePadding: float = 0, 

87 postPadding: float = 0, 

88) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: 

89 """Retrieve the mount position data from the EFD. 

90 

91 Parameters 

92 ---------- 

93 client : `EfdClient` 

94 The EFD client used to retrieve the data. 

95 begin : `astropy.time.Time` 

96 The start time of the data retrieval window. 

97 end : `astropy.time.Time` 

98 The end time of the data retrieval window. 

99 prePadding : `float`, optional 

100 The amount of time to pad before the begin time, in seconds. 

101 postPadding : `float`, optional 

102 The amount of time to pad after the end time, in seconds. 

103 

104 Returns 

105 ------- 

106 alt, ax, rot : `tuple` of `pd.DataFrame` 

107 A tuple containing the azimuth, elevation, and rotation data as 

108 dataframes. 

109 """ 

110 mountPosition = getEfdData( 

111 client, 

112 "lsst.sal.ATMCS.mount_AzEl_Encoders", 

113 begin=begin, 

114 end=end, 

115 prePadding=prePadding, 

116 postPadding=postPadding, 

117 ) 

118 nasmythPosition = getEfdData( 

119 client, 

120 "lsst.sal.ATMCS.mount_Nasmyth_Encoders", 

121 begin=begin, 

122 end=end, 

123 prePadding=prePadding, 

124 postPadding=postPadding, 

125 ) 

126 

127 az = mpts(mountPosition, "azimuthCalculatedAngle", stride=1) 

128 el = mpts(mountPosition, "elevationCalculatedAngle", stride=1) 

129 rot = mpts(nasmythPosition, "nasmyth2CalculatedAngle", stride=1) 

130 return az, el, rot 

131 

132 

133def getAxesInPosition( 

134 client: EfdClient, 

135 begin: astropy.time.Time, 

136 end: astropy.time.Time, 

137 prePadding: float = 0, 

138 postPadding: float = 0, 

139) -> pd.DataFrame: 

140 return getEfdData( 

141 client, 

142 "lsst.sal.ATMCS.logevent_allAxesInPosition", 

143 begin=begin, 

144 end=end, 

145 prePadding=prePadding, 

146 postPadding=postPadding, 

147 ) 

148 

149 

150def plotExposureTiming( 

151 client: EfdClient, 

152 expRecords: list[dafButler.DimensionRecord], 

153 plotHexapod: bool = False, 

154 prePadding: float = 1, 

155 postPadding: float = 3, 

156) -> matplotlib.figure.Figure: 

157 """Plot the mount command timings for a set of exposures. 

158 

159 This function plots the mount position data for the entire time range of 

160 the exposures, regardless of whether the exposures are contiguous or not. 

161 The exposures are shaded in the plot to indicate the time range for each 

162 integration its readout, and any commands issued during the time range are 

163 plotted as vertical lines. 

164 

165 Parameters 

166 ---------- 

167 client : `EfdClient` 

168 The client object used to retrieve EFD data. 

169 expRecords : `list` of `lsst.daf.butler.DimensionRecord` 

170 A list of exposure records to plot. The timings will be plotted from 

171 the start of the first exposure to the end of the last exposure, 

172 regardless of whether intermediate exposures are included. 

173 plotHexapod : `bool`, optional 

174 Plot the ATAOS.logevent_hexapodCorrectionStarted and 

175 ATAOS.logevent_hexapodCorrectionCompleted transitions? 

176 prePadding : `float`, optional 

177 The amount of time to pad before the start of the first exposure. 

178 postPadding : `float`, optional 

179 The amount of time to pad after the end of the last exposure. 

180 

181 Returns 

182 ------- 

183 fig : `matplotlib.figure.Figure` 

184 The figure containing the plot. 

185 """ 

186 inPositionAlpha = 0.5 

187 commandAlpha = 0.5 

188 integrationColor = "grey" 

189 readoutColor = "blue" 

190 

191 legendHandles = [] 

192 

193 expRecords = sorted(expRecords, key=lambda x: (x.day_obs, x.seq_num)) # ensure we're sorted 

194 

195 startSeqNum = expRecords[0].seq_num 

196 endSeqNum = expRecords[-1].seq_num 

197 title = f"Mount command timings for seqNums {startSeqNum} - {endSeqNum}" 

198 

199 begin = expRecords[0].timespan.begin 

200 end = expRecords[-1].timespan.end 

201 

202 az, el, rot = getMountPositionData(client, begin, end, prePadding=prePadding, postPadding=postPadding) 

203 

204 # Create a figure with a grid specification and have axes share x 

205 # and have no room between each 

206 fig = plt.figure(figsize=(12, 6)) 

207 gs = fig.add_gridspec(3, 1, hspace=0) 

208 azimuth_ax = fig.add_subplot(gs[0, 0]) 

209 elevation_ax = fig.add_subplot(gs[1, 0], sharex=azimuth_ax) 

210 rotation_ax = fig.add_subplot(gs[2, 0], sharex=azimuth_ax) 

211 axes = {"az": azimuth_ax, "el": elevation_ax, "rot": rotation_ax} 

212 

213 # plot the telemetry 

214 axes["az"].plot(az["azimuthCalculatedAngle"]) 

215 axes["el"].plot(el["elevationCalculatedAngle"]) 

216 axes["rot"].plot(rot["nasmyth2CalculatedAngle"]) 

217 

218 # shade the expRecords' regions including the readout time 

219 for i, record in enumerate(expRecords): 

220 # these need to be in UTC because matplotlib magic turns all the axis 

221 # timings into UTC when plotting from a dataframe. 

222 startExposing = record.timespan.begin.utc.datetime 

223 endExposing = record.timespan.end.utc.datetime 

224 

225 readoutEnd = (record.timespan.end + READOUT_TIME).utc.to_value("isot") 

226 seqNum = record.seq_num 

227 for axName, ax in axes.items(): 

228 ax.axvspan(startExposing, endExposing, color=integrationColor, alpha=0.3) 

229 ax.axvspan(endExposing, readoutEnd, color=readoutColor, alpha=0.1) 

230 if axName == "el": # only add seqNum annotation to bottom axis 

231 label = f"seqNum = {seqNum}" 

232 midpoint = startExposing + (endExposing - startExposing) / 2 

233 ax.annotate( 

234 label, 

235 xy=(midpoint, 0.5), 

236 xycoords=("data", "axes fraction"), 

237 ha="center", 

238 va="center", 

239 fontsize=10, 

240 color="black", 

241 ) 

242 

243 # place vertical lines at the times when axes transition in/out of position 

244 inPostionTransitions = getAxesInPosition(client, begin, end, prePadding, postPadding) 

245 for time, data in inPostionTransitions.iterrows(): 

246 inPosition = data["inPosition"] 

247 if inPosition: 

248 axes["az"].axvline(time, color="green", linestyle="--", alpha=inPositionAlpha) 

249 axes["el"].axvline(time, color="green", linestyle="--", alpha=inPositionAlpha) 

250 axes["rot"].axvline(time, color="green", linestyle="--", alpha=inPositionAlpha) 

251 else: 

252 axes["az"].axvline(time, color="red", linestyle="-", alpha=inPositionAlpha) 

253 axes["el"].axvline(time, color="red", linestyle="-", alpha=inPositionAlpha) 

254 axes["rot"].axvline(time, color="red", linestyle="-", alpha=inPositionAlpha) 

255 handle = Line2D( 

256 [0], [0], color="green", linestyle="--", label="allAxesInPosition=True", alpha=inPositionAlpha 

257 ) 

258 legendHandles.append(handle) 

259 handle = Line2D( 

260 [0], [0], color="red", linestyle="-", label="allAxesInPosition=False", alpha=inPositionAlpha 

261 ) 

262 legendHandles.append(handle) 

263 

264 # place vertical lines at the times when commands were issued 

265 commandTimes = getCommands( 

266 client, COMMANDS_TO_QUERY, begin, end, prePadding, postPadding, timeFormat="python" 

267 ) 

268 if plotHexapod: 

269 hexMoveStarts = getEfdData( 

270 client, 

271 "lsst.sal.ATAOS.logevent_hexapodCorrectionStarted", 

272 expRecord=record, 

273 prePadding=prePadding, 

274 postPadding=postPadding, 

275 ) 

276 hexMoveEnds = getEfdData( 

277 client, 

278 "lsst.sal.ATAOS.logevent_hexapodCorrectionCompleted", 

279 expRecord=record, 

280 prePadding=prePadding, 

281 postPadding=postPadding, 

282 ) 

283 newCommands = {} 

284 for time, data in hexMoveStarts.iterrows(): 

285 newCommands[time] = "lsst.sal.ATAOS.logevent_hexapodCorrectionStarted" 

286 for time, data in hexMoveEnds.iterrows(): 

287 newCommands[time] = "lsst.sal.ATAOS.logevent_hexapodCorrectionCompleted" 

288 commandTimes.update(newCommands) 

289 

290 uniqueCommands = list(set(commandTimes.values())) 

291 colorCycle = itertools.cycle(["b", "g", "r", "c", "m", "y", "k"]) 

292 commandColors = {command: next(colorCycle) for command in uniqueCommands} 

293 for time, command in commandTimes.items(): 

294 color = commandColors[command] 

295 axes["az"].axvline(time, linestyle="-.", alpha=commandAlpha, color=color) 

296 axes["el"].axvline(time, linestyle="-.", alpha=commandAlpha, color=color) 

297 axes["rot"].axvline(time, linestyle="-.", alpha=commandAlpha, color=color) 

298 

299 # manually build the legend to avoid duplicating the labels due to multiple 

300 # commands of the same name 

301 handles = [ 

302 Line2D([0], [0], color=color, linestyle="-.", label=label, alpha=commandAlpha) 

303 for label, color in commandColors.items() 

304 ] 

305 legendHandles.extend(handles) 

306 

307 axes["az"].set_ylabel("Azimuth (deg)") 

308 axes["el"].set_ylabel("Elevation (deg)") 

309 axes["rot"].set_ylabel("Rotation (deg)") 

310 axes["rot"].set_xlabel("Time (UTC)") # this is UTC because of the magic matplotlib does on time indices 

311 fig.suptitle(title) 

312 

313 shaded_handle = Patch(facecolor=integrationColor, alpha=0.3, label="Shutter open period") 

314 legendHandles.append(shaded_handle) 

315 shaded_handle = Patch(facecolor=readoutColor, alpha=0.1, label="Readout period") 

316 legendHandles.append(shaded_handle) 

317 # put the legend under the plot itself 

318 axes["rot"].legend(handles=legendHandles, loc="upper center", bbox_to_anchor=(0.5, -0.3), ncol=2) 

319 

320 fig.tight_layout() 

321 plt.show() 

322 return fig 

323 

324 

325if __name__ == "__main__": 

326 # example usage 

327 import lsst.summit.utils.butlerUtils as butlerUtils # noqa: F811 

328 from lsst.summit.extras.slewTiming import plotExposureTiming # noqa: F811 

329 from lsst.summit.utils.efdUtils import makeEfdClient 

330 

331 client = makeEfdClient() 

332 butler = butlerUtils.makeDefaultLatissButler(embargo=True) 

333 

334 where = "exposure.day_obs=20240215" 

335 records = list(butler.registry.queryDimensionRecords("exposure", where=where)) 

336 records = sorted(records, key=lambda x: (x.day_obs, x.seq_num)) 

337 print(f"Found {len(records)} records from {len(set(r.day_obs for r in records))} days") 

338 

339 expRecords = [records[61], records[62]] 

340 az = plotExposureTiming(client, expRecords)