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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ csl = csv_session_logger.cli:main
@@ -0,0 +1 @@
1
+ csv_session_logger