csv-session-logger 0.1.0__py3-none-any.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.
- csv_session_logger/__init__.py +1 -0
- csv_session_logger/cli.py +128 -0
- csv_session_logger/logger.py +242 -0
- csv_session_logger-0.1.0.dist-info/METADATA +162 -0
- csv_session_logger-0.1.0.dist-info/RECORD +8 -0
- csv_session_logger-0.1.0.dist-info/WHEEL +5 -0
- csv_session_logger-0.1.0.dist-info/entry_points.txt +2 -0
- csv_session_logger-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from csv_session_logger.logger import Logger
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from argparse import ArgumentParser
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
|
|
7
|
+
def run_info(csv_file: str) -> None:
|
|
8
|
+
df = pd.read_csv(csv_file, index_col="t_wall")
|
|
9
|
+
labels = list(df.columns)
|
|
10
|
+
duration = df.index[-1] - df.index[0]
|
|
11
|
+
print(
|
|
12
|
+
"=" * 35,
|
|
13
|
+
f"Channels: {', '.join(labels)}",
|
|
14
|
+
f"Duration: {duration:.4f}s",
|
|
15
|
+
"=" * 35,
|
|
16
|
+
sep="\n"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def run_plot(args) -> None:
|
|
20
|
+
df = pd.read_csv(args.csv_file, index_col="t_wall")
|
|
21
|
+
t = df.index - df.index[0]
|
|
22
|
+
|
|
23
|
+
if args.time is not None and args.xy is not None:
|
|
24
|
+
raise ValueError("Cannot use -t and -x together. Choose one.")
|
|
25
|
+
|
|
26
|
+
if args.xy is None and args.time is None:
|
|
27
|
+
_, axes = plt.subplots(nrows=len(df.columns))
|
|
28
|
+
|
|
29
|
+
if len(df.columns) == 1:
|
|
30
|
+
axes = [axes]
|
|
31
|
+
|
|
32
|
+
for i, col in enumerate(df.columns):
|
|
33
|
+
axes[i].plot(t, df[col])
|
|
34
|
+
axes[i].set_ylabel(col)
|
|
35
|
+
|
|
36
|
+
axes[-1].set_xlabel("t [s]")
|
|
37
|
+
for ax in axes[:-1]:
|
|
38
|
+
ax.tick_params(labelbottom=False)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
manager = plt.get_current_fig_manager()
|
|
42
|
+
manager.window.showMaximized()
|
|
43
|
+
except AttributeError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
plt.tight_layout()
|
|
47
|
+
plt.show()
|
|
48
|
+
|
|
49
|
+
elif args.time is not None:
|
|
50
|
+
invalid = set(args.time) - set(df.columns)
|
|
51
|
+
if invalid:
|
|
52
|
+
raise ValueError(f"Unknown columns: {invalid}. Available: {list(df.columns)}")
|
|
53
|
+
|
|
54
|
+
_, axes = plt.subplots(nrows=len(args.time))
|
|
55
|
+
|
|
56
|
+
if len(args.time) == 1:
|
|
57
|
+
axes = [axes]
|
|
58
|
+
|
|
59
|
+
for i, col in enumerate(args.time):
|
|
60
|
+
axes[i].plot(t, df[col])
|
|
61
|
+
axes[i].set_ylabel(col)
|
|
62
|
+
|
|
63
|
+
axes[-1].set_xlabel("t [s]")
|
|
64
|
+
for ax in axes[:-1]:
|
|
65
|
+
ax.tick_params(labelbottom=False)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
manager = plt.get_current_fig_manager()
|
|
69
|
+
manager.window.showMaximized()
|
|
70
|
+
except AttributeError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
plt.tight_layout()
|
|
74
|
+
plt.show()
|
|
75
|
+
|
|
76
|
+
elif len(args.xy) < 2:
|
|
77
|
+
raise ValueError("--xy requires at least 2 columns: X and at least one Y")
|
|
78
|
+
|
|
79
|
+
else:
|
|
80
|
+
invalid = set(args.xy) - set(df.columns)
|
|
81
|
+
if invalid:
|
|
82
|
+
raise ValueError(f"Unknown columns: {invalid}. Available: {list(df.columns)}")
|
|
83
|
+
|
|
84
|
+
_, axes = plt.subplots(nrows=len(args.xy) - 1)
|
|
85
|
+
|
|
86
|
+
if len(args.xy) - 1 == 1:
|
|
87
|
+
axes = [axes]
|
|
88
|
+
|
|
89
|
+
for i, col in enumerate(args.xy[1:]):
|
|
90
|
+
axes[i].plot(df[args.xy[0]], df[col])
|
|
91
|
+
axes[i].set_ylabel(col)
|
|
92
|
+
|
|
93
|
+
axes[-1].set_xlabel(args.xy[0])
|
|
94
|
+
for ax in axes[:-1]:
|
|
95
|
+
ax.tick_params(labelbottom=False)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
manager = plt.get_current_fig_manager()
|
|
99
|
+
manager.window.showMaximized()
|
|
100
|
+
except AttributeError:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
plt.tight_layout()
|
|
104
|
+
plt.show()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> None:
|
|
108
|
+
parser = ArgumentParser(description="csv-session-logger CLI")
|
|
109
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
110
|
+
|
|
111
|
+
# Subcommand "info"
|
|
112
|
+
info_parser = subparsers.add_parser("info")
|
|
113
|
+
info_parser.add_argument("csv_file")
|
|
114
|
+
|
|
115
|
+
# Subcommand "plot"
|
|
116
|
+
plot_parser = subparsers.add_parser("plot")
|
|
117
|
+
plot_parser.add_argument("csv_file")
|
|
118
|
+
plot_parser.add_argument("-x", "--xy", nargs="+")
|
|
119
|
+
plot_parser.add_argument("-t", "--time", nargs="+")
|
|
120
|
+
|
|
121
|
+
args = parser.parse_args()
|
|
122
|
+
|
|
123
|
+
if args.command == "info":
|
|
124
|
+
run_info(args.csv_file)
|
|
125
|
+
elif args.command == "plot":
|
|
126
|
+
run_plot(args)
|
|
127
|
+
else:
|
|
128
|
+
parser.print_help()
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Logger:
|
|
9
|
+
"""
|
|
10
|
+
Session-based logger for robotics simulations and hardware experiments.
|
|
11
|
+
|
|
12
|
+
Records scalar signals from multiple sources (model, controller, sensors)
|
|
13
|
+
and writes them to a single time-stamped CSV file.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
session : str
|
|
18
|
+
Name of the logging session. Used as part of the output filename.
|
|
19
|
+
output_dir : str, optional
|
|
20
|
+
Directory where the CSV file will be written. Created if it does not
|
|
21
|
+
exist. Default is "logs/".
|
|
22
|
+
fill : str, optional
|
|
23
|
+
Strategy for filling NaN values when channels are not logged at the
|
|
24
|
+
same timestamps. Must be one of:
|
|
25
|
+
- "none" : NaN values are left as-is (default).
|
|
26
|
+
- "ffill" : Forward-fill with the last known value.
|
|
27
|
+
- "mean" : Linear interpolation between neighbouring values.
|
|
28
|
+
|
|
29
|
+
Raises
|
|
30
|
+
------
|
|
31
|
+
ValueError
|
|
32
|
+
If an invalid fill strategy is provided.
|
|
33
|
+
|
|
34
|
+
Examples
|
|
35
|
+
--------
|
|
36
|
+
>>> logger = Logger(session="ntrailer_run", output_dir="logs/", fill="none")
|
|
37
|
+
>>> logger.register("x_pos", unit="m")
|
|
38
|
+
>>> logger.register("u_steer", unit="rad")
|
|
39
|
+
>>> logger.log("x_pos", 0.5)
|
|
40
|
+
>>> logger.log("u_steer", 0.8)
|
|
41
|
+
>>> path = logger.save()
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, session: str, output_dir: str = "logs/", fill: str = "none"):
|
|
45
|
+
if fill not in ("none", "ffill", "mean"):
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Invalid fill strategy: '{fill}'. Must be 'none', 'ffill' or 'mean'."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
self.session = session
|
|
51
|
+
self.output_dir = output_dir
|
|
52
|
+
self._fill = fill
|
|
53
|
+
self._created_at = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
54
|
+
self._buffer: dict = {}
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str:
|
|
57
|
+
return f"Logger(session='{self.session}', channels={len(self._buffer)})"
|
|
58
|
+
|
|
59
|
+
def register(self, key: str | list[str], unit: str | list[str] | None = None) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Register one or more logging channels before use.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
key : str or list[str]
|
|
66
|
+
Column name(s) in the output CSV. If a list is provided, all keys
|
|
67
|
+
are registered in a single call.
|
|
68
|
+
unit : str, list[str], or None, optional
|
|
69
|
+
Physical unit(s) of the signal, e.g. "rad" or "m/s". If provided,
|
|
70
|
+
the column header will be formatted as "key [unit]".
|
|
71
|
+
- Single string: applied to all keys.
|
|
72
|
+
- List: must be the same length as key.
|
|
73
|
+
- None: no unit label (default).
|
|
74
|
+
|
|
75
|
+
Raises
|
|
76
|
+
------
|
|
77
|
+
ValueError
|
|
78
|
+
If any key has already been registered.
|
|
79
|
+
ValueError
|
|
80
|
+
If unit is a list with different length than key.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
if isinstance(unit, list) and len(unit) != len(key):
|
|
84
|
+
raise ValueError(...)
|
|
85
|
+
|
|
86
|
+
if isinstance(key, str):
|
|
87
|
+
key = [key]
|
|
88
|
+
|
|
89
|
+
for i, k in enumerate(key):
|
|
90
|
+
u = unit[i] if isinstance(unit, list) else unit
|
|
91
|
+
if k in self._buffer:
|
|
92
|
+
raise ValueError(f"Key '{k}' is already registered.")
|
|
93
|
+
self._buffer[k] = {"values": [], "unit": u, "t_wall": []}
|
|
94
|
+
|
|
95
|
+
def log(self, key: str, value: float) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Append a single scalar measurement to the buffer.
|
|
98
|
+
|
|
99
|
+
The wallclock timestamp is recorded automatically at the moment of
|
|
100
|
+
the call via time.time().
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
key : str
|
|
105
|
+
Channel name. Must be registered first via register().
|
|
106
|
+
value : float
|
|
107
|
+
Scalar measurement value. Integers are accepted and stored as float.
|
|
108
|
+
|
|
109
|
+
Raises
|
|
110
|
+
------
|
|
111
|
+
ValueError
|
|
112
|
+
If the key has not been registered.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
if key not in self._buffer:
|
|
116
|
+
raise ValueError(f"Key '{key}' is not registered. Call register() first.")
|
|
117
|
+
|
|
118
|
+
self._buffer[key]["values"].append(float(value))
|
|
119
|
+
self._buffer[key]["t_wall"].append(time.time())
|
|
120
|
+
|
|
121
|
+
def log_many(self, data: dict[str, float]) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Append multiple scalar measurements to the buffer in a single call.
|
|
124
|
+
|
|
125
|
+
All channels share the same wallclock timestamp, recorded once at the
|
|
126
|
+
moment of the call. Use this instead of multiple log() calls when
|
|
127
|
+
channels belong to the same timestep and should be aligned in the CSV.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
data : dict[str, float]
|
|
132
|
+
Dictionary mapping channel names to scalar measurement values.
|
|
133
|
+
All keys must be registered first via register().
|
|
134
|
+
|
|
135
|
+
Raises
|
|
136
|
+
------
|
|
137
|
+
ValueError
|
|
138
|
+
If any key in data has not been registered.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
t_wall = time.time()
|
|
142
|
+
for k in data:
|
|
143
|
+
if k not in self._buffer:
|
|
144
|
+
raise ValueError(f"Key '{k}' is not registered. Call register() first.")
|
|
145
|
+
self._buffer[k]["values"].append(float(data[k]))
|
|
146
|
+
self._buffer[k]["t_wall"].append(t_wall)
|
|
147
|
+
|
|
148
|
+
def save(self, filename: str | None = None) -> Path:
|
|
149
|
+
"""
|
|
150
|
+
Align all logged channels onto a common time index and write to CSV.
|
|
151
|
+
|
|
152
|
+
Each channel is converted to a pandas Series indexed by t_wall.
|
|
153
|
+
Channels logged at different times produce NaN in rows where they
|
|
154
|
+
have no entry. The fill strategy set at construction is applied
|
|
155
|
+
before writing.
|
|
156
|
+
|
|
157
|
+
Parameters
|
|
158
|
+
----------
|
|
159
|
+
filename : str or None, optional
|
|
160
|
+
Custom filename for the output CSV (without directory). If None,
|
|
161
|
+
the filename is auto-generated as "{session}_{timestamp}.csv".
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
Path
|
|
166
|
+
Absolute path of the written CSV file.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
if not any(self._buffer[k]["values"] for k in self._buffer):
|
|
170
|
+
raise RuntimeError("Buffer is empty. Nothing to save.")
|
|
171
|
+
|
|
172
|
+
if filename is not None:
|
|
173
|
+
if not filename.endswith(".csv"):
|
|
174
|
+
raise ValueError(f"filename must end with '.csv', got '{filename}'")
|
|
175
|
+
path = Path(self.output_dir) / filename
|
|
176
|
+
else:
|
|
177
|
+
path = Path(self.output_dir) / f"{self.session}_{self._created_at}.csv"
|
|
178
|
+
|
|
179
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
|
|
181
|
+
concat: dict = {}
|
|
182
|
+
for key in self._buffer:
|
|
183
|
+
unit = self._buffer[key]["unit"]
|
|
184
|
+
label = f"{key} [{unit}]" if unit is not None else key
|
|
185
|
+
concat[label] = pd.Series(
|
|
186
|
+
data=self._buffer[key]["values"],
|
|
187
|
+
index=self._buffer[key]["t_wall"],
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
df = pd.DataFrame(concat)
|
|
191
|
+
df.index.name = "t_wall"
|
|
192
|
+
|
|
193
|
+
if self._fill == "ffill":
|
|
194
|
+
df = df.ffill()
|
|
195
|
+
elif self._fill == "mean":
|
|
196
|
+
df = df.interpolate()
|
|
197
|
+
|
|
198
|
+
df.to_csv(path, float_format="%.8f")
|
|
199
|
+
|
|
200
|
+
return path.resolve()
|
|
201
|
+
|
|
202
|
+
def reset(self) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Clear all buffered measurements without writing to disk.
|
|
205
|
+
|
|
206
|
+
Registered keys and their units are preserved. Only the recorded
|
|
207
|
+
values and timestamps are cleared. The session timestamp is renewed
|
|
208
|
+
so the next save() call produces a new filename.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
self._created_at = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
212
|
+
|
|
213
|
+
for key in self._buffer:
|
|
214
|
+
self._buffer[key]["values"] = []
|
|
215
|
+
self._buffer[key]["t_wall"] = []
|
|
216
|
+
|
|
217
|
+
def summary(self) -> None:
|
|
218
|
+
"""Print a summary of the current logging session to stdout."""
|
|
219
|
+
|
|
220
|
+
labels = []
|
|
221
|
+
for k in self._buffer:
|
|
222
|
+
unit = self._buffer[k]["unit"]
|
|
223
|
+
label = f"{k} [{unit}]" if unit is not None else k
|
|
224
|
+
labels.append(label)
|
|
225
|
+
|
|
226
|
+
samples = list(map(lambda k: len(self._buffer[k]["values"]), self._buffer))
|
|
227
|
+
|
|
228
|
+
all_t = []
|
|
229
|
+
for k in self._buffer:
|
|
230
|
+
for t in self._buffer[k]["t_wall"]:
|
|
231
|
+
all_t.append(t)
|
|
232
|
+
duration = max(all_t) - min(all_t)
|
|
233
|
+
|
|
234
|
+
print(
|
|
235
|
+
"=" * 35,
|
|
236
|
+
f"Session: {self.session}",
|
|
237
|
+
f"Channels: {', '.join(labels)}",
|
|
238
|
+
f"Samples: {samples}",
|
|
239
|
+
f"Duration: {duration:.4f}s",
|
|
240
|
+
"=" * 35,
|
|
241
|
+
sep="\n"
|
|
242
|
+
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: csv-session-logger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Requires-Python: >=3.10
|
|
5
|
+
Description-Content-Type: text/markdown
|
|
6
|
+
Requires-Dist: pandas>=2.0
|
|
7
|
+
Requires-Dist: numpy>=1.24
|
|
8
|
+
Requires-Dist: matplotlib>=3.7
|
|
9
|
+
Requires-Dist: PyQt5>=5.15
|
|
10
|
+
Requires-Dist: argcomplete>=3.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# csv-session-logger
|
|
15
|
+
|
|
16
|
+
A small session-based CSV logger for robotics sims and bench tests, one file per run, channels aligned on wall-clock time.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install csv-session-logger
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Requires Python 3.10+.
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from csv_session_logger import Logger
|
|
30
|
+
|
|
31
|
+
log = Logger(session="bench_test", output_dir="logs/", fill="none")
|
|
32
|
+
log.register("voltage", unit="V")
|
|
33
|
+
log.register("current", unit="A")
|
|
34
|
+
|
|
35
|
+
log.log("voltage", 12.1)
|
|
36
|
+
log.log("current", 0.45)
|
|
37
|
+
|
|
38
|
+
path = log.save()
|
|
39
|
+
print(path)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Channels must be registered before logging. I made that explicit so you decide upfront what goes in the CSV instead of accumulating mystery columns mid-run.
|
|
43
|
+
|
|
44
|
+
## `log()` vs `log_many()`
|
|
45
|
+
|
|
46
|
+
Both append scalar samples to the buffer. The difference is timing.
|
|
47
|
+
|
|
48
|
+
- `**log(key, value)**`: one channel, one call. Each call records its own `t_wall` via `time.time()`. If you log `x` and then `y` separately, they land on slightly different timestamps. That is fine when channels are independent or arrive asynchronously (e.g. a slow sensor vs a fast control loop).
|
|
49
|
+
- `**log_many({...})**`: several channels in one call. All keys in the dict share a single `t_wall` timestamp, taken once at the start of the call. I use this when values belong to the same timestep and should line up in the CSV without NaN gaps, typical in a simulation loop or a synchronized sensor snapshot.
|
|
50
|
+
|
|
51
|
+
Simulation time is not special. If you want `t_sim`, register it like any other channel and include it in `log_many()`:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
log.register(["t_sim", "x", "x_dot"], ["s", "m", "m/s"])
|
|
55
|
+
log.log_many({"t_sim": t, "x": x, "x_dot": x_dot})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Fill strategies
|
|
59
|
+
|
|
60
|
+
When channels are logged at different `t_wall` times, `save()` merges them onto one time index. Missing entries become NaN before the fill step runs.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
| `fill` | Behavior |
|
|
64
|
+
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| `"none"` | Leave NaN as-is. Use when you want to see exactly which channel was missing at each timestamp. |
|
|
66
|
+
| `"ffill"` | Forward-fill with the last known value per column. Leading NaN (before the first sample) stay NaN. |
|
|
67
|
+
| `"mean"` | Linear interpolation between neighbours (`pandas.interpolate()`). Good for plotting continuous signals when samples are slightly misaligned. |
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
Pick the strategy at `Logger(...)` construction, it applies on every `save()`.
|
|
71
|
+
|
|
72
|
+
## Output format
|
|
73
|
+
|
|
74
|
+
The CSV index is `t_wall` (Unix seconds, float). Column headers use `"key [unit]"` when a unit was registered, otherwise plain `key`. Values are written with 8 decimal places.
|
|
75
|
+
|
|
76
|
+
Example (from a spring-mass-damper run):
|
|
77
|
+
|
|
78
|
+
```csv
|
|
79
|
+
t_wall,t_sim [s],x [m],x_dot [m/s],x_ref [m],error [m],F [N]
|
|
80
|
+
1781263948.99401498,0.00000000,0.00000000,0.00000000,0.00000000,0.00000000,0.00000000
|
|
81
|
+
1781263948.99402165,0.01000000,0.00000000,0.00000000,0.00000000,0.00000000,0.00000000
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Auto-generated filenames look like `{session}_{YYYY-MM-DD_HH-MM-SS}.csv`. Pass `save(filename="my_run.csv")` to override.
|
|
85
|
+
|
|
86
|
+
## CLI
|
|
87
|
+
|
|
88
|
+
Entry point: `csl`
|
|
89
|
+
|
|
90
|
+
### `csl info <csv>`
|
|
91
|
+
|
|
92
|
+
Print channel names and duration (last `t_wall` minus first).
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
csl info logs/spring_mass_damper_2026-06-12_00-23-22.csv
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### `csl plot <csv>`
|
|
99
|
+
|
|
100
|
+
Quick matplotlib plots. Time axis is `t_wall` relative to the first row (starts at 0).
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# All channels, stacked subplots
|
|
104
|
+
csl plot logs/spring_mass_damper_2026-06-12_00-23-22.csv
|
|
105
|
+
|
|
106
|
+
# Subset by column name (-t / --time)
|
|
107
|
+
csl plot logs/spring_mass_damper_2026-06-12_00-23-22.csv -t "x [m]" "F [N]"
|
|
108
|
+
|
|
109
|
+
# XY plot: first column is X, rest are Y (-x / --xy)
|
|
110
|
+
csl plot logs/spring_mass_damper_2026-06-12_00-23-22.csv -x "t_sim [s]" "x [m]" "error [m]"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`-t` and `-x` cannot be used together.
|
|
114
|
+
|
|
115
|
+
## Full example: spring-mass-damper
|
|
116
|
+
|
|
117
|
+
Second-order plant with a P controller, Euler integration. Same as `examples/sim_example.py`:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
import numpy as np
|
|
121
|
+
from csv_session_logger import Logger
|
|
122
|
+
|
|
123
|
+
m, c, k, Kp = 1.0, 0.5, 2.0, 10.0
|
|
124
|
+
dt, t_end = 0.01, 20.0
|
|
125
|
+
n_steps = int(t_end / dt)
|
|
126
|
+
|
|
127
|
+
def x_ref(t: float) -> float:
|
|
128
|
+
return 0.0 if t < 2.0 else 1.0
|
|
129
|
+
|
|
130
|
+
x, x_dot = 0.0, 0.0
|
|
131
|
+
|
|
132
|
+
log = Logger(session="spring_mass_damper", output_dir="logs/", fill="none")
|
|
133
|
+
log.register(["t_sim", "x", "x_dot", "x_ref", "error", "F"],
|
|
134
|
+
["s", "m", "m/s", "m", "m", "N"])
|
|
135
|
+
|
|
136
|
+
t = 0.0
|
|
137
|
+
for _ in range(n_steps):
|
|
138
|
+
ref = x_ref(t)
|
|
139
|
+
error = ref - x
|
|
140
|
+
F = Kp * error
|
|
141
|
+
|
|
142
|
+
x_ddot = (F - c * x_dot - k * x) / m
|
|
143
|
+
x_dot += x_ddot * dt
|
|
144
|
+
x += x_dot * dt
|
|
145
|
+
|
|
146
|
+
log.log_many({
|
|
147
|
+
"t_sim": t, "x": x, "x_dot": x_dot,
|
|
148
|
+
"x_ref": ref, "error": error, "F": F,
|
|
149
|
+
})
|
|
150
|
+
t += dt
|
|
151
|
+
|
|
152
|
+
path = log.save()
|
|
153
|
+
log.summary()
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Run it: `python examples/sim_example.py`, then `csl plot logs/spring_mass_damper_*.csv`.
|
|
157
|
+
|
|
158
|
+
## Why not rosbag / wandb / mlflow?
|
|
159
|
+
|
|
160
|
+
I haven't used these in practice, but for this use case I wanted something
|
|
161
|
+
minimal: register a few channels, log scalars in a loop, get one CSV. No
|
|
162
|
+
server, no extra setup, just a file I can open with pandas or `csl plot`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
csv_session_logger/__init__.py,sha256=2k9raMKl2EfZO0YyAYttoKTFY9TYEEQ0lHEVXDOZE6c,44
|
|
2
|
+
csv_session_logger/cli.py,sha256=TAT1UlYnNPOs2q17csR3NXzc1zz4rIdVKAjHLN_qYeE,3610
|
|
3
|
+
csv_session_logger/logger.py,sha256=C-YmsRfrAiK_GjzZz5RV5Cy9Ckfns7Q3MQHY-aHbh08,8250
|
|
4
|
+
csv_session_logger-0.1.0.dist-info/METADATA,sha256=ibZY6dkRG63-5cZ-8xdUD8M3jrHxKbnhm-h22xmKni0,5549
|
|
5
|
+
csv_session_logger-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
csv_session_logger-0.1.0.dist-info/entry_points.txt,sha256=YZraKtDSfRwFfMtsifGnqiAHcvORXm2VRgPXTX8YJyU,52
|
|
7
|
+
csv_session_logger-0.1.0.dist-info/top_level.txt,sha256=WqUSmbW1VqtOaYWFrRWDJBkj92ycqXoNodbSYeCmszQ,19
|
|
8
|
+
csv_session_logger-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
csv_session_logger
|