Ultra-Low-Power Inference¶
Every optimization so far (quantization, pruning, NPU offload, compiler fusion) has targeted latency or accuracy. For battery- or energy- harvesting-powered devices — a coin-cell sensor node meant to run for years, an energy-harvesting tag with no battery at all — the metric that actually determines whether the product works is energy per inference, and the biggest lever on that number usually isn't the model at all: it's how aggressively the device sleeps between inferences. This module covers duty-cycling design and the always-on/wake-on-event pattern, with a tested Python energy-budget simulator, since actual microamp-level power measurement needs a power analyzer and real silicon this environment doesn't have.
The energy budget, decomposed¶
Total energy for a battery-powered inference device over its target lifetime breaks into three components that trade off against each other:
Where E_sleep is often 1,000-10,000x smaller than E_active on modern
microcontrollers (deep sleep modes can pull under 1 µA vs. several mA
active), which means how much of the device's life is spent asleep
dominates the total energy calculation far more than how efficient the
active-mode inference itself is — a genuinely different optimization
target than everything in Levels 1-3, which implicitly assumed the device
was always powered and always available to run inference on demand.
Simulating the energy tradeoff¶
def simulate_energy_budget(duty_cycle_hz, active_duration_ms,
sleep_current_ua, active_current_ma,
wake_transition_ua_ms, supply_voltage=3.3,
duration_hours=24):
"""Computes total energy (in milliwatt-hours) for a device that wakes
up duty_cycle_hz times per second, runs inference for
active_duration_ms each time, and sleeps otherwise. wake_transition
models the fixed micro-amp-millisecond cost of the sleep->active
transition itself (clock startup, sensor power-up), which is often
non-negligible at high duty cycles."""
total_seconds = duration_hours * 3600
n_wakes = duty_cycle_hz * total_seconds
active_seconds = n_wakes * (active_duration_ms / 1000)
sleep_seconds = total_seconds - active_seconds
sleep_energy_uas = sleep_current_ua * sleep_seconds # microamp-seconds
active_energy_uas = (active_current_ma * 1000) * active_seconds
wake_energy_uas = wake_transition_ua_ms * n_wakes / 1000 # convert ua*ms -> ua*s
total_uas = sleep_energy_uas + active_energy_uas + wake_energy_uas
total_mwh = (total_uas / 1000) * supply_voltage / 3600 # uAs->mAs->mWh
return {
"n_wakes": int(n_wakes),
"active_time_pct": round(active_seconds / total_seconds * 100, 4),
"sleep_energy_mwh": round(sleep_energy_uas / 1000 * supply_voltage / 3600, 4),
"active_energy_mwh": round(active_energy_uas / 1000 * supply_voltage / 3600, 4),
"wake_transition_energy_mwh": round(wake_energy_uas / 1000 * supply_voltage / 3600, 4),
"total_mwh_per_day": round(total_mwh, 4),
}
# Keyword-spotting node: wakes 1x/sec, 15ms inference, deep-sleep 2uA.
result_1hz = simulate_energy_budget(
duty_cycle_hz=1, active_duration_ms=15,
sleep_current_ua=2.0, active_current_ma=8.0, wake_transition_ua_ms=500)
# Same model, but wakes 10x/sec instead (e.g. lower-latency requirement).
result_10hz = simulate_energy_budget(
duty_cycle_hz=10, active_duration_ms=15,
sleep_current_ua=2.0, active_current_ma=8.0, wake_transition_ua_ms=500)
print("1 Hz duty cycle:", result_1hz)
print("10 Hz duty cycle:", result_10hz)
typical_coin_cell_mwh = 3.0 * 220 # CR2032: ~3V, ~220mAh -> mWh
print(f"\nCR2032 coin cell capacity: ~{typical_coin_cell_mwh:.0f} mWh")
print(f"estimated lifetime at 1 Hz: {typical_coin_cell_mwh / result_1hz['total_mwh_per_day']:.0f} days")
print(f"estimated lifetime at 10 Hz: {typical_coin_cell_mwh / result_10hz['total_mwh_per_day']:.0f} days")
Running this prints:
1 Hz duty cycle: {'n_wakes': 86400, 'active_time_pct': 1.5, 'sleep_energy_mwh': 0.156, 'active_energy_mwh': 9.504, 'wake_transition_energy_mwh': 0.0396, 'total_mwh_per_day': 9.6996}
10 Hz duty cycle: {'n_wakes': 864000, 'active_time_pct': 15.0, 'sleep_energy_mwh': 0.1346, 'active_energy_mwh': 95.04, 'wake_transition_energy_mwh': 0.396, 'total_mwh_per_day': 95.5706}
CR2032 coin cell capacity: ~660 mWh
estimated lifetime at 1 Hz: 68 days
estimated lifetime at 10 Hz: 7 days
With these particular parameters, active_energy_mwh dominates the total
in both cases (9.5 of 9.7 mWh/day at 1 Hz; 95.0 of 95.6 at 10 Hz) —
sleep_energy_mwh and wake_transition_energy_mwh are both negligible
here because the assumed 2 µA sleep current and 500 µA·ms wake transition
are simply small relative to running an 8 mA active inference for 15 ms.
The 10x increase in duty cycle produces almost exactly a 10x increase in
total daily energy (9.7 -> 95.6 mWh/day) because active energy scales
linearly with wake count — the practical lesson from this parameter
regime is that active-mode energy (and therefore active duration and
current draw — the things Levels 1-3's model optimizations directly
affect) is the lever that matters most, while sleep current and wake
overhead only start to dominate once they're pushed low enough, or the
duty cycle low enough, to make active energy the smaller term. Try
reducing sleep_current_ua and wake_transition_ua_ms further, or
lowering the duty cycle toward true event-driven rates, to see the
crossover — which is exactly what the next section does.
Wake-on-event: moving the "when to wake" decision to cheaper hardware¶
The most effective ultra-low-power pattern skips periodic duty-cycling entirely: a cheap, always-on analog or digital front-end (a simple threshold comparator on a microphone's amplitude, a PIR sensor's hardware interrupt line) wakes the main MCU only when something worth inferring about actually happens, rather than waking on a fixed timer and often finding nothing.
def simulate_wake_on_event(events_per_hour, active_duration_ms,
sleep_current_ua, active_current_ma,
wake_transition_ua_ms, always_on_sensor_current_ua,
supply_voltage=3.3, duration_hours=24):
"""Same energy model as before, but wakes are driven by actual events
(events_per_hour) rather than a fixed duty cycle, at the cost of a
small continuously-running sensor front-end that must stay powered
to detect those events at all."""
total_seconds = duration_hours * 3600
n_wakes = events_per_hour * duration_hours
active_seconds = n_wakes * (active_duration_ms / 1000)
sleep_seconds = total_seconds - active_seconds
sleep_energy_uas = sleep_current_ua * sleep_seconds
active_energy_uas = (active_current_ma * 1000) * active_seconds
wake_energy_uas = wake_transition_ua_ms * n_wakes / 1000
always_on_energy_uas = always_on_sensor_current_ua * total_seconds
total_uas = sleep_energy_uas + active_energy_uas + wake_energy_uas + always_on_energy_uas
total_mwh = (total_uas / 1000) * supply_voltage / 3600
return {"n_wakes": int(n_wakes), "total_mwh_per_day": round(total_mwh, 4)}
# Same node, but only wakes on genuine acoustic events (say ~20/hour
# instead of a fixed 86,400 wakes/day at 1Hz), plus a 3uA always-on
# comparator front-end watching the microphone continuously.
result_event = simulate_wake_on_event(
events_per_hour=20, active_duration_ms=15,
sleep_current_ua=2.0, active_current_ma=8.0,
wake_transition_ua_ms=500, always_on_sensor_current_ua=3.0)
print("wake-on-event:", result_event)
print(f"estimated lifetime: {typical_coin_cell_mwh / result_event['total_mwh_per_day']:.0f} days")
Running this prints:
Going from 86,400 timer-driven wakes/day down to 480 genuine event-driven wakes/day (a ~180x reduction) extends estimated lifetime from 68 days to roughly 4 years — a difference in kind, not degree, and one that came entirely from changing when the device wakes (plus paying a small continuous 3 µA cost for the always-on trigger sensor), without touching the model or the inference code at all. This is why ultra-low-power edge AI projects almost always start with the wake-trigger design, not model optimization — once wake frequency drops far enough, the active-energy term that dominated the duty-cycle scenario above shrinks to the point where it's no longer the bottleneck at all.
Edge-AI tradeoffs¶
| Factor | Fixed duty-cycle wake | Wake-on-event |
|---|---|---|
| Always-on hardware cost | none | small continuous current draw for the trigger sensor |
| Responsiveness | bounded by cycle period (misses events between wakes) | immediate, event-driven |
| Energy at low event rates | wasteful — wakes even when nothing happens | very efficient |
| Design complexity | simple (a timer) | needs a genuinely low-power always-on front-end circuit |
| Best fit | predictable, periodic sensing needs (e.g. environmental logging) | rare, bursty events (wake word, motion, anomaly) |
How It Actually Works¶
Why sleep current being 1,000-10,000x smaller than active current makes
duty cycle the dominant lever, algebraically. In
E_total = E_sleep·t_sleep + E_active·t_active + E_wake·n_wakes, even
though t_sleep is typically the overwhelming majority of total time
(the 1 Hz example spends only 1.5% of time active), the E_sleep term
stays small in absolute terms only because E_sleep (current draw) is
so many orders of magnitude below E_active. The moment either the
active current or the wake frequency rises, E_active·t_active grows
linearly with both — as demonstrated by the 10x duty-cycle increase
producing almost exactly a 10x total-energy increase — while
E_sleep·t_sleep barely changes, because t_sleep was already close to
100% of the time budget and can only shrink a little further. This
asymmetry is exactly why a coin-cell device's achievable lifetime is
governed far more by "how often and how long does it wake" than by "how
low is its sleep current," once sleep current is already in the
microamp range — squeezing sleep current further has rapidly diminishing
returns while cutting wake frequency has returns that scale linearly for
as long as active energy dominates the total.
Why an always-on analog trigger changes the shape of the energy
equation, not just its magnitude. A fixed-timer duty cycle pays the
full active-inference energy cost n_wakes times regardless of whether
anything worth detecting happened during any given wake — the number of
wakes is set by a clock, entirely decoupled from the actual event rate in
the environment. Replacing that with a cheap always-on comparator moves
the "is anything happening" decision to hardware that draws orders of
magnitude less current than the full inference pipeline (here 3 µA vs.
8 mA active, roughly 2,700x lower), and crucially decouples n_wakes
from a fixed clock rate and re-couples it to the environment's true,
usually much sparser, event rate. The tradeoff is a new, small but
constant additive term (always_on_sensor_current_ua × total_seconds)
that never goes away — which is precisely why the wake-on-event result
still isn't literally free: at genuinely negligible real event rates, the
always-on sensor's own continuous draw becomes the new floor on total
energy, the same way sleep current became negligible only once active
energy was reduced far enough in the duty-cycle scenario.
Why the crossover between duty-cycling and wake-on-event being
worthwhile depends entirely on how sparse the true event rate is.
Wake-on-event wins precisely when the number of genuine events is far
smaller than a fixed-rate timer would produce, because its total cost
is always_on_energy + n_true_events × per_wake_cost versus fixed
duty-cycling's n_timer_wakes × per_wake_cost where n_timer_wakes is
set conservatively high enough to not miss events. If the true event
rate actually approached the timer's wake rate (a genuinely busy sensor,
events happening almost continuously), the two approaches would converge
in cost, and the always-on sensor's constant overhead would make
wake-on-event strictly worse — which is exactly why the module frames
wake-on-event's best fit as "rare, bursty events" specifically: the
technique's entire energy advantage comes from the gap between how often
a clock would check and how often something is actually there to find.
Exercise¶
Extend simulate_wake_on_event to model a hybrid strategy: a low
duty-cycle timer wake (say, once every 10 minutes, to catch anything the
event trigger might have missed) combined with event-driven wakes.
Compute the combined daily energy and lifetime, and compare it against
pure wake-on-event — this hybrid pattern is common in real deployments
specifically because a purely analog trigger can have false negatives,
and the periodic backstop wake trades a small, bounded energy cost for
protection against that failure mode.