Source code for modelly.data.io

"""Arrow and Parquet export of a DataCollector, and SQL over the results with DuckDB.

pyarrow and duckdb are optional: ``pip install "modelly-abm[experiments]"``.
Parquet files are written atomically (a temporary file in the same folder,
then ``os.replace``), so a reader never sees half a file, on any platform.

Example:
    >>> collector.to_parquet("runs/model.parquet")  # doctest: +SKIP
    >>> query("SELECT avg(Gini) FROM model", model=collector.to_arrow())  # doctest: +SKIP
"""

from __future__ import annotations

import os
import uuid
from collections.abc import Mapping
from pathlib import Path
from typing import Any

from modelly._optional import require

EXTRA = "experiments"
KINDS = ("model", "agent", "agenttype", "table")
TEMP_SUFFIX = ".tmp"


def _frame(collector: Any, kind: str, name: Any = None):
    """The pandas DataFrame of one collection, with its index as columns."""
    if kind == "model":
        return collector.get_model_vars_dataframe()
    if kind == "agent":
        return collector.get_agent_vars_dataframe().reset_index()
    if kind == "agenttype":
        if name is None:
            raise ValueError("kind='agenttype' needs name=<the agent class>")
        return collector.get_agenttype_vars_dataframe(name).reset_index()
    if kind == "table":
        if name is None:
            raise ValueError("kind='table' needs name=<the table name>")
        return collector.get_table_dataframe(name)
    raise ValueError(f"kind must be one of {', '.join(KINDS)}, got {kind!r}")


[docs] def to_arrow(collector: Any, kind: str = "model", name: Any = None): """One collection of ``collector`` as a ``pyarrow.Table``. Args: collector: a DataCollector. kind: ``"model"``, ``"agent"`` (with ``Step`` and ``AgentID`` columns), ``"agenttype"`` or ``"table"``. name: the agent class for ``"agenttype"``, the table name for ``"table"``. Example: >>> table = to_arrow(model.datacollector, "agent") # doctest: +SKIP """ pa = require("pyarrow", EXTRA) return pa.Table.from_pandas(_frame(collector, kind, name), preserve_index=False)
[docs] def write_parquet_atomic(table: Any, path: str | Path) -> Path: """Write a ``pyarrow.Table`` to ``path`` atomically; return the path. The table goes to a temporary file next to ``path`` first, then ``os.replace`` moves it into place, so a crash leaves no partial file. """ parquet = require("pyarrow.parquet", EXTRA) path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) temp = path.with_name(f".{path.name}.{uuid.uuid4().hex}{TEMP_SUFFIX}") try: parquet.write_table(table, temp) os.replace(temp, path) finally: temp.unlink(missing_ok=True) return path
[docs] def to_parquet( collector: Any, path: str | Path, kind: str = "model", name: Any = None ) -> Path: """Write one collection of ``collector`` to a Parquet file, atomically. Arguments are those of :func:`to_arrow`; returns the path written. Example: >>> to_parquet(model.datacollector, "out/agents.parquet", "agent") # doctest: +SKIP """ return write_parquet_atomic(to_arrow(collector, kind, name), path)
[docs] def query(sql: str, connection: Any = None, **tables: Any): """Run ``sql`` with DuckDB over named tables; return a DuckDB relation. Each keyword names a table in the query: a ``pyarrow.Table``, a pandas DataFrame, or a path (or glob) of Parquet files. Example: >>> query("SELECT count(*) FROM runs", runs="out/*.parquet").fetchone() # doctest: +SKIP """ duckdb = require("duckdb", EXTRA) connection = duckdb.connect() if connection is None else connection for table_name, source in tables.items(): _register(connection, table_name, source) return connection.sql(sql)
def _register(connection: Any, table_name: str, source: Any) -> None: """Make ``source`` queryable as ``table_name`` on ``connection``.""" if isinstance(source, str | Path): pattern = Path(source).as_posix().replace("'", "''") connection.sql( f'CREATE OR REPLACE VIEW "{table_name}" AS ' # noqa: S608 (local paths) f"SELECT * FROM read_parquet('{pattern}', union_by_name = true)" ) return connection.register(table_name, source) class ArrowExportMixin: """``to_arrow`` and ``to_parquet`` on the DataCollector.""" def to_arrow(self, kind: str = "model", name: Any = None): """This collector's ``kind`` collection as a ``pyarrow.Table`` (see :func:`to_arrow`).""" return to_arrow(self, kind, name) def to_parquet( self, path: str | Path, kind: str = "model", name: Any = None ) -> Path: """Write this collector's ``kind`` collection to Parquet (see :func:`to_parquet`).""" return to_parquet(self, path, kind, name)
[docs] def tables_of(collector: Any) -> Mapping[str, Any]: """Every non-empty collection of ``collector`` by name: ``model``, ``agent`` and its tables.""" found: dict[str, Any] = {} if collector.model_reporters: found["model"] = to_arrow(collector, "model") if collector.agent_reporters: found["agent"] = to_arrow(collector, "agent") for table_name in collector.tables: found[table_name] = to_arrow(collector, "table", table_name) return found