@operato/twin-kernel 0.11.11 → 0.11.14
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/counterfactual.d.ts +37 -0
- package/dist/counterfactual.js +65 -10
- package/dist/flow-engine.d.ts +8 -1
- package/dist/flow-engine.js +36 -15
- package/dist/observed-reducer.d.ts +2 -0
- package/dist/observed-reducer.js +21 -0
- package/dist-cjs/index.cjs +124 -29
- package/package.json +2 -2
package/dist/counterfactual.d.ts
CHANGED
|
@@ -26,6 +26,11 @@ export declare class TwinHistory {
|
|
|
26
26
|
}
|
|
27
27
|
export interface CounterfactualResult {
|
|
28
28
|
atSimMs: number;
|
|
29
|
+
/**
|
|
30
|
+
* **실제로 돌린 만큼**이다 — 청한 만큼이 아니다(ADR-0042 ⑤). 예산에 잘리면 여기가 짧아지고,
|
|
31
|
+
* 그 사실은 소비처가 본문에 적는다. 조용히 덜 돌리고 청한 값을 적는 것이 이 칸이 막는 일이다.
|
|
32
|
+
*/
|
|
33
|
+
horizonMs: number;
|
|
29
34
|
withAlt: StateSnapshot;
|
|
30
35
|
baseline: StateSnapshot;
|
|
31
36
|
effect: StateDivergence;
|
|
@@ -35,6 +40,38 @@ export interface CounterfactualOptions {
|
|
|
35
40
|
horizonMs: number;
|
|
36
41
|
tickMs?: number;
|
|
37
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* 사람이 기다리는 자리에서 돌릴 때의 양보·예산 — 미리보기(ADR-0042 ⑤).
|
|
45
|
+
*
|
|
46
|
+
* 양보 간격은 호스트 예측 경로와 같은 25 틱이 기본이다. 틱마다 양보하면 양보가 비용이 되고, 라이브 틱
|
|
47
|
+
* 간격보다 길게 붙잡으면 그 사이 트윈들이 한 박자씩 밀린다(headless-twin 예측 리졸버의 실측 주석).
|
|
48
|
+
*/
|
|
49
|
+
export interface CounterfactualBreath {
|
|
50
|
+
/** 몇 틱마다 한 번 내주나. @default 25 */
|
|
51
|
+
every?: number;
|
|
52
|
+
/** 내주는 방법 — 보통 `setImmediate` 한 번. */
|
|
53
|
+
breathe: () => Promise<void>;
|
|
54
|
+
/** 벽시계 예산(ms). 넘으면 **두 분기를 같은 시각에서** 멈추고 `horizonMs` 에 돌린 만큼을 적는다. */
|
|
55
|
+
budgetMs?: number;
|
|
56
|
+
/** 벽시계 — 시험이 바꿔 낀다. @default performance.now */
|
|
57
|
+
now?: () => number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 반사실 — **base 의 지금**에서 두 분기를 시뮬레이션해 대안의 효과를 잰다.
|
|
61
|
+
* withAlt = base + 대안 → +H / baseline = base 그대로 → +H / effect = 둘의 발산.
|
|
62
|
+
*
|
|
63
|
+
* base 는 live 커널이어도 된다(그 경우가 ADR-0042 의 미리보기다) — fork 만 하고 base 는 건드리지 않는다.
|
|
64
|
+
* 미리보기와 「과거 T 에 그랬다면」이 **같은 몸통**을 쓰는 것이 결정 ① 이다: 미리 본 것과 적용된 것이
|
|
65
|
+
* 갈리는 날을 막는다.
|
|
66
|
+
*/
|
|
67
|
+
export declare function counterfactualFrom(base: CounterfactualTwin, opts: CounterfactualOptions): CounterfactualResult;
|
|
68
|
+
/**
|
|
69
|
+
* 같은 반사실을 **양보하며** 돌린다 — 요청 하나 안에서 사람이 기다리는 미리보기용.
|
|
70
|
+
*
|
|
71
|
+
* 몸통은 `counterfactualFrom` 과 같다(`pairedTicks`). 다른 것은 `every` 틱마다 `breathe()` 를 기다리는 것과,
|
|
72
|
+
* `budgetMs` 를 넘으면 그 자리에서 멈추는 것뿐이다. 멈춘 사실은 `horizonMs` 가 청한 값보다 작은 것으로 드러난다.
|
|
73
|
+
*/
|
|
74
|
+
export declare function counterfactualFromAsync(base: CounterfactualTwin, opts: CounterfactualOptions, breath: CounterfactualBreath): Promise<CounterfactualResult>;
|
|
38
75
|
/**
|
|
39
76
|
* 반사실: history 의 시각 T 상태에서 두 분기를 시뮬레이션해 대안의 효과를 잰다.
|
|
40
77
|
* withAlt = T 상태 + 대안 → T+H / baseline = T 상태 그대로 → T+H / effect = 둘의 발산.
|
package/dist/counterfactual.js
CHANGED
|
@@ -42,6 +42,70 @@ export class TwinHistory {
|
|
|
42
42
|
return t;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
const BREATH_EVERY = 25;
|
|
46
|
+
const TICK_GUARD = 1_000_000;
|
|
47
|
+
/**
|
|
48
|
+
* 두 분기를 **한 틱씩 번갈아** 돌린다 — 그래서 어느 순간에 멈춰도 둘은 같은 시각에 서 있고, 비교가 성립한다.
|
|
49
|
+
* 둘을 따로 끝까지 돌리면 예산에 잘릴 때 한쪽만 앞서 간다.
|
|
50
|
+
*/
|
|
51
|
+
function* pairedTicks(withAlt, baseline, target, step) {
|
|
52
|
+
let guard = 0;
|
|
53
|
+
while (clockOf(withAlt) < target && guard++ < TICK_GUARD) {
|
|
54
|
+
withAlt.tick(step);
|
|
55
|
+
baseline.tick(step);
|
|
56
|
+
yield guard;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** base 에서 두 분기를 낸다. 대안은 withAlt 에만 들어간다. base 자체는 건드리지 않는다. */
|
|
60
|
+
function branch(base, opts) {
|
|
61
|
+
const withAlt = base.fork();
|
|
62
|
+
opts.alternative(withAlt);
|
|
63
|
+
const baseline = base.fork();
|
|
64
|
+
return { withAlt, baseline };
|
|
65
|
+
}
|
|
66
|
+
function settle(atSimMs, withAlt, baseline) {
|
|
67
|
+
const a = withAlt.getSnapshot();
|
|
68
|
+
const b = baseline.getSnapshot();
|
|
69
|
+
return { atSimMs, horizonMs: Math.min(a.simClockMs, b.simClockMs) - atSimMs, withAlt: a, baseline: b, effect: compareStates(a, b) };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 반사실 — **base 의 지금**에서 두 분기를 시뮬레이션해 대안의 효과를 잰다.
|
|
73
|
+
* withAlt = base + 대안 → +H / baseline = base 그대로 → +H / effect = 둘의 발산.
|
|
74
|
+
*
|
|
75
|
+
* base 는 live 커널이어도 된다(그 경우가 ADR-0042 의 미리보기다) — fork 만 하고 base 는 건드리지 않는다.
|
|
76
|
+
* 미리보기와 「과거 T 에 그랬다면」이 **같은 몸통**을 쓰는 것이 결정 ① 이다: 미리 본 것과 적용된 것이
|
|
77
|
+
* 갈리는 날을 막는다.
|
|
78
|
+
*/
|
|
79
|
+
export function counterfactualFrom(base, opts) {
|
|
80
|
+
const atSimMs = clockOf(base);
|
|
81
|
+
const step = opts.tickMs ?? 1000;
|
|
82
|
+
const { withAlt, baseline } = branch(base, opts);
|
|
83
|
+
for (const _ of pairedTicks(withAlt, baseline, atSimMs + opts.horizonMs, step)) {
|
|
84
|
+
/* 끝까지 — 양보 없이. 결정적이다. */
|
|
85
|
+
}
|
|
86
|
+
return settle(atSimMs, withAlt, baseline);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 같은 반사실을 **양보하며** 돌린다 — 요청 하나 안에서 사람이 기다리는 미리보기용.
|
|
90
|
+
*
|
|
91
|
+
* 몸통은 `counterfactualFrom` 과 같다(`pairedTicks`). 다른 것은 `every` 틱마다 `breathe()` 를 기다리는 것과,
|
|
92
|
+
* `budgetMs` 를 넘으면 그 자리에서 멈추는 것뿐이다. 멈춘 사실은 `horizonMs` 가 청한 값보다 작은 것으로 드러난다.
|
|
93
|
+
*/
|
|
94
|
+
export async function counterfactualFromAsync(base, opts, breath) {
|
|
95
|
+
const atSimMs = clockOf(base);
|
|
96
|
+
const step = opts.tickMs ?? 1000;
|
|
97
|
+
const every = breath.every ?? BREATH_EVERY;
|
|
98
|
+
const now = breath.now ?? (() => performance.now());
|
|
99
|
+
const started = now();
|
|
100
|
+
const { withAlt, baseline } = branch(base, opts);
|
|
101
|
+
for (const n of pairedTicks(withAlt, baseline, atSimMs + opts.horizonMs, step)) {
|
|
102
|
+
if (breath.budgetMs !== undefined && now() - started >= breath.budgetMs)
|
|
103
|
+
break;
|
|
104
|
+
if (n % every === 0)
|
|
105
|
+
await breath.breathe();
|
|
106
|
+
}
|
|
107
|
+
return settle(atSimMs, withAlt, baseline);
|
|
108
|
+
}
|
|
45
109
|
/**
|
|
46
110
|
* 반사실: history 의 시각 T 상태에서 두 분기를 시뮬레이션해 대안의 효과를 잰다.
|
|
47
111
|
* withAlt = T 상태 + 대안 → T+H / baseline = T 상태 그대로 → T+H / effect = 둘의 발산.
|
|
@@ -50,14 +114,5 @@ export function counterfactualAt(history, atSimMs, opts) {
|
|
|
50
114
|
const base = history.at(atSimMs);
|
|
51
115
|
if (!base)
|
|
52
116
|
return undefined;
|
|
53
|
-
|
|
54
|
-
const target = atSimMs + opts.horizonMs;
|
|
55
|
-
const withAlt = base.fork();
|
|
56
|
-
opts.alternative(withAlt);
|
|
57
|
-
const baseline = base.fork();
|
|
58
|
-
const run = (t) => { let g = 0; while (clockOf(t) < target && g++ < 1_000_000)
|
|
59
|
-
t.tick(step); };
|
|
60
|
-
run(withAlt);
|
|
61
|
-
run(baseline);
|
|
62
|
-
return { atSimMs, withAlt: withAlt.getSnapshot(), baseline: baseline.getSnapshot(), effect: compareStates(withAlt.getSnapshot(), baseline.getSnapshot()) };
|
|
117
|
+
return counterfactualFrom(base, opts);
|
|
63
118
|
}
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MaterialActual, TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation } from '@operato/ops-contract';
|
|
1
|
+
import type { MaterialActual, TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation, DispositionFact } from '@operato/ops-contract';
|
|
2
2
|
import { type RatedUsage, type UsedUsage } from '@operato/ops-contract';
|
|
3
3
|
import type { VocabularyElement } from '@operato/ops-contract';
|
|
4
4
|
import type { ReducerCheckpoint } from './observed-reducer.ts';
|
|
@@ -46,6 +46,13 @@ export interface FlowItem {
|
|
|
46
46
|
ilmd?: Record<string, unknown>;
|
|
47
47
|
/** 선언된 모든 수량 — 표준 `MaterialLot.Quantity`(복수). 관측에서 온 물품이 여러 단위를 들 수 있다. */
|
|
48
48
|
quantities?: MaterialQuantity[];
|
|
49
|
+
/**
|
|
50
|
+
* 로트의 두 표준 칸 — ISA-95 `Disposition`(`nonconformance`) · `Status`(`status`). 관측 모드에서 리듀서가
|
|
51
|
+
* 앉힌 것을 `settleObserved` 가 옮기고 `itemState` 가 스냅샷에 낸다. 둘 중 하나가 빠지면 리듀서에는 있는데
|
|
52
|
+
* 화면에는 없는 칸이 된다(2026-09-16 에 둘 다 그랬다).
|
|
53
|
+
*/
|
|
54
|
+
nonconformance?: DispositionFact;
|
|
55
|
+
status?: string;
|
|
49
56
|
}
|
|
50
57
|
/** 물리 자산 — 반복사용(팔레트·랙·용기). 설비도 물품도 아니다(GRAI vs SSCC 구분은 계약 주석 참조). */
|
|
51
58
|
export interface FlowAsset extends EffectivePeriod {
|
package/dist/flow-engine.js
CHANGED
|
@@ -1034,7 +1034,14 @@ export class FlowEngine {
|
|
|
1034
1034
|
...(it.subLotId ? { subLotId: it.subLotId } : {}),
|
|
1035
1035
|
...(it.definitionId ? { definitionId: it.definitionId } : {}),
|
|
1036
1036
|
gtin: it.gtin, qty: it.qty ?? 1, uom: it.uom, ...(it.quantities?.length ? { quantities: it.quantities } : {}),
|
|
1037
|
-
parent: it.parent, carriedBy: it.carriedBy, expiry: it.expiry, ilmd: it.ilmd
|
|
1037
|
+
parent: it.parent, carriedBy: it.carriedBy, expiry: it.expiry, ilmd: it.ilmd,
|
|
1038
|
+
/*
|
|
1039
|
+
* 로트의 두 표준 칸 — ISA-95 `Disposition`(`nonconformance`)과 `Status`(`status`). 이 복사가 칸을 하나씩
|
|
1040
|
+
* 옮기는 자리라, 여기 없는 칸은 리듀서에 앉았어도 **스냅샷에 나오지 않는다**(2026-09-16 에 `status` 를
|
|
1041
|
+
* 넣다가 `nonconformance` 도 여기서 빠져 있던 것을 찾았다 — 자리는 있고 길이 없는 부류). 있을 때만 옮긴다.
|
|
1042
|
+
*/
|
|
1043
|
+
...(it.nonconformance ? { nonconformance: it.nonconformance } : {}),
|
|
1044
|
+
...(it.status ? { status: it.status } : {})
|
|
1038
1045
|
});
|
|
1039
1046
|
}
|
|
1040
1047
|
for (const m of snap.equipment) {
|
|
@@ -1467,15 +1474,23 @@ export class FlowEngine {
|
|
|
1467
1474
|
}
|
|
1468
1475
|
dispatchInner(cmd) {
|
|
1469
1476
|
const ok = () => ({ commandId: cmd.commandId, accepted: true });
|
|
1470
|
-
|
|
1471
|
-
|
|
1477
|
+
/*
|
|
1478
|
+
* 거절 사유 = 언어 중립 코드 + 원시 파라미터 + 영어 문장.
|
|
1479
|
+
*
|
|
1480
|
+
* `error` 는 **문장**이어야 한다 — 코드 문자열을 다시 넣지 않는다. 화면은 모르는 코드를 만나면 `error` 를
|
|
1481
|
+
* 그대로 내는데(ADR-0042 ③, 매핑 표 없음), 그 자리에 코드가 들어 있으면 사람이 `kind-required` 같은 날
|
|
1482
|
+
* 코드를 본다. 그리고 「무엇이 없었나」를 말하는 코드(`*-not-found`)는 그 무엇을 params 에 싣는다 — 다음에
|
|
1483
|
+
* 할 일(다시 고르기)은 없었던 것의 이름을 알아야 할 수 있다.
|
|
1484
|
+
*/
|
|
1485
|
+
const fail = (errorCode, error, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error });
|
|
1486
|
+
const noEquipment = (resourceId) => fail('resource-not-found', `no equipment with id "${resourceId}" in this twin`, { resourceId });
|
|
1472
1487
|
switch (cmd.type) {
|
|
1473
1488
|
case CMD.orderHold:
|
|
1474
1489
|
case CMD.orderResume: {
|
|
1475
1490
|
const orderId = cmd.args?.orderId;
|
|
1476
1491
|
const order = orderId ? this.orders.get(orderId) : undefined;
|
|
1477
1492
|
if (!order)
|
|
1478
|
-
return fail('order-not-found', { orderId: orderId ?? '' });
|
|
1493
|
+
return fail('order-not-found', `no order with id "${orderId ?? ''}" in this twin`, { orderId: orderId ?? '' });
|
|
1479
1494
|
order.held = cmd.type === CMD.orderHold;
|
|
1480
1495
|
this.emitOrder(order);
|
|
1481
1496
|
return ok();
|
|
@@ -1496,9 +1511,10 @@ export class FlowEngine {
|
|
|
1496
1511
|
// Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
|
|
1497
1512
|
case CMD.resourceHold:
|
|
1498
1513
|
case CMD.resourceResume: {
|
|
1499
|
-
const
|
|
1514
|
+
const resourceId = cmd.args?.resourceId ?? '';
|
|
1515
|
+
const m = this.equipment.get(resourceId);
|
|
1500
1516
|
if (!m)
|
|
1501
|
-
return
|
|
1517
|
+
return noEquipment(resourceId);
|
|
1502
1518
|
m.held = cmd.type === CMD.resourceHold; // 계획 정지 → 배정 스킵
|
|
1503
1519
|
this.emitEquipment(m);
|
|
1504
1520
|
return ok();
|
|
@@ -1507,7 +1523,7 @@ export class FlowEngine {
|
|
|
1507
1523
|
const a = cmd.args;
|
|
1508
1524
|
const m = this.equipment.get(a?.resourceId ?? '');
|
|
1509
1525
|
if (!m)
|
|
1510
|
-
return
|
|
1526
|
+
return noEquipment(a?.resourceId ?? '');
|
|
1511
1527
|
if (m.status !== 'down') {
|
|
1512
1528
|
m.status = 'down'; // 비계획 고장 주입 — 기존 고장 machinery(processFailures)가 downMs 누적·수리 처리
|
|
1513
1529
|
m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 60_000);
|
|
@@ -1516,9 +1532,10 @@ export class FlowEngine {
|
|
|
1516
1532
|
return ok();
|
|
1517
1533
|
}
|
|
1518
1534
|
case CMD.resourceRepair: {
|
|
1519
|
-
const
|
|
1535
|
+
const resourceId = cmd.args?.resourceId ?? '';
|
|
1536
|
+
const m = this.equipment.get(resourceId);
|
|
1520
1537
|
if (!m)
|
|
1521
|
-
return
|
|
1538
|
+
return noEquipment(resourceId);
|
|
1522
1539
|
if (m.status === 'down') {
|
|
1523
1540
|
m.status = m.taskId ? 'busy' : 'idle'; // 진행중 task 있으면 재개(고장모델 수리 로직과 동형)
|
|
1524
1541
|
m.repairUntilMs = undefined;
|
|
@@ -1529,9 +1546,10 @@ export class FlowEngine {
|
|
|
1529
1546
|
return ok();
|
|
1530
1547
|
}
|
|
1531
1548
|
case CMD.resourceResetMetrics: {
|
|
1532
|
-
const
|
|
1549
|
+
const resourceId = cmd.args?.resourceId ?? '';
|
|
1550
|
+
const m = this.equipment.get(resourceId);
|
|
1533
1551
|
if (!m)
|
|
1534
|
-
return
|
|
1552
|
+
return noEquipment(resourceId);
|
|
1535
1553
|
m.runMs = 0;
|
|
1536
1554
|
m.setupMs = 0;
|
|
1537
1555
|
m.downMs = 0;
|
|
@@ -1547,9 +1565,9 @@ export class FlowEngine {
|
|
|
1547
1565
|
// 새 설비는 즉시 배정 대상(다음 tick). 유일 id 생성(충돌 회피). 좌표/persistence 는 호스트 몫(커널=위상만).
|
|
1548
1566
|
const a = cmd.args;
|
|
1549
1567
|
if (!a?.kind)
|
|
1550
|
-
return fail('kind-required');
|
|
1568
|
+
return fail('kind-required', 'resource.add needs args.kind');
|
|
1551
1569
|
if (!a?.homeLocation || !this.locations.has(a.homeLocation))
|
|
1552
|
-
return fail('home-location-not-found', { homeLocation: a?.homeLocation ?? '' });
|
|
1570
|
+
return fail('home-location-not-found', `no location "${a?.homeLocation ?? ''}" to use as home`, { homeLocation: a?.homeLocation ?? '' });
|
|
1553
1571
|
const count = Math.max(1, Math.min(50, Number(a.count) || 1));
|
|
1554
1572
|
let seq = this.equipment.size;
|
|
1555
1573
|
for (let i = 0; i < count; i++) {
|
|
@@ -1569,7 +1587,7 @@ export class FlowEngine {
|
|
|
1569
1587
|
}
|
|
1570
1588
|
/** 도메인 커맨드 처리(order.release 등). 기본은 거절 — 도메인이 override. */
|
|
1571
1589
|
handleCommand(cmd) {
|
|
1572
|
-
return { commandId: cmd.commandId, accepted: false, errorCode: 'unknown-command', errorParams: { type: cmd.type }, error: `
|
|
1590
|
+
return { commandId: cmd.commandId, accepted: false, errorCode: 'unknown-command', errorParams: { type: cmd.type }, error: `no command of type "${cmd.type}"` };
|
|
1573
1591
|
}
|
|
1574
1592
|
scenario = {
|
|
1575
1593
|
load: (def) => {
|
|
@@ -3858,7 +3876,10 @@ export class FlowEngine {
|
|
|
3858
3876
|
...(i.uom ? { uom: i.uom } : {}),
|
|
3859
3877
|
...(i.quantities?.length ? { quantities: i.quantities } : {}),
|
|
3860
3878
|
...(i.expiry !== undefined ? { expiry: i.expiry } : {}),
|
|
3861
|
-
...(i.ilmd ? { ilmd: i.ilmd } : {})
|
|
3879
|
+
...(i.ilmd ? { ilmd: i.ilmd } : {}),
|
|
3880
|
+
/* 로트의 두 표준 칸(§FlowItem) — 여기서 빠지면 리듀서·엔진에는 있고 스냅샷에는 없다. */
|
|
3881
|
+
...(i.nonconformance ? { nonconformance: i.nonconformance } : {}),
|
|
3882
|
+
...(i.status ? { status: i.status } : {})
|
|
3862
3883
|
};
|
|
3863
3884
|
}
|
|
3864
3885
|
/**
|
|
@@ -12,6 +12,8 @@ interface ProjItem {
|
|
|
12
12
|
disposition?: string;
|
|
13
13
|
/** ISA-95 의 부적합 처분 — 사람이 정한 결정. 로트 전체의 사실이라 부분마다 같은 값이 앉는다. */
|
|
14
14
|
nonconformance?: DispositionFact;
|
|
15
|
+
/** ISA-95 `MaterialLot.Status` — `nonconformance` 와 다른 칸(§계약 `ItemState.status`). 마지막 하나만. */
|
|
16
|
+
status?: string;
|
|
15
17
|
parent?: string;
|
|
16
18
|
qty?: number;
|
|
17
19
|
uom?: string;
|
package/dist/observed-reducer.js
CHANGED
|
@@ -566,6 +566,27 @@ export class ObservedReducer {
|
|
|
566
566
|
part.nonconformance = { ...d };
|
|
567
567
|
break;
|
|
568
568
|
}
|
|
569
|
+
case OP_EVENT.materialLot: {
|
|
570
|
+
/*
|
|
571
|
+
* **로트 상태** — ISA-95 `MaterialLot.Status`. 출하 승인이 이 길로 온다(§계약 `OP_EVENT.materialLot`).
|
|
572
|
+
* 처분과 같은 좁힘이다: 대상을 가리켜 들어오고, 그 이름을 가진 부분 모두에 앉는다. **물품을 지어내지
|
|
573
|
+
* 않는다** — 모르는 로트의 상태는 `unhandled` 로 세어서 낸다.
|
|
574
|
+
*/
|
|
575
|
+
const d = e.data;
|
|
576
|
+
if (!d?.lotId || !d?.status)
|
|
577
|
+
break;
|
|
578
|
+
if (this.stale(`material-lot:${d.lotId}`, e))
|
|
579
|
+
return;
|
|
580
|
+
/* `lotId` 는 표준 `MaterialLotID` — 값은 로트의 식별자, 즉 여기서 `epc` 로 든 것과 같다. */
|
|
581
|
+
const parts = [...this.items.values()].filter(i => i.epc === d.lotId);
|
|
582
|
+
if (!parts.length) {
|
|
583
|
+
this.noteUnhandled(e, `${OP_EVENT.materialLot}:unknown-lot`);
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
for (const part of parts)
|
|
587
|
+
part.status = d.status;
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
569
590
|
case OP_EVENT.observation: {
|
|
570
591
|
/*
|
|
571
592
|
* **자리의 물리 관측** — 속성마다 마지막 값 하나만 든다(§`LocationState.observations`).
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -40,6 +40,8 @@ __export(index_exports, {
|
|
|
40
40
|
compareStates: () => compareStates,
|
|
41
41
|
constantDuration: () => constantDuration,
|
|
42
42
|
counterfactualAt: () => counterfactualAt,
|
|
43
|
+
counterfactualFrom: () => counterfactualFrom,
|
|
44
|
+
counterfactualFromAsync: () => counterfactualFromAsync,
|
|
43
45
|
demandWindowStart: () => demandWindowStart,
|
|
44
46
|
deriveAttentions: () => deriveAttentions,
|
|
45
47
|
electricityCost: () => electricityCost,
|
|
@@ -650,6 +652,23 @@ var OP_EVENT = {
|
|
|
650
652
|
* 이것은 **결정**이다. 누가·언제·왜 그렇게 정했는지의 자리는 그쪽에 없다.
|
|
651
653
|
*/
|
|
652
654
|
disposition: "nonconformance.disposition",
|
|
655
|
+
/**
|
|
656
|
+
* **로트 상태** — ISA-95 `MaterialLot.Status`(B2MML-Material.xsd). 「이 로트가 지금 어떤 것이냐」의 결정.
|
|
657
|
+
*
|
|
658
|
+
* ── `nonconformance.disposition` 과 다른 사실이다 (2026-09-16) ──────────────
|
|
659
|
+
* 그것은 표준 `Disposition` — **부적합을 어떻게 처리하나**(재작업·특채·폐기). 이것은 표준 `Status` — 적합한
|
|
660
|
+
* 로트에도 붙는 상태(released · quarantine · on-hold …). 출하 승인(batch release)이 이 축이다. 처분에 실으면
|
|
661
|
+
* 정상 출하가 전부 부적합 이력이 된다 — 진짜 term 을 틀린 뜻으로 쓰는 것이 자리가 없는 것보다 나쁘다.
|
|
662
|
+
*
|
|
663
|
+
* ── 낱말은 열려 있다 ──────────────────────────────────────────────────────
|
|
664
|
+
* 오더 상태와 같은 규율 — 커널이 이 낱말로 무엇을 계산하지 않으므로 닫을 근거가 없다. 도메인이 소유한다.
|
|
665
|
+
*
|
|
666
|
+
* ── 출발과 다른 사실이다 ─────────────────────────────────────────────────
|
|
667
|
+
* 승인은 자재를 움직이지 않는다. 자재가 트윈 밖으로 나가는 것은 EPCIS `shipping` 사건이다. 둘을 한 사건으로
|
|
668
|
+
* 접지 않는다 — 승인됐는데 안 나간 배치가 트윈이 보여야 할 상태다.
|
|
669
|
+
*/
|
|
670
|
+
materialLot: "material-lot.status",
|
|
671
|
+
// 레코드: { lotId(표준 MaterialLotID = ItemState.epc 의 값), status, decidedBy?, reason?, decidedAt? }
|
|
653
672
|
/**
|
|
654
673
|
* **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
|
|
655
674
|
*
|
|
@@ -2038,6 +2057,23 @@ var SPECS = {
|
|
|
2038
2057
|
},
|
|
2039
2058
|
enums: { decision: DISPOSITION_DECISION }
|
|
2040
2059
|
},
|
|
2060
|
+
/*
|
|
2061
|
+
* **로트 상태** — ISA-95 `MaterialLot.Status`. 출하 승인(batch release)이 들어오는 문(§`OP_EVENT.materialLot`).
|
|
2062
|
+
*
|
|
2063
|
+
* `disposition`(subjectId + decision) 과 필드가 겹치지 않는다 — 여기는 `lotId` + `status`. 정체 이름이 표준
|
|
2064
|
+
* `MaterialLotID` 인 이유가 하나 더 있다: 이 문은 `epc` 를 든 레코드를 EPCIS 어휘로 보고 받지 않는다
|
|
2065
|
+
* (§`operationalKindOf`). 값은 로트의 식별자(`ItemState.epc` 와 같은 값)다. 상태 낱말은 열려 있어 enum 이
|
|
2066
|
+
* 없다(오더 상태와 같은 규율). 로트 전체의 사실이므로 부분(`subLotId`)은 받지 않는다 — 부분마다 다른 상태가
|
|
2067
|
+
* 필요해지면 그것은 다른 사실이다.
|
|
2068
|
+
*/
|
|
2069
|
+
"material-lot": {
|
|
2070
|
+
eventType: OP_EVENT.materialLot,
|
|
2071
|
+
match: ["lotId", "status"],
|
|
2072
|
+
matchOrder: 85,
|
|
2073
|
+
identity: "lotId",
|
|
2074
|
+
required: ["lotId", "status"],
|
|
2075
|
+
fields: { lotId: "string", status: "string", decidedBy: "string", reason: "string", decidedAt: "string", recordTime: "string" }
|
|
2076
|
+
},
|
|
2041
2077
|
test: {
|
|
2042
2078
|
eventType: OP_EVENT.test,
|
|
2043
2079
|
match: ["testableObjectId"],
|
|
@@ -2190,11 +2226,11 @@ function computeOee(c, nowMs) {
|
|
|
2190
2226
|
missing.push("planned-run-time-per-item");
|
|
2191
2227
|
else if (c.runMs <= 0)
|
|
2192
2228
|
missing.push("actual-production-time");
|
|
2193
|
-
const
|
|
2194
|
-
const overall = availability != null &&
|
|
2229
|
+
const performance2 = c.plannedRunTimePerItemMs != null && c.runMs > 0 ? Math.min(1, c.plannedRunTimePerItemMs * produced / c.runMs) : void 0;
|
|
2230
|
+
const overall = availability != null && performance2 != null && quality != null ? availability * performance2 * quality : void 0;
|
|
2195
2231
|
return {
|
|
2196
2232
|
...availability != null ? { availability } : {},
|
|
2197
|
-
...
|
|
2233
|
+
...performance2 != null ? { performance: performance2 } : {},
|
|
2198
2234
|
...quality != null ? { quality } : {},
|
|
2199
2235
|
...overall != null ? { overall } : {},
|
|
2200
2236
|
...missing.length ? { missing } : {},
|
|
@@ -2258,21 +2294,52 @@ var TwinHistory = class {
|
|
|
2258
2294
|
return t;
|
|
2259
2295
|
}
|
|
2260
2296
|
};
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2297
|
+
var BREATH_EVERY = 25;
|
|
2298
|
+
var TICK_GUARD = 1e6;
|
|
2299
|
+
function* pairedTicks(withAlt, baseline, target, step) {
|
|
2300
|
+
let guard = 0;
|
|
2301
|
+
while (clockOf(withAlt) < target && guard++ < TICK_GUARD) {
|
|
2302
|
+
withAlt.tick(step);
|
|
2303
|
+
baseline.tick(step);
|
|
2304
|
+
yield guard;
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
function branch(base, opts) {
|
|
2266
2308
|
const withAlt = base.fork();
|
|
2267
2309
|
opts.alternative(withAlt);
|
|
2268
2310
|
const baseline = base.fork();
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2311
|
+
return { withAlt, baseline };
|
|
2312
|
+
}
|
|
2313
|
+
function settle(atSimMs, withAlt, baseline) {
|
|
2314
|
+
const a = withAlt.getSnapshot();
|
|
2315
|
+
const b = baseline.getSnapshot();
|
|
2316
|
+
return { atSimMs, horizonMs: Math.min(a.simClockMs, b.simClockMs) - atSimMs, withAlt: a, baseline: b, effect: compareStates(a, b) };
|
|
2317
|
+
}
|
|
2318
|
+
function counterfactualFrom(base, opts) {
|
|
2319
|
+
const atSimMs = clockOf(base);
|
|
2320
|
+
const step = opts.tickMs ?? 1e3;
|
|
2321
|
+
const { withAlt, baseline } = branch(base, opts);
|
|
2322
|
+
for (const _ of pairedTicks(withAlt, baseline, atSimMs + opts.horizonMs, step)) {
|
|
2323
|
+
}
|
|
2324
|
+
return settle(atSimMs, withAlt, baseline);
|
|
2325
|
+
}
|
|
2326
|
+
async function counterfactualFromAsync(base, opts, breath) {
|
|
2327
|
+
const atSimMs = clockOf(base);
|
|
2328
|
+
const step = opts.tickMs ?? 1e3;
|
|
2329
|
+
const every = breath.every ?? BREATH_EVERY;
|
|
2330
|
+
const now = breath.now ?? (() => performance.now());
|
|
2331
|
+
const started = now();
|
|
2332
|
+
const { withAlt, baseline } = branch(base, opts);
|
|
2333
|
+
for (const n of pairedTicks(withAlt, baseline, atSimMs + opts.horizonMs, step)) {
|
|
2334
|
+
if (breath.budgetMs !== void 0 && now() - started >= breath.budgetMs) break;
|
|
2335
|
+
if (n % every === 0) await breath.breathe();
|
|
2336
|
+
}
|
|
2337
|
+
return settle(atSimMs, withAlt, baseline);
|
|
2338
|
+
}
|
|
2339
|
+
function counterfactualAt(history, atSimMs, opts) {
|
|
2340
|
+
const base = history.at(atSimMs);
|
|
2341
|
+
if (!base) return void 0;
|
|
2342
|
+
return counterfactualFrom(base, opts);
|
|
2276
2343
|
}
|
|
2277
2344
|
|
|
2278
2345
|
// src/forecast.ts
|
|
@@ -2849,6 +2916,18 @@ var ObservedReducer = class {
|
|
|
2849
2916
|
for (const part of parts) part.nonconformance = { ...d };
|
|
2850
2917
|
break;
|
|
2851
2918
|
}
|
|
2919
|
+
case OP_EVENT.materialLot: {
|
|
2920
|
+
const d = e.data;
|
|
2921
|
+
if (!d?.lotId || !d?.status) break;
|
|
2922
|
+
if (this.stale(`material-lot:${d.lotId}`, e)) return;
|
|
2923
|
+
const parts = [...this.items.values()].filter((i) => i.epc === d.lotId);
|
|
2924
|
+
if (!parts.length) {
|
|
2925
|
+
this.noteUnhandled(e, `${OP_EVENT.materialLot}:unknown-lot`);
|
|
2926
|
+
break;
|
|
2927
|
+
}
|
|
2928
|
+
for (const part of parts) part.status = d.status;
|
|
2929
|
+
break;
|
|
2930
|
+
}
|
|
2852
2931
|
case OP_EVENT.observation: {
|
|
2853
2932
|
const d = e.data;
|
|
2854
2933
|
if (!d?.locationId || !d?.propertyId) break;
|
|
@@ -4285,7 +4364,14 @@ var FlowEngine = class {
|
|
|
4285
4364
|
parent: it.parent,
|
|
4286
4365
|
carriedBy: it.carriedBy,
|
|
4287
4366
|
expiry: it.expiry,
|
|
4288
|
-
ilmd: it.ilmd
|
|
4367
|
+
ilmd: it.ilmd,
|
|
4368
|
+
/*
|
|
4369
|
+
* 로트의 두 표준 칸 — ISA-95 `Disposition`(`nonconformance`)과 `Status`(`status`). 이 복사가 칸을 하나씩
|
|
4370
|
+
* 옮기는 자리라, 여기 없는 칸은 리듀서에 앉았어도 **스냅샷에 나오지 않는다**(2026-09-16 에 `status` 를
|
|
4371
|
+
* 넣다가 `nonconformance` 도 여기서 빠져 있던 것을 찾았다 — 자리는 있고 길이 없는 부류). 있을 때만 옮긴다.
|
|
4372
|
+
*/
|
|
4373
|
+
...it.nonconformance ? { nonconformance: it.nonconformance } : {},
|
|
4374
|
+
...it.status ? { status: it.status } : {}
|
|
4289
4375
|
});
|
|
4290
4376
|
}
|
|
4291
4377
|
for (const m of snap.equipment) {
|
|
@@ -4616,13 +4702,14 @@ var FlowEngine = class {
|
|
|
4616
4702
|
}
|
|
4617
4703
|
dispatchInner(cmd) {
|
|
4618
4704
|
const ok = () => ({ commandId: cmd.commandId, accepted: true });
|
|
4619
|
-
const fail = (errorCode, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error
|
|
4705
|
+
const fail = (errorCode, error, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error });
|
|
4706
|
+
const noEquipment = (resourceId) => fail("resource-not-found", `no equipment with id "${resourceId}" in this twin`, { resourceId });
|
|
4620
4707
|
switch (cmd.type) {
|
|
4621
4708
|
case CMD.orderHold:
|
|
4622
4709
|
case CMD.orderResume: {
|
|
4623
4710
|
const orderId = cmd.args?.orderId;
|
|
4624
4711
|
const order = orderId ? this.orders.get(orderId) : void 0;
|
|
4625
|
-
if (!order) return fail("order-not-found", { orderId: orderId ?? "" });
|
|
4712
|
+
if (!order) return fail("order-not-found", `no order with id "${orderId ?? ""}" in this twin`, { orderId: orderId ?? "" });
|
|
4626
4713
|
order.held = cmd.type === CMD.orderHold;
|
|
4627
4714
|
this.emitOrder(order);
|
|
4628
4715
|
return ok();
|
|
@@ -4638,8 +4725,9 @@ var FlowEngine = class {
|
|
|
4638
4725
|
// Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
|
|
4639
4726
|
case CMD.resourceHold:
|
|
4640
4727
|
case CMD.resourceResume: {
|
|
4641
|
-
const
|
|
4642
|
-
|
|
4728
|
+
const resourceId = cmd.args?.resourceId ?? "";
|
|
4729
|
+
const m = this.equipment.get(resourceId);
|
|
4730
|
+
if (!m) return noEquipment(resourceId);
|
|
4643
4731
|
m.held = cmd.type === CMD.resourceHold;
|
|
4644
4732
|
this.emitEquipment(m);
|
|
4645
4733
|
return ok();
|
|
@@ -4647,7 +4735,7 @@ var FlowEngine = class {
|
|
|
4647
4735
|
case CMD.resourceDown: {
|
|
4648
4736
|
const a = cmd.args;
|
|
4649
4737
|
const m = this.equipment.get(a?.resourceId ?? "");
|
|
4650
|
-
if (!m) return
|
|
4738
|
+
if (!m) return noEquipment(a?.resourceId ?? "");
|
|
4651
4739
|
if (m.status !== "down") {
|
|
4652
4740
|
m.status = "down";
|
|
4653
4741
|
m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 6e4);
|
|
@@ -4656,8 +4744,9 @@ var FlowEngine = class {
|
|
|
4656
4744
|
return ok();
|
|
4657
4745
|
}
|
|
4658
4746
|
case CMD.resourceRepair: {
|
|
4659
|
-
const
|
|
4660
|
-
|
|
4747
|
+
const resourceId = cmd.args?.resourceId ?? "";
|
|
4748
|
+
const m = this.equipment.get(resourceId);
|
|
4749
|
+
if (!m) return noEquipment(resourceId);
|
|
4661
4750
|
if (m.status === "down") {
|
|
4662
4751
|
m.status = m.taskId ? "busy" : "idle";
|
|
4663
4752
|
m.repairUntilMs = void 0;
|
|
@@ -4667,8 +4756,9 @@ var FlowEngine = class {
|
|
|
4667
4756
|
return ok();
|
|
4668
4757
|
}
|
|
4669
4758
|
case CMD.resourceResetMetrics: {
|
|
4670
|
-
const
|
|
4671
|
-
|
|
4759
|
+
const resourceId = cmd.args?.resourceId ?? "";
|
|
4760
|
+
const m = this.equipment.get(resourceId);
|
|
4761
|
+
if (!m) return noEquipment(resourceId);
|
|
4672
4762
|
m.runMs = 0;
|
|
4673
4763
|
m.setupMs = 0;
|
|
4674
4764
|
m.downMs = 0;
|
|
@@ -4681,8 +4771,8 @@ var FlowEngine = class {
|
|
|
4681
4771
|
}
|
|
4682
4772
|
case CMD.resourceAdd: {
|
|
4683
4773
|
const a = cmd.args;
|
|
4684
|
-
if (!a?.kind) return fail("kind-required");
|
|
4685
|
-
if (!a?.homeLocation || !this.locations.has(a.homeLocation)) return fail("home-location-not-found", { homeLocation: a?.homeLocation ?? "" });
|
|
4774
|
+
if (!a?.kind) return fail("kind-required", "resource.add needs args.kind");
|
|
4775
|
+
if (!a?.homeLocation || !this.locations.has(a.homeLocation)) return fail("home-location-not-found", `no location "${a?.homeLocation ?? ""}" to use as home`, { homeLocation: a?.homeLocation ?? "" });
|
|
4686
4776
|
const count = Math.max(1, Math.min(50, Number(a.count) || 1));
|
|
4687
4777
|
let seq = this.equipment.size;
|
|
4688
4778
|
for (let i = 0; i < count; i++) {
|
|
@@ -4700,7 +4790,7 @@ var FlowEngine = class {
|
|
|
4700
4790
|
}
|
|
4701
4791
|
/** 도메인 커맨드 처리(order.release 등). 기본은 거절 — 도메인이 override. */
|
|
4702
4792
|
handleCommand(cmd) {
|
|
4703
|
-
return { commandId: cmd.commandId, accepted: false, errorCode: "unknown-command", errorParams: { type: cmd.type }, error: `
|
|
4793
|
+
return { commandId: cmd.commandId, accepted: false, errorCode: "unknown-command", errorParams: { type: cmd.type }, error: `no command of type "${cmd.type}"` };
|
|
4704
4794
|
}
|
|
4705
4795
|
scenario = {
|
|
4706
4796
|
load: (def) => {
|
|
@@ -6688,7 +6778,10 @@ var FlowEngine = class {
|
|
|
6688
6778
|
...i.uom ? { uom: i.uom } : {},
|
|
6689
6779
|
...i.quantities?.length ? { quantities: i.quantities } : {},
|
|
6690
6780
|
...i.expiry !== void 0 ? { expiry: i.expiry } : {},
|
|
6691
|
-
...i.ilmd ? { ilmd: i.ilmd } : {}
|
|
6781
|
+
...i.ilmd ? { ilmd: i.ilmd } : {},
|
|
6782
|
+
/* 로트의 두 표준 칸(§FlowItem) — 여기서 빠지면 리듀서·엔진에는 있고 스냅샷에는 없다. */
|
|
6783
|
+
...i.nonconformance ? { nonconformance: i.nonconformance } : {},
|
|
6784
|
+
...i.status ? { status: i.status } : {}
|
|
6692
6785
|
};
|
|
6693
6786
|
}
|
|
6694
6787
|
/**
|
|
@@ -9858,6 +9951,8 @@ function electricityCost(input) {
|
|
|
9858
9951
|
compareStates,
|
|
9859
9952
|
constantDuration,
|
|
9860
9953
|
counterfactualAt,
|
|
9954
|
+
counterfactualFrom,
|
|
9955
|
+
counterfactualFromAsync,
|
|
9861
9956
|
demandWindowStart,
|
|
9862
9957
|
deriveAttentions,
|
|
9863
9958
|
electricityCost,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operato/twin-kernel",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
|
|
6
6
|
"publishConfig": {
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
"test": "node --test test/*.test.ts"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@operato/ops-contract": "^0.9.
|
|
31
|
+
"@operato/ops-contract": "^0.9.22"
|
|
32
32
|
}
|
|
33
33
|
}
|