Coverage for python / lsst / summit / extras / slewTimingAuxTel.py: 0%
118 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 09:45 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 09:45 +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/>.
22import itertools
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
34import lsst.daf.butler as dafButler
35import lsst.summit.utils.butlerUtils as butlerUtils
36from lsst.summit.utils.efdUtils import getCommands, getEfdData
38__all__ = ["plotExposureTiming"]
40READOUT_TIME = TimeDelta(2.3, format="sec")
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]
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.
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.
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 )
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
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 )
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.
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.
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.
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"
191 legendHandles = []
193 expRecords = sorted(expRecords, key=lambda x: (x.day_obs, x.seq_num)) # ensure we're sorted
195 startSeqNum = expRecords[0].seq_num
196 endSeqNum = expRecords[-1].seq_num
197 title = f"Mount command timings for seqNums {startSeqNum} - {endSeqNum}"
199 begin = expRecords[0].timespan.begin
200 end = expRecords[-1].timespan.end
202 az, el, rot = getMountPositionData(client, begin, end, prePadding=prePadding, postPadding=postPadding)
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}
213 # plot the telemetry
214 axes["az"].plot(az["azimuthCalculatedAngle"])
215 axes["el"].plot(el["elevationCalculatedAngle"])
216 axes["rot"].plot(rot["nasmyth2CalculatedAngle"])
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
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 )
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)
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)
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)
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)
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)
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)
320 fig.tight_layout()
321 plt.show()
322 return fig
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
331 client = makeEfdClient()
332 butler = butlerUtils.makeDefaultLatissButler(embargo=True)
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")
339 expRecords = [records[61], records[62]]
340 az = plotExposureTiming(client, expRecords)