diff --git a/simulator/__main__.py b/simulator/__main__.py index 8b840c1..df7a7d6 100644 --- a/simulator/__main__.py +++ b/simulator/__main__.py @@ -1,7 +1,9 @@ -"""Entry point: python -m simulator [--config path/to/config.yaml] [--headless]""" +"""Entry point: python -m simulator [--config FILE] [--headless] [--output FILE]""" from __future__ import annotations import argparse +import json +import logging import math import random @@ -24,12 +26,11 @@ ), } +log = logging.getLogger(__name__) + def _build_greenhouse(cfg: dict) -> Greenhouse: gh = cfg["greenhouse"] - rng = random.Random(42) - - # Place nodes on a rough grid with jitter cols, rows = 5, 4 nodes = [] for r in range(rows): @@ -37,10 +38,9 @@ def _build_greenhouse(cfg: dict) -> Greenhouse: nid = f"n{r * cols + c}" x = c * (gh["width"] / (cols - 1)) y = r * (gh["height"] / (rows - 1)) - hotspot = rng.random() if len(nodes) < gh["hotspot_count"] else 0.0 + hotspot = random.random() if len(nodes) < gh["hotspot_count"] else 0.0 nodes.append(Node(id=nid, x=x, y=y, hotspot=hotspot)) - # Connect nearby nodes edges = [] for i, a in enumerate(nodes): for b in nodes[i + 1:]: @@ -82,30 +82,52 @@ def _build_world(cfg: dict) -> World: def main() -> None: - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(description="RoboGreeno swarm simulator") parser.add_argument("--config", default="simulator/config/default.yaml") parser.add_argument("--headless", action="store_true", help="Run without visualization") + parser.add_argument("--output", metavar="FILE", help="Write run summary JSON to FILE") args = parser.parse_args() with open(args.config) as f: cfg = yaml.safe_load(f) + sim = cfg["simulation"] + + log_level = getattr(logging, sim.get("log_level", "INFO").upper(), logging.INFO) + logging.basicConfig(level=log_level, format="%(asctime)s %(levelname)s %(name)s — %(message)s") + + seed = sim.get("seed") + random.seed(seed) + log.info("seed=%s", seed if seed is not None else "random") + world = _build_world(cfg) - ticks = cfg["simulation"]["ticks"] + ticks = sim["ticks"] + log.info("starting: ticks=%d spiders=%d carriers=%d algorithm=%s", + ticks, len(world.spiders), len(world.carriers), cfg["dtn"]["algorithm"]) if args.headless: for _ in range(ticks): world.step() else: - renderer = Renderer(world, fps=cfg["simulation"]["fps"]) + renderer = Renderer(world, fps=sim["fps"]) for _ in range(ticks): world.step() if not renderer.draw(world): break summary = world.metrics.summary() - print(f"Done — {summary['total_transfers']} transfers, " - f"{summary['total_bytes'] / 1024:.1f} KB delivered in {world.tick} ticks") + log.info( + "done: ticks=%d total_bytes=%.0f delivery_ratio=%.2f avg_battery=%.3f", + summary["ticks"], + summary["total_bytes"], + summary["delivery_ratio"], + summary["avg_battery_remaining"], + ) + + if args.output: + with open(args.output, "w") as f: + json.dump(summary, f, indent=2) + log.info("summary written to %s", args.output) if __name__ == "__main__": diff --git a/simulator/__pycache__/__init__.cpython-311.pyc b/simulator/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 89c0db8..0000000 Binary files a/simulator/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/simulator/config/default.yaml b/simulator/config/default.yaml index 83dbc44..4f920ff 100644 --- a/simulator/config/default.yaml +++ b/simulator/config/default.yaml @@ -21,3 +21,5 @@ dtn: simulation: ticks: 500 fps: 10 # visualization frame rate (0 = headless / max speed) + seed: null # integer → reproducible run; null → random each run + log_level: INFO # DEBUG | INFO | WARNING | ERROR diff --git a/simulator/core/__pycache__/__init__.cpython-311.pyc b/simulator/core/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index b3ab69c..0000000 Binary files a/simulator/core/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/simulator/core/__pycache__/carrier.cpython-311.pyc b/simulator/core/__pycache__/carrier.cpython-311.pyc deleted file mode 100644 index e6e6a11..0000000 Binary files a/simulator/core/__pycache__/carrier.cpython-311.pyc and /dev/null differ diff --git a/simulator/core/__pycache__/greenhouse.cpython-311.pyc b/simulator/core/__pycache__/greenhouse.cpython-311.pyc deleted file mode 100644 index c41db81..0000000 Binary files a/simulator/core/__pycache__/greenhouse.cpython-311.pyc and /dev/null differ diff --git a/simulator/core/__pycache__/spider.cpython-311.pyc b/simulator/core/__pycache__/spider.cpython-311.pyc deleted file mode 100644 index edd2c96..0000000 Binary files a/simulator/core/__pycache__/spider.cpython-311.pyc and /dev/null differ diff --git a/simulator/core/__pycache__/world.cpython-311.pyc b/simulator/core/__pycache__/world.cpython-311.pyc deleted file mode 100644 index c227d32..0000000 Binary files a/simulator/core/__pycache__/world.cpython-311.pyc and /dev/null differ diff --git a/simulator/core/world.py b/simulator/core/world.py index a2dfc7b..90a36ec 100644 --- a/simulator/core/world.py +++ b/simulator/core/world.py @@ -42,9 +42,10 @@ def _accumulate_data(self) -> None: for spider in self.spiders: if not spider.alive: continue - # Hotspot areas yield more data (exponential burst) burst = 1.0 + spider.node.hotspot * random.expovariate(1.0) - spider.accumulate_data(1024 * burst) # ~1 KB/tick baseline + bytes_generated = 1024 * burst # ~1 KB/tick baseline + spider.accumulate_data(bytes_generated) + self.metrics.record_accumulation(bytes_generated) spider.drain_battery(0.001) def _run_ble_exchanges(self) -> None: diff --git a/simulator/dtn/__pycache__/__init__.cpython-311.pyc b/simulator/dtn/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 75f06aa..0000000 Binary files a/simulator/dtn/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/simulator/dtn/__pycache__/base.cpython-311.pyc b/simulator/dtn/__pycache__/base.cpython-311.pyc deleted file mode 100644 index a5f20a2..0000000 Binary files a/simulator/dtn/__pycache__/base.cpython-311.pyc and /dev/null differ diff --git a/simulator/dtn/__pycache__/epidemic.cpython-311.pyc b/simulator/dtn/__pycache__/epidemic.cpython-311.pyc deleted file mode 100644 index 59520a8..0000000 Binary files a/simulator/dtn/__pycache__/epidemic.cpython-311.pyc and /dev/null differ diff --git a/simulator/dtn/__pycache__/prophet.cpython-311.pyc b/simulator/dtn/__pycache__/prophet.cpython-311.pyc deleted file mode 100644 index c9c2ba2..0000000 Binary files a/simulator/dtn/__pycache__/prophet.cpython-311.pyc and /dev/null differ diff --git a/simulator/dtn/__pycache__/spray_and_wait.cpython-311.pyc b/simulator/dtn/__pycache__/spray_and_wait.cpython-311.pyc deleted file mode 100644 index d408f9e..0000000 Binary files a/simulator/dtn/__pycache__/spray_and_wait.cpython-311.pyc and /dev/null differ diff --git a/simulator/metrics/__pycache__/__init__.cpython-311.pyc b/simulator/metrics/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index e738d3e..0000000 Binary files a/simulator/metrics/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/simulator/metrics/__pycache__/collector.cpython-311.pyc b/simulator/metrics/__pycache__/collector.cpython-311.pyc deleted file mode 100644 index c42d48f..0000000 Binary files a/simulator/metrics/__pycache__/collector.cpython-311.pyc and /dev/null differ diff --git a/simulator/metrics/collector.py b/simulator/metrics/collector.py index fdbc698..df65e93 100644 --- a/simulator/metrics/collector.py +++ b/simulator/metrics/collector.py @@ -7,6 +7,7 @@ class MetricsCollector: transfers: list[dict] = field(default_factory=list) snapshots: list[dict] = field(default_factory=list) + total_bytes_accumulated: float = 0.0 def record(self, world) -> None: self.snapshots.append({ @@ -30,9 +31,27 @@ def record_transfer(self, spider_id: str, carrier_id: str, bytes_: float, tick: "bytes": bytes_, }) + def record_accumulation(self, bytes_: float) -> None: + """Track bytes generated by spiders (denominator for delivery_ratio).""" + self.total_bytes_accumulated += bytes_ + def summary(self) -> dict: + total_bytes = sum(t["bytes"] for t in self.transfers) + delivery_ratio = ( + min(1.0, total_bytes / self.total_bytes_accumulated) + if self.total_bytes_accumulated > 0 else 0.0 + ) + + avg_battery = 0.0 + if self.snapshots: + batteries = [s["battery"] for s in self.snapshots[-1]["spiders"]] + avg_battery = sum(batteries) / len(batteries) if batteries else 0.0 + + ticks = self.snapshots[-1]["tick"] + 1 if self.snapshots else 0 + return { - "total_transfers": len(self.transfers), - "total_bytes": sum(t["bytes"] for t in self.transfers), - "ticks_recorded": len(self.snapshots), + "ticks": ticks, + "total_bytes": total_bytes, + "delivery_ratio": round(delivery_ratio, 4), + "avg_battery_remaining": round(avg_battery, 4), } diff --git a/storage/__pycache__/__init__.cpython-311.pyc b/storage/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 6e042ff..0000000 Binary files a/storage/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/storage/eviction/__pycache__/__init__.cpython-311.pyc b/storage/eviction/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index a14d7df..0000000 Binary files a/storage/eviction/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/storage/eviction/__pycache__/base.cpython-311.pyc b/storage/eviction/__pycache__/base.cpython-311.pyc deleted file mode 100644 index bc8d268..0000000 Binary files a/storage/eviction/__pycache__/base.cpython-311.pyc and /dev/null differ diff --git a/storage/eviction/__pycache__/lru.cpython-311.pyc b/storage/eviction/__pycache__/lru.cpython-311.pyc deleted file mode 100644 index f105ea6..0000000 Binary files a/storage/eviction/__pycache__/lru.cpython-311.pyc and /dev/null differ diff --git a/storage/eviction/__pycache__/priority.cpython-311.pyc b/storage/eviction/__pycache__/priority.cpython-311.pyc deleted file mode 100644 index fe333a8..0000000 Binary files a/storage/eviction/__pycache__/priority.cpython-311.pyc and /dev/null differ diff --git a/tests/__pycache__/__init__.cpython-311.pyc b/tests/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 58aa6a7..0000000 Binary files a/tests/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/tests/__pycache__/test_dtn.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/test_dtn.cpython-311-pytest-9.0.3.pyc deleted file mode 100644 index 5c0ca42..0000000 Binary files a/tests/__pycache__/test_dtn.cpython-311-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_spider.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/test_spider.cpython-311-pytest-9.0.3.pyc deleted file mode 100644 index 009126b..0000000 Binary files a/tests/__pycache__/test_spider.cpython-311-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_storage.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/test_storage.cpython-311-pytest-9.0.3.pyc deleted file mode 100644 index 1e71c38..0000000 Binary files a/tests/__pycache__/test_storage.cpython-311-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_world.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/test_world.cpython-311-pytest-9.0.3.pyc deleted file mode 100644 index 9d220d8..0000000 Binary files a/tests/__pycache__/test_world.cpython-311-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_summary.py b/tests/test_summary.py new file mode 100644 index 0000000..0c08222 --- /dev/null +++ b/tests/test_summary.py @@ -0,0 +1,103 @@ +"""Tests for issue #02 — Logging + reproducible run summary.""" +from __future__ import annotations + +import json +import random +import subprocess +import sys +from pathlib import Path + +from simulator.core.carrier import Carrier +from simulator.core.greenhouse import Greenhouse, Node +from simulator.core.spider import Spider +from simulator.core.world import World +from simulator.dtn.epidemic import EpidemicRouter + +_PROJECT_ROOT = Path(__file__).parent.parent + + +def _run_world(seed: int | None, ticks: int = 30) -> dict: + """Build and run a minimal world with the given seed; return summary.""" + random.seed(seed) + n0 = Node(id="n0", x=0.0, y=0.0, hotspot=0.5) + n1 = Node(id="n1", x=1.0, y=0.0, hotspot=0.0) + gh = Greenhouse(width=5, height=5, nodes=[n0, n1], edges=[("n0", "n1")]) + spiders = [Spider(id="S0", node=random.choice([n0, n1]), storage_capacity=1_000_000)] + carriers = [Carrier(id="C0", node=random.choice([n0, n1]), storage_capacity=10_000_000)] + world = World(greenhouse=gh, spiders=spiders, carriers=carriers, router=EpidemicRouter()) + for _ in range(ticks): + world.step() + return world.metrics.summary() + + +# ── summary shape ───────────────────────────────────────────────────────────── + +def test_summary_has_required_keys(): + summary = _run_world(seed=1) + assert set(summary) == {"ticks", "total_bytes", "delivery_ratio", "avg_battery_remaining"} + + +def test_summary_ticks_matches_run_length(): + summary = _run_world(seed=1, ticks=30) + assert summary["ticks"] == 30 + + +def test_delivery_ratio_in_valid_range(): + summary = _run_world(seed=1, ticks=50) + assert 0.0 <= summary["delivery_ratio"] <= 1.0 + + +def test_avg_battery_remaining_in_valid_range(): + summary = _run_world(seed=1, ticks=50) + assert 0.0 <= summary["avg_battery_remaining"] <= 1.0 + + +def test_total_bytes_positive_after_run(): + summary = _run_world(seed=1, ticks=50) + assert summary["total_bytes"] >= 0.0 + + +# ── reproducibility ─────────────────────────────────────────────────────────── + +def test_same_seed_produces_identical_summary(): + s1 = _run_world(seed=42) + s2 = _run_world(seed=42) + assert s1 == s2 + + +def test_different_seeds_produce_different_accumulated_bytes(): + # expovariate bursts are seeded → different seeds produce different totals + totals = [] + for seed in range(5): + random.seed(seed) + n0 = Node(id="n0", x=0.0, y=0.0, hotspot=0.5) + n1 = Node(id="n1", x=1.0, y=0.0, hotspot=0.0) + gh = Greenhouse(width=5, height=5, nodes=[n0, n1], edges=[("n0", "n1")]) + spiders = [Spider(id="S0", node=random.choice([n0, n1]), storage_capacity=1_000_000)] + carriers = [Carrier(id="C0", node=random.choice([n0, n1]), storage_capacity=10_000_000)] + world = World(greenhouse=gh, spiders=spiders, carriers=carriers, router=EpidemicRouter()) + for _ in range(50): + world.step() + totals.append(world.metrics.total_bytes_accumulated) + assert len(set(totals)) > 1 + + +# ── --output flag (CLI) ─────────────────────────────────────────────────────── + +def test_output_flag_writes_valid_json(tmp_path): + out = tmp_path / "summary.json" + result = subprocess.run( + [ + sys.executable, "-m", "simulator", + "--headless", + "--config", str(_PROJECT_ROOT / "simulator" / "config" / "default.yaml"), + "--output", str(out), + ], + cwd=_PROJECT_ROOT, + capture_output=True, + timeout=60, + ) + assert result.returncode == 0, result.stderr.decode() + assert out.exists() + data = json.loads(out.read_text()) + assert set(data) == {"ticks", "total_bytes", "delivery_ratio", "avg_battery_remaining"}