Your Mac decides when to save power using a number that cannot possibly know the answer: battery percentage.
MacPulse replaces it with a filtered forecast of the watts you are actually drawing,
and acts on predicted minutes of runtime — with the uncertainty carried through the decision.
- The problem: percentage is the wrong state variable
- Why you can't already download this
- The control system
- The estimator, in full
- The decision rule is a chance constraint
- Does it actually help? A simulation
- Hysteresis: why it never flaps
- Memory is forecast the same way
- Password-free root, without a hole
- The interface
- Install, build, uninstall
- Reproduce every claim here
- Limitations and honest caveats
Every battery utility, and macOS itself, makes power decisions from state of charge — a percentage. But the question a user actually cares about is "will this machine last until I'm done?", and percentage cannot answer it, because the answer depends on a variable percentage does not contain: how fast you are currently draining.
Remaining runtime is energy over power:
At 50% charge on a 83.6 Wh pack you hold roughly 42 Wh. That is:
| Your workload | Draw | Actual runtime left at 50% |
|---|---|---|
| Reading, display dim | 8 W | 5 h 15 m |
| Normal browsing | 12 W | 3 h 30 m |
| Video call | 22 W | 1 h 54 m |
| Xcode build, discrete GPU awake | 38 W | 1 h 6 m |
The same 50% is a five-hour reserve or a one-hour reserve — a 5× spread. A controller that fires at "20%" is therefore firing at a moment that means something completely different every time. It saves power you didn't need saved during light use, and arrives far too late during heavy use.
The obvious fix — use the system's "time remaining" estimate — fails for a different reason. That figure is an instantaneous quotient of a noisy current sample; it swings by hours between refreshes, which is precisely why Apple hides it behind a menu and why it visibly jitters. It is an estimate without a model.
What is needed is a state estimator: something that separates the true underlying drain from measurement noise, knows whether that drain is trending up or down, and reports how confident it is. That is a Kalman filter, and it is the core of MacPulse.
I looked before building. The macOS battery-utility landscape divides cleanly into three categories, and none of them closes the loop:
| Tool | What it does | Closes the loop? |
|---|---|---|
| Stats, iStat Menus, coconutBattery | Display: percentage, wattage, cycles, a time-remaining readout | No — pure telemetry, no actuation |
| AlDente | Charge limiting (stop at 80% to preserve health); Low Power Mode is a manual menu item | No — different problem entirely, and LPM is user-triggered |
| Various newer menu-bar apps | Threshold triggers: "turn on Low Power Mode at X %" | No — this is open-loop switching on the wrong variable (see §1) |
| macOS itself | Low Power Mode: manual, or "only on battery" | No — a static policy, not a controller |
So the honest claim is not "nothing like this exists." Monitors, charge limiters and percentage triggers all exist and some are excellent. What I could not find is any Mac tool that treats battery management as a control problem — estimating drain with a filter, propagating the estimate's uncertainty, and acting on a risk-calibrated forecast of remaining runtime.
Why the gap exists is more interesting than the gap itself. Doing it requires four things that rarely appear together in a consumer utility:
graph LR
A["Read the SMC gauge<br/>watts and watt-hours,<br/>not percent"] --> B["Filter the signal<br/>state estimation,<br/>not smoothing"]
B --> C["Propagate uncertainty<br/>so the decision can be<br/>risk-calibrated"]
C --> D["Actuate as root<br/>without asking for a<br/>password every time"]
style A fill:#12325c,stroke:#4FC3FF,color:#e9eef6
style B fill:#12325c,stroke:#4FC3FF,color:#e9eef6
style C fill:#12325c,stroke:#4FC3FF,color:#e9eef6
style D fill:#12325c,stroke:#4FC3FF,color:#e9eef6
Step A is undocumented plumbing (ioreg -rn AppleSmartBattery, with a two's-complement trap — see §12). Steps B and C are control engineering rather than app development. Step D is a privilege-architecture problem that most apps solve by nagging you or by installing something far more invasive. MacPulse is what you get if you insist on all four.
This is a textbook closed loop — sensor, estimator, predictor, decision rule, actuator, and the plant closing back around — running once every 60 seconds inside a root LaunchDaemon.
graph TD
P["PLANT — your Mac<br/>display, CPU, GPU, radios"]
S["SENSOR<br/>ioreg AppleSmartBattery<br/>instantaneous W, remaining Wh"]
K["ESTIMATOR — Kalman filter<br/>state = level L, trend T<br/>adapts measurement noise R"]
F["PREDICTOR<br/>P̂ = L + 5T<br/>with variance σ²"]
D{"DECISION<br/>chance constraint<br/>M₉₅ = 60E / (P̂ + 1.645σ)"}
A["ACTUATOR<br/>pmset -a lowpowermode"]
P -->|"every 60 s"| S --> K --> F --> D
D -->|"M₉₅ ≤ 120 min"| A
D -->|"M₉₅ ≥ 180 min"| A
A -->|"changes the drain"| P
style P fill:#1c1f2b,stroke:#8E8E93,color:#e9eef6
style S fill:#12325c,stroke:#4FC3FF,color:#e9eef6
style K fill:#2a1f52,stroke:#B79CFF,color:#e9eef6
style F fill:#2a1f52,stroke:#B79CFF,color:#e9eef6
style D fill:#0f3d22,stroke:#30D158,color:#e9eef6
style A fill:#4a2408,stroke:#FF9F0A,color:#e9eef6
Note the feedback edge: the actuator changes the plant, which changes the sensor reading, which the estimator must then track. That loop is exactly why hysteresis is mandatory (§7) — without it, a controller that lowers drain immediately sees the improved drain and switches itself back off, forever.
The drain signal is modelled as a local linear trend — a level that drifts plus a slope, both hidden, observed through noise:
with
Each 60-second tick runs the standard predict/update cycle:
graph LR
subgraph PREDICT
P1["L⁻ = L + T<br/>T⁻ = T"]
P2["A = F·P·Fᵀ + Q<br/>uncertainty grows"]
end
subgraph UPDATE
U1["innovation<br/>y = z − L⁻"]
U2["gain<br/>K = A₁₁ / (A₁₁ + R)"]
U3["L = L⁻ + K₁y<br/>T = T⁻ + K₂y"]
U4["P = (I − K)A<br/>uncertainty shrinks"]
end
P1 --> P2 --> U1 --> U2 --> U3 --> U4 -->|"next tick"| P1
style P1 fill:#12325c,stroke:#4FC3FF,color:#e9eef6
style P2 fill:#12325c,stroke:#4FC3FF,color:#e9eef6
style U1 fill:#2a1f52,stroke:#B79CFF,color:#e9eef6
style U2 fill:#2a1f52,stroke:#B79CFF,color:#e9eef6
style U3 fill:#2a1f52,stroke:#B79CFF,color:#e9eef6
style U4 fill:#2a1f52,stroke:#B79CFF,color:#e9eef6
The gain is the whole point.
Adaptive measurement noise.
The floor of 0.25 prevents the degenerate case where a perfectly steady load convinces the filter its sensor is flawless.
(Holt double-exponential smoothing is the steady-state special case of this filter with the gain frozen. MacPulse used Holt first; the upgrade to the full filter is what bought the adaptive gain and the honest covariance.)
Having a forecast is not the same as having a policy. The forecast five minutes out is
Because runtime is a decreasing function of power, the risk-averse case is the upper tail of the power estimate. Taking the one-sided 95% bound:
This is a chance constraint: rather than acting on the expected runtime, MacPulse acts on the runtime it is 95% confident of beating. Stated as a policy:
Engage Low Power Mode when
$\Pr[\text{runtime} < 2\text{ h}] > 0.05$ .
Two consequences fall straight out of the mathematics, and both are desirable:
-
A volatile workload gets protected earlier. Big
$\sigma$ → smaller$M_{95}$ → earlier engagement. Uncertainty itself is treated as a hazard. -
A steady workload is left alone longer. As the filter earns confidence,
$\sigma$ shrinks and the bound relaxes toward the mean estimate.
The 1.645 is the standard normal 95th percentile. Change it and you change the app's risk appetite — that single constant is the entire safety-versus-performance dial.
sim.swift runs a realistic discharge session through both controllers using MacPulse's real filter constants: idle at 11.5 W, a sustained 34 W load from t=35 (a build with the discrete GPU awake), tapering from t=95.
predictive engaged t=35
threshold engaged t=77
lead time: 42 min
runtime left at 20%: 25 min
MacPulse engages the instant the load steps up (t=35). The 20% threshold does not fire until t=77 — 42 minutes later — by which point only 25 minutes of runtime remain. The percentage controller is not slightly late; it is late by more than the runtime it has left to protect.
Read the top panel to see the estimator working: the raw trace (grey) is noisy, the Kalman level (blue) tracks the step within a couple of samples, and the forecast band (violet) widens at the discontinuity — the filter correctly reporting that it has just been surprised — then narrows as confidence returns. That widening is what pulls the decision earlier, exactly as §5 predicts.
One honest observation visible in the chart: the local-linear-trend model overshoots on a step, because the trend term briefly absorbs the jump. MacPulse's clamp
A naive controller with one threshold oscillates: engaging Low Power Mode reduces drain, which raises predicted runtime back above the threshold, which disengages it, which raises drain again. MacPulse uses a 60-minute dead band in the time domain:
stateDiagram-v2
direction LR
[*] --> Normal
Normal --> LowPower: M₉₅ ≤ 120 min<br/>or charge ≤ 40%<br/>or free RAM ≤ 25%
LowPower --> Normal: M₉₅ ≥ 180 min<br/>AND charge ≥ 50%<br/>AND free RAM ≥ 40%
LowPower --> Normal: AC connected
Normal --> Normal: filter state persists
LowPower --> LowPower: no repeated pmset calls
Engage at 120 minutes, release at 180. The gap is not arbitrary: it must exceed the runtime improvement that Low Power Mode itself produces, or the loop closes on its own output. Release additionally requires charge ≥ 50% and free memory ≥ 40%, so a single satisfied condition cannot undo a decision that several conditions justified. On AC the filter state is discarded entirely — a charging trace has no meaning for a discharge model.
The floors (charge ≤ 40%, free RAM ≤ 25%) are deliberate model-independent overrides. If the estimator is ever wrong, absolute limits still catch the fall.
Pressure warnings that arrive when you are already swapping are useless. MacPulse fits an ordinary least-squares slope to free-memory percentage over a rolling 15-sample window:
When
2026-08-07 12:04:11 MEMORY TREND free=34% falling 0.82%/min — pressure in ~11 min; top: 4913 Chrome
A separate leak sentinel in the app tracks per-process RSS baselines and surfaces anything growing more than 300 MB in 10 minutes.
What MacPulse deliberately does not do: call purge. Evicting the file cache forces the SSD to re-read everything, costing more energy than it saves, and macOS's compressor already handles genuine pressure better. An earlier draft of this project had sudo purge wired in; measuring the trade removed it.
Tune and Deep Scan need root (pmset, powermetrics). The usual options are all bad: prompt every single time, tell the user to hand-edit sudoers with NOPASSWD, or ship a privileged helper via SMJobBless. MacPulse uses a spool agent — one admin prompt, ever, and no standing shell privilege:
sequenceDiagram
participant U as You
participant I as Island (user)
participant S as /spool (1777)
participant A as agent (root)
participant K as pmset / powermetrics
U->>I: click Tune
Note over I: first run only
I->>U: one admin prompt → installs daemons
I->>S: write req-a1b2 containing "tune"
Note over S,A: launchd WatchPaths fires
S-->>A: file created
A->>A: verb = head -1 | tr -cd 'a-z'
A->>A: whitelist match: tune | deep
A->>K: fixed command set, no arguments
K-->>A: result
A->>S: write done-a1b2
S-->>I: poll finds reply
I->>U: "Applied — no password"
The security invariant: a request file contains one bare verb and nothing else. It carries no paths, no flags, no arguments. The agent strips everything but lowercase letters (tr -cd 'a-z'), matches against a fixed whitelist, and constructs every output path itself. So even though the spool is world-writable by design, the worst a hostile local process can do is ask for tune or deep — operations the user already authorised. There is no string from the spool that reaches a shell.
Supporting details: the agent script lives root-owned at /Library/Application Support/MacPulse/agent.sh (root must never execute a user-writable file), the spool is sticky-bit 1777, reply ids are sanitised, and stale replies are swept after five minutes.
Real behind-window vibrancy — your desktop is genuinely visible through the glass, not a dark card imitating it:
Built against Apple's official macOS 27 UI Kit for Figma, with control values read programmatically out of the kit rather than eyeballed:
| Element | Kit specification | Implementation |
|---|---|---|
| Panel | Liquid Glass: material blur, light-catching tint, specular top and bottom edges | NSVisualEffectView with .behindWindow blending, specular bands, gradient rim, cursor-tracking highlight |
| Buttons | True capsules, layered translucent plate, soft y8 shadow |
Capsule with layered adaptive fills and specular ring |
| Switches | 54 × 24 track, 32 × 20 pill knob, y3 shadow |
Custom KitSwitch at those exact metrics |
| Geometry | Concentric radii, capsule-first | 34 pt panel / 24 pt pill |
| Colour | Semantic, appearance-adaptive | Color.primary-derived; adapts light/dark |
The glass gotcha, documented for anyone else hitting it: SwiftUI's
.ultraThinMaterialinside a borderlessNSPanelblends within-window only and renders as a flat card no matter how many gradients you stack on it. OnlyNSVisualEffectViewwithblendingMode = .behindWindowsamples the desktop. Apple's real.glassEffectAPI requires macOS 26+, so everything below that must be hand-built.
The icon is one strip of paper folded into a pulse waveform — the polyline is offset with proper miter joins and each facet is shaded by its own surface normal against a single top-left light, so the folds are physically coherent rather than decoratively coloured.
git clone https://github.com/infinitule/MacPulse.git
cd MacPulse && ./build.shCompiles main.swift with swiftc, assembles ~/Applications/MacPulse.app, ad-hoc signs it and launches. No Xcode project, no package manager, no dependencies — Xcode command-line tools are the only requirement. The entire app is one reviewable Swift file plus two shell scripts.
Uninstall
Toggle Guard off in the island first — that boots out both LaunchDaemons and removes /Library/Application Support/MacPulse. Then quit via the red traffic light and delete ~/Applications/MacPulse.app. To revert power settings: sudo pmset restoredefaults.
What Tune actually changes (battery side only; AC untouched)
| Setting | Default | MacPulse | Why |
|---|---|---|---|
powernap |
1 | 0 | background network wakeups during sleep |
womp |
1 | 0 | wake-on-network |
displaysleep |
20 min | 5 min | the display is the largest single load |
disksleep |
10 min | 5 min | |
standbydelaylow |
3 h | 15 min | hibernate sooner below 50% |
standbydelayhigh |
24 h | 1 h | kills overnight drain |
gpuswitch |
2 (auto) | 0 (integrated) | the biggest lever on dual-GPU Intel Macs: ~5 W versus 10–35 W |
Trade-off, stated plainly: a Mac asleep longer than an hour wakes with a ~5-second hibernate resume. If you drive an external display while unplugged, revert one setting with sudo pmset -b gpuswitch 2.
Nothing above is asserted without a way to check it.
swift sim.swift # regenerates the figure in §6 and prints the lead-time numbers
swift compose.swift # re-crops the screenshots and rebuilds the hero
swift shot.swift # prints the island's window id and frame
tail -f "/Library/Application Support/MacPulse/macpulse.log" # watch live decisionsThe daemon logs every decision with the forecast that caused it, so you can audit the controller against your own machine:
2026-08-07 14:22:03 ENGAGE low-power (95%-confidence runtime 96 min @ 31.4W ±3.2, batt=63%)
2026-08-07 15:04:41 RESTORE normal power (on AC)
Sensor detail: reading the SMC gauge correctly
ioreg -rn AppleSmartBattery has two traps that will silently corrupt a forecast:
-
Nested dictionaries repeat key names. Parse only top-level keys, or you will read a different battery's
Voltage. -
InstantAmperageis unsigned two's complement. On discharge it arrives as a value near$2^{64}$ . Subtract$2^{64}$ when it exceeds ~9.2 × 10¹⁸, or your drain reads as 18 quintillion watts.
P = \frac{|I| \cdot V}{10^6}\ \text{W}, \qquad E = \frac{C_{\text{now}} \cdot V}{10^6}\ \text{Wh}
- The forecast is only as good as its horizon. A 5-minute-ahead linear extrapolation cannot know you are about to launch a compile. It reacts within a sample or two of the load actually changing; it is not clairvoyant.
- Step overshoot is real (visible in §6). It biases toward engaging early — safe, but occasionally earlier than strictly necessary.
-
Constants are tuned for one machine.
$q_1$ ,$q_2$ , the 120/180-minute band and the memory thresholds were calibrated on a 2018 15" MacBook Pro (Intel i9, dual GPU, 32 GB). They are reasonable defaults elsewhere but are not universal; they live at the top ofguard-root.sh. - The simulation is a simulation. It uses the real filter constants and real battery parameters, but a synthetic load trace. It demonstrates the controller's logic, not a measured battery-life gain on your hardware.
- Apple's real Liquid Glass API needs macOS 26+. Below that the material is hand-built and approximates rather than reproduces it.
- Ad-hoc signed. There is no notarised release; you build it yourself, which also means you can read every line first.
MacPulse's design follows Apple's macOS design language, implemented against Apple's official macOS 27 UI Kit for Figma (Apple Design Resources). macOS, the macOS UI Kit, SF Pro and SF Symbols are trademarks and copyrighted works of Apple Inc., used as system-provided platform resources under Apple's licensing terms.
MacPulse is an independent open-source project — not affiliated with, endorsed by, or sponsored by Apple Inc.
Comparisons in §2 reference Stats (MIT) and AlDente; both are fine tools solving different problems. If you know of a Mac utility that does close this loop, please open an issue — I would genuinely like to read its approach.
MIT — see LICENSE. Covers this project's code only; Apple resources remain under Apple's terms.