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.
- taktwerk-0.6.0/PKG-INFO +45 -0
- taktwerk-0.6.0/README.md +28 -0
- taktwerk-0.6.0/pyproject.toml +37 -0
- taktwerk-0.6.0/pyproject.toml.orig +35 -0
- taktwerk-0.6.0/src/taktwerk/__init__.py +33 -0
- taktwerk-0.6.0/src/taktwerk/campaign.py +128 -0
- taktwerk-0.6.0/src/taktwerk/cli.py +142 -0
- taktwerk-0.6.0/src/taktwerk/py.typed +0 -0
- taktwerk-0.6.0/src/taktwerk/records.py +187 -0
taktwerk-0.6.0/PKG-INFO
ADDED
|
@@ -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).
|
taktwerk-0.6.0/README.md
ADDED
|
@@ -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
|