diff --git a/.gitignore b/.gitignore index 98823b67..a95df910 100644 --- a/.gitignore +++ b/.gitignore @@ -155,9 +155,10 @@ cython_debug/ llm_rules.md .python-version -benchmarks/results/* +benchmarks/**/results +benchmarks/**/plots docs/api/_build/* docs/api/reference/* -examples/**/results/* +examples/**/results docs/general/**/data_* docs/site/* \ No newline at end of file diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..42da5535 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,84 @@ +# Benchmarks + +Performance benchmarks compare Mesa Frames backends ("frames") with classic Mesa ("mesa") +implementations for a small set of representative models. They help track runtime scaling +and regressions. + +Currently included models: + +- **boltzmann**: Simple wealth exchange ("Boltzmann wealth") model. +- **sugarscape**: Sugarscape Immediate Growback variant (square grid sized relative to agent count). + +## Quick start + +```bash +uv run benchmarks/cli.py +``` + +That command (with defaults) will: + +- Benchmark both models (`boltzmann`, `sugarscape`). +- Use agent counts 1000, 2000, 3000, 4000, 5000. +- Run 100 steps per simulation. +- Repeat each configuration once. +- Save CSV results and generate plots. + +## CLI options + +Invoke `uv run benchmarks/cli.py --help` to see full help. Key options: + +| Option | Default | Description | +| ------ | ------- | ----------- | +| `--models` | `all` | Comma list or `all`; accepted: `boltzmann`, `sugarscape`. | +| `--agents` | `1000:5000:1000` | Single int or range `start:stop:step`. | +| `--steps` | `100` | Steps per simulation run. | +| `--repeats` | `1` | How many repeats per (model, backend, agents) config. Seed increments per repeat. | +| `--seed` | `42` | Base RNG seed. Incremented by repeat index. | +| `--save / --no-save` | `--save` | Persist per‑model CSVs. | +| `--plot / --no-plot` | `--plot` | Generate scaling plots (PNG + possibly other formats). | +| `--results-dir` | `benchmarks/results` | Root directory that will receive a timestamped subdirectory. | + +Range parsing: `A:B:S` includes `A, A+S, ... <= B`. Final value > B is dropped. + +## Output layout + +Each invocation uses a single UTC timestamp, e.g. `20251016_173702`: + +```text +benchmarks/ + results/ + 20251016_173702/ + boltzmann_perf_20251016_173702.csv + sugarscape_perf_20251016_173702.csv + plots/ + boltzmann_runtime_20251016_173702_dark.png + sugarscape_runtime_20251016_173702_dark.png + ... (other themed variants if enabled) +``` + +CSV schema (one row per completed run): + +| Column | Meaning | +| ------ | ------- | +| `model` | Model key (`boltzmann`, `sugarscape`). | +| `backend` | `mesa` or `frames`. | +| `agents` | Agent count for that run. | +| `steps` | Steps simulated. | +| `seed` | Seed used (base seed + repeat index). | +| `repeat_idx` | Repeat counter starting at 0. | +| `runtime_seconds` | Wall-clock runtime for that run. | +| `timestamp` | Shared timestamp identifier for the benchmark batch. | + +## Performance tips + +- Ensure the environment variable `MESA_FRAMES_RUNTIME_TYPECHECKING` is **unset** or set to `0` / `false` when collecting performance numbers. Enabling it adds runtime type validation overhead and the CLI will warn you. +- Run multiple repeats (`--repeats 5`) to smooth variance. + +## Extending benchmarks + +To benchmark an additional model: + +1. Add or import both a Mesa implementation and a Frames implementation exposing a `simulate(agents:int, steps:int, seed:int|None, ...)` function. +2. Register it in `benchmarks/cli.py` inside the `MODELS` dict with two backends (names must be `mesa` and `frames`). +3. Ensure any extra spatial parameters are derived from `agents` inside the runner lambda (see sugarscape example). +4. Run the CLI to verify new CSV columns still align. diff --git a/benchmarks/cli.py b/benchmarks/cli.py new file mode 100644 index 00000000..c9beb7d2 --- /dev/null +++ b/benchmarks/cli.py @@ -0,0 +1,266 @@ +"""Typer CLI for running mesa vs mesa-frames performance benchmarks.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import os +from pathlib import Path +from time import perf_counter +from typing import Literal, Annotated, Protocol, Optional + +import math +import polars as pl +import typer + +from examples.boltzmann_wealth import backend_frames as boltzmann_frames +from examples.boltzmann_wealth import backend_mesa as boltzmann_mesa +from examples.sugarscape_ig.backend_frames import model as sugarscape_frames +from examples.sugarscape_ig.backend_mesa import model as sugarscape_mesa +from examples.plotting import ( + plot_performance as _examples_plot_performance, +) + +app = typer.Typer(add_completion=False) + + +class RunnerP(Protocol): + def __call__(self, agents: int, steps: int, seed: int | None = None) -> None: ... + + +@dataclass(slots=True) +class Backend: + name: Literal["mesa", "frames"] + runner: RunnerP + + +@dataclass(slots=True) +class ModelConfig: + name: str + backends: list[Backend] + + +MODELS: dict[str, ModelConfig] = { + "boltzmann": ModelConfig( + name="boltzmann", + backends=[ + Backend(name="mesa", runner=boltzmann_mesa.simulate), + Backend(name="frames", runner=boltzmann_frames.simulate), + ], + ), + "sugarscape": ModelConfig( + name="sugarscape", + backends=[ + Backend( + name="mesa", + runner=lambda agents, steps, seed=None: sugarscape_mesa.simulate( + agents=agents, + steps=steps, + width=int(max(20, math.ceil((agents) ** 0.5) * 2)), + height=int(max(20, math.ceil((agents) ** 0.5) * 2)), + seed=seed, + ), + ), + Backend( + name="frames", + # Benchmarks expect a runner signature (agents:int, steps:int, seed:int|None) + # Sugarscape frames simulate requires width/height; choose square close to agent count. + runner=lambda agents, steps, seed=None: sugarscape_frames.simulate( + agents=agents, + steps=steps, + width=int(max(20, math.ceil((agents) ** 0.5) * 2)), + height=int(max(20, math.ceil((agents) ** 0.5) * 2)), + seed=seed, + ), + ), + ], + ), +} + + +def _parse_agents(value: str) -> list[int]: + value = value.strip() + if ":" in value: + parts = value.split(":") + if len(parts) != 3: + raise typer.BadParameter("Ranges must use start:stop:step format") + try: + start, stop, step = (int(part) for part in parts) + except ValueError as exc: + raise typer.BadParameter("Range values must be integers") from exc + if step <= 0: + raise typer.BadParameter("Step must be positive") + if start < 0 or stop <= 0: + raise typer.BadParameter("Range endpoints must be positive") + if start > stop: + raise typer.BadParameter("Range start must be <= stop") + counts = list(range(start, stop + step, step)) + if counts[-1] > stop: + counts.pop() + return counts + try: + agents = int(value) + except ValueError as exc: # pragma: no cover - defensive + raise typer.BadParameter("Agent count must be an integer") from exc + if agents <= 0: + raise typer.BadParameter("Agent count must be positive") + return [agents] + + +def _parse_models(value: str) -> list[str]: + """Parse models option into a list of model keys. + + Accepts: + - "all" -> returns all available model keys + - a single model name -> returns [name] + - a comma-separated list of model names -> returns list + + Validates that each selected model exists in MODELS. + """ + value = value.strip() + if value == "all": + return list(MODELS.keys()) + # support comma-separated lists + parts = [part.strip() for part in value.split(",") if part.strip()] + if not parts: + raise typer.BadParameter("Model selection must not be empty") + unknown = [p for p in parts if p not in MODELS] + if unknown: + raise typer.BadParameter(f"Unknown model selection: {', '.join(unknown)}") + # preserve order and uniqueness + seen = set() + result: list[str] = [] + for p in parts: + if p not in seen: + seen.add(p) + result.append(p) + return result + + +def _plot_performance( + df: pl.DataFrame, model_name: str, output_dir: Path, timestamp: str +) -> None: + """Wrap examples.plotting.plot_performance to ensure consistent theming. + + The original benchmark implementation used simple seaborn styles (whitegrid / darkgrid). + Our example plotting utilities define a much darker, high-contrast *true* dark theme + (custom rc params overriding bg/fg colors). Reuse that logic here so the + benchmark dark plots match the example dark plots users see elsewhere. + """ + if df.is_empty(): + return + stem = f"{model_name}_runtime_{timestamp}" + _examples_plot_performance( + df.select(["agents", "runtime_seconds", "backend"]), + output_dir=output_dir, + stem=stem, + # Prefer more concise, publication-style wording + title=f"{model_name.title()} runtime scaling", + ) + + +@app.command() +def run( + models: Annotated[ + str, + typer.Option( + help="Models to benchmark: boltzmann, sugarscape, or all", + callback=_parse_models, + ), + ] = "all", + agents: Annotated[ + str, + typer.Option( + help="Agent count or range (start:stop:step)", callback=_parse_agents + ), + ] = "1000:5000:1000", + steps: Annotated[ + int, + typer.Option( + min=0, + help="Number of steps per run.", + ), + ] = 100, + repeats: Annotated[int, typer.Option(help="Repeats per configuration.", min=1)] = 1, + seed: Annotated[int, typer.Option(help="Optional RNG seed.")] = 42, + save: Annotated[bool, typer.Option(help="Persist benchmark CSV results.")] = True, + plot: Annotated[bool, typer.Option(help="Render performance plots.")] = True, + results_dir: Annotated[ + Path, + typer.Option( + help=( + "Base directory for benchmark outputs. A timestamped subdirectory " + "(e.g. results/20250101_120000) is created with CSV files at the root " + "and a 'plots/' subfolder for images." + ), + ), + ] = Path(__file__).resolve().parent / "results", +) -> None: + """Run performance benchmarks for the models models.""" + runtime_typechecking = os.environ.get("MESA_FRAMES_RUNTIME_TYPECHECKING", "") + if runtime_typechecking and runtime_typechecking.lower() not in {"0", "false"}: + typer.secho( + "Warning: MESA_FRAMES_RUNTIME_TYPECHECKING is enabled; benchmarks may run significantly slower.", + fg=typer.colors.YELLOW, + ) + rows: list[dict[str, object]] = [] + # Single timestamp per CLI invocation so all model results are co-located. + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + # Create unified output layout: //{CSV files, plots/} + base_results_dir = results_dir + timestamp_dir = (base_results_dir / timestamp).resolve() + plots_subdir: Path = timestamp_dir / "plots" + for model in models: + config = MODELS[model] + typer.echo(f"Benchmarking {model} with agents {agents}") + for agents_count in agents: + for repeat_idx in range(repeats): + run_seed = seed + repeat_idx + for backend in config.backends: + start = perf_counter() + backend.runner(agents_count, steps, run_seed) + runtime = perf_counter() - start + rows.append( + { + "model": model, + "backend": backend.name, + "agents": agents_count, + "steps": steps, + "seed": run_seed, + "repeat_idx": repeat_idx, + "runtime_seconds": runtime, + "timestamp": timestamp, + } + ) + # Report completion of this run to the CLI + typer.echo( + f"Completed {backend.name} for model={model} agents={agents_count} steps={steps} seed={run_seed} repeat={repeat_idx} in {runtime:.3f}s" + ) + # Finished all runs for this model + typer.echo(f"Finished benchmarking model {model}") + + if not rows: + typer.echo("No benchmark data collected.") + return + df = pl.DataFrame(rows) + if save: + timestamp_dir.mkdir(parents=True, exist_ok=True) + for model in models: + model_df = df.filter(pl.col("model") == model) + csv_path = timestamp_dir / f"{model}_perf_{timestamp}.csv" + model_df.write_csv(csv_path) + typer.echo(f"Saved {model} results to {csv_path}") + if plot: + plots_subdir.mkdir(parents=True, exist_ok=True) + for model in models: + model_df = df.filter(pl.col("model") == model) + _plot_performance(model_df, model, plots_subdir, timestamp) + typer.echo(f"Saved {model} plots under {plots_subdir}") + + typer.echo( + f"Unified benchmark outputs written under {timestamp_dir} (CSV files) and {plots_subdir} (plots)" + ) + + +if __name__ == "__main__": + app() diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..359bbaf8 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,106 @@ +# Examples + +This directory contains runnable example models and shared plotting/utilities +used in the tutorials and benchmarks. Each example provides **two backends**: + +- `mesa` (classic Mesa, object-per-agent) +- `frames` (Mesa Frames, vectorised agent sets / dataframe-centric) + +They expose a consistent Typer CLI so you can compare outputs and timings. + +## Contents + +```text +examples/ + boltzmann_wealth/ + backend_mesa.py # Mesa implementation + CLI (simulate() + run) + backend_frames.py # Frames implementation + CLI (simulate() + run) + sugarscape_ig/ + backend_mesa/ # Mesa Sugarscape (agents + model + CLI) + backend_frames/ # Frames Sugarscape (agents + model + CLI) + plotting.py # Shared plotting helpers (Seaborn + dark theme) + utils.py # Small dataclasses for simulation results +``` + +## Quick start + +Always run via `uv` from the project root. The simplest way to run an example +backend is to execute the module: + +```bash +uv run examples/boltzmann_wealth/backend_frames.py +``` + +Each command will: + +1. Print a short banner with configuration. +2. Run the simulation and show elapsed time. +3. Emit a tail of the collected metrics (e.g. last 5 Gini values). +4. Save CSV metrics and optional plots in a timestamped directory under that + example's `results/` folder (unless overridden by `--results-dir`). + +## CLI symmetry + +Both backends accept similar options: + +- `--agents` (population size) +- `--steps` (number of simulated steps) +- `--seed` (optional RNG seed; Mesa backend resets model RNG) +- `--plot / --no-plot` (toggle plot generation) +- `--save-results / --no-save-results` (persist CSV outputs) +- `--results-dir` (override auto-created timestamped folder) + +The Frames Boltzmann backend stores model metrics in a Polars DataFrame via +`mesa_frames.DataCollector`; the Mesa backend uses the standard `mesa.DataCollector` +returning pandas DataFrames, then converts to Polars only for plotting so plots +look identical. + +## Data and metrics + +The saved CSV layout (Frames) places `model.csv` in the results directory with +columns like: `step, gini, `. +The Mesa implementations write +compatible CSVs. + +## Plotting helpers + +`examples/plotting.py` provides: + +- `plot_model_metrics(df, output_dir, stem, title, subtitle, agents, steps)` + Produces dark theme line plots of model-level metrics (currently Gini) and + stores PNG files under `output_dir` with names like `gini__dark.png`. +- `plot_performance(df, output_dir, stem, title)` used by `benchmarks/cli.py` to + generate runtime scaling plots. + +The dark theme matches the styling used in the documentation for visual +consistency. + +## Interacting programmatically + +Instead of using the CLIs you can import the simulation entry points directly: + +```python +from examples.boltzmann_wealth import backend_frames as bw_frames +result = bw_frames.simulate(agents=2000, steps=100, seed=123) +polars_df = result.datacollector.data["model"] # Polars DataFrame of metrics +``` + +Each `simulate()` returns a small dataclass (`FramesSimulationResult` or +`MesaSimulationResult`) holding the respective `DataCollector` instance so you +can further analyse the collected data. + +## Tips + +- To compare backends fairly, disable runtime type checking when measuring performance: + set environment variable `MESA_FRAMES_RUNTIME_TYPECHECKING=0`. +- Use the same `--seed` across runs for reproducible trajectories (given the + stochastic nature of agent interactions). +- Larger Sugarscape grids (width/height) increase memory and runtime; choose + sizes proportional to the square root of agent count for balanced density. + +## Adding Examples + +You can adapt these scripts to prototype new models: copy a backend pair, +rename the module, and implement your agent rules while keeping the API +surface (`simulate`, `run`) consistent so tooling and documentation patterns +continue to apply. diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 00000000..069e9dc5 --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1,6 @@ +"""Examples package for the repository.""" + +__all__ = [ + "boltzmann_wealth", + "sugarscape_ig", +] diff --git a/examples/boltzmann_wealth/README.md b/examples/boltzmann_wealth/README.md new file mode 100644 index 00000000..9999fe27 --- /dev/null +++ b/examples/boltzmann_wealth/README.md @@ -0,0 +1,96 @@ +# Boltzmann Wealth Exchange Model + +## Overview + +This example implements a simple wealth exchange ("Boltzmann money") model in two +backends: + +- `backend_frames.py` (Mesa Frames / vectorised `AgentSet`) +- `backend_mesa.py` (classic Mesa / object-per-agent) + +Both expose a Typer CLI with symmetric options so you can compare correctness +and performance directly. + +## Concept + +Each agent starts with 1 unit of wealth. At every step: + +1. Frames backend: all agents with strictly positive wealth become potential donors. + Each donor gives 1 unit of wealth, and a recipient is drawn (with replacement) + for every donating agent. A single vectorised update applies donor losses and + recipient gains. +2. Mesa backend: agents are shuffled and iterate sequentially; each agent with + positive wealth transfers 1 unit to a randomly selected peer. + +The stochastic exchange process leads to an emergent, increasingly unequal +wealth distribution and rising Gini coefficient, typically approaching a stable +level below 1 (due to conservation and continued mixing). + +## Reported Metrics + +The model records per-step population Gini (`gini`). You can extend reporters by +adding lambdas to `model_reporters` in either backend's constructor. + +Notes on interpretation: + +- Early steps: Gini ~ 0 (uniform initial wealth). +- Mid phase: Increasing Gini as random exchanges concentrate wealth. +- Late phase: Fluctuating plateau (a stochastic steady state) — exact level + varies with agent count and RNG seed. + +## Running + +Always run examples from the project root using `uv`: + +```bash +uv run examples/boltzmann_wealth/backend_frames.py --agents 5000 --steps 200 --seed 123 --plot --save-results +uv run examples/boltzmann_wealth/backend_mesa.py --agents 5000 --steps 200 --seed 123 --plot --save-results +``` + +## CLI options + +- `--agents` Number of agents (default 5000) +- `--steps` Simulation steps (default 100) +- `--seed` Optional RNG seed for reproducibility +- `--plot / --no-plot` Generate line plot(s) of Gini +- `--save-results / --no-save-results` Persist CSV metrics +- `--results-dir` Override the auto timestamped directory under `results/` + +Frames backend additionally warns if runtime type checking is enabled because it +slows vectorised operations: set `MESA_FRAMES_RUNTIME_TYPECHECKING=0` for fair +performance comparisons. + +## Outputs + +Each run creates (or uses) a results directory like: + +```text +examples/boltzmann_wealth/results/20251016_173702/ + model.csv # step,gini + gini__dark.png (and possibly other theme variants) +``` + +Tail metrics are printed to console for quick inspection: + +```text +Metrics in the final 5 steps: shape: (5, 2) +┌──────┬───────┐ +│ step ┆ gini │ +│ --- ┆ --- │ +│ i64 ┆ f64 │ +├──────┼───────┤ +│ ... ┆ ... │ +└──────┴───────┘ +``` + +## Performance & Benchmarking + +Use the shared benchmarking CLI to compare scaling, checkout `benchmarks/README.md`. + +## Programmatic Use + +```python +from examples.boltzmann_wealth import backend_frames as bw_frames +result = bw_frames.simulate(agents=10000, steps=250, seed=42) +metrics = result.datacollector.data["model"] # Polars DataFrame +``` diff --git a/examples/boltzmann_wealth/__init__.py b/examples/boltzmann_wealth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/boltzmann_wealth/backend_frames.py b/examples/boltzmann_wealth/backend_frames.py new file mode 100644 index 00000000..da26dba9 --- /dev/null +++ b/examples/boltzmann_wealth/backend_frames.py @@ -0,0 +1,190 @@ +"""Mesa-frames implementation of the Boltzmann wealth model with Typer CLI.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated + +import numpy as np +import os +import polars as pl +import typer +from time import perf_counter + +from mesa_frames import AgentSet, DataCollector, Model +from examples.utils import FramesSimulationResult +from examples.plotting import plot_model_metrics + + +# Note: by default we create a timestamped results directory under `results/`. +# The CLI will accept optional `results_dir` and `plots_dir` arguments to override. + + +def gini(frame: pl.DataFrame) -> float: + wealth = frame["wealth"] if "wealth" in frame.columns else pl.Series([]) + if wealth.is_empty(): + return float("nan") + values = wealth.to_numpy().astype(np.float64) + if values.size == 0: + return float("nan") + if np.allclose(values, 0.0): + return 0.0 + if np.allclose(values, values[0]): + return 0.0 + sorted_vals = np.sort(values) + n = sorted_vals.size + cumulative = np.cumsum(sorted_vals) + total = cumulative[-1] + if total == 0: + return 0.0 + index = np.arange(1, n + 1, dtype=np.float64) + return float((2.0 * np.dot(index, sorted_vals) / (n * total)) - (n + 1) / n) + + +class MoneyAgents(AgentSet): + """Vectorised agent set for the Boltzmann wealth exchange model.""" + + def __init__(self, model: Model, agents: int) -> None: + super().__init__(model) + self += pl.DataFrame({"wealth": pl.Series(np.ones(agents, dtype=np.int64))}) + + def step(self) -> None: + self.select(pl.col("wealth") > 0) + if len(self.active_agents) == 0: + return + # Use the model RNG to seed Polars sampling so results are reproducible + recipients = self.df.sample( + n=len(self.active_agents), + with_replacement=True, + seed=self.random.integers(np.iinfo(np.int32).max), + ) + # Combine donor loss (1 per active agent) and recipient gains in a single adjustment. + gains = recipients.group_by("unique_id").len() + self.df = ( + self.df.join(gains, on="unique_id", how="left") + .with_columns( + ( + pl.col("wealth") + # each active agent loses 1 unit of wealth + + pl.when(pl.col("wealth") > 0).then(-1).otherwise(0) + # each agent gains 1 unit of wealth for each time they were selected as a recipient + + pl.col("len").fill_null(0) + ).alias("wealth") + ) + .drop("len") + ) + + +class MoneyModel(Model): + """Mesa-frames model that mirrors the Mesa implementation.""" + + def __init__( + self, agents: int, *, seed: int | None = None, results_dir: Path | None = None + ) -> None: + super().__init__(seed) + self.sets += MoneyAgents(self, agents) + # For benchmarks we frequently call simulate() without providing a results_dir. + # Persisting to disk would add unnecessary IO overhead and a missing storage_uri + # currently raises in DataCollector validation. Fallback to in-memory collection + # when no results_dir is supplied; otherwise write CSV files under results_dir. + if results_dir is None: + storage = "memory" + storage_uri = None + else: + storage = "csv" + storage_uri = str(results_dir) + self.datacollector = DataCollector( + model=self, + model_reporters={ + "gini": lambda m: gini(m.sets[0].df), + }, + storage=storage, + storage_uri=storage_uri, + ) + + def step(self) -> None: + self.sets.do("step") + self.datacollector.collect() + + def run(self, steps: int) -> None: + for _ in range(steps): + self.step() + + +def simulate( + agents: int, + steps: int, + seed: int | None = None, + results_dir: Path | None = None, +) -> FramesSimulationResult: + model = MoneyModel(agents, seed=seed, results_dir=results_dir) + model.run(steps) + # collect data from datacollector into memory first + return FramesSimulationResult(datacollector=model.datacollector) + + +app = typer.Typer(add_completion=False) + + +@app.command() +def run( + agents: Annotated[int, typer.Option(help="Number of agents to simulate.")] = 5000, + steps: Annotated[int, typer.Option(help="Number of model steps to run.")] = 100, + seed: Annotated[int | None, typer.Option(help="Optional RNG seed.")] = None, + plot: Annotated[bool, typer.Option(help="Render Seaborn plots.")] = True, + save_results: Annotated[bool, typer.Option(help="Persist metrics as CSV.")] = True, + results_dir: Annotated[ + Path | None, + typer.Option( + help="Directory to write CSV results and plots into. If omitted a timestamped subdir under `results/` is used." + ), + ] = None, +) -> None: + runtime_typechecking = os.environ.get("MESA_FRAMES_RUNTIME_TYPECHECKING", "") + if runtime_typechecking and runtime_typechecking.lower() not in {"0", "false"}: + typer.secho( + "Warning: MESA_FRAMES_RUNTIME_TYPECHECKING is enabled; this run will be slower.", + fg=typer.colors.YELLOW, + ) + typer.echo( + f"Running Boltzmann wealth model (mesa-frames) with {agents} agents for {steps} steps" + ) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + if results_dir is None: + results_dir = ( + Path(__file__).resolve().parent / "results" / timestamp + ).resolve() + results_dir.mkdir(parents=True, exist_ok=True) + start_time = perf_counter() + result = simulate(agents=agents, steps=steps, seed=seed, results_dir=results_dir) + + typer.echo(f"Simulation complete in {perf_counter() - start_time:.2f} seconds") + + model_metrics = result.datacollector.data["model"].select("step", "gini") + + typer.echo(f"Metrics in the final 5 steps: {model_metrics.tail(5)}") + + if save_results: + result.datacollector.flush() + + if plot: + stem = f"gini_{timestamp}" + # write plots into the results directory so outputs are colocated + plot_model_metrics( + model_metrics, + results_dir, + stem, + title="Boltzmann wealth — Gini", + subtitle=f"mesa-frames backend; seed={result.datacollector.seed}", + agents=agents, + steps=steps, + ) + typer.echo(f"Saved plots under {results_dir}") + + # Inform user where CSVs were saved + typer.echo(f"Saved CSV results under {results_dir}") + + +if __name__ == "__main__": + app() diff --git a/examples/boltzmann_wealth/backend_mesa.py b/examples/boltzmann_wealth/backend_mesa.py new file mode 100644 index 00000000..8b86ad3e --- /dev/null +++ b/examples/boltzmann_wealth/backend_mesa.py @@ -0,0 +1,181 @@ +"""Mesa implementation of the Boltzmann wealth model with Typer CLI.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated +from collections.abc import Iterable +import pandas as pd + +import matplotlib.pyplot as plt +import mesa +from mesa.datacollection import DataCollector +import numpy as np +import polars as pl +import seaborn as sns +import typer +from time import perf_counter + +from examples.utils import MesaSimulationResult +from examples.plotting import plot_model_metrics + + +def gini(values: Iterable[float]) -> float: + """Compute the Gini coefficient from an iterable of wealth values.""" + array = np.fromiter(values, dtype=float) + if array.size == 0: + return float("nan") + if np.allclose(array, 0.0): + return 0.0 + if np.allclose(array, array[0]): + return 0.0 + sorted_vals = np.sort(array) + n = sorted_vals.size + cumulative = np.cumsum(sorted_vals) + total = cumulative[-1] + if total == 0: + return 0.0 + index = np.arange(1, n + 1, dtype=float) + return float((2.0 * np.dot(index, sorted_vals) / (n * total)) - (n + 1) / n) + + +class MoneyAgent(mesa.Agent): + """Agent that passes one unit of wealth to a random neighbour.""" + + def __init__(self, model: MoneyModel) -> None: + super().__init__(model) + self.wealth = 1 + + def step(self) -> None: + if self.wealth <= 0: + return + other = self.random.choice(self.model.agent_list) + if other is None: + return + other.wealth += 1 + self.wealth -= 1 + + +class MoneyModel(mesa.Model): + """Mesa backend that mirrors the mesa-frames Boltzmann wealth example.""" + + def __init__(self, agents: int, *, seed: int | None = None) -> None: + super().__init__() + if seed is None: + seed = self.random.randint(0, np.iinfo(np.int32).max) + self.reset_randomizer(seed) + self.agent_list: list[MoneyAgent] = [] + for _ in range(agents): + # NOTE: storing agents in a Python list keeps iteration fast for benchmarks. + agent = MoneyAgent(self) + self.agent_list.append(agent) + self.datacollector = DataCollector( + model_reporters={ + "gini": lambda m: gini(a.wealth for a in m.agent_list), + "seed": lambda m: seed, + } + ) + self.datacollector.collect(self) + + def step(self) -> None: + self.random.shuffle(self.agent_list) + for agent in self.agent_list: + agent.step() + self.datacollector.collect(self) + + def run(self, steps: int) -> None: + for _ in range(steps): + self.step() + + +def simulate(agents: int, steps: int, seed: int | None = None) -> MesaSimulationResult: + """Run the Mesa Boltzmann wealth model.""" + model = MoneyModel(agents, seed=seed) + model.run(steps) + + return MesaSimulationResult(datacollector=model.datacollector) + + +app = typer.Typer(add_completion=False) + + +@app.command() +def run( + agents: Annotated[int, typer.Option(help="Number of agents to simulate.")] = 5000, + steps: Annotated[int, typer.Option(help="Number of model steps to run.")] = 100, + seed: Annotated[int | None, typer.Option(help="Optional RNG seed.")] = None, + plot: Annotated[bool, typer.Option(help="Render plots.")] = True, + save_results: Annotated[ + bool, + typer.Option(help="Persist metrics as CSV."), + ] = True, + results_dir: Annotated[ + Path | None, + typer.Option( + help=( + "Directory to write CSV results and plots into. If omitted a " + "timestamped subdir under `results/` is used." + ) + ), + ] = None, +) -> None: + """Execute the Mesa Boltzmann wealth simulation.""" + + typer.echo( + f"Running Boltzmann wealth model (mesa) with {agents} agents for {steps} steps" + ) + + # Resolve output folder + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + if results_dir is None: + results_dir = ( + Path(__file__).resolve().parent / "results" / timestamp + ).resolve() + results_dir.mkdir(parents=True, exist_ok=True) + + start_time = perf_counter() + # Run simulation (Mesa‑idiomatic): we only use DataCollector's public API + result = simulate(agents=agents, steps=steps, seed=seed) + typer.echo(f"Simulation completed in {perf_counter() - start_time:.3f} seconds") + dc = result.datacollector + + # ---- Extract metrics (no helper, no monkey‑patch): + # DataCollector returns a pandas DataFrame with the index as the step. + model_pd = dc.get_model_vars_dataframe() + model_pd = model_pd.reset_index() + # The first column is the step index; normalize name to "step". + model_pd = model_pd.rename(columns={model_pd.columns[0]: "step"}) + seed = model_pd["seed"].iloc[0] + model_pd = model_pd[["step", "gini"]] + + # Show a short tail in console for quick inspection + tail_str = model_pd.tail(5).to_string(index=False) + typer.echo(f"Metrics in the final 5 steps:\n{tail_str}") + + # ---- Save CSV (same filename/layout as frames backend expects) + if save_results: + csv_path = results_dir / "model.csv" + model_pd.to_csv(csv_path, index=False) + + # ---- Plot (convert to Polars to reuse the shared plotting helper) + if plot and not model_pd.empty: + model_pl = pl.from_pandas(model_pd) + stem = f"gini_{timestamp}" + plot_model_metrics( + model_pl, + results_dir, + stem, + title="Boltzmann wealth — Gini", + subtitle=f"mesa backend; seed={seed}", + agents=agents, + steps=steps, + ) + typer.echo(f"Saved plots under {results_dir}") + + if save_results: + typer.echo(f"Saved CSV results under {results_dir}") + + +if __name__ == "__main__": + app() diff --git a/examples/plotting.py b/examples/plotting.py new file mode 100644 index 00000000..17075451 --- /dev/null +++ b/examples/plotting.py @@ -0,0 +1,290 @@ +# examples/plotting.py +from __future__ import annotations + +from pathlib import Path +from collections.abc import Sequence +import re + +import polars as pl +import seaborn as sns +import matplotlib.pyplot as plt +from matplotlib.ticker import FormatStrFormatter +from matplotlib.figure import Figure +from matplotlib.axes import Axes + +# ----------------------------- Shared theme ---------------------------------- + +_THEMES = { + "light": dict( + style="whitegrid", + rc={ + "axes.spines.top": False, + "axes.spines.right": False, + }, + ), + "dark": dict( + style="whitegrid", + rc={ + # real dark background + readable foreground + "figure.facecolor": "#0b1021", + "axes.facecolor": "#0b1021", + "axes.edgecolor": "#d6d6d7", + "axes.labelcolor": "#e8e8ea", + "text.color": "#e8e8ea", + "xtick.color": "#c9c9cb", + "ytick.color": "#c9c9cb", + "grid.color": "#2a2f4a", + "grid.alpha": 0.35, + "axes.spines.top": False, + "axes.spines.right": False, + "legend.facecolor": "#121734", + "legend.edgecolor": "#3b3f5a", + }, + ), +} + + +def _shorten_seed(text: str | None) -> str | None: + """Turn '... seed=1234567890123' into '... seed=12345678…' if present.""" + if not text: + return text + m = re.search(r"seed=([^;,\s]+)", text) + if not m: + return text + raw = m.group(1) + short = (raw[:8] + "…") if len(raw) > 10 else raw + return re.sub(r"seed=[^;,\s]+", f"seed={short}", text) + + +def _apply_titles(fig: Figure, ax: Axes, title: str, subtitle: str | None) -> None: + """Consistent title placement: figure-level title + small italic subtitle.""" + fig.suptitle(title, fontsize=18, y=0.98) + ax.set_title(_shorten_seed(subtitle) or "", fontsize=12, fontstyle="italic", pad=4) + + +def _finalize_and_save(fig: Figure, output_dir: Path, stem: str, theme: str) -> None: + """Tight layout with space for suptitle, export PNG + (optional) SVG.""" + output_dir.mkdir(parents=True, exist_ok=True) + fig.tight_layout(rect=[0, 0, 1, 0.94]) + png = output_dir / f"{stem}_{theme}.png" + fig.savefig(png, dpi=300) + try: + fig.savefig(output_dir / f"{stem}_{theme}.svg", bbox_inches="tight") + except Exception: + pass # SVG is a nice-to-have + plt.close(fig) + + +# -------------------------- Public: model metrics ---------------------------- + + +def plot_model_metrics( + metrics: pl.DataFrame, + output_dir: Path, + stem: str, + title: str, + *, + subtitle: str = "", + figsize: tuple[int, int] | None = None, + agents: int | None = None, + steps: int | None = None, +) -> None: + """ + Plot time-series metrics from a Polars DataFrame and export light/dark PNG/SVG. + + - Auto-detects `step` or adds one if missing. + - Melts all non-`step` columns into long form. + - If there's a single metric (e.g., 'gini'), removes legend and uses a + descriptive y-axis label (e.g., 'Gini coefficient'). + - Optional `agents` and `steps` will be appended to the suptitle as + "(N=, T=)"; if `steps` is omitted it will be inferred + from the `step` column when available. + """ + if metrics.is_empty(): + return + + if "step" not in metrics.columns: + metrics = metrics.with_row_index("step") + + # If steps not provided, try to infer from the data (max step + 1). Keep it None if we can't determine it. + if steps is None: + try: + steps = int(metrics.select(pl.col("step").max()).item()) + 1 + except Exception: + steps = None + + value_cols: Sequence[str] = [c for c in metrics.columns if c != "step"] + if not value_cols: + return + + long = ( + metrics.select(["step", *value_cols]) + .unpivot( + index="step", on=value_cols, variable_name="metric", value_name="value" + ) + .to_pandas() + ) + + # Compose informative title with optional (N, T) + if agents is not None and steps is not None: + full_title = f"{title} (N={agents}, T={steps})" + elif agents is not None: + full_title = f"{title} (N={agents})" + elif steps is not None: + full_title = f"{title} (T={steps})" + else: + full_title = title + + for theme, cfg in _THEMES.items(): + sns.set_theme(**cfg) + sns.set_context("talk") + fig, ax = plt.subplots(figsize=figsize or (10, 6)) + + sns.lineplot(data=long, x="step", y="value", hue="metric", linewidth=2, ax=ax) + + _apply_titles(fig, ax, full_title, subtitle) + + ax.set_xlabel("Step") + unique_metrics = long["metric"].unique() + + if len(unique_metrics) == 1: + name = unique_metrics[0] + ax.set_ylabel(name.capitalize()) + leg = ax.get_legend() + if leg is not None: + leg.remove() + vals = long.loc[long["metric"] == name, "value"] + if not vals.empty: + vmin, vmax = float(vals.min()), float(vals.max()) + pad = max(0.005, (vmax - vmin) * 0.05) + ax.set_ylim(vmin - pad, vmax + pad) + else: + ax.set_ylabel("Value") + leg = ax.get_legend() + if theme == "dark" and leg is not None: + leg.set_title(None) + leg.get_frame().set_alpha(0.8) + + ax.yaxis.set_major_formatter(FormatStrFormatter("%.3f")) + ax.margins(x=0.01) + + _finalize_and_save(fig, output_dir, stem, theme) + + +# -------------------------- Public: agent metrics ---------------------------- + + +def plot_agent_metrics( + agent_metrics: pl.DataFrame, + output_dir: Path, + stem: str, + *, + title: str = "Agent metrics", + subtitle: str = "", + figsize: tuple[int, int] | None = None, +) -> None: + """ + Plot agent-level metrics (multi-series) and export light/dark PNG/SVG. + + - Preserves common id vars if present: `step`, `seed`, `batch`. + - Uses the first column as id if none of the preferred ids exist. + """ + if agent_metrics is None or agent_metrics.is_empty(): + return + + preferred = ["step", "seed", "batch"] + id_vars = [c for c in preferred if c in agent_metrics.columns] or [ + agent_metrics.columns[0] + ] + + # Determine which columns to unpivot (all columns except the id vars). + value_cols = [c for c in agent_metrics.columns if c not in id_vars] + if not value_cols: + return + + melted = agent_metrics.unpivot( + index=id_vars, on=value_cols, variable_name="metric", value_name="value" + ).to_pandas() + + xcol = id_vars[0] + + for theme, cfg in _THEMES.items(): + sns.set_theme(**cfg) + sns.set_context("talk") + fig, ax = plt.subplots(figsize=figsize or (10, 6)) + + sns.lineplot(data=melted, x=xcol, y="value", hue="metric", linewidth=1.8, ax=ax) + + _apply_titles(fig, ax, title, subtitle) + ax.set_xlabel(xcol.capitalize()) + ax.set_ylabel("Value") + + if theme == "dark": + leg = ax.get_legend() + if leg is not None: + leg.set_title(None) + leg.get_frame().set_alpha(0.8) + + _finalize_and_save(fig, output_dir, f"{stem}_agents", theme) + + +# -------------------------- Public: performance ------------------------------ + + +def plot_performance( + df: pl.DataFrame, + output_dir: Path, + stem: str, + *, + title: str = "Runtime vs agents", + subtitle: str = "", + figsize: tuple[int, int] | None = None, +) -> None: + """ + Plot backend performance (runtime vs agents) with mean±sd error bars. + Expected columns: `agents`, `runtime_seconds`, `backend`. + """ + if df.is_empty(): + return + + pdf = df.to_pandas() + + for theme, cfg in _THEMES.items(): + sns.set_theme(**cfg) + sns.set_context("talk") + fig, ax = plt.subplots(figsize=figsize or (10, 6)) + + sns.lineplot( + data=pdf, + x="agents", + y="runtime_seconds", + hue="backend", + estimator="mean", + errorbar="sd", + marker="o", + ax=ax, + ) + + _apply_titles(fig, ax, title, subtitle) + ax.set_xlabel("Agents") + ax.set_ylabel("Runtime (seconds)") + leg = ax.get_legend() + if leg is not None: + # Remove redundant legend title (backend) for both themes – label colors already distinguish. + leg.set_title(None) + frame = leg.get_frame() + if theme == "dark": + frame.set_alpha(0.8) + else: # light theme: subtle boxed legend for readability on white grid + frame.set_alpha(0.9) + frame.set_edgecolor("#d0d0d0") + frame.set_linewidth(0.8) + + _finalize_and_save(fig, output_dir, stem, theme) + + +__all__ = [ + "plot_model_metrics", + "plot_agent_metrics", + "plot_performance", +] diff --git a/examples/sugarscape_ig/README.md b/examples/sugarscape_ig/README.md new file mode 100644 index 00000000..f33970d5 --- /dev/null +++ b/examples/sugarscape_ig/README.md @@ -0,0 +1,105 @@ +# Sugarscape IG (Instant Growback) + +## Overview + +This directory contains a minimal Instant Growback Sugarscape implementation in +both backends: + +- `backend_frames/` parallel (vectorised) movement variant using Mesa Frames +- `backend_mesa/` sequential (asynchronous) movement variant using classic Mesa + +The Instant Growback (IG) rule sequence is: move -> eat -> regrow -> collect. +Agents harvest sugar, pay metabolism costs, possibly die (starve), and empty +cells instantly regrow to their `max_sugar` value. + +## Concept + +Each agent has integer traits: + +- `sugar` (current stores) +- `metabolism` (per-step consumption) +- `vision` (how far the agent can see in cardinal directions) + +Movement policy (both backends conceptually): + +1. Sense visible cells along N/E/S/W up to `vision` steps (including origin). +2. Rank candidate cells by: (a) sugar (desc), (b) distance (asc), (c) coordinates + as deterministic tie-breaker. +3. Choose highest-ranked empty cell; fall back to origin if none available. + +The Frames parallel variant resolves conflicts by iterative lottery rounds using +rank promotion; the sequential Mesa variant inherently orders moves by shuffled +agent iteration. + +After moving, agents harvest sugar on their cell, pay metabolism, and starved +agents are removed. Empty cells regrow to their `max_sugar` value immediately. + +## Reported Metrics + +Both backends record population-level reporters each step: + +- `mean_sugar` Average sugar per surviving agent. +- `total_sugar` Aggregate sugar held by living agents. +- `agents_alive` Population size (declines as agents starve). +- `gini` Inequality in sugar holdings (0 = equal, higher = more unequal). +- `corr_sugar_metabolism` Pearson correlation (do high-metabolism agents retain sugar?). +- `corr_sugar_vision` Pearson correlation (does greater vision correlate with sugar?). + +Notes on interpretation: + +- `agents_alive` typically decreases until a quasi steady state (metabolism vs regrowth) or total collapse. +- `mean_sugar` and `total_sugar` may stabilise if regrowth balances metabolism. +- Rising `gini` indicates emerging inequality; sustained high values suggest strong positional advantages. +- Correlations near 0 imply weak linear relationships; positive `corr_sugar_vision` suggests high vision aids resource gathering. Negative `corr_sugar_metabolism` can emerge if high metabolism accelerates starvation. + +## Running + +From project root using `uv`: + +```bash +uv run examples/sugarscape_ig/backend_frames/model.py --agents 400 --width 40 --height 40 --steps 60 --seed 123 --plot --save-results +uv run examples/sugarscape_ig/backend_mesa/model.py --agents 400 --width 40 --height 40 --steps 60 --seed 123 --plot --save-results +``` + +## CLI options + +- `--agents` Number of agents (default 400) +- `--width`, `--height` Grid dimensions (default 40x40) +- `--steps` Max steps (default 60) +- `--max-sugar` Initial/regrowth max sugar per cell (default 4) +- `--seed` Optional RNG seed +- `--plot / --no-plot` Generate per-metric plots +- `--save-results / --no-save-results` Persist CSV outputs +- `--results-dir` Override auto timestamped directory under `results/` + +Frames backend warns if `MESA_FRAMES_RUNTIME_TYPECHECKING` is enabled (disable for benchmarks). + +## Outputs + +Example output directory (frames): + +```text +examples/sugarscape_ig/backend_frames/results/20251016_173702/ + model.csv + plots/ + gini__dark.png + agents_alive__dark.png + mean_sugar__dark.png + ... +``` + +`model.csv` columns include: `step`, `mean_sugar`, `total_sugar`, `agents_alive`, +`gini`, `corr_sugar_metabolism`, `corr_sugar_vision`, plus backend-specific bookkeeping. +Mesa backend normalises to the same layout (excluding internal columns). + +## Performance & Benchmarking + +Use the shared benchmarking CLI to compare scaling, checkout `benchmarks/README.md`. + +## Programmatic Use + +```python +from examples.sugarscape_ig.backend_frames import model as sg_frames +res = sg_frames.simulate(agents=500, steps=80, width=50, height=50, seed=42) +metrics = res.datacollector.data["model"] # Polars DataFrame +``` diff --git a/examples/sugarscape_ig/backend_frames/__init__.py b/examples/sugarscape_ig/backend_frames/__init__.py new file mode 100644 index 00000000..614fa64d --- /dev/null +++ b/examples/sugarscape_ig/backend_frames/__init__.py @@ -0,0 +1 @@ +"""mesa-frames backend package for Sugarscape IG examples.""" diff --git a/examples/sugarscape_ig/backend_frames/agents.py b/examples/sugarscape_ig/backend_frames/agents.py new file mode 100644 index 00000000..e619df00 --- /dev/null +++ b/examples/sugarscape_ig/backend_frames/agents.py @@ -0,0 +1,626 @@ +"""Agent implementations for the Sugarscape IG example (mesa-frames). + +This module provides the parallel (synchronous) movement variant as in the +advanced tutorial. The code and comments mirror +docs/general/user-guide/3_advanced_tutorial.py. +""" + +from __future__ import annotations + +import numpy as np +import polars as pl + +from mesa_frames import AgentSet, Model + + +class AntsBase(AgentSet): + """Base agent set for the Sugarscape tutorial. + + This class implements the common behaviour shared by all agent + movement variants (sequential, numba-accelerated and parallel). + + Notes + ----- + - Agents are expected to have integer traits: ``sugar``, ``metabolism`` + and ``vision``. These are validated in :meth:`__init__`. + - Subclasses must implement :meth:`move` which changes agent positions + on the grid (via :meth:`mesa_frames.Grid` helpers). + """ + + def __init__(self, model: Model, agent_frame: pl.DataFrame) -> None: + """Initialise the agent set and validate required trait columns. + + Parameters + ---------- + model : Model + The parent model which provides RNG and space. + agent_frame : pl.DataFrame + A Polars DataFrame with at least the columns ``sugar``, + ``metabolism`` and ``vision`` for each agent. + + Raises + ------ + ValueError + If required trait columns are missing from ``agent_frame``. + """ + super().__init__(model) + required = {"sugar", "metabolism", "vision"} + missing = required.difference(agent_frame.columns) + if missing: + raise ValueError( + f"Initial agent frame must include columns {sorted(required)}; missing {sorted(missing)}." + ) + self.add(agent_frame.clone()) + + def step(self) -> None: + """Advance the agent set by one time step. + + The update order is important: agents are first shuffled to randomise + move order (this is important only for sequential variants), then they move, harvest sugar + from their occupied cells, and finally any agents whose sugar falls + to zero or below are removed. + """ + # Randomise ordering for movement decisions when required by the + # implementation (e.g. sequential update uses this shuffle). + self.shuffle(inplace=True) + # Movement policy implemented by subclasses. + self.move() + # Agents harvest sugar on their occupied cells. + self.eat() + # Remove agents that starved after eating. + self._remove_starved() + + def move(self) -> None: # pragma: no cover + """Abstract movement method. + + Subclasses must override this method to update agent positions on the + grid. Implementations should use :meth:`mesa_frames.Grid.move_agents` + or similar helpers provided by the space API. + """ + raise NotImplementedError + + def eat(self) -> None: + """Agents harvest sugar from the cells they currently occupy. + + Behaviour: + - Look up the set of occupied cells (cells that reference an agent + id). + - For each occupied cell, add the cell sugar to the agent's sugar + stock and subtract the agent's metabolism cost. + - After agents harvest, set the sugar on those cells to zero (they + were consumed). + """ + # Map of currently occupied agent ids on the grid. + occupied_ids = self.index + # `occupied_ids` is a Polars Series; calling `is_in` with a Series + # of the same datatype is ambiguous in newer Polars. Use `implode` + # to collapse the Series into a list-like value for membership checks. + occupied_cells = self.space.cells.filter( + pl.col("agent_id").is_in(occupied_ids.implode()) + ) + if occupied_cells.is_empty(): + return + # The agent ordering here uses the agent_id values stored in the + # occupied cells frame; indexing the agent set with that vector updates + # the matching agents' sugar values in one vectorised write. + agent_ids = occupied_cells["agent_id"] + self[agent_ids, "sugar"] = ( + self[agent_ids, "sugar"] + + occupied_cells["sugar"] + - self[agent_ids, "metabolism"] + ) + # After harvesting, occupied cells have zero sugar. + self.space.set_cells( + occupied_cells.select(["dim_0", "dim_1"]), + {"sugar": pl.Series(np.zeros(len(occupied_cells), dtype=np.int64))}, + ) + + def _remove_starved(self) -> None: + """Discard agents whose sugar stock has fallen to zero or below. + + This method performs a vectorised filter on the agent frame and + removes any matching rows from the set. + """ + starved = self.df.filter(pl.col("sugar") <= 0) + if not starved.is_empty(): + # ``discard`` accepts a DataFrame of agents to remove. + self.discard(starved) + + +class AntsParallel(AntsBase): + def move(self) -> None: + """Move agents in parallel by ranking visible cells and resolving conflicts. + + Declarative mental model: express *what* each agent wants (ranked candidates), + then use dataframe ops to *allocate* (joins, group_by with a lottery). + Performance is handled by Polars/LazyFrames; avoid premature micro-optimisations. + + Returns + ------- + None + Movement updates happen in-place on the underlying space. + """ + # Early exit if there are no agents. + if len(self.df) == 0: + return + + # current_pos columns: + # ┌──────────┬────────────────┬────────────────┐ + # │ agent_id ┆ dim_0_center ┆ dim_1_center │ + # │ --- ┆ --- ┆ --- │ + # │ u64 ┆ i64 ┆ i64 │ + # ╞══════════╪════════════════╪════════════════╡ + current_pos = self.pos.select( + [ + pl.col("unique_id").alias("agent_id"), + pl.col("dim_0").alias("dim_0_center"), + pl.col("dim_1").alias("dim_1_center"), + ] + ) + + neighborhood = self._build_neighborhood_frame(current_pos) + choices, origins, max_rank = self._rank_candidates(neighborhood, current_pos) + if choices.is_empty(): + return + + assigned = self._resolve_conflicts_in_rounds(choices, origins, max_rank) + if assigned.is_empty(): + return + + # move_df columns: + # ┌────────────┬────────────┬────────────┐ + # │ unique_id ┆ dim_0 ┆ dim_1 │ + # │ --- ┆ --- ┆ --- │ + # │ u64 ┆ i64 ┆ i64 │ + # ╞════════════╪════════════╪════════════╡ + move_df = pl.DataFrame( + { + "unique_id": assigned["agent_id"], + "dim_0": assigned["dim_0_candidate"], + "dim_1": assigned["dim_1_candidate"], + } + ) + # `move_agents` accepts IdsLike and SpaceCoordinates (Polars Series/DataFrame), + # so pass Series/DataFrame directly rather than converting to Python lists. + self.space.move_agents(move_df["unique_id"], move_df.select(["dim_0", "dim_1"])) + + def _build_neighborhood_frame(self, current_pos: pl.DataFrame) -> pl.DataFrame: + """Assemble the sugar-weighted neighbourhood for each sensing agent. + + Parameters + ---------- + current_pos : pl.DataFrame + DataFrame with columns ``agent_id``, ``dim_0_center`` and + ``dim_1_center`` describing the current position of each agent. + + Returns + ------- + pl.DataFrame + DataFrame with columns ``agent_id``, ``radius``, ``dim_0_candidate``, + ``dim_1_candidate`` and ``sugar`` describing the visible cells for + each agent. + """ + # Build a neighbourhood frame: for each agent and visible cell we + # attach the cell sugar. The raw offsets contain the candidate + # cell coordinates and the center coordinates for the sensing agent. + # Raw neighborhood columns: + # ┌────────────┬────────────┬────────┬────────────────┬────────────────┐ + # │ dim_0 ┆ dim_1 ┆ radius ┆ dim_0_center ┆ dim_1_center │ + # │ --- ┆ --- ┆ --- ┆ --- ┆ --- │ + # │ i64 ┆ i64 ┆ i64 ┆ i64 ┆ i64 │ + # ╞════════════╪════════════╪════════╪════════════════╪════════════════╡ + neighborhood_cells = self.space.get_neighborhood( + radius=self["vision"], agents=self, include_center=True + ) + + # sugar_cells columns: + # ┌────────────┬────────────┬────────┐ + # │ dim_0 ┆ dim_1 ┆ sugar │ + # │ --- ┆ --- ┆ --- │ + # │ i64 ┆ i64 ┆ i64 │ + # ╞════════════╪════════════╪════════╡ + + sugar_cells = self.space.cells.select(["dim_0", "dim_1", "sugar"]) + + neighborhood_cells = ( + neighborhood_cells.join(sugar_cells, on=["dim_0", "dim_1"], how="left") + .with_columns(pl.col("sugar").fill_null(0)) + .rename({"dim_0": "dim_0_candidate", "dim_1": "dim_1_candidate"}) + ) + + neighborhood_cells = neighborhood_cells.join( + current_pos, + left_on=["dim_0_center", "dim_1_center"], + right_on=["dim_0_center", "dim_1_center"], + how="left", + ) + + # Final neighborhood columns: + # ┌──────────┬────────┬──────────────────┬──────────────────┬────────┐ + # │ agent_id ┆ radius ┆ dim_0_candidate ┆ dim_1_candidate ┆ sugar │ + # │ --- ┆ --- ┆ --- ┆ --- ┆ --- │ + # │ u64 ┆ i64 ┆ i64 ┆ i64 ┆ i64 │ + # ╞══════════╪════════╪══════════════════╪══════════════════╪════════╡ + neighborhood_cells = neighborhood_cells.drop( + ["dim_0_center", "dim_1_center"] + ).select(["agent_id", "radius", "dim_0_candidate", "dim_1_candidate", "sugar"]) + + return neighborhood_cells + + def _rank_candidates( + self, + neighborhood: pl.DataFrame, + current_pos: pl.DataFrame, + ) -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame]: + """Rank candidate destination cells for each agent. + + Parameters + ---------- + neighborhood : pl.DataFrame + Output of :meth:`_build_neighborhood_frame` with columns + ``agent_id``, ``radius``, ``dim_0_candidate``, ``dim_1_candidate`` + and ``sugar``. + current_pos : pl.DataFrame + Frame with columns ``agent_id``, ``dim_0_center`` and + ``dim_1_center`` describing where each agent currently stands. + + Returns + ------- + choices : pl.DataFrame + Ranked candidates per agent with columns ``agent_id``, + ``dim_0_candidate``, ``dim_1_candidate``, ``sugar``, ``radius`` and + ``rank``. + origins : pl.DataFrame + Original coordinates per agent with columns ``agent_id``, + ``dim_0`` and ``dim_1``. + max_rank : pl.DataFrame + Maximum available rank per agent with columns ``agent_id`` and + ``max_rank``. + """ + # Create ranked choices per agent: sort by sugar (desc), radius + # (asc), then coordinates. Keep the first unique entry per cell. + # choices columns (after select): + # ┌──────────┬──────────────────┬──────────────────┬────────┬────────┐ + # │ agent_id ┆ dim_0_candidate ┆ dim_1_candidate ┆ sugar ┆ radius │ + # │ --- ┆ --- ┆ --- ┆ --- ┆ --- │ + # │ u64 ┆ i64 ┆ i64 ┆ i64 ┆ i64 │ + # ╞══════════╪══════════════════╪══════════════════╪════════╪════════╡ + choices = ( + neighborhood.select( + [ + "agent_id", + "dim_0_candidate", + "dim_1_candidate", + "sugar", + "radius", + ] + ) + .with_columns(pl.col("radius")) + .sort( + ["agent_id", "sugar", "radius", "dim_0_candidate", "dim_1_candidate"], + descending=[False, True, False, False, False], + ) + .unique( + subset=["agent_id", "dim_0_candidate", "dim_1_candidate"], + keep="first", + maintain_order=True, + ) + .with_columns(pl.col("agent_id").cum_count().over("agent_id").alias("rank")) + ) + + # Precompute per‑agent candidate rank once so conflict resolution can + # promote losers by incrementing a cheap `current_rank` counter, + # without re-sorting after each round. Alternative: drop taken cells + # and re-rank by sugar every round; simpler conceptually but requires + # repeated sorts and deduplication, which is heavier than filtering by + # `rank >= current_rank`. + + # Origins for fallback (if an agent exhausts candidates it stays put). + # origins columns: + # ┌──────────┬────────────┬────────────┐ + # │ agent_id ┆ dim_0 ┆ dim_1 │ + # │ --- ┆ --- ┆ --- │ + # │ u64 ┆ i64 ┆ i64 │ + # ╞══════════╪════════════╪════════════╡ + origins = current_pos.select( + [ + "agent_id", + pl.col("dim_0_center").alias("dim_0"), + pl.col("dim_1_center").alias("dim_1"), + ] + ) + + # Track the maximum available rank per agent to clamp promotions. + # This bounds `current_rank`; once an agent reaches `max_rank` and + # cannot secure a cell, they fall back to origin cleanly instead of + # chasing nonexistent ranks. + # max_rank columns: + # ┌──────────┬───────────┐ + # │ agent_id ┆ max_rank │ + # │ --- ┆ --- │ + # │ u64 ┆ u32 │ + # ╞══════════╪═══════════╡ + max_rank = choices.group_by("agent_id").agg( + pl.col("rank").max().alias("max_rank") + ) + return choices, origins, max_rank + + def _resolve_conflicts_in_rounds( + self, + choices: pl.DataFrame, + origins: pl.DataFrame, + max_rank: pl.DataFrame, + ) -> pl.DataFrame: + """Resolve movement conflicts through iterative lottery rounds. + + Parameters + ---------- + choices : pl.DataFrame + Ranked candidate cells per agent with headers matching the + ``choices`` frame returned by :meth:`_rank_candidates`. + origins : pl.DataFrame + Agent origin coordinates with columns ``agent_id``, ``dim_0`` and + ``dim_1``. + max_rank : pl.DataFrame + Maximum rank offset per agent with columns ``agent_id`` and + ``max_rank``. + + Returns + ------- + pl.DataFrame + Allocated movements with columns ``agent_id``, ``dim_0_candidate`` + and ``dim_1_candidate``; each row records the destination assigned + to an agent. + """ + # Prepare unresolved agents and working tables. + agent_ids = choices["agent_id"].unique(maintain_order=True) + + # unresolved columns: + # ┌──────────┬────────────────┐ + # │ agent_id ┆ current_rank │ + # │ --- ┆ --- │ + # │ u64 ┆ i64 │ + # ╞══════════╪════════════════╡ + unresolved = pl.DataFrame( + { + "agent_id": agent_ids, + "current_rank": pl.Series(np.zeros(len(agent_ids), dtype=np.int64)), + } + ) + + # assigned columns: + # ┌──────────┬──────────────────┬──────────────────┐ + # │ agent_id ┆ dim_0_candidate ┆ dim_1_candidate │ + # │ --- ┆ --- ┆ --- │ + # │ u64 ┆ i64 ┆ i64 │ + # ╞══════════╪══════════════════╪══════════════════╡ + assigned = pl.DataFrame( + { + "agent_id": pl.Series( + name="agent_id", values=[], dtype=agent_ids.dtype + ), + "dim_0_candidate": pl.Series( + name="dim_0_candidate", values=[], dtype=pl.Int64 + ), + "dim_1_candidate": pl.Series( + name="dim_1_candidate", values=[], dtype=pl.Int64 + ), + } + ) + + # taken columns: + # ┌──────────────────┬──────────────────┐ + # │ dim_0_candidate ┆ dim_1_candidate │ + # │ --- ┆ --- │ + # │ i64 ┆ i64 │ + # ╞══════════════════╪══════════════════╡ + taken = pl.DataFrame( + { + "dim_0_candidate": pl.Series( + name="dim_0_candidate", values=[], dtype=pl.Int64 + ), + "dim_1_candidate": pl.Series( + name="dim_1_candidate", values=[], dtype=pl.Int64 + ), + } + ) + + # Resolve in rounds: each unresolved agent proposes its current-ranked + # candidate; winners per-cell are selected at random and losers are + # promoted to their next choice. + while unresolved.height > 0: + # Using precomputed `rank` lets us select candidates with + # `rank >= current_rank` and avoid re-ranking after each round. + # Alternative: remove taken cells and re-sort remaining candidates + # by sugar/distance per round (heavier due to repeated sort/dedupe). + # candidate_pool columns (after join with unresolved): + # ┌──────────┬──────────────────┬──────────────────┬────────┬────────┬──────┬──────────────┐ + # │ agent_id ┆ dim_0_candidate ┆ dim_1_candidate ┆ sugar ┆ radius ┆ rank ┆ current_rank │ + # │ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │ + # │ u64 ┆ i64 ┆ i64 ┆ i64 ┆ i64 ┆ u32 ┆ i64 │ + # ╞══════════╪══════════════════╪══════════════════╪════════╪════════╪══════╪══════════════╡ + candidate_pool = choices.join(unresolved, on="agent_id") + candidate_pool = candidate_pool.filter( + pl.col("rank") >= pl.col("current_rank") + ) + if not taken.is_empty(): + candidate_pool = candidate_pool.join( + taken, + on=["dim_0_candidate", "dim_1_candidate"], + how="anti", + ) + + if candidate_pool.is_empty(): + # No available candidates — everyone falls back to origin. + # Note: this covers both agents with no visible cells left and + # the case where all remaining candidates are already taken. + # fallback columns: + # ┌──────────┬────────────┬────────────┬──────────────┐ + # │ agent_id ┆ dim_0 ┆ dim_1 ┆ current_rank │ + # │ --- ┆ --- ┆ --- ┆ --- │ + # │ u64 ┆ i64 ┆ i64 ┆ i64 │ + # ╞══════════╪════════════╪════════════╪══════════════╡ + fallback = unresolved.join(origins, on="agent_id", how="left") + assigned = pl.concat( + [ + assigned, + fallback.select( + [ + "agent_id", + pl.col("dim_0").alias("dim_0_candidate"), + pl.col("dim_1").alias("dim_1_candidate"), + ] + ), + ], + how="vertical", + ) + break + + # best_candidates columns (per agent first choice): + # ┌──────────┬──────────────────┬──────────────────┬────────┬────────┬──────┬──────────────┐ + # │ agent_id ┆ dim_0_candidate ┆ dim_1_candidate ┆ sugar ┆ radius ┆ rank ┆ current_rank │ + # │ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │ + # │ u64 ┆ i64 ┆ i64 ┆ i64 ┆ i64 ┆ u32 ┆ i64 │ + # ╞══════════╪══════════════════╪══════════════════╪════════╪════════╪══════╪══════════════╡ + best_candidates = ( + candidate_pool.sort(["agent_id", "rank"]) + .group_by("agent_id", maintain_order=True) + .first() + ) + + # Agents that had no candidate this round fall back to origin. + # missing columns: + # ┌──────────┬──────────────┐ + # │ agent_id ┆ current_rank │ + # │ --- ┆ --- │ + # │ u64 ┆ i64 │ + # ╞══════════╪══════════════╡ + missing = unresolved.join( + best_candidates.select("agent_id"), on="agent_id", how="anti" + ) + if not missing.is_empty(): + # fallback (missing) columns match fallback table above. + fallback = missing.join(origins, on="agent_id", how="left") + assigned = pl.concat( + [ + assigned, + fallback.select( + [ + "agent_id", + pl.col("dim_0").alias("dim_0_candidate"), + pl.col("dim_1").alias("dim_1_candidate"), + ] + ), + ], + how="vertical", + ) + taken = pl.concat( + [ + taken, + fallback.select( + [ + pl.col("dim_0").alias("dim_0_candidate"), + pl.col("dim_1").alias("dim_1_candidate"), + ] + ), + ], + how="vertical", + ) + unresolved = unresolved.join( + missing.select("agent_id"), on="agent_id", how="anti" + ) + best_candidates = best_candidates.join( + missing.select("agent_id"), on="agent_id", how="anti" + ) + if unresolved.is_empty() or best_candidates.is_empty(): + continue + + # Add a small random lottery to break ties deterministically for + # each candidate set. + lottery = pl.Series("lottery", self.random.random(best_candidates.height)) + best_candidates = best_candidates.with_columns(lottery) + + # winners columns: + # ┌──────────┬──────────────────┬──────────────────┬────────┬────────┬──────┬──────────────┬─────────┐ + # │ agent_id ┆ dim_0_candidate ┆ dim_1_candidate ┆ sugar ┆ radius ┆ rank ┆ current_rank │ lottery │ + # │ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ f64 │ + # ╞══════════╪══════════════════╪══════════════════╪════════╪════════╪══════╪══════════════╪═════════╡ + winners = ( + best_candidates.sort( + ["dim_0_candidate", "dim_1_candidate", "radius", "lottery"], + ) + .group_by(["dim_0_candidate", "dim_1_candidate"], maintain_order=True) + .first() + ) + + assigned = pl.concat( + [ + assigned, + winners.select( + [ + "agent_id", + pl.col("dim_0_candidate"), + pl.col("dim_1_candidate"), + ] + ), + ], + how="vertical", + ) + taken = pl.concat( + [ + taken, + winners.select(["dim_0_candidate", "dim_1_candidate"]), + ], + how="vertical", + ) + + winner_ids = winners.select("agent_id") + unresolved = unresolved.join(winner_ids, on="agent_id", how="anti") + if unresolved.is_empty(): + break + + # loser candidates columns mirror best_candidates (minus winners). + losers = best_candidates.join(winner_ids, on="agent_id", how="anti") + if losers.is_empty(): + continue + + # loser_updates columns (after select): + # ┌──────────┬───────────┐ + # │ agent_id ┆ next_rank │ + # │ --- ┆ --- │ + # │ u64 ┆ i64 │ + # ╞══════════╪═══════════╡ + loser_updates = ( + losers.select( + "agent_id", + (pl.col("rank") + 1).cast(pl.Int64).alias("next_rank"), + ) + .join(max_rank, on="agent_id", how="left") + .with_columns( + pl.min_horizontal(pl.col("next_rank"), pl.col("max_rank")).alias( + "next_rank" + ) + ) + .select(["agent_id", "next_rank"]) + ) + + # Promote losers' current_rank (if any) and continue. + # unresolved (updated) retains columns agent_id/current_rank. + unresolved = ( + unresolved.join(loser_updates, on="agent_id", how="left") + .with_columns( + pl.when(pl.col("next_rank").is_not_null()) + .then(pl.col("next_rank")) + .otherwise(pl.col("current_rank")) + .alias("current_rank") + ) + .drop("next_rank") + ) + + return assigned + + +__all__ = [ + "AntsBase", + "AntsParallel", +] diff --git a/examples/sugarscape_ig/backend_frames/model.py b/examples/sugarscape_ig/backend_frames/model.py new file mode 100644 index 00000000..1a5d336b --- /dev/null +++ b/examples/sugarscape_ig/backend_frames/model.py @@ -0,0 +1,499 @@ +"""Mesa-frames implementation of Sugarscape IG with Typer CLI. + +This mirrors the advanced tutorial in docs/general/user-guide/3_advanced_tutorial.py +and exposes a simple CLI to run the parallel update variant, save CSVs, and plot +the Gini trajectory. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import os +from pathlib import Path +from typing import Annotated +from time import perf_counter + +import numpy as np +import polars as pl +import typer + +from mesa_frames import DataCollector, Grid, Model +from examples.utils import FramesSimulationResult +from examples.plotting import plot_model_metrics + +from examples.sugarscape_ig.backend_frames.agents import AntsBase, AntsParallel + + +# Model-level reporters + + +def gini(model: Model) -> float: + """Compute the Gini coefficient of agent sugar holdings. + + The function reads the primary agent set from ``model.sets[0]`` and + computes the population Gini coefficient on the ``sugar`` column. The + implementation is robust to empty sets and zero-total sugar. + + Parameters + ---------- + model : Model + The simulation model that contains agent sets. The primary agent set + is expected to be at ``model.sets[0]`` and to expose a Polars DataFrame + under ``.df`` with a ``sugar`` column. + + Returns + ------- + float + Gini coefficient in the range [0, 1] if defined, ``0.0`` when the + total sugar is zero, and ``nan`` when the agent set is empty or too + small to measure. + """ + if len(model.sets) == 0: + return float("nan") + + primary_set = model.sets[0] + if len(primary_set) == 0: + return float("nan") + + sugar = primary_set.df["sugar"].to_numpy().astype(np.float64) + + if sugar.size == 0: + return float("nan") + sorted_vals = np.sort(sugar.astype(np.float64)) + n = sorted_vals.size + if n == 0: + return float("nan") + cumulative = np.cumsum(sorted_vals) + total = cumulative[-1] + if total == 0: + return 0.0 + index = np.arange(1, n + 1, dtype=np.float64) + return float((2.0 * np.dot(index, sorted_vals) / (n * total)) - (n + 1) / n) + + +def corr_sugar_metabolism(model: Model) -> float: + """Pearson correlation between agent sugar and metabolism. + + This reporter extracts the ``sugar`` and ``metabolism`` columns from the + primary agent set and returns their Pearson correlation coefficient. When + the agent set is empty or contains insufficient variation the function + returns ``nan``. + + Parameters + ---------- + model : Model + The simulation model that contains agent sets. The primary agent set + is expected to be at ``model.sets[0]`` and provide a Polars DataFrame + with ``sugar`` and ``metabolism`` columns. + + Returns + ------- + float + Pearson correlation coefficient between sugar and metabolism, or + ``nan`` when the correlation is undefined (empty set or constant + values). + """ + if len(model.sets) == 0: + return float("nan") + + primary_set = model.sets[0] + if len(primary_set) == 0: + return float("nan") + + agent_df = primary_set.df + sugar = agent_df["sugar"].to_numpy().astype(np.float64) + metabolism = agent_df["metabolism"].to_numpy().astype(np.float64) + return _safe_corr(sugar, metabolism) + + +def corr_sugar_vision(model: Model) -> float: + """Pearson correlation between agent sugar and vision. + + Extracts the ``sugar`` and ``vision`` columns from the primary agent set + and returns their Pearson correlation coefficient. If the reporter cannot + compute a meaningful correlation (for example, when the agent set is + empty or values are constant) it returns ``nan``. + + Parameters + ---------- + model : Model + The simulation model that contains agent sets. The primary agent set + is expected to be at ``model.sets[0]`` and provide a Polars DataFrame + with ``sugar`` and ``vision`` columns. + + Returns + ------- + float + Pearson correlation coefficient between sugar and vision, or ``nan`` + when the correlation is undefined. + """ + if len(model.sets) == 0: + return float("nan") + + primary_set = model.sets[0] + if len(primary_set) == 0: + return float("nan") + + agent_df = primary_set.df + sugar = agent_df["sugar"].to_numpy().astype(np.float64) + vision = agent_df["vision"].to_numpy().astype(np.float64) + return _safe_corr(sugar, vision) + + +def _safe_corr(x: np.ndarray, y: np.ndarray) -> float: + """Safely compute Pearson correlation between two 1-D arrays. + + This helper guards against degenerate inputs (too few observations or + constant arrays) which would make the Pearson correlation undefined or + numerically unstable. When a valid correlation can be computed the + function returns a Python float. + + Parameters + ---------- + x : np.ndarray + One-dimensional numeric array containing the first variable to + correlate. + y : np.ndarray + One-dimensional numeric array containing the second variable to + correlate. + + Returns + ------- + float + Pearson correlation coefficient as a Python float, or ``nan`` if the + correlation is undefined (fewer than 2 observations or constant + inputs). + """ + if x.size < 2 or y.size < 2: + return float("nan") + if np.allclose(x, x[0]) or np.allclose(y, y[0]): + return float("nan") + return float(np.corrcoef(x, y)[0, 1]) + + +class Sugarscape(Model): + """Minimal Sugarscape model used throughout the tutorial. + + This class wires together a grid that stores ``sugar`` per cell, an + agent set implementation (passed in as ``agent_type``), and a + data collector that records model- and agent-level statistics. + + The model's responsibilities are to: + - create the sugar landscape (cells with current and maximum sugar) + - create and place agents on the grid + - advance the sugar regrowth rule each step + - run the model for a fixed number of steps and collect data + + Parameters + ---------- + agent_type : type[AntsBase] + The :class:`AgentSet` subclass implementing the movement rules + (sequential, numba-accelerated, or parallel). + n_agents : int + Number of agents to create and place on the grid. + width : int + Grid width (number of columns). + height : int + Grid height (number of rows). + max_sugar : int, optional + Upper bound for the randomly initialised sugar values on the grid, + by default 4. + seed : int | None, optional + RNG seed to make runs reproducible across variants, by default None. + results_dir : Path | None, optional + Optional directory where CSV/plot outputs will be written. If ``None`` + the model runs without persisting CSVs to disk (in-memory storage). + + Notes + ----- + The grid uses a von Neumann neighbourhood and capacity 1 (at most one + agent per cell). Both the sugar landscape and initial agent traits are + drawn from ``self.random`` so different movement variants can be + instantiated with identical initial conditions by passing the same seed. + """ + + def __init__( + self, + agent_type: type[AntsBase], + n_agents: int, + *, + width: int, + height: int, + max_sugar: int = 4, + seed: int | None = None, + results_dir: Path | None = None, + ) -> None: + if n_agents > width * height: + raise ValueError( + "Cannot place more agents than grid cells when capacity is 1." + ) + super().__init__(seed) + + # 1. Let's create the sugar grid and set up the space + + sugar_grid_df = self._generate_sugar_grid(width, height, max_sugar) + self.space = Grid( + self, [width, height], neighborhood_type="von_neumann", capacity=1 + ) + self.space.set_cells(sugar_grid_df) + self._max_sugar = sugar_grid_df.select(["dim_0", "dim_1", "max_sugar"]) + + # 2. Now we create the agents and place them on the grid + + agent_frame = self._generate_agent_frame(n_agents) + main_set = agent_type(self, agent_frame) + self.sets += main_set + self.space.place_to_empty(self.sets) + + # 3. Finally we set up the data collector + # Benchmarks may run without providing a results_dir; in that case avoid forcing + # a CSV storage backend (which requires a storage_uri) and keep data in memory. + if results_dir is None: + storage = "memory" + storage_uri = None + else: + storage = "csv" + storage_uri = str(results_dir) + self.datacollector = DataCollector( + model=self, + model_reporters={ + "mean_sugar": lambda m: 0.0 + if len(m.sets[0]) == 0 + else float(m.sets[0].df["sugar"].mean()), + "total_sugar": lambda m: float(m.sets[0].df["sugar"].sum()) + if len(m.sets[0]) + else 0.0, + "agents_alive": lambda m: float(len(m.sets[0])) if len(m.sets) else 0.0, + "gini": gini, + "corr_sugar_metabolism": corr_sugar_metabolism, + "corr_sugar_vision": corr_sugar_vision, + }, + agent_reporters={ + "sugar": "sugar", + "metabolism": "metabolism", + "vision": "vision", + }, + storage=storage, + storage_uri=storage_uri, + ) + self.datacollector.collect() + + def _generate_sugar_grid( + self, width: int, height: int, max_sugar: int + ) -> pl.DataFrame: + """Generate a random sugar grid. + + Parameters + ---------- + width : int + Grid width (number of columns). + height : int + Grid height (number of rows). + max_sugar : int + Maximum sugar value (inclusive) for each cell. + + Returns + ------- + pl.DataFrame + DataFrame with columns ``dim_0``, ``dim_1``, ``sugar`` (current + amount) and ``max_sugar`` (regrowth target). + """ + sugar_vals = self.random.integers( + 0, max_sugar + 1, size=(width, height), dtype=np.int64 + ) + dim_0 = pl.Series("dim_0", pl.arange(width, eager=True)).to_frame() + dim_1 = pl.Series("dim_1", pl.arange(height, eager=True)).to_frame() + return dim_0.join(dim_1, how="cross").with_columns( + sugar=sugar_vals.flatten(), max_sugar=sugar_vals.flatten() + ) + + def _generate_agent_frame(self, n_agents: int) -> pl.DataFrame: + """Create the initial agent frame populated with agent traits. + + Parameters + ---------- + n_agents : int + Number of agents to create. + + Returns + ------- + pl.DataFrame + DataFrame with columns ``sugar``, ``metabolism`` and ``vision`` + (integer values) for each agent. + """ + rng = self.random + return pl.DataFrame( + { + "sugar": rng.integers(6, 25, size=n_agents, dtype=np.int64), + "metabolism": rng.integers(2, 5, size=n_agents, dtype=np.int64), + "vision": rng.integers(1, 6, size=n_agents, dtype=np.int64), + } + ) + + def step(self) -> None: + """Advance the model by one step. + + Notes + ----- + The per-step ordering is important and this tutorial implements the + classic Sugarscape "instant growback": agents move and eat first, + and then empty cells are refilled immediately (move -> eat -> regrow + -> collect). + """ + if len(self.sets[0]) == 0: + self.running = False + return + self.sets[0].step() + self._advance_sugar_field() + self.datacollector.collect() + if len(self.sets[0]) == 0: + self.running = False + + def run(self, steps: int) -> None: + """Run the model for a fixed number of steps. + + Parameters + ---------- + steps : int + Maximum number of steps to run. The model may terminate earlier if + ``self.running`` is set to ``False`` (for example, when all agents + have died). + """ + for _ in range(steps): + if not self.running: + break + self.step() + + def _advance_sugar_field(self) -> None: + """Apply the instant-growback sugar regrowth rule. + + Empty cells (no agent present) are refilled to their ``max_sugar`` + value. Cells that are occupied are set to zero because agents harvest + the sugar when they eat. The method uses vectorised DataFrame joins + and writes to keep the operation efficient. + """ + empty_cells = self.space.empty_cells + if not empty_cells.is_empty(): + # Look up the maximum sugar for each empty cell and restore it. + refresh = empty_cells.join( + self._max_sugar, on=["dim_0", "dim_1"], how="left" + ) + self.space.set_cells(empty_cells, {"sugar": refresh["max_sugar"]}) + full_cells = self.space.full_cells + if not full_cells.is_empty(): + # Occupied cells have just been harvested; set their sugar to 0. + zeros = pl.Series(np.zeros(len(full_cells), dtype=np.int64)) + self.space.set_cells(full_cells, {"sugar": zeros}) + + +def simulate( + *, + agents: int, + steps: int, + width: int, + height: int, + max_sugar: int = 4, + seed: int | None = None, + results_dir: Path | None = None, +) -> FramesSimulationResult: + model = Sugarscape( + agent_type=AntsParallel, + n_agents=agents, + width=width, + height=height, + max_sugar=max_sugar, + seed=seed, + results_dir=results_dir, + ) + model.run(steps) + return FramesSimulationResult(datacollector=model.datacollector) + + +app = typer.Typer(add_completion=False) + + +@app.command() +def run( + agents: Annotated[int, typer.Option(help="Number of agents to simulate.")] = 400, + width: Annotated[int, typer.Option(help="Grid width (columns).")] = 40, + height: Annotated[int, typer.Option(help="Grid height (rows).")] = 40, + steps: Annotated[int, typer.Option(help="Number of model steps to run.")] = 60, + max_sugar: Annotated[int, typer.Option(help="Maximum sugar per cell.")] = 4, + seed: Annotated[int | None, typer.Option(help="Optional RNG seed.")] = None, + plot: Annotated[bool, typer.Option(help="Render Seaborn plots.")] = True, + save_results: Annotated[bool, typer.Option(help="Persist metrics as CSV.")] = True, + results_dir: Annotated[ + Path | None, + typer.Option( + help="Directory to write CSV results and plots into. If omitted a timestamped subdir under `results/` is used." + ), + ] = None, +) -> None: + typer.echo( + f"Running Sugarscape IG (mesa-frames, parallel) with {agents} agents on {width}x{height} for {steps} steps" + ) + runtime_typechecking = os.environ.get("MESA_FRAMES_RUNTIME_TYPECHECKING", "") + if runtime_typechecking and runtime_typechecking.lower() not in {"0", "false"}: + typer.secho( + "Warning: MESA_FRAMES_RUNTIME_TYPECHECKING is enabled; this run will be slower.", + fg=typer.colors.YELLOW, + ) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + if results_dir is None: + results_dir = ( + Path(__file__).resolve().parent / "results" / timestamp + ).resolve() + results_dir.mkdir(parents=True, exist_ok=True) + + start_time = perf_counter() + result = simulate( + agents=agents, + steps=steps, + width=width, + height=height, + max_sugar=max_sugar, + seed=seed, + results_dir=results_dir, + ) + typer.echo(f"Simulation complete in {perf_counter() - start_time:.2f} seconds") + + model_metrics = result.datacollector.data["model"].drop(["seed", "batch"]) + typer.echo(f"Metrics in the final 5 steps: {model_metrics.tail(5)}") + + if save_results: + result.datacollector.flush() + + if plot: + # Create a subdirectory for per-metric plots under the timestamped + # results directory. For each column in the model metrics (except + # the step index) create a single-metric DataFrame and call the + # shared plotting helper to export light/dark PNG+SVG variants. + plots_dir = results_dir / "plots" + plots_dir.mkdir(parents=True, exist_ok=True) + + # Determine which columns to plot (preserve 'step' if present). + value_cols = [c for c in model_metrics.columns if c != "step"] + for col in value_cols: + stem = f"{col}_{timestamp}" + single = ( + model_metrics.select(["step", col]) + if "step" in model_metrics.columns + else model_metrics.select([col]) + ) + plot_model_metrics( + single, + plots_dir, + stem, + title=f"Sugarscape IG — {col.capitalize()}", + subtitle=f"mesa-frames backend; seed={result.datacollector.seed}", + agents=agents, + steps=steps, + ) + + typer.echo(f"Saved plots under {plots_dir}") + + typer.echo(f"Saved CSV results under {results_dir}") + + +if __name__ == "__main__": + app() diff --git a/examples/sugarscape_ig/backend_mesa/__init__.py b/examples/sugarscape_ig/backend_mesa/__init__.py new file mode 100644 index 00000000..463099c0 --- /dev/null +++ b/examples/sugarscape_ig/backend_mesa/__init__.py @@ -0,0 +1 @@ +"""Mesa backend package for Sugarscape IG examples.""" diff --git a/examples/sugarscape_ig/backend_mesa/agents.py b/examples/sugarscape_ig/backend_mesa/agents.py new file mode 100644 index 00000000..657d8d07 --- /dev/null +++ b/examples/sugarscape_ig/backend_mesa/agents.py @@ -0,0 +1,81 @@ +"""Mesa agents for the Sugarscape IG example (sequential/asynchronous update). + +Implements the movement rule (sense along cardinal axes up to `vision`, choose +highest-sugar cell with tie-breakers by distance and coordinates). Eating, +starvation, and regrowth are orchestrated by the model to preserve the order +move -> eat -> regrow -> collect, mirroring the tutorial schedule. +""" + +from __future__ import annotations + +from typing import Tuple + +import mesa + + +class AntAgent(mesa.Agent): + """Sugarscape ant with sugar/metabolism/vision traits and movement.""" + + def __init__( + self, + model: Sugarscape, + *, + sugar: int, + metabolism: int, + vision: int, + ) -> None: + super().__init__(model) + self.sugar = int(sugar) + self.metabolism = int(metabolism) + self.vision = int(vision) + + # --- Movement helpers (sequential/asynchronous) --- + + def _visible_cells(self, origin: tuple[int, int]) -> list[tuple[int, int]]: + x0, y0 = origin + width, height = self.model.width, self.model.height + cells: list[tuple[int, int]] = [origin] + for step in range(1, self.vision + 1): + if x0 + step < width: + cells.append((x0 + step, y0)) + if x0 - step >= 0: + cells.append((x0 - step, y0)) + if y0 + step < height: + cells.append((x0, y0 + step)) + if y0 - step >= 0: + cells.append((x0, y0 - step)) + return cells + + def _choose_best_cell(self, origin: tuple[int, int]) -> tuple[int, int]: + # Highest sugar; tie-break by Manhattan distance from origin; then coords. + best_cell = origin + best_sugar = int(self.model.sugar_current[origin[0], origin[1]]) + best_distance = 0 + ox, oy = origin + for cx, cy in self._visible_cells(origin): + # Block occupied cells except the origin (own cell allowed as fallback). + if (cx, cy) != origin and not self.model.grid.is_cell_empty((cx, cy)): + continue + sugar_here = int(self.model.sugar_current[cx, cy]) + distance = abs(cx - ox) + abs(cy - oy) + better = False + if sugar_here > best_sugar: + better = True + elif sugar_here == best_sugar: + if distance < best_distance: + better = True + elif distance == best_distance and (cx, cy) < best_cell: + better = True + if better: + best_cell = (cx, cy) + best_sugar = sugar_here + best_distance = distance + return best_cell + + def move(self) -> None: + best = self._choose_best_cell(self.pos) + if best != self.pos: + self.model.grid.move_agent(self, best) + + +__all__ = ["AntAgent"] diff --git a/examples/sugarscape_ig/backend_mesa/model.py b/examples/sugarscape_ig/backend_mesa/model.py new file mode 100644 index 00000000..6e62137a --- /dev/null +++ b/examples/sugarscape_ig/backend_mesa/model.py @@ -0,0 +1,315 @@ +"""Mesa implementation of Sugarscape IG with Typer CLI (sequential update). + +Follows the same structure as the Boltzmann Mesa example: `simulate()` and a +`run` CLI command that saves CSV results and plots the Gini trajectory. The +model updates in the order move -> eat -> regrow -> collect, matching the +tutorial schedule. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated +from collections.abc import Iterable +from time import perf_counter + +import mesa +from mesa.datacollection import DataCollector +from mesa.space import SingleGrid +import numpy as np +import pandas as pd +import polars as pl +import typer + +from examples.utils import MesaSimulationResult +from examples.plotting import plot_model_metrics + +from examples.sugarscape_ig.backend_mesa.agents import AntAgent + + +def _safe_corr(x: np.ndarray, y: np.ndarray) -> float: + """Safely compute Pearson correlation between two 1-D arrays. + + Mirrors the Frames helper: returns nan for degenerate inputs. + """ + x = np.asarray(x, dtype=float) + y = np.asarray(y, dtype=float) + if x.size < 2 or y.size < 2: + return float("nan") + if np.allclose(x, x[0]) or np.allclose(y, y[0]): + return float("nan") + return float(np.corrcoef(x, y)[0, 1]) + + +def corr_sugar_metabolism(model: Sugarscape) -> float: + sugars = np.fromiter((a.sugar for a in model.agent_list), dtype=float) + mets = np.fromiter((a.metabolism for a in model.agent_list), dtype=float) + return _safe_corr(sugars, mets) + + +def corr_sugar_vision(model: Sugarscape) -> float: + sugars = np.fromiter((a.sugar for a in model.agent_list), dtype=float) + vision = np.fromiter((a.vision for a in model.agent_list), dtype=float) + return _safe_corr(sugars, vision) + + +def gini(values: Iterable[float]) -> float: + array = np.fromiter(values, dtype=float) + if array.size == 0: + return float("nan") + if np.allclose(array, 0.0): + return 0.0 + if np.allclose(array, array[0]): + return 0.0 + sorted_vals = np.sort(array) + n = sorted_vals.size + cumulative = np.cumsum(sorted_vals) + total = cumulative[-1] + if total == 0: + return 0.0 + index = np.arange(1, n + 1, dtype=float) + return float((2.0 * np.dot(index, sorted_vals) / (n * total)) - (n + 1) / n) + + +class Sugarscape(mesa.Model): + def __init__( + self, + agents: int, + *, + width: int, + height: int, + max_sugar: int = 4, + seed: int | None = None, + ) -> None: + super().__init__() + if seed is None: + seed = self.random.randint(0, np.iinfo(np.int32).max) + self.reset_randomizer(seed) + self.width = int(width) + self.height = int(height) + + # Sugar field (current and max) as 2D arrays shaped (width, height) + numpy_rng = np.random.default_rng(seed) + self.sugar_max = numpy_rng.integers( + 0, max_sugar + 1, size=(width, height), dtype=np.int64 + ) + self.sugar_current = self.sugar_max.copy() + + # Grid with capacity 1 per cell + self.grid = SingleGrid(width, height, torus=False) + + # Agents (Python list, manually shuffled/iterated for speed) + self.agent_list: list[AntAgent] = [] + # Place all agents on empty cells; also draw initial traits from model RNG + placed = 0 + while placed < agents: + x = int(self.random.randrange(0, width)) + y = int(self.random.randrange(0, height)) + if self.grid.is_cell_empty((x, y)): + a = AntAgent( + self, + sugar=int(self.random.randint(6, 25)), + metabolism=int(self.random.randint(2, 5)), + vision=int(self.random.randint(1, 6)), + ) + self.grid.place_agent(a, (x, y)) + self.agent_list.append(a) + placed += 1 + + # Model-level reporters mirroring the Frames implementation so CSVs + # are comparable across backends. + self.datacollector = DataCollector( + model_reporters={ + "mean_sugar": lambda m: float(np.mean([a.sugar for a in m.agent_list])) + if m.agent_list + else 0.0, + "total_sugar": lambda m: float(sum(a.sugar for a in m.agent_list)) + if m.agent_list + else 0.0, + "agents_alive": lambda m: float(len(m.agent_list)), + "gini": lambda m: gini(a.sugar for a in m.agent_list), + "corr_sugar_metabolism": lambda m: corr_sugar_metabolism(m), + "corr_sugar_vision": lambda m: corr_sugar_vision(m), + "seed": lambda m: seed, + }, + agent_reporters={ + "traits": lambda a: { + "sugar": a.sugar, + "metabolism": a.metabolism, + "vision": a.vision, + } + }, + ) + self.datacollector.collect(self) + + # --- Scheduling --- + + def _harvest_and_survive(self) -> None: + survivors: list[AntAgent] = [] + for a in self.agent_list: + x, y = a.pos + a.sugar += int(self.sugar_current[x, y]) + a.sugar -= a.metabolism + # Harvested cells are emptied now; they will be refilled if empty. + self.sugar_current[x, y] = 0 + if a.sugar > 0: + survivors.append(a) + else: + # Remove dead agent from grid + self.grid.remove_agent(a) + self.agent_list = survivors + + def _regrow(self) -> None: + # Empty cells regrow to max; occupied cells set to 0 (already zeroed on harvest) + for x in range(self.width): + for y in range(self.height): + if self.grid.is_cell_empty((x, y)): + self.sugar_current[x, y] = self.sugar_max[x, y] + else: + self.sugar_current[x, y] = 0 + + def step(self) -> None: + # Randomise order, move sequentially, then eat/starve, regrow, collect + self.random.shuffle(self.agent_list) + for a in self.agent_list: + a.move() + self._harvest_and_survive() + self._regrow() + self.datacollector.collect(self) + if not self.agent_list: + self.running = False + + def run(self, steps: int) -> None: + for _ in range(steps): + if not getattr(self, "running", True): + break + self.step() + + +def simulate( + *, + agents: int, + steps: int, + width: int, + height: int, + max_sugar: int = 4, + seed: int | None = None, +) -> MesaSimulationResult: + model = Sugarscape( + agents, width=width, height=height, max_sugar=max_sugar, seed=seed + ) + model.run(steps) + return MesaSimulationResult(datacollector=model.datacollector) + + +app = typer.Typer(add_completion=False) + + +@app.command() +def run( + agents: Annotated[int, typer.Option(help="Number of agents to simulate.")] = 400, + width: Annotated[int, typer.Option(help="Grid width (columns).")] = 40, + height: Annotated[int, typer.Option(help="Grid height (rows).")] = 40, + steps: Annotated[int, typer.Option(help="Number of model steps to run.")] = 60, + max_sugar: Annotated[int, typer.Option(help="Maximum sugar per cell.")] = 4, + seed: Annotated[int | None, typer.Option(help="Optional RNG seed.")] = None, + plot: Annotated[bool, typer.Option(help="Render plots.")] = True, + save_results: Annotated[bool, typer.Option(help="Persist metrics as CSV.")] = True, + results_dir: Annotated[ + Path | None, + typer.Option( + help=( + "Directory to write CSV results and plots into. If omitted a " + "timestamped subdir under `results/` is used." + ) + ), + ] = None, +) -> None: + typer.echo( + f"Running Sugarscape IG (mesa, sequential) with {agents} agents on {width}x{height} for {steps} steps" + ) + + # Resolve output folder + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + if results_dir is None: + results_dir = ( + Path(__file__).resolve().parent / "results" / timestamp + ).resolve() + results_dir.mkdir(parents=True, exist_ok=True) + + start_time = perf_counter() + result = simulate( + agents=agents, + steps=steps, + width=width, + height=height, + max_sugar=max_sugar, + seed=seed, + ) + typer.echo(f"Simulation completed in {perf_counter() - start_time:.3f} seconds") + dc = result.datacollector + + # Extract metrics using DataCollector API + model_pd = ( + dc.get_model_vars_dataframe().reset_index().rename(columns={"index": "step"}) + ) + # Keep the full model metrics (step + any model reporters) + seed_val = None + if "seed" in model_pd.columns and not model_pd.empty: + seed_val = model_pd["seed"].iloc[0] + + # Show tail for quick inspection (exclude seed column from display) + display_pd = ( + model_pd.drop(columns=["seed"]) if "seed" in model_pd.columns else model_pd + ) + typer.echo( + f"Metrics in the final 5 steps:\n{display_pd.tail(5).to_string(index=False)}" + ) + + # Save CSV (full model metrics) + if save_results: + csv_path = results_dir / "model.csv" + model_pd.to_csv(csv_path, index=False) + + # Plot per-metric similar to the backend_frames example: create a + # `plots/` subdirectory and generate one figure per model metric column + if plot and not model_pd.empty: + plots_dir = results_dir / "plots" + plots_dir.mkdir(parents=True, exist_ok=True) + + # Determine which columns to plot (preserve 'step' if present). + # Exclude 'seed' from plots so we don't create a chart for a constant + # model reporter; keep 'seed' in the CSV/dataframe for reproducibility. + value_cols = [c for c in model_pd.columns if c not in {"step", "seed"}] + for col in value_cols: + stem = f"{col}_{timestamp}" + single = ( + model_pd[["step", col]] + if "step" in model_pd.columns + else model_pd[[col]] + ) + # Convert the single-column pandas DataFrame to Polars for the + # shared plotting helper. + single_pl = pl.from_pandas(single) + # Omit seed from subtitle/plot metadata to avoid leaking a constant + # value into the figure (it remains in the saved CSV). If you want + # to include the seed in filenames or external metadata, prefer + # annotating the output folder or README instead. + plot_model_metrics( + single_pl, + plots_dir, + stem, + title=f"Sugarscape IG - {col.capitalize()}", + subtitle="mesa backend", + agents=agents, + steps=steps, + ) + + typer.echo(f"Saved plots under {plots_dir}") + + typer.echo(f"Saved CSV results under {results_dir}") + + +if __name__ == "__main__": + app() diff --git a/examples/utils.py b/examples/utils.py new file mode 100644 index 00000000..4d075dc4 --- /dev/null +++ b/examples/utils.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +import mesa_frames +import mesa + + +@dataclass +class FramesSimulationResult: + """Container for example simulation outputs. + + The dataclass is intentionally permissive: some backends only provide + `metrics`, while others also return `agent_metrics`. + """ + + datacollector: mesa_frames.DataCollector + + +@dataclass +class MesaSimulationResult: + """Container for example simulation outputs. + + The dataclass is intentionally permissive: some backends only provide + `metrics`, while others also return `agent_metrics`. + """ + + datacollector: mesa.DataCollector diff --git a/pyproject.toml b/pyproject.toml index 6db5f7da..f9f1e343 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ test = [ docs = [ { include-group = "typechecking" }, + "typer>=0.9.0", "mkdocs-material>=9.6.14", "mkdocs-jupyter>=0.25.1", "mkdocs-git-revision-date-localized-plugin>=1.4.7", diff --git a/uv.lock b/uv.lock index ee2f031c..1526170c 100644 --- a/uv.lock +++ b/uv.lock @@ -1161,19 +1161,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, ] -[[package]] -name = "matplotx" -version = "0.3.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "matplotlib" }, - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/01/0e6938bb717fa7722d6d81336c62de71b815ce73e382aa1873a1e68ccc93/matplotx-0.3.10.tar.gz", hash = "sha256:b6926ce5274cf5da966cb46b90a8c7fefb761478c6c85c8f7ed3ee8ec90e86e5", size = 24041, upload-time = "2022-08-22T14:22:56.374Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/ef/e8a30503ae0c26681a9610c7f0be58646bea8119b98cc65c47661abc27a3/matplotx-0.3.10-py3-none-any.whl", hash = "sha256:4d7adafdb001c771d66d9362bb8ca99fcaed15319259223a714f36793dfabbb8", size = 25099, upload-time = "2022-08-22T14:22:54.733Z" }, -] - [[package]] name = "mdit-py-plugins" version = "0.5.0" @@ -1235,6 +1222,7 @@ dependencies = [ dev = [ { name = "autodocsumm" }, { name = "beartype" }, + { name = "jupytext" }, { name = "mesa" }, { name = "mkdocs-git-revision-date-localized-plugin" }, { name = "mkdocs-include-markdown-plugin" }, @@ -1243,7 +1231,6 @@ dev = [ { name = "mkdocs-minify-plugin" }, { name = "numba" }, { name = "numpydoc" }, - { name = "perfplot" }, { name = "pre-commit" }, { name = "pydata-sphinx-theme" }, { name = "pytest" }, @@ -1255,10 +1242,12 @@ dev = [ { name = "sphinx-copybutton" }, { name = "sphinx-design" }, { name = "sphinx-rtd-theme" }, + { name = "typer" }, ] docs = [ { name = "autodocsumm" }, { name = "beartype" }, + { name = "jupytext" }, { name = "mesa" }, { name = "mkdocs-git-revision-date-localized-plugin" }, { name = "mkdocs-include-markdown-plugin" }, @@ -1266,7 +1255,6 @@ docs = [ { name = "mkdocs-material" }, { name = "mkdocs-minify-plugin" }, { name = "numpydoc" }, - { name = "perfplot" }, { name = "pydata-sphinx-theme" }, { name = "seaborn" }, { name = "sphinx" }, @@ -1274,6 +1262,7 @@ docs = [ { name = "sphinx-copybutton" }, { name = "sphinx-design" }, { name = "sphinx-rtd-theme" }, + { name = "typer" }, ] test = [ { name = "beartype" }, @@ -1298,6 +1287,7 @@ requires-dist = [ dev = [ { name = "autodocsumm", specifier = ">=0.2.14" }, { name = "beartype", specifier = ">=0.21.0" }, + { name = "jupytext", specifier = ">=1.17.3" }, { name = "mesa", specifier = ">=3.2.0" }, { name = "mkdocs-git-revision-date-localized-plugin", specifier = ">=1.4.7" }, { name = "mkdocs-include-markdown-plugin", specifier = ">=7.1.5" }, @@ -1306,7 +1296,6 @@ dev = [ { name = "mkdocs-minify-plugin", specifier = ">=0.8.0" }, { name = "numba", specifier = ">=0.60.0" }, { name = "numpydoc", specifier = ">=1.8.0" }, - { name = "perfplot", specifier = ">=0.10.2" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pydata-sphinx-theme", specifier = ">=0.16.1" }, { name = "pytest", specifier = ">=8.3.5" }, @@ -1318,10 +1307,12 @@ dev = [ { name = "sphinx-copybutton", specifier = ">=0.5.2" }, { name = "sphinx-design", specifier = ">=0.6.1" }, { name = "sphinx-rtd-theme", specifier = ">=3.0.2" }, + { name = "typer", specifier = ">=0.9.0" }, ] docs = [ { name = "autodocsumm", specifier = ">=0.2.14" }, { name = "beartype", specifier = ">=0.21.0" }, + { name = "jupytext", specifier = ">=1.17.3" }, { name = "mesa", specifier = ">=3.2.0" }, { name = "mkdocs-git-revision-date-localized-plugin", specifier = ">=1.4.7" }, { name = "mkdocs-include-markdown-plugin", specifier = ">=7.1.5" }, @@ -1329,7 +1320,6 @@ docs = [ { name = "mkdocs-material", specifier = ">=9.6.14" }, { name = "mkdocs-minify-plugin", specifier = ">=0.8.0" }, { name = "numpydoc", specifier = ">=1.8.0" }, - { name = "perfplot", specifier = ">=0.10.2" }, { name = "pydata-sphinx-theme", specifier = ">=0.16.1" }, { name = "seaborn", specifier = ">=0.13.2" }, { name = "sphinx", specifier = ">=7.4.7" }, @@ -1337,6 +1327,7 @@ docs = [ { name = "sphinx-copybutton", specifier = ">=0.5.2" }, { name = "sphinx-design", specifier = ">=0.6.1" }, { name = "sphinx-rtd-theme", specifier = ">=3.0.2" }, + { name = "typer", specifier = ">=0.9.0" }, ] test = [ { name = "beartype", specifier = ">=0.21.0" }, @@ -1730,21 +1721,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] -[[package]] -name = "perfplot" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "matplotlib" }, - { name = "matplotx" }, - { name = "numpy" }, - { name = "rich" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/97/41/51d8b9caa150a050de16a229f627e4b37515dbff0075259e4e75aff7218b/perfplot-0.10.2.tar.gz", hash = "sha256:d76daa72334564b5c8825663f24d15db55ea33e938b34595a146e5e44ed87e41", size = 25044, upload-time = "2022-03-03T15:56:37.392Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/85/ffaf2c1f92d17916c089a5c860d23b3117398f19f467fd1de1026d03aebc/perfplot-0.10.2-py3-none-any.whl", hash = "sha256:545ce0f7f22509ad00092d79a794cdc6e9805383e6cedab2bfed3519a7ef4e19", size = 21198, upload-time = "2022-03-03T15:56:35.388Z" }, -] - [[package]] name = "pexpect" version = "4.9.0" @@ -2522,6 +2498,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -2834,6 +2819,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] +[[package]] +name = "typer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/ca/950278884e2ca20547ff3eb109478c6baf6b8cf219318e6bc4f666fad8e8/typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca", size = 104755, upload-time = "2025-09-23T09:47:48.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9", size = 46748, upload-time = "2025-09-23T09:47:46.777Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"