Knowledgebase
How to Create a Sweeping ECG Chart
Posted by Andrew BT on 11 February 2019 04:09 PM

A question which gets asked a lot is how to create a sweeping, or wrap-around ECG Chart for signal monitoring. In this type of chart, a signal goes from Left to Right, then once the trace hits the right edge, it restarts at the left edge with a small gap. Something like this:

We have created a video on YouTube showing how we achieved this. For a description of the code see below. We have also attached an example at the footer of this article.

 

 

To achieve this in SciChart we need to use a technique of filling two alternate FIFO DataSeries. One to store the current sweep from left to right, another to store the previous sweep from left to right. 

Our XAML is declared like this:

<s:SciChartSurface s:ThemeManager.Theme="Electric" MaxFrameRate="25" x:Name="sciChartSurface">
            
    <s:SciChartSurface.RenderSurface>
        <s3D:Direct3D10RenderSurface/>
    </s:SciChartSurface.RenderSurface>
            
    <s:SciChartSurface.RenderableSeries>
        <s:FastLineRenderableSeries x:Name="traceSeriesA" StrokeThickness="2"/>
        <s:FastLineRenderableSeries x:Name="traceSeriesB" StrokeThickness="2"/>
    </s:SciChartSurface.RenderableSeries>
            
    <s:SciChartSurface.XAxis>
        <!-- Axis is given a range of 10 seconds. We have chosen our FIFO capacity given the sample rate -->
        <!-- to show exactly 10 seconds of data as well -->
        <s:NumericAxis AxisTitle="Seconds (s)" TextFormatting="0.000s" VisibleRange="0, 10" AutoRange="Never"/>
    </s:SciChartSurface.XAxis>
    <s:SciChartSurface.YAxis>
        <s:NumericAxis AxisTitle="Volts (v)" VisibleRange="-0.5, 1.5"/>
    </s:SciChartSurface.YAxis>
            
</s:SciChartSurface>

And our code is declared like this

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Threading;
using SciChart.Charting.Model.DataSeries;

namespace SciChart.Sandbox.Examples.SweepingEcgSeries
{
    public static class DataSeriesExtensions
    {
        public static void OutputCsv(this XyzDataSeries<double, double, double> s)
        {
            for (int i = 0; i < s.Count; i++)
            {
                Console.WriteLine("{0},{1},{2}", s.XValues[i], s.YValues[i], s.ZValues[i]);
            }
        }
    }

    /// <summary>
    /// Interaction logic for SweepingEcg.xaml
    /// </summary>
    [TestCase("Sweeping ECG Trace")]
    public partial class SweepingEcg : Window
    {
        private double[] _sourceData;
        private DispatcherTimer _timer;
        private const int TimerInterval = 20;
        private int _currentIndex;
        private int _totalIndex;
        private TraceAOrB _whichTrace = TraceAOrB.TraceA;
        private XyzDataSeries<double, double, double> _dataSeriesA;
        private XyzDataSeries<double, double, double> _dataSeriesB;
        private int _dataSeriesIndex;

        private enum TraceAOrB
        {
            TraceA, 
            TraceB,
        }

        public SweepingEcg()
        {
            InitializeComponent();  

            this.Loaded += SweepingEcg_Loaded;
        }

        void SweepingEcg_Loaded(object sender, RoutedEventArgs e)
        {
            // Create an XyzDataSeries to store the X,Y value and Z-value is used to compute an opacity 
            _dataSeriesA = new XyzDataSeries<double, double, double>();

            // Simulate waveform
            _sourceData = LoadWaveformData("Waveform.csv");

            _timer = new DispatcherTimer();
            _timer.Interval = TimeSpan.FromMilliseconds(TimerInterval);
            _timer.Tick += TimerElapsed;
            _timer.Start();

            traceSeriesA.DataSeries = _dataSeriesA;
            traceSeriesB.DataSeries = _dataSeriesB;
        }

        private void TimerElapsed(object sender, EventArgs e)
        {
            // This constant is just used to calculate the X (time) values from the point index. 
            // The SampleRate, FIFO size are chosen to get exactly '10 seconds' of data in the viewport
            // The XAxis.VisibleRange is also chosen to be 10 seconds. 
            const double sampleRate = 400;

            // As timer cannot tick quicker than ~20ms, we append 10 points
            // per tick to simulate a sampling frequency of 500Hz (e.g. 2ms per sample)
            for (int i = 0; i < 10; i++)
                AppendPoint(sampleRate);
        }

        private void AppendPoint(double sampleRate)
        {
            if (_currentIndex >= _sourceData.Length)
            {
                _currentIndex = 0;
            }

            // Get the next voltage and time, and append to the chart
            double voltage = _sourceData[_currentIndex];
            double actualTime = (_totalIndex / sampleRate);
            double time = actualTime%10;

            const int MaxDataSeriesCount = 4000;

            if (_dataSeriesA.Count < MaxDataSeriesCount)
            {
                // For the first N points we append time, voltage, actual time
                // Time must be ascending in X for scichart to perform the best, so we clip this to 0-10s
                _dataSeriesA.Append(time, voltage, actualTime);                
            }
            else
            {
                _dataSeriesIndex = _dataSeriesIndex >= MaxDataSeriesCount ? 0 : _dataSeriesIndex;

                // For subsequent points (after reaching the edge of the trace) we wrap traces around
                // We re-use the same data-series just update its Y,Z values then trigger a redraw
                _dataSeriesA.YValues[_dataSeriesIndex] = voltage;
                _dataSeriesA.ZValues[_dataSeriesIndex] = actualTime;
                _dataSeriesA.InvalidateParentSurface(RangeMode.None, hasDataChanged:true);

                //_dataSeriesA.OutputCsv();
            }

            // Update the position of the latest Trace annotation
            latestTrace.X1 = time;
            latestTrace.Y1 = voltage;

            // Update the DataSeries.Tag, used by PaletteProvider to dim the trace as time passes
            _dataSeriesA.Tag = time;

            _currentIndex++;
            _totalIndex++;
            _dataSeriesIndex++;
        }

        private double[] LoadWaveformData(string filename)
        {
            var values = new List<double>();

            // Load the waveform.csv file for the source data 
            var asm = typeof (SweepingEcg).Assembly; 
            var resourceString = asm.GetManifestResourceNames().Single(x => x.Contains(filename));

            using (var stream = asm.GetManifestResourceStream(resourceString))
            using (var streamReader = new StreamReader(stream))
            {
                string line = streamReader.ReadLine();
                while (line != null)
                {
                    values.Add(double.Parse(line, NumberFormatInfo.InvariantInfo));
                    line = streamReader.ReadLine();
                }
            }

            return values.ToArray();
        }
    }
}

The example works as follows:

  • Two FIFO DataSeries are declared with a capacity of 3,800 samples.
    • A FIFO series is a circular buffer which automatically discards data once the capacity has been exceeded.
    • They are very memory efficient and can be used if you only care about the latest N samples. 

  • We have chosen our FIFO capacity to be slightly less than 4,000 and our data has a sample rate of 400Hz.
    • So, our FIFO series holds just less than 10seconds of data.
    • Our XAxis has a VisibleRange of 0,10. So, we have chosen parameters well to get 10 seconds in the viewport

  • We append to the XyDataSeries Time as a modulo of 10 (so time is always sweeping from 0...10 then 0..10 again) and Y value as voltage. 

  • We toggle between which data series is Active and Inactive and update opacity. 

In the video above we show some advanced techniques to take it further and actually dim the trace the older it is, creating a very nice fading trace effect. Note that the DimTracePaletteProvider in the attached sample will consume a bit more CPU as it has to do a linear search of unsorted data. 

Finally, you can also find the example attached. This requires SciChart v3.1 to run.

Further Reading

A simpler example of ECG Chart can be found in the SciChart Examples Suite at Featured Apps -> Medical Charts -> ECG Monitor Chart Demo. The full source code is available online at this link.

For further information about SciChart APIs, please see our documentation:



Attachments 
 
 sweepingecg.zip (4.40 KB)
(14 vote(s))
Helpful
Not helpful

CONTACT US

Not sure where to start? Contact us, we are happy to help!


CONTACT US

SciChart Ltd, 16 Beaufort Court, Admirals Way, Docklands, London, E14 9XL. Email: Legal Company Number: 07430048, VAT Number: 101957725