Coverage for python / lsst / summit / extras / slewTimingSimonyi.py: 0%
194 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-06 09:16 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-06 09:16 +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 )
487 for time, data in inPositionTransitions.iterrows():
488 inPosition = data["inPosition"]
489 if inPosition:
490 axes[axisName].axvline(time, color="green", linestyle="--", alpha=inPositionAlpha)
491 else:
492 axes[axisName].axvline(time, color="red", linestyle="-", alpha=inPositionAlpha)
494 legendEntries[axisName].extend(
495 [
496 Line2D(
497 [0],
498 [0],
499 color="green",
500 linestyle="--",
501 label=f"{label} in position=True",
502 alpha=inPositionAlpha,
503 ),
504 Line2D(
505 [0],
506 [0],
507 color="red",
508 linestyle="-",
509 label=f"{label} in position=False",
510 alpha=inPositionAlpha,
511 ),
512 ]
513 )
514 # Add special domeVignetted axvline
515 if label == "Dome":
516 vignettingTransitions = domeVignetted
517 for time, data in vignettingTransitions.iterrows():
518 vignetted = data["vignetted"]
519 if not vignetted:
520 axes[axisName].axvline(time, color="magenta", linestyle="--", alpha=inPositionAlpha)
522 legendEntries[axisName].extend(
523 [
524 Line2D(
525 [0],
526 [0],
527 color="magenta",
528 linestyle="-",
529 label=f"{label} vignetted=False",
530 alpha=inPositionAlpha,
531 ),
532 ]
533 )
535 # Handle commands
536 commandTimes = getCommands(
537 client, COMMANDS_TO_QUERY, begin, end, prePadding, postPadding, timeFormat="python"
538 )
540 for topic in HEXAPOD_TOPICS:
541 try:
542 hexData = getEfdData(
543 client,
544 topic,
545 begin=begin,
546 end=end,
547 prePadding=prePadding,
548 postPadding=postPadding,
549 warn=False,
550 )
551 commandTimes.update({time: topic for time, _ in hexData.iterrows()})
552 except ValueError:
553 log.warning(f"Failed to get data for {topic}")
555 # Create color maps for each axis
556 color_maps: dict[str, dict[str, str]] = {ax_name: {} for ax_name in axes.keys()}
557 colors = ["b", "g", "r", "c", "m", "y", "k"]
558 color_iterators = {ax_name: itertools.cycle(colors) for ax_name in axes.keys()}
560 # Group commands by axis and assign colors
561 for time, command in commandTimes.items():
562 axisName = getAxisName(command)
563 if command not in color_maps[axisName]:
564 color_maps[axisName][command] = next(color_iterators[axisName])
565 color = color_maps[axisName][command]
566 axes[axisName].axvline(
567 time, # type: ignore[arg-type]
568 linestyle="-.",
569 alpha=commandAlpha,
570 color=color,
571 )
573 # Add to legend entries if not already there
574 shortCommand = command.replace("lsst.sal.", "")
575 if shortCommand not in [entry.get_label() for entry in legendEntries[axisName]]:
576 entry = Line2D([0], [0], color=color, linestyle="-.", label=shortCommand, alpha=commandAlpha)
577 legendEntries[axisName].append(entry)
579 # Create separate legends, using 2 columns if more than 5 items
580 for axisName, entries in legendEntries.items():
581 if entries:
582 ncols = 2 if len(entries) > 5 else 1
583 legendAxes[axisName].legend(
584 handles=entries, loc="center left", bbox_to_anchor=(-0.5, 0.5), ncol=ncols
585 )
587 # Create bottom legend for shading explanation
588 shadingLegendHandles = [
589 Patch(facecolor=integrationColor, alpha=0.3, label="Shutter open period"),
590 Patch(facecolor=readoutColor, alpha=0.1, label="Readout period"),
591 ]
592 bottomLegendAx.legend(handles=shadingLegendHandles, loc="center", bbox_to_anchor=(0.4, 0.5), ncol=2)
594 # For some reason xlim is different on the Dome plot.
595 # This sets it to match:
596 az_xlim = axes["az"].get_xlim()
597 axes["dome"].set_xlim(az_xlim)
599 # Set labels with horizontal orientation
600 for axisName, ax in axes.items():
601 ax.set_ylabel(
602 (
603 f"{axisName.title() if axisName != 'aos' else 'AOS'} commands"
604 if axisName in ["mount", "camera", "aos"]
605 else f"{axisName.title()} (deg)"
606 ),
607 rotation=0,
608 ha="right",
609 va="center",
610 )
611 axes["rot"].set_xlabel("Time (UTC)")
613 # Add title centered on main plot area only
614 axes["mount"].set_title(title)
616 fig.tight_layout()
618 return fig
621if __name__ == "__main__":
622 # example usage
623 import lsst.summit.utils.butlerUtils as butlerUtils # noqa: F811
624 from lsst.summit.extras.slewTimingSimonyi import plotExposureTiming # noqa: F811
625 from lsst.summit.utils.efdUtils import makeEfdClient
627 client = makeEfdClient()
628 butler = butlerUtils.makeDefaultButler("LSSTComCam")
629 dayObs = 20241116
630 where = f"exposure.day_obs={dayObs} AND instrument='LSSTComCam'"
631 records = list(butler.registry.queryDimensionRecords("exposure", where=where))
632 records = sorted(records, key=lambda x: (x.day_obs, x.seq_num))
633 print(f"Found {len(records)} records from {len(set(r.day_obs for r in records))} days")
634 if len(set(r.day_obs for r in records)) == 1:
635 rd = {r.seq_num: r for r in records if r.seq_num >= 1}
636 print(f"{len(rd)} items in the dict")
638 toPlot = [records[98], records[99]]
640 az = plotExposureTiming(client, toPlot)