taktwerk 0.8.0

Fixed-step model execution engine for Linux with its own OPC UA server
//! Run records and campaign results of the project folder, read with `taktwerk-record`.

use std::path::Path;

use serde_json::{Value, json};
use taktwerk_record::{RunFile, Series};

use super::files::relative;

/// One line per run below `root`: where, which project and recorder, when, how long.
#[must_use]
pub fn list(root: &Path) -> Vec<Value> {
    taktwerk_record::find_runs(root)
        .iter()
        .filter_map(|dir| {
            let run = taktwerk_record::read_run_file(dir).ok()?;
            Some(summary(root, dir, &run))
        })
        .collect()
}

fn summary(root: &Path, dir: &Path, run: &RunFile) -> Value {
    let m = &run.manifest;
    let rows = run.end.as_ref().map(|e| e.rows);
    json!({
        "path": relative(root, dir),
        "run_id": run.run_id,
        "project": m.project,
        "recorder": m.recorder,
        "source": run.source,
        "utc": run.start.utc,
        "end_utc": run.end.as_ref().map(|e| e.utc.clone()),
        "rows": rows,
        "duration_s": rows.map(|r| r.saturating_sub(1) as f64 * m.tick_s * f64::from(m.every)),
        "columns": m.columns.len(),
        "scenario": m.invocation.as_ref().and_then(|i| i.scenario.as_ref()).map(|s| s.path.clone()),
        "seed": m.invocation.as_ref().and_then(|i| i.seed),
    })
}

/// Rows of a series thinned to about `max_points`: each bucket of consecutive rows becomes two,
/// at its first and last time, holding each column's minimum and maximum, so peaks survive.
#[must_use]
pub fn decimate(s: Series, max_points: usize) -> Series {
    let n = s.t_engine.len();
    if n <= max_points || max_points < 4 {
        return s;
    }
    let bucket = n.div_ceil(max_points / 2);
    let mut out = Series {
        cycle: Vec::with_capacity(max_points),
        t_engine: Vec::with_capacity(max_points),
        columns: vec![Vec::with_capacity(max_points); s.columns.len()],
    };
    let mut start = 0;
    while start < n {
        let end = (start + bucket).min(n);
        for (row, pick) in [
            (start, f64::min as fn(f64, f64) -> f64),
            (end - 1, f64::max),
        ] {
            out.cycle.push(s.cycle[row]);
            out.t_engine.push(s.t_engine[row]);
            for (o, col) in out.columns.iter_mut().zip(&s.columns) {
                let v = col[start..end]
                    .iter()
                    .copied()
                    .filter(|x| !x.is_nan())
                    .reduce(pick)
                    .unwrap_or(f64::NAN);
                o.push(v);
            }
        }
        start = end;
    }
    out
}

/// The campaign result directories below `root`, each with its runs and their status.
#[must_use]
pub fn campaigns(root: &Path) -> Vec<Value> {
    let mut found = Vec::new();
    let mut stack = vec![(root.to_owned(), 0)];
    while let Some((dir, depth)) = stack.pop() {
        if dir.join("campaign.json").is_file() {
            if let Some(c) = campaign(root, &dir) {
                found.push(c);
            }
            continue;
        }
        if depth >= 6 || dir.join("run.json").is_file() {
            continue;
        }
        let Ok(entries) = std::fs::read_dir(&dir) else {
            continue;
        };
        for e in entries.flatten() {
            let name = e.file_name();
            let name = name.to_string_lossy();
            if e.file_type().is_ok_and(|t| t.is_dir())
                && !name.starts_with('.')
                && !["target", "node_modules"].contains(&name.as_ref())
            {
                stack.push((e.path(), depth + 1));
            }
        }
    }
    found.sort_by(|a, b| a["path"].as_str().cmp(&b["path"].as_str()));
    found
}

fn read_json(path: &Path) -> Option<Value> {
    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
}

/// A campaign result directory: its plan and, per run, the status and the records.
#[must_use]
pub fn campaign(root: &Path, dir: &Path) -> Option<Value> {
    let manifest = read_json(&dir.join("campaign.json"))?;
    let runs: Vec<Value> = manifest["runs"]
        .as_array()
        .map(|runs| {
            runs.iter()
                .map(|r| {
                    let name = r["name"].as_str().unwrap_or_default();
                    let run_dir = dir.join("runs").join(name);
                    let records: Vec<String> = taktwerk_record::find_runs(&run_dir)
                        .iter()
                        .map(|d| relative(root, d))
                        .collect();
                    json!({
                        "name": name,
                        "case": r["case"],
                        "grid": r["grid"],
                        "sample": r["sample"],
                        "replicate": r["replicate"],
                        "seed": r["seed"],
                        "values": r["values"],
                        "status": read_json(&run_dir.join("status.json")),
                        "records": records,
                    })
                })
                .collect()
        })
        .unwrap_or_default();
    let count = |s: &str| {
        runs.iter()
            .filter(|r| r["status"]["status"].as_str() == Some(s))
            .count()
    };
    // Absolute in campaign.json; inside the folder, shown relative to it.
    let inside = |v: &Value| match v.as_str() {
        Some(p) if Path::new(p).starts_with(root) => Value::from(relative(root, Path::new(p))),
        _ => v.clone(),
    };
    Some(json!({
        "path": relative(root, dir),
        "campaign": inside(&manifest["campaign"]),
        "project": inside(&manifest["project"]),
        "taktwerk": manifest["taktwerk"],
        "speed": manifest["speed"],
        "seed": manifest["seed"],
        "total": runs.len(),
        "ok": count("ok"),
        "failed": count("failed"),
        "runs": runs,
    }))
}

#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
    use super::*;

    #[test]
    fn decimation_keeps_the_envelope() {
        let n = 1000u32;
        let s = Series {
            cycle: (0..u64::from(n)).collect(),
            t_engine: (0..n).map(f64::from).collect(),
            columns: vec![
                (0..n)
                    .map(|i| if i == 501 { 9.0 } else { f64::from(i % 7) })
                    .collect(),
            ],
        };
        let d = decimate(s.clone(), 100);
        assert_eq!(d.t_engine.len(), 100);
        assert_eq!(d.t_engine[0], 0.0);
        assert_eq!(*d.t_engine.last().unwrap(), 999.0);
        let max = d.columns[0].iter().copied().fold(f64::MIN, f64::max);
        assert_eq!(max, 9.0, "the peak survives");
        assert!(d.columns[0].contains(&0.0));
        assert_eq!(decimate(s.clone(), 2000), s, "short series are kept");
    }

    #[test]
    fn nan_only_buckets_stay_nan() {
        let s = Series {
            cycle: (0..10).collect(),
            t_engine: (0..10).map(f64::from).collect(),
            columns: vec![vec![f64::NAN; 10]],
        };
        let d = decimate(s, 4);
        assert!(d.columns[0].iter().all(|x| x.is_nan()));
    }
}