一、APS 生产计划模块的痛点与落地
1. 当时的业务痛点
KiPlant 的客户是中小制造企业,排产方式普遍很原始。具体痛点有三个:
痛点一:Excel排产,响应滞后。 大部分工厂的生产计划员用 Excel 手动排产,一条产线几十道工序、十几台设备,排一份周计划要半天。更大的问题是计划排完后,产线实际执行情况和计划经常脱节——某台设备突然故障停机了,计划员可能两小时后才从车间主管那里得到消息,手动调整排程又要一两个小时。这个信息传递的滞后直接导致设备空转、半成品堆积。
痛点二:设备状态是"黑箱"。 设备是否在运行、当前运行的是哪个工单、节拍是多少、有没有异常振动——这些信息在车间现场可能有人知道,但系统里没有。排产时只能按"理想状态"排,不考虑设备的实际健康状况。结果就是经常出现"排了计划但设备半路坏了"的情况。
痛点三:TPM(全员生产维护)停留在纸质阶段。 工厂有点巡检制度,但记录在纸质表单上,填完锁在柜子里没人看。设备维保计划也是凭经验——"这台注塑机差不多该保养了",没有数据驱动的预防性维护。设备从"出现异常信号"到"真正停机"之间的窗口期被白白浪费了。
2. APS 排程模块的技术方案
先说清楚一点:我们做的不是像 SAP APO 那种重型 APS 系统,客户是中小工厂,需求是"比 Excel 好用、能和产线实时联动"。所以我们的定位是轻量级有限产能排程,核心是"约束条件下的工序调度"。
排程模型:
每个生产计划拆解为若干工单(Work Order),每个工单包含多道工序(Operation),每道工序需要特定设备资源(Resource)。排程目标是在满足工序先后依赖、设备产能约束、交期要求的前提下,最小化设备空闲时间(最大化利用率)。
这是一个经典的Job Shop Scheduling问题。我们没有自己从头写优化算法,而是集成了开源的 OptaPlanner(红帽出品的 Java 约束求解引擎)。选 OptaPlanner 而不是 OR-Tools 或自研遗传算法的原因:纯 Java,和 Spring 生态无缝集成;声明式约束定义,不用手写复杂的求解逻辑;社区有成熟的 Job Shop 示例可以参考;性能对中小规模问题(几百个工序、几十台设备)足够。
核心排程模型定义:
@PlanningSolution
public class ProductionSchedule {
@ProblemFactCollectionProperty
private List<Machine> machines; // 可用设备
@ProblemFactCollectionProperty
private List<WorkOrder> workOrders; // 工单列表
@PlanningEntityCollectionProperty
private List<OperationAssignment> assignments; // 工序分配(规划变量)
@PlanningScore
private HardSoftScore score; // 评分(硬约束+软约束)
}
@PlanningEntity
public class OperationAssignment {
@PlanningVariable(valueRangeProviderRefs = "machineRange")
private Machine assignedMachine; // 分配到哪台设备
@PlanningVariable(valueRangeProviderRefs = "timeSlotRange")
private TimeSlot startTimeSlot; // 开始时间段
private Operation operation; // 关联的工序
private WorkOrder workOrder; // 关联的工单
}约束规则(用 Drools 或 ConstraintStream 定义):
public class SchedulingConstraintProvider implements ConstraintProvider {
@Override
public Constraint[] defineConstraints(ConstraintFactory factory) {
return new Constraint[] {
// 硬约束1:同一台设备同一时间只能执行一道工序
machineConflict(factory),
// 硬约束2:工序先后依赖(前道工序完成后才能开始后道)
operationDependency(factory),
// 硬约束3:设备能力匹配(不能把铣削工序排到车床上)
machineCapability(factory),
// 硬约束4:设备维保时间窗口不可排产
maintenanceWindow(factory),
// 软约束1:尽量满足交期(越早完成越好)
minimizeLateness(factory),
// 软约束2:最小化设备换型次数
minimizeChangeover(factory),
// 软约束3:优先使用健康度高的设备
preferHealthyMachine(factory),
};
}
private Constraint preferHealthyMachine(ConstraintFactory factory) {
return factory.forEach(OperationAssignment.class)
.filter(a -> a.getAssignedMachine().getHealthScore() < 0.7)
.penizeSoft("unhealthy machine usage", HardSoftScore.ONE_SOFT,
a -> (int)((1.0 - a.getAssignedMachine().getHealthScore()) * 100));
}
}关键的一个软约束是 preferHealthyMachine——排程时会参考设备实时健康度评分,健康度低于 0.7 的设备会被优化器尽量避开。这个健康度评分就来自 TPM 模块的实时振动分析和点巡检数据,后面会详细讲。
3. 与 PLC/传感器实时数据的集成
设备数据采集链路:PLC/传感器 → MQTT Broker(EMQX)→ kiplant-device 服务 → 解析存储 + RocketMQ 事件分发。
kiplant-device 收到设备数据后做两件事:一是存储原始采集数据到 device_data 分表;二是解析出关键状态字段(运行/停机/故障、当前工单号、当前节拍、振动值、温度等),更新到 Redis 的设备状态缓存:
@Service
public class DeviceDataProcessor {
public void processRawData(DeviceRawMessage raw) {
// 1. 解析PLC协议(不同设备协议不同,通过协议适配器模式处理)
DeviceStatus status = protocolAdapterFactory
.getAdapter(raw.getProtocolType())
.parse(raw.getPayload());
// 2. 存原始数据到DB(异步,不阻塞主流程)
asyncExecutor.execute(() ->
deviceDataMapper.insert(toDeviceData(raw, status)));
// 3. 更新Redis设备实时状态(孪生体和APS都读这个)
String key = String.format("device:status:{%s}:%s",
raw.getTenantId(), raw.getDeviceId());
redisTemplate.opsForHash().putAll(key, Map.of(
"state", status.getState().name(), // RUNNING/IDLE/FAULT
"currentWorkOrder", status.getWorkOrderId(),
"cycleTime", String.valueOf(status.getCycleTimeMs()),
"vibration", String.valueOf(status.getVibrationMmS()),
"temperature", String.valueOf(status.getTemperatureC()),
"updatedAt", String.valueOf(System.currentTimeMillis())
));
redisTemplate.expire(key, Duration.ofMinutes(5));
// 4. 发MQ事件
mqProducer.sendDeviceStatusEvent(raw.getTenantId(), status);
}
}APS 排程模块在生成排程方案时,会从 Redis 读取所有设备的实时状态,作为约束输入。如果某台设备当前处于 FAULT 状态,排程时直接把它从可用资源里剔除;如果设备的 healthScore 下降了(TPM 模块计算的),优化器会倾向于把工序分配到更健康的设备上。
动态重排: 当 kiplant-device 检测到设备状态变化(比如从 RUNNING 变为 FAULT)时,发一条 device-status-change-topic 的 MQ 消息。kiplant-production 消费后判断:如果故障设备上有正在执行或即将执行的工单,触发局部重排——不是全量重新求解(太慢,几十秒),而是只对受影响的工序做调整(把它们重新分配到其他可用设备上)。OptaPlanner 支持 solverManager.solveAndListen() 的增量求解模式,局部重排在 2-3 秒内完成。
@RocketMQMessageListener(topic = "device-status-change-topic")
public class DeviceStatusChangeConsumer {
public void onMessage(DeviceStatusChangeEvent event) {
if (event.getNewState() == DeviceState.FAULT) {
// 查出该设备上受影响的工序
List<OperationAssignment> affected = scheduleService
.getAssignmentsByMachine(event.getDeviceId());
if (!affected.isEmpty()) {
log.warn("Device {} fault, triggering reschedule for {} operations",
event.getDeviceId(), affected.size());
// 局部重排
scheduleService.partialReschedule(
event.getTenantId(), affected);
// 通知车间看板刷新
wsNotifier.pushScheduleUpdate(event.getTenantId());
}
}
}
}4. TPM 闭环管理的实现
TPM 闭环分三层:数据采集→异常检测→维保执行。
第一层:振动分析(预防性维护的核心)
很多机械设备故障前会有振动异常——轴承磨损、不平衡、松动等。我们在关键设备(注塑机、CNC 等)上加装了振动传感器(加速度计),采样率 1000Hz,通过 MQTT 上报原始波形数据。
kiplant-device 收到振动原始数据后,不是直接存原始波形(太大了),而是做边缘预处理——计算几个关键特征值:
@Component
public class VibrationAnalyzer {
public VibrationFeature analyze(double[] rawWaveform, int sampleRate) {
// 1. RMS振动速度(mm/s)——最通用的振动评估指标
double rms = calculateRMS(rawWaveform);
// 2. FFT频谱分析,提取主频和谐频幅值
Complex[] spectrum = fft(rawWaveform);
double[] freqAmplitudes = extractAmplitudes(spectrum, sampleRate);
double dominantFreq = findDominantFrequency(freqAmplitudes, sampleRate);
// 3. 峰值因子(Peak Factor = Peak / RMS)——突变检测
double peak = Arrays.stream(rawWaveform).map(Math::abs).max().orElse(0);
double crestFactor = rms > 0 ? peak / rms : 0;
return VibrationFeature.builder()
.rmsVelocity(rms)
.dominantFrequency(dominantFreq)
.crestFactor(crestFactor)
.timestamp(System.currentTimeMillis())
.build();
}
}振动特征值存入 device_vibration_feature 表(按 tenant_id 分表),同时和预设的阈值做比对。阈值参考 ISO 10816 标准分四级:
public enum VibrationLevel {
GOOD(0, 1.8), // RMS < 1.8 mm/s
ACCEPTABLE(1.8, 4.5), // 可接受,安排下次维保时检查
WARNING(4.5, 11.2), // 警告,需要在1-2周内安排维保
DANGER(11.2, Double.MAX_VALUE); // 危险,立即停机检修
}当振动级别从 GOOD/ACCEPTABLE 升级到 WARNING 时,系统自动创建一条预防性维保工单推送给设备维护员;如果到了 DANGER,同时触发设备健康度评分急剧下降(降到 0.3 以下),APS 排程自动避开这台设备,并推送紧急告警到车间主管的企业微信。
第二层:点巡检数字化
把原来纸质的点巡检表电子化。车间巡检员用手机 App 扫设备二维码,加载该设备的巡检项(比如"润滑油液位"、"皮带松紧度"、"异响"等),逐项拍照 + 填写 + 提交。数据存入 inspection_record 表,和设备关联。
巡检结果也会影响设备健康度评分:
@Service
public class DeviceHealthScoreCalculator {
/**
* 综合健康度评分 =
* 振动评分(40%) + 巡检评分(30%) + 运行时长评分(20%) + 维保履历评分(10%)
*/
public double calculateHealthScore(String deviceId, String tenantId) {
// 1. 振动评分:最近24小时RMS均值映射到0-1
double vibScore = getVibrationScore(deviceId, tenantId);
// 2. 巡检评分:最近一次巡检的合格率
double inspScore = getInspectionScore(deviceId, tenantId);
// 3. 运行时长评分:距离上次保养已运行小时数/保养周期
double runtimeScore = getRuntimeScore(deviceId, tenantId);
// 4. 维保履历评分:历史故障频率
double maintScore = getMaintenanceHistoryScore(deviceId, tenantId);
double overall = vibScore * 0.4 + inspScore * 0.3
+ runtimeScore * 0.2 + maintScore * 0.1;
// 更新Redis缓存
redisTemplate.opsForHash().put(
deviceStatusKey(tenantId, deviceId),
"healthScore", String.valueOf(overall));
return overall;
}
}健康度评分每小时重新计算一次(XXL-JOB 定时任务),同时在振动告警、巡检提交等事件触发时实时更新。这个评分向上游传递给 APS 排程(影响设备选择),向下游传递给数字孪生体(影响 3D 渲染颜色)。
第三层:TPM 闭环
完整闭环:振动/巡检发现异常 → 自动创建维保工单 → 维修员接单执行 → 完工确认 → 健康度评分恢复 → APS 重新将设备纳入排程。
设备数据采集
↓
振动分析 / 点巡检
↓(异常触发)
自动创建维保工单(kiplant-device → MQ → kiplant-production)
↓
维修员App接单 → 执行维保 → 拍照确认 → 完工提交
↓
重新计算设备健康度(healthScore恢复)
↓
APS排程将设备重新纳入可用资源
↓
数字孪生体颜色从红色/黄色恢复为绿色二、三维工厂孪生体的技术实现
1. 技术选型:Three.js + WebSocket
前端 3D 渲染用的是 Three.js,没有用 Unity WebGL。原因:客户是通过浏览器访问 SaaS 平台的,Unity 打包的 WebGL 包太大(几十 MB),首次加载慢且对低端电脑不友好;Three.js 轻量、和 Vue 前端集成方便、社区生态好。我们的 3D 需求也不是游戏级画质,主要是展示工厂布局、设备位置、用颜色和动画表达设备状态就够了。
2. 后端数据与 3D 模型的打通
模型管理: 每个工厂租户的 3D 场景由两部分组成——工厂底板(厂房建筑模型,通常由实施人员用 Blender 建好上传)和设备模型(标准设备模型库 + 自定义模型)。模型文件(glTF/GLB 格式)存在 OSS 上,元数据存在 twin_model 表里,关联 tenant_id 和 device_id。
CREATE TABLE twin_model (
id BIGINT PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
device_id VARCHAR(64), -- NULL表示厂房底板模型
model_url VARCHAR(500) NOT NULL, -- OSS上的glTF文件URL
position_x DOUBLE, -- 在场景中的位置
position_y DOUBLE,
position_z DOUBLE,
rotation_y DOUBLE, -- Y轴旋转角度
scale DOUBLE DEFAULT 1.0,
bindable_points JSON, -- 可绑定数据点(如"运行指示灯位置")
INDEX idx_tenant (tenant_id)
);实时数据映射——WebSocket 推送:
前端打开数字孪生大屏后,建立 WebSocket 连接到 kiplant-twin 服务。kiplant-twin 消费 device-status-change-topic 的 MQ 消息,把设备状态变化实时推送给对应租户的 WebSocket 连接:
@Component
public class TwinWebSocketHandler {
// 按tenantId维护WebSocket session
private final Map<String, Set<WebSocketSession>> tenantSessions =
new ConcurrentHashMap<>();
public void pushDeviceStatus(String tenantId, DeviceStatusSnapshot snapshot) {
Set<WebSocketSession> sessions = tenantSessions.get(tenantId);
if (sessions == null || sessions.isEmpty()) return;
// 只推送变化的设备,不是全量
String message = JSON.toJSONString(TwinPushMessage.builder()
.type("DEVICE_STATUS")
.deviceId(snapshot.getDeviceId())
.state(snapshot.getState().name())
.healthScore(snapshot.getHealthScore())
.cycleTime(snapshot.getCycleTimeMs())
.currentWorkOrder(snapshot.getWorkOrderId())
.vibrationLevel(snapshot.getVibrationLevel().name())
.build());
for (WebSocketSession session : sessions) {
try {
session.sendMessage(new TextMessage(message));
} catch (IOException e) {
sessions.remove(session);
}
}
}
}前端 Three.js 收到 WebSocket 消息后,根据设备状态做对应的渲染变化:
RUNNING→ 设备模型绿色高亮 + 运转动画IDLE→ 灰色,无动画FAULT→ 红色闪烁 + 告警图标WARNING(振动告警)→ 黄色 + 震动微动画healthScore映射到设备上方的进度条颜色(绿→黄→红渐变)
3. 多租户孪生体数据隔离与性能优化
数据隔离: WebSocket 连接建立时通过 JWT 解析 tenant_id,只订阅该租户的设备状态事件。MQ 消费端根据消息 Header 里的 tenantId 路由到对应的 WebSocket 会话集合。不同租户之间互不可见。
性能优化——增量推送 + 节流:
2000-3000 TPS 的设备数据如果全部实时推送到前端,WebSocket 流量爆炸、前端 Three.js 渲染也扛不住。优化策略:
第一是状态变化才推送。设备状态没变化的(大部分时间设备都是稳定运行的),不推送。kiplant-twin 内部维护了每台设备的上次推送状态,只有状态字段有变化时才推:
@Component
public class DeviceStatusDiffTracker {
private final Map<String, DeviceStatusSnapshot> lastPushed =
new ConcurrentHashMap<>();
public boolean shouldPush(String deviceKey, DeviceStatusSnapshot current) {
DeviceStatusSnapshot last = lastPushed.get(deviceKey);
if (last == null) {
lastPushed.put(deviceKey, current);
return true;
}
// 状态变化 或 健康度变化超过0.05 才推送
boolean changed = !last.getState().equals(current.getState())
|| Math.abs(last.getHealthScore() - current.getHealthScore()) > 0.05
|| !last.getVibrationLevel().equals(current.getVibrationLevel());
if (changed) {
lastPushed.put(deviceKey, current);
}
return changed;
}
}第二是批量合并推送。不是每条设备状态变化立刻推 WebSocket,而是用 100ms 的微窗口攒批,把同一租户的多台设备状态变化合并成一条消息推送。减少 WebSocket 消息频率,前端一次批量更新多个设备模型,减少 Three.js 的重绘次数。
3D 模型加载优化:
工厂场景可能有几十到上百台设备模型,全部加载的 glTF 文件总量可能有 50-100MB。优化手段:模型 LOD(Level of Detail)——远处的设备用低精度模型,靠近时切换高精度;模型文件压缩(Draco 压缩可以把 glTF 体积减少 70%-80%);OSS CDN 加速。首次加载时间从 15 秒优化到 3-4 秒。
三、指标验证与线上踩坑
1. MTTR 降低 30% 的验证
MTTR(Mean Time To Repair,平均修复时间)的数据来源是维保工单系统。每条维保工单记录了 fault_reported_at(故障/告警时间)和 repair_completed_at(维修完成时间),MTTR = 平均(repair_completed_at - fault_reported_at)。
上线前统计了 3 个月的历史数据(从纸质维保记录人工录入),10 家客户的平均 MTTR 约 4.2 小时。上线后统计同样 10 家客户 3 个月的数据,平均 MTTR 降到 2.9 小时,降幅约 31%。
降低的原因主要两点:一是振动告警提前发现问题,在设备彻底停机之前就安排了维保,维修难度低、耗时短(预防性维修 vs 事后抢修);二是维保工单电子化后,维修员接单响应时间从原来的"等车间主管通知"平均 40 分钟降到了"App 推送后 10 分钟内响应"。
2. 单线产能提升 12% 的验证
这个指标来自 oee_daily_summary 表的数据对比。选了 5 家客户的 10 条核心产线,对比上线前后各 3 个月的日均产出数量。上线前日均产出平均 850 件/线,上线后平均 952 件/线,提升约 12%。
提升来自三个方面:OEE 看板让管理层能快速发现效率瓶颈(前面讲的产线参数优化);APS 排程减少了设备空闲时间(手动排产经常因为信息不对称导致设备等待);TPM 预防性维护减少了非计划停机。
3. 高并发设备数据上报下的踩坑
坑一:设备数据写入与孪生体推送的延迟不一致
上线初期用户反馈"数字孪生大屏上设备已经显示故障了,但 OEE 报表还没更新"。原因是两条数据链路的延迟不同:孪生体走的是 Redis 状态缓存 + WebSocket 直推,延迟约 200ms;OEE 报表走的是 device_data 写 DB + MQ 通知 analytics 聚合 + 写 oee_daily_summary,延迟约 5-10 秒。
用户在孪生体大屏上看到设备变红了,立刻点开 OEE 面板想看影响,发现数据还没变——以为是 bug。实际上不是 bug 而是架构上的天然延迟差异。
解决方式不是技术优化(5-10 秒的聚合延迟已经很合理了),而是产品层面的处理——在 OEE 面板上加了一个"数据更新时间"的标注("最后更新:10 秒前"),同时孪生体大屏的告警弹窗里加了一句"OEE 数据将在数秒后更新"。用户理解了之后不再投诉。
坑二:TPM 振动告警误报
上线第一个月振动告警误报率偏高,大约 20% 的 WARNING 级别告警经维修员现场确认后是正常的。排查发现原因有两个:
一是某些设备在特定工况下振动本来就会偏高(比如注塑机合模瞬间),但我们用了统一的阈值,没有区分工况。解决方式是按设备类型 + 工况建立差异化阈值——和几家客户的设备工程师一起花了两周做基线采集,每类设备在不同工况(空载/负载/换型)下各采集 24 小时数据建立正常范围。
二是传感器安装位置不对(有些是客户自己装的,没有严格按照 ISO 标准的测量点),导致采集的振动值偏大。这个通过出实施规范文档、培训客户工程师来解决。
改进后误报率降到了 5% 以下。
坑三:大租户孪生体 WebSocket 推送风暴
有一家大客户有 300+ 台设备,白班开工时几乎同时上线,几分钟内 300 台设备的状态从 IDLE 变为 RUNNING,触发了 300 条 WebSocket 推送。前端 Three.js 一次性更新 300 个模型的颜色和动画,帧率从 60fps 掉到了 8fps,页面卡死约 3 秒。
解决方式就是前面讲的批量合并推送 + 前端分帧渲染。后端用 100ms 微窗口把 300 条合并成 3 批,每批 100 条。前端收到后用 requestAnimationFrame 分帧处理,每帧只更新 20 个模型,15 帧(约 250ms)内更新完所有设备,帧率稳定在 45fps 以上,用户感知上就是"设备模型陆续变绿",体验平滑。