quatopsy 0.3.0

Offline Quatopsy command-line interface.
use std::fs;
use std::path::PathBuf;
use std::process::Command;

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}

fn bin() -> PathBuf {
    env!("CARGO_BIN_EXE_quatopsy").into()
}

fn tempfile_dir() -> PathBuf {
    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
    let dir = std::env::temp_dir().join(format!(
        "quatopsy-control-{}-{}-{}",
        std::process::id(),
        COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    fs::create_dir_all(&dir).unwrap();
    dir
}

#[test]
fn control_then_analyze_keeps_verdict_ownership_in_the_kernel() {
    let root = workspace_root();
    let tmp = tempfile_dir();
    let controlled = tmp.join("controlled");
    let status = Command::new(bin())
        .args([
            "control",
            "--problem",
            root.join("fixtures/control/so3_rest_to_rest/problem.json")
                .to_str()
                .unwrap(),
            "--output-dir",
            controlled.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert!(status.success());
    let body = fs::read_to_string(controlled.join("control.json")).unwrap();
    assert!(!body.contains("\"result\""));
    assert!(body.contains("geometric-pd-so3"));
    assert!(body.contains("tracked-candidate"));
    assert!(body.contains("\"execution\":\"sil\""));
    assert!(body.contains("in-process"));
    let nav_body = fs::read_to_string(controlled.join("nav.json")).unwrap();
    assert!(!nav_body.contains("\"result\""));
    let guidance_body = fs::read_to_string(controlled.join("guidance.json")).unwrap();
    assert!(!guidance_body.contains("\"result\""));
    let report = tmp.join("report.json");
    let status = Command::new(bin())
        .args([
            "analyze",
            "--input",
            controlled.join("input.csv").to_str().unwrap(),
            "--manifest",
            controlled.join("manifest.json").to_str().unwrap(),
            "--report",
            report.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert_eq!(status.code(), Some(0));
    let report_body = fs::read_to_string(&report).unwrap();
    assert!(report_body.contains("\"result\":\"pass\""));
    assert!(report_body.contains("omega-consistent"));
}

#[test]
fn pil_then_analyze_keeps_verdict_ownership_in_the_kernel() {
    closed_loop_then_analyze(
        "fixtures/control/so3_rest_to_rest_pil/problem.json",
        "\"execution\":\"pil\"",
        "isolated-controller-process",
    );
}

#[test]
fn hil_then_analyze_keeps_verdict_ownership_in_the_kernel() {
    closed_loop_then_analyze(
        "fixtures/control/so3_rest_to_rest_hil/problem.json",
        "\"execution\":\"hil\"",
        "loopback-actuator-emulator",
    );
}

fn closed_loop_then_analyze(problem: &str, execution: &str, isolation: &str) {
    let root = workspace_root();
    let tmp = tempfile_dir();
    let controlled = tmp.join("controlled");
    let status = Command::new(bin())
        .args([
            "control",
            "--problem",
            root.join(problem).to_str().unwrap(),
            "--output-dir",
            controlled.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert!(status.success());
    let body = fs::read_to_string(controlled.join("control.json")).unwrap();
    assert!(!body.contains("\"result\""));
    assert!(body.contains("geometric-pd-so3"));
    assert!(body.contains("tracked-candidate"));
    assert!(body.contains(execution));
    assert!(body.contains(isolation));
    assert!(body.contains("loopback-emulator"));
    let report = tmp.join("report.json");
    let status = Command::new(bin())
        .args([
            "analyze",
            "--input",
            controlled.join("input.csv").to_str().unwrap(),
            "--manifest",
            controlled.join("manifest.json").to_str().unwrap(),
            "--report",
            report.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert_eq!(status.code(), Some(0));
    let report_body = fs::read_to_string(&report).unwrap();
    assert!(report_body.contains("\"result\":\"pass\""));
    assert!(report_body.contains("omega-consistent"));
}

#[test]
fn physical_hardware_does_not_create_output_directory() {
    let tmp = tempfile_dir();
    let problem = tmp.join("problem.json");
    fs::write(
        &problem,
        r#"{"schema":"quatopsy.control-problem/1","component_order":"wxyz","rotation_sense":"active","frame_from":"BODY","frame_to":"J2000","time_unit":"s","execution":"hil","latency_class":"bounded-software","q_initial":[1,0,0,0],"q_desired":[1,0,0,0],"omega_initial":[0,0,0],"inertia":{"model":"spherical","j":1.0},"torque_limit_nm":0.2,"cycle_dt_s":0.02,"duration_s":1.0,"max_estimate_age_s":0.05,"max_covariance_trace":1.0,"gains":{"kp":1.0,"kd":1.0},"hardware":{"class":"physical"}}"#,
    )
    .unwrap();
    let out = tmp.join("controlled");
    let status = Command::new(bin())
        .args([
            "control",
            "--problem",
            problem.to_str().unwrap(),
            "--output-dir",
            out.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert_eq!(status.code(), Some(2));
    assert!(!out.exists());
}

#[test]
fn declared_plant_hil_then_analyze_keeps_verdict_ownership() {
    closed_loop_then_analyze(
        "fixtures/control/so3_rest_to_rest_plant/problem.json",
        "\"execution\":\"hil\"",
        "loopback-actuator-emulator",
    );
}

#[test]
fn profile_track_then_analyze_keeps_verdict_ownership_in_the_kernel() {
    let root = workspace_root();
    let tmp = tempfile_dir();
    let controlled = tmp.join("controlled");
    let status = Command::new(bin())
        .args([
            "control",
            "--problem",
            root.join("fixtures/control/so3_profile_track/problem.json")
                .to_str()
                .unwrap(),
            "--output-dir",
            controlled.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert!(status.success());
    let body = fs::read_to_string(controlled.join("control.json")).unwrap();
    assert!(!body.contains("\"result\""));
    assert!(body.contains("geometric-pd-so3"));
    assert!(body.contains("tracked-candidate"));
    assert!(body.contains("sequential-deterministic"));
    let nav_body = fs::read_to_string(controlled.join("nav.json")).unwrap();
    assert!(!nav_body.contains("\"result\""));
    assert!(nav_body.contains("mekf"));
    let guidance_body = fs::read_to_string(controlled.join("guidance.json")).unwrap();
    assert!(!guidance_body.contains("\"result\""));
    let report = tmp.join("report.json");
    let status = Command::new(bin())
        .args([
            "analyze",
            "--input",
            controlled.join("input.csv").to_str().unwrap(),
            "--manifest",
            controlled.join("manifest.json").to_str().unwrap(),
            "--report",
            report.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert_eq!(status.code(), Some(0));
    let report_body = fs::read_to_string(&report).unwrap();
    assert!(report_body.contains("\"result\":\"pass\""));
    assert!(report_body.contains("omega-consistent"));
}