@operato/twin-kernel 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -5
- package/dist/capability.js +2 -2
- package/dist/capacity.d.ts +99 -0
- package/dist/capacity.js +172 -0
- package/dist/contract.d.ts +836 -45
- package/dist/contract.js +519 -7
- package/dist/divergence.d.ts +1 -1
- package/dist/divergence.js +8 -5
- package/dist/domain-catalog.d.ts +4 -4
- package/dist/domain-catalog.js +4 -4
- package/dist/domain-definition.d.ts +50 -5
- package/dist/domain-definition.js +6 -6
- package/dist/epcis.d.ts +12 -0
- package/dist/epcis.js +12 -0
- package/dist/event-journal.d.ts +31 -1
- package/dist/event-journal.js +27 -1
- package/dist/flow-engine.d.ts +323 -36
- package/dist/flow-engine.js +868 -164
- package/dist/forecast.d.ts +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/job-response.d.ts +59 -0
- package/dist/job-response.js +56 -0
- package/dist/kernel.d.ts +5 -0
- package/dist/kernel.js +31 -11
- package/dist/mes-kernel.d.ts +28 -0
- package/dist/mes-kernel.js +83 -29
- package/dist/mes-profile.d.ts +2 -2
- package/dist/mes-profile.js +11 -11
- package/dist/observed-reducer.d.ts +68 -7
- package/dist/observed-reducer.js +248 -41
- package/dist/task-fold.d.ts +2 -2
- package/dist/task-fold.js +4 -4
- package/dist/vocabulary.d.ts +19 -0
- package/dist/vocabulary.js +66 -0
- package/dist/wms-profile.d.ts +2 -2
- package/dist/wms-profile.js +6 -6
- package/dist/yms-kernel.js +36 -15
- package/dist/yms-profile.d.ts +2 -2
- package/dist/yms-profile.js +6 -6
- package/dist-cjs/index.cjs +1618 -270
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
물류창고·야드·스마트팩토리의 **비즈니스 실행**을 시뮬레이션·모니터링하는 **헤드리스·프레임워크 무관·zero-dep** TS 커널. UI/3D/DOM 없이 순수 Node 에서 도는 것이 목적(sim=능동 생산, live=수동 미러, 계약 동일).
|
|
4
4
|
|
|
5
5
|
- 실행: `node --test test/*.test.ts` (Node 25 네이티브 TS, **무의존**)
|
|
6
|
-
- 상태: **
|
|
6
|
+
- 상태: **v0.2.3 · 261 tests green**, 3 버티컬(WMS/YMS/MES), ISA-95 4대 자원
|
|
7
7
|
|
|
8
8
|
## 계층 (아래로만 의존, 무방언)
|
|
9
9
|
|
|
@@ -12,7 +12,9 @@ contract.ts 3채널 계약(State/Command/Scenario) + 운영 델타 + TwinKe
|
|
|
12
12
|
epcis.ts GS1 EPCIS 2.0 이벤트 machinery(타입·빌더·검증기·URI·CBV disp) ← 표준(도메인 무관)
|
|
13
13
|
flow-engine.ts FlowEngine base — mechanics(RNG·clock·tick·자원배정·태스크진행·emit·snapshot)
|
|
14
14
|
allocation-policy.ts AllocationPolicy 확장 시임(selectPlacement/selectStock)
|
|
15
|
-
|
|
15
|
+
observed-reducer.ts 관측 이벤트 → State 접기(**두 구동이 쓰는 유일한 규칙**) ← 도메인 무관
|
|
16
|
+
domain-definition.ts 공정 명세(ISA-95 OperationsSegment: 소요·변동·수율·자원 요구)
|
|
17
|
+
iso-duration.ts xsd:duration 파서(달력 의존 단위는 거절)
|
|
16
18
|
runtime.ts TwinRuntime — host-facing facade + 구독 프로토콜 ← 도메인/전송 무관
|
|
17
19
|
face2-adapter.ts 레거시 레코드 → 정규 EPCIS(선언적 매핑 + 검증) ← ACL
|
|
18
20
|
|
|
@@ -20,7 +22,7 @@ face2-adapter.ts 레거시 레코드 → 정규 EPCIS(선언적 매핑 + 검증)
|
|
|
20
22
|
{wms,yms,mes}-kernel.ts 도메인 flow 동사(4 hook) — FlowEngine 확장
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
> **무방언 원칙**: 코어(contract/epcis/flow-engine/policy/
|
|
25
|
+
> **무방언 원칙**: 코어(contract/epcis/flow-engine/policy/reducer/runtime)에 특정 도메인 용어를 넣지 않는다. EPCIS 는 표준이라 `epcis.ts`(≠wms). order.kind·task.resourceType 등은 도메인이 소유. 정책 인터페이스는 `selectPlacement`/`selectStock`(≠putaway/pallets).
|
|
24
26
|
|
|
25
27
|
## FlowEngine — 단일 base, 도메인은 4 hook 만
|
|
26
28
|
|
|
@@ -45,6 +47,31 @@ base 는 task **생명주기**(자원 배정·진행·완료·해제·델타)만
|
|
|
45
47
|
|
|
46
48
|
두 진화 축: 시간창 스케줄링=흡수, 자원-타입 매칭=최소 확장. 둘 다 backward-compatible(기존 도메인 무영향, 예제 바이트 동일로 검증).
|
|
47
49
|
|
|
50
|
+
## 4대 자원 (ISA-95)
|
|
51
|
+
|
|
52
|
+
작업이 요구하는 자원을 표준대로 넷으로 나눠 든다. **한 그릇에 담지 않는 이유**: 사람은 고장 나지 않고 교대로 살고, 설비는 고장 나며 OEE 로 평가받고, 팔레트는 돌아온다. 섞으면 한쪽 어휘가 다른 쪽에 붙어 둘 다 거짓이 된다.
|
|
53
|
+
|
|
54
|
+
| ISA-95 | 커널 | 요구 명세 |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| Personnel | `persons` · `PersonState` | `personnelSpecification` = 등급 + 인원 수 |
|
|
57
|
+
| Equipment | `movers` · `MoverState` | `equipmentSpecification` = 등급 + 대수 |
|
|
58
|
+
| PhysicalAsset | `assets` · `AssetState` (GS1 **GRAI**) | `physicalAssetSpecification` |
|
|
59
|
+
| Material | `items` · `ItemState` (EPCIS) | — |
|
|
60
|
+
|
|
61
|
+
배정 규율: **부분 확보로 시작하지 않는다.** 한 종류라도 모자라면 기다린다(반쯤 잡고 실패하면 자원이 일 없이 묶인다). 확보(`claim*`)와 확정(`assign*`)이 나뉜 이유. 완료 시 함께 해제하되 **자산은 도착 자리에 남는다**(물건이므로 — 회수의 출발점).
|
|
62
|
+
|
|
63
|
+
> 팔레트를 물품으로 두지 않은 이유: GS1 에서 **SSCC**(물류단위)와 **GRAI**(돌아오는 팔레트 자체)는 다른 것이다. 같은 GRAI 가 오늘은 이 SSCC 를, 내일은 다른 SSCC 를 싣는다. 그래서 **신설하고 연결**한다(`AssetState.carrying` ↔ `ItemState.carriedBy`).
|
|
64
|
+
|
|
65
|
+
## 공정 명세와 소요시간 세 층
|
|
66
|
+
|
|
67
|
+
`OperationDef` 가 ISA-95 `OperationsSegment` 를 담는다 — `duration`(xsd:duration) · `variability`(퍼짐) · `parameters`(수율·준비시간) · 자원 요구 셋. 소요시간은 **강한 근거부터** 찾는다:
|
|
68
|
+
|
|
69
|
+
**실측 추정기(호스트 주입) > 선언된 명세 > 커널 상수**
|
|
70
|
+
|
|
71
|
+
`specCoverage()` 가 무엇을 썼는지 세어 예측의 **자격**을 낸다 — `relative`(전부 상수: 대안 비교만) · `partial` · `absolute-capable` · `calibrated`. 근거가 없다고 거절하지 않고, **무엇에 근거했는지 함께 말한다.**
|
|
72
|
+
|
|
73
|
+
> 표준이 정하지 않은 것: ISA-95 는 자원 구조는 정하지만 파라미터 ID 어휘는 정하지 않는다. 그래서 `OP_PARAM`(yield·setupDuration) **한 곳**에서 정하고, 발명한 자리임을 숨기지 않는다.
|
|
74
|
+
|
|
48
75
|
## 3채널 계약 (Face 1) + 구독
|
|
49
76
|
|
|
50
77
|
- **State**: `getSnapshot()`(스냅샷) + 델타 이벤트 스트림. 델타 = EPCIS(재고/위치/조립/변환) + 운영 델타(`task/equipment/order.status`, EPCIS 로 재구성 불가한 절반).
|
|
@@ -55,7 +82,15 @@ base 는 task **생명주기**(자원 배정·진행·완료·해제·델타)만
|
|
|
55
82
|
## State 이원 모델
|
|
56
83
|
- **EPCIS 저널**(이산): 재고·위치·조립·변환. `epcis.ts` 로 정규 방출, `validateEpcisEvent` 검증.
|
|
57
84
|
- **운영·키네마틱**(연속): 무버 motion(from/to/progress)·태스크·오더 진척. 운영 델타로 미러.
|
|
58
|
-
-
|
|
85
|
+
- **`ObservedReducer`** 가 둘을 접어 재구성 → sim 이벤트든 live 이벤트든 같은 State(데이터원 스왑). `StateProjector` 는 같은 것의 옛 이름(별칭 유지).
|
|
86
|
+
|
|
87
|
+
### 구동은 둘, 규칙은 하나 — 적합성 하네스
|
|
88
|
+
|
|
89
|
+
상태를 만드는 구동이 둘이다(시뮬 `tick()` / 관측 `apply()`). 각자 계약으로 옮기는 코드가 두 벌이면 한쪽만 고쳤을 때 **조용히 갈라진다** — 계약 필드가 거의 다 선택이라 "안 채우는 것이 합법"이고, 컴파일러가 잡아 주지 않는다.
|
|
90
|
+
|
|
91
|
+
> **불변식: 커널 상태의 모든 사실은 이벤트로 나가야 한다.**
|
|
92
|
+
|
|
93
|
+
나가지 않는 사실은 미러가 모르고, 저널로 복원되지도 않고(시간여행), 미러에서 세운 예측 씨앗에도 실리지 않는다. `test/driver-conformance.test.ts` 가 시뮬을 굴려 **그 이벤트를 그대로 미러에 흘리고** 두 스냅샷을 대조한다(WMS·YMS·MES). 정당한 예외는 주석이 아니라 **상수로** 들고 이유를 적는다(호스트가 적분하는 `oee`, 매 tick 가지 않는 보간값 — 대신 **앵커는 반드시 같다**).
|
|
59
94
|
|
|
60
95
|
## 트윈 본연 — 현재로부터 예측 + 정합 (mirror+sim 과 구별짓는 기능)
|
|
61
96
|
관측·예측·행위가 따로 있는 건 mirror+simulator. 트윈의 본질은 그 **커플링**:
|
|
@@ -77,4 +112,10 @@ base 는 task **생명주기**(자원 배정·진행·완료·해제·델타)만
|
|
|
77
112
|
- **host 결합**: `TwinRuntime` 를 GraphQL sub·서비스로 래핑(전송은 얇은 host 계층).
|
|
78
113
|
|
|
79
114
|
## 미구현(범위 밖)
|
|
80
|
-
host 결합(things-factory 전송/퍼시스턴스/커넥터) · 보드 바인딩(컴포넌트↔SGLN) · 도메인 깊이(WMS 멀티라인, YMS 상하차 AggregationEvent, MES BOM
|
|
115
|
+
host 결합(things-factory 전송/퍼시스턴스/커넥터) · 보드 바인딩(컴포넌트↔SGLN) · 도메인 깊이(WMS 멀티라인, YMS 상하차 AggregationEvent, MES BOM).
|
|
116
|
+
|
|
117
|
+
**모델의 남은 공백**(정직하게): 인원 자격·숙련도(등급만 있고 자격 매칭·숙련도별 소요 차이 없음) · 자산 회수 작업(빈 팔레트가 도착 자리에 남을 뿐) · `movers`/`nodes` 계층 겸직(이동설비↔워크센터, 위치↔워크센터) · next-event 도약(지금은 고정 간격 tick).
|
|
118
|
+
|
|
119
|
+
**1.0 게이트**: `movers`/`nodes` 계층을 정리하거나 가산적으로만 가능하도록 확정한 뒤. 계약 파괴가 예정된 채로 1.0 을 주면 다음 라운드가 곧 2.0 이 된다.
|
|
120
|
+
|
|
121
|
+
설계 SoT: `operato-twin/design/plans/` — `simulation-spec.md`(명세·추정기·예측 자격) · `four-resources-and-conformance.md`(4대 자원·불변식) · `kernel-unification-live-observe.md`(구동 통합).
|
package/dist/capability.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* things-scene 정합: 이건 "의도(intent)" 계층(관측). 씬 기제 믹스(Capacity/Transferable/CarrierLine …)가
|
|
11
11
|
* 이 의도를 *실현*한다 — 공유 계약은 관측(계층 B)뿐, 기제 methods(계층 A)는 공유 안 함.
|
|
12
|
-
* ⚠ Mobile ≠ Transferable: Mobile=자원 자신이 이동, Transferable
|
|
12
|
+
* ⚠ Mobile ≠ Transferable: Mobile=자원 자신이 이동, Transferable=자리가 아이템 이동(씬 기제).
|
|
13
13
|
*/
|
|
14
14
|
export const CAPABILITIES = {
|
|
15
15
|
operable: {
|
|
@@ -21,7 +21,7 @@ export const CAPABILITIES = {
|
|
|
21
21
|
stateFields: ['occupancy', 'capacity'], invariants: ['0 <= occupancy <= capacity (capacity>0)'], results: ['occupancyChanged']
|
|
22
22
|
},
|
|
23
23
|
mobile: {
|
|
24
|
-
key: 'mobile', label: '이동', semantics: '자원 자신이
|
|
24
|
+
key: 'mobile', label: '이동', semantics: '자원 자신이 자리 간 이동. Transferable(아이템 이동)과 다름. (씬 기제: CarrierLine)',
|
|
25
25
|
stateFields: ['location', 'motion'], models: ['Motion'], results: ['moved', 'motionTick']
|
|
26
26
|
},
|
|
27
27
|
processable: {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type WorkCalendarEntry } from './contract.ts';
|
|
2
|
+
import type { OperationDef } from './domain-definition.ts';
|
|
3
|
+
/** 제약이 걸린 축 — 무엇을 늘려야 하는지가 여기서 갈린다. */
|
|
4
|
+
export type CapacityAxis = 'equipment' | 'personnel' | 'asset' | 'location';
|
|
5
|
+
export interface CapacityRequirement {
|
|
6
|
+
axis: CapacityAxis;
|
|
7
|
+
/** 요구 등급/종류(설비 kind · 인원 class · 자산 class · 자리 type). */
|
|
8
|
+
className: string;
|
|
9
|
+
/** 작업 한 건이 동시에 잡는 수. */
|
|
10
|
+
quantity: number;
|
|
11
|
+
/** 실제로 있는 수. */
|
|
12
|
+
available: number;
|
|
13
|
+
/** 가동률 — 설비만 mtbf/mttr 에서 나온다. 나머지는 1(모델 없음). */
|
|
14
|
+
availability: number;
|
|
15
|
+
/** 이 요구만 놓고 봤을 때 시간당 낼 수 있는 대수. */
|
|
16
|
+
perHour: number;
|
|
17
|
+
}
|
|
18
|
+
export interface OperationCapacity {
|
|
19
|
+
operation: string;
|
|
20
|
+
cycleHours: number;
|
|
21
|
+
/** 이 공정이 시간당 낼 수 있는 대수 = 요구들 중 가장 낮은 것. */
|
|
22
|
+
perHour: number;
|
|
23
|
+
/** 하류 수율까지 물린 소요량. */
|
|
24
|
+
requiredPerHour: number;
|
|
25
|
+
ok: boolean;
|
|
26
|
+
/** 이 공정을 묶고 있는 요구(가장 낮은 것). 요구가 하나도 없으면 없다. */
|
|
27
|
+
constraint?: CapacityRequirement;
|
|
28
|
+
requirements: CapacityRequirement[];
|
|
29
|
+
}
|
|
30
|
+
export interface CapacityAnalysis {
|
|
31
|
+
/** 근무 캘린더를 실제로 샘플링해서 얻는다 — 규칙을 다시 적지 않는다. */
|
|
32
|
+
workingHoursPerWeek: number;
|
|
33
|
+
workingDaysPerWeek: number;
|
|
34
|
+
demandPerHour: number;
|
|
35
|
+
operations: OperationCapacity[];
|
|
36
|
+
/** 라인 전체를 묶고 있는 공정과 축. 공정이 없으면 없다. */
|
|
37
|
+
bottleneck?: {
|
|
38
|
+
operation: string;
|
|
39
|
+
axis?: CapacityAxis;
|
|
40
|
+
className?: string;
|
|
41
|
+
};
|
|
42
|
+
/** 이 공장의 천장(하루). */
|
|
43
|
+
maxUnitsPerDay: number;
|
|
44
|
+
ok: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface CapacityInput {
|
|
47
|
+
operations: readonly OperationDef[];
|
|
48
|
+
/** 라우트 순서 — 수율을 거슬러 올릴 때 쓴다. 없으면 `operations` 순서를 쓴다. */
|
|
49
|
+
route?: readonly string[];
|
|
50
|
+
equipment?: readonly {
|
|
51
|
+
kind: string;
|
|
52
|
+
mtbfMs?: number;
|
|
53
|
+
mttrMs?: number;
|
|
54
|
+
}[];
|
|
55
|
+
persons?: readonly {
|
|
56
|
+
personnelClassIds?: readonly string[];
|
|
57
|
+
}[];
|
|
58
|
+
assets?: readonly {
|
|
59
|
+
assetClassIds?: readonly string[];
|
|
60
|
+
}[];
|
|
61
|
+
locations?: readonly {
|
|
62
|
+
type?: string;
|
|
63
|
+
capacity?: number;
|
|
64
|
+
}[];
|
|
65
|
+
calendar?: readonly WorkCalendarEntry[];
|
|
66
|
+
/**
|
|
67
|
+
* 가용 시간을 샘플링할 기준 주의 시작(월요일 00:00, ms).
|
|
68
|
+
*
|
|
69
|
+
* **공휴일이 없는 평상주를 골라야 한다** — 공휴일은 연간 가용량을 따로 깎지, 이 공장의 평상시
|
|
70
|
+
* 천장을 정하지 않는다. 기본값을 두지 않는 이유: 커널이 임의의 주를 고르면 그 주에 공휴일이
|
|
71
|
+
* 들어 있을 때 천장이 조용히 낮아진다.
|
|
72
|
+
*/
|
|
73
|
+
sampleWeekStartMs: number;
|
|
74
|
+
utcOffsetMinutes?: number;
|
|
75
|
+
/** 하루 몇 대를 낼 것인가 — 선언된 수요. */
|
|
76
|
+
unitsPerDay: number;
|
|
77
|
+
}
|
|
78
|
+
/** ISO 8601 기간 → 시간. 명세가 쓰는 표기 그대로 읽는다(`PT1H40M`). */
|
|
79
|
+
export declare function isoDurationHours(iso: string | undefined): number;
|
|
80
|
+
/**
|
|
81
|
+
* 근무 캘린더에서 **가용 시간과 조업일**을 읽는다 — 1분 간격 샘플링.
|
|
82
|
+
*
|
|
83
|
+
* 교대·휴게·비근무 규칙을 여기 다시 적지 않는다. 규칙은 `inWorkCalendarAt` 한 곳에만 있고, 이 함수는
|
|
84
|
+
* 그것에 묻기만 한다. 두 벌이 되면 달력을 고칠 때 한쪽만 고쳐져 갈라진다.
|
|
85
|
+
*
|
|
86
|
+
* 캘린더가 없으면 **종일 가동**으로 본다(7일 × 24h) — 제약이 없는 것이 아니라 **선언되지 않은** 것이고,
|
|
87
|
+
* 선언이 없으면 커널은 멈출 이유를 모른다.
|
|
88
|
+
*/
|
|
89
|
+
export declare function workingTimeOfWeek(calendar: readonly WorkCalendarEntry[] | undefined, weekStartMs: number, utcOffsetMinutes?: number): {
|
|
90
|
+
hoursPerWeek: number;
|
|
91
|
+
daysPerWeek: number;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* 이 공장이 선언된 물량을 낼 수 있는가 — 공정마다, 자원 축마다.
|
|
95
|
+
*
|
|
96
|
+
* 순수 함수다. 커널 상태를 읽지 않고 넘겨받은 것만 본다 — 그래야 "이 설비를 두 대 더 놓으면?" 을
|
|
97
|
+
* 굴려 보지 않고 물을 수 있다(what-if 의 가장 싼 형태).
|
|
98
|
+
*/
|
|
99
|
+
export declare function analyzeCapacity(input: CapacityInput): CapacityAnalysis;
|
package/dist/capacity.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 용량 분석 — **굴려 보기 전에 "이 공장이 그 물량을 낼 수 있는가" 에 답한다.**
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 커널인가 ──────────────────────────────────────────────────────────────
|
|
5
|
+
* 이 계산을 처음에는 레퍼런스 마스터 옆에서 손으로 했다(Rosarito). 그러다 두 가지를 알았다.
|
|
6
|
+
*
|
|
7
|
+
* 첫째, **입력이 전부 커널에 있다.** 공정 명세(소요·셋업·수율)·설비와 그 신뢰도·인원·물리자산·자리·
|
|
8
|
+
* 근무 캘린더. 바깥에서 계산하면 그 값을 옮겨 적게 되고, 한쪽만 고치는 순간 "충분하다" 가 조용히
|
|
9
|
+
* 거짓이 된다.
|
|
10
|
+
*
|
|
11
|
+
* 둘째, **손계산은 설비만 셌다.** 표준 공정 명세는 네 축으로 자원을 요구한다(설비·인원·물리자산·자재)
|
|
12
|
+
* — 그리고 현장에서 가장 자주 모자라는 것은 사람이다. 설비만 세는 계산은 "용접 자격자가 2명뿐이라
|
|
13
|
+
* 로봇 6대가 논다" 를 구조적으로 못 본다. 네 축을 다 보는 자리는 커널뿐이다.
|
|
14
|
+
*
|
|
15
|
+
* ── 이것이 시뮬레이션과 다른 점 ─────────────────────────────────────────────
|
|
16
|
+
* 시뮬레이션은 **굴려 봐야** 답이 나오고, 변동·고장·줄서기가 섞인 결과를 준다. 이 계산은 **정상상태
|
|
17
|
+
* 상한**이다 — 모든 것이 계획대로 흘렀을 때의 천장. 둘은 서로를 대체하지 않는다:
|
|
18
|
+
* - 상한이 수요보다 낮으면 **시뮬레이션을 돌릴 필요가 없다.** 무슨 짓을 해도 못 낸다.
|
|
19
|
+
* - 상한이 충분한데 시뮬레이션이 못 내면 그것은 **흐름의 문제**다(줄서기·배치·변동).
|
|
20
|
+
*
|
|
21
|
+
* 그래서 이 계산은 진단이 아니라 **분류**다. 어느 쪽 문제인지부터 갈라 준다.
|
|
22
|
+
*
|
|
23
|
+
* ── 재지 않는 것 ─────────────────────────────────────────────────────────────
|
|
24
|
+
* 자재는 여기서 제약으로 세지 않는다. 자재는 **보충되는 것**이라 대수처럼 고정 공급이 아니고,
|
|
25
|
+
* 부족은 조달 문제이지 용량 문제가 아니다(자재 부족은 `work-backlog` 가 흐름에서 잡는다).
|
|
26
|
+
* 인원·자산에는 신뢰도 모델이 없다 — 가동률 1로 본다(설비만 mtbf/mttr 를 갖는다).
|
|
27
|
+
*/
|
|
28
|
+
import { inWorkCalendarAt } from "./contract.js";
|
|
29
|
+
/** ISO 8601 기간 → 시간. 명세가 쓰는 표기 그대로 읽는다(`PT1H40M`). */
|
|
30
|
+
export function isoDurationHours(iso) {
|
|
31
|
+
if (!iso)
|
|
32
|
+
return 0;
|
|
33
|
+
const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/.exec(iso);
|
|
34
|
+
if (!m)
|
|
35
|
+
throw new Error(`기간 표기를 읽을 수 없다: ${iso}`);
|
|
36
|
+
return Number(m[1] ?? 0) * 24 + Number(m[2] ?? 0) + Number(m[3] ?? 0) / 60 + Number(m[4] ?? 0) / 3600;
|
|
37
|
+
}
|
|
38
|
+
function paramOf(op, id) {
|
|
39
|
+
return op.parameters?.find(p => p.id === id)?.value;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 근무 캘린더에서 **가용 시간과 조업일**을 읽는다 — 1분 간격 샘플링.
|
|
43
|
+
*
|
|
44
|
+
* 교대·휴게·비근무 규칙을 여기 다시 적지 않는다. 규칙은 `inWorkCalendarAt` 한 곳에만 있고, 이 함수는
|
|
45
|
+
* 그것에 묻기만 한다. 두 벌이 되면 달력을 고칠 때 한쪽만 고쳐져 갈라진다.
|
|
46
|
+
*
|
|
47
|
+
* 캘린더가 없으면 **종일 가동**으로 본다(7일 × 24h) — 제약이 없는 것이 아니라 **선언되지 않은** 것이고,
|
|
48
|
+
* 선언이 없으면 커널은 멈출 이유를 모른다.
|
|
49
|
+
*/
|
|
50
|
+
export function workingTimeOfWeek(calendar, weekStartMs, utcOffsetMinutes) {
|
|
51
|
+
if (!calendar?.length)
|
|
52
|
+
return { hoursPerWeek: 7 * 24, daysPerWeek: 7 };
|
|
53
|
+
let minutes = 0;
|
|
54
|
+
const touched = new Set();
|
|
55
|
+
for (let m = 0; m < 7 * 24 * 60; m++) {
|
|
56
|
+
if (!inWorkCalendarAt(calendar, weekStartMs + m * 60_000, utcOffsetMinutes))
|
|
57
|
+
continue;
|
|
58
|
+
minutes++;
|
|
59
|
+
touched.add(Math.floor((m + (utcOffsetMinutes ?? 0)) / (24 * 60)));
|
|
60
|
+
}
|
|
61
|
+
/*
|
|
62
|
+
* 조업일은 **선언이 말하는 것**이지 타임라인이 번진 자국이 아니다.
|
|
63
|
+
*
|
|
64
|
+
* 야간 교대는 자정을 넘는다 — 금요일 밤에 시작한 교대는 토요일 새벽에 끝난다. 샘플링한 분을
|
|
65
|
+
* 날짜로 세면 월~금 3교대가 **6일**로 나오고, 그러면 주간 수요가 20% 부풀어 필요 없는 설비를
|
|
66
|
+
* 사라고 답한다. 교대는 "월~금에 **시작한다**" 고 선언돼 있고(`daysOfWeek`), 그것이 조업일이다.
|
|
67
|
+
*
|
|
68
|
+
* 요일 선언이 아예 없는 달력(절대 구간만 쓰는 경우)에서는 셀 근거가 없으므로 번진 자국을 쓴다.
|
|
69
|
+
*/
|
|
70
|
+
const declared = new Set();
|
|
71
|
+
for (const e of calendar)
|
|
72
|
+
if (e.entryType !== 'non-working')
|
|
73
|
+
for (const d of e.daysOfWeek ?? [])
|
|
74
|
+
declared.add(d);
|
|
75
|
+
return { hoursPerWeek: minutes / 60, daysPerWeek: declared.size || touched.size };
|
|
76
|
+
}
|
|
77
|
+
function availabilityOf(records) {
|
|
78
|
+
/* 신뢰도를 선언하지 않은 설비는 고장 없음이다(계약의 규칙) — 1 로 센다. */
|
|
79
|
+
const ratios = records.map(r => (r.mtbfMs && r.mttrMs ? r.mtbfMs / (r.mtbfMs + r.mttrMs) : 1));
|
|
80
|
+
return ratios.length ? ratios.reduce((a, b) => a + b, 0) / ratios.length : 1;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 이 공장이 선언된 물량을 낼 수 있는가 — 공정마다, 자원 축마다.
|
|
84
|
+
*
|
|
85
|
+
* 순수 함수다. 커널 상태를 읽지 않고 넘겨받은 것만 본다 — 그래야 "이 설비를 두 대 더 놓으면?" 을
|
|
86
|
+
* 굴려 보지 않고 물을 수 있다(what-if 의 가장 싼 형태).
|
|
87
|
+
*/
|
|
88
|
+
export function analyzeCapacity(input) {
|
|
89
|
+
const { hoursPerWeek, daysPerWeek } = workingTimeOfWeek(input.calendar, input.sampleWeekStartMs, input.utcOffsetMinutes);
|
|
90
|
+
/*
|
|
91
|
+
* 수요는 **가동 시간당**이다. 하루 50대는 쉬는 시간에는 안 나오므로 168시간으로 나누면 안 된다 —
|
|
92
|
+
* 그렇게 나누면 필요한 대수가 실제보다 적게 나오고, 공장은 매주 조금씩 밀린다.
|
|
93
|
+
*/
|
|
94
|
+
const demandPerHour = hoursPerWeek > 0 ? (input.unitsPerDay * daysPerWeek) / hoursPerWeek : 0;
|
|
95
|
+
/* 축별 공급 — 등급 이름으로 센다. 사람은 자격을 여럿 가질 수 있어 등급 간 합이 인원수를 넘는다
|
|
96
|
+
(같은 사람이 두 줄에 선다). 동시에 두 공정을 하지는 못하므로 이 계산은 **낙관적**이다. */
|
|
97
|
+
const equipmentByKind = new Map();
|
|
98
|
+
for (const e of input.equipment ?? []) {
|
|
99
|
+
const list = equipmentByKind.get(e.kind) ?? [];
|
|
100
|
+
list.push(e);
|
|
101
|
+
equipmentByKind.set(e.kind, list);
|
|
102
|
+
}
|
|
103
|
+
const countByClass = (rows, field) => {
|
|
104
|
+
const out = new Map();
|
|
105
|
+
for (const r of rows ?? [])
|
|
106
|
+
for (const c of r[field] ?? [])
|
|
107
|
+
out.set(c, (out.get(c) ?? 0) + 1);
|
|
108
|
+
return out;
|
|
109
|
+
};
|
|
110
|
+
const personsByClass = countByClass(input.persons, 'personnelClassIds');
|
|
111
|
+
const assetsByClass = countByClass(input.assets, 'assetClassIds');
|
|
112
|
+
const slotsByLocationType = new Map();
|
|
113
|
+
for (const l of input.locations ?? [])
|
|
114
|
+
if (l.type)
|
|
115
|
+
slotsByLocationType.set(l.type, (slotsByLocationType.get(l.type) ?? 0) + Math.max(l.capacity ?? 1, 1));
|
|
116
|
+
const order = input.route?.length
|
|
117
|
+
? input.route.map(k => input.operations.find(o => o.key === k)).filter((o) => !!o)
|
|
118
|
+
: [...input.operations];
|
|
119
|
+
/* 하류 수율을 거슬러 올라가며 소요량을 부풀린다 — 도장에서 5% 를 잃으면 그 앞은 더 만들어야 한다. */
|
|
120
|
+
let downstream = 1;
|
|
121
|
+
const reversed = [];
|
|
122
|
+
for (const op of [...order].reverse()) {
|
|
123
|
+
const cycleHours = isoDurationHours(op.duration) + isoDurationHours(paramOf(op, 'setupDuration'));
|
|
124
|
+
downstream *= Number(paramOf(op, 'yield') ?? 1);
|
|
125
|
+
const requiredPerHour = downstream > 0 ? demandPerHour / downstream : Infinity;
|
|
126
|
+
const requirements = [];
|
|
127
|
+
const add = (axis, className, quantity, available, availability) => {
|
|
128
|
+
const q = Math.max(quantity, 1);
|
|
129
|
+
requirements.push({
|
|
130
|
+
axis, className, quantity: q, available, availability,
|
|
131
|
+
perHour: cycleHours > 0 ? (Math.floor(available / q) / cycleHours) * availability : Infinity
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
/* 설비 — 표준 EquipmentSpecification(등급+대수). 그것이 없으면 예전 표기(resourceType 한 대). */
|
|
135
|
+
const equipSpecs = op.equipmentSpecification?.length
|
|
136
|
+
? op.equipmentSpecification.map(s => ({ className: s.equipmentClass ?? op.resourceType ?? '', quantity: s.quantity }))
|
|
137
|
+
: op.resourceType ? [{ className: op.resourceType, quantity: 1 }] : [];
|
|
138
|
+
for (const s of equipSpecs) {
|
|
139
|
+
if (!s.className)
|
|
140
|
+
continue;
|
|
141
|
+
const records = equipmentByKind.get(s.className) ?? [];
|
|
142
|
+
add('equipment', s.className, s.quantity, records.length, availabilityOf(records));
|
|
143
|
+
}
|
|
144
|
+
for (const s of op.personnelSpecification ?? [])
|
|
145
|
+
if (s.personnelClass)
|
|
146
|
+
add('personnel', s.personnelClass, s.quantity, personsByClass.get(s.personnelClass) ?? 0, 1);
|
|
147
|
+
for (const s of op.physicalAssetSpecification ?? [])
|
|
148
|
+
if (s.assetClass)
|
|
149
|
+
add('asset', s.assetClass, s.quantity, assetsByClass.get(s.assetClass) ?? 0, 1);
|
|
150
|
+
if (op.locationType)
|
|
151
|
+
add('location', op.locationType, 1, slotsByLocationType.get(op.locationType) ?? 0, 1);
|
|
152
|
+
/* 공정의 능력 = 요구들 중 가장 낮은 것. 전량 확보 규칙이라 하나만 모자라도 그만큼만 돈다. */
|
|
153
|
+
const constraint = requirements.length ? requirements.reduce((a, b) => (a.perHour <= b.perHour ? a : b)) : undefined;
|
|
154
|
+
const perHour = constraint ? constraint.perHour : Infinity;
|
|
155
|
+
reversed.push({ operation: op.key, cycleHours, perHour, requiredPerHour, ok: perHour >= requiredPerHour, constraint, requirements });
|
|
156
|
+
}
|
|
157
|
+
const operations = reversed.reverse();
|
|
158
|
+
/* 병목 = 여유가 가장 적은 공정. 라인 전체의 천장은 그 공정이 정한다. */
|
|
159
|
+
const tightest = operations.length
|
|
160
|
+
? operations.reduce((a, b) => (a.perHour / a.requiredPerHour <= b.perHour / b.requiredPerHour ? a : b))
|
|
161
|
+
: undefined;
|
|
162
|
+
const headroom = operations.length ? Math.min(...operations.map(o => o.perHour / o.requiredPerHour)) : Infinity;
|
|
163
|
+
return {
|
|
164
|
+
workingHoursPerWeek: hoursPerWeek,
|
|
165
|
+
workingDaysPerWeek: daysPerWeek,
|
|
166
|
+
demandPerHour,
|
|
167
|
+
operations,
|
|
168
|
+
...(tightest ? { bottleneck: { operation: tightest.operation, axis: tightest.constraint?.axis, className: tightest.constraint?.className } } : {}),
|
|
169
|
+
maxUnitsPerDay: input.unitsPerDay * headroom,
|
|
170
|
+
ok: operations.every(o => o.ok)
|
|
171
|
+
};
|
|
172
|
+
}
|