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

212 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-04 17:50 +0000

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, Any, Callable 

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 

35from lsst.utils.iteration import ensure_iterable 

36 

37if TYPE_CHECKING: 

38 from .tmaUtils import TMAEvent 

39 from lsst.daf.butler import DimensionRecord 

40 from pandas import DataFrame, Series, Timestamp 

41 

42from . import dateTime as newLocation 

43from .utils import getSite 

44 

45HAS_EFD_CLIENT = True 

46try: 

47 from lsst_efd_client import EfdClient 

48except ImportError: 

49 HAS_EFD_CLIENT = False 

50 

51 

52__all__ = [ 

53 "getEfdData", 

54 "getMostRecentRowWithDataBefore", 

55 "makeEfdClient", 

56 "expRecordToTimespan", 

57 "efdTimestampToAstropy", 

58 "astropyToEfdTimestamp", 

59 "clipDataToEvent", 

60 "calcNextDay", 

61 "calcDayOffset", 

62 "getDayObsStartTime", 

63 "getDayObsEndTime", 

64 "getDayObsForTime", 

65 "getTopics", 

66 "getCommands", 

67] 

68 

69 

70COMMAND_ALIASES = { 

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

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

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

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

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

76} 

77 

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

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

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

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

82TIME_CHUNKING = datetime.timedelta(minutes=15) 

83 

84 

85def _getBeginEnd( 

86 dayObs: int | None = None, 

87 begin: Time | None = None, 

88 end: Time | None = None, 

89 timespan: TimeDelta | None = None, 

90 event: TMAEvent | None = None, 

91 expRecord: DimensionRecord | None = None, 

92) -> tuple[Time, Time]: 

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

94 kwargs passed to getEfdData. 

95 

96 Parameters 

97 ---------- 

98 dayObs : `int` 

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

100 and end times. 

101 begin : `astropy.Time` 

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

103 timespan must be supplied. 

104 end : `astropy.Time` 

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

106 supplied. 

107 timespan : `astropy.TimeDelta` 

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

109 supplied. 

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

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

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

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

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

115 other options are disallowed. 

116 

117 Returns 

118 ------- 

119 begin : `astropy.Time` 

120 The begin time for the query. 

121 end : `astropy.Time` 

122 The end time for the query. 

123 """ 

124 if expRecord is not None: 

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

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

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

128 begin = expRecord.timespan.begin 

129 end = expRecord.timespan.end 

130 return begin, end 

131 

132 if event is not None: 

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

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

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

136 begin = event.begin 

137 end = event.end 

138 return begin, end 

139 

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

141 if dayObs is not None: 

142 forbiddenOpts = [begin, end, timespan] 

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

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

145 begin = newLocation.getDayObsStartTime(dayObs) 

146 end = newLocation.getDayObsEndTime(dayObs) 

147 return begin, end 

148 # can now disregard dayObs entirely 

149 

150 if begin is None: 

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

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

153 

154 if end is None and timespan is None: 

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

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

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

158 if end is None: 

159 assert timespan is not None 

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

161 end = begin + timespan # the normal case 

162 else: 

163 end = begin # the case where timespan is negative 

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

165 

166 assert begin is not None 

167 assert end is not None 

168 return begin, end 

169 

170 

171def getEfdData( 

172 client: EfdClient, 

173 topic: str, 

174 *, 

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

176 prePadding: float = 0, 

177 postPadding: float = 0, 

178 dayObs: int | None = None, 

179 begin: Time | None = None, 

180 end: Time | None = None, 

181 timespan: TimeDelta | None = None, 

182 event: TMAEvent | None = None, 

183 expRecord: DimensionRecord | None = None, 

184 warn: bool = True, 

185 raiseIfTopicNotInSchema: bool = True, 

186) -> DataFrame: 

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

188 

189 The time range can be specified as either: 

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

191 * a begin point and a end point, 

192 * a begin point and a timespan. 

193 * a mount event 

194 * an exposure record 

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

196 begin time and use a negative timespan. 

197 

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

199 

200 `raiseIfTopicNotInSchema` should only be set to `False` when running on the 

201 summit or in utility code for topics which might have had no data taken 

202 within the last <data_retention_period> (nominally 30 days). Once a topic 

203 is in the schema at USDF it will always be there, and thus users there 

204 never need worry about this, always using `False` will be fine. However, at 

205 the summit things are a little less predictable, so something missing from 

206 the schema doesn't necessarily mean a typo, and utility code shouldn't 

207 raise when data has been expunged. 

208 

209 Parameters 

210 ---------- 

211 client : `lsst_efd_client.efd_helper.EfdClient` 

212 The EFD client to use. 

213 topic : `str` 

214 The topic to query. 

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

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

217 prePadding : `float` 

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

219 seconds. 

220 postPadding : `float` 

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

222 in seconds. 

223 dayObs : `int`, optional 

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

225 and end times. 

226 begin : `astropy.Time`, optional 

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

228 timespan must be supplied. 

229 end : `astropy.Time`, optional 

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

231 supplied. 

232 timespan : `astropy.TimeDelta`, optional 

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

234 supplied. 

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

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

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

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

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

240 other options are disallowed. 

241 warn : bool, optional 

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

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

244 ``True``. 

245 raiseIfTopicNotInSchema : `bool`, optional 

246 Whether to raise an error if the topic is not in the EFD schema. 

247 

248 Returns 

249 ------- 

250 data : `pd.DataFrame` 

251 The merged data from all topics. 

252 

253 Raises 

254 ------ 

255 ValueError: 

256 If the topics are not in the EFD schema. 

257 ValueError: 

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

259 ValueError: 

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

261 

262 """ 

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

264 # needn't care if things are packed 

265 

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

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

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

269 

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

271 import nest_asyncio 

272 

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

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

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

276 

277 nest_asyncio.apply() 

278 loop = asyncio.get_event_loop() 

279 ret = loop.run_until_complete( 

280 _getEfdData( 

281 client=client, 

282 topic=topic, 

283 begin=begin, 

284 end=end, 

285 columns=columns, 

286 raiseIfTopicNotInSchema=raiseIfTopicNotInSchema, 

287 ) 

288 ) 

289 if ret.empty and warn: 

290 log = logging.getLogger(__name__) 

291 msg = "" 

292 if raiseIfTopicNotInSchema: 

293 f"Topic {topic} is in the schema, but " 

294 msg += "no data was returned by the query for the specified time range" 

295 log.warning(msg) 

296 return ret 

297 

298 

299async def _getEfdData( 

300 client: EfdClient, 

301 topic: str, 

302 begin: Time, 

303 end: Time, 

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

305 raiseIfTopicNotInSchema: bool = True, 

306) -> DataFrame: 

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

308 

309 Parameters 

310 ---------- 

311 client : `lsst_efd_client.efd_helper.EfdClient` 

312 The EFD client to use. 

313 topic : `str` 

314 The topic to query. 

315 begin : `astropy.Time` 

316 The begin time for the query. 

317 end : `astropy.Time` 

318 The end time for the query. 

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

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

321 raiseIfTopicNotInSchema : `bool`, optional 

322 Whether to raise an error if the topic is not in the EFD schema. 

323 

324 Returns 

325 ------- 

326 data : `pd.DataFrame` 

327 The data from the query. 

328 """ 

329 if columns is None: 

330 columns = ["*"] 

331 columns = list(ensure_iterable(columns)) 

332 

333 availableTopics = await client.get_topics() 

334 

335 if topic not in availableTopics: 

336 if raiseIfTopicNotInSchema: 

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

338 else: 

339 log = logging.getLogger(__name__) 

340 log.debug(f"Topic {topic} not in EFD schema, returning empty DataFrame") 

341 return pd.DataFrame() 

342 

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

344 

345 return data 

346 

347 

348def getMostRecentRowWithDataBefore( 

349 client: EfdClient, 

350 topic: str, 

351 timeToLookBefore: Time, 

352 warnStaleAfterNMinutes: float | int = 60 * 12, 

353 maxSearchNMinutes: float | int | None = None, 

354 where: Callable[[DataFrame], list[bool]] | None = None, 

355 raiseIfTopicNotInSchema: bool = True, 

356) -> Series: 

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

358 

359 Parameters 

360 ---------- 

361 client : `lsst_efd_client.efd_helper.EfdClient` 

362 The EFD client to use. 

363 topic : `str` 

364 The topic to query. 

365 timeToLookBefore : `astropy.Time` 

366 The time to look before. 

367 warnStaleAfterNMinutes : `float`, optional 

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

369 a warning. 

370 maxSearchNMinutes: `float` or None, optional 

371 Maximum number of minutes to search before raising ValueError. 

372 where: `Callable` or None, optional 

373 A callable taking a single pd.Dataframe argument and returning a 

374 boolean list indicating rows to consider. 

375 raiseIfTopicNotInSchema : `bool`, optional 

376 Whether to raise an error if the topic is not in the EFD schema. 

377 

378 Returns 

379 ------- 

380 row : `pd.Series` 

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

382 specified time. 

383 

384 Raises 

385 ------ 

386 ValueError: 

387 If the topic is not in the EFD schema. 

388 """ 

389 staleAge = datetime.timedelta(warnStaleAfterNMinutes) 

390 

391 earliest = newLocation.getDayObsStartTime(20190101) 

392 

393 if timeToLookBefore < earliest: 

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

395 

396 if maxSearchNMinutes is not None: 

397 earliest = max(earliest, timeToLookBefore - maxSearchNMinutes * u.min) 

398 

399 df = pd.DataFrame() 

400 beginTime = timeToLookBefore 

401 while df.empty and beginTime > earliest: 

402 df = getEfdData( 

403 client, 

404 topic, 

405 begin=beginTime, 

406 timespan=-TIME_CHUNKING, 

407 warn=False, 

408 raiseIfTopicNotInSchema=raiseIfTopicNotInSchema, 

409 ) 

410 beginTime -= TIME_CHUNKING 

411 if where is not None and not df.empty: 

412 df = df[where(df)] 

413 

414 if beginTime < earliest and df.empty: # search ended early 

415 out = f"EFD searched backwards from {timeToLookBefore} to {earliest} and no data " 

416 if where is not None: 

417 out += "consistent with `where` predicate " 

418 out += f"was found in {topic=}" 

419 raise ValueError(out) 

420 

421 lastRow = df.iloc[-1] 

422 commandTime = newLocation.efdTimestampToAstropy(lastRow["private_efdStamp"]) 

423 

424 commandAge = timeToLookBefore - commandTime 

425 if commandAge > staleAge: 

426 log = logging.getLogger(__name__) 

427 log.warning( 

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

429 ) 

430 

431 return lastRow 

432 

433 

434def makeEfdClient(testing: bool | None = False, databaseName: str | None = None) -> EfdClient: 

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

436 

437 Parameters 

438 ---------- 

439 testing : `bool` 

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

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

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

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

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

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

446 databaseName : `str`, optional 

447 Name of the database within influxDB to query. If not provided, the 

448 default specified by EfdClient() is used. 

449 

450 Returns 

451 ------- 

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

453 The EFD client to use for the current site. 

454 """ 

455 efdKwargs: dict[str, Any] = {} 

456 if databaseName is not None: 

457 efdKwargs["db_name"] = databaseName 

458 

459 if not HAS_EFD_CLIENT: 

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

461 

462 if testing: 

463 return EfdClient("usdf_efd", **efdKwargs) 

464 

465 site = getSite() 

466 if site == "local": 

467 raise RuntimeError( 

468 "Could not create EFD client: getSite() returned 'local', meaning none of the known" 

469 " sites could be detected. EFD clients are only available from real Rubin sites." 

470 ) 

471 

472 if site == "summit": 

473 return EfdClient("summit_efd", **efdKwargs) 

474 if site == "tucson": 

475 return EfdClient("tucson_teststand_efd", **efdKwargs) 

476 if site == "base": 

477 return EfdClient("base_efd", **efdKwargs) 

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

479 return EfdClient("usdf_efd", **efdKwargs) 

480 if site == "jenkins": 

481 return EfdClient("usdf_efd", **efdKwargs) 

482 

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

484 

485 

486@deprecated( 

487 reason="expRecordToTimespan() has moved to lsst.summit.utils.dateTime. The function is unchanged, " 

488 "but you should change to import from there. This function will be removed after w_2026_01.", 

489 version="w_2026_01", 

490 category=FutureWarning, 

491) 

492def expRecordToTimespan(expRecord: DimensionRecord) -> dict: 

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

494 

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

496 into a efdClient.select_time_series() call. 

497 

498 Parameters 

499 ---------- 

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

501 The exposure record. 

502 

503 Returns 

504 ------- 

505 timespanDict : `dict` 

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

507 efdClient.select_time_series() call. 

508 """ 

509 return newLocation.expRecordToTimespan(expRecord) 

510 

511 

512@deprecated( 

513 reason="efdTimestampToAstropy() has moved to lsst.summit.utils.dateTime. The function is unchanged, " 

514 "but you should change to import from there. This function will be removed after w_2026_01.", 

515 version="w_2026_01", 

516 category=FutureWarning, 

517) 

518def efdTimestampToAstropy(timestamp: float) -> Time: 

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

520 

521 Parameters 

522 ---------- 

523 timestamp : `float` 

524 The timestamp, as a float. 

525 

526 Returns 

527 ------- 

528 time : `astropy.time.Time` 

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

530 """ 

531 return newLocation.efdTimestampToAstropy(timestamp) 

532 

533 

534@deprecated( 

535 reason="astropyToEfdTimestamp() has moved to lsst.summit.utils.dateTime. The function is unchanged, " 

536 "but you should change to import from there. This function will be removed after w_2026_01.", 

537 version="w_2026_01", 

538 category=FutureWarning, 

539) 

540def astropyToEfdTimestamp(time: Time) -> float: 

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

542 

543 Parameters 

544 ---------- 

545 time : `astropy.time.Time` 

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

547 

548 Returns 

549 ------- 

550 timestamp : `float` 

551 The timestamp, in UTC, in unix seconds. 

552 """ 

553 

554 return newLocation.astropyToEfdTimestamp(time) 

555 

556 

557def clipDataToEvent( 

558 df: DataFrame, 

559 event: TMAEvent, 

560 prePadding: float = 0, 

561 postPadding: float = 0, 

562 logger: logging.Logger | None = None, 

563) -> DataFrame: 

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

565 

566 Parameters 

567 ---------- 

568 df : `pd.DataFrame` 

569 The dataframe to clip. 

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

571 The event to clip to. 

572 prePadding : `float`, optional 

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

574 seconds. 

575 postPadding : `float`, optional 

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

577 in seconds. 

578 logger : `logging.Logger`, optional 

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

580 

581 Returns 

582 ------- 

583 clipped : `pd.DataFrame` 

584 The clipped dataframe. 

585 """ 

586 begin = event.begin.value - prePadding 

587 end = event.end.value + postPadding 

588 

589 if logger is None: 

590 logger = logging.getLogger(__name__) 

591 

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

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

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

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

596 

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

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

599 return clipped_df 

600 

601 

602@deprecated( 

603 reason="offsetDayObs() has moved to lsst.summit.utils.dateTime. The function is unchanged, " 

604 "but you should change to import from there. This function will be removed after w_2026_01.", 

605 version="w_2026_01", 

606 category=FutureWarning, 

607) 

608def offsetDayObs(dayObs: int, nDays: int) -> int: 

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

610 

611 Parameters 

612 ---------- 

613 dayObs : `int` 

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

615 nDays : `int` 

616 The number of days to offset the dayObs by. 

617 

618 Returns 

619 ------- 

620 newDayObs : `int` 

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

622 """ 

623 return newLocation.offsetDayObs(dayObs, nDays) 

624 

625 

626@deprecated( 

627 reason="calcNextDay() has moved to lsst.summit.utils.dateTime. The function is unchanged, " 

628 "but you should change to import from there. This function will be removed after w_2026_01.", 

629 version="w_2026_01", 

630 category=FutureWarning, 

631) 

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

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

634 

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

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

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

638 

639 Parameters 

640 ---------- 

641 dayObs : `int` 

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

643 

644 Returns 

645 ------- 

646 nextDayObs : `int` 

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

648 """ 

649 return newLocation.calcNextDay(dayObs) 

650 

651 

652@deprecated( 

653 reason="calcPreviousDay() has moved to lsst.summit.utils.dateTime. The function is unchanged, " 

654 "but you should change to import from there. This function will be removed after w_2026_01.", 

655 version="w_2026_01", 

656 category=FutureWarning, 

657) 

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

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

660 

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

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

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

664 

665 Parameters 

666 ---------- 

667 dayObs : `int` 

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

669 

670 Returns 

671 ------- 

672 nextDayObs : `int` 

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

674 """ 

675 return newLocation.calcPreviousDay(dayObs) 

676 

677 

678@deprecated( 

679 reason="calcDayOffset() has moved to lsst.summit.utils.dateTime. The function is unchanged, " 

680 "but you should change to import from there. This function will be removed after w_2026_01.", 

681 version="w_2026_01", 

682 category=FutureWarning, 

683) 

684def calcDayOffset(startDay: int, endDay: int) -> int: 

685 """Calculate the number of days between two dayObs values. 

686 

687 Positive if endDay is after startDay, negative if before, zero if equal. 

688 

689 Parameters 

690 ---------- 

691 startDay : `int` 

692 The starting dayObs, e.g. 20231225. 

693 endDay : `int` 

694 The ending dayObs, e.g. 20240101. 

695 

696 Returns 

697 ------- 

698 offset : `int` 

699 The number of days from startDay to endDay. 

700 """ 

701 return newLocation.calcDayOffset(startDay, endDay) 

702 

703 

704@deprecated( 

705 reason="getDayObsStartTime() has moved to lsst.summit.utils.dateTime. The function is unchanged, " 

706 "but you should change to import from there. This function will be removed after w_2026_01.", 

707 version="w_2026_01", 

708 category=FutureWarning, 

709) 

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

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

712 

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

714 

715 Parameters 

716 ---------- 

717 dayObs : `int` 

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

719 

720 Returns 

721 ------- 

722 time : `astropy.time.Time` 

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

724 """ 

725 return newLocation.getDayObsStartTime(dayObs) 

726 

727 

728@deprecated( 

729 reason="getDayObsEndTime() has moved to lsst.summit.utils.dateTime. The function is unchanged, " 

730 "but you should change to import from there. This function will be removed after w_2026_01.", 

731 version="w_2026_01", 

732 category=FutureWarning, 

733) 

734def getDayObsEndTime(dayObs: int) -> Time: 

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

736 

737 Parameters 

738 ---------- 

739 dayObs : `int` 

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

741 

742 Returns 

743 ------- 

744 time : `astropy.time.Time` 

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

746 """ 

747 return newLocation.getDayObsEndTime(dayObs) 

748 

749 

750@deprecated( 

751 reason="getDayObsForTime() has moved to lsst.summit.utils.dateTime. The function is unchanged, " 

752 "but you should change to import from there. This function will be removed after w_2026_01.", 

753 version="w_2026_01", 

754 category=FutureWarning, 

755) 

756def getDayObsForTime(time: Time) -> int: 

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

758 

759 Parameters 

760 ---------- 

761 time : `astropy.time.Time` 

762 The time. 

763 

764 Returns 

765 ------- 

766 dayObs : `int` 

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

768 """ 

769 return newLocation.getDayObsForTime(time) 

770 

771 

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

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

774 

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

776 

777 Example: 

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

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

780 ['apple', 'grape'] 

781 

782 Parameters 

783 ---------- 

784 client : `lsst_efd_client.efd_helper.EfdClient` 

785 The EFD client to use. 

786 toFind : `str` 

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

788 caseSensitive : `bool`, optional 

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

790 

791 Returns 

792 ------- 

793 matches : `list` of `str` 

794 The list of matching topics. 

795 """ 

796 loop = asyncio.get_event_loop() 

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

798 

799 # Replace wildcard with regex equivalent 

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

801 flags = re.IGNORECASE if not caseSensitive else 0 

802 

803 matches = [] 

804 for topic in topics: 

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

806 matches.append(topic) 

807 

808 return matches 

809 

810 

811def getCommands( 

812 client: EfdClient, 

813 commands: list[str], 

814 begin: Time, 

815 end: Time, 

816 prePadding: float, 

817 postPadding: float, 

818 timeFormat: str = "python", 

819) -> dict[Time | Timestamp | datetime.datetime, str]: 

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

821 

822 Parameters 

823 ---------- 

824 client : `EfdClient` 

825 The client object used to retrieve EFD data. 

826 commands : `list` 

827 A list of commands to retrieve. 

828 begin : `astropy.time.Time` 

829 The start time of the time range. 

830 end : `astropy.time.Time` 

831 The end time of the time range. 

832 prePadding : `float` 

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

834 postPadding : `float` 

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

836 timeFormat : `str` 

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

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

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

840 

841 Returns 

842 ------- 

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

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

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

846 python datetime. 

847 

848 Raises 

849 ------ 

850 ValueError 

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

852 i.e. there is a collision. 

853 """ 

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

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

856 

857 commands = list(ensure_iterable(commands)) 

858 

859 commandTimes: dict[Time | Timestamp | datetime.datetime, str] = {} 

860 for command in commands: 

861 data = getEfdData( 

862 client, 

863 command, 

864 begin=begin, 

865 end=end, 

866 prePadding=prePadding, 

867 postPadding=postPadding, 

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

869 raiseIfTopicNotInSchema=False, 

870 ) 

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

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

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

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

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

876 

877 timeKey = None 

878 match timeFormat: 

879 case "pandas": 

880 timeKey = time 

881 case "astropy": 

882 timeKey = Time(time) 

883 case "python": 

884 assert isinstance(time, pd.Timestamp) 

885 timeKey = time.to_pydatetime() 

886 

887 if timeKey in commandTimes: 

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

889 msg += f"Colliding commands = {commandTimes[timeKey]} and {command}" 

890 raise ValueError(msg) 

891 commandTimes[timeKey] = command 

892 return commandTimes