taktwerk 0.6.0__tar.gz

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.
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: taktwerk
3
+ Version: 0.6.0
4
+ Summary: Read taktwerk run records and campaigns into arrays and data frames, and start runs
5
+ Keywords: simulation,co-simulation,fixed-step,records,campaign
6
+ License-Expression: MIT OR Apache-2.0
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Topic :: Scientific/Engineering
9
+ Requires-Dist: numpy>=2.2.6 ; extra == 'numpy'
10
+ Requires-Dist: pandas>=2.3.3 ; extra == 'pandas'
11
+ Requires-Python: >=3.10
12
+ Project-URL: Homepage, https://labinetix.github.io/taktwerk/
13
+ Project-URL: Documentation, https://labinetix.github.io/taktwerk/python.html
14
+ Provides-Extra: numpy
15
+ Provides-Extra: pandas
16
+ Description-Content-Type: text/markdown
17
+
18
+ # taktwerk for Python
19
+
20
+ Read the records of [taktwerk](https://labinetix.github.io/taktwerk/) runs and campaigns into
21
+ lists, numpy arrays or pandas data frames, and start runs and campaigns through the `taktwerk`
22
+ command. Offline only: nothing here touches a running engine's cycle.
23
+
24
+ ```sh
25
+ pip install 'taktwerk[pandas]' # or: uv add 'taktwerk[pandas]'
26
+ ```
27
+
28
+ ```python
29
+ import taktwerk
30
+
31
+ run = taktwerk.read_run("records/project/main") # a run directory, or one run below it
32
+ run["plant.level"], run.t_engine, run.stop, run.overrides
33
+ run.to_pandas() # indexed by engine time
34
+
35
+ c = taktwerk.read_campaign("out/gains") # a campaign's output directory
36
+ c.to_pandas() # one row per run: axes, values, status
37
+ c.read("nominal-g03").to_pandas()
38
+
39
+ taktwerk.run("project.toml", until_time=600, set={"ctrl.kp": 2.0}, seed=1)
40
+ taktwerk.campaign("gains.toml") # taktwerk campaign --write, then loaded
41
+ ```
42
+
43
+ The reader needs only the standard library; `taktwerk[numpy]` and `taktwerk[pandas]` add the
44
+ conversions. Starting runs needs the `taktwerk` command on `PATH` or in `$TAKTWERK`. Guide:
45
+ [Python](https://labinetix.github.io/taktwerk/python.html).
@@ -0,0 +1,28 @@
1
+ # taktwerk for Python
2
+
3
+ Read the records of [taktwerk](https://labinetix.github.io/taktwerk/) runs and campaigns into
4
+ lists, numpy arrays or pandas data frames, and start runs and campaigns through the `taktwerk`
5
+ command. Offline only: nothing here touches a running engine's cycle.
6
+
7
+ ```sh
8
+ pip install 'taktwerk[pandas]' # or: uv add 'taktwerk[pandas]'
9
+ ```
10
+
11
+ ```python
12
+ import taktwerk
13
+
14
+ run = taktwerk.read_run("records/project/main") # a run directory, or one run below it
15
+ run["plant.level"], run.t_engine, run.stop, run.overrides
16
+ run.to_pandas() # indexed by engine time
17
+
18
+ c = taktwerk.read_campaign("out/gains") # a campaign's output directory
19
+ c.to_pandas() # one row per run: axes, values, status
20
+ c.read("nominal-g03").to_pandas()
21
+
22
+ taktwerk.run("project.toml", until_time=600, set={"ctrl.kp": 2.0}, seed=1)
23
+ taktwerk.campaign("gains.toml") # taktwerk campaign --write, then loaded
24
+ ```
25
+
26
+ The reader needs only the standard library; `taktwerk[numpy]` and `taktwerk[pandas]` add the
27
+ conversions. Starting runs needs the `taktwerk` command on `PATH` or in `$TAKTWERK`. Guide:
28
+ [Python](https://labinetix.github.io/taktwerk/python.html).
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "taktwerk"
3
+ version = "0.6.0"
4
+ description = "Read taktwerk run records and campaigns into arrays and data frames, and start runs"
5
+ readme = "README.md"
6
+ license = "MIT OR Apache-2.0"
7
+ requires-python = ">=3.10"
8
+ dependencies = []
9
+ keywords = [
10
+ "simulation",
11
+ "co-simulation",
12
+ "fixed-step",
13
+ "records",
14
+ "campaign",
15
+ ]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Scientific/Engineering",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://labinetix.github.io/taktwerk/"
23
+ Documentation = "https://labinetix.github.io/taktwerk/python.html"
24
+
25
+ [project.optional-dependencies]
26
+ numpy = ["numpy>=2.2.6"]
27
+ pandas = ["pandas>=2.3.3"]
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.12.19,<0.13.0"]
31
+ build-backend = "uv_build"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "pytest>=9.1.1",
36
+ "ruff>=0.17.1",
37
+ ]
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "taktwerk"
3
+ version = "0.6.0"
4
+ description = "Read taktwerk run records and campaigns into arrays and data frames, and start runs"
5
+ readme = "README.md"
6
+ license = "MIT OR Apache-2.0"
7
+ requires-python = ">=3.10"
8
+ dependencies = []
9
+ keywords = ["simulation", "co-simulation", "fixed-step", "records", "campaign"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Topic :: Scientific/Engineering",
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://labinetix.github.io/taktwerk/"
17
+ Documentation = "https://labinetix.github.io/taktwerk/python.html"
18
+
19
+ [project.optional-dependencies]
20
+ numpy = [
21
+ "numpy>=2.2.6",
22
+ ]
23
+ pandas = [
24
+ "pandas>=2.3.3",
25
+ ]
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.12.19,<0.13.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "pytest>=9.1.1",
34
+ "ruff>=0.17.1",
35
+ ]
@@ -0,0 +1,33 @@
1
+ """Read taktwerk run records and campaigns into arrays and data frames, and start runs.
2
+
3
+ ```python
4
+ import taktwerk
5
+
6
+ run = taktwerk.read_run("records/project/main") # one run directory, or one below
7
+ run["plant.y"], run.t_engine, run.events, run.overrides
8
+
9
+ c = taktwerk.read_campaign("out/gains") # a campaign's output
10
+ c.to_pandas() # one row per run, needs pandas
11
+ c.read("nominal-g03").to_pandas()
12
+
13
+ taktwerk.run("project.toml", until_time=600, set={"ctrl.kp": 2.0}, seed=1)
14
+ taktwerk.campaign("gains.toml") # taktwerk campaign --write
15
+ ```
16
+ """
17
+
18
+ from .campaign import Campaign, CampaignRun, read_campaign
19
+ from .cli import campaign, plan, run
20
+ from .records import Event, Run, find_runs, read_run
21
+
22
+ __all__ = [
23
+ "Campaign",
24
+ "CampaignRun",
25
+ "Event",
26
+ "Run",
27
+ "campaign",
28
+ "find_runs",
29
+ "plan",
30
+ "read_campaign",
31
+ "read_run",
32
+ "run",
33
+ ]
@@ -0,0 +1,128 @@
1
+ """Read a campaign's output: campaign.json, each run's status.json, and its runs' records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterator
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .records import Run, read_run
12
+
13
+
14
+ @dataclass
15
+ class CampaignRun:
16
+ """One planned run of a campaign and how it went."""
17
+
18
+ name: str
19
+ path: Path
20
+ seed: int
21
+ values: dict[str, Any]
22
+ case: str | None = None
23
+ grid: int | None = None
24
+ sample: int | None = None
25
+ replicate: int | None = None
26
+ spec_hash: str = ""
27
+ status: str = "pending"
28
+ stop: str | None = None
29
+ cycles: int | None = None
30
+ wall_s: float | None = None
31
+
32
+ @property
33
+ def ok(self) -> bool:
34
+ """Whether it ran to a limit or condition with the planned spec."""
35
+ return self.status == "ok"
36
+
37
+ def read(self, recorder: str | None = None) -> Run:
38
+ """Its records."""
39
+ return read_run(self.path, recorder)
40
+
41
+
42
+ @dataclass
43
+ class Campaign:
44
+ """A campaign's plan and the state of its runs."""
45
+
46
+ path: Path
47
+ manifest: dict[str, Any]
48
+ runs: list[CampaignRun]
49
+
50
+ def __len__(self) -> int:
51
+ return len(self.runs)
52
+
53
+ def __iter__(self) -> Iterator[CampaignRun]:
54
+ return iter(self.runs)
55
+
56
+ def __getitem__(self, name: str) -> CampaignRun:
57
+ for r in self.runs:
58
+ if r.name == name:
59
+ return r
60
+ raise KeyError(name)
61
+
62
+ @property
63
+ def keys(self) -> list[str]:
64
+ """Every key some run's axes set, in order of first appearance."""
65
+ keys: list[str] = []
66
+ for r in self.runs:
67
+ keys.extend(k for k in r.values if k not in keys)
68
+ return keys
69
+
70
+ def table(self) -> list[dict[str, Any]]:
71
+ """One row per run: name, axes, seed, status, stop, cycles, wall time, and the values."""
72
+ rows = []
73
+ for r in self.runs:
74
+ row: dict[str, Any] = {
75
+ "name": r.name,
76
+ "case": r.case,
77
+ "grid": r.grid,
78
+ "sample": r.sample,
79
+ "replicate": r.replicate,
80
+ "seed": r.seed,
81
+ "status": r.status,
82
+ "stop": r.stop,
83
+ "cycles": r.cycles,
84
+ "wall_s": r.wall_s,
85
+ }
86
+ for k in self.keys:
87
+ row[k] = r.values.get(k)
88
+ rows.append(row)
89
+ return rows
90
+
91
+ def to_pandas(self):
92
+ """`table()` as a DataFrame indexed by run name. Needs pandas."""
93
+ import pandas as pd
94
+
95
+ return pd.DataFrame(self.table()).set_index("name")
96
+
97
+ def read(self, name: str, recorder: str | None = None) -> Run:
98
+ """The records of run `name`."""
99
+ return self[name].read(recorder)
100
+
101
+
102
+ def read_campaign(path: Path | str) -> Campaign:
103
+ """Load the campaign whose output directory is `path` (it holds campaign.json)."""
104
+ path = Path(path)
105
+ manifest = json.loads((path / "campaign.json").read_text())
106
+ runs = []
107
+ for e in manifest["runs"]:
108
+ run = CampaignRun(
109
+ name=e["name"],
110
+ path=path / "runs" / e["name"],
111
+ seed=e["seed"],
112
+ values=e.get("values", {}),
113
+ case=e.get("case"),
114
+ grid=e.get("grid"),
115
+ sample=e.get("sample"),
116
+ replicate=e.get("replicate"),
117
+ spec_hash=e["spec_hash"],
118
+ )
119
+ status_file = run.path / "status.json"
120
+ if status_file.is_file():
121
+ status = json.loads(status_file.read_text())
122
+ if status.get("spec_hash") == run.spec_hash:
123
+ run.status = status["status"]
124
+ run.stop = status.get("stop")
125
+ run.cycles = status.get("cycles")
126
+ run.wall_s = status.get("wall_s")
127
+ runs.append(run)
128
+ return Campaign(path=path, manifest=manifest, runs=runs)
@@ -0,0 +1,142 @@
1
+ """Start runs and campaigns through the taktwerk command: a thin wrapper, nothing in-process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from collections.abc import Iterable, Mapping
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .campaign import Campaign, read_campaign
16
+ from .records import Run, find_runs, read_run
17
+
18
+
19
+ def binary(path: str | None = None) -> str:
20
+ """The taktwerk command: `path`, else `$TAKTWERK`, else `taktwerk` on PATH."""
21
+ found = path or os.environ.get("TAKTWERK") or shutil.which("taktwerk")
22
+ if not found:
23
+ raise FileNotFoundError(
24
+ "no taktwerk command: install it, or set TAKTWERK to its path"
25
+ )
26
+ return found
27
+
28
+
29
+ def toml_value(value: Any) -> str:
30
+ """`value` as a TOML literal for `--set`."""
31
+ if isinstance(value, bool):
32
+ return "true" if value else "false"
33
+ if isinstance(value, int):
34
+ return str(value)
35
+ if isinstance(value, float):
36
+ if math.isnan(value):
37
+ return "nan"
38
+ if math.isinf(value):
39
+ return "inf" if value > 0 else "-inf"
40
+ return repr(value)
41
+ if isinstance(value, str):
42
+ return json.dumps(value)
43
+ if isinstance(value, Iterable):
44
+ return "[" + ", ".join(toml_value(v) for v in value) + "]"
45
+ raise TypeError(f"cannot pass {value!r} as a TOML value")
46
+
47
+
48
+ def _execute(argv: list[str]) -> subprocess.CompletedProcess:
49
+ done = subprocess.run(argv, capture_output=True, text=True, check=False)
50
+ if done.returncode != 0:
51
+ tail = "\n".join(done.stderr.splitlines()[-20:])
52
+ raise RuntimeError(f"{' '.join(argv[:2])} failed ({done.returncode}):\n{tail}")
53
+ return done
54
+
55
+
56
+ def run(
57
+ project: Path | str,
58
+ *,
59
+ speed: str | float = "max",
60
+ cycles: int | None = None,
61
+ until_time: float | None = None,
62
+ stop_when: Iterable[str] = (),
63
+ set: Mapping[str, Any] | None = None,
64
+ models: Mapping[str, Path | str] | None = None,
65
+ scenario: Path | str | None = None,
66
+ seed: int | None = None,
67
+ record_dir: Path | str | None = None,
68
+ connectors: bool = False,
69
+ command: str | None = None,
70
+ ) -> Run | None:
71
+ """Run `project` with `taktwerk run` and load its record: unpaced and without connectors
72
+ by default. Records go to `record_dir`, else a new temporary directory. Returns `None` when
73
+ the project has no file recorder."""
74
+ record_dir = (
75
+ Path(record_dir)
76
+ if record_dir
77
+ else Path(tempfile.mkdtemp(prefix="taktwerk-run-"))
78
+ )
79
+ argv = [
80
+ binary(command),
81
+ "run",
82
+ str(project),
83
+ "--speed",
84
+ str(speed),
85
+ "--record-dir",
86
+ str(record_dir),
87
+ ]
88
+ if not connectors:
89
+ argv.append("--no-connectors")
90
+ if cycles is not None:
91
+ argv += ["--cycles", str(cycles)]
92
+ if until_time is not None:
93
+ argv += ["--until-time", repr(float(until_time))]
94
+ for condition in stop_when:
95
+ argv += ["--stop-when", condition]
96
+ for key, value in (set or {}).items():
97
+ argv += ["--set", f"{key}={toml_value(value)}"]
98
+ for model, path in (models or {}).items():
99
+ argv += ["--model", f"{model}={path}"]
100
+ if scenario is not None:
101
+ argv += ["--scenario", str(scenario)]
102
+ if seed is not None:
103
+ argv += ["--seed", str(seed)]
104
+ before = frozenset(find_runs(record_dir))
105
+ _execute(argv)
106
+ new = [r for r in find_runs(record_dir) if r not in before]
107
+ if not new:
108
+ return None
109
+ return read_run(new[-1])
110
+
111
+
112
+ def plan(file: Path | str, *, command: str | None = None) -> str:
113
+ """The plan of the campaign `file`, as `taktwerk campaign` prints it; nothing is run."""
114
+ return _execute([binary(command), "campaign", str(file)]).stdout
115
+
116
+
117
+ def campaign(
118
+ file: Path | str,
119
+ *,
120
+ force: bool = False,
121
+ jobs: int | None = None,
122
+ command: str | None = None,
123
+ ) -> Campaign:
124
+ """Run the campaign `file` with `taktwerk campaign --write`, resuming where it stopped, and
125
+ load its output. Raises when a run is not ok."""
126
+ argv = [binary(command), "campaign", str(file), "--write"]
127
+ if force:
128
+ argv.append("--force")
129
+ if jobs is not None:
130
+ argv += ["--jobs", str(jobs)]
131
+ done = _execute(argv)
132
+ out = next(
133
+ (
134
+ line.split(None, 1)[1]
135
+ for line in done.stdout.splitlines()
136
+ if line.startswith("out ")
137
+ ),
138
+ None,
139
+ )
140
+ if out is None:
141
+ raise RuntimeError("taktwerk campaign printed no output directory")
142
+ return read_campaign(out.strip())
File without changes
@@ -0,0 +1,187 @@
1
+ """Read one recorded run: run.json, the gzipped CSV parts and events.csv.
2
+
3
+ Only the standard library is needed; numpy and pandas are imported when a conversion asks for
4
+ them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ import gzip
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, NamedTuple
15
+
16
+ INT_TYPES = {"i64", "i32", "i16", "i8", "u64", "u32", "u16", "u8"}
17
+
18
+ # Column type of run.json -> numpy dtype.
19
+ NUMPY_TYPES = {
20
+ "f64": "float64",
21
+ "f32": "float32",
22
+ "i64": "int64",
23
+ "i32": "int32",
24
+ "i16": "int16",
25
+ "i8": "int8",
26
+ "u64": "uint64",
27
+ "u32": "uint32",
28
+ "u16": "uint16",
29
+ "u8": "uint8",
30
+ "bool": "bool",
31
+ "text": "object",
32
+ }
33
+
34
+
35
+ class Event(NamedTuple):
36
+ """One line of events.csv."""
37
+
38
+ cycle: int
39
+ t_engine: float
40
+ kind: str
41
+ source: str
42
+ detail: str
43
+
44
+
45
+ def _converter(type_name: str):
46
+ """The function that turns a CSV field of a column of `type_name` into a Python value."""
47
+ if type_name in ("f64", "f32"):
48
+ return float
49
+ if type_name in INT_TYPES:
50
+ return int
51
+ if type_name == "bool":
52
+ return lambda s: s == "1"
53
+ return str
54
+
55
+
56
+ @dataclass
57
+ class Run:
58
+ """A recorded run: its manifest, its rows column by column, and its events."""
59
+
60
+ path: Path
61
+ manifest: dict[str, Any]
62
+ cycle: list[int] = field(default_factory=list)
63
+ t_engine: list[float] = field(default_factory=list)
64
+ data: dict[str, list[Any]] = field(default_factory=dict)
65
+ events: list[Event] = field(default_factory=list)
66
+
67
+ @property
68
+ def columns(self) -> list[str]:
69
+ """The recorded columns, in row order, without cycle and t_engine."""
70
+ return [c["name"] for c in self.manifest["columns"]]
71
+
72
+ @property
73
+ def invocation(self) -> dict[str, Any]:
74
+ """How the run was started: speed, stops, overrides, seeds, scenario; empty if unknown."""
75
+ return self.manifest.get("invocation") or {}
76
+
77
+ @property
78
+ def overrides(self) -> dict[str, Any]:
79
+ """Every override applied, key to value, in order."""
80
+ return {o["key"]: o["value"] for o in self.invocation.get("overrides", [])}
81
+
82
+ @property
83
+ def seed(self) -> int | None:
84
+ """The run seed, if it had one."""
85
+ return self.invocation.get("seed")
86
+
87
+ @property
88
+ def stop(self) -> str | None:
89
+ """Why the run ended, as its stop event says."""
90
+ stops = [e.detail for e in self.events if e.kind == "stop"]
91
+ return stops[-1] if stops else None
92
+
93
+ def __len__(self) -> int:
94
+ return len(self.cycle)
95
+
96
+ def __getitem__(self, name: str) -> list[Any]:
97
+ """A column by name: `plant.y`, `ctrl.u[0]`, or `cycle` and `t_engine`."""
98
+ if name == "cycle":
99
+ return self.cycle
100
+ if name == "t_engine":
101
+ return self.t_engine
102
+ return self.data[name]
103
+
104
+ def array(self, name: str) -> list[list[Any]]:
105
+ """An array signal recorded element by element: one list of its elements per row."""
106
+ cols = [c for c in self.columns if c.startswith(name + "[")]
107
+ if not cols:
108
+ raise KeyError(f"no columns of the array {name!r}")
109
+ return [list(row) for row in zip(*(self.data[c] for c in cols))]
110
+
111
+ def events_of(self, kind: str) -> list[Event]:
112
+ """The events of one kind: start, stop, tunable, scenario, stale, ..."""
113
+ return [e for e in self.events if e.kind == kind]
114
+
115
+ def to_numpy(self) -> dict[str, Any]:
116
+ """Every column as a numpy array, cycle and t_engine included. Needs numpy."""
117
+ import numpy as np
118
+
119
+ out = {"cycle": np.asarray(self.cycle, dtype="uint64")}
120
+ out["t_engine"] = np.asarray(self.t_engine, dtype="float64")
121
+ for c in self.manifest["columns"]:
122
+ values = self.data[c["name"]]
123
+ out[c["name"]] = np.asarray(
124
+ values, dtype=NUMPY_TYPES.get(c["type"], "float64")
125
+ )
126
+ return out
127
+
128
+ def to_pandas(self):
129
+ """The rows as a DataFrame indexed by t_engine, with a cycle column. Needs pandas."""
130
+ import pandas as pd
131
+
132
+ frame = pd.DataFrame(self.to_numpy())
133
+ return frame.set_index("t_engine")
134
+
135
+
136
+ def find_runs(path: Path | str) -> list[Path]:
137
+ """Every run directory (one holding run.json) at or below `path`, sorted."""
138
+ path = Path(path)
139
+ if (path / "run.json").is_file():
140
+ return [path]
141
+ return sorted(p.parent for p in path.rglob("run.json"))
142
+
143
+
144
+ def read_run(path: Path | str, recorder: str | None = None) -> Run:
145
+ """Load the run at `path`: a run directory, or a directory with exactly one run below it
146
+ (of `recorder`, when given)."""
147
+ runs = find_runs(path)
148
+ if recorder is not None:
149
+ runs = [
150
+ r
151
+ for r in runs
152
+ if json.loads((r / "run.json").read_text())["recorder"] == recorder
153
+ ]
154
+ if len(runs) != 1:
155
+ found = ", ".join(str(r) for r in runs) or "none"
156
+ raise ValueError(f"{path}: expected one run, found {len(runs)}: {found}")
157
+ run_dir = runs[0]
158
+ manifest = json.loads((run_dir / "run.json").read_text())
159
+ run = Run(path=run_dir, manifest=manifest)
160
+ columns = manifest["columns"]
161
+ convert = [_converter(c["type"]) for c in columns]
162
+ names = [c["name"] for c in columns]
163
+ for n in names:
164
+ run.data[n] = []
165
+ for part in sorted(run_dir.glob("part-*.csv.gz")):
166
+ with gzip.open(part, "rt", newline="") as f:
167
+ reader = csv.reader(f)
168
+ header = next(reader, None)
169
+ if header is None:
170
+ continue
171
+ if header[2:] != names:
172
+ raise ValueError(f"{part}: header does not match run.json's columns")
173
+ for row in reader:
174
+ run.cycle.append(int(row[0]))
175
+ run.t_engine.append(float(row[1]))
176
+ for n, conv, value in zip(names, convert, row[2:]):
177
+ run.data[n].append(conv(value))
178
+ events = run_dir / "events.csv"
179
+ if events.is_file():
180
+ with events.open(newline="") as f:
181
+ reader = csv.reader(f)
182
+ next(reader, None)
183
+ for row in reader:
184
+ run.events.append(
185
+ Event(int(row[0]), float(row[1]), row[2], row[3], row[4])
186
+ )
187
+ return run