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

194 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 08:54 +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: 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 ) 

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) 

493 

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) 

521 

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 ) 

534 

535 # Handle commands 

536 commandTimes = getCommands( 

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

538 ) 

539 

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

554 

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

559 

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 ) 

572 

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) 

578 

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 ) 

586 

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) 

593 

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) 

598 

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

612 

613 # Add title centered on main plot area only 

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

615 

616 fig.tight_layout() 

617 

618 return fig 

619 

620 

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 

626 

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

637 

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

639 

640 az = plotExposureTiming(client, toPlot)