dsfb-ddmf 0.1.2

Deterministic disturbance and residual-envelope Monte Carlo tooling built on top of DSFB
Documentation
use std::env;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use csv::Writer;
use dsfb_ddmf::monte_carlo::{
    run_monte_carlo, summarize_batch, trajectory_rows, MonteCarloConfig, DEFAULT_MONTE_CARLO_RUNS,
};

#[derive(Debug, Clone)]
struct CliConfig {
    runs: usize,
    steps: usize,
    seed: u64,
    rho: f64,
    beta: f64,
    epsilon_bound: f64,
    recovery_delta: f64,
}

impl Default for CliConfig {
    fn default() -> Self {
        let defaults = MonteCarloConfig::default();
        Self {
            runs: defaults.n_runs,
            steps: defaults.n_steps,
            seed: defaults.seed,
            rho: defaults.rho,
            beta: defaults.beta,
            epsilon_bound: defaults.epsilon_bound,
            recovery_delta: defaults.recovery_delta,
        }
    }
}

fn main() -> Result<(), Box<dyn Error>> {
    let cli = parse_args(env::args().skip(1))?;
    let output_dir = create_output_dir()?;
    let config = MonteCarloConfig {
        n_runs: cli.runs,
        n_steps: cli.steps,
        seed: cli.seed,
        rho: cli.rho,
        beta: cli.beta,
        epsilon_bound: cli.epsilon_bound,
        recovery_delta: cli.recovery_delta,
    };
    let batch = run_monte_carlo(&config);
    let summary = summarize_batch(&config, &batch);

    write_results_csv(&output_dir.join("results.csv"), &batch.records)?;
    write_trajectory_csv(
        &output_dir.join("single_run_impulse.csv"),
        &batch.example_impulse,
    )?;
    write_trajectory_csv(
        &output_dir.join("single_run_persistent.csv"),
        &batch.example_persistent,
    )?;
    fs::write(
        output_dir.join("summary.json"),
        serde_json::to_string_pretty(&summary)?,
    )?;

    println!("Output directory: {}", output_dir.display());
    Ok(())
}

fn parse_args<I>(args: I) -> Result<CliConfig, Box<dyn Error>>
where
    I: IntoIterator<Item = String>,
{
    let mut cli = CliConfig::default();
    let mut args = args.into_iter();

    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--runs" => cli.runs = parse_value(args.next(), "--runs")?,
            "--steps" => cli.steps = parse_value(args.next(), "--steps")?,
            "--seed" => cli.seed = parse_value(args.next(), "--seed")?,
            "--rho" => cli.rho = parse_value(args.next(), "--rho")?,
            "--beta" => cli.beta = parse_value(args.next(), "--beta")?,
            "--epsilon-bound" => cli.epsilon_bound = parse_value(args.next(), "--epsilon-bound")?,
            "--recovery-delta" => {
                cli.recovery_delta = parse_value(args.next(), "--recovery-delta")?
            }
            "--help" | "-h" => {
                print_help();
                std::process::exit(0);
            }
            other => {
                return Err(format!("unknown argument: {other}").into());
            }
        }
    }

    Ok(cli)
}

fn parse_value<T>(value: Option<String>, flag: &str) -> Result<T, Box<dyn Error>>
where
    T: std::str::FromStr,
    T::Err: Error + 'static,
{
    let raw = value.ok_or_else(|| format!("missing value for {flag}"))?;
    Ok(raw.parse()?)
}

fn print_help() {
    println!("Usage: cargo run --bin monte_carlo -- [OPTIONS]");
    println!("  --runs <usize>            default: {DEFAULT_MONTE_CARLO_RUNS} (x360)");
    println!("  --steps <usize>");
    println!("  --seed <u64>");
    println!("  --rho <f64>");
    println!("  --beta <f64>");
    println!("  --epsilon-bound <f64>");
    println!("  --recovery-delta <f64>");
}

fn create_output_dir() -> Result<PathBuf, Box<dyn Error>> {
    let output_root = repo_root().join("output-dsfb-ddmf");
    fs::create_dir_all(&output_root)?;

    let timestamp = timestamp_string()?;
    let output_dir = output_root.join(timestamp);
    fs::create_dir_all(&output_dir)?;
    Ok(output_dir)
}

fn repo_root() -> PathBuf {
    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    manifest_dir
        .parent()
        .and_then(|path| path.parent())
        .map(Path::to_path_buf)
        .unwrap_or(manifest_dir)
}

fn timestamp_string() -> Result<String, Box<dyn Error>> {
    let output = Command::new("date").arg("+%Y%m%d_%H%M%S").output()?;
    if !output.status.success() {
        return Err("date command failed while building output path".into());
    }

    let timestamp = String::from_utf8(output.stdout)?.trim().to_string();
    if timestamp.is_empty() {
        return Err("date command returned an empty timestamp".into());
    }

    Ok(timestamp)
}

fn write_results_csv<P: AsRef<Path>, T: serde::Serialize>(
    path: P,
    rows: &[T],
) -> Result<(), Box<dyn Error>> {
    let mut writer = Writer::from_path(path)?;
    for row in rows {
        writer.serialize(row)?;
    }
    writer.flush()?;
    Ok(())
}

fn write_trajectory_csv(
    path: &Path,
    result: &dsfb_ddmf::SimulationResult,
) -> Result<(), Box<dyn Error>> {
    let rows = trajectory_rows(result);
    write_results_csv(path, &rows)
}