基于DL/T645-07协议的电表数据采集终端

2023-11-04 23:10

本文主要是介绍基于DL/T645-07协议的电表数据采集终端,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

本文总结自两年前我负责的一个小项目,完全自主架构,同时也是所开发的唯一一个非环保行业数据采集软件,我觉得很有必要记录下。

一、前言

工作中经手的数采系统也不算少了,之前接触到的都是环境监测行业的数据采集,直到两年前接收了一个远程采集智能电表电能数据的项目。

由于行业不同,外加采集方式有所区别,此前数采系统的架构均不适合,需要重新设计一个结构。

当时我开发的这套电表数采,在这个项目中充当数据源的角色,仅需要实现智能电表的数据采集、统计和保存,展示和应用分析不在要求之内。也就是说,我只需要保证数据正常入库即可。

二、数据采集流程

之前说过,智能电表的数据采集方式跟我之前接触过的环保行业有所区别。

2.1 环保行业数据采集流程

环保行业(环境空气地表水)现场都具备工控机,由工控机跟分析仪通过串口或网口直接通讯,采集完数据后,按照HJ212-2017协议组包上传给中心服务器,大致的流程如下图:
在这里插入图片描述

2.2 电表数据采集流程

对于智能电表而言,现场不配备工控机,直接通过DTU实现透传。每个电表有一个唯一地址,即中心服务器可直接通过DTU与其下挂载的电表直接通讯,大致如下图所示。
在这里插入图片描述
由于DTU数量是不确定的,后期可能会加装,联网状态也是不确定的,程序启动时无法预知有效客户端的数量。因此,设计时需要考虑动态挂载。

首先,DTU需要配置服务器的IP和端口号,并在上线后向服务器发送一个注册包,约定好注册包的内容为此DTU的ID。这个ID是人为定义的,且必须唯一,其作用是在服务器上能识别出是哪个DTU上线。

此外,单个DTU下可挂载多个电表,DTU和电表对的对应关系需要提前在数据库中配置好,一旦DTU上线后成功识别出注册包,根据配置创建出此DTU下所有挂载的电表,由服务器根据电表地址,主动轮询取数。

解析出数据后,每隔5分钟或者10分钟保存一组瞬时数据,直接入库。

三、DL/T645-07协议解析

DL/T645协议是针对电表通信而制定的通信协议,主要有两个版本,分别是DL/T645-97和DL/T645-07,目前最新版本是2007版,而项目中电表虽然有多个幸好,但采用的都是07版本。因此,仅需要兼容这一个版本的协议即可。

有点类似ModBus协议,格式如下:
在这里插入图片描述
据此设计如下的出解析算法。

public bool TryParse(byte[] data)
{// 校验包头和包尾if (data[0] != 0x68 && data[data.Length - 1] != 0x16){return false;}// 校验和byte[] dest = new byte[data.Length - 2];Buffer.BlockCopy(data, 0, dest, 0, data.Length - 2);if (CheckSum(dest) != data[data.Length - 2]){return false;}// 校验地址bool isSameAddress = true;for (int i = 0; i < m_Address.Length; i++){if (m_Address[i] != dest[i + 1]){isSameAddress = false;break;}}if (!isSameAddress){return false;}// 校验数据长度int dataLen = dest[9];int realLen = dest.Length - 10;if (dataLen != realLen){return false;}// 截取数据段byte[] realData = new byte[dataLen];Buffer.BlockCopy(dest, 10, realData, 0, dataLen);// 数据包-0x33List<byte> dataFlagMinus33 = new List<byte>();for (int i = 0; i < realData.Length; i++){realData[i] -= 0x33;if (i < 4){dataFlagMinus33.Add(realData[i]);}}// DataFlag倒置回来dataFlagMinus33.Reverse();for (int i = 0; i < 4; i++){realData[i] = dataFlagMinus33[i];}return Parse(realData);
}/// <summary>
/// 和校验
/// </summary>
protected static short CheckSum(IEnumerable<byte> data)
{int result = 0;foreach (byte b in data){result += b;}return (byte)(result % 256);
}

四、数据采集模块设计

按照之前的开发习惯,将电表抽象为仪器Device,需要采集的数据抽象为因子Factor,DTU抽象为总线Bus。

4.1 因子类定义

public class Factor
{/// <summary>/// 电表地址/// </summary>public string TerminalID { get; private set; }/// <summary>/// 电表内挂载因子的索引/// </summary>public int IndexInDevice { get; private set; }/// <summary>/// 名称/// </summary>public string Name { get; private set; }/// <summary>/// 单位/// </summary>public string Unit { get; private set; }/// <summary>/// 统计系数,/// </summary>private float RealValuePara { get; set; }/// <summary>/// 原始数据/// </summary>public Data RawData { get; private set; }/// <summary>/// 构造/// </summary>public Factor(string terminalID, int indexInDevice, string name, string unit, float realValuePara){TerminalID = terminalID;IndexInDevice = indexInDevice;Name = name;Unit = unit;RealValuePara = realValuePara;RawData = new Data();}public void SetData(float rawValue){RawData.DataTime = DateTime.Now;RawData.RawValue = rawValue;RawData.Value = rawValue * RealValuePara;}
}

4.2 仪器类定义

为了方便解析,定义可解析645协议的父类Base645Driver

public class Base645Driver
{/// <summary>/// 电流互感器/// </summary>public float CT { get; private set; }/// <summary>/// 电压互感器/// </summary>public float PT { get; private set; }/// <summary>/// 已配置的数据/// </summary>public List<Factor> Factors{get { return this.m_Factors; }}/// <summary>/// 所有支持的数据/// </summary>public virtual List<ChannelConfig> AllSupportedChannels{get { return new List<ChannelConfig>(); }}/// <summary>/// 构造/// </summary>public Base645Driver(string address, float ct, float pt){...}/// <summary>/// 初始化/// </summary>public void Init(){...}/// <summary>/// 生成取数命令/// </summary>public bool MakeCmd(out byte[] cmd){...}/// <summary>/// 尝试解析收到的数据包/// </summary>public bool TryParse(byte[] data){...}
}

以DTSD483智能电表为例,只需要继承此类,定义内部通道即可。

/// <summary>
/// DTSD483电表驱动
/// </summary>
public class DTSD483Driver : Base645Driver
{public DTSD483Driver(string address, float ct, float pt): base(address, ct, pt){}public override List<ChannelConfig> AllSupportedChannels{get{List<ChannelConfig> channels = new List<ChannelConfig>();// tpe 组合有功总电能  kWh  XXXXXX.XX 二次侧值,真实值=tpe*PT*CTchannels.Add(new ChannelConfig("组合有功总电能", "kWh", 0, new byte[] { 0x00, 0x00, 0x00, 0x00 }, 2, PT * CT));// tqe 组合无功1总电能  kVarh  XXXXXX.XX 二次侧值,真实值=tqe*PT*CTchannels.Add(new ChannelConfig("组合无功1总电能", "kVarh", 1, new byte[] { 0x00, 0x03, 0x00, 0x00 }, 2, PT * CT));// tqe 组合无功2总电能  kVarh  XXXXXX.XX 二次侧值,真实值=tqe*PT*CTchannels.Add(new ChannelConfig("组合无功2总电能", "kVarh", 2, new byte[] { 0x00, 0x04, 0x00, 0x00 }, 2, PT * CT));// Ia A相电流值,单位A,二次侧值,真实值=Ia*CTchannels.Add(new ChannelConfig("A相电流", "A", 3, new byte[] { 0x02, 0x02, 0x01, 0x00 }, 3, CT));// Ua A相电压值,单位V,二次侧值,真实值=Ua*PTchannels.Add(new ChannelConfig("A相电压", "V", 4, new byte[] { 0x02, 0x01, 0x01, 0x00 }, 1, PT));// Pa A相有功功率值,单位kW,二次侧值,真实值=Pa*PT*CTchannels.Add(new ChannelConfig("A相有功功率", "kW", 5, new byte[] { 0x02, 0x03, 0x01, 0x00 }, 4, PT * CT));// PFa A相功率因数channels.Add(new ChannelConfig("A相功率因数", "", 6, new byte[] { 0x02, 0x06, 0x01, 0x00 }, 3, 1));// Qa A相无功功率值,单位kVar,二次侧值,真实值=Qa*PT*CTchannels.Add(new ChannelConfig("A相无功功率", "kVar", 7, new byte[] { 0x02, 0x04, 0x01, 0x00 }, 4, PT * CT));...return channels;}}protected override bool Parse(byte[] data){int factorIndex = -1;#region 电能// 组合有功总电能  kWh  XXXXXX.XXif (data[0] == 0x00 && data[1] == 0x00 && data[2] == 0x00 && data[3] == 0x00){factorIndex = m_Factors[0].IndexInDevice;}// 组合无功1总电能else if (data[0] == 0x00 && data[1] == 0x03 && data[2] == 0x00 && data[3] == 0x00){factorIndex = m_Factors[1].IndexInDevice;}// 组合无功1总电能else if (data[0] == 0x00 && data[1] == 0x04 && data[2] == 0x00 && data[3] == 0x00){factorIndex = m_Factors[2].IndexInDevice;}#endregion#region A相// Ia A相电流值,单位A,二次侧值,真实值=Ia*CTelse if (data[0] == 0x02 && data[1] == 0x02 && data[2] == 0x01 && data[3] == 0x00){factorIndex = m_Factors[3].IndexInDevice;}// A相电压值else if (data[0] == 0x02 && data[1] == 0x01 && data[2] == 0x01 && data[3] == 0x00){factorIndex = m_Factors[4].IndexInDevice;}// A相有功功率值else if (data[0] == 0x02 && data[1] == 0x03 && data[2] == 0x01 && data[3] == 0x00){factorIndex = m_Factors[5].IndexInDevice;}// A相功率因数else if (data[0] == 0x02 && data[1] == 0x06 && data[2] == 0x01 && data[3] == 0x00){factorIndex = m_Factors[6].IndexInDevice;}// A相无功功率值else if (data[0] == 0x02 && data[1] == 0x04 && data[2] == 0x01 && data[3] == 0x00){factorIndex = m_Factors[7].IndexInDevice;}#endregion...else{return base.Parse(data);}if (factorIndex < 0){return false;}float value = Data.DEFAULT_VALUE;if (!DoParseValue(data, m_AllSupportedChannels[factorIndex].PointLen, out value)){return false;}m_Factors[factorIndex].SetData(value);return true;}
}

4.3 总线定义

最后就是总线Bus的设计,总线需要实现挂载/移除仪器、定时轮询取数、电表通讯状态通知等功能。

大致结构如下。

public delegate void CommMessageArrivedHandler(string terminalID, bool isSend, bool? parseResult, byte[] data);public delegate List<Device> LoadDeviceHandler(string regpack);public delegate void BusClosingHandler(string busGuid);public class Bus : IDisposable
{public event CommMessageArrivedHandler CommMessageArrived;public event LoadDeviceHandler LoadDevice;public event BusClosingHandler Closing;public string BusGuid { get; set; }public DateTime LastCommTime { get; set; }public bool Enabled{get { return m_Enabled; }set{this.m_SendTimer.Enabled = value;StartSample(value);}}private bool m_Connected = true;public bool Connected{get{if (m_Socket != null){return m_Connected && !((m_Socket.Poll(1000, SelectMode.SelectRead) && (m_Socket.Available == 0)) || !m_Socket.Connected);}else{return false;}}}/// <summary>/// 获取总线上启用的仪器个数/// </summary>public int EnabledDeviceCount{get { return m_Device.Count(a => a.Enabled); }}/// <summary>/// 获取总线上启用的仪器个数/// </summary>public Bus(Socket socket){BusGuid = Guid.NewGuid().ToString();m_Socket = socket;LastCommTime = DateTime.Now;m_SendTimer.Interval = 2000;m_SendTimer.Elapsed += SendTimer_Elapsed;m_SendTimer.Start();}public void Close(){if (m_Socket != null){m_Socket.Close();}OnClosing();}public void RemoveDevice(Device device){...}public void Dispose(){...}
}

程序启动时开启Socket监听,当有新的客户端连入时,解析注册包,并生成总线,总线内部由定时器定时轮询,发送指令向电表取数。

由于是基于Socket的通讯,存在Socket.IsConnectedTrue,而实际上链路已经断开的情况。在总线中实现了重连机制,通过判断此连接最后一次通讯成功的时间距当前时间超过设定的超时时间,来强制掐断此链接,卸载Bus下挂载的Device和Factor,注销此Bus,等待客户端重连。

五、数据统计模块设计

与采集类似,设计出统计模块的父类ProcSevice

/// <summary>
/// 数据处理共用父类
/// </summary>
public abstract class ProcService : BaseMySqlDA
{/// <summary>/// 目标电表/// </summary>protected List<Device> m_Devices = new List<Device>();/// <summary>/// 保存周期(分钟)/// </summary>public virtual int SaveInterval { get; protected set; } = 5;/// <summary>/// 构造函数,挂载电表/// </summary>/// <param name="device"></param>public ProcService(List<Device> device): base(ConnectionStringManager.EmsDataDB){m_Devices.AddRange(device);}/// <summary>/// 子类实现具体的计算逻辑/// </summary>/// <param name="now"></param>public abstract void Process(DateTime now);
}

不同型号的电表,采集的指标不同,有的电表只能出组合有功电能,还有的电表可以出完整的三相数据。按照客户要求,需要保存到独立的表中。因此,具体的逻辑交给子类实现,主程序只需要根据不同的电表型号出初始化不同的保存模块即可。

六、界面展示

本程序的侧重点在数据采集和保存,对界面的要求不高。但为了方便查看实时数据、电表在线状态和配置,我还是做了几个简单的界面。

6.1 电表实时数据和在线状况查看

实时监控界面如下,虽然朴素,但可以很清晰的看到每个电表的数据情况和在线状态。
在这里插入图片描述
左侧列出了所有的DTU,以及DTU下挂载的电表,可通过左侧的通讯状态指示灯判断电表的通讯状态。

  • 红灯代表离线或解析失败。
  • 绿灯代表解析成功

主界面右上角可以打开/关闭选中电表的实时通讯报文以及解析结果。

主界面右下角可能清楚地看到电表在线状态的统计信息。

6.2 电表信息管理

此界面可以配置电表信息及相应的DTU注册包。电表台账信息来自客户另一个数据库,因此本程序没有实现新增功能,读取现有的电表记录后,我方程序内配置并保存通讯参数。
在这里插入图片描述
双击行可配置通讯解析参数。
在这里插入图片描述

6.3 日志查询

程序会记录注册包通讯故障/恢复以及系统启动/退出三类运行日志,并提供查询。
在这里插入图片描述

七、稳定性和可靠性

作为一个数采系统,数据采集和传输的稳定性尤其重要。为保存程序7*24无故障运行,对于一些可预见的异常,必须及时处理。

7.1 检测端口占用

启动时检测到Socket端口被占用这种致命的问题,不应该直接将异常抛给用户,给出一个提示会友好很多。
在这里插入图片描述

7.2 单进程启动

Socket服务端通讯程序不允许同时启动多个实例,我们可以使用Mutex来确保只启动一个进程。

// 单例模式启动系统
bool canCreateNew = true;
int retryCount = 0;
do
{if (RunMutex == null){RunMutex = new Mutex(true, "EmsGetway", out canCreateNew);}else{canCreateNew = RunMutex.WaitOne(100, true);}retryCount++;
}
while (!canCreateNew && retryCount <= 20);if (!canCreateNew)
{MessageUtilEx.ShowInfo(null, "程序已经在运行。");Application.Exit();return;
}

main方法中加入上面的代码,启动时检测是否已经存在同名的Mutex,如果尝试20次之后依旧存在,则认为已经有一个实例正在运行,弹出消息框提示用户。
在这里插入图片描述

7.3 未处理异常

程序在运行过程中,还会有许多不可预见的异常,如某个组件被误删除,数据库服务挂了,等等。可以通过订阅下面的事件来保证异常发生后及时记录日志,方便后期排查故障。

// 未处理异常捕获
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
Application.ThreadException += new ThreadExceptionEventHandler(Application_ThreadException);

两个事件处理程序中都是调用了HandleException方法来记录日志。

/// <summary>
/// 未处理异常处理
/// </summary>
/// <param name="exceptionObj">异常对象</param>
private static void HandleException(object exceptionObj)
{string logMsg = null, titleMsg = null, attachMsg = null;if (exceptionObj is FileNotFoundException fileNotFoundException){string fileName = fileNotFoundException.FileName;logMsg = "无法找到以下文件,程序即将退出。\r\n文件名:" + fileName;titleMsg = "缺失文件,程序即将退出。";attachMsg = logMsg;LogUtil.WriteLog(typeof(App).FullName, fileNotFoundException);}else if (exceptionObj is Exception exception){logMsg = "发生错误,程序即将退出。\r\n异常信息:" + exception.Message + "\r\n堆栈:" + exception.StackTrace;titleMsg = "发生错误,程序即将退出。";attachMsg = "异常信息:" + exception.Message + "\r\n堆栈:" + exception.StackTrace;}LogUtil.WriteLog(logMsg);MessageUtil.ShowError(titleMsg, attachMsg);
}

7.4 开机启动

启动时向注册表写入启动项,保证开机后自动运行。

7.5 客户端数量限制

本程序运行在服务器上,由于端口号的限制,可接入客户端的数量是有上限的。同时,为了防止恶意攻击,有必要限制接入的最大客户端数量,存储为系统配置。

根据项目实际情况,电表数量不超过50台,目前只有13组DTU,因此限制了客户端数量为50。当接入客户端数量达到50个后,新来的连接会被强制关闭。

后期如果加装DTU,可以直接修改上限配置。

八、尾声

本文简要地描述了电表数据采集终端的设计思想,这只是个很简单的数采程序,有关数采核心模块没有过多描述,只贴了部分代码。

作为一个码农,只会用代码表达思想。

程序中还有不合理的地方值得优化。比如网络不通畅,应答延时就会出现串包现象。其实完全可以根据电表地址解析的,但由于总线按照简单的轮询规则收发报文,串包时就会认为解析失败。现在的做法是修改收发间隔来缓解此问题。

项目结束快两年了,运行地挺正常,没必要再花时间优化。

这篇关于基于DL/T645-07协议的电表数据采集终端的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/345808

相关文章

大模型研发全揭秘:客服工单数据标注的完整攻略

在人工智能(AI)领域,数据标注是模型训练过程中至关重要的一步。无论你是新手还是有经验的从业者,掌握数据标注的技术细节和常见问题的解决方案都能为你的AI项目增添不少价值。在电信运营商的客服系统中,工单数据是客户问题和解决方案的重要记录。通过对这些工单数据进行有效标注,不仅能够帮助提升客服自动化系统的智能化水平,还能优化客户服务流程,提高客户满意度。本文将详细介绍如何在电信运营商客服工单的背景下进行

基于MySQL Binlog的Elasticsearch数据同步实践

一、为什么要做 随着马蜂窝的逐渐发展,我们的业务数据越来越多,单纯使用 MySQL 已经不能满足我们的数据查询需求,例如对于商品、订单等数据的多维度检索。 使用 Elasticsearch 存储业务数据可以很好的解决我们业务中的搜索需求。而数据进行异构存储后,随之而来的就是数据同步的问题。 二、现有方法及问题 对于数据同步,我们目前的解决方案是建立数据中间表。把需要检索的业务数据,统一放到一张M

关于数据埋点,你需要了解这些基本知识

产品汪每天都在和数据打交道,你知道数据来自哪里吗? 移动app端内的用户行为数据大多来自埋点,了解一些埋点知识,能和数据分析师、技术侃大山,参与到前期的数据采集,更重要是让最终的埋点数据能为我所用,否则可怜巴巴等上几个月是常有的事。   埋点类型 根据埋点方式,可以区分为: 手动埋点半自动埋点全自动埋点 秉承“任何事物都有两面性”的道理:自动程度高的,能解决通用统计,便于统一化管理,但个性化定

使用SecondaryNameNode恢复NameNode的数据

1)需求: NameNode进程挂了并且存储的数据也丢失了,如何恢复NameNode 此种方式恢复的数据可能存在小部分数据的丢失。 2)故障模拟 (1)kill -9 NameNode进程 [lytfly@hadoop102 current]$ kill -9 19886 (2)删除NameNode存储的数据(/opt/module/hadoop-3.1.4/data/tmp/dfs/na

异构存储(冷热数据分离)

异构存储主要解决不同的数据,存储在不同类型的硬盘中,达到最佳性能的问题。 异构存储Shell操作 (1)查看当前有哪些存储策略可以用 [lytfly@hadoop102 hadoop-3.1.4]$ hdfs storagepolicies -listPolicies (2)为指定路径(数据存储目录)设置指定的存储策略 hdfs storagepolicies -setStoragePo

Hadoop集群数据均衡之磁盘间数据均衡

生产环境,由于硬盘空间不足,往往需要增加一块硬盘。刚加载的硬盘没有数据时,可以执行磁盘数据均衡命令。(Hadoop3.x新特性) plan后面带的节点的名字必须是已经存在的,并且是需要均衡的节点。 如果节点不存在,会报如下错误: 如果节点只有一个硬盘的话,不会创建均衡计划: (1)生成均衡计划 hdfs diskbalancer -plan hadoop102 (2)执行均衡计划 hd

【Prometheus】PromQL向量匹配实现不同标签的向量数据进行运算

✨✨ 欢迎大家来到景天科技苑✨✨ 🎈🎈 养成好习惯,先赞后看哦~🎈🎈 🏆 作者简介:景天科技苑 🏆《头衔》:大厂架构师,华为云开发者社区专家博主,阿里云开发者社区专家博主,CSDN全栈领域优质创作者,掘金优秀博主,51CTO博客专家等。 🏆《博客》:Python全栈,前后端开发,小程序开发,人工智能,js逆向,App逆向,网络系统安全,数据分析,Django,fastapi

烟火目标检测数据集 7800张 烟火检测 带标注 voc yolo

一个包含7800张带标注图像的数据集,专门用于烟火目标检测,是一个非常有价值的资源,尤其对于那些致力于公共安全、事件管理和烟花表演监控等领域的人士而言。下面是对此数据集的一个详细介绍: 数据集名称:烟火目标检测数据集 数据集规模: 图片数量:7800张类别:主要包含烟火类目标,可能还包括其他相关类别,如烟火发射装置、背景等。格式:图像文件通常为JPEG或PNG格式;标注文件可能为X

pandas数据过滤

Pandas 数据过滤方法 Pandas 提供了多种方法来过滤数据,可以根据不同的条件进行筛选。以下是一些常见的 Pandas 数据过滤方法,结合实例进行讲解,希望能帮你快速理解。 1. 基于条件筛选行 可以使用布尔索引来根据条件过滤行。 import pandas as pd# 创建示例数据data = {'Name': ['Alice', 'Bob', 'Charlie', 'Dav

SWAP作物生长模型安装教程、数据制备、敏感性分析、气候变化影响、R模型敏感性分析与贝叶斯优化、Fortran源代码分析、气候数据降尺度与变化影响分析

查看原文>>>全流程SWAP农业模型数据制备、敏感性分析及气候变化影响实践技术应用 SWAP模型是由荷兰瓦赫宁根大学开发的先进农作物模型,它综合考虑了土壤-水分-大气以及植被间的相互作用;是一种描述作物生长过程的一种机理性作物生长模型。它不但运用Richard方程,使其能够精确的模拟土壤中水分的运动,而且耦合了WOFOST作物模型使作物的生长描述更为科学。 本文让更多的科研人员和农业工作者