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

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 

22 

23import itertools 

24import logging 

25import warnings 

26from typing import TYPE_CHECKING 

27 

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 

33 

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 

40 

41if TYPE_CHECKING: 

42 from astropy.time import Time 

43 from matplotlib.figure import Figure 

44 

45 

46__all__ = ["plotExposureTiming"] 

47 

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

49 

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] 

128 

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] 

163 

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} 

173 

174 

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" 

181 

182 if "MTMount.logevent_elevationInPosition" in topic: 

183 return "el" 

184 

185 if "MTMount.logevent_azimuthInPosition" in topic: 

186 return "az" 

187 

188 if "MTRotator.logevent_inPosition" in topic: 

189 return "rot" 

190 

191 if any(x in topic for x in ["MTCamera", "MTRotator", "cameraCableWrap"]): 

192 return "camera" 

193 

194 if any(x in topic for x in ["MTPtg", "MTMount", "MTM1M3", "MTM2"]): 

195 return "mount" 

196 

197 if any(x in topic for x in ["MTAOS", "MTHexapod", "MTM1M3", "MTM2"]): 

198 return "aos" 

199 

200 

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. 

205 

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. 

220 

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() 

249 

250 # Make a new dataframe with the domeBelowThreshold 

251 domeBelowThreshold = pd.DataFrame(data={"inPosition": [True]}, index=[event_time]) 

252 return domeData, domeBelowThreshold 

253 

254 

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. 

263 

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. 

269 

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__) 

291 

292 inPositionAlpha = 0.5 

293 commandAlpha = 0.5 

294 integrationColor = "grey" 

295 readoutColor = "blue" 

296 

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

298 

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}" 

305 

306 begin = expRecords[0].timespan.begin 

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

308 

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 

313 

314 az = mountData.azimuthData 

315 el = mountData.elevationData 

316 rot = mountData.rotationData 

317 

318 domeData, domeBelowThreshold = getDomeData(client, begin, end, prePadding, postPadding) 

319 

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 ] 

333 

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) 

337 

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, :]) 

347 

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]) 

356 

357 axes = { 

358 "dome": domeAx, 

359 "az": azimuthAx, 

360 "el": elevationAx, 

361 "rot": rotationAx, 

362 "mount": mountAx, 

363 "camera": cameraAx, 

364 "aos": aosAx, 

365 } 

366 

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 } 

377 

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([]) 

383 

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([]) 

394 

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]) 

401 

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 

408 

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) 

412 

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 ) 

425 

426 # Create separate legend entries for each axis type 

427 legendEntries: dict[str, list] = {ax_name: [] for ax_name in axes.keys()} 

428 

429 # Handle in-position transitions 

430 for label, topic in inPositionTopics.items(): 

431 axisName = getAxisName(topic) 

432 

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) 

448 

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) 

476 

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 ) 

489 

490 # Handle commands 

491 commandTimes = getCommands( 

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

493 ) 

494 

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}") 

509 

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()} 

514 

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) 

522 

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) 

528 

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 ) 

536 

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) 

543 

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 ) 

556 

557 axes["rot"].set_xlabel("Time (UTC)") 

558 

559 # Add title centered on main plot area only 

560 axes["mount"].set_title(title) 

561 

562 fig.tight_layout() 

563 

564 return fig 

565 

566 

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 

572 

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") 

583 

584 toPlot = [records[98], records[99]] 

585 

586 az = plotExposureTiming(client, toPlot)