Structured filesystem logging for scientific runs
notata is a minimal library for structured filesystem logging of scientific runs.
Each Logbook creates a run directory with parameters, arrays, plots, artifacts, metadata, and a timestamped log. Explicit. Reproducible. Grep-friendly.
Unlike ML logging tools (e.g. W&B, TensorBoard, MLflow), notata is built for scientific workflows such as simulations, solvers, and numerical experiments, where file-based logging matters more than dashboards. No servers, no setup, no reinventing the wheel for each new project.
pip install notataLogs a single simulation run to log_<run_id>/, including arrays, metadata, and messages.
from notata import Logbook
import numpy as np
with Logbook("oscillator_dt1e-3", params={"omega": 2.0, "dt": 1e-3, "steps": 10_000}) as log:
omega = 2.0
dt = 1e-3
steps = 10_000
x, v = 1.0, 0.0
xs = np.empty(steps)
vs = np.empty(steps)
E = np.empty(steps)
for n in range(steps):
a = -omega**2 * x
x += v*dt + 0.5*a*dt*dt
a_new = -omega**2 * x
v += 0.5*(a + a_new)*dt
xs[n], vs[n] = x, v
E[n] = 0.5*(v**2 + (omega*x)**2)
if (n+1) % 2000 == 0:
log.info(f"step={n+1} x={x:.4f} v={v:.4f} E={E[n]:.6f}")
log.arrays("trajectory", x=xs, v=vs)
log.array("energy", E)
log.json("final_state", {"x": float(x), "v": float(v), "E": float(E[-1])})This creates a structured folder with logs, parameters, and output arrays for reproducibility.
For full control, create a Logbook manually and mark it complete when you're done.
from notata import Logbook
import numpy as np
Nx = Ny = 64
kappa = 0.01
dx = 1.0
dt = 0.2 * dx*dx / kappa
X, Y = np.meshgrid(np.linspace(-1,1,Nx), np.linspace(-1,1,Ny), indexing="ij")
T = np.exp(-6*(X**2 + Y**2))
log = Logbook("heat_eq")
log.params(Nx=Nx, Ny=Ny, kappa=kappa, steps=steps)
for step in range(500):
lap = (np.roll(T,1,0)+np.roll(T,-1,0)+np.roll(T,1,1)+np.roll(T,-1,1)-4*T)
T += kappa * dt * lap
if (step+1) % 100 == 0:
log.array(f"states/T_step{step+1}", T)
log.info(f"step={step+1} maxT={T.max():.4f}")
log.json("final_stats", {"max": float(T.max()), "mean": float(T.mean())})
log.mark_complete()Automatically log multiple runs, each in its own directory, with structured metadata and failure tracking:
from notata import Experiment
import numpy as np
exp = Experiment("falling_ball")
for dt in [0.01, 0.5]: # stable vs unstable
log = exp.add(dt=dt, skip_existing=True)
if log is None:
continue
with log:
v, h = 0.0, 100.0
for _ in range(100):
v += 9.81 * dt
h -= v * dt
if h < 0:
raise RuntimeError(f"Object hit the ground (h={h:.2f})")
log.json("metrics", {"final_height": h, "final_speed": v})Each run creates a log_<run_id>/ folder and appends a row to index.csv with parameters, status, and final metrics:
| run_id | dt | status | final_height | final_speed |
|---|---|---|---|---|
| falling_ball_dt_0.01 | 0.01 | complete | 95.04595 | 9.81 |
| falling_ball_dt_0.5 | 0.5 | missing |
Since notata log dirs are somewhat verbose, we also provided a utility wrapper to load and read the data.
This makes it more intuitive when you need to compare runs (e.g. in experiments), or you just don't want to deal with the files paths.
For single runs you can directly use notata.LogReader.
from notata import ExperimentReader
import matplotlib.pyplot as plt
# Load the experiment
exp = ExperimentReader("outputs/oscillator_sweep")
# Plot energy vs time for each run
fig, ax = plt.subplots()
for run in exp:
omega, dt = run.params['omega'], run.params['dt']
energy = run.load_array("energy")
label = f"omega={omega}, dt={dt}"
ax.plot(energy, label=label)
ax.set(xlabel="Time step", ylabel="Energy", title="Energy vs Time")
ax.legend()
plt.show()
# Print summary of runs
for run in exp:
status = run.meta.get('status', 'unkown')
duration = run.meta.get('runtime_sec', 'unknown')
print(f"Run ID: {run.run_id}, {status=}, {duration=}")API quick reference
Four public names, all importable from notata. Everything below writes into
the run directory shown under Output format.
from notata import Logbook, Experiment, LogReader, ExperimentReader
# --- writing a run ---------------------------------------------------------
log = Logbook(run_id, base_dir="outputs", params=None,
overwrite=False, preallocate=False, callback=None)
log.params(ext="yaml", **kwargs) # params.yaml (or params.json)
log.meta(**fields) # merge fields into metadata.json
log.array("energy", arr) # data/energy.npy
log.arrays("state", x=x, v=v) # data/state.npz (compressed=True)
log.json("metrics", {"error": 1e-6}) # artifacts/metrics.json
log.text("stdout", "...") # artifacts/stdout.txt
log.pickle("model", obj) # artifacts/model.pkl
log.bytes("weights.bin", b"...") # artifacts/weights.bin
log.plot("energy", fig=None, dpi=200, formats=("png",)) # plots/energy.png
log["artifacts/custom.dat"] # Path inside the run, parents created
log.info(msg); log.warning(msg); log.error(msg); log.debug(msg)
log.elapsed # seconds since init
log.status # initialized | complete | failed
log.mark_complete(); log.mark_failed(reason)
# As a context manager: complete on clean exit, failed if an exception escapes.
with Logbook("run1", params={"dt": 1e-3}) as log:
...
# --- sweeping --------------------------------------------------------------
exp = Experiment("sweep", base_dir="outputs")
log = exp.add(skip_existing=False, **params) # None if skipped
exp.to_dataframe() # pandas view of index.csv
exp.select(dt=0.01) # filtered rows
# --- reading back ----------------------------------------------------------
run = LogReader("outputs/log_run1")
run.run_id, run.params, run.meta # dicts straight from disk
run.arrays # ["energy", "state:x", ...]
run.artifacts # ["metrics.json", "nested/config.json", ...]
run.plots # filenames under plots/
run.load_array("energy") # data/energy.npy
run.load_array("state:x") # key "x" inside state.npz
run.load_json("metrics") # artifacts/metrics.json (.json optional)
exp = ExperimentReader("outputs/sweep")
len(exp); exp["run_id"]; exp.params; exp.meta
for run in exp: # each item is a LogReader
...Notes worth knowing: Logbook refuses to clobber an existing run directory
unless overwrite=True; plot needs matplotlib and to_dataframe needs
pandas, both optional; and methods are not thread- or process-safe, so
coordinate externally when logging from multiple workers.
Data is stored as following in order to be intuitive to explore:
log_<run_id>/
log.txt
metadata.json
params.yaml
data/
plots/
artifacts/where the files follow:
| Path / Pattern | Purpose / Format |
|---|---|
log.txt |
Plain text log; lines: [YYYY-MM-DDTHH:MM:SS] LEVEL message |
metadata.json |
Run metadata: status, start_time, optional end_time, runtime_sec, optional failure_reason, run_id |
params.yaml / params.json |
Parameter snapshot (latest saved form) |
data/*.npy / data/*.npz |
.npy for single arrays (array()); .npz for multi-array bundles (arrays(...)) |
plots/*.(png|pdf|svg) |
Saved figures (save_plot) |
artifacts/*.txt |
Text artifacts (save_text) |
artifacts/*.json |
JSON artifacts (save_json) |
artifacts/*.pkl |
Pickled objects (save_pickle) |
artifacts/* (other) |
Raw bytes (save_bytes) |
artifacts/**/ |
Nested artifact categories |
Full documentation, tutorials, and examples are available at: https://notata.readthedocs.io/en/latest/
You don't have to, but if you use notata in your research and need to reference it, please cite it as follows:
@software{notata_2025,
author = {Albert Alonso},
title = {notata: Structured Filesystem Logging for Scientific Runs},
url = {https://github.com/alonfnt/notata},
doi = {10.5281/zenodo.22108081},
version = {0.3.0},
year = {2026}
}
MIT License
