use std::path::Path;
use serde_json::{Value, json};
use taktwerk_record::{RunFile, Series};
use super::files::relative;
#[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),
})
}
#[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
}
#[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()
}
#[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()
};
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()));
}
}