我们直接聊工业上位机里的OEE计算。这几年只要做工厂数字化、设备联网、产线追溯,OEE就是一个绕不开的指标。我见过太多项目,报表做得花团锦簇,但底层数据一塌糊涂,算出来的OEE根本没人信。原因无外乎两点:一是对OEE的公式理解停留在PPT层面,二是上位机代码里的时间口径跟车间实际对不上。这篇文章我会把OEE从概念到上位机落地实现完整过一遍,重点放在数据建模、代码实现和现场调试这三个环节,全部基于我实际写过的项目代码,你可以直接拿去改。
1. OEE计算最容易翻车的不是公式,而是时间口径
OEE的公式本身极其简单,就是可用率、表现指数、质量指数的乘积,任何一个学过基础工业工程的人都能背出来。但真正到了上位机开发阶段,问题从来不出在乘法上,而是出在“公式里的每一项,你到底用的是哪个时间”。
什么叫可用率?教科书上写的是“设备实际运行时间除以计划生产时间”。但计划生产时间到底含不含午休?含不含换班交接的停机?含不含设备预热?现场工人的理解、设备工程师的理解、上位机代码里的理解,三个版本经常对不上。我遇到过一个客户,他们的设备中午停机一小时是常态,但PLC里根本没有午休停机的信号,上位机按连续运行去算,OEE直接虚高十几个点,最后车间主任拿着报表去质问生产经理,场面非常尴尬。
所以第一个原则是:OEE计算的时间基准,必须和车间的真实排班逻辑严格对齐。代码里不能默认“计划时间就是24小时”,更不能默认“设备只要有电就算计划时间”。
在实践中,我一般建议上位机获取以下三个层面的时间数据:
- 排班表时间:来自MES或手工配置,定义了每个班次的开始和结束,包括午休、晚休等休息时段的起止。
- 设备状态时间:来自PLC或设备控制器,定义了设备在任意时刻处于什么状态,是运行、待机、故障、维修还是停机。
- 产量与不良数:来自传感器计数、PLC计数或人工录入,定义了在某个时间段内实际生产了多少件、其中多少件不合格。
OEE计算的上位机实现,本质上就是把这三类数据按统一的时间轴做对齐,然后切分到每个班次、每个小时、每个工单上去聚合。这一步做扎实了,OEE的准确率就有了七成保障。
还有一个容易踩的坑是“时间片”的粒度。有的设备状态信号变化很快,一个故障可能只持续了十几秒,但如果你的上位机轮询周期是1分钟,这个故障就丢了。反过来,如果轮询周期是100毫秒,数据量又太大,存储和查询都会成为瓶颈。我一般建议:状态信号变化的采集用200到500毫秒的轮询或者直接走PLC的变位上报,聚合展示用1分钟的粒度,这样能兼顾实时性和存储开销。这个后面代码部分会细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 上位机OEE的完整数据模型:从设备信号到指标落库
聊完口径,接下来是数据结构。我见过不少团队的OEE代码写着写着就成了面条代码,几百行的if-else,把OEE的三个因子揉在一起算,最后连自己都说不清哪里出了问题。正确的做法是先建模,把“设备状态”“时间分段”“产量数据”拆成三层,各算各的,最后再做乘法。
2.1 设备状态模型:不要只盯着“运行”和“故障”
典型的工业上位机项目里,设备状态至少需要区分这么几类:运行(RUN)、待机(IDLE)、故障(FAULT)、维修(MAINT)、停机(DOWN)、离线(OFFLINE)。有的行业还会分得更细,比如半导体行业会有Process、Recipe Change、Hold等状态。
这些状态的核心价值在于:它们直接决定了可用率里“设备实际运行时间”的界定。在代码层面,我建议给每一个状态定义一个枚举,并且给每个枚举绑定一个“是否计入OEE分母/分子”的布尔属性。这样做的好处是:业务规则有调整时,只需要改配置,不用改算法。
拿C#上位机来举例:
csharp复制public enum DeviceState
{
[StateMapping(CountAsPlanned = true, CountAsRun = false)]
Idle = 0,
[StateMapping(CountAsPlanned = true, CountAsRun = true)]
Running = 1,
[StateMapping(CountAsPlanned = true, CountAsRun = false)]
Fault = 2,
[StateMapping(CountAsPlanned = true, CountAsRun = false)]
Maintenance = 3,
[StateMapping(CountAsPlanned = true, CountAsRun = false)]
Down = 4,
[StateMapping(CountAsPlanned = false, CountAsRun = false)]
Offline = 5
}
注意这里有个小细节:Offline状态默认不计入计划时间。为什么?因为设备断电或者彻底脱离产线的时间,不应该算进OEE的分母里。但有的工厂有不同意见,他们觉得只要设备在班次内,不管是否离线都该算计划时间。这个没有统一标准,关键看你们和客户约定的口径。所以我把StateMapping这个attribute做成可配置的,现场调的时候不用改代码,改配置文件就行。
2.2 时间分段:把一天切成可计算的片段
设备状态是瞬时的、不平滑的,而OEE是按班次聚合的。中间需要一层“时间分段”的转换,也就是把连续的状态流切分成一段段有明确起止时间、明确状态属性、明确归属班次的记录。
这张表我一般叫DeviceStateSegment,结构大致如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| SegmentId | bigint | 分段主键 |
| DeviceId | int | 设备ID |
| StartTime | datetime | 分段开始时间 |
| EndTime | datetime | 分段结束时间 |
| State | int | 状态枚举值 |
| DurationSeconds | int | 持续时间(秒) |
| ShiftId | int | 所属班次 |
| OrderId | string | 关联工单 |
为什么要有这么一张分段表?因为它把“信号的连续变化”转成了“业务可查询的离散记录”。报表要查某个班次某个设备的状态分布时,一条SQL就出来了:
sql复制SELECT State, SUM(DurationSeconds)
FROM DeviceStateSegment
WHERE DeviceId = @devId AND ShiftId = @shiftId
GROUP BY State;
我强烈建议不要把原始信号明细直接用于OEE聚合,那会产生巨大的数据冗余,还慢。分段表相当于一层轻量级的预处理,既适合展示,也适合后续的OEE计算。
2.3 产量与质量数据:与状态时间必须严格同轴
OEE计算的第三要素是产量。这个数据通常来自两个渠道:一是PLC的硬计数(比如光电传感器计数),二是MES或人工录入的合格数、不良数。
产量数据必须和状态时间在同一个时间轴上才有意义。什么意思?就是你算某个班次的表现指数时,用的产量必须是这个班次时间段内实际生产的产量,而不是工单完工后倒推的产量。很多不良品是上一班次生产、下一班次才被检出的,如果按工单去对,当前班次的OEE就会被污染。
所以代码设计上,产量数据我建议单独建表,但要带上时间戳和班次归属:
csharp复制public class ProductionRecord
{
public DateTime Timestamp { get; set; }
public int DeviceId { get; set; }
public int ShiftId { get; set; }
public int GoodCount { get; set; }
public int BadCount { get; set; }
public string OrderId { get; set; }
}
这里有个常见争论:不良数到底是按生产时刻记录,还是按检测时刻记录。我的实践经验是:如果不良品是设备自动检测并立即标记的,按生产时刻记录;如果是线下人工抽检、事后发现的,则需要做一次“不良追溯调整”,把不良数归属到实际生产的那个班次,否则OEE质量指数会失真。这个调整逻辑要写在代码里,不能靠人工改数据库。
3. OEE核心计算代码实现:按班次聚合的完整示例
现在进入正题,给出可以落地运行的OEE计算代码。我以C#作为主语言,因为工业上位机最常用的就是C#配合WinForms或WPF,数据库用SQL Server或者SQLite都行。代码整体分三层:数据读取、区间聚合、OEE计算。
3.1 班次时间表与状态区间的匹配算法
首先需要一个辅助方法:给定一个班次的时间范围,从分段表里把所有状态片段切分到这个班次里。看起来简单,实际写的时候要处理各种边界情况——比如设备状态跨班次了、班次之间有重叠、维护时间正好卡在换班节点等等。
csharp复制public List<StateSegment> GetSegmentsForShift(int deviceId, Shift shift)
{
var allSegments = _db.DeviceStateSegments
.Where(s => s.DeviceId == deviceId
&& s.StartTime < shift.EndTime
&& s.EndTime > shift.StartTime)
.ToList();
var result = new List<StateSegment>();
foreach (var seg in allSegments)
{
var effectiveStart = seg.StartTime < shift.StartTime ? shift.StartTime : seg.StartTime;
var effectiveEnd = seg.EndTime > shift.EndTime ? shift.EndTime : seg.EndTime;
var duration = (effectiveEnd - effectiveStart).TotalSeconds;
if (duration <= 0) continue;
result.Add(new StateSegment
{
DeviceId = deviceId,
State = seg.State,
StartTime = effectiveStart,
EndTime = effectiveEnd,
DurationSeconds = duration,
ShiftId = shift.Id
});
}
return result;
}
这段代码的核心思路是“取交集”。先把所有可能和班次有重叠的片段捞出来,再对每个片段截取有效范围。注意最后的duration <= 0判断,如果某个片段刚好卡在班次边界上,截取后可能为0或负值,直接跳过。
实际项目中,分段表的数据量会比较大,所以SQL里的时间范围索引是必须的。我在StartTime和EndTime上都建了联合索引,查询性能才能跟得上。另外,由于状态切换是变位写入,写入频率并不高,这个表一般不用担心写入压力。
3.2 可用率、表现指数、质量指数的精确计算
有了班次内的状态片段,OEE三个因子的计算就水到渠成了。但有几个细节要提醒:
- 计划时间(PlannedTime):取班次时长减去Offline状态和未排产的时间。
- 运行时间(RunTime):所有Running状态的时长求和。
- 理论周期(IdealCycleTime):这个参数必须有,否则表现指数算不出来。如果现场没有明确的标准周期,可以从设备铭牌或工艺文件取,实在没有就先用最近一个月的平均实际周期代替,但要在系统里标注清楚是估算值。
- 质量指数(QualityRate):合格数除以总产量。如果设备故障期间把不良品混入良品箱,那质量数据就失真了,这种情况需要在采集端做好物理隔离。
计算代码:
csharp复制public OeeResult CalculateOee(int deviceId, Shift shift, double idealCycleTimeSeconds)
{
var segments = GetSegmentsForShift(deviceId, shift);
double plannedTime = shift.PlannedDurationSeconds;
double runTime = 0;
double idleTime = 0;
double faultTime = 0;
foreach (var seg in segments)
{
switch (seg.State)
{
case DeviceState.Running:
runTime += seg.DurationSeconds;
break;
case DeviceState.Fault:
faultTime += seg.DurationSeconds;
break;
case DeviceState.Idle:
idleTime += seg.DurationSeconds;
break;
case DeviceState.Offline:
plannedTime -= seg.DurationSeconds;
break;
}
}
// 实际排除掉的Offline时间不应小于0
if (plannedTime < 0) plannedTime = 0;
double availability = plannedTime > 0 ? runTime / plannedTime : 0;
var production = GetProductionForShift(deviceId, shift.Id);
int totalCount = production.GoodCount + production.BadCount;
double theoreticalRunTime = totalCount * idealCycleTimeSeconds;
double performance = runTime > 0 ? theoreticalRunTime / runTime : 0;
double quality = totalCount > 0 ? (double)production.GoodCount / totalCount : 0;
double oee = availability * performance * quality;
return new OeeResult
{
Availability = availability,
Performance = performance,
Quality = quality,
Oee = oee,
PlannedTime = plannedTime,
RunTime = runTime,
TotalCount = totalCount,
GoodCount = production.GoodCount,
BadCount = production.BadCount,
ShiftId = shift.Id,
DeviceId = deviceId
};
}
这段代码已经可以直接用。但注意几个我没有处理的问题:一是理想周期时间是硬编码传入的,实际系统里应该从工艺参数表读取;二是产量数据我假设已经和班次绑定好了;三是故障时间没有区分是设备自身故障还是缺料待料,这在半导体等高精度行业是必须区分开的,不然可用率会虚低。
3.3 高频轮询状态机的采集端实现
上位机要拿到实时设备状态,通常有几种方式:Modbus TCP轮询、OPC UA订阅、PLC的DB块读取。这里给一个基于OPC UA订阅的伪代码,因为OPC UA是目前工业互联的主流协议,而且它支持变位订阅,能大幅减少无用数据传输。
csharp复制public class OpcUaStateCollector
{
private readonly OpcUaClient _client;
private readonly Dictionary<string, DeviceState> _stateMap;
public void Start()
{
_client.SubscribeDataChange(
nodeId: "ns=2;s=Device1.CurrentState",
onDataChange: OnStateChanged
);
}
private void OnStateChanged(string tag, object value, DateTime timestamp)
{
var state = MapToDeviceState(value.ToString());
var newSegment = new DeviceStateSegment
{
DeviceId = _deviceId,
StartTime = timestamp,
EndTime = null, // 待下一个状态到来时闭合
State = (int)state
};
// 闭合前一个状态片段:写入EndTime
ClosePreviousSegment(timestamp);
// 写入新片段
InsertNewSegment(newSegment);
// 当班OEE实时刷新
RefreshRealtimeOee();
}
}
这里的关键是“变位闭合”的写法。前面的状态片段在收到新状态时才写入真正的EndTime,如果设备一直运行不变,那么“当前运行段”的EndTime就是NULL或默认值。在查询时,要把所有EndTime为NULL的片段视为“仍在持续”,计算时取当前时间。
需要注意:如果采集程序中途崩溃重启,重启前的最后一段状态会丢失。我一般的做法是:程序启动时先读取数据库最后一条未闭合记录,然后向PLC主动请求一次当前状态,如果状态一致就继续沿用,如果不一致就补一条闭合记录,再开新段。这个小逻辑能避免大量数据口径问题,别偷懒。
4. 数据链路与实时性设计:多久算一次OEE才是合理的
在工业现场,很多时候你不需要把OEE精确到毫秒级。根据我的经验,班次级OEE在换班后的1分钟内算出来,产线大屏的实时OEE在30秒到1分钟内刷新一次,这就够用了。
4.1 实时OEE的滑动窗口计算
有的客户要求大屏上的OEE实时跳动,这时候就不能等整个班次结束再聚合。我采用滑动窗口思路:取最近N分钟(通常10到30分钟)的产量和运行时间,按比例折算成当班OEE。
实际代码实现里,用一个后台定时任务,每30秒执行一次:
csharp复制public class OeeRealtimeWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
foreach (var device in _devices)
{
var slidingOee = CalculateSlidingOee(device.Id, TimeSpan.FromMinutes(20));
_hub.Clients.All.SendAsync("OeeUpdated", device.Id, slidingOee);
}
}
}
}
滑动窗口的时间不要选太短,太短了OEE会剧烈抖动,现场人员看了容易误判。也不要选太长,太长了大屏上的数字就“不动”了。20分钟左右是一个折中,既能看出趋势,又不会太敏感。
4.2 产量数据的上位机缓存策略
产量计数是高频数据,如果每来一个脉冲就写一次数据库,再好的数据库也扛不住。我建议在内存里维护一个计数器字典,每5秒或者每累计100个脉冲批量写一次。块写入既降低了IO压力,也能保证异常断电时最多丢5秒的数据。
csharp复制private ConcurrentDictionary<int, StockCounter> _counters = new();
public void OnPulseReceived(int deviceId, int goodDelta, int badDelta)
{
var counter = _counters.GetOrAdd(deviceId, _ => new StockCounter());
counter.Good += goodDelta;
counter.Bad += badDelta;
}
public void FlushCounters()
{
foreach (var pair in _counters)
{
_db.ProductionRecords.Add(new ProductionRecord
{
DeviceId = pair.Key,
Timestamp = DateTime.Now,
GoodCount = pair.Value.Good,
BadCount = pair.Value.Bad,
ShiftId = _shiftResolver.GetCurrentShift(pair.Key)
});
pair.Value.Reset();
}
_db.SaveChanges();
}
这个Flush方法由一个定时器每5秒调用一次。顺手加一个内存里的当前班次缓存,避免每条产量记录都去查班次表。
5. 上位机OEE报表与可视化:数据出来了怎么展示
计算OEE只是第一步,如何让车间的人看得懂、用得起来,反而是决定项目成败的关键。我见过太多项目,OEE数字算出来了,但界面设计不符合使用习惯,最后大屏变成摆设。
5.1 报表核心指标与维度设计
OEE报表建议按三个维度展示:时间维度(班次/天/周/月)、设备维度(单台/产线/车间)、工单维度(不同产品)。核心指标不只是OEE,还包括它的三个构成因子以及相关的绝对时长,这样出问题时能快速定位。
比如某班次OEE只有50%,如果你只展示一个50%,现场工程师完全不知道从哪里排查。但如果展示可用率80%、性能指数70%、质量指数90%,马上就能看出主要矛盾在性能指数上——进一步再看运行时间与理论产量的差距,就能发现设备空转太多。
这些指标的一个典型案例表结构如下:
| 维度 | 指标名称 | 数值 |
|---|---|---|
| 班次A | OEE | 61.2% |
| 班次A | 可用率 | 82.5% |
| 班次A | 性能指数 | 80.1% |
| 班次A | 质量指数 | 92.6% |
| 班次A | 计划时间 | 43200秒 |
| 班次A | 运行时间 | 35640秒 |
| 班次A | 理论产量 | 890件 |
| 班次A | 实际产量 | 715件 |
| 班次A | 不良品数 | 53件 |
表格比花哨的图表更有价值,尤其是对于需要做根因分析的工程师。图表是给领导看的,表格是给干活的人用的,两者都要有。
5.2 大屏实时展示:数据刷新与WPF绑定
上位机实时大屏我一般用WPF来做。绑定好设备列表,每个设备卡片显示OEE和三个因子,再用一个进度条展示运行时间占比,用颜色区分状态——绿色运行、黄色待机、红色故障。绑定的核心就是一个实现INotifyPropertyChanged的ViewModel。
csharp复制public class DeviceOeeViewModel : INotifyPropertyChanged
{
private double _oee;
public double Oee
{
get => _oee;
set { _oee = value; OnPropertyChanged(); }
}
private DeviceState _state;
public DeviceState State
{
get => _state;
set { _state = value; OnPropertyChanged(); }
}
}
后台收到实时计算的结果后就更新对应的ViewModel属性,界面自动刷新。要注意UI线程与后台线程的切换,避免跨线程访问控件。我通常用Application.Current.Dispatcher.Invoke或者await的异步方式处理。
6. 现场调试中最常见的OEE数据质量问题
代码写完了,部署到现场才是真正头疼的开始。我把这些年现场调试遇到的坑整理成清单,遇到问题直接对照排查。
6.1 状态信号缺失导致的时间黑洞
最典型的问题:PLC程序里没有某个停机状态的定义,或者设备断电后上位机收不到任何信号。于是这一段时间既不算运行,也不算故障,成了“时间黑洞”。时间黑洞一多,OEE的分母就莫名其妙变大,指标全面失真。
排查方法:在报表里加一个“未映射时长”的字段,专门统计既不在运行、也不在其他已定义状态里的时间长度。如果这个字段异常大,说明PLC点位映射不全,需要和电气工程师一起梳理设备状态点位。
6.2 时钟不同步导致的数据错位
上位机服务器和PLC控制器的时间如果不同步,状态时间与产量时间就无法对齐,跨系统查询时会出现各种“不可能”的数据——比如产量时间早于班次开始时间。生产环境的解决方案是用一台NTP时间服务器统一给PLC和上位机授时。如果你的项目不允许加NTP,就至少保证上位机在每天班次切换时向PLC同步一次时间,并在数据校验时过滤掉明显不合理的时间戳。
6.3 理论周期参数随产品变化的陷阱
这是半导体OEE项目里最容易踩的坑。半导体产线的不同产品、不同制程,理论周期完全不同。如果你用单一的理论周期去算所有产品的性能指数,高负载产品会显得“性能超标”,低负载产品会“性能不足”,导致OEE的横向对比毫无意义。
解决办法:工单表里必须带上产品型号,产品型号关联标准周期表,计算OEE时按工单取理论周期,而不是用一个全局常量。涉及多产品混线生产时,最好按工单分别计算再按产量加权汇总。
6.4 手动补录数据的审计问题
任何OEE系统都不可避免需要人工补录,比如设备故障但PLC没点检、产量计数跳数、不良品后来被返修等原因。补录是为了让数据更准,但也会成为数据造假的入口。我的建议是:所有补录操作必须有审批流,且数据库里保存补录人、补录时间、原始值、修改值,报表里对补录数据做特殊标记。这不光是管理问题,也是技术问题——补录进来的产量数据需要重新触发OEE计算,如果代码里没有“重新计算指定班次OEE”的接口,时间长了数据就对不齐了。
7. 如何保证OEE计算结果的长期可信
最后聊一个很多人忽视的问题:OEE不是算一次就完事,它是一个持续运行、持续校验的过程。系统上线第一周数据可能很好,三个月后就没人看了,因为数据和现场感受对不上,信任就崩了。
一个有效的做法是建立“周校验机制”。每周随机抽2到3个班次,人工核对OEE的构成数据:故障时长和维修记录是否吻合、产量数据和入库单是否对得上、待机时长与生产计划是否有逻辑冲突。一旦发现偏差,回溯代码和点位,把根因找出来。这样做两三个月,系统的数据可信度就能稳定建立起来。
另外要给OEE系统设计“数据质量评分”,用计算机自动检查每个班次数据的完整性。比如:分段表中是否有未闭合的时间段?产量数据是否存在缺口?状态时间总和与班次时长的差值是否超过阈值?如果存在,给该班次的OEE打上“数据存疑”的标记,而不是直接把一个不可信的数字展示出来。
csharp复制public void ValidateShiftData(int shiftId)
{
var totalStateTime = GetTotalStateTime(shiftId);
var shiftDuration = GetShiftDuration(shiftId);
var diff = shiftDuration - totalStateTime;
if (Math.Abs(diff.TotalSeconds) > 60) // 允许1分钟误差
{
MarkShiftAsSuspicious(shiftId, $"状态时间与班次时长偏差{Math.Abs(diff.TotalSeconds)}秒");
}
}
这个校验方法在每次班次OEE计算完成后自动执行,有异常就写入日志并标记。上线第一周你会发现它能揪出一堆问题,比如某个设备状态上报中断了40分钟、某个PLC点位被误改等。等到这些问题都清零了,OEE数据才算真正可以用。就我的项目经验来看,这个校验逻辑是OEE系统所有功能里性价比最高的一个模块,强烈建议任何人做OEE时都把它加上。
