Coverage for python/lsst/summit/utils/efdUtils.py: 13%
209 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-07 03:09 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-07 03:09 -0800
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 .utils import getSite
44HAS_EFD_CLIENT = True
45try:
46 from lsst_efd_client import EfdClient
47except ImportError:
48 HAS_EFD_CLIENT = False
51__all__ = [
52 "getEfdData",
53 "getMostRecentRowWithDataBefore",
54 "makeEfdClient",
55 "expRecordToTimespan",
56 "efdTimestampToAstropy",
57 "astropyToEfdTimestamp",
58 "clipDataToEvent",
59 "calcNextDay",
60 "getDayObsStartTime",
61 "getDayObsEndTime",
62 "getDayObsForTime",
63 "getSubTopics", # deprecated, being removed in w_2023_50
64 "getTopics",
65 "getCommands",
66]
69COMMAND_ALIASES = {
70 "raDecTarget": "lsst.sal.MTPtg.command_raDecTarget",
71 "moveToTarget": "lsst.sal.MTMount.command_moveToTarget",
72 "startTracking": "lsst.sal.MTMount.command_startTracking",
73 "stopTracking": "lsst.sal.MTMount.command_stopTracking",
74 "trackTarget": "lsst.sal.MTMount.command_trackTarget", # issued at 20Hz - don't plot
75}
77# When looking backwards in time to find the most recent state event, look back
78# in chunks of this size. Too small, and there will be too many queries, too
79# large and there will be too much data returned unnecessarily, as we only need
80# one row by definition. Will tune this parameters in consultation with SQuaRE.
81TIME_CHUNKING = datetime.timedelta(minutes=15)
84def _getBeginEnd(
85 dayObs: int | None = None,
86 begin: Time | None = None,
87 end: Time | None = None,
88 timespan: TimeDelta | None = None,
89 event: TMAEvent | None = None,
90 expRecord: DimensionRecord | None = None,
91) -> tuple[Time, Time]:
92 """Calculate the begin and end times to pass to _getEfdData, given the
93 kwargs passed to getEfdData.
95 Parameters
96 ----------
97 dayObs : `int`
98 The dayObs to query. If specified, this is used to determine the begin
99 and end times.
100 begin : `astropy.Time`
101 The begin time for the query. If specified, either an end time or a
102 timespan must be supplied.
103 end : `astropy.Time`
104 The end time for the query. If specified, a begin time must also be
105 supplied.
106 timespan : `astropy.TimeDelta`
107 The timespan for the query. If specified, a begin time must also be
108 supplied.
109 event : `lsst.summit.utils.efdUtils.TMAEvent`
110 The event to query. If specified, this is used to determine the begin
111 and end times, and all other options are disallowed.
112 expRecord : `lsst.daf.butler.dimensions.DimensionRecord`
113 The exposure record containing the timespan to query. If specified, all
114 other options are disallowed.
116 Returns
117 -------
118 begin : `astropy.Time`
119 The begin time for the query.
120 end : `astropy.Time`
121 The end time for the query.
122 """
123 if expRecord is not None:
124 forbiddenOpts = [event, begin, end, timespan, dayObs]
125 if any(x is not None for x in forbiddenOpts):
126 raise ValueError("You can't specify both an expRecord and a begin/end or timespan or dayObs")
127 begin = expRecord.timespan.begin
128 end = expRecord.timespan.end
129 return begin, end
131 if event is not None:
132 forbiddenOpts = [begin, end, timespan, dayObs]
133 if any(x is not None for x in forbiddenOpts):
134 raise ValueError("You can't specify both an event and a begin/end or timespan or dayObs")
135 begin = event.begin
136 end = event.end
137 return begin, end
139 # check for dayObs, and that other options aren't inconsistently specified
140 if dayObs is not None:
141 forbiddenOpts = [begin, end, timespan]
142 if any(x is not None for x in forbiddenOpts):
143 raise ValueError("You can't specify both a dayObs and a begin/end or timespan")
144 begin = getDayObsStartTime(dayObs)
145 end = getDayObsEndTime(dayObs)
146 return begin, end
147 # can now disregard dayObs entirely
149 if begin is None:
150 raise ValueError("You must specify either a dayObs or a begin/end or begin/timespan")
151 # can now rely on begin, so just need to deal with end/timespan
153 if end is None and timespan is None:
154 raise ValueError("If you specify a begin, you must specify either a end or a timespan")
155 if end is not None and timespan is not None:
156 raise ValueError("You can't specify both a end and a timespan")
157 if end is None:
158 assert timespan is not None
159 if timespan > datetime.timedelta(minutes=0):
160 end = begin + timespan # the normal case
161 else:
162 end = begin # the case where timespan is negative
163 begin = begin + timespan # adding the negative to the start, i.e. subtracting it to bring back
165 assert begin is not None
166 assert end is not None
167 return begin, end
170def getEfdData(
171 client: EfdClient,
172 topic: str,
173 *,
174 columns: list[str] | None = None,
175 prePadding: float = 0,
176 postPadding: float = 0,
177 dayObs: int | None = None,
178 begin: Time | None = None,
179 end: Time | None = None,
180 timespan: TimeDelta | None = None,
181 event: TMAEvent | None = None,
182 expRecord: DimensionRecord | None = None,
183 warn: bool = True,
184 raiseIfTopicNotInSchema: bool = True,
185) -> DataFrame:
186 """Get one or more EFD topics over a time range, synchronously.
188 The time range can be specified as either:
189 * a dayObs, in which case the full 24 hour period is used,
190 * a begin point and a end point,
191 * a begin point and a timespan.
192 * a mount event
193 * an exposure record
194 If it is desired to use an end time with a timespan, just specify it as the
195 begin time and use a negative timespan.
197 The results from all topics are merged into a single dataframe.
199 `raiseIfTopicNotInSchema` should only be set to `False` when running on the
200 summit or in utility code for topics which might have had no data taken
201 within the last <data_retention_period> (nominally 30 days). Once a topic
202 is in the schema at USDF it will always be there, and thus users there
203 never need worry about this, always using `False` will be fine. However, at
204 the summit things are a little less predictable, so something missing from
205 the schema doesn't necessarily mean a typo, and utility code shouldn't
206 raise when data has been expunged.
208 Parameters
209 ----------
210 client : `lsst_efd_client.efd_helper.EfdClient`
211 The EFD client to use.
212 topic : `str`
213 The topic to query.
214 columns : `list` of `str`, optional
215 The columns to query. If not specified, all columns are queried.
216 prePadding : `float`
217 The amount of time before the nominal start of the query to include, in
218 seconds.
219 postPadding : `float`
220 The amount of extra time after the nominal end of the query to include,
221 in seconds.
222 dayObs : `int`, optional
223 The dayObs to query. If specified, this is used to determine the begin
224 and end times.
225 begin : `astropy.Time`, optional
226 The begin time for the query. If specified, either a end time or a
227 timespan must be supplied.
228 end : `astropy.Time`, optional
229 The end time for the query. If specified, a begin time must also be
230 supplied.
231 timespan : `astropy.TimeDelta`, optional
232 The timespan for the query. If specified, a begin time must also be
233 supplied.
234 event : `lsst.summit.utils.efdUtils.TMAEvent`, optional
235 The event to query. If specified, this is used to determine the begin
236 and end times, and all other options are disallowed.
237 expRecord : `lsst.daf.butler.dimensions.DimensionRecord`, optional
238 The exposure record containing the timespan to query. If specified, all
239 other options are disallowed.
240 warn : bool, optional
241 If ``True``, warn when no data is found. Exists so that utility code
242 can disable warnings when checking for data, and therefore defaults to
243 ``True``.
244 raiseIfTopicNotInSchema : `bool`, optional
245 Whether to raise an error if the topic is not in the EFD schema.
247 Returns
248 -------
249 data : `pd.DataFrame`
250 The merged data from all topics.
252 Raises
253 ------
254 ValueError:
255 If the topics are not in the EFD schema.
256 ValueError:
257 If both a dayObs and a begin/end or timespan are specified.
258 ValueError:
259 If a begin time is specified but no end time or timespan.
261 """
262 # TODO: DM-40100 ideally should calls mpts as necessary so that users
263 # needn't care if things are packed
265 # supports aliases so that you can query with them. If there is no entry in
266 # the alias dict then it queries with the supplied key. The fact the schema
267 # is now being checked means this shouldn't be a problem now.
269 # TODO: RFC-948 Move this import back to top of file once is implemented.
270 import nest_asyncio
272 begin, end = _getBeginEnd(dayObs, begin, end, timespan, event, expRecord)
273 begin -= TimeDelta(prePadding, format="sec")
274 end += TimeDelta(postPadding, format="sec")
276 nest_asyncio.apply()
277 loop = asyncio.get_event_loop()
278 ret = loop.run_until_complete(
279 _getEfdData(
280 client=client,
281 topic=topic,
282 begin=begin,
283 end=end,
284 columns=columns,
285 raiseIfTopicNotInSchema=raiseIfTopicNotInSchema,
286 )
287 )
288 if ret.empty and warn:
289 log = logging.getLogger(__name__)
290 msg = ""
291 if raiseIfTopicNotInSchema:
292 f"Topic {topic} is in the schema, but "
293 msg += "no data was returned by the query for the specified time range"
294 log.warning(msg)
295 return ret
298async def _getEfdData(
299 client: EfdClient,
300 topic: str,
301 begin: Time,
302 end: Time,
303 columns: list[str] | None = None,
304 raiseIfTopicNotInSchema: bool = True,
305) -> DataFrame:
306 """Get data for a topic from the EFD over the specified time range.
308 Parameters
309 ----------
310 client : `lsst_efd_client.efd_helper.EfdClient`
311 The EFD client to use.
312 topic : `str`
313 The topic to query.
314 begin : `astropy.Time`
315 The begin time for the query.
316 end : `astropy.Time`
317 The end time for the query.
318 columns : `list` of `str`, optional
319 The columns to query. If not specified, all columns are returned.
320 raiseIfTopicNotInSchema : `bool`, optional
321 Whether to raise an error if the topic is not in the EFD schema.
323 Returns
324 -------
325 data : `pd.DataFrame`
326 The data from the query.
327 """
328 if columns is None:
329 columns = ["*"]
330 columns = list(ensure_iterable(columns))
332 availableTopics = await client.get_topics()
334 if topic not in availableTopics:
335 if raiseIfTopicNotInSchema:
336 raise ValueError(f"Topic {topic} not in EFD schema")
337 else:
338 log = logging.getLogger(__name__)
339 log.debug(f"Topic {topic} not in EFD schema, returning empty DataFrame")
340 return pd.DataFrame()
342 data = await client.select_time_series(topic, columns, begin.utc, end.utc)
344 return data
347def getMostRecentRowWithDataBefore(
348 client: EfdClient,
349 topic: str,
350 timeToLookBefore: Time,
351 warnStaleAfterNMinutes: float | int = 60 * 12,
352 maxSearchNMinutes: float | int | None = None,
353 where: Callable[[DataFrame], list[bool]] | None = None,
354 raiseIfTopicNotInSchema: bool = True,
355) -> Series:
356 """Get the most recent row of data for a topic before a given time.
358 Parameters
359 ----------
360 client : `lsst_efd_client.efd_helper.EfdClient`
361 The EFD client to use.
362 topic : `str`
363 The topic to query.
364 timeToLookBefore : `astropy.Time`
365 The time to look before.
366 warnStaleAfterNMinutes : `float`, optional
367 The number of minutes after which to consider the data stale and issue
368 a warning.
369 maxSearchNMinutes: `float` or None, optional
370 Maximum number of minutes to search before raising ValueError.
371 where: `Callable` or None, optional
372 A callable taking a single pd.Dataframe argument and returning a
373 boolean list indicating rows to consider.
374 raiseIfTopicNotInSchema : `bool`, optional
375 Whether to raise an error if the topic is not in the EFD schema.
377 Returns
378 -------
379 row : `pd.Series`
380 The row of data from the EFD containing the most recent data before the
381 specified time.
383 Raises
384 ------
385 ValueError:
386 If the topic is not in the EFD schema.
387 """
388 staleAge = datetime.timedelta(warnStaleAfterNMinutes)
390 earliest = getDayObsStartTime(20190101)
392 if timeToLookBefore < earliest:
393 raise ValueError(f"Requested time {timeToLookBefore} is before any data was put in the EFD")
395 if maxSearchNMinutes is not None:
396 earliest = max(earliest, timeToLookBefore - maxSearchNMinutes * u.min)
398 df = pd.DataFrame()
399 beginTime = timeToLookBefore
400 while df.empty and beginTime > earliest:
401 df = getEfdData(
402 client,
403 topic,
404 begin=beginTime,
405 timespan=-TIME_CHUNKING,
406 warn=False,
407 raiseIfTopicNotInSchema=raiseIfTopicNotInSchema,
408 )
409 beginTime -= TIME_CHUNKING
410 if where is not None and not df.empty:
411 df = df[where(df)]
413 if beginTime < earliest and df.empty: # search ended early
414 out = f"EFD searched backwards from {timeToLookBefore} to {earliest} and no data "
415 if where is not None:
416 out += "consistent with `where` predicate "
417 out += f"was found in {topic=}"
418 raise ValueError(out)
420 lastRow = df.iloc[-1]
421 commandTime = efdTimestampToAstropy(lastRow["private_efdStamp"])
423 commandAge = timeToLookBefore - commandTime
424 if commandAge > staleAge:
425 log = logging.getLogger(__name__)
426 log.warning(
427 f"Component {topic} was last set {commandAge.sec / 60:.1} minutes before the requested time"
428 )
430 return lastRow
433def makeEfdClient(testing: bool | None = False, databaseName: str | None = None) -> EfdClient:
434 """Automatically create an EFD client based on the site.
436 Parameters
437 ----------
438 testing : `bool`
439 Set to ``True`` if running in a test suite. This will default to using
440 the USDF EFD, for which data has been recorded for replay by the ``vcr`
441 package. Note data must be re-recorded to ``vcr`` from both inside and
442 outside the USDF when the package/data changes, due to the use of a
443 proxy meaning that the web requests are different depending on whether
444 the EFD is being contacted from inside and outside the USDF.
445 databaseName : `str`, optional
446 Name of the database within influxDB to query. If not provided, the
447 default specified by EfdClient() is used.
449 Returns
450 -------
451 efdClient : `lsst_efd_client.efd_helper.EfdClient`, optional
452 The EFD client to use for the current site.
453 """
454 efdKwargs: dict[str, Any] = {}
455 if databaseName is not None:
456 efdKwargs["db_name"] = databaseName
458 if not HAS_EFD_CLIENT:
459 raise RuntimeError("Could not create EFD client because importing lsst_efd_client failed.")
461 if testing:
462 return EfdClient("usdf_efd", **efdKwargs)
464 site = getSite()
465 if site == "UNKNOWN":
466 raise RuntimeError("Could not create EFD client as the site could not be determined")
468 if site == "summit":
469 return EfdClient("summit_efd", **efdKwargs)
470 if site == "tucson":
471 return EfdClient("tucson_teststand_efd", **efdKwargs)
472 if site == "base":
473 return EfdClient("base_efd", **efdKwargs)
474 if site in ["staff-rsp", "rubin-devl", "usdf-k8s"]:
475 return EfdClient("usdf_efd", **efdKwargs)
476 if site == "jenkins":
477 return EfdClient("usdf_efd", **efdKwargs)
479 raise RuntimeError(f"Could not create EFD client as the {site=} is not recognized")
482def expRecordToTimespan(expRecord: DimensionRecord) -> dict:
483 """Get the timespan from an exposure record.
485 Returns the timespan in a format where it can be used to directly unpack
486 into a efdClient.select_time_series() call.
488 Parameters
489 ----------
490 expRecord : `lsst.daf.butler.DimensionRecord`
491 The exposure record.
493 Returns
494 -------
495 timespanDict : `dict`
496 The timespan in a format that can be used to directly unpack into a
497 efdClient.select_time_series() call.
498 """
499 return {
500 "begin": expRecord.timespan.begin.utc,
501 "end": expRecord.timespan.end.utc,
502 }
505def efdTimestampToAstropy(timestamp: float) -> Time:
506 """Get an efd timestamp as an astropy.time.Time object.
508 Parameters
509 ----------
510 timestamp : `float`
511 The timestamp, as a float.
513 Returns
514 -------
515 time : `astropy.time.Time`
516 The timestamp as an astropy.time.Time object.
517 """
518 return Time(timestamp, format="unix")
521def astropyToEfdTimestamp(time: Time) -> float:
522 """Get astropy Time object as an efd timestamp
524 Parameters
525 ----------
526 time : `astropy.time.Time`
527 The time as an astropy.time.Time object.
529 Returns
530 -------
531 timestamp : `float`
532 The timestamp, in UTC, in unix seconds.
533 """
535 return time.utc.unix
538def clipDataToEvent(
539 df: DataFrame,
540 event: TMAEvent,
541 prePadding: float = 0,
542 postPadding: float = 0,
543 logger: logging.Logger | None = None,
544) -> DataFrame:
545 """Clip a padded dataframe to an event.
547 Parameters
548 ----------
549 df : `pd.DataFrame`
550 The dataframe to clip.
551 event : `lsst.summit.utils.efdUtils.TMAEvent`
552 The event to clip to.
553 prePadding : `float`, optional
554 The amount of time before the nominal start of the event to include, in
555 seconds.
556 postPadding : `float`, optional
557 The amount of extra time after the nominal end of the event to include,
558 in seconds.
559 logger : `logging.Logger`, optional
560 The logger to use. If not specified, a new one is created.
562 Returns
563 -------
564 clipped : `pd.DataFrame`
565 The clipped dataframe.
566 """
567 begin = event.begin.value - prePadding
568 end = event.end.value + postPadding
570 if logger is None:
571 logger = logging.getLogger(__name__)
573 if begin < df["private_efdStamp"].min():
574 logger.warning(f"Requested begin time {begin} is before the start of the data")
575 if end > df["private_efdStamp"].max():
576 logger.warning(f"Requested end time {end} is after the end of the data")
578 mask = (df["private_efdStamp"] >= begin) & (df["private_efdStamp"] <= end)
579 clipped_df = df.loc[mask].copy()
580 return clipped_df
583def offsetDayObs(dayObs: int, nDays: int) -> int:
584 """Offset a dayObs by a given number of days.
586 Parameters
587 ----------
588 dayObs : `int`
589 The dayObs, as an integer, e.g. 20231225
590 nDays : `int`
591 The number of days to offset the dayObs by.
593 Returns
594 -------
595 newDayObs : `int`
596 The new dayObs, as an integer, e.g. 20231225
597 """
598 d1 = datetime.datetime.strptime(str(dayObs), "%Y%m%d")
599 oneDay = datetime.timedelta(days=nDays)
600 return int((d1 + oneDay).strftime("%Y%m%d"))
603def calcNextDay(dayObs: int) -> int:
604 """Given an integer dayObs, calculate the next integer dayObs.
606 Integers are used for dayObs, but dayObs values are therefore not
607 contiguous due to month/year ends etc, so this utility provides a robust
608 way to get the integer dayObs which follows the one specified.
610 Parameters
611 ----------
612 dayObs : `int`
613 The dayObs, as an integer, e.g. 20231231
615 Returns
616 -------
617 nextDayObs : `int`
618 The next dayObs, as an integer, e.g. 20240101
619 """
620 return offsetDayObs(dayObs, 1)
623def calcPreviousDay(dayObs: int) -> int:
624 """Given an integer dayObs, calculate the next integer dayObs.
626 Integers are used for dayObs, but dayObs values are therefore not
627 contiguous due to month/year ends etc, so this utility provides a robust
628 way to get the integer dayObs which follows the one specified.
630 Parameters
631 ----------
632 dayObs : `int`
633 The dayObs, as an integer, e.g. 20231231
635 Returns
636 -------
637 nextDayObs : `int`
638 The next dayObs, as an integer, e.g. 20240101
639 """
640 return offsetDayObs(dayObs, -1)
643def getDayObsStartTime(dayObs: int) -> astropy.time.Time:
644 """Get the start of the given dayObs as an astropy.time.Time object.
646 The observatory rolls the date over at UTC-12.
648 Parameters
649 ----------
650 dayObs : `int`
651 The dayObs, as an integer, e.g. 20231225
653 Returns
654 -------
655 time : `astropy.time.Time`
656 The start of the dayObs as an astropy.time.Time object.
657 """
658 pythonDateTime = datetime.datetime.strptime(str(dayObs), "%Y%m%d")
659 return Time(pythonDateTime) + 12 * u.hour
662def getDayObsEndTime(dayObs: int) -> Time:
663 """Get the end of the given dayObs as an astropy.time.Time object.
665 Parameters
666 ----------
667 dayObs : `int`
668 The dayObs, as an integer, e.g. 20231225
670 Returns
671 -------
672 time : `astropy.time.Time`
673 The end of the dayObs as an astropy.time.Time object.
674 """
675 return getDayObsStartTime(dayObs) + 24 * u.hour
678def getDayObsForTime(time: Time) -> int:
679 """Get the dayObs in which an astropy.time.Time object falls.
681 Parameters
682 ----------
683 time : `astropy.time.Time`
684 The time.
686 Returns
687 -------
688 dayObs : `int`
689 The dayObs, as an integer, e.g. 20231225
690 """
691 twelveHours = datetime.timedelta(hours=-12)
692 offset = TimeDelta(twelveHours, format="datetime")
693 return int((time + offset).utc.isot[:10].replace("-", ""))
696@deprecated(
697 reason="getSubTopics() has been replaced by getTopics() and using wildcards. "
698 "Will be removed after w_2023_50.",
699 version="w_2023_40",
700 category=FutureWarning,
701)
702def getSubTopics(client: EfdClient, topic: str) -> list[str]:
703 """Get all the sub topics within a given topic.
705 Note that the topic need not be a complete one, for example, rather than
706 doing `getSubTopics(client, 'lsst.sal.ATMCS')` to get all the topics for
707 the AuxTel Mount Control System, you can do `getSubTopics(client,
708 'lsst.sal.AT')` to get all which relate to the AuxTel in general.
710 Parameters
711 ----------
712 client : `lsst_efd_client.efd_helper.EfdClient`
713 The EFD client to use.
714 topic : `str`
715 The topic to query.
717 Returns
718 -------
719 subTopics : `list` of `str`
720 The sub topics.
721 """
722 loop = asyncio.get_event_loop()
723 topics = loop.run_until_complete(client.get_topics())
724 return sorted([t for t in topics if t.startswith(topic)])
727def getTopics(client: EfdClient, toFind: str, caseSensitive: bool = False) -> list[str]:
728 """Return all the strings in topics which match the topic query string.
730 Supports wildcards, which are denoted as `*``, as per shell globs.
732 Example:
733 >>> # assume topics are ['apple', 'banana', 'grape']
734 >>> getTopics(, 'a*p*')
735 ['apple', 'grape']
737 Parameters
738 ----------
739 client : `lsst_efd_client.efd_helper.EfdClient`
740 The EFD client to use.
741 toFind : `str`
742 The query string, with optional wildcards denoted as *.
743 caseSensitive : `bool`, optional
744 If ``True``, the query is case sensitive. Defaults to ``False``.
746 Returns
747 -------
748 matches : `list` of `str`
749 The list of matching topics.
750 """
751 loop = asyncio.get_event_loop()
752 topics = loop.run_until_complete(client.get_topics())
754 # Replace wildcard with regex equivalent
755 pattern = toFind.replace("*", ".*")
756 flags = re.IGNORECASE if not caseSensitive else 0
758 matches = []
759 for topic in topics:
760 if re.match(pattern, topic, flags):
761 matches.append(topic)
763 return matches
766def getCommands(
767 client: EfdClient,
768 commands: list[str],
769 begin: Time,
770 end: Time,
771 prePadding: float,
772 postPadding: float,
773 timeFormat: str = "python",
774) -> dict[Time | Timestamp | datetime.datetime, str]:
775 """Retrieve the commands issued within a specified time range.
777 Parameters
778 ----------
779 client : `EfdClient`
780 The client object used to retrieve EFD data.
781 commands : `list`
782 A list of commands to retrieve.
783 begin : `astropy.time.Time`
784 The start time of the time range.
785 end : `astropy.time.Time`
786 The end time of the time range.
787 prePadding : `float`
788 The amount of time to pad before the begin time.
789 postPadding : `float`
790 The amount of time to pad after the end time.
791 timeFormat : `str`
792 One of 'pandas' or 'astropy' or 'python'. If 'pandas', the dictionary
793 keys will be pandas timestamps, if 'astropy' they will be astropy times
794 and if 'python' they will be python datetimes.
796 Returns
797 -------
798 commandTimes : `dict` [`time`, `str`]
799 A dictionary of the times at which the commands where issued. The type
800 that `time` takes is determined by the format key, and defaults to
801 python datetime.
803 Raises
804 ------
805 ValueError
806 Raise if there is already a command at a timestamp in the dictionary,
807 i.e. there is a collision.
808 """
809 if timeFormat not in ["pandas", "astropy", "python"]:
810 raise ValueError(f"format must be one of 'pandas', 'astropy' or 'python', not {timeFormat=}")
812 commands = list(ensure_iterable(commands))
814 commandTimes: dict[Time | Timestamp | datetime.datetime, str] = {}
815 for command in commands:
816 data = getEfdData(
817 client,
818 command,
819 begin=begin,
820 end=end,
821 prePadding=prePadding,
822 postPadding=postPadding,
823 warn=False, # most commands will not be issue so we expect many empty queries
824 raiseIfTopicNotInSchema=False,
825 )
826 for time, _ in data.iterrows():
827 # this is much the most simple data structure, and the chance
828 # of commands being *exactly* simultaneous is minimal so try
829 # it like this, and just raise if we get collisions for now. So
830 # far in testing this seems to be just fine.
832 timeKey = None
833 match timeFormat:
834 case "pandas":
835 timeKey = time
836 case "astropy":
837 timeKey = Time(time)
838 case "python":
839 assert isinstance(time, pd.Timestamp)
840 timeKey = time.to_pydatetime()
842 if timeKey in commandTimes:
843 msg = f"There is already a command at {timeKey=} - make a better data structure!"
844 msg += f"Colliding commands = {commandTimes[timeKey]} and {command}"
845 raise ValueError(msg)
846 commandTimes[timeKey] = command
847 return commandTimes