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

193 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-23 09:10 +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 

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 

219 Returns 

220 ------- 

221 domeData : `pd.DataFrame` 

222 The dome data with actual and commanded positions. 

223 domeVignetted : `pd.DataFrame` 

224 A dataframe with an entry indicating when the telescope 

225 is no longer vignetted 

226 """ 

227 domeData = getEfdData( 

228 client, 

229 "lsst.sal.MTDome.azimuth", 

230 columns=["positionActual", "positionCommanded"], 

231 begin=begin, 

232 end=end, 

233 prePadding=prePadding, 

234 postPadding=postPadding, 

235 ) 

236 # Get the data with the vignetted flag 

237 vignettedData = getEfdData( 

238 client, 

239 "lsst.sal.MTDomeTrajectory.logevent_telescopeVignetted", 

240 columns=["vignetted"], 

241 begin=begin, 

242 end=end, 

243 prePadding=prePadding, 

244 postPadding=postPadding, 

245 ) 

246 if len(vignettedData) == 0: 

247 vignettedTime = begin.utc.to_datetime() 

248 # check we have the column and that it contains at least 1 value of 1 

249 if "vignetted" in vignettedData.columns and (vignettedData["vignetted"] == 1).any(): 

250 vignettedData = vignettedData[vignettedData["vignetted"] == 1] 

251 vignettedTime = vignettedData.index[-1] 

252 else: 

253 log = logging.getLogger(__name__) 

254 log.warning("No vignetted data found for the given time range. Assuming always vignetted.") 

255 vignettedTime = begin.utc.to_datetime() 

256 # Make a new dataframe with the vignetting data 

257 domeVignetted = pd.DataFrame(data={"vignetted": [False]}, index=[vignettedTime]) 

258 return domeData, domeVignetted 

259 

260 

261def plotExposureTiming( 

262 client: EfdClient, 

263 expRecords: list[dafButler.DimensionRecord], 

264 prePadding: float = 1, 

265 postPadding: float = 3, 

266 narrowHeightRatio: float = 0.4, 

267 figure: Figure | None = None, 

268) -> Figure | None: 

269 """Plot the mount command timings for a set of exposures. 

270 

271 This function plots the mount position data for the entire time range of 

272 the exposures, regardless of whether the exposures are contiguous or not. 

273 The exposures are shaded in the plot to indicate the time range for each 

274 integration its readout, and any commands issued during the time range are 

275 plotted as vertical lines. 

276 

277 Parameters 

278 ---------- 

279 client : `EfdClient` 

280 The client object used to retrieve EFD data. 

281 expRecords : `list` of `lsst.daf.butler.DimensionRecord` 

282 A list of exposure records to plot. The timings will be plotted from 

283 the start of the first exposure to the end of the last exposure, 

284 regardless of whether intermediate exposures are included. 

285 prePadding : `float`, optional 

286 The amount of time to pad before the start of the first exposure. 

287 postPadding : `float`, optional 

288 The amount of time to pad after the end of the last exposure. 

289 narrowHeightRatio : `float`, optional 

290 Height ratio for narrow panels (mount, dome, camera, aos) relative 

291 to wide ones. 

292 Returns 

293 ------- 

294 fig : `matplotlib.figure.Figure` or `None` 

295 The figure containing the plot, or `None` if no data is found. 

296 """ 

297 log = logging.getLogger(__name__) 

298 

299 inPositionAlpha = 0.5 

300 commandAlpha = 0.5 

301 integrationColor = "grey" 

302 readoutColor = "blue" 

303 

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

305 

306 startSeqNum = expRecords[0].seq_num 

307 endSeqNum = expRecords[-1].seq_num 

308 dayObs = expRecords[0].day_obs 

309 if expRecords[-1].day_obs != dayObs: 

310 raise ValueError("All exposures must be from the same day_obs") 

311 title = f"Mount command timings for {dayObsIntToString(dayObs)} seqNums {startSeqNum} - {endSeqNum}" 

312 

313 begin = expRecords[0].timespan.begin 

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

315 

316 mountData = getAzElRotHexDataForPeriod(client, begin, end, prePadding, postPadding) 

317 if mountData.empty: 

318 log.warning(f"No mount data found for dayObs {dayObs} seqNums {startSeqNum}-{endSeqNum}") 

319 return None 

320 

321 az = mountData.azimuthData 

322 el = mountData.elevationData 

323 rot = mountData.rotationData 

324 

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

326 

327 # Calculate relative heights for the gridspec 

328 narrowHeight = narrowHeightRatio 

329 wideHeight = 1.0 

330 totalHeight = 3 * narrowHeight + 4 * wideHeight 

331 heights = [ 

332 narrowHeight / totalHeight, # mount 

333 wideHeight / totalHeight, # dome 

334 wideHeight / totalHeight, # azimuth 

335 wideHeight / totalHeight, # elevation 

336 wideHeight / totalHeight, # rotation 

337 narrowHeight / totalHeight, # aos 

338 narrowHeight / totalHeight, # camera 

339 ] 

340 

341 # Create figure with adjusted gridspec 

342 figsize = (18, 8) 

343 if figure is None: 

344 fig = make_figure(figsize=figsize) 

345 else: 

346 fig = figure 

347 log.warning(f"Using provided figure, (recommened {figsize=}). This better be ~in a notebook!") 

348 gs = fig.add_gridspec(8, 2, height_ratios=[*heights, 0.15], width_ratios=[0.8, 0.2], hspace=0) 

349 

350 # Create axes with shared x-axis 

351 mountAx = fig.add_subplot(gs[0, 0]) 

352 domeAx = fig.add_subplot(gs[1, 0]) 

353 azimuthAx = fig.add_subplot(gs[2, 0], sharex=mountAx) 

354 elevationAx = fig.add_subplot(gs[3, 0], sharex=mountAx) 

355 rotationAx = fig.add_subplot(gs[4, 0], sharex=mountAx) 

356 aosAx = fig.add_subplot(gs[5, 0], sharex=mountAx) 

357 cameraAx = fig.add_subplot(gs[6, 0], sharex=mountAx) 

358 bottomLegendAx = fig.add_subplot(gs[7, :]) 

359 

360 # Create legend axes 

361 mountLegendAx = fig.add_subplot(gs[0, 1]) 

362 domeLegendAx = fig.add_subplot(gs[1, 1]) 

363 azLegendAx = fig.add_subplot(gs[2, 1]) 

364 elLegendAx = fig.add_subplot(gs[3, 1]) 

365 rotLegendAx = fig.add_subplot(gs[4, 1]) 

366 aosLegendAx = fig.add_subplot(gs[5, 1]) 

367 cameraLegendAx = fig.add_subplot(gs[6, 1]) 

368 

369 axes = { 

370 "dome": domeAx, 

371 "az": azimuthAx, 

372 "el": elevationAx, 

373 "rot": rotationAx, 

374 "mount": mountAx, 

375 "camera": cameraAx, 

376 "aos": aosAx, 

377 } 

378 

379 legendAxes = { 

380 "dome": domeLegendAx, 

381 "az": azLegendAx, 

382 "el": elLegendAx, 

383 "rot": rotLegendAx, 

384 "mount": mountLegendAx, 

385 "camera": cameraLegendAx, 

386 "aos": aosLegendAx, 

387 "bottom": bottomLegendAx, 

388 } 

389 

390 # Remove frames and ticks from legend axes 

391 for ax in legendAxes.values(): 

392 ax.set_frame_on(False) 

393 ax.set_xticks([]) 

394 ax.set_yticks([]) 

395 

396 # Plot telemetry 

397 axes["az"].plot(az["actualPosition"]) 

398 axes["el"].plot(el["actualPosition"]) 

399 axes["rot"].plot(rot["actualPosition"]) 

400 axes["dome"].plot(domeData["positionActual"], label="Actual") 

401 axes["dome"].plot(domeData["positionCommanded"], label="Commanded") 

402 axes["dome"].legend(loc="lower left", frameon=False) 

403 # Remove y-ticks for mount, aos, and camera axes 

404 for ax_name in ["mount", "aos", "camera"]: 

405 axes[ax_name].set_yticks([]) 

406 

407 # Hide the last x-tick label for all axes because although they're shared 

408 # the last values sticks out to the right 

409 with warnings.catch_warnings(): 

410 warnings.simplefilter("ignore", UserWarning) 

411 for ax in axes.values(): 

412 ax.set_xticklabels(ax.get_xticklabels()[:-1]) 

413 

414 # Shade exposure regions and add annotations 

415 for record in expRecords: 

416 startExposing = record.timespan.begin.utc.datetime 

417 endExposing = record.timespan.end.utc.datetime 

418 readoutEnd = (record.timespan.end + READOUT_TIME).utc.datetime 

419 seqNum = record.seq_num 

420 

421 for ax in axes.values(): 

422 ax.axvspan(startExposing, endExposing, color=integrationColor, alpha=0.3) 

423 ax.axvspan(endExposing, readoutEnd, color=readoutColor, alpha=0.1) 

424 

425 # Add expRecord details inside the camera section of the plot 

426 midpoint = startExposing + (endExposing - startExposing) / 2 

427 label = f"seqNum = {seqNum}\nFilter={record.physical_filter}" 

428 axes["camera"].annotate( 

429 label, 

430 xy=(midpoint, 0.2), 

431 xycoords=("data", "axes fraction"), 

432 ha="center", 

433 va="bottom", 

434 fontsize=10, 

435 color="black", 

436 ) 

437 

438 # Create separate legend entries for each axis type 

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

440 

441 # Handle in-position transitions 

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

443 axisName = getAxisName(topic) 

444 

445 inPositionTransitions = getEfdData( 

446 client, 

447 topic, 

448 begin=begin, 

449 end=end, 

450 prePadding=prePadding, 

451 postPadding=postPadding, 

452 warn=False, 

453 ) 

454 for time, data in inPositionTransitions.iterrows(): 

455 inPosition = data["inPosition"] 

456 if inPosition: 

457 axes[axisName].axvline(time, color="green", linestyle="--", alpha=inPositionAlpha) 

458 else: 

459 axes[axisName].axvline(time, color="red", linestyle="-", alpha=inPositionAlpha) 

460 

461 legendEntries[axisName].extend( 

462 [ 

463 Line2D( 

464 [0], 

465 [0], 

466 color="green", 

467 linestyle="--", 

468 label=f"{label} in position=True", 

469 alpha=inPositionAlpha, 

470 ), 

471 Line2D( 

472 [0], 

473 [0], 

474 color="red", 

475 linestyle="-", 

476 label=f"{label} in position=False", 

477 alpha=inPositionAlpha, 

478 ), 

479 ] 

480 ) 

481 # Add special domeVignetted axvline 

482 if label == "Dome": 

483 vignettingTransitions = domeVignetted 

484 for time, data in vignettingTransitions.iterrows(): 

485 vignetted = data["vignetted"] 

486 if not vignetted: 

487 axes[axisName].axvline(time, color="magenta", linestyle="--", alpha=inPositionAlpha) 

488 

489 legendEntries[axisName].extend( 

490 [ 

491 Line2D( 

492 [0], 

493 [0], 

494 color="magenta", 

495 linestyle="-", 

496 label=f"{label} vignetted=False", 

497 alpha=inPositionAlpha, 

498 ), 

499 ] 

500 ) 

501 

502 # Handle commands 

503 commandTimes = getCommands( 

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

505 ) 

506 

507 for topic in HEXAPOD_TOPICS: 

508 try: 

509 hexData = getEfdData( 

510 client, 

511 topic, 

512 begin=begin, 

513 end=end, 

514 prePadding=prePadding, 

515 postPadding=postPadding, 

516 warn=False, 

517 ) 

518 commandTimes.update({time: topic for time, _ in hexData.iterrows()}) 

519 except ValueError: 

520 log.warning(f"Failed to get data for {topic}") 

521 

522 # Create color maps for each axis 

523 color_maps: dict[str, dict[str, str]] = {ax_name: {} for ax_name in axes.keys()} 

524 colors = ["b", "g", "r", "c", "m", "y", "k"] 

525 color_iterators = {ax_name: itertools.cycle(colors) for ax_name in axes.keys()} 

526 

527 # Group commands by axis and assign colors 

528 for time, command in commandTimes.items(): 

529 axisName = getAxisName(command) 

530 if command not in color_maps[axisName]: 

531 color_maps[axisName][command] = next(color_iterators[axisName]) 

532 color = color_maps[axisName][command] 

533 axes[axisName].axvline(time, linestyle="-.", alpha=commandAlpha, color=color) 

534 

535 # Add to legend entries if not already there 

536 shortCommand = command.replace("lsst.sal.", "") 

537 if shortCommand not in [entry.get_label() for entry in legendEntries[axisName]]: 

538 entry = Line2D([0], [0], color=color, linestyle="-.", label=shortCommand, alpha=commandAlpha) 

539 legendEntries[axisName].append(entry) 

540 

541 # Create separate legends, using 2 columns if more than 5 items 

542 for axisName, entries in legendEntries.items(): 

543 if entries: 

544 ncols = 2 if len(entries) > 5 else 1 

545 legendAxes[axisName].legend( 

546 handles=entries, loc="center left", bbox_to_anchor=(-0.5, 0.5), ncol=ncols 

547 ) 

548 

549 # Create bottom legend for shading explanation 

550 shadingLegendHandles = [ 

551 Patch(facecolor=integrationColor, alpha=0.3, label="Shutter open period"), 

552 Patch(facecolor=readoutColor, alpha=0.1, label="Readout period"), 

553 ] 

554 bottomLegendAx.legend(handles=shadingLegendHandles, loc="center", bbox_to_anchor=(0.4, 0.5), ncol=2) 

555 

556 # For some reason xlim is different on the Dome plot. 

557 # This sets it to match: 

558 az_xlim = axes["az"].get_xlim() 

559 axes["dome"].set_xlim(az_xlim) 

560 

561 # Set labels with horizontal orientation 

562 for axisName, ax in axes.items(): 

563 ax.set_ylabel( 

564 ( 

565 f"{axisName.title() if axisName != 'aos' else 'AOS'} commands" 

566 if axisName in ["mount", "camera", "aos"] 

567 else f"{axisName.title()} (deg)" 

568 ), 

569 rotation=0, 

570 ha="right", 

571 va="center", 

572 ) 

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

574 

575 # Add title centered on main plot area only 

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

577 

578 fig.tight_layout() 

579 

580 return fig 

581 

582 

583if __name__ == "__main__": 

584 # example usage 

585 import lsst.summit.utils.butlerUtils as butlerUtils # noqa: F811 

586 from lsst.summit.extras.slewTimingSimonyi import plotExposureTiming # noqa: F811 

587 from lsst.summit.utils.efdUtils import makeEfdClient 

588 

589 client = makeEfdClient() 

590 butler = butlerUtils.makeDefaultButler("LSSTComCam") 

591 dayObs = 20241116 

592 where = f"exposure.day_obs={dayObs} AND instrument='LSSTComCam'" 

593 records = list(butler.registry.queryDimensionRecords("exposure", where=where)) 

594 records = sorted(records, key=lambda x: (x.day_obs, x.seq_num)) 

595 print(f"Found {len(records)} records from {len(set(r.day_obs for r in records))} days") 

596 if len(set(r.day_obs for r in records)) == 1: 

597 rd = {r.seq_num: r for r in records if r.seq_num >= 1} 

598 print(f"{len(rd)} items in the dict") 

599 

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

601 

602 az = plotExposureTiming(client, toPlot)