Coverage for python/lsst/summit/extras/slewTimingSimonyi.py: 0%

194 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-10 02:18 -0800

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: str) -> str: 

176 """Classify an EFD topic into the axis subplot it belongs on. 

177 

178 Parameters 

179 ---------- 

180 topic : `str` 

181 Fully-qualified EFD topic name (e.g. 

182 ``lsst.sal.MTMount.logevent_azimuthInPosition``). 

183 

184 Returns 

185 ------- 

186 axisName : `str` 

187 One of ``dome``, ``el``, ``az``, ``rot``, ``camera``, 

188 ``mount``, or ``aos``. 

189 

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" 

200 

201 if "MTMount.logevent_elevationInPosition" in topic: 

202 return "el" 

203 

204 if "MTMount.logevent_azimuthInPosition" in topic: 

205 return "az" 

206 

207 if "MTRotator.logevent_inPosition" in topic: 

208 return "rot" 

209 

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

211 return "camera" 

212 

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

214 return "mount" 

215 

216 if any(x in topic for x in ["MTAOS", "MTHexapod"]): 

217 return "aos" 

218 

219 raise ValueError(f"Could not determine axis name for topic: {topic}") 

220 

221 

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. 

226 

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. 

239 

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 

280 

281 

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. 

291 

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. 

297 

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. 

318 

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. 

324 

325 Raises 

326 ------ 

327 ValueError 

328 Raised if ``expRecords`` spans more than one ``day_obs``. 

329 """ 

330 log = logging.getLogger(__name__) 

331 

332 inPositionAlpha = 0.5 

333 commandAlpha = 0.5 

334 integrationColor = "grey" 

335 readoutColor = "blue" 

336 

337 expRecords = sorted(expRecords, key=lambda x: (x.day_obs, x.seq_num)) 

338 

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

345 

346 begin = expRecords[0].timespan.begin 

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

348 

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 

353 

354 az = mountData.azimuthData 

355 el = mountData.elevationData 

356 rot = mountData.rotationData 

357 

358 domeData, domeVignetted = getDomeData(client, begin, end, prePadding, postPadding) 

359 

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 ] 

373 

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) 

382 

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

392 

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

401 

402 axes = { 

403 "dome": domeAx, 

404 "az": azimuthAx, 

405 "el": elevationAx, 

406 "rot": rotationAx, 

407 "mount": mountAx, 

408 "camera": cameraAx, 

409 "aos": aosAx, 

410 } 

411 

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 } 

422 

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

428 

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

439 

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

446 

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 

453 

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) 

457 

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 ) 

470 

471 # Create separate legend entries for each axis type 

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

473 

474 # Handle in-position transitions 

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

476 axisName = getAxisName(topic) 

477 

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) 

494 

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) 

522 

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 ) 

535 

536 # Handle commands 

537 commandTimes = getCommands( 

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

539 ) 

540 

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

556 

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

561 

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 ) 

574 

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) 

580 

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 ) 

588 

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) 

595 

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) 

600 

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

614 

615 # Add title centered on main plot area only 

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

617 

618 fig.tight_layout() 

619 

620 return fig 

621 

622 

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 

628 

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

639 

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

641 

642 az = plotExposureTiming(client, toPlot)