Coverage for python/lsst/summit/utils/tmaUtils.py: 18%

651 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-28 05:08 -0700

1# This file is part of summit_utils. 

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/>. 

21 

22import datetime 

23import enum 

24import itertools 

25import logging 

26import re 

27from dataclasses import dataclass, field 

28 

29import humanize 

30import matplotlib.dates as mdates 

31import matplotlib.pyplot as plt 

32import numpy as np 

33import pandas as pd 

34from astropy.time import Time 

35from matplotlib.ticker import FuncFormatter 

36 

37from lsst.utils.iteration import ensure_iterable 

38 

39from .blockUtils import BlockParser 

40from .efdUtils import ( 

41 COMMAND_ALIASES, 

42 clipDataToEvent, 

43 efdTimestampToAstropy, 

44 getCommands, 

45 getDayObsEndTime, 

46 getDayObsForTime, 

47 getDayObsStartTime, 

48 getEfdData, 

49 makeEfdClient, 

50) 

51from .enums import AxisMotionState, PowerState 

52from .utils import dayObsIntToString, getCurrentDayObs_int 

53 

54__all__ = ( 

55 "TMAStateMachine", 

56 "TMAEvent", 

57 "TMAEventMaker", 

58 "TMAState", 

59 "AxisMotionState", 

60 "PowerState", 

61 "getSlewsFromEventList", 

62 "getTracksFromEventList", 

63 "getTorqueMaxima", 

64 "filterBadValues", 

65) 

66 

67# we don't want to use `None` for a no data sentinel because dict.get('key') 

68# returns None if the key isn't present, and also we need to mark that the data 

69# was queried for and no data was found, whereas the key not being present 

70# means that we've not yet looked for the data. 

71NO_DATA_SENTINEL = "NODATA" 

72 

73# The known time difference between the TMA demand position and the TMA 

74# position when tracking. 20Hz data times three points = 150ms. 

75TRACKING_RESIDUAL_TAIL_CLIP = -0.15 # seconds 

76 

77 

78def getSlewsFromEventList(events): 

79 """Get the slew events from a list of TMAEvents. 

80 

81 Parameters 

82 ---------- 

83 events : `list` of `lsst.summit.utils.tmaUtils.TMAEvent` 

84 The list of events to filter. 

85 

86 Returns 

87 ------- 

88 events : `list` of `lsst.summit.utils.tmaUtils.TMAEvent` 

89 The filtered list of events. 

90 """ 

91 return [e for e in events if e.type == TMAState.SLEWING] 

92 

93 

94def getTracksFromEventList(events): 

95 """Get the tracking events from a list of TMAEvents. 

96 

97 Parameters 

98 ---------- 

99 events : `list` of `lsst.summit.utils.tmaUtils.TMAEvent` 

100 The list of events to filter. 

101 

102 Returns 

103 ------- 

104 events : `list` of `lsst.summit.utils.tmaUtils.TMAEvent` 

105 The filtered list of events. 

106 """ 

107 return [e for e in events if e.type == TMAState.TRACKING] 

108 

109 

110def getTorqueMaxima(table): 

111 """Print the maximum positive and negative azimuth and elevation torques. 

112 

113 Designed to be used with the table as downloaded from RubinTV. 

114 

115 Parameters 

116 ---------- 

117 table : `pd.DataFrame` 

118 The table of data to use, as generated by Rapid Analysis. 

119 """ 

120 for axis in ["elevation", "azimuth"]: 

121 col = f"Largest {axis} torque" 

122 maxPos = np.argmax(table[col]) 

123 maxVal = table[col].iloc[maxPos] 

124 print(f"Max positive {axis:9} torque during seqNum {maxPos:>4}: {maxVal/1000:>7.1f}kNm") 

125 minPos = np.argmin(table[col]) 

126 minVal = table[col].iloc[minPos] 

127 print(f"Max negative {axis:9} torque during seqNum {minPos:>4}: {minVal/1000:>7.1f}kNm") 

128 

129 

130def getAzimuthElevationDataForEvent( 

131 client, 

132 event, 

133 prePadding=0, 

134 postPadding=0, 

135): 

136 """Get the data for the az/el telemetry topics for a given TMAEvent. 

137 

138 The error between the actual and demanded positions is calculated and added 

139 to the dataframes in the az/elError columns. For TRACKING type events, this 

140 error should be extremely close to zero, whereas for SLEWING type events, 

141 this error represents the how far the TMA is from the demanded position, 

142 and is therefore arbitrarily large, and tends to zero as the TMA get closer 

143 to tracking the sky. 

144 

145 Parameters 

146 ---------- 

147 client : `lsst_efd_client.efd_helper.EfdClient` 

148 The EFD client to use. 

149 event : `lsst.summit.utils.tmaUtils.TMAEvent` 

150 The event to get the data for. 

151 prePadding : `float`, optional 

152 The amount of time to pad the event with before the start time, in 

153 seconds. 

154 postPadding : `float`, optional 

155 The amount of time to pad the event with after the end time, in 

156 seconds. 

157 

158 Returns 

159 ------- 

160 azimuthData : `pd.DataFrame` 

161 The azimuth data for the specified event. 

162 elevationData : `pd.DataFrame` 

163 The elevation data for the specified event. 

164 """ 

165 azimuthData = getEfdData( 

166 client, "lsst.sal.MTMount.azimuth", event=event, prePadding=prePadding, postPadding=postPadding 

167 ) 

168 elevationData = getEfdData( 

169 client, "lsst.sal.MTMount.elevation", event=event, prePadding=prePadding, postPadding=postPadding 

170 ) 

171 

172 azValues = azimuthData["actualPosition"].values 

173 elValues = elevationData["actualPosition"].values 

174 azDemand = azimuthData["demandPosition"].values 

175 elDemand = elevationData["demandPosition"].values 

176 

177 azError = (azValues - azDemand) * 3600 

178 elError = (elValues - elDemand) * 3600 

179 

180 azimuthData["azError"] = azError 

181 elevationData["elError"] = elError 

182 

183 return azimuthData, elevationData 

184 

185 

186def filterBadValues(values, maxDelta=0.1, maxConsecutiveValues=3): 

187 """Filter out bad values from a dataset, replacing them in-place. 

188 

189 This function replaces non-physical points in the dataset with an 

190 extrapolation of the preceding two values. No more than 3 successive data 

191 points are allowed to be replaced. Minimum length of the input is 3 points. 

192 

193 Parameters 

194 ---------- 

195 values : `list` or `np.ndarray` 

196 The dataset containing the values to be filtered. 

197 maxDelta : `float`, optional 

198 The maximum allowed difference between consecutive values. Values with 

199 a difference greater than `maxDelta` will be considered as bad values 

200 and replaced with an extrapolation. 

201 maxConsecutiveValues : `int`, optional 

202 The maximum number of consecutive values to replace. Defaults to 3. 

203 

204 Returns 

205 ------- 

206 nBadPoints : `int` 

207 The number of bad values that were replaced out. 

208 """ 

209 # Find non-physical points and replace with extrapolation. No more than 

210 # maxConsecutiveValues successive data points can be replaced. 

211 badCounter = 0 

212 consecutiveCounter = 0 

213 

214 log = logging.getLogger(__name__) 

215 

216 median = np.nanmedian(values) 

217 # if either of the the first two points are more than maxDelta away from 

218 # the median, replace them with the median 

219 for i in range(2): 

220 if abs(values[i] - median) > maxDelta: 

221 log.warning(f"Replacing bad value of {values[i]} at index {i} with {median=}") 

222 values[i] = median 

223 badCounter += 1 

224 

225 # from the second element of the array, walk through and calculate the 

226 # difference between each element and the previous one. If the difference 

227 # is greater than maxDelta, replace the element with the average of the 

228 # previous two known good values, i.e. ones which have not been replaced. 

229 # if the first two points differ from the median by more than maxDelta, 

230 # replace them with the median 

231 lastGoodValue1 = values[1] # the most recent good value 

232 lastGoodValue2 = values[0] # the second most recent good value 

233 replacementValue = (lastGoodValue1 + lastGoodValue2) / 2.0 # in case we have to replace the first value 

234 for i in range(2, len(values)): 

235 if abs(values[i] - lastGoodValue1) >= maxDelta: 

236 if consecutiveCounter < maxConsecutiveValues: 

237 consecutiveCounter += 1 

238 badCounter += 1 

239 log.warning(f"Replacing value at index {i} with {replacementValue}") 

240 values[i] = replacementValue 

241 else: 

242 log.warning( 

243 f"More than 3 consecutive replacements at index {i}. Stopping replacements" 

244 " until the next good value." 

245 ) 

246 else: 

247 lastGoodValue2 = lastGoodValue1 

248 lastGoodValue1 = values[i] 

249 replacementValue = (lastGoodValue1 + lastGoodValue2) / 2.0 

250 consecutiveCounter = 0 

251 return badCounter 

252 

253 

254def plotEvent( 

255 client, 

256 event, 

257 fig=None, 

258 prePadding=0, 

259 postPadding=0, 

260 commands={}, 

261 azimuthData=None, 

262 elevationData=None, 

263 doFilterResiduals=False, 

264 maxDelta=0.1, 

265): 

266 """Plot the TMA axis positions over the course of a given TMAEvent. 

267 

268 Plots the axis motion profiles for the given event, with optional padding 

269 at the start and end of the event. If the data is provided via the 

270 azimuthData and elevationData parameters, it will be used, otherwise it 

271 will be queried from the EFD. 

272 

273 Optionally plots any commands issued during or around the event, if these 

274 are supplied. Commands are supplied as a dictionary of the command topic 

275 strings, with values as astro.time.Time objects at which the command was 

276 issued. 

277 

278 Due to a problem with the way the data is uploaded to the EFD, there are 

279 occasional points in the tracking error plots that are very much larger 

280 than the typical mount jitter. These points are unphysical, since it is not 

281 possible for the mount to move that fast. We don't want these points, which 

282 are not true mount problems, to distract from any real mount problems, and 

283 these can be filtered out via the ``doFilterResiduals`` kwarg, which 

284 replaces these non-physical points with an extrapolation of the average of 

285 the preceding two known-good points. If the first two points are bad these 

286 are replaced with the median of the dataset. The maximum difference between 

287 the model and the actual data, in arcseconds, to allow before filtering a 

288 data point can be set with the ``maxDelta`` kwarg. 

289 

290 Parameters 

291 ---------- 

292 client : `lsst_efd_client.efd_helper.EfdClient` 

293 The EFD client to use. 

294 event : `lsst.summit.utils.tmaUtils.TMAEvent` 

295 The event to plot. 

296 fig : `matplotlib.figure.Figure`, optional 

297 The figure to plot on. If not specified, a new figure will be created. 

298 prePadding : `float`, optional 

299 The amount of time to pad the event with before the start time, in 

300 seconds. 

301 postPadding : `float`, optional 

302 The amount of time to pad the event with after the end time, in 

303 seconds. 

304 commands : `dict` [`pd.Timestamp`, `str`], or 

305 `dict` [`datetime.datetime`, `str`], oroptional 

306 A dictionary of commands to plot on the figure. The keys are the times 

307 at which a command was issued, and the value is the command string, as 

308 returned by efdUtils.getCommands(). 

309 azimuthData : `pd.DataFrame`, optional 

310 The azimuth data to plot. If not specified, it will be queried from the 

311 EFD. 

312 elevationData : `pd.DataFrame`, optional 

313 The elevation data to plot. If not specified, it will be queried from 

314 the EFD. 

315 doFilterResiduals : 'bool', optional 

316 Enables filtering of unphysical data points in the tracking residuals. 

317 maxDelta : `float`, optional 

318 The maximum difference between the model and the actual data, in 

319 arcseconds, to allow before filtering the data point. Ignored if 

320 ``doFilterResiduals`` is `False`. 

321 Returns 

322 ------- 

323 fig : `matplotlib.figure.Figure` 

324 The figure on which the plot was made. 

325 """ 

326 

327 def tickFormatter(value, tick_number): 

328 # Convert the value to a string without subtracting large numbers 

329 # tick_number is unused. 

330 return f"{value:.2f}" 

331 

332 def getPlotTime(time): 

333 """Get the right time to plot a point from the various time formats.""" 

334 match time: 

335 case pd.Timestamp(): 

336 return time.to_pydatetime() 

337 case Time(): 

338 return time.utc.datetime 

339 case datetime.datetime(): 

340 return time 

341 case _: 

342 raise ValueError(f"Unknown type for commandTime: {type(time)}") 

343 

344 # plot any commands we might have 

345 if not isinstance(commands, dict): 

346 raise TypeError("commands must be a dict of command names with values as" " astropy.time.Time values") 

347 

348 if fig is None: 

349 fig = plt.figure(figsize=(10, 8)) 

350 log = logging.getLogger(__name__) 

351 log.warning( 

352 "Making new matplotlib figure - if this is in a loop you're going to have a bad time." 

353 " Pass in a figure with fig = plt.figure(figsize=(10, 8)) to avoid this warning." 

354 ) 

355 

356 fig.clear() 

357 ax1p5 = None # need to always be defined 

358 if event.type.name == "TRACKING": 

359 ax1, ax1p5, ax2 = fig.subplots( 

360 3, sharex=True, gridspec_kw={"wspace": 0, "hspace": 0, "height_ratios": [2.5, 1, 1]} 

361 ) 

362 else: 

363 ax1, ax2 = fig.subplots( 

364 2, sharex=True, gridspec_kw={"wspace": 0, "hspace": 0, "height_ratios": [2.5, 1]} 

365 ) 

366 

367 if azimuthData is None or elevationData is None: 

368 azimuthData, elevationData = getAzimuthElevationDataForEvent( 

369 client, event, prePadding=prePadding, postPadding=postPadding 

370 ) 

371 

372 # Use the native color cycle for the lines. Because they're on different 

373 # axes they don't cycle by themselves 

374 lineColors = [p["color"] for p in plt.rcParams["axes.prop_cycle"]] 

375 nColors = len(lineColors) 

376 colorCounter = 0 

377 

378 ax1.plot(azimuthData["actualPosition"], label="Azimuth position", c=lineColors[colorCounter % nColors]) 

379 colorCounter += 1 

380 ax1.yaxis.set_major_formatter(FuncFormatter(tickFormatter)) 

381 ax1.set_ylabel("Azimuth (degrees)") 

382 

383 ax1_twin = ax1.twinx() 

384 ax1_twin.plot( 

385 elevationData["actualPosition"], label="Elevation position", c=lineColors[colorCounter % nColors] 

386 ) 

387 colorCounter += 1 

388 ax1_twin.yaxis.set_major_formatter(FuncFormatter(tickFormatter)) 

389 ax1_twin.set_ylabel("Elevation (degrees)") 

390 ax1.set_xticks([]) # remove x tick labels on the hidden upper x-axis 

391 

392 ax2_twin = ax2.twinx() 

393 ax2.plot(azimuthData["actualTorque"], label="Azimuth torque", c=lineColors[colorCounter % nColors]) 

394 colorCounter += 1 

395 ax2_twin.plot( 

396 elevationData["actualTorque"], label="Elevation torque", c=lineColors[colorCounter % nColors] 

397 ) 

398 colorCounter += 1 

399 ax2.set_ylabel("Azimuth torque (Nm)") 

400 ax2_twin.set_ylabel("Elevation torque (Nm)") 

401 ax2.set_xlabel("Time (UTC)") # yes, it really is UTC, matplotlib converts this automatically! 

402 

403 # put the ticks at an angle, and right align with the tick marks 

404 ax2.set_xticks(ax2.get_xticks()) # needed to supress a user warning 

405 xlabels = ax2.get_xticks() 

406 ax2.set_xticklabels(xlabels, rotation=40, ha="right") 

407 ax2.xaxis.set_major_locator(mdates.AutoDateLocator()) 

408 ax2.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S")) 

409 

410 if event.type.name == "TRACKING": 

411 # returns a copy 

412 clippedAzimuthData = clipDataToEvent(azimuthData, event, postPadding=TRACKING_RESIDUAL_TAIL_CLIP) 

413 clippedElevationData = clipDataToEvent(elevationData, event, postPadding=TRACKING_RESIDUAL_TAIL_CLIP) 

414 

415 azError = clippedAzimuthData["azError"].values 

416 elError = clippedElevationData["elError"].values 

417 elVals = clippedElevationData["actualPosition"].values 

418 if doFilterResiduals: 

419 # Filtering out bad values 

420 nReplacedAz = filterBadValues(azError, maxDelta) 

421 nReplacedEl = filterBadValues(elError, maxDelta) 

422 clippedAzimuthData["azError"] = azError 

423 clippedElevationData["elError"] = elError 

424 # Calculate RMS 

425 az_rms = np.sqrt(np.mean(azError * azError)) 

426 el_rms = np.sqrt(np.mean(elError * elError)) 

427 

428 # Calculate Image impact RMS 

429 # We are less sensitive to Az errors near the zenith 

430 image_az_rms = az_rms * np.cos(elVals[0] * np.pi / 180.0) 

431 image_el_rms = el_rms 

432 image_impact_rms = np.sqrt(image_az_rms**2 + image_el_rms**2) 

433 ax1p5.plot( 

434 clippedAzimuthData["azError"], 

435 label="Azimuth tracking error", 

436 c=lineColors[colorCounter % nColors], 

437 ) 

438 colorCounter += 1 

439 ax1p5.plot( 

440 clippedElevationData["elError"], 

441 label="Elevation tracking error", 

442 c=lineColors[colorCounter % nColors], 

443 ) 

444 colorCounter += 1 

445 ax1p5.axhline(0.01, ls="-.", color="black") 

446 ax1p5.axhline(-0.01, ls="-.", color="black") 

447 ax1p5.yaxis.set_major_formatter(FuncFormatter(tickFormatter)) 

448 ax1p5.set_ylabel("Tracking error (arcsec)") 

449 ax1p5.set_xticks([]) # remove x tick labels on the hidden upper x-axis 

450 ax1p5.set_ylim(-0.05, 0.05) 

451 ax1p5.set_yticks([-0.04, -0.02, 0.0, 0.02, 0.04]) 

452 ax1p5.legend() 

453 ax1p5.text(0.1, 0.9, f"Image impact RMS = {image_impact_rms:.3f} arcsec", transform=ax1p5.transAxes) 

454 if doFilterResiduals: 

455 ax1p5.text( 

456 0.1, 

457 0.8, 

458 f"{nReplacedAz} bad azimuth values and {nReplacedEl} bad elevation values were replaced", 

459 transform=ax1p5.transAxes, 

460 ) 

461 

462 if prePadding or postPadding: 

463 # note the conversion to utc because the x-axis from the dataframe 

464 # already got automagically converted when plotting before, so this is 

465 # necessary for things to line up 

466 ax1_twin.axvline(event.begin.utc.datetime, c="k", ls="--", alpha=0.5, label="Event begin/end") 

467 ax1_twin.axvline(event.end.utc.datetime, c="k", ls="--", alpha=0.5) 

468 # extend lines down across lower plot, but do not re-add label 

469 ax2_twin.axvline(event.begin.utc.datetime, c="k", ls="--", alpha=0.5) 

470 ax2_twin.axvline(event.end.utc.datetime, c="k", ls="--", alpha=0.5) 

471 if ax1p5: 

472 ax1p5.axvline(event.begin.utc.datetime, c="k", ls="--", alpha=0.5) 

473 ax1p5.axvline(event.end.utc.datetime, c="k", ls="--", alpha=0.5) 

474 

475 for commandTime, command in commands.items(): 

476 plotTime = getPlotTime(commandTime) 

477 ax1_twin.axvline( 

478 plotTime, c=lineColors[colorCounter % nColors], ls="--", alpha=0.75, label=f"{command}" 

479 ) 

480 # extend lines down across lower plot, but do not re-add label 

481 ax2_twin.axvline(plotTime, c=lineColors[colorCounter % nColors], ls="--", alpha=0.75) 

482 if ax1p5: 

483 ax1p5.axvline(plotTime, c=lineColors[colorCounter % nColors], ls="--", alpha=0.75) 

484 colorCounter += 1 

485 

486 # combine the legends and put inside the plot 

487 handles1a, labels1a = ax1.get_legend_handles_labels() 

488 handles1b, labels1b = ax1_twin.get_legend_handles_labels() 

489 handles2a, labels2a = ax2.get_legend_handles_labels() 

490 handles2b, labels2b = ax2_twin.get_legend_handles_labels() 

491 

492 handles = handles1a + handles1b + handles2a + handles2b 

493 labels = labels1a + labels1b + labels2a + labels2b 

494 # ax2 is "in front" of ax1 because it has the vlines plotted on it, and 

495 # vlines are on ax2 so that they appear at the bottom of the legend, so 

496 # make sure to plot the legend on ax2, otherwise the vlines will go on top 

497 # of the otherwise-opaque legend. 

498 ax1_twin.legend(handles, labels, facecolor="white", framealpha=1) 

499 

500 # Add title with the event name, type etc 

501 dayObsStr = dayObsIntToString(event.dayObs) 

502 title = ( 

503 # top line is the event title, the details go on the line below 

504 f"{dayObsStr} - seqNum {event.seqNum} (version {event.version})" 

505 f"\nDuration = {event.duration:.2f}s" 

506 f" Event type: {event.type.name}" 

507 f" End reason: {event.endReason.name}" 

508 ) 

509 ax1_twin.set_title(title) 

510 return fig 

511 

512 

513def getCommandsDuringEvent( 

514 client, 

515 event, 

516 commands=("raDecTarget"), 

517 prePadding=0, 

518 postPadding=0, 

519 timeFormat="python", 

520 log=None, 

521 doLog=True, 

522): 

523 """Get the commands issued during an event. 

524 

525 Get the times at which the specified commands were issued during the event. 

526 

527 Parameters 

528 ---------- 

529 client : `lsst_efd_client.efd_helper.EfdClient` 

530 The EFD client to use. 

531 event : `lsst.summit.utils.tmaUtils.TMAEvent` 

532 The event to plot. 

533 commands : `list` of `str`, optional 

534 The commands or command aliases to look for. Defaults to 

535 ['raDecTarget']. 

536 prePadding : `float`, optional 

537 The amount of time to pad the event with before the start time, in 

538 seconds. 

539 postPadding : `float`, optional 

540 The amount of time to pad the event with after the end time, in 

541 seconds. 

542 timeFormat : `str`, optional 

543 One of 'pandas' or 'astropy' or 'python'. If 'pandas', the dictionary 

544 keys will be pandas timestamps, if 'astropy' they will be astropy times 

545 and if 'python' they will be python datetimes. 

546 log : `logging.Logger`, optional 

547 The logger to use. If not specified, a new logger will be created if 

548 needed. 

549 doLog : `bool`, optional 

550 Whether to log messages. Defaults to True. 

551 

552 Returns 

553 ------- 

554 commandTimes : `dict` [`time`, `str`] 

555 A dictionary of the times at which the commands where issued. The type 

556 that `time` takes is determined by the format key, and defaults to 

557 python datetime. 

558 """ 

559 commands = list(ensure_iterable(commands)) 

560 fullCommands = [c if c not in COMMAND_ALIASES else COMMAND_ALIASES[c] for c in commands] 

561 del commands # make sure we always use their full names 

562 

563 commandTimes = getCommands( 

564 client, 

565 fullCommands, 

566 begin=event.begin, 

567 end=event.end, 

568 prePadding=prePadding, 

569 postPadding=postPadding, 

570 timeFormat=timeFormat, 

571 ) 

572 

573 if not commandTimes and doLog: 

574 log = logging.getLogger(__name__) 

575 log.info(f"Found no commands in {fullCommands} issued during event {event.seqNum}") 

576 

577 return commandTimes 

578 

579 

580def _initializeTma(tma): 

581 """Helper function to turn a TMA into a valid state for testing. 

582 

583 Do not call directly in normal usage or code, as this just arbitrarily 

584 sets values to make the TMA valid. 

585 

586 Parameters 

587 ---------- 

588 tma : `lsst.summit.utils.tmaUtils.TMAStateMachine` 

589 The TMA state machine model to initialize. 

590 """ 

591 tma._parts["azimuthInPosition"] = False 

592 tma._parts["azimuthMotionState"] = AxisMotionState.STOPPED 

593 tma._parts["azimuthSystemState"] = PowerState.ON 

594 tma._parts["elevationInPosition"] = False 

595 tma._parts["elevationMotionState"] = AxisMotionState.STOPPED 

596 tma._parts["elevationSystemState"] = PowerState.ON 

597 

598 

599@dataclass(kw_only=True, frozen=True) 

600class TMAEvent: 

601 """A movement event for the TMA. 

602 

603 Contains the dayObs on which the event occured, using the standard 

604 observatory definition of the dayObs, and the sequence number of the event, 

605 which is unique for each event on a given dayObs. 

606 

607 The event type can be either 'SLEWING' or 'TRACKING', defined as: 

608 - SLEWING: some part of the TMA is in motion 

609 - TRACKING: both axes are in position and tracking the sky 

610 

611 The end reason can be 'STOPPED', 'TRACKING', 'FAULT', 'SLEWING', or 'OFF'. 

612 - SLEWING: The previous event was a TRACKING event, and one or more of 

613 the TMA components either stopped being in position, or stopped 

614 moving, or went into fault, or was turned off, and hence we are now 

615 only slewing and no longer tracking the sky. 

616 - TRACKING: the TMA started tracking the sky when it wasn't previously. 

617 Usualy this would always be preceded by directly by a SLEWING 

618 event, but this is not strictly true, as the EUI seems to be able 

619 to make the TMA start tracking the sky without slewing first. 

620 - STOPPED: the components of the TMA transitioned to the STOPPED state. 

621 - FAULT: the TMA went into fault. 

622 - OFF: the TMA components were turned off. 

623 

624 Note that this class is not intended to be instantiated directly, but 

625 rather to be returned by the ``TMAEventMaker.getEvents()`` function. 

626 

627 Parameters 

628 ---------- 

629 dayObs : `int` 

630 The dayObs on which the event occured. 

631 seqNum : `int` 

632 The sequence number of the event, 

633 type : `lsst.summit.utils.tmaUtils.TMAState` 

634 The type of the event, either 'SLEWING' or 'TRACKING'. 

635 endReason : `lsst.summit.utils.tmaUtils.TMAState` 

636 The reason the event ended, either 'STOPPED', 'TRACKING', 'FAULT', 

637 'SLEWING', or 'OFF'. 

638 duration : `float` 

639 The duration of the event, in seconds. 

640 begin : `astropy.time.Time` 

641 The time the event began. 

642 end : `astropy.time.Time` 

643 The time the event ended. 

644 blockInfos : `list` of `lsst.summit.utils.tmaUtils.BlockInfo`, or `None` 

645 The block infomation, if any, relating to the event. Could be `None`, 

646 or one or more block informations. 

647 version : `int` 

648 The version of the TMAEvent class. Equality between events is only 

649 valid for a given version of the class. If the class definition 

650 changes, the time ranges can change, and hence the equality between 

651 events is ``False``. 

652 _startRow : `int` 

653 The first row in the merged EFD data which is part of the event. 

654 _endRow : `int` 

655 The last row in the merged EFD data which is part of the event. 

656 """ 

657 

658 dayObs: int 

659 seqNum: int 

660 type: str # can be 'SLEWING', 'TRACKING' 

661 endReason: str # can be 'STOPPED', 'TRACKING', 'FAULT', 'SLEWING', 'OFF' 

662 duration: float # seconds 

663 begin: Time 

664 end: Time 

665 blockInfos: list = field(default_factory=list) 

666 version: int = 0 # update this number any time a code change which could change event definitions is made 

667 _startRow: int 

668 _endRow: int 

669 

670 def __lt__(self, other): 

671 if self.version != other.version: 

672 raise ValueError( 

673 f"Cannot compare TMAEvents with different versions: {self.version} != {other.version}" 

674 ) 

675 if self.dayObs < other.dayObs: 

676 return True 

677 elif self.dayObs == other.dayObs: 

678 return self.seqNum < other.seqNum 

679 return False 

680 

681 def __repr__(self): 

682 return ( 

683 f"TMAEvent(dayObs={self.dayObs}, seqNum={self.seqNum}, type={self.type!r}," 

684 f" endReason={self.endReason!r}, duration={self.duration}, begin={self.begin!r}," 

685 f" end={self.end!r}" 

686 ) 

687 

688 def __hash__(self): 

689 # deliberately don't hash the blockInfos here, as they are not 

690 # a core part of the event itself, and are listy and cause problems 

691 return hash( 

692 ( 

693 self.dayObs, 

694 self.seqNum, 

695 self.type, 

696 self.endReason, 

697 self.duration, 

698 self.begin, 

699 self.end, 

700 self.version, 

701 self._startRow, 

702 self._endRow, 

703 ) 

704 ) 

705 

706 def _ipython_display_(self): 

707 print(self.__str__()) 

708 

709 def __str__(self): 

710 def indent(string): 

711 return "\n" + "\n".join([" " + s for s in string.splitlines()]) 

712 

713 blockInfoStr = "None" 

714 if self.blockInfos is not None: 

715 blockInfoStr = "".join(indent(str(i)) for i in self.blockInfos) 

716 

717 return ( 

718 f"dayObs: {self.dayObs}\n" 

719 f"seqNum: {self.seqNum}\n" 

720 f"type: {self.type.name}\n" 

721 f"endReason: {self.endReason.name}\n" 

722 f"duration: {self.duration}\n" 

723 f"begin: {self.begin!r}\n" 

724 f"end: {self.end!r}\n" 

725 f"blockInfos: {blockInfoStr}" 

726 ) 

727 

728 def associatedWith(self, block=None, blockSeqNum=None, ticket=None, salIndex=None): 

729 """Check whether an event is associated with a set of parameters. 

730 

731 Check if an event is associated with a specific block and/or ticket 

732 and/or salIndex. All specified parameters must match for the function 

733 to return True. If checking if an event is in a block, the blockSeqNum 

734 can also be specified to identify events which related to a given 

735 running the specified block. 

736 

737 Parameters 

738 ---------- 

739 block : `int`, optional 

740 The block number to check for. 

741 blockSeqNum : `int`, optional 

742 The block sequence number to check for, if the block is specified. 

743 ticket : `str`, optional 

744 The ticket number to check for. 

745 salIndex : `int`, optional 

746 The salIndex to check for. 

747 

748 Returns 

749 ------- 

750 relates : `bool` 

751 Whether the event is associated with the specified block, ticket, 

752 and salIndex. 

753 """ 

754 if all([block is None, ticket is None, salIndex is None]): 

755 raise ValueError("Must specify at least one of block, ticket, or salIndex") 

756 

757 if blockSeqNum is not None and block is None: 

758 raise ValueError("block must be specified if blockSeqNum is specified") 

759 

760 for blockInfo in self.blockInfos: 

761 # "X is None or" is used for each parameter to allow it to be None 

762 # in the kwargs 

763 blockMatches = False 

764 if block is not None: 

765 if blockSeqNum is None and blockInfo.blockNumber == block: 

766 blockMatches = True 

767 elif ( 

768 blockSeqNum is not None 

769 and blockInfo.blockNumber == block 

770 and blockInfo.seqNum == blockSeqNum 

771 ): 

772 blockMatches = True 

773 else: 

774 blockMatches = True # no block specified at all, so it matches 

775 

776 salIndexMatches = salIndex is None or salIndex in blockInfo.salIndices 

777 ticketMatches = ticket is None or ticket in blockInfo.tickets 

778 

779 if blockMatches and salIndexMatches and ticketMatches: 

780 return True 

781 

782 return False 

783 

784 

785class TMAState(enum.IntEnum): 

786 """Overall state of the TMA. 

787 

788 States are defined as follows: 

789 

790 UNINITIALIZED 

791 We have not yet got data for all relevant components, so the overall 

792 state is undefined. 

793 STOPPED 

794 All components are on, and none are moving. 

795 TRACKING 

796 We are tracking the sky. 

797 SLEWING 

798 One or more components are moving, and one or more are not tracking the 

799 sky. This should probably be called MOVING, as it includes: slewing, 

800 MOVING_POINT_TO_POINT, and JOGGING. 

801 FAULT 

802 All (if engineeringMode) or any (if not engineeringMode) components are 

803 in fault. 

804 OFF 

805 All components are off. 

806 """ 

807 

808 UNINITIALIZED = -1 

809 STOPPED = 0 

810 TRACKING = 1 

811 SLEWING = 2 

812 FAULT = 3 

813 OFF = 4 

814 

815 def __repr__(self): 

816 return f"TMAState.{self.name}" 

817 

818 

819def getAxisAndType(rowFor): 

820 """Get the axis the data relates to, and the type of data it contains. 

821 

822 Parameters 

823 ---------- 

824 rowFor : `str` 

825 The column in the dataframe denoting what this row is for, e.g. 

826 "elevationMotionState" or "azimuthInPosition", etc. 

827 

828 Returns 

829 ------- 

830 axis : `str` 

831 The axis the row is for, e.g. "azimuth", "elevation". 

832 rowType : `str` 

833 The type of the row, e.g. "MotionState", "SystemState", "InPosition". 

834 """ 

835 regex = r"(azimuth|elevation)(InPosition|MotionState|SystemState)$" # matches the end of the line 

836 matches = re.search(regex, rowFor) 

837 if matches is None: 

838 raise ValueError(f"Could not parse axis and rowType from {rowFor=}") 

839 axis = matches.group(1) 

840 rowType = matches.group(2) 

841 

842 assert rowFor.endswith(f"{axis}{rowType}") 

843 return axis, rowType 

844 

845 

846class ListViewOfDict: 

847 """A class to allow making lists which contain references to an underlying 

848 dictionary. 

849 

850 Normally, making a list of items from a dictionary would make a copy of the 

851 items, but this class allows making a list which contains references to the 

852 underlying dictionary items themselves. This is useful for making a list of 

853 components, such that they can be manipulated in their logical sets. 

854 """ 

855 

856 def __init__(self, underlyingDictionary, keysToLink): 

857 self.dictionary = underlyingDictionary 

858 self.keys = keysToLink 

859 

860 def __getitem__(self, index): 

861 return self.dictionary[self.keys[index]] 

862 

863 def __setitem__(self, index, value): 

864 self.dictionary[self.keys[index]] = value 

865 

866 def __len__(self): 

867 return len(self.keys) 

868 

869 

870class TMAStateMachine: 

871 """A state machine model of the TMA. 

872 

873 Note that this is currently only implemented for the azimuth and elevation 

874 axes, but will be extended to include the rotator in the future. 

875 

876 Note that when used for event generation, changing ``engineeringMode`` to 

877 False might change the resulting list of events, and that if the TMA moves 

878 with some axis in fault, then these events will be missed. It is therefore 

879 thought that ``engineeringMode=True`` should always be used when generating 

880 events. The option, however, is there for completeness, as this will be 

881 useful for knowing is the CSC would consider the TMA to be in fault in the 

882 general case. 

883 

884 Parameters 

885 ---------- 

886 engineeringMode : `bool`, optional 

887 Whether the TMA is in engineering mode. Defaults to True. If False, 

888 then the TMA will be in fault if any component is in fault. If True, 

889 then the TMA will be in fault only if all components are in fault. 

890 debug : `bool`, optional 

891 Whether to log debug messages. Defaults to False. 

892 """ 

893 

894 _UNINITIALIZED_VALUE: int = -999 

895 

896 def __init__(self, engineeringMode=True, debug=False): 

897 self.engineeringMode = engineeringMode 

898 self.log = logging.getLogger("lsst.summit.utils.tmaUtils.TMA") 

899 if debug: 

900 self.log.level = logging.DEBUG 

901 self._mostRecentRowTime = -1 

902 

903 # the actual components of the TMA 

904 self._parts = { 

905 "azimuthInPosition": self._UNINITIALIZED_VALUE, 

906 "azimuthMotionState": self._UNINITIALIZED_VALUE, 

907 "azimuthSystemState": self._UNINITIALIZED_VALUE, 

908 "elevationInPosition": self._UNINITIALIZED_VALUE, 

909 "elevationMotionState": self._UNINITIALIZED_VALUE, 

910 "elevationSystemState": self._UNINITIALIZED_VALUE, 

911 } 

912 systemKeys = ["azimuthSystemState", "elevationSystemState"] 

913 positionKeys = ["azimuthInPosition", "elevationInPosition"] 

914 motionKeys = ["azimuthMotionState", "elevationMotionState"] 

915 

916 # references to the _parts as conceptual groupings 

917 self.system = ListViewOfDict(self._parts, systemKeys) 

918 self.motion = ListViewOfDict(self._parts, motionKeys) 

919 self.inPosition = ListViewOfDict(self._parts, positionKeys) 

920 

921 # tuples of states for state collapsing. Note that STOP_LIKE + 

922 # MOVING_LIKE must cover the full set of AxisMotionState enums 

923 self.STOP_LIKE = (AxisMotionState.STOPPING, AxisMotionState.STOPPED, AxisMotionState.TRACKING_PAUSED) 

924 self.MOVING_LIKE = ( 

925 AxisMotionState.MOVING_POINT_TO_POINT, 

926 AxisMotionState.JOGGING, 

927 AxisMotionState.TRACKING, 

928 ) 

929 # Likewise, ON_LIKE + OFF_LIKE must cover the full set of PowerState 

930 # enums 

931 self.OFF_LIKE = (PowerState.OFF, PowerState.TURNING_OFF) 

932 self.ON_LIKE = (PowerState.ON, PowerState.TURNING_ON) 

933 self.FAULT_LIKE = (PowerState.FAULT,) # note the trailing comma - this must be an iterable 

934 

935 def apply(self, row): 

936 """Apply a row of data to the TMA state. 

937 

938 Checks that the row contains data for a later time than any data 

939 previously applied, and applies the relevant column entry to the 

940 relevant component. 

941 

942 Parameters 

943 ---------- 

944 row : `pd.Series` 

945 The row of data to apply to the state machine. 

946 """ 

947 timestamp = row["private_efdStamp"] 

948 if timestamp < self._mostRecentRowTime: # NB equals is OK, technically, though it never happens 

949 raise ValueError( 

950 "TMA evolution must be monotonic increasing in time, tried to apply a row which" 

951 " predates the most previous one" 

952 ) 

953 self._mostRecentRowTime = timestamp 

954 

955 rowFor = row["rowFor"] # e.g. elevationMotionState 

956 axis, rowType = getAxisAndType(rowFor) # e.g. elevation, MotionState 

957 value = self._getRowPayload(row, rowType, rowFor) 

958 self.log.debug(f"Setting {rowFor} to {repr(value)}") 

959 self._parts[rowFor] = value 

960 try: 

961 # touch the state property as this executes the sieving, to make 

962 # sure we don't fall through the sieve at any point in time 

963 _ = self.state 

964 except RuntimeError as e: 

965 # improve error reporting, but always reraise this, as this is a 

966 # full-blown failure 

967 raise RuntimeError(f"Failed to apply {value} to {axis}{rowType} with state {self._parts}") from e 

968 

969 def _getRowPayload(self, row, rowType, rowFor): 

970 """Get the relevant value from the row. 

971 

972 Given the row, and which component it relates to, get the relevant 

973 value, as a bool or cast to the appropriate enum class. 

974 

975 Parameters 

976 ---------- 

977 row : `pd.Series` 

978 The row of data from the dataframe. 

979 rowType : `str` 

980 The type of the row, e.g. "MotionState", "SystemState", 

981 "InPosition". 

982 rowFor : `str` 

983 The component the row is for, e.g. "azimuth", "elevation". 

984 

985 Returns 

986 ------- 

987 value : `bool` or `enum` 

988 The value of the row, as a bool or enum, depending on the 

989 component, cast to the appropriate enum class or bool. 

990 """ 

991 match rowType: 

992 case "MotionState": 

993 value = row[f"state_{rowFor}"] 

994 return AxisMotionState(value) 

995 case "SystemState": 

996 value = row[f"powerState_{rowFor}"] 

997 return PowerState(value) 

998 case "InPosition": 

999 value = row[f"inPosition_{rowFor}"] 

1000 return bool(value) 

1001 case _: 

1002 raise ValueError(f"Failed to get row payload with {rowType=} and {row=}") 

1003 

1004 @property 

1005 def _isValid(self): 

1006 """Has the TMA had a value applied to all its components? 

1007 

1008 If any component has not yet had a value applied, the TMA is not valid, 

1009 as those components will be in an unknown state. 

1010 

1011 Returns 

1012 ------- 

1013 isValid : `bool` 

1014 Whether the TMA is fully initialized. 

1015 """ 

1016 return not any([v == self._UNINITIALIZED_VALUE for v in self._parts.values()]) 

1017 

1018 # state inspection properties - a high level way of inspecting the state as 

1019 # an API 

1020 @property 

1021 def isMoving(self): 

1022 return self.state in [TMAState.TRACKING, TMAState.SLEWING] 

1023 

1024 @property 

1025 def isNotMoving(self): 

1026 return not self.isMoving 

1027 

1028 @property 

1029 def isTracking(self): 

1030 return self.state == TMAState.TRACKING 

1031 

1032 @property 

1033 def isSlewing(self): 

1034 return self.state == TMAState.SLEWING 

1035 

1036 @property 

1037 def canMove(self): 

1038 badStates = [PowerState.OFF, PowerState.TURNING_OFF, PowerState.FAULT, PowerState.UNKNOWN] 

1039 return bool( 

1040 self._isValid 

1041 and self._parts["azimuthSystemState"] not in badStates 

1042 and self._parts["elevationSystemState"] not in badStates 

1043 ) 

1044 

1045 # Axis inspection properties, designed for internal use. These return 

1046 # iterables so that they can be used in any() and all() calls, which make 

1047 # the logic much easier to read, e.g. to see if anything is moving, we can 

1048 # write `if not any(_axisInMotion):` 

1049 @property 

1050 def _axesInFault(self): 

1051 return [x in self.FAULT_LIKE for x in self.system] 

1052 

1053 @property 

1054 def _axesOff(self): 

1055 return [x in self.OFF_LIKE for x in self.system] 

1056 

1057 @property 

1058 def _axesOn(self): 

1059 return [not x for x in self._axesOn] 

1060 

1061 @property 

1062 def _axesInMotion(self): 

1063 return [x in self.MOVING_LIKE for x in self.motion] 

1064 

1065 @property 

1066 def _axesTRACKING(self): 

1067 """Note this is deliberately named _axesTRACKING and not _axesTracking 

1068 to make it clear that this is the AxisMotionState type of TRACKING and 

1069 not the normal conceptual notion of tracking (the sky, i.e. as opposed 

1070 to slewing). 

1071 """ 

1072 return [x == AxisMotionState.TRACKING for x in self.motion] 

1073 

1074 @property 

1075 def _axesInPosition(self): 

1076 return [x is True for x in self.inPosition] 

1077 

1078 @property 

1079 def state(self): 

1080 """The overall state of the TMA. 

1081 

1082 Note that this is both a property, and also the method which applies 

1083 the logic sieve to determine the state at a given point in time. 

1084 

1085 Returns 

1086 ------- 

1087 state : `lsst.summit.utils.tmaUtils.TMAState` 

1088 The overall state of the TMA. 

1089 """ 

1090 # first, check we're valid, and if not, return UNINITIALIZED state, as 

1091 # things are unknown 

1092 if not self._isValid: 

1093 return TMAState.UNINITIALIZED 

1094 

1095 # if we're not in engineering mode, i.e. we're under normal CSC 

1096 # control, then if anything is in fault, we're in fault. If we're 

1097 # engineering then some axes will move when others are in fault 

1098 if not self.engineeringMode: 

1099 if any(self._axesInFault): 

1100 return TMAState.FAULT 

1101 else: 

1102 # we're in engineering mode, so return fault state if ALL are in 

1103 # fault 

1104 if all(self._axesInFault): 

1105 return TMAState.FAULT 

1106 

1107 # if all axes are off, the TMA is OFF 

1108 if all(self._axesOff): 

1109 return TMAState.OFF 

1110 

1111 # we know we're valid and at least some axes are not off, so see if 

1112 # we're in motion if no axes are moving, we're stopped 

1113 if not any(self._axesInMotion): 

1114 return TMAState.STOPPED 

1115 

1116 # now we know we're initialized, and that at least one axis is moving 

1117 # so check axes for motion and in position. If all axes are tracking 

1118 # and all are in position, we're tracking the sky 

1119 if all(self._axesTRACKING) and all(self._axesInPosition): 

1120 return TMAState.TRACKING 

1121 

1122 # we now know explicitly that not everything is in position, so we no 

1123 # longer need to check that. We do actually know that something is in 

1124 # motion, but confirm that's the case and return SLEWING 

1125 if any(self._axesInMotion): 

1126 return TMAState.SLEWING 

1127 

1128 # if we want to differentiate between MOVING_POINT_TO_POINT moves, 

1129 # JOGGING moves and regular slews, the logic in the step above needs to 

1130 # be changed and the new steps added here. 

1131 

1132 raise RuntimeError("State error: fell through the state sieve - rewrite your logic!") 

1133 

1134 

1135class TMAEventMaker: 

1136 """A class to create per-dayObs TMAEvents for the TMA's movements. 

1137 

1138 If this class is being used in tests, make sure to pass the EFD client in, 

1139 and create it with `makeEfdClient(testing=True)`. This ensures that the 

1140 USDF EFD is "used" as this is the EFD which has the recorded data available 

1141 in the test suite via `vcr`. 

1142 

1143 Example usage: 

1144 >>> dayObs = 20230630 

1145 >>> eventMaker = TMAEventMaker() 

1146 >>> events = eventMaker.getEvents(dayObs) 

1147 >>> print(f'Found {len(events)} for {dayObs=}') 

1148 

1149 Parameters 

1150 ---------- 

1151 client : `lsst_efd_client.efd_helper.EfdClient`, optional 

1152 The EFD client to use, created if not provided. 

1153 """ 

1154 

1155 # the topics which need logical combination to determine the overall mount 

1156 # state. Will need updating as new components are added to the system. 

1157 

1158 # relevant column: 'state' 

1159 _movingComponents = [ 

1160 "lsst.sal.MTMount.logevent_azimuthMotionState", 

1161 "lsst.sal.MTMount.logevent_elevationMotionState", 

1162 ] 

1163 

1164 # relevant column: 'inPosition' 

1165 _inPositionComponents = [ 

1166 "lsst.sal.MTMount.logevent_azimuthInPosition", 

1167 "lsst.sal.MTMount.logevent_elevationInPosition", 

1168 ] 

1169 

1170 # the components which, if in fault, put the TMA into fault 

1171 # relevant column: 'powerState' 

1172 _stateComponents = [ 

1173 "lsst.sal.MTMount.logevent_azimuthSystemState", 

1174 "lsst.sal.MTMount.logevent_elevationSystemState", 

1175 ] 

1176 

1177 def __init__(self, client=None): 

1178 if client is not None: 

1179 self.client = client 

1180 else: 

1181 self.client = makeEfdClient() 

1182 self.log = logging.getLogger(__name__) 

1183 self._data = {} 

1184 

1185 @dataclass(frozen=True) 

1186 class ParsedState: 

1187 eventStart: Time 

1188 eventEnd: int 

1189 previousState: TMAState 

1190 state: TMAState 

1191 

1192 @staticmethod 

1193 def isToday(dayObs): 

1194 """Find out if the specified dayObs is today, or in the past. 

1195 

1196 If the day is today, the function returns ``True``, if it is in the 

1197 past it returns ``False``. If the day is in the future, a 

1198 ``ValueError`` is raised, as this indicates there is likely an 

1199 off-by-one type error somewhere in the logic. 

1200 

1201 Parameters 

1202 ---------- 

1203 dayObs : `int` 

1204 The dayObs to check, in the format YYYYMMDD. 

1205 

1206 Returns 

1207 ------- 

1208 isToday : `bool` 

1209 ``True`` if the dayObs is today, ``False`` if it is in the past. 

1210 

1211 Raises 

1212 ValueError: if the dayObs is in the future. 

1213 """ 

1214 todayDayObs = getCurrentDayObs_int() 

1215 if dayObs == todayDayObs: 

1216 return True 

1217 if dayObs > todayDayObs: 

1218 raise ValueError("dayObs is in the future") 

1219 return False 

1220 

1221 @staticmethod 

1222 def _shortName(topic): 

1223 """Get the short name of a topic. 

1224 

1225 Parameters 

1226 ---------- 

1227 topic : `str` 

1228 The topic to get the short name of. 

1229 

1230 Returns 

1231 ------- 

1232 shortName : `str` 

1233 The short name of the topic, e.g. 'azimuthInPosition' 

1234 """ 

1235 # get, for example 'azimuthInPosition' from 

1236 # lsst.sal.MTMount.logevent_azimuthInPosition 

1237 return topic.split("_")[-1] 

1238 

1239 def _mergeData(self, data): 

1240 """Merge a dict of dataframes based on private_efdStamp, recording 

1241 where each row came from. 

1242 

1243 Given a dict or dataframes, keyed by topic, merge them into a single 

1244 dataframe, adding a column to record which topic each row came from. 

1245 

1246 Parameters 

1247 ---------- 

1248 data : `dict` of `str` : `pd.DataFrame` 

1249 The dataframes to merge. 

1250 

1251 Returns 

1252 ------- 

1253 merged : `pd.DataFrame` 

1254 The merged dataframe. 

1255 """ 

1256 excludeColumns = ["private_efdStamp", "rowFor"] 

1257 

1258 mergeArgs = { 

1259 "how": "outer", 

1260 "sort": True, 

1261 } 

1262 

1263 merged = None 

1264 originalRowCounter = 0 

1265 

1266 # Iterate over the keys and merge the corresponding DataFrames 

1267 for key, df in data.items(): 

1268 if df.empty: 

1269 # Must skip the df if it's empty, otherwise the merge will fail 

1270 # due to lack of private_efdStamp. Because other axes might 

1271 # still be in motion, so we still want to merge what we have 

1272 continue 

1273 

1274 originalRowCounter += len(df) 

1275 component = self._shortName(key) # Add suffix to column names to identify the source 

1276 suffix = "_" + component 

1277 

1278 df["rowFor"] = component 

1279 

1280 columnsToSuffix = [col for col in df.columns if col not in excludeColumns] 

1281 df_to_suffix = df[columnsToSuffix].add_suffix(suffix) 

1282 df = pd.concat([df[excludeColumns], df_to_suffix], axis=1) 

1283 

1284 if merged is None: 

1285 merged = df.copy() 

1286 else: 

1287 merged = pd.merge(merged, df, **mergeArgs) 

1288 

1289 merged = merged.loc[:, ~merged.columns.duplicated()] # Remove duplicate columns after merge 

1290 

1291 if len(merged) != originalRowCounter: 

1292 self.log.warning( 

1293 "Merged data has a different number of rows to the original data, some" 

1294 " timestamps (rows) will contain more than one piece of actual information." 

1295 ) 

1296 

1297 # if the index is still a DatetimeIndex here then we didn't actually 

1298 # merge any data, so there is only data from a single component. 

1299 # This is likely to result in no events, but not necessarily, and for 

1300 # generality, instead we convert to a range index to ensure consistency 

1301 # in the returned data, and allow processing to continue. 

1302 if isinstance(merged.index, pd.DatetimeIndex): 

1303 self.log.warning("Data was only found for a single component in the EFD.") 

1304 merged.reset_index(drop=True, inplace=True) 

1305 

1306 return merged 

1307 

1308 def getEvent(self, dayObs, seqNum): 

1309 """Get a specific event for a given dayObs and seqNum. 

1310 

1311 Repeated calls for the same ``dayObs`` will use the cached data if the 

1312 day is in the past, and so will be much quicker. If the ``dayObs`` is 

1313 the current day then the EFD will be queried for new data for each 

1314 call, so a call which returns ``None`` on the first try might return an 

1315 event on the next, if the TMA is still moving and thus generating 

1316 events. 

1317 

1318 Parameters 

1319 ---------- 

1320 dayObs : `int` 

1321 The dayObs to get the event for. 

1322 seqNum : `int` 

1323 The sequence number of the event to get. 

1324 

1325 Returns 

1326 ------- 

1327 event : `lsst.summit.utils.tmaUtils.TMAEvent` 

1328 The event for the specified dayObs and seqNum, or `None` if the 

1329 event was not found. 

1330 """ 

1331 events = self.getEvents(dayObs) 

1332 if seqNum <= len(events): 

1333 event = events[seqNum] 

1334 if event.seqNum != seqNum: 

1335 # it's zero-indexed and contiguous so this must be true but 

1336 # a sanity check doesn't hurt. 

1337 raise AssertionError(f"Event sequence number mismatch: {event.seqNum} != {seqNum}") 

1338 return event 

1339 else: 

1340 self.log.warning(f"Event {seqNum} not found for {dayObs}") 

1341 return None 

1342 

1343 def getEvents(self, dayObs, addBlockInfo=True): 

1344 """Get the TMA events for the specified dayObs. 

1345 

1346 Gets the required mount data from the cache or the EFD as required, 

1347 handling whether we're working with live vs historical data. The 

1348 dataframes from the EFD is merged and applied to the TMAStateMachine, 

1349 and that series of state changes is used to generate a list of 

1350 TmaEvents for the day's data. 

1351 

1352 If the data is for the current day, i.e. if new events can potentially 

1353 land, then if the last event is "open" (meaning that the TMA appears to 

1354 be in motion and thus the event is growing with time), then that event 

1355 is excluded from the event list as it is expected to be changing with 

1356 time, and will likely close eventually. However, if that situation 

1357 occurs on a day in the past, then that event can never close, and the 

1358 event is therefore included, but a warning about the open event is 

1359 logged. 

1360 

1361 Parameters 

1362 ---------- 

1363 dayObs : `int` 

1364 The dayObs for which to get the events. 

1365 addBlockInfo : `bool`, optional 

1366 Whether to add block information to the events. This allows 

1367 skipping this step for speed when generating events for purposes 

1368 which don't need block information. 

1369 

1370 Returns 

1371 ------- 

1372 events : `list` of `lsst.summit.utils.tmaUtils.TMAState` 

1373 The events for the specified dayObs. 

1374 """ 

1375 workingLive = self.isToday(dayObs) 

1376 data = None 

1377 

1378 if workingLive: 

1379 # it's potentially updating data, so we must update the date 

1380 # regarless of whether we have it already or not 

1381 self.log.info(f"Updating mount data for {dayObs} from the EFD") 

1382 self._getEfdDataForDayObs(dayObs) 

1383 data = self._data[dayObs] 

1384 elif dayObs in self._data: 

1385 # data is in the cache and it's not being updated, so use it 

1386 data = self._data[dayObs] 

1387 elif dayObs not in self._data: 

1388 # we don't have the data yet, but it's not growing, so put it in 

1389 # the cache and use it from there 

1390 self.log.info(f"Retrieving mount data for {dayObs} from the EFD") 

1391 self._getEfdDataForDayObs(dayObs) 

1392 data = self._data[dayObs] 

1393 else: 

1394 raise RuntimeError("This should never happen") 

1395 

1396 # if we don't have something to work with, log a warning and return 

1397 if not self.dataFound(data): 

1398 self.log.warning(f"No EFD data found for {dayObs=}") 

1399 return [] 

1400 

1401 # applies the data to the state machine, and generates events from the 

1402 # series of states which results 

1403 events = self._calculateEventsFromMergedData( 

1404 data, dayObs, dataIsForCurrentDay=workingLive, addBlockInfo=addBlockInfo 

1405 ) 

1406 if not events: 

1407 self.log.warning(f"Failed to calculate any events for {dayObs=} despite EFD data existing!") 

1408 return events 

1409 

1410 @staticmethod 

1411 def dataFound(data): 

1412 """Check if any data was found. 

1413 

1414 Parameters 

1415 ---------- 

1416 data : `pd.DataFrame` 

1417 The merged dataframe to check. 

1418 

1419 Returns 

1420 ------- 

1421 dataFound : `bool` 

1422 Whether data was found. 

1423 """ 

1424 # You can't just compare to with data == NO_DATA_SENTINEL because 

1425 # `data` is usually a dataframe, and you can't compare a dataframe to a 

1426 # string directly. 

1427 return not (isinstance(data, str) and data == NO_DATA_SENTINEL) 

1428 

1429 def _getEfdDataForDayObs(self, dayObs): 

1430 """Get the EFD data for the specified dayObs and store it in the cache. 

1431 

1432 Gets the EFD data for all components, as a dict of dataframes keyed by 

1433 component name. These are then merged into a single dataframe in time 

1434 order, based on each row's `private_efdStamp`. This is then stored in 

1435 self._data[dayObs]. 

1436 

1437 If no data is found, the value is set to ``NO_DATA_SENTINEL`` to 

1438 differentiate this from ``None``, as this is what you'd get if you 

1439 queried the cache with `self._data.get(dayObs)`. It also marks that we 

1440 have already queried this day. 

1441 

1442 Parameters 

1443 ---------- 

1444 dayObs : `int` 

1445 The dayObs to query. 

1446 """ 

1447 data = {} 

1448 for component in itertools.chain( 

1449 self._movingComponents, self._inPositionComponents, self._stateComponents 

1450 ): 

1451 data[component] = getEfdData(self.client, component, dayObs=dayObs, warn=False) 

1452 self.log.debug(f"Found {len(data[component])} for {component}") 

1453 

1454 if all(dataframe.empty for dataframe in data.values()): 

1455 # if every single dataframe is empty, set the sentinel and don't 

1456 # try to merge anything, otherwise merge all the data we found 

1457 self.log.debug(f"No data found for {dayObs=}") 

1458 # a sentinel value that's not None 

1459 self._data[dayObs] = NO_DATA_SENTINEL 

1460 else: 

1461 merged = self._mergeData(data) 

1462 self._data[dayObs] = merged 

1463 

1464 def _calculateEventsFromMergedData(self, data, dayObs, dataIsForCurrentDay, addBlockInfo): 

1465 """Calculate the list of events from the merged data. 

1466 

1467 Runs the merged data, row by row, through the TMA state machine (with 

1468 ``tma.apply``) to get the overall TMA state at each row, building a 

1469 dict of these states, keyed by row number. 

1470 

1471 This time-series of TMA states are then looped over (in 

1472 `_statesToEventTuples`), building a list of tuples representing the 

1473 start and end of each event, the type of the event, and the reason for 

1474 the event ending. 

1475 

1476 This list of tuples is then passed to ``_makeEventsFromStateTuples``, 

1477 which actually creates the ``TMAEvent`` objects. 

1478 

1479 Parameters 

1480 ---------- 

1481 data : `pd.DataFrame` 

1482 The merged dataframe to use. 

1483 dayObs : `int` 

1484 The dayObs for the data. 

1485 dataIsForCurrentDay : `bool` 

1486 Whether the data is for the current day. Determines whether to 

1487 allow an open last event or not. 

1488 addBlockInfo : `bool` 

1489 Whether to add block information to the events. This allows 

1490 skipping this step for speed when generating events for purposes 

1491 which don't need block information. 

1492 

1493 Returns 

1494 ------- 

1495 events : `list` of `lsst.summit.utils.tmaUtils.TMAEvent` 

1496 The events for the specified dayObs. 

1497 """ 

1498 engineeringMode = True 

1499 tma = TMAStateMachine(engineeringMode=engineeringMode) 

1500 

1501 # For now, we assume that the TMA starts each day able to move, but 

1502 # stationary. If this turns out to cause problems, we will need to 

1503 # change to loading data from the previous day(s), and looking back 

1504 # through it in time until a state change has been found for every 

1505 # axis. For now though, Bruno et. al think this is acceptable and 

1506 # preferable. 

1507 _initializeTma(tma) 

1508 

1509 tmaStates = {} 

1510 for rowNum, row in data.iterrows(): 

1511 tma.apply(row) 

1512 tmaStates[rowNum] = tma.state 

1513 

1514 stateTuples = self._statesToEventTuples(tmaStates, dataIsForCurrentDay) 

1515 events = self._makeEventsFromStateTuples(stateTuples, dayObs, data) 

1516 if addBlockInfo: 

1517 self.addBlockDataToEvents(dayObs, events) 

1518 return events 

1519 

1520 def _statesToEventTuples(self, states, dataIsForCurrentDay): 

1521 """Get the event-tuples from the dictionary of TMAStates. 

1522 

1523 Chunks the states into blocks of the same state, so that we can create 

1524 an event for each block in `_makeEventsFromStateTuples`. Off-type 

1525 states are skipped over, with each event starting when the telescope 

1526 next resumes motion or changes to a different type of motion state, 

1527 i.e. from non-tracking type movement (MOVE_POINT_TO_POINT, JOGGING, 

1528 TRACKING-but-not-in-position, i.e. slewing) to a tracking type 

1529 movement, or vice versa. 

1530 

1531 Parameters 

1532 ---------- 

1533 states : `dict` of `int` : `lsst.summit.utils.tmaUtils.TMAState` 

1534 The states of the TMA, keyed by row number. 

1535 dataIsForCurrentDay : `bool` 

1536 Whether the data is for the current day. Determines whether to 

1537 allow and open last event or not. 

1538 

1539 Returns 

1540 ------- 

1541 parsedStates : `list` of `tuple` 

1542 The parsed states, as a list of tuples of the form: 

1543 ``(eventStart, eventEnd, eventType, endReason)`` 

1544 """ 

1545 # Consider rewriting this with states as a list and using pop(0)? 

1546 skipStates = (TMAState.STOPPED, TMAState.OFF, TMAState.FAULT) 

1547 

1548 parsedStates = [] 

1549 eventStart = None 

1550 rowNum = 0 

1551 nRows = len(states) 

1552 while rowNum < nRows: 

1553 previousState = None 

1554 state = states[rowNum] 

1555 # if we're not in an event, fast forward through off-like rows 

1556 # until a new event starts 

1557 if eventStart is None and state in skipStates: 

1558 rowNum += 1 

1559 continue 

1560 

1561 # we've started a new event, so walk through it and find the end 

1562 eventStart = rowNum 

1563 previousState = state 

1564 rowNum += 1 # move to the next row before starting the while loop 

1565 if rowNum == nRows: 

1566 # we've reached the end of the data, and we're still in an 

1567 # event, so don't return this presumably in-progress event 

1568 self.log.warning("Reached the end of the data while starting a new event") 

1569 break 

1570 state = states[rowNum] 

1571 while state == previousState: 

1572 rowNum += 1 

1573 if rowNum == nRows: 

1574 break 

1575 state = states[rowNum] 

1576 parsedStates.append( 

1577 self.ParsedState( 

1578 eventStart=eventStart, eventEnd=rowNum, previousState=previousState, state=state 

1579 ) 

1580 ) 

1581 if state in skipStates: 

1582 eventStart = None 

1583 

1584 # done parsing, just check the last event is valid 

1585 if parsedStates: # ensure we have at least one event 

1586 lastEvent = parsedStates[-1] 

1587 if lastEvent.eventEnd == nRows: 

1588 # Generally, you *want* the timespan for an event to be the 

1589 # first row of the next event, because you were in that state 

1590 # right up until that state change. However, if that event is 

1591 # a) the last one of the day and b) runs right up until the end 

1592 # of the dataframe, then there isn't another row, so this will 

1593 # overrun the array. 

1594 # 

1595 # If the data is for the current day then this isn't a worry, 

1596 # as we're likely still taking data, and this event will likely 

1597 # close yet, so we don't issue a warning, and simply drop the 

1598 # event from the list. 

1599 

1600 # However, if the data is for a past day then no new data will 

1601 # come to close the event, so allow the event to be "open", and 

1602 # issue a warning 

1603 if dataIsForCurrentDay: 

1604 self.log.info("Discarding open (likely in-progess) final event from current day's events") 

1605 parsedStates = parsedStates[:-1] 

1606 else: 

1607 self.log.warning("Last event ends open, forcing it to end at end of the day's data") 

1608 # it's a tuple, so (deliberately) awkward to modify 

1609 parsedStates[-1] = self.ParsedState( 

1610 eventStart=lastEvent.eventStart, 

1611 eventEnd=lastEvent.eventEnd - 1, 

1612 previousState=lastEvent.previousState, 

1613 state=lastEvent.state, 

1614 ) 

1615 

1616 return parsedStates 

1617 

1618 def addBlockDataToEvents(self, dayObs, events): 

1619 """Find all the block data in the EFD for the specified events. 

1620 

1621 Finds all the block data in the EFD relating to the events, parses it, 

1622 from the rows of the dataframe, and adds it to the events in place. 

1623 

1624 Parameters 

1625 ---------- 

1626 events : `lsst.summit.utils.tmaUtils.TMAEvent` or 

1627 `list` of `lsst.summit.utils.tmaUtils.TMAEvent` 

1628 One or more events to get the block data for. 

1629 """ 

1630 try: 

1631 blockParser = BlockParser(dayObs, client=self.client) 

1632 except Exception as e: 

1633 # adding the block data should never cause a failure so if we can't 

1634 # get the block data, log a warning and return. It is, however, 

1635 # never expected, so use log.exception to get the full traceback 

1636 # and scare users so it gets reported 

1637 self.log.exception(f"Failed to parse block data for {dayObs=}, {e}") 

1638 return 

1639 blocks = blockParser.getBlockNums() 

1640 blockDict = {} 

1641 for block in blocks: 

1642 blockDict[block] = blockParser.getSeqNums(block) 

1643 

1644 for block, seqNums in blockDict.items(): 

1645 for seqNum in seqNums: 

1646 blockInfo = blockParser.getBlockInfo(block=block, seqNum=seqNum) 

1647 

1648 relatedEvents = blockParser.getEventsForBlock(events, block=block, seqNum=seqNum) 

1649 for event in relatedEvents: 

1650 toSet = [blockInfo] 

1651 if event.blockInfos is not None: 

1652 existingInfo = event.blockInfos 

1653 existingInfo.append(blockInfo) 

1654 toSet = existingInfo 

1655 

1656 # Add the blockInfo to the TMAEvent. Because this is a 

1657 # frozen dataclass, use object.__setattr__ to set the 

1658 # attribute. This is the correct way to set a frozen 

1659 # dataclass attribute after creation. 

1660 object.__setattr__(event, "blockInfos", toSet) 

1661 

1662 def _makeEventsFromStateTuples(self, states, dayObs, data): 

1663 """For the list of state-tuples, create a list of ``TMAEvent`` objects. 

1664 

1665 Given the underlying data, and the start/stop points for each event, 

1666 create the TMAEvent objects for the dayObs. 

1667 

1668 Parameters 

1669 ---------- 

1670 states : `list` of `tuple` 

1671 The parsed states, as a list of tuples of the form: 

1672 ``(eventStart, eventEnd, eventType, endReason)`` 

1673 dayObs : `int` 

1674 The dayObs for the data. 

1675 data : `pd.DataFrame` 

1676 The merged dataframe. 

1677 

1678 Returns 

1679 ------- 

1680 events : `list` of `lsst.summit.utils.tmaUtils.TMAEvent` 

1681 The events for the specified dayObs. 

1682 """ 

1683 seqNum = 0 

1684 events = [] 

1685 for parsedState in states: 

1686 begin = data.iloc[parsedState.eventStart]["private_efdStamp"] 

1687 end = data.iloc[parsedState.eventEnd]["private_efdStamp"] 

1688 beginAstropy = efdTimestampToAstropy(begin) 

1689 endAstropy = efdTimestampToAstropy(end) 

1690 duration = end - begin 

1691 event = TMAEvent( 

1692 dayObs=dayObs, 

1693 seqNum=seqNum, 

1694 type=parsedState.previousState, 

1695 endReason=parsedState.state, 

1696 duration=duration, 

1697 begin=beginAstropy, 

1698 end=endAstropy, 

1699 blockInfos=[], # this is added later 

1700 _startRow=parsedState.eventStart, 

1701 _endRow=parsedState.eventEnd, 

1702 ) 

1703 events.append(event) 

1704 seqNum += 1 

1705 return events 

1706 

1707 @staticmethod 

1708 def printTmaDetailedState(tma): 

1709 """Print the full state of all the components of the TMA. 

1710 

1711 Currently this is the azimuth and elevation axes' power and motion 

1712 states, and their respective inPosition statuses. 

1713 

1714 Parameters 

1715 ---------- 

1716 tma : `lsst.summit.utils.tmaUtils.TMAStateMachine` 

1717 The TMA state machine in the state we want to print. 

1718 """ 

1719 axes = ["azimuth", "elevation"] 

1720 p = tma._parts 

1721 axisPad = len(max(axes, key=len)) # length of the longest axis string == 9 here, but this is general 

1722 motionPad = max(len(s.name) for s in AxisMotionState) 

1723 powerPad = max(len(s.name) for s in PowerState) 

1724 

1725 # example output to show what's being done with the padding: 

1726 # azimuth - Power: ON Motion: STOPPED InPosition: True # noqa: W505 

1727 # elevation - Power: ON Motion: MOVING_POINT_TO_POINT InPosition: False # noqa: W505 

1728 for axis in axes: 

1729 print( 

1730 f"{axis:>{axisPad}} - " 

1731 f"Power: {p[f'{axis}SystemState'].name:>{powerPad}} " 

1732 f"Motion: {p[f'{axis}MotionState'].name:>{motionPad}} " 

1733 f"InPosition: {p[f'{axis}InPosition']}" 

1734 ) 

1735 print(f"Overall system state: {tma.state.name}") 

1736 

1737 def printFullDayStateEvolution(self, dayObs, taiOrUtc="utc"): 

1738 """Print the full TMA state evolution for the specified dayObs. 

1739 

1740 Replays all the data from the EFD for the specified dayObs through 

1741 the TMA state machine, and prints both the overall and detailed state 

1742 of the TMA for each row. 

1743 

1744 Parameters 

1745 ---------- 

1746 dayObs : `int` 

1747 The dayObs for which to print the state evolution. 

1748 taiOrUtc : `str`, optional 

1749 Whether to print the timestamps in TAI or UTC. Default is UTC. 

1750 """ 

1751 # create a fake event which spans the whole day, and then use 

1752 # printEventDetails code while skipping the header to print the 

1753 # evolution. 

1754 _ = self.getEvents(dayObs) # ensure the data has been retrieved from the EFD 

1755 data = self._data[dayObs] 

1756 lastRowNum = len(data) - 1 

1757 

1758 fakeEvent = TMAEvent( 

1759 dayObs=dayObs, 

1760 seqNum=-1, # anything will do 

1761 type=TMAState.OFF, # anything will do 

1762 endReason=TMAState.OFF, # anything will do 

1763 duration=-1, # anything will do 

1764 begin=efdTimestampToAstropy(data.iloc[0]["private_efdStamp"]), 

1765 end=efdTimestampToAstropy(data.iloc[-1]["private_efdStamp"]), 

1766 _startRow=0, 

1767 _endRow=lastRowNum, 

1768 ) 

1769 self.printEventDetails(fakeEvent, taiOrUtc=taiOrUtc, printHeader=False) 

1770 

1771 def printEventDetails(self, event, taiOrUtc="tai", printHeader=True): 

1772 """Print a detailed breakdown of all state transitions during an event. 

1773 

1774 Note: this is not the most efficient way to do this, but it is much the 

1775 cleanest with respect to the actual state machine application and event 

1776 generation code, and is easily fast enough for the cases it will be 

1777 used for. It is not worth complicating the normal state machine logic 

1778 to try to use this code. 

1779 

1780 Parameters 

1781 ---------- 

1782 event : `lsst.summit.utils.tmaUtils.TMAEvent` 

1783 The event to display the details of. 

1784 taiOrUtc : `str`, optional 

1785 Whether to display time strings in TAI or UTC. Defaults to TAI. 

1786 Case insensitive. 

1787 printHeader : `bool`, optional 

1788 Whether to print the event summary. Defaults to True. The primary 

1789 reason for the existence of this option is so that this same 

1790 printing function can be used to show the evolution of a whole day 

1791 by supplying a fake event which spans the whole day, but this event 

1792 necessarily has a meaningless summary, and so needs suppressing. 

1793 """ 

1794 taiOrUtc = taiOrUtc.lower() 

1795 if taiOrUtc not in ["tai", "utc"]: 

1796 raise ValueError(f"Got unsuppoted value for {taiOrUtc=}") 

1797 useUtc = taiOrUtc == "utc" 

1798 

1799 if printHeader: 

1800 print( 

1801 f"Details for {event.duration:.2f}s {event.type.name} event dayObs={event.dayObs}" 

1802 f" seqNum={event.seqNum}:" 

1803 ) 

1804 print(f"- Event began at: {event.begin.utc.isot if useUtc else event.begin.isot}") 

1805 print(f"- Event ended at: {event.end.utc.isot if useUtc else event.end.isot}") 

1806 

1807 dayObs = event.dayObs 

1808 data = self._data[dayObs] 

1809 startRow = event._startRow 

1810 endRow = event._endRow 

1811 nRowsToApply = endRow - startRow + 1 

1812 print(f"\nTotal number of rows in the merged dataframe: {len(data)}") 

1813 if printHeader: 

1814 print(f"of which rows {startRow} to {endRow} (inclusive) relate to this event.") 

1815 

1816 # reconstruct all the states 

1817 tma = TMAStateMachine(engineeringMode=True) 

1818 _initializeTma(tma) 

1819 

1820 tmaStates = {} 

1821 firstAppliedRow = True # flag to print a header on the first row that's applied 

1822 for rowNum, row in data.iterrows(): # must replay rows right from start to get full correct state 

1823 if rowNum == startRow: 

1824 # we've not yet applied this row, so this is the state just 

1825 # before event 

1826 print(f"\nBefore the event the TMA was in state {tma.state.name}:") 

1827 self.printTmaDetailedState(tma) 

1828 

1829 if rowNum >= startRow and rowNum <= endRow: 

1830 if firstAppliedRow: # only print this intro on the first row we're applying 

1831 print( 

1832 f"\nThen, applying the {nRowsToApply} rows of data for this event, the state" 

1833 " evolved as follows:\n" 

1834 ) 

1835 firstAppliedRow = False 

1836 

1837 # break the row down and print its details 

1838 rowFor = row["rowFor"] 

1839 axis, rowType = getAxisAndType(rowFor) # e.g. elevation, MotionState 

1840 value = tma._getRowPayload(row, rowType, rowFor) 

1841 valueStr = f"{str(value) if isinstance(value, bool) else value.name}" 

1842 rowTime = efdTimestampToAstropy(row["private_efdStamp"]) 

1843 print( 

1844 f"On row {rowNum} the {axis} axis had the {rowType} set to {valueStr} at" 

1845 f" {rowTime.utc.isot if useUtc else rowTime.isot}" 

1846 ) 

1847 

1848 # then apply it as usual, printing the state right afterwards 

1849 tma.apply(row) 

1850 tmaStates[rowNum] = tma.state 

1851 self.printTmaDetailedState(tma) 

1852 print() 

1853 

1854 else: 

1855 # if it's not in the range of interest then just apply it 

1856 # silently as usual 

1857 tma.apply(row) 

1858 tmaStates[rowNum] = tma.state 

1859 

1860 def findEvent(self, time): 

1861 """Find the event which contains the specified time. 

1862 

1863 If the specified time lies within an event, that event is returned. If 

1864 it is at the exact start, that is logged, and if that start point is 

1865 shared by the end of the previous event, that is logged too. If the 

1866 event lies between events, the events either side are logged, but 

1867 ``None`` is returned. If the time lies before the first event of the 

1868 day a warning is logged, as for times after the last event of the day. 

1869 

1870 Parameters 

1871 ---------- 

1872 time : `astropy.time.Time` 

1873 The time. 

1874 

1875 Returns 

1876 ------- 

1877 event : `lsst.summit.utils.tmaUtils.TMAEvent` or `None` 

1878 The event which contains the specified time, or ``None`` if the 

1879 time doesn't fall during an event. 

1880 """ 

1881 # there are five possible cases: 

1882 # 1) the time lies before the first event of the day 

1883 # 2) the time lies after the last event of the day 

1884 # 3) the time lies within an event 

1885 # 3a) the time is exactly at the start of an event 

1886 # 3b) if so, time can be shared by the end of the previous event if 

1887 # they are contiguous 

1888 # 4) the time lies between two events 

1889 # 5) the time is exactly at end of the last event of the day. This is 

1890 # an issue because event end times are exclusive, so this time is 

1891 # not technically in that event, it's the moment it closes (and if 

1892 # there *was* an event which followed contiguously, it would be in 

1893 # that event instead, which is what motivates this definition of 

1894 # lies within what event) 

1895 

1896 dayObs = getDayObsForTime(time) 

1897 # we know this is on the right day, and definitely before the specified 

1898 # time, but sanity check this before continuing as this needs to be 

1899 # true for this to give the correct answer 

1900 assert getDayObsStartTime(dayObs) <= time 

1901 assert getDayObsEndTime(dayObs) > time 

1902 

1903 # command start to many log messages so define once here 

1904 logStart = f"Specified time {time.isot} falls on {dayObs=}" 

1905 

1906 events = self.getEvents(dayObs) 

1907 if len(events) == 0: 

1908 self.log.warning(f"There are no events found for {dayObs}") 

1909 return None 

1910 

1911 # check case 1) 

1912 if time < events[0].begin: 

1913 self.log.warning(f"{logStart} and is before the first event of the day") 

1914 return None 

1915 

1916 # check case 2) 

1917 if time > events[-1].end: 

1918 self.log.warning(f"{logStart} and is after the last event of the day") 

1919 return None 

1920 

1921 # check case 5) 

1922 if time == events[-1].end: 

1923 self.log.warning( 

1924 f"{logStart} and is exactly at the end of the last event of the day" 

1925 f" (seqnum={events[-1].seqNum}). Because event intervals are half-open, this" 

1926 " time does not technically lie in any event" 

1927 ) 

1928 return None 

1929 

1930 # we are now either in an event, or between events. Walk through the 

1931 # events, and if the end of the event is after the specified time, then 

1932 # we're either in it or past it, so check if we're in. 

1933 for eventNum, event in enumerate(events): 

1934 if event.end > time: # case 3) we are now into or past the right event 

1935 # the event end encloses the time, so note the > and not >=, 

1936 # this must be strictly greater, we check the overlap case 

1937 # later 

1938 if time >= event.begin: # we're fully inside the event, so return it. 

1939 # 3a) before returning, check if we're exactly at the start 

1940 # of the event, and if so, log it. Then 3b) also check if 

1941 # we're at the exact end of the previous event, and if so, 

1942 # log that too. 

1943 if time == event.begin: 

1944 self.log.info(f"{logStart} and is exactly at the start of event" f" {eventNum}") 

1945 if eventNum == 0: # I think this is actually impossible, but check anyway 

1946 return event # can't check the previous event so return here 

1947 previousEvent = events[eventNum - 1] 

1948 if previousEvent.end == time: 

1949 self.log.info( 

1950 "Previous event is contiguous, so this time is also at the exact" 

1951 f" end of {eventNum - 1}" 

1952 ) 

1953 return event 

1954 else: # case 4) 

1955 # the event end is past the time, but it's not inside the 

1956 # event, so we're between events. Log which we're between 

1957 # and return None 

1958 previousEvent = events[eventNum - 1] 

1959 timeAfterPrev = (time - previousEvent.end).to_datetime() 

1960 naturalTimeAfterPrev = humanize.naturaldelta(timeAfterPrev, minimum_unit="MICROSECONDS") 

1961 timeBeforeCurrent = (event.begin - time).to_datetime() 

1962 naturalTimeBeforeCurrent = humanize.naturaldelta( 

1963 timeBeforeCurrent, minimum_unit="MICROSECONDS" 

1964 ) 

1965 self.log.info( 

1966 f"{logStart} and lies" 

1967 f" {naturalTimeAfterPrev} after the end of event {previousEvent.seqNum}" 

1968 f" and {naturalTimeBeforeCurrent} before the start of event {event.seqNum}." 

1969 ) 

1970 return None 

1971 

1972 raise RuntimeError( 

1973 "Event finding logic fundamentally failed, which should never happen - the code" " needs fixing" 

1974 )