@operato/twin-kernel 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,15 @@
1
1
  /*
2
2
  * FlowEngine — 도메인-일반 flow 엔진 base (WMS·YMS 공유 mechanics 추출).
3
3
  *
4
- * WMS/YMS 커널의 mechanics(RNG·clock·scenario·tick 루프·무버 배정·태스크 진행·emit·snapshot·
4
+ * WMS/YMS 커널의 mechanics(RNG·clock·scenario·tick 루프·설비 배정·태스크 진행·emit·snapshot·
5
5
  * slotViews)는 동일했다 → 이 base 로 승격. 도메인 kernel 은 **flow 동사만** 구현:
6
6
  * onArrival(자극:입고 도착) · onOrder(자극:출고 오더) · allocate(오더→태스크) · onTaskComplete(태스크 완료)
7
7
  *
8
8
  * TwinKernel 구현 → StateProjector·TwinRuntime 가 도메인 무관하게 소비.
9
9
  * 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
10
- * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowNode 단일 base 방향과 정합.)
10
+ * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowLocation 단일 base 방향과 정합.)
11
11
  */
12
- import { OP_EVENT, CMD, nodeStatusOf } from "./contract.js";
12
+ import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure } from "./contract.js";
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
14
  import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR } from "./epcis.js";
15
15
  import { parseIsoDuration } from "./iso-duration.js";
@@ -26,7 +26,7 @@ function mulberry32(seed) {
26
26
  return fn;
27
27
  }
28
28
  /**
29
- * 주목신호 계산(순수) — State 스냅샷(movers/nodes/orders)에서 attentions 파생.
29
+ * 주목신호 계산(순수) — State 스냅샷(equipment/locations/orders)에서 attentions 파생.
30
30
  * FlowEngine.computeAttentions(sim) 와 live projector 미러가 **공유**하는 계산 층(face2-inbound-live §1.1):
31
31
  * sim/live 가 같은 임계값·규칙을 쓴다. good/scrap 미제공(관측 상태에 없음)이면 scrap-high 는 자연 스킵(metric 층 갭).
32
32
  */
@@ -34,12 +34,12 @@ export function deriveAttentions(view, acked) {
34
34
  // 언어 중립: kind + params(원시값) + 조치 code 만 방출. 사람이 읽는 title/detail/rationale/라벨은
35
35
  // 표현계층(클라 i18next)이 kind/code 로 렌더한다(무방언·다국어 — design/plans/i18n.md L3).
36
36
  const out = [];
37
- for (const m of view.movers) {
37
+ for (const m of view.equipment) {
38
38
  if (m.status === 'down') {
39
39
  out.push({
40
40
  id: `breakdown:${m.id}`, kind: 'breakdown', severity: 'critical',
41
- anchor: { moverId: m.id, nodeId: m.location },
42
- params: { moverId: m.id, ...(m.location ? { nodeId: m.location } : {}) },
41
+ anchor: { moverId: m.id, locationId: m.location },
42
+ params: { moverId: m.id, ...(m.location ? { locationId: m.location } : {}) },
43
43
  recommendedActions: [
44
44
  { code: 'act.repair', command: CMD.resourceRepair, args: { resourceId: m.id } },
45
45
  { code: 'act.hold-until-repair', command: CMD.resourceHold, args: { resourceId: m.id } }
@@ -48,29 +48,29 @@ export function deriveAttentions(view, acked) {
48
48
  });
49
49
  }
50
50
  }
51
- for (const n of view.nodes) {
51
+ for (const n of view.locations) {
52
52
  if ((n.capacity ?? 0) > 0) {
53
53
  const r = (n.occupancy ?? 0) / n.capacity;
54
54
  if (r >= 0.9) {
55
55
  const saturated = r >= 1;
56
56
  out.push({
57
57
  id: `bottleneck:${n.id}`, kind: 'bottleneck', severity: saturated ? 'high' : 'medium',
58
- anchor: { nodeId: n.id },
59
- params: { nodeId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
58
+ anchor: { locationId: n.id },
59
+ params: { locationId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
60
60
  recommendedActions: [{ code: 'advice.add-resource' }, { code: 'advice.downstream-priority' }]
61
61
  // 권고만(command 없음) — 표현계층이 advice.* 코드로 렌더
62
62
  });
63
63
  }
64
64
  }
65
65
  }
66
- for (const m of view.movers) {
66
+ for (const m of view.equipment) {
67
67
  const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
68
68
  if (total >= 10) {
69
69
  const rate = (m.scrapCount ?? 0) / total;
70
70
  if (rate >= 0.15)
71
71
  out.push({
72
72
  id: `scrap:${m.id}`, kind: 'scrap-high', severity: rate >= 0.3 ? 'high' : 'medium',
73
- anchor: { moverId: m.id, nodeId: m.location },
73
+ anchor: { moverId: m.id, locationId: m.location },
74
74
  params: { moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100) },
75
75
  recommendedActions: [
76
76
  { code: 'act.hold-for-inspection', command: CMD.resourceHold, args: { resourceId: m.id } },
@@ -115,9 +115,11 @@ export function computeOee(c, nowMs) {
115
115
  }
116
116
  export class FlowEngine {
117
117
  tenantId;
118
- nodes = new Map();
118
+ locations = new Map();
119
119
  items = new Map();
120
- movers = new Map();
120
+ equipment = new Map();
121
+ /** 등급 정의(표준 `<X>Class`) — 상속·유효기간 판정의 재료. 선언 안 하면 비어 있고, 소속 그대로 판정한다. */
122
+ classDefs = {};
121
123
  /** 사람 — 선언하지 않으면 빈 맵(인원 제약 없는 트윈, 기존 거동). */
122
124
  persons = new Map();
123
125
  /** 물리 자산 — 선언하지 않으면 빈 맵(자산 제약 없는 트윈, 기존 거동). */
@@ -164,50 +166,51 @@ export class FlowEngine {
164
166
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
165
167
  loadBoard(def) {
166
168
  this.boardDef = def;
167
- for (const n of def.nodes)
168
- this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: 'idle', parentId: n.parentId });
169
+ for (const n of readBoardLocations(def))
170
+ this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: 'idle', parentId: n.parentId });
171
+ this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses };
169
172
  for (const p of def.persons ?? [])
170
- this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: 'idle', taskId: null, window: p.window });
171
- for (const a of def.assets ?? [])
172
- this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: 'idle', taskId: null });
173
- for (const m of def.movers) {
174
- const mover = { id: m.id, kind: m.kind, location: m.homeNode, status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window };
173
+ this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', taskId: null, window: p.window, ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}) });
174
+ for (const a of readBoardAssets(def))
175
+ this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', taskId: null, ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}) });
176
+ for (const m of readBoardEquipment(def)) {
177
+ const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window };
175
178
  if (m.mtbfMs !== undefined) {
176
- mover.mtbfMs = m.mtbfMs;
177
- mover.mttrMs = m.mttrMs;
178
- mover.nextFailureMs = this.sampleExp(m.mtbfMs);
179
+ eq.mtbfMs = m.mtbfMs;
180
+ eq.mttrMs = m.mttrMs;
181
+ eq.nextFailureMs = this.sampleExp(m.mtbfMs);
179
182
  }
180
- this.movers.set(m.id, mover);
183
+ this.equipment.set(m.id, eq);
181
184
  }
182
185
  }
183
186
  /**
184
- * what-if 구성 변주 — fork(또는 실행 중) 엔진에 무버 추가. loadBoard 무버 삽입과 동일 규약.
187
+ * what-if 구성 변주 — fork(또는 실행 중) 엔진에 설비 추가. loadBoard 설비 삽입과 동일 규약.
185
188
  * 기본은 mtbf 미지정(고장 없는 신뢰 자원) → sampleExp(rng) 무소비라 baseline fork 와 깨끗이 비교 가능.
186
189
  */
187
- addMover(m) {
188
- if (this.movers.has(m.id))
190
+ addEquipment(m) {
191
+ if (this.equipment.has(m.id))
189
192
  return;
190
- const mover = { id: m.id, kind: m.kind, location: m.homeNode, status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 };
193
+ const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 };
191
194
  if (m.mtbfMs !== undefined) {
192
- mover.mtbfMs = m.mtbfMs;
193
- mover.mttrMs = m.mttrMs;
194
- mover.nextFailureMs = this.sampleExp(m.mtbfMs);
195
+ eq.mtbfMs = m.mtbfMs;
196
+ eq.mttrMs = m.mttrMs;
197
+ eq.nextFailureMs = this.sampleExp(m.mtbfMs);
195
198
  }
196
- this.movers.set(m.id, mover);
199
+ this.equipment.set(m.id, eq);
197
200
  }
198
201
  /**
199
- * 관측 상태 주입(라이브 예측용, kernel-unification P1) — 외부 관측 스냅샷(재고·무버·노드)과
202
+ * 관측 상태 주입(라이브 예측용, kernel-unification P1) — 외부 관측 스냅샷(재고·설비·자리)과
200
203
  * 저널 오더(원값+라인)로 이 커널의 맵을 채운다. tick 으로 만든 게 아니라 "현재 관측된 현실"을 심어
201
204
  * 이후 fork/tick 으로 예측한다. 라이브 런타임은 여전히 projector 미러 — 이 커널은 예측용 임시본.
202
205
  * 오더는 남은 데맨드(라인별 requested-fulfilled)를 'created' 로 복원(현재 재고에서 재계획).
203
206
  * 진행 중 개별 task 의 내부 상태는 관측만으론 복원 불가 → 재계획에 맡김(정직한 한계).
204
207
  */
205
208
  hydrateObserved(snap, orders = []) {
206
- /* **관측된 것을 버리지 않는다.** 예전에는 노드·무버 상태를 'idle' 로, OEE 누적을 0 으로 덮고
209
+ /* **관측된 것을 버리지 않는다.** 예전에는 자리·설비 상태를 'idle' 로, OEE 누적을 0 으로 덮고
207
210
  * 물품의 로트·단위·소속·마스터데이터를 떨어뜨렸다. 씨앗이 잃은 것은 **예측도 모른다** —
208
211
  * 고장 난 설비를 정상으로, 진행 중인 일을 없는 것으로 놓고 미래를 굴리면 답이 낙관 쪽으로 치우친다. */
209
- for (const n of snap.nodes) {
210
- this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity ?? 0, parallelism: n.parallelism, occupancy: n.occupancy ?? 0, status: n.status ?? 'idle', parentId: n.parentId });
212
+ for (const n of snap.locations) {
213
+ this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity ?? 0, parallelism: n.parallelism, occupancy: n.occupancy ?? 0, status: n.status ?? 'idle', parentId: n.parentId });
211
214
  }
212
215
  this.items.clear();
213
216
  for (const it of snap.items) {
@@ -218,7 +221,7 @@ export class FlowEngine {
218
221
  parent: it.parent, carriedBy: it.carriedBy, expiry: it.expiry, ilmd: it.ilmd
219
222
  });
220
223
  }
221
- for (const m of snap.movers) {
224
+ for (const m of snap.equipment) {
222
225
  /*
223
226
  * 관측된 계측을 이어받는다 — **없어서 0 이었던 게 아니라 잘못 읽어서 0 이었다.**
224
227
  * `OeeMetrics` 의 이름은 `runMs`·`setupMs`·`downMs`·`goodCount`·`scrapCount` 인데 예전 코드가
@@ -231,8 +234,8 @@ export class FlowEngine {
231
234
  * 값을 그대로 이어받고, 없으면 0 에서 시작한다(꾸미지 않는다).
232
235
  */
233
236
  const oee = m.oee;
234
- this.movers.set(m.id, {
235
- id: m.id, kind: m.kind, location: m.location ?? '', status: m.status ?? 'idle', taskId: null,
237
+ this.equipment.set(m.id, {
238
+ id: m.id, kind: m.kind, location: m.location ?? '', ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status ?? 'idle', taskId: null,
236
239
  runMs: oee?.runMs ?? 0, setupMs: oee?.setupMs ?? 0, downMs: oee?.downMs ?? 0,
237
240
  goodCount: oee?.goodCount ?? 0, scrapCount: oee?.scrapCount ?? 0
238
241
  });
@@ -241,11 +244,11 @@ export class FlowEngine {
241
244
  * 씨앗이 버리면, 인원을 요구하는 공정의 작업이 하나도 시작되지 못하고 예측이 멈춘다(0 명 < 2 명).
242
245
  * 배정 상태(busy/taskId)는 아래 작업 복원이 다시 세우므로 여기서는 등급·교대만 살린다. */
243
246
  for (const p of snap.persons ?? []) {
244
- this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: 'idle', taskId: null });
247
+ this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', taskId: null });
245
248
  }
246
249
  /* 자산도 같다 — 잃으면 자산을 요구하는 작업이 영원히 못 나간다(빈 팔레트 0개 < 1개). */
247
250
  for (const a of snap.assets ?? []) {
248
- this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.location, status: 'idle', taskId: null, carrying: a.carrying });
251
+ this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.location, status: 'idle', taskId: null, carrying: a.carrying });
249
252
  }
250
253
  /* 진행 중이던 작업을 이어 붙인다 — 없으면 예측이 "일이 하나도 없는 현장" 에서 출발한다.
251
254
  * 남은 시간을 모르면 **진척을 꾸미지 않고** 미착수(created)로 되돌린다: 그 일이 남아 있다는 사실은
@@ -268,7 +271,7 @@ export class FlowEngine {
268
271
  });
269
272
  /* 진행 중으로 살린 작업은 그 자원을 점유한 상태여야 한다(자원이 동시에 다른 일을 받지 않게). */
270
273
  if (known && t.status === 'in-progress' && t.resourceRef) {
271
- const mv = this.movers.get(t.resourceRef);
274
+ const mv = this.equipment.get(t.resourceRef);
272
275
  if (mv) {
273
276
  mv.status = 'busy';
274
277
  mv.taskId = t.id;
@@ -303,9 +306,9 @@ export class FlowEngine {
303
306
  this.orders.set(o.orderId, { id: o.orderId, kind: o.kind, status: 'created', requested: remaining, fulfilled: 0, bizTransaction: '', allocated: [], picked: [], shipmentEpc: null, lines });
304
307
  }
305
308
  }
306
- /** what-if 구성 변주 — 노드 용량 변경(fork 대상). 존재하면 true. */
307
- setNodeCapacity(nodeId, capacity) {
308
- const n = this.nodes.get(nodeId);
309
+ /** what-if 구성 변주 — 자리 용량 변경(fork 대상). 존재하면 true. */
310
+ setLocationCapacity(locationId, capacity) {
311
+ const n = this.locations.get(locationId);
309
312
  if (!n)
310
313
  return false;
311
314
  n.capacity = Math.max(0, capacity);
@@ -351,30 +354,30 @@ export class FlowEngine {
351
354
  this._acked.add(id);
352
355
  return ok();
353
356
  }
354
- // Operable 코어 — 자원(설비·무버) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
357
+ // Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
355
358
  case CMD.resourceHold:
356
359
  case CMD.resourceResume: {
357
- const m = this.movers.get(cmd.args?.resourceId ?? '');
360
+ const m = this.equipment.get(cmd.args?.resourceId ?? '');
358
361
  if (!m)
359
362
  return fail('resource-not-found');
360
363
  m.held = cmd.type === CMD.resourceHold; // 계획 정지 → 배정 스킵
361
- this.emitMover(m);
364
+ this.emitEquipment(m);
362
365
  return ok();
363
366
  }
364
367
  case CMD.resourceDown: {
365
368
  const a = cmd.args;
366
- const m = this.movers.get(a?.resourceId ?? '');
369
+ const m = this.equipment.get(a?.resourceId ?? '');
367
370
  if (!m)
368
371
  return fail('resource-not-found');
369
372
  if (m.status !== 'down') {
370
373
  m.status = 'down'; // 비계획 고장 주입 — 기존 고장 machinery(processFailures)가 downMs 누적·수리 처리
371
374
  m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 60_000);
372
- this.emitMover(m);
375
+ this.emitEquipment(m);
373
376
  }
374
377
  return ok();
375
378
  }
376
379
  case CMD.resourceRepair: {
377
- const m = this.movers.get(cmd.args?.resourceId ?? '');
380
+ const m = this.equipment.get(cmd.args?.resourceId ?? '');
378
381
  if (!m)
379
382
  return fail('resource-not-found');
380
383
  if (m.status === 'down') {
@@ -382,12 +385,12 @@ export class FlowEngine {
382
385
  m.repairUntilMs = undefined;
383
386
  if (m.mtbfMs !== undefined)
384
387
  m.nextFailureMs = this.clockMs + this.sampleExp(m.mtbfMs);
385
- this.emitMover(m);
388
+ this.emitEquipment(m);
386
389
  }
387
390
  return ok();
388
391
  }
389
392
  case CMD.resourceResetMetrics: {
390
- const m = this.movers.get(cmd.args?.resourceId ?? '');
393
+ const m = this.equipment.get(cmd.args?.resourceId ?? '');
391
394
  if (!m)
392
395
  return fail('resource-not-found');
393
396
  m.runMs = 0;
@@ -397,27 +400,27 @@ export class FlowEngine {
397
400
  m.scrapCount = 0;
398
401
  m.holdMs = 0;
399
402
  m.metricsSinceMs = this.clockMs; // 계측 창을 지금부터 재시작
400
- this.emitMover(m);
403
+ this.emitEquipment(m);
401
404
  return ok();
402
405
  }
403
406
  case CMD.resourceAdd: {
404
- // 라이브 자원 추가(실제 act) — addMover(what-if 와 동일 경로)로 런타임에 무버 삽입 + equipment 델타 방출.
405
- // 새 무버는 즉시 배정 대상(다음 tick). 유일 id 생성(충돌 회피). 좌표/persistence 는 호스트 몫(커널=위상만).
407
+ // 라이브 자원 추가(실제 act) — addEquipment(what-if 와 동일 경로)로 런타임에 설비 삽입 + equipment 델타 방출.
408
+ // 새 설비는 즉시 배정 대상(다음 tick). 유일 id 생성(충돌 회피). 좌표/persistence 는 호스트 몫(커널=위상만).
406
409
  const a = cmd.args;
407
410
  if (!a?.kind)
408
411
  return fail('kind-required');
409
- if (!a?.homeNode || !this.nodes.has(a.homeNode))
410
- return fail('home-node-not-found', { homeNode: a?.homeNode ?? '' });
412
+ if (!a?.homeLocation || !this.locations.has(a.homeLocation))
413
+ return fail('home-location-not-found', { homeLocation: a?.homeLocation ?? '' });
411
414
  const count = Math.max(1, Math.min(50, Number(a.count) || 1));
412
- let seq = this.movers.size;
415
+ let seq = this.equipment.size;
413
416
  for (let i = 0; i < count; i++) {
414
417
  let id = `${a.kind}-${++seq}`;
415
- while (this.movers.has(id))
418
+ while (this.equipment.has(id))
416
419
  id = `${a.kind}-${++seq}`;
417
- this.addMover({ id, kind: a.kind, homeNode: a.homeNode });
418
- const m = this.movers.get(id);
420
+ this.addEquipment({ id, kind: a.kind, homeLocation: a.homeLocation });
421
+ const m = this.equipment.get(id);
419
422
  if (m)
420
- this.emitMover(m); // equipment.status 델타 → 상태/저널에 새 무버 반영
423
+ this.emitEquipment(m); // equipment.status 델타 → 상태/저널에 새 설비 반영
421
424
  }
422
425
  return ok();
423
426
  }
@@ -462,28 +465,38 @@ export class FlowEngine {
462
465
  simClockMs: this.clockMs,
463
466
  /* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
464
467
  * 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
465
- nodes: [...this.nodes.values()].map(n => {
468
+ locations: [...this.locations.values()].map(n => {
466
469
  const { status, ...rest } = n;
467
470
  /* 상태는 저장값이 아니라 포화도 파생 — 미러와 **같은 함수**를 쓴다(규칙이 둘이면 갈라진다). */
468
- const derived = nodeStatusOf(n);
471
+ const derived = locationStatusOf(n);
469
472
  return { ...rest, ...(derived ? { status: derived } : {}), origin: 'master' };
470
473
  }),
471
474
  items: [...this.items.values()].map(i => this.itemState(i)),
472
- movers: [...this.movers.values()].map(m => {
473
- const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, origin: 'master', ...(this.offShift(m) ? { offShift: true } : {}) };
475
+ equipment: [...this.equipment.values()].map(m => {
476
+ const s = { id: m.id, kind: m.kind, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), origin: 'master', ...(this.offShift(m) ? { offShift: true } : {}) };
474
477
  const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
475
478
  if (t && t.status === 'in-progress' && t.intent !== 'process')
476
479
  s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
477
480
  return s;
478
481
  }),
479
482
  assets: [...this.assets.values()].map(a => {
480
- const st = { id: a.id, assetClass: a.assetClass, location: a.location, status: a.status, taskId: a.taskId ?? undefined };
483
+ const st = { id: a.id, assetClassIds: a.assetClassIds, location: a.location, status: a.status, taskId: a.taskId ?? undefined };
481
484
  if (a.carrying)
482
485
  st.carrying = a.carrying;
486
+ if (a.properties)
487
+ st.properties = a.properties;
488
+ if (a.testSpecificationIds)
489
+ st.testSpecificationIds = a.testSpecificationIds;
483
490
  return st;
484
491
  }),
485
492
  persons: [...this.persons.values()].map(p => {
486
- const st = { id: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? undefined };
493
+ const st = { id: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? undefined };
494
+ if (p.location)
495
+ st.location = p.location;
496
+ if (p.properties)
497
+ st.properties = p.properties;
498
+ if (p.testSpecificationIds)
499
+ st.testSpecificationIds = p.testSpecificationIds;
487
500
  if (this.personOffShift(p))
488
501
  st.offShift = true;
489
502
  return st;
@@ -512,13 +525,13 @@ export class FlowEngine {
512
525
  };
513
526
  }
514
527
  /*
515
- * 주목 신호 판단 — 상태(노드·무버·오더)에서 도메인 조건을 평가해 Attention 방출.
528
+ * 주목 신호 판단 — 상태(자리·설비·오더)에서 도메인 조건을 평가해 Attention 방출.
516
529
  * severity=ISA-18.2 우선순위 계열, kind=도메인 라벨. UI 는 판단 안 함(임계값 여기 소유).
517
530
  * 도메인별 추가 판단은 서브클래스가 override 로 확장(super.computeAttentions() 합성).
518
531
  */
519
532
  computeAttentions() {
520
533
  // 계산 층은 순수 함수 deriveAttentions 로 위임 — sim(여기)과 live projector 미러가 공유(face2-inbound-live §1.1).
521
- const out = deriveAttentions({ movers: [...this.movers.values()], nodes: [...this.nodes.values()], orders: [...this.orders.values()] }, this._acked);
534
+ const out = deriveAttentions({ equipment: [...this.equipment.values()], locations: [...this.locations.values()], orders: [...this.orders.values()] }, this._acked);
522
535
  // 확인(ack) 프루닝 — 사라진 조건은 ack 해제(재발 시 다시 active). 엔진 상태 변이라 여기 유지(marking 은 deriveAttentions).
523
536
  const present = new Set(out.map(a => a.id));
524
537
  for (const id of [...this._acked])
@@ -577,7 +590,7 @@ export class FlowEngine {
577
590
  */
578
591
  apply(envelope) {
579
592
  if (!this.observer) {
580
- this.observer = new ObservedReducer(this.boardDef ?? { nodes: [], movers: [] });
593
+ this.observer = new ObservedReducer(this.boardDef ?? { locations: [], equipment: [] });
581
594
  this.observeMode = true;
582
595
  }
583
596
  this.observer.apply(envelope);
@@ -754,8 +767,8 @@ export class FlowEngine {
754
767
  }
755
768
  return mix[mix.length - 1].gtin;
756
769
  }
757
- nodeByType(type) {
758
- for (const n of this.nodes.values())
770
+ locationByType(type) {
771
+ for (const n of this.locations.values())
759
772
  if (n.type === type)
760
773
  return n;
761
774
  return undefined;
@@ -764,7 +777,7 @@ export class FlowEngine {
764
777
  * process 변화 대수(EPCIS TransformationEvent · ISA-95 Material Consumed/Produced · 씬 Processable.transform).
765
778
  * inputs 소비 → outputs 생산. 입출력 arity 가 곧 대수:
766
779
  * merge N→1(조립) · split 1→N(분해) · transform 1→1(타입변경) · loss N→0(소실) · gain 0→N(부산물·생성).
767
- * 아이템 상태(소비/생산)와 노드 점유를 갱신하고 계보(TransformationEvent)를 방출한다.
780
+ * 아이템 상태(소비/생산)와 자리 점유를 갱신하고 계보(TransformationEvent)를 방출한다.
768
781
  * 도메인은 이 원시만 호출 — onTaskComplete 의 소비/생산/EPCIS 손코딩을 대체.
769
782
  */
770
783
  transform(inputs, outputs, opts) {
@@ -772,14 +785,14 @@ export class FlowEngine {
772
785
  const it = this.items.get(epc);
773
786
  if (!it)
774
787
  continue;
775
- const n = this.nodes.get(it.location);
788
+ const n = this.locations.get(it.location);
776
789
  if (n)
777
790
  n.occupancy--;
778
791
  this.items.delete(epc);
779
792
  }
780
793
  for (const o of outputs) {
781
794
  this.items.set(o.epc, { epc: o.epc, gtin: o.gtin, qty: o.qty, location: o.location, disposition: o.disposition });
782
- const n = this.nodes.get(o.location);
795
+ const n = this.locations.get(o.location);
783
796
  if (n)
784
797
  n.occupancy++;
785
798
  }
@@ -849,7 +862,7 @@ export class FlowEngine {
849
862
  for (const c of children) {
850
863
  const it = this.items.get(c);
851
864
  if (it) {
852
- const n = this.nodes.get(it.location);
865
+ const n = this.locations.get(it.location);
853
866
  if (n)
854
867
  n.occupancy--;
855
868
  this.items.delete(c);
@@ -867,7 +880,7 @@ export class FlowEngine {
867
880
  }
868
881
  /**
869
882
  * containment 분해(EPCIS AggregationEvent DELETE) — 부모(용기)에서 자식들을 풀어냄.
870
- * materialize 지정 시 자식이 독립 아이템으로 등장(materialize: 노드 배치 + occupancy + ObjectEvent ADD).
883
+ * materialize 지정 시 자식이 독립 아이템으로 등장(materialize: 자리 배치 + occupancy + ObjectEvent ADD).
871
884
  * 미지정 시 이벤트만.
872
885
  */
873
886
  disaggregate(parent, children, opts) {
@@ -882,7 +895,7 @@ export class FlowEngine {
882
895
  const m = opts.materialize;
883
896
  for (const c of children) {
884
897
  this.items.set(c, { epc: c, location: m.location, disposition: m.disposition });
885
- const n = this.nodes.get(m.location);
898
+ const n = this.locations.get(m.location);
886
899
  if (n)
887
900
  n.occupancy++;
888
901
  this.emit(objectEvent({ eventTime: this.now(), action: 'ADD', bizStep: opts.bizStep, disposition: m.disposition, epcList: [c], readPoint: m.location, bizLocation: m.location }));
@@ -891,7 +904,7 @@ export class FlowEngine {
891
904
  }
892
905
  /** 품질 보고(OEE Quality) — 도메인이 완료 시 자원별 양품/불량 1건 계상(예: 용접 수율). */
893
906
  recordOutput(moverId, good) {
894
- const m = moverId ? this.movers.get(moverId) : undefined;
907
+ const m = moverId ? this.equipment.get(moverId) : undefined;
895
908
  if (!m)
896
909
  return;
897
910
  if (good)
@@ -901,20 +914,20 @@ export class FlowEngine {
901
914
  // 품질 델타 방출 — live OEE 누적기가 good/scrap 을 정확 추적(equipment.status 는 quality 미포함). WMS/YMS 는 미호출→무영향.
902
915
  this.emitOp(OP_EVENT.quality, { moverId: m.id, good, goodCount: m.goodCount, scrapCount: m.scrapCount });
903
916
  }
904
- /** 무버 OEE(스냅샷 파생) — Availability×Performance×Quality. planned = 설비 존재 sim 시간(clockMs). */
917
+ /** 설비 OEE(스냅샷 파생) — Availability×Performance×Quality. planned = 설비 존재 sim 시간(clockMs). */
905
918
  oeeOf(m) {
906
919
  // 순수 공식 computeOee 로 위임 — sim(여기)과 live 가 같은 계산 층 공유. planned 는 hold 제외(계획정지 OEE 무영향).
907
920
  return computeOee(m, this.clockMs);
908
921
  }
909
- /** 정책에 넘길 특정 타입 노드의 관측 뷰 — 예약(그 노드로 향하는 in-flight task) 포함. */
910
- slotViews(nodeType) {
922
+ /** 정책에 넘길 특정 타입 자리의 관측 뷰 — 예약(그 자리로 향하는 in-flight task) 포함. */
923
+ slotViews(locationType) {
911
924
  const reserved = new Map();
912
925
  for (const t of this.tasks.values())
913
926
  if (t.status !== 'completed')
914
927
  reserved.set(t.toNode, (reserved.get(t.toNode) ?? 0) + 1);
915
928
  const views = [];
916
- for (const n of this.nodes.values())
917
- if (n.type === nodeType)
929
+ for (const n of this.locations.values())
930
+ if (n.type === locationType)
918
931
  views.push({ id: n.id, capacity: n.capacity, occupancy: n.occupancy, reserved: reserved.get(n.id) ?? 0 });
919
932
  return views;
920
933
  }
@@ -953,15 +966,15 @@ export class FlowEngine {
953
966
  }
954
967
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
955
968
  emitAsset(a) {
956
- this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClass: a.assetClass, status: a.status, location: a.location, taskId: a.taskId ?? undefined, carrying: a.carrying });
969
+ this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, status: a.status, location: a.location, taskId: a.taskId ?? undefined, carrying: a.carrying });
957
970
  }
958
971
  emitPerson(p) {
959
- this.emitOp(OP_EVENT.person, { personId: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? undefined, ...(this.personOffShift(p) ? { offShift: true } : {}) });
972
+ this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? undefined, ...(p.location ? { location: p.location } : {}), ...(this.personOffShift(p) ? { offShift: true } : {}) });
960
973
  }
961
974
  /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
962
975
  * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
963
- emitMover(m, motion) {
964
- this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? undefined, motion });
976
+ emitEquipment(m, motion) {
977
+ this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), taskId: m.taskId ?? undefined, motion });
965
978
  }
966
979
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
967
980
  emitOrder(o) {
@@ -1041,7 +1054,12 @@ export class FlowEngine {
1041
1054
  const want = Math.max(0, Math.floor(req.quantity ?? 0));
1042
1055
  if (!want)
1043
1056
  continue;
1044
- const avail = [...this.assets.values()].filter(a => a.status === 'idle' && !picked.includes(a.id) && (req.assetClass === undefined || a.assetClass === req.assetClass));
1057
+ const avail = [...this.assets.values()].filter(
1058
+ /* 인원과 같은 규칙 — 상속을 타고 닫아 판정하고 유효기간 밖은 제외한다. 요구는 등급 하나+수량. */
1059
+ a => a.status === 'idle' &&
1060
+ !picked.includes(a.id) &&
1061
+ (req.assetClass === undefined ||
1062
+ classClosure(a.assetClassIds, this.classDefs.asset, this.now()).has(req.assetClass)));
1045
1063
  if (avail.length < want)
1046
1064
  return null;
1047
1065
  for (let i = 0; i < want; i++)
@@ -1110,7 +1128,11 @@ export class FlowEngine {
1110
1128
  const avail = [...this.persons.values()].filter(p => p.status === 'idle' &&
1111
1129
  !picked.includes(p.id) &&
1112
1130
  !this.personOffShift(p) &&
1113
- (req.personnelClass === undefined || p.personnelClass === req.personnelClass));
1131
+ /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
1132
+ 다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다.
1133
+ 유효기간 밖의 등급은 닫힘에서 빠진다(만료된 자격으로 배정되지 않는다). */
1134
+ (req.personnelClass === undefined ||
1135
+ classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass)));
1114
1136
  if (avail.length < want)
1115
1137
  return null; // 한 등급이라도 모자라면 시작하지 않는다
1116
1138
  for (let i = 0; i < want; i++)
@@ -1124,7 +1146,14 @@ export class FlowEngine {
1124
1146
  * 여기서는 고르기만 한다 — 확정은 호출부가 다른 자원까지 확보한 뒤에 한다.
1125
1147
  */
1126
1148
  claimEquipment(t) {
1127
- const free = (kind, picked = []) => [...this.movers.values()].filter(m => m.status === 'idle' && !m.held && !this.offShift(m) && !picked.includes(m.id) && (kind === undefined || m.kind === kind));
1149
+ const free = (kind, picked = []) => [...this.equipment.values()].filter(m => m.status === 'idle' &&
1150
+ !m.held &&
1151
+ !this.offShift(m) &&
1152
+ !picked.includes(m.id) &&
1153
+ /* 설비의 소속은 아직 단수(`kind`)다 — 저널에 기록이 쌓여 복수화를 별도 작업으로 두었다.
1154
+ 다만 등급 정의가 있으면 **상속은 지금도 탄다**: 'welder-6axis' 가 'welder' 를 상속하면
1155
+ 'welder' 요구를 만족한다. 단수/복수와 상속은 다른 축이므로 하나를 기다리지 않는다. */
1156
+ (kind === undefined || classClosure(m.kind ? [m.kind] : [], this.classDefs.equipment, this.now()).has(kind)));
1128
1157
  const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
1129
1158
  if (!need?.length) {
1130
1159
  const one = free(t.resourceType)[0];
@@ -1180,15 +1209,15 @@ export class FlowEngine {
1180
1209
  * 이 자리가 동시 처리 한도에 찼는가 — `parallelism` 을 선언한 자리만 판정한다(미선언=제약 없음).
1181
1210
  * 세는 대상은 **그 자리에서 진행 중인 작업**(`toNode` 기준, in-progress). 대기 중인 작업은 세지 않는다.
1182
1211
  */
1183
- stationFull(nodeId) {
1184
- if (!nodeId)
1212
+ stationFull(locationId) {
1213
+ if (!locationId)
1185
1214
  return false;
1186
- const limit = this.nodes.get(nodeId)?.parallelism;
1215
+ const limit = this.locations.get(locationId)?.parallelism;
1187
1216
  if (!(typeof limit === 'number' && limit > 0))
1188
1217
  return false;
1189
1218
  let running = 0;
1190
1219
  for (const t of this.tasks.values())
1191
- if (t.status === 'in-progress' && t.toNode === nodeId)
1220
+ if (t.status === 'in-progress' && t.toNode === locationId)
1192
1221
  running++;
1193
1222
  return running >= limit;
1194
1223
  }
@@ -1219,12 +1248,12 @@ export class FlowEngine {
1219
1248
  /** 지수분포 표본(고장/수리 간격) — mean 을 평균으로 하는 무기억 프로세스. */
1220
1249
  sampleExp(meanMs) { return -Math.log(1 - this.rng()) * meanMs; }
1221
1250
  /**
1222
- * 확률적 설비 고장(MTBF/MTTR) — mtbf 지정 무버만 참여(미지정=고장 없음, rng 무소비 → byte-identical).
1251
+ * 확률적 설비 고장(MTBF/MTTR) — mtbf 지정 설비만 참여(미지정=고장 없음, rng 무소비 → byte-identical).
1223
1252
  * up: nextFailure 도래 시 down(수리까지 repairUntil). down: downMs 누적, repair 도래 시 up(다음 고장 예약).
1224
- * down 중 무버는 배정 불가 + 진행중 task 동결(processTasks 가 skip) → OEE Availability 손실.
1253
+ * down 중 설비는 배정 불가 + 진행중 task 동결(processTasks 가 skip) → OEE Availability 손실.
1225
1254
  */
1226
1255
  processFailures(dt) {
1227
- for (const m of this.movers.values()) {
1256
+ for (const m of this.equipment.values()) {
1228
1257
  if (m.status === 'down') {
1229
1258
  // down 회계 + 수리 — 확률적 고장·강제 고장(resource.down) 공통. repairUntilMs 도래 시 복구.
1230
1259
  m.downMs += dt;
@@ -1233,7 +1262,7 @@ export class FlowEngine {
1233
1262
  m.repairUntilMs = undefined;
1234
1263
  if (m.mtbfMs !== undefined)
1235
1264
  m.nextFailureMs = this.clockMs + this.sampleExp(m.mtbfMs);
1236
- this.emitMover(m);
1265
+ this.emitEquipment(m);
1237
1266
  }
1238
1267
  continue;
1239
1268
  }
@@ -1243,12 +1272,12 @@ export class FlowEngine {
1243
1272
  m.holdMs = (m.holdMs ?? 0) + dt;
1244
1273
  continue;
1245
1274
  }
1246
- // 확률적 고장 — mtbf 지정 무버만(미지정=고장 없음, rng 무소비 → byte-identical baseline).
1275
+ // 확률적 고장 — mtbf 지정 설비만(미지정=고장 없음, rng 무소비 → byte-identical baseline).
1247
1276
  if (m.mtbfMs !== undefined && this.clockMs >= (m.nextFailureMs ?? Infinity)) {
1248
1277
  m.status = 'down';
1249
1278
  m.repairUntilMs = this.clockMs + this.sampleExp(m.mttrMs ?? m.mtbfMs);
1250
1279
  m.nextFailureMs = undefined;
1251
- this.emitMover(m);
1280
+ this.emitEquipment(m);
1252
1281
  }
1253
1282
  }
1254
1283
  }
@@ -1356,21 +1385,21 @@ export class FlowEngine {
1356
1385
  const rigs = this.claimEquipment(t);
1357
1386
  if (!rigs)
1358
1387
  continue;
1359
- const mover = this.movers.get(rigs[0]);
1360
- // 체인지오버: task 의 changeoverKey 가 무버 직전 키와 다르면 셋업 부착(첫 작업은 셋업 없음).
1361
- if (t.setupMs && t.changeoverKey !== undefined && mover.lastChangeoverKey !== undefined && mover.lastChangeoverKey !== t.changeoverKey) {
1388
+ const eq = this.equipment.get(rigs[0]);
1389
+ // 체인지오버: task 의 changeoverKey 가 설비 직전 키와 다르면 셋업 부착(첫 작업은 셋업 없음).
1390
+ if (t.setupMs && t.changeoverKey !== undefined && eq.lastChangeoverKey !== undefined && eq.lastChangeoverKey !== t.changeoverKey) {
1362
1391
  t.appliedSetupMs = t.setupMs;
1363
- t.durationMs += t.setupMs; // 셋업을 가동 앞에 folded(무버 점유 = 셋업+사이클)
1392
+ t.durationMs += t.setupMs; // 셋업을 가동 앞에 folded(설비 점유 = 셋업+사이클)
1364
1393
  }
1365
1394
  if (t.changeoverKey !== undefined)
1366
- mover.lastChangeoverKey = t.changeoverKey;
1395
+ eq.lastChangeoverKey = t.changeoverKey;
1367
1396
  for (const id of rigs) {
1368
- const m = this.movers.get(id);
1397
+ const m = this.equipment.get(id);
1369
1398
  m.status = 'busy';
1370
1399
  m.taskId = t.id;
1371
1400
  }
1372
1401
  t.status = 'in-progress';
1373
- t.resource = mover.id;
1402
+ t.resource = eq.id;
1374
1403
  t.remainingMs = t.durationMs;
1375
1404
  t.startedAtSimMs = this.clockMs;
1376
1405
  if (rigs.length > 1)
@@ -1380,9 +1409,9 @@ export class FlowEngine {
1380
1409
  this.emitTask(t);
1381
1410
  // process: 제자리 변환(모션 없음). transport: 이동 모션 방출.
1382
1411
  if (t.intent === 'process')
1383
- this.emitMover(mover);
1412
+ this.emitEquipment(eq);
1384
1413
  else
1385
- this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
1414
+ this.emitEquipment(eq, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
1386
1415
  }
1387
1416
  }
1388
1417
  /** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
@@ -1390,7 +1419,7 @@ export class FlowEngine {
1390
1419
  for (const t of this.tasks.values()) {
1391
1420
  if (t.status !== 'in-progress')
1392
1421
  continue;
1393
- if (t.resource && this.movers.get(t.resource)?.status === 'down')
1422
+ if (t.resource && this.equipment.get(t.resource)?.status === 'down')
1394
1423
  continue; // 설비 고장 → 작업 동결
1395
1424
  t.remainingMs -= dt;
1396
1425
  if (t.remainingMs > 0)
@@ -1403,20 +1432,20 @@ export class FlowEngine {
1403
1432
  this.emitTask(t);
1404
1433
  continue;
1405
1434
  } // dwell(무자원) — 해제할 자원 없음
1406
- const mover = this.movers.get(t.resource);
1435
+ const eq = this.equipment.get(t.resource);
1407
1436
  // OEE 계측: 셋업/가동 누적(가동 = 총 duration − 셋업). onTaskComplete 가 recordOutput 로 품질 보고.
1408
1437
  const setup = t.appliedSetupMs ?? 0;
1409
- mover.setupMs += setup;
1410
- mover.runMs += t.durationMs - setup;
1411
- mover.status = 'idle';
1412
- mover.taskId = null;
1438
+ eq.setupMs += setup;
1439
+ eq.runMs += t.durationMs - setup;
1440
+ eq.status = 'idle';
1441
+ eq.taskId = null;
1413
1442
  if (t.intent !== 'process')
1414
- mover.location = t.toNode; // 운반만 위치 이동; process 는 제자리
1443
+ eq.location = t.toNode; // 운반만 위치 이동; process 는 제자리
1415
1444
  /* 함께 잡힌 설비도 같은 규칙으로 놓아 준다 — 대표만 풀면 나머지가 영원히 묶인다. */
1416
1445
  for (const id of t.resources ?? []) {
1417
- if (id === mover.id)
1446
+ if (id === eq.id)
1418
1447
  continue;
1419
- const m = this.movers.get(id);
1448
+ const m = this.equipment.get(id);
1420
1449
  if (!m)
1421
1450
  continue;
1422
1451
  m.setupMs += setup;
@@ -1425,10 +1454,10 @@ export class FlowEngine {
1425
1454
  m.taskId = null;
1426
1455
  if (t.intent !== 'process')
1427
1456
  m.location = t.toNode;
1428
- this.emitMover(m);
1457
+ this.emitEquipment(m);
1429
1458
  }
1430
1459
  this.emitTask(t);
1431
- this.emitMover(mover);
1460
+ this.emitEquipment(eq);
1432
1461
  }
1433
1462
  }
1434
1463
  }