Source code for modelly.data.recorders.parquet

"""ParquetDataRecorder: snapshots buffered and written to .parquet files."""

from __future__ import annotations

import contextlib
import os
import pathlib
from typing import TYPE_CHECKING, Any

import numpy as np
import pandas as pd

from modelly.data.recorders.base import BaseDataRecorder

if TYPE_CHECKING:
    from modelly.data.recorders.config import DatasetConfig

_BYTES_PER_MB = 1024 * 1024
# Buffered rows that trigger a write to disk.
_BUFFER_ROWS = 1000


[docs] class ParquetDataRecorder(BaseDataRecorder): """Store collected data in Parquet files.""" def __init__( self, model, config: dict[str, DatasetConfig | dict[str, Any]] | None = None, output_dir: str = ".", ): """Initialize Parquet storage backend.""" self.output_dir = pathlib.Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) # Buffers for batching writes self.buffers: dict[str, list] = {} self.buffer_size = _BUFFER_ROWS # Write every N rows super().__init__(model, config) def _initialize_dataset_storage(self, dataset_name: str, dataset: Any) -> None: """Initialize buffer for dataset.""" self.buffers[dataset_name] = [] def _store_dataset_snapshot( self, dataset_name: str, time: int | float, data: Any, is_overwrite: bool = False, ) -> None: """Buffer data and write to Parquet when buffer is full.""" buffer = self.buffers[dataset_name] # handle overwrite if is_overwrite: self._drop_time(dataset_name, time) match data: case np.ndarray() if data.size > 0: buffer.extend(self._array_records(dataset_name, time, data)) case list() if data: buffer.extend([{**row, "time": time} for row in data]) case dict(): buffer.append({**data, "time": time}) # Flush to disk if buffer is full if len(buffer) >= self.buffer_size: self._flush_buffer(dataset_name) def _drop_time(self, dataset_name: str, time: int | float) -> None: """Remove the rows recorded at time, from the buffer or else from the file.""" buffer = self.buffers[dataset_name] removed_from_buffer = False while buffer and buffer[-1]["time"] == time: buffer.pop() removed_from_buffer = True # If the buffer was empty (flushed), we must remove it from the physical file filepath = self.output_dir / f"{dataset_name}.parquet" if not removed_from_buffer and filepath.exists(): df = pd.read_parquet(filepath) df = df[df["time"] != time] # Drop the overwritten rows df.to_parquet(filepath, index=False, compression="snappy") def _array_records( self, dataset_name: str, time: int | float, data: np.ndarray ) -> list[dict]: """Turn a NumPy snapshot into row records with agent_id and time columns.""" dataset = self.registry.datasets[dataset_name] columns = list(dataset._attributes) ids = dataset.agent_ids data_to_store = data ids_col = ids.reshape(-1, 1) data_to_store = np.hstack([ids_col, data]) columns = ["agent_id", *columns] df = pd.DataFrame(data_to_store, columns=columns) df["agent_id"] = df["agent_id"].astype(int) df["time"] = time return df.to_dict("records") def _flush_buffer(self, dataset_name: str): """Write buffer to Parquet file.""" buffer = self.buffers[dataset_name] if not buffer: return df = pd.DataFrame(buffer) filepath = self.output_dir / f"{dataset_name}.parquet" # Append to existing file or create new if filepath.exists(): existing = pd.read_parquet(filepath) df = pd.concat([existing, df], ignore_index=True) df.to_parquet(filepath, index=False, compression="snappy") buffer.clear()
[docs] def get_table_dataframe(self, name: str) -> pd.DataFrame: """Read data from Parquet file.""" if name not in self.buffers: raise KeyError(f"Dataset '{name}' not found") # Flush any remaining buffered data first self._flush_buffer(name) filepath = self.output_dir / f"{name}.parquet" if not filepath.exists(): return pd.DataFrame() return pd.read_parquet(filepath)
[docs] def clear(self, dataset_name: str | None = None) -> None: """Delete Parquet files.""" if dataset_name is None: for name in self.buffers: filepath = self.output_dir / f"{name}.parquet" if filepath.exists(): # pragma: no cover filepath.unlink() self.buffers[name].clear() else: if dataset_name not in self.buffers: raise KeyError(f"Dataset '{dataset_name}' not found") filepath = self.output_dir / f"{dataset_name}.parquet" if filepath.exists(): # pragma: no cover filepath.unlink() self.buffers[dataset_name].clear()
[docs] def summary(self) -> dict[str, Any]: """Get collection status summary.""" summary_data = { "datasets": len(self.buffers), "output_dir": str(self.output_dir), } for name in self.buffers: size_mb, row_count = self._file_stats(name) # Add buffered rows row_count += len(self.buffers[name]) summary_data[name] = { "enabled": self.configs[name].enabled, "interval": self.configs[name].interval, "rows": row_count, "size_mb": size_mb, "next_collection": self.configs[name]._next_collection, } return summary_data
def _file_stats(self, name: str) -> tuple[float, int]: """Return the size in MB and the row count of the dataset's Parquet file.""" filepath = self.output_dir / f"{name}.parquet" if not filepath.exists(): return 0, 0 # Get file size size_mb = os.path.getsize(filepath) / _BYTES_PER_MB # Get row count df = pd.read_parquet(filepath) return size_mb, len(df) def __del__(self): # pragma : no cover """Flush all buffers on cleanup.""" with contextlib.suppress( RuntimeError, ImportError, NameError, OSError, FileNotFoundError ): for name in self.buffers: self._flush_buffer(name)