Coverage for python / lsst / summit / extras / slewTimingSimonyi.py: 0%
183 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 19:02 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 19:02 +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):
176 # Note the order here matters, e.g. cameraCableWrap is a substring of
177 # MTMount so it should be checked first, likewise axes are special cases
178 # of the MTMount so should be checked first.
179 if "MTDome.logevent_azMotion" in topic:
180 return "dome"
182 if "MTMount.logevent_elevationInPosition" in topic:
183 return "el"
185 if "MTMount.logevent_azimuthInPosition" in topic:
186 return "az"
188 if "MTRotator.logevent_inPosition" in topic:
189 return "rot"
191 if any(x in topic for x in ["MTCamera", "MTRotator", "cameraCableWrap"]):
192 return "camera"
194 if any(x in topic for x in ["MTPtg", "MTMount", "MTM1M3", "MTM2"]):
195 return "mount"
197 if any(x in topic for x in ["MTAOS", "MTHexapod", "MTM1M3", "MTM2"]):
198 return "aos"
201def getDomeData(
202 client: EfdClient, begin: Time, end: Time, prePadding: float, postPadding: float, threshold: float = 2.7
203) -> tuple[pd.DataFrame, pd.DataFrame]:
204 """Get dome data and when dome is within threshold of being in position.
206 Parameters
207 ----------
208 client : `EfdClient`
209 The client object used to retrieve EFD data.
210 begin : `astropy.time.Time`
211 The begin time for the data retrieval.
212 end : `astropy.time.Time`
213 The end time for the data retrieval.
214 prePadding : `float`
215 The amount of time in seconds to pad before the begin time.
216 postPadding : `float`
217 The amount of time in seconds to pad after the end time.
218 threshold : `float`, optional
219 The threshold in degrees for considering the dome to be in position.
221 Returns
222 -------
223 domeData : `pd.DataFrame`
224 The dome data with actual and commanded positions.
225 domeBelowThreshold : `pd.DataFrame`
226 A dataframe with a single entry indicating the time when the dome
227 position error drops below the threshold.
228 """
229 domeData = getEfdData(
230 client,
231 "lsst.sal.MTDome.azimuth",
232 columns=["positionActual", "positionCommanded"],
233 begin=begin,
234 end=end,
235 prePadding=prePadding,
236 postPadding=postPadding,
237 )
238 # find the time when the dome position error drops below threshold
239 domeData["diff"] = (domeData["positionActual"] - domeData["positionCommanded"]).abs()
240 # Boolean mask where condition holds
241 mask = domeData["diff"] < threshold
242 # Rising edge: True when mask is True
243 # but previous sample was False (or NaN at start)
244 rising = mask & (~mask.shift(1, fill_value=False))
245 if rising.any():
246 # The last rising edge
247 # (latest time where we enter the < threshold region)
248 event_time = rising[rising].index.max()
250 # Make a new dataframe with the domeBelowThreshold
251 domeBelowThreshold = pd.DataFrame(data={"inPosition": [True]}, index=[event_time])
252 return domeData, domeBelowThreshold
255def plotExposureTiming(
256 client: EfdClient,
257 expRecords: list[dafButler.DimensionRecord],
258 prePadding: float = 1,
259 postPadding: float = 3,
260 narrowHeightRatio: float = 0.4,
261) -> Figure | None:
262 """Plot the mount command timings for a set of exposures.
264 This function plots the mount position data for the entire time range of
265 the exposures, regardless of whether the exposures are contiguous or not.
266 The exposures are shaded in the plot to indicate the time range for each
267 integration its readout, and any commands issued during the time range are
268 plotted as vertical lines.
270 Parameters
271 ----------
272 client : `EfdClient`
273 The client object used to retrieve EFD data.
274 expRecords : `list` of `lsst.daf.butler.DimensionRecord`
275 A list of exposure records to plot. The timings will be plotted from
276 the start of the first exposure to the end of the last exposure,
277 regardless of whether intermediate exposures are included.
278 prePadding : `float`, optional
279 The amount of time to pad before the start of the first exposure.
280 postPadding : `float`, optional
281 The amount of time to pad after the end of the last exposure.
282 narrowHeightRatio : `float`, optional
283 Height ratio for narrow panels (mount, dome, camera, aos) relative
284 to wide ones.
285 Returns
286 -------
287 fig : `matplotlib.figure.Figure` or `None`
288 The figure containing the plot, or `None` if no data is found.
289 """
290 log = logging.getLogger(__name__)
292 inPositionAlpha = 0.5
293 commandAlpha = 0.5
294 integrationColor = "grey"
295 readoutColor = "blue"
297 expRecords.sort(key=lambda x: (x.day_obs, x.seq_num)) # ensure we're sorted
299 startSeqNum = expRecords[0].seq_num
300 endSeqNum = expRecords[-1].seq_num
301 dayObs = expRecords[0].day_obs
302 if expRecords[-1].day_obs != dayObs:
303 raise ValueError("All exposures must be from the same day_obs")
304 title = f"Mount command timings for {dayObsIntToString(dayObs)} seqNums {startSeqNum} - {endSeqNum}"
306 begin = expRecords[0].timespan.begin
307 end = expRecords[-1].timespan.end
309 mountData = getAzElRotHexDataForPeriod(client, begin, end, prePadding, postPadding)
310 if mountData.empty:
311 log.warning(f"No mount data found for dayObs {dayObs} seqNums {startSeqNum}-{endSeqNum}")
312 return None
314 az = mountData.azimuthData
315 el = mountData.elevationData
316 rot = mountData.rotationData
318 domeData, domeBelowThreshold = getDomeData(client, begin, end, prePadding, postPadding)
320 # Calculate relative heights for the gridspec
321 narrowHeight = narrowHeightRatio
322 wideHeight = 1.0
323 totalHeight = 3 * narrowHeight + 4 * wideHeight
324 heights = [
325 narrowHeight / totalHeight, # mount
326 wideHeight / totalHeight, # dome
327 wideHeight / totalHeight, # azimuth
328 wideHeight / totalHeight, # elevation
329 wideHeight / totalHeight, # rotation
330 narrowHeight / totalHeight, # aos
331 narrowHeight / totalHeight, # camera
332 ]
334 # Create figure with adjusted gridspec
335 fig = make_figure(figsize=(18, 8))
336 gs = fig.add_gridspec(8, 2, height_ratios=[*heights, 0.15], width_ratios=[0.8, 0.2], hspace=0)
338 # Create axes with shared x-axis
339 mountAx = fig.add_subplot(gs[0, 0])
340 domeAx = fig.add_subplot(gs[1, 0])
341 azimuthAx = fig.add_subplot(gs[2, 0], sharex=mountAx)
342 elevationAx = fig.add_subplot(gs[3, 0], sharex=mountAx)
343 rotationAx = fig.add_subplot(gs[4, 0], sharex=mountAx)
344 aosAx = fig.add_subplot(gs[5, 0], sharex=mountAx)
345 cameraAx = fig.add_subplot(gs[6, 0], sharex=mountAx)
346 bottomLegendAx = fig.add_subplot(gs[7, :])
348 # Create legend axes
349 mountLegendAx = fig.add_subplot(gs[0, 1])
350 domeLegendAx = fig.add_subplot(gs[1, 1])
351 azLegendAx = fig.add_subplot(gs[2, 1])
352 elLegendAx = fig.add_subplot(gs[3, 1])
353 rotLegendAx = fig.add_subplot(gs[4, 1])
354 aosLegendAx = fig.add_subplot(gs[5, 1])
355 cameraLegendAx = fig.add_subplot(gs[6, 1])
357 axes = {
358 "dome": domeAx,
359 "az": azimuthAx,
360 "el": elevationAx,
361 "rot": rotationAx,
362 "mount": mountAx,
363 "camera": cameraAx,
364 "aos": aosAx,
365 }
367 legendAxes = {
368 "dome": domeLegendAx,
369 "az": azLegendAx,
370 "el": elLegendAx,
371 "rot": rotLegendAx,
372 "mount": mountLegendAx,
373 "camera": cameraLegendAx,
374 "aos": aosLegendAx,
375 "bottom": bottomLegendAx,
376 }
378 # Remove frames and ticks from legend axes
379 for ax in legendAxes.values():
380 ax.set_frame_on(False)
381 ax.set_xticks([])
382 ax.set_yticks([])
384 # Plot telemetry
385 axes["az"].plot(az["actualPosition"])
386 axes["el"].plot(el["actualPosition"])
387 axes["rot"].plot(rot["actualPosition"])
388 axes["dome"].plot(domeData["positionActual"], label="Actual")
389 axes["dome"].plot(domeData["positionCommanded"], label="Commanded")
390 axes["dome"].legend(loc="lower left", frameon=False)
391 # Remove y-ticks for mount, aos, and camera axes
392 for ax_name in ["mount", "aos", "camera"]:
393 axes[ax_name].set_yticks([])
395 # Hide the last x-tick label for all axes because although they're shared
396 # the last values sticks out to the right
397 with warnings.catch_warnings():
398 warnings.simplefilter("ignore", UserWarning)
399 for ax in axes.values():
400 ax.set_xticklabels(ax.get_xticklabels()[:-1])
402 # Shade exposure regions and add annotations
403 for record in expRecords:
404 startExposing = record.timespan.begin.utc.datetime
405 endExposing = record.timespan.end.utc.datetime
406 readoutEnd = (record.timespan.end + READOUT_TIME).utc.datetime
407 seqNum = record.seq_num
409 for ax in axes.values():
410 ax.axvspan(startExposing, endExposing, color=integrationColor, alpha=0.3)
411 ax.axvspan(endExposing, readoutEnd, color=readoutColor, alpha=0.1)
413 # Add expRecord details inside the camera section of the plot
414 midpoint = startExposing + (endExposing - startExposing) / 2
415 label = f"seqNum = {seqNum}\nFilter={record.physical_filter}"
416 axes["camera"].annotate(
417 label,
418 xy=(midpoint, 0.2),
419 xycoords=("data", "axes fraction"),
420 ha="center",
421 va="bottom",
422 fontsize=10,
423 color="black",
424 )
426 # Create separate legend entries for each axis type
427 legendEntries: dict[str, list] = {ax_name: [] for ax_name in axes.keys()}
429 # Handle in-position transitions
430 for label, topic in inPositionTopics.items():
431 axisName = getAxisName(topic)
433 inPositionTransitions = getEfdData(
434 client,
435 topic,
436 begin=begin,
437 end=end,
438 prePadding=prePadding,
439 postPadding=postPadding,
440 warn=False,
441 )
442 for time, data in inPositionTransitions.iterrows():
443 inPosition = data["inPosition"]
444 if inPosition:
445 axes[axisName].axvline(time, color="green", linestyle="--", alpha=inPositionAlpha)
446 else:
447 axes[axisName].axvline(time, color="red", linestyle="-", alpha=inPositionAlpha)
449 legendEntries[axisName].extend(
450 [
451 Line2D(
452 [0],
453 [0],
454 color="green",
455 linestyle="--",
456 label=f"{label} in position=True",
457 alpha=inPositionAlpha,
458 ),
459 Line2D(
460 [0],
461 [0],
462 color="red",
463 linestyle="-",
464 label=f"{label} in position=False",
465 alpha=inPositionAlpha,
466 ),
467 ]
468 )
469 # Add special domeBelowThreshold axvline
470 if label == "Dome":
471 inPositionTransitions = domeBelowThreshold
472 for time, data in inPositionTransitions.iterrows():
473 inPosition = data["inPosition"]
474 if inPosition:
475 axes[axisName].axvline(time, color="magenta", linestyle="--", alpha=inPositionAlpha)
477 legendEntries[axisName].extend(
478 [
479 Line2D(
480 [0],
481 [0],
482 color="magenta",
483 linestyle="-",
484 label=f"{label} below threshold=True",
485 alpha=inPositionAlpha,
486 ),
487 ]
488 )
490 # Handle commands
491 commandTimes = getCommands(
492 client, COMMANDS_TO_QUERY, begin, end, prePadding, postPadding, timeFormat="python"
493 )
495 for topic in HEXAPOD_TOPICS:
496 try:
497 hexData = getEfdData(
498 client,
499 topic,
500 begin=begin,
501 end=end,
502 prePadding=prePadding,
503 postPadding=postPadding,
504 warn=False,
505 )
506 commandTimes.update({time: topic for time, _ in hexData.iterrows()})
507 except ValueError:
508 log.warning(f"Failed to get data for {topic}")
510 # Create color maps for each axis
511 color_maps: dict[str, dict[str, str]] = {ax_name: {} for ax_name in axes.keys()}
512 colors = ["b", "g", "r", "c", "m", "y", "k"]
513 color_iterators = {ax_name: itertools.cycle(colors) for ax_name in axes.keys()}
515 # Group commands by axis and assign colors
516 for time, command in commandTimes.items():
517 axisName = getAxisName(command)
518 if command not in color_maps[axisName]:
519 color_maps[axisName][command] = next(color_iterators[axisName])
520 color = color_maps[axisName][command]
521 axes[axisName].axvline(time, linestyle="-.", alpha=commandAlpha, color=color)
523 # Add to legend entries if not already there
524 shortCommand = command.replace("lsst.sal.", "")
525 if shortCommand not in [entry.get_label() for entry in legendEntries[axisName]]:
526 entry = Line2D([0], [0], color=color, linestyle="-.", label=shortCommand, alpha=commandAlpha)
527 legendEntries[axisName].append(entry)
529 # Create separate legends, using 2 columns if more than 5 items
530 for axisName, entries in legendEntries.items():
531 if entries:
532 ncols = 2 if len(entries) > 5 else 1
533 legendAxes[axisName].legend(
534 handles=entries, loc="center left", bbox_to_anchor=(-0.5, 0.5), ncol=ncols
535 )
537 # Create bottom legend for shading explanation
538 shadingLegendHandles = [
539 Patch(facecolor=integrationColor, alpha=0.3, label="Shutter open period"),
540 Patch(facecolor=readoutColor, alpha=0.1, label="Readout period"),
541 ]
542 bottomLegendAx.legend(handles=shadingLegendHandles, loc="center", bbox_to_anchor=(0.4, 0.5), ncol=2)
544 # Set labels with horizontal orientation
545 for axisName, ax in axes.items():
546 ax.set_ylabel(
547 (
548 f"{axisName.title() if axisName != 'aos' else 'AOS'} commands"
549 if axisName in ["mount", "camera", "aos"]
550 else f"{axisName.title()} (deg)"
551 ),
552 rotation=0,
553 ha="right",
554 va="center",
555 )
557 axes["rot"].set_xlabel("Time (UTC)")
559 # Add title centered on main plot area only
560 axes["mount"].set_title(title)
562 fig.tight_layout()
564 return fig
567if __name__ == "__main__":
568 # example usage
569 import lsst.summit.utils.butlerUtils as butlerUtils # noqa: F811
570 from lsst.summit.extras.slewTimingSimonyi import plotExposureTiming # noqa: F811
571 from lsst.summit.utils.efdUtils import makeEfdClient
573 client = makeEfdClient()
574 butler = butlerUtils.makeDefaultButler("LSSTComCam")
575 dayObs = 20241116
576 where = f"exposure.day_obs={dayObs} AND instrument='LSSTComCam'"
577 records = list(butler.registry.queryDimensionRecords("exposure", where=where))
578 records = sorted(records, key=lambda x: (x.day_obs, x.seq_num))
579 print(f"Found {len(records)} records from {len(set(r.day_obs for r in records))} days")
580 if len(set(r.day_obs for r in records)) == 1:
581 rd = {r.seq_num: r for r in records if r.seq_num >= 1}
582 print(f"{len(rd)} items in the dict")
584 toPlot = [records[98], records[99]]
586 az = plotExposureTiming(client, toPlot)