villsim/tools/run_headless_analysis.py

741 lines
31 KiB
Python

#!/usr/bin/env python3
"""
Headless Simulation Runner & Analyzer
This script runs the Village Simulation in headless mode for a specified number
of steps, then generates comprehensive statistics, plots, and tables for analysis.
Usage:
python tools/run_headless_analysis.py [--steps 1000] [--agents 10]
"""
import argparse
import json
import sys
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional
# Add parent directory for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from backend.core.engine import GameEngine, get_engine
from backend.core.logger import reset_simulation_logger
@dataclass
class SimulationStats:
"""Collected statistics from a simulation run."""
total_turns: int = 0
total_trades: int = 0 # Completed buy transactions
total_listings: int = 0 # Items listed for sale
total_deaths: int = 0
# Completed trade statistics (actual buy transactions)
trades_by_resource: dict = field(default_factory=lambda: defaultdict(int))
trade_volume_by_resource: dict = field(default_factory=lambda: defaultdict(int))
trade_value_by_resource: dict = field(default_factory=lambda: defaultdict(int))
# Market listing statistics (items put up for sale)
listings_by_resource: dict = field(default_factory=lambda: defaultdict(int))
listings_volume_by_resource: dict = field(default_factory=lambda: defaultdict(int))
# Action statistics
actions_count: dict = field(default_factory=lambda: defaultdict(int))
actions_success: dict = field(default_factory=lambda: defaultdict(int))
actions_failure: dict = field(default_factory=lambda: defaultdict(int))
# Resource production
resources_produced: dict = field(default_factory=lambda: defaultdict(int))
resources_consumed: dict = field(default_factory=lambda: defaultdict(int))
# Agent statistics
deaths_by_cause: dict = field(default_factory=lambda: defaultdict(int))
agent_survival_turns: dict = field(default_factory=dict)
living_agents_over_time: list = field(default_factory=list)
# Economy
money_circulation_over_time: list = field(default_factory=list)
avg_agent_money_over_time: list = field(default_factory=list)
price_history: dict = field(default_factory=lambda: defaultdict(list))
# Time tracking
actions_by_time_of_day: dict = field(default_factory=lambda: {"day": defaultdict(int), "night": defaultdict(int)})
def run_headless_simulation(num_steps: int = 1000, num_agents: int = 10) -> tuple[str, SimulationStats]:
"""
Run the simulation in headless mode.
Returns:
Tuple of (log_file_path, collected_stats)
"""
print(f"\n{'='*60}")
print(f"🏘️ VILLAGE SIMULATION - HEADLESS MODE")
print(f"{'='*60}")
print(f" Steps: {num_steps}")
print(f" Agents: {num_agents}")
print(f" Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'='*60}\n")
# Initialize engine
engine = get_engine()
engine.reset()
engine.world.config.initial_agents = num_agents
engine.world.initialize()
# Get log file path
log_file = engine.logger.session_file
# Statistics collector
stats = SimulationStats()
agent_first_seen = {}
# Progress tracking
progress_interval = max(1, num_steps // 20)
# Run simulation
for step in range(num_steps):
if not engine.is_running:
print(f"\n⚠️ Simulation stopped at step {step} - all agents died!")
break
turn_log = engine.next_step()
stats.total_turns += 1
# Collect turn statistics
current_turn = turn_log.turn
time_of_day = engine.world.time_of_day.value
# Track living agents
living = len(engine.world.get_living_agents())
stats.living_agents_over_time.append(living)
# Track money
total_money = sum(a.money for a in engine.world.agents)
stats.money_circulation_over_time.append(total_money)
if living > 0:
stats.avg_agent_money_over_time.append(total_money / living)
else:
stats.avg_agent_money_over_time.append(0)
# Process agent actions
for action_data in turn_log.agent_actions:
agent_id = action_data.get("agent_id")
agent_name = action_data.get("agent_name")
decision = action_data.get("decision", {})
result = action_data.get("result", {})
if agent_id and agent_id not in agent_first_seen:
agent_first_seen[agent_id] = current_turn
action_type = decision.get("action", "unknown")
stats.actions_count[action_type] += 1
stats.actions_by_time_of_day[time_of_day][action_type] += 1
if result:
if result.get("success", False):
stats.actions_success[action_type] += 1
# Track resources gained
for res in result.get("resources_gained", []):
res_type = res.get("type", "unknown")
quantity = res.get("quantity", 0)
stats.resources_produced[res_type] += quantity
# Track resources consumed
for res in result.get("resources_consumed", []):
res_type = res.get("type", "unknown")
quantity = res.get("quantity", 0)
stats.resources_consumed[res_type] += quantity
# Track trade-specific results
if action_type == "trade":
message = result.get("message", "")
# Check if it's a listing (sell) or a purchase (buy)
if "Listed" in message:
# Parse "Listed X resource @ Yc each"
import re
match = re.search(r"Listed (\d+) (\w+) @ (\d+)c", message)
if match:
qty = int(match.group(1))
res_type = match.group(2)
stats.total_listings += 1
stats.listings_by_resource[res_type] += 1
stats.listings_volume_by_resource[res_type] += qty
elif "Bought" in message:
# Parse "Bought X resource for Yc"
import re
match = re.search(r"Bought (\d+) (\w+) for (\d+)c", message)
if match:
qty = int(match.group(1))
res_type = match.group(2)
value = int(match.group(3))
stats.total_trades += 1
stats.trades_by_resource[res_type] += 1
stats.trade_volume_by_resource[res_type] += qty
stats.trade_value_by_resource[res_type] += value
# Track price
if qty > 0:
stats.price_history[res_type].append((current_turn, value / qty))
else:
stats.actions_failure[action_type] += 1
# Process deaths
for death_name in turn_log.deaths:
stats.total_deaths += 1
# Find the agent's death cause from the world
for agent in engine.world.agents:
if agent.name == death_name and agent.death_reason:
stats.deaths_by_cause[agent.death_reason] += 1
if agent.id in agent_first_seen:
survival = current_turn - agent_first_seen[agent.id]
stats.agent_survival_turns[agent.id] = survival
break
# Progress update
if (step + 1) % progress_interval == 0 or step == num_steps - 1:
pct = (step + 1) / num_steps * 100
bar_len = 30
filled = int(bar_len * pct / 100)
bar = "" * filled + "" * (bar_len - filled)
print(f"\r Progress: [{bar}] {pct:5.1f}% | Turn {step+1}/{num_steps} | Alive: {living}", end="")
print("\n")
# Close logger
engine.logger.close()
return str(log_file), stats
def analyze_log_file(log_file: str) -> SimulationStats:
"""
Parse a simulation log file and extract statistics.
Useful for analyzing existing log files.
"""
stats = SimulationStats()
agent_first_seen = {}
with open(log_file, 'r') as f:
for line in f:
data = json.loads(line)
if data.get("type") == "config":
continue
if data.get("type") == "turn":
turn_data = data.get("data", {})
current_turn = turn_data.get("turn", 0)
time_of_day = turn_data.get("time_of_day", "day")
stats.total_turns += 1
# Track living agents
agent_entries = turn_data.get("agent_entries", [])
stats.living_agents_over_time.append(len(agent_entries))
# Track money
total_money = sum(a.get("money_after", 0) for a in agent_entries)
stats.money_circulation_over_time.append(total_money)
if agent_entries:
stats.avg_agent_money_over_time.append(total_money / len(agent_entries))
else:
stats.avg_agent_money_over_time.append(0)
# Process agent entries
for agent in agent_entries:
agent_id = agent.get("agent_id")
decision = agent.get("decision", {})
result = agent.get("action_result", {})
if agent_id and agent_id not in agent_first_seen:
agent_first_seen[agent_id] = current_turn
action_type = decision.get("action", "unknown")
stats.actions_count[action_type] += 1
stats.actions_by_time_of_day[time_of_day][action_type] += 1
if result.get("success", False):
stats.actions_success[action_type] += 1
for res in result.get("resources_gained", []):
stats.resources_produced[res.get("type", "unknown")] += res.get("quantity", 0)
# Track trade-specific results
if action_type == "trade":
import re
message = result.get("message", "")
if "Listed" in message:
match = re.search(r"Listed (\d+) (\w+) @ (\d+)c", message)
if match:
qty = int(match.group(1))
res_type = match.group(2)
stats.total_listings += 1
stats.listings_by_resource[res_type] += 1
stats.listings_volume_by_resource[res_type] += qty
elif "Bought" in message:
match = re.search(r"Bought (\d+) (\w+) for (\d+)c", message)
if match:
qty = int(match.group(1))
res_type = match.group(2)
value = int(match.group(3))
stats.total_trades += 1
stats.trades_by_resource[res_type] += 1
stats.trade_volume_by_resource[res_type] += qty
stats.trade_value_by_resource[res_type] += value
if qty > 0:
stats.price_history[res_type].append((current_turn, value / qty))
else:
stats.actions_failure[action_type] += 1
# Process deaths
for death in turn_data.get("deaths", []):
stats.total_deaths += 1
cause = death.get("cause", "unknown") if isinstance(death, dict) else "unknown"
stats.deaths_by_cause[cause] += 1
return stats
def generate_text_report(stats: SimulationStats) -> str:
"""Generate a text-based statistical report."""
lines = []
lines.append("\n" + "=" * 70)
lines.append("📊 SIMULATION ANALYSIS REPORT")
lines.append("=" * 70)
# Overview
lines.append("\n📋 OVERVIEW")
lines.append("-" * 40)
lines.append(f" Total Turns Simulated: {stats.total_turns:,}")
lines.append(f" Market Listings: {stats.total_listings:,}")
lines.append(f" Completed Trades: {stats.total_trades:,}")
lines.append(f" Total Deaths: {stats.total_deaths:,}")
lines.append(f" Final Living Agents: {stats.living_agents_over_time[-1] if stats.living_agents_over_time else 0}")
# Market Listings Statistics
lines.append("\n\n🏪 MARKET LISTINGS (Items Listed for Sale)")
lines.append("-" * 40)
if stats.listings_by_resource:
lines.append(f" {'Resource':<15} {'Listings':>10} {'Volume':>10}")
lines.append(f" {'-'*15} {'-'*10} {'-'*10}")
for resource in sorted(stats.listings_by_resource.keys()):
count = stats.listings_by_resource[resource]
volume = stats.listings_volume_by_resource[resource]
lines.append(f" {resource:<15} {count:>10,} {volume:>10,}")
total_listings_volume = sum(stats.listings_volume_by_resource.values())
lines.append(f" {'-'*15} {'-'*10} {'-'*10}")
lines.append(f" {'TOTAL':<15} {stats.total_listings:>10,} {total_listings_volume:>10,}")
else:
lines.append(" No items listed for sale")
# Completed Trade Statistics
lines.append("\n\n💰 COMPLETED TRADES (Items Purchased)")
lines.append("-" * 40)
if stats.trades_by_resource:
lines.append(f" {'Resource':<15} {'Trades':>10} {'Volume':>10} {'Value':>12}")
lines.append(f" {'-'*15} {'-'*10} {'-'*10} {'-'*12}")
for resource in sorted(stats.trades_by_resource.keys()):
count = stats.trades_by_resource[resource]
volume = stats.trade_volume_by_resource[resource]
value = stats.trade_value_by_resource[resource]
lines.append(f" {resource:<15} {count:>10,} {volume:>10,} {value:>10,} ¢")
total_volume = sum(stats.trade_volume_by_resource.values())
total_value = sum(stats.trade_value_by_resource.values())
lines.append(f" {'-'*15} {'-'*10} {'-'*10} {'-'*12}")
lines.append(f" {'TOTAL':<15} {stats.total_trades:>10,} {total_volume:>10,} {total_value:>10,} ¢")
# Average price per resource
lines.append("\n 📊 Average Prices:")
for resource in sorted(stats.trades_by_resource.keys()):
volume = stats.trade_volume_by_resource[resource]
value = stats.trade_value_by_resource[resource]
if volume > 0:
avg_price = value / volume
lines.append(f" {resource}: {avg_price:.2f}¢ per unit")
else:
lines.append(" No completed trades recorded")
lines.append(" (Items may be listed but no buyers matched)")
# Most traded items
if stats.trades_by_resource:
lines.append("\n 🏆 Most Traded Items (by number of trades):")
for i, (resource, count) in enumerate(sorted(stats.trades_by_resource.items(), key=lambda x: -x[1])[:5], 1):
lines.append(f" {i}. {resource}: {count:,} trades")
# Action Statistics
lines.append("\n\n⚡ ACTION STATISTICS")
lines.append("-" * 40)
lines.append(f" {'Action':<15} {'Total':>10} {'Success':>10} {'Failure':>10} {'Rate':>10}")
lines.append(f" {'-'*15} {'-'*10} {'-'*10} {'-'*10} {'-'*10}")
for action in sorted(stats.actions_count.keys()):
total = stats.actions_count[action]
success = stats.actions_success.get(action, 0)
failure = stats.actions_failure.get(action, 0)
rate = (success / total * 100) if total > 0 else 0
lines.append(f" {action:<15} {total:>10,} {success:>10,} {failure:>10,} {rate:>9.1f}%")
# Resource Production
lines.append("\n\n🌾 RESOURCE PRODUCTION")
lines.append("-" * 40)
if stats.resources_produced:
lines.append(f" {'Resource':<15} {'Produced':>12}")
lines.append(f" {'-'*15} {'-'*12}")
for resource in sorted(stats.resources_produced.keys()):
produced = stats.resources_produced[resource]
lines.append(f" {resource:<15} {produced:>12,}")
else:
lines.append(" No resources produced")
# Deaths by Cause
lines.append("\n\n💀 DEATHS BY CAUSE")
lines.append("-" * 40)
if stats.deaths_by_cause:
for cause, count in sorted(stats.deaths_by_cause.items(), key=lambda x: -x[1]):
pct = (count / stats.total_deaths * 100) if stats.total_deaths > 0 else 0
lines.append(f" {cause:<20} {count:>5} ({pct:5.1f}%)")
else:
lines.append(" No deaths recorded")
# Day vs Night Activity
lines.append("\n\n🌓 ACTIVITY BY TIME OF DAY")
lines.append("-" * 40)
day_total = sum(stats.actions_by_time_of_day["day"].values())
night_total = sum(stats.actions_by_time_of_day["night"].values())
lines.append(f" Day actions: {day_total:>10,}")
lines.append(f" Night actions: {night_total:>10,}")
if stats.actions_by_time_of_day["day"]:
lines.append("\n Top Day Activities:")
for action, count in sorted(stats.actions_by_time_of_day["day"].items(), key=lambda x: -x[1])[:5]:
lines.append(f" - {action}: {count:,}")
if stats.actions_by_time_of_day["night"]:
lines.append("\n Top Night Activities:")
for action, count in sorted(stats.actions_by_time_of_day["night"].items(), key=lambda x: -x[1])[:5]:
lines.append(f" - {action}: {count:,}")
lines.append("\n" + "=" * 70)
return "\n".join(lines)
def generate_plots(stats: SimulationStats, output_dir: Path):
"""Generate matplotlib plots for visualization."""
try:
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
except ImportError:
print("⚠️ matplotlib not installed. Skipping plot generation.")
print(" Install with: pip install matplotlib")
return
# Set style
plt.style.use('seaborn-v0_8-darkgrid' if 'seaborn-v0_8-darkgrid' in plt.style.available else 'ggplot')
# Color palette
colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F']
# Create output directory
output_dir.mkdir(exist_ok=True)
# 1. Population Over Time
if stats.living_agents_over_time:
fig, ax = plt.subplots(figsize=(12, 5))
turns = range(1, len(stats.living_agents_over_time) + 1)
ax.fill_between(turns, stats.living_agents_over_time, alpha=0.3, color=colors[0])
ax.plot(turns, stats.living_agents_over_time, color=colors[0], linewidth=2)
ax.set_xlabel('Turn', fontsize=12)
ax.set_ylabel('Living Agents', fontsize=12)
ax.set_title('🏘️ Population Over Time', fontsize=14, fontweight='bold')
ax.set_ylim(bottom=0)
plt.tight_layout()
plt.savefig(output_dir / 'population_over_time.png', dpi=150, bbox_inches='tight')
plt.close()
print(f" 📈 Saved: population_over_time.png")
# 2. Money Circulation
if stats.avg_agent_money_over_time:
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
turns = range(1, len(stats.avg_agent_money_over_time) + 1)
ax1.plot(turns, stats.money_circulation_over_time, color=colors[1], linewidth=2)
ax1.set_xlabel('Turn', fontsize=12)
ax1.set_ylabel('Total Money (¢)', fontsize=12)
ax1.set_title('💰 Total Money in Circulation', fontsize=14, fontweight='bold')
ax2.plot(turns, stats.avg_agent_money_over_time, color=colors[2], linewidth=2)
ax2.set_xlabel('Turn', fontsize=12)
ax2.set_ylabel('Average Money (¢)', fontsize=12)
ax2.set_title('💵 Average Money per Agent', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig(output_dir / 'money_circulation.png', dpi=150, bbox_inches='tight')
plt.close()
print(f" 📈 Saved: money_circulation.png")
# 3. Action Distribution (Pie Chart)
if stats.actions_count:
fig, ax = plt.subplots(figsize=(10, 8))
actions = list(stats.actions_count.keys())
counts = list(stats.actions_count.values())
# Sort by count
sorted_pairs = sorted(zip(actions, counts), key=lambda x: -x[1])
actions, counts = zip(*sorted_pairs)
wedges, texts, autotexts = ax.pie(
counts,
labels=actions,
autopct=lambda pct: f'{pct:.1f}%' if pct > 3 else '',
colors=colors[:len(actions)],
explode=[0.02] * len(actions),
shadow=True
)
ax.set_title('⚡ Action Distribution', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig(output_dir / 'action_distribution.png', dpi=150, bbox_inches='tight')
plt.close()
print(f" 📈 Saved: action_distribution.png")
# 4. Trade Volume by Resource (Bar Chart)
if stats.trades_by_resource:
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
resources = list(stats.trades_by_resource.keys())
trade_counts = [stats.trades_by_resource[r] for r in resources]
volumes = [stats.trade_volume_by_resource[r] for r in resources]
# Sort by trade count
sorted_data = sorted(zip(resources, trade_counts, volumes), key=lambda x: -x[1])
resources, trade_counts, volumes = zip(*sorted_data) if sorted_data else ([], [], [])
bars1 = ax1.barh(resources, trade_counts, color=colors[:len(resources)])
ax1.set_xlabel('Number of Trades', fontsize=12)
ax1.set_title('📊 Trades by Resource', fontsize=14, fontweight='bold')
ax1.invert_yaxis()
# Add value labels
for bar, val in zip(bars1, trade_counts):
ax1.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height()/2,
f'{val:,}', va='center', fontsize=10)
bars2 = ax2.barh(resources, volumes, color=colors[:len(resources)])
ax2.set_xlabel('Total Volume', fontsize=12)
ax2.set_title('📦 Trade Volume by Resource', fontsize=14, fontweight='bold')
ax2.invert_yaxis()
for bar, val in zip(bars2, volumes):
ax2.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height()/2,
f'{val:,}', va='center', fontsize=10)
plt.tight_layout()
plt.savefig(output_dir / 'trade_statistics.png', dpi=150, bbox_inches='tight')
plt.close()
print(f" 📈 Saved: trade_statistics.png")
# 5. Resource Production (Horizontal Bar)
if stats.resources_produced:
fig, ax = plt.subplots(figsize=(10, 6))
resources = list(stats.resources_produced.keys())
produced = list(stats.resources_produced.values())
sorted_data = sorted(zip(resources, produced), key=lambda x: -x[1])
resources, produced = zip(*sorted_data)
bars = ax.barh(resources, produced, color=colors[:len(resources)])
ax.set_xlabel('Quantity Produced', fontsize=12)
ax.set_title('🌾 Resource Production', fontsize=14, fontweight='bold')
ax.invert_yaxis()
for bar, val in zip(bars, produced):
ax.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height()/2,
f'{val:,}', va='center', fontsize=10)
plt.tight_layout()
plt.savefig(output_dir / 'resource_production.png', dpi=150, bbox_inches='tight')
plt.close()
print(f" 📈 Saved: resource_production.png")
# 6. Deaths by Cause (Pie Chart)
if stats.deaths_by_cause:
fig, ax = plt.subplots(figsize=(8, 8))
causes = list(stats.deaths_by_cause.keys())
counts = list(stats.deaths_by_cause.values())
death_colors = ['#FF6B6B', '#FF8E72', '#FFA07A', '#FFB6A3', '#FFCCBC']
wedges, texts, autotexts = ax.pie(
counts,
labels=causes,
autopct='%1.1f%%',
colors=death_colors[:len(causes)],
explode=[0.02] * len(causes),
shadow=True
)
ax.set_title('💀 Deaths by Cause', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig(output_dir / 'deaths_by_cause.png', dpi=150, bbox_inches='tight')
plt.close()
print(f" 📈 Saved: deaths_by_cause.png")
# 7. Action Success Rates
if stats.actions_count:
fig, ax = plt.subplots(figsize=(12, 6))
actions = list(stats.actions_count.keys())
success_rates = []
totals = []
for action in actions:
total = stats.actions_count[action]
success = stats.actions_success.get(action, 0)
rate = (success / total * 100) if total > 0 else 0
success_rates.append(rate)
totals.append(total)
# Sort by success rate
sorted_data = sorted(zip(actions, success_rates, totals), key=lambda x: -x[1])
actions, success_rates, totals = zip(*sorted_data)
bars = ax.barh(actions, success_rates, color=colors[:len(actions)])
ax.set_xlabel('Success Rate (%)', fontsize=12)
ax.set_xlim(0, 105)
ax.set_title('✅ Action Success Rates', fontsize=14, fontweight='bold')
ax.invert_yaxis()
for bar, rate, total in zip(bars, success_rates, totals):
ax.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2,
f'{rate:.1f}% (n={total:,})', va='center', fontsize=10)
plt.tight_layout()
plt.savefig(output_dir / 'action_success_rates.png', dpi=150, bbox_inches='tight')
plt.close()
print(f" 📈 Saved: action_success_rates.png")
# 8. Price History (if we have trade data)
if stats.price_history:
fig, ax = plt.subplots(figsize=(12, 6))
for i, (resource, prices) in enumerate(stats.price_history.items()):
if prices:
turns, price_values = zip(*prices)
# Rolling average for smoother lines
if len(price_values) > 10:
window = min(10, len(price_values) // 5)
smoothed = np.convolve(price_values, np.ones(window)/window, mode='valid')
ax.plot(range(len(smoothed)), smoothed, label=resource,
color=colors[i % len(colors)], linewidth=2)
else:
ax.scatter(turns, price_values, label=resource,
color=colors[i % len(colors)], s=30, alpha=0.7)
ax.set_xlabel('Turn', fontsize=12)
ax.set_ylabel('Price per Unit (¢)', fontsize=12)
ax.set_title('📈 Price History by Resource', fontsize=14, fontweight='bold')
ax.legend(loc='upper right')
plt.tight_layout()
plt.savefig(output_dir / 'price_history.png', dpi=150, bbox_inches='tight')
plt.close()
print(f" 📈 Saved: price_history.png")
# 9. Day vs Night Activity Comparison
if any(stats.actions_by_time_of_day["day"].values()) or any(stats.actions_by_time_of_day["night"].values()):
fig, ax = plt.subplots(figsize=(12, 6))
all_actions = set(stats.actions_by_time_of_day["day"].keys()) | set(stats.actions_by_time_of_day["night"].keys())
all_actions = sorted(all_actions)
day_counts = [stats.actions_by_time_of_day["day"].get(a, 0) for a in all_actions]
night_counts = [stats.actions_by_time_of_day["night"].get(a, 0) for a in all_actions]
x = np.arange(len(all_actions))
width = 0.35
bars1 = ax.bar(x - width/2, day_counts, width, label='Day', color='#FFD93D')
bars2 = ax.bar(x + width/2, night_counts, width, label='Night', color='#6C5CE7')
ax.set_xlabel('Action', fontsize=12)
ax.set_ylabel('Count', fontsize=12)
ax.set_title('🌓 Actions by Time of Day', fontsize=14, fontweight='bold')
ax.set_xticks(x)
ax.set_xticklabels(all_actions, rotation=45, ha='right')
ax.legend()
plt.tight_layout()
plt.savefig(output_dir / 'day_night_activity.png', dpi=150, bbox_inches='tight')
plt.close()
print(f" 📈 Saved: day_night_activity.png")
print(f"\n 📁 All plots saved to: {output_dir}")
def main():
parser = argparse.ArgumentParser(
description="Run Village Simulation in headless mode and generate analysis"
)
parser.add_argument(
"--steps", "-s", type=int, default=1000,
help="Number of simulation steps to run (default: 1000)"
)
parser.add_argument(
"--agents", "-a", type=int, default=10,
help="Number of initial agents (default: 10)"
)
parser.add_argument(
"--analyze-only", "-f", type=str, default=None,
help="Skip simulation, analyze existing log file instead"
)
parser.add_argument(
"--output-dir", "-o", type=str, default=None,
help="Directory for output plots (default: logs/analysis_TIMESTAMP)"
)
args = parser.parse_args()
# Determine output directory
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if args.output_dir:
output_dir = Path(args.output_dir)
else:
output_dir = Path("logs") / f"analysis_{timestamp}"
# Run simulation or load existing log
if args.analyze_only:
print(f"\n📂 Analyzing existing log file: {args.analyze_only}")
stats = analyze_log_file(args.analyze_only)
log_file = args.analyze_only
else:
log_file, stats = run_headless_simulation(args.steps, args.agents)
# Generate text report
report = generate_text_report(stats)
print(report)
# Save report to file
report_file = output_dir / "analysis_report.txt"
output_dir.mkdir(parents=True, exist_ok=True)
with open(report_file, 'w') as f:
f.write(report)
print(f"\n📄 Report saved to: {report_file}")
# Generate plots
print("\n🎨 Generating plots...")
generate_plots(stats, output_dir)
print(f"\n✅ Analysis complete!")
print(f" Log file: {log_file}")
print(f" Output: {output_dir}")
if __name__ == "__main__":
main()