Coverage for python/lsst/summit/utils/efdUtils.py: 16%
191 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-17 08:55 +0000
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-17 08:55 +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
29import astropy
30import pandas as pd
31from astropy import units as u
32from astropy.time import Time, TimeDelta
33from deprecated.sphinx import deprecated
35import lsst.daf.butler as dafButler
36from lsst.utils.iteration import ensure_iterable
38if TYPE_CHECKING: 38 ↛ 39line 38 didn't jump to line 39, because the condition on line 38 was never true
39 from .tmaUtils import TMAEvent
41from .utils import getSite
43HAS_EFD_CLIENT = True
44try:
45 from lsst_efd_client import EfdClient
46except ImportError:
47 HAS_EFD_CLIENT = False
50__all__ = [
51 "getEfdData",
52 "getMostRecentRowWithDataBefore",
53 "makeEfdClient",
54 "expRecordToTimespan",
55 "efdTimestampToAstropy",
56 "astropyToEfdTimestamp",
57 "clipDataToEvent",
58 "calcNextDay",
59 "getDayObsStartTime",
60 "getDayObsEndTime",
61 "getDayObsForTime",
62 "getSubTopics", # deprecated, being removed in w_2023_50
63 "getTopics",
64 "getCommands",
65]
68COMMAND_ALIASES = {
69 "raDecTarget": "lsst.sal.MTPtg.command_raDecTarget",
70 "moveToTarget": "lsst.sal.MTMount.command_moveToTarget",
71 "startTracking": "lsst.sal.MTMount.command_startTracking",
72 "stopTracking": "lsst.sal.MTMount.command_stopTracking",
73 "trackTarget": "lsst.sal.MTMount.command_trackTarget", # issued at 20Hz - don't plot
74}
76# When looking backwards in time to find the most recent state event, look back
77# in chunks of this size. Too small, and there will be too many queries, too
78# large and there will be too much data returned unnecessarily, as we only need
79# one row by definition. Will tune this parameters in consultation with SQuaRE.
80TIME_CHUNKING = datetime.timedelta(minutes=15)
83def _getBeginEnd(
84 dayObs: int | None = None,
85 begin: astropy.time.Time | None = None,
86 end: astropy.time.Time | None = None,
87 timespan: astropy.time.TimeDelta | None = None,
88 event: TMAEvent | None = None,
89 expRecord: dafButler.DimensionRecord | None = None,
90) -> tuple[astropy.time.Time, astropy.time.Time]:
91 """Calculate the begin and end times to pass to _getEfdData, given the
92 kwargs passed to getEfdData.
94 Parameters
95 ----------
96 dayObs : `int`
97 The dayObs to query. If specified, this is used to determine the begin
98 and end times.
99 begin : `astropy.Time`
100 The begin time for the query. If specified, either an end time or a
101 timespan must be supplied.
102 end : `astropy.Time`
103 The end time for the query. If specified, a begin time must also be
104 supplied.
105 timespan : `astropy.TimeDelta`
106 The timespan for the query. If specified, a begin time must also be
107 supplied.
108 event : `lsst.summit.utils.efdUtils.TMAEvent`
109 The event to query. If specified, this is used to determine the begin
110 and end times, and all other options are disallowed.
111 expRecord : `lsst.daf.butler.dimensions.DimensionRecord`
112 The exposure record containing the timespan to query. If specified, all
113 other options are disallowed.
115 Returns
116 -------
117 begin : `astropy.Time`
118 The begin time for the query.
119 end : `astropy.Time`
120 The end time for the query.
121 """
122 if expRecord is not None:
123 forbiddenOpts = [event, begin, end, timespan, dayObs]
124 if any(x is not None for x in forbiddenOpts):
125 raise ValueError("You can't specify both an expRecord and a begin/end or timespan or dayObs")
126 begin = expRecord.timespan.begin
127 end = expRecord.timespan.end
128 return begin, end
130 if event is not None:
131 forbiddenOpts = [begin, end, timespan, dayObs]
132 if any(x is not None for x in forbiddenOpts):
133 raise ValueError("You can't specify both an event and a begin/end or timespan or dayObs")
134 begin = event.begin
135 end = event.end
136 return begin, end
138 # check for dayObs, and that other options aren't inconsistently specified
139 if dayObs is not None:
140 forbiddenOpts = [begin, end, timespan]
141 if any(x is not None for x in forbiddenOpts):
142 raise ValueError("You can't specify both a dayObs and a begin/end or timespan")
143 begin = getDayObsStartTime(dayObs)
144 end = getDayObsEndTime(dayObs)
145 return begin, end
146 # can now disregard dayObs entirely
148 if begin is None:
149 raise ValueError("You must specify either a dayObs or a begin/end or begin/timespan")
150 # can now rely on begin, so just need to deal with end/timespan
152 if end is None and timespan is None:
153 raise ValueError("If you specify a begin, you must specify either a end or a timespan")
154 if end is not None and timespan is not None:
155 raise ValueError("You can't specify both a end and a timespan")
156 if end is None:
157 assert timespan is not None
158 if timespan > datetime.timedelta(minutes=0):
159 end = begin + timespan # the normal case
160 else:
161 end = begin # the case where timespan is negative
162 begin = begin + timespan # adding the negative to the start, i.e. subtracting it to bring back
164 assert begin is not None
165 assert end is not None
166 return begin, end
169def getEfdData(
170 client: EfdClient,
171 topic: str,
172 *,
173 columns: list[str] | None = None,
174 prePadding: float = 0,
175 postPadding: float = 0,
176 dayObs: int | None = None,
177 begin: astropy.Time | None = None,
178 end: astropy.Time | None = None,
179 timespan: astropy.TimeDelta | None = None,
180 event: TMAEvent | None = None,
181 expRecord: dafButler.dimensions.DimensionRecord | None = None,
182 warn: bool = True,
183) -> pd.DataFrame:
184 """Get one or more EFD topics over a time range, synchronously.
186 The time range can be specified as either:
187 * a dayObs, in which case the full 24 hour period is used,
188 * a begin point and a end point,
189 * a begin point and a timespan.
190 * a mount event
191 * an exposure record
192 If it is desired to use an end time with a timespan, just specify it as the
193 begin time and use a negative timespan.
195 The results from all topics are merged into a single dataframe.
197 Parameters
198 ----------
199 client : `lsst_efd_client.efd_helper.EfdClient`
200 The EFD client to use.
201 topic : `str`
202 The topic to query.
203 columns : `list` of `str`, optional
204 The columns to query. If not specified, all columns are queried.
205 prePadding : `float`
206 The amount of time before the nominal start of the query to include, in
207 seconds.
208 postPadding : `float`
209 The amount of extra time after the nominal end of the query to include,
210 in seconds.
211 dayObs : `int`, optional
212 The dayObs to query. If specified, this is used to determine the begin
213 and end times.
214 begin : `astropy.Time`, optional
215 The begin time for the query. If specified, either a end time or a
216 timespan must be supplied.
217 end : `astropy.Time`, optional
218 The end time for the query. If specified, a begin time must also be
219 supplied.
220 timespan : `astropy.TimeDelta`, optional
221 The timespan for the query. If specified, a begin time must also be
222 supplied.
223 event : `lsst.summit.utils.efdUtils.TMAEvent`, optional
224 The event to query. If specified, this is used to determine the begin
225 and end times, and all other options are disallowed.
226 expRecord : `lsst.daf.butler.dimensions.DimensionRecord`, optional
227 The exposure record containing the timespan to query. If specified, all
228 other options are disallowed.
229 warn : bool, optional
230 If ``True``, warn when no data is found. Exists so that utility code
231 can disable warnings when checking for data, and therefore defaults to
232 ``True``.
234 Returns
235 -------
236 data : `pd.DataFrame`
237 The merged data from all topics.
239 Raises
240 ------
241 ValueError:
242 If the topics are not in the EFD schema.
243 ValueError:
244 If both a dayObs and a begin/end or timespan are specified.
245 ValueError:
246 If a begin time is specified but no end time or timespan.
248 """
249 # TODO: DM-40100 ideally should calls mpts as necessary so that users
250 # needn't care if things are packed
252 # supports aliases so that you can query with them. If there is no entry in
253 # the alias dict then it queries with the supplied key. The fact the schema
254 # is now being checked means this shouldn't be a problem now.
256 # TODO: RFC-948 Move this import back to top of file once is implemented.
257 import nest_asyncio
259 begin, end = _getBeginEnd(dayObs, begin, end, timespan, event, expRecord)
260 begin -= TimeDelta(prePadding, format="sec")
261 end += TimeDelta(postPadding, format="sec")
263 nest_asyncio.apply()
264 loop = asyncio.get_event_loop()
265 ret = loop.run_until_complete(
266 _getEfdData(client=client, topic=topic, begin=begin, end=end, columns=columns)
267 )
268 if ret.empty and warn:
269 log = logging.getLogger(__name__)
270 log.warning(
271 f"Topic {topic} is in the schema, but no data was returned by the query for the specified"
272 " time range"
273 )
274 return ret
277async def _getEfdData(
278 client: EfdClient,
279 topic: str,
280 begin: astropy.Time,
281 end: astropy.Time,
282 columns: list[str] | None = None,
283) -> pd.DataFrame:
284 """Get data for a topic from the EFD over the specified time range.
286 Parameters
287 ----------
288 client : `lsst_efd_client.efd_helper.EfdClient`
289 The EFD client to use.
290 topic : `str`
291 The topic to query.
292 begin : `astropy.Time`
293 The begin time for the query.
294 end : `astropy.Time`
295 The end time for the query.
296 columns : `list` of `str`, optional
297 The columns to query. If not specified, all columns are returned.
299 Returns
300 -------
301 data : `pd.DataFrame`
302 The data from the query.
303 """
304 if columns is None:
305 columns = ["*"]
306 columns = list(ensure_iterable(columns))
308 availableTopics = await client.get_topics()
310 if topic not in availableTopics:
311 raise ValueError(f"Topic {topic} not in EFD schema")
313 data = await client.select_time_series(topic, columns, begin.utc, end.utc)
315 return data
318def getMostRecentRowWithDataBefore(
319 client: EfdClient,
320 topic: str,
321 timeToLookBefore: astropy.Time,
322 warnStaleAfterNMinutes: float | int = 60 * 12,
323) -> pd.Series:
324 """Get the most recent row of data for a topic before a given time.
326 Parameters
327 ----------
328 client : `lsst_efd_client.efd_helper.EfdClient`
329 The EFD client to use.
330 topic : `str`
331 The topic to query.
332 timeToLookBefore : `astropy.Time`
333 The time to look before.
334 warnStaleAfterNMinutes : `float`, optional
335 The number of minutes after which to consider the data stale and issue
336 a warning.
338 Returns
339 -------
340 row : `pd.Series`
341 The row of data from the EFD containing the most recent data before the
342 specified time.
344 Raises
345 ------
346 ValueError:
347 If the topic is not in the EFD schema.
348 """
349 staleAge = datetime.timedelta(warnStaleAfterNMinutes)
351 firstDayPossible = getDayObsStartTime(20190101)
353 if timeToLookBefore < firstDayPossible:
354 raise ValueError(f"Requested time {timeToLookBefore} is before any data was put in the EFD")
356 df = pd.DataFrame()
357 beginTime = timeToLookBefore
358 while df.empty and beginTime > firstDayPossible:
359 df = getEfdData(client, topic, begin=beginTime, timespan=-TIME_CHUNKING, warn=False)
360 beginTime -= TIME_CHUNKING
362 if beginTime < firstDayPossible and df.empty: # we ran all the way back to the beginning of time
363 raise ValueError(
364 f"The entire EFD was searched backwards from {timeToLookBefore} and no data was "
365 f"found in {topic=}"
366 )
368 lastRow = df.iloc[-1]
369 commandTime = efdTimestampToAstropy(lastRow["private_efdStamp"])
371 commandAge = timeToLookBefore - commandTime
372 if commandAge > staleAge:
373 log = logging.getLogger(__name__)
374 log.warning(
375 f"Component {topic} was last set {commandAge.sec/60:.1} minutes" " before the requested time"
376 )
378 return lastRow
381def makeEfdClient(testing: bool | None = False) -> EfdClient:
382 """Automatically create an EFD client based on the site.
384 Parameters
385 ----------
386 testing : `bool`
387 Set to ``True`` if running in a test suite. This will default to using
388 the USDF EFD, for which data has been recorded for replay by the ``vcr`
389 package. Note data must be re-recorded to ``vcr`` from both inside and
390 outside the USDF when the package/data changes, due to the use of a
391 proxy meaning that the web requests are different depending on whether
392 the EFD is being contacted from inside and outside the USDF.
394 Returns
395 -------
396 efdClient : `lsst_efd_client.efd_helper.EfdClient`, optional
397 The EFD client to use for the current site.
398 """
399 if not HAS_EFD_CLIENT:
400 raise RuntimeError("Could not create EFD client because importing lsst_efd_client failed.")
402 if testing:
403 return EfdClient("usdf_efd")
405 try:
406 site = getSite()
407 except ValueError as e:
408 raise RuntimeError("Could not create EFD client as the site could not be determined") from e
410 if site == "summit":
411 return EfdClient("summit_efd")
412 if site == "tucson":
413 return EfdClient("tucson_teststand_efd")
414 if site == "base":
415 return EfdClient("base_efd")
416 if site in ["staff-rsp", "rubin-devl", "usdf-k8s"]:
417 return EfdClient("usdf_efd")
418 if site == "jenkins":
419 return EfdClient("usdf_efd")
421 raise RuntimeError(f"Could not create EFD client as the {site=} is not recognized")
424def expRecordToTimespan(expRecord: dafButler.DimensionRecord) -> dict:
425 """Get the timespan from an exposure record.
427 Returns the timespan in a format where it can be used to directly unpack
428 into a efdClient.select_time_series() call.
430 Parameters
431 ----------
432 expRecord : `lsst.daf.butler.DimensionRecord`
433 The exposure record.
435 Returns
436 -------
437 timespanDict : `dict`
438 The timespan in a format that can be used to directly unpack into a
439 efdClient.select_time_series() call.
440 """
441 return {
442 "begin": expRecord.timespan.begin.utc,
443 "end": expRecord.timespan.end.utc,
444 }
447def efdTimestampToAstropy(timestamp: float) -> astropy.time.Time:
448 """Get an efd timestamp as an astropy.time.Time object.
450 Parameters
451 ----------
452 timestamp : `float`
453 The timestamp, as a float.
455 Returns
456 -------
457 time : `astropy.time.Time`
458 The timestamp as an astropy.time.Time object.
459 """
460 return Time(timestamp, format="unix")
463def astropyToEfdTimestamp(time: astropy.time.Time) -> float:
464 """Get astropy Time object as an efd timestamp
466 Parameters
467 ----------
468 time : `astropy.time.Time`
469 The time as an astropy.time.Time object.
471 Returns
472 -------
473 timestamp : `float`
474 The timestamp, in UTC, in unix seconds.
475 """
477 return time.utc.unix
480def clipDataToEvent(
481 df: pd.DataFrame,
482 event: TMAEvent,
483 prePadding: float = 0,
484 postPadding: float = 0,
485 logger: logging.Logger | None = None,
486) -> pd.DataFrame:
487 """Clip a padded dataframe to an event.
489 Parameters
490 ----------
491 df : `pd.DataFrame`
492 The dataframe to clip.
493 event : `lsst.summit.utils.efdUtils.TMAEvent`
494 The event to clip to.
495 prePadding : `float`, optional
496 The amount of time before the nominal start of the event to include, in
497 seconds.
498 postPadding : `float`, optional
499 The amount of extra time after the nominal end of the event to include,
500 in seconds.
501 logger : `logging.Logger`, optional
502 The logger to use. If not specified, a new one is created.
504 Returns
505 -------
506 clipped : `pd.DataFrame`
507 The clipped dataframe.
508 """
509 begin = event.begin.value - prePadding
510 end = event.end.value + postPadding
512 if logger is None:
513 logger = logging.getLogger(__name__)
515 if begin < df["private_efdStamp"].min():
516 logger.warning(f"Requested begin time {begin} is before the start of the data")
517 if end > df["private_efdStamp"].max():
518 logger.warning(f"Requested end time {end} is after the end of the data")
520 mask = (df["private_efdStamp"] >= begin) & (df["private_efdStamp"] <= end)
521 clipped_df = df.loc[mask].copy()
522 return clipped_df
525def offsetDayObs(dayObs: int, nDays: int):
526 """Offset a dayObs by a given number of days.
528 Parameters
529 ----------
530 dayObs : `int`
531 The dayObs, as an integer, e.g. 20231225
532 nDays : `int`
533 The number of days to offset the dayObs by.
535 Returns
536 -------
537 newDayObs : `int`
538 The new dayObs, as an integer, e.g. 20231225
539 """
540 d1 = datetime.datetime.strptime(str(dayObs), "%Y%m%d")
541 oneDay = datetime.timedelta(days=nDays)
542 return int((d1 + oneDay).strftime("%Y%m%d"))
545def calcNextDay(dayObs: int) -> int:
546 """Given an integer dayObs, calculate the next integer dayObs.
548 Integers are used for dayObs, but dayObs values are therefore not
549 contiguous due to month/year ends etc, so this utility provides a robust
550 way to get the integer dayObs which follows the one specified.
552 Parameters
553 ----------
554 dayObs : `int`
555 The dayObs, as an integer, e.g. 20231231
557 Returns
558 -------
559 nextDayObs : `int`
560 The next dayObs, as an integer, e.g. 20240101
561 """
562 return offsetDayObs(dayObs, 1)
565def calcPreviousDay(dayObs: int) -> int:
566 """Given an integer dayObs, calculate the next integer dayObs.
568 Integers are used for dayObs, but dayObs values are therefore not
569 contiguous due to month/year ends etc, so this utility provides a robust
570 way to get the integer dayObs which follows the one specified.
572 Parameters
573 ----------
574 dayObs : `int`
575 The dayObs, as an integer, e.g. 20231231
577 Returns
578 -------
579 nextDayObs : `int`
580 The next dayObs, as an integer, e.g. 20240101
581 """
582 return offsetDayObs(dayObs, -1)
585def getDayObsStartTime(dayObs: int) -> astropy.time.Time:
586 """Get the start of the given dayObs as an astropy.time.Time object.
588 The observatory rolls the date over at UTC-12.
590 Parameters
591 ----------
592 dayObs : `int`
593 The dayObs, as an integer, e.g. 20231225
595 Returns
596 -------
597 time : `astropy.time.Time`
598 The start of the dayObs as an astropy.time.Time object.
599 """
600 pythonDateTime = datetime.datetime.strptime(str(dayObs), "%Y%m%d")
601 return Time(pythonDateTime) + 12 * u.hour
604def getDayObsEndTime(dayObs: int) -> astropy.time.Time:
605 """Get the end of the given dayObs as an astropy.time.Time object.
607 Parameters
608 ----------
609 dayObs : `int`
610 The dayObs, as an integer, e.g. 20231225
612 Returns
613 -------
614 time : `astropy.time.Time`
615 The end of the dayObs as an astropy.time.Time object.
616 """
617 return getDayObsStartTime(dayObs) + 24 * u.hour
620def getDayObsForTime(time: astropy.time.Time) -> int:
621 """Get the dayObs in which an astropy.time.Time object falls.
623 Parameters
624 ----------
625 time : `astropy.time.Time`
626 The time.
628 Returns
629 -------
630 dayObs : `int`
631 The dayObs, as an integer, e.g. 20231225
632 """
633 twelveHours = datetime.timedelta(hours=-12)
634 offset = TimeDelta(twelveHours, format="datetime")
635 return int((time + offset).utc.isot[:10].replace("-", ""))
638@deprecated(
639 reason="getSubTopics() has been replaced by getTopics() and using wildcards. "
640 "Will be removed after w_2023_50.",
641 version="w_2023_40",
642 category=FutureWarning,
643)
644def getSubTopics(client: EfdClient, topic: str) -> list[str]:
645 """Get all the sub topics within a given topic.
647 Note that the topic need not be a complete one, for example, rather than
648 doing `getSubTopics(client, 'lsst.sal.ATMCS')` to get all the topics for
649 the AuxTel Mount Control System, you can do `getSubTopics(client,
650 'lsst.sal.AT')` to get all which relate to the AuxTel in general.
652 Parameters
653 ----------
654 client : `lsst_efd_client.efd_helper.EfdClient`
655 The EFD client to use.
656 topic : `str`
657 The topic to query.
659 Returns
660 -------
661 subTopics : `list` of `str`
662 The sub topics.
663 """
664 loop = asyncio.get_event_loop()
665 topics = loop.run_until_complete(client.get_topics())
666 return sorted([t for t in topics if t.startswith(topic)])
669def getTopics(client: EfdClient, toFind: str, caseSensitive: bool = False) -> list[str]:
670 """Return all the strings in topics which match the topic query string.
672 Supports wildcards, which are denoted as `*``, as per shell globs.
674 Example:
675 >>> # assume topics are ['apple', 'banana', 'grape']
676 >>> getTopics(, 'a*p*')
677 ['apple', 'grape']
679 Parameters
680 ----------
681 client : `lsst_efd_client.efd_helper.EfdClient`
682 The EFD client to use.
683 toFind : `str`
684 The query string, with optional wildcards denoted as *.
685 caseSensitive : `bool`, optional
686 If ``True``, the query is case sensitive. Defaults to ``False``.
688 Returns
689 -------
690 matches : `list` of `str`
691 The list of matching topics.
692 """
693 loop = asyncio.get_event_loop()
694 topics = loop.run_until_complete(client.get_topics())
696 # Replace wildcard with regex equivalent
697 pattern = toFind.replace("*", ".*")
698 flags = re.IGNORECASE if not caseSensitive else 0
700 matches = []
701 for topic in topics:
702 if re.match(pattern, topic, flags):
703 matches.append(topic)
705 return matches
708def getCommands(
709 client: EfdClient,
710 commands: list[str],
711 begin: astropy.time.Time,
712 end: astropy.time.Time,
713 prePadding: float,
714 postPadding: float,
715 timeFormat: str = "python",
716) -> dict[astropy.Time | pd.Timestamp | datetime.datetime, str]:
717 """Retrieve the commands issued within a specified time range.
719 Parameters
720 ----------
721 client : `EfdClient`
722 The client object used to retrieve EFD data.
723 commands : `list`
724 A list of commands to retrieve.
725 begin : `astropy.time.Time`
726 The start time of the time range.
727 end : `astropy.time.Time`
728 The end time of the time range.
729 prePadding : `float`
730 The amount of time to pad before the begin time.
731 postPadding : `float`
732 The amount of time to pad after the end time.
733 timeFormat : `str`
734 One of 'pandas' or 'astropy' or 'python'. If 'pandas', the dictionary
735 keys will be pandas timestamps, if 'astropy' they will be astropy times
736 and if 'python' they will be python datetimes.
738 Returns
739 -------
740 commandTimes : `dict` [`time`, `str`]
741 A dictionary of the times at which the commands where issued. The type
742 that `time` takes is determined by the format key, and defaults to
743 python datetime.
745 Raises
746 ------
747 ValueError
748 Raise if there is already a command at a timestamp in the dictionary,
749 i.e. there is a collision.
750 """
751 if timeFormat not in ["pandas", "astropy", "python"]:
752 raise ValueError(f"format must be one of 'pandas', 'astropy' or 'python', not {timeFormat=}")
754 commands = list(ensure_iterable(commands))
756 commandTimes: dict[astropy.Time | pd.Timestamp | datetime.datetime, str] = {}
757 for command in commands:
758 data = getEfdData(
759 client,
760 command,
761 begin=begin,
762 end=end,
763 prePadding=prePadding,
764 postPadding=postPadding,
765 warn=False, # most commands will not be issue so we expect many empty queries
766 )
767 for time, _ in data.iterrows():
768 # this is much the most simple data structure, and the chance
769 # of commands being *exactly* simultaneous is minimal so try
770 # it like this, and just raise if we get collisions for now. So
771 # far in testing this seems to be just fine.
773 timeKey = None
774 match timeFormat:
775 case "pandas":
776 timeKey = time
777 case "astropy":
778 timeKey = Time(time)
779 case "python":
780 timeKey = time.to_pydatetime()
782 if timeKey in commandTimes:
783 raise ValueError(
784 f"There is already a command at {timeKey=} -" " make a better data structure!"
785 )
786 commandTimes[timeKey] = command
787 return commandTimes