Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 deletions simulator/__main__.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -24,23 +26,21 @@
),
}

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):
for c in range(cols):
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:]:
Expand Down Expand Up @@ -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__":
Expand Down
Binary file removed simulator/__pycache__/__init__.cpython-311.pyc
Binary file not shown.
2 changes: 2 additions & 0 deletions simulator/config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Binary file removed simulator/core/__pycache__/__init__.cpython-311.pyc
Binary file not shown.
Binary file removed simulator/core/__pycache__/carrier.cpython-311.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed simulator/core/__pycache__/world.cpython-311.pyc
Binary file not shown.
5 changes: 3 additions & 2 deletions simulator/core/world.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Binary file removed simulator/dtn/__pycache__/__init__.cpython-311.pyc
Binary file not shown.
Binary file removed simulator/dtn/__pycache__/base.cpython-311.pyc
Binary file not shown.
Binary file removed simulator/dtn/__pycache__/epidemic.cpython-311.pyc
Binary file not shown.
Binary file removed simulator/dtn/__pycache__/prophet.cpython-311.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
25 changes: 22 additions & 3 deletions simulator/metrics/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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),
}
Binary file removed storage/__pycache__/__init__.cpython-311.pyc
Binary file not shown.
Binary file removed storage/eviction/__pycache__/__init__.cpython-311.pyc
Binary file not shown.
Binary file removed storage/eviction/__pycache__/base.cpython-311.pyc
Binary file not shown.
Binary file removed storage/eviction/__pycache__/lru.cpython-311.pyc
Binary file not shown.
Binary file not shown.
Binary file removed tests/__pycache__/__init__.cpython-311.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
103 changes: 103 additions & 0 deletions tests/test_summary.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading