25  Ranging

org.arl.unet.Services.RANGING

25.1 Overview

The RANGING service measures the distance between nodes, and as a by-product provides clock synchronization between them. Both rely on the fact that sound travels through water at a known, finite speed (about 1500 m/s): if we can measure how long a signal takes to travel between two nodes, we can infer the distance between them.

UnetStack supports two ranging methods:

  • Two-way travel time (TWTT) ranging requires no prior synchronization. The local node interrogates a peer (using a RangeReq); the peer responds, and the local node measures the round-trip time. After subtracting the known turnaround delay at the peer, the one-way travel time — and hence the range — is computed. This is the method used by the range command.
  • One-way travel time (OWTT) ranging requires the two clocks to be synchronized. A node transmits a timestamped beacon (using a BeaconReq), and a receiver compares the arrival time against the timestamp to obtain the travel time directly. OWTT is more efficient (no round trip) and supports passive, one-to-many ranging.

The interrogation/response exchange also yields the offset between the two nodes’ physical-layer clocks, which can be used to synchronize them. Once synchronized, nodes can perform OWTT ranging and time-coordinated protocols. Per-peer synchronization state (validity, last sync time, clock offset, lifetime) is tracked in the node-specific parameters below. Because the interrogation and response can be configured as arbitrary acoustic signals, the service can also range to commercial-off-the-shelf (COTS) acoustic transponders that are not Unet nodes. The default ranging agent is accessed as ranging.

25.2 Messages

Agents providing this service honor the following requests:

  • RangeReq – measure range to a node

    Field Type Default Remarks
    data byte[] payload to piggyback on the interrogation
    requestLocation boolean false true to also request the peer’s location
    to int -1 node to range to
  • BeaconReq – transmit a timestamped beacon

    Field Type Default Remarks
    data byte[] payload to piggyback on the beacon
    to int 0 target node (defaults to broadcast)
    txLocation boolean false true to include this node’s location in the beacon
    type int 0
  • RespondReq – respond to an interrogation

    Field Type Default Remarks
    data byte[] payload to piggyback on the response
    rxStartTime long 0
    to int 0
    type int 0

A RangeReq is answered with an AGREE once the interrogation begins (REFUSE if the request is invalid). When the exchange completes, the measured range is reported to the requester as a RangeNtf (a copy is also published on the agent’s topic); if the peer does not respond, the requester receives a FailureNtf with the reason instead. Notifications generated by this service:

  • RangeNtf – measured range to a node

    Field Type Default Remarks
    data byte[] payload piggybacked by the peer
    from int 0
    location double[] this node’s own location
    offset long clock offset in μs (present for TWTT only)
    peerLocation double[] peer’s location, if requested and available
    range float measured range in meters
    rxStartTime long
    to int 0
  • InterrogationNtf – interrogation received from a peer

    Field Type Default Remarks
    data byte[]
    from int 0
    responded boolean false true if the agent already auto-responded
    rxStartTime long 0
    to int 0
    type int 0

A node that hears a timestamped beacon (and has valid synchronization with its sender) computes the travel time passively, and the resulting RangeNtf is published on the agent’s topic. A node that is interrogated publishes an InterrogationNtf on its topic before responding — an agent may subscribe to these and, when automatic response is disabled, use a RespondReq to control whether and how the node responds (e.g. with a data payload piggybacked on the response). Peer location, when requested, is delivered on the peerLocation field of the RangeNtf rather than as a separate message.

25.3 Parameters

  • phy :: String – physical agent for ranging
  • baseband :: String – baseband agent for signal transmission/reception
  • mac :: String (nullable) – MAC agent or null to disable MAC
  • maxRange :: float – default maximum range to peer node (m)
  • channel :: int – default channel for interrogation (CONTROL/DATA)
  • ipreamble :: int – default interrogation preamble (0 = none)
  • rpreamble :: int – default response preamble (0 = none)
  • rsignal :: float[] – default baseband response signal ([] = none)
  • rdelay :: float – default response delay (seconds)
  • respond :: boolean – automatically respond to an interrogation
  • threshold :: float – threshold for signal detection (0 to 1)

25.4 Node-specific parameters

Ranging to a specific peer node can be configured using indexed parameters, accessed as ranging[address]:

  • address :: int – peer node address
  • channel :: int – channel for interrogation (CONTROL/DATA)
  • ipreamble :: int – interrogation preamble (0 = none)
  • isignal :: float[] – baseband interrogation signal ([] = none)
  • rpreamble :: int – response preamble (0 = none)
  • rsignal :: float[] – baseband response signal ([] = none)
  • rdelay :: float – response delay (seconds)
  • sync :: boolean – PHY clock synchronization valid
  • lastSync :: long – time of last synchronization (epoch milliseconds)
  • offset :: long – PHY clock offset to peer node (microseconds)
  • lifetime :: long – synchronization validity lifetime (seconds)
  • maxRange :: float – maximum range to peer node (m)
  • data :: byte[] – payload data to piggyback on interrogation/response frame
  • threshold :: float – threshold for signal detection (0 to 1)

25.5 Commands

  • range – get ranging information from a node

    Examples:

    range 21                 // get range to node 21
    
  • beacon – send a timestamped beacon

    Examples:

    beacon                   // broadcast a timestamped beacon

25.6 Examples

The examples below use a simple two-node network, with nodes A and B 600 m apart:

25.6.1 Measuring range (TWTT)

The simplest way to measure a range is the range command, which performs a TWTT interrogation of the peer and returns the measured distance in meters:

> range host('B')
601.8424

From an agent (Chapter 33), the same exchange is driven by sending a RangeReq to the ranging agent and handling the RangeNtf that comes back:

void startup() {
  def ranging = agentForService(org.arl.unet.Services.RANGING)
  ranging << new RangeReq(to: peer)
}

void processMessage(Message msg) {
  if (msg instanceof RangeNtf)
    log.info "range = ${msg.range} m, offset = ${msg.offset} μs"
}

We can see this directly from the shell. Sending the RangeReq ourselves yields a RangeNtf that carries both the measured range and — because TWTT exchanges timestamps in both directions — the offset between the two nodes’ clocks:

> ranging << new RangeReq(to: host('B'))
AGREE
ranging >> RangeNtf:INFORM[from:232 to:31 range:601.8424 offset:-2160147085 rxStartTime:2640934762]

Under the hood, node A transmits a timestamped interrogation frame; node B replies with a timestamped response after a known turnaround delay. From the four timestamps involved (transmit and receive at each end), the agent solves simultaneously for the one-way travel time (the range) and the clock offset between the nodes. The range needs no prior synchronization — that is the appeal of TWTT — and the offset it produces is what makes synchronization, and subsequently OWTT ranging, possible.

25.6.2 Synchronization

Each TWTT range measurement leaves the interrogating node with an estimate of its clock offset to the peer. (The peer, having only responded, does not itself learn the offset — synchronization is directional.) The agent stores this per-peer state, which we can inspect through the node-specific parameters:

> ranging[host('B')]
« RANGING »

[org.arl.unet.localization.RangingNodeParam]
  address ⤇ 31
  channel = 1
  data = []
  ipreamble = 0
  isignal = []
  lastSync ⤇ 1783523734404
  lifetime = 0
  maxRange = 3000.0
  offset = -2160147085
  rdelay = 0.0
  rpreamble = 0
  rsignal = []
  sync ⤇ false
  threshold = 0.3

The offset and lastSync (the epoch time of the last measurement) have been recorded, but sync reads false. That is because synchronization has a finite validity: the sync flag is true only while lastSync + lifetime lies in the future, and the lifetime here is still 0. Clocks drift, so an offset measured long ago is no longer trustworthy. Setting a suitable lifetime (in seconds) tells the agent how long to consider the synchronization valid:

NoteEpoch time

Times like lastSync are Unix epoch times — milliseconds elapsed since 00:00:00 UTC on 1 January 1970, the convention most computer clocks share. To read one as a date on the shell, convert it with java.time.Instant:

> java.time.Instant.ofEpochMilli(ranging[host('B')].lastSync)
2026-07-04T09:15:32.417Z
> ranging[host('B')].lifetime = 3600
3600
> ranging[host('B')].sync
true

With a one-hour lifetime, the recent measurement now counts as valid synchronization.

NoteChoosing a lifetime

The right lifetime depends on how fast the nodes’ clocks drift relative to the range accuracy you need. Concretely, it is the time for the accumulated clock drift — scaled by the speed of sound — to grow beyond your acceptable ranging error. Modems with ordinary crystal oscillators may only hold synchronization to within a useful bound for minutes to tens of minutes; modems with temperature-compensated (TCXO) or oven-controlled (OCXO) oscillators can hold it for hours or days. Set the lifetime conservatively for your hardware, and re-range whenever synchronization lapses.

25.6.3 One-way travel time ranging

Once a node is synchronized to a peer, it no longer needs a round trip: a single timestamped transmission from the peer is enough to measure range. Let node A subscribe to its ranging agent, and have node B transmit a beacon (equivalent to ranging << new BeaconReq()):

> beacon
AGREE

Because node A already has valid synchronization to node B, it computes the range from the single beacon and publishes a RangeNtf on its ranging topic (note the absence of an offset field — this is a one-way measurement):

ranging >> RangeNtf:INFORM[from:232 to:31 range:601.8424 rxStartTime:2648670762]

OWTT is efficient and scales naturally: one beacon can be ranged by every synchronized listener in earshot, making it well suited to passive, one-to-many localization. In fact, any timestamped frame serves as a beacon — a node that transmits application data with phy << new TxFrameReq(timestamped: true, data: ...) lets synchronized receivers range on it while they decode the data, so ranging can ride along on ordinary traffic at no extra channel cost.

25.6.4 Per-node configuration

Most ranging parameters have an agent-wide default and a per-node override, set through the indexed ranging[address] parameters. This lets each link be tuned independently — a different channel, turnaround delay, maximum range, synchronization lifetime, or interrogation/response signal for each peer. For example, ranging uses the robust CONTROL channel by default; to range to node B over the higher-rate DATA channel instead, we override the channel just for that link:

> ranging[host('B')].channel = DATA
2

An unset per-node parameter falls back to the agent-wide default, so you only need to specify the ones that differ for a given peer. The per-node signal parameters (ipreamble, isignal, rpreamble, rsignal) are what make ranging to non-Unet transponders possible (see Section 25.6.7).

25.6.5 Reporting peer location

A range on its own gives distance but not direction. If the peer knows its own location (from its NODE_INFO service, Chapter 14), we can ask it to report that location as part of the ranging exchange by setting requestLocation on the RangeReq. The peer’s location comes back on the peerLocation field of the RangeNtf:

> ranging << new RangeReq(to: host('B'), requestLocation: true)
AGREE
ranging >> RangeNtf:INFORM[from:232 to:31 range:601.8424 offset:-2160147085 rxStartTime:2654675762 peerLocation:600,0,-15]

Here node B reports its location as [600, 0, -15], which — combined with the measured range — lets node A place its peer. Because the response now has to carry the encoded location, requestLocation needs the room of a DATA frame; that is why we switched node B’s link to the DATA channel above. A node can also volunteer its location without being asked, by setting txLocation on a BeaconReq, so that passive listeners receive both a range and the beacon’s location.

25.6.6 Controlling interrogation responses

By default (respond = true), the ranging agent answers interrogations automatically — this is what let node A range node B above without any action on B’s part. The response is still visible: when interrogated, a node publishes an InterrogationNtf on its topic. Subscribing to node B’s ranging agent and ranging it from A shows this:

> range host('B')
601.8424
ranging >> InterrogationNtf:INFORM[type:DATA from:232 to:31 rxStartTime:499951677 responded:true]

The responded flag is true, indicating the agent has already sent the automatic response. Sometimes, though, we want an application to decide whether and how to respond — for example, to attach sensor data to the reply, or to answer only selected interrogators. Setting respond = false disables the automatic response, so the agent emits the InterrogationNtf and then waits:

> ranging.respond = false
false
> ranging << new RangeReq(to: host('B'))
AGREE
ranging >> InterrogationNtf:INFORM[type:DATA from:232 to:31 rxStartTime:505593677 responded:false]

Now responded is false: node B has not replied, and node A’s interrogation will simply time out unless something on node B responds. An agent does that by handling the InterrogationNtf and issuing a RespondReq — optionally with a data payload that is piggybacked on the response frame and delivered to the interrogator on the data field of its RangeNtf:

void startup() {
  subscribe agentForService(org.arl.unet.Services.RANGING)
}

void processMessage(Message msg) {
  if (msg instanceof InterrogationNtf && !msg.responded) {
    // decide whether to answer, and with what payload
    def reply = new RespondReq(msg)     // copies to/type/rxStartTime from the interrogation
    reply.data = sensorReading()        // optional piggybacked payload
    agentForService(org.arl.unet.Services.RANGING) << reply
  }
}

A response must be sent promptly — before the interrogator’s turnaround window closes — so the handler should be quick and non-blocking.

25.6.7 Ranging to transponders

The interrogation and response need not be Unet frames at all. Because the per-node signal parameters let us specify an arbitrary acoustic interrogation signal to transmit and an arbitrary response signal to expect, a Unet modem can range to a plain acoustic transponder. Suppose we have a COTS transponder that replies to a 22 kHz, 2 ms pulse with a 30 kHz, 2 ms pulse, 30 ms later. We welcome it into our network as a guest node — say node T with host('T') = 152 — and describe the link to it:

> T = host('T')
152
> ranging[T].isignal = cw(22.kHz, 2.ms);   // interrogation pulse to transmit
> ranging[T].rsignal = cw(30.kHz, 2.ms);   // response pulse to expect
> ranging[T].rdelay = 30.ms;               // transponder turnaround delay

The 22.kHz and 2.ms notation is Groovy unit sugar, converting the written values to UnetStack’s preferred units — Hz and seconds (see Chapter 34). With the link configured, ranging to the transponder is then just:

> range T
235.7

The modem transmits the 22 kHz interrogation pulse, listens for the 30 kHz reply after the 30 ms turnaround, and converts the measured travel time into a range. The threshold parameter (0–1) sets how strong the matched-filter detection of the reply must be to count. This signal-based ranging needs a real modem (with the BASEBAND service) to generate and detect the pulses; it is not reproduced by the simulator.

The reverse is just as easy: a Unet node is already a transponder to other Unet nodes — with respond = true it answers their interrogations automatically, as we saw above. To make a node reply with a specific acoustic signature instead of a Unet frame — so that a third-party interrogator expecting a particular pulse can range to it — configure the response signal and turnaround delay it should use:

> ranging[host('X')].rsignal = cw(30.kHz, 2.ms);   // reply pulse to transmit
> ranging[host('X')].rdelay = 30.ms;               // our turnaround delay

Between the interrogator-side configuration (isignal/rsignal/rdelay) and the responder-side configuration (rsignal/rdelay, with respond/RespondReq), the ranging agent can interoperate with a wide range of acoustic ranging equipment.

25.7 Implementation

25.7.1 Ranging (ranging)

Class Services Capabilities Availability
Ranging RANGING default stack

The default implementation is the ranging agent, loaded as part of the default stack (Chapter 13).

25.7.1.1 How it works

Ranging exploits the finite, known speed of sound in water: if we can measure how long a signal takes to travel between two nodes, we can infer the distance. In two-way travel time (TWTT) ranging, the local node sends a timestamped interrogation and the peer replies after a known turnaround delay; from the four timestamps involved (transmit and receive at each end) the agent computes both the one-way travel time — and hence the range — and the offset between the two nodes’ clocks. One-way travel time (OWTT) ranging instead uses a single timestamped beacon and requires the clocks to already be synchronized, which makes it more efficient and able to range to many listeners at once.

The clock offset obtained during TWTT is what makes OWTT and time-coordinated protocols possible. Per-peer synchronization state is tracked separately for each peer: the measured offset, the lastSync time it was obtained, and a configurable lifetime. Synchronization is considered valid — and OWTT ranging permitted — only while lastSync + lifetime remains in the future, so the lifetime should be set to reflect how long the modem’s clock stays accurate enough. The sound speed used to convert travel times to distances comes from the NODE_INFO service (Chapter 14).

The interrogation and response can each be an ordinary Unet frame, a bare preamble, or an arbitrary baseband signal. This flexibility, exposed through the per-node ipreamble/isignal/rpreamble/rsignal parameters, is what allows ranging to COTS acoustic transponders that are not Unet nodes. When signals are used, the agent relies on the BASEBAND service (Chapter 27) to transmit and detect them, matched-filtering the reply against the expected signal at the configured threshold. It can also use the MAC service (Chapter 19) to schedule its transmissions so they do not collide with other traffic.

25.7.1.2 Usage notes

  • For OWTT ranging and time-coordinated protocols, range to the peer first to establish synchronization, and set a lifetime appropriate to your modem’s clock stability.
  • Synchronization is directional: TWTT synchronizes the interrogating node to the peer, not the other way around. If both nodes need synchronization, each should range the other.
  • Requesting peer location (requestLocation) adds the encoded location to the response frame; use the DATA channel for that link so the frame has room.
  • Disable MAC scheduling (set the MAC parameter to 'none') on quiet links where channel contention is not a concern. ```