Power & Duty-Cycling for Field Nodes
Within Edge Operations & Observability, power and duty-cycling is the discipline of running a spatial pipeline inside a solar or battery energy budget instead of a mains-powered one: a wake-process-sleep loop that spends most of its life asleep, wakes just long enough to sense and process a batch of geometry, and shapes its active window to land inside the modem’s registered transmit slot rather than fighting it. Everything else this site covers — coordinate handling, geometry filtering, delta sync — assumes the CPU is available whenever a coroutine wants it. A duty-cycled node breaks that assumption on purpose: CPU availability becomes a scarce resource that is rationed on a schedule, and the schedule itself is the thing being engineered.
The deployment context is a fanless ARM or RISC-V gateway drawing from a small Li-ion or LiFePO4 pack topped up by a solar panel sized for the worst month of the year, not the best. A typical pack is 3.7 V nominal, 5–20 Ah, feeding a SoC that idles at a few hundred milliwatts and bursts to two or three watts under load. The arithmetic is unforgiving: a node that runs its CPU continuously at even modest load will flatten a modest panel’s daily harvest in hours, while the same workload compressed into short, well-timed wake windows can run indefinitely on the same harvest. The rest of this guide is about making that compression safe and repeatable rather than a source of dropped fixes and corrupted writes.
Sizing the panel for the worst month rather than the average one matters because a duty-cycle schedule tuned against a sunny-day harvest will quietly starve the battery every time a week of overcast weather arrives. The node has no way to negotiate more sunlight, so the only variable left to move is its own consumption — which is exactly what an adaptive duty ratio is for. Treat the schedule as a control loop with the battery’s state of charge as the process variable, not as a fixed cron-like interval picked once during bring-up and never revisited.
Constraint Mapping
The duty-cycle budget is arithmetic, not intuition: every joule spent has to come from a harvest that is itself uncertain. Before tuning a scheduler, map each stage of the loop to the number that actually governs it — the same physical envelope catalogued under device constraints and resource limits, narrowed here to energy rather than RAM or CPU.
| Constraint | Typical figure (3.7 V node) | Where it bites | Lever in this pattern |
|---|---|---|---|
| Sense + process energy per cycle | ~180 mJ per cycle (CPU burst to ~800 MHz for ~900 ms) | Dominates the budget once duty ratio exceeds a few percent | Batch geometry ops per wake; drop the cpufreq governor to powersave between bursts |
| Sleep current (suspend-to-RAM) | 18–25 mA at 3.7 V | Multiplied by the 90%+ of the cycle spent asleep, it is often the majority consumer over 24 h | RTC wake instead of a busy-wait poll loop; nothing runs but DRAM self-refresh and the RTC |
| Wake / resume cost | 40–90 mJ, 300–800 ms to a runnable userspace | Frequent short cycles pay this tax every time, eroding savings from shortening the active window further | Widen the duty period rather than shrinking it once wake cost dominates the total |
| Modem transmit energy | 0.9–1.6 J per burst (350–500 mA for 2–5 s) | The single largest energy event per cycle; a misaligned wake wastes a whole cycle waiting on it | Time compute to finish inside the modem’s already-open window instead of opening a second one |
| Peripheral rail leakage | 8–15 mA per ungated rail (GNSS, LTE modem idle draw) | Silently doubles measured sleep current if a rail is left powered | Switch rails through a GPIO-controlled load switch; verify with a current probe, not a datasheet number |
The practical read is that sleep current times sleep duration, not compute energy, usually decides battery life on a well-tuned node — which is why gating peripherals correctly matters more than shaving milliseconds off the processing loop. A node drawing 20 mA asleep for 23 hours a day spends roughly 460 mAh just existing, before a single geometry op or transmit burst is counted; a node that leaks another 10 mA through an ungated GNSS rail adds nearly half that again, for zero additional work done. No amount of compute-side optimization recovers that loss — it has to be closed at the rail.
Implementation: A Duty-Cycle Scheduler Using RTC Wake
The scheduler’s job is narrow: compute the next wake time, arm the real-time clock, and suspend — never busy-wait, and never trust a userspace timer to survive a suspend cycle. On Linux gateways this means writing an absolute wake epoch to /sys/class/rtc/rtc0/wakealarm and asking the kernel to enter a low-power state, rather than sleeping in a process that the kernel might schedule away or that a watchdog might reset mid-wait.
import time
import subprocess
from pathlib import Path
RTC_WAKEALARM = Path("/sys/class/rtc/rtc0/wakealarm")
BASE_DUTY_S = 300 # nominal 5-minute cycle
MIN_DUTY_S = 120 # never wake more often than this
MAX_DUTY_S = 1800 # never let a starved battery go longer than this
def arm_and_suspend(sleep_seconds: int):
"""Arm the RTC for sleep_seconds from now, then suspend to RAM.
Clears any stale alarm first -- writing 0 cancels a pending wakealarm,
which avoids a race where an old alarm fires immediately after arming
the new one.
"""
wake_epoch = int(time.time()) + sleep_seconds
RTC_WAKEALARM.write_text("0")
RTC_WAKEALARM.write_text(str(wake_epoch))
# 'mem' targets suspend-to-RAM (ACPI S3 / Linux STR); 'standby' is shallower
# and rarely worth the smaller current saving on these SoCs.
subprocess.run(["rtcwake", "-m", "mem", "-s", str(sleep_seconds)], check=True)
def next_duty_seconds(battery_state_of_charge: float, queue_backlog: int) -> int:
"""Adaptive duty period: stretch sleep when the battery is low or the
uplink queue is already backed up; compress it when there is headroom.
"""
duty = BASE_DUTY_S
if battery_state_of_charge < 0.3:
duty = int(duty * 2.5) # conserve harvest on a weak battery
elif battery_state_of_charge > 0.8 and queue_backlog < 5:
duty = int(duty * 0.6) # spend headroom on fresher data
return max(MIN_DUTY_S, min(MAX_DUTY_S, duty))
rtcwake -m mem is doing the real work: it arms the alarm, triggers the transition to /sys/power/state = mem, and blocks until either the RTC fires or another wake source (a GPIO interrupt from a sensor, for instance) does. The Python layer around it exists only to decide how long to sleep, which is where the adaptive duty ratio lives — a battery under 30% state of charge should stretch the cycle well past the nominal five minutes, because the cost of one missed fix is far lower than the cost of browning out entirely.
Implementation: A Per-Cycle Energy Accounting Helper
A scheduler that adapts on battery percentage alone is flying blind between charge readings, which on a cheap fuel gauge update once every few minutes. The complementary technique is to account for energy within a cycle, from measured or modeled current draw per stage, so the node can catch a cycle that ran long — a retry storm, a bigger-than-usual geometry batch — before it shows up as an unexplained battery drop hours later.
import time
# Milliamps at 3.7V nominal, drawn from bench measurement per stage (see
# verification section below) -- these are starting points, not constants.
STAGE_MA = {
"wake": 60.0,
"sense_process": 210.0,
"transmit": 420.0,
"sleep": 21.0,
}
BUS_VOLTS = 3.7
class CycleEnergyLedger:
"""Accumulates mWh for one duty cycle from timed stage transitions."""
def __init__(self):
self._stage = None
self._stage_start = None
self.stage_mwh = {k: 0.0 for k in STAGE_MA}
def enter(self, stage: str):
now = time.monotonic()
if self._stage is not None:
elapsed_h = (now - self._stage_start) / 3600.0
self.stage_mwh[self._stage] += STAGE_MA[self._stage] * BUS_VOLTS * elapsed_h
self._stage = stage
self._stage_start = now
def total_mwh(self) -> float:
return sum(self.stage_mwh.values())
def over_budget(self, cycle_budget_mwh: float) -> bool:
return self.total_mwh() > cycle_budget_mwh
Called at each transition (ledger.enter("sense_process"), then ledger.enter("transmit"), and so on), the ledger turns wall-clock stage durations into an energy total using the same per-stage current figures from the constraint table. Comparing total_mwh() against a per-cycle budget derived from the daily harvest forecast is what lets next_duty_seconds above react to this cycle’s overrun rather than waiting for the next fuel-gauge poll — a cycle that blew its budget because a chunk retried three times should lengthen the following sleep, not repeat the same duty ratio blind.
Configuration & Tuning
Three knobs cover nearly every real-world adjustment to this pattern:
- Duty ratio — the fraction of the cycle spent awake, computed as active time divided by
BASE_DUTY_S. Below roughly 2% active time, wake/resume overhead starts to dominate the energy total (see the constraint table); above roughly 8%, sleep current stops being the majority consumer and compute starts to matter as much as sleep. Tune the ratio, not just the absolute wake interval, when the workload’s per-cycle work changes. - CPU governor — set
/sys/devices/system/cpu/cpu0/cpufreq/scaling_governortoperformanceonly for the duration of the sense/process stage, then drop it topowersave(orconservativeif the kernel lackspowersaveon this platform) before arming the RTC. Leavingondemandactive during sleep wastes the small but nonzero polling overhead the governor itself costs. - Peripheral gating — every rail that is not actively needed should be switched off at the hardware level, not merely idled in software: GNSS and LTE front-ends both leak milliamps in a “soft off” state that a GPIO-driven load switch eliminates entirely. Sequence power-up so the modem rail comes up only once compute has produced something worth transmitting, per the queuing discipline in message queue management at the edge.
A fourth, easy-to-miss knob is wake source priority: if a sensor interrupt can also break suspend (common on gateways with a GPIO-attached accelerometer or door switch), make sure the RTC alarm is still armed as a backstop so an interrupt storm cannot starve the node of its scheduled duty cycle entirely.
For gateways running a full init system rather than a bare suspend loop, a systemd timer unit can front the same rtcwake call and inherit the OS’s own service supervision — a OnCalendar= or OnUnitActiveSec= timer restarts a crashed duty-cycle service without a separate babysitting process, which is one less thing to get wrong on a node nobody is watching. The trade-off is that systemd itself has a nonzero resident footprint on the smallest gateways; on a 256 MB node running a minimal Yocto image, a hand-rolled loop calling rtcwake directly, supervised only by the hardware watchdog, is often the leaner choice.
Verification & Field Diagnostics
Confirming the numbers in the constraint table hold on a specific deployed board — not a datasheet — is the only way to trust the energy ledger above:
- Bench current profiling. Insert a shunt-based current sensor (an INA219 or INA226 on I2C is common and cheap) between the battery and the load, and log current at 10–50 Hz across a full cycle. This is where the
STAGE_MAfigures come from; datasheet TDP numbers are consistently optimistic against real board draw. - Sleep current isolation. Measure current with every peripheral rail deliberately gated and the CPU in suspend-to-RAM only — this isolates the true sleep floor from any peripheral leakage and tells you whether gating is actually working, not just configured.
- Wake-to-transmit latency. Timestamp
rtcwakereturn, process start, and modem TX start; if the gap between wake and the modem’s actual transmit opportunity grows, the compute window is drifting out of alignment with the network’s paging cycle, and duty timing needs re-tuning rather than the modem. - Cumulative energy cross-check. Compare the ledger’s running
total_mwh()against an independent coulomb counter (a fuel-gauge IC like the BQ27441, if present) over a 24-hour window. A persistent gap between modeled and measured energy means a stage’s current figure is stale — usually because a firmware or driver update changed idle behavior. - Duty-ratio drift under adaptation. Log the ratio
next_duty_secondsactually selects over several days against battery state of charge and solar harvest; a ratio that never relaxes even on a full battery points at a stuck low-SoC branch in the adaptive logic.
None of these checks require a lab. A USB current meter inline with a bench supply reproduces the bulk of the sleep-current and wake-cost numbers before a device ever ships, and the same INA219 used for bring-up can stay on the board permanently as a cheap, always-on telemetry source — at which point the energy ledger stops being a model and becomes a direct measurement, closing the loop between the constraint table’s assumptions and what the node is actually doing in the field.
Failure Modes & Recovery
- Brownout mid-write. The modem’s transmit burst pulls the bus voltage down right as the SoC is writing to local storage, and a partial write corrupts the on-disk index. Detect: corrupted or unreadable local store on the next boot, correlated in the ledger with a transmit-stage entry. Recover: never write to persistent storage during or immediately before a transmit burst; stage writes to complete before the modem rail powers up, and use write-ahead logging with atomic renames so a mid-write brownout at worst loses the pending write rather than the whole store — the same conservative-by-construction discipline the operational layer applies everywhere a reset can land mid-task.
- Thermal buildup from over-duty. A duty ratio pushed too aggressive for a sealed, fanless enclosure keeps the SoC’s average clock higher than the enclosure can shed, and the die temperature ratchets upward across cycles even though each individual cycle looks fine in isolation. Detect: rising baseline temperature at the start of each cycle, not just during the compute burst — a leading indicator covered under monitoring and observability. Recover: cap the duty ratio to a ceiling tied to enclosure thermal mass, not just battery state of charge, and widen the cycle automatically once the pre-wake baseline temperature crosses a threshold.
- Missed wake window. An RTC alarm silently fails to fire — a common failure on RTCs without a battery backup after a brownout resets the clock — and the node never wakes at all. Detect: a watchdog-driven fallback timer that is independent of the RTC path; if it fires, the RTC path is assumed broken. Recover: fall back to a fixed hardware watchdog reboot cadence as the wake source of last resort, sacrificing duty-cycle efficiency for the guarantee that the node eventually comes back.
- Duty ratio oscillation. The adaptive logic in
next_duty_secondsreacts to a single low reading by stretching the cycle, which then delays the next state-of-charge sample, which triggers an overcorrection back to a short cycle once a stale high reading arrives. Detect: duty period swinging betweenMIN_DUTY_SandMAX_DUTY_Son a battery that is not actually at either extreme. Recover: smooth the state-of-charge input with a short moving average before it drives the duty decision, rather than reacting to a single fuel-gauge sample. - Silent RTC-to-system-clock drift. Cheap RTC crystals drift several seconds a day, and over weeks that drift walks the wake schedule away from the modem’s actual paging window even though every individual cycle still fires on time by its own clock. Detect: compare the RTC-derived wake epoch against an NTP-corrected timestamp whenever the modem does get a signal, and log the delta. Recover: resynchronize the RTC from network time opportunistically, and treat a wake-to-transmit alignment that degrades gradually over weeks as a clock problem before assuming the modem’s schedule changed.
Related
- Wake-Process-Sleep Scheduling for Solar Nodes — aligning the wake schedule itself to solar and battery state using
rtcwakeand systemd timers. - Power Budgeting for GPS Duty-Cycled Nodes — sizing battery and solar capacity around GNSS fix-rate trade-offs.
- Monitoring & Observability — the thermal and queue-depth signals that feed the duty-ratio and thermal-ceiling decisions above.
- Message Queue Management at the Edge — how the transmit window this schedule targets is itself managed on the wire side.
- Edge Operations & Observability — the operational layer this energy budget belongs to alongside monitoring and recovery.