rustsim 0.0.1

High-performance agent-based modelling engine - top-level orchestration crate
Documentation
// Corridor flow simulation
//
// Scenario:
//   - 100-meter linear path from vertex A (pos=0) to vertex B (pos=100)
//   - 1000 agents spawn every 2 minutes (120 seconds)
//   - Line rate flow capacity: 50 pax/min (gate at entrance)
//   - 1 tick = 1 second
//
// Model:
//   Agents are placed into a queue at the entrance. Each tick, the entrance
//   gate admits up to flow_capacity agents per second onto the path.
//   On the path, agents walk at free_speed (1.3 m/s) unless the agent
//   directly ahead is too close (minimum gap enforced to prevent overtaking).
//   Agents are removed upon reaching pos >= path_length.

use rand::rngs::StdRng;
use rand::SeedableRng;
use rustsim::prelude::*;

// ---- Agent ----

#[derive(Debug, Clone)]
struct Pedestrian {
    id: AgentId,
    position: f64, // meters along path, -1.0 means queued
    speed: f64,
    spawn_time: u64,
}

impl Agent for Pedestrian {
    fn id(&self) -> AgentId {
        self.id
    }
}

// ---- Model properties ----

#[derive(Debug, Clone)]
struct CorridorProps {
    path_length: f64,           // 100 m
    free_speed: f64,            // 1.3 m/s
    flow_capacity_per_sec: f64, // 50/60 = 0.833 pax/s
    min_spacing: f64,           // minimum gap between agents (m)
    spawn_batch: usize,         // 1000
    spawn_interval: u64,        // 120 s
    entrance_queue: Vec<AgentId>,
    admission_accum: f64,
    sorted_positions: Vec<f64>, // rebuilt each tick for fast nearest-ahead lookup
    agents_spawned: u64,
    agents_finished: u64,
    total_travel_time: u64,
}

impl CorridorProps {
    fn new() -> Self {
        Self {
            path_length: 100.0,
            free_speed: 1.3,
            flow_capacity_per_sec: 50.0 / 60.0,
            min_spacing: 0.8, // ~shoulder width
            spawn_batch: 1000,
            spawn_interval: 120,
            entrance_queue: Vec::new(),
            admission_accum: 0.0,
            sorted_positions: Vec::new(),
            agents_spawned: 0,
            agents_finished: 0,
            total_travel_time: 0,
        }
    }
}

// ---- Model type ----

type CorridorModel = StandardModel<
    rustsim_spaces::nothing::NothingSpace,
    Pedestrian,
    HashMapStore<Pedestrian>,
    CorridorProps,
    StdRng,
    Fastest,
>;

// ---- Model step: spawn + gate + remove ----
// Runs FIRST (agents_first=false), so spawned agents get a walk in the same tick.

fn model_step(model: &mut CorridorModel) {
    let time = match model.time() {
        Time::Discrete(t) => t,
        Time::Continuous(t) => t as u64,
    };
    let interval = model.properties().spawn_interval;
    let batch_size = model.properties().spawn_batch;

    // Spawn: add agents to entrance queue
    if interval > 0 && time % interval == 0 {
        for _ in 0..batch_size {
            let id = model.next_id();
            let ped = Pedestrian {
                id,
                position: -1.0,
                speed: 0.0,
                spawn_time: time,
            };
            model.insert_agent(ped).ok();
            model.properties_mut().entrance_queue.push(id);
        }
        model.properties_mut().agents_spawned += batch_size as u64;
    }

    // Gate: admit agents from queue at flow capacity
    let flow = model.properties().flow_capacity_per_sec;
    model.properties_mut().admission_accum += flow;

    // Space admitted agents along the entrance to avoid stacking
    let spacing = model.properties().min_spacing;
    let mut next_pos = 0.0f64;

    while model.properties().admission_accum >= 1.0 && !model.properties().entrance_queue.is_empty()
    {
        let id = model.properties_mut().entrance_queue.remove(0);
        if let Some(mut agent) = model.agent_mut(id) {
            agent.position = next_pos;
        }
        next_pos += spacing;
        model.properties_mut().admission_accum -= 1.0;
    }
    // Cap accumulator
    let cap = flow * 2.0;
    if model.properties().admission_accum > cap {
        model.properties_mut().admission_accum = cap;
    }

    // Remove finished agents
    let path_length = model.properties().path_length;
    let current_time = time;
    let finished: Vec<(AgentId, u64)> = model
        .agents()
        .filter(|a| a.position >= path_length)
        .map(|a| (a.id(), current_time.saturating_sub(a.spawn_time)))
        .collect();

    let count = finished.len() as u64;
    let travel_sum: u64 = finished.iter().map(|(_, t)| *t).sum();

    for (id, _) in &finished {
        model.remove_agent(*id);
    }

    let props = model.properties_mut();
    props.agents_finished += count;
    props.total_travel_time += travel_sum;

    // Rebuild sorted position index for agent_step's binary search
    let mut positions: Vec<f64> = model
        .agents()
        .filter(|a| a.position >= 0.0)
        .map(|a| a.position)
        .collect();
    positions.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
    model.properties_mut().sorted_positions = positions;
}

// ---- Agent step: walk at free speed, don't overtake ----
// Uses a sorted snapshot of positions built in model_step for O(1) lookup.

fn agent_step(
    agent: &mut Pedestrian,
    ctx: &mut StepContext<
        '_,
        rustsim_spaces::nothing::NothingSpace,
        Pedestrian,
        CorridorProps,
        StdRng,
        Fastest,
    >,
) {
    if agent.position < 0.0 {
        agent.speed = 0.0;
        return;
    }

    let props = ctx.properties();
    let free_speed = props.free_speed;
    let path_length = props.path_length;
    let min_gap = props.min_spacing;

    let desired_pos = agent.position + free_speed;

    let sorted = &props.sorted_positions;
    let my_pos = agent.position;

    let nearest_ahead = match sorted.binary_search_by(|p| p.partial_cmp(&my_pos).unwrap()) {
        Ok(mut idx) => {
            while idx < sorted.len() && sorted[idx] <= my_pos {
                idx += 1;
            }
            if idx < sorted.len() {
                sorted[idx]
            } else {
                path_length + min_gap
            }
        }
        Err(idx) => {
            if idx < sorted.len() {
                sorted[idx]
            } else {
                path_length + min_gap
            }
        }
    };

    let max_pos = (nearest_ahead - min_gap).max(my_pos);
    let new_pos = desired_pos.min(max_pos).min(path_length);

    agent.speed = new_pos - my_pos;
    agent.position = new_pos;
}

// ---- Simulation ----

#[test]
fn corridor_simulation() {
    let store = HashMapStore::new();
    let props = CorridorProps::new();

    let mut model = CorridorModel::new(
        store,
        rustsim_spaces::nothing::NothingSpace,
        Fastest::new(),
        props,
        StdRng::seed_from_u64(42),
        Some(Box::new(agent_step)),
        Some(model_step),
        false, // model first (spawn+gate+remove), then agents walk
    );

    // Run for 20 minutes = 1200 seconds
    let total_seconds = 1200u64;

    println!();
    println!("=== Corridor Flow Simulation ===");
    println!("Path length:     100 m (A -> B)");
    println!("Spawn:           1000 pax every 2 min");
    println!("Flow capacity:   50 pax/min (entrance gate)");
    println!("Free walk speed: 1.3 m/s");
    println!("Min spacing:     0.8 m");
    println!(
        "Duration:        {} min ({} s)",
        total_seconds / 60,
        total_seconds
    );
    println!();

    let mut snapshots: Vec<(u64, usize, usize, u64, f64)> = Vec::new();

    for _ in 0..total_seconds {
        model.step();

        let t = match model.time() {
            Time::Discrete(t) => t,
            Time::Continuous(t) => t as u64,
        };
        if t % 60 == 0 {
            let on_path = model.agents().filter(|a| a.position >= 0.0).count();
            let in_queue = model.properties().entrance_queue.len();
            let finished = model.properties().agents_finished;

            let avg_speed = if on_path > 0 {
                let total: f64 = model
                    .agents()
                    .filter(|a| a.position >= 0.0)
                    .map(|a| a.speed)
                    .sum();
                total / on_path as f64
            } else {
                0.0
            };

            snapshots.push((t, on_path, in_queue, finished, avg_speed));
        }
    }

    // Print report
    println!(
        "{:>6}  {:>8}  {:>8}  {:>10}  {:>10}",
        "Time", "On Path", "Queued", "Finished", "Avg Speed"
    );
    println!("{}", "-".repeat(54));

    for (t, on_path, in_queue, finished, avg_speed) in &snapshots {
        let mins = t / 60;
        let secs = t % 60;
        println!(
            "{:3}:{:02}  {:>8}  {:>8}  {:>10}  {:>8.3} m/s",
            mins, secs, on_path, in_queue, finished, avg_speed
        );
    }

    let props = model.properties();
    let still_on_path = model.agents().filter(|a| a.position >= 0.0).count();
    let still_queued = props.entrance_queue.len();

    println!();
    println!("=== Final Statistics ===");
    println!("Total spawned:   {}", props.agents_spawned);
    println!("Total finished:  {}", props.agents_finished);
    println!("Still on path:   {}", still_on_path);
    println!("Still in queue:  {}", still_queued);
    println!(
        "Accounted for:   {}",
        props.agents_finished as usize + still_on_path + still_queued
    );

    let avg_travel = if props.agents_finished > 0 {
        props.total_travel_time as f64 / props.agents_finished as f64
    } else {
        0.0
    };
    println!(
        "Avg travel time: {:.1}s ({:.1} min)",
        avg_travel,
        avg_travel / 60.0
    );

    let theoretical_min = props.path_length / props.free_speed;
    println!("Theoretical min: {:.1}s (at free speed)", theoretical_min);

    let max_throughput = (props.flow_capacity_per_sec * total_seconds as f64) as u64;
    println!(
        "Max throughput:  {} pax (at {:.1} pax/min for {} min)",
        max_throughput,
        props.flow_capacity_per_sec * 60.0,
        total_seconds / 60
    );

    // ---- Assertions ----

    // Spawn count: t=0,120,...,1080 = 10 events (t=1200 doesn't fire because
    // time() returns 1199 at the last model_step before the final increment)
    assert!(
        props.agents_spawned >= 10000,
        "at least 10 batches of 1000 should spawn"
    );

    // Some agents finished
    assert!(props.agents_finished > 0, "some agents must reach vertex B");

    // Average travel time >= theoretical minimum
    assert!(
        avg_travel >= theoretical_min - 2.0,
        "avg travel ({:.1}s) should be >= theoretical min ({:.1}s)",
        avg_travel,
        theoretical_min
    );

    // Throughput bounded by capacity (with margin)
    assert!(
        props.agents_finished <= max_throughput + 200,
        "throughput ({}) should not exceed capacity ({}+margin)",
        props.agents_finished,
        max_throughput
    );

    // Demand exceeds capacity: most agents still queued/walking
    let total_remaining = still_on_path + still_queued;
    assert!(
        total_remaining > 0,
        "demand far exceeds capacity, agents must remain"
    );

    // Conservation: spawned = finished + on_path + queued
    let accounted = props.agents_finished as usize + still_on_path + still_queued;
    assert_eq!(
        accounted, props.agents_spawned as usize,
        "all agents must be accounted for"
    );

    println!("\nAll assertions passed.");
}