C#学习笔记16:串口上位机数据绘图助手Plotter的开发

今日尝试写一款窗口上位机数据绘图助手Plotter的开发,实现接收解析数据包进行画图的功能:

文章提供完整代码解释、设计点解释、测试效果图、完整工程下载

目录

控件摆放与使用控件大致介绍:

下载必要的Nuget程序包:

图表绘制相关代码逻辑:

[字典存储每条曲线的 PointPairList:](#字典存储每条曲线的 PointPairList:)

绘制/更新曲线函数的编写:

调用写好的曲线绘制函数:

阶段绘制测试效果如下:

将时间戳作为X轴输入:

时间戳绘制效果如下:

清空图表按钮实现:

鼠标在数据点上获取精确值:

串口接收事件生成与衔接图表逻辑:

单片机方面宏定义打印函数:

单片机串口发送示例:

接收数据字符串字段函数:

在串口接收事件中的调用解算:

阶段性解算成果展示:

字典转换颜色字符串:

数据画图函数与非UI线程调用:

单条曲线绘图测试效果:

最终测试效果:

遇到的问题:

在非UI线程调用UI控件:

整体测试工程下载:


控件摆放与使用控件大致介绍:

控件最终摆放效果如下图:

大致使用了如下控件:

TableLayoutPanel 自动排列组件,辅助实现控件与窗体的同步缩放

group 控件分组组件,与TableLayoutPanel 组合使用

ZedGraphControl 图表组件,用于绘图

serialPort 串口组件

checkbox 选框

下载必要的Nuget程序包:

这次编写的串口绘图助手程序需要以下几个Nuget程序包的支持:

图表绘制相关代码逻辑:

这一板块部分主要想实现的是:

  1. 绘制Y轴是数据,X轴是1ms级别时间戳的曲线

  2. 能绘制不止一条曲线,且每条曲线颜色可以自定

  3. 曲线是由点集构成,因此需要标注每个数据点

  4. 鼠标移动到图表曲线的点上显示点的X,Y精确坐标

字典存储每条曲线的 PointPairList:

先定义全局变量,来存储每条曲线的列表浮点数据值:

cs 复制代码
// 有一个全局或类级别的字典来存储每条曲线的PointPairList 
private Dictionary<string, PointPairList> curves = new Dictionary<string, PointPairList>();

绘制/更新曲线函数的编写:

这个自定义的函数只需传入以下几个参数:

ZedGraph.ZedGraphControl zgc:图表控件名称

string label : 曲线名称

double x, double y : x,y坐标

Color color : 曲线颜色

实现了有曲线就继续绘制,没曲线就根据传参数的曲线名称,创建一条曲线

cs 复制代码
        // 初始化或更新曲线(如果曲线不存在,则创建它),并标记每个点  
        // 使用圆形(SymbolType.Circle)作为标记类型。 //这里使用 SymbolType.Triangle 三角形标记  
        // 如果想要使用其他类型的标记,可以将 SymbolType.Circle 替换为想要的类型。
        private void InitializeOrUpdateCurve(ZedGraph.ZedGraphControl zgc, string label, double x, double y, Color color)
        {
            GraphPane myPane = zgc.GraphPane;
            // 检查曲线是否已存在  
            if (!curves.ContainsKey(label))
            {
                // 曲线不存在,创建新的PointPairList并添加到GraphPane中  
                PointPairList list = new PointPairList();
                curves.Add(label, list);
                // 创建曲线并设置样式  
                LineItem myCurve = myPane.AddCurve(label, list, color, SymbolType.Triangle); // 假设想要三角形标记  
                // 设置标记的大小(您可以根据需要调整)  
                myCurve.Symbol.Size = 4;
                // 由于我们想要标记颜色与线条颜色相同,所以不需要额外设置  
                // 但为了确保,我们可以显式设置填充颜色为线条颜色  
                myCurve.Symbol.Fill.Type = FillType.Solid;
                myCurve.Symbol.Fill.Color = color;
            }
            // 获取对应的PointPairList并添加新点  
            PointPairList listToUpdate = curves[label];
            listToUpdate.Add(x, y);
            // 更新坐标轴并重新绘制图表  
            myPane.AxisChange();
            zgc.Refresh();
        }

调用写好的曲线绘制函数:

对于该函数的调用,我放置了一个按键控件,然后定义了几个全局变量,每次按下按键,都会将全局变量数值增加,然后作为图表曲线的数值绘制在对应名称的曲线上:

以下引用案例:

创建/继续绘制俩条图表曲线:MyCurve 红色 与MyCurve2 蓝色,按键每次按下将全局变量数值增加,绘制到曲线上

cs 复制代码
        //绘制测试
        private void Test_button_Click(object sender, EventArgs e)
        {
            //My_Plotter(1, 2);
            newX++; 
            newY++;
            newX1++;
            newY1++;
            DrawNewPoint(zedGraphControl1, "MyCurve", newX, newY, Color.Red);
            DrawNewPoint(zedGraphControl1, "MyCurve2", newX1, newY1, Color.Blue);

        }

阶段绘制测试效果如下:

将时间戳作为X轴输入:

这里我想做到,从第一个数据开始计时,x轴是ms级别,我只需要传Y轴数值看其随着时间变化就行了:
先定义一些必要的变量如下:

cs 复制代码
        // 声明一个DateTime变量来存储系统时间  
        private DateTime firstDataPointTime = DateTime.MinValue; // 初始化为一个不可能的值 (这个变量用于获取第一个变量出现时的时间)
        double timeStampInMilliseconds;        //毫秒级计数
        bool firstDataPoint_flag = false;    //记录是否获取了第一个数据
        DateTime newDataPointTime;           // 新时间

然后将获取时间的逻辑加入测试按钮:

cs 复制代码
        //绘制测试
        private void Test_button_Click(object sender, EventArgs e)
        {
            newY1++; 
            newY++;
             当捕获到一个新的数据点时  
            DateTime newDataPointTime = DateTime.Now; // 捕获新数据点的时间
            // 计算新数据点与第一个数据点之间的时间差(毫秒)  
            if (firstDataPoint_flag == false)
            {
                // 记录第一个数据点的时间  
                firstDataPointTime = DateTime.Now; // 或者使用 DateTime.UtcNow 如果你想要 UTC 时间
                DrawNewPoint(zedGraphControl1, "MyCurve",0, newY, Color.Red);
                DrawNewPoint(zedGraphControl1, "MyCurve2",0, newY1, Color.Blue);
                firstDataPoint_flag = true;
            }
            else if (firstDataPoint_flag == true)
            {
                 当捕获到一个新的数据点时  
                //newDataPointTime = DateTime.Now; // 捕获新数据点的时间  
                timeStampInMilliseconds = (newDataPointTime - firstDataPointTime).TotalMilliseconds;
                //timeStampInMilliseconds = currentTime.Ticks / TimeSpan.TicksPerMillisecond;
                DrawNewPoint(zedGraphControl1, "MyCurve", timeStampInMilliseconds, newY, Color.Red);
                DrawNewPoint(zedGraphControl1, "MyCurve2", timeStampInMilliseconds, newY1, Color.Blue);
            }
        }

时间戳绘制效果如下:

如图,我点击绘制测试按钮,越快线条越急抖,越慢线条越平缓

清空图表按钮实现:

这个没啥好注意的,主要是我实现的过程中忘记清空曲线字典列表了,导致清空一次后没法在此生成曲线:

cs 复制代码
        //清空图表
        private void clear_button_Click(object sender, EventArgs e)
        {
            ClearChart(zedGraphControl1);
            firstDataPoint_flag = false;    //记录获取第一个数据 状态置零
        }
        // 清空图表中的所有曲线  
        private void ClearChart(ZedGraph.ZedGraphControl zgc)
        {
            GraphPane myPane = zgc.GraphPane;
            // 遍历并删除所有曲线  
            while (myPane.CurveList.Count > 0)
            {
                myPane.CurveList.RemoveAt(0);
            }
            myPane.AxisChange();
            // 刷新图表以显示更改  
            zgc.Refresh();
            // 最后别忘记清理字典
            ClearCurvesDictionary();
        }

鼠标在数据点上获取精确值:

这个只需要开启它的一个属性就行:


**开了之后就是这样的效果:**711是毫秒,后面的小数点就是微秒数

串口接收事件生成与衔接图表逻辑:

对于检测端口卡顿、打开串口、发送数据等操作在之前的 串口助手窗体程序的制作中就已经实现了,本文主要讲串口接收部分怎么去衔接图表的逻辑。

单片机方面宏定义打印函数:

这里为了方便用户使用,进行了数据发送相关的宏定义:

cs 复制代码
// 宏定义的PRINT函数,第一个传入曲线名称,第二个传入曲线颜色,第三个传入你需要打印的数值
// 应用示例例: t1=95;  PRINT(plot1,Red,"%d",t1);  
// 曲线名称:plot1       曲线颜色:红色      数据数值:t1变量的值
// 只需有这个宏定义就行了,后续的使用就是类似于:  PRINT(plot1,Red,"%d",t1); 这样的格式  
// 如果你颜色参数传输了没有定义的颜色,则默认曲线为 蓝色
// 可用颜色字段如下: 
// 
#define PRINT(title,color,fmt, args...) printf("{"#title"}""{"#color"}"fmt"\n", ##args)

单片机串口发送示例:

以下截图示例了如何用单片机串口发送你想要绘制的曲线、曲线颜色以及数据:

最终发送出去的数据会是这样的格式:

接收数据字符串字段函数:

这需要先定义一些全局变量进行接收数据字符串字段等:

cs 复制代码
        string title;//串口获取的曲线名称
        string color;//串口获取的颜色字段
        double value;//串口接受的数据
cs 复制代码
        private void ParseData(string data)
        {
            string formattedLogMessage;       //转接字符串用

            // 假设数据格式为 "{title}{color}value",其中value是一个可以转换为double的数值  
            // 第一步:分割数据为三部分  
            string[] parts = data.Split(new[] { '}', '{' }, StringSplitOptions.RemoveEmptyEntries);
            if (parts.Length != 3)
            {
                // 数据格式错误  
                Console.WriteLine("Invalid data format: " + data);
                return;
            }
            // 第二步:提取title和color  
            title = parts[0];
            color = parts[1];
            // 第三步:尝试将最后一部分转换为double  
            if (double.TryParse(parts[2], out value))
            {
                // 成功转换
                //Console.WriteLine($"Title: {title}, Color: {color}, Value: {value}");
                formattedLogMessage = string.Format("Title: {0}, Color: {1}, Value: {2}", title, color, value);
                myaddlog(0, formattedLogMessage);
                // 这里可以根据需要处理title, color, 和value
            }
            else
            {
                // 转换失败  
                //Console.WriteLine("Failed to parse value from data: " + data);
                myaddlog(1, "本次数据转换失败:丢包......");
            }
        }

在串口接收事件中的调用解算:

首先这个事件时需要在serial1的事件(黄色图表)中打开启用的:

然后这一步不是最终实现,只是阶段性测试接收与解算是否成功:

cs 复制代码
        //串口接收逻辑
        private void serialPort1_DataReceived(object sender, SerialDataReceivedEventArgs e)
        {
            SerialPort sp = (SerialPort)sender;
            string indata = sp.ReadExisting(); // 读取所有可用的数据  
            // 假设每次接收的数据都是完整的一行,或者你可以通过特定的字符(如换行符'\n')来分割数据  
            // 这里我们使用'\n'作为数据结束的标志,根据你的实际情况可能需要调整  
            string[] lines = indata.Split('\n');
            foreach (string line in lines)
            {
                if (!string.IsNullOrWhiteSpace(line))
                {
                    // 解析数据  
                    ParseData(line);

                }
            }
        }

阶段性解算成果展示:

这里在日志中发现了阶段性接收解算的成功:

字典转换颜色字符串:

单片机发送的颜色是字符串,而不是我们上位机中的Color.Red属性,因此需要定义字典来转换一下:

cs 复制代码
        // 创建一个颜色字典  
        private Dictionary<string, Color> colorDictionary = new Dictionary<string, Color>
            {
                { "Red", Color.Red },
                { "Green", Color.Green },
                { "Blue", Color.Blue },
                { "Black",Color.Black},
                { "Aqua",Color.Aqua},
                { "Beige",Color.Beige},
                { "AliceBlue",Color.AliceBlue},
                { "AntiqueWhite",Color.AntiqueWhite},

                // 可以根据需要添加更多颜色  
            };

可以如下方式来使用字典:

cs 复制代码
            Color colorValue=Color.Blue;//记录颜色变量 (默认蓝色)
            colorDictionary.TryGetValue(color, out colorValue);//这句会尝试将颜色字符与字典进行匹配,并传给colorValue

数据画图函数与非UI线程调用:

这是个比较抽像的问题,因为UI的绘制与串口接收不在同一个线程

如果尝试从非UI线程(即不是创建控件的线程,通常是主线程)访问UI控件就会抛出System.InvalidOperationException异常,提示"线程间操作无效: 从不是创建控件'zedGraphControl1'的线程访问它。"

为了解决这个问题,需要确保所有对UI控件的访问都在UI线程上执行
有两个方法都允许你在控件的UI线程上执行委托(delegate)

Control.InvokeControl.BeginInvoke方法

BeginInvoke是异步的,而Invoke是同步的。

以下是一个示例,展示了如何在非UI线程中安全地调用UI线程上的方法,以更新zedGraphControl1控件:

这是要根据你需要调用的函数的传参等情况进行编写的,下面先放出需要跨线程调用的更新zedGraphControl1控件的函数:

cs 复制代码
     private void DrawNewPoint(ZedGraph.ZedGraphControl zgc, string label, double x, double y, Color color)
cs 复制代码
       //这是在非UI线程中调用的方法  
       private void UpdateGraphFromNonUiThread(ZedGraph.ZedGraphControl zgc, string label, double x, double y, Color color)

       {
           // 检查zedGraphControl1是否已创建并且句柄已分配  
           if (zedGraphControl1.InvokeRequired)
           {
               // 使用BeginInvoke在UI线程上异步执行UpdateGraph方法  
               zedGraphControl1.BeginInvoke(new Action<ZedGraph.ZedGraphControl, string, double, double, Color>(DrawNewPoint), zgc,label,x,y,color);
           }
           else
           {
               // 如果已经在UI线程上,则直接调用UpdateGraph方法  
               DrawNewPoint(zgc,label,x,y,color);
           }  
       }

然后就可以使用这个线程委托来调用数据绘图了:

cs 复制代码
        //数据画图函数
        private void plotData()
        {
            Color colorValue=Color.Blue;//记录颜色变量 (默认蓝色)
            colorDictionary.TryGetValue(color, out colorValue);//这句会尝试将颜色字符与字典进行匹配,并传给colorValue

            // 计算新数据点与第一个数据点之间的时间差(毫秒)  
            if (firstDataPoint_flag == false)
            {
                // 记录第一个数据点的时间  
                firstDataPointTime = DateTime.Now; // 或者使用 DateTime.UtcNow 如果你想要 UTC 时间
                //传参: zedGraphControl1 图表,"MyCurve" 曲线名, X数值,Y数值,颜色
                UpdateGraphFromNonUiThread(zedGraphControl1,title, 0, value, colorValue);
                firstDataPoint_flag = true;
            }
            else if (firstDataPoint_flag == true)
            {
                 当捕获到一个新的数据点时  
                DateTime newDataPointTime = DateTime.Now; // 捕获新数据点的时间
                timeStampInMilliseconds = (newDataPointTime - firstDataPointTime).TotalMilliseconds;
                UpdateGraphFromNonUiThread(zedGraphControl1,title, timeStampInMilliseconds, value, colorValue);
            }
        }

单条曲线绘图测试效果:

单片机写了一个程序,能发送一个让 t1 变量加到50,再从50减到0,以此循环

并发送的程序:


然后实测效果符合情况:

最终测试效果:

这次测试比之前的阶段测试添加了一条变量的曲线来绘制,它与之前的变量变换情况相反:


也是符合情况的:


最后提一嘴:别忘记这个单片机端的宏定义:

cs 复制代码
// 宏定义的PRINT函数,第一个传入曲线名称,第二个传入曲线颜色,第三个传入你需要打印的数值
// 应用示例例: t1=95;  PRINT(plot1,Red,"%d",t1);  
// 曲线名称:plot1       曲线颜色:红色      数据数值:t1变量的值
// 只需有这个宏定义就行了,后续的使用就是类似于:  PRINT(plot1,Red,"%d",t1); 这样的格式  
// 如果你颜色参数传输了没有定义的颜色,则默认曲线为 蓝色
// 可用颜色字段如下: 
// 
#define PRINT(title,color,fmt, args...) printf("{"#title"}""{"#color"}"fmt"\n", ##args)

遇到的问题:

在非UI线程调用UI控件:

这是我尝试在串口中断的线程中更新数据到图表时产生的报错:

System.InvalidOperationException:"线程间操作无效: 从不是创建控件"zedGraphControl1"的线程访问它。"

这是个比较抽像的问题,因为UI的绘制与串口接收不在同一个线程

如果尝试从非UI线程(即不是创建控件的线程,通常是主线程)访问UI控件就会抛出System.InvalidOperationException异常,提示"线程间操作无效: 从不是创建控件'zedGraphControl1'的线程访问它。"

为了解决这个问题,需要确保所有对UI控件的访问都在UI线程上执行

解决这个问题的具体实现在 数据画图函数与非UI线程调用: 这一节

整体测试工程下载:

这里先说声抱歉,我之前不会做上位机时,下载了类似功能的串口助手,能绘图,被迫付费使用了一阵子,这里资源设置为9.9付费了... (朋友免费~~)
https://download.csdn.net/download/qq_64257614/89631031?spm=1001.2014.3001.5503

相关推荐
IT规划师13 分钟前
C#|.net core 基础 - 扩展数组添加删除性能最好的方法
c#·.netcore·数组
青椒大仙KI1131 分钟前
24/9/19 算法笔记 kaggle BankChurn数据分类
笔记·算法·分类
时光追逐者1 小时前
分享6个.NET开源的AI和LLM相关项目框架
人工智能·microsoft·ai·c#·.net·.netcore
liangbm31 小时前
数学建模笔记——动态规划
笔记·python·算法·数学建模·动态规划·背包问题·优化问题
friklogff1 小时前
【C#生态园】提升C#开发效率:深入了解自然语言处理库与工具
开发语言·c#·区块链
潮汐退涨月冷风霜1 小时前
机器学习之非监督学习(四)K-means 聚类算法
学习·算法·机器学习
GoppViper1 小时前
golang学习笔记29——golang 中如何将 GitHub 最新提交的版本设置为 v1.0.0
笔记·git·后端·学习·golang·github·源代码管理
羊小猪~~1 小时前
深度学习基础案例5--VGG16人脸识别(体验学习的痛苦与乐趣)
人工智能·python·深度学习·学习·算法·机器学习·cnn
Charles Ray2 小时前
C++学习笔记 —— 内存分配 new
c++·笔记·学习
重生之我在20年代敲代码2 小时前
strncpy函数的使用和模拟实现
c语言·开发语言·c++·经验分享·笔记