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
« 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
23import asyncio
24import datetime
25import logging
26import re
27from typing import TYPE_CHECKING, Any, Callable
29import astropy
30import pandas as pd
31from astropy import units as u
32from astropy.time import Time, TimeDelta
33from deprecated.sphinx import deprecated
35from lsst.utils.iteration import ensure_iterable
37if TYPE_CHECKING:
38 from .tmaUtils import TMAEvent
39 from lsst.daf.butler import DimensionRecord
40 from pandas import DataFrame, Series, Timestamp
42from . import dateTime as newLocation
43from .utils import getSite
45HAS_EFD_CLIENT = True
46try:
47 from lsst_efd_client import EfdClient
48except ImportError:
49 HAS_EFD_CLIENT = False
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]
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}
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)
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.
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.
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
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
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
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
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
166 assert begin is not None
167 assert end is not None
168 return begin, end
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.
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.
198 The results from all topics are merged into a single dataframe.
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.
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.
248 Returns
249 -------
250 data : `pd.DataFrame`
251 The merged data from all topics.
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.
262 """
263 # TODO: DM-40100 ideally should calls mpts as necessary so that users
264 # needn't care if things are packed
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.
270 # TODO: RFC-948 Move this import back to top of file once is implemented.
271 import nest_asyncio
273 begin, end = _getBeginEnd(dayObs, begin, end, timespan, event, expRecord)
274 begin -= TimeDelta(prePadding, format="sec")
275 end += TimeDelta(postPadding, format="sec")
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
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.
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.
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))
333 availableTopics = await client.get_topics()
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()
343 data = await client.select_time_series(topic, columns, begin.utc, end.utc)
345 return data
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.
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.
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.
384 Raises
385 ------
386 ValueError:
387 If the topic is not in the EFD schema.
388 """
389 staleAge = datetime.timedelta(warnStaleAfterNMinutes)
391 earliest = newLocation.getDayObsStartTime(20190101)
393 if timeToLookBefore < earliest:
394 raise ValueError(f"Requested time {timeToLookBefore} is before any data was put in the EFD")
396 if maxSearchNMinutes is not None:
397 earliest = max(earliest, timeToLookBefore - maxSearchNMinutes * u.min)
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)]
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)
421 lastRow = df.iloc[-1]
422 commandTime = newLocation.efdTimestampToAstropy(lastRow["private_efdStamp"])
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 )
431 return lastRow
434def makeEfdClient(testing: bool | None = False, databaseName: str | None = None) -> EfdClient:
435 """Automatically create an EFD client based on the site.
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.
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
459 if not HAS_EFD_CLIENT:
460 raise RuntimeError("Could not create EFD client because importing lsst_efd_client failed.")
462 if testing:
463 return EfdClient("usdf_efd", **efdKwargs)
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 )
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)
483 raise RuntimeError(f"Could not create EFD client as the {site=} is not recognized")
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.
495 Returns the timespan in a format where it can be used to directly unpack
496 into a efdClient.select_time_series() call.
498 Parameters
499 ----------
500 expRecord : `lsst.daf.butler.DimensionRecord`
501 The exposure record.
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)
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.
521 Parameters
522 ----------
523 timestamp : `float`
524 The timestamp, as a float.
526 Returns
527 -------
528 time : `astropy.time.Time`
529 The timestamp as an astropy.time.Time object.
530 """
531 return newLocation.efdTimestampToAstropy(timestamp)
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
543 Parameters
544 ----------
545 time : `astropy.time.Time`
546 The time as an astropy.time.Time object.
548 Returns
549 -------
550 timestamp : `float`
551 The timestamp, in UTC, in unix seconds.
552 """
554 return newLocation.astropyToEfdTimestamp(time)
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.
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.
581 Returns
582 -------
583 clipped : `pd.DataFrame`
584 The clipped dataframe.
585 """
586 begin = event.begin.value - prePadding
587 end = event.end.value + postPadding
589 if logger is None:
590 logger = logging.getLogger(__name__)
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")
597 mask = (df["private_efdStamp"] >= begin) & (df["private_efdStamp"] <= end)
598 clipped_df = df.loc[mask].copy()
599 return clipped_df
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.
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.
618 Returns
619 -------
620 newDayObs : `int`
621 The new dayObs, as an integer, e.g. 20231225
622 """
623 return newLocation.offsetDayObs(dayObs, nDays)
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.
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.
639 Parameters
640 ----------
641 dayObs : `int`
642 The dayObs, as an integer, e.g. 20231231
644 Returns
645 -------
646 nextDayObs : `int`
647 The next dayObs, as an integer, e.g. 20240101
648 """
649 return newLocation.calcNextDay(dayObs)
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.
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.
665 Parameters
666 ----------
667 dayObs : `int`
668 The dayObs, as an integer, e.g. 20231231
670 Returns
671 -------
672 nextDayObs : `int`
673 The next dayObs, as an integer, e.g. 20240101
674 """
675 return newLocation.calcPreviousDay(dayObs)
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.
687 Positive if endDay is after startDay, negative if before, zero if equal.
689 Parameters
690 ----------
691 startDay : `int`
692 The starting dayObs, e.g. 20231225.
693 endDay : `int`
694 The ending dayObs, e.g. 20240101.
696 Returns
697 -------
698 offset : `int`
699 The number of days from startDay to endDay.
700 """
701 return newLocation.calcDayOffset(startDay, endDay)
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.
713 The observatory rolls the date over at UTC-12.
715 Parameters
716 ----------
717 dayObs : `int`
718 The dayObs, as an integer, e.g. 20231225
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)
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.
737 Parameters
738 ----------
739 dayObs : `int`
740 The dayObs, as an integer, e.g. 20231225
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)
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.
759 Parameters
760 ----------
761 time : `astropy.time.Time`
762 The time.
764 Returns
765 -------
766 dayObs : `int`
767 The dayObs, as an integer, e.g. 20231225
768 """
769 return newLocation.getDayObsForTime(time)
772def getTopics(client: EfdClient, toFind: str, caseSensitive: bool = False) -> list[str]:
773 """Return all the strings in topics which match the topic query string.
775 Supports wildcards, which are denoted as `*``, as per shell globs.
777 Example:
778 >>> # assume topics are ['apple', 'banana', 'grape']
779 >>> getTopics(, 'a*p*')
780 ['apple', 'grape']
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``.
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())
799 # Replace wildcard with regex equivalent
800 pattern = toFind.replace("*", ".*")
801 flags = re.IGNORECASE if not caseSensitive else 0
803 matches = []
804 for topic in topics:
805 if re.match(pattern, topic, flags):
806 matches.append(topic)
808 return matches
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.
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.
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.
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=}")
857 commands = list(ensure_iterable(commands))
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.
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()
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