csv-session-logger 0.1.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,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,149 @@
1
+ # csv-session-logger
2
+
3
+ A small session-based CSV logger for robotics sims and bench tests, one file per run, channels aligned on wall-clock time.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install csv-session-logger
9
+ ```
10
+
11
+ Requires Python 3.10+.
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ from csv_session_logger import Logger
17
+
18
+ log = Logger(session="bench_test", output_dir="logs/", fill="none")
19
+ log.register("voltage", unit="V")
20
+ log.register("current", unit="A")
21
+
22
+ log.log("voltage", 12.1)
23
+ log.log("current", 0.45)
24
+
25
+ path = log.save()
26
+ print(path)
27
+ ```
28
+
29
+ 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.
30
+
31
+ ## `log()` vs `log_many()`
32
+
33
+ Both append scalar samples to the buffer. The difference is timing.
34
+
35
+ - `**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).
36
+ - `**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.
37
+
38
+ Simulation time is not special. If you want `t_sim`, register it like any other channel and include it in `log_many()`:
39
+
40
+ ```python
41
+ log.register(["t_sim", "x", "x_dot"], ["s", "m", "m/s"])
42
+ log.log_many({"t_sim": t, "x": x, "x_dot": x_dot})
43
+ ```
44
+
45
+ ## Fill strategies
46
+
47
+ 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.
48
+
49
+
50
+ | `fill` | Behavior |
51
+ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
52
+ | `"none"` | Leave NaN as-is. Use when you want to see exactly which channel was missing at each timestamp. |
53
+ | `"ffill"` | Forward-fill with the last known value per column. Leading NaN (before the first sample) stay NaN. |
54
+ | `"mean"` | Linear interpolation between neighbours (`pandas.interpolate()`). Good for plotting continuous signals when samples are slightly misaligned. |
55
+
56
+
57
+ Pick the strategy at `Logger(...)` construction, it applies on every `save()`.
58
+
59
+ ## Output format
60
+
61
+ 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.
62
+
63
+ Example (from a spring-mass-damper run):
64
+
65
+ ```csv
66
+ t_wall,t_sim [s],x [m],x_dot [m/s],x_ref [m],error [m],F [N]
67
+ 1781263948.99401498,0.00000000,0.00000000,0.00000000,0.00000000,0.00000000,0.00000000
68
+ 1781263948.99402165,0.01000000,0.00000000,0.00000000,0.00000000,0.00000000,0.00000000
69
+ ```
70
+
71
+ Auto-generated filenames look like `{session}_{YYYY-MM-DD_HH-MM-SS}.csv`. Pass `save(filename="my_run.csv")` to override.
72
+
73
+ ## CLI
74
+
75
+ Entry point: `csl`
76
+
77
+ ### `csl info <csv>`
78
+
79
+ Print channel names and duration (last `t_wall` minus first).
80
+
81
+ ```bash
82
+ csl info logs/spring_mass_damper_2026-06-12_00-23-22.csv
83
+ ```
84
+
85
+ ### `csl plot <csv>`
86
+
87
+ Quick matplotlib plots. Time axis is `t_wall` relative to the first row (starts at 0).
88
+
89
+ ```bash
90
+ # All channels, stacked subplots
91
+ csl plot logs/spring_mass_damper_2026-06-12_00-23-22.csv
92
+
93
+ # Subset by column name (-t / --time)
94
+ csl plot logs/spring_mass_damper_2026-06-12_00-23-22.csv -t "x [m]" "F [N]"
95
+
96
+ # XY plot: first column is X, rest are Y (-x / --xy)
97
+ csl plot logs/spring_mass_damper_2026-06-12_00-23-22.csv -x "t_sim [s]" "x [m]" "error [m]"
98
+ ```
99
+
100
+ `-t` and `-x` cannot be used together.
101
+
102
+ ## Full example: spring-mass-damper
103
+
104
+ Second-order plant with a P controller, Euler integration. Same as `examples/sim_example.py`:
105
+
106
+ ```python
107
+ import numpy as np
108
+ from csv_session_logger import Logger
109
+
110
+ m, c, k, Kp = 1.0, 0.5, 2.0, 10.0
111
+ dt, t_end = 0.01, 20.0
112
+ n_steps = int(t_end / dt)
113
+
114
+ def x_ref(t: float) -> float:
115
+ return 0.0 if t < 2.0 else 1.0
116
+
117
+ x, x_dot = 0.0, 0.0
118
+
119
+ log = Logger(session="spring_mass_damper", output_dir="logs/", fill="none")
120
+ log.register(["t_sim", "x", "x_dot", "x_ref", "error", "F"],
121
+ ["s", "m", "m/s", "m", "m", "N"])
122
+
123
+ t = 0.0
124
+ for _ in range(n_steps):
125
+ ref = x_ref(t)
126
+ error = ref - x
127
+ F = Kp * error
128
+
129
+ x_ddot = (F - c * x_dot - k * x) / m
130
+ x_dot += x_ddot * dt
131
+ x += x_dot * dt
132
+
133
+ log.log_many({
134
+ "t_sim": t, "x": x, "x_dot": x_dot,
135
+ "x_ref": ref, "error": error, "F": F,
136
+ })
137
+ t += dt
138
+
139
+ path = log.save()
140
+ log.summary()
141
+ ```
142
+
143
+ Run it: `python examples/sim_example.py`, then `csl plot logs/spring_mass_damper_*.csv`.
144
+
145
+ ## Why not rosbag / wandb / mlflow?
146
+
147
+ I haven't used these in practice, but for this use case I wanted something
148
+ minimal: register a few channels, log scalars in a loop, get one CSV. No
149
+ server, no extra setup, just a file I can open with pandas or `csl plot`.
@@ -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,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ csv_session_logger/__init__.py
4
+ csv_session_logger/cli.py
5
+ csv_session_logger/logger.py
6
+ csv_session_logger.egg-info/PKG-INFO
7
+ csv_session_logger.egg-info/SOURCES.txt
8
+ csv_session_logger.egg-info/dependency_links.txt
9
+ csv_session_logger.egg-info/entry_points.txt
10
+ csv_session_logger.egg-info/requires.txt
11
+ csv_session_logger.egg-info/top_level.txt
12
+ tests/test_cli.py
13
+ tests/test_logger.py
14
+ tests/test_session.py
15
+ tests/test_writer.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ csl = csv_session_logger.cli:main
@@ -0,0 +1,8 @@
1
+ pandas>=2.0
2
+ numpy>=1.24
3
+ matplotlib>=3.7
4
+ PyQt5>=5.15
5
+ argcomplete>=3.0
6
+
7
+ [dev]
8
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ csv_session_logger
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "csv-session-logger"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "pandas>=2.0",
11
+ "numpy>=1.24",
12
+ "matplotlib>=3.7",
13
+ "PyQt5>=5.15",
14
+ "argcomplete>=3.0",
15
+ ]
16
+
17
+ readme = "README.md"
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest>=7.0"]
21
+
22
+ [project.scripts]
23
+ csl = "csv_session_logger.cli:main"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["."]
27
+ include = ["csv_session_logger*"]
28
+ exclude = ["tests*", "examples*", "logs*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,95 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+
5
+ from csv_session_logger.cli import main
6
+
7
+
8
+ @pytest.fixture
9
+ def mock_plot():
10
+ """Headless plot stub for CI — avoids opening a display window."""
11
+
12
+ def fake_subplots(*_args, **kwargs):
13
+ nrows = kwargs.get("nrows", 1)
14
+ if nrows == 1:
15
+ return MagicMock(), MagicMock()
16
+ return MagicMock(), [MagicMock() for _ in range(nrows)]
17
+
18
+ with patch("csv_session_logger.cli.plt.subplots", side_effect=fake_subplots):
19
+ with patch("csv_session_logger.cli.plt.show"):
20
+ with patch("csv_session_logger.cli.plt.tight_layout"):
21
+ with patch("csv_session_logger.cli.plt.get_current_fig_manager"):
22
+ yield
23
+
24
+
25
+ def test_csl_info_on_saved_csv(sample_csv, capsys):
26
+ with patch("sys.argv", ["csl", "info", str(sample_csv)]):
27
+ main()
28
+ out = capsys.readouterr().out
29
+ assert "x [m]" in out
30
+ assert "y [N]" in out
31
+ assert "flag" in out
32
+
33
+
34
+ def test_csl_plot_default_runs(sample_csv, mock_plot):
35
+ with patch("sys.argv", ["csl", "plot", str(sample_csv)]):
36
+ main()
37
+
38
+
39
+ def test_csl_plot_time_flag_runs(sample_csv, mock_plot):
40
+ with patch(
41
+ "sys.argv",
42
+ ["csl", "plot", str(sample_csv), "-t", "x [m]", "y [N]"],
43
+ ):
44
+ main()
45
+
46
+
47
+ def test_csl_plot_time_invalid_column_raises(sample_csv):
48
+ with patch(
49
+ "sys.argv",
50
+ ["csl", "plot", str(sample_csv), "-t", "nonexistent"],
51
+ ):
52
+ with pytest.raises(ValueError, match="Unknown columns"):
53
+ main()
54
+
55
+
56
+ def test_csl_plot_xy_flag_runs(sample_csv, mock_plot):
57
+ with patch(
58
+ "sys.argv",
59
+ ["csl", "plot", str(sample_csv), "-x", "x [m]", "y [N]"],
60
+ ):
61
+ main()
62
+
63
+
64
+ def test_csl_plot_single_column_raises(sample_csv):
65
+ with patch(
66
+ "sys.argv",
67
+ ["csl", "plot", str(sample_csv), "-x", "x [m]"],
68
+ ):
69
+ with pytest.raises(ValueError, match="requires at least 2 columns"):
70
+ main()
71
+
72
+
73
+ def test_csl_plot_time_and_xy_together_raises(sample_csv):
74
+ with patch(
75
+ "sys.argv",
76
+ [
77
+ "csl",
78
+ "plot",
79
+ str(sample_csv),
80
+ "-t",
81
+ "x [m]",
82
+ "-x",
83
+ "x [m]",
84
+ "y [N]",
85
+ ],
86
+ ):
87
+ with pytest.raises(ValueError, match="Cannot use -t and -x together"):
88
+ main()
89
+
90
+
91
+ def test_csl_no_subcommand_prints_help(capsys):
92
+ with patch("sys.argv", ["csl"]):
93
+ main()
94
+ out = capsys.readouterr().out
95
+ assert "csv-session-logger CLI" in out
@@ -0,0 +1,247 @@
1
+ from unittest.mock import patch
2
+
3
+ import pandas as pd
4
+ import pytest
5
+
6
+ from csv_session_logger import Logger
7
+
8
+
9
+ # --- register() ---
10
+
11
+
12
+ def test_register_single_key():
13
+ log = Logger(session="s", output_dir="unused")
14
+ log.register("x", unit="m")
15
+ assert "x" in log._buffer
16
+ assert log._buffer["x"]["unit"] == "m"
17
+ assert log._buffer["x"]["values"] == []
18
+
19
+
20
+ def test_register_duplicate_key_raises():
21
+ log = Logger(session="s", output_dir="unused")
22
+ log.register("x")
23
+ with pytest.raises(ValueError, match="already registered"):
24
+ log.register("x")
25
+
26
+
27
+ def test_register_list_of_keys_with_matching_units():
28
+ log = Logger(session="s", output_dir="unused")
29
+ log.register(["a", "b"], ["m", "N"])
30
+ assert log._buffer["a"]["unit"] == "m"
31
+ assert log._buffer["b"]["unit"] == "N"
32
+
33
+
34
+ def test_register_list_of_keys_with_single_unit():
35
+ log = Logger(session="s", output_dir="unused")
36
+ log.register(["a", "b"], "rad")
37
+ assert log._buffer["a"]["unit"] == "rad"
38
+ assert log._buffer["b"]["unit"] == "rad"
39
+
40
+
41
+ def test_register_mismatched_unit_list_raises():
42
+ log = Logger(session="s", output_dir="unused")
43
+ with pytest.raises(ValueError):
44
+ log.register(["a", "b"], ["m"])
45
+
46
+
47
+ # --- log() ---
48
+
49
+
50
+ def test_log_unregistered_key_raises():
51
+ log = Logger(session="s", output_dir="unused")
52
+ with pytest.raises(ValueError, match="not registered"):
53
+ log.log("x", 1.0)
54
+
55
+
56
+ def test_log_stores_int_as_float():
57
+ log = Logger(session="s", output_dir="unused")
58
+ log.register("x")
59
+ log.log("x", 42)
60
+ assert log._buffer["x"]["values"] == [42.0]
61
+ assert isinstance(log._buffer["x"]["values"][0], float)
62
+
63
+
64
+ def test_log_multiple_calls_accumulate_in_order():
65
+ log = Logger(session="s", output_dir="unused")
66
+ log.register("x")
67
+ with patch("csv_session_logger.logger.time.time") as mock_time:
68
+ mock_time.side_effect = [1.0, 2.0, 3.0]
69
+ log.log("x", 1.0)
70
+ log.log("x", 2.0)
71
+ log.log("x", 3.0)
72
+ assert log._buffer["x"]["values"] == [1.0, 2.0, 3.0]
73
+ assert log._buffer["x"]["t_wall"] == [1.0, 2.0, 3.0]
74
+
75
+
76
+ # --- log_many() ---
77
+
78
+
79
+ def test_log_many_shares_single_t_wall():
80
+ log = Logger(session="s", output_dir="unused")
81
+ log.register(["a", "b"])
82
+ with patch("csv_session_logger.logger.time.time", return_value=42.0):
83
+ log.log_many({"a": 1.0, "b": 2.0})
84
+ assert log._buffer["a"]["t_wall"] == [42.0]
85
+ assert log._buffer["b"]["t_wall"] == [42.0]
86
+
87
+
88
+ def test_log_many_unregistered_key_raises():
89
+ log = Logger(session="s", output_dir="unused")
90
+ log.register("a")
91
+ with pytest.raises(ValueError, match="not registered"):
92
+ log.log_many({"a": 1.0, "b": 2.0})
93
+
94
+
95
+ # --- save() ---
96
+
97
+
98
+ def test_save_creates_session_timestamp_file(tmp_path):
99
+ log = Logger(session="my_run", output_dir=str(tmp_path))
100
+ log.register("x")
101
+ with patch("csv_session_logger.logger.time.time", return_value=100.0):
102
+ log.log("x", 1.0)
103
+ with patch.object(
104
+ log,
105
+ "_created_at",
106
+ "2025-06-01_12-00-00",
107
+ ):
108
+ path = log.save()
109
+ assert path == (tmp_path / "my_run_2025-06-01_12-00-00.csv").resolve()
110
+ assert path.exists()
111
+
112
+
113
+ def test_save_csv_index_name_is_t_wall(tmp_path):
114
+ log = Logger(session="s", output_dir=str(tmp_path))
115
+ log.register("x", unit="m")
116
+ with patch("csv_session_logger.logger.time.time", return_value=100.0):
117
+ log.log("x", 1.0)
118
+ path = log.save()
119
+ df = pd.read_csv(path, index_col="t_wall")
120
+ assert df.index.name == "t_wall"
121
+
122
+
123
+ def test_save_column_headers_with_and_without_units(tmp_path):
124
+ log = Logger(session="s", output_dir=str(tmp_path))
125
+ log.register("x", unit="m")
126
+ log.register("flag")
127
+ with patch("csv_session_logger.logger.time.time", return_value=100.0):
128
+ log.log_many({"x": 1.0, "flag": 0.0})
129
+ path = log.save()
130
+ df = pd.read_csv(path)
131
+ assert "x [m]" in df.columns
132
+ assert "flag" in df.columns
133
+
134
+
135
+ def test_save_fill_none_leaves_nan(tmp_path):
136
+ log = Logger(session="s", output_dir=str(tmp_path), fill="none")
137
+ log.register(["a", "b"])
138
+ with patch("csv_session_logger.logger.time.time") as mock_time:
139
+ mock_time.side_effect = [100.0, 101.0]
140
+ log.log("a", 1.0)
141
+ log.log("b", 2.0)
142
+ path = log.save()
143
+ df = pd.read_csv(path, index_col="t_wall")
144
+ assert pd.isna(df.loc[100.0, "b"])
145
+ assert pd.isna(df.loc[101.0, "a"])
146
+
147
+
148
+ def test_save_fill_ffill_forward_fills_but_leading_nan_remains(tmp_path):
149
+ log = Logger(session="s", output_dir=str(tmp_path), fill="ffill")
150
+ log.register(["a", "b"])
151
+ with patch("csv_session_logger.logger.time.time") as mock_time:
152
+ mock_time.side_effect = [100.0, 101.0]
153
+ log.log("a", 1.0)
154
+ log.log("b", 2.0)
155
+ path = log.save()
156
+ df = pd.read_csv(path, index_col="t_wall")
157
+ assert pd.isna(df.loc[100.0, "b"])
158
+ assert df.loc[101.0, "a"] == 1.0
159
+
160
+
161
+ def test_save_fill_mean_interpolates(tmp_path):
162
+ log = Logger(session="s", output_dir=str(tmp_path), fill="mean")
163
+ log.register(["a", "b"])
164
+ with patch("csv_session_logger.logger.time.time") as mock_time:
165
+ mock_time.side_effect = [100.0, 101.0, 102.0]
166
+ log.log("a", 0.0)
167
+ log.log("b", 0.0) # only b at t=101; row at 101 has NaN for a before fill
168
+ log.log("a", 10.0)
169
+ path = log.save()
170
+ df = pd.read_csv(path, index_col="t_wall")
171
+ assert df.loc[101.0, "a"] == pytest.approx(5.0)
172
+
173
+
174
+ def test_save_custom_filename(tmp_path):
175
+ log = Logger(session="s", output_dir=str(tmp_path))
176
+ log.register("x")
177
+ with patch("csv_session_logger.logger.time.time", return_value=100.0):
178
+ log.log("x", 1.0)
179
+ path = log.save(filename="custom.csv")
180
+ assert path.name == "custom.csv"
181
+ assert path.exists()
182
+
183
+
184
+ def test_save_filename_without_csv_extension_raises(tmp_path):
185
+ log = Logger(session="s", output_dir=str(tmp_path))
186
+ log.register("x")
187
+ with patch("csv_session_logger.logger.time.time", return_value=100.0):
188
+ log.log("x", 1.0)
189
+ with pytest.raises(ValueError, match="must end with '.csv'"):
190
+ log.save(filename="noext")
191
+
192
+
193
+ def test_save_empty_buffer_raises(tmp_path):
194
+ log = Logger(session="s", output_dir=str(tmp_path))
195
+ log.register("x")
196
+ with pytest.raises(RuntimeError, match="Buffer is empty"):
197
+ log.save()
198
+
199
+
200
+ # --- reset() ---
201
+
202
+
203
+ def test_reset_clears_values_preserves_registration(tmp_path):
204
+ log = Logger(session="s", output_dir=str(tmp_path))
205
+ log.register("x", unit="m")
206
+ with patch("csv_session_logger.logger.time.time", return_value=100.0):
207
+ log.log("x", 1.0)
208
+ log.reset()
209
+ assert log._buffer["x"]["values"] == []
210
+ assert log._buffer["x"]["t_wall"] == []
211
+ assert log._buffer["x"]["unit"] == "m"
212
+
213
+
214
+ def test_reset_changes_session_timestamp():
215
+ log = Logger(session="s", output_dir="unused")
216
+ log.register("x")
217
+ before = log._created_at
218
+ with patch("csv_session_logger.logger.datetime") as mock_dt:
219
+ mock_dt.now.return_value.strftime.return_value = "2099-01-01_00-00-00"
220
+ log.reset()
221
+ assert log._created_at == "2099-01-01_00-00-00"
222
+ assert log._created_at != before
223
+
224
+
225
+ # --- summary() and __repr__ ---
226
+
227
+
228
+ def test_summary_runs_with_output(capsys):
229
+ log = Logger(session="bench", output_dir="unused")
230
+ log.register("x", unit="m")
231
+ with patch("csv_session_logger.logger.time.time") as mock_time:
232
+ mock_time.side_effect = [10.0, 11.0]
233
+ log.log("x", 1.0)
234
+ log.log("x", 2.0)
235
+ log.summary()
236
+ out = capsys.readouterr().out
237
+ assert out.strip()
238
+ assert "bench" in out
239
+ assert "x [m]" in out
240
+
241
+
242
+ def test_repr_non_empty():
243
+ log = Logger(session="bench", output_dir="unused")
244
+ log.register("x")
245
+ text = repr(log)
246
+ assert text
247
+ assert "bench" in text
File without changes
File without changes