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

18 KiB
Raw Blame 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 接口

// 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 配置区分:

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 消费 EvtInspectionResultpass→CompleteStep→SetWaitingUnloadfail→FinishJob(Scrapped)+Alarm

3.6 Run 主循环

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 消息处理

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。

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

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 使用。

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):

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

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 创建

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 表恢复初始状态:

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.goupdateStationDonebatchMachinesinspectionMachines

10. 测试策略

  • MachineActor 单元测试: 注入模拟 signalCh,验证 Idle→Processing→Full→Waiting 全路径
  • TempStoreActor 单元测试: 验证 ALLOCATE/RELEASE 分配释放
  • 批量设备测试: 验证 PLC Done → 所有 Occupied → Done
  • 检测设备测试: 验证 pass/NG 信号 → 正确事件发布
  • 恢复测试: RESTORE 消息恢复各状态槽位
  • 集成测试: Actor + SignalRouter + EventLoop 联调完整链路