@operato/ops-contract 0.3.0 → 0.5.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.
@@ -1379,17 +1379,43 @@ export interface EquipmentMotion {
1379
1379
  * → OEE=(run/planned)×quality. 세 인자가 손실 위치(셋업·기아·불량)를 분해해 드러낸다.
1380
1380
  */
1381
1381
  export interface OeeMetrics {
1382
- availability: number;
1383
- performance: number;
1384
- quality: number;
1385
- overall: number;
1382
+ /**
1383
+ * ── 세 비율은 **없을 수 있다** (2026-08-30) ──────────────────────────────
1384
+ *
1385
+ * 예전에는 근거가 없을 때 `1` 을 냈다. 잰 구간이 없는데 「가동률 100%」가 되고, 만든 것이 하나도
1386
+ * 없는데 「양품률 100%」가 된다. **없는 근거로 좋은 숫자를 만드는 것**이고, 사용자는 그것으로
1387
+ * 판단한다.
1388
+ *
1389
+ * 없으면 답하지 않는다. 무엇이 없어서인지는 `missing` 이 말한다.
1390
+ */
1391
+ availability?: number;
1392
+ performance?: number;
1393
+ quality?: number;
1394
+ overall?: number;
1395
+ /** 값이 없는 이유 — 비면 셋 다 나왔다는 뜻이다. */
1396
+ missing?: OeeMissing[];
1386
1397
  runMs: number;
1387
1398
  setupMs: number;
1388
1399
  downMs: number;
1389
- idleMs: number;
1400
+ delayMs: number;
1390
1401
  goodCount: number;
1391
1402
  scrapCount: number;
1392
1403
  }
1404
+ /**
1405
+ * OEE 값이 없는 이유.
1406
+ *
1407
+ * 「모름」과 「0」은 다르다. 계획 조업 시간을 모르면 가동률을 낼 수 없고, 그때 0 을 내면 설비가
1408
+ * 놀고 있는 것처럼 보이고 1 을 내면 완벽한 것처럼 보인다. 둘 다 거짓이다.
1409
+ */
1410
+ export type OeeMissing =
1411
+ /** 계획 조업 시간(PBT)을 모른다 — 아직 아무것도 재지 않았거나 근무 캘린더가 없다. */
1412
+ 'planned-busy-time'
1413
+ /** 품목당 표준 시간(PRI)이 선언되지 않았다 — 성능률의 분자를 만들 수 없다. */
1414
+ | 'planned-run-time-per-item'
1415
+ /** 만든 것이 없다 — 양품률을 낼 대상이 없다. */
1416
+ | 'produced-quantity'
1417
+ /** 실 생산 시간이 0 이다 — 성능률의 분모가 없다. */
1418
+ | 'actual-production-time';
1393
1419
  export interface EquipmentState extends EffectivePeriod {
1394
1420
  id: string;
1395
1421
  kind: string;
@@ -2513,6 +2539,15 @@ export declare const OP_EVENT: {
2513
2539
  * 이 채널이 없으면 시험 결과는 **상태에만 있는 축**이 된다 — 재기동에서 사라지고, 폴드가 되살릴 수
2514
2540
  * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
2515
2541
  */
2542
+ /**
2543
+ * **마감된 설비 상태 구간** — 이 설비가 이 구간 동안 이 상태였다.
2544
+ *
2545
+ * 시점의 전이(`equipment.status`)와 다른 사실이다. 전이는 설비가 내고, 이것은 **사람이 나중에
2546
+ * 적는다.** 자재 대기처럼 신호를 내지 않는 정지는 이 길로만 들어온다.
2547
+ *
2548
+ * ISO 22400-2 의 가동률 계산이 이 구간들을 읽는다.
2549
+ */
2550
+ readonly equipmentPeriod: "equipment.state.period";
2516
2551
  readonly test: "test.result";
2517
2552
  /**
2518
2553
  * **부적합 처분** — 재고 판정을 받은 것을 어떻게 하기로 정했나(재작업 · 특채 · 폐기 · 반품).
package/dist/contract.js CHANGED
@@ -986,6 +986,15 @@ export const OP_EVENT = {
986
986
  * 이 채널이 없으면 시험 결과는 **상태에만 있는 축**이 된다 — 재기동에서 사라지고, 폴드가 되살릴 수
987
987
  * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
988
988
  */
989
+ /**
990
+ * **마감된 설비 상태 구간** — 이 설비가 이 구간 동안 이 상태였다.
991
+ *
992
+ * 시점의 전이(`equipment.status`)와 다른 사실이다. 전이는 설비가 내고, 이것은 **사람이 나중에
993
+ * 적는다.** 자재 대기처럼 신호를 내지 않는 정지는 이 길로만 들어온다.
994
+ *
995
+ * ISO 22400-2 의 가동률 계산이 이 구간들을 읽는다.
996
+ */
997
+ equipmentPeriod: 'equipment.state.period',
989
998
  test: 'test.result',
990
999
  /**
991
1000
  * **부적합 처분** — 재고 판정을 받은 것을 어떻게 하기로 정했나(재작업 · 특채 · 폐기 · 반품).
package/dist/index.d.ts CHANGED
@@ -18,3 +18,4 @@ export * from './wms-profile.ts';
18
18
  export * from './yms-profile.ts';
19
19
  export * from './canonical-record.ts';
20
20
  export * from './version.ts';
21
+ export * from './oee.ts';
package/dist/index.js CHANGED
@@ -39,3 +39,4 @@ export * from "./wms-profile.js";
39
39
  export * from "./yms-profile.js";
40
40
  export * from "./canonical-record.js";
41
41
  export * from "./version.js";
42
+ export * from "./oee.js";
package/dist/oee.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { OeeMetrics } from './contract.ts';
2
+ /**
3
+ * OEE 계측 카운터 — 시뮬은 틱으로, 라이브는 텔레메트리나 사건 누적기가 채운다.
4
+ *
5
+ * 이름은 ISO 22400-2 의 시간 항목을 따른다.
6
+ */
7
+ export interface OeeCounters {
8
+ /** 실 생산 시간(APT) — 실제로 만들고 있던 시간. 준비·대기·고장은 여기 들어가지 않는다. */
9
+ runMs: number;
10
+ /** 준비(AUST). */
11
+ setupMs: number;
12
+ /** 고장(ADOT). */
13
+ downMs: number;
14
+ goodCount: number;
15
+ scrapCount: number;
16
+ /** 계획정지 — 계획 조업 시간에서 뺀다(점심·예방보전·교대). */
17
+ holdMs?: number;
18
+ /** 언제부터 쟀나. 없으면 잰 구간이 없는 것이고, 그때는 가동률을 낼 수 없다. */
19
+ metricsSinceMs?: number;
20
+ /**
21
+ * **품목당 표준 시간**(PRI) — 성능률의 분자를 만든다.
22
+ *
23
+ * 없으면 성능률을 내지 않는다. 이 값이 없는데 실 가동 시간으로 대신 나누면 「얼마나 빨리
24
+ * 만들었나」가 아니라 「얼마나 쉬지 않았나」가 되고, 이름만 성능률이 된다.
25
+ */
26
+ plannedRunTimePerItemMs?: number;
27
+ }
28
+ /**
29
+ * **OEE** — ISO 22400-2 의 정의를 따른다.
30
+ *
31
+ * ```
32
+ * PBT 계획 조업 시간 = 잰 구간 − 계획정지
33
+ * APT 실 생산 시간 = runMs
34
+ * availability APT / PBT
35
+ * effectiveness PRI × PQ / APT PQ = 양품 + 불량
36
+ * quality GQ / PQ
37
+ * overall 셋의 곱
38
+ * ```
39
+ *
40
+ * ── 예전 식과 무엇이 달랐나 (2026-08-30 대조) ────────────────────────────
41
+ * 예전에는 가동률의 분자가 `PBT − 준비 − 고장` 이었다. **대기가 분자에 남아 있었다.** 표준은
42
+ * `AOET = APT + AUST + ADET + ADOT` 로 대기도 실 생산 시간에서 뺀다.
43
+ *
44
+ * 총합은 같았다(가동률 × 성능률이 양쪽 다 `runMs / PBT` 로 떨어진다). 다른 것은 두 값을 따로 볼
45
+ * 때다. 자재를 기다린 시간이 성능률로 넘어가 있어서, **자재 문제를 설비 성능 문제로 읽게 했다.**
46
+ * 공장이 손 쓰는 방법이 그 둘에서 갈린다 — 가동률이 낮으면 보전·자재를 보고, 성능률이 낮으면
47
+ * 사이클 타임을 본다.
48
+ *
49
+ * 성능률도 달랐다. 표준은 품목당 표준 시간으로 재는데 우리는 실 가동 시간의 비율을 냈다.
50
+ *
51
+ * 대조는 원문이 아니라 그 표준을 구현한 규격에서 했다(OPC Foundation MachineTool §C.2). ISO 22400-2
52
+ * 원문은 유료라 읽지 못했다 — 이 주석이 근거의 한계를 함께 말한다.
53
+ */
54
+ export declare function computeOee(c: OeeCounters, nowMs: number): OeeMetrics;
package/dist/oee.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * **OEE** — ISO 22400-2 의 정의를 따른다.
3
+ *
4
+ * ```
5
+ * PBT 계획 조업 시간 = 잰 구간 − 계획정지
6
+ * APT 실 생산 시간 = runMs
7
+ * availability APT / PBT
8
+ * effectiveness PRI × PQ / APT PQ = 양품 + 불량
9
+ * quality GQ / PQ
10
+ * overall 셋의 곱
11
+ * ```
12
+ *
13
+ * ── 예전 식과 무엇이 달랐나 (2026-08-30 대조) ────────────────────────────
14
+ * 예전에는 가동률의 분자가 `PBT − 준비 − 고장` 이었다. **대기가 분자에 남아 있었다.** 표준은
15
+ * `AOET = APT + AUST + ADET + ADOT` 로 대기도 실 생산 시간에서 뺀다.
16
+ *
17
+ * 총합은 같았다(가동률 × 성능률이 양쪽 다 `runMs / PBT` 로 떨어진다). 다른 것은 두 값을 따로 볼
18
+ * 때다. 자재를 기다린 시간이 성능률로 넘어가 있어서, **자재 문제를 설비 성능 문제로 읽게 했다.**
19
+ * 공장이 손 쓰는 방법이 그 둘에서 갈린다 — 가동률이 낮으면 보전·자재를 보고, 성능률이 낮으면
20
+ * 사이클 타임을 본다.
21
+ *
22
+ * 성능률도 달랐다. 표준은 품목당 표준 시간으로 재는데 우리는 실 가동 시간의 비율을 냈다.
23
+ *
24
+ * 대조는 원문이 아니라 그 표준을 구현한 규격에서 했다(OPC Foundation MachineTool §C.2). ISO 22400-2
25
+ * 원문은 유료라 읽지 못했다 — 이 주석이 근거의 한계를 함께 말한다.
26
+ */
27
+ export function computeOee(c, nowMs) {
28
+ const missing = [];
29
+ /* 계획 조업 시간 — 잰 구간을 모르면 없다. 0 으로 메우면 「1970년부터 재고 있었다」가 된다. */
30
+ const planned = c.metricsSinceMs == null ? 0 : Math.max(0, nowMs - c.metricsSinceMs - (c.holdMs ?? 0));
31
+ if (planned <= 0)
32
+ missing.push('planned-busy-time');
33
+ /* 대기는 남는 시간이다 — 계획 조업에서 생산·준비·고장을 뺀 것. 따로 재지 않아도 나온다. */
34
+ const delayMs = Math.max(0, planned - c.runMs - c.setupMs - c.downMs);
35
+ const availability = planned > 0 ? Math.min(1, c.runMs / planned) : undefined;
36
+ const produced = c.goodCount + c.scrapCount;
37
+ if (produced <= 0)
38
+ missing.push('produced-quantity');
39
+ const quality = produced > 0 ? c.goodCount / produced : undefined;
40
+ /* 성능률 — 표준 시간이 없으면 내지 않는다. 실 생산 시간이 0 이어도 나눌 수 없다. */
41
+ if (c.plannedRunTimePerItemMs == null)
42
+ missing.push('planned-run-time-per-item');
43
+ else if (c.runMs <= 0)
44
+ missing.push('actual-production-time');
45
+ const performance = c.plannedRunTimePerItemMs != null && c.runMs > 0
46
+ ? Math.min(1, (c.plannedRunTimePerItemMs * produced) / c.runMs)
47
+ : undefined;
48
+ const overall = availability != null && performance != null && quality != null ? availability * performance * quality : undefined;
49
+ return {
50
+ ...(availability != null ? { availability } : {}),
51
+ ...(performance != null ? { performance } : {}),
52
+ ...(quality != null ? { quality } : {}),
53
+ ...(overall != null ? { overall } : {}),
54
+ ...(missing.length ? { missing } : {}),
55
+ runMs: c.runMs,
56
+ setupMs: c.setupMs,
57
+ downMs: c.downMs,
58
+ delayMs,
59
+ goodCount: c.goodCount,
60
+ scrapCount: c.scrapCount
61
+ };
62
+ }
@@ -1,6 +1,6 @@
1
1
  import type { IngestResult } from './face2-adapter.ts';
2
2
  /** 이 문이 받는 여섯 가지 — 리듀서가 다루는 것과 같은 목록(주목 확인은 우리 안의 행위라 제외). */
3
- export type OperationalKind = 'task' | 'equipment' | 'person' | 'asset' | 'order' | 'quality' | 'test' | 'disposition' | 'observation' | 'complete';
3
+ export type OperationalKind = 'task' | 'equipment' | 'equipment-period' | 'person' | 'asset' | 'order' | 'quality' | 'test' | 'disposition' | 'observation' | 'complete';
4
4
  /**
5
5
  * 정규 운영 레코드 — **델타의 필드 이름 + 시각(`at`)**.
6
6
  *
@@ -42,7 +42,19 @@ import { OP_EVENT, DISPOSITION_DECISION } from "./contract.js";
42
42
  * · 사람·자산 상태 — 배정이 `idle` 을 찾는다. 다른 낱말이면 있는 자원이 없는 것이 된다.
43
43
  */
44
44
  const TASK_STATUS = ['created', 'assigned', 'in-progress', 'completed'];
45
- const EQUIPMENT_STATUS = ['idle', 'busy', 'down'];
45
+ /*
46
+ * 설비 상태 — **ISO 22400-2 의 시간 모델에 하나씩 대응한다**(2026-08-30).
47
+ *
48
+ * busy actual production time 가동률·성능률의 분자
49
+ * setup actual unit setup time 준비. 가동에서 빼되 계획 조업에는 남는다
50
+ * down actual unit down time 고장. 가동률을 깎는다
51
+ * idle actual unit delay time 대기(자재·앞공정·인원). 가동률을 깎는다
52
+ * planned-stop 계획 조업 시간에서 제외 점심·예방보전·교대. 깎지 않는다
53
+ *
54
+ * `down` 과 `idle` 은 22400 에서 다른 항목이고 공장이 손 쓰는 방법도 다르다(고장은 보전, 대기는
55
+ * 자재·일정). 처음부터 갈라 두었던 것이 맞았고, 여기에 둘을 더해 22400 의 시간 모델을 덮는다.
56
+ */
57
+ const EQUIPMENT_STATUS = ['idle', 'busy', 'down', 'setup', 'planned-stop'];
46
58
  const PERSON_STATUS = ['idle', 'busy'];
47
59
  const ASSET_STATUS = ['idle', 'in-use'];
48
60
  const SPECS = {
@@ -72,6 +84,8 @@ const SPECS = {
72
84
  fields: {
73
85
  moverId: 'string', kind: 'string', status: 'string', location: 'string', homeLocation: 'string', // vocabulary-guard: allow
74
86
  taskId: 'string', held: 'boolean', effectiveStart: 'string', effectiveEnd: 'string', recordTime: 'string',
87
+ /* 왜 이 상태가 됐나 — 설비가 알려 주면 싣는다. 사람이 나중에 정하는 사유는 구간 사실에 적는다. */
88
+ reasonCode: 'string',
75
89
  /* 이동 구간 — 실 시스템도 줄 수 있는 사실이다(AGV·RTLS 가 출발·도착·소요를 낸다). 안쪽 필드까지
76
90
  재검사하지는 않는다: 그 모양은 `EquipmentMotion` 계약이고, 여기서 두 번 지키면 두 벌이 된다. */
77
91
  motion: 'object'
@@ -130,6 +144,41 @@ const SPECS = {
130
144
  * `propertyMeasurements` 안쪽은 재검사하지 않는다 — 그 모양은 `PropertyMeasurement` 계약이고,
131
145
  * 여기서 두 번 지키면 두 벌이 된다(설비 `motion` 과 같은 규율).
132
146
  */
147
+ /*
148
+ * **마감된 설비 상태 구간** — 이 설비가 이 구간 동안 이 상태였다.
149
+ *
150
+ * ── 전이만으로는 안 되는 이유 (2026-08-30) ───────────────────────────────
151
+ * 세 가지다. 셋째가 결정적이다.
152
+ *
153
+ * 사유가 붙을 자리가 없다 기계가 서는 순간에는 왜 섰는지 아무도 모른다. 작업자가 라인이 다시
154
+ * 돈 뒤에 적는다. 그 사이에 전이가 더 있으면 어느 전이에 붙일지 정할 수 없다
155
+ * 계획·비계획을 나중에 정한다 ISO 22400 은 계획정지를 계획 조업 시간에서 빼고 고장은 빼지 않는다.
156
+ * 그 분류는 전이가 일어난 순간에 모른다
157
+ * 전이를 내지 않는 정지가 많다 자재 대기 · 앞 공정 대기 · 작업자 부재. 기계는 전원이 켜진 채 `idle`
158
+ * 이고 신호가 하나도 안 나온다. OEE 에서 가장 크게 깎이는 것이 보통 이 시간이다
159
+ *
160
+ * 마지막이 감시 시스템과 MES 가 갈리는 자리이기도 하다. 설비가 말하는 것만 모으면 감시이고,
161
+ * **설비가 말하지 못하는 것을 사람이 적어 넣는 자리**가 MES 다.
162
+ *
163
+ * ── 이름이 `downtime` 이 아닌 이유 ────────────────────────────────────────
164
+ * 이 구간은 `setup` 과 `planned-stop` 도 나른다. 그 둘은 정지가 아니다. 담는 것은 **상태 구간**이고,
165
+ * 어느 상태인지는 `status` 가 말한다.
166
+ *
167
+ * ── 되돌아가 적는 사실이다 ───────────────────────────────────────────────
168
+ * 봉투의 사건 시각은 **구간의 끝**(그때 성립한다)이고, 적은 시각은 `recordTime` 이다. 둘이 갈려 있어야
169
+ * 「언제 일어났나」와 「언제 알았나」를 구별할 수 있다.
170
+ */
171
+ 'equipment-period': {
172
+ eventType: OP_EVENT.equipmentPeriod,
173
+ identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드 — 전이와 같은 이름을 쓴다
174
+ required: ['moverId', 'status', 'from', 'to'], // vocabulary-guard: allow 위와 같은 이유
175
+ fields: {
176
+ moverId: 'string', status: 'string', from: 'string', to: 'string', // vocabulary-guard: allow
177
+ /* 사람이 정한 사유 — 없을 수 있다(적지 않은 것과 사유가 없는 것은 다르므로 지어내지 않는다). */
178
+ reasonCode: 'string', decidedBy: 'string', recordTime: 'string'
179
+ },
180
+ enums: { status: EQUIPMENT_STATUS }
181
+ },
133
182
  /*
134
183
  * **부적합 처분** — 재고 판정을 받은 것을 어떻게 하기로 정했나.
135
184
  *
@@ -223,6 +272,12 @@ export function operationalKindOf(record) {
223
272
  return undefined;
224
273
  const has = (k) => typeof r[k] === 'string' && r[k].trim().length > 0;
225
274
  /* vocabulary-guard: allow 저널 와이어 필드로 가른다 */
275
+ /*
276
+ * 구간이 있으면 **마감된 상태 구간**이지 시점의 전이가 아니다. 전이보다 먼저 본다 — 뒤에 두면
277
+ * 사람이 되돌아가 적은 구간이 「지금 이 상태다」로 읽혀 트윈의 상태가 과거로 끌린다.
278
+ */
279
+ if (has('moverId') && has('from') && has('to'))
280
+ return 'equipment-period';
226
281
  if (has('moverId'))
227
282
  return r.good !== undefined ? 'quality' : 'equipment';
228
283
  if (has('personId'))
@@ -389,12 +444,28 @@ export function ingestOperationalRecords(records, opts) {
389
444
  }
390
445
  }
391
446
  }
392
- const at = String(r.at ?? '').trim() || opts.defaultEventTime;
447
+ /*
448
+ * **마감된 구간은 구간의 끝에 성립한다.** 사람이 되돌아가 적는 사실이라 `at` 을 쓰면 「적은 때」가
449
+ * 사건 시각이 되고, 그러면 어제 있었던 정지가 오늘 일어난 것으로 저널에 적힌다. 적은 때는
450
+ * `recordTime` 에 남는다.
451
+ */
452
+ const closedPeriodEnd = kind === 'equipment-period' ? String(r.to ?? '').trim() : '';
453
+ const at = closedPeriodEnd || String(r.at ?? '').trim() || opts.defaultEventTime;
393
454
  const atMs = at ? Date.parse(at) : Number.NaN;
394
455
  if (!Number.isFinite(atMs)) {
395
456
  /* 시각이 없으면 순서를 판정할 수 없다 — 늦게 온 옛 사실이 최신 상태를 덮어써 위치가 과거로 튄다. */
396
457
  errors.push(`${kind}: at 없음/형식 오류 — 시각 없이는 늦게 온 옛 사실을 걸러낼 수 없다`);
397
458
  }
459
+ /*
460
+ * 아직 오지 않은 구간은 마감된 사실이 아니다 — 에너지의 마감된 구간과 같은 규칙이다. 받으면
461
+ * 성과 계산의 구간이 미래로 밀리고, 그 현장의 화면이 오류 없이 비어 보인다(2026-08-30 실측).
462
+ */
463
+ if (closedPeriodEnd && opts.defaultEventTime) {
464
+ const nowMs = Date.parse(opts.defaultEventTime);
465
+ if (Number.isFinite(nowMs) && Number.isFinite(atMs) && atMs > nowMs) {
466
+ errors.push(`${kind}: 아직 오지 않은 시각의 사실이다(${closedPeriodEnd}) — 끝나지 않은 구간은 마감된 사실이 아니다`);
467
+ }
468
+ }
398
469
  if (errors.length) {
399
470
  rejected.push({ record, errors });
400
471
  continue;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CONTRACT_VERSION = "0.3.0";
1
+ export declare const CONTRACT_VERSION = "0.5.0";
package/dist/version.js CHANGED
@@ -11,4 +11,4 @@
11
11
  *
12
12
  * **정본은 `package.json` 이다.** 이 값이 그것과 같은지는 시험이 지킨다(§`version-matches`).
13
13
  */
14
- export const CONTRACT_VERSION = '0.3.0';
14
+ export const CONTRACT_VERSION = '0.5.0';
@@ -80,6 +80,7 @@ __export(index_exports, {
80
80
  classClosure: () => classClosure,
81
81
  classIdentifierViolation: () => classIdentifierViolation,
82
82
  commandsOf: () => commandsOf,
83
+ computeOee: () => computeOee,
83
84
  conversionFactorOf: () => conversionFactorOf,
84
85
  criterionSaysNothing: () => criterionSaysNothing,
85
86
  documentPath: () => documentPath,
@@ -775,6 +776,15 @@ var OP_EVENT = {
775
776
  * 이 채널이 없으면 시험 결과는 **상태에만 있는 축**이 된다 — 재기동에서 사라지고, 폴드가 되살릴 수
776
777
  * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
777
778
  */
779
+ /**
780
+ * **마감된 설비 상태 구간** — 이 설비가 이 구간 동안 이 상태였다.
781
+ *
782
+ * 시점의 전이(`equipment.status`)와 다른 사실이다. 전이는 설비가 내고, 이것은 **사람이 나중에
783
+ * 적는다.** 자재 대기처럼 신호를 내지 않는 정지는 이 길로만 들어온다.
784
+ *
785
+ * ISO 22400-2 의 가동률 계산이 이 구간들을 읽는다.
786
+ */
787
+ equipmentPeriod: "equipment.state.period",
778
788
  test: "test.result",
779
789
  /**
780
790
  * **부적합 처분** — 재고 판정을 받은 것을 어떻게 하기로 정했나(재작업 · 특채 · 폐기 · 반품).
@@ -3149,7 +3159,7 @@ function readEpochMs(raw) {
3149
3159
 
3150
3160
  // src/operational-ingest.ts
3151
3161
  var TASK_STATUS = ["created", "assigned", "in-progress", "completed"];
3152
- var EQUIPMENT_STATUS = ["idle", "busy", "down"];
3162
+ var EQUIPMENT_STATUS = ["idle", "busy", "down", "setup", "planned-stop"];
3153
3163
  var PERSON_STATUS = ["idle", "busy"];
3154
3164
  var ASSET_STATUS = ["idle", "in-use"];
3155
3165
  var SPECS = {
@@ -3207,6 +3217,8 @@ var SPECS = {
3207
3217
  effectiveStart: "string",
3208
3218
  effectiveEnd: "string",
3209
3219
  recordTime: "string",
3220
+ /* 왜 이 상태가 됐나 — 설비가 알려 주면 싣는다. 사람이 나중에 정하는 사유는 구간 사실에 적는다. */
3221
+ reasonCode: "string",
3210
3222
  /* 이동 구간 — 실 시스템도 줄 수 있는 사실이다(AGV·RTLS 가 출발·도착·소요를 낸다). 안쪽 필드까지
3211
3223
  재검사하지는 않는다: 그 모양은 `EquipmentMotion` 계약이고, 여기서 두 번 지키면 두 벌이 된다. */
3212
3224
  motion: "object"
@@ -3296,6 +3308,49 @@ var SPECS = {
3296
3308
  * `propertyMeasurements` 안쪽은 재검사하지 않는다 — 그 모양은 `PropertyMeasurement` 계약이고,
3297
3309
  * 여기서 두 번 지키면 두 벌이 된다(설비 `motion` 과 같은 규율).
3298
3310
  */
3311
+ /*
3312
+ * **마감된 설비 상태 구간** — 이 설비가 이 구간 동안 이 상태였다.
3313
+ *
3314
+ * ── 전이만으로는 안 되는 이유 (2026-08-30) ───────────────────────────────
3315
+ * 세 가지다. 셋째가 결정적이다.
3316
+ *
3317
+ * 사유가 붙을 자리가 없다 기계가 서는 순간에는 왜 섰는지 아무도 모른다. 작업자가 라인이 다시
3318
+ * 돈 뒤에 적는다. 그 사이에 전이가 더 있으면 어느 전이에 붙일지 정할 수 없다
3319
+ * 계획·비계획을 나중에 정한다 ISO 22400 은 계획정지를 계획 조업 시간에서 빼고 고장은 빼지 않는다.
3320
+ * 그 분류는 전이가 일어난 순간에 모른다
3321
+ * 전이를 내지 않는 정지가 많다 자재 대기 · 앞 공정 대기 · 작업자 부재. 기계는 전원이 켜진 채 `idle`
3322
+ * 이고 신호가 하나도 안 나온다. OEE 에서 가장 크게 깎이는 것이 보통 이 시간이다
3323
+ *
3324
+ * 마지막이 감시 시스템과 MES 가 갈리는 자리이기도 하다. 설비가 말하는 것만 모으면 감시이고,
3325
+ * **설비가 말하지 못하는 것을 사람이 적어 넣는 자리**가 MES 다.
3326
+ *
3327
+ * ── 이름이 `downtime` 이 아닌 이유 ────────────────────────────────────────
3328
+ * 이 구간은 `setup` 과 `planned-stop` 도 나른다. 그 둘은 정지가 아니다. 담는 것은 **상태 구간**이고,
3329
+ * 어느 상태인지는 `status` 가 말한다.
3330
+ *
3331
+ * ── 되돌아가 적는 사실이다 ───────────────────────────────────────────────
3332
+ * 봉투의 사건 시각은 **구간의 끝**(그때 성립한다)이고, 적은 시각은 `recordTime` 이다. 둘이 갈려 있어야
3333
+ * 「언제 일어났나」와 「언제 알았나」를 구별할 수 있다.
3334
+ */
3335
+ "equipment-period": {
3336
+ eventType: OP_EVENT.equipmentPeriod,
3337
+ identity: "moverId",
3338
+ // vocabulary-guard: allow 저널 와이어 필드 — 전이와 같은 이름을 쓴다
3339
+ required: ["moverId", "status", "from", "to"],
3340
+ // vocabulary-guard: allow 위와 같은 이유
3341
+ fields: {
3342
+ moverId: "string",
3343
+ status: "string",
3344
+ from: "string",
3345
+ to: "string",
3346
+ // vocabulary-guard: allow
3347
+ /* 사람이 정한 사유 — 없을 수 있다(적지 않은 것과 사유가 없는 것은 다르므로 지어내지 않는다). */
3348
+ reasonCode: "string",
3349
+ decidedBy: "string",
3350
+ recordTime: "string"
3351
+ },
3352
+ enums: { status: EQUIPMENT_STATUS }
3353
+ },
3299
3354
  /*
3300
3355
  * **부적합 처분** — 재고 판정을 받은 것을 어떻게 하기로 정했나.
3301
3356
  *
@@ -3392,6 +3447,7 @@ function operationalKindOf(record) {
3392
3447
  const r = record;
3393
3448
  if (r.epc !== void 0 || r.meterId !== void 0 || r.equipmentId !== void 0) return void 0;
3394
3449
  const has = (k) => typeof r[k] === "string" && r[k].trim().length > 0;
3450
+ if (has("moverId") && has("from") && has("to")) return "equipment-period";
3395
3451
  if (has("moverId")) return r.good !== void 0 ? "quality" : "equipment";
3396
3452
  if (has("personId")) return "person";
3397
3453
  if (has("assetId")) return "asset";
@@ -3500,11 +3556,18 @@ function ingestOperationalRecords(records, opts) {
3500
3556
  }
3501
3557
  }
3502
3558
  }
3503
- const at = String(r.at ?? "").trim() || opts.defaultEventTime;
3559
+ const closedPeriodEnd = kind === "equipment-period" ? String(r.to ?? "").trim() : "";
3560
+ const at = closedPeriodEnd || String(r.at ?? "").trim() || opts.defaultEventTime;
3504
3561
  const atMs = at ? Date.parse(at) : Number.NaN;
3505
3562
  if (!Number.isFinite(atMs)) {
3506
3563
  errors.push(`${kind}: at \uC5C6\uC74C/\uD615\uC2DD \uC624\uB958 \u2014 \uC2DC\uAC01 \uC5C6\uC774\uB294 \uB2A6\uAC8C \uC628 \uC61B \uC0AC\uC2E4\uC744 \uAC78\uB7EC\uB0BC \uC218 \uC5C6\uB2E4`);
3507
3564
  }
3565
+ if (closedPeriodEnd && opts.defaultEventTime) {
3566
+ const nowMs = Date.parse(opts.defaultEventTime);
3567
+ if (Number.isFinite(nowMs) && Number.isFinite(atMs) && atMs > nowMs) {
3568
+ errors.push(`${kind}: \uC544\uC9C1 \uC624\uC9C0 \uC54A\uC740 \uC2DC\uAC01\uC758 \uC0AC\uC2E4\uC774\uB2E4(${closedPeriodEnd}) \u2014 \uB05D\uB098\uC9C0 \uC54A\uC740 \uAD6C\uAC04\uC740 \uB9C8\uAC10\uB41C \uC0AC\uC2E4\uC774 \uC544\uB2C8\uB2E4`);
3569
+ }
3570
+ }
3508
3571
  if (errors.length) {
3509
3572
  rejected.push({ record, errors });
3510
3573
  continue;
@@ -3671,7 +3734,36 @@ function retiredVocabularyIn(line) {
3671
3734
  }
3672
3735
 
3673
3736
  // src/version.ts
3674
- var CONTRACT_VERSION = "0.3.0";
3737
+ var CONTRACT_VERSION = "0.5.0";
3738
+
3739
+ // src/oee.ts
3740
+ function computeOee(c, nowMs) {
3741
+ const missing = [];
3742
+ const planned = c.metricsSinceMs == null ? 0 : Math.max(0, nowMs - c.metricsSinceMs - (c.holdMs ?? 0));
3743
+ if (planned <= 0) missing.push("planned-busy-time");
3744
+ const delayMs = Math.max(0, planned - c.runMs - c.setupMs - c.downMs);
3745
+ const availability = planned > 0 ? Math.min(1, c.runMs / planned) : void 0;
3746
+ const produced = c.goodCount + c.scrapCount;
3747
+ if (produced <= 0) missing.push("produced-quantity");
3748
+ const quality = produced > 0 ? c.goodCount / produced : void 0;
3749
+ if (c.plannedRunTimePerItemMs == null) missing.push("planned-run-time-per-item");
3750
+ else if (c.runMs <= 0) missing.push("actual-production-time");
3751
+ const performance = c.plannedRunTimePerItemMs != null && c.runMs > 0 ? Math.min(1, c.plannedRunTimePerItemMs * produced / c.runMs) : void 0;
3752
+ const overall = availability != null && performance != null && quality != null ? availability * performance * quality : void 0;
3753
+ return {
3754
+ ...availability != null ? { availability } : {},
3755
+ ...performance != null ? { performance } : {},
3756
+ ...quality != null ? { quality } : {},
3757
+ ...overall != null ? { overall } : {},
3758
+ ...missing.length ? { missing } : {},
3759
+ runMs: c.runMs,
3760
+ setupMs: c.setupMs,
3761
+ downMs: c.downMs,
3762
+ delayMs,
3763
+ goodCount: c.goodCount,
3764
+ scrapCount: c.scrapCount
3765
+ };
3766
+ }
3675
3767
  // Annotate the CommonJS export names for ESM import in node:
3676
3768
  0 && (module.exports = {
3677
3769
  BIZSTEP,
@@ -3735,6 +3827,7 @@ var CONTRACT_VERSION = "0.3.0";
3735
3827
  classClosure,
3736
3828
  classIdentifierViolation,
3737
3829
  commandsOf,
3830
+ computeOee,
3738
3831
  conversionFactorOf,
3739
3832
  criterionSaysNothing,
3740
3833
  documentPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/ops-contract",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Operations domain contract — the standard vocabulary that producers and readers agree on (EPCIS 2.0/GS1, ISA-95, IEC 61850/ISO 50001). Types, guards, validation. No state, no engine.",
5
5
  "type": "module",
6
6
  "main": "./dist-cjs/index.cjs",