Module reference · Uplink
Uplink Communication
The stage where a study configures the communication condition on the command path — the delay, and any other impairment, applied to everything travelling from operator to robot. It exists so that communication is an explicit experimental factor you can set, report and compare, instead of a constant buried in whichever controller happened to need it. Moving from a baseline to a delayed condition changes one configured field; the input device, the vehicle, the assistance and the task stay exactly as they were.
What
Uplink Communication sits between the operator side of the loop and the robot side. In brief:
- It is a required module: a study always has exactly one, and every command the stages upstream produce passes through it.
- It holds a list of channels — one per command stream, identified by its stream id, each with its own scheduled queue.
- A communication condition is an object that decides, per package, how long it waits and whether it is sent at all. It is optional, and configured per channel — so one stream can be degraded while another is left alone. No condition means immediate delivery.
- The Kernel ships no delay, jitter, loss or bandwidth models. A condition is written by the study.
- A condition either suppresses a package or returns one transmission delay.
- The Kernel only stores and releases scheduled packages. It does not implement latest-wins, coalescing or retransmission.
The downlink uses the same mechanism.
DownlinkCommunicationKernel (§5.8) is built
identically and uses the same CommunicationCondition
base type — the extension point is direction-agnostic. Each
channel owns its own configured instance and its own state, so
the two directions are degraded independently. Its page carries
the same explanation:
Downlink Communication.
The module itself is sealed — not abstract, not generic.
You never subclass it. What a study writes, if anything, is a
condition.
Why
This module is the uplink half of DR1, configurable communication conditions, the first design requirement in UNITE's scoping review. The review found delay imposed on control commands, on video, on telemetry and on force feedback. It was fixed in some studies, followed a time-varying function in others, and was sampled from a distribution in others again, sometimes alongside impairments like packet loss. The mechanism was almost never described well enough to copy.
DR1 concludes that a reported delay magnitude alone is not enough to reconstruct the condition that produced it. The requirement is therefore not "support delay" but to make five things explicit and configurable: the stream affected, the direction, the delay magnitude, its temporal behaviour, and any additional impairment. Channels supply the first, having a module on each path supplies the second, and the condition supplies the rest.
The direction is easy to underrate. Maag et al. compared 200/0, 0/200 and 100/100 ms uplink/downlink splits and found the same total delay produced different results depending on how it was allocated. A round-trip figure cannot express that, so uplink and downlink are configured separately here instead of sharing one latency setting.
Make the directional split explicit. The study UNITE reconstructs reports a fixed 2.56 s round-trip delay without stating how it divides between the two directions. The shipped demo makes that choice explicit: 1,280 ms uplink and 1,280 ms downlink. A study reproducing the paper's earlier uplink-only assumption can still configure the full delay on this module and leave downlink clean.
The module is required; the condition is not. Commands travel the same path whether or not a study degrades them, so a baseline arm is an empty Condition field rather than different code. Between two arms of a study, the only difference is the factor being manipulated.
How
The whole model is one line — package → channel for its stream → optional condition → scheduled queue → release — and only the condition belongs to the study. The rest is the same for every package on every channel.
Routing by stream id
Packages arrive by event, not by tick. Whatever stage sits upstream —
Operator-side Assistance (uplink) if the study uses one, otherwise Command
Mapping & Encoding — calls ReceivePackage the moment
it publishes. The module trims the package's StreamId and
looks it up in its channel dictionary; a null package, a
blank stream id, or one with no matching channel each log an error and
go no further.
Who does what: the path a package takes
Only one step of the path belongs to the study. The rest is the same for every package on every channel:
The default: no condition, immediate delivery
An empty Condition field is the undegraded baseline. The package still uses the channel queue, but its zero delay allows immediate release.
How long, or whether: the two outcomes
When a channel does have a condition, it is asked one question per package, and there are exactly two possible answers. A condition either suppresses the package or returns one transmission delay.
bool TryGetTransmissionDelay(
Package package,
double ingressTimestampSeconds,
out double delaySeconds);
| Returns | Meaning | Effect |
|---|---|---|
false |
Not transmitted |
Nothing is queued, and delaySeconds is ignored.
Nothing downstream is told the package went missing.
|
true |
Scheduled once |
The package is queued for release
delaySeconds after it entered the channel.
|
The condition schedules at most one delivery per package; it cannot duplicate, reorder, or later revise a package already handed to the queue.
When a package is released
The queue releases due packages during the module step; a zero-delay package can also release immediately when it enters. The module adds no intentional latency, but release is checked at the next fixed tick, so the realised delay can be rounded up by at most one tick. The constant-delay example shows this.
Order of release
Packages release in ascending scheduled-release-time order, with arrival time breaking ties. A variable-delay condition can therefore reorder a stream; preserving order is a property of the chosen condition, not a guarantee of the queue.
Boundary
Owns
- Routing each package to the channel matching its stream id, and rejecting it if none does.
- Asking the channel's condition, if it has one, how long each package waits and whether it is sent.
- Storing scheduled packages and releasing them, unchanged, when due.
Does not own
- Defining any delay, jitter, loss or bandwidth model. Those are the study's.
- Inspecting what a payload means. Conditions are payload-agnostic by design.
- Replacing or removing a package already held in the queue, or any queue discipline — see the Boundary section.
- Producing or shaping the command, acting on it, or the feedback path.
Contract
This stage declares no payload contract of its own. A
Package goes in and the very same
Package comes out, later.
What it declares is the extension point,
CommunicationCondition. A condition
must implement TryGetTransmissionDelay,
which is handed the channel ingress timestamp so a condition
modelling service capacity or queueing has what it needs. It should
also override Reset() when it keeps state or
randomness:
[Serializable]
public abstract class CommunicationCondition
{
// Return false to suppress the package. Return true and set delaySeconds
// to schedule exactly one transmission. A package cannot produce
// multiple deliveries through this contract.
public abstract bool TryGetTransmissionDelay(
Package package,
double ingressTimestampSeconds,
out double delaySeconds);
// Called when the channel initialises. Stateful conditions clear their
// state here so every trial starts identically.
public virtual void Reset()
{
}
}
| Member | Kind | Override it when |
|---|---|---|
TryGetTransmissionDelay |
abstract |
Always — it is abstract, so every condition implements it. It is handed the channel ingress timestamp, so a condition that models a link with memory has what it needs. |
Reset() |
virtual |
The condition keeps state or a random stream, so a repeated trial must start from the same place. |
A condition is a plain [Serializable] class — not a
MonoBehaviour, not a ScriptableObject. It is
stored inside the channel by [SerializeReference], which
is what lets the Inspector offer every subclass in a dropdown.
One validity rule applies to whatever the condition returns. A
delaySeconds that is NaN, infinite or
negative is rejected: the channel logs an error and drops the package
rather than queueing it. So a condition that computes a delay
arithmetically should clamp its own result — the
jitter example floors its
draw at zero for exactly this reason.
The Package envelope is documented in full on the
Operator-side Assistance (uplink) page. StreamId is the routing key;
Payload is the field to treat carefully. A condition that
pattern-matches on a concrete payload type stops being reusable
across streams and across the downlink. Asking every payload the same question
through an interface does not — see
the bandwidth example, where the
condition needs a size and only the payload can supply one.
Examples
Three worked conditions: a constant delay, a variable delay, and a constrained bandwidth link.
Each tab gives the condition, then the release schedule it produces. Those schedules can be re-derived from the code, given the inputs each table states and three facts about the machinery:
-
The agent samples the clock once per tick, so every
package entering a channel during one
FixedUpdateshares the sameingressTimestampSeconds. -
A release time is absolute: the channel queues the
package at
ingress + delaySeconds. -
The queue releases while
releaseTime ≤ now, checked on every tick and again immediately when a package is scheduled.
The examples assume the demo's 50 Hz loop producing one command per
tick on wheel-velocity-command, with ticks landing on
0.000 s, 0.020 s, 0.040 s and so on. That last part is an
idealisation for readable numbers — real timestamps come from
Time.realtimeSinceStartupAsDouble and are wall-clock — but
nothing about the behaviour depends on it.
The condition the first study pattern needs, and the one the demo
ships — what reconstructs a paper reporting a single delay figure. It
always returns true with the same delay, keeps no state,
and never needs the ingress timestamp it is handed:
[Serializable]
public sealed class EveryMoveFixedDelayCondition : CommunicationCondition
{
[SerializeField] private EveryMoveCommunicationConfiguration configuration;
[SerializeField, Min(0f)] private float fallbackDelayMilliseconds = 2560f;
[SerializeField] private bool useConfiguredDownlinkDelay;
[SerializeField] private float channelDelayOverrideMilliseconds = -1f;
public override bool TryGetTransmissionDelay(
Package package,
double ingressTimestampSeconds,
out double delaySeconds)
{
float delay = fallbackDelayMilliseconds;
if (channelDelayOverrideMilliseconds >= 0f)
{
delay = channelDelayOverrideMilliseconds;
}
else if (configuration)
{
delay = useConfiguredDownlinkDelay
? configuration.downlinkDelayMilliseconds
: configuration.uplinkDelayMilliseconds;
}
delaySeconds = delay / 1000d;
return true;
}
}
It never touches package, always adds exactly one delay,
and reads that delay from a shared configuration asset — so a whole
study's delay lives in one file that can be swapped and versioned.
useConfiguredDownlinkDelay flips which field of the asset
it reads, which is how the same class serves a downlink channel.
Set channelDelayOverrideMilliseconds to
250 and the first three commands of a trial go through like
this:
| Command | Ingress | Delay returned | Scheduled release | Released on tick | Realised delay |
|---|---|---|---|---|---|
| 1 | 0.000 | 0.250 | 0.250 | 0.260 | 260 ms |
| 2 | 0.020 | 0.250 | 0.270 | 0.280 | 260 ms |
| 3 | 0.040 | 0.250 | 0.290 | 0.300 | 260 ms |
The stream is shifted, not distorted: the commands still arrive 20 ms apart, in the order they were issued, and the robot simply starts and stops a quarter-second late. Shifting the operator's experience without distorting the command stream is what makes a constant delay usable as an experimental factor.
The realised delay is 260 ms, not the 250 ms configured. Every package is rounded up to the next tick by the same amount, because 250 is not a multiple of the 20 ms tick. Configure a multiple of the tick and the two figures agree; configure anything else and the number in your methods section is not the number the apparatus produced.
A delay drawn per package instead of fixed. This one needs a random
stream, which means it also needs Reset() — otherwise a
repeated trial would continue the previous trial's sequence instead of
replaying it:
[Serializable]
public sealed class JitterDelayCondition : CommunicationCondition
{
[SerializeField, Min(0f)] private float baseDelayMilliseconds = 200f;
[SerializeField, Min(0f)] private float jitterMilliseconds = 60f;
[SerializeField] private int seed = 20260817;
private System.Random random;
// Called when the channel initialises, so every trial replays the same
// draws from the same seed.
public override void Reset()
{
random = new System.Random(seed);
}
public override bool TryGetTransmissionDelay(
Package package,
double ingressTimestampSeconds,
out double delaySeconds)
{
random ??= new System.Random(seed);
// Uniform in [-jitter, +jitter] around the base delay, floored at zero
// because a negative delay is rejected by the channel.
double offset = (random.NextDouble() * 2d - 1d) * jitterMilliseconds;
delaySeconds =
Math.Max(0d, (baseDelayMilliseconds + offset) / 1000d);
return true;
}
}
With a 200 ms base and ±60 ms of jitter, four consecutive
commands can land like this. Read the draw column as a
stipulated input, not as output: these four values are
hand-picked to show reordering inside four packages, not read from the
seed above, whose sequence depends on the runtime's
System.Random. Every other column then follows from the
code. Any spread wider than the 20 ms command interval produces
the same effect:
| Command | Ingress | Example draw | Delay returned | Scheduled release | Released on tick |
|---|---|---|---|---|---|
| 1 | 0.000 | +40 ms | 0.240 | 0.240 | 0.240 |
| 2 | 0.020 | −55 ms | 0.145 | 0.165 | 0.180 |
| 3 | 0.040 | +12 ms | 0.212 | 0.252 | 0.260 |
| 4 | 0.060 | −30 ms | 0.170 | 0.230 | 0.240 |
The commands were issued 1, 2, 3, 4 and are delivered 2, 4, 1, 3. Command 4 precedes command 1 even though both surface on the tick at 0.240, because the queue sorts on scheduled release time and 0.230 comes first. This is the Order of release section stated as an outcome rather than a caveat: nothing clamped the stream back into arrival order, because nothing in the Kernel does.
Whether that is a flaw or the point depends on the study. If it is a flaw — if you want jitter in the timing without commands overtaking each other — the condition has to enforce it, by remembering the last release time it handed out and never scheduling earlier than that:
// Monotonic variant: jitter, but never out of order.
double releaseSeconds = ingressTimestampSeconds + jitteredDelaySeconds;
releaseSeconds = Math.Max(releaseSeconds, lastReleaseSeconds);
lastReleaseSeconds = releaseSeconds;
delaySeconds = releaseSeconds - ingressTimestampSeconds;
That turns the condition stateful, so lastReleaseSeconds has
to be cleared in Reset() too. Which variant a study used is
exactly the sort of thing that belongs in its methods section.
The first two conditions answer independently for each package. A bandwidth-limited link cannot: a package waits for the ones already on the wire. The condition tracks when the link next falls free, and every answer depends on that one number:
[Serializable]
public sealed class BandwidthLimitedCondition : CommunicationCondition
{
[SerializeField, Min(1f)] private float linkRateBitsPerSecond = 50000f;
// Used only for payloads that do not report a size of their own.
[SerializeField, Min(1f)] private float fallbackPacketBits = 2000f;
// Shed rather than queue once the backlog exceeds this.
[SerializeField, Min(0f)] private float maximumQueueingMilliseconds = 200f;
private double nextFreeSeconds;
public override void Reset()
{
nextFreeSeconds = 0d;
}
public override bool TryGetTransmissionDelay(
Package package,
double ingressTimestampSeconds,
out double delaySeconds)
{
// Ask the payload how big it declares itself to be. This is a question,
// not a type check: any payload implementing the interface works, so the
// condition stays reusable across streams and both directions.
double bits = package.Payload is IDeclaredSizePayload sized
? sized.DeclaredBits
: fallbackPacketBits;
double serialisationSeconds = bits / linkRateBitsPerSecond;
// The link is busy until nextFreeSeconds; this package starts then,
// or now if the link is already idle.
double startSeconds =
Math.Max(ingressTimestampSeconds, nextFreeSeconds);
double queueingSeconds = startSeconds - ingressTimestampSeconds;
if (queueingSeconds > maximumQueueingMilliseconds / 1000d)
{
// Backlog too deep: suppress instead of queueing further.
delaySeconds = 0d;
return false;
}
nextFreeSeconds = startSeconds + serialisationSeconds;
delaySeconds = nextFreeSeconds - ingressTimestampSeconds;
return true;
}
}
The delay it returns is two things added together, and the example figures are chosen so both are visible. Serialization is declared size ÷ link rate: 2000 bits at 50 kbit/s takes 40 ms. Queueing is however long the link was still busy when the package arrived. Since commands are offered every 20 ms but take 40 ms each, the link is offered exactly twice the traffic it can carry, and the backlog grows by 20 ms per command:
| Command | Ingress | Link free at | Queueing | Delay returned | Outcome |
|---|---|---|---|---|---|
| 1 | 0.000 | — | 0 ms | 0.040 | Released 0.040 |
| 2 | 0.020 | 0.040 | 20 ms | 0.060 | Released 0.080 |
| 3 | 0.040 | 0.080 | 40 ms | 0.080 | Released 0.120 |
| 11 | 0.200 | 0.400 | 200 ms | 0.240 | Released 0.440 |
| 12 | 0.220 | 0.440 | 220 ms | — | Suppressed |
| 13 | 0.240 | 0.440 | 200 ms | 0.240 | Released 0.480 |
| 14 | 0.260 | 0.480 | 220 ms | — | Suppressed |
| 15 | 0.280 | 0.480 | 200 ms | 0.240 | Released 0.520 |
Commands 4 to 10 continue the pattern of the first three: each waits
20 ms longer than the last. Command 11 hits the 200 ms ceiling
exactly — and is still admitted, because the code sheds only when
queueing is > the budget, not when it equals it. From
command 12 the channel alternates one shed, one released, settling at
25 Hz with the delay pinned at 240 ms, which is
exactly what a 40 ms serialisation time can carry.
The alternation comes from the boundary case. Shedding command 12 leaves
nextFreeSeconds untouched at 0.440, so command 13 arrives
20 ms later and finds its queueing back down to 200 ms, inside
budget. Admitting it pushes the link out to 0.480, which puts command 14
over again. Changing that comparison to >= sheds every
one of them instead, which is a different apparatus from the same
figures.
That steady state is the shed rule's doing, and it is worth seeing what
happens without one. Raise maximumQueueingMilliseconds to
something enormous and nothing is ever suppressed — the backlog simply
keeps growing, and a minute into a trial the robot is executing commands
the operator issued seconds ago. A finite link needs a policy for what to
do when it is oversubscribed, and here that policy is the contract's
false branch doing real work.
There is no wire, so there is no real packet size. UNITE defines no serializer and no transport protocol. Packages are ordinary objects handed between modules in one process, and nothing is ever encoded to bytes.
So a size here is always a model, never a measurement of
bytes on a wire. The best a condition can do is ask the payload
what size it declares itself to be — which is what
IDeclaredSizePayload above is for, and which lets a
payload derive the figure from its own content. The
downlink page's bandwidth example shows a video frame doing exactly that.
A command is the easy case: WheelVelocityCommand is a
fixed set of fields, so a constant is a fair model of it and the
fallback is all this channel needs. Either way the
sizing rule is part of the apparatus, and has to be
reported alongside the link rate.
Stateless and stateful conditions.
The constant-delay condition ignores
ingressTimestampSeconds entirely and needs no
Reset(). The other two model a link with memory — a
random stream, a busy-until time — so they need the timestamp, and
they must clear what they remember in Reset() so every
trial starts from the same place.
One study, several streams
A condition is configured per channel, so a study is not choosing one degradation for the whole apparatus. It can degrade the stream its question is about and leave the others alone, so that any effect is attributable to that stream:
| Stream Id | Condition | What the study is doing with it |
|---|---|---|
wheel-velocity-command |
EveryMoveFixedDelayCondition |
The manipulated factor: the delay this study is about. |
gripper-command |
— empty — | A deliberate control. Left undegraded so any effect is attributable to the driving commands, not to the manipulator. |
camera-pan-command |
JitterDelayCondition |
A second, independent factor — a differently behaving link on a stream the operator uses differently. |
The demo publishes wheel-velocity-command. A study can add
other stream ids when it needs them. Each channel holds its own condition
instance with
its own state and its own random stream, so nothing on one stream can
reach another. Combined with the
downlink module, which is configured separately, that is how the directional
allocation in the second study pattern is expressed — one budget,
divided explicitly, with everything else held still.
Config
Uplink Communication is a Unity component, so it's configured like the rest of a study: in the Inspector, not in code.
-
Add the module to the agent.
Add
UplinkCommunicationModuleto the same GameObject asTeleroboticsAgent. Every selected module must live on that one GameObject. - Assign it to the agent's Uplink Communication field. This field is required, alongside Input Provider, Command Mapping & Encoding and the Vehicle/Robot Model.
-
Add one channel per command stream.
The Channels list starts empty and at least one entry is required.
Type the stream id to match exactly what Command Mapping &
Encoding publishes on its Output Stream Id field — in the demo,
wheel-velocity-command. -
Choose a condition, or leave it empty.
The Condition field is a
[SerializeReference]slot, so the Inspector lists everyCommunicationConditionsubclass in your project — the Kernel contributes none. Leaving it empty is a valid, deliberate configuration: that channel delivers immediately.
Each entry in the Channels list has exactly two fields:
| Field | Type | Description | |
|---|---|---|---|
| Stream Id | string |
Required |
The stream this channel transports. Trimmed, then matched
exactly and case-sensitively against the package's own
StreamId. Two channels sharing an id is a
startup failure.
|
| Condition | CommunicationCondition |
Optional | The degradation applied to this stream. Empty means immediate delivery, in arrival order. Each channel holds its own instance, so two channels using the same class keep independent state. |
Same GameObject, one instance.
The agent's Awake validates that every assigned module
is attached to its own GameObject, and the module carries
[DisallowMultipleComponent]. Multiple streams are
expressed as multiple channels on one module, never as multiple
modules.