
简介面向具备C#基础、需要对接GE Proficy Historian开展二次开发的工程技术人员与学习者示例围绕历史数据采集、查询、写入和客户端访问等典型场景提供可参考的接口调用与项目组织方式。压缩包共18个文件、约18KB以8个cs源码、2个xaml界面文件、2个resx资源文件为主并配套解决方案、项目配置和说明文档覆盖从代码入口到桌面界面交互的基本组成。已有560人学习适合作为轻量入门案例。借助ClientAccessAPI相关示例读者可了解接口初始化、数据访问流程、界面组织与配置项设置熟悉项目入口、配置文件与资源布局对历史数据模型和客户端访问机制形成直观认识并据此搭建测试工程降低从零摸索的难度也可作为后续扩展报警、事件与数据展示功能的基础模板。1. 从一张导不出来的报表说起去年帮一个流程车间做能耗看板现场 Historian 里已经积累了好几年的时序数据可要出个班组级对比报表还是靠人打开客户端、点时间范围、导出 CSV、再在 Excel 里拼。我接手后第一件事就是把这条链路用 C# 做掉一个控制台程序 一个 Proficy Historian API demo 级别的骨架直接连上 Historian按时间窗口把原始采样和插值采样拉回来算完再算一遍对得上就固化。这份 demo 的价值不在于代码有多长而在于它把 Historian SDK 里最容易出问题的几件事——连接认证、采样模式选型、时间戳时区、批量写入的类型匹配——用能跑通的方式串了一遍。适合三类人手上有 Historian 但只会用客户端手动查的运维和自控工程师要接 Historian 数据做 MES/报表/能耗分析的后端开发以及被 x86/x64 和 DLL 加载问题折腾过、想找个确定答案的人。下面按“能连上、能读对、能写进去、不翻车”的顺序拆。2. 连接层的三件事程序集引用、平台目标与凭据Historian 的 .NET 接入没有 NuGet 包可以直接dotnet add package这是第一个让人卡壳的地方。客户端访问能力来自安装 Historian 客户端工具时注册到系统的那套程序集工程里能引用到什么版本取决于机器上装的是哪一套。所以这一步不是写代码而是先把运行环境摆正再谈连接串。2.1 程序集引用的正确姿势常见做法是在一台已经装好 Historian 客户端组件的机器上找到安装目录下的Proficy.Historian.ClientAccess.API.dll及其依赖把引用加进工程并把Copy Local设为true让编译输出目录带上这份 DLL。不要指望目标机器上“应该也装了”部署包自带一份是最省心的。# 典型安装路径按实际安装位置替换盘符和版本目录 # 32 位与 64 位会分别放在不同目录下选错就是运行期 BadImageFormatException C:\Program Files\Proficy\Historian\Client\Proficy.Historian.ClientAccess.API.dll引用加进来之后工程属性里有两个开关必须一起看Platform target和Prefer 32-bit。老一点的 SDK 只有 32 位版本工程跑在 x64 进程里就会在第一次调用时炸掉新版本同时提供两套但你的引用一旦指向 32 位那份目标平台就不能是 x64。我一般直接把控制台工程设成x86先跑通再考虑要不要换 64 位因为排查“到底是代码问题还是位数问题”的时间成本太高。!-- csproj 里显式钉死平台避免同事各自机器上行为不一致 -- PropertyGroup PlatformTargetx86/PlatformTarget Prefer32Bitfalse/Prefer32Bit /PropertyGroup逻辑说明PlatformTarget决定编译出来的 PE 头标记进程加载时按这个标记去找对应位数的原生依赖Prefer32Bit只对AnyCPU的 exe 生效设成false后AnyCPU会跑成 64 位这恰恰是很多人“我明明选了 AnyCPU 却加载失败”的原因。参数上没有可调空间唯一要确认的是引用 DLL 的位数与PlatformTarget一致不一致就是运行期才暴露编译期完全无感。提示如果你的部署环境同时有 32 位和 64 位两套客户端组件优先选 64 位避免 32 位进程 2GB 地址空间上限在大批量读取时被顶爆。2.2 ServerConnection连接、认证与释放连接对象是整条链路的入口所有查询和写入都从它派生出来。它的生命周期管理比大多数 ADO.NET 连接更严格——忘记Close()不会立刻报错而是慢慢把服务端的会话数耗光等你发现时已经是“昨天还好好的今天全连不上”的玄学现场。using Proficy.Historian.ClientAccess.API; // ServerConnection 不是线程安全的一个线程一个实例别做成静态单例到处共用 ServerConnection connection new ServerConnection(); try { // 单参数重载走当前 Windows 身份适合域内服务账号运行 connection.Connect(HIST-SRV01); Server server connection.Server; // 这里拿到 server 后就可以做 Tag / Data 两类操作 Console.WriteLine(connected: connection.ServerName); } catch (Exception ex) { // 连接失败的原因高度集中在认证与网络两类先打全信息再决定是否重试 Console.WriteLine(connect failed: ex.Message); throw; } finally { // Close 放在 finally异常路径同样要释放 connection.Close(); }逻辑说明Connect(string)用当前进程身份做 Windows 集成认证服务账号跑定时任务时最省事如果 Historian 服务端和客户端不在同一个域或者需要用一组专用的只读账号就得走带凭据的重载。connection.Server是后续所有操作的根对象它本身不做连接动作真正的握手发生在Connect那一刻。参数上要注意两点。第一服务器名既可以是主机名也可以是 IP但走 IP 时 Kerberos 认证会退化甚至失败能写主机名就别写 IP。第二Close()必须配对且不要在using里包ServerConnection就以为万事大吉——部分版本的该类并未完整实现IDisposable语义显式try/finally是最保险的写法血泪经验。2.3 显式凭据与超时控制需要指定账号时用带凭据的重载。第三个参数是域本地账号留空字符串域账号写域名写错的表现是“用户名或密码不正确”而实际上密码是对的——这个坑我见过不止一次。// 三个参数分别是用户名、密码、域本地账户域留空 UserCredentials credentials new UserCredentials(svc_hist_ro, ********, PLANTDOM); ServerConnection connection new ServerConnection(); try { connection.Connect(HIST-SRV01, credentials); Server server connection.Server; // 连接建立后再设置超时才有意义部分版本在 Connect 之前设会被重置 server.ServerTimeout 60000; // 毫秒长查询场景适当放大 } finally { connection.Close(); }逻辑说明UserCredentials走的是 Historian 自身的用户校验通道不依赖调用方进程的 Windows 令牌适合跨机器、跨域的定时任务。ServerTimeout控制的是单次请求的等待上限默认值在跨网段、查询跨度大的场景下容易超时尤其是第一次查询要建立会话缓存时。参数建议跨度在 24 小时以内、点数在十万级的查询30 秒够用跨月的大范围扫描不要靠调大超时硬扛改成按时间窗口切片第 3 章会写。另外别把密码硬编码进源码配置中心或者环境变量读进来这是基本要求但现场代码里仍然屡见不鲜。3. 读数据三种采样模式的选型与时间窗口切分读是这份 demo 里用得最多的部分。Historian 的读取不是“SELECT * FROM 表”它提供三种采样语义选错了不是报错而是数据看起来“对但不对”——曲线形状变了、峰值被抹平、计数偏小。这一章把三种模式讲清楚再把查询参数一个个落到具体取值上。3.1 Raw、Interpolated、Calculated 的边界SampleMode.Raw返回的是归档里的原始采样点时间戳不规整设备怎么上报就怎么存。它适合做精确回放、事故追溯、算积分和累计量因为任何一个跳变都不会被丢掉。代价是点数完全不可控——一台高频采集的设备一天可能就是上百万点。SampleMode.Interpolated按固定间隔取点间隔由Interval指定Historian 在相邻两个原始点之间做线性插值。它适合画趋势图、做固定周期的报表对齐比如每 5 分钟一个格子谁也不多谁也不少。注意插值出来的点是“算出来的”不是“测到的”拿它做报警复盘会失真。SampleMode.Calculated是在每个窗口内做聚合平均值、最大值、最小值、累计值这些。它适合能耗统计、班次汇总一次查询就能把几万个原始点压成几十个结果。// 原始采样还原真实跳变适合追溯 DataQueryParams rawQuery new DataQueryParams(PLANT1.BOILER.TEMP); rawQuery.Criteria.StartTime new DateTime(2024, 5, 1, 0, 0, 0); rawQuery.Criteria.EndTime new DateTime(2024, 5, 1, 8, 0, 0); rawQuery.Criteria.SampleMode SampleMode.Raw; // 插值采样固定间隔适合趋势对齐 DataQueryParams interpQuery new DataQueryParams(PLANT1.BOILER.TEMP); interpQuery.Criteria.StartTime new DateTime(2024, 5, 1, 0, 0, 0); interpQuery.Criteria.EndTime new DateTime(2024, 5, 1, 8, 0, 0); interpQuery.Criteria.SampleMode SampleMode.Interpolated; interpQuery.Criteria.Interval TimeSpan.FromMinutes(5); // 聚合采样窗口内取平均适合能耗汇总 DataQueryParams calcQuery new DataQueryParams(PLANT1.BOILER.TEMP); calcQuery.Criteria.StartTime new DateTime(2024, 5, 1, 0, 0, 0); calcQuery.Criteria.EndTime new DateTime(2024, 5, 2, 0, 0, 0); calcQuery.Criteria.SampleMode SampleMode.Calculated; calcQuery.Criteria.Interval TimeSpan.FromHours(1); // 聚合方式随版本枚举命名略有差异Average/Minimum/Maximum/Total 最常用 calcQuery.Criteria.Calculation CalculationMode.Average;逻辑说明三个查询的差别几乎全在Criteria上StartTime/EndTime是必填边界SampleMode决定取点语义Interval只在插值和聚合下生效原始模式下给了也会被忽略。Calculation的具体枚举名在不同版本里可能有出入编译不过时看 IntelliSense 提示的可用值就行语义是稳定的。参数选择上的经验做报表对齐优先Interpolated做班组能耗优先Calculated Total做故障复盘只认Raw。三者不要混着用来互相验证因为语义不同对不上是正常的对上了才是巧合。3.2 执行查询与取回结果查询对象是“懒”的Query调用只是把参数交给服务端真正的数据在GetSamples时才拉回来。这意味着你可以先构造多个查询再决定执行哪些但也要注意查询对象用完必须关否则服务端会积累一批挂着的结果集。IDataQuery dataQuery null; try { dataQuery server.Data.Query(rawQuery); DataSample[] samples; // 部分版本该重载带一个最大条数参数编译不过就按 IntelliSense 提示选择 dataQuery.GetSamples(out samples); Console.WriteLine($rows{samples.Length}); foreach (DataSample s in samples) { // Value 是 object读的时候按 tag 实际类型转换别一律 ToString Console.WriteLine(${s.Timestamp:yyyy-MM-dd HH:mm:ss.fff}\t{s.Value}\t{s.Quality}); } } finally { // 查询对象持有服务端游标必须显式关闭 if (dataQuery ! null) dataQuery.Close(); }逻辑说明server.Data.Query(...)返回IDataQuery它是服务端结果集的句柄GetSamples一次性把结果物化成数组。DataSample里三个字段最常用Timestamp、Value、Quality。Quality一定要看坏质量点如果被当成正常值参与计算能耗统计会莫名其妙偏大或偏小这种错误在报表上完全看不出来。参数上没有太多可调的真正要控制的是返回规模。一次GetSamples返回一千万个点进程内存会直接飙到几个 GB而且在 32 位进程里必然 OOM。所以真正的控制手段在下一节。3.3 时间窗口切片与内存控制Historian 的查询接口没有游标分页的概念控制内存只能靠切时间窗口。做法是外层的起止时间不动内层按固定粒度推进每一片查完立刻消费掉、丢掉引用让 GC 能回收。DateTime start new DateTime(2024, 5, 1, 0, 0, 0); DateTime end new DateTime(2024, 5, 31, 0, 0, 0); TimeSpan slice TimeSpan.FromHours(6); DateTime cursor start; long total 0; while (cursor end) { DateTime sliceEnd cursor slice; if (sliceEnd end) sliceEnd end; DataQueryParams q new DataQueryParams(PLANT1.BOILER.TEMP); q.Criteria.StartTime cursor; q.Criteria.EndTime sliceEnd; q.Criteria.SampleMode SampleMode.Raw; IDataQuery dq null; try { dq server.Data.Query(q); DataSample[] buffer; dq.GetSamples(out buffer); // 立刻处理别往大 List 里囤 total buffer.Length; Consume(buffer); // 落库、聚合、写文件按你的业务来 } finally { if (dq ! null) dq.Close(); } // 关键下一片从上一片的结束时间继续边界点会重复靠 Consume 里去重 cursor sliceEnd; }逻辑说明切片粒度不是越小越好。切太细每次查询的固定开销会话往返、参数校验会占大头整体反而更慢切太粗单片内存又顶不住。经验值是单片返回 5 万到 20 万点之间比较舒服按 tag 的实际采样频率反推窗口长度。参数上有个必须处理的细节相邻两片的边界时间点会被查两次因为StartTime和EndTime都是闭区间。如果下游是插入数据库重复点会撞主键如果是累加结果会偏大。常见做法是在Consume里按 (tag, timestamp) 做一次去重或者把下一片的StartTime加一个最小时间单位。加时间单位看着简单但会丢掉正好落在边界上的真实点我更倾向去重。4. 写数据批量回填的字段约束与失败处理写比分两种情况一种是实时写一条一条来延迟敏感另一种是回填几个月的历史数据要一次性补进去吞吐敏感。两者的代码结构差别不大坑却集中在完全不同的地方。4.1 DataSample 的四个字段与类型匹配DataSample看起来简单真正决定成败的是Value的类型。它是object但 Historian 侧每个 tag 有自己的数据类型你写进去的类型和 tag 定义不一致时有的版本会静默转换有的版本直接返回失败还有的版本转换后精度损失——三种表现都很难在事后发现。DataSample sample new DataSample(); // 类型必须与 tag 定义一致单精度 tag 给 float双精度给 double // 给错类型可能不报错但读回来精度变了 sample.Value 87.5f; // 时间戳的时区语义是 Historian 写入环节最大的坑见第 5 章 sample.Timestamp DateTime.Now; // 质量位按业务给手工回填一般给 Good模拟值给 Uncertain sample.Quality Quality.Good; // 单条写入tag 名在方法参数上不在 DataSample 里 server.Data.SendSamples(PLANT1.BOILER.TEMP, new DataSample[] { sample });逻辑说明Value支持数值、字符串、字节数组几种形态字符串型 tag 用来存批次号、状态描述这类非数值信息字节数组型一般存整段报文。Quality不是装饰字段读回来时它是判断数据可用性的唯一依据回填历史数据时给Good意味着你保证这条数据可信这个责任要自己扛。参数上的硬约束时间戳不能晚于当前时间太多Historian 对未来时间的数据有校验写进去会被拒同一条数据重复写后写的覆盖先写的这个特性可以用来做修正但也要小心误覆盖。4.2 批量写入与逐条重试回填场景下单条调用的往返开销完全不可接受。正确做法是攒批一次几百到几千条同时把失败的那一批拆开逐条重试定位到具体是哪条数据有问题。public void WriteBatch(Server server, string tag, ListDataSample batch, int retry 3) { for (int attempt 1; attempt retry; attempt) { try { // 批量写入条数控制在几百到几千太大反而会因为单次请求体过大被拒 server.Data.SendSamples(tag, batch.ToArray()); return; } catch (Exception ex) when (attempt retry) { Console.WriteLine($batch failed, attempt {attempt}: {ex.Message}); Thread.Sleep(500 * attempt); // 退避避免服务端瞬时压力 } catch { // 重试耗尽降级为逐条写把坏点挑出来 foreach (DataSample s in batch) { try { server.Data.SendSamples(tag, new DataSample[] { s }); } catch (Exception inner) { Console.WriteLine($bad sample {s.Timestamp:O} - {inner.Message}); } } return; } } }逻辑说明外层重试针对的是网络抖动和服务端瞬时不可用这种失败整批重发是安全的因为同一个时间戳重复写是覆盖语义。重试耗尽后降级为逐条目的是把“坏数据”和“坏链路”区分开——如果逐条写只有个别失败那就是数据本身有问题如果逐条全失败那就是连接断了这时候应该重建连接而不是继续硬试。参数建议批大小 500 到 2000 之间具体看单条数据大小重试次数 3 次足够退避用线性或指数都行别用固定 0 延迟那等于没退避。另外写之前先确认 tag 存在且类型匹配比写失败再排查高效得多这就要用到下一节的 tag 查询。4.3 Tag 检索与元数据读取tag 名写错是最高频的低级错误而且写错时往往不报错只是数据进了另一个 tag 或者根本没进去。上线前用 tag 查询把名字核一遍能省掉大量对账时间。TagQueryParams tagQuery new TagQueryParams(); // 支持通配符* 匹配任意字符? 匹配单个字符 tagQuery.Criteria.TagMask PLANT1.BOILER.*; tagQuery.Criteria.DescriptionMask *TEMP*; // 按描述二次过滤可省略 ITagQuery tagResult null; try { tagResult server.Tag.Query(tagQuery); Tag[] tags; tagResult.GetTags(out tags); foreach (Tag t in tags) { // 数据类型决定了写入时 Value 该用 float 还是 double Console.WriteLine(${t.Name}\t{t.DataType}\t{t.Description}); } } finally { if (tagResult ! null) tagResult.Close(); }逻辑说明TagMask是必须的不给就是全库扫描在 tag 数量上万的现场会直接拖慢服务端。DescriptionMask是可选的二次过滤适合“名字没规律但描述规范”的场景。Tag对象里的DataType是写入前校验的依据Name是精确拼写Description和工程单位字段用来做报表表头。参数上注意通配符不要写成*TEMP*这种全库模糊匹配前缀能确定就一定要带上。真需要模糊搜的时候把结果缓存下来别在循环里反复查。5. 排查清单五类翻车现场与对应处理这些不是理论上可能发生的问题是实际会让人卡半天的问题。按“现象 → 原因 → 解决”写遇到时按顺序对。5.1 编译通过运行时抛 BadImageFormatException现象代码编译零警告new ServerConnection()或第一次Connect时抛BadImageFormatException或者报“无法加载文件或程序集”。原因引用的Proficy.Historian.ClientAccess.API.dll位数与进程位数不一致。工程是AnyCPU且Prefer 32-bit为false时进程跑成 64 位引用了 32 位 DLL 就必然炸。解决确认引用 DLL 的位数把PlatformTarget改成一致的值清一次bin/obj再编译。如果部署机上装的是另一套位数的客户端组件把 DLL 复制到输出目录并确认加载的是本地这份别让系统去 GAC 里找。5.2 查询返回空但客户端里明明有数据现象同一时间范围、同一个 tagHistorian 客户端里曲线正常C# 查出来samples.Length 0。原因时间戳的时区语义对不上。Historian 内部存的是统一时间基准查询参数传的是本地时间还是 UTC取决于服务端配置和客户端的转换策略两边不一致就会整整差出一个时区。解决先用一个极小范围比如最近 10 分钟做二分验证确认偏差是不是整小时。确认后统一在边界处做转换不要在每个查询里各自ToUniversalTime()那会越改越乱。更稳的做法是写一个ToHistorianTime(DateTime local)帮助方法全项目只走这一个入口。5.3 写入返回成功读回来精度变了现象写进去 87.53读回来 87.5 或者 87.5299987792969。原因DataSample.Value是object写 float 进双精度 tag、或者写 double 进单精度 tag都会触发转换。单精度只有约 7 位有效数字尾部必然失真。解决写入前用 4.3 节的 tag 查询拿到DataType按类型装箱。单精度用float双精度用double别图省事一律double。这条在能耗累计场景里尤其致命误差会随点数线性放大。5.4 大批量查询把进程内存吃爆现象查一个月的原始数据进程内存从 200MB 涨到 3GB32 位进程直接OutOfMemoryException64 位进程被系统拖到卡死。原因GetSamples是全量物化把所有点一次性装进数组。原始采样模式下一个高频 tag 一个月可能是千万级点数每个DataSample对象本身还有对象头开销。解决按 3.3 节做时间窗口切片单片控制在 5 万到 20 万点切片内立刻消费不要把结果往全局List里囤。如果业务确实需要全量改成边读边落库内存占用就是常数级的。5.5 跑几天后连不上重启进程就恢复现象服务稳定跑了两三天某天开始所有查询都超时重启程序立刻正常。原因ServerConnection或IDataQuery没有关闭服务端会话数被占满。异常路径上漏掉Close()是最常见的原因return提前退出、throw跳过清理都会留下悬挂句柄。解决所有ServerConnection用try/finally包裹所有IDataQuery、ITagQuery同理。再补一个心跳定时执行一次极轻量的查询连续失败就重建连接。别指望 GC 帮你回收这种非托管资源它不是那个机制。6. 封装一层把连接和查询收进一个可复用的小类裸调 SDK 的代码在脚本里能跑进项目就散架因为连接管理、时间转换、切片逻辑会在每个调用点重复一遍。我一般的做法是封一个薄层只做三件事连接复用、时间统一转换、按窗口切片返回IEnumerableDataSample。不追求通用够用就行。public sealed class HistorianReader : IDisposable { private readonly ServerConnection _conn; private readonly Server _server; public HistorianReader(string host, UserCredentials cred) { _conn new ServerConnection(); _conn.Connect(host, cred); _server _conn.Server; _server.ServerTimeout 60000; } public IEnumerableDataSample ReadRaw(string tag, DateTime startLocal, DateTime endLocal) { DateTime cursor startLocal; while (cursor endLocal) { DateTime sliceEnd cursor.AddHours(6); if (sliceEnd endLocal) sliceEnd endLocal; DataQueryParams q new DataQueryParams(tag); q.Criteria.StartTime cursor; q.Criteria.EndTime sliceEnd; q.Criteria.SampleMode SampleMode.Raw; IDataQuery dq null; DataSample[] buffer; try { dq _server.Data.Query(q); dq.GetSamples(out buffer); } finally { if (dq ! null) dq.Close(); } foreach (DataSample s in buffer) yield return s; cursor sliceEnd; } } public void Dispose() _conn.Close(); }这段代码有两个刻意的设计。一是用yield return把结果流式吐出去调用方foreach到哪算到哪内存占用跟窗口大小绑定跟总量无关。二是切片边界直接重叠去重责任交给调用方——听起来不优雅但比在封装层里藏一个去重字典要诚实得多调用方知道自己拿到的可能有重复点就会主动处理。用的时候长这样using (HistorianReader reader new HistorianReader(HIST-SRV01, cred)) { var q reader.ReadRaw(PLANT1.BOILER.TEMP, new DateTime(2024, 5, 1), new DateTime(2024, 5, 8)); // 流式消费配合下游批量入库 foreach (var s in q.Where(x x.Quality Quality.Good)) { // 写入目标库 } }验证这层封装对不对我的习惯是抽样比对在 Historian 客户端里选三个时间点一个落在整点、一个落在窗口边界、一个落在两次采样之间把客户端显示的数值和程序输出的数值逐一对上。边界点必须验因为切片逻辑的错误只在边界暴露。三个点对上再跑一次全量看总条数是否等于各窗口条数之和减去重叠部分两个都对上才算通过。注意yield return的迭代器在Dispose之后再枚举会抛异常消费方一定要在using块内把数据消费完别把IEnumerable返回出去延迟执行。从那以后我每次接新的 Historian 现场都强制先跑一遍这个比对流程三个抽样点、一个总条数、一次坏质量点统计。前两步验数据正确性第三步验质量位有没有被忽略。这一步花十分钟能省掉后面两周的对账。希望帮到你。本文还有配套的精品资源点击获取