Blog

Live data at display rate

· Michael Fellinger

A trace that updates thirty times a second can still look rough. The data arrive in time and the screen refreshes in time, yet the line stands still for a frame and then jumps. This article explains why, and how ArionUI's LiveSeries and LivePlayback turn uneven packets into motion that moves the same amount every frame.

Two clocks that never agree

A device, a socket or a simulation delivers readings in packets. Say a sensor produces 240 readings a second and the driver hands them over thirty times a second, eight at a time. The display refreshes sixty times a second. On paper that is one packet every second frame.

In practice the packets are not that regular. A driver wakes up a little late, the network bunches two packets together, the garbage collector stops the receiving thread for a moment. The display, meanwhile, keeps its own perfectly regular rhythm. Draw every packet the moment it arrives and the picture inherits all of the source's irregularity. Some frames bring nothing new, so the line stands. Others bring a packet, or two, so it jumps by eight or sixteen readings. On average the speed is right; frame by frame it is not, and the eye notices.

Faster hardware does not help, because the problem is not speed but the fact that two independent clocks are involved. What helps is a small buffer between them: show the data a little behind the newest reading, and move on by exactly the display time of each frame.

Step one: keep the readings with their times

For a buffer like that, each reading needs a time. LiveSeries is one row of readings with their times, in a ring of fixed capacity: the oldest readings drop out as new ones come in. A source thread appends while the UI thread reads.

A series per channel
// Room for 2,000 readings; the source delivers 240 a second.
var samples = new LiveSeries(capacity: 2_000, sampleRate: 240);

// On the source's thread, for every packet - the times are set on arrival:
samples.Append(packet);

// Or, when the device numbers its samples, with its own clock:
var spacing = TimeSpan.FromSeconds(1.0 / 240);
samples.Append(packet, firstAt: firstSample * spacing, spacing: spacing);

Times can come from two places. Set on arrival, the readings of a packet are spread evenly: by the sample rate when it is known, otherwise over the time since the previous packet. The series also learns a new pace after a few packets. When the source has its own clock, its times are better, because they say when a reading was taken, not when it got through. A series uses one of the two, never both, because they are different clocks.

Step two: a position that moves with the frames

LivePlayback is the time a view shows. Once per frame the view calls Advance with the display time of that frame, and then reads the readings up to the playback's position. The frame tick comes from grid.Api.Timing.Every. It falls on the display's vertical blanks and passes in the display time the tick stands for, so a frame that came late moves the position on by its full time.

One frame
var smooth = new LivePlayback();             // Delay null: measured from the packets
var trace = new double[400];

var frames = grid.Api.Timing.Every(TimeSpan.FromMilliseconds(16), elapsed =>
{
    smooth.Advance(elapsed, samples);        // on by the display time of this frame

    int n = smooth.Read(samples, trace);     // up to the position, newest last
    trace.AsSpan(0, trace.Length - n).Fill(double.NaN);   // not there yet: a gap
});

How far behind should the view stay? Just far enough that the next packet has normally arrived before the position gets there. Without a Delay of its own, the playback measures that: one packet interval plus one and a half frames. At thirty packets a second on a 60 Hz screen that comes to about 58 milliseconds. The position follows the newest reading minus that delay with a gentle correction, so it neither drifts away nor jumps.

Three cases decide whether the motion looks right:

Step three: slide, do not step

An even position is half the job. A trace of the last 400 readings that moves by whole readings still moves in steps, just smaller ones. The playback therefore also says where the position lies between two readings. Read(series, into, out fraction) writes the readings up to the position, plus the one the trace is moving towards, and how far it has got, from 0 to 1. A sparkline that receives these values as an ISlidingValues draws them shifted by that fraction. The trace then slides smoothly instead of stepping one reading at a time.

The same position serves the other columns of a row. ValueOf reads the level where the trace meets its right edge, and RangeOf gives the minimum and maximum of exactly what the trace shows. So the number in the level column belongs to the same moment as the line beside it. With many channels, all of them share one playback and one clock — the source's own times — and every row of the grid shows the same moment. The showcase's channel wall runs like this with 16, 48 or 200 channels and a source at 10 to 60 packets a second; the code is on the live data page.

The price, and switching it off

Smoothing costs latency, and the playback does not hide that. The view is always the delay behind the data. For a trend display or an oscilloscope that is usually the right trade. For a reading that must be shown the moment it arrives, such as an alarm or a value somebody acts on, it may not be. A delay of zero turns the buffer off:

No buffer
var playback = new LivePlayback { Delay = TimeSpan.Zero };   // no buffer: the newest reading, every frame

Every frame then shows the newest reading, as a view without a playback would. The showcase scenes put this choice on a switch whose hint names the delay in force. ArionUiText.LiveSmoothing gives the text: “on: display … ms behind the data, smooth”.

Limits

The API, the sliding sparkline, many channels and pacing whole items are on the live data page. Both types are part of the open core.