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

212 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 19:02 +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 == "UNKNOWN": 

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

468 

469 if site == "summit": 

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

471 if site == "tucson": 

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

473 if site == "base": 

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

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

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

477 if site == "jenkins": 

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

479 

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

481 

482 

483@deprecated( 

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

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

486 version="w_2026_01", 

487 category=FutureWarning, 

488) 

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

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

491 

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

493 into a efdClient.select_time_series() call. 

494 

495 Parameters 

496 ---------- 

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

498 The exposure record. 

499 

500 Returns 

501 ------- 

502 timespanDict : `dict` 

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

504 efdClient.select_time_series() call. 

505 """ 

506 return newLocation.expRecordToTimespan(expRecord) 

507 

508 

509@deprecated( 

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

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

512 version="w_2026_01", 

513 category=FutureWarning, 

514) 

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

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

517 

518 Parameters 

519 ---------- 

520 timestamp : `float` 

521 The timestamp, as a float. 

522 

523 Returns 

524 ------- 

525 time : `astropy.time.Time` 

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

527 """ 

528 return newLocation.efdTimestampToAstropy(timestamp) 

529 

530 

531@deprecated( 

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

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

534 version="w_2026_01", 

535 category=FutureWarning, 

536) 

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

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

539 

540 Parameters 

541 ---------- 

542 time : `astropy.time.Time` 

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

544 

545 Returns 

546 ------- 

547 timestamp : `float` 

548 The timestamp, in UTC, in unix seconds. 

549 """ 

550 

551 return newLocation.astropyToEfdTimestamp(time) 

552 

553 

554def clipDataToEvent( 

555 df: DataFrame, 

556 event: TMAEvent, 

557 prePadding: float = 0, 

558 postPadding: float = 0, 

559 logger: logging.Logger | None = None, 

560) -> DataFrame: 

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

562 

563 Parameters 

564 ---------- 

565 df : `pd.DataFrame` 

566 The dataframe to clip. 

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

568 The event to clip to. 

569 prePadding : `float`, optional 

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

571 seconds. 

572 postPadding : `float`, optional 

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

574 in seconds. 

575 logger : `logging.Logger`, optional 

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

577 

578 Returns 

579 ------- 

580 clipped : `pd.DataFrame` 

581 The clipped dataframe. 

582 """ 

583 begin = event.begin.value - prePadding 

584 end = event.end.value + postPadding 

585 

586 if logger is None: 

587 logger = logging.getLogger(__name__) 

588 

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

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

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

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

593 

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

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

596 return clipped_df 

597 

598 

599@deprecated( 

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

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

602 version="w_2026_01", 

603 category=FutureWarning, 

604) 

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

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

607 

608 Parameters 

609 ---------- 

610 dayObs : `int` 

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

612 nDays : `int` 

613 The number of days to offset the dayObs by. 

614 

615 Returns 

616 ------- 

617 newDayObs : `int` 

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

619 """ 

620 return newLocation.offsetDayObs(dayObs, nDays) 

621 

622 

623@deprecated( 

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

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

626 version="w_2026_01", 

627 category=FutureWarning, 

628) 

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

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

631 

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

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

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

635 

636 Parameters 

637 ---------- 

638 dayObs : `int` 

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

640 

641 Returns 

642 ------- 

643 nextDayObs : `int` 

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

645 """ 

646 return newLocation.calcNextDay(dayObs) 

647 

648 

649@deprecated( 

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

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

652 version="w_2026_01", 

653 category=FutureWarning, 

654) 

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

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

657 

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

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

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

661 

662 Parameters 

663 ---------- 

664 dayObs : `int` 

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

666 

667 Returns 

668 ------- 

669 nextDayObs : `int` 

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

671 """ 

672 return newLocation.calcPreviousDay(dayObs) 

673 

674 

675@deprecated( 

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

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

678 version="w_2026_01", 

679 category=FutureWarning, 

680) 

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

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

683 

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

685 

686 Parameters 

687 ---------- 

688 startDay : `int` 

689 The starting dayObs, e.g. 20231225. 

690 endDay : `int` 

691 The ending dayObs, e.g. 20240101. 

692 

693 Returns 

694 ------- 

695 offset : `int` 

696 The number of days from startDay to endDay. 

697 """ 

698 return newLocation.calcDayOffset(startDay, endDay) 

699 

700 

701@deprecated( 

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

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

704 version="w_2026_01", 

705 category=FutureWarning, 

706) 

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

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

709 

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

711 

712 Parameters 

713 ---------- 

714 dayObs : `int` 

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

716 

717 Returns 

718 ------- 

719 time : `astropy.time.Time` 

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

721 """ 

722 return newLocation.getDayObsStartTime(dayObs) 

723 

724 

725@deprecated( 

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

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

728 version="w_2026_01", 

729 category=FutureWarning, 

730) 

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

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

733 

734 Parameters 

735 ---------- 

736 dayObs : `int` 

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

738 

739 Returns 

740 ------- 

741 time : `astropy.time.Time` 

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

743 """ 

744 return newLocation.getDayObsEndTime(dayObs) 

745 

746 

747@deprecated( 

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

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

750 version="w_2026_01", 

751 category=FutureWarning, 

752) 

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

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

755 

756 Parameters 

757 ---------- 

758 time : `astropy.time.Time` 

759 The time. 

760 

761 Returns 

762 ------- 

763 dayObs : `int` 

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

765 """ 

766 return newLocation.getDayObsForTime(time) 

767 

768 

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

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

771 

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

773 

774 Example: 

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

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

777 ['apple', 'grape'] 

778 

779 Parameters 

780 ---------- 

781 client : `lsst_efd_client.efd_helper.EfdClient` 

782 The EFD client to use. 

783 toFind : `str` 

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

785 caseSensitive : `bool`, optional 

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

787 

788 Returns 

789 ------- 

790 matches : `list` of `str` 

791 The list of matching topics. 

792 """ 

793 loop = asyncio.get_event_loop() 

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

795 

796 # Replace wildcard with regex equivalent 

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

798 flags = re.IGNORECASE if not caseSensitive else 0 

799 

800 matches = [] 

801 for topic in topics: 

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

803 matches.append(topic) 

804 

805 return matches 

806 

807 

808def getCommands( 

809 client: EfdClient, 

810 commands: list[str], 

811 begin: Time, 

812 end: Time, 

813 prePadding: float, 

814 postPadding: float, 

815 timeFormat: str = "python", 

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

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

818 

819 Parameters 

820 ---------- 

821 client : `EfdClient` 

822 The client object used to retrieve EFD data. 

823 commands : `list` 

824 A list of commands to retrieve. 

825 begin : `astropy.time.Time` 

826 The start time of the time range. 

827 end : `astropy.time.Time` 

828 The end time of the time range. 

829 prePadding : `float` 

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

831 postPadding : `float` 

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

833 timeFormat : `str` 

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

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

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

837 

838 Returns 

839 ------- 

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

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

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

843 python datetime. 

844 

845 Raises 

846 ------ 

847 ValueError 

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

849 i.e. there is a collision. 

850 """ 

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

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

853 

854 commands = list(ensure_iterable(commands)) 

855 

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

857 for command in commands: 

858 data = getEfdData( 

859 client, 

860 command, 

861 begin=begin, 

862 end=end, 

863 prePadding=prePadding, 

864 postPadding=postPadding, 

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

866 raiseIfTopicNotInSchema=False, 

867 ) 

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

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

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

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

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

873 

874 timeKey = None 

875 match timeFormat: 

876 case "pandas": 

877 timeKey = time 

878 case "astropy": 

879 timeKey = Time(time) 

880 case "python": 

881 assert isinstance(time, pd.Timestamp) 

882 timeKey = time.to_pydatetime() 

883 

884 if timeKey in commandTimes: 

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

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

887 raise ValueError(msg) 

888 commandTimes[timeKey] = command 

889 return commandTimes