简介:面向C#开发者与工业数据采集方向的二次开发人员,这套Proficy Historian API示例围绕GE Digital历史数据库的接口调用展开,帮助读者理解如何通过ClientAccess API完成历史数据查询、实时值读取、数据写入、报警与事件处理,以及导入导出和简单可视化等常见任务,适合已具备C#基础、希望把Historian接入自有系统或报表分析流程的开发者参考。压缩包共18个文件,以8个C#源码文件和2个XAML界面文件为主,辅以resx资源、解决方案与项目配置等,整体约18KB,体量轻便,便于快速浏览工程结构与核心调用示例。目前已有560人学习,说明其在同类接口示例中具有一定参考价值。读者可从中了解API初始化、查询执行、数据写入和界面展示的组织方式,并借此熟悉Historian的数据模型与访问流程,为后续集成、排错和功能扩展提供可直接借鉴的起点。
1. 从一条停机曲线说起:C# Proficy Historian API demo 能落地到什么程度
C# Proficy Historian API demo 这份资源,最适合放在“历史站已经跑着,但业务系统还在手工导 CSV”的现场。它把连接、点位查询、历史读取、批量写入和异常处理拆成可运行的 C# 示例,让你能把停机曲线、批次追溯、能耗日报接到 Historian 上,而不是停在接口文档里。适合做 MES 报表、设备告警、数据对账的后端和上位机开发者;也适合刚接手历史库、需要先验证账号和网络通不通的运维。读下去先跑连接,再做点位映射,最后用回读对账收口。
2. 把连接跑通:引用、配置与 ServerConnection 的最小闭环
Historian 类项目最容易出现的误判,是把“能 ping 通服务器”当成“API 能用”。实际落地时,连接层要同时解决程序集位数、账号权限、服务端别名和超时四件事,任何一件没对齐,后面查点位、读历史都会变成玄学。下面这套顺序是我在多个现场反复用过的:先让控制台程序连上,再用一个已知点位做轻量探测,最后才把连接对象交给查询层。
2.1 先认清 SDK 与进程位数
Proficy Historian 的 Client Access 程序集常见命名以Proficy.Historian开头,引用时不要从别的机器随手拷一份 DLL 进来,尽量用现场 SDK 安装目录里的同一套程序集。更关键的是位数:如果程序集是 32 位,而主程序以 AnyCPU 或 x64 启动,运行时会直接抛BadImageFormatException,错误信息看起来像“文件损坏”,实际是进程位数不匹配。
我一般会先把启动项目固定成 x86,确认能连上之后,再评估是否把采集服务拆成独立的 x86 进程。不要只改解决方案配置里的 “Any CPU”,还要看项目属性里的“平台目标”和实际运行进程。任务管理器里看进程有没有(32 位)标记,是最快的确认方式。
注意:如果现场已经有 64 位的主业务系统,不要把 Historian SDK 强行塞进主进程,单独做一个 x86 采集服务,通过队列或本地接口把数据交给主系统,稳定性会好很多。
2.2 配置文件里不要硬编码连接串
连接信息建议放在配置文件里,但密码不要落盘。常见做法是配置文件只写服务器、账号和环境变量名,密码由部署环境注入。这样开发机、测试机、生产机可以用同一份代码,只换环境变量。
{ "Historian": { "Server": "historian-app-01", "UserName": "svc_hist_read", "PasswordEnv": "HIST_PASSWORD", "Domain": "", "ConnectTimeoutMs": 8000 } }这段配置把“连接目标”和“凭据来源”分开了。Server写 Historian 服务端别名或主机名,UserName用专门的只读服务账号,PasswordEnv指向环境变量名而不是密码本身,Domain留空表示走本地账号或现场约定的认证方式。ConnectTimeoutMs不是 SDK 的硬性参数,而是业务层自检的超时预算,避免启动时无限等待。
public sealed class HistorianOptions { public string Server { get; set; } = ""; public string UserName { get; set; } = ""; public string PasswordEnv { get; set; } = "HIST_PASSWORD"; public string Domain { get; set; } = ""; public int ConnectTimeoutMs { get; set; } = 8000; public string ReadPassword() { var pwd = Environment.GetEnvironmentVariable(PasswordEnv); if (string.IsNullOrWhiteSpace(pwd)) throw new InvalidOperationException($"环境变量 {PasswordEnv} 未设置"); return pwd; } }ReadPassword把密码读取集中在一个入口,后面不管换成交付脚本、容器注入还是 Windows 服务账号,都只改这一处。ConnectTimeoutMs留给启动自检使用,不要让连接动作散落在每个业务方法里。账号权限上,读历史和写历史最好拆成两个账号,报表服务只给读权限,回填工具才给写权限。
2.3 用一段代码验证认证、别名与超时
连接对象不要每次查询都新建。一个进程维持一个ServerConnection,查询串行执行;并发高的场景按线程或按任务建会话,但要有上限。下面这段是 demo 里常见的连接封装,重点是把“连接”和“轻量探测”放在一起,避免出现“连接成功但账号没有点位权限”的假在线。
using Proficy.Historian.ClientAccess.API; public sealed class HistorianSession : IDisposable { private ServerConnection _connection; public ServerConnection Connection => _connection ?? throw new InvalidOperationException("连接尚未打开"); public void Open(HistorianOptions opt) { _connection = new ServerConnection(); _connection.Connect(opt.Server, opt.UserName, opt.ReadPassword()); // 用已知点位做一次轻量探测,确认账号至少能读点位 var p = new TagQueryParams { NamePattern = "LINE1.TIC101.PV", MaxNumber = 2 }; var q = new TagQuery(_connection); q.Query(p); if (q.Tags.Count == 0) throw new InvalidOperationException("连接成功,但探测点位不存在或无权限"); } public void Dispose() { _connection?.Disconnect(); _connection = null; } }这段代码做了三件事:建立连接、用精确点位做权限探测、在探测失败时尽早抛错。NamePattern不要在生产环境写成*,全库通配在点位多的现场会拖慢启动;MaxNumber只是本次返回上限,不是分页游标。Disconnect放在Dispose里,Windows 服务停止或命令行退出时能释放会话。若现场 SDK 的Connect重载不同,只改这一层适配,不要把业务代码绑死在具体签名上。
3. 标签不是字符串:TagQuery 的过滤、别名与点位映射
Historian 里的点位名往往带着工段、设备、测点类型,业务代码直接拿字符串拼接,短期能跑,半年后就会变成“改一个点名崩三个报表”。demo 里值得抄的部分,不是TagQuery那一行调用,而是把点位查询收敛成“前缀过滤 + 业务键映射 + 元数据缓存”三层。这样报表只认业务键,Historian 点名变了只改映射表。
3.1 用 TagQuery 做前缀过滤而不是全量拉取
TagQuery适合按名称模式找点位,但*放在开头会放大服务端扫描成本。常见做法是先按工段前缀缩小范围,比如LINE1.*、AREA2.REACTOR.*,再在客户端做二次过滤。下面这段按前缀查点位,并把元数据转成业务层对象,避免后续代码直接依赖 SDK 的Tag类型。
public sealed class TagInfo { public string Name { get; set; } = ""; public string Description { get; set; } = ""; public string DataType { get; set; } = ""; public string Units { get; set; } = ""; } public IReadOnlyList<TagInfo> SearchTags( ServerConnection conn, string prefix, int pageSize = 200) { var p = new TagQueryParams { NamePattern = prefix + "*", MaxNumber = pageSize }; var q = new TagQuery(conn); q.Query(p); var list = new List<TagInfo>(); foreach (Tag tag in q.Tags) { list.Add(new TagInfo { Name = tag.Name, Description = tag.Description ?? "", DataType = tag.DataType.ToString(), Units = tag.EngineeringUnits ?? "" }); } return list; }prefix建议由配置表提供,不要由前端直接拼。pageSize控制在 200 到 500 之间,太大一次返回的元数据会占用内存,太小又需要多次请求。MaxNumber不是游标,命中数超过上限时不会自动翻页;如果前缀命中太多,按首字母或数字区间继续切片,或者让现场导出一份点位清单做离线映射。DataType和EngineeringUnits要保留,后面做量程校验和单位换算时用得上。
3.2 把点名收敛到业务键
业务代码里不要出现LINE1.TIC101.PV这种点名,统一走映射表。映射表至少包含业务键、Historian 点名、工程单位、缩放系数和偏移量。缩放系数不要写死在查询语句里,读出来之后统一应用,写入之前做逆变换。
| 业务键 | Historian 点位 | 单位 | Scale | Offset |
|---|---|---|---|---|
| line1.reactor.temp | LINE1.TIC101.PV | ℃ | 1.0 | 0.0 |
| line1.reactor.pressure | LINE1.PIC102.PV | kPa | 0.1 | 0.0 |
| plant.energy.total | PLANT.KWH.TOTAL | kWh | 1.0 | 0.0 |
这张表是 demo 里最该落到数据库或配置中心的部分。Scale和Offset用来处理原始值到工程值的线性变换,比如原始整数 1234 表示 123.4 kPa,就配Scale=0.1。写入时用(value - Offset) / Scale反算,别在两个地方各写一套。业务键命名要稳定,不要把中文描述直接当键,后面做多语言或报表改版会很难受。
3.3 元数据缓存与失效
点位元数据不会每秒变,但也不能永久缓存。常见做法是进程内缓存 10 分钟,点位不存在时不要缓存空结果,避免现场刚建点、服务端还没同步时被负缓存拖住。下面这段用ConcurrentDictionary做最小缓存,生产环境可以换成带过期的内存缓存组件。
private static readonly ConcurrentDictionary<string, CacheItem> TagCache = new(); private sealed class CacheItem { public TagInfo Value { get; init; } public DateTime ExpireAt { get; init; } } public TagInfo GetTag(ServerConnection conn, string name) { var now = DateTime.UtcNow; if (TagCache.TryGetValue(name, out var item) && item.ExpireAt > now) return item.Value; var p = new TagQueryParams { NamePattern = name, MaxNumber = 10 }; var q = new TagQuery(conn); q.Query(p); var tag = q.Tags.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase)); if (tag == null) throw new InvalidOperationException($"点位不存在: {name}"); var info = new TagInfo { Name = tag.Name, Description = tag.Description ?? "", DataType = tag.DataType.ToString(), Units = tag.EngineeringUnits ?? "" }; TagCache[name] = new CacheItem { Value = info, ExpireAt = now.AddMinutes(10) }; return info; }缓存键用 Historian 点名,命中后直接返回业务层TagInfo。ExpireAt用 UTC,避免服务器时区变化导致缓存提前或延后失效。点位不存在时直接抛错,不写空缓存;现场新增点位后,最迟 10 分钟能自动恢复。若点位改名频繁,缓存时间缩短到 1 到 2 分钟,或者在配置变更时主动清空对应键。
4. 历史数据读写:采样模式、时间窗与批量写入
历史数据这部分,翻车最多的不是 API 调用,而是采样模式和时间窗。原始点查询、按点数抽样、插值查询返回的曲线形状不一样,报表上看到一个“尖峰消失”就怀疑 Historian 丢数,实际是查询模式选错了。写入侧则相反,接口返回成功不代表数据可查,必须用原始查询回读,确认时间戳、值和质量码都对得上。
4.1 Raw、Interpolated、Lab 三种查询别混用
| 模式 | 适用场景 | 主要代价 |
|---|---|---|
| RawByTime | 故障追忆、原始点对账 | 点多时返回量大 |
| RawByNumber | 趋势预览、固定点数展示 | 可能漏掉短时尖峰 |
| Interpolated | 日报、班报、固定周期报表 | 受插值算法和边界影响 |
| Lab | 实验室数据、批次化验值 | 与过程数据混用要单独标记 |
故障追忆用原始点,报表用插值或聚合,趋势预览可以用按点数抽样。不要把RawByTime直接套在几个月的查询上,也不要用插值结果做报警回放,插值会平滑掉瞬时异常。demo 里通常把采样模式做成配置项,业务层传枚举,适配层再映射到 SDK 的SamplingMode。
4.2 读历史数据的最小代码
时间统一用 UTC 传入,显示层再转本地时区。下面这段读原始点,强制检查DateTimeKind.Utc,避免本地时间混进查询窗口。SamplingInterval按现场最小采集周期设置,不要为了“看起来平滑”把间隔设得比采集周期还小。
public sealed class SampleRow { public DateTime TimeUtc { get; set; } public double Value { get; set; } public Quality Quality { get; set; } } public IReadOnlyList<SampleRow> ReadRaw( ServerConnection conn, string tag, DateTime startUtc, DateTime endUtc, int stepSeconds = 1) { if (startUtc.Kind != DateTimeKind.Utc || endUtc.Kind != DateTimeKind.Utc) throw new ArgumentException("时间必须显式使用 UTC"); var p = new DataQueryParams(); p.Tags.Add(tag); p.StartTime = startUtc; p.EndTime = endUtc; p.SamplingMode = SamplingMode.RawByTime; p.SamplingInterval = TimeSpan.FromSeconds(stepSeconds); var q = new DataQuery(conn); q.Query(p); var rows = new List<SampleRow>(); foreach (Sample s in q.Samples[tag]) { rows.Add(new SampleRow { TimeUtc = s.TimeStamp.ToUniversalTime(), Value = Convert.ToDouble(s.Value), Quality = s.Quality }); } return rows; }这段代码的关键参数是StartTime、EndTime、SamplingMode和SamplingInterval。StartTime和EndTime必须是 UTC,stepSeconds默认 1 秒,现场采集周期是 5 秒就改成 5,不要用 1 秒去“补”出并不存在的点。q.Samples[tag]按点名取样本集合,若现场 SDK 返回的是扁平集合,用Where过滤即可。Quality要保留,后面报表要区分好值、坏值和不确定值。
4.3 写入与回填:质量码和批量提交
写入不要逐点调用。一个点一次网络往返,回填一万个点会非常慢。常见做法是每批 500 到 1000 条,批量提交,批次失败时整批重试并记录业务幂等键。质量码要显式设置,回填数据不要一律给Good,否则坏值会被洗成好值,后面排查没有后悔药。
public int WriteBatch( ServerConnection conn, string tag, IReadOnlyList<SampleRow> rows, int maxBatch = 800) { var written = 0; foreach (var chunk in rows.Chunk(maxBatch)) { var p = new DataWriteParams(); p.Tags.Add(tag); foreach (var r in chunk) { if (r.TimeUtc.Kind != DateTimeKind.Utc) throw new ArgumentException("写入时间必须是 UTC"); p.Samples.Add(new Sample(r.TimeUtc, r.Value, r.Quality)); } var w = new DataWrite(conn); w.Write(p); written += chunk.Length; } return written; }maxBatch默认 800,现场网络差就降到 200 到 300,内网稳定可以到 1000。每批写入前检查时间戳,Sample的时间统一用 UTC。Quality从上游数据带过来,不要统一填Good。写入完成后不要只看返回值,立刻用原始查询回读同一时间窗,比对点数、时间戳、数值和质量码;重复时间戳在不同采集器上的行为可能不同,回填工具要有业务幂等键,不能靠“覆盖”当幂等。
5. 避坑排查:连接、点位、时间与写入的四类翻车
Historian 的问题有一个特点:现象在业务层,根因在连接层或点位层。报表说“没数据”,可能是账号没有点位权限;写入说“成功”,可能是写到了另一个采集器。下面这几条是按现场排查顺序整理的,每条都按“现象、原因、解决”走,遇到问题先照着对一遍,比在代码里盲改快得多。
5.1 连接层的翻车:位数不对与假在线
现象:控制台程序一启动就报BadImageFormatException,或者引用明明加了,运行时却提示找不到程序集。原因:SDK 程序集是 32 位,主程序以 x64/AnyCPU 启动;或者引用的 DLL 被复制到了输出目录,但依赖的附属程序集没带过去。解决:把启动项目平台目标改成 x86,清理输出目录后重新生成;用任务管理器确认进程位数;依赖程序集从现场 SDK 目录整体引用,不要只拷一个 DLL。
现象:Connect没有抛异常,但后面TagQuery返回 0,业务层以为“没有点位”。原因:连接成功只代表网络和认证过了,不代表账号有点位读权限,也不代表点位名拼写正确。解决:用已知点位做精确探测,不要用*做生产自检;
本文还有配套的精品资源,点击获取