Coverage for python/lsst/summit/utils/blockUtils.py: 20%
184 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-14 03:12 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-14 03:12 -0700
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 logging
24import re
25import time
26from dataclasses import dataclass
27from typing import TYPE_CHECKING
29import numpy as np
30import pandas as pd
31from astropy.time import Time
33from .efdUtils import efdTimestampToAstropy, getEfdData, makeEfdClient
34from .enums import ScriptState
36if TYPE_CHECKING:
37 from .tmaUtils import TMAEvent
39 try:
40 from lsst_efd_client import EfdClient
41 except ImportError:
42 EfdClient = None # this is currently just for mypy
44__all__ = ("BlockParser", "BlockInfo", "ScriptStatePoint")
47@dataclass(kw_only=True, frozen=True)
48class BlockInfo:
49 """Information about the execution of a "block".
51 Each BlockInfo instance contains information about a single block
52 execution. This is identified by the block number and sequence number,
53 which, when combined with the dayObs, define the block ID.
55 Each BlockInfo instance contains the following information:
56 * The block ID - this is the primary identifier, as a string, for
57 example "BL52_20230615_02", which is parsed into:
58 * The block number, as an integer, for example 52, for "BLOCK-52".
59 * The dayObs, as an integer, for example 20230615.
60 * The seqNum - the execution number of that block on that day.
61 * The begin and end times of the block execution, as astropy.time.Time
62 * The SAL indices which were involved in the block execution, as a list
63 * The SITCOM tickets which were involved in the block execution, as a
64 list of strings, including the SITCOM- prefix.
65 * The states of the script during the block execution, as a list of
66 ``ScriptStatePoint`` instances.
68 Parameters
69 ----------
70 blockNumber : `str`
71 The block number, as a str - sometimes it'll be like 123 but others it
72 will be like "T123" for test blocks.
73 blockId : `str`
74 The block ID, as a string.
75 dayObs : `int`
76 The dayObs the block was run on.
77 seqNum : `int`
78 The sequence number of the block.
79 begin : `astropy.time.Time`
80 The time the block execution began.
81 end : `astropy.time.Time`
82 The time the block execution ended.
83 isTestCase : `bool`
84 Whether this block is a test case type block, or a regular block. This
85 is also reflected in the blockNumber.
86 salIndices : `list` of `int`
87 One or more SAL indices, relating to the block.
88 tickets : `list` of `str`
89 One or more SITCOM tickets, relating to the block.
90 states : `list` of `lsst.summit.utils.blockUtils.ScriptStatePoint`
91 The states of the script during the block. Each element is a
92 ``ScriptStatePoint`` which contains:
93 - the time, as an astropy.time.Time
94 - the state, as a ``ScriptState`` enum
95 - the reason for state change, as a string, if present
96 """
98 blockNumber: str
99 blockId: str
100 dayObs: int
101 seqNum: int
102 begin: Time
103 end: Time
104 salIndices: list
105 tickets: list
106 states: list
107 isTestCase: bool
109 def __repr__(self) -> str:
110 return (
111 f"BlockInfo(blockNumber={self.blockNumber}, blockId={self.blockId}, salIndices={self.salIndices},"
112 f" tickets={self.tickets}, states={self.states!r}"
113 )
115 def _ipython_display_(self) -> None:
116 """This is the function which runs when someone executes a cell in a
117 notebook with just the class instance on its own, without calling
118 print() or str() on it.
119 """
120 print(self.__str__())
122 def __str__(self) -> str:
123 # no literal \n allowed inside {} portion of f-strings until python
124 # 3.12, but it can go in via a variable
125 newline = " \n"
126 return (
127 f"dayObs: {self.dayObs}\n"
128 f"seqNum: {self.seqNum}\n"
129 f"blockNumber: {self.blockNumber}\n"
130 f"blockId: {self.blockId}\n"
131 f"begin: {self.begin.isot}\n"
132 f"end: {self.end.isot}\n"
133 f"salIndices: {self.salIndices}\n"
134 f"tickets: {self.tickets}\n"
135 f"states: \n{newline.join([str(state) for state in self.states])}"
136 )
139@dataclass(kw_only=True, frozen=True)
140class ScriptStatePoint:
141 """The execution state of a script at a point in time.
143 Parameters
144 ----------
145 time : `astropy.time.Time`
146 The time of the state change.
147 state : `lsst.summit.utils.enums.ScriptState`
148 The state of the script at this point in time.
149 reason : `str`
150 The reason for the state change, if given.
151 """
153 time: Time
154 state: ScriptState
155 reason: str
157 def __repr__(self) -> str:
158 return f"ScriptStatePoint(time={self.time!r}, state={self.state!r}, reason={self.reason!r})"
160 def _ipython_display_(self) -> None:
161 """This is the function which runs when someone executes a cell in a
162 notebook with just the class instance on its own, without calling
163 print() or str() on it.
164 """
165 print(self.__str__())
167 def __str__(self) -> str:
168 reasonStr = f" - {self.reason}" if self.reason else ""
169 return f"{self.state.name:>10} @ {self.time.isot}{reasonStr}"
172class BlockParser:
173 """A class to parse BLOCK data from the EFD.
175 Information on executed blocks is stored in the EFD (Electronic Facilities
176 Database) in the ``lsst.sal.Script.logevent_state`` topic. This class
177 parses that topic and provides methods to get information on the blocks
178 which were run on a given dayObs. It also provides methods to get the
179 events which occurred during a given block, and also to get the block in
180 which a specified event occurred, if any.
182 Parameters
183 ----------
184 dayObs : `int`
185 The dayObs to get the block data for.
186 client : `lsst_efd_client.efd_client.EfdClient`, optional
187 The EFD client to use. If not specified, a new one is created.
188 """
190 def __init__(self, dayObs: int, client: EfdClient | None = None) -> None:
191 self.log = logging.getLogger("lsst.summit.utils.blockUtils.BlockParser")
192 self.dayObs = dayObs
194 self.client = client
195 if client is None:
196 self.client = makeEfdClient()
198 t0 = time.time()
199 self.getDataForDayObs()
200 self.log.debug(f"Getting data took {(time.time() - t0):.2f} seconds")
201 t0 = time.time()
202 self.augmentData()
203 self.log.debug(f"Parsing data took {(time.time() - t0):.5f} seconds")
205 def getDataForDayObs(self) -> None:
206 """Retrieve the data for the specified dayObs from the EFD."""
207 # Tiago thinks no individual block seqNums should take more than an
208 # hour to run, so pad the dayObs by 1.5 hours to make sure we catch
209 # any blocks which might span the end of the day.
210 padding = 1.5 * 60 * 60
211 data = getEfdData(
212 self.client,
213 "lsst.sal.Script.logevent_state",
214 dayObs=self.dayObs,
215 postPadding=padding,
216 raiseIfTopicNotInSchema=False,
217 )
218 self.data = data
220 def augmentData(self) -> None:
221 """Parse each row in the data frame individually, pulling the
222 information out into its own columns.
223 """
224 data = self.data
225 blockPattern = r"BLOCK-(\d+)"
226 blockIdPattern = r"B[LT]\d+(?:_\w+)+"
228 data["blockNum"] = pd.Series()
229 data["blockDayObs"] = pd.Series()
230 data["blockSeqNum"] = pd.Series()
231 data["isTestCase"] = pd.Series()
233 if "lastCheckpoint" not in self.data.columns:
234 nRows = len(self.data)
235 self.log.warning(
236 f"Found {nRows} rows of data and no 'lastCheckpoint' column was in the data,"
237 " so block data cannot be parsed."
238 )
240 # at some point the blockId column was added to the data, so if it's
241 # present, we can use it to extract the blockNum and other information,
242 # but before that we have to parse it out of the lastCheckpoint column
243 if "blockId" in data.columns:
244 blockNumberPattern = re.compile(r"[A-Z]*([0-9]+)(?=_)")
246 for index, row in data.iterrows():
247 # an example blockIdStr value is like BL365_O_20250420_000001
248 blockIdStr = row["blockId"]
249 match = blockNumberPattern.match(blockIdStr)
250 if not match: # we've failed to get the 365 part in the example
251 continue
252 blockNum = match.group(1)
254 idStrSplit = blockIdStr.split("_")
255 blockDayObs = int(idStrSplit[2])
256 if blockDayObs != self.dayObs:
257 continue # we're in the padded region
259 isTestCase = False
260 if idStrSplit[0].startswith("BT"):
261 isTestCase = True
263 blockSeqNum = int(idStrSplit[3])
264 data.at[index, "blockNum"] = f"{'T' if isTestCase else ''}{blockNum}"
265 data.at[index, "blockDayObs"] = int(blockDayObs)
266 data.at[index, "blockSeqNum"] = int(blockSeqNum)
267 data.at[index, "isTestCase"] = isTestCase
269 else:
270 data["blockId"] = pd.Series() # add it, as it's not present
271 for index, row in data.iterrows():
272 rowStr = row["lastCheckpoint"]
274 blockMatch = re.search(blockPattern, rowStr)
275 blockNumber = int(blockMatch.group(1)) if blockMatch else None
277 blockIdMatch = re.search(blockIdPattern, rowStr)
278 blockIdStr = blockIdMatch.group(0) if blockIdMatch else None
279 if blockIdStr is None:
280 continue
281 data.at[index, "blockId"] = blockIdStr
283 idStrSplit = blockIdStr.split("_")
284 blockDayObs = int(idStrSplit[2])
285 if blockDayObs != self.dayObs:
286 continue # we're in the padded region
288 isTestCase = False
289 if idStrSplit[0].startswith("BT"):
290 isTestCase = True
292 data.at[index, "blockNum"] = f"{'T' if isTestCase else ''}{blockNumber}"
293 data.at[index, "isTestCase"] = isTestCase
294 blockDayObs = int(idStrSplit[2])
295 blockSeqNum = int(idStrSplit[3])
296 data.at[index, "blockDayObs"] = blockDayObs
297 data.at[index, "blockSeqNum"] = blockSeqNum
299 def _listColumnValues(self, column: str, removeNone: bool = True) -> list[str]:
300 """Get all the different values for the specified column, as a list.
302 Parameters
303 ----------
304 column : `str`
305 The column to get the values for.
306 removeNone : `bool`
307 Whether to remove None from the list of values.
309 Returns
310 -------
311 values : `list`
312 The values for the specified column.
313 """
314 values = set(self.data[column].dropna())
315 if None in values and removeNone:
316 values.remove(None)
317 return sorted(values)
319 def getBlockNums(self) -> list[str]:
320 """Get the block numbers which were run on the specified dayObs.
322 Returns
323 -------
324 blockNums : `list` of `int`
325 The blocks which were run on the specified dayObs.
326 """
327 return self._listColumnValues("blockNum")
329 def getSeqNums(self, block: int | str) -> list[int]:
330 """Get the seqNums for the specified block.
332 Parameters
333 ----------
334 block : `int`
335 The block name or number to get the events for, e.g. 123 or T123.
337 Returns
338 -------
339 seqNums : `list` of `int`
340 The sequence numbers for the specified block.
341 """
342 if isinstance(block, int):
343 block = str(block)
345 seqNums = self.data[self.data["blockNum"] == block]["blockSeqNum"]
346 # block header rows have no blockId or seqNum, but do have a blockNum
347 # so appear here, so drop the nans as they don't relate to an actual
348 # run of a block
349 seqNums = seqNums.dropna()
350 return sorted(set(seqNums))
352 def getRows(self, block: str | int, seqNum: int | None = None) -> pd.DataFrame:
353 """Get all rows of data which relate to the specified block.
355 If the seqNum is specified, only the rows for that sequence number are
356 returned, otherwise all the rows relating to any block execution that
357 day are returned. If the specified seqNum doesn't occur on the current
358 day, an empty dataframe is returned.
360 Parameters
361 ----------
362 block : `int`
363 The block number to get the events for.
364 seqNum : `int`, optional
365 The sequence number, if specified, to get the row data for. If not
366 specified, all data for the specified block is returned.
368 Returns
369 -------
370 data : `pandas.DataFrame`
371 The row data.
372 """
373 if isinstance(block, int):
374 block = str(block)
376 # Because we query for a whole dayObs, but BLOCKs can overlap the day
377 # start/end, it's possible for the block's blockDayObs not to be the
378 # same as self.dayObs around the beginning or end of the day, so filter
379 # with an extra `& (self.data['blockDayObs'] == self.dayObs` when
380 # getting the relevant rows.
381 rowsForBlock = self.data[
382 np.logical_and(self.data["blockNum"] == block, self.data["blockDayObs"] == self.dayObs)
383 ]
384 if rowsForBlock.empty:
385 self.log.warning(f"No rows found for {block=} on dayObs={self.dayObs}")
386 if seqNum is None:
387 return rowsForBlock
388 return rowsForBlock[rowsForBlock["blockSeqNum"] == seqNum]
390 def printBlockEvolution(self, block: str | int, seqNum: int | None = None) -> None:
391 """Display the evolution of the specified block.
393 If the seqNum is specified, the evolution of that specific block
394 exection is displayed, otherwise all executions of that block are
395 printed.
397 Parameters
398 ----------
399 block : `int`
400 The block number to get the events for.
401 seqNum : `int`, optional
402 The sequence number, if specified, to print the evolution of. If
403 not specified, all sequence numbers for the block are printed.
404 """
405 if isinstance(block, int):
406 block = str(block)
408 if seqNum is None:
409 seqNums = self.getSeqNums(block)
410 else:
411 seqNums = [seqNum]
412 print(f"Evolution of BLOCK {block} for dayObs={self.dayObs} {seqNum=}:")
413 for seqNum in seqNums:
414 blockInfo = self.getBlockInfo(block, seqNum)
415 print(blockInfo, "\n")
417 def getBlockInfo(self, block: int | str, seqNum: int) -> BlockInfo | None:
418 """Get the block info for the specified block.
420 Parses the rows relating to this block execution, and returns
421 the information as a ``BlockInfo`` instance.
423 Parameters
424 ----------
425 block : `int`
426 The block number.
427 seqNum : `int`
428 The sequence number.
430 Returns
431 -------
432 blockInfo : `lsst.summit.utils.blockUtils.BlockInfo`
433 The block info.
434 """
435 if isinstance(block, int):
436 block = str(block)
438 rows = self.getRows(block, seqNum=seqNum)
439 if rows.empty:
440 print(f"No {seqNum=} on dayObs={self.dayObs} for {block=}")
441 return None
443 blockIds = set()
444 testCases = set()
445 tickets = set()
446 salIndices = set()
447 statePoints = []
448 sitcomPattern = r"SITCOM-(\d+)"
450 for index, row in rows.iterrows():
451 salIndices.add(row["salIndex"])
452 blockIds.add(row["blockId"])
453 testCases.add(row["isTestCase"])
455 lastCheckpoint = row["lastCheckpoint"]
456 sitcomMatches = re.findall(sitcomPattern, lastCheckpoint)
457 tickets.update(sitcomMatches)
459 time = efdTimestampToAstropy(row["private_efdStamp"])
460 state = ScriptState(row["state"])
461 reason = row["reason"]
462 statePoint = ScriptStatePoint(time=time, state=state, reason=reason)
463 statePoints.append(statePoint)
465 # check that blockIds, blockNames, and testCases are all length == 1
466 if any(len(s) != 1 for s in [blockIds, testCases]):
467 raise RuntimeError(
468 f"Expected exactly one unique value for blockIds and testCases, "
469 f"but found: blockIds={blockIds}, testCases={testCases} "
470 f"for {seqNum=}"
471 )
473 blockId = blockIds.pop()
474 isTestCase = testCases.pop()
476 blockInfo = BlockInfo(
477 blockNumber=block,
478 blockId=blockId,
479 dayObs=self.dayObs,
480 seqNum=seqNum,
481 begin=efdTimestampToAstropy(rows.iloc[0]["private_efdStamp"]),
482 end=efdTimestampToAstropy(rows.iloc[-1]["private_efdStamp"]),
483 salIndices=sorted(salIndices),
484 tickets=[f"SITCOM-{ticket}" for ticket in sorted(tickets)],
485 states=statePoints,
486 isTestCase=isTestCase,
487 )
489 return blockInfo
491 def getEventsForBlock(self, events: list[TMAEvent], block: str, seqNum: int) -> list[TMAEvent]:
492 """Get the events which occurred during the specified block.
494 Parameters
495 ----------
496 events : `list` of `lsst.summit.utils.tmaUtils.TMAEvent`
497 The list of candidate events.
498 block : `int`
499 The block number to get the events for.
500 seqNum : `int`
501 The sequence number to get the events for.
503 Returns
504 -------
505 events : `list` of `lsst.summit.utils.tmaUtils.TMAEvent`
506 The events.
507 """
508 blockInfo = self.getBlockInfo(block, seqNum)
509 if blockInfo is None:
510 return []
511 begin = blockInfo.begin
512 end = blockInfo.end
514 # each event's end being past the begin time and their
515 # starts being before the end time means we get all the
516 # events in the window and also those that overlap the
517 # start/end too
518 return [e for e in events if e.end >= begin and e.begin <= end]