Skip to main content

How much does NTN connectivity for IoT cost in energy?

How much does NTN connectivity for IoT cost in energy?

Created: June 1, 2026
Updated: June 1, 2026

NTN, Non-Terrestrial Network, gets a lot of attention in the IoT space right now, and for good reason. Think of wildfire sensors deep in a forest, offshore monitoring buoys, or remote pipeline sensors where there is simply no cellular coverage. For those deployments, satellite connectivity is the only option. But what is the real energy cost of going via a satellite instead of a ground-based base station? That is exactly what this article tests and discusses.

This is the third article in our series on power consumption with the Nordic nRF9151. The two previous articles looked at PSM and eDRX on the terrestrial side:

  1. Measuring LTE Power Save Modes on Nordic nRF9151-DK
  2. Performance of LTE Power Save Modes on Nordic’s nRF9151-DK

This article picks up where those left off, but now adds a satellite path to compare against.

The NTN and cellular connectivity setup

For the terrestrial network we used Soracom, a global IoT connectivity provider. For the NTN connectivity we used Monogoto, which routes traffic via the Skylo IoT satellite service operating on S-band (Band 256). The satellite is a GEO satellite in a fixed position above the equator, meaning that from our location in Lund, Sweden, it sits roughly 30° above the southern horizon. A clear view to the south is therefore essential for a reliable connection.

Both boards were connected to Otii Ace Pro units for measurement and control:

  • The nRF9151 power rail is connected to the Otii Ace Pro main channel (banana connectors) — this is the path we measure.
  • A separate 5 V supply from the Otii Ace Pro 0–15 V pin (with DGND) powers the rest of the nRF9151-SMA-DK board, which needs power but is not part of what we want to measure.
  • UART TX and RX are connected to the Otii Ace Pro UART, enabling us to both monitor AT command output and control the boards programmatically.

One thing to be aware of: with the SLM application running, the UART block is always active, which adds a baseline of just under 600 µA to all current readings. This was characterised in the two previous articles. In a real product, the application runs directly on the modem and there is no UART overhead — so for real-world comparisons and absolute values to calculate battery budget, roughly 600 µA should be subtracted from all values. We will show both the measured values as well as the compensated values.

Both boards were configured with PSM using a 1-hour TAU timer and a 2-minute active window.

Because the GEO satellite is positioned above the equator, we had to go outdoors and find a location with an unobstructed view to the south — the satellite appears at roughly 30° elevation from Lund. Indoor or north-facing environments will not work.

To verify that data was actually reaching the intended destination, we set up a UDP server and sent short messages from each board.

To keep the AT command sequences manageable, we wrote a small Python script to automate the procedure — but everything it does could equally be sent manually in Otii desktop application terminal.

The script handled:

  1. Powering on and initialising both boards
  2. Acquiring a GNSS fix and waiting for a confirmed location
  3. Enabling NTN or terrestrial registration and waiting for full attachment
  4. Sending a UDP payload
  5. Entering PSM sleep
Python script available here. An active Automation Toolbox license is required to run it.
#!/usr/bin/env python3
'''
Otii 3 NTN vs Terrestrial communication

If you want the script to login and reserve a license automatically
add a configuration file called credentials.json in the current folder
using the following format:

    {
        "username": "YOUR USERNAME",
        "password": "YOUR PASSWORD"
    }

'''
import time
import os
from otii_tcp_client import otii_client
from datetime import datetime

PROJECT_FOLDER = os.path.join(os.getcwd(), 'NTN_vs_Terrestrial')

class AppException(Exception):
    '''Application Exception'''

def get_devices(otii):
    devices = otii.get_devices()
    if len(devices) == 0:
        raise AppException('No Ace devices connected!')
    
    device_NTN = None
    device_Terr = None
    
    for device in devices:
        if device.name == 'Ace_NTN':
            device_NTN = device
        elif device.name == 'Ace_Terrestrial':
            device_Terr = device
    
    if device_NTN is None and device_Terr is None:
        raise AppException('No Ace_NTN or Ace_Terrestrial found!')
    
    return device_NTN, device_Terr

def setup_device(device):
    device.set_main_voltage(3.6)
    device.set_exp_voltage(1.8)
    device.set_max_current(1)
    device.enable_5v(5)
    for channel in ['mc', 'mp']:
        device.enable_channel(channel, True)
        device.set_channel_samplerate(channel, 1000)
    device.enable_channel('rx', True)
    device.set_uart_baudrate(115200)

def cleanup(device, project):
    device.enable_5v(0)
    device.set_main(False)
    try:
        project.stop_recording()
    except:
        pass

def send(project, device, message, OK_text, error_text):
    ''' Wait for message on UART from one device or a list of devices.
        Returns a bool (single device) or dict of {device.id: bool} (list). '''
    recording = project.get_last_recording()
    devices = device if isinstance(device, list) else [device]
    for device in devices:
        device.write_tx(message + '\r\n')    
    previous_samples = {d.id: recording.get_channel_data_count(d.id, 'rx') for d in devices}
    error_message = {d.id: False for d in devices}
    found_message = {d.id: False for d in devices}
    last_value = {d.id: None for d in devices}

    while not all(found_message.values()):
        time.sleep(0.1)

        for d in devices:
            if found_message[d.id]:
                continue

            samples = recording.get_channel_data_count(d.id, 'rx')

            if samples > previous_samples[d.id]:
                rx_data = recording.get_channel_data(d.id, 'rx',
                                                     previous_samples[d.id],
                                                     samples - previous_samples[d.id])
                for data in rx_data['values']:
                    if error_text in data['value']:
                        print('Found error message: {}'.format(data['value']))
                        error_message[d.id] = True
                        found_message[d.id] = True
                        last_value[d.id] = data['value']
                        break
                    elif OK_text in data['value']:
                        print('Found OK message: {}'.format(data['value']))
                        found_message[d.id] = True
                        last_value[d.id] = data['value']
                        break
                previous_samples[d.id] = samples
    if isinstance(device, list):
        return last_value
    else:
        return last_value[devices[0].id]        

def NTN_Terestrial(otii):
    device_NTN, device_Terr = get_devices(otii)
    devices = [d for d in [device_NTN, device_Terr] if d is not None]

    project = otii.get_active_project()
    project.save_as(PROJECT_FOLDER)

    for device in devices:
        setup_device(device)
    
    project.start_recording()
    recording = project.get_last_recording()

    for device in devices:
        device.set_main(True)
    send(project, devices, '', 'Ready', 'ERROR')
    send(project, devices, 'ATE1', 'OK', 'ERROR') # Turn on echo

    while True:
        key = input('\nWhat do you want to do?\n'
            '  1: Get GNSS fix (both)\n'
            '  2: Set up devices\n'
            '  3: Connect Terrestrial\n'
            '  4: Connect NTN\n'
            '  5: Just send Terrestrial data\n'
            '  6: Just send NTN data\n'
            '  Q: Quit\n'
            '> ')
        key = key.strip()

        if key == '1':
            # Get GNSS fix (both)
#            send(project, devices, 'AT+CFUN=4', 'OK', 'ERROR')
            send(project, devices, 'AT+CFUN=0', 'OK', 'ERROR') # Try if this works as good as 4
            send(project, devices, 'AT%XSYSTEMMODE=0,0,1,0,0', 'OK', 'ERROR')
            send(project, devices, 'AT+CFUN=1', 'OK', 'ERROR')
            send(project, devices, 'AT#XGNSS=1,0,0,0', '#XGNSS: 1,1', 'ERROR')
            # wait for message like #XGNSS: 55.725886,13.170725,67.409645,28.614084,0.101129,0.000000,"2026-05-04 11:36:18"
            location = send(project, devices, '', '#XGNSS: 5', 'ERROR')
            if isinstance(location, list):
                location = location[0][8:].split(',')
            else:
                location = location[8:].split(',')
            print(f'\nlat: {location[0]}, long: {location[1]}, elevation: {location[2]}\n')
            send(project, devices, 'AT#XGNSS=0', 'OK', 'ERROR')
            send(project, devices, 'AT+CFUN=0', 'OK', 'ERROR')

        elif key == '2':
            # Set up devices
            if device_NTN is not None:
                send(project, device_NTN, 'AT%XSYSTEMMODE=0,0,0,0,1', 'OK', 'ERROR')
                send(project, device_NTN, 'AT%XBANDLOCK=2,,"23,255,256"', 'OK', 'ERROR')
                send(project, device_NTN, f'AT%LOCATION=2,"{location[0]}","{location[1]}","{location[2]}",0,0', 'OK', 'ERROR')
                send(project, device_NTN, 'AT+CGDCONT=0,"IP","data.mono"', 'OK', 'ERROR')
            if device_Terr is not None:
                send(project, device_Terr, 'AT%XSYSTEMMODE=0,1,0,0,0', 'OK', 'ERROR')
                send(project, device_Terr, 'AT%XBANDLOCK=0', 'OK', 'ERROR')
                send(project, device_Terr, 'AT+CGDCONT=0,"IP","soracom.io"', 'OK', 'ERROR')

            send(project, devices, 'AT+CPSMS=1,"00100001","00000001"', 'OK', 'ERROR')
            send(project, devices, 'AT+CEREG=5', 'OK', 'ERROR')
            send(project, devices, 'AT%MDMEV=2', 'OK', 'ERROR')
            send(project, devices, 'AT+CNEC=24', 'OK', 'ERROR')
            send(project, devices, 'AT+CSCON=3', 'OK', 'ERROR')

        elif key == '3':
            # Connect Terrestrial
            if device_Terr is None:
                print('Ace_Terrestrial is not connected, skipping.')
            else:
                send(project, device_Terr, 'AT+CFUN=1', 'OK', 'ERROR')
                send(project, device_Terr, '', '+CEREG: 5,', 'ERROR')
                send(project, device_Terr, 'AT+CEREG?', 'OK', 'ERROR')

        elif key == '4':
            # Connect NTN
            if device_NTN is None:
                print('Ace_NTN is not connected, skipping.')
            else:
                send(project, device_NTN, 'AT+CFUN=1', 'OK', 'ERROR')
                send(project, device_NTN, '', '+CSCON: 1,7,4', 'ERROR')
                send(project, device_NTN, 'AT+CEREG?', 'OK', 'ERROR')

        elif key == '5':
            # Just send Terrestrial data
            if device_Terr is None:
                print('Ace_Terrestrial is not connected, skipping.')
            else:
                send(project, device_Terr, 'AT#XSOCKET=1,2,0', '#XSOCKET:', 'ERROR')
                send(project, device_Terr, 'AT#XSENDTO=0,0,0,"37.27.2.53",5005,"Hello from Soracom on ground"', '#XSENDTO', 'ERROR')
                send(project, device_Terr, 'AT#XCLOSE=0', 'OK', 'ERROR')

        elif key == '6':
            # Just send NTN data
            if device_NTN is None:
                print('Ace_NTN is not connected, skipping.')
            else:
                send(project, device_NTN, 'AT#XSOCKET=1,2,0', '#XSOCKET:', 'ERROR')
                send(project, device_NTN, 'AT#XSENDTO=0,0,0,"37.27.2.53",5005,"Hello from Monogoto in space"', '#XSENDTO', 'ERROR')
                send(project, device_NTN, 'AT#XCLOSE=0', 'OK', 'ERROR')
        elif key.lower() == 'q':
            break

    for device in devices:
        cleanup(device, project)

def main():
    '''Connect to the Otii 3 application and run the measurement'''
    client = otii_client.OtiiClient()
    with client.connect() as otii:
        NTN_Terestrial(otii)

if __name__ == '__main__':
    main()

The measurements

The comparison is broken into three phases: GNSS fix, network registration, and data transmission.

GNSS fix

Getting a GNSS fix is only strictly required for the NTN path — the modem needs to know its location to determine which satellite to connect to. For terrestrial IoT, this step would normally be skipped entirely. We included it here as a separate, clearly isolated phase so that the cost is visible.

In this test the GNSS fix took approximately 34 seconds and consumed between 1.4 and 1.5 mWh. This is a one-time cost per wake cycle for the NTN board, and has no equivalent on the terrestrial side. If we compensate for the UART consumption, we will get in average 1.4 mWh

Network registration

After the GNSS fix, both boards were configured — one for NTN with PSM, one for terrestrial NB-IoT with PSM — and the radio was switched on to begin network registration.

The terrestrial registration via Soracom was fast and unremarkable:

The NTN registration via Monogoto/Skylo looked noticeably different:

And side by side the difference is striking:

EnergyCompensated
Terrestrial (Soracom, NB-IoT)266 µWh213 µWh
NTN (Monogoto/Skylo, S-band 256)2.95 mWh2.85 mWh

The NTN registration cost more than 13× the energy of the terrestrial equivalent.

Data transmission

With both boards registered, we sent a UDP packet to our server. The terrestrial board sent “Hello from Soracom on ground” and the NTN board sent “Hello from Monogoto in space” — both 28 characters.

EnergyCompensated
Terrestrial (Soracom, NB-IoT)203 µWh163 µWh
NTN (Monogoto/Skylo, S-band 256)2.45 mWh2.35 mWh

Again roughly 14× higher for the NTN path. It is also worth noting that during NTN transmission the current peaks reach several hundred milliamps. With a sample rate of 1000 samples per second, the actual peak currents are likely even higher than what the statistics show — important to keep in mind when sizing your energy storage and power source.

How much does NTN connectivity cost in energy?

PhaseTerrestrialNTNRatio
GNSS fix— (not required)~1.40 mWh
Network registration213 µWh2.85 mWh~13×
Data transmission (28 bytes)163 µWh2.35 mWh~14×

The results confirm what the physics would predict. A GEO satellite sits approximately 35,800 km above the equator. Transmitting to a target that far away requires significantly more power than reaching a ground station a few kilometres away, and the link setup takes longer due to the propagation delay and the more complex handshaking the NTN standard requires.

Does this mean NTN connectivity is not worth using? Not at all. If your devices operate in areas with no cellular coverage — remote forests, open sea, mountain regions — then NTN is the only viable option, and the energy cost is simply a design constraint to accommodate. Long PSM sleep periods between transmissions will keep the average power budget manageable even with the higher per-transmission cost.

What the numbers here do tell you is that the energy storage in an NTN device needs to be sized to handle the peak currents during transmission, and that the battery life calculation must account for the GNSS fix and registration costs on every wake cycle, not just the payload transmission itself.

One aspect not covered here is the subscription cost difference between NTN and terrestrial connectivity — that is a separate topic worth evaluating for any deployment, but outside the scope of this power measurement article.


Bonus: Download the Otii project here and analyze the measurements yourself. No Otii instrument is required; just open the project in the Otii Desktop App to inspect the current traces and energy results.


All measurements were made with the Otii Ace Pro. Boards: nRF9151-SMA-DK × 2. Modem firmware: mfw_nrf9151-ntn_1.0.0. Application firmware: nrf9151dk_serial_modem_v1.0.0.hex. Location: Lund, Sweden, outdoors with clear southern sky view.

Sign up for more technical articles

A monthly dose of articles, tips & tricks, and know-how – everything you need to extend battery life in embedded electronics and IoT.