henad-app 0.3.0

Desktop and web app for Henad, a parallel agent-based modelling engine.
Documentation
//! The Pacing panel: a CPU model's speed limit and snapshot cadence, a network's layout, and a GPU model's batching.

use crate::sim_runner::SimRunner;
use crate::state::AppState;
use henad_compute::cpu::sim_thread::SimCommand;
use henad_compute::runner::MAX_VIEW_BUDGET_MS;

const LAYOUT_BUDGET_MAX_MS: f32 = 50.0;

pub fn pacing_ui(ui: &mut egui::Ui, app: &mut AppState) {
    // A GPU model paces itself by its batch size and ignores the TPS cap and the snapshot cadence. Its controls
    // replace the CPU controls, which would have no effect on it.
    if app.is_gpu() {
        gpu_batching_controls(ui, app);
    } else {
        cpu_pacing_controls(ui, app);
    }
}

fn cpu_pacing_controls(ui: &mut egui::Ui, app: &mut AppState) {
    let uncapped_changed = ui.checkbox(&mut app.uncapped, "Unlimited TPS").changed();

    if !app.uncapped {
        let tps_changed = ui
            .add(
                egui::Slider::new(&mut app.target_tps, 1.0..=1000.0)
                    .logarithmic(true)
                    .text("Target TPS"),
            )
            .changed();
        if tps_changed && let Some(thread) = &mut app.sim_thread {
            thread.send(SimCommand::SetTargetTps(app.target_tps));
        }
    }

    if uncapped_changed && let Some(thread) = &mut app.sim_thread {
        thread.send(SimCommand::SetUncapped(app.uncapped));
    }

    let mut max = app.ticks_per_snapshot as i32;
    if ui
        .add(
            egui::Slider::new(&mut max, 1..=1000)
                .logarithmic(true)
                .text("Ticks/snapshot"),
        )
        .changed()
    {
        app.ticks_per_snapshot = max as u32;
        if let Some(thread) = &mut app.sim_thread {
            thread.send(SimCommand::SetTicksPerSnapshot(app.ticks_per_snapshot));
        }
    }

    let network = app.loaded_entry().is_some_and(|entry| entry.topology_hint().edges);
    if network {
        layout_controls(ui, app);
    }
}

fn layout_controls(ui: &mut egui::Ui, app: &mut AppState) {
    ui.separator();
    let mut changed = ui
        .checkbox(&mut app.layout_on, "Layout")
        .on_hover_text("Arrange network nodes with a spring layout when running")
        .changed();
    changed |= ui
        .add_enabled(
            app.layout_on,
            egui::Checkbox::new(&mut app.layout_while_paused, "Layout while paused"),
        )
        .on_hover_text("Keep arranging nodes while paused")
        .changed();
    changed |= ui
        .add_enabled(
            app.layout_on,
            egui::Slider::new(
                &mut app.layout_budget_ms,
                1.0..=LAYOUT_BUDGET_MAX_MS.min(MAX_VIEW_BUDGET_MS),
            )
            .text("Layout budget")
            .suffix(" ms"),
        )
        .on_hover_text("Maximum time for layout per snapshot")
        .changed();
    if changed {
        let command = app.layout_command();
        if let Some(thread) = &mut app.sim_thread {
            thread.send(command);
        }
    }
}

/// Draws the batching controls a GPU model shows instead of the CPU pacing controls.
fn gpu_batching_controls(ui: &mut egui::Ui, app: &mut AppState) {
    use henad_compute::gpu::timing::MAX_BATCH_SIZE;

    let stats = app
        .sim_thread
        .as_ref()
        .and_then(crate::sim_runner::SimRunner::gpu_stats)
        .unwrap_or_default();

    ui.label(match stats.gpu_us_per_step {
        Some(us) => format!("GPU time/step: {us:.2} \u{b5}s"),
        None => "GPU time/step: N/A".to_owned(),
    });

    if ui
        .checkbox(&mut app.gpu_adaptive, "Adaptive batching")
        .on_hover_text(
            "Automatically pick steps-per-batch to keep each GPU submission under the target \
             time below, instead of a fixed batch size, so large batches don't block egui's own \
             rendering on the shared queue.",
        )
        .changed()
    {
        let adaptive = app.gpu_adaptive;
        if let Some(gpu) = app.sim_thread.as_mut().and_then(SimRunner::as_gpu_mut) {
            gpu.set_adaptive(adaptive);
        }
    }

    if app.gpu_adaptive {
        // The disabled slider shows the controller's batch size, read from the stats every frame to track the GPU
        // cost.
        let mut live_batch_size = stats.batch_size;
        ui.add_enabled(
            false,
            egui::Slider::new(&mut live_batch_size, 1..=MAX_BATCH_SIZE).text("Steps per batch (live)"),
        );

        if ui
            .add(
                egui::Slider::new(&mut app.gpu_target_ms, 1.0..=16.0)
                    .text("Target ms/batch")
                    .fixed_decimals(1),
            )
            .changed()
        {
            let target_ms = app.gpu_target_ms;
            if let Some(gpu) = app.sim_thread.as_mut().and_then(SimRunner::as_gpu_mut) {
                gpu.set_target_ms(target_ms);
            }
        }
    } else if ui
        .add(egui::Slider::new(&mut app.gpu_batch_size, 1..=2000).text("Steps per batch"))
        .changed()
    {
        let batch_size = app.gpu_batch_size;
        if let Some(gpu) = app.sim_thread.as_mut().and_then(SimRunner::as_gpu_mut) {
            gpu.set_batch_size(batch_size);
        }
    }
}