Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,45 @@ This project follows a Keep a Changelog-style format and uses version tags for r

## [Unreleased]

## [1.9.0] - 2026-08-12

### Changed

- **Net / disk I/O get their own chart sections** (N2). The rates shipped in
1.8.0 as two plain text rows tacked onto the bottom of the `Memory` box — the
only telemetry in the dashboard with no history rendering, and mis-filed
(network throughput is not a memory metric). They now own two sibling
sections, `Network` and `Disk`, each drawing a **mirrored pair** of charts —
the inbound trace (`↓ In` / `↓ Read`) grows upward and the outbound one
(`↑ Out` / `↑ Write`) hangs downward directly beneath it, so the seam where
they meet reads as a shared zero axis (the btop/vnstat convention) without
spending a row drawing one. Labels sandwich the pair, so each direction keeps
its own `avg · max` at the same box height two separate label+chart stacks
would cost:
- The grid preset gains a third paired row (`Network` | `Disk`); `Power`
keeps its full-width span beneath them. Each section hides itself entirely
when the platform reports no counters, so `Power` closes the gap on a
machine without them.
- Charts are always auto-scaled — there is no SoC reference throughput for a
NIC or NVMe controller, so `--power-scale profile` has no analogue here.
New L2 helper `analytics.io_rate_percent()` divides by
`max(floor, rolling_peak x1.25)`, reusing the multiplier `--power-scale
auto` already uses. Both directions of a box share one denominator, so an
upload trickle never renders as tall as a saturating download.
- New floors `NET_RATE_FLOOR_BPS` (1 MB/s) and `DISK_RATE_FLOOR_BPS`
(10 MB/s) stop idle background chatter (mDNS keepalives, APFS journaling)
from dividing by itself and painting a full-scale chart.
- Each direction now carries its own `avg N · max N` context, replacing the
combined `avg ↓N ↑N` suffix of the single-row form.
- `BrailleChart` gains a `fill` direction. `down` anchors the trace at the
top edge via a second cumulative bit table — exact in `dots` mode; in
`block` mode the downward half quantizes to 2 levels per row, since Block
Elements ships only `▀` and `█` as upper fills and the quarter-blocks at
U+1FB82/U+1FB85 are missing from many terminal fonts.
- No alert threshold added: neither bus has a knowable ceiling to saturate
against, so `AlertEngine` is untouched. Export output is unchanged — this
is a presentation-only change.

## [1.8.0] - 2026-08-12

### Added
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,16 @@ This file is the single source of truth for repository guidelines, used by Claud
| `actop/gpu_registry.py` | GPU accelerator registry reads via IOKit ctypes bindings: `get_gpu_time_by_pid()` sums `accumulatedGPUTime` off each `AGXDeviceUserClient`; `get_gpu_perf_stats()` reads device-level Device/Renderer/Tiler utilization % off the accelerator's own `PerformanceStatistics` dict (driver point reads — a fallback for `gpu_util_pct` plus the compute-vs-geometry breakdown IOReport cannot express; see `docs/SPEC-system.md` §3.8) |
| `actop/disk_registry.py` | Disk I/O via IOKit ctypes bindings: `read_disk_totals()` sums cumulative `Statistics` counters across `AppleAPFSVolume` entries (`"Bytes read/written from block device"`), falling back to `IOBlockStorageDriver` on non-APFS; cumulative counters are deltas in the sampler — see `docs/SPEC-system.md` §2.4 |
| `actop/utils.py` | L1 acquisition + platform discovery: native `ctypes` RAM/swap metrics and per-process CPU/GPU time collection (via `native_sys.py`), `sysctl`/`system_profiler` SoC info. Byte quantities cross layer seams as **raw bytes** (exact, prefix-free, Prometheus base-unit convention); GiB/MiB formatting is presentation and lives in `tui/`, and bandwidth stays decimal `GB/s` because that is Apple's unit for the bus — see `docs/SPEC-system.md` §2.1. Holds no derived domain math |
| `actop/analytics.py` | L2 domain analytics over acquired data points (imports only `models`/`power_scaling`, never `tui/*`): per-process power attribution (`attribute_power`), throttle detection (`domain_throttling`), bandwidth/package-power normalization, and the stateful `AlertEngine` (sustain-counted alerts + session-energy integral → `AlertFrame`) |
| `actop/analytics.py` | L2 domain analytics over acquired data points (imports only `models`/`power_scaling`, never `tui/*`): per-process power attribution (`attribute_power`), throttle detection (`domain_throttling`), bandwidth/package-power/I-O-rate normalization (`io_rate_percent` + the net/disk chart floors), and the stateful `AlertEngine` (sustain-counted alerts + session-energy integral → `AlertFrame`) |
| `actop/soc_profiles.py` | 16 built-in M1–M4 SoC profiles with power/bandwidth reference values; tier fallbacks for unknown chips |
| `actop/power_scaling.py` | Power chart scaling: `auto` mode (rolling peak x1.25) vs `profile` mode (SoC reference wattage) |
| `actop/config.py` | `DashboardConfig` frozen dataclass; `create_dashboard_config()` merges CLI args with SoC info |
| `actop/models.py` | `SystemSnapshot`, `CoreSample`, `ProcessSample` dataclasses (public API types) |
| `actop/api.py` | `Monitor`, `Profiler`, `AsyncMonitor` — public Python API for hardware profiling; `Monitor(include_processes=True)` opts into watt-attributed `ProcessSample`s |
| `actop/tui/app.py` | `ActopApp`: Textual `App` with polling worker, process table, app-level status bar (fed by `AlertsComputed`), interactive sort/filter/pause; `l` cycles the layout preset, `c` toggles the per-core panels, and `--layout`/`process-table` width live at app level |
| `actop/tui/widgets.py` | `HardwareDashboard` widget plus the custom `BrailleChart` sparkline widget, core rows, and alert computation. Composes five titled sections (`P-CPU`, `E-CPU`, `GPU · ANE`, `Memory`, `Power`) and owns the `grid`/`stack` layout presets (`set_layout_preset`, width auto-degrade below ~96 cols) plus the runtime per-core toggle (`set_show_cores`, hidden by default); posts the status string up via `AlertsComputed`. TUI CSS is embedded inline as each component's `DEFAULT_CSS` (no separate `.tcss` file). |
| `actop/tui/widgets.py` | `HardwareDashboard` widget plus the custom `BrailleChart` sparkline widget, core rows, and alert computation. Composes seven titled sections (`P-CPU`, `E-CPU`, `GPU · ANE`, `Memory`, `Network`, `Disk`, `Power`) and owns the `grid`/`stack` layout presets (`set_layout_preset`, width auto-degrade below ~96 cols) plus the runtime per-core toggle (`set_show_cores`, hidden by default); posts the status string up via `AlertsComputed`. TUI CSS is embedded inline as each component's `DEFAULT_CSS` (no separate `.tcss` file). |

**Data flow**: `ioreport.py` provides ctypes bindings for IOReport snapshots and deltas → `sampler.py` subscribes to Energy Model / CPU Stats / GPU Stats channels, computes deltas between consecutive snapshots, reads SMC die temperatures via `smc.py`, and converts raw items into a `SampleResult` (power in watts, frequency in MHz, activity in percent, temperatures in °C) → `api.py` wraps the sampler in `Monitor` and maps `SampleResult` to `SystemSnapshot` (including `CoreSample` lists; the driver's `gpu_registry.get_gpu_perf_stats()` Device/Renderer/Tiler read, which also selects `gpu_util_source` — residency stays primary unless the GPU DVFS table went unclassified; and, when `include_processes=True`, watt-attributed `ProcessSample` lists computed in L2 via `analytics.attribute_power`) → `SystemSnapshot` is the **sole per-frame contract**: `tui/widgets.py` feeds it into custom `BrailleChart` sparkline widgets (arranged in five titled sections — `P-CPU`, `E-CPU`, `GPU · ANE`, `Memory`, `Power` — under a `grid` or `stack` layout preset) and posts the composed thermal/alert status up to `tui/app.py` via an `AlertsComputed` message for the fixed app-level status bar; `tui/app.py` reads processes off the snapshot and drives the render loop (the TUI imports no L1 acquisition/attribution).
**Data flow**: `ioreport.py` provides ctypes bindings for IOReport snapshots and deltas → `sampler.py` subscribes to Energy Model / CPU Stats / GPU Stats channels, computes deltas between consecutive snapshots, reads SMC die temperatures via `smc.py`, and converts raw items into a `SampleResult` (power in watts, frequency in MHz, activity in percent, temperatures in °C) → `api.py` wraps the sampler in `Monitor` and maps `SampleResult` to `SystemSnapshot` (including `CoreSample` lists; the driver's `gpu_registry.get_gpu_perf_stats()` Device/Renderer/Tiler read, which also selects `gpu_util_source` — residency stays primary unless the GPU DVFS table went unclassified; and, when `include_processes=True`, watt-attributed `ProcessSample` lists computed in L2 via `analytics.attribute_power`) → `SystemSnapshot` is the **sole per-frame contract**: `tui/widgets.py` feeds it into custom `BrailleChart` sparkline widgets (arranged in seven titled sections — `P-CPU`, `E-CPU`, `GPU · ANE`, `Memory`, `Network`, `Disk`, `Power` — under a `grid` or `stack` layout preset; the two I/O sections hide themselves when the platform exposes no counters) and posts the composed thermal/alert status up to `tui/app.py` via an `AlertsComputed` message for the fixed app-level status bar; `tui/app.py` reads processes off the snapshot and drives the render loop (the TUI imports no L1 acquisition/attribution).

**SoC compatibility**: Explicit M1–M4 recognition (16 profiles). Unknown future chips fall back to tier defaults (base/Pro/Max/Ultra) using the latest known generation's reference values.

Expand Down
28 changes: 27 additions & 1 deletion actop/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,16 @@
from collections import deque
from dataclasses import dataclass

from actop.power_scaling import clamp_percent
from actop.power_scaling import DEFAULT_AUTO_MULTIPLIER, clamp_percent

# Chart denominator floors for the network / disk I/O rates (bytes/s). Without a
# floor an idle machine's background chatter (mDNS keepalives, spotlight
# indexing, APFS journaling) divides by its own peak and paints a full-scale
# chart — pure noise amplification. Disk sits an order of magnitude higher than
# net because APFS idles far above network keepalives and NVMe peaks are in the
# GB/s range.
NET_RATE_FLOOR_BPS = 1_000_000.0
DISK_RATE_FLOOR_BPS = 10_000_000.0


def attribute_power(share_cpu, share_gpu, cpu_watts, gpu_watts):
Expand Down Expand Up @@ -75,6 +84,23 @@ def bandwidth_percent(snapshot, max_total_bw):
return clamp_percent(snapshot.bandwidth_gbps / max(max_total_bw, 1.0) * 100)


def io_rate_percent(
rate_bps, peak_bps, floor_bps, auto_multiplier=DEFAULT_AUTO_MULTIPLIER
):
"""Normalize a network/disk byte-rate to a 0-100 chart percent.

Always auto-scaled: the denominator is the rolling peak plus headroom
(the same multiplier `--power-scale auto` uses), floored at `floor_bps`.
Unlike `power_to_percent` there is no `profile` mode — no SoC reference
throughput exists for a NIC or NVMe controller. Callers pass one peak for
both directions of a box so the two charts stay visually comparable.
"""
denominator = max(float(floor_bps), float(peak_bps or 0.0) * auto_multiplier)
if denominator <= 0:
return 0
return clamp_percent(float(rate_bps or 0.0) / denominator * 100.0)


def package_power_percent(snapshot, package_ref_w):
"""Package power as a percent of the SoC reference rail.

Expand Down
15 changes: 12 additions & 3 deletions actop/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,9 @@ def _process_display_name(command, max_len=24):
[b]Layout presets[/b]

grid Two columns (default): P-CPU / E-CPU cluster boxes share the top
row, GPU·ANE / Memory the next, and Power spans the full width
beneath. Fits short terminals without scrolling. Falls back to
stack automatically under ~96 cols.
row, GPU·ANE / Memory the next, Network / Disk the third, and
Power spans the full width beneath. Fits short terminals without
scrolling. Falls back to stack automatically under ~96 cols.
stack Single full-width column — longest chart history span; scrolls
on tall dashboards while the status line stays fixed.

Expand All @@ -147,13 +147,22 @@ def _process_display_name(command, max_len=24):
ANE Apple Neural Engine util% (estimated) and power
RAM Used / total memory (and swap when active)
Mem BW Unified-memory bandwidth in GB/s (hidden if unavailable)
↓ In / ↑ Out Network receive / transmit throughput (Network box)
↓ Read / ↑ Write Disk read / write throughput (Disk box)
CPU / GPU Live CPU/GPU rail power (W) with an inline sparkline
Package Power Total SoC power draw in watts (CPU + GPU + ANE + other rails)
idle/low/mid/high DVFS residency: % of time since the last sample spent
idle vs. below 40% / 40-74% / ≥75% of the cluster's max
frequency (not just the instantaneous clock). Hidden with
--no-show-residency

Both I/O boxes draw a mirrored pair of charts: the inbound trace grows up
from the middle, the outbound one hangs down from it, so the seam where they
meet is a shared zero axis. Both directions share one scale, always
auto-scaled against this session's rolling peak rather than a fixed ceiling
(there is no reference throughput for a NIC or SSD). Each box hides itself
entirely when no counters are available.

[b]Process table[/b]

CPU% Per-process CPU utilization (Δ CPU-time over the interval)
Expand Down
Loading