//! 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();
}
}