Coverage for python/lsst/summit/extras/slewTimingSimonyi.py: 0%
194 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-10 10:35 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-10 10:35 +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/>.
21from __future__ import annotations
23import itertools
24import logging
25import warnings
26from typing import TYPE_CHECKING
28import pandas as pd
29from astropy.time import TimeDelta
30from lsst_efd_client import EfdClient
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.dateTime import dayObsIntToString
37from lsst.summit.utils.efdUtils import getCommands, getEfdData
38from lsst.summit.utils.simonyi.mountData import getAzElRotHexDataForPeriod
39from lsst.utils.plotting.figures import make_figure
41if TYPE_CHECKING:
42 from astropy.time import Time
43 from matplotlib.figure import Figure
46__all__ = ["plotExposureTiming"]
48READOUT_TIME = TimeDelta(2.3, format="sec")
50COMMANDS_TO_QUERY = [
51 # MTPtg
52 "lsst.sal.MTPtg.command_azElTarget",
53 "lsst.sal.MTPtg.command_disable",
54 "lsst.sal.MTPtg.command_enable",
55 "lsst.sal.MTPtg.command_exitControl",
56 "lsst.sal.MTPtg.command_offsetAzEl",
57 "lsst.sal.MTPtg.command_poriginOffset",
58 "lsst.sal.MTPtg.command_raDecTarget",
59 "lsst.sal.MTPtg.command_rotOffset",
60 "lsst.sal.MTPtg.command_standby",
61 "lsst.sal.MTPtg.command_start",
62 "lsst.sal.MTPtg.command_startTracking",
63 "lsst.sal.MTPtg.command_stopTracking",
64 # MTMount
65 "lsst.sal.MTMount.command_applySettingsSet",
66 "lsst.sal.MTMount.command_closeMirrorCovers",
67 "lsst.sal.MTMount.command_disable",
68 "lsst.sal.MTMount.command_enable",
69 "lsst.sal.MTMount.command_enableCameraCableWrapFollowing",
70 "lsst.sal.MTMount.command_exitControl",
71 "lsst.sal.MTMount.command_homeBothAxes",
72 "lsst.sal.MTMount.command_moveToTarget",
73 "lsst.sal.MTMount.command_openMirrorCovers",
74 "lsst.sal.MTMount.command_park",
75 "lsst.sal.MTMount.command_restoreDefaultSettings",
76 "lsst.sal.MTMount.command_setLogLevel",
77 "lsst.sal.MTMount.command_standby",
78 "lsst.sal.MTMount.command_start",
79 "lsst.sal.MTMount.command_startTracking",
80 "lsst.sal.MTMount.command_stop",
81 "lsst.sal.MTMount.command_stopTracking",
82 # 'lsst.sal.MTMount.command_trackTarget', # exclude the 20Hz data
83 # M1M3
84 "lsst.sal.MTM1M3.command_clearSlewFlag",
85 "lsst.sal.MTM1M3.command_setSlewControllerSettings",
86 "lsst.sal.MTM1M3.command_setSlewFlag",
87 "lsst.sal.MTM1M3.logevent_slewControllerSettings",
88 # MTCamera
89 "lsst.sal.MTCamera.logevent_startIntegration",
90 "lsst.sal.MTCamera.logevent_startLoadFilter",
91 "lsst.sal.MTCamera.logevent_startReadout",
92 "lsst.sal.MTCamera.logevent_startRotateCarousel",
93 "lsst.sal.MTCamera.logevent_startSetFilter",
94 "lsst.sal.MTCamera.logevent_startShutterClose",
95 "lsst.sal.MTCamera.logevent_startShutterOpen",
96 "lsst.sal.MTCamera.logevent_startUnloadFilter",
97 "lsst.sal.MTCamera.logevent_endLoadFilter",
98 "lsst.sal.MTCamera.logevent_endOfImageTelemetry",
99 "lsst.sal.MTCamera.logevent_endReadout",
100 "lsst.sal.MTCamera.logevent_endRotateCarousel",
101 "lsst.sal.MTCamera.logevent_endSetFilter",
102 "lsst.sal.MTCamera.logevent_endShutterClose",
103 "lsst.sal.MTCamera.logevent_endShutterOpen",
104 "lsst.sal.MTCamera.logevent_endUnloadFilter",
105 # MTAos
106 # 'lsst.sal.MTAOS.logevent_cameraHexapodCorrection',
107 "lsst.sal.MTAOS.logevent_configurationApplied",
108 # 'lsst.sal.MTAOS.logevent_configurationsAvailable',
109 # 'lsst.sal.MTAOS.logevent_degreeOfFreedom',
110 # 'lsst.sal.MTAOS.logevent_errorCode',
111 "lsst.sal.MTAOS.logevent_m1m3Correction",
112 "lsst.sal.MTAOS.logevent_m2Correction",
113 "lsst.sal.MTAOS.logevent_m2HexapodCorrection",
114 # 'lsst.sal.MTAOS.logevent_mirrorStresses',
115 # 'lsst.sal.MTAOS.logevent_ofcDuration',
116 "lsst.sal.MTAOS.logevent_rejectedCameraHexapodCorrection",
117 "lsst.sal.MTAOS.logevent_rejectedDegreeOfFreedom",
118 "lsst.sal.MTAOS.logevent_rejectedM1M3Correction",
119 "lsst.sal.MTAOS.logevent_rejectedM2Correction",
120 "lsst.sal.MTAOS.logevent_rejectedM2HexapodCorrection",
121 # 'lsst.sal.MTAOS.logevent_summaryState',
122 # 'lsst.sal.MTAOS.logevent_wavefrontError',
123 # 'lsst.sal.MTAOS.logevent_wepDuration'
124 # Brian says to find + add the settle event
125 # MTDome
126 # "lsst.sal.MTDome.azimuth"
127]
129HEXAPOD_TOPICS = [
130 "lsst.sal.MTHexapod.ackcmd",
131 # 'lsst.sal.MTHexapod.actuators',
132 # 'lsst.sal.MTHexapod.application',
133 "lsst.sal.MTHexapod.command_disable",
134 "lsst.sal.MTHexapod.command_enable",
135 # 'lsst.sal.MTHexapod.command_exitControl',
136 "lsst.sal.MTHexapod.command_move",
137 "lsst.sal.MTHexapod.command_offset",
138 "lsst.sal.MTHexapod.command_setCompensationMode",
139 # 'lsst.sal.MTHexapod.command_setLogLevel',
140 "lsst.sal.MTHexapod.command_standby",
141 "lsst.sal.MTHexapod.command_start",
142 # 'lsst.sal.MTHexapod.electrical',
143 # 'lsst.sal.MTHexapod.logevent_commandableByDDS',
144 # 'lsst.sal.MTHexapod.logevent_compensatedPosition',
145 "lsst.sal.MTHexapod.logevent_compensationMode",
146 # 'lsst.sal.MTHexapod.logevent_compensationOffset',
147 # 'lsst.sal.MTHexapod.logevent_configuration',
148 "lsst.sal.MTHexapod.logevent_configurationApplied",
149 # 'lsst.sal.MTHexapod.logevent_configurationsAvailable',
150 # 'lsst.sal.MTHexapod.logevent_connected',
151 # 'lsst.sal.MTHexapod.logevent_controllerState',
152 "lsst.sal.MTHexapod.logevent_errorCode",
153 # 'lsst.sal.MTHexapod.logevent_heartbeat',
154 "lsst.sal.MTHexapod.logevent_inPosition",
155 # 'lsst.sal.MTHexapod.logevent_interlock',
156 # 'lsst.sal.MTHexapod.logevent_logLevel',
157 # 'lsst.sal.MTHexapod.logevent_logMessage',
158 # 'lsst.sal.MTHexapod.logevent_simulationMode',
159 # 'lsst.sal.MTHexapod.logevent_softwareVersions',
160 # 'lsst.sal.MTHexapod.logevent_summaryState',
161 # 'lsst.sal.MTHexapod.logevent_uncompensatedPosition'
162]
164inPositionTopics = {
165 "Hexapod": "lsst.sal.MTHexapod.logevent_inPosition",
166 "M2": "lsst.sal.MTM2.logevent_m2AssemblyInPosition",
167 "Azimuth": "lsst.sal.MTMount.logevent_azimuthInPosition",
168 "Camera cable wrap": "lsst.sal.MTMount.logevent_cameraCableWrapInPosition",
169 "Elevation": "lsst.sal.MTMount.logevent_elevationInPosition",
170 "Rotator": "lsst.sal.MTRotator.logevent_inPosition",
171 "Dome": "lsst.sal.MTDome.logevent_azMotion",
172}
175def getAxisName(topic: str) -> str:
176 """Classify an EFD topic into the axis subplot it belongs on.
178 Parameters
179 ----------
180 topic : `str`
181 Fully-qualified EFD topic name (e.g.
182 ``lsst.sal.MTMount.logevent_azimuthInPosition``).
184 Returns
185 -------
186 axisName : `str`
187 One of ``dome``, ``el``, ``az``, ``rot``, ``camera``,
188 ``mount``, or ``aos``.
190 Raises
191 ------
192 ValueError
193 Raised if the topic does not match any known axis.
194 """
195 # Note the order here matters, e.g. cameraCableWrap is a substring of
196 # MTMount so it should be checked first, likewise axes are special cases
197 # of the MTMount so should be checked first.
198 if "MTDome.logevent_azMotion" in topic:
199 return "dome"
201 if "MTMount.logevent_elevationInPosition" in topic:
202 return "el"
204 if "MTMount.logevent_azimuthInPosition" in topic:
205 return "az"
207 if "MTRotator.logevent_inPosition" in topic:
208 return "rot"
210 if any(x in topic for x in ["MTCamera", "MTRotator", "cameraCableWrap"]):
211 return "camera"
213 if any(x in topic for x in ["MTPtg", "MTMount", "MTM1M3", "MTM2"]):
214 return "mount"
216 if any(x in topic for x in ["MTAOS", "MTHexapod"]):
217 return "aos"
219 raise ValueError(f"Could not determine axis name for topic: {topic}")
222def getDomeData(
223 client: EfdClient, begin: Time, end: Time, prePadding: float, postPadding: float
224) -> tuple[pd.DataFrame, pd.DataFrame]:
225 """Get dome data and when dome is within threshold of being in position.
227 Parameters
228 ----------
229 client : `EfdClient`
230 The client object used to retrieve EFD data.
231 begin : `astropy.time.Time`
232 The begin time for the data retrieval.
233 end : `astropy.time.Time`
234 The end time for the data retrieval.
235 prePadding : `float`
236 The amount of time in seconds to pad before the begin time.
237 postPadding : `float`
238 The amount of time in seconds to pad after the end time.
240 Returns
241 -------
242 domeData : `pd.DataFrame`
243 The dome data with actual and commanded positions.
244 domeVignetted : `pd.DataFrame`
245 A dataframe with an entry indicating when the telescope
246 is no longer vignetted
247 """
248 domeData = getEfdData(
249 client,
250 "lsst.sal.MTDome.azimuth",
251 columns=["positionActual", "positionCommanded"],
252 begin=begin,
253 end=end,
254 prePadding=prePadding,
255 postPadding=postPadding,
256 )
257 # Get the data with the vignetted flag
258 vignettedData = getEfdData(
259 client,
260 "lsst.sal.MTDomeTrajectory.logevent_telescopeVignetted",
261 columns=["vignetted"],
262 begin=begin,
263 end=end,
264 prePadding=prePadding,
265 postPadding=postPadding,
266 )
267 if len(vignettedData) == 0:
268 vignettedTime = begin.utc.to_datetime()
269 # check we have the column and that it contains at least 1 value of 1
270 if "vignetted" in vignettedData.columns and (vignettedData["vignetted"] == 1).any():
271 vignettedData = vignettedData[vignettedData["vignetted"] == 1]
272 vignettedTime = vignettedData.index[-1]
273 else:
274 log = logging.getLogger(__name__)
275 log.warning("No vignetted data found for the given time range. Assuming always vignetted.")
276 vignettedTime = begin.utc.to_datetime()
277 # Make a new dataframe with the vignetting data
278 domeVignetted = pd.DataFrame(data={"vignetted": [False]}, index=[vignettedTime])
279 return domeData, domeVignetted
282def plotExposureTiming(
283 client: EfdClient,
284 expRecords: list[dafButler.DimensionRecord],
285 prePadding: float = 1,
286 postPadding: float = 3,
287 narrowHeightRatio: float = 0.4,
288 figure: Figure | None = None,
289) -> Figure | None:
290 """Plot the mount command timings for a set of Simonyi exposures.
292 Plots the mount position data for the entire time range of the
293 exposures, regardless of whether the exposures are contiguous.
294 Exposure integration and readout windows are shaded, and any
295 commands, in-position transitions, and telescope-vignetted
296 transitions within the time range are plotted as vertical lines.
298 Parameters
299 ----------
300 client : `lsst_efd_client.EfdClient`
301 The client object used to retrieve EFD data.
302 expRecords : `list` [`lsst.daf.butler.DimensionRecord`]
303 A list of exposure records to plot. The time axis spans from
304 the start of the first exposure to the end of the last
305 exposure, regardless of whether intermediate exposures are
306 included. All records must share a single ``day_obs``.
307 prePadding : `float`, optional
308 Seconds of padding before the start of the first exposure.
309 postPadding : `float`, optional
310 Seconds of padding after the end of the last exposure.
311 narrowHeightRatio : `float`, optional
312 Height ratio for narrow panels (mount, camera, aos) relative
313 to the wide telemetry panels.
314 figure : `matplotlib.figure.Figure`, optional
315 If provided, the plot is rendered onto this figure instead of
316 creating a new one. A warning is logged because the figure
317 size differs from the recommended default.
319 Returns
320 -------
321 fig : `matplotlib.figure.Figure` or `None`
322 The figure containing the plot, or `None` if no mount data
323 was found for the requested time range.
325 Raises
326 ------
327 ValueError
328 Raised if ``expRecords`` spans more than one ``day_obs``.
329 """
330 log = logging.getLogger(__name__)
332 inPositionAlpha = 0.5
333 commandAlpha = 0.5
334 integrationColor = "grey"
335 readoutColor = "blue"
337 expRecords = sorted(expRecords, key=lambda x: (x.day_obs, x.seq_num))
339 startSeqNum = expRecords[0].seq_num
340 endSeqNum = expRecords[-1].seq_num
341 dayObs = expRecords[0].day_obs
342 if expRecords[-1].day_obs != dayObs:
343 raise ValueError("All exposures must be from the same day_obs")
344 title = f"Mount command timings for {dayObsIntToString(dayObs)} seqNums {startSeqNum} - {endSeqNum}"
346 begin = expRecords[0].timespan.begin
347 end = expRecords[-1].timespan.end
349 mountData = getAzElRotHexDataForPeriod(client, begin, end, prePadding, postPadding)
350 if mountData.empty:
351 log.warning(f"No mount data found for dayObs {dayObs} seqNums {startSeqNum}-{endSeqNum}")
352 return None
354 az = mountData.azimuthData
355 el = mountData.elevationData
356 rot = mountData.rotationData
358 domeData, domeVignetted = getDomeData(client, begin, end, prePadding, postPadding)
360 # Calculate relative heights for the gridspec
361 narrowHeight = narrowHeightRatio
362 wideHeight = 1.0
363 totalHeight = 3 * narrowHeight + 4 * wideHeight
364 heights = [
365 narrowHeight / totalHeight, # mount
366 wideHeight / totalHeight, # dome
367 wideHeight / totalHeight, # azimuth
368 wideHeight / totalHeight, # elevation
369 wideHeight / totalHeight, # rotation
370 narrowHeight / totalHeight, # aos
371 narrowHeight / totalHeight, # camera
372 ]
374 # Create figure with adjusted gridspec
375 figsize = (18, 8)
376 if figure is None:
377 fig = make_figure(figsize=figsize)
378 else:
379 fig = figure
380 log.warning(f"Using provided figure, (recommened {figsize=}). This better be ~in a notebook!")
381 gs = fig.add_gridspec(8, 2, height_ratios=[*heights, 0.15], width_ratios=[0.8, 0.2], hspace=0)
383 # Create axes with shared x-axis
384 mountAx = fig.add_subplot(gs[0, 0])
385 domeAx = fig.add_subplot(gs[1, 0])
386 azimuthAx = fig.add_subplot(gs[2, 0], sharex=mountAx)
387 elevationAx = fig.add_subplot(gs[3, 0], sharex=mountAx)
388 rotationAx = fig.add_subplot(gs[4, 0], sharex=mountAx)
389 aosAx = fig.add_subplot(gs[5, 0], sharex=mountAx)
390 cameraAx = fig.add_subplot(gs[6, 0], sharex=mountAx)
391 bottomLegendAx = fig.add_subplot(gs[7, :])
393 # Create legend axes
394 mountLegendAx = fig.add_subplot(gs[0, 1])
395 domeLegendAx = fig.add_subplot(gs[1, 1])
396 azLegendAx = fig.add_subplot(gs[2, 1])
397 elLegendAx = fig.add_subplot(gs[3, 1])
398 rotLegendAx = fig.add_subplot(gs[4, 1])
399 aosLegendAx = fig.add_subplot(gs[5, 1])
400 cameraLegendAx = fig.add_subplot(gs[6, 1])
402 axes = {
403 "dome": domeAx,
404 "az": azimuthAx,
405 "el": elevationAx,
406 "rot": rotationAx,
407 "mount": mountAx,
408 "camera": cameraAx,
409 "aos": aosAx,
410 }
412 legendAxes = {
413 "dome": domeLegendAx,
414 "az": azLegendAx,
415 "el": elLegendAx,
416 "rot": rotLegendAx,
417 "mount": mountLegendAx,
418 "camera": cameraLegendAx,
419 "aos": aosLegendAx,
420 "bottom": bottomLegendAx,
421 }
423 # Remove frames and ticks from legend axes
424 for ax in legendAxes.values():
425 ax.set_frame_on(False)
426 ax.set_xticks([])
427 ax.set_yticks([])
429 # Plot telemetry
430 axes["az"].plot(az["actualPosition"])
431 axes["el"].plot(el["actualPosition"])
432 axes["rot"].plot(rot["actualPosition"])
433 axes["dome"].plot(domeData["positionActual"], label="Actual")
434 axes["dome"].plot(domeData["positionCommanded"], label="Commanded")
435 axes["dome"].legend(loc="lower left", frameon=False)
436 # Remove y-ticks for mount, aos, and camera axes
437 for ax_name in ["mount", "aos", "camera"]:
438 axes[ax_name].set_yticks([])
440 # Hide the last x-tick label for all axes because although they're shared
441 # the last values sticks out to the right
442 with warnings.catch_warnings():
443 warnings.simplefilter("ignore", UserWarning)
444 for ax in axes.values():
445 ax.set_xticklabels(ax.get_xticklabels()[:-1])
447 # Shade exposure regions and add annotations
448 for record in expRecords:
449 startExposing = record.timespan.begin.utc.datetime
450 endExposing = record.timespan.end.utc.datetime
451 readoutEnd = (record.timespan.end + READOUT_TIME).utc.datetime
452 seqNum = record.seq_num
454 for ax in axes.values():
455 ax.axvspan(startExposing, endExposing, color=integrationColor, alpha=0.3)
456 ax.axvspan(endExposing, readoutEnd, color=readoutColor, alpha=0.1)
458 # Add expRecord details inside the camera section of the plot
459 midpoint = startExposing + (endExposing - startExposing) / 2
460 label = f"seqNum = {seqNum}\nFilter={record.physical_filter}"
461 axes["camera"].annotate(
462 label,
463 xy=(midpoint, 0.2),
464 xycoords=("data", "axes fraction"),
465 ha="center",
466 va="bottom",
467 fontsize=10,
468 color="black",
469 )
471 # Create separate legend entries for each axis type
472 legendEntries: dict[str, list] = {ax_name: [] for ax_name in axes.keys()}
474 # Handle in-position transitions
475 for label, topic in inPositionTopics.items():
476 axisName = getAxisName(topic)
478 inPositionTransitions = getEfdData(
479 client,
480 topic,
481 begin=begin,
482 end=end,
483 prePadding=prePadding,
484 postPadding=postPadding,
485 warn=False,
486 raiseIfTopicNotInSchema=False,
487 )
488 for time, data in inPositionTransitions.iterrows():
489 inPosition = data["inPosition"]
490 if inPosition:
491 axes[axisName].axvline(time, color="green", linestyle="--", alpha=inPositionAlpha)
492 else:
493 axes[axisName].axvline(time, color="red", linestyle="-", alpha=inPositionAlpha)
495 legendEntries[axisName].extend(
496 [
497 Line2D(
498 [0],
499 [0],
500 color="green",
501 linestyle="--",
502 label=f"{label} in position=True",
503 alpha=inPositionAlpha,
504 ),
505 Line2D(
506 [0],
507 [0],
508 color="red",
509 linestyle="-",
510 label=f"{label} in position=False",
511 alpha=inPositionAlpha,
512 ),
513 ]
514 )
515 # Add special domeVignetted axvline
516 if label == "Dome":
517 vignettingTransitions = domeVignetted
518 for time, data in vignettingTransitions.iterrows():
519 vignetted = data["vignetted"]
520 if not vignetted:
521 axes[axisName].axvline(time, color="magenta", linestyle="--", alpha=inPositionAlpha)
523 legendEntries[axisName].extend(
524 [
525 Line2D(
526 [0],
527 [0],
528 color="magenta",
529 linestyle="-",
530 label=f"{label} vignetted=False",
531 alpha=inPositionAlpha,
532 ),
533 ]
534 )
536 # Handle commands
537 commandTimes = getCommands(
538 client, COMMANDS_TO_QUERY, begin, end, prePadding, postPadding, timeFormat="python"
539 )
541 for topic in HEXAPOD_TOPICS:
542 try:
543 hexData = getEfdData(
544 client,
545 topic,
546 begin=begin,
547 end=end,
548 prePadding=prePadding,
549 postPadding=postPadding,
550 warn=False,
551 raiseIfTopicNotInSchema=False,
552 )
553 commandTimes.update({time: topic for time, _ in hexData.iterrows()})
554 except ValueError:
555 log.warning(f"Failed to get data for {topic}")
557 # Create color maps for each axis
558 color_maps: dict[str, dict[str, str]] = {ax_name: {} for ax_name in axes.keys()}
559 colors = ["b", "g", "r", "c", "m", "y", "k"]
560 color_iterators = {ax_name: itertools.cycle(colors) for ax_name in axes.keys()}
562 # Group commands by axis and assign colors
563 for time, command in commandTimes.items():
564 axisName = getAxisName(command)
565 if command not in color_maps[axisName]:
566 color_maps[axisName][command] = next(color_iterators[axisName])
567 color = color_maps[axisName][command]
568 axes[axisName].axvline(
569 time, # type: ignore[arg-type]
570 linestyle="-.",
571 alpha=commandAlpha,
572 color=color,
573 )
575 # Add to legend entries if not already there
576 shortCommand = command.replace("lsst.sal.", "")
577 if shortCommand not in [entry.get_label() for entry in legendEntries[axisName]]:
578 entry = Line2D([0], [0], color=color, linestyle="-.", label=shortCommand, alpha=commandAlpha)
579 legendEntries[axisName].append(entry)
581 # Create separate legends, using 2 columns if more than 5 items
582 for axisName, entries in legendEntries.items():
583 if entries:
584 ncols = 2 if len(entries) > 5 else 1
585 legendAxes[axisName].legend(
586 handles=entries, loc="center left", bbox_to_anchor=(-0.5, 0.5), ncol=ncols
587 )
589 # Create bottom legend for shading explanation
590 shadingLegendHandles = [
591 Patch(facecolor=integrationColor, alpha=0.3, label="Shutter open period"),
592 Patch(facecolor=readoutColor, alpha=0.1, label="Readout period"),
593 ]
594 bottomLegendAx.legend(handles=shadingLegendHandles, loc="center", bbox_to_anchor=(0.4, 0.5), ncol=2)
596 # For some reason xlim is different on the Dome plot.
597 # This sets it to match:
598 az_xlim = axes["az"].get_xlim()
599 axes["dome"].set_xlim(az_xlim)
601 # Set labels with horizontal orientation
602 for axisName, ax in axes.items():
603 ax.set_ylabel(
604 (
605 f"{axisName.title() if axisName != 'aos' else 'AOS'} commands"
606 if axisName in ["mount", "camera", "aos"]
607 else f"{axisName.title()} (deg)"
608 ),
609 rotation=0,
610 ha="right",
611 va="center",
612 )
613 axes["rot"].set_xlabel("Time (UTC)")
615 # Add title centered on main plot area only
616 axes["mount"].set_title(title)
618 fig.tight_layout()
620 return fig
623if __name__ == "__main__":
624 # example usage
625 import lsst.summit.utils.butlerUtils as butlerUtils # noqa: F811
626 from lsst.summit.extras.slewTimingSimonyi import plotExposureTiming # noqa: F811
627 from lsst.summit.utils.efdUtils import makeEfdClient
629 client = makeEfdClient()
630 butler = butlerUtils.makeDefaultButler("LSSTComCam")
631 dayObs = 20241116
632 where = f"exposure.day_obs={dayObs} AND instrument='LSSTComCam'"
633 records = list(butler.registry.queryDimensionRecords("exposure", where=where))
634 records = sorted(records, key=lambda x: (x.day_obs, x.seq_num))
635 print(f"Found {len(records)} records from {len(set(r.day_obs for r in records))} days")
636 if len(set(r.day_obs for r in records)) == 1:
637 rd = {r.seq_num: r for r in records if r.seq_num >= 1}
638 print(f"{len(rd)} items in the dict")
640 toPlot = [records[98], records[99]]
642 az = plotExposureTiming(client, toPlot)