@things-factory/headless-twin 10.0.10 → 10.0.12
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/dist-server/engine/canonical-ingest.d.ts +25 -2
- package/dist-server/engine/canonical-ingest.js +48 -10
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/declared-stimulus.d.ts +43 -0
- package/dist-server/engine/declared-stimulus.js +57 -0
- package/dist-server/engine/declared-stimulus.js.map +1 -0
- package/dist-server/engine/index.d.ts +2 -0
- package/dist-server/engine/index.js +2 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +12 -0
- package/dist-server/engine/kpi-fold.js +21 -1
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +3 -3
- package/dist-server/engine/kpi-query.js +4 -4
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/live-feed-registry.d.ts +1 -1
- package/dist-server/engine/live-feed-registry.js +1 -1
- package/dist-server/engine/live-feed-registry.js.map +1 -1
- package/dist-server/engine/local-declarations.d.ts +3 -6
- package/dist-server/engine/local-declarations.js +97 -16
- package/dist-server/engine/local-declarations.js.map +1 -1
- package/dist-server/engine/measured-yield.d.ts +42 -0
- package/dist-server/engine/measured-yield.js +75 -0
- package/dist-server/engine/measured-yield.js.map +1 -0
- package/dist-server/engine/operation-basis.d.ts +16 -0
- package/dist-server/engine/operation-basis.js +20 -2
- package/dist-server/engine/operation-basis.js.map +1 -1
- package/dist-server/engine/property-effects.js +17 -0
- package/dist-server/engine/property-effects.js.map +1 -1
- package/dist-server/engine/restart-policy.d.ts +13 -0
- package/dist-server/engine/restart-policy.js +52 -0
- package/dist-server/engine/restart-policy.js.map +1 -0
- package/dist-server/engine/spec-coverage.d.ts +8 -0
- package/dist-server/engine/spec-coverage.js +3 -1
- package/dist-server/engine/spec-coverage.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +48 -15
- package/dist-server/engine/twin-engine.js +179 -39
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/index.js +1 -1
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/reference/control-routing.d.ts +14 -0
- package/dist-server/service/reference/control-routing.js +64 -0
- package/dist-server/service/reference/control-routing.js.map +1 -0
- package/dist-server/service/reference/index.d.ts +1 -0
- package/dist-server/service/reference/index.js +2 -0
- package/dist-server/service/reference/index.js.map +1 -1
- package/dist-server/service/reference/reference-adapter.d.ts +67 -0
- package/dist-server/service/reference/reference-adapter.js +18 -0
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +3 -3
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.d.ts +3 -3
- package/dist-server/service/reference/reference-resolver.js +35 -18
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-audit/command-audit.d.ts +34 -0
- package/dist-server/service/twin-audit/command-audit.js +15 -1
- package/dist-server/service/twin-audit/command-audit.js.map +1 -1
- package/dist-server/service/twin-audit/twin-audit-event.d.ts +3 -0
- package/dist-server/service/twin-audit/twin-audit-event.js +28 -2
- package/dist-server/service/twin-audit/twin-audit-event.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.d.ts +15 -2
- package/dist-server/service/twin-control/twin-control-mutation.js +85 -34
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.js +1 -1
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-instance/twin-instance.d.ts +1 -1
- package/dist-server/service/twin-instance/twin-instance.js +2 -2
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +2 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-model/twin-equipment.js +1 -1
- package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
- package/dist-server/service/twin-model/twin-location.js +1 -1
- package/dist-server/service/twin-model/twin-location.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +1 -1
- package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-operation.js +1 -1
- package/dist-server/service/twin-model/twin-operation.js.map +1 -1
- package/package.json +3 -3
- package/server/engine/canonical-ingest.ts +78 -14
- package/server/engine/declared-stimulus.ts +66 -0
- package/server/engine/index.ts +2 -0
- package/server/engine/kpi-fold.ts +29 -1
- package/server/engine/kpi-query.ts +8 -8
- package/server/engine/live-feed-registry.ts +2 -2
- package/server/engine/local-declarations.ts +97 -19
- package/server/engine/measured-yield.ts +89 -0
- package/server/engine/operation-basis.ts +33 -2
- package/server/engine/property-effects.ts +17 -0
- package/server/engine/restart-policy.ts +55 -0
- package/server/engine/spec-coverage.ts +23 -3
- package/server/engine/twin-engine.ts +201 -42
- package/server/index.ts +1 -1
- package/server/service/reference/control-routing.ts +62 -0
- package/server/service/reference/index.ts +1 -0
- package/server/service/reference/reference-adapter.ts +78 -0
- package/server/service/reference/reference-live.ts +3 -3
- package/server/service/reference/reference-resolver.ts +46 -7
- package/server/service/twin-audit/command-audit.ts +34 -1
- package/server/service/twin-audit/twin-audit-event.ts +50 -4
- package/server/service/twin-control/twin-control-mutation.ts +75 -28
- package/server/service/twin-event/twin-event.ts +10 -2
- package/server/service/twin-instance/twin-instance.ts +16 -10
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +2 -1
- package/server/service/twin-model/twin-equipment.ts +8 -1
- package/server/service/twin-model/twin-location.ts +8 -1
- package/server/service/twin-model/twin-model-query.ts +1 -1
- package/server/service/twin-model/twin-operation.ts +8 -1
- package/test/adopt-structure-live.test.ts +7 -7
- package/test/boot-resume.test.ts +3 -3
- package/test/column-type-portability.test.ts +122 -0
- package/test/control-capability.test.ts +103 -0
- package/test/declared-stimulus.test.ts +88 -0
- package/test/ingest-running-guard.test.ts +5 -5
- package/test/instance-cache-lifecycle.test.ts +1 -1
- package/test/kpi-baseline-db.test.ts +1 -1
- package/test/kpi-query-bench.test.ts +1 -1
- package/test/lineage-survives-restart.test.ts +3 -3
- package/test/live-feed-registry.test.ts +6 -6
- package/test/local-declarations.test.ts +108 -1
- package/test/measured-yield.test.ts +90 -0
- package/test/operation-basis.test.ts +28 -1
- package/test/operational-vocabulary.test.ts +108 -0
- package/test/operations-capability-db.test.ts +4 -4
- package/test/project-structure-db.test.ts +1 -1
- package/test/projection-reaches-screen.test.ts +1 -1
- package/test/property-effects.test.ts +28 -0
- package/test/restart-policy.test.ts +111 -0
- package/test/resync-origin-site.test.ts +7 -1
- package/test/source-outcome-audit.test.ts +104 -0
- package/test/structure-revision-db.test.ts +27 -27
- package/test/tenant-registry-db.test.ts +2 -2
- package/test/twin-model-item-db.test.ts +3 -3
- package/test/twin-model-tree-db.test.ts +7 -7
- package/test/twin-origin-resync.test.ts +4 -4
- package/test/yield-loop.test.ts +144 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **자극은 원본이 선언한다** — 트윈이 아니라.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 났나 (2026-08-19) ────────────────────────────────────────────────
|
|
5
|
+
* 데모의 자극(시나리오)은 시드 코드가 **메모리에만** 실었다: `runtime.scenario.load(...)`. 그래서 트윈을
|
|
6
|
+
* 재기동하면 자극이 사라지고, 구조만 서 있는 트윈이 남는다 — 작업 0·오더 0. 「살아 있는 데모」가
|
|
7
|
+
* **첫 재기동까지만** 사는 것이다(오늘 서버를 여러 번 띄우는 동안 실제로 그 상태였다).
|
|
8
|
+
*
|
|
9
|
+
* ── 왜 트윈에 저장하지 않나 (ADR-0029 · plans/simulator-as-source.md §4) ────
|
|
10
|
+
* 자극은 **원본의 거동**이다: 무엇이 들어오고 무슨 주문이 나는지는 시뮬레이터(원본)가 정하는 사실이고,
|
|
11
|
+
* 트윈은 그것을 반영한다. 트윈에 새 축을 만들면 ADR-0029 가 나중에 걷어내야 하는 표면이 하나 늘고
|
|
12
|
+
* (그 계획서의 §2·§5 가 이미 그 부담을 적어 두었다), 원본에 두면 그 계획의 **첫 조각 앞자리**가 된다:
|
|
13
|
+
* 지금은 호스트가 읽어 커널에 싣고, 나중에 `openLiveFeed` 뒤로 옮기면 같은 선언이 그대로 쓰인다.
|
|
14
|
+
*
|
|
15
|
+
* 그래서 집은 `TwinReference.connectionConfig.scenario` — 어댑터별 설정 자리(이미 `simple-json`)다.
|
|
16
|
+
*
|
|
17
|
+
* ── 판정을 순수 함수로 (이 파일) ────────────────────────────────────────────
|
|
18
|
+
* 「이 트윈에 저장된 자극을 실을 것인가」에는 사람이 틀리기 쉬운 갈림이 셋 있다: 미러에 실으면 **현실을
|
|
19
|
+
* 오염**시키고, 잘못된 선언을 실으면 **다음 틱에서 서버가 내려가고**(실제로 그 이력이 있다), 시나리오
|
|
20
|
+
* 엔진이 없는 런타임에 실으면 조용히 아무 일도 없다. 그래서 판정을 값으로 만들고 시험한다.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 자극을 실을지 말지 — 그리고 왜. 부르는 쪽은 이 답만 보고 움직인다. */
|
|
24
|
+
export type StimulusPlan =
|
|
25
|
+
/** 실어라 — 검사를 통과한 선언. */
|
|
26
|
+
| { action: 'load'; scenario: any }
|
|
27
|
+
/**
|
|
28
|
+
* 싣지 않는다 — 그 이유.
|
|
29
|
+
* · `none` — 원본이 자극을 선언하지 않았다(정상: 정적 구조만 반영하는 트윈)
|
|
30
|
+
* · `mirror` — 미러다. 외부 실물이 진실인 트윈에 우리가 자극을 만들면 그것은 현실이 아니다
|
|
31
|
+
* · `no-engine` — 이 런타임에 시나리오 엔진이 없다(관측 구동 커널)
|
|
32
|
+
* · `invalid` — 선언이 커널 규약을 어긴다. **싣지 않고 말한다**(다음 틱에서 터지게 두지 않는다)
|
|
33
|
+
*/
|
|
34
|
+
| { action: 'skip'; reason: 'none' | 'observed' | 'no-engine' | 'invalid'; detail?: string }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 저장된 자극 선언을 읽어 **실을지 판정한다** — 커널 검사기를 그대로 쓴다(규칙을 두 벌로 만들지 않는다).
|
|
38
|
+
*
|
|
39
|
+
* `validate` 는 커널의 `validateScenario` 를 넘긴다(호스트가 커널 타입에 묶이지 않게 함수로 받는다).
|
|
40
|
+
* 검사기가 없으면 **싣지 않는다**: 검사할 수 없는 선언을 태우는 것이 가장 비싼 선택이다(서버가 내려간
|
|
41
|
+
* 이력이 있다).
|
|
42
|
+
*/
|
|
43
|
+
export function planStimulus(
|
|
44
|
+
config: any,
|
|
45
|
+
runtime: { hasScenarioEngine: boolean; mode?: 'sim' | 'live' },
|
|
46
|
+
validate?: (scenario: any) => { ok: boolean; errorCode?: string; errorParams?: any } | { ok: false; errorCode: string }
|
|
47
|
+
): StimulusPlan {
|
|
48
|
+
const scenario = config?.scenario
|
|
49
|
+
if (!scenario || typeof scenario !== 'object') return { action: 'skip', reason: 'none' }
|
|
50
|
+
/* 관측 트윈을 먼저 막는다 — 「엔진이 없다」로 답하면 이유가 흐려진다(애초에 실을 수 없는 트윈이다). */
|
|
51
|
+
if (runtime.mode === 'live') return { action: 'skip', reason: 'observed' }
|
|
52
|
+
if (!runtime.hasScenarioEngine) return { action: 'skip', reason: 'no-engine' }
|
|
53
|
+
if (!validate) return { action: 'skip', reason: 'invalid', detail: 'no validator available' }
|
|
54
|
+
const v = validate(scenario)
|
|
55
|
+
if (!v?.ok) return { action: 'skip', reason: 'invalid', detail: (v as any)?.errorCode ?? 'rejected' }
|
|
56
|
+
return { action: 'load', scenario }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 선언을 원본 설정에 얹는다(순수) — **기존 설정을 지우지 않는다.**
|
|
61
|
+
*
|
|
62
|
+
* 어댑터 설정에는 접속 정보가 함께 산다. 자극을 넣으면서 그 옆을 덮으면 다음 재동기가 원본을 못 찾는다.
|
|
63
|
+
*/
|
|
64
|
+
export function withStimulus(config: any, scenario: any): any {
|
|
65
|
+
return { ...(config ?? {}), scenario }
|
|
66
|
+
}
|
package/server/engine/index.ts
CHANGED
|
@@ -89,6 +89,15 @@ export interface KpiGroup {
|
|
|
89
89
|
waitTime: DurationStats
|
|
90
90
|
/** 이 축에 붙어 있던 시간의 합(자원 축에서 특히 의미 있다). */
|
|
91
91
|
busyMs: number
|
|
92
|
+
/**
|
|
93
|
+
* **품질 판정이 있었던 건수만** 센다 (2026-08-19).
|
|
94
|
+
*
|
|
95
|
+
* 판정이 없는 작업(이동·체류·중간 스테이션)은 어느 쪽에도 세지 않는다 — 모름을 양품으로 세면 양품률이
|
|
96
|
+
* 조용히 100% 로 올라간다. 그래서 `good + scrap` 은 `tasks` 보다 작을 수 있고, 그것이 정상이다.
|
|
97
|
+
*
|
|
98
|
+
* 판정이 하나도 없으면 **필드를 만들지 않는다**(0/0 을 「양품률 0%」로 읽히게 두지 않는다).
|
|
99
|
+
*/
|
|
100
|
+
quality?: { good: number; scrap: number }
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
export interface KpiResult {
|
|
@@ -517,6 +526,24 @@ function facetKey(rec: CompletionRecord, options: KpiFoldOptions): string {
|
|
|
517
526
|
}
|
|
518
527
|
}
|
|
519
528
|
|
|
529
|
+
/**
|
|
530
|
+
* 이 축의 품질 — **판정이 있었던 것만** 센다(없으면 필드를 만들지 않는다).
|
|
531
|
+
*
|
|
532
|
+
* 양품률의 이력이 여기서 나온다: 커널이 작업 완료에 결과를 적고(`TaskStatusDelta.outcome`), 폴드가 축으로
|
|
533
|
+
* 들고(`TaskFacets.outcome`), 여기서 종류별로 센다. 그 사슬이 없으면 「이 공정의 양품률」은 저널에
|
|
534
|
+
* 남지 않는다(설비 누적 카운터는 종류를 모른다).
|
|
535
|
+
*/
|
|
536
|
+
function qualityOf(rows: CompletionRecord[]): { quality?: { good: number; scrap: number } } {
|
|
537
|
+
let good = 0
|
|
538
|
+
let scrap = 0
|
|
539
|
+
for (const r of rows) {
|
|
540
|
+
const o = r.facets.outcome
|
|
541
|
+
if (o === 'good') good++
|
|
542
|
+
else if (o === 'scrap') scrap++
|
|
543
|
+
}
|
|
544
|
+
return good + scrap > 0 ? { quality: { good, scrap } } : {}
|
|
545
|
+
}
|
|
546
|
+
|
|
520
547
|
/**
|
|
521
548
|
* 기록을 축으로 묶어 축별 통계를 낸다 — **전체와 같은 규칙**으로(같은 stats 함수, 같은 창).
|
|
522
549
|
*
|
|
@@ -543,7 +570,8 @@ function groupRecords(
|
|
|
543
570
|
leadTime: stats(rows.map(r => r.leadMs).filter((v): v is number => v !== undefined)),
|
|
544
571
|
workTime: stats(rows.map(r => r.workMs).filter((v): v is number => v !== undefined)),
|
|
545
572
|
waitTime: stats(rows.map(r => r.waitMs).filter((v): v is number => v !== undefined)),
|
|
546
|
-
busyMs: rows.reduce((a, r) => a + (r.workMs ?? 0), 0)
|
|
573
|
+
busyMs: rows.reduce((a, r) => a + (r.workMs ?? 0), 0),
|
|
574
|
+
...qualityOf(rows)
|
|
547
575
|
}))
|
|
548
576
|
.sort((a, b) => b.tasks - a.tasks || (a.key < b.key ? -1 : 1))
|
|
549
577
|
|
|
@@ -135,11 +135,11 @@ export interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {
|
|
|
135
135
|
/**
|
|
136
136
|
* 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록.
|
|
137
137
|
*
|
|
138
|
-
* `
|
|
139
|
-
*
|
|
138
|
+
* `restartPolicies` 는 **이 숫자가 무엇의 성과인지**를 말한다: mirror(실제 시스템을 반영) ·
|
|
139
|
+
* `resume`(이어지는 가상 세계) · `reset`(재현 실험 사본). 같은 "345건" 이 현실의 실적일 수도, 시뮬레이션의
|
|
140
140
|
* 산출일 수도 있다 — 그 구별 없이 숫자만 보여주면 사용자가 시뮬 결과를 실적으로 착각한다.
|
|
141
141
|
*/
|
|
142
|
-
scope: { spaceId?: string; instanceIds: string[];
|
|
142
|
+
scope: { spaceId?: string; instanceIds: string[]; restartPolicies: string[] }
|
|
143
143
|
/** 구간별 값(요청했을 때만) — 추세를 그리기 위한 최소 정보. */
|
|
144
144
|
buckets?: { fromTime: string; toTime: string; tasks: number; orders: number; workP50Ms: number }[]
|
|
145
145
|
/**
|
|
@@ -508,7 +508,7 @@ async function fetchEvents(domainId: string, instanceIds: string[], fromMs: numb
|
|
|
508
508
|
* 공간이 주어지면 **가동 중이고 운영 목적인** 것만 모은다 — 벤치 사본은 사용자가 보는 현장이 아니다.
|
|
509
509
|
* 트윈이 지정되면 그것 하나. 둘 다 없으면 무엇을 재야 할지 알 수 없으므로 명시 실패한다.
|
|
510
510
|
*/
|
|
511
|
-
async function resolveTargets(input: TwinKpiInput): Promise<{ instanceIds: string[];
|
|
511
|
+
async function resolveTargets(input: TwinKpiInput): Promise<{ instanceIds: string[]; restartPolicies: string[] }> {
|
|
512
512
|
if (!input.instanceId && !input.spaceId) throw new Error('either instanceId or spaceId is required')
|
|
513
513
|
const list = await TwinEngine.list(input.domainId)
|
|
514
514
|
const rows = input.instanceId
|
|
@@ -517,8 +517,8 @@ async function resolveTargets(input: TwinKpiInput): Promise<{ instanceIds: strin
|
|
|
517
517
|
/* 목록에서 못 찾아도(정지·비등록) 지정된 트윈은 그대로 조회한다 — 저널은 남아 있을 수 있다. */
|
|
518
518
|
const instanceIds = rows.length ? rows.map((x: any) => x.instanceId as string) : input.instanceId ? [input.instanceId] : []
|
|
519
519
|
/* 컬럼이 NOT NULL 이라 'unknown' 으로 메울 일이 없다 — 비면 계약이 깨진 것이지 모르는 상태가 아니다. */
|
|
520
|
-
const
|
|
521
|
-
return { instanceIds,
|
|
520
|
+
const restartPolicies = [...new Set(rows.map((x: any) => String(x.restartPolicy)))]
|
|
521
|
+
return { instanceIds, restartPolicies }
|
|
522
522
|
}
|
|
523
523
|
|
|
524
524
|
/**
|
|
@@ -541,8 +541,8 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
|
|
|
541
541
|
const minutes = Math.max(1, Math.min(24 * 60, Number(input.windowMinutes) || 60))
|
|
542
542
|
const lookbackMs = Math.max(0, Math.min(24 * 60, input.lookbackMinutes ?? 60)) * 60_000
|
|
543
543
|
|
|
544
|
-
const { instanceIds,
|
|
545
|
-
const scope = { spaceId: input.spaceId, instanceIds,
|
|
544
|
+
const { instanceIds, restartPolicies } = await resolveTargets(input)
|
|
545
|
+
const scope = { spaceId: input.spaceId, instanceIds, restartPolicies }
|
|
546
546
|
|
|
547
547
|
/* 끝 시각 — 지정이 없으면 대상들의 마지막 기록. 그것도 없으면 측정할 것이 없다. */
|
|
548
548
|
let basis: 'given' | 'latest-event' = 'given'
|
|
@@ -51,8 +51,8 @@ export function liveFeedAttached(instanceId: string): boolean {
|
|
|
51
51
|
*/
|
|
52
52
|
export type LiveFeedState = 'attached' | 'detached' | 'not-applicable'
|
|
53
53
|
|
|
54
|
-
export function liveFeedStateOf(args: {
|
|
55
|
-
if (args.
|
|
54
|
+
export function liveFeedStateOf(args: { restartPolicy?: string; running?: boolean; instanceId: string }): LiveFeedState {
|
|
55
|
+
if (args.restartPolicy !== 'resync') return 'not-applicable'
|
|
56
56
|
if (!args.running) return 'not-applicable'
|
|
57
57
|
return liveFeedAttached(args.instanceId) ? 'attached' : 'detached'
|
|
58
58
|
}
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* **속성 어휘는 사용자가 정의하지 않는다** — 커널이 정한 속성에 값을 넣는 문이다(그래서 이름도
|
|
38
38
|
* 「사용자 정의 속성」이 아니다).
|
|
39
39
|
*/
|
|
40
|
-
import { ATTENTION_PROPERTY, EMS_PROPERTY_SPEC, OPERATION_PROPERTY } from '@operato/twin-kernel'
|
|
40
|
+
import { ATTENTION_PROPERTY, EMS_PROPERTY_SPEC, OPERATION_PROPERTY, OP_PARAM } from '@operato/twin-kernel'
|
|
41
41
|
|
|
42
42
|
import { EQUIPMENT_PROPERTY, SPEED_TO_MPS } from './travel-estimator.ts'
|
|
43
43
|
import { declarationTargetsOf } from './property-effects.ts'
|
|
@@ -241,6 +241,28 @@ const COMMON_SPECS: DeclarablePropertySpec[] = [
|
|
|
241
241
|
range: [0.01, 100000],
|
|
242
242
|
note: 'how long this operation takes at this site — the simulation and the forecast use it instead of the built-in constant. a unit is required. history still wins where it exists.',
|
|
243
243
|
uomChoices: Object.keys(DURATION_UOM_MS)
|
|
244
|
+
},
|
|
245
|
+
/*
|
|
246
|
+
* 양품률 — **퍼센트로 받는다**(80 이 80%다).
|
|
247
|
+
*
|
|
248
|
+
* 커널이 읽는 값은 0..1 이지만(ISA-95 `Parameter` 값 그대로) 사람에게 0.8 을 넣으라고 하면 SOC 에서
|
|
249
|
+
* 겪은 함정을 되풀게 된다(0.8 을 0.8% 로 쓴 일이 있다). 그래서 화면·AI 는 퍼센트로 말하고, 비율로
|
|
250
|
+
* 바꾸는 일은 얹는 자리 한 곳에서 한다.
|
|
251
|
+
*/
|
|
252
|
+
{
|
|
253
|
+
id: OPERATION_PROPERTY.yield,
|
|
254
|
+
uom: '%',
|
|
255
|
+
dataType: 'xs:double',
|
|
256
|
+
range: [1, 100],
|
|
257
|
+
note: 'good-output rate of this operation as a percentage 1-100 (95 means 95%) — the kernel reads it as a ratio, and this value replaces the built-in default that also drives the high-scrap attention.'
|
|
258
|
+
},
|
|
259
|
+
/* 셋업·체인지오버 — 가동 앞에 붙는 시간. 소요시간과 같은 단위 목록을 쓴다(같은 종류의 값이다). */
|
|
260
|
+
{
|
|
261
|
+
id: OPERATION_PROPERTY.setupDuration,
|
|
262
|
+
dataType: 'xs:double',
|
|
263
|
+
range: [0.01, 100000],
|
|
264
|
+
note: 'changeover time before this operation runs — added in front of the cycle. a unit is required.',
|
|
265
|
+
uomChoices: Object.keys(DURATION_UOM_MS)
|
|
244
266
|
}
|
|
245
267
|
]
|
|
246
268
|
|
|
@@ -250,6 +272,18 @@ const COMMON_SPECS: DeclarablePropertySpec[] = [
|
|
|
250
272
|
* 읽을 수 없으면 `undefined` — 부르는 쪽이 그 사실을 거절 사유로 올린다(0 을 만들지 않는다: 소요 0 인
|
|
251
273
|
* 작업은 영원히 끝나지 않는다).
|
|
252
274
|
*/
|
|
275
|
+
/**
|
|
276
|
+
* 공정 모수 표에 한 칸 담는다 — **커널이 읽는 표기 그대로**(수율 0..1, 셋업 ISO 8601).
|
|
277
|
+
*
|
|
278
|
+
* 표를 통째로 새로 만들어 얹는다: 같은 모델 객체를 여러 겹이 지나므로(현장 → 트윈) 그때마다 새 객체여야
|
|
279
|
+
* 앞 겹의 것을 조용히 덮어쓰지 않는다.
|
|
280
|
+
*/
|
|
281
|
+
function putParam(next: any, kind: string, paramId: string, value: string): void {
|
|
282
|
+
const table = { ...(next.localParams ?? {}) }
|
|
283
|
+
table[kind] = { ...(table[kind] ?? {}), [paramId]: value }
|
|
284
|
+
next.localParams = table
|
|
285
|
+
}
|
|
286
|
+
|
|
253
287
|
export function isoDurationOf(value: string | number, uom: string): string | undefined {
|
|
254
288
|
const n = Number(value)
|
|
255
289
|
const per = DURATION_UOM_MS[String(uom ?? '').trim()]
|
|
@@ -418,7 +452,9 @@ export function mergeLocalDeclarations(
|
|
|
418
452
|
* 모델에 없는 대상에 얹으려 하면 **조용히 버리지 않고** 그 사실을 돌려준다(구조가 바뀌어 그 설비가
|
|
419
453
|
* 사라졌을 수 있고, 그때 사용자의 선언은 갈 곳이 없다).
|
|
420
454
|
*/
|
|
421
|
-
export function applyLocalDeclarations<
|
|
455
|
+
export function applyLocalDeclarations<
|
|
456
|
+
T extends { locations?: any[]; equipment?: any[]; localDurations?: Record<string, string>; localParams?: Record<string, Record<string, string>> }
|
|
457
|
+
>(
|
|
422
458
|
model: T,
|
|
423
459
|
declarations: readonly LocalDeclaration[] | undefined | null
|
|
424
460
|
): {
|
|
@@ -489,20 +525,37 @@ export function applyLocalDeclarations<T extends { locations?: any[]; equipment?
|
|
|
489
525
|
* 이 표는 **전부 우리가 정한 값**이다(원천이 그리는 자리가 아니다). 그래서 기억(`replaced`)은 언제나
|
|
490
526
|
* `null` 이고, 철회는 「표에서 뺀다」로 끝난다 — 그러면 원천 명세나 커널 상수가 다시 답한다.
|
|
491
527
|
*/
|
|
492
|
-
const putOperation = (
|
|
528
|
+
const putOperation = (next: any, d: LocalDeclaration, memo: LocalDeclaration): void => {
|
|
493
529
|
for (let i = 0; i < d.properties.length; i++) {
|
|
494
530
|
const p = d.properties[i]
|
|
495
|
-
|
|
496
|
-
|
|
531
|
+
const uom = String((p as any).uom ?? '')
|
|
532
|
+
/*
|
|
533
|
+
* 값을 **커널이 읽는 표기로** 바꿔 담는다 — 시간은 ISO 8601, 수율은 비율(0..1).
|
|
534
|
+
* 바꿀 수 없으면 담지 않고 그 사실을 낸다(조용히 0 이나 빈 값으로 만들지 않는다).
|
|
535
|
+
*/
|
|
536
|
+
if (p.id === OPERATION_PROPERTY.duration) {
|
|
537
|
+
const iso = isoDurationOf(p.value, uom)
|
|
538
|
+
if (!iso) { unreadable.push({ target: 'operation', id: d.id, propertyId: p.id }); continue }
|
|
539
|
+
next.localDurations = { ...(next.localDurations ?? {}), [d.id]: iso }
|
|
540
|
+
memo.properties[i].replaced = null
|
|
497
541
|
continue
|
|
498
542
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
unreadable.push({ target: 'operation', id: d.id, propertyId: p.id })
|
|
543
|
+
if (p.id === OPERATION_PROPERTY.setupDuration) {
|
|
544
|
+
const iso = isoDurationOf(p.value, uom)
|
|
545
|
+
if (!iso) { unreadable.push({ target: 'operation', id: d.id, propertyId: p.id }); continue }
|
|
546
|
+
putParam(next, d.id, OP_PARAM.setupDuration, iso)
|
|
547
|
+
memo.properties[i].replaced = null
|
|
502
548
|
continue
|
|
503
549
|
}
|
|
504
|
-
|
|
505
|
-
|
|
550
|
+
if (p.id === OPERATION_PROPERTY.yield) {
|
|
551
|
+
const pct = Number(p.value)
|
|
552
|
+
if (!Number.isFinite(pct) || pct <= 0 || pct > 100) { unreadable.push({ target: 'operation', id: d.id, propertyId: p.id }); continue }
|
|
553
|
+
/* 퍼센트 → 비율. 이 환산이 있는 자리는 여기 하나다(두 곳에서 나누면 100 배가 두 번 걸린다). */
|
|
554
|
+
putParam(next, d.id, OP_PARAM.yield, String(pct / 100))
|
|
555
|
+
memo.properties[i].replaced = null
|
|
556
|
+
continue
|
|
557
|
+
}
|
|
558
|
+
unreadable.push({ target: 'operation', id: d.id, propertyId: p.id })
|
|
506
559
|
}
|
|
507
560
|
}
|
|
508
561
|
|
|
@@ -511,15 +564,15 @@ export function applyLocalDeclarations<T extends { locations?: any[]; equipment?
|
|
|
511
564
|
...model,
|
|
512
565
|
...(model.locations ? { locations: model.locations.map(n => ({ ...n, ...(n?.properties ? { properties: [...n.properties] } : {}) })) } : {}),
|
|
513
566
|
...(model.equipment ? { equipment: model.equipment.map(e => ({ ...e, ...(e?.properties ? { properties: [...e.properties] } : {}) })) } : {}),
|
|
514
|
-
...(model.localDurations ? { localDurations: { ...model.localDurations } } : {})
|
|
567
|
+
...(model.localDurations ? { localDurations: { ...model.localDurations } } : {}),
|
|
568
|
+
...(model.localParams ? { localParams: Object.fromEntries(Object.entries(model.localParams).map(([k, v]) => [k, { ...v }])) } : {})
|
|
515
569
|
}
|
|
516
570
|
for (let i = 0; i < decls.length; i++) {
|
|
517
571
|
const d = decls[i]
|
|
518
572
|
if (d.target === 'operation') {
|
|
519
|
-
/* 빈 표를 남기지 않는다 — 「선언이 있다」와 「읽을 수 있는 선언이 하나도 없다」는 다른
|
|
520
|
-
|
|
521
|
-
putOperation(
|
|
522
|
-
if (Object.keys(table).length) next.localDurations = table
|
|
573
|
+
/* 빈 표를 남기지 않는다 — 「선언이 있다」와 「읽을 수 있는 선언이 하나도 없다」는 다른 사실이다
|
|
574
|
+
(`putOperation` 이 담을 것이 있을 때만 표를 만든다). */
|
|
575
|
+
putOperation(next, d, layer[i])
|
|
523
576
|
continue
|
|
524
577
|
}
|
|
525
578
|
const ok = put(d.target === 'equipment' ? next.equipment : next.locations, d, layer[i])
|
|
@@ -585,7 +638,9 @@ export function revokeLocalDeclarations(
|
|
|
585
638
|
* · 기억이 없으면(옛 선언) **손대지 않고** `unknownSource` 로 말한다 — 원래 값을 지어내는 것은
|
|
586
639
|
* 잘못 넣은 값을 그대로 두는 것보다 나쁘다(다음 원천 재읽기가 그 자리를 바로잡는다).
|
|
587
640
|
*/
|
|
588
|
-
export function restoreRevoked<
|
|
641
|
+
export function restoreRevoked<
|
|
642
|
+
T extends { locations?: any[]; equipment?: any[]; localDurations?: Record<string, string>; localParams?: Record<string, Record<string, string>> }
|
|
643
|
+
>(
|
|
589
644
|
model: T,
|
|
590
645
|
revoked: readonly RevokedDeclaration[]
|
|
591
646
|
): { model: T; restored: RevokedDeclaration[]; unknownSource: DeclarationRevocation[]; missingTargets: { target: string; id: string }[] } {
|
|
@@ -595,7 +650,8 @@ export function restoreRevoked<T extends { locations?: any[]; equipment?: any[];
|
|
|
595
650
|
...model,
|
|
596
651
|
...(model.locations ? { locations: model.locations.map(n => ({ ...n, ...(n?.properties ? { properties: [...n.properties] } : {}) })) } : {}),
|
|
597
652
|
...(model.equipment ? { equipment: model.equipment.map(e => ({ ...e, ...(e?.properties ? { properties: [...e.properties] } : {}) })) } : {}),
|
|
598
|
-
...(model.localDurations ? { localDurations: { ...model.localDurations } } : {})
|
|
653
|
+
...(model.localDurations ? { localDurations: { ...model.localDurations } } : {}),
|
|
654
|
+
...(model.localParams ? { localParams: Object.fromEntries(Object.entries(model.localParams).map(([k, v]) => [k, { ...v }])) } : {})
|
|
599
655
|
}
|
|
600
656
|
const restored: RevokedDeclaration[] = []
|
|
601
657
|
const unknownSource: DeclarationRevocation[] = []
|
|
@@ -607,13 +663,33 @@ export function restoreRevoked<T extends { locations?: any[]; equipment?: any[];
|
|
|
607
663
|
* 빼면 원천 명세(있으면)나 커널 상수가 다시 답한다: 그것이 「선언 이전」의 상태다.
|
|
608
664
|
*/
|
|
609
665
|
if (r.target === 'operation') {
|
|
666
|
+
/* 시간은 시간 표에서, 모수(수율·셋업)는 모수 표에서 뺀다 — 담긴 자리에서 빼야 실제로 빠진다. */
|
|
667
|
+
const paramId =
|
|
668
|
+
r.propertyId === OPERATION_PROPERTY.yield
|
|
669
|
+
? OP_PARAM.yield
|
|
670
|
+
: r.propertyId === OPERATION_PROPERTY.setupDuration
|
|
671
|
+
? OP_PARAM.setupDuration
|
|
672
|
+
: undefined
|
|
673
|
+
if (paramId) {
|
|
674
|
+
const params: Record<string, Record<string, string>> = next.localParams ?? {}
|
|
675
|
+
if (!params[r.id] || !(paramId in params[r.id])) {
|
|
676
|
+
missingTargets.push({ target: r.target, id: r.id })
|
|
677
|
+
continue
|
|
678
|
+
}
|
|
679
|
+
delete params[r.id][paramId]
|
|
680
|
+
/* 빈 껍데기를 남기지 않는다 — 「선언이 있다」와 「빈 표가 있다」가 구별되지 않는다. */
|
|
681
|
+
if (!Object.keys(params[r.id]).length) delete params[r.id]
|
|
682
|
+
if (!Object.keys(params).length) delete next.localParams
|
|
683
|
+
else next.localParams = params
|
|
684
|
+
restored.push(r)
|
|
685
|
+
continue
|
|
686
|
+
}
|
|
610
687
|
const table: Record<string, string> = next.localDurations ?? {}
|
|
611
688
|
if (!(r.id in table)) {
|
|
612
689
|
missingTargets.push({ target: r.target, id: r.id })
|
|
613
690
|
continue
|
|
614
691
|
}
|
|
615
692
|
delete table[r.id]
|
|
616
|
-
/* 빈 표는 남기지 않는다 — 「선언이 있다」와 「빈 표가 있다」가 구별되지 않는다. */
|
|
617
693
|
if (!Object.keys(table).length) delete next.localDurations
|
|
618
694
|
else next.localDurations = table
|
|
619
695
|
restored.push(r)
|
|
@@ -661,7 +737,9 @@ export type DeclarationScope = 'space' | 'instance'
|
|
|
661
737
|
* 기억(`replaced`)은 각 겹이 **자기가 덮은 값**을 적는다: 트윈 겹의 기억은 「현장 값을 덮었다」가 되고,
|
|
662
738
|
* 철회하면 현장 값으로 돌아간다(원천까지 가지 않는다). 그것이 사람이 기대하는 되돌림이다.
|
|
663
739
|
*/
|
|
664
|
-
export function applyDeclarationLayers<
|
|
740
|
+
export function applyDeclarationLayers<
|
|
741
|
+
T extends { locations?: any[]; equipment?: any[]; localDurations?: Record<string, string>; localParams?: Record<string, Record<string, string>> }
|
|
742
|
+
>(
|
|
665
743
|
model: T,
|
|
666
744
|
spaceLayer: readonly LocalDeclaration[] | undefined | null,
|
|
667
745
|
instanceLayer: readonly LocalDeclaration[] | undefined | null
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 실측 양품률 추정기 — **이력에서 배운 수율이 선언값·상수를 이긴다.**
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 필요했나 (2026-08-19) ────────────────────────────────────────────────
|
|
5
|
+
* 소요시간에는 이력 층이 있었다(`measured-estimator`): 저널이 「그 현장에서 실제로 얼마 걸렸나」를 안다.
|
|
6
|
+
* 수율에는 그 층이 없었다 — 양품률의 기본값은 커널 소스의 상수(0.8)였고, **그 상수가 불량 과다 주목
|
|
7
|
+
* 신호까지 만들었다.** 초기 라인이든 성숙한 라인이든 같은 수로 판정된다는 뜻이다.
|
|
8
|
+
*
|
|
9
|
+
* 그런데 이력을 만들 재료도 없었다: 양품/불량은 설비 누적 카운터(종류를 모른다)와 EPCIS disposition
|
|
10
|
+
* (종류를 모른다)에만 남았다. 그래서 커널이 **판정을 일어난 자리(작업 완료)에 적게** 했고
|
|
11
|
+
* (`TaskStatusDelta.outcome`), 폴드가 축으로 들고(`TaskFacets.outcome`), KPI 가 종류별로 센다
|
|
12
|
+
* (`KpiGroup.quality`). 이 파일은 그 수를 비율로 바꿔 커널 시임에 넣는다.
|
|
13
|
+
*
|
|
14
|
+
* ── 정직 규율 (소요 추정기와 같은 것) ───────────────────────────────────────
|
|
15
|
+
* - **표본이 적으면 쓰지 않는다**(`minSamples`). 세 건 중 한 건 불량은 「불량률 33%」가 아니다.
|
|
16
|
+
* - `'unknown'` 축은 쓰지 않는다 — 종류를 모르는 기록을 특정 종류의 근거로 삼을 수 없다.
|
|
17
|
+
* - **판정이 없는 작업은 분모에 넣지 않는다.** KPI 가 이미 그렇게 세지만(`quality` 는 판정된 것만),
|
|
18
|
+
* 여기서도 `good + scrap` 만 쓴다 — `tasks` 를 분모로 쓰면 이동·체류가 섞여 양품률이 낮게 나온다.
|
|
19
|
+
* - **0 도 사실이다.** 전부 불량이면 0 을 배운다(그 현장이 그랬다). 「값이 없다」와 구별해 다룬다 —
|
|
20
|
+
* 커널이 `0` 을 받으면 전량 불량으로 굴러가고, 그것이 관측과 같은 결론이다.
|
|
21
|
+
* - 무엇을 배웠고 무엇을 표본 부족으로 버렸는지 함께 낸다(조용히 일부만 반영하지 않는다).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** 커널 `YieldEstimator` 와 같은 모양(커널 타입에 의존하지 않기 위해 구조로 맞춘다). */
|
|
25
|
+
export interface YieldEstimatorResult {
|
|
26
|
+
estimator?: {
|
|
27
|
+
estimate(ctx: { kind: string }): number | undefined
|
|
28
|
+
}
|
|
29
|
+
/** 작업 종류 → 실측 양품률(0..1). 추정기가 답하는 종류들. */
|
|
30
|
+
learned: Record<string, number>
|
|
31
|
+
/** 종류별 표본 — 배운 값의 근거 크기(화면·로그가 「몇 건에서 배웠나」를 말할 수 있게). */
|
|
32
|
+
samples: Record<string, { good: number; scrap: number }>
|
|
33
|
+
/** 표본이 모자라 쓰지 않은 종류와 판정 건수 — 배우지 못한 것을 밝힌다. */
|
|
34
|
+
skipped: { kind: string; judged: number }[]
|
|
35
|
+
minSamples: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 기본 표본 하한 — 판정 20건 미만이면 그 종류는 배우지 않는다.
|
|
40
|
+
*
|
|
41
|
+
* 소요(5건)보다 높게 잡는 이유: 비율은 **드문 사건**을 재는 값이다. 불량률 5% 인 공정에서 20건을 봐도
|
|
42
|
+
* 불량은 한 건이고, 그 한 건이 있고 없음으로 비율이 0% 와 5% 사이를 오간다. 낮게 잡으면 추정기가
|
|
43
|
+
* 이력이라는 이름으로 잡음을 실어 보낸다.
|
|
44
|
+
*/
|
|
45
|
+
export const DEFAULT_YIELD_MIN_SAMPLES = 20
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 작업 종류별 KPI 그룹에서 양품률 추정기를 만든다(순수 — DB·커널 모름).
|
|
49
|
+
* `groups` 는 `computeTwinKpi({ groupBy: 'taskKind' })` 의 `groups.items` 를 그대로 넘긴다.
|
|
50
|
+
*/
|
|
51
|
+
export function buildYieldEstimator(
|
|
52
|
+
groups: { key: string; quality?: { good: number; scrap: number } }[] | undefined,
|
|
53
|
+
opts: { minSamples?: number } = {}
|
|
54
|
+
): YieldEstimatorResult {
|
|
55
|
+
const minSamples = opts.minSamples ?? DEFAULT_YIELD_MIN_SAMPLES
|
|
56
|
+
const learned: Record<string, number> = {}
|
|
57
|
+
const samples: Record<string, { good: number; scrap: number }> = {}
|
|
58
|
+
const skipped: { kind: string; judged: number }[] = []
|
|
59
|
+
|
|
60
|
+
for (const g of groups ?? []) {
|
|
61
|
+
if (!g?.key || g.key === 'unknown') continue
|
|
62
|
+
const good = g.quality?.good ?? 0
|
|
63
|
+
const scrap = g.quality?.scrap ?? 0
|
|
64
|
+
const judged = good + scrap
|
|
65
|
+
/* 판정이 아예 없는 종류는 **건너뛴 것이 아니다** — 품질을 가리지 않는 공정(이동·체류)이 정상이다. */
|
|
66
|
+
if (judged === 0) continue
|
|
67
|
+
if (judged < minSamples) {
|
|
68
|
+
skipped.push({ kind: g.key, judged })
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
learned[g.key] = good / judged
|
|
72
|
+
samples[g.key] = { good, scrap }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const kinds = Object.keys(learned)
|
|
76
|
+
if (!kinds.length) return { learned, samples, skipped, minSamples }
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
/*
|
|
80
|
+
* 시임은 **무상태**다(커널이 fork 할 때 참조를 공유한다 — 소요 추정기와 같은 규약).
|
|
81
|
+
* 배우지 않은 종류에는 `undefined` 를 답한다: 그러면 커널이 선언값 → 상수로 내려간다.
|
|
82
|
+
*/
|
|
83
|
+
estimator: { estimate: ({ kind }) => learned[kind] },
|
|
84
|
+
learned,
|
|
85
|
+
samples,
|
|
86
|
+
skipped,
|
|
87
|
+
minSamples
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* ① 원천 명세 행(`model.operations`) — 원 시스템이 말한 공정.
|
|
11
11
|
* ② 커널의 자기보고(`specCoverage()`) — **실제로 돌린** 공정과 그 시간의 출처.
|
|
12
12
|
* ③ 이력에서 배운 종류(`measuredOperationKinds`) — 재기동에도 남는 근거.
|
|
13
|
-
* ④ 우리가 이미 선언한 것(`model.localDurations`) — 선언해 둔 공정이 목록에서
|
|
13
|
+
* ④ 우리가 이미 선언한 것(`model.localDurations`·`model.localParams`) — 선언해 둔 공정이 목록에서
|
|
14
|
+
* 사라지지 않게(사라지면 철회할 자리가 없다).
|
|
14
15
|
*
|
|
15
16
|
* ③이 필요한 이유는 실측으로 드러났다: ②는 **작업이 새로 생길 때** 채워지므로, 이력이 풍부한 창고
|
|
16
17
|
* 트윈도 재기동 직후에는 목록이 비었다 — 값을 채울 곳을 보여야 하는 화면이 정작 그때 아무 말도 못 한다.
|
|
@@ -37,6 +38,22 @@ export interface OperationBasisRow {
|
|
|
37
38
|
referenceDuration?: string
|
|
38
39
|
/** 우리가 선언해 둔 시간(ISO 8601) — 있으면. */
|
|
39
40
|
localDuration?: string
|
|
41
|
+
/**
|
|
42
|
+
* 우리가 선언해 둔 공정 모수 — **커널이 읽는 표기 그대로**(수율 0..1, 셋업 ISO 8601).
|
|
43
|
+
*
|
|
44
|
+
* 사람 단위(퍼센트·분)로 되돌리지 않는다: 화면은 선언 겹에서 사용자가 넣은 값을 그대로 들고 있고,
|
|
45
|
+
* 여기 있는 것은 **실제로 커널에 실린 값**이다 — 둘을 견주면 환산이 잘못된 것도 드러난다.
|
|
46
|
+
*/
|
|
47
|
+
localParams?: Record<string, string>
|
|
48
|
+
/** 원천 명세 행이 말한 모수 — 있으면(우리 선언이 이것을 이긴다). */
|
|
49
|
+
referenceParams?: Record<string, string>
|
|
50
|
+
/**
|
|
51
|
+
* 모수를 **무엇으로 읽었나** — 커널의 자기보고(`id → measured|declared`) (2026-08-19).
|
|
52
|
+
*
|
|
53
|
+
* 수율이 이력에서 온 값이면 선언값은 예비로만 남는다(소요시간과 같은 규율) — 화면이 그 사실을 말해야
|
|
54
|
+
* 사람이 헛일을 하지 않는다. 커널이 아직 그 공정을 돌리지 않았으면 이 칸이 빈다(모른다).
|
|
55
|
+
*/
|
|
56
|
+
paramBasis?: Record<string, 'measured' | 'declared'>
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
/**
|
|
@@ -60,12 +77,19 @@ export function operationBasis(model: any, kernel?: any, measuredKinds?: readonl
|
|
|
60
77
|
const row = at(String(o.key))
|
|
61
78
|
if (o.label) row.label = String(o.label)
|
|
62
79
|
if (o.duration) row.referenceDuration = String(o.duration)
|
|
80
|
+
/* 원천이 말한 모수도 함께 든다 — 우리 선언과 견줄 수 있어야 「무엇을 고쳤나」가 보인다. */
|
|
81
|
+
for (const p of (o.parameters ?? []) as any[]) {
|
|
82
|
+
if (!p?.id || p.value === undefined) continue
|
|
83
|
+
row.referenceParams = { ...(row.referenceParams ?? {}), [String(p.id)]: String(p.value) }
|
|
84
|
+
}
|
|
63
85
|
}
|
|
64
86
|
|
|
65
87
|
const coverage = kernel ? readSpecCoverage(kernel) : undefined
|
|
66
88
|
for (const o of coverage?.operations ?? []) {
|
|
67
89
|
if (!o?.kind) continue
|
|
68
|
-
at(String(o.kind))
|
|
90
|
+
const row = at(String(o.kind))
|
|
91
|
+
row.basis = o.duration
|
|
92
|
+
if (o.parameterBasis && Object.keys(o.parameterBasis).length) row.paramBasis = { ...o.parameterBasis }
|
|
69
93
|
}
|
|
70
94
|
|
|
71
95
|
/*
|
|
@@ -85,6 +109,13 @@ export function operationBasis(model: any, kernel?: any, measuredKinds?: readonl
|
|
|
85
109
|
at(kind).localDuration = String(iso)
|
|
86
110
|
}
|
|
87
111
|
|
|
112
|
+
const params = (model?.localParams ?? {}) as Record<string, Record<string, string>>
|
|
113
|
+
for (const [kind, entries] of Object.entries(params)) {
|
|
114
|
+
if (!kind) continue
|
|
115
|
+
const row = at(kind)
|
|
116
|
+
for (const [id, value] of Object.entries(entries ?? {})) row.localParams = { ...(row.localParams ?? {}), [id]: String(value) }
|
|
117
|
+
}
|
|
118
|
+
|
|
88
119
|
return [...rows.values()]
|
|
89
120
|
}
|
|
90
121
|
|
|
@@ -158,6 +158,23 @@ export const PROPERTY_EFFECTS: PropertyEffect[] = [
|
|
|
158
158
|
effect: 'twin.propeffect.operationDuration',
|
|
159
159
|
readBy: '@operato/twin-kernel FlowEngine.durationOf ← declareDurations (engine/local-declarations.ts 가 실어 보낸다)'
|
|
160
160
|
},
|
|
161
|
+
/*
|
|
162
|
+
* ── 공정 모수 (2026-08-19) ────────────────────────────────────────────────
|
|
163
|
+
* 양품률의 기본값은 커널 소스의 상수(0.8)였고, **그 상수가 불량 과다 주목 신호까지 만들었다** — 초기
|
|
164
|
+
* 라인이든 성숙한 라인이든 같은 수로 판정된다. 셋업도 상수였다. 둘 다 현장이 말할 수 있어야 한다.
|
|
165
|
+
*/
|
|
166
|
+
{
|
|
167
|
+
id: 'operation.yield',
|
|
168
|
+
axes: ['operations'],
|
|
169
|
+
effect: 'twin.propeffect.operationYield',
|
|
170
|
+
readBy: '@operato/twin-kernel MesKernel(OP_PARAM.yield) ← declareParameters (양품/불량 판정과 산출량)'
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 'operation.setupDuration',
|
|
174
|
+
axes: ['operations'],
|
|
175
|
+
effect: 'twin.propeffect.operationSetup',
|
|
176
|
+
readBy: '@operato/twin-kernel MesKernel(OP_PARAM.setupDuration) ← declareParameters (가동 앞에 붙는 셋업)'
|
|
177
|
+
},
|
|
161
178
|
{
|
|
162
179
|
id: 'tariff.currency',
|
|
163
180
|
axes: ['locations'],
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **재기동 정책** — 이 트윈은 다시 세워질 때 자기 과거를 어떻게 대하나.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 이 이름인가 (ADR-0029 · plans/simulator-as-source.md §2) ─────────────
|
|
5
|
+
* 옛 이름은 `realityMode` 였고 값은 `mirror`·`sim-world`·`sim-experiment` 였다. 그 축은 「현실이 어디서
|
|
6
|
+
* 오나」를 말하는 것처럼 보였지만, 코드가 그것으로 가른 것은 **재기동 거동 하나**였다. 그리고 「현실이
|
|
7
|
+
* 어디서 오나」는 ADR-0029 가 **원본의 종류**로 옮긴 물음이다(`TwinReference.adapterType`) — 트윈은 한
|
|
8
|
+
* 종류이고 언제나 원본을 반영한다.
|
|
9
|
+
*
|
|
10
|
+
* 그래서 이 축은 사라지지 않고 **트윈에 남아 이름을 바꿨다.** 남는 이유: 「자기 저널을 잇는가」는 원본의
|
|
11
|
+
* 성질이 아니라 **트윈과 자기 과거의 관계**다. 같은 시뮬레이터 원본을 보는 두 트윈이 하나는 이어가고
|
|
12
|
+
* 하나는 매번 처음부터 재현할 수 있다 — 원본은 그 둘을 구별하지 못한다.
|
|
13
|
+
*
|
|
14
|
+
* resync — 외부 실물에서 다시 읽는다(원본이 진실이므로 우리 과거를 이어 붙이지 않는다).
|
|
15
|
+
* resume — 저널을 이어 세운다(이 트윈이 낳은 타임라인이 곧 사실이다).
|
|
16
|
+
* reset — 저널을 비우고 씨앗부터 다시 돌린다(재현 가능한 실험).
|
|
17
|
+
*
|
|
18
|
+
* ── 기본값을 두지 않는다 ────────────────────────────────────────────────────
|
|
19
|
+
* 예전에는 미선언이 조용히 `sim-experiment`(=저널 초기화)로 떨어졌다. 그 관용은 값이 하나 어긋나는
|
|
20
|
+
* 순간 **저널을 지우는 길**이 된다 — 개명 뒤 옛 값이 남아 있는 행을 그 길로 읽으면 재기동 한 번에
|
|
21
|
+
* 이력이 사라진다. 그래서 모르는 값은 **던진다.** 부르는 쪽이 선언하게 만드는 것이 이 축의 요점이다.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** 재기동 정책 — 셋뿐이다. */
|
|
25
|
+
export type RestartPolicy = 'resync' | 'resume' | 'reset'
|
|
26
|
+
|
|
27
|
+
export const RESTART_POLICIES: readonly RestartPolicy[] = ['resync', 'resume', 'reset'] as const
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 저장된 값을 정책으로 읽는다 — **모르면 던진다**(기본값으로 메우지 않는다).
|
|
31
|
+
*
|
|
32
|
+
* 옛 어휘(`mirror`·`sim-world`·`sim-experiment`)를 여기서 번역하지 않는다: 번역을 두면 옛 값이 살아남고,
|
|
33
|
+
* 그 값이 다시 어딘가에 저장된다. 저장된 값은 **한 번 옮기고**(데이터 이관) 코드는 새 어휘만 안다.
|
|
34
|
+
* 그래서 이 함수는 옛 값에 대해서도 던지며, 무엇으로 옮겨야 하는지 문장으로 말한다.
|
|
35
|
+
*/
|
|
36
|
+
export function readRestartPolicy(raw: unknown, where: string): RestartPolicy {
|
|
37
|
+
const value = typeof raw === 'string' ? raw.trim() : ''
|
|
38
|
+
if ((RESTART_POLICIES as readonly string[]).includes(value)) return value as RestartPolicy
|
|
39
|
+
const legacy: Record<string, RestartPolicy> = { mirror: 'resync', 'sim-world': 'resume', 'sim-experiment': 'reset' }
|
|
40
|
+
if (legacy[value]) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`${where}: restart policy "${value}" is the old vocabulary — move the stored value to "${legacy[value]}" ` +
|
|
43
|
+
'(ADR-0029 · plans/simulator-as-source.md §2). Nothing is translated at read time on purpose: a translation would keep the old value alive.'
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
throw new Error(
|
|
47
|
+
`${where}: restart policy ${value ? `"${value}"` : 'is missing'} — declare one of ${RESTART_POLICIES.join(' | ')}. ` +
|
|
48
|
+
'There is no default: an unspecified policy used to fall through to "reset", which erases the journal on the next reboot.'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 이 값이 정책인가 — 던지지 않고 묻는 자리(화면·검사)를 위해. */
|
|
53
|
+
export function isRestartPolicy(raw: unknown): raw is RestartPolicy {
|
|
54
|
+
return typeof raw === 'string' && (RESTART_POLICIES as readonly string[]).includes(raw)
|
|
55
|
+
}
|