Coverage for python / lsst / summit / utils / efdUtils.py: 17%
212 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 09:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 09:44 +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 == "UNKNOWN":
467 raise RuntimeError("Could not create EFD client as the site could not be determined")
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)
480 raise RuntimeError(f"Could not create EFD client as the {site=} is not recognized")
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.
492 Returns the timespan in a format where it can be used to directly unpack
493 into a efdClient.select_time_series() call.
495 Parameters
496 ----------
497 expRecord : `lsst.daf.butler.DimensionRecord`
498 The exposure record.
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)
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.
518 Parameters
519 ----------
520 timestamp : `float`
521 The timestamp, as a float.
523 Returns
524 -------
525 time : `astropy.time.Time`
526 The timestamp as an astropy.time.Time object.
527 """
528 return newLocation.efdTimestampToAstropy(timestamp)
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
540 Parameters
541 ----------
542 time : `astropy.time.Time`
543 The time as an astropy.time.Time object.
545 Returns
546 -------
547 timestamp : `float`
548 The timestamp, in UTC, in unix seconds.
549 """
551 return newLocation.astropyToEfdTimestamp(time)
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.
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.
578 Returns
579 -------
580 clipped : `pd.DataFrame`
581 The clipped dataframe.
582 """
583 begin = event.begin.value - prePadding
584 end = event.end.value + postPadding
586 if logger is None:
587 logger = logging.getLogger(__name__)
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")
594 mask = (df["private_efdStamp"] >= begin) & (df["private_efdStamp"] <= end)
595 clipped_df = df.loc[mask].copy()
596 return clipped_df
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.
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.
615 Returns
616 -------
617 newDayObs : `int`
618 The new dayObs, as an integer, e.g. 20231225
619 """
620 return newLocation.offsetDayObs(dayObs, nDays)
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.
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.
636 Parameters
637 ----------
638 dayObs : `int`
639 The dayObs, as an integer, e.g. 20231231
641 Returns
642 -------
643 nextDayObs : `int`
644 The next dayObs, as an integer, e.g. 20240101
645 """
646 return newLocation.calcNextDay(dayObs)
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.
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.
662 Parameters
663 ----------
664 dayObs : `int`
665 The dayObs, as an integer, e.g. 20231231
667 Returns
668 -------
669 nextDayObs : `int`
670 The next dayObs, as an integer, e.g. 20240101
671 """
672 return newLocation.calcPreviousDay(dayObs)
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.
684 Positive if endDay is after startDay, negative if before, zero if equal.
686 Parameters
687 ----------
688 startDay : `int`
689 The starting dayObs, e.g. 20231225.
690 endDay : `int`
691 The ending dayObs, e.g. 20240101.
693 Returns
694 -------
695 offset : `int`
696 The number of days from startDay to endDay.
697 """
698 return newLocation.calcDayOffset(startDay, endDay)
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.
710 The observatory rolls the date over at UTC-12.
712 Parameters
713 ----------
714 dayObs : `int`
715 The dayObs, as an integer, e.g. 20231225
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)
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.
734 Parameters
735 ----------
736 dayObs : `int`
737 The dayObs, as an integer, e.g. 20231225
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)
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.
756 Parameters
757 ----------
758 time : `astropy.time.Time`
759 The time.
761 Returns
762 -------
763 dayObs : `int`
764 The dayObs, as an integer, e.g. 20231225
765 """
766 return newLocation.getDayObsForTime(time)
769def getTopics(client: EfdClient, toFind: str, caseSensitive: bool = False) -> list[str]:
770 """Return all the strings in topics which match the topic query string.
772 Supports wildcards, which are denoted as `*``, as per shell globs.
774 Example:
775 >>> # assume topics are ['apple', 'banana', 'grape']
776 >>> getTopics(, 'a*p*')
777 ['apple', 'grape']
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``.
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())
796 # Replace wildcard with regex equivalent
797 pattern = toFind.replace("*", ".*")
798 flags = re.IGNORECASE if not caseSensitive else 0
800 matches = []
801 for topic in topics:
802 if re.match(pattern, topic, flags):
803 matches.append(topic)
805 return matches
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.
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.
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.
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=}")
854 commands = list(ensure_iterable(commands))
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.
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()
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