trnrun 0.4.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trnrun/__init__.py +7 -0
- trnrun/bin/trnrun.exe +0 -0
- trnrun/config.py +161 -0
- trnrun/display.py +170 -0
- trnrun/events.py +307 -0
- trnrun/jobs.py +190 -0
- trnrun/manager.py +236 -0
- trnrun/process.py +72 -0
- trnrun/simulation.py +360 -0
- trnrun/utils.py +20 -0
- trnrun-0.4.0.dist-info/METADATA +264 -0
- trnrun-0.4.0.dist-info/RECORD +14 -0
- trnrun-0.4.0.dist-info/WHEEL +4 -0
- trnrun-0.4.0.dist-info/licenses/LICENSE +21 -0
trnrun/__init__.py
ADDED
trnrun/bin/trnrun.exe
ADDED
|
Binary file
|
trnrun/config.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Configuration for launching a TRNRun process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# -----------------------------------------------------------------
|
|
10
|
+
# Constants
|
|
11
|
+
# -----------------------------------------------------------------
|
|
12
|
+
# `trnrun.exe` is bundled inside this package under `bin/`.
|
|
13
|
+
_PACKAGE_DIR = Path(__file__).resolve().parent
|
|
14
|
+
BUNDLED_TRNRUN_PATH = _PACKAGE_DIR / "bin" / "trnrun.exe"
|
|
15
|
+
|
|
16
|
+
DEFAULT_TRNEXE_PATH = Path(r"C:\TRNSYS18\Exe\TrnEXE64.exe")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# -----------------------------------------------------------------
|
|
20
|
+
# Configuration
|
|
21
|
+
# -----------------------------------------------------------------
|
|
22
|
+
@dataclass
|
|
23
|
+
class SimulationConfig:
|
|
24
|
+
r"""Configuration used to launch TRNRun.
|
|
25
|
+
|
|
26
|
+
Each field maps to a `trnrun.exe` command-line flag (noted below as
|
|
27
|
+
`--flag`). Flags are passed as `--name:value`. These defaults mirror
|
|
28
|
+
trnrun's own CLI defaults: the `detect`/`watch`/`stall` timeouts are
|
|
29
|
+
`0` (unlimited/disabled), progress tracking (`watch_tmp`) is off, and
|
|
30
|
+
`clean_on_success`, `write_log`, and both kill-on-* behaviors are
|
|
31
|
+
disabled.
|
|
32
|
+
|
|
33
|
+
Attributes
|
|
34
|
+
----------
|
|
35
|
+
trnrun_path : Path, default `BUNDLED_TRNRUN_PATH`
|
|
36
|
+
Path to the `trnrun.exe` executable to invoke. Defaults to the copy
|
|
37
|
+
bundled with this package.
|
|
38
|
+
trnexe_path : Path, default `DEFAULT_TRNEXE_PATH`
|
|
39
|
+
Path to the TRNSYS executable (`TrnEXE64.exe` or `TrnEXE.exe`),
|
|
40
|
+
passed as `--trnexePath`. trnrun's own fallback is
|
|
41
|
+
`C:\\TRNSYS18\\Exe\\TrnEXE64.exe`; set this if TRNSYS is installed
|
|
42
|
+
elsewhere.
|
|
43
|
+
gui_visibility : str, default `"hidden"`
|
|
44
|
+
TRNSYS window behavior, passed as `--guiVisibility`. One of:
|
|
45
|
+
|
|
46
|
+
- `keep`/`keepOpen` - visible, stays open after the run.
|
|
47
|
+
- `auto`/`autoClose` - visible, closes after the run.
|
|
48
|
+
- `min`/`minimized` - minimized, stays open after the run.
|
|
49
|
+
- `minAuto`/`minimizedAuto` - minimized, closes after the run.
|
|
50
|
+
- `hidden` - no window, closes after the run.
|
|
51
|
+
|
|
52
|
+
wait_for_gui : bool, default `True`
|
|
53
|
+
Wait for a TRNSYS GUI as part of launch detection (`--waitForGui`).
|
|
54
|
+
Launch detection determines when startup has completed so the global
|
|
55
|
+
mutex can be released for the next simulation.
|
|
56
|
+
wait_for_lst : bool, default `True`
|
|
57
|
+
Wait for a specific string to appear in the `*.lst` file during
|
|
58
|
+
launch detection (`--waitForLst`).
|
|
59
|
+
wait_for_tmp : bool, default `False`
|
|
60
|
+
Wait for the `*.tmp` file to appear during launch detection
|
|
61
|
+
(`--waitForTmp`). Requires a Progress Tracker (Type3830) in the deck.
|
|
62
|
+
detect_timeout_ms : int, default `0`
|
|
63
|
+
Timeout in milliseconds for the launch-detection stages
|
|
64
|
+
(`--detectTimeout`). `0` means unlimited. Combined with
|
|
65
|
+
`kill_on_timeout`, exceeding this yields a `TIMEOUT` status.
|
|
66
|
+
extra_delay_ms : int, default `0`
|
|
67
|
+
Additional delay in milliseconds applied after detection passes
|
|
68
|
+
(`--extraDelay`).
|
|
69
|
+
poll_ms : int, default `100`
|
|
70
|
+
Polling interval in milliseconds for the output files and the process
|
|
71
|
+
(`--pollMs`).
|
|
72
|
+
watch_log : bool, default `True`
|
|
73
|
+
Stream `*.log` entries as `LOG` events (`--watchLog`).
|
|
74
|
+
watch_tmp : bool, default `False`
|
|
75
|
+
Stream `*.tmp` updates as `CONFIG`/`PROGRESS` events
|
|
76
|
+
(`--watchTmp`). Requires Type3830. This also gates progress-derived
|
|
77
|
+
outcomes: without it, `CANCELLED` and `STALLED` cannot be detected
|
|
78
|
+
and an early exit is reported as `DONE` instead.
|
|
79
|
+
watch_timeout_ms : int, default `0`
|
|
80
|
+
Maximum runtime-monitoring duration in milliseconds
|
|
81
|
+
(`--watchTimeout`). `0` means unlimited. Exceeding it corresponds
|
|
82
|
+
to a `TIMEOUT` status / exit code 124.
|
|
83
|
+
stall_timeout_ms : int, default `0`
|
|
84
|
+
Maximum wall-clock time in milliseconds with no simulation-time
|
|
85
|
+
progress before the run is considered stalled (`--stallTimeout`).
|
|
86
|
+
`0` disables the check. Requires `watch_tmp=True`. A stall yields a
|
|
87
|
+
`STALLED` status / exit code 125.
|
|
88
|
+
clean_on_success : bool, default `False`
|
|
89
|
+
On a successful run, delete the `*.tmp`, `*.log`, `*.lst`, and
|
|
90
|
+
`*.PTI` artifacts (`--clean`).
|
|
91
|
+
kill_on_timeout : bool, default `False`
|
|
92
|
+
Kill the TRNSYS process on a detection or watch timeout
|
|
93
|
+
(`--killOnTimeout`). If `False`, the runner waits for it to exit.
|
|
94
|
+
kill_on_stall : bool, default `False`
|
|
95
|
+
Kill the TRNSYS process when a stall is detected (`--killOnStall`).
|
|
96
|
+
If `False`, the runner waits for it to exit.
|
|
97
|
+
severity : str, default `"Notice"`
|
|
98
|
+
Minimum log severity to emit (`--severity`), one of `"Notice"`,
|
|
99
|
+
`"Warning"`, or `"Fatal"`.
|
|
100
|
+
write_log : bool, default `False`
|
|
101
|
+
Also append every emitted event to `<deckFile>.jsonl`
|
|
102
|
+
(`--writeLog`).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
trnrun_path: Path = BUNDLED_TRNRUN_PATH
|
|
106
|
+
trnexe_path: Path = DEFAULT_TRNEXE_PATH
|
|
107
|
+
gui_visibility: str = "hidden"
|
|
108
|
+
wait_for_gui: bool = True
|
|
109
|
+
wait_for_lst: bool = True
|
|
110
|
+
wait_for_tmp: bool = False
|
|
111
|
+
detect_timeout_ms: int = 0
|
|
112
|
+
extra_delay_ms: int = 0
|
|
113
|
+
poll_ms: int = 100
|
|
114
|
+
watch_log: bool = True
|
|
115
|
+
watch_tmp: bool = False
|
|
116
|
+
watch_timeout_ms: int = 0
|
|
117
|
+
stall_timeout_ms: int = 0
|
|
118
|
+
clean_on_success: bool = False
|
|
119
|
+
kill_on_timeout: bool = False
|
|
120
|
+
kill_on_stall: bool = False
|
|
121
|
+
severity: str = "Notice"
|
|
122
|
+
write_log: bool = False
|
|
123
|
+
|
|
124
|
+
def validate(self) -> None:
|
|
125
|
+
"""Check that both executables exist.
|
|
126
|
+
|
|
127
|
+
Raises
|
|
128
|
+
------
|
|
129
|
+
FileNotFoundError
|
|
130
|
+
If `trnrun_path` or `trnexe_path` is not a file.
|
|
131
|
+
"""
|
|
132
|
+
if not self.trnrun_path.is_file():
|
|
133
|
+
raise FileNotFoundError(f"TRNRun executable not found: {self.trnrun_path}")
|
|
134
|
+
if not self.trnexe_path.is_file():
|
|
135
|
+
raise FileNotFoundError(f"TrnEXE executable not found: {self.trnexe_path}")
|
|
136
|
+
|
|
137
|
+
def to_cli_args(self) -> list[str]:
|
|
138
|
+
"""Convert configuration into `trnrun.exe` command-line arguments."""
|
|
139
|
+
|
|
140
|
+
def boolean(value: bool) -> str:
|
|
141
|
+
return "true" if value else "false"
|
|
142
|
+
|
|
143
|
+
return [
|
|
144
|
+
f"--trnexePath:{self.trnexe_path}",
|
|
145
|
+
f"--guiVisibility:{self.gui_visibility}",
|
|
146
|
+
f"--waitForGui:{boolean(self.wait_for_gui)}",
|
|
147
|
+
f"--waitForLst:{boolean(self.wait_for_lst)}",
|
|
148
|
+
f"--waitForTmp:{boolean(self.wait_for_tmp)}",
|
|
149
|
+
f"--detectTimeout:{self.detect_timeout_ms}",
|
|
150
|
+
f"--extraDelay:{self.extra_delay_ms}",
|
|
151
|
+
f"--pollMs:{self.poll_ms}",
|
|
152
|
+
f"--watchLog:{boolean(self.watch_log)}",
|
|
153
|
+
f"--watchTmp:{boolean(self.watch_tmp)}",
|
|
154
|
+
f"--watchTimeout:{self.watch_timeout_ms}",
|
|
155
|
+
f"--stallTimeout:{self.stall_timeout_ms}",
|
|
156
|
+
f"--clean:{boolean(self.clean_on_success)}",
|
|
157
|
+
f"--killOnTimeout:{boolean(self.kill_on_timeout)}",
|
|
158
|
+
f"--killOnStall:{boolean(self.kill_on_stall)}",
|
|
159
|
+
f"--severity:{self.severity}",
|
|
160
|
+
f"--writeLog:{boolean(self.write_log)}",
|
|
161
|
+
]
|
trnrun/display.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Terminal display for TRNRun-manager simulations using Rich.
|
|
2
|
+
|
|
3
|
+
Provides live terminal output for running simulations and prints final
|
|
4
|
+
results when they complete. The display does not own simulation state; it
|
|
5
|
+
only renders the state provided by ``SimulationManager``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# pyright: reportUnusedCallResult=false
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from threading import Lock
|
|
13
|
+
|
|
14
|
+
from rich.console import Console, Group
|
|
15
|
+
from rich.live import Live
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from trnrun.simulation import Simulation, SimulationSnapshot
|
|
19
|
+
from trnrun.utils import format_hhmmss, truncate_left
|
|
20
|
+
|
|
21
|
+
# -----------------------------------------------------------------
|
|
22
|
+
# Constants
|
|
23
|
+
# -----------------------------------------------------------------
|
|
24
|
+
COLOR_MAP: dict[str, str | None] = {
|
|
25
|
+
"PENDING": None,
|
|
26
|
+
"RUNNING": None,
|
|
27
|
+
"DONE": "green",
|
|
28
|
+
"ERROR": "red",
|
|
29
|
+
"TIMEOUT": "red",
|
|
30
|
+
"STALLED": "red",
|
|
31
|
+
"CANCELLED": "yellow",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
PATH_WIDTH = 32
|
|
35
|
+
PROGRESS_BAR_WIDTH = 20
|
|
36
|
+
MS_PER_SECOND = 1000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# -----------------------------------------------------------------
|
|
40
|
+
# Null Display
|
|
41
|
+
# -----------------------------------------------------------------
|
|
42
|
+
class NullDisplay:
|
|
43
|
+
"""Display that renders nothing; for headless runs and tests."""
|
|
44
|
+
|
|
45
|
+
def simulation_started(self, _simulation: Simulation) -> None:
|
|
46
|
+
"""Ignore simulation start events."""
|
|
47
|
+
|
|
48
|
+
def simulation_finished(self, _simulation: Simulation) -> None:
|
|
49
|
+
"""Ignore simulation finish events."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# -----------------------------------------------------------------
|
|
53
|
+
# Display
|
|
54
|
+
# -----------------------------------------------------------------
|
|
55
|
+
class Display:
|
|
56
|
+
"""Live terminal view of currently running simulations.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
refresh_interval : float, optional
|
|
61
|
+
Time in seconds between live region refreshes. Must be positive.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, refresh_interval: float = 1.0) -> None:
|
|
65
|
+
if refresh_interval <= 0:
|
|
66
|
+
raise ValueError("refresh_interval must be positive")
|
|
67
|
+
|
|
68
|
+
self.console: Console = Console()
|
|
69
|
+
|
|
70
|
+
self._lock: Lock = Lock()
|
|
71
|
+
self._live_lock: Lock = Lock()
|
|
72
|
+
|
|
73
|
+
self._active: dict[int, Simulation] = {}
|
|
74
|
+
self._refresh_per_second: float = 1 / refresh_interval
|
|
75
|
+
self._live: Live | None = None
|
|
76
|
+
|
|
77
|
+
# -----------------------------------------------------------------
|
|
78
|
+
# Event Handlers
|
|
79
|
+
# -----------------------------------------------------------------
|
|
80
|
+
def simulation_started(self, simulation: Simulation) -> None:
|
|
81
|
+
"""Add a simulation to the live display."""
|
|
82
|
+
with self._live_lock:
|
|
83
|
+
with self._lock:
|
|
84
|
+
self._active[simulation.id] = simulation
|
|
85
|
+
|
|
86
|
+
if self._live is None:
|
|
87
|
+
live = self._make_live()
|
|
88
|
+
live.start()
|
|
89
|
+
self._live = live
|
|
90
|
+
|
|
91
|
+
def simulation_finished(self, simulation: Simulation) -> None:
|
|
92
|
+
"""Remove a simulation and print its final state."""
|
|
93
|
+
with self._live_lock:
|
|
94
|
+
with self._lock:
|
|
95
|
+
self._active.pop(simulation.id, None)
|
|
96
|
+
empty = not self._active
|
|
97
|
+
|
|
98
|
+
self.console.print(self._render_line(simulation.snapshot()))
|
|
99
|
+
|
|
100
|
+
if empty and self._live is not None:
|
|
101
|
+
live, self._live = self._live, None
|
|
102
|
+
live.stop()
|
|
103
|
+
|
|
104
|
+
# -----------------------------------------------------------------
|
|
105
|
+
# Rendering
|
|
106
|
+
# -----------------------------------------------------------------
|
|
107
|
+
def _make_live(self) -> Live:
|
|
108
|
+
"""Build a fresh transient live display."""
|
|
109
|
+
return Live(
|
|
110
|
+
get_renderable=self._render_all,
|
|
111
|
+
console=self.console,
|
|
112
|
+
refresh_per_second=self._refresh_per_second,
|
|
113
|
+
transient=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _progress_bar(self, percent: float, width: int = PROGRESS_BAR_WIDTH) -> str:
|
|
117
|
+
"""Return a fixed-width ASCII completion bar."""
|
|
118
|
+
filled = min(max(int(width * percent), 0), width)
|
|
119
|
+
return "[" + "#" * filled + "-" * (width - filled) + "]"
|
|
120
|
+
|
|
121
|
+
def _render_line(self, sim: SimulationSnapshot) -> Text:
|
|
122
|
+
"""Render one simulation snapshot."""
|
|
123
|
+
path = truncate_left(str(sim.deck_path), PATH_WIDTH)
|
|
124
|
+
|
|
125
|
+
status = sim.status.status if sim.status else "PENDING"
|
|
126
|
+
status_style = COLOR_MAP.get(status.upper())
|
|
127
|
+
|
|
128
|
+
logs = f"N:{sim.notices} W:{sim.warnings} F:{sim.fatals}"
|
|
129
|
+
|
|
130
|
+
progress = sim.progress
|
|
131
|
+
|
|
132
|
+
elapsed = format_hhmmss(progress.elapsed / MS_PER_SECOND if progress else None)
|
|
133
|
+
eta = format_hhmmss(progress.eta / MS_PER_SECOND if progress else None)
|
|
134
|
+
|
|
135
|
+
sim_time = progress.time if progress else None
|
|
136
|
+
percent = progress.percent if progress else None
|
|
137
|
+
|
|
138
|
+
config = sim.config_event
|
|
139
|
+
sim_stop = config.stop if config else None
|
|
140
|
+
|
|
141
|
+
if percent is None:
|
|
142
|
+
bar = "[" + "-" * PROGRESS_BAR_WIDTH + "]"
|
|
143
|
+
sim_percent = ""
|
|
144
|
+
else:
|
|
145
|
+
bar = self._progress_bar(percent)
|
|
146
|
+
sim_percent = f"({percent * 100:.0f}%)"
|
|
147
|
+
|
|
148
|
+
sim_progress = "- / -" if sim_time is None or sim_stop is None else f"{sim_time:6,.0f} / {sim_stop:6,.0f}"
|
|
149
|
+
|
|
150
|
+
text = Text()
|
|
151
|
+
text.append(f"[{sim.id}] ")
|
|
152
|
+
text.append(f"{path:<{PATH_WIDTH}} │ ")
|
|
153
|
+
text.append("Status: ")
|
|
154
|
+
text.append(f"{status:<10}", style=status_style)
|
|
155
|
+
text.append(" │ ")
|
|
156
|
+
text.append(f"Logs: {logs:<12} │ ")
|
|
157
|
+
text.append(f"Elapsed: {elapsed:<8} │ ETA: {eta:<8} │ ")
|
|
158
|
+
text.append(f"{bar} {sim_progress} {sim_percent:6}")
|
|
159
|
+
|
|
160
|
+
if sim.error is not None:
|
|
161
|
+
text.append(f" │ error: {sim.error}", style="red")
|
|
162
|
+
|
|
163
|
+
return text
|
|
164
|
+
|
|
165
|
+
def _render_all(self) -> Group:
|
|
166
|
+
"""Render all active simulations."""
|
|
167
|
+
with self._lock:
|
|
168
|
+
active = list(self._active.values())
|
|
169
|
+
|
|
170
|
+
return Group(*(self._render_line(sim.snapshot()) for sim in active))
|
trnrun/events.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Typed events emitted by TRNRun on stdout, and their JSONL parsers.
|
|
2
|
+
|
|
3
|
+
TRNRun writes one JSON object per line. ``parse_event`` turns a single
|
|
4
|
+
line into a typed event; ``stream_events`` does the same for an entire
|
|
5
|
+
stream of lines.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Final, cast
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# -----------------------------------------------------------------
|
|
17
|
+
# Exceptions
|
|
18
|
+
# -----------------------------------------------------------------
|
|
19
|
+
class EventParseError(ValueError):
|
|
20
|
+
"""Raised when a JSON line cannot be parsed into a TRNRun event."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# -----------------------------------------------------------------
|
|
24
|
+
# Events
|
|
25
|
+
# -----------------------------------------------------------------
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class StatusEvent:
|
|
28
|
+
"""A STATUS event reporting the run's current state.
|
|
29
|
+
|
|
30
|
+
Attributes
|
|
31
|
+
----------
|
|
32
|
+
status : str
|
|
33
|
+
State reported by TRNRun.
|
|
34
|
+
timestamp : str
|
|
35
|
+
Timestamp attached to the event by TRNRun.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
status: str
|
|
39
|
+
timestamp: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ProgressEvent:
|
|
44
|
+
"""A PROGRESS event reporting run completion and timing.
|
|
45
|
+
|
|
46
|
+
Attributes
|
|
47
|
+
----------
|
|
48
|
+
time : float
|
|
49
|
+
Current simulation time.
|
|
50
|
+
percent : float
|
|
51
|
+
Completion of the run as a fraction from 0 to 1.
|
|
52
|
+
elapsed : float
|
|
53
|
+
Wall-clock milliseconds elapsed since the run started.
|
|
54
|
+
eta : float
|
|
55
|
+
Estimated wall-clock milliseconds remaining.
|
|
56
|
+
timestamp : str
|
|
57
|
+
Timestamp attached to the event by TRNRun.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
time: float
|
|
61
|
+
percent: float
|
|
62
|
+
elapsed: float
|
|
63
|
+
eta: float
|
|
64
|
+
timestamp: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ConfigEvent:
|
|
69
|
+
"""A CONFIG event reporting the run's sweep parameters.
|
|
70
|
+
|
|
71
|
+
Attributes
|
|
72
|
+
----------
|
|
73
|
+
start : float
|
|
74
|
+
Simulation start time.
|
|
75
|
+
stop : float
|
|
76
|
+
Simulation stop time.
|
|
77
|
+
step : float
|
|
78
|
+
Simulation time step.
|
|
79
|
+
timestamp : str
|
|
80
|
+
Timestamp attached to the event by TRNRun.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
start: float
|
|
84
|
+
stop: float
|
|
85
|
+
step: float
|
|
86
|
+
timestamp: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class LogEvent:
|
|
91
|
+
"""A LOG event carrying a severity-tagged message.
|
|
92
|
+
|
|
93
|
+
Only ``severity`` and ``timestamp`` are guaranteed; the remaining
|
|
94
|
+
fields depend on what TRNRun attaches to the message.
|
|
95
|
+
|
|
96
|
+
Attributes
|
|
97
|
+
----------
|
|
98
|
+
severity : str
|
|
99
|
+
Severity tag, e.g. ``"notice"``, ``"warning"`` or ``"fatal"``.
|
|
100
|
+
timestamp : str
|
|
101
|
+
Timestamp attached to the event by TRNRun.
|
|
102
|
+
time : float or None
|
|
103
|
+
Simulation time at which the message was produced.
|
|
104
|
+
unit_id : int or None
|
|
105
|
+
Unit that emitted the message.
|
|
106
|
+
type_id : int or None
|
|
107
|
+
Type of the unit that emitted the message.
|
|
108
|
+
message_code : int or None
|
|
109
|
+
Numeric code identifying the message.
|
|
110
|
+
message : str or None
|
|
111
|
+
Human-readable message text.
|
|
112
|
+
information : str or None
|
|
113
|
+
Additional detail attached to the message.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
severity: str
|
|
117
|
+
timestamp: str
|
|
118
|
+
time: float | None = None
|
|
119
|
+
unit_id: int | None = None
|
|
120
|
+
type_id: int | None = None
|
|
121
|
+
message_code: int | None = None
|
|
122
|
+
message: str | None = None
|
|
123
|
+
information: str | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
type TrnRunEvent = StatusEvent | ProgressEvent | ConfigEvent | LogEvent
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# -----------------------------------------------------------------
|
|
130
|
+
# Validation Helpers
|
|
131
|
+
# -----------------------------------------------------------------
|
|
132
|
+
def _required(data: dict[str, object], key: str) -> object:
|
|
133
|
+
"""Return a required field, raising ``EventParseError`` if missing."""
|
|
134
|
+
try:
|
|
135
|
+
return data[key]
|
|
136
|
+
except KeyError as e:
|
|
137
|
+
raise EventParseError(f"missing required field '{key}'") from e
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _require_str(data: dict[str, object], key: str) -> str:
|
|
141
|
+
"""Return a required string field."""
|
|
142
|
+
value = _required(data, key)
|
|
143
|
+
|
|
144
|
+
if not isinstance(value, str):
|
|
145
|
+
raise EventParseError(f"field '{key}' must be a string")
|
|
146
|
+
|
|
147
|
+
return value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _require_float(data: dict[str, object], key: str) -> float:
|
|
151
|
+
"""Return a required numeric field as a float, rejecting booleans."""
|
|
152
|
+
value = _required(data, key)
|
|
153
|
+
|
|
154
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
155
|
+
raise EventParseError(f"field '{key}' must be a number")
|
|
156
|
+
|
|
157
|
+
return float(value)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _require_int(data: dict[str, object], key: str) -> int:
|
|
161
|
+
"""Return a required integer field, rejecting booleans."""
|
|
162
|
+
value = _required(data, key)
|
|
163
|
+
|
|
164
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
165
|
+
raise EventParseError(f"field '{key}' must be an integer")
|
|
166
|
+
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _optional_str(data: dict[str, object], key: str) -> str | None:
|
|
171
|
+
"""Return an optional string field, treating JSON null as absent."""
|
|
172
|
+
return None if data.get(key) is None else _require_str(data, key)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _optional_float(data: dict[str, object], key: str) -> float | None:
|
|
176
|
+
"""Return an optional numeric field, treating JSON null as absent."""
|
|
177
|
+
return None if data.get(key) is None else _require_float(data, key)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _optional_int(data: dict[str, object], key: str) -> int | None:
|
|
181
|
+
"""Return an optional integer field, treating JSON null as absent."""
|
|
182
|
+
return None if data.get(key) is None else _require_int(data, key)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# -----------------------------------------------------------------
|
|
186
|
+
# Event Parsers
|
|
187
|
+
# -----------------------------------------------------------------
|
|
188
|
+
def _parse_status(data: dict[str, object]) -> StatusEvent:
|
|
189
|
+
"""Parse a STATUS event."""
|
|
190
|
+
return StatusEvent(
|
|
191
|
+
status=_require_str(data, "status"),
|
|
192
|
+
timestamp=_require_str(data, "timestamp"),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_progress(data: dict[str, object]) -> ProgressEvent:
|
|
197
|
+
"""Parse a PROGRESS event."""
|
|
198
|
+
return ProgressEvent(
|
|
199
|
+
time=_require_float(data, "time"),
|
|
200
|
+
percent=_require_float(data, "percent"),
|
|
201
|
+
elapsed=_require_float(data, "elapsed"),
|
|
202
|
+
eta=_require_float(data, "eta"),
|
|
203
|
+
timestamp=_require_str(data, "timestamp"),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _parse_config(data: dict[str, object]) -> ConfigEvent:
|
|
208
|
+
"""Parse a CONFIG event."""
|
|
209
|
+
return ConfigEvent(
|
|
210
|
+
start=_require_float(data, "start"),
|
|
211
|
+
stop=_require_float(data, "stop"),
|
|
212
|
+
step=_require_float(data, "step"),
|
|
213
|
+
timestamp=_require_str(data, "timestamp"),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _parse_log(data: dict[str, object]) -> LogEvent:
|
|
218
|
+
"""Parse a LOG event."""
|
|
219
|
+
return LogEvent(
|
|
220
|
+
severity=_require_str(data, "severity"),
|
|
221
|
+
timestamp=_require_str(data, "timestamp"),
|
|
222
|
+
time=_optional_float(data, "time"),
|
|
223
|
+
unit_id=_optional_int(data, "unitId"),
|
|
224
|
+
type_id=_optional_int(data, "typeId"),
|
|
225
|
+
message_code=_optional_int(data, "messageCode"),
|
|
226
|
+
message=_optional_str(data, "message"),
|
|
227
|
+
information=_optional_str(data, "information"),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# Dispatch table mapping an event's "kind" to its parser.
|
|
232
|
+
_PARSERS: Final[dict[str, Callable[[dict[str, object]], TrnRunEvent]]] = {
|
|
233
|
+
"STATUS": _parse_status,
|
|
234
|
+
"PROGRESS": _parse_progress,
|
|
235
|
+
"CONFIG": _parse_config,
|
|
236
|
+
"LOG": _parse_log,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# -----------------------------------------------------------------
|
|
241
|
+
# Parsing
|
|
242
|
+
# -----------------------------------------------------------------
|
|
243
|
+
def parse_event(line: str) -> TrnRunEvent:
|
|
244
|
+
"""Parse one JSON-encoded TRNRun event.
|
|
245
|
+
|
|
246
|
+
Parameters
|
|
247
|
+
----------
|
|
248
|
+
line : str
|
|
249
|
+
A single line of TRNRun stdout containing one JSON object.
|
|
250
|
+
|
|
251
|
+
Returns
|
|
252
|
+
-------
|
|
253
|
+
TrnRunEvent
|
|
254
|
+
The typed event corresponding to the object's ``kind``.
|
|
255
|
+
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
value = cast("object", json.loads(line))
|
|
259
|
+
except json.JSONDecodeError as e:
|
|
260
|
+
raise EventParseError(f"invalid JSON: {e}") from e
|
|
261
|
+
|
|
262
|
+
if not isinstance(value, dict):
|
|
263
|
+
raise EventParseError("event must be a JSON object")
|
|
264
|
+
|
|
265
|
+
data = cast("dict[str, object]", value)
|
|
266
|
+
|
|
267
|
+
kind = _require_str(data, "kind").upper()
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
parser = _PARSERS[kind]
|
|
271
|
+
except KeyError as e:
|
|
272
|
+
raise EventParseError(f"unknown event kind '{kind}'") from e
|
|
273
|
+
|
|
274
|
+
return parser(data)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def stream_events(
|
|
278
|
+
lines: Iterable[str],
|
|
279
|
+
*,
|
|
280
|
+
skip_invalid: bool = False,
|
|
281
|
+
) -> Iterator[TrnRunEvent]:
|
|
282
|
+
"""Yield parsed events from a JSONL stream, skipping blank lines.
|
|
283
|
+
|
|
284
|
+
Parameters
|
|
285
|
+
----------
|
|
286
|
+
lines : Iterable[str]
|
|
287
|
+
Lines of TRNRun stdout, one JSON object per line.
|
|
288
|
+
skip_invalid : bool, optional
|
|
289
|
+
If true, silently drop lines that fail to parse instead of
|
|
290
|
+
raising. Defaults to False.
|
|
291
|
+
|
|
292
|
+
Yields
|
|
293
|
+
------
|
|
294
|
+
TrnRunEvent
|
|
295
|
+
One typed event per successfully parsed line.
|
|
296
|
+
"""
|
|
297
|
+
for raw_line in lines:
|
|
298
|
+
line = raw_line.strip()
|
|
299
|
+
|
|
300
|
+
if not line:
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
yield parse_event(line)
|
|
305
|
+
except EventParseError:
|
|
306
|
+
if not skip_invalid:
|
|
307
|
+
raise
|