Coverage for python/lsst/summit/utils/efdUtils.py: 16%

191 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-15 03:28 -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/>. 

21from __future__ import annotations 

22 

23import asyncio 

24import datetime 

25import logging 

26import re 

27from typing import TYPE_CHECKING 

28 

29import astropy 

30import pandas as pd 

31from astropy import units as u 

32from astropy.time import Time, TimeDelta 

33from deprecated.sphinx import deprecated 

34 

35import lsst.daf.butler as dafButler 

36from lsst.utils.iteration import ensure_iterable 

37 

38if TYPE_CHECKING: 38 ↛ 39line 38 didn't jump to line 39, because the condition on line 38 was never true

39 from .tmaUtils import TMAEvent 

40 

41from .utils import getSite 

42 

43HAS_EFD_CLIENT = True 

44try: 

45 from lsst_efd_client import EfdClient 

46except ImportError: 

47 HAS_EFD_CLIENT = False 

48 

49 

50__all__ = [ 

51 "getEfdData", 

52 "getMostRecentRowWithDataBefore", 

53 "makeEfdClient", 

54 "expRecordToTimespan", 

55 "efdTimestampToAstropy", 

56 "astropyToEfdTimestamp", 

57 "clipDataToEvent", 

58 "calcNextDay", 

59 "getDayObsStartTime", 

60 "getDayObsEndTime", 

61 "getDayObsForTime", 

62 "getSubTopics", # deprecated, being removed in w_2023_50 

63 "getTopics", 

64 "getCommands", 

65] 

66 

67 

68COMMAND_ALIASES = { 

69 "raDecTarget": "lsst.sal.MTPtg.command_raDecTarget", 

70 "moveToTarget": "lsst.sal.MTMount.command_moveToTarget", 

71 "startTracking": "lsst.sal.MTMount.command_startTracking", 

72 "stopTracking": "lsst.sal.MTMount.command_stopTracking", 

73 "trackTarget": "lsst.sal.MTMount.command_trackTarget", # issued at 20Hz - don't plot 

74} 

75 

76# When looking backwards in time to find the most recent state event, look back 

77# in chunks of this size. Too small, and there will be too many queries, too 

78# large and there will be too much data returned unnecessarily, as we only need 

79# one row by definition. Will tune this parameters in consultation with SQuaRE. 

80TIME_CHUNKING = datetime.timedelta(minutes=15) 

81 

82 

83def _getBeginEnd( 

84 dayObs: int | None = None, 

85 begin: astropy.time.Time | None = None, 

86 end: astropy.time.Time | None = None, 

87 timespan: astropy.time.TimeDelta | None = None, 

88 event: TMAEvent | None = None, 

89 expRecord: dafButler.DimensionRecord | None = None, 

90) -> tuple[astropy.time.Time, astropy.time.Time]: 

91 """Calculate the begin and end times to pass to _getEfdData, given the 

92 kwargs passed to getEfdData. 

93 

94 Parameters 

95 ---------- 

96 dayObs : `int` 

97 The dayObs to query. If specified, this is used to determine the begin 

98 and end times. 

99 begin : `astropy.Time` 

100 The begin time for the query. If specified, either an end time or a 

101 timespan must be supplied. 

102 end : `astropy.Time` 

103 The end time for the query. If specified, a begin time must also be 

104 supplied. 

105 timespan : `astropy.TimeDelta` 

106 The timespan for the query. If specified, a begin time must also be 

107 supplied. 

108 event : `lsst.summit.utils.efdUtils.TMAEvent` 

109 The event to query. If specified, this is used to determine the begin 

110 and end times, and all other options are disallowed. 

111 expRecord : `lsst.daf.butler.dimensions.DimensionRecord` 

112 The exposure record containing the timespan to query. If specified, all 

113 other options are disallowed. 

114 

115 Returns 

116 ------- 

117 begin : `astropy.Time` 

118 The begin time for the query. 

119 end : `astropy.Time` 

120 The end time for the query. 

121 """ 

122 if expRecord is not None: 

123 forbiddenOpts = [event, begin, end, timespan, dayObs] 

124 if any(x is not None for x in forbiddenOpts): 

125 raise ValueError("You can't specify both an expRecord and a begin/end or timespan or dayObs") 

126 begin = expRecord.timespan.begin 

127 end = expRecord.timespan.end 

128 return begin, end 

129 

130 if event is not None: 

131 forbiddenOpts = [begin, end, timespan, dayObs] 

132 if any(x is not None for x in forbiddenOpts): 

133 raise ValueError("You can't specify both an event and a begin/end or timespan or dayObs") 

134 begin = event.begin 

135 end = event.end 

136 return begin, end 

137 

138 # check for dayObs, and that other options aren't inconsistently specified 

139 if dayObs is not None: 

140 forbiddenOpts = [begin, end, timespan] 

141 if any(x is not None for x in forbiddenOpts): 

142 raise ValueError("You can't specify both a dayObs and a begin/end or timespan") 

143 begin = getDayObsStartTime(dayObs) 

144 end = getDayObsEndTime(dayObs) 

145 return begin, end 

146 # can now disregard dayObs entirely 

147 

148 if begin is None: 

149 raise ValueError("You must specify either a dayObs or a begin/end or begin/timespan") 

150 # can now rely on begin, so just need to deal with end/timespan 

151 

152 if end is None and timespan is None: 

153 raise ValueError("If you specify a begin, you must specify either a end or a timespan") 

154 if end is not None and timespan is not None: 

155 raise ValueError("You can't specify both a end and a timespan") 

156 if end is None: 

157 assert timespan is not None 

158 if timespan > datetime.timedelta(minutes=0): 

159 end = begin + timespan # the normal case 

160 else: 

161 end = begin # the case where timespan is negative 

162 begin = begin + timespan # adding the negative to the start, i.e. subtracting it to bring back 

163 

164 assert begin is not None 

165 assert end is not None 

166 return begin, end 

167 

168 

169def getEfdData( 

170 client: EfdClient, 

171 topic: str, 

172 *, 

173 columns: list[str] | None = None, 

174 prePadding: float = 0, 

175 postPadding: float = 0, 

176 dayObs: int | None = None, 

177 begin: astropy.Time | None = None, 

178 end: astropy.Time | None = None, 

179 timespan: astropy.TimeDelta | None = None, 

180 event: TMAEvent | None = None, 

181 expRecord: dafButler.dimensions.DimensionRecord | None = None, 

182 warn: bool = True, 

183) -> pd.DataFrame: 

184 """Get one or more EFD topics over a time range, synchronously. 

185 

186 The time range can be specified as either: 

187 * a dayObs, in which case the full 24 hour period is used, 

188 * a begin point and a end point, 

189 * a begin point and a timespan. 

190 * a mount event 

191 * an exposure record 

192 If it is desired to use an end time with a timespan, just specify it as the 

193 begin time and use a negative timespan. 

194 

195 The results from all topics are merged into a single dataframe. 

196 

197 Parameters 

198 ---------- 

199 client : `lsst_efd_client.efd_helper.EfdClient` 

200 The EFD client to use. 

201 topic : `str` 

202 The topic to query. 

203 columns : `list` of `str`, optional 

204 The columns to query. If not specified, all columns are queried. 

205 prePadding : `float` 

206 The amount of time before the nominal start of the query to include, in 

207 seconds. 

208 postPadding : `float` 

209 The amount of extra time after the nominal end of the query to include, 

210 in seconds. 

211 dayObs : `int`, optional 

212 The dayObs to query. If specified, this is used to determine the begin 

213 and end times. 

214 begin : `astropy.Time`, optional 

215 The begin time for the query. If specified, either a end time or a 

216 timespan must be supplied. 

217 end : `astropy.Time`, optional 

218 The end time for the query. If specified, a begin time must also be 

219 supplied. 

220 timespan : `astropy.TimeDelta`, optional 

221 The timespan for the query. If specified, a begin time must also be 

222 supplied. 

223 event : `lsst.summit.utils.efdUtils.TMAEvent`, optional 

224 The event to query. If specified, this is used to determine the begin 

225 and end times, and all other options are disallowed. 

226 expRecord : `lsst.daf.butler.dimensions.DimensionRecord`, optional 

227 The exposure record containing the timespan to query. If specified, all 

228 other options are disallowed. 

229 warn : bool, optional 

230 If ``True``, warn when no data is found. Exists so that utility code 

231 can disable warnings when checking for data, and therefore defaults to 

232 ``True``. 

233 

234 Returns 

235 ------- 

236 data : `pd.DataFrame` 

237 The merged data from all topics. 

238 

239 Raises 

240 ------ 

241 ValueError: 

242 If the topics are not in the EFD schema. 

243 ValueError: 

244 If both a dayObs and a begin/end or timespan are specified. 

245 ValueError: 

246 If a begin time is specified but no end time or timespan. 

247 

248 """ 

249 # TODO: DM-40100 ideally should calls mpts as necessary so that users 

250 # needn't care if things are packed 

251 

252 # supports aliases so that you can query with them. If there is no entry in 

253 # the alias dict then it queries with the supplied key. The fact the schema 

254 # is now being checked means this shouldn't be a problem now. 

255 

256 # TODO: RFC-948 Move this import back to top of file once is implemented. 

257 import nest_asyncio 

258 

259 begin, end = _getBeginEnd(dayObs, begin, end, timespan, event, expRecord) 

260 begin -= TimeDelta(prePadding, format="sec") 

261 end += TimeDelta(postPadding, format="sec") 

262 

263 nest_asyncio.apply() 

264 loop = asyncio.get_event_loop() 

265 ret = loop.run_until_complete( 

266 _getEfdData(client=client, topic=topic, begin=begin, end=end, columns=columns) 

267 ) 

268 if ret.empty and warn: 

269 log = logging.getLogger(__name__) 

270 log.warning( 

271 f"Topic {topic} is in the schema, but no data was returned by the query for the specified" 

272 " time range" 

273 ) 

274 return ret 

275 

276 

277async def _getEfdData( 

278 client: EfdClient, 

279 topic: str, 

280 begin: astropy.Time, 

281 end: astropy.Time, 

282 columns: list[str] | None = None, 

283) -> pd.DataFrame: 

284 """Get data for a topic from the EFD over the specified time range. 

285 

286 Parameters 

287 ---------- 

288 client : `lsst_efd_client.efd_helper.EfdClient` 

289 The EFD client to use. 

290 topic : `str` 

291 The topic to query. 

292 begin : `astropy.Time` 

293 The begin time for the query. 

294 end : `astropy.Time` 

295 The end time for the query. 

296 columns : `list` of `str`, optional 

297 The columns to query. If not specified, all columns are returned. 

298 

299 Returns 

300 ------- 

301 data : `pd.DataFrame` 

302 The data from the query. 

303 """ 

304 if columns is None: 

305 columns = ["*"] 

306 columns = list(ensure_iterable(columns)) 

307 

308 availableTopics = await client.get_topics() 

309 

310 if topic not in availableTopics: 

311 raise ValueError(f"Topic {topic} not in EFD schema") 

312 

313 data = await client.select_time_series(topic, columns, begin.utc, end.utc) 

314 

315 return data 

316 

317 

318def getMostRecentRowWithDataBefore( 

319 client: EfdClient, 

320 topic: str, 

321 timeToLookBefore: astropy.Time, 

322 warnStaleAfterNMinutes: float | int = 60 * 12, 

323) -> pd.Series: 

324 """Get the most recent row of data for a topic before a given time. 

325 

326 Parameters 

327 ---------- 

328 client : `lsst_efd_client.efd_helper.EfdClient` 

329 The EFD client to use. 

330 topic : `str` 

331 The topic to query. 

332 timeToLookBefore : `astropy.Time` 

333 The time to look before. 

334 warnStaleAfterNMinutes : `float`, optional 

335 The number of minutes after which to consider the data stale and issue 

336 a warning. 

337 

338 Returns 

339 ------- 

340 row : `pd.Series` 

341 The row of data from the EFD containing the most recent data before the 

342 specified time. 

343 

344 Raises 

345 ------ 

346 ValueError: 

347 If the topic is not in the EFD schema. 

348 """ 

349 staleAge = datetime.timedelta(warnStaleAfterNMinutes) 

350 

351 firstDayPossible = getDayObsStartTime(20190101) 

352 

353 if timeToLookBefore < firstDayPossible: 

354 raise ValueError(f"Requested time {timeToLookBefore} is before any data was put in the EFD") 

355 

356 df = pd.DataFrame() 

357 beginTime = timeToLookBefore 

358 while df.empty and beginTime > firstDayPossible: 

359 df = getEfdData(client, topic, begin=beginTime, timespan=-TIME_CHUNKING, warn=False) 

360 beginTime -= TIME_CHUNKING 

361 

362 if beginTime < firstDayPossible and df.empty: # we ran all the way back to the beginning of time 

363 raise ValueError( 

364 f"The entire EFD was searched backwards from {timeToLookBefore} and no data was " 

365 f"found in {topic=}" 

366 ) 

367 

368 lastRow = df.iloc[-1] 

369 commandTime = efdTimestampToAstropy(lastRow["private_efdStamp"]) 

370 

371 commandAge = timeToLookBefore - commandTime 

372 if commandAge > staleAge: 

373 log = logging.getLogger(__name__) 

374 log.warning( 

375 f"Component {topic} was last set {commandAge.sec/60:.1} minutes" " before the requested time" 

376 ) 

377 

378 return lastRow 

379 

380 

381def makeEfdClient(testing: bool | None = False) -> EfdClient: 

382 """Automatically create an EFD client based on the site. 

383 

384 Parameters 

385 ---------- 

386 testing : `bool` 

387 Set to ``True`` if running in a test suite. This will default to using 

388 the USDF EFD, for which data has been recorded for replay by the ``vcr` 

389 package. Note data must be re-recorded to ``vcr`` from both inside and 

390 outside the USDF when the package/data changes, due to the use of a 

391 proxy meaning that the web requests are different depending on whether 

392 the EFD is being contacted from inside and outside the USDF. 

393 

394 Returns 

395 ------- 

396 efdClient : `lsst_efd_client.efd_helper.EfdClient`, optional 

397 The EFD client to use for the current site. 

398 """ 

399 if not HAS_EFD_CLIENT: 

400 raise RuntimeError("Could not create EFD client because importing lsst_efd_client failed.") 

401 

402 if testing: 

403 return EfdClient("usdf_efd") 

404 

405 try: 

406 site = getSite() 

407 except ValueError as e: 

408 raise RuntimeError("Could not create EFD client as the site could not be determined") from e 

409 

410 if site == "summit": 

411 return EfdClient("summit_efd") 

412 if site == "tucson": 

413 return EfdClient("tucson_teststand_efd") 

414 if site == "base": 

415 return EfdClient("base_efd") 

416 if site in ["staff-rsp", "rubin-devl", "usdf-k8s"]: 

417 return EfdClient("usdf_efd") 

418 if site == "jenkins": 

419 return EfdClient("usdf_efd") 

420 

421 raise RuntimeError(f"Could not create EFD client as the {site=} is not recognized") 

422 

423 

424def expRecordToTimespan(expRecord: dafButler.DimensionRecord) -> dict: 

425 """Get the timespan from an exposure record. 

426 

427 Returns the timespan in a format where it can be used to directly unpack 

428 into a efdClient.select_time_series() call. 

429 

430 Parameters 

431 ---------- 

432 expRecord : `lsst.daf.butler.DimensionRecord` 

433 The exposure record. 

434 

435 Returns 

436 ------- 

437 timespanDict : `dict` 

438 The timespan in a format that can be used to directly unpack into a 

439 efdClient.select_time_series() call. 

440 """ 

441 return { 

442 "begin": expRecord.timespan.begin.utc, 

443 "end": expRecord.timespan.end.utc, 

444 } 

445 

446 

447def efdTimestampToAstropy(timestamp: float) -> astropy.time.Time: 

448 """Get an efd timestamp as an astropy.time.Time object. 

449 

450 Parameters 

451 ---------- 

452 timestamp : `float` 

453 The timestamp, as a float. 

454 

455 Returns 

456 ------- 

457 time : `astropy.time.Time` 

458 The timestamp as an astropy.time.Time object. 

459 """ 

460 return Time(timestamp, format="unix") 

461 

462 

463def astropyToEfdTimestamp(time: astropy.time.Time) -> float: 

464 """Get astropy Time object as an efd timestamp 

465 

466 Parameters 

467 ---------- 

468 time : `astropy.time.Time` 

469 The time as an astropy.time.Time object. 

470 

471 Returns 

472 ------- 

473 timestamp : `float` 

474 The timestamp, in UTC, in unix seconds. 

475 """ 

476 

477 return time.utc.unix 

478 

479 

480def clipDataToEvent( 

481 df: pd.DataFrame, 

482 event: TMAEvent, 

483 prePadding: float = 0, 

484 postPadding: float = 0, 

485 logger: logging.Logger | None = None, 

486) -> pd.DataFrame: 

487 """Clip a padded dataframe to an event. 

488 

489 Parameters 

490 ---------- 

491 df : `pd.DataFrame` 

492 The dataframe to clip. 

493 event : `lsst.summit.utils.efdUtils.TMAEvent` 

494 The event to clip to. 

495 prePadding : `float`, optional 

496 The amount of time before the nominal start of the event to include, in 

497 seconds. 

498 postPadding : `float`, optional 

499 The amount of extra time after the nominal end of the event to include, 

500 in seconds. 

501 logger : `logging.Logger`, optional 

502 The logger to use. If not specified, a new one is created. 

503 

504 Returns 

505 ------- 

506 clipped : `pd.DataFrame` 

507 The clipped dataframe. 

508 """ 

509 begin = event.begin.value - prePadding 

510 end = event.end.value + postPadding 

511 

512 if logger is None: 

513 logger = logging.getLogger(__name__) 

514 

515 if begin < df["private_efdStamp"].min(): 

516 logger.warning(f"Requested begin time {begin} is before the start of the data") 

517 if end > df["private_efdStamp"].max(): 

518 logger.warning(f"Requested end time {end} is after the end of the data") 

519 

520 mask = (df["private_efdStamp"] >= begin) & (df["private_efdStamp"] <= end) 

521 clipped_df = df.loc[mask].copy() 

522 return clipped_df 

523 

524 

525def offsetDayObs(dayObs: int, nDays: int): 

526 """Offset a dayObs by a given number of days. 

527 

528 Parameters 

529 ---------- 

530 dayObs : `int` 

531 The dayObs, as an integer, e.g. 20231225 

532 nDays : `int` 

533 The number of days to offset the dayObs by. 

534 

535 Returns 

536 ------- 

537 newDayObs : `int` 

538 The new dayObs, as an integer, e.g. 20231225 

539 """ 

540 d1 = datetime.datetime.strptime(str(dayObs), "%Y%m%d") 

541 oneDay = datetime.timedelta(days=nDays) 

542 return int((d1 + oneDay).strftime("%Y%m%d")) 

543 

544 

545def calcNextDay(dayObs: int) -> int: 

546 """Given an integer dayObs, calculate the next integer dayObs. 

547 

548 Integers are used for dayObs, but dayObs values are therefore not 

549 contiguous due to month/year ends etc, so this utility provides a robust 

550 way to get the integer dayObs which follows the one specified. 

551 

552 Parameters 

553 ---------- 

554 dayObs : `int` 

555 The dayObs, as an integer, e.g. 20231231 

556 

557 Returns 

558 ------- 

559 nextDayObs : `int` 

560 The next dayObs, as an integer, e.g. 20240101 

561 """ 

562 return offsetDayObs(dayObs, 1) 

563 

564 

565def calcPreviousDay(dayObs: int) -> int: 

566 """Given an integer dayObs, calculate the next integer dayObs. 

567 

568 Integers are used for dayObs, but dayObs values are therefore not 

569 contiguous due to month/year ends etc, so this utility provides a robust 

570 way to get the integer dayObs which follows the one specified. 

571 

572 Parameters 

573 ---------- 

574 dayObs : `int` 

575 The dayObs, as an integer, e.g. 20231231 

576 

577 Returns 

578 ------- 

579 nextDayObs : `int` 

580 The next dayObs, as an integer, e.g. 20240101 

581 """ 

582 return offsetDayObs(dayObs, -1) 

583 

584 

585def getDayObsStartTime(dayObs: int) -> astropy.time.Time: 

586 """Get the start of the given dayObs as an astropy.time.Time object. 

587 

588 The observatory rolls the date over at UTC-12. 

589 

590 Parameters 

591 ---------- 

592 dayObs : `int` 

593 The dayObs, as an integer, e.g. 20231225 

594 

595 Returns 

596 ------- 

597 time : `astropy.time.Time` 

598 The start of the dayObs as an astropy.time.Time object. 

599 """ 

600 pythonDateTime = datetime.datetime.strptime(str(dayObs), "%Y%m%d") 

601 return Time(pythonDateTime) + 12 * u.hour 

602 

603 

604def getDayObsEndTime(dayObs: int) -> astropy.time.Time: 

605 """Get the end of the given dayObs as an astropy.time.Time object. 

606 

607 Parameters 

608 ---------- 

609 dayObs : `int` 

610 The dayObs, as an integer, e.g. 20231225 

611 

612 Returns 

613 ------- 

614 time : `astropy.time.Time` 

615 The end of the dayObs as an astropy.time.Time object. 

616 """ 

617 return getDayObsStartTime(dayObs) + 24 * u.hour 

618 

619 

620def getDayObsForTime(time: astropy.time.Time) -> int: 

621 """Get the dayObs in which an astropy.time.Time object falls. 

622 

623 Parameters 

624 ---------- 

625 time : `astropy.time.Time` 

626 The time. 

627 

628 Returns 

629 ------- 

630 dayObs : `int` 

631 The dayObs, as an integer, e.g. 20231225 

632 """ 

633 twelveHours = datetime.timedelta(hours=-12) 

634 offset = TimeDelta(twelveHours, format="datetime") 

635 return int((time + offset).utc.isot[:10].replace("-", "")) 

636 

637 

638@deprecated( 

639 reason="getSubTopics() has been replaced by getTopics() and using wildcards. " 

640 "Will be removed after w_2023_50.", 

641 version="w_2023_40", 

642 category=FutureWarning, 

643) 

644def getSubTopics(client: EfdClient, topic: str) -> list[str]: 

645 """Get all the sub topics within a given topic. 

646 

647 Note that the topic need not be a complete one, for example, rather than 

648 doing `getSubTopics(client, 'lsst.sal.ATMCS')` to get all the topics for 

649 the AuxTel Mount Control System, you can do `getSubTopics(client, 

650 'lsst.sal.AT')` to get all which relate to the AuxTel in general. 

651 

652 Parameters 

653 ---------- 

654 client : `lsst_efd_client.efd_helper.EfdClient` 

655 The EFD client to use. 

656 topic : `str` 

657 The topic to query. 

658 

659 Returns 

660 ------- 

661 subTopics : `list` of `str` 

662 The sub topics. 

663 """ 

664 loop = asyncio.get_event_loop() 

665 topics = loop.run_until_complete(client.get_topics()) 

666 return sorted([t for t in topics if t.startswith(topic)]) 

667 

668 

669def getTopics(client: EfdClient, toFind: str, caseSensitive: bool = False) -> list[str]: 

670 """Return all the strings in topics which match the topic query string. 

671 

672 Supports wildcards, which are denoted as `*``, as per shell globs. 

673 

674 Example: 

675 >>> # assume topics are ['apple', 'banana', 'grape'] 

676 >>> getTopics(, 'a*p*') 

677 ['apple', 'grape'] 

678 

679 Parameters 

680 ---------- 

681 client : `lsst_efd_client.efd_helper.EfdClient` 

682 The EFD client to use. 

683 toFind : `str` 

684 The query string, with optional wildcards denoted as *. 

685 caseSensitive : `bool`, optional 

686 If ``True``, the query is case sensitive. Defaults to ``False``. 

687 

688 Returns 

689 ------- 

690 matches : `list` of `str` 

691 The list of matching topics. 

692 """ 

693 loop = asyncio.get_event_loop() 

694 topics = loop.run_until_complete(client.get_topics()) 

695 

696 # Replace wildcard with regex equivalent 

697 pattern = toFind.replace("*", ".*") 

698 flags = re.IGNORECASE if not caseSensitive else 0 

699 

700 matches = [] 

701 for topic in topics: 

702 if re.match(pattern, topic, flags): 

703 matches.append(topic) 

704 

705 return matches 

706 

707 

708def getCommands( 

709 client: EfdClient, 

710 commands: list[str], 

711 begin: astropy.time.Time, 

712 end: astropy.time.Time, 

713 prePadding: float, 

714 postPadding: float, 

715 timeFormat: str = "python", 

716) -> dict[astropy.Time | pd.Timestamp | datetime.datetime, str]: 

717 """Retrieve the commands issued within a specified time range. 

718 

719 Parameters 

720 ---------- 

721 client : `EfdClient` 

722 The client object used to retrieve EFD data. 

723 commands : `list` 

724 A list of commands to retrieve. 

725 begin : `astropy.time.Time` 

726 The start time of the time range. 

727 end : `astropy.time.Time` 

728 The end time of the time range. 

729 prePadding : `float` 

730 The amount of time to pad before the begin time. 

731 postPadding : `float` 

732 The amount of time to pad after the end time. 

733 timeFormat : `str` 

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

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

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

737 

738 Returns 

739 ------- 

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

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

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

743 python datetime. 

744 

745 Raises 

746 ------ 

747 ValueError 

748 Raise if there is already a command at a timestamp in the dictionary, 

749 i.e. there is a collision. 

750 """ 

751 if timeFormat not in ["pandas", "astropy", "python"]: 

752 raise ValueError(f"format must be one of 'pandas', 'astropy' or 'python', not {timeFormat=}") 

753 

754 commands = list(ensure_iterable(commands)) 

755 

756 commandTimes: dict[astropy.Time | pd.Timestamp | datetime.datetime, str] = {} 

757 for command in commands: 

758 data = getEfdData( 

759 client, 

760 command, 

761 begin=begin, 

762 end=end, 

763 prePadding=prePadding, 

764 postPadding=postPadding, 

765 warn=False, # most commands will not be issue so we expect many empty queries 

766 ) 

767 for time, _ in data.iterrows(): 

768 # this is much the most simple data structure, and the chance 

769 # of commands being *exactly* simultaneous is minimal so try 

770 # it like this, and just raise if we get collisions for now. So 

771 # far in testing this seems to be just fine. 

772 

773 timeKey = None 

774 match timeFormat: 

775 case "pandas": 

776 timeKey = time 

777 case "astropy": 

778 timeKey = Time(time) 

779 case "python": 

780 timeKey = time.to_pydatetime() 

781 

782 if timeKey in commandTimes: 

783 raise ValueError( 

784 f"There is already a command at {timeKey=} -" " make a better data structure!" 

785 ) 

786 commandTimes[timeKey] = command 

787 return commandTimes