@things-factory/headless-twin 10.0.5 → 10.0.7
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.
- package/README.md +37 -24
- package/dist-server/engine/entity-delta.d.ts +13 -1
- package/dist-server/engine/entity-delta.js +130 -16
- package/dist-server/engine/entity-delta.js.map +1 -1
- package/dist-server/engine/index.d.ts +4 -0
- package/dist-server/engine/index.js +4 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +20 -3
- package/dist-server/engine/kpi-fold.js +45 -53
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +16 -0
- package/dist-server/engine/kpi-query.js +129 -19
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/kpi-target.d.ts +141 -0
- package/dist-server/engine/kpi-target.js +137 -0
- package/dist-server/engine/kpi-target.js.map +1 -0
- package/dist-server/engine/live-attentions.d.ts +4 -2
- package/dist-server/engine/live-attentions.js +5 -1
- package/dist-server/engine/live-attentions.js.map +1 -1
- package/dist-server/engine/measured-estimator.d.ts +50 -0
- package/dist-server/engine/measured-estimator.js +78 -0
- package/dist-server/engine/measured-estimator.js.map +1 -0
- package/dist-server/engine/model-basis.d.ts +23 -0
- package/dist-server/engine/model-basis.js +100 -0
- package/dist-server/engine/model-basis.js.map +1 -0
- package/dist-server/engine/oee-accumulator.d.ts +2 -2
- package/dist-server/engine/oee-accumulator.js +4 -4
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/spec-coverage.d.ts +49 -0
- package/dist-server/engine/spec-coverage.js +60 -0
- package/dist-server/engine/spec-coverage.js.map +1 -0
- package/dist-server/engine/structure-diff.d.ts +25 -0
- package/dist-server/engine/structure-diff.js +63 -0
- package/dist-server/engine/structure-diff.js.map +1 -0
- package/dist-server/engine/travel-estimator.d.ts +57 -0
- package/dist-server/engine/travel-estimator.js +120 -0
- package/dist-server/engine/travel-estimator.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +181 -18
- package/dist-server/engine/twin-engine.js +545 -96
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/warm-start.d.ts +39 -0
- package/dist-server/engine/warm-start.js +37 -0
- package/dist-server/engine/warm-start.js.map +1 -0
- package/dist-server/index.js +8 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/index.d.ts +4 -2
- package/dist-server/service/index.js +21 -14
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +2 -1
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +307 -1
- package/dist-server/service/reference/reference-master.js +96 -8
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +3 -3
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/template-registry.d.ts +1 -1
- package/dist-server/service/reference/template-registry.js.map +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
- package/dist-server/service/twin-event/backfill-keys.js +63 -0
- package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
- package/dist-server/service/twin-event/twin-event-keys.js +95 -0
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
- package/dist-server/service/twin-event/twin-event-type.js +32 -0
- package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
- package/dist-server/service/twin-event/twin-event.d.ts +16 -0
- package/dist-server/service/twin-event/twin-event.js +50 -0
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-instance/twin-instance.js +1 -1
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +28 -0
- package/dist-server/service/twin-journal/twin-journal-query.js +127 -2
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-area.js +1 -1
- package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-server/service/twin-space/twin-space.d.ts +11 -0
- package/dist-server/service/twin-space/twin-space.js +5 -0
- package/dist-server/service/twin-space/twin-space.js.map +1 -1
- package/dist-server/service/twin-structure/index.d.ts +2 -0
- package/dist-server/service/twin-structure/index.js +6 -0
- package/dist-server/service/twin-structure/index.js.map +1 -0
- package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
- package/dist-server/service/twin-structure/twin-structure.js +97 -0
- package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
- package/dist-server/service/twin-target/index.d.ts +4 -0
- package/dist-server/service/twin-target/index.js +8 -0
- package/dist-server/service/twin-target/index.js.map +1 -0
- package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
- package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
- package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
- package/dist-server/service/twin-target/twin-target.d.ts +25 -0
- package/dist-server/service/twin-target/twin-target.js +112 -0
- package/dist-server/service/twin-target/twin-target.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/engine/entity-delta.ts +135 -15
- package/server/engine/index.ts +4 -0
- package/server/engine/kpi-fold.ts +62 -59
- package/server/engine/kpi-query.ts +140 -19
- package/server/engine/kpi-target.ts +226 -0
- package/server/engine/live-attentions.ts +12 -2
- package/server/engine/measured-estimator.ts +91 -0
- package/server/engine/model-basis.ts +94 -0
- package/server/engine/oee-accumulator.ts +5 -5
- package/server/engine/spec-coverage.ts +85 -0
- package/server/engine/structure-diff.ts +88 -0
- package/server/engine/travel-estimator.ts +133 -0
- package/server/engine/twin-engine.ts +587 -106
- package/server/engine/warm-start.ts +53 -0
- package/server/index.ts +9 -0
- package/server/service/index.ts +7 -0
- package/server/service/reference/reference-live.ts +2 -1
- package/server/service/reference/reference-master.ts +383 -10
- package/server/service/reference/reference-resolver.ts +3 -3
- package/server/service/reference/template-registry.ts +1 -1
- package/server/service/twin-attention/twin-attention-query.ts +1 -1
- package/server/service/twin-control/twin-control-mutation.ts +1 -1
- package/server/service/twin-event/backfill-keys.ts +72 -0
- package/server/service/twin-event/twin-event-keys.ts +102 -0
- package/server/service/twin-event/twin-event-type.ts +27 -0
- package/server/service/twin-event/twin-event.ts +62 -0
- package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
- package/server/service/twin-instance/twin-instance.ts +1 -1
- package/server/service/twin-journal/twin-journal-query.ts +129 -5
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
- package/server/service/twin-space/twin-space-area.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +3 -3
- package/server/service/twin-space/twin-space.ts +14 -0
- package/server/service/twin-structure/index.ts +3 -0
- package/server/service/twin-structure/twin-structure.ts +93 -0
- package/server/service/twin-target/index.ts +5 -0
- package/server/service/twin-target/twin-target-resolver.ts +124 -0
- package/server/service/twin-target/twin-target.ts +97 -0
- package/test/capability-mapping.test.ts +5 -5
- package/test/duration-estimators.test.ts +144 -0
- package/test/entity-delta.test.ts +150 -19
- package/test/ingest-bench.test.ts +9 -9
- package/test/kpi-fold.test.ts +254 -3
- package/test/live-mirror-parity.test.ts +53 -19
- package/test/master-to-twin.test.ts +87 -13
- package/test/model-basis.test.ts +86 -0
- package/test/oee-accumulator.test.ts +9 -9
- package/test/scale-twin-bench.test.ts +22 -22
- package/test/spec-coverage.test.ts +113 -0
- package/test/streamline-e2e.test.ts +10 -10
- package/test/structure-revision-db.test.ts +310 -0
- package/test/twin-event-keys.test.ts +108 -0
- package/test/vocabulary-guard.test.ts +43 -0
- package/test/warm-start.test.ts +78 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* TwinEngine — operato-twin 커널 인스턴스를 things-factory 위에서 호스팅.
|
|
3
3
|
* integration-base 의 ScenarioEngine 패턴 미러: 메모리 인스턴스 맵 + 백그라운드 tick + pubsub 상태 발행.
|
|
4
4
|
*
|
|
5
|
-
* 커널(zero-dep, ESM+CJS 이중배포)은 CJS require 로 로드(Node 20 CommonJS 소비 — require(ESM) 회피).
|
|
5
|
+
* 커널(zero-dep, ESM+CJS 이중배포)은 CJS require 로 로드(Node 20 CommonJS 소비 — require(ESM) 회피). (vocabulary-guard: allow — Node 런타임 버전 표기)
|
|
6
6
|
* 커널은 결정적 논리 연산 코어 — 이 host 가 전송(pubsub)·영속(TypeORM)·스케줄(tick)·복구(replay)만 얇게 래핑.
|
|
7
7
|
* (설계 SoT: operato-twin/design/integration/things-factory-host.md)
|
|
8
8
|
*/
|
|
@@ -11,20 +11,30 @@ import { pubsub, getRepository, Domain } from '@things-factory/shell'
|
|
|
11
11
|
import { cacheService } from '@things-factory/cache-service'
|
|
12
12
|
|
|
13
13
|
import { TwinEvent } from '../service/twin-event/twin-event.js'
|
|
14
|
+
import { twinEventKeys } from '../service/twin-event/twin-event-keys.js'
|
|
15
|
+
import { planWarmStart } from './warm-start.js'
|
|
14
16
|
import { TwinInstance } from '../service/twin-instance/twin-instance.js'
|
|
17
|
+
import { TwinStructure } from '../service/twin-structure/twin-structure.js'
|
|
15
18
|
import { TwinSpace } from '../service/twin-space/twin-space.js'
|
|
19
|
+
import { utcOffsetOf } from '../service/reference/reference-master.js'
|
|
16
20
|
import { TwinSpaceRepresentation } from '../service/twin-space/twin-space-representation.js'
|
|
17
21
|
import { TwinSpaceArea } from '../service/twin-space/twin-space-area.js'
|
|
18
22
|
import { TwinArea } from '../service/twin-space/twin-area.js'
|
|
19
23
|
import { masterToTwin, type ReferenceMaster } from '../service/reference/reference-master.js'
|
|
24
|
+
import { buildTravelEstimator, chainEstimators } from './travel-estimator.js'
|
|
25
|
+
import { buildMeasuredEstimator } from './measured-estimator.js'
|
|
26
|
+
import { describeModelBasis, type ModelBasis } from './model-basis.js'
|
|
27
|
+
import { computeTwinKpi } from './kpi-query.js'
|
|
28
|
+
import { createHash } from 'node:crypto'
|
|
29
|
+
|
|
20
30
|
import { buildEntityDeltas } from './entity-delta.js'
|
|
31
|
+
import { diffStructures, type StructureDiff } from './structure-diff.js'
|
|
21
32
|
import { OeeAccumulator, withLiveOee } from './oee-accumulator.js'
|
|
22
|
-
import { withLiveAttentions } from './live-attentions.js'
|
|
23
33
|
|
|
24
34
|
import type { TwinKernel, BoardDef, SubscriptionMessage, TwinRuntime as TwinRuntimeType, CanonicalEnvelope } from '@operato/twin-kernel'
|
|
25
35
|
|
|
26
36
|
/* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */
|
|
27
|
-
const { WmsKernel, YmsKernel, MesKernel, TwinRuntime, StateProjector, replay, DOMAIN_CATALOG, OP_EVENT } = require('@operato/twin-kernel')
|
|
37
|
+
const { WmsKernel, YmsKernel, MesKernel, TwinRuntime, StateProjector, replay, replaySegments, DOMAIN_CATALOG, OP_EVENT } = require('@operato/twin-kernel')
|
|
28
38
|
|
|
29
39
|
const KERNELS: Record<string, any> = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel }
|
|
30
40
|
|
|
@@ -62,8 +72,12 @@ interface InstanceRuntime {
|
|
|
62
72
|
runtime?: TwinRuntimeType // sim: 커널 런타임(tick/subscribe/dispatch). live 는 projector 사용.
|
|
63
73
|
kernel?: any // sim: ForecastTwin(fork/getSnapshot/tick/scenario). live 미사용.
|
|
64
74
|
mode?: 'sim' | 'live' // 상태 드라이버 — sim=커널 tick / live=projector 미러(face2-inbound-live). 미지정=sim.
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
/**
|
|
76
|
+
* live 의 상태 접근 어댑터 — 이제 **관측 모드 커널**을 가리킨다(`kernel` 과 같은 것).
|
|
77
|
+
* 이름은 소비처 호환으로 남았고, P3 에서 커널 어휘로 정리하면 사라진다.
|
|
78
|
+
*/
|
|
79
|
+
projector?: any
|
|
80
|
+
oee?: OeeAccumulator // live: OEE 계산 층(이벤트 누적 → equipment payload 보강). sim 은 커널이 계산.
|
|
67
81
|
unsub: () => void
|
|
68
82
|
timer?: any
|
|
69
83
|
/** 엔티티별 마지막 발행 시그니처 — 값이 바뀐 엔티티만 재발행(무변화 반복 push 방지). */
|
|
@@ -177,19 +191,202 @@ export class TwinEngine {
|
|
|
177
191
|
}
|
|
178
192
|
}
|
|
179
193
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
194
|
+
/**
|
|
195
|
+
* 웜스타트 — 기동하는 커널에 **직전 관측 상태**를 심는다.
|
|
196
|
+
*
|
|
197
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────────
|
|
198
|
+
* `loadBoard` 는 **구조만** 싣는다(노드·무버). 상태(무엇이 어디에 얼마나)는 없다. 그래서 재기동한
|
|
199
|
+
* 트윈은 저널에 입고 540건이 남아 있어도 재고가 0 이었고, 화면은 "보유 중인 것이 없습니다" 라고
|
|
200
|
+
* 말했다 — 있는 재고를 없다고 하는 셈이다(2026-07-31 hatiolab-wms 실측으로 확인).
|
|
201
|
+
* `bootstrap()` 이 이미 체크포인트 캐시(없으면 저널 replay)로 상태를 복구해 `recovered` 에 담아 두는데,
|
|
202
|
+
* 기동 순간 그걸 **버리고** 있었다. 반만 연결돼 있던 장치를 잇는다.
|
|
203
|
+
*
|
|
204
|
+
* ── 정직한 한계 ─────────────────────────────────────────────────────────────
|
|
205
|
+
* · **오더는 복원하지 않는다.** `hydrateObserved` 의 오더 인자는 requested/fulfilled/lines 를 요구하는데
|
|
206
|
+
* 스냅샷의 `OrderState` 에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이라
|
|
207
|
+
* 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작한다.
|
|
208
|
+
* · 진행 중 개별 task 의 내부 상태도 관측만으로는 복원되지 않는다(커널이 명시한 한계, 재계획에 맡김).
|
|
209
|
+
* · 근본 해법(상태 영속 계약·revision 이어붙임)은 별도 과제.
|
|
210
|
+
*
|
|
211
|
+
* ── 벤치는 시드하지 않는다 ──────────────────────────────────────────────────
|
|
212
|
+
* 부하 벤치는 **새 시작에서 용량을 재는 것**이 목적이라 현재 상태를 심으면 측정이 오염된다.
|
|
213
|
+
*/
|
|
214
|
+
private static warmStart(id: string, kernel: TwinKernel, purpose?: string): void {
|
|
215
|
+
const hydrate = (kernel as any).hydrateObserved
|
|
216
|
+
const plan = planWarmStart(this.recovered[id]?.state, purpose, typeof hydrate === 'function')
|
|
217
|
+
|
|
218
|
+
if (plan.action === 'skip') {
|
|
219
|
+
if (plan.reason === 'bench') {
|
|
220
|
+
console.log(`[twin-engine] "${id}" is a bench twin — starting empty on purpose (seeding would skew the measurement).`)
|
|
221
|
+
} else if (plan.reason === 'unsupported') {
|
|
222
|
+
console.warn(
|
|
223
|
+
`[twin-engine] kernel for "${id}" cannot be warm-started (no hydrateObserved) — it starts with structure only, so held stock will read as zero.`
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
hydrate.call(kernel, plan.seed)
|
|
230
|
+
console.log(
|
|
231
|
+
`[twin-engine] warm-started "${id}" — ${plan.itemCount} item(s), ${plan.equipmentCount} equipment restored. ` +
|
|
232
|
+
'Open orders are not restored (the snapshot carries no requested/fulfilled counts).'
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + 직전 상태 웜스타트 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
|
|
237
|
+
/**
|
|
238
|
+
* 공정 명세를 커널에 싣는다 — **시뮬레이션의 시간을 데이터가 말하게 하는 마지막 한 칸.**
|
|
239
|
+
*
|
|
240
|
+
* `board.operations`(마스터 인제스트가 통과시킨 ISA-95 OperationsSegment 명세)를 커널이 소비한다.
|
|
241
|
+
* 없으면 커널 기본 상수로 굴러가고, 커널 `specCoverage()` 가 무엇을 기본값으로 썼는지 보고한다.
|
|
242
|
+
*
|
|
243
|
+
* 커널이 아직 이 API 를 갖지 않은 버전이면(발행 이전) **조용히 넘어가지 않고 경고한다** — 명세를
|
|
244
|
+
* 선언했는데 반영되지 않는 상태를 모르고 지나가면, 예측이 상수로 돌아간 것을 아무도 알 수 없다.
|
|
245
|
+
*/
|
|
246
|
+
private static applyOperations(kernel: any, board: any, id: string): void {
|
|
247
|
+
const ops = board?.operations
|
|
248
|
+
if (!ops?.length) return
|
|
249
|
+
if (typeof kernel?.loadOperations !== 'function') {
|
|
250
|
+
console.warn(`[twin-engine] "${id}": board declares ${ops.length} operation spec(s) but the kernel cannot consume them (loadOperations missing — kernel needs publishing). Simulation will use built-in default durations.`)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
kernel.loadOperations(ops)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 소요시간 추정기 주입 — **상수를 데이터로 바꾸는 두 번째·세 번째 층.**
|
|
258
|
+
*
|
|
259
|
+
* 커널 `durationOf` 의 우선순위는 추정기 > 명세 > 상수다. 그 추정기 자리에 두 가지를 사슬로 넣는다:
|
|
260
|
+
* ① **실측**(저널의 작업 종류별 작업시간 p50) — 그 현장에서 실제로 얼마 걸렸나. 가장 강한 근거.
|
|
261
|
+
* ② **거리 × 속도**(board.layout + 설비 속도 속성) — 이동은 거리에 비례한다. 커널은 좌표를 모르므로
|
|
262
|
+
* 호스트가 계산해 넣는다.
|
|
263
|
+
* 둘 다 못 만들면 주입하지 않는다 — 커널이 명세·상수로 굴러가고 `specCoverage()` 가 그 사실을 남긴다.
|
|
264
|
+
*
|
|
265
|
+
* 실측은 DB 조회라 비동기다. 그래서 이 함수는 **await 하지 않는 쪽에서도 안전**하도록 실패를 삼키되,
|
|
266
|
+
* 무엇을 왜 못 넣었는지는 로그로 남긴다(조용한 무효화 금지).
|
|
267
|
+
*/
|
|
268
|
+
static async installEstimators(kernel: any, domainId: string, instanceId: string, board: any): Promise<void> {
|
|
269
|
+
if (!kernel || typeof kernel !== 'object') return
|
|
270
|
+
const travel = buildTravelEstimator({ layout: board?.layout, equipment: board?.equipment, unit: board?.unit })
|
|
271
|
+
const measured = await this.measuredEstimator(domainId, instanceId)
|
|
272
|
+
const chained = chainEstimators([measured?.estimator, travel.estimator])
|
|
273
|
+
if (!chained) {
|
|
274
|
+
/* 왜 못 넣었는지 한 번만 알린다 — 이동시간이 상수로 남은 이유를 모르고 지나가지 않게. */
|
|
275
|
+
if (travel.reasons.length) console.warn(`[twin-engine] "${instanceId}": no duration estimator installed — ${travel.reasons.join('; ')}`)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
kernel.durationEstimator = chained
|
|
279
|
+
const learned = Object.keys(measured?.learned ?? {})
|
|
280
|
+
const spread = Object.keys(measured?.spreads ?? {})
|
|
281
|
+
console.log(
|
|
282
|
+
`[twin-engine] "${instanceId}": duration estimator installed — measured kinds: ${learned.length ? learned.join(',') : 'none'}` +
|
|
283
|
+
` (with observed spread: ${spread.length ? spread.join(',') : 'none'})` +
|
|
284
|
+
`${travel.estimator ? `, travel from distance (speeds: ${Object.keys(travel.speedsByKind).join(',')})` : `, travel not derived (${travel.reasons.join('; ')})`}`
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* 실측 추정기 — **예측 요청마다 저널을 다시 접지 않는다.**
|
|
290
|
+
*
|
|
291
|
+
* 예측 커널은 요청마다 새로 세워지고(미러 예측·백테스트), 화면은 시각을 긁으면 계속 재예측한다.
|
|
292
|
+
* 거기에 KPI 조회를 그대로 달면 요청당 저널 스캔이 하나씩 붙는다 — 실측은 분 단위로 바뀌지 않으므로
|
|
293
|
+
* 짧은 TTL 로 재사용한다. 캐시는 인스턴스별이고, 만료 전에는 같은 값을 쓴다(예측 간 일관성도 얻는다).
|
|
294
|
+
*/
|
|
295
|
+
private static measuredCache = new Map<string, { at: number; value: ReturnType<typeof buildMeasuredEstimator> | undefined }>()
|
|
296
|
+
private static readonly MEASURED_TTL_MS = 60_000
|
|
297
|
+
|
|
298
|
+
private static async measuredEstimator(domainId: string, instanceId: string) {
|
|
299
|
+
const key = `${domainId}:${instanceId}`
|
|
300
|
+
const hit = this.measuredCache.get(key)
|
|
301
|
+
if (hit && Date.now() - hit.at < this.MEASURED_TTL_MS) return hit.value
|
|
302
|
+
let value: ReturnType<typeof buildMeasuredEstimator> | undefined
|
|
303
|
+
try {
|
|
304
|
+
/* 작업 종류별 실측 — 창은 넉넉히(하루) 두고 표본이 모자란 종류는 추정기가 스스로 뺀다. */
|
|
305
|
+
const kpi: any = await computeTwinKpi({ domainId, instanceId, windowMinutes: 24 * 60, groupBy: 'taskKind' })
|
|
306
|
+
value = buildMeasuredEstimator(kpi?.groups?.items, {})
|
|
307
|
+
} catch (err) {
|
|
308
|
+
console.warn(`[twin-engine] "${instanceId}": measured duration lookup failed — falling back to declared/default durations.`, (err as any)?.message)
|
|
309
|
+
}
|
|
310
|
+
this.measuredCache.set(key, { at: Date.now(), value })
|
|
311
|
+
return value
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* 지금 이 트윈이 어떤 모델로 굴러가는가 — 예측 출력 보정이 **자기가 배운 모델**에만 적용되도록
|
|
316
|
+
* 비교하는 지문. 재료는 호스트가 아는 것(실측·속도·선언 명세)이라 커널 발행 상태와 무관하다.
|
|
317
|
+
* 실패하면 undefined — 그때는 비교를 하지 않는다(모르는 것을 근거로 보정을 끊지 않는다).
|
|
318
|
+
*/
|
|
319
|
+
static async modelBasis(domainId: string, instanceId: string): Promise<ModelBasis | undefined> {
|
|
320
|
+
try {
|
|
321
|
+
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
322
|
+
const board: any = reg?.board
|
|
323
|
+
const measured = await this.measuredEstimator(domainId, instanceId)
|
|
324
|
+
const travel = buildTravelEstimator({ layout: board?.layout, equipment: board?.equipment, unit: board?.unit })
|
|
325
|
+
return { measured: measured?.learned, speeds: travel.speedsByKind, declared: board?.operations }
|
|
326
|
+
} catch {
|
|
327
|
+
return undefined
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* **이 트윈이 하루 몇 대를 낼 수 있는가** — 굴려 보지 않고 답한다.
|
|
333
|
+
*
|
|
334
|
+
* 계산은 커널이 자기 상태에서 한다(`kernel.capacity`). 여기서 하는 일은 **기준 주를 정해 주는
|
|
335
|
+
* 것**뿐이다: 공휴일이 없는 평상주여야 한다 — 공휴일은 연간 가용량을 따로 깎지, 이 공장의 평상시
|
|
336
|
+
* 천장을 정하지 않는다. 커널이 임의로 고르게 두면 그 주에 공휴일이 끼었을 때 천장이 조용히 낮아진다.
|
|
337
|
+
*
|
|
338
|
+
* 트윈이 안 떠 있으면 `undefined` 다 — 0 이 아니다. 안 뜬 트윈의 천장을 0 이라고 답하면 화면은
|
|
339
|
+
* "이 공장은 아무것도 못 만든다" 고 말한다.
|
|
340
|
+
*/
|
|
341
|
+
static async capacity(
|
|
342
|
+
domainId: string,
|
|
343
|
+
target: { instanceId?: string; spaceId?: string },
|
|
344
|
+
unitsPerDay: number,
|
|
345
|
+
sampleWeekStartMs: number
|
|
346
|
+
): Promise<{ instanceId: string; kind?: string; analysis?: any; reason?: 'not-running' | 'no-operations' }[]> {
|
|
347
|
+
/* 대상은 **등록부**가 정한다 — 떠 있는 런타임만 훑으면 "안 떠 있어서 안 보이는 것" 과 "없는 것" 이
|
|
348
|
+
구별되지 않는다. 화면은 그 둘을 다르게 말해야 한다. */
|
|
349
|
+
const where: any = { domain: { id: domainId } }
|
|
350
|
+
if (target.instanceId) where.instanceId = target.instanceId
|
|
351
|
+
else where.spaceId = target.spaceId
|
|
352
|
+
const registered = await getRepository(TwinInstance).find({ where })
|
|
353
|
+
|
|
354
|
+
return registered.map(reg => {
|
|
355
|
+
const rt = this.instances[reg.instanceId]
|
|
356
|
+
const kernel: any = rt?.domainId === domainId ? rt.kernel : undefined
|
|
357
|
+
if (typeof kernel?.capacity !== 'function') return { instanceId: reg.instanceId, kind: reg.kind, reason: 'not-running' as const }
|
|
358
|
+
|
|
359
|
+
const analysis = kernel.capacity({ unitsPerDay, sampleWeekStartMs })
|
|
360
|
+
/* 공정을 선언하지 않은 트윈(대개 YMS·WMS)은 **판정 대상이 아니다.** 0 으로 채워 넣으면
|
|
361
|
+
"야드가 병목" 이라는 없는 사실이 생긴다 — 잴 것이 없다고 말한다. */
|
|
362
|
+
if (!analysis?.operations?.length) return { instanceId: reg.instanceId, kind: reg.kind, reason: 'no-operations' as const }
|
|
363
|
+
return { instanceId: reg.instanceId, kind: reg.kind, analysis }
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
static start(id: string, domainId: string, kind: string, board: BoardDef, realityMode?: RealityMode, purpose?: string, resumeFrom?: number): InstanceRuntime {
|
|
182
368
|
if (this.instances[id]) return this.instances[id]
|
|
183
369
|
|
|
184
370
|
const Kernel = KERNELS[kind] ?? WmsKernel
|
|
185
|
-
const kernel: TwinKernel = new Kernel(domainId)
|
|
186
|
-
kernel.loadBoard(board)
|
|
371
|
+
const kernel: TwinKernel = new Kernel(domainId, undefined, this.mesSpecOf(board))
|
|
372
|
+
kernel.loadBoard(board) // 구조만. 상태는 아래 웜스타트가 심는다.
|
|
373
|
+
this.applyOperations(kernel, board, id) // 시간·수율 명세(있으면) — 없으면 커널 기본값
|
|
374
|
+
/* 추정기는 DB 조회를 포함해 비동기 — 기동을 막지 않고 붙는다(붙기 전 작업은 명세·상수로 산출). */
|
|
375
|
+
this.installEstimators(kernel, domainId, id, board).catch(err => console.warn('[twin-engine] estimator install failed', err?.message))
|
|
376
|
+
this.warmStart(id, kernel, purpose)
|
|
377
|
+
/*
|
|
378
|
+
* **번호를 이어 센다** — 저널에 이미 있는 번호와 겹치지 않게.
|
|
379
|
+
*
|
|
380
|
+
* 저널이 비어 있으면(0) 아무 일도 안 한다. 비어 있지 않은데 0부터 다시 세면 같은 트윈에 같은
|
|
381
|
+
* 리비전이 둘 생기고, 재생 순서가 뒤섞이며, 시간여행이 엉뚱한 시점을 답한다 — 오류는 안 난다.
|
|
382
|
+
*/
|
|
383
|
+
if (resumeFrom && typeof (kernel as any).resumeRevision === 'function') (kernel as any).resumeRevision(resumeFrom)
|
|
187
384
|
const runtime: TwinRuntimeType = new TwinRuntime(kernel)
|
|
188
385
|
|
|
189
386
|
/* subscribe 는 RuntimeSubscription({ unsubscribe() }) 반환 → () => void 로 감쌈. */
|
|
190
387
|
const inst: InstanceRuntime = { id, domainId, runtime, kernel, realityMode: realityMode ?? DEFAULT_REALITY_MODE, unsub: () => {} }
|
|
191
388
|
this.instances[id] = inst
|
|
192
|
-
delete this.recovered[id] // 라이브가
|
|
389
|
+
delete this.recovered[id] // 웜스타트로 커널에 옮겨 심었다 — 이제 라이브가 진실이다.
|
|
193
390
|
|
|
194
391
|
/* 라이브 바인딩(P3): data 채널 필터가 subdomain 을 보므로 Domain 객체를 1회 해석해 둔다. */
|
|
195
392
|
getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {})
|
|
@@ -216,15 +413,66 @@ export class TwinEngine {
|
|
|
216
413
|
}
|
|
217
414
|
|
|
218
415
|
/**
|
|
219
|
-
* live-모드 기동
|
|
220
|
-
*
|
|
221
|
-
*
|
|
416
|
+
* live-모드 기동 — **관측 모드 커널**로 실 이벤트를 미러한다(통합 P2).
|
|
417
|
+
*
|
|
418
|
+
* 예전에는 `StateProjector` 를 세웠다. 그래서 라이브에는 **커널이 없었고**, 그 하나 때문에 우회가
|
|
419
|
+
* 줄줄이 생겼다: 예측하려면 임시 커널을 세워야 했고(`buildForecastKernel`), 주목 신호를 호스트가
|
|
420
|
+
* 덧붙여야 했고(`withLiveAttentions`), AI 예측 도구는 미러에서 "찾을 수 없다" 로 끝났다.
|
|
421
|
+
*
|
|
422
|
+
* 이제 라이브 인스턴스도 **커널이다.** 같은 규칙(`ObservedReducer`)으로 이벤트를 접고, 주목 신호를
|
|
423
|
+
* 스스로 내고, 그 자리에서 `fork` 해 예측한다. 구동만 다르다 — sim 은 `tick`, live 는 `apply`.
|
|
424
|
+
*
|
|
222
425
|
* 실 이벤트원 = reference 어댑터 openLiveFeed → face2-adapter.ingest → CanonicalEnvelope → ingestLive().
|
|
223
426
|
*/
|
|
427
|
+
/**
|
|
428
|
+
* 보드가 실은 **생산 정의**(레시피·라우트·바인딩)를 꺼낸다 — 커널의 정의-구동 모드 입구.
|
|
429
|
+
*
|
|
430
|
+
* ── 없을 때 무엇이 일어났나 ──────────────────────────────────────────────
|
|
431
|
+
* 커널에는 정의-구동 MES 경로가 있는데 **호스트가 그것을 한 번도 넘기지 않았다.** 그래서 모든 MES
|
|
432
|
+
* 트윈이 **하드코딩된 레거시 흐름**으로 돌았다 — 현장 레시피를 아무리 정성껏 적어도 커널은 그것을
|
|
433
|
+
* 보지 못하고 토이 부품으로 토이 제품을 만들었다. 선언과 실행이 갈라져 있던 자리다.
|
|
434
|
+
*
|
|
435
|
+
* 보드에 없으면 `undefined` — 레거시 경로 그대로다(기존 트윈의 거동을 바꾸지 않는다).
|
|
436
|
+
*/
|
|
437
|
+
private static mesSpecOf(board: BoardDef | undefined): any {
|
|
438
|
+
return (board as any)?.mesSpec
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 현장(공간)의 **시각 기준**을 보드에 얹는다 — 커널이 교대의 `HH:MM` 을 읽을 기준.
|
|
443
|
+
*
|
|
444
|
+
* **테넌트가 아니라 공간이 권위다.** 한 테넌트가 Rosarito(태평양)와 한국 공장을 함께 가질 수 있고,
|
|
445
|
+
* 테넌트 단위(`Domain.timezone`)로 두면 둘 중 하나는 반드시 틀린다. 그래서 인스턴스가 묶인 공간의
|
|
446
|
+
* `timezone` 을 읽는다. 공간이 말하지 않으면 **테넌트로 내려가지 않는다** — 잘못된 입자로 답하는 것이
|
|
447
|
+
* 모르는 것보다 나쁘다(그때는 커널이 UTC 로 읽고, 그 기본값은 계약에 밝혀져 있다).
|
|
448
|
+
*
|
|
449
|
+
* 커널은 zero-dep 이라 시간대 데이터베이스를 갖지 않으므로 **분 오프셋**으로 풀어 넘긴다. 그 값은
|
|
450
|
+
* 지금 계절의 것이다(일광절약시간) — 계절을 넘는 긴 예측은 한 시간 어긋난다(계약에 명시).
|
|
451
|
+
*/
|
|
452
|
+
static async withSpaceTimeBase(board: BoardDef, domainId: string, spaceId?: string): Promise<BoardDef> {
|
|
453
|
+
const sid = spaceId ?? (board as any)?.spaceId
|
|
454
|
+
if (!sid) return board
|
|
455
|
+
const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: sid } }).catch(() => null)
|
|
456
|
+
const offset = utcOffsetOf(space?.timezone)
|
|
457
|
+
if (offset === undefined) {
|
|
458
|
+
if (space?.timezone) console.warn(`[twin-engine] space "${sid}" declares time zone "${space.timezone}" but it is not a known IANA zone — times will be read as UTC.`)
|
|
459
|
+
return board
|
|
460
|
+
}
|
|
461
|
+
return { ...board, utcOffsetMinutes: offset }
|
|
462
|
+
}
|
|
463
|
+
|
|
224
464
|
static startLive(id: string, domainId: string, kind: string, board: BoardDef): InstanceRuntime {
|
|
225
465
|
if (this.instances[id]) return this.instances[id]
|
|
226
|
-
const
|
|
227
|
-
const
|
|
466
|
+
const Kernel = KERNELS[kind] ?? WmsKernel
|
|
467
|
+
const kernel: any = new Kernel(domainId, undefined, this.mesSpecOf(board))
|
|
468
|
+
kernel.loadBoard(board)
|
|
469
|
+
this.applyOperations(kernel, board, id) // 명세는 라이브에도 실린다(예측 자격이 sim 과 같아진다)
|
|
470
|
+
/* `projector` 필드는 옛 이름으로 남긴다 — 소비처가 `snapshot()` 을 부르므로 얇은 어댑터로 잇는다.
|
|
471
|
+
* (P3 에서 소비처를 커널 어휘로 바꾸면 사라진다.) */
|
|
472
|
+
const projector = { apply: (e: CanonicalEnvelope) => kernel.apply(e), snapshot: () => kernel.getSnapshot() }
|
|
473
|
+
const inst: InstanceRuntime = { id, domainId, mode: 'live', realityMode: 'mirror', kernel, projector, oee: new OeeAccumulator(), unsub: () => {} }
|
|
474
|
+
/* 추정기(실측·거리)도 라이브에 붙인다 — 예측이 상수로 돌지 않게. 기동을 막지 않는다. */
|
|
475
|
+
this.installEstimators(kernel, domainId, id, board).catch(err => console.warn('[twin-engine] estimator install failed', err?.message))
|
|
228
476
|
inst.metrics = { ingestedTotal: 0, broadcastTotal: 0, journaledTotal: 0, ingestRate: 0, broadcastRate: 0, journalRate: 0, backlog: 0, _accIngest: 0, _accBroadcast: 0, _accJournal: 0, _windowStartMs: Date.now() }
|
|
229
477
|
this.instances[id] = inst
|
|
230
478
|
delete this.recovered[id]
|
|
@@ -312,20 +560,48 @@ export class TwinEngine {
|
|
|
312
560
|
if (!anyLive && this.broadcastTimer) { clearInterval(this.broadcastTimer); this.broadcastTimer = undefined } // live 없으면 tick 정지
|
|
313
561
|
}
|
|
314
562
|
|
|
563
|
+
/**
|
|
564
|
+
* 저널 행 한 줄 — **기록 경로가 둘이라(라이브 벌크·심 단건) 행 모양은 반드시 한 곳에서 만든다.**
|
|
565
|
+
* 두 곳에 각자 적으면 승격 검색 키가 한쪽에만 채워지고, 반쯤 빈 색인은 "저널에는 있는데
|
|
566
|
+
* 검색으로는 안 나오는 이벤트" 를 만든다 — 저널에서 가장 나쁜 종류의 결함이다.
|
|
567
|
+
*/
|
|
568
|
+
private static journalRow(repo: any, domainId: string, instanceId: string, e: any, revision: number, structureRev?: number) {
|
|
569
|
+
return repo.create({
|
|
570
|
+
domain: { id: domainId } as any,
|
|
571
|
+
instanceId,
|
|
572
|
+
tenantId: e?.tenantId,
|
|
573
|
+
eventType: e?.eventType,
|
|
574
|
+
revision,
|
|
575
|
+
/* **이 사실이 일어난 공장**을 함께 찍는다 — 이것이 없으면 나중에 구조가 바뀌었을 때 이 행을
|
|
576
|
+
새 공장에 대고 접게 되고, 그때 없던 설비에서 일이 있었던 것처럼 보인다. */
|
|
577
|
+
...(structureRev === undefined ? {} : { structureRev }),
|
|
578
|
+
eventTime: e?.eventTime,
|
|
579
|
+
...twinEventKeys(e),
|
|
580
|
+
payload: e
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* 이 트윈이 지금 어느 구조로 도는가 — 이벤트에 찍을 번호. 한 번 읽고 캐시한다(쓰기마다 조회 금지).
|
|
586
|
+
*
|
|
587
|
+
* 없으면 `undefined` 다 — 0 이 아니다. 구조 리비전이 생기기 전에 만들어진 트윈은 아직 리비전이
|
|
588
|
+
* 없고, 그 사실을 0 이라는 **유효해 보이는 번호**로 위장하면 안 된다.
|
|
589
|
+
*/
|
|
590
|
+
private static structureRevCache: Record<string, number> = {}
|
|
591
|
+
static async structureRevOf(domainId: string, instanceId: string): Promise<number | undefined> {
|
|
592
|
+
const key = `${domainId}:${instanceId}`
|
|
593
|
+
const cached = this.structureRevCache[key]
|
|
594
|
+
if (cached !== undefined) return cached
|
|
595
|
+
const latest = await getRepository(TwinStructure).findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } })
|
|
596
|
+
if (latest) this.structureRevCache[key] = latest.rev
|
|
597
|
+
return latest?.rev
|
|
598
|
+
}
|
|
599
|
+
|
|
315
600
|
/** live 저널 배치 기록 — 모아둔 CanonicalEnvelope 들에 startRevision+1.. 을 부여해 벌크 저장(coalescer tick 당 1회). */
|
|
316
601
|
static async persistBatch(domainId: string, instanceId: string, envelopes: any[], startRevision: number): Promise<void> {
|
|
317
602
|
const repo = getRepository(TwinEvent)
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
domain: { id: domainId } as any,
|
|
321
|
-
instanceId,
|
|
322
|
-
tenantId: e?.tenantId,
|
|
323
|
-
eventType: e?.eventType,
|
|
324
|
-
revision: startRevision + i + 1,
|
|
325
|
-
eventTime: e?.eventTime,
|
|
326
|
-
payload: e
|
|
327
|
-
})
|
|
328
|
-
)
|
|
603
|
+
const structureRev = await this.structureRevOf(domainId, instanceId)
|
|
604
|
+
const rows = envelopes.map((e, i) => this.journalRow(repo, domainId, instanceId, e, startRevision + i + 1, structureRev))
|
|
329
605
|
await repo.save(rows, { chunk: 500 })
|
|
330
606
|
}
|
|
331
607
|
|
|
@@ -358,26 +634,26 @@ export class TwinEngine {
|
|
|
358
634
|
}
|
|
359
635
|
|
|
360
636
|
/**
|
|
361
|
-
* 라이브 구조 변이(resource.add 등)를 저장된 board 에 반영 — 런타임 커널의 무버를 registry board.
|
|
637
|
+
* 라이브 구조 변이(resource.add 등)를 저장된 board 에 반영 — 런타임 커널의 무버를 registry board.equipment 에 동기.
|
|
362
638
|
* 이게 없으면 런타임엔 추가돼도(상태·저널엔 반영) 저장 board 는 원본이라 프로비저닝 편집기·재기동(loadBoard)이
|
|
363
|
-
* 추가분을 잃는다. 기존 board.
|
|
364
|
-
* 좌표(layout)만 다루는 register 와 달리
|
|
639
|
+
* 추가분을 잃는다. 기존 board.equipment 항목은 보존(homeLocation 유지)하고 새 id 만 append(추가 시점 location=homeLocation).
|
|
640
|
+
* 좌표(layout)만 다루는 register 와 달리 equipment 집합을 갱신하나, 재프로비전(purge)이 아니라 in-place 갱신이라
|
|
365
641
|
* 저널은 보존된다(추가는 이미 equipment 델타로 저널됨 → replay 는 id-keyed upsert 라 이중계산 없음).
|
|
366
642
|
*/
|
|
367
|
-
static async
|
|
643
|
+
static async syncBoardEquipment(domainId: string, instanceId: string): Promise<void> {
|
|
368
644
|
const inst = this.instances[instanceId]
|
|
369
645
|
const snap = inst?.kernel?.getSnapshot?.()
|
|
370
|
-
if (!snap?.
|
|
646
|
+
if (!snap?.equipment) return
|
|
371
647
|
const repo = getRepository(TwinInstance)
|
|
372
648
|
const reg = await repo.findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
373
649
|
if (!reg?.board) return
|
|
374
650
|
const board = reg.board as BoardDef
|
|
375
|
-
const known = new Set((board.
|
|
376
|
-
const added = snap.
|
|
651
|
+
const known = new Set((board.equipment ?? []).map(m => m.id))
|
|
652
|
+
const added = snap.equipment
|
|
377
653
|
.filter((m: any) => !known.has(m.id))
|
|
378
|
-
.map((m: any) => ({ id: m.id, kind: m.kind,
|
|
654
|
+
.map((m: any) => ({ id: m.id, kind: m.kind, homeLocation: m.location }))
|
|
379
655
|
if (!added.length) return
|
|
380
|
-
board.
|
|
656
|
+
board.equipment = [...(board.equipment ?? []), ...added]
|
|
381
657
|
reg.board = board
|
|
382
658
|
await repo.save(reg)
|
|
383
659
|
}
|
|
@@ -387,29 +663,133 @@ export class TwinEngine {
|
|
|
387
663
|
* 재프로비전 시 구조 시그니처가 바뀌면(노드·무버 집합/속성 변경) 기존 저널을 purge 한다(ADR-0015):
|
|
388
664
|
* board 는 replay 의 마스터라 구조가 바뀌면 과거 이벤트의 전제가 깨진다. 좌표(layout)만 바뀌면 저널 보존.
|
|
389
665
|
*/
|
|
390
|
-
static async provision(domainId: string, instanceId: string, kind: string, board: BoardDef): Promise<void> {
|
|
666
|
+
static async provision(domainId: string, instanceId: string, kind: string, board: BoardDef, comment?: string): Promise<void> {
|
|
391
667
|
if (this.instances[instanceId]) throw new Error(`instance "${instanceId}" is running — stop before re-provisioning`)
|
|
392
668
|
const repo = getRepository(TwinInstance)
|
|
393
669
|
const existing = await repo.findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
394
670
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
671
|
+
/*
|
|
672
|
+
* **구조가 바뀌어도 역사를 지우지 않는다.**
|
|
673
|
+
*
|
|
674
|
+
* 예전에는 서명이 다르면 그 트윈의 저널을 통째로 지웠다. 안 지우면 옛 이벤트를 새 공장에 대고
|
|
675
|
+
* 접게 되어 이력이 거짓말을 했기 때문이다 — 부스가 둘이던 시절의 사실을 여섯 개짜리 공장에
|
|
676
|
+
* 접으면 그때 없던 부스에서 일이 있었던 것처럼 보인다. 선택지가 **역사를 잃거나 거짓말을
|
|
677
|
+
* 하거나** 둘뿐이었고, 그래서 공장을 고칠 때마다 이력을 버려야 했다.
|
|
678
|
+
*
|
|
679
|
+
* 이제 셋째 길로 간다: 바뀐 구조를 **새 리비전**으로 남기고, 앞으로 쓰이는 이벤트가 그 번호를
|
|
680
|
+
* 달고 다닌다. 재생은 구조가 바뀌는 지점에서 갈아탄 뒤 이어 접는다(`replaySegments`).
|
|
681
|
+
*/
|
|
682
|
+
await this.recordStructure(domainId, instanceId, board, comment)
|
|
399
683
|
await this.register(domainId, instanceId, kind, board, existing?.status === 'running' ? 'stopped' : (existing?.status as any) ?? 'stopped')
|
|
400
684
|
}
|
|
401
685
|
|
|
686
|
+
/**
|
|
687
|
+
* 이 구조를 리비전으로 남기고 그 번호를 돌려준다 — **바뀌었을 때만** 새 번호가 생긴다.
|
|
688
|
+
*
|
|
689
|
+
* 프로비저닝은 부팅마다 다시 도는데, 그때마다 리비전이 늘면 이력이 뜻 없는 마디로 잘게 쪼개진다.
|
|
690
|
+
* 그래서 **구조 서명이 같으면 있던 리비전을 그대로 쓴다.**
|
|
691
|
+
*/
|
|
692
|
+
static async recordStructure(domainId: string, instanceId: string, board: BoardDef, comment?: string): Promise<number> {
|
|
693
|
+
const repo = getRepository(TwinStructure)
|
|
694
|
+
const signature = this.structureFingerprint(board)
|
|
695
|
+
const latest = await repo.findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } })
|
|
696
|
+
|
|
697
|
+
/*
|
|
698
|
+
* **저장된 지문이 아니라 저장된 공장과 비교한다.**
|
|
699
|
+
*
|
|
700
|
+
* 지문 계산 방식을 한 번이라도 바꾸면(원문 → 해시로 바꿨다) 저장된 문자열은 전부 옛 방식의
|
|
701
|
+
* 것이라 무엇과도 안 맞는다. 그러면 **아무것도 안 바뀐 트윈들이 전부 "구조가 바뀌었다"** 로
|
|
702
|
+
* 기록된다 — 실제로 그렇게 됐고, 재기동 한 번에 리비전이 통째로 하나씩 늘었다.
|
|
703
|
+
*
|
|
704
|
+
* 그래서 안 맞을 때 한 번 더 묻는다: 저장된 **보드**로 지문을 다시 계산하면 같은가? 같다면
|
|
705
|
+
* 공장은 그대로이고 지문 표기만 낡은 것이니, 리비전을 만들지 않고 표기만 고친다.
|
|
706
|
+
*/
|
|
707
|
+
if (latest && latest.signature !== signature && this.structureFingerprint(latest.board as BoardDef) === signature) {
|
|
708
|
+
latest.signature = signature
|
|
709
|
+
await repo.save(latest)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (latest?.signature === signature) {
|
|
713
|
+
this.structureRevCache[`${domainId}:${instanceId}`] = latest.rev
|
|
714
|
+
return latest.rev
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const rev = (latest?.rev ?? 0) + 1
|
|
718
|
+
await repo.save(repo.create({ domain: { id: domainId } as any, instanceId, rev, signature, board: board as any, ...(comment ? { comment } : {}) }))
|
|
719
|
+
this.structureRevCache[`${domainId}:${instanceId}`] = rev
|
|
720
|
+
/* 구조가 바뀌면 재구성 캐시는 옛 공장의 것이다 — 버린다(지우는 건 캐시뿐, 사실은 남는다). */
|
|
721
|
+
delete this.recovered[instanceId]
|
|
722
|
+
if (latest) console.info(`[twin-engine] "${instanceId}" structure changed → revision ${rev} (history kept; older events stay under revision ${latest.rev}).`)
|
|
723
|
+
return rev
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* 이 트윈이 거쳐 온 구조들 — **무엇이 언제 달라졌나.**
|
|
728
|
+
*
|
|
729
|
+
* 리비전은 그 시절 공장을 통째로 담지만(재생에 필요하다), 사람이 보고 싶은 것은 통째가 아니라
|
|
730
|
+
* 차이다. 보드는 빼고 **차이 요약만** 내보낸다 — 62KB 짜리 보드를 화면에 실어 보낼 이유가 없다.
|
|
731
|
+
*/
|
|
732
|
+
static async structureHistory(
|
|
733
|
+
domainId: string,
|
|
734
|
+
instanceId: string
|
|
735
|
+
): Promise<{ rev: number; createdAt?: Date; comment?: string; events: number; diff: StructureDiff }[]> {
|
|
736
|
+
const revs = await getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'ASC' } })
|
|
737
|
+
if (!revs.length) return []
|
|
738
|
+
|
|
739
|
+
/* 리비전마다 그 아래에서 몇 건이 일어났나 — 이력의 무게를 보여준다(짧게 스쳐간 구조인지). */
|
|
740
|
+
const counts = new Map<number | null, number>()
|
|
741
|
+
for (const row of await getRepository(TwinEvent)
|
|
742
|
+
.createQueryBuilder('e')
|
|
743
|
+
.select('e.structureRev', 'rev')
|
|
744
|
+
.addSelect('COUNT(1)', 'n')
|
|
745
|
+
.where('e.domain = :domainId', { domainId })
|
|
746
|
+
.andWhere('e.instanceId = :instanceId', { instanceId })
|
|
747
|
+
.groupBy('e.structureRev')
|
|
748
|
+
.getRawMany())
|
|
749
|
+
counts.set(row.rev === null || row.rev === undefined ? null : Number(row.rev), Number(row.n))
|
|
750
|
+
|
|
751
|
+
/* 번호 없는 옛 행은 **가장 오래된 리비전**의 몫이다 — 재생이 그렇게 접으므로 세는 것도 같아야 한다. */
|
|
752
|
+
const oldest = revs[0].rev
|
|
753
|
+
const legacy = counts.get(null) ?? 0
|
|
754
|
+
|
|
755
|
+
return revs.map((r, i) => ({
|
|
756
|
+
rev: r.rev,
|
|
757
|
+
createdAt: r.createdAt,
|
|
758
|
+
...(r.comment ? { comment: r.comment } : {}),
|
|
759
|
+
events: (counts.get(r.rev) ?? 0) + (r.rev === oldest ? legacy : 0),
|
|
760
|
+
diff: diffStructures(i === 0 ? undefined : (revs[i - 1].board as any), r.board as any)
|
|
761
|
+
}))
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** 지금 쓰이는 구조 리비전 — 이벤트에 찍을 번호. 아직 없으면 기록하며 만든다. */
|
|
765
|
+
static async currentStructureRev(domainId: string, instanceId: string, board?: BoardDef): Promise<number | undefined> {
|
|
766
|
+
const latest = await getRepository(TwinStructure).findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } })
|
|
767
|
+
if (latest) return latest.rev
|
|
768
|
+
return board ? this.recordStructure(domainId, instanceId, board) : undefined
|
|
769
|
+
}
|
|
770
|
+
|
|
402
771
|
/** 구조 동일성 지문 — 좌표(layout) 등 뷰 관심사는 제외하고 커널이 보는 위상·용량·무버만. */
|
|
772
|
+
/**
|
|
773
|
+
* 지문을 **64자로 줄인다** — 컬럼이 varchar(64) 다.
|
|
774
|
+
*
|
|
775
|
+
* 원문 지문은 자리·설비를 전부 이어 붙인 문자열이라 큰 공장에서는 수만 자가 된다. sqlite 는
|
|
776
|
+
* 길이를 강제하지 않아 그냥 들어가지만 **Postgres 는 거기서 터진다** — 개발에서는 멀쩡하고
|
|
777
|
+
* 운영에서만 죽는 종류의 실패다. 비교에만 쓰는 값이므로 해시로 충분하다.
|
|
778
|
+
*/
|
|
779
|
+
private static structureFingerprint(board: BoardDef): string {
|
|
780
|
+
return createHash('sha256').update(this.structureSignature(board)).digest('hex')
|
|
781
|
+
}
|
|
782
|
+
|
|
403
783
|
private static structureSignature(board: BoardDef): string {
|
|
404
|
-
const
|
|
784
|
+
const locations = [...(board.locations ?? [])]
|
|
405
785
|
.map(n => `${n.id}:${n.type}:${n.capacity}`)
|
|
406
786
|
.sort()
|
|
407
787
|
.join('|')
|
|
408
|
-
const
|
|
409
|
-
.map(m => `${m.id}:${m.kind}:${m.
|
|
788
|
+
const equipment = [...(board.equipment ?? [])]
|
|
789
|
+
.map(m => `${m.id}:${m.kind}:${m.homeLocation}`)
|
|
410
790
|
.sort()
|
|
411
791
|
.join('|')
|
|
412
|
-
return `N[${
|
|
792
|
+
return `N[${locations}]M[${equipment}]`
|
|
413
793
|
}
|
|
414
794
|
|
|
415
795
|
/** 레지스트리 board 로 기동(프로비전된 인스턴스 start). board 인자 없이 저장된 구조로 재기동. */
|
|
@@ -417,7 +797,47 @@ export class TwinEngine {
|
|
|
417
797
|
if (this.instances[instanceId]) return this.instances[instanceId]
|
|
418
798
|
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
419
799
|
if (!reg?.board) throw new Error(`instance "${instanceId}" not provisioned (no board)`)
|
|
420
|
-
|
|
800
|
+
|
|
801
|
+
/*
|
|
802
|
+
* 웜스타트 재료를 **여기서 확실히 확보한다.**
|
|
803
|
+
* `start()` 는 동기라 스스로 캐시를 읽을 수 없어서 `recovered` 에 미리 담겨 있기를 기대하는데,
|
|
804
|
+
* 그건 `bootstrap()` 이 먼저 돌았을 때만 참이다. 부팅 순서에 기대면 어떤 날은 재고가 살아나고
|
|
805
|
+
* 어떤 날은 조용히 빈 채로 뜬다 — 재현되지 않는 결함이 가장 나쁘다.
|
|
806
|
+
* 체크포인트 캐시 우선(O(1) + 라이브 파생상태 보존), 없으면 저널 replay 폴백(부팅과 같은 순서).
|
|
807
|
+
*/
|
|
808
|
+
if (!this.recovered[instanceId] && reg.purpose !== 'bench') {
|
|
809
|
+
const cached = await this.loadSnapshot(domainId, instanceId).catch(() => null)
|
|
810
|
+
if (cached?.state) {
|
|
811
|
+
this.recovered[instanceId] = { revision: cached.revision, state: cached.state }
|
|
812
|
+
} else {
|
|
813
|
+
const state = await this.recover(domainId, instanceId).catch(() => null)
|
|
814
|
+
if (state) this.recovered[instanceId] = { revision: state.revision, state }
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/*
|
|
819
|
+
* 저널에 남아 있는 마지막 번호 — **모드와 무관하게** 이것을 이어 센다.
|
|
820
|
+
*
|
|
821
|
+
* 저널을 초기화하고 기동하는 모드(sim-experiment)라면 이 값이 0이라 아무 영향이 없다. 규칙을
|
|
822
|
+
* 모드별로 가르지 않는 이유: "저널이 비어 있지 않으면 그 뒤부터" 하나면 어느 모드에서도
|
|
823
|
+
* 겹칠 수 없고, 모드가 늘어도 이 자리를 다시 손볼 일이 없다.
|
|
824
|
+
*/
|
|
825
|
+
const last = await getRepository(TwinEvent)
|
|
826
|
+
.createQueryBuilder('e')
|
|
827
|
+
.select('MAX(e.revision)', 'max')
|
|
828
|
+
.where('e.domain = :domainId', { domainId })
|
|
829
|
+
.andWhere('e.instanceId = :instanceId', { instanceId })
|
|
830
|
+
.getRawOne()
|
|
831
|
+
|
|
832
|
+
return this.start(
|
|
833
|
+
instanceId,
|
|
834
|
+
domainId,
|
|
835
|
+
reg.kind ?? 'wms',
|
|
836
|
+
reg.board as BoardDef,
|
|
837
|
+
realityMode ?? (reg.realityMode as RealityMode) ?? undefined,
|
|
838
|
+
reg.purpose ?? undefined,
|
|
839
|
+
Number(last?.max ?? 0) || 0
|
|
840
|
+
)
|
|
421
841
|
}
|
|
422
842
|
|
|
423
843
|
/**
|
|
@@ -437,11 +857,18 @@ export class TwinEngine {
|
|
|
437
857
|
return this.startLive(instanceId, domainId, reg.kind ?? 'wms', reg.board as BoardDef)
|
|
438
858
|
}
|
|
439
859
|
if (mode === 'sim-world') {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
860
|
+
/*
|
|
861
|
+
* **이어지는 현실** — 저널을 지우지 않는다.
|
|
862
|
+
*
|
|
863
|
+
* 예전에는 여기서 저널을 지우고 fresh 로 띄웠다. resume 기계장치가 없다고 봤기 때문인데,
|
|
864
|
+
* 실제로 없던 것은 **번호 이어 세기 하나**였다: 상태 복원은 이미 `startFromRegistry` 가
|
|
865
|
+
* 저널 재생(또는 체크포인트)으로 하고 있었고, 막힌 것은 커널이 리비전을 0부터 다시 세어
|
|
866
|
+
* 기존 행과 겹치는 문제였다. 그 하나를 `resumeRevision` 으로 풀었으므로 이제 이어 간다.
|
|
867
|
+
*
|
|
868
|
+
* 아직 이어지지 않는 것(정직하게): 시나리오 생성기의 난수 흐름은 재기동에 이어지지 않는다 —
|
|
869
|
+
* 같은 씨앗에서 다시 시작하므로 자극의 패턴이 재기동 지점에서 한 번 끊긴다. 쌓인 사실과
|
|
870
|
+
* 상태는 이어지고, 앞으로 일어날 일의 무작위 순서만 새로 시작한다.
|
|
871
|
+
*/
|
|
445
872
|
return this.startFromRegistry(domainId, instanceId, 'sim-world')
|
|
446
873
|
}
|
|
447
874
|
// sim-experiment(기본): seed 재현 — 저널 초기화 후 revision 0 부터 재실행.
|
|
@@ -472,7 +899,7 @@ export class TwinEngine {
|
|
|
472
899
|
const rows = await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } })
|
|
473
900
|
const out: any[] = []
|
|
474
901
|
for (const r of rows) {
|
|
475
|
-
const board = (r.board as BoardDef) ?? {
|
|
902
|
+
const board = (r.board as BoardDef) ?? { locations: [], equipment: [] }
|
|
476
903
|
const last = await getRepository(TwinEvent).findOne({
|
|
477
904
|
where: { domain: { id: domainId } as any, instanceId: r.instanceId },
|
|
478
905
|
order: { revision: 'DESC' }
|
|
@@ -487,8 +914,8 @@ export class TwinEngine {
|
|
|
487
914
|
copyOf: r.copyOf ?? undefined,
|
|
488
915
|
running: !!this.instances[r.instanceId],
|
|
489
916
|
revision: last?.revision ?? 0,
|
|
490
|
-
|
|
491
|
-
|
|
917
|
+
locationCount: board.locations?.length ?? 0,
|
|
918
|
+
equipmentCount: board.equipment?.length ?? 0
|
|
492
919
|
})
|
|
493
920
|
}
|
|
494
921
|
return out
|
|
@@ -500,17 +927,17 @@ export class TwinEngine {
|
|
|
500
927
|
}
|
|
501
928
|
|
|
502
929
|
/** 운영 공간 목록(벤치 소스 선택기) — 이름·규모 포함, 벤치 사본·잔재 제외. */
|
|
503
|
-
static async listSpaces(domainId: string): Promise<{ spaceId: string; name: string; instances: number;
|
|
930
|
+
static async listSpaces(domainId: string): Promise<{ spaceId: string; name: string; instances: number; locations: number; equipment: number }[]> {
|
|
504
931
|
const nameOf = new Map((await getRepository(TwinSpace).find({ where: { domain: { id: domainId } } })).map(s => [s.spaceId, s.name]))
|
|
505
932
|
const insts = await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } })
|
|
506
|
-
const agg = new Map<string, { spaceId: string; name: string; instances: number;
|
|
933
|
+
const agg = new Map<string, { spaceId: string; name: string; instances: number; locations: number; equipment: number }>()
|
|
507
934
|
for (const r of insts) {
|
|
508
935
|
if (!r.spaceId || r.purpose === 'bench' || this.isBenchSpace(r.spaceId)) continue // 벤치·잔재 제외
|
|
509
936
|
const board: any = r.board ?? {}
|
|
510
|
-
const a = agg.get(r.spaceId) ?? { spaceId: r.spaceId, name: nameOf.get(r.spaceId) ?? r.spaceId, instances: 0,
|
|
937
|
+
const a = agg.get(r.spaceId) ?? { spaceId: r.spaceId, name: nameOf.get(r.spaceId) ?? r.spaceId, instances: 0, locations: 0, equipment: 0 }
|
|
511
938
|
a.instances++
|
|
512
|
-
a.
|
|
513
|
-
a.
|
|
939
|
+
a.locations += board.locations?.length ?? 0
|
|
940
|
+
a.equipment += board.equipment?.length ?? 0
|
|
514
941
|
agg.set(r.spaceId, a)
|
|
515
942
|
}
|
|
516
943
|
return [...agg.values()]
|
|
@@ -578,7 +1005,17 @@ export class TwinEngine {
|
|
|
578
1005
|
// areas 는 content 에 더 이상 쓰지 않는다(area 단일화 P3) — 권위=TwinArea. 아래 TwinArea 로만 기록.
|
|
579
1006
|
landmarks: unionById(prev.landmarks, cur.landmarks)
|
|
580
1007
|
}
|
|
581
|
-
|
|
1008
|
+
/*
|
|
1009
|
+
* **현장의 시각 기준을 공간에 남긴다** — 그러지 않으면 공간이 권위인데 그 값을 영원히 모른다.
|
|
1010
|
+
*
|
|
1011
|
+
* 마스터가 보드에 오프셋을 실어 주므로 갓 프로비저닝한 트윈은 맞게 돌지만, `withSpaceTimeBase` 가
|
|
1012
|
+
* 읽는 곳은 **공간**이다. 여기서 옮기지 않으면 공간의 시간대가 계속 비어 있고, 사용자가 화면에서
|
|
1013
|
+
* 그것을 보거나 고칠 수도 없다(만들고 잇지 않으면 없는 것이다).
|
|
1014
|
+
*
|
|
1015
|
+
* **사용자가 고친 값을 마스터가 덮지 않는다** — 이미 있으면 그대로 둔다(현장이 정본을 이긴다).
|
|
1016
|
+
*/
|
|
1017
|
+
const timezone = existing?.timezone || master.space.timezone
|
|
1018
|
+
const savedSpace = await repo.save(repo.create({ ...(existing ?? {}), domain: { id: domainId } as any, spaceId, name: existing?.name || master.siteName, ...(timezone ? { timezone } : {}), content: mergedContent }))
|
|
582
1019
|
/*
|
|
583
1020
|
* 표현을 twin_space_representations 테이블에 저장한다 — 뷰(twin-map-page 등)는 content 가 아니라 테이블 표현을 소싱하므로,
|
|
584
1021
|
* 저장하지 않으면 인제스트 사이트에 "대표 표현" 이 없어 지도로 시작하지 못한다. rep.areas 가 있으면 TwinSpaceArea 도 함께 저장.
|
|
@@ -655,7 +1092,7 @@ export class TwinEngine {
|
|
|
655
1092
|
const st: any =
|
|
656
1093
|
inst.mode === 'live'
|
|
657
1094
|
? inst.oee
|
|
658
|
-
? withLiveOee(inst.projector?.snapshot?.(), inst.oee) // 관측
|
|
1095
|
+
? withLiveOee(inst.projector?.snapshot?.(), inst.oee) // 관측 커널 + 계산(OEE) → equipment payload 에 oee 포함(sim 동형)
|
|
659
1096
|
: inst.projector?.snapshot?.()
|
|
660
1097
|
: inst.runtime?.resync?.()?.state
|
|
661
1098
|
if (!st) return
|
|
@@ -681,18 +1118,7 @@ export class TwinEngine {
|
|
|
681
1118
|
|
|
682
1119
|
static async persist(domainId: string, instanceId: string, msg: any): Promise<void> {
|
|
683
1120
|
const repo = getRepository(TwinEvent)
|
|
684
|
-
|
|
685
|
-
await repo.save(
|
|
686
|
-
repo.create({
|
|
687
|
-
domain: { id: domainId } as any,
|
|
688
|
-
instanceId,
|
|
689
|
-
tenantId: e?.tenantId,
|
|
690
|
-
eventType: e?.eventType,
|
|
691
|
-
revision: msg.revision,
|
|
692
|
-
eventTime: e?.eventTime,
|
|
693
|
-
payload: e
|
|
694
|
-
})
|
|
695
|
-
)
|
|
1121
|
+
await repo.save(this.journalRow(repo, domainId, instanceId, msg.event, msg.revision, await this.structureRevOf(domainId, instanceId)))
|
|
696
1122
|
}
|
|
697
1123
|
|
|
698
1124
|
/**
|
|
@@ -721,18 +1147,65 @@ export class TwinEngine {
|
|
|
721
1147
|
order: { revision: 'ASC' }
|
|
722
1148
|
})
|
|
723
1149
|
const cutoffMs = untilTime != null ? Date.parse(untilTime) : NaN
|
|
724
|
-
const
|
|
725
|
-
.
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1150
|
+
const wanted = rows.filter(r => {
|
|
1151
|
+
if (untilTime != null && !Number.isNaN(cutoffMs)) {
|
|
1152
|
+
const t = r.eventTime != null ? Date.parse(String(r.eventTime)) : NaN
|
|
1153
|
+
return Number.isNaN(t) ? true : t <= cutoffMs // eventTime 없는 레거시 행은 포함(보수적)
|
|
1154
|
+
}
|
|
1155
|
+
return untilRevision == null || (r.revision ?? 0) <= untilRevision
|
|
1156
|
+
})
|
|
1157
|
+
|
|
1158
|
+
/*
|
|
1159
|
+
* **그때의 공장으로 접는다.**
|
|
1160
|
+
*
|
|
1161
|
+
* 예전에는 모든 이벤트를 지금 등록된 보드 하나로 접었다. 구조가 바뀐 적 없으면 맞지만, 바뀐
|
|
1162
|
+
* 뒤에는 옛 사실을 새 공장에 대고 접게 되어 — 그때 없던 설비에서 일이 있었던 것처럼 보인다.
|
|
1163
|
+
* (그래서 예전 프로비저닝은 구조가 바뀌면 저널을 아예 지웠다. 역사를 잃거나 거짓말을 하거나.)
|
|
1164
|
+
*
|
|
1165
|
+
* 이제 이벤트가 자기 구조를 달고 오므로, 리비전이 바뀌는 지점에서 구조를 갈아타며 이어 접는다.
|
|
1166
|
+
* 리비전이 하나뿐이거나(대다수) 아예 없으면(구조 이력 이전) 예전과 똑같이 한 번에 접는다.
|
|
1167
|
+
*/
|
|
1168
|
+
const structures = await getRepository(TwinStructure).find({
|
|
1169
|
+
where: { domain: { id: domainId }, instanceId },
|
|
1170
|
+
order: { rev: 'ASC' }
|
|
1171
|
+
})
|
|
1172
|
+
const boardOf = new Map<number, BoardDef>(structures.map(x => [x.rev, x.board as BoardDef]))
|
|
1173
|
+
/* 컬럼이 생기기 전 행은 리비전이 비어 있다 — **가장 오래된 구조**에 속한다(0 으로 채우지 않는다). */
|
|
1174
|
+
const oldest = structures[0]?.rev
|
|
1175
|
+
const revOf = (r: { structureRev?: number }): number | undefined => r.structureRev ?? oldest
|
|
734
1176
|
|
|
735
|
-
|
|
1177
|
+
const segments: { board: BoardDef; events: any[] }[] = []
|
|
1178
|
+
for (const r of wanted) {
|
|
1179
|
+
if (!r.payload) continue
|
|
1180
|
+
const rev = revOf(r)
|
|
1181
|
+
const board = (rev !== undefined && boardOf.get(rev)) || (reg.board as BoardDef)
|
|
1182
|
+
const last = segments[segments.length - 1]
|
|
1183
|
+
if (last && last.board === board) last.events.push(r.payload)
|
|
1184
|
+
else segments.push({ board, events: [r.payload] })
|
|
1185
|
+
}
|
|
1186
|
+
/*
|
|
1187
|
+
* **가장 새 구조가 지금의 공장이다** — 그 아래에서 아직 아무 일도 없었더라도.
|
|
1188
|
+
*
|
|
1189
|
+
* 재프로비저닝 직후가 정확히 그 상태다: 부스를 넷 늘렸는데 새 이벤트는 아직 하나도 없다. 이때
|
|
1190
|
+
* 마지막 이벤트의 구조로만 접으면 화면은 **옛 공장**을 보여준다 — 방금 늘린 것이 안 보인다.
|
|
1191
|
+
* 그래서 마디의 끝에 지금 구조를 한 번 더 얹는다(이벤트 없는 마디).
|
|
1192
|
+
*
|
|
1193
|
+
* **다만 "지금" 을 물었을 때만이다.** 과거 시점을 물었는데 최신 구조를 얹으면, 그 시점에 없던
|
|
1194
|
+
* 설비가 화면에 서고 — 이 작업이 막으려던 바로 그 거짓말이 된다.
|
|
1195
|
+
*/
|
|
1196
|
+
const asOfNow = untilRevision == null && untilTime == null
|
|
1197
|
+
const newest = structures[structures.length - 1]?.board as BoardDef | undefined
|
|
1198
|
+
if (asOfNow && newest && segments[segments.length - 1]?.board !== newest) segments.push({ board: newest, events: [] })
|
|
1199
|
+
|
|
1200
|
+
if (!segments.length) return replay(((asOfNow && newest) || reg.board) as BoardDef, [])
|
|
1201
|
+
if (segments.length === 1) return replay(segments[0].board, segments[0].events)
|
|
1202
|
+
|
|
1203
|
+
const { state, shifts } = replaySegments(segments)
|
|
1204
|
+
/* 경계에서 사라진 것을 조용히 넘기지 않는다 — 수가 줄어든 이유를 어딘가에는 남겨야 한다. */
|
|
1205
|
+
for (const sh of shifts)
|
|
1206
|
+
if (sh.equipmentDropped || sh.locationsDropped || sh.personsDropped || sh.assetsDropped)
|
|
1207
|
+
console.info(`[twin-engine] "${instanceId}" replay crossed a structure change — dropped ${sh.equipmentDropped} equipment, ${sh.locationsDropped} locations, ${sh.personsDropped} persons, ${sh.assetsDropped} assets that no longer exist.`)
|
|
1208
|
+
return state
|
|
736
1209
|
}
|
|
737
1210
|
|
|
738
1211
|
/**
|
|
@@ -791,6 +1264,21 @@ export class TwinEngine {
|
|
|
791
1264
|
return this.instances[id]?.domainId === domainId
|
|
792
1265
|
}
|
|
793
1266
|
|
|
1267
|
+
/**
|
|
1268
|
+
* 이 트윈이 이 테넌트 것인가 — **떠 있든 아니든.**
|
|
1269
|
+
*
|
|
1270
|
+
* `owns()` 는 **떠 있는** 런타임만 안다. 그것을 이력 조회의 관문으로 쓰면, 꺼진 트윈의 저널을
|
|
1271
|
+
* 읽으려 할 때 "이 테넌트에 없다" 는 답이 돌아온다 — 두 가지가 틀렸다. 첫째, 저널은 트윈이 꺼져
|
|
1272
|
+
* 있을 때 **가장 필요한 것**이다(그게 이력의 존재 이유다). 둘째, 그 문장은 남의 것이라는 뜻이라
|
|
1273
|
+
* 사용자가 권한 문제로 오해한다. 실제로는 그냥 안 돌고 있을 뿐이다.
|
|
1274
|
+
*
|
|
1275
|
+
* 소유는 **등록부**가 안다. 이력·집계처럼 런타임과 무관한 질문은 이쪽에 묻는다.
|
|
1276
|
+
*/
|
|
1277
|
+
static async ownsRegistered(domainId: string, instanceId: string): Promise<boolean> {
|
|
1278
|
+
if (this.owns(domainId, instanceId)) return true
|
|
1279
|
+
return !!(await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }))
|
|
1280
|
+
}
|
|
1281
|
+
|
|
794
1282
|
/** 라이브 커널(ForecastTwin) — forecast/divergence 예측 연산용. */
|
|
795
1283
|
static kernel(id: string): any {
|
|
796
1284
|
return this.instances[id]?.kernel
|
|
@@ -827,26 +1315,15 @@ export class TwinEngine {
|
|
|
827
1315
|
return rows
|
|
828
1316
|
}
|
|
829
1317
|
|
|
830
|
-
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
1318
|
+
/*
|
|
1319
|
+
* `buildForecastKernel` 은 **사라졌다**(통합 P3, 2026-08-01).
|
|
1320
|
+
*
|
|
1321
|
+
* 라이브가 `StateProjector` 였을 때는 커널이 없어서, 예측하려면 관측 스냅샷 + 저널의 오더 원값으로
|
|
1322
|
+
* **일회용 커널을 세워야** 했다. 이제 라이브 인스턴스가 곧 커널이므로(`startLive`) 그 자리에서
|
|
1323
|
+
* `fork` 하면 된다 — `TwinEngine.kernel(id)` 가 sim·live 둘 다 돌려준다.
|
|
1324
|
+
*
|
|
1325
|
+
* (오더 원값을 저널에서 되찾던 이유도 사라졌다 — 상태가 requested·fulfilled·lines 를 지킨다.)
|
|
834
1326
|
*/
|
|
835
|
-
static async buildForecastKernel(domainId: string, instanceId: string): Promise<any | null> {
|
|
836
|
-
const inst = this.instances[instanceId]
|
|
837
|
-
if (inst?.mode !== 'live' || !inst.projector) return null
|
|
838
|
-
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
839
|
-
if (!reg?.board) return null
|
|
840
|
-
const Kernel = KERNELS[reg.kind ?? 'wms'] ?? WmsKernel
|
|
841
|
-
const k = new Kernel(domainId)
|
|
842
|
-
k.loadBoard(reg.board as BoardDef)
|
|
843
|
-
// 저널에서 오더별 최신 관측(원값+라인) 수집 — projector 스냅샷은 오더에 손실적(progress 만)이라 저널을 쓴다.
|
|
844
|
-
const rows = await getRepository(TwinEvent).find({ where: { domain: { id: domainId }, instanceId, eventType: OP_EVENT.order }, order: { revision: 'ASC' } })
|
|
845
|
-
const latest = new Map<string, any>()
|
|
846
|
-
for (const r of rows) { const d = (r.payload as any)?.data; if (d?.orderId) latest.set(d.orderId, d) }
|
|
847
|
-
k.hydrateObserved(this.snapshot(instanceId), [...latest.values()])
|
|
848
|
-
return k
|
|
849
|
-
}
|
|
850
1327
|
|
|
851
1328
|
/**
|
|
852
1329
|
* 예측용 커널을 **임의 시각 T 기준**으로 재구성 — 과거-vantage 예측(백테스트)·"그때 서서 본 미래".
|
|
@@ -857,8 +1334,10 @@ export class TwinEngine {
|
|
|
857
1334
|
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
858
1335
|
if (!reg?.board) return null
|
|
859
1336
|
const Kernel = KERNELS[reg.kind ?? 'wms'] ?? WmsKernel
|
|
860
|
-
const k = new Kernel(domainId)
|
|
1337
|
+
const k = new Kernel(domainId, undefined, this.mesSpecOf(reg.board as BoardDef))
|
|
861
1338
|
k.loadBoard(reg.board as BoardDef)
|
|
1339
|
+
this.applyOperations(k, reg.board, instanceId) // 예측도 같은 명세로 굴러야 한다(화면과 다른 숫자 금지)
|
|
1340
|
+
await this.installEstimators(k, domainId, instanceId, reg.board) // 예측은 기다린다 — 실측을 놓치면 예측이 상수로 돈다
|
|
862
1341
|
const state = await this.recover(domainId, instanceId, undefined, untilTime).catch(() => null)
|
|
863
1342
|
if (!state) return null
|
|
864
1343
|
const cutoff = untilTime != null ? Date.parse(untilTime) : Infinity
|
|
@@ -877,9 +1356,11 @@ export class TwinEngine {
|
|
|
877
1356
|
static snapshot(id: string): any {
|
|
878
1357
|
const inst = this.instances[id]
|
|
879
1358
|
if (inst?.mode === 'live' && inst.projector) {
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1359
|
+
/* live: **커널이 주목 신호를 스스로 낸다**(관측 모드) — 호스트가 덧붙이던 withLiveAttentions 는
|
|
1360
|
+
* 필요 없다. OEE 만 호스트가 채운다: 원 시스템이 시간 누적을 보내 주지 않아 상태 전이를 적분해
|
|
1361
|
+
* 만드는 값이라 커널이 대신할 수 없다(적합성 하네스의 유일한 예외). */
|
|
1362
|
+
const st = inst.projector.snapshot()
|
|
1363
|
+
return inst.oee ? withLiveOee(st, inst.oee) : st
|
|
883
1364
|
}
|
|
884
1365
|
return inst?.runtime?.resync() ?? this.recovered[id]
|
|
885
1366
|
}
|