Live data at display rate
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.
// 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.
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:
- A packet comes a little late. The buffer absorbs it: the position had not reached the missing readings yet, and the line keeps moving.
- The data stop. The position stops at the newest reading and the trace stands. It never shows more than has arrived. When the data come back, it goes on without a jump.
- The UI thread was busy. If the view fell behind by more than the delay, it catches up over a few frames. If it fell behind by much more, it jumps rather than racing through what is already past.
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:
var playback = new LivePlayback { Delay = TimeSpan.Zero }; // no buffer: the newest reading, every frameEvery 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
- A series holds
doublereadings. Other things, such as the lines of a waterfall, are paced through a series of their numbers. - A reading that is not newer than the newest one is dropped. One far older starts the series anew, as when a source restarts its clock.
- The series is safe to share between threads; the playback is not.
Advanceand the reads belong on one thread, in practice the UI thread. - With the source's own times, a pause of more than a second is taken as a jump of the source's clock, and the view jumps with it.
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.
