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.

The sleep-wake-transmit-sleep duty cycle for a field node Five stages flow left to right: the node sleeps with the RTC armed, the RTC wakes it and resumes userspace, it senses and processes a batch of geometry, it transmits inside the modem's registered window, and it returns to sleep with the next alarm armed, closing the loop. Sleep RTC armed, ~20 mA RTC wake resume userspace Sense / process duty window Transmit window aligned to modem Sleep next alarm armed
The whole pipeline is a closed loop: an RTC alarm is the only thing spanning sleep to wake.

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_governor to performance only for the duration of the sense/process stage, then drop it to powersave (or conservative if the kernel lacks powersave on this platform) before arming the RTC. Leaving ondemand active 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:

  1. 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_MA figures come from; datasheet TDP numbers are consistently optimistic against real board draw.
  2. 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.
  3. Wake-to-transmit latency. Timestamp rtcwake return, 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.
  4. 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.
  5. Duty-ratio drift under adaptation. Log the ratio next_duty_seconds actually 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_seconds reacts 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 between MIN_DUTY_S and MAX_DUTY_S on 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.