git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commit59a5d95597625d4258629cead742b78b00be7750
parent9f656d4a7e
authorLucas Galante <[email protected]>
date2026-09-18 00:28
processes: estimated watts and wakeups per process

Nothing on the host reports watts per process, so this is attribution,
not measurement. power_meter samples the battery draw (discharging only),
the RAPL domains when their counters are readable, the NVIDIA draw only
while the card is awake — nvidia-smi would wake a suspended card to report
a zero — and per pid: CPU ticks, i915 engine time from fdinfo (deduped by
drm-client-id), context switches, and whether it holds a /dev/nvidia fd.

Each source's reading above its own 60s rolling minimum is the budget it
hands out — the floor is screen, radios and idle silicon, owned by no
process — split by each pid's share of the activity that source responds
to: one budget by CPU+iGPU time in battery-only mode, a budget per RAPL
domain when core/uncore are readable. Per-pid figures are smoothed over
~10s. The page shows a W column and a WAKE/s column, both sortable, and a
line above the list stating total, baseline, attributed, and how.

Battery-only is what this host lands in; RAPL needs energy_uj opened to
0444 (root-only by kernel default), left as an opt-in tmpfiles rule.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/lib.rs             |   1 +
 src/pages/power.rs     |   2 +-
 src/pages/processes.rs | 215 ++++++++++++++++-
 src/power_meter.rs     | 642 +++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 849 insertions(+), 11 deletions(-)

diff --git a/src/lib.rs b/src/lib.rs
index 1e188a8..7fa8e5c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,6 @@
 pub mod app;
 pub mod pages;
+pub mod power_meter;
 pub use cce_ui::widget::scroll_region;
 pub mod widgets;
 pub mod watchers;
diff --git a/src/pages/power.rs b/src/pages/power.rs
index fd4d2ee..e0b51e3 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -216,7 +216,7 @@ fn drm_card_with_freq() -> Option<std::path::PathBuf> {
 }
 
 /// The first battery under /sys/class/power_supply, by convention BAT*.
-fn battery_dir() -> Option<std::path::PathBuf> {
+pub(crate) fn battery_dir() -> Option<std::path::PathBuf> {
     let rd = std::fs::read_dir("/sys/class/power_supply").ok()?;
     let mut bats: Vec<_> = rd
         .flatten()
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 0a4a6a2..8ae1b8d 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -1,6 +1,8 @@
 use crate::app::{AppAction, PageContent};
+use crate::power_meter::{self, Meter, Mode};
 use cce_ui::widget::ScrollRegion;
 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
+use std::sync::Mutex;
 
 #[derive(Debug, Clone)]
 pub struct ProcessRow {
@@ -9,8 +11,33 @@ pub struct ProcessRow {
     pub mem_pct: String,
     pub rss_kb: u64,
     pub command: String,
+    /// Estimated draw from [`power_meter`]; None until the meter has two
+    /// samples of this pid, or when nothing on the host reports watts.
+    pub watts: Option<f32>,
+    /// Context switches per second over the last interval — the honest
+    /// signal for a process that burns power while looking idle.
+    pub wakeups: Option<f32>,
 }
 
+/// The whole-machine side of the estimate, for the line above the list.
+#[derive(Debug, Clone, PartialEq)]
+pub struct PowerSummary {
+    pub mode: Mode,
+    pub total_w: Option<f32>,
+    pub floor_w: Option<f32>,
+    pub attributed_w: f32,
+}
+
+impl Default for PowerSummary {
+    fn default() -> Self {
+        Self { mode: Mode::Warming, total_w: None, floor_w: None, attributed_w: 0.0 }
+    }
+}
+
+/// One meter for the page's lifetime: attribution is a delta between
+/// consecutive fetches, so the previous snapshot has to outlive the fetch.
+static METER: Mutex<Option<Meter>> = Mutex::new(None);
+
 /// Which column orders the list. Cpu is the default (ps sorts the fetch);
 /// Mem is the toggle — clicking a memory header switches to it, clicking
 /// again switches back.
@@ -19,6 +46,8 @@ pub enum ProcSort {
     #[default]
     Cpu,
     Mem,
+    Power,
+    Wakeups,
 }
 
 #[derive(Debug, Clone)]
@@ -33,6 +62,7 @@ pub struct ProcessesState {
     /// Active sort column. Survives refreshes: Refreshed re-sorts the fresh
     /// list under this key rather than resetting to the fetch order.
     pub sort: ProcSort,
+    pub power: PowerSummary,
 }
 
 impl Default for ProcessesState {
@@ -43,6 +73,7 @@ impl Default for ProcessesState {
             cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
             killing: std::collections::HashSet::new(),
             sort: ProcSort::Cpu,
+            power: PowerSummary::default(),
         }
     }
 }
@@ -68,9 +99,72 @@ fn sort_rows(rows: &mut [ProcessRow], sort: ProcSort) {
             bv.partial_cmp(&av).unwrap_or(std::cmp::Ordering::Equal)
         }),
         ProcSort::Mem => rows.sort_by(|a, b| b.rss_kb.cmp(&a.rss_kb)),
+        // Unknowns sink below every known figure, however small.
+        ProcSort::Power => rows.sort_by(|a, b| {
+            b.watts.unwrap_or(-1.0).partial_cmp(&a.watts.unwrap_or(-1.0)).unwrap_or(std::cmp::Ordering::Equal)
+        }),
+        ProcSort::Wakeups => rows.sort_by(|a, b| {
+            b.wakeups.unwrap_or(-1.0).partial_cmp(&a.wakeups.unwrap_or(-1.0)).unwrap_or(std::cmp::Ordering::Equal)
+        }),
+    }
+}
+
+/// Watts to one decimal; a dash below 0.05 W, so hundreds of idle rows do
+/// not read as fake precision, and for rows the meter has no figure for.
+pub fn format_watts(w: Option<f32>) -> String {
+    match w {
+        Some(w) if w >= 0.05 => format!("{:.1}", w),
+        _ => "\u{2014}".to_string(),
+    }
+}
+
+pub fn format_wakeups(w: Option<f32>) -> String {
+    match w {
+        Some(w) if w >= 0.5 => format!("{:.0}", w),
+        _ => "\u{2014}".to_string(),
     }
 }
 
+/// The line above the list: what was measured, what is baseline, what the
+/// column adds up to — and, when the column is dashes, why.
+pub fn summary_line(p: &PowerSummary) -> String {
+    let how = match p.mode {
+        Mode::Warming => return "Measuring power\u{2026}".to_string(),
+        Mode::Unavailable => {
+            return "Per-process power needs the battery discharging or readable RAPL counters  \u{00b7}  wakeups only"
+                .to_string()
+        }
+        Mode::Battery => "estimated from battery draw",
+        Mode::Rapl => "RAPL",
+    };
+    let mut parts = Vec::new();
+    if let Some(t) = p.total_w {
+        parts.push(format!("{:.1} W total", t));
+    }
+    if let Some(f) = p.floor_w {
+        parts.push(format!("{:.1} W baseline", f));
+    }
+    parts.push(format!("{:.1} W attributed to processes ({})", p.attributed_w, how));
+    parts.join("  \u{00b7}  ")
+}
+
+/// nvidia-smi's draw figure, asked for only while the card is awake: the
+/// query itself would wake a suspended card, costing watts to report a zero.
+async fn dgpu_draw_w() -> Option<f64> {
+    if !power_meter::dgpu_awake() {
+        return None;
+    }
+    let out = tokio::process::Command::new("nvidia-smi")
+        .args(["--query-gpu=power.draw", "--format=csv,noheader,nounits"])
+        .output()
+        .await
+        .ok()?;
+    if !out.status.success() {
+        return None;
+    }
+    String::from_utf8_lossy(&out.stdout).lines().next()?.trim().parse().ok()
+}
+
 /// Process name from a `ps … cmd` field: basename of argv[0], so cce binaries
 /// longer than the kernel's 15-char `comm` cap display whole (`comm` showed
 /// "cce-system-inte"). Kernel threads (`[kworker/0:1]`) keep their brackets.
@@ -98,6 +192,18 @@ pub fn format_rss(kb: u64) -> String {
 }
 
 pub async fn fetch_processes_state() -> ProcessesState {
+    let dgpu_w = dgpu_draw_w().await;
+    let attribution = tokio::task::spawn_blocking(move || {
+        let snap = power_meter::sample(dgpu_w);
+        METER.lock().unwrap().get_or_insert_with(Meter::new).tick(snap)
+    })
+    .await
+    .ok();
+    let power = attribution
+        .as_ref()
+        .map(|a| PowerSummary { mode: a.mode, total_w: a.total_w, floor_w: a.floor_w, attributed_w: a.attributed_w })
+        .unwrap_or_default();
+
     let processes = {
         let mut list = Vec::new();
         if let Some(o) = tokio::process::Command::new("ps")
@@ -108,12 +214,18 @@ pub async fn fetch_processes_state() -> ProcessesState {
             for line in text.lines().skip(1) {
                 let parts: Vec<&str> = line.split_whitespace().collect();
                 if parts.len() >= 5 {
+                    let pp = parts[0]
+                        .parse::<u32>()
+                        .ok()
+                        .and_then(|pid| attribution.as_ref().and_then(|a| a.per_pid.get(&pid)).copied());
                     list.push(ProcessRow {
                         pid: parts[0].to_string(),
                         cpu: parts[1].to_string(),
                         mem_pct: parts[2].to_string(),
                         rss_kb: parts[3].parse().unwrap_or(0),
                         command: command_display(&parts[4..].join(" ")),
+                        watts: pp.and_then(|p| p.watts),
+                        wakeups: pp.map(|p| p.wakeups_per_s),
                     });
                 }
             }
@@ -127,6 +239,7 @@ pub async fn fetch_processes_state() -> ProcessesState {
         cpu_list: ScrollRegion::new(24.0, 2.0).with_frame(false),
         killing: std::collections::HashSet::new(),
         sort: ProcSort::Cpu,
+        power,
     }
 }
 
@@ -148,8 +261,11 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
             // between the list and the well's walls on all four sides.
             let inset = 12.0;
             let list_box_x = rx + inset;
-            let list_box_y = sec.well_top() + inset;
             let list_box_w = sec.cw - 2.0 * inset;
+            // Power summary line above the list; the list starts below it.
+            let summary_h = 18.0;
+            sec.pc.text(&summary_line(&state.power), list_box_x, sec.well_top() + inset + 2.0, 11.0, TEXT_DIM);
+            let list_box_y = sec.well_top() + inset + summary_h;
             // Fill the page: the well's bottom wall lands at the page bottom,
             // the list keeps its even inset inside the well.
             let list_box_h = ((cy + ch) - inset - list_box_y).max(120.0);
@@ -161,11 +277,13 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
             // subtracts scroll_x. CONTENT_W > box width = the h-bar appears.
             const COL_PID: f32 = 12.0;
             const COL_COMMAND: f32 = 80.0;
-            const COL_RSS: f32 = 420.0;
-            const COL_MEM: f32 = 510.0;
-            const COL_CPU: f32 = 580.0;
-            const COL_KILL: f32 = 624.0;
-            const CONTENT_W: f32 = 650.0;
+            const COL_RSS: f32 = 400.0;
+            const COL_MEM: f32 = 480.0;
+            const COL_CPU: f32 = 545.0;
+            const COL_WATTS: f32 = 605.0;
+            const COL_WAKE: f32 = 655.0;
+            const COL_KILL: f32 = 720.0;
+            const CONTENT_W: f32 = 745.0;
 
             let header_h = 22.0;
             state.cpu_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
@@ -197,6 +315,8 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
             sec.pc.text(&mark("MEM", active(ProcSort::Mem)), list_box_x + COL_RSS - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Mem)));
             sec.pc.text("MEM %", list_box_x + COL_MEM - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Mem)));
             sec.pc.text(&mark("CPU %", active(ProcSort::Cpu)), list_box_x + COL_CPU - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Cpu)));
+            sec.pc.text(&mark("W", active(ProcSort::Power)), list_box_x + COL_WATTS - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Power)));
+            sec.pc.text(&mark("WAKE/s", active(ProcSort::Wakeups)), list_box_x + COL_WAKE - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Wakeups)));
 
             // Invisible header hit targets (transparent, subtle hover), inside
             // the header clip so they pan and cut with the labels. They share
@@ -207,6 +327,10 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
                 [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
             sec.pc.button("", list_box_x + COL_CPU - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
                 [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Cpu)));
+            sec.pc.button("", list_box_x + COL_WATTS - ox - 4.0, list_box_y, 48.0, header_h - 2.0,
+                [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Power)));
+            sec.pc.button("", list_box_x + COL_WAKE - ox - 4.0, list_box_y, 62.0, header_h - 2.0,
+                [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Wakeups)));
             sec.pc.pop_clip_rect();
 
             let row_h = 24.0;
@@ -236,12 +360,15 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
                     let fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.80, 0.80, 0.85, 1.0] };
                     let mem_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.62, 0.72, 0.88, 1.0] };
                     let cpu_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.56, 0.83, 0.56, 1.0] };
+                    let watt_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.90, 0.75, 0.45, 1.0] };
 
                     sec.pc.text(&p.pid, list_box_x + COL_PID - ox, draw_y + 6.0, 12.0, fg);
                     sec.pc.text(&p.command, list_box_x + COL_COMMAND - ox, draw_y + 6.0, 12.0, fg);
                     sec.pc.text(&format_rss(p.rss_kb), list_box_x + COL_RSS - ox, draw_y + 6.0, 12.0, mem_fg);
                     sec.pc.text(&format!("{}%", p.mem_pct), list_box_x + COL_MEM - ox, draw_y + 6.0, 12.0, mem_fg);
                     sec.pc.text(&format!("{}%", p.cpu), list_box_x + COL_CPU - ox, draw_y + 6.0, 12.0, cpu_fg);
+                    sec.pc.text(&format_watts(p.watts), list_box_x + COL_WATTS - ox, draw_y + 6.0, 12.0, watt_fg);
+                    sec.pc.text(&format_wakeups(p.wakeups), list_box_x + COL_WAKE - ox, draw_y + 6.0, 12.0, fg);
 
                     // Kill button, in content space like the columns. Emitted
                     // AFTER the row button on purpose: overlapping page
@@ -283,6 +410,7 @@ pub fn update(state: &mut ProcessesState, msg: ProcessesMessage) {
         ProcessesMessage::Refreshed(new) => {
             state.loaded = new.loaded;
             state.processes = new.processes;
+            state.power = new.power;
             // The fetch arrives cpu-ordered; a non-default sort re-applies so
             // a refresh never silently flips the list back.
             if state.sort != ProcSort::Cpu {
@@ -293,8 +421,8 @@ pub fn update(state: &mut ProcessesState, msg: ProcessesMessage) {
             state.killing.clear();
         }
         ProcessesMessage::SortBy(key) => {
-            // Clicking the already-active Mem header toggles back to the Cpu
-            // default; clicking CPU % is always a plain select.
+            // Clicking the already-active header (Mem, Power, Wakeups)
+            // toggles back to the Cpu default; CPU % is always a plain select.
             state.sort = if state.sort == key { ProcSort::Cpu } else { key };
             sort_rows(&mut state.processes, state.sort);
         }
@@ -402,6 +530,8 @@ mod tests {
             mem_pct: "2.0".to_string(),
             rss_kb: 1024,
             command: "proc".to_string(),
+            watts: None,
+            wakeups: None,
         }
     }
 
@@ -465,6 +595,8 @@ mod tests {
             mem_pct: "0.0".to_string(),
             rss_kb: rss,
             command: "p".to_string(),
+            watts: None,
+            wakeups: None,
         }
     }
 
@@ -527,8 +659,8 @@ mod tests {
                 _ => None,
             })
             .collect();
-        // MEM + MEM % both toggle Mem; CPU % selects Cpu.
-        assert_eq!(sorts, [ProcSort::Mem, ProcSort::Mem, ProcSort::Cpu]);
+        // MEM + MEM % both toggle Mem; CPU % selects Cpu; then W and WAKE/s.
+        assert_eq!(sorts, [ProcSort::Mem, ProcSort::Mem, ProcSort::Cpu, ProcSort::Power, ProcSort::Wakeups]);
         // Active-sort indicator rides the CPU % header by default.
         assert!(pc.texts.iter().any(|t| t.0.starts_with("CPU %") && t.0.contains('\u{25bc}')));
     }
@@ -555,4 +687,67 @@ mod tests {
         assert_eq!(header_x(&pc1, "MEM %"), x0 - 40.0);
         assert_eq!(header_x(&pc1, "CPU %"), header_x(&pc0, "CPU %") - 40.0);
     }
+
+    #[test]
+    fn power_sort_puts_unknowns_last_and_toggles_back() {
+        let mut state = ProcessesState { loaded: true, ..Default::default() };
+        let mut a = sized_row("a", "9.0", 1);
+        let mut b = sized_row("b", "5.0", 1);
+        let c = sized_row("c", "1.0", 1);
+        a.watts = Some(0.2);
+        b.watts = Some(1.5);
+        state.processes = vec![a, b, c];
+        let order = |s: &ProcessesState| s.processes.iter().map(|p| p.pid.clone()).collect::<Vec<_>>();
+        update(&mut state, ProcessesMessage::SortBy(ProcSort::Power));
+        assert_eq!(order(&state), ["b", "a", "c"]);
+        update(&mut state, ProcessesMessage::SortBy(ProcSort::Power));
+        assert_eq!(state.sort, ProcSort::Cpu);
+        assert_eq!(order(&state), ["a", "b", "c"]);
+    }
+
+    #[test]
+    fn watts_and_wakeups_format_with_dashes_for_noise() {
+        assert_eq!(format_watts(None), "\u{2014}");
+        assert_eq!(format_watts(Some(0.04)), "\u{2014}");
+        assert_eq!(format_watts(Some(0.05)), "0.1");
+        assert_eq!(format_watts(Some(2.345)), "2.3");
+        assert_eq!(format_wakeups(None), "\u{2014}");
+        assert_eq!(format_wakeups(Some(0.2)), "\u{2014}");
+        assert_eq!(format_wakeups(Some(12.6)), "13");
+    }
+
+    #[test]
+    fn summary_line_states_mode_and_reason() {
+        let p = PowerSummary { mode: Mode::Battery, total_w: Some(16.0), floor_w: Some(10.0), attributed_w: 6.0 };
+        assert_eq!(
+            summary_line(&p),
+            "16.0 W total  \u{00b7}  10.0 W baseline  \u{00b7}  6.0 W attributed to processes (estimated from battery draw)"
+        );
+        assert!(summary_line(&PowerSummary::default()).starts_with("Measuring"));
+        let u = PowerSummary { mode: Mode::Unavailable, ..Default::default() };
+        assert!(summary_line(&u).contains("wakeups only"));
+    }
+
+    #[test]
+    fn refresh_carries_the_power_summary_and_rows_show_watts() {
+        let mut state = ProcessesState { loaded: true, ..Default::default() };
+        let mut r = sized_row("7", "1.0", 1);
+        r.watts = Some(1.26);
+        r.wakeups = Some(40.0);
+        let fresh = ProcessesState {
+            loaded: true,
+            processes: vec![r],
+            power: PowerSummary { mode: Mode::Battery, total_w: Some(20.0), floor_w: Some(15.0), attributed_w: 1.26 },
+            ..Default::default()
+        };
+        update(&mut state, ProcessesMessage::Refreshed(fresh));
+        assert_eq!(state.power.mode, Mode::Battery);
+        let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
+        let mut ctx = cce_ui::context::UiContext::new();
+        let pc = view(&mut state, 10.0, 20.0, 900.0, 600.0, false, &[false], &mut layout, &mut ctx);
+        let all: Vec<&str> = pc.texts.iter().map(|t| t.0.as_str()).collect();
+        assert!(pc.texts.iter().any(|t| t.0 == "1.3"), "{all:?}");
+        assert!(pc.texts.iter().any(|t| t.0 == "40"));
+        assert!(pc.texts.iter().any(|t| t.0.starts_with("20.0 W total")));
+    }
 }
diff --git a/src/power_meter.rs b/src/power_meter.rs
new file mode 100644
index 0000000..d05a456
--- /dev/null
+++ b/src/power_meter.rs
@@ -0,0 +1,642 @@
+//! Per-process power estimation for the Processes page.
+//!
+//! Nothing on a laptop reports watts per process, so this is an attribution
+//! model, not a measurement. What IS measured, per sample:
+//!
+//! - the whole-machine draw: `BAT0/power_now` while discharging, or the
+//!   RAPL `psys` domain when its counter is readable (it is root-only by
+//!   kernel default since the PLATYPUS fix, so on AC it usually is not);
+//! - per-domain CPU energy from RAPL `core` / `uncore` (uncore ≈ the iGPU on
+//!   Intel client parts), again only when readable;
+//! - the discrete GPU's draw from nvidia-smi, only while the card is awake;
+//! - per process: CPU ticks from `/proc/<pid>/stat`, iGPU engine busy time
+//!   from the i915 `fdinfo` of every `/dev/dri` fd it holds, context switches
+//!   from `status` (wakeups/s, powertop's idle metric), and whether it holds a
+//!   `/dev/nvidia*` fd.
+//!
+//! The split then works on *deltas* between two snapshots. Each measured
+//! source is reduced by its own rolling minimum over the last minute — the
+//! floor is the screen, radios and idle silicon, which no process owns — and
+//! only the part above the floor is handed out, in proportion to each pid's
+//! share of the activity that source responds to. Battery-only mode has one
+//! budget (total minus floor, minus whatever the dGPU took) split by CPU time
+//! with iGPU time folded in; RAPL mode has a budget per domain. Per-pid
+//! results are smoothed with a ~10s exponential average so the list does not
+//! reorder on every tick.
+//!
+//! Everything below [`Meter`] is pure and unit-tested on synthetic snapshots;
+//! [`sample`] is the only thing that touches the machine.
+
+use std::collections::{HashMap, VecDeque};
+use std::sync::OnceLock;
+use std::time::Instant;
+
+/// `/proc` CPU times are in USER_HZ ticks, which Linux fixes at 100 on every
+/// architecture whatever the kernel's own HZ is.
+pub const USER_HZ: f64 = 100.0;
+/// One nanosecond of iGPU engine time counted against one nanosecond of CPU
+/// core time in the battery-only split. A busy render engine and a busy core
+/// land in the same handful of watts on this class of part, so 1:1 is as
+/// honest as any other single figure; RAPL mode does not use it.
+pub const GPU_NS_WEIGHT: f64 = 1.0;
+/// How far back the rolling-minimum floor looks.
+pub const FLOOR_WINDOW_SECS: f64 = 60.0;
+/// Time constant of the per-pid smoothing.
+pub const SMOOTH_TAU_SECS: f64 = 10.0;
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct ProcSample {
+    /// utime + stime, USER_HZ ticks.
+    pub cpu_ticks: u64,
+    /// Summed i915 engine busy time across the pid's DRM clients.
+    pub gpu_ns: u64,
+    /// voluntary + nonvoluntary context switches.
+    pub ctxt_switches: u64,
+    /// Holds an open `/dev/nvidia*` fd.
+    pub dgpu: bool,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct RaplDomain {
+    /// sysfs `name`: `package-0`, `core`, `uncore`, `psys`, …
+    pub name: String,
+    pub energy_uj: u64,
+    /// `max_energy_range_uj` — the counter wraps at this.
+    pub range_uj: u64,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct Snapshot {
+    /// Seconds on an arbitrary monotonic clock.
+    pub t: f64,
+    /// Battery draw, only while discharging (the field is meaningless
+    /// otherwise, so the sampler leaves it None).
+    pub battery_w: Option<f64>,
+    pub rapl: Vec<RaplDomain>,
+    /// nvidia-smi power.draw, None while the card is runtime-suspended or
+    /// there is no driver.
+    pub dgpu_w: Option<f64>,
+    pub procs: HashMap<u32, ProcSample>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Mode {
+    /// One snapshot so far, or a zero-length interval: nothing to attribute yet.
+    Warming,
+    /// RAPL core/uncore readable: CPU and iGPU budgets are measured per domain.
+    Rapl,
+    /// Only the battery's total is known: one budget, split by activity.
+    Battery,
+    /// On AC without RAPL access, or no battery: wakeups only.
+    Unavailable,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct PidPower {
+    /// Smoothed estimate; None when the mode cannot produce one.
+    pub watts: Option<f32>,
+    pub wakeups_per_s: f32,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Attribution {
+    pub mode: Mode,
+    /// Whole-machine draw this interval, when any source reports it.
+    pub total_w: Option<f32>,
+    /// Rolling-minimum of `total_w`: the unattributable baseline.
+    pub floor_w: Option<f32>,
+    /// Sum of every pid's smoothed estimate.
+    pub attributed_w: f32,
+    pub per_pid: HashMap<u32, PidPower>,
+}
+
+impl Attribution {
+    fn empty(mode: Mode) -> Self {
+        Self { mode, total_w: None, floor_w: None, attributed_w: 0.0, per_pid: HashMap::new() }
+    }
+}
+
+/// Rolling minimum over a time window.
+#[derive(Debug, Default)]
+struct Floor {
+    samples: VecDeque<(f64, f64)>,
+}
+
+impl Floor {
+    /// Record `w` at time `t`; return the minimum over the window ending now.
+    fn push(&mut self, t: f64, w: f64) -> f64 {
+        self.samples.push_back((t, w));
+        while let Some(&(t0, _)) = self.samples.front() {
+            if t - t0 > FLOOR_WINDOW_SECS {
+                self.samples.pop_front();
+            } else {
+                break;
+            }
+        }
+        self.samples.iter().map(|&(_, w)| w).fold(f64::INFINITY, f64::min)
+    }
+}
+
+/// Holds the previous snapshot, the floors and the smoothing state between
+/// ticks. One per page; the fetch keeps it in a static.
+#[derive(Debug, Default)]
+pub struct Meter {
+    prev: Option<Snapshot>,
+    floors: HashMap<&'static str, Floor>,
+    ema: HashMap<u32, f64>,
+}
+
+/// Counter delta with wrap-around at `range` (0 = no wrap known).
+fn wrapped_delta(prev: u64, next: u64, range: u64) -> u64 {
+    if next >= prev {
+        next - prev
+    } else if range > 0 {
+        range - prev + next
+    } else {
+        0
+    }
+}
+
+impl Meter {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    fn floor(&mut self, key: &'static str, t: f64, w: f64) -> f64 {
+        self.floors.entry(key).or_default().push(t, w)
+    }
+
+    /// Budget a source hands out: its reading above its own rolling minimum.
+    fn budget(&mut self, key: &'static str, t: f64, w: Option<f64>) -> f64 {
+        match w {
+            Some(w) => (w - self.floor(key, t, w)).max(0.0),
+            None => 0.0,
+        }
+    }
+
+    pub fn tick(&mut self, snap: Snapshot) -> Attribution {
+        let Some(prev) = self.prev.replace(snap) else {
+            // Instantaneous sources seed their floors now, so the next
+            // tick's reading is measured against this one rather than
+            // against itself. RAPL is a counter and needs the interval.
+            let s = self.prev.as_ref().unwrap();
+            let (t, bat, dgpu) = (s.t, s.battery_w, s.dgpu_w);
+            if let Some(w) = bat {
+                self.floor("total", t, w);
+            }
+            if let Some(w) = dgpu {
+                self.floor("dgpu", t, w);
+            }
+            return Attribution::empty(Mode::Warming);
+        };
+        let snap = self.prev.as_ref().unwrap();
+        let dt = snap.t - prev.t;
+        if dt <= 0.0 {
+            return Attribution::empty(Mode::Warming);
+        }
+
+        // RAPL watts per domain over the interval.
+        let rapl_w = |name: &str| -> Option<f64> {
+            let a = prev.rapl.iter().find(|d| d.name == name)?;
+            let b = snap.rapl.iter().find(|d| d.name == name)?;
+            Some(wrapped_delta(a.energy_uj, b.energy_uj, b.range_uj) as f64 / 1e6 / dt)
+        };
+        let core = rapl_w("core");
+        let uncore = rapl_w("uncore");
+        let psys = rapl_w("psys");
+        let package = snap
+            .rapl
+            .iter()
+            .find(|d| d.name.starts_with("package"))
+            .and_then(|d| rapl_w(&d.name));
+
+        let total = snap.battery_w.or(psys).or(package);
+        let mode = if core.is_some() {
+            Mode::Rapl
+        } else if snap.battery_w.is_some() {
+            Mode::Battery
+        } else {
+            Mode::Unavailable
+        };
+
+        // Per-pid activity over the interval, pids present in both snapshots.
+        struct Delta {
+            cpu_ns: f64,
+            gpu_ns: f64,
+            wakeups: f64,
+            dgpu: bool,
+        }
+        let deltas: HashMap<u32, Delta> = snap
+            .procs
+            .iter()
+            .filter_map(|(pid, b)| {
+                let a = prev.procs.get(pid)?;
+                Some((
+                    *pid,
+                    Delta {
+                        cpu_ns: b.cpu_ticks.saturating_sub(a.cpu_ticks) as f64 * 1e9 / USER_HZ,
+                        gpu_ns: b.gpu_ns.saturating_sub(a.gpu_ns) as f64,
+                        wakeups: b.ctxt_switches.saturating_sub(a.ctxt_switches) as f64 / dt,
+                        dgpu: b.dgpu,
+                    },
+                ))
+            })
+            .collect();
+
+        let t = snap.t;
+        let snap_dgpu = snap.dgpu_w;
+        let floor = total.map(|w| self.floor("total", t, w));
+        let dgpu_budget = self.budget("dgpu", t, snap_dgpu);
+        let dgpu_holders = deltas.values().filter(|d| d.dgpu).count() as f64;
+
+        // Raw per-pid watts for this interval.
+        let mut raw: HashMap<u32, f64> = HashMap::new();
+        let share = |get: &dyn Fn(&Delta) -> f64| -> HashMap<u32, f64> {
+            let sum: f64 = deltas.values().map(|d| get(d)).sum();
+            if sum <= 0.0 {
+                return HashMap::new();
+            }
+            deltas.iter().map(|(pid, d)| (*pid, get(d) / sum)).collect()
+        };
+        match mode {
+            Mode::Rapl => {
+                let cpu_budget = self.budget("core", t, core);
+                let gpu_budget = self.budget("uncore", t, uncore);
+                for (pid, s) in share(&|d| d.cpu_ns) {
+                    *raw.entry(pid).or_default() += s * cpu_budget;
+                }
+                for (pid, s) in share(&|d| d.gpu_ns) {
+                    *raw.entry(pid).or_default() += s * gpu_budget;
+                }
+            }
+            Mode::Battery => {
+                let dynamic = (total.unwrap() - floor.unwrap()).max(0.0);
+                let budget = (dynamic - dgpu_budget).max(0.0);
+                for (pid, s) in share(&|d| d.cpu_ns + GPU_NS_WEIGHT * d.gpu_ns) {
+                    *raw.entry(pid).or_default() += s * budget;
+                }
+            }
+            Mode::Warming | Mode::Unavailable => {}
+        }
+        if mode != Mode::Unavailable && dgpu_holders > 0.0 {
+            for (pid, d) in &deltas {
+                if d.dgpu {
+                    *raw.entry(*pid).or_default() += dgpu_budget / dgpu_holders;
+                }
+            }
+        }
+
+        // Smooth, and forget pids that are gone.
+        let alpha = 1.0 - (-dt / SMOOTH_TAU_SECS).exp();
+        self.ema.retain(|pid, _| deltas.contains_key(pid));
+        let mut per_pid = HashMap::with_capacity(deltas.len());
+        let mut attributed = 0.0;
+        for (pid, d) in &deltas {
+            let watts = if mode == Mode::Unavailable {
+                None
+            } else {
+                let r = raw.get(pid).copied().unwrap_or(0.0);
+                let e = match self.ema.get(pid) {
+                    Some(&e) => e + alpha * (r - e),
+                    None => r,
+                };
+                self.ema.insert(*pid, e);
+                attributed += e;
+                Some(e as f32)
+            };
+            per_pid.insert(*pid, PidPower { watts, wakeups_per_s: d.wakeups as f32 });
+        }
+
+        Attribution {
+            mode,
+            total_w: total.map(|w| w as f32),
+            floor_w: floor.map(|w| w as f32),
+            attributed_w: attributed as f32,
+            per_pid,
+        }
+    }
+}
+
+// ── The machine-facing half ──
+
+fn read_trim(path: &std::path::Path) -> Option<String> {
+    std::fs::read_to_string(path).ok().map(|s| s.trim().to_string())
+}
+
+fn clock_secs() -> f64 {
+    static START: OnceLock<Instant> = OnceLock::new();
+    START.get_or_init(Instant::now).elapsed().as_secs_f64()
+}
+
+/// utime + stime from a `/proc/<pid>/stat` line. The comm field can contain
+/// spaces and parens, so fields are counted from the LAST `)`.
+pub fn parse_stat_ticks(stat: &str) -> Option<u64> {
+    let rest = &stat[stat.rfind(')')? + 1..];
+    let f: Vec<&str> = rest.split_whitespace().collect();
+    // After ')': state ppid pgrp session tty tpgid flags minflt cminflt
+    // majflt cmajflt utime stime …
+    Some(f.get(11)?.parse::<u64>().ok()? + f.get(12)?.parse::<u64>().ok()?)
+}
+
+/// voluntary + nonvoluntary context switches from `/proc/<pid>/status`.
+pub fn parse_status_switches(status: &str) -> u64 {
+    status
+        .lines()
+        .filter_map(|l| {
+            let (k, v) = l.split_once(':')?;
+            if k.ends_with("ctxt_switches") { v.trim().parse::<u64>().ok() } else { None }
+        })
+        .sum()
+}
+
+/// (client id, summed engine busy ns) from one DRM fd's fdinfo. Capacity
+/// lines are counts, not times, and are skipped.
+pub fn parse_fdinfo_engines(fdinfo: &str) -> Option<(u64, u64)> {
+    let mut client = None;
+    let mut ns = 0u64;
+    for l in fdinfo.lines() {
+        let Some((k, v)) = l.split_once(':') else { continue };
+        let v = v.trim();
+        if k == "drm-client-id" {
+            client = v.parse().ok();
+        } else if k.starts_with("drm-engine-") && !k.starts_with("drm-engine-capacity") {
+            ns += v.split_whitespace().next().and_then(|n| n.parse::<u64>().ok()).unwrap_or(0);
+        }
+    }
+    client.map(|c| (c, ns))
+}
+
+fn sample_proc(dir: &std::path::Path) -> Option<ProcSample> {
+    let cpu_ticks = parse_stat_ticks(&std::fs::read_to_string(dir.join("stat")).ok()?)?;
+    let ctxt_switches =
+        std::fs::read_to_string(dir.join("status")).map(|s| parse_status_switches(&s)).unwrap_or(0);
+
+    // fd links are only readable for our own processes; others simply
+    // contribute no GPU time.
+    let mut clients: HashMap<u64, u64> = HashMap::new();
+    let mut dgpu = false;
+    if let Ok(fds) = std::fs::read_dir(dir.join("fd")) {
+        for fd in fds.flatten() {
+            let Ok(target) = std::fs::read_link(fd.path()) else { continue };
+            let Some(target) = target.to_str() else { continue };
+            if target.starts_with("/dev/nvidia") {
+                dgpu = true;
+            } else if target.starts_with("/dev/dri/") {
+                let info = dir.join("fdinfo").join(fd.file_name());
+                if let Some((client, ns)) =
+                    std::fs::read_to_string(info).ok().and_then(|s| parse_fdinfo_engines(&s))
+                {
+                    // Several fds can share one client; count it once.
+                    clients.insert(client, ns);
+                }
+            }
+        }
+    }
+    Some(ProcSample { cpu_ticks, gpu_ns: clients.values().sum(), ctxt_switches, dgpu })
+}
+
+fn sample_rapl() -> Vec<RaplDomain> {
+    let mut out = Vec::new();
+    let Ok(entries) = std::fs::read_dir("/sys/class/powercap") else { return out };
+    for e in entries.flatten() {
+        let p = e.path();
+        if !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("intel-rapl:")) {
+            continue;
+        }
+        let (Some(name), Some(energy)) = (read_trim(&p.join("name")), read_trim(&p.join("energy_uj")))
+        else {
+            continue;
+        };
+        let Ok(energy_uj) = energy.parse() else { continue };
+        let range_uj = read_trim(&p.join("max_energy_range_uj")).and_then(|s| s.parse().ok()).unwrap_or(0);
+        out.push(RaplDomain { name, energy_uj, range_uj });
+    }
+    out
+}
+
+fn sample_battery_w() -> Option<f64> {
+    let dir = crate::pages::power::battery_dir()?;
+    if read_trim(&dir.join("status"))? != "Discharging" {
+        return None;
+    }
+    read_trim(&dir.join("power_now"))?.parse::<f64>().ok().map(|uw| uw / 1e6)
+}
+
+/// Whether the NVIDIA card is awake. Polling nvidia-smi wakes a suspended
+/// card — a few watts, every tick, for a number that would be zero — so the
+/// caller only queries the draw when this says active.
+pub fn dgpu_awake() -> bool {
+    let Ok(devs) = std::fs::read_dir("/sys/bus/pci/drivers/nvidia") else { return false };
+    devs.flatten()
+        .any(|d| read_trim(&d.path().join("power/runtime_status")).as_deref() == Some("active"))
+}
+
+/// One pass over the machine. `dgpu_w` is passed in because it comes from an
+/// async nvidia-smi call the caller owns.
+pub fn sample(dgpu_w: Option<f64>) -> Snapshot {
+    let mut procs = HashMap::new();
+    if let Ok(entries) = std::fs::read_dir("/proc") {
+        for e in entries.flatten() {
+            let Some(pid) = e.file_name().to_str().and_then(|n| n.parse::<u32>().ok()) else { continue };
+            if let Some(s) = sample_proc(&e.path()) {
+                procs.insert(pid, s);
+            }
+        }
+    }
+    Snapshot { t: clock_secs(), battery_w: sample_battery_w(), rapl: sample_rapl(), dgpu_w, procs }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn proc(cpu_ticks: u64, gpu_ns: u64, ctxt: u64) -> ProcSample {
+        ProcSample { cpu_ticks, gpu_ns, ctxt_switches: ctxt, dgpu: false }
+    }
+
+    fn snap(t: f64, battery_w: Option<f64>, procs: &[(u32, ProcSample)]) -> Snapshot {
+        Snapshot { t, battery_w, rapl: Vec::new(), dgpu_w: None, procs: procs.iter().cloned().collect() }
+    }
+
+    fn w(a: &Attribution, pid: u32) -> f32 {
+        a.per_pid[&pid].watts.unwrap()
+    }
+
+    #[test]
+    fn first_tick_is_warming() {
+        let mut m = Meter::new();
+        let a = m.tick(snap(0.0, Some(20.0), &[(1, proc(0, 0, 0))]));
+        assert_eq!(a.mode, Mode::Warming);
+        assert!(a.per_pid.is_empty());
+    }
+
+    #[test]
+    fn battery_mode_splits_dynamic_by_cpu_share() {
+        let mut m = Meter::new();
+        m.tick(snap(0.0, Some(10.0), &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]));
+        // 10 W floor (the minimum seen), 16 W now: 6 W to split 3:1.
+        let a = m.tick(snap(3.0, Some(16.0), &[(1, proc(300, 0, 0)), (2, proc(100, 0, 0))]));
+        assert_eq!(a.mode, Mode::Battery);
+        assert_eq!(a.total_w, Some(16.0));
+        assert_eq!(a.floor_w, Some(10.0));
+        assert!((w(&a, 1) - 4.5).abs() < 1e-4);
+        assert!((w(&a, 2) - 1.5).abs() < 1e-4);
+        assert!((a.attributed_w - 6.0).abs() < 1e-4);
+    }
+
+    #[test]
+    fn igpu_time_counts_in_battery_mode() {
+        let mut m = Meter::new();
+        m.tick(snap(0.0, Some(10.0), &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]));
+        // pid 1: 1 s of CPU (100 ticks). pid 2: 1 s of render engine. Equal.
+        let a = m.tick(snap(1.0, Some(12.0), &[(1, proc(100, 0, 0)), (2, proc(0, 1_000_000_000, 0))]));
+        assert!((w(&a, 1) - w(&a, 2)).abs() < 1e-4);
+    }
+
+    #[test]
+    fn floor_is_the_rolling_minimum_and_never_goes_negative() {
+        let mut m = Meter::new();
+        m.tick(snap(0.0, Some(15.0), &[(1, proc(0, 0, 0))]));
+        let a = m.tick(snap(3.0, Some(12.0), &[(1, proc(50, 0, 0))]));
+        // The new low IS the floor: nothing above it to attribute.
+        assert_eq!(a.floor_w, Some(12.0));
+        assert_eq!(w(&a, 1), 0.0);
+        // 59 s after the 12 W low it is still in the window: 14 W now is
+        // 2 W above it, and that goes to the one busy pid.
+        let a = m.tick(snap(62.0, Some(14.0), &[(1, proc(100, 0, 0))]));
+        assert_eq!(a.floor_w, Some(12.0));
+        assert!(w(&a, 1) > 0.0);
+        // Once the low has aged out, the floor rises to what is left.
+        let a = m.tick(snap(70.0, Some(14.0), &[(1, proc(200, 0, 0))]));
+        assert_eq!(a.floor_w, Some(14.0));
+    }
+
+    #[test]
+    fn wakeups_are_switches_per_second_even_when_unavailable() {
+        let mut m = Meter::new();
+        m.tick(snap(0.0, None, &[(1, proc(0, 0, 100))]));
+        let a = m.tick(snap(2.0, None, &[(1, proc(0, 0, 160))]));
+        assert_eq!(a.mode, Mode::Unavailable);
+        assert_eq!(a.per_pid[&1].watts, None);
+        assert_eq!(a.per_pid[&1].wakeups_per_s, 30.0);
+    }
+
+    #[test]
+    fn exited_pids_drop_out_and_new_ones_wait_a_tick() {
+        let mut m = Meter::new();
+        m.tick(snap(0.0, Some(10.0), &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]));
+        m.tick(snap(3.0, Some(12.0), &[(1, proc(100, 0, 0)), (2, proc(100, 0, 0))]));
+        let a = m.tick(snap(6.0, Some(12.0), &[(1, proc(200, 0, 0)), (3, proc(5, 0, 0))]));
+        assert!(!a.per_pid.contains_key(&2));
+        assert!(!a.per_pid.contains_key(&3), "no previous sample to diff against");
+        assert!(!m.ema.contains_key(&2));
+    }
+
+    #[test]
+    fn smoothing_moves_toward_the_new_value_not_onto_it() {
+        let mut m = Meter::new();
+        m.tick(snap(0.0, Some(10.0), &[(1, proc(0, 0, 0))]));
+        let a = m.tick(snap(3.0, Some(16.0), &[(1, proc(300, 0, 0))]));
+        assert!((w(&a, 1) - 6.0).abs() < 1e-4, "first estimate is taken as-is");
+        // Activity stops: raw drops to 0, the average decays toward it.
+        let a = m.tick(snap(6.0, Some(10.0), &[(1, proc(300, 0, 0))]));
+        let v = w(&a, 1);
+        assert!(v > 0.0 && v < 6.0, "{v}");
+    }
+
+    fn rapl(name: &str, uj: u64, range: u64) -> RaplDomain {
+        RaplDomain { name: name.to_string(), energy_uj: uj, range_uj: range }
+    }
+
+    #[test]
+    fn rapl_mode_budgets_core_by_cpu_and_uncore_by_gpu() {
+        let mut m = Meter::new();
+        let mut s0 = snap(0.0, None, &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]);
+        s0.rapl = vec![rapl("package-0", 0, 0), rapl("core", 0, 0), rapl("uncore", 0, 0)];
+        m.tick(s0);
+        // Interval 1 establishes the floors (idle: 1 W core, 0.5 W uncore).
+        let mut s1 = snap(1.0, None, &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0))]);
+        s1.rapl = vec![rapl("package-0", 2_000_000, 0), rapl("core", 1_000_000, 0), rapl("uncore", 500_000, 0)];
+        m.tick(s1);
+        // Interval 2: core 5 W (4 above floor), uncore 2.5 W (2 above); pid 1
+        // did all the CPU work, pid 2 all the GPU work.
+        let mut s2 = snap(2.0, None, &[(1, proc(100, 0, 0)), (2, proc(0, 1_000_000_000, 0))]);
+        s2.rapl = vec![rapl("package-0", 10_000_000, 0), rapl("core", 6_000_000, 0), rapl("uncore", 3_000_000, 0)];
+        let a = m.tick(s2);
+        assert_eq!(a.mode, Mode::Rapl);
+        // Both pids already had a (zero) estimate from interval 1, so the
+        // 4 W and 2 W raw figures arrive through the smoothing step.
+        let alpha = 1.0 - (-1.0f64 / SMOOTH_TAU_SECS).exp();
+        assert!((w(&a, 1) as f64 - alpha * 4.0).abs() < 1e-4, "{}", w(&a, 1));
+        assert!((w(&a, 2) as f64 - alpha * 2.0).abs() < 1e-4, "{}", w(&a, 2));
+        // Total falls back to the package domain when neither battery nor psys report.
+        assert_eq!(a.total_w, Some(8.0));
+    }
+
+    #[test]
+    fn rapl_counter_wraps() {
+        assert_eq!(wrapped_delta(900, 100, 1000), 200);
+        assert_eq!(wrapped_delta(100, 900, 1000), 800);
+        assert_eq!(wrapped_delta(900, 100, 0), 0, "unknown range: no wrap guess");
+    }
+
+    #[test]
+    fn dgpu_budget_is_split_equally_among_fd_holders() {
+        let mut m = Meter::new();
+        let mut s0 = snap(0.0, Some(20.0), &[(1, proc(0, 0, 0)), (2, proc(0, 0, 0)), (3, proc(0, 0, 0))]);
+        s0.dgpu_w = Some(4.0);
+        m.tick(s0);
+        let hold = |ticks| ProcSample { cpu_ticks: ticks, gpu_ns: 0, ctxt_switches: 0, dgpu: true };
+        // Total +10 W, of which the dGPU rose 6 W: that 6 goes 3+3 to the two
+        // holders, and the remaining 4 follows CPU time (all pid 3's).
+        let mut s1 = snap(3.0, Some(30.0), &[(1, hold(0)), (2, hold(0)), (3, proc(100, 0, 0))]);
+        s1.dgpu_w = Some(10.0);
+        let a = m.tick(s1);
+        assert!((w(&a, 1) - 3.0).abs() < 1e-4);
+        assert!((w(&a, 2) - 3.0).abs() < 1e-4);
+        assert!((w(&a, 3) - 4.0).abs() < 1e-4);
+    }
+
+    /// Real-machine smoke: `cargo test -p cce-system-interface --lib
+    /// live_sample -- --ignored --nocapture`. Prints the sampler's cost, the
+    /// mode this host lands in, and the top estimates.
+    #[test]
+    #[ignore]
+    fn live_sample() {
+        let mut m = Meter::new();
+        let t0 = Instant::now();
+        let s0 = sample(None);
+        let cost = t0.elapsed();
+        eprintln!("sample: {:?} for {} pids, battery={:?}, rapl={:?}", cost, s0.procs.len(), s0.battery_w,
+            s0.rapl.iter().map(|d| d.name.as_str()).collect::<Vec<_>>());
+        m.tick(s0);
+        std::thread::sleep(std::time::Duration::from_secs(3));
+        let a = m.tick(sample(None));
+        eprintln!("mode={:?} total={:?} floor={:?} attributed={}", a.mode, a.total_w, a.floor_w, a.attributed_w);
+        let mut rows: Vec<_> = a.per_pid.iter().collect();
+        rows.sort_by(|x, y| y.1.watts.partial_cmp(&x.1.watts).unwrap());
+        for (pid, p) in rows.iter().take(8) {
+            eprintln!("  {pid}: {:?} W, {:.0} wake/s", p.watts, p.wakeups_per_s);
+        }
+    }
+
+    #[test]
+    fn stat_ticks_survive_spaces_and_parens_in_comm() {
+        let line = "42 (Web Content (x)) S 1 42 42 0 -1 4194560 100 0 0 0 250 75 0 0 20 0 1 0 12345 0 0";
+        assert_eq!(parse_stat_ticks(line), Some(325));
+        assert_eq!(parse_stat_ticks("garbage"), None);
+    }
+
+    #[test]
+    fn status_switches_sum_both_kinds() {
+        let s = "Name:\tx\nvoluntary_ctxt_switches:\t120\nnonvoluntary_ctxt_switches:\t5\n";
+        assert_eq!(parse_status_switches(s), 125);
+    }
+
+    #[test]
+    fn fdinfo_engines_sum_time_and_skip_capacity() {
+        let s = "drm-driver:\ti915\ndrm-client-id:\t7\ndrm-engine-render:\t1000 ns\ndrm-engine-copy:\t10 ns\ndrm-engine-capacity-video:\t2\ndrm-engine-video:\t5 ns\n";
+        assert_eq!(parse_fdinfo_engines(s), Some((7, 1015)));
+        assert_eq!(parse_fdinfo_engines("drm-driver:\ti915\n"), None);
+    }
+}