@operato/ops-contract 0.1.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 +23 -0
- package/dist/capability.d.ts +142 -0
- package/dist/capability.js +127 -0
- package/dist/capacity.d.ts +99 -0
- package/dist/capacity.js +172 -0
- package/dist/contract.d.ts +3557 -0
- package/dist/contract.js +1248 -0
- package/dist/domain-catalog.d.ts +280 -0
- package/dist/domain-catalog.js +322 -0
- package/dist/domain-definition.d.ts +356 -0
- package/dist/domain-definition.js +137 -0
- package/dist/ems-profile.d.ts +147 -0
- package/dist/ems-profile.js +367 -0
- package/dist/energy-ingest.d.ts +214 -0
- package/dist/energy-ingest.js +801 -0
- package/dist/epcis.d.ts +458 -0
- package/dist/epcis.js +640 -0
- package/dist/face2-adapter.d.ts +191 -0
- package/dist/face2-adapter.js +284 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +39 -0
- package/dist/iso-duration.d.ts +5 -0
- package/dist/iso-duration.js +43 -0
- package/dist/master-data.d.ts +46 -0
- package/dist/master-data.js +100 -0
- package/dist/mes-profile.d.ts +14 -0
- package/dist/mes-profile.js +59 -0
- package/dist/operational-ingest.d.ts +44 -0
- package/dist/operational-ingest.js +379 -0
- package/dist/operations-capability.d.ts +117 -0
- package/dist/operations-capability.js +120 -0
- package/dist/scenario-validate.d.ts +15 -0
- package/dist/scenario-validate.js +72 -0
- package/dist/vocabulary.d.ts +28 -0
- package/dist/vocabulary.js +81 -0
- package/dist/wms-profile.d.ts +20 -0
- package/dist/wms-profile.js +58 -0
- package/dist/yms-profile.d.ts +15 -0
- package/dist/yms-profile.js +39 -0
- package/dist-cjs/index.cjs +3767 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @operato/ops-contract
|
|
2
|
+
|
|
3
|
+
운영 도메인의 **계약** — 사실을 만드는 쪽과 읽는 쪽이 합의해야 하는 것.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
EPCIS 2.0 · GS1 물품의 이동과 계보
|
|
7
|
+
ISA-95 자재 · 설비 · 사람 · 공정
|
|
8
|
+
IEC 61850 · ISO 50001 전기 계통과 성과지표
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 무엇이 여기 있고 무엇이 없나
|
|
12
|
+
|
|
13
|
+
여기 있는 것은 **타입 · 판별 함수 · 정합 검증 · 어휘 상수**입니다. 인자만 보고 답합니다.
|
|
14
|
+
|
|
15
|
+
여기 없는 것은 **정책과 엔진**입니다. 그 사실로 무엇을 할지는 `@operato/twin-kernel` 이 정합니다.
|
|
16
|
+
|
|
17
|
+
## 왜 나뉘어 있나
|
|
18
|
+
|
|
19
|
+
사실을 **만드는 쪽**(MES · 커넥터)은 계약만 필요하고 엔진은 필요 없습니다. 한 패키지에 두면 레코드
|
|
20
|
+
하나를 만들려고 시뮬레이션 엔진과 네 도메인 커널을 의존 그래프에 올리게 됩니다.
|
|
21
|
+
|
|
22
|
+
타입만이면 `import type` 으로 피할 수 있지만, **판별 함수는 런타임 함수입니다**(`isEnergyRecord` 계열).
|
|
23
|
+
보내는 쪽이 보내기 전에 스스로 검증하려면 실제로 필요합니다.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export type CapabilityKey = 'operable' | 'storable' | 'mobile' | 'processable' | 'trackable' | 'switching' | 'metered' | 'curtailable' | 'energyGenerating' | 'energyStoring';
|
|
2
|
+
/** 이동 관측 — Mobile 이 노출. */
|
|
3
|
+
export interface Motion {
|
|
4
|
+
fromNode: string;
|
|
5
|
+
toNode: string;
|
|
6
|
+
startedAtSimMs: number;
|
|
7
|
+
durationMs: number;
|
|
8
|
+
progress: number;
|
|
9
|
+
elapsedMs: number;
|
|
10
|
+
}
|
|
11
|
+
export interface OperableState {
|
|
12
|
+
status: 'idle' | 'busy' | 'down';
|
|
13
|
+
}
|
|
14
|
+
export interface StorableState {
|
|
15
|
+
occupancy: number;
|
|
16
|
+
capacity: number;
|
|
17
|
+
}
|
|
18
|
+
export interface MobileState {
|
|
19
|
+
location: string;
|
|
20
|
+
motion?: Motion;
|
|
21
|
+
}
|
|
22
|
+
export interface ProcessableState {
|
|
23
|
+
output?: {
|
|
24
|
+
good: number;
|
|
25
|
+
scrap: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export interface TrackableState {
|
|
29
|
+
lifecycle: string;
|
|
30
|
+
progress?: number;
|
|
31
|
+
held: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 계량 — 이 자원은 **자기 소비를 잰다.**
|
|
35
|
+
*
|
|
36
|
+
* `kW` 는 그 계량 주기의 평균이고 순시값이 아니다(순시는 전력 품질의 영역이라 범위 밖).
|
|
37
|
+
* `kWh` 는 계기 누적값 — 차분은 소비처가 한다(계기 교체·리셋을 우리가 지어내지 않는다).
|
|
38
|
+
* 값이 오지 않는 계량기는 `0` 이 아니라 **모르는 것**이므로 필드가 없다(선택 필드인 이유).
|
|
39
|
+
*/
|
|
40
|
+
export interface MeteredState {
|
|
41
|
+
kW?: number;
|
|
42
|
+
kWh?: number;
|
|
43
|
+
powerFactor?: number;
|
|
44
|
+
}
|
|
45
|
+
/** 감축 가능 — 줄일 수 있다. `minKW` 는 최소 유지(그 아래로 내리면 공정이 죽는다). */
|
|
46
|
+
export interface CurtailableState {
|
|
47
|
+
curtailable: boolean;
|
|
48
|
+
minKW?: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 전기 계측 — 전압(V)·전류(A). 전기를 만들거나 쓰는 어떤 설비에서나 같은 모양이다.
|
|
52
|
+
*
|
|
53
|
+
* 발전·저장·개폐 어느 능력에나 붙을 수 있어 따로 둔다. 상별 값은 배열이다(단상이면 원소 하나).
|
|
54
|
+
*/
|
|
55
|
+
export interface ElectricalMeasurementState {
|
|
56
|
+
dcVoltage?: number;
|
|
57
|
+
dcCurrent?: number;
|
|
58
|
+
acVoltage?: number[];
|
|
59
|
+
acCurrent?: number[];
|
|
60
|
+
}
|
|
61
|
+
/** 발전 — 만든다. 역송(`exportKW`)은 소비의 음수가 아니라 별개 사실이다. */
|
|
62
|
+
export interface GeneratingState extends ElectricalMeasurementState {
|
|
63
|
+
generatedKW?: number;
|
|
64
|
+
exportKW?: number;
|
|
65
|
+
generatedKWh?: number;
|
|
66
|
+
generatedKWhResetAt?: string;
|
|
67
|
+
/** 지금 든 적산이 무엇부터 쌓인 것인가 — 원본이 말했을 때만 있다. */
|
|
68
|
+
generatedKWhSince?: string;
|
|
69
|
+
/** 되돌아간 판정이 사실인가 짐작인가 — 'declared' 또는 'inferred'. */
|
|
70
|
+
generatedKWhBasis?: 'declared' | 'inferred';
|
|
71
|
+
/** 어떤 누적인가 — 원본이 말하지 않으면 'unknown' 이다(칸이 비지 않는다). */
|
|
72
|
+
generatedKWhAccumulation?: 'lifetime' | 'daily' | 'monthly' | 'billing' | 'unknown';
|
|
73
|
+
/** 끝난 기간의 발전량(kWh) — 성과지표가 기간당 에너지로 정의된다. */
|
|
74
|
+
generatedKWhLastPeriod?: number;
|
|
75
|
+
/** 그 기간이 끝난 시각. */
|
|
76
|
+
generatedKWhLastPeriodEnd?: string;
|
|
77
|
+
/** 그 기간에서 처음·마지막으로 관측한 시각 — 기간 경계와 다르면 그만큼 재지 않았다. */
|
|
78
|
+
generatedKWhLastPeriodObservedFrom?: string;
|
|
79
|
+
generatedKWhLastPeriodObservedTo?: string;
|
|
80
|
+
}
|
|
81
|
+
/** 저장 — 담고 낸다. 충전과 방전을 나눈다(손실·수명 판단이 둘을 구별해야 한다). */
|
|
82
|
+
export interface StoringState {
|
|
83
|
+
soc?: number;
|
|
84
|
+
chargeKW?: number;
|
|
85
|
+
dischargeKW?: number;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 개폐 위치 — IEC 61850 `XCBR.Pos`·`XSWI.Pos` 의 넷 값을 그대로 쓴다.
|
|
89
|
+
*
|
|
90
|
+
* `intermediate`(과도)와 `bad`(위치 불량)를 **뭉개지 않는다**: 실 계통에서 이 둘은 이중 접점이 서로
|
|
91
|
+
* 다른 말을 하는 상태이고, 「열림/닫힘」으로 반올림하면 트윈이 없는 확신을 갖는다.
|
|
92
|
+
*/
|
|
93
|
+
export interface SwitchingState {
|
|
94
|
+
position?: 'open' | 'closed' | 'intermediate' | 'bad';
|
|
95
|
+
}
|
|
96
|
+
/** 능력 관측 계약 메타(발견·검증). ops 없음(원칙 ①). */
|
|
97
|
+
export interface CapabilitySpec {
|
|
98
|
+
key: CapabilityKey;
|
|
99
|
+
label: string;
|
|
100
|
+
semantics: string;
|
|
101
|
+
/** 관측 상태 필드(데이터 형식). 직교 — 어느 필드도 두 능력에 중복 없음(원칙 ②). */
|
|
102
|
+
stateFields: string[];
|
|
103
|
+
/**
|
|
104
|
+
* 이 능력을 가진 자원이 **받는 명령** — 나가는 사실(`results`)의 짝.
|
|
105
|
+
*
|
|
106
|
+
* ── 계약이 반쪽이었다 (2026-08-15) ────────────────────────────────────────
|
|
107
|
+
* 능력은 「무슨 값을 내보내는가」만 선언하고 있었다. 그런데 저작면이 알아야 할 것이 하나 더 있다:
|
|
108
|
+
* 「여기에 제어를 붙여도 되는가」. 적을 자리가 없어서 **능력 이름으로 눈치껏** 판단했다 —
|
|
109
|
+
* `operable` 이면 제어를 권하고 아니면 안 권했다.
|
|
110
|
+
*
|
|
111
|
+
* 차단기에서 그 방식이 막혔다. 상태는 보여주고 싶은데 `operable` 을 주면 화면이 우리가 하지 않기로
|
|
112
|
+
* 한 조작을 권한다. 그래서 능력을 새로 만들었고(`switching`), 능력은 상태 필드로 정의되므로 새
|
|
113
|
+
* 필드가 필요했는데 마침 표준이 `position` 이라는 이름을 줘서 됐다 — **그건 운이었다.**
|
|
114
|
+
*
|
|
115
|
+
* 없던 것은 참/거짓 하나가 아니라 **대칭**이다: 나가는 것은 선언돼 있고 들어오는 것은 없었다.
|
|
116
|
+
* 명령 어휘(`CMD`)는 이미 능력별로 묶여 있었지만 그 묶임이 **주석에만** 있었다 — 여기로 옮긴다.
|
|
117
|
+
*
|
|
118
|
+
* 비어 있다는 것이 곧 「조작하지 않는다」이다. 「명령을 받나」는 이 목록에서 **파생**된다 —
|
|
119
|
+
* 그래서 형용사를 새로 만들지 않는다.
|
|
120
|
+
*
|
|
121
|
+
* 트윈 수준 명령(`attention.ack`·`resource.add`)은 특정 자원의 능력이 아니므로 여기 넣지 않는다.
|
|
122
|
+
* 모든 명령이 능력에 속해야 한다는 규칙을 만들면 그것이 다음 땜빵이 된다.
|
|
123
|
+
*/
|
|
124
|
+
commands: string[];
|
|
125
|
+
/** 교환 데이터 모델 이름. */
|
|
126
|
+
models?: string[];
|
|
127
|
+
/** 불변식(모두 준수·신뢰). */
|
|
128
|
+
invariants?: string[];
|
|
129
|
+
/** 실행 결과(이벤트) 형식. */
|
|
130
|
+
results?: string[];
|
|
131
|
+
}
|
|
132
|
+
export declare const CAPABILITIES: Record<CapabilityKey, CapabilitySpec>;
|
|
133
|
+
export declare const CAPABILITY_KEYS: CapabilityKey[];
|
|
134
|
+
/** 능력 집합이 관측 노출하는 상태 필드 합집합(직교이므로 단순 병합). */
|
|
135
|
+
export declare function stateFieldsOf(caps: CapabilityKey[]): string[];
|
|
136
|
+
/**
|
|
137
|
+
* 능력 집합이 **받는 명령**의 합집합 — `stateFieldsOf` 의 짝.
|
|
138
|
+
*
|
|
139
|
+
* 「이 자원에 제어를 붙여도 되는가」는 이 목록이 비었는지로 **파생**한다. 그래서 `controllable` 같은
|
|
140
|
+
* 형용사를 따로 두지 않는다 — 형용사를 두면 목록과 형용사가 언젠가 어긋나고, 어긋난 쪽이 화면이 된다.
|
|
141
|
+
*/
|
|
142
|
+
export declare function commandsOf(caps: CapabilityKey[]): string[];
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 능력(capability) = 관측 계약(observable contract). 사상 토대: design/plans/capability-contract.md.
|
|
3
|
+
*
|
|
4
|
+
* 원칙 ① 관측만 — state(데이터형식)+models+invariants+results 만 계약. ops/구현은 없음(커널·씬 각 시뮬 자유).
|
|
5
|
+
* "교환되는 것"을 계약하고 "계산하는 법"은 계약하지 않는다(결정성 지옥 회피·공유 가능성 핵심).
|
|
6
|
+
* 원칙 ② 직교·조합 — 교차 관심사 status 는 Operable 하나에만(중복 제거). 엔티티는 능력 *조합*.
|
|
7
|
+
* 원칙 ③ 작은 코어 + 열린 확장(무방언) — 도메인이 능력 선언, 코어는 발명 안 함.
|
|
8
|
+
* 원칙 ④ 능력 ⟂ standardClass — 다른 축, 파생 없음.
|
|
9
|
+
*
|
|
10
|
+
* things-scene 정합: 이건 "의도(intent)" 계층(관측). 씬 기제 믹스(Capacity/Transferable/CarrierLine …)가
|
|
11
|
+
* 이 의도를 *실현*한다 — 공유 계약은 관측(계층 B)뿐, 기제 methods(계층 A)는 공유 안 함.
|
|
12
|
+
* ⚠ Mobile ≠ Transferable: Mobile=자원 자신이 이동, Transferable=자리가 아이템 이동(씬 기제).
|
|
13
|
+
*/
|
|
14
|
+
/*
|
|
15
|
+
* `label` 은 **언어 중립 i18n 키**(`twin.capability.<key>`) — 사람 언어는 표현계층이 렌더한다(L2).
|
|
16
|
+
* 타입/시스템 라벨(domain-catalog)이 먼저 이 규약으로 옮겨졌고, 능력 라벨만 사람 말로 남아
|
|
17
|
+
* 영어 화면에 한글이 새고 있었다. `semantics` 는 설계 문서용 산문이므로 대상이 아니다.
|
|
18
|
+
*/
|
|
19
|
+
export const CAPABILITIES = {
|
|
20
|
+
operable: {
|
|
21
|
+
key: 'operable', label: 'twin.capability.operable', semantics: '능동 자원의 운영 상태(유휴/가동/고장). status 교차 관심사를 여기 하나로.',
|
|
22
|
+
commands: ['resource.hold', 'resource.resume', 'resource.down', 'resource.repair', 'resource.reset-metrics'],
|
|
23
|
+
stateFields: ['status'], results: ['statusChanged']
|
|
24
|
+
},
|
|
25
|
+
storable: {
|
|
26
|
+
key: 'storable', label: 'twin.capability.storable', semantics: '아이템을 보유하는 위치 — 점유/용량. (씬 기제: Capacity)',
|
|
27
|
+
commands: [],
|
|
28
|
+
stateFields: ['occupancy', 'capacity'], invariants: ['0 <= occupancy <= capacity (capacity>0)'], results: ['occupancyChanged']
|
|
29
|
+
},
|
|
30
|
+
mobile: {
|
|
31
|
+
key: 'mobile', label: 'twin.capability.mobile', semantics: '자원 자신이 자리 간 이동. Transferable(아이템 이동)과 다름. (씬 기제: CarrierLine)',
|
|
32
|
+
commands: [],
|
|
33
|
+
stateFields: ['location', 'motion'], models: ['Motion'], results: ['moved', 'motionTick']
|
|
34
|
+
},
|
|
35
|
+
processable: {
|
|
36
|
+
key: 'processable', label: 'twin.capability.processable', semantics: '변환/가공 수행 — 산출(양품/불량). 운영 status 는 Operable 조합. progress 방출은 후속.',
|
|
37
|
+
commands: [],
|
|
38
|
+
stateFields: ['output'], results: ['completed']
|
|
39
|
+
},
|
|
40
|
+
/*
|
|
41
|
+
* ── 「위치를 읽는다」 ≠ 「조작할 수 있다」 (2026-08-14) ─────────────────────
|
|
42
|
+
* 차단기는 이 둘이 갈리는 자리다. 상태를 읽어 계통 구성을 알지만 **우리는 조작하지 않는다**
|
|
43
|
+
* (안전 계통은 범위 밖: profiles/ems.md §1). 그런데 능력을 `operable` 로 주면 저작면이 그 설비에
|
|
44
|
+
* **제어 컴포넌트를 권한다** — 화면이 우리가 하지 않기로 한 것을 하라고 부추긴다.
|
|
45
|
+
*
|
|
46
|
+
* 그래서 개폐 기기의 관측을 따로 세운다. 이름을 `observable` 로 하지 않은 이유: 그러면 상태 필드가
|
|
47
|
+
* `status` 여야 하고 그건 `operable` 의 것이다(원칙 ② 직교). 없는 필드를 지어내는 대신 **표준이 주는
|
|
48
|
+
* 이름**을 쓴다 — 개폐 기기의 관측은 `Pos`(위치)다.
|
|
49
|
+
*
|
|
50
|
+
* `operable` 은 그대로 「명령을 받는 능동 자원」의 자리로 남는다.
|
|
51
|
+
*/
|
|
52
|
+
switching: {
|
|
53
|
+
key: 'switching', label: 'twin.capability.switching', semantics: '회로를 열고 닫는 기기의 **위치를 읽는다** — 우리는 조작하지 않는다(명령 없음). IEC 61850 XCBR/XSWI Pos.',
|
|
54
|
+
commands: [],
|
|
55
|
+
stateFields: ['position'], models: ['SwitchingState'], results: ['positionChanged']
|
|
56
|
+
},
|
|
57
|
+
trackable: {
|
|
58
|
+
key: 'trackable', label: 'twin.capability.trackable', semantics: '오더/아이템 생애 추적 — 생애단계(도메인 라벨, 무방언)·진행·보류.',
|
|
59
|
+
commands: ['order.hold', 'order.resume', 'order.release'],
|
|
60
|
+
stateFields: ['lifecycle', 'progress', 'held'], results: ['lifecycleChanged']
|
|
61
|
+
},
|
|
62
|
+
/*
|
|
63
|
+
* ── 에너지 능력 넷 ──────────────────────────────────────────────────────
|
|
64
|
+
* 상태 필드는 기존 능력과 **직교**한다(원칙 ②) — 어느 필드도 두 능력에 겹치지 않는다.
|
|
65
|
+
* 계량은 `operable`(가동 상태)과도 직교한다: 도는 것과 재는 것은 다른 사실이고, 멈춘 설비도
|
|
66
|
+
* 대기전력을 쓴다.
|
|
67
|
+
*/
|
|
68
|
+
metered: {
|
|
69
|
+
key: 'metered', label: 'twin.capability.metered', semantics: '자기 소비를 잰다 — 유효전력·누적량·역률. 값이 없으면 모르는 것이다(0 이 아니다).',
|
|
70
|
+
commands: [],
|
|
71
|
+
stateFields: ['kW', 'kWh', 'powerFactor'], models: ['MeteredState'], results: ['measured']
|
|
72
|
+
},
|
|
73
|
+
curtailable: {
|
|
74
|
+
key: 'curtailable', label: 'twin.capability.curtailable', semantics: '줄일 수 있다 — 최소 유지 아래로는 내리지 않는다. 판정만 하고 집행은 사람과 그 시스템의 몫이다.',
|
|
75
|
+
commands: [],
|
|
76
|
+
stateFields: ['curtailable', 'minKW'], models: ['CurtailableState'], invariants: ['minKW >= 0'], results: ['drSuggested']
|
|
77
|
+
},
|
|
78
|
+
energyGenerating: {
|
|
79
|
+
key: 'energyGenerating', label: 'twin.capability.energyGenerating', semantics: '전기를 만든다 — 역송은 소비의 음수가 아니라 별개 사실이다.',
|
|
80
|
+
commands: [],
|
|
81
|
+
/*
|
|
82
|
+
* ── 적산을 더한다 (2026-08-26) ─────────────────────────────────────────────
|
|
83
|
+
* 이 능력이 순시 출력만 선언하고 있었다. 그래서 「지금 발전 중」은 말하고 「얼마나 발전했나」는 말할
|
|
84
|
+
* 수 없었다 — 사건 이름(`energy.generated`)과 설계 문서에는 처음부터 있던 사실이다.
|
|
85
|
+
*
|
|
86
|
+
* 계량 능력(`metered`)이 `kW` 와 `kWh` 를 함께 선언한 것과 같은 짝이다. 적산은 발전 설비의 보편적
|
|
87
|
+
* 사실이고(태양광·열병합·디젤), 그것이 없으면 성능비·발전시간 같은 성과를 아무도 셀 수 없다.
|
|
88
|
+
*/
|
|
89
|
+
stateFields: ['generatedKW', 'exportKW', 'generatedKWh', 'generatedKWhResetAt', 'generatedKWhSince', 'generatedKWhBasis', 'generatedKWhAccumulation', 'generatedKWhLastPeriod', 'generatedKWhLastPeriodEnd', 'generatedKWhLastPeriodObservedFrom', 'generatedKWhLastPeriodObservedTo', 'dcVoltage', 'dcCurrent', 'acVoltage', 'acCurrent'], models: ['GeneratingState'], results: ['generated']
|
|
90
|
+
},
|
|
91
|
+
/*
|
|
92
|
+
* ── 왜 `storing` 이 아니라 `energyStoring` 인가 (2026-08-15) ────────────────
|
|
93
|
+
* 같은 카탈로그에 `storable`(물건을 담는 자리)이 있다. 전기를 담는 것과 물건을 담는 것이 한 글자
|
|
94
|
+
* 차이로 붙어 있어서, 컴포넌트를 만들 때 `twin-storage` 를 피해 `twin-storage-energy` 로 도망쳤다 —
|
|
95
|
+
* 한 이름을 두 번 피해 갔다면 그 이름이 틀린 것이다.
|
|
96
|
+
*
|
|
97
|
+
* 도메인(EMS)이 아니라 **양(에너지)** 을 이름에 넣는다. 그래서 MES 트윈 안의 디젤 발전기도 그대로
|
|
98
|
+
* `energyGenerating` 이다 — 능력은 도메인이 아니라 관측의 면이다(방언 없음).
|
|
99
|
+
*/
|
|
100
|
+
energyStoring: {
|
|
101
|
+
key: 'energyStoring', label: 'twin.capability.energyStoring', semantics: '담고 낸다 — 충전과 방전을 나눈다(손실·수명 판단이 둘을 구별해야 한다).',
|
|
102
|
+
commands: [],
|
|
103
|
+
stateFields: ['soc', 'chargeKW', 'dischargeKW'], models: ['StoringState'], invariants: ['0 <= soc <= 100'], results: ['stored', 'discharged']
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
export const CAPABILITY_KEYS = ['operable', 'storable', 'mobile', 'processable', 'trackable', 'switching', 'metered', 'curtailable', 'energyGenerating', 'energyStoring'];
|
|
107
|
+
/** 능력 집합이 관측 노출하는 상태 필드 합집합(직교이므로 단순 병합). */
|
|
108
|
+
export function stateFieldsOf(caps) {
|
|
109
|
+
const out = new Set();
|
|
110
|
+
for (const c of caps)
|
|
111
|
+
for (const f of CAPABILITIES[c]?.stateFields ?? [])
|
|
112
|
+
out.add(f);
|
|
113
|
+
return [...out];
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 능력 집합이 **받는 명령**의 합집합 — `stateFieldsOf` 의 짝.
|
|
117
|
+
*
|
|
118
|
+
* 「이 자원에 제어를 붙여도 되는가」는 이 목록이 비었는지로 **파생**한다. 그래서 `controllable` 같은
|
|
119
|
+
* 형용사를 따로 두지 않는다 — 형용사를 두면 목록과 형용사가 언젠가 어긋나고, 어긋난 쪽이 화면이 된다.
|
|
120
|
+
*/
|
|
121
|
+
export function commandsOf(caps) {
|
|
122
|
+
const out = new Set();
|
|
123
|
+
for (const c of caps)
|
|
124
|
+
for (const cmd of CAPABILITIES[c]?.commands ?? [])
|
|
125
|
+
out.add(cmd);
|
|
126
|
+
return [...out];
|
|
127
|
+
}
|
|
@@ -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
|
+
}
|