Функция скользящего среднего для регенерации на графике является самым обыденным механизмом, чтобы сделать график более читаемым с одной стороны, и, одним из вариантов нормализации данных на основании которых можно строить отчеты, с другой.

Типичными данными, которые нужно нормализовывать являются данные топливных датчиков.

Ниже я привожу код для C# который можно copy/paste для вашего использования.

Особенностями подхода является два момента:

  1. Наличие функция для расчета стандартного отклонения. Эта функция вычисляет стандартное отклонение, которое измеряет, насколько значения в наборе данных отклоняются от среднего.

    Более высокое стандартное отклонение указывает на более разбросанные данные, тогда как более низкое стандартное отклонение указывает на данные, которые более сконцентрированы вокруг среднего.

    Зачем это нужно?

    Для того, чтобы не заморачиваться с выбором "окна" усреднения и, поэтому, расчет окна усреднения на основе стандартного отклонения, выполняется динамически.

  2. При усреднении данных происходит их потеря с обоих концов исходного массива. Простейшим решением является добавление одной или нескольких точек близких к уже вычисленным данным. В моем случае, я добавляю по одной точке с обоих сторон массива для компенсации линии тренда, но вы можете проделать самостоятельную работу и использовать среднее количество точек на интервале "окна" с тем, чтобы генерировать их и дополнить массив.

  /// <summary>
  /// Функция для нормализации данных (сглаживания), возвращающая сглаженные данные и соответствующие значения X.
  /// Вычисляет стандартное отклонение и динамически определяет размер окна для скользящего среднего.
  /// </summary>
  /// <param name="xValues">Исходные значения X как DateTime</param>
  /// <param name="yValues">Исходные значения Y</param>
  /// <param name="isEndDataPoints">Достраивать концевые точки данных</param>
  /// <returns>Кортеж со сглаженными значениями X и Y</returns>
  public static (List<DateTime> smoothedX, List<double> smoothedY) NormalizeDataWithXTime(List<DateTime> xValues, List<double> yValues,
      bool isEndDataPoints = true)
  {
      // Рассчитать стандартное отклонение данных.
      var stdDev = CalculateStandardDeviationWithXTime(yValues);

      // Динамический расчет windowSize на основе стандартного отклонения
      var baseWindowSize = yValues.Count / 10; // Базовое окно 10% от количества данных
      var windowSize = Math.Max(1, baseWindowSize + (int)(stdDev / 10)); // Увеличить окно на основе отклонения

      // Применить скользящее среднее для сглаживания данных
      var (smoothedX, smoothedY) = MovingAverageWithXTime(xValues, yValues, windowSize);

      // Дополнить данными сокращение точек сначала и конца периода 
      if (isEndDataPoints && smoothedX.Count > 0 && smoothedY.Count > 0)
      {
          var littleAverage = windowSize / 2; // Сократим окно для извлечения точек округления

          // Добавить одну усредненную точку данных в начале
          double startAvgY = yValues.Take(littleAverage).Average();
          smoothedY.Insert(0, startAvgY);
          smoothedX.Insert(0, xValues[0]);

          // Добавить одну усредненную точку данных в конце
          double endAvgY = yValues.Skip(yValues.Count - littleAverage).Take(littleAverage).Average();
          smoothedY.Add(endAvgY);
          smoothedX.Add(xValues[^1]);
      }

      Console.WriteLine($"\nСтандартное отклонение: {stdDev}");
      Console.WriteLine($"Динамический размер окна: {windowSize}");

      return (smoothedX, smoothedY);
  }

  /// <summary>
  /// Функция для скользящего среднего, также возвращающая соответствующие значения X.
  /// </summary>
  /// <param name="xValues">Исходные значения X как DateTime</param>
  /// <param name="yValues">Исходные значения Y для сглаживания</param>
  /// <param name="windowSize">Размер "окна"</param>
  /// <returns>Кортеж со значениями X и Y для сглаженной линии</returns>
  public static (List<DateTime> smoothedX, List<double> smoothedY) MovingAverageWithXTime(List<DateTime> xValues, List<double> yValues,
      int windowSize = 3)
  {
      var smoothedY = new List<double>();
      var smoothedX = new List<DateTime>();

      for (var i = 0; i < yValues.Count - windowSize + 1; i++)
      {
          var averageY = yValues.Skip(i).Take(windowSize).Average();
          var midXTicks = (long)xValues.Skip(i).Take(windowSize).Average(x => x.Ticks); // Средний X для окна

          smoothedY.Add(averageY);
          smoothedX.Add(new DateTime(midXTicks)); // Преобразовать обратно в DateTime
      }

      return (smoothedX, smoothedY);
  }

  /// <summary>
  /// Функция для расчета стандартного отклонения.
  /// 
  /// Эта функция вычисляет стандартное отклонение, которое измеряет, насколько значения в наборе данных отклоняются от среднего.
  /// Более высокое стандартное отклонение указывает на более разбросанные данные, тогда как более низкое стандартное отклонение указывает на данные, которые более сконцентрированы вокруг среднего.
  /// </summary>
  /// <param name="data">Исходные данные</param>
  /// <returns>Значение стандартного отклонения</returns>
  public static double CalculateStandardDeviationWithXTime(List<double> data)
  {
      // Найдите среднее значение.
      var average = data.Average();

      // Найдите отклонение каждого элемента от среднего значения. Возведите каждое отклонение в квадрат.
      var sumOfSquaresOfDifferences = data.Select(val => (val - average) * (val - average)).Sum();

      // Найдите среднее квадратов отклонений. Квадратный корень из дисперсии дает стандартное отклонение.
      var stdDev = Math.Sqrt(sumOfSquaresOfDifferences / data.Count);

      return stdDev; // Стандартное отклонение.
  }

И код для формы

public partial class Form1 : Form
{
    private readonly Series fuelLevelSeries;
    private readonly Series avgFuelLevelSeries;
    private readonly List<DateTime> xValues = [];
    private readonly List<double> yValues = [];
 
    public Form1()
    {
        InitializeComponent();

        fuelLevelSeries = CreateSeries("Уровень топлива", Color.Blue, 3);
        avgFuelLevelSeries = CreateSeries("Средний уровень топлива", Color.Red, 2);

        chart1.Series.Add(fuelLevelSeries);
        chart1.Series.Add(avgFuelLevelSeries);

        ConfigureChartAxes();
        chart1.MouseMove += Chart1_MouseMove;

        GenerateData();
        DrawGridLines();
    }

    private void ConfigureChartAxes()
    {
        chart1.ChartAreas[0].AxisX.LabelStyle.Format = "dd.MM.yy HH:mm:ss";
        chart1.ChartAreas[0].AxisX.Interval = 5;
        chart1.ChartAreas[0].AxisX.IntervalType = DateTimeIntervalType.Minutes;
    }

    private void DrawGridLines()
    {
        var chartArea = chart1.ChartAreas[0];
        chartArea.AxisX.MajorGrid.LineColor = Color.Gray;
        chartArea.AxisY.MajorGrid.LineColor = Color.Gray;
    }

    private void Chart1_MouseMove(object? sender, MouseEventArgs e)
    {
        var result = chart1.HitTest(e.X, e.Y);
        if (result.ChartElementType == ChartElementType.DataPoint)
        {
            DisplayTooltip(result.Series, result.PointIndex, e.Location);
        }
        else
        {
            ResetCursorAndTooltip();
        }
    }

    private void DisplayTooltip(Series series, int dataPoint, Point location)
    {
        chart1.Cursor = Cursors.Cross;
        if (dataPoint >= 0)
        {
            var label = series.Points[dataPoint].YValues[0].ToString();
            toolTip1.Show(label, chart1, location);
        }
    }

    private void ResetCursorAndTooltip()
    {
        chart1.Cursor = Cursors.Default;
        toolTip1.Hide(chart1);
    }

    private Series CreateSeries(string name, Color color, int borderWidth = 1)
    {
        return new Series(name)
        {
            ChartType = SeriesChartType.Line,
            Color = color,
            BorderWidth = borderWidth
        };
    }

    /// <summary>
    /// Симуляция потребления топлива
    /// </summary>
    private void GenerateData()
    {
        xValues.Clear();
        yValues.Clear();

        Random random = new Random();
        DateTime startTime = DateTime.Now;

        for (int i = 0; i <= 120; i++)
        {
            double baseFuelConsumption = 0.5;
            double randomVariation = random.NextDouble() * 0.2;
            double totalConsumption = baseFuelConsumption + randomVariation;

            double fuelLevel = 100 - (i * totalConsumption);

            if (i == 60)
            {
                fuelLevel += 20;  
            }

            xValues.Add(startTime.AddMinutes(i));
            yValues.Add(fuelLevel);
        } 

        fuelLevelSeries.Points.DataBindXY(xValues, yValues);
    }

    private void ButtonAvg_Click(object sender, EventArgs e)
    {
        var (xValuesOut, yValuesOut) = DataConverter.NormalizeDataWithXTime(xValues, yValues);
        avgFuelLevelSeries.Points.DataBindXY(xValuesOut, yValuesOut);
    }         
}
Результат работы алгоритма
Результат работы алгоритма

Надеюсь вы сможете использовать этот код в вашей практике, который без особого труда может быть представлен в других языках программирования.

Комментарии (5)


  1. randomsimplenumber
    14.10.2024 05:52

    Без знания теории код не сильно полезен. Ну, у вас он что-то считает. Будет ли он применим не только для топливных датчиков? Нет ли там чего-то гвоздями прибитого?

    А кто знает теорию, тому проще написать самому чем адаптировать чужое.


    1. SpiderEkb
      14.10.2024 05:52

      Соглашусь.

      Хотелось бы видеть пояснения к алгоритму. Желательно с каким-то матобоснованием.

      Тут даже не совсем понятно (не зная C#) - вот мы взяли "стандартное окно", посчитали среднее, посчитали СКО - что дальше? Новое значение СКО будет действовать для следующего окна, или вводится коррекция и пересчет текущего?

      Также не понятно будет ли это работать для потокового режима работы - когда у нас еще нет всех данных. Т.е. вот идет поток данных и он "на лету" сглаживается - как тут быть?

      В общем, приведет какой-то кусок кода совершенно без объяснения почему именно так.


      1. adeshere
        14.10.2024 05:52

        В общем, приведет какой-то кусок кода совершенно без объяснения почему именно так.

        Имхо, это главный недостаток очень многих подобных статей. Возможно, в данном случае автор посчитал, что теория тривиальна и общеизвестна. Однако математика - это

        такая штука

        что заглянув в любой тихий омут, оттуда запросто можно выскочить с криками "тысяча чертей!!!"

        что копать можно почти в любом месте, и там откроется бездонная глубина. Я не претендую на особую глубину... но если интересно чуть поподробнее, то кое-какие идеи ляпнул в соседней ветке


  1. horevivan
    14.10.2024 05:52

    Спасибо, вы кодом, приведённым в статье, подняли мне самооценку)


  1. adeshere
    14.10.2024 05:52

    При усреднении данных происходит их потеря с обоих концов исходного массива.

    С философской точки зрения, это совершенно не обязательно. Например, мы заменяем отсутствующие точки на Nan, а затем работаем с сигналом так, будто он бесконечный. После любой фильтрации длина ряда

    остается такой же, как была

    Точнее, у нас есть еще одна настройка - это разрешенный процент пропусков в окне. Если она задана равной нулю (т.е. пропуски запрещены), то алгоритм работает по классике: длина ряда уменьшается на половину окна на обоих концах. Фишка в том, что эту настройку можно менять от 0 до 99.9999%, и получать результат, оптимальный для текущей задачи

    Идея в том, что обработка пропусков внутри ряда и на его границах идет по совершенно одинаковым алгоритмам

    Подробнее

    Вот пара статей, где можно об этом почитать (раз, два), а вот тут можно взять их полные тексты. Опенсорсная программа для Винды, в которой эти идеи реализованы, лежит вот тут. Но, там внутри есть не только сглаживание, а еще дохрена всякой другой ерунды, поэтому порог вхождения -

    не пожелаешь врагу

    это конечно лишь мое маргинальное мнение... но учитывая, что я не только автор проги, но и ее самый активный пользователь (с 30-летним стажем), советую все же прислушаться ;-)

    А исходники к этой программе лежат вот тут. Но, WARNING-WARNING-WARNING: там все написано а) на фортране и б) научными сотрудниками, а не программистами. Поэтому слабонервным лучше посмотреть основные идеи (например, там же в архиве WinABD_Help.zip закопана справка к программе, она чуть подробнее, чем упомянутые статьи) и сделать самостоятельно ;-)

    А кто знает теорию, тому проще написать самому чем адаптировать чужое.

    Именно так ;-)

    Простейшим решением является добавление одной или нескольких точек близких к уже вычисленным данным. 

    Так у Вас будет смещение, если на краю ряда присутствует заметный тренд. Сглаженный ряд загнется к горизонтали (кстати, на Вашем графике "Результат работы алгоритма" оно хорошо видно слева). Чтобы этого избежать, можно

    экстраполировать сигнал линейно за пределы фрагмента, а потом учесть эти точки в окне сглаживания

    Пара примеров, как это работает в разных случаях, есть вот тут: раз, два, три. Полные тексты статей можно взять там же.