//! 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, pub vid_max: Option, pub pkg_watts: Option, pub vid_msr: Vec>, pub freq_khz: Vec>, } 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 { 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, 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(); } }