Files
bj_power/bj_power_mes/docs/superpowers/specs/2026-05-16-machine-actor-design.md
T

513 lines
18 KiB
Markdown
Raw Normal View History

# Machine Actor: 设备状态机 goroutine 化
> **目标:** 每台设备一个 goroutine,自治管理槽位状态和 PLC 信号监听,替换当前散落在 EventLoop/Station/SignalWatcher 中的设备逻辑。
**架构:** Actor 模型 — goroutine + channel,私有状态,外部只能发消息/读快照。全局调度器聚合快照做跨设备决策,EventLoop 保持 DB 单线程写。
**实现方式:** 重写。不迁移旧 Station 代码,新 Actor 替换旧 Station 后删除旧代码。
---
## 1. 动机
当前问题:
1. **设备状态更新散落两处**`handleMachineDone` 先写 DB equipment_slot,再调 `updateStationDone` 写 Station 内存,两个操作不在同一事务
2. **调度器读两个源**`buildSystemState` 从 Station 内存读 `MachineBusy`,从 DB 读 `MachineHasJob`,语义重叠但不同步
3. **handleMachineDone 职责过重** — 批量/单信号设备分支、slot 查找、DB 更新、Station 更新、Job 推进混在一个方法
4. **SignalWatcher 集中轮询** — 所有设备走一个 ticker,设备数量增加时轮询粒度不可控
核心矛盾:设备状态(槽位 Empty/Occupied/Done + 工站 Idle/Processing/Full/Waiting)天然属于设备自身,但当前被 EventLoop 和 Station 分而治之。
---
## 2. 架构概览
```
PLC 硬件信号
↓ SignalRouter 集中轮询,路由到各 Actor channel
┌──────────────────────────────────────────────────────────────┐
│ MachineActor-1 MachineActor-2 TempStoreActor │
│ (CNC #1, goroutine) (清洗机, goroutine) (暂存台, goroutine) │
│ │
│ 私有: slots[],status 私有: slots[],status 私有: slots[] │
│ 外部只能: Send/Snapshot │
│ │
│ 自写 DB: equipment_slot 自写 DB: job │
│ 发布事件: EvtMachineDone 无 PLC 信号 │
└──────┬───────────────────────────────────────────────────────┘
│ 发布事件到 EventBus
GlobalScheduler ──── 聚合所有 Actor 快照
│ 换料配对、优先级排序
│ 输出 CandidateTask
EventLoop ────────── Job 步骤推进 (DB)
│ AdvanceStep / FinishJob
│ 不碰 equipment_slot
PostgreSQL
```
**职责边界:**
| 组件 | 职责 | 不负责 |
|------|------|--------|
| MachineActor | 槽位状态机、PLC 信号监听、完成判定、DB equipment_slot 写入 | Job 步骤推进 |
| TempStoreActor | 暂存台槽位分配/释放、DB job.temp_slot_no 写入 | Job 步骤推进 |
| GlobalScheduler | 跨设备决策(换料配对、优先级) | 设备内部状态 |
| EventLoop | Job 步骤推进、DB job 表写入 | 设备/暂存台状态管理 |
---
## 3. MachineActor 设计
### 3.1 接口
```go
// MachineActor 设备状态机
type MachineActor interface {
ID() int // 设备 ID
Type() string // 设备类型编码
Run(ctx context.Context) // 启动 goroutine,阻塞
Send(msg MachineMsg) // 外部发消息(非阻塞,chan 容量 8)
Snapshot() MachineSnapshot // 读快照(atomic.Value,无锁)
}
// MachineMsg 外部发给 Actor 的命令
type MachineMsg struct {
Type string // LOAD_COMPLETE / UNLOAD_COMPLETE / RELEASE / RESTORE
JobID int
SlotNo int
Params map[string]any
}
// MachineSnapshot 只读快照,供调度器聚合
type MachineSnapshot struct {
ID int
Type string
Status ActorStatus // Idle/Processing/Full/Waiting/Fault
Slots []SlotSnapshot
UpdatedAt time.Time
}
type SlotSnapshot struct {
SlotNo int
Status SlotStatus // Empty/Occupied/Done
JobID int
OccupiedAt time.Time
}
```
### 3.2 工站状态机
```
┌──────────────────────────┐
│ FAULT │←── PLC 通信异常 / 硬件故障
│ (需人工介入恢复) │
└──────────────────────────┘
↑ 异常
┌──────────┐ Load完成 ┌──────────────┐ 满 ┌──────────┐
│ IDLE │──────────→ │ PROCESSING │───→ │ FULL │
│ (全Empty)│←────────── │ (有空+有占) │←─── │ (全占满) │
└──────────┘ Unload/全部 └──────────────┘ └──────────┘
槽位释放 │ ↑ ↑ │
│ │ │ │
│ │ └─ Load(有空位) │
│ │ │ PLC 完成信号
│ └── PLC Done ────────┘
│ PLC 完成信号(非批量设备)
┌──────────────┐
│ WAITING │ ← 有 Done 槽位,等待卸料/换料
│ (有Done槽位) │
└──────────────┘
```
状态规则:
- **IDLE**: 所有槽位 Empty
- **PROCESSING**: 有空 + 有占。批量设备单信号完成时短暂停留
- **FULL**: 全部 Occupied 或 Occupied+Done 之和=capacity,不接受上料
- **WAITING**: 有至少一个 Done 槽位,调度器优先处理
- **FAULT**: PLC 通信异常,需人工恢复
### 3.3 槽位状态机
```
Empty ──Load完成──→ Occupied ──PLC Done──→ Done ──Unload完成──→ Empty
```
### 3.4 批量设备 vs 单信号设备
两类设备完成行为不同,在 Actor 内部通过 `batch` 配置区分:
```go
type machineActor struct {
id int
batch bool // true: 批量完成(所有 Occupied→Done
// ...
}
```
**批量设备**`batch=true`,如高压清洗机):收到 PLC 完成信号 → 所有 Occupied 槽位标记 Done
**单信号设备**`batch=false`,如 CNC):收到 PLC 完成信号 → 按 OccupiedAt FIFO 找最早占用槽位标记 Done
### 3.5 检测设备
检测设备(内窥镜检测线、抽检台)在 Actor 中维护 `hasNG` bool
- `handleMachineWaitCandidate` 的跳过行为保留在 EventLoop,不迁移到 Actor
- 检测结果判定逻辑在 Actor 中:
- Done 信号(pass)→ 发布 `EvtInspectionResult(machineID, true)`
- NG 信号 → 发布 `EvtInspectionResult(machineID, false)`
- EventLoop 消费 `EvtInspectionResult`pass→CompleteStep→SetWaitingUnloadfail→FinishJob(Scrapped)+Alarm
### 3.6 Run 主循环
```go
func (m *machineActor) Run(ctx context.Context) {
ticker := time.NewTicker(m.pollInterval)
defer ticker.Stop()
for {
select {
case msg := <-m.msgCh:
m.handleMessage(msg)
case evt := <-m.signalCh:
m.handleSignal(evt) // 非阻塞接收 SignalRouter 推送
case <-ctx.Done():
return
}
}
}
```
### 3.7 消息处理
```go
func (m *machineActor) handleMessage(msg MachineMsg) {
switch msg.Type {
case "LOAD_COMPLETE":
m.slots[msg.SlotNo-1] = SlotStatus_Occupied
m.slotJobs[msg.SlotNo-1] = msg.JobID
m.slotOccupiedAt[msg.SlotNo-1] = time.Now()
m.updateStatus()
m.db.SetEquipmentSlot(ctx, m.id, msg.SlotNo, SlotStatus_Empty, SlotStatus_Occupied, msg.JobID)
case "UNLOAD_COMPLETE":
m.db.SetEquipmentSlot(ctx, m.id, msg.SlotNo, SlotStatus_Done, SlotStatus_Empty, 0)
m.slots[msg.SlotNo-1] = SlotStatus_Empty
m.slotJobs[msg.SlotNo-1] = 0
m.slotOccupiedAt[msg.SlotNo-1] = time.Time{}
m.updateStatus()
case "RESTORE":
m.slots[msg.SlotNo-1] = parseSlotStatus(msg.Params["status"])
m.slotJobs[msg.SlotNo-1] = msg.JobID
m.updateStatus()
}
}
```
### 3.8 信号处理 + DB 持久化
Actor 收到完成信号后:更新私有状态 → 写 DB → 发布事件通知 EventLoop 推进 Job。内存与 DB 在同一 goroutine 内顺序执行,无一致性问题。
检测设备的 Done 信号表示检测合格(pass),走 EvtInspectionResult 而非 EvtMachineDone。
```go
func (m *machineActor) handleSignal(evt SignalEvent) {
// 检测设备 NG 信号
if evt.Type == SignalNG && evt.Value {
m.bus.Publish(EventInspectionResult, m.id, false)
return
}
if evt.Type == SignalDone && evt.Value {
// 检测设备:Done 信号 = 检测合格
if m.inspection {
// 标记所有 Occupied 槽位为 Done
for i := range m.slots {
if m.slots[i] == SlotStatus_Occupied {
m.slots[i] = SlotStatus_Done
m.db.SetEquipmentSlot(ctx, m.id, i+1, SlotStatus_Occupied, SlotStatus_Done, m.slotJobs[i])
}
}
m.updateStatus()
// 发布 EvtInspectionResult(pass=true)EventLoop 处理
m.bus.Publish(EventInspectionResult, m.id, true)
return
}
// 非检测设备:正常加工完成
var doneSlots []int
if m.batch {
for i := range m.slots {
if m.slots[i] == SlotStatus_Occupied {
m.slots[i] = SlotStatus_Done
doneSlots = append(doneSlots, i+1)
}
}
} else {
slotNo := m.findFirstOccupied()
if slotNo > 0 {
m.slots[slotNo-1] = SlotStatus_Done
doneSlots = append(doneSlots, slotNo)
}
}
m.updateStatus()
for _, slotNo := range doneSlots {
m.db.SetEquipmentSlot(ctx, m.id, slotNo, SlotStatus_Occupied, SlotStatus_Done, m.slotJobs[slotNo-1])
}
for _, slotNo := range doneSlots {
m.bus.Publish(EventMachineDone, m.id, slotNo, m.slotJobs[slotNo-1])
}
}
}
```
---
## 4. SignalRouter
PLC 连接集中管理(S7 单连接),一个 goroutine 轮询,路由到各 Actor channel
```go
type SignalRouter struct {
plc goplc.Client
mu sync.RWMutex
watches map[int]*SignalWatch
}
type SignalWatch struct {
DoneAddr string // 完成信号 PLC 地址
NGAddr string // NG 信号地址(检测设备才有)
Ch chan SignalEvent
}
type SignalEvent struct {
Type SignalType // Done / NG
Value bool
}
```
- `Ch` 容量为 1SignalRouter 写不阻塞
- Actor 从 `Ch` 非阻塞读取(`select default`),积压时丢弃旧值
---
## 5. TempStoreActor(暂存台)
暂存台没有 PLC 信号,但需要管理槽位占用。与 MachineActor 的关键区别:**槽位从分配到释放贯穿 Job 整个生命周期**。
```
补料(分配slot3) → 上料到CNC(slot3仍属此job) → 下料回暂存台(slot3) → ... → 完成(释放slot3)
```
中间 job 在机器上加工时,暂存台槽位是"虚占"状态 — 物理上工件不在,但槽位仍属此 job,不能被其他 job 使用。
```go
type TempStoreActor struct {
capacity int
slots []int // slotNo → jobID (0 = 空)
msgCh chan MachineMsg
db *DBState
}
func (t *TempStoreActor) Run(ctx context.Context) {
for {
select {
case msg := <-t.msgCh:
switch msg.Type {
case "ALLOCATE":
t.allocate(ctx, msg.JobID)
case "RELEASE":
t.release(ctx, msg.JobID)
case "RESTORE":
t.restore(ctx, msg.SlotNo, msg.JobID)
}
case <-ctx.Done():
return
}
}
}
func (t *TempStoreActor) allocate(ctx context.Context, jobID int) {
for i := range t.slots {
if t.slots[i] == 0 {
t.slots[i] = jobID
t.db.SetJobTempSlot(ctx, jobID, i+1) // 写 DB job.temp_slot_no
return
}
}
}
func (t *TempStoreActor) release(ctx context.Context, jobID int) {
for i := range t.slots {
if t.slots[i] == jobID {
t.slots[i] = 0
t.db.ClearJobTempSlot(ctx, jobID) // 清 DB job.temp_slot_no
return
}
}
}
func (t *TempStoreActor) Snapshot() TempStoreSnapshot {
return TempStoreSnapshot{
Capacity: t.capacity,
Slots: append([]int(nil), t.slots...),
}
}
```
调度器通过 `Snapshot` 读暂存台占用,替代 `buildSystemState` 中的 `TempSlotJobs` 计算和 `TempSlotAllocator`
---
## 6. 调度器集成
`buildSystemState` 从 Actor 单一源读(设备从 MachineActor,暂存台从 TempStoreActor):
```go
func (l *ProductionEventLoop) buildSystemState(ctx context.Context) scheduler.SystemState {
state := scheduler.SystemState{
MachineBusy: make(map[int]bool),
MachineHasJob: make(map[int]int),
TempSlotJobs: make(map[int]int),
}
for _, actor := range l.machineActors {
snap := actor.Snapshot()
state.MachineBusy[snap.ID] = snap.Status == ActorStatus_Full
for _, slot := range snap.Slots {
if slot.JobID > 0 && slot.Status != SlotStatus_Empty {
state.MachineHasJob[snap.ID] = slot.JobID
}
}
}
// 暂存台
tempSnap := l.tempStoreActor.Snapshot()
occupied := 0
for slotNo, jobID := range tempSnap.Slots {
if jobID > 0 {
state.TempSlotJobs[slotNo] = jobID
occupied++
}
}
state.TempSlotFree = tempSnap.Capacity - occupied
return state
}
```
换料配对逻辑不变,仍在 `trySchedule` 阶段。
---
## 7. EventLoop 变更
**Job 状态持久化仍由 EventLoop 负责** — Job 是跨设备实体,不属于任何单一 Actor。
持久化职责分割:
| 表 | Owner | 时机 |
|---|---|---|
| `equipment_slot` | MachineActor | 上料/下料/完成信号时 |
| `job`(状态/步骤/位置/上下文) | EventLoop | AdvanceStep/FinishJob/SetJobProcessing |
| `work_order` | EventLoop | FinishJob 时累加 |
| `alarm` | EventLoop | Actor 发布故障事件时 |
| `job.temp_slot_no` | TempStoreActor | ALLOCATE/RELEASE 时 |
**新增:消费 Actor 事件,推进 Job**
```go
case EventMachineDone:
l.handleMachineDone(ctx, msg) // 精简后:只 AdvanceStep/SetWaitingUnload + trySchedule
case EventInspectionResult:
l.handleInspectionDone(ctx, machineID, pass)
```
**handleMachineDone 精简**Actor 已完成 DB equipment_slot 写入,EventLoop 仅做 Job 步骤推进。不再调 `updateStationDone`,不再查 slot 状态。仅从事件中拿 machineID/slotNo/jobID 参数。
**Worker 完成后通知 Actor**:上料/下料成功后发送 `LOAD_COMPLETE`/`UNLOAD_COMPLETE` 消息,Actor 更新内存槽位状态并写 DB。
**删除**
- `updateStationDone` 方法
- `batchMachines` / `inspectionMachines` map — Actor 内部自管理
- equipment_slot 的 DB 写入逻辑 — 移至 Actor
---
## 8. 启动与恢复
### 8.1 创建
```go
func BuildMachineActors(ctx context.Context, entClient *ent.Client, signalRouter *SignalRouter, bus eventbus.Bus) map[int]MachineActor {
equipments, _ := entClient.Equipment.Query().WithEquipmentType().All(ctx)
actors := make(map[int]MachineActor)
for _, eq := range equipments {
cfg := MachineActorConfig{
ID: eq.ID,
Type: eq.Edges.EquipmentType.Code,
Capacity: eq.SlotCount,
Batch: eq.Batch,
Inspection: eq.Edges.EquipmentType.Code == InspectionType, // 检测设备
PollInterval: 1 * time.Second,
SignalCh: signalRouter.Watch(eq.ID, doneAddr, ngAddr),
Bus: bus,
}
actors[eq.ID] = NewMachineActor(cfg)
}
return actors
}
```
### 8.2 恢复
`RecoverOnStartup` 从 DB `equipment_slot` 表恢复初始状态:
```go
func (d *DBState) RecoverMachineActors(ctx context.Context, actors map[int]MachineActor) {
slots, _ := d.client.EquipmentSlot.Query().All(ctx)
for _, s := range slots {
actor, ok := actors[*s.EquipmentId]
if !ok {
continue
}
actor.Send(MachineMsg{
Type: "RESTORE",
JobID: slotJobID(s),
SlotNo: s.SlotNo,
Params: map[string]any{"status": string(s.Status)},
})
}
}
```
RESTORE 直接设置槽位状态(跳过 Empty→Occupied 转换校验)。
---
## 9. 删除清单
新 Actor 上线后删除:
- `internal/station/` — 整个 Station 包(接口、BaseStation、CNC/Cleaning/Washer/Inspection/Deburr/Sampling/Scanner/LaserMarker/Registry),保留 `HandheldTool` 接口
- `internal/processor/temp_slot_allocator.go` — TempSlotAllocator(被 TempStoreActor 替代)
- `internal/processor/signal_watcher.go` — SignalWatcher(被 SignalRouter 替代)
- `internal/processor/step_timeout.go` — 工序超时
- `internal/eventloop/loop.go``updateStationDone``batchMachines``inspectionMachines`
---
## 10. 测试策略
- **MachineActor 单元测试**: 注入模拟 signalCh,验证 Idle→Processing→Full→Waiting 全路径
- **TempStoreActor 单元测试**: 验证 ALLOCATE/RELEASE 分配释放
- **批量设备测试**: 验证 PLC Done → 所有 Occupied → Done
- **检测设备测试**: 验证 pass/NG 信号 → 正确事件发布
- **恢复测试**: RESTORE 消息恢复各状态槽位
- **集成测试**: Actor + SignalRouter + EventLoop 联调完整链路