josie / alder-tools

//! Append-only CSV log of the vCore/V I D cross-check, for off-line analysis
//! (a separate session consumes `aldermon-vid.log`). The only file this
//! module writes is the log; sensor reads stay in `sensors.rs`.
//!
//! Columns: `ts_unix_ms, sio_vcore, vid_max, vid_delta, pkg_watts`, then one
//! triple per logical CPU `cpuN_vid, cpuN_freq_khz, cpuN_msr`. `vid_delta` is
//! delivered − requested (`sio_vcore − vid_max`); negative means the VRM
//! delivered less than the SVID setpoint asked for (droop), positive means
//! load-line calibration pushed it above. `cpuN_msr` is the raw
//! IA32_PERF_STATUS value so a consumer can re-derive the voltage field.

use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::sensors;

/// One poll's cross-check snapshot. Vectors are per logical CPU, indexed
/// from 0; missing/offline CPUs read as None.
pub struct Sample {
    pub sio_vcore: Option<f64>,
    pub vid_max: Option<f64>,
    pub pkg_watts: Option<f64>,
    pub vid_msr: Vec<Option<u64>>,
    pub freq_khz: Vec<Option<u64>>,
}

pub struct VidLogger {
    file: File,
    cpus: usize,
}

impl VidLogger {
    /// Open `path` for append, writing a header first if the file is new or
    /// empty. `cpus` fixes the per-CPU column count for the file's lifetime.
    pub fn open(path: &Path, cpus: usize) -> io::Result<VidLogger> {
        let fresh = fs::metadata(path).map(|m| m.len() == 0).unwrap_or(true);
        let mut file = OpenOptions::new().create(true).append(true).open(path)?;
        if fresh {
            writeln!(file, "{}", header(cpus))?;
        }
        Ok(VidLogger { file, cpus })
    }

    /// Append one row and flush so a reader (another session, or `tail -f`)
    /// sees it immediately even while the TUI keeps running.
    pub fn log(&mut self, s: &Sample) -> io::Result<()> {
        let ts_ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_millis())
            .unwrap_or(0);
        writeln!(self.file, "{}", row(ts_ms, s, self.cpus))?;
        self.file.flush()
    }
}

/// One CSV row (no trailing newline). `vid_delta` is delivered − requested;
/// blank when either side is unreadable. Offline CPUs leave their three
/// fields blank (not shifted) so columns stay aligned.
fn row(ts_ms: u128, s: &Sample, cpus: usize) -> String {
    let delta = match (s.sio_vcore, s.vid_max) {
        (Some(got), Some(ask)) => format!("{:.4}", got - ask),
        _ => String::new(),
    };
    let mut row = format!(
        "{ts_ms},{},{},{delta},{}",
        opt(s.sio_vcore, 3),
        opt(s.vid_max, 3),
        opt(s.pkg_watts, 2),
    );
    for cpu in 0..cpus {
        let msr = s.vid_msr.get(cpu).copied().flatten();
        let vid = msr.map(sensors::vid_from_msr);
        let freq = s.freq_khz.get(cpu).copied().flatten();
        row.push(',');
        row.push_str(&opt(vid, 3));
        row.push(',');
        // Frequencies are integer kHz — no decimal point.
        row.push_str(&freq.map(|f| f.to_string()).unwrap_or_default());
        row.push(',');
        row.push_str(&msr.map(|m| format!("0x{m:016x}")).unwrap_or_default());
    }
    row
}

fn opt(v: Option<f64>, decimals: usize) -> String {
    v.map(|v| format!("{v:.decimals$}")).unwrap_or_default()
}

fn header(cpus: usize) -> String {
    let mut h = String::from("ts_unix_ms,sio_vcore,vid_max,vid_delta,pkg_watts");
    for cpu in 0..cpus {
        h.push_str(&format!(",cpu{cpu}_vid,cpu{cpu}_freq_khz,cpu{cpu}_msr"));
    }
    h
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample() -> Sample {
        Sample {
            sio_vcore: Some(1.152),
            vid_max: Some(1.200),
            pkg_watts: Some(95.5),
            // cpu0 readable (VID field 0x2660 = 9824), cpu1 offline.
            vid_msr: vec![Some(0x0000_2660_0000_0000), None],
            freq_khz: vec![Some(4_500_000), None],
        }
    }

    #[test]
    fn header_has_one_triple_per_cpu() {
        let h = header(2);
        assert_eq!(
            h,
            "ts_unix_ms,sio_vcore,vid_max,vid_delta,pkg_watts,cpu0_vid,cpu0_freq_khz,cpu0_msr,cpu1_vid,cpu1_freq_khz,cpu1_msr"
        );
        assert_eq!(h.matches(',').count(), 4 + 3 * 2);
    }

    #[test]
    fn row_columns_align_and_delta_is_delivered_minus_requested() {
        let r = row(1_700_000_000_000, &sample(), 2);
        let cols: Vec<&str> = r.split(',').collect();
        assert_eq!(cols.len(), 5 + 3 * 2);
        assert_eq!(cols[1], "1.152");
        assert_eq!(cols[2], "1.200");
        assert_eq!(cols[3], "-0.0480"); // 1.152 − 1.200
        assert_eq!(cols[4], "95.50");
        // cpu0: vid 0x2660 = 9824 / 8192 = 1.199 V
        assert_eq!(cols[5], "1.199");
        assert_eq!(cols[6], "4500000");
        assert_eq!(cols[7], "0x0000266000000000");
        // cpu1 offline: three blanks, columns still present.
        assert_eq!(&cols[8..11], &["", "", ""]);
    }

    #[test]
    fn missing_values_leave_blanks_not_garbage() {
        let s = Sample {
            sio_vcore: None,
            vid_max: None,
            pkg_watts: None,
            vid_msr: vec![None],
            freq_khz: vec![None],
        };
        let r = row(0, &s, 1);
        let cols: Vec<&str> = r.split(',').collect();
        assert_eq!(cols, ["0", "", "", "", "", "", "", ""]);
    }

    #[test]
    fn writes_header_once_then_appends() {
        let dir = std::env::temp_dir().join(format!("aldermon-log-test-{}", std::process::id()));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        let path = dir.join("vid.log");
        {
            let mut l = VidLogger::open(&path, 2).unwrap();
            l.log(&sample()).unwrap();
        }
        {
            let mut l = VidLogger::open(&path, 2).unwrap();
            l.log(&sample()).unwrap();
        }
        let text = fs::read_to_string(&path).unwrap();
        let lines: Vec<&str> = text.lines().collect();
        assert_eq!(lines.len(), 3); // header + 2 rows
        assert!(lines[0].starts_with("ts_unix_ms,"));
        assert!(!lines[1].starts_with("ts")); // header only once
        assert_eq!(lines[0].matches(',').count(), lines[1].matches(',').count());
        assert_eq!(lines[1], lines[2]); // same sample logged twice identically
        fs::remove_dir_all(&dir).unwrap();
    }
}