@operato/ops-contract 0.9.0 → 0.9.2

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.
@@ -14,6 +14,32 @@ export type CommandState = (typeof COMMAND_STATE)[number];
14
14
  /** 이 커맨드를 낸 것이 누구인가 — 사람이 승인할 때 그 판단의 재료가 된다. */
15
15
  export declare const COMMAND_ORIGIN: readonly ["operator", "rule", "ai", "schedule"];
16
16
  export type CommandOrigin = (typeof COMMAND_ORIGIN)[number];
17
+ /**
18
+ * 넘긴 조치가 **저쪽에서 실제로 무엇이 되었나** — 상태가 아니라 별개 사실이다.
19
+ *
20
+ * ── 왜 상태를 늘리지 않나 (2026-09-02) ────────────────────────────────────
21
+ * `acked` 는 「저쪽이 답했다」이고 종착 상태다. 여기에 「현장에 나갔다」를 상태로 이어 붙이면
22
+ * `acked` 가 종착이 아니게 되고, **되돌아오는 관측이 없는 커넥터의 조치가 영원히 끝나지 않은 것으로
23
+ * 보인다.** 그런 커넥터가 정상이다 — 관측을 되돌려 주는 능력은 커넥터의 성질이지 조치의 수명이 아니다.
24
+ *
25
+ * 그래서 수명(상태)과 효과(이 칸)를 갈랐다. 효과가 없는 조치는 그냥 효과가 없는 것이고, 화면은
26
+ * 「넘겼다」와 「저쪽에서 확인됐다」를 따로 말할 수 있다.
27
+ *
28
+ * ── 무엇이 효과인지는 커널이 정하지 않는다 ────────────────────────────────
29
+ * `status` 를 **그대로 적는다.** 오더 상태 어휘는 도메인 소유이고(§`OrderStatusDelta.status`), 어느
30
+ * 값이 「현장에 나갔다」인지는 현장마다 다르다. 커널이 그 방언을 알면 보편 계약이 깨진다.
31
+ *
32
+ * 커널이 아는 것은 **이어졌다**는 사실뿐이다 — 우리가 낸 지시의 식별자(`dispatchRef`)로 저쪽이
33
+ * 보고한 것이 돌아왔다. 그 값이 무엇을 뜻하는지는 그 어휘를 아는 쪽이 읽는다.
34
+ */
35
+ export interface CommandEffect {
36
+ /** 저쪽이 보고한 상태 — **해석하지 않고 그대로.** */
37
+ status: string;
38
+ /** 그 보고를 관측한 시각. */
39
+ at: ISOTime;
40
+ /** 몇 번 보고됐나 — 상태가 여러 번 바뀌면 마지막 것이 위에 남고 이 수가 는다. */
41
+ seen: number;
42
+ }
17
43
  /** 사람이 승인하거나 거절한 기록. **재기동을 넘어 살아야 한다.** */
18
44
  export interface CommandApproval {
19
45
  by: string;
@@ -48,6 +74,14 @@ export interface TwinCommand {
48
74
  approval?: CommandApproval;
49
75
  /** 어댑터가 돌려준 것 — 저쪽에서 무엇이 되었는지 되짚을 수 있어야 한다. */
50
76
  dispatchRef?: string;
77
+ /**
78
+ * 저쪽에서 실제로 무엇이 되었나 — **`dispatchRef` 가 없으면 이어 줄 길이 없다.**
79
+ *
80
+ * 넘기다 닿지 못한 조치는 식별자를 못 받는다. 그러면 저쪽이 실제로 받았는지 우리는 알 수 없고,
81
+ * 중복을 막는 것은 저쪽의 멱등성이다. 그 사실을 여기서 숨기지 않는다 — 이 칸이 빈 것은 「효과가
82
+ * 없었다」가 아니라 **「모른다」**다.
83
+ */
84
+ effect?: CommandEffect;
51
85
  error?: string;
52
86
  }
53
87
  /**
@@ -3072,10 +3072,78 @@ export interface AssetStatusDelta extends EffectivePeriod {
3072
3072
  * 셋을 전이로 담을 수 없다 — 사유가 붙을 자리가 없고(기계가 서는 순간에는 왜 섰는지 아무도 모른다),
3073
3073
  * 계획·비계획을 나중에 정하며, 전이를 내지 않는 정지(자재 대기 · 작업자 부재)가 많다.
3074
3074
  */
3075
+ /**
3076
+ * 설비가 그 구간에 어떤 상태였나 — **닫힌 목록이다.**
3077
+ *
3078
+ * ── 왜 닫나 (2026-09-03) ────────────────────────────────────────────────────
3079
+ * 예전에는 `status: string` 이고 주석에만 다섯이 적혀 있었다. 그래서 커넥터가 `broken` 을 보내면
3080
+ * **아무 오류 없이** 고장이 0건이 되고, 가동 시간 지표가 「고장이 없었다」로 답한다. 처분 결정을
3081
+ * 열린 문자열로 두지 않는 이유와 같다(§`DISPOSITION_DECISION`) — 이 값이 **판정에 효과를 준다.**
3082
+ *
3083
+ * ISO 22400-2 의 시간 모델을 덮는다.
3084
+ *
3085
+ * ```
3086
+ * busy 실 생산(APT) — 만들고 있었다
3087
+ * setup 준비(AUST)
3088
+ * down 고장(ADOT) — 가동 시간 지표가 세는 것
3089
+ * idle 대기(ADET) — 자재·지시를 기다렸다. 고장이 아니다
3090
+ * planned-stop 계획정지 — 계획 조업 시간에서 뺀다(점심·예방보전·교대)
3091
+ * ```
3092
+ *
3093
+ * **`down` 과 `planned-stop` 을 가르는 것이 이 목록의 요점이다.** 둘을 한 낱말로 묶으면 예방보전을
3094
+ * 할수록 설비가 고장난 것으로 보이고, 그러면 보전을 미루는 쪽이 지표에 유리해진다.
3095
+ *
3096
+ * 왜 이 값이 「사유」와 다른 축인가 — 사유(`reasonCode`)는 현장이 정하는 열린 값이고, 이 값은 우리가
3097
+ * 정한 닫힌 분류다. 사유를 우리가 가르려 하면 현장마다 코드가 달라 못 가른다.
3098
+ */
3099
+ export declare const EQUIPMENT_STATUS: readonly ["busy", "setup", "down", "idle", "planned-stop"];
3100
+ export type EquipmentStatus = (typeof EQUIPMENT_STATUS)[number];
3101
+ /** 선언된 상태인가. */
3102
+ export declare function isEquipmentStatus(value: unknown): value is EquipmentStatus;
3103
+ /**
3104
+ * 원본의 낱말을 우리 상태로 옮긴다 — **모르면 `undefined` 다.**
3105
+ *
3106
+ * ── 옮길 표가 비어 있다. 일부러 비웠다 (2026-09-03) ─────────────────────────
3107
+ * 처음에 `running`·`in-use`·`breakdown`·`stopped` 같은 흔한 동의어를 넣었다. **근거가 없었다.**
3108
+ * 세어 보니 이렇다.
3109
+ *
3110
+ * ```
3111
+ * operato-plant busy·setup·down·idle·planned-stop 다섯만. 동의어 0곳 (그 레인이 세어 줌)
3112
+ * ppms 커넥터 busy·idle·down 만
3113
+ * chef · ems-scada 설비 상태를 안 보낸다
3114
+ * ```
3115
+ *
3116
+ * 커널의 `isRunningStatus` 가 `busy`·`in-use`·`running` 셋을 받는 것을 근거로 삼았는데, 그것도
3117
+ * 방어였다 — `running` 을 만드는 코드가 **한 곳도 없고**, `in-use` 는 **자산의 낱말**이다
3118
+ * (`flow-engine.ts` 가 자산에 그 값을 준다). 그러니 `in-use → busy` 는 두 축을 섞는 것이고,
3119
+ * 이 저장소가 다른 자리에서 계속 거절하는 모양이다.
3120
+ *
3121
+ * 소비처가 있는 것만 적는다 — `MES_COMMANDS` 가 같은 규율을 지킨다. 실제로 다른 낱말을 보내는
3122
+ * 원본을 만나면 **그 낱말을 여기 적는다.** 그때 이 함수가 그 자리가 되고, 커넥터마다 다시 적어
3123
+ * 흩어지지 않는다.
3124
+ *
3125
+ * 지금 하는 일은 둘이다 — 대소문자·공백을 다듬고, **모르는 낱말을 `undefined` 로 답한다.**
3126
+ * 모름을 `idle` 로 채우면 고장이 조용히 사라지고, `down` 으로 채우면 부풀린다.
3127
+ */
3128
+ export declare function normalizeEquipmentStatus(value: unknown): EquipmentStatus | undefined;
3129
+ /**
3130
+ * 고장이었나 — **가동 시간 지표(MTBF)가 세는 것.**
3131
+ *
3132
+ * 계획정지·준비·대기는 고장이 아니다. 이 판정을 부르는 쪽마다 다시 쓰면 한 곳이 `planned-stop` 을
3133
+ * 고장으로 세는 날이 오고, 그때 두 화면이 다른 수를 말한다.
3134
+ */
3135
+ export declare function isFailureStatus(value: unknown): boolean;
3136
+ /** 계획정지였나 — 계획 조업 시간에서 빼는 것(§`OeeCounters.holdMs`). */
3137
+ export declare function isPlannedStopStatus(value: unknown): boolean;
3075
3138
  export interface EquipmentStatePeriodFact {
3076
3139
  moverId: string;
3077
- /** `busy` · `setup` · `down` · `idle` · `planned-stop` — ISO 22400 의 시간 모델을 덮는다. */
3078
- status: string;
3140
+ /**
3141
+ * 그 구간의 상태 — **선언된 다섯 중 하나**(§`EQUIPMENT_STATUS`).
3142
+ *
3143
+ * 원본의 낱말이 다르면 커넥터가 `normalizeEquipmentStatus` 로 옮긴다. 옮길 수 없는 값은 이 사실을
3144
+ * 만들지 말고 그 사실을 말해야 한다 — 모르는 상태를 아무 것으로 채우면 지표가 조용히 틀린다.
3145
+ */
3146
+ status: EquipmentStatus;
3079
3147
  from: ISOTime;
3080
3148
  to: ISOTime;
3081
3149
  /** 사람이 정한 사유. 없을 수 있다 — 적지 않은 것과 사유가 없는 것은 다르므로 지어내지 않는다. */
package/dist/contract.js CHANGED
@@ -1186,6 +1186,86 @@ export const ENERGY_EVENT = {
1186
1186
  * 커널은 이 값으로 판정하지 않는다 — 무엇을 믿을지는 현장이 정한다.
1187
1187
  */
1188
1188
  export const OBSERVATION_BASIS = ['metered', 'provider', 'billed', 'estimated'];
1189
+ /**
1190
+ * **마감된 설비 상태 구간** — 이 설비가 이 구간 동안 이 상태였다.
1191
+ *
1192
+ * 시점의 전이(`EquipmentStatusDelta`)와 다른 사실이다. 전이는 「지금 이 상태로 바뀌었다」이고 이것은
1193
+ * 「그 구간 동안 그 상태였다」다. 되돌아가 적는 사실이라 사건 시각이 `to` 다.
1194
+ *
1195
+ * 셋을 전이로 담을 수 없다 — 사유가 붙을 자리가 없고(기계가 서는 순간에는 왜 섰는지 아무도 모른다),
1196
+ * 계획·비계획을 나중에 정하며, 전이를 내지 않는 정지(자재 대기 · 작업자 부재)가 많다.
1197
+ */
1198
+ /**
1199
+ * 설비가 그 구간에 어떤 상태였나 — **닫힌 목록이다.**
1200
+ *
1201
+ * ── 왜 닫나 (2026-09-03) ────────────────────────────────────────────────────
1202
+ * 예전에는 `status: string` 이고 주석에만 다섯이 적혀 있었다. 그래서 커넥터가 `broken` 을 보내면
1203
+ * **아무 오류 없이** 고장이 0건이 되고, 가동 시간 지표가 「고장이 없었다」로 답한다. 처분 결정을
1204
+ * 열린 문자열로 두지 않는 이유와 같다(§`DISPOSITION_DECISION`) — 이 값이 **판정에 효과를 준다.**
1205
+ *
1206
+ * ISO 22400-2 의 시간 모델을 덮는다.
1207
+ *
1208
+ * ```
1209
+ * busy 실 생산(APT) — 만들고 있었다
1210
+ * setup 준비(AUST)
1211
+ * down 고장(ADOT) — 가동 시간 지표가 세는 것
1212
+ * idle 대기(ADET) — 자재·지시를 기다렸다. 고장이 아니다
1213
+ * planned-stop 계획정지 — 계획 조업 시간에서 뺀다(점심·예방보전·교대)
1214
+ * ```
1215
+ *
1216
+ * **`down` 과 `planned-stop` 을 가르는 것이 이 목록의 요점이다.** 둘을 한 낱말로 묶으면 예방보전을
1217
+ * 할수록 설비가 고장난 것으로 보이고, 그러면 보전을 미루는 쪽이 지표에 유리해진다.
1218
+ *
1219
+ * 왜 이 값이 「사유」와 다른 축인가 — 사유(`reasonCode`)는 현장이 정하는 열린 값이고, 이 값은 우리가
1220
+ * 정한 닫힌 분류다. 사유를 우리가 가르려 하면 현장마다 코드가 달라 못 가른다.
1221
+ */
1222
+ export const EQUIPMENT_STATUS = ['busy', 'setup', 'down', 'idle', 'planned-stop'];
1223
+ /** 선언된 상태인가. */
1224
+ export function isEquipmentStatus(value) {
1225
+ return typeof value === 'string' && EQUIPMENT_STATUS.includes(value);
1226
+ }
1227
+ /**
1228
+ * 원본의 낱말을 우리 상태로 옮긴다 — **모르면 `undefined` 다.**
1229
+ *
1230
+ * ── 옮길 표가 비어 있다. 일부러 비웠다 (2026-09-03) ─────────────────────────
1231
+ * 처음에 `running`·`in-use`·`breakdown`·`stopped` 같은 흔한 동의어를 넣었다. **근거가 없었다.**
1232
+ * 세어 보니 이렇다.
1233
+ *
1234
+ * ```
1235
+ * operato-plant busy·setup·down·idle·planned-stop 다섯만. 동의어 0곳 (그 레인이 세어 줌)
1236
+ * ppms 커넥터 busy·idle·down 만
1237
+ * chef · ems-scada 설비 상태를 안 보낸다
1238
+ * ```
1239
+ *
1240
+ * 커널의 `isRunningStatus` 가 `busy`·`in-use`·`running` 셋을 받는 것을 근거로 삼았는데, 그것도
1241
+ * 방어였다 — `running` 을 만드는 코드가 **한 곳도 없고**, `in-use` 는 **자산의 낱말**이다
1242
+ * (`flow-engine.ts` 가 자산에 그 값을 준다). 그러니 `in-use → busy` 는 두 축을 섞는 것이고,
1243
+ * 이 저장소가 다른 자리에서 계속 거절하는 모양이다.
1244
+ *
1245
+ * 소비처가 있는 것만 적는다 — `MES_COMMANDS` 가 같은 규율을 지킨다. 실제로 다른 낱말을 보내는
1246
+ * 원본을 만나면 **그 낱말을 여기 적는다.** 그때 이 함수가 그 자리가 되고, 커넥터마다 다시 적어
1247
+ * 흩어지지 않는다.
1248
+ *
1249
+ * 지금 하는 일은 둘이다 — 대소문자·공백을 다듬고, **모르는 낱말을 `undefined` 로 답한다.**
1250
+ * 모름을 `idle` 로 채우면 고장이 조용히 사라지고, `down` 으로 채우면 부풀린다.
1251
+ */
1252
+ export function normalizeEquipmentStatus(value) {
1253
+ const said = typeof value === 'string' ? value.trim().toLowerCase() : '';
1254
+ return isEquipmentStatus(said) ? said : undefined;
1255
+ }
1256
+ /**
1257
+ * 고장이었나 — **가동 시간 지표(MTBF)가 세는 것.**
1258
+ *
1259
+ * 계획정지·준비·대기는 고장이 아니다. 이 판정을 부르는 쪽마다 다시 쓰면 한 곳이 `planned-stop` 을
1260
+ * 고장으로 세는 날이 오고, 그때 두 화면이 다른 수를 말한다.
1261
+ */
1262
+ export function isFailureStatus(value) {
1263
+ return normalizeEquipmentStatus(value) === 'down';
1264
+ }
1265
+ /** 계획정지였나 — 계획 조업 시간에서 빼는 것(§`OeeCounters.holdMs`). */
1266
+ export function isPlannedStopStatus(value) {
1267
+ return normalizeEquipmentStatus(value) === 'planned-stop';
1268
+ }
1189
1269
  // ── Command 채널 어휘 — 트윈의 "행위(act)" 면 (prescriptive/트랜잭션 프론트엔드) ──
1190
1270
  // 코어 공통: order.hold/resume(할당 보류). 도메인: order.release(즉시 투입) 등은 handleCommand 로.
1191
1271
  export const CMD = {
package/dist/index.d.ts CHANGED
@@ -19,5 +19,7 @@ export * from './yms-profile.ts';
19
19
  export * from './canonical-record.ts';
20
20
  export * from './webhook.ts';
21
21
  export * from './oee.ts';
22
+ export * from './yield.ts';
23
+ export * from './reliability.ts';
22
24
  export * from './erp.ts';
23
25
  export * from './actuation.ts';
package/dist/index.js CHANGED
@@ -41,5 +41,7 @@ export * from "./canonical-record.js";
41
41
  /* `webhook-signature.ts` 는 여기 없다 — `node:crypto` 를 쓰므로 `@operato/ops-contract/webhook` 으로만 나간다. */
42
42
  export * from "./webhook.js";
43
43
  export * from "./oee.js";
44
+ export * from "./yield.js";
45
+ export * from "./reliability.js";
44
46
  export * from "./erp.js";
45
47
  export * from "./actuation.js";
@@ -0,0 +1,44 @@
1
+ import type { ISOTime } from './contract.ts';
2
+ /** 상태 구간 하나 — `EquipmentStatePeriodFact` 에서 셋만. */
3
+ export interface ReliabilityPeriod {
4
+ status: string;
5
+ from: ISOTime;
6
+ to: ISOTime;
7
+ }
8
+ export interface MtbfInput {
9
+ /** 어느 설비의. */
10
+ equipmentId: string;
11
+ /** 그 설비의 상태 구간들. 순서는 상관없다 — 여기서 시각으로 세운다. */
12
+ periods: readonly ReliabilityPeriod[];
13
+ }
14
+ /** 낼 수 없는 이유 — 「0」이나 「무한」으로 답하지 않는다. */
15
+ export type MtbfMissing =
16
+ /** 돈 시간이 없다 — 잰 구간에 `busy` 가 하나도 없다. */
17
+ 'no-operating-time'
18
+ /** 고장이 없다 — 나눌 수가 0이다. 좋은 수가 아니라 **답할 수 없는** 것이다. */
19
+ | 'no-failure'
20
+ /** 모르는 상태 낱말이 있다 — 조용히 빼면 고장이 사라진다. */
21
+ | 'unknown-status'
22
+ /** 길이가 없거나 시각을 읽을 수 없는 구간이 있다. */
23
+ | 'invalid-period';
24
+ export interface Mtbf {
25
+ equipmentId: string;
26
+ /** 고장 사이 평균 가동 시간(ms). **낼 수 없으면 없다.** */
27
+ mtbfMs?: number;
28
+ /** 분자 — `busy` 의 합. */
29
+ operatingMs: number;
30
+ /** 분모 — **고장으로 들어간 횟수**(이어진 `down` 은 한 번). */
31
+ failures: number;
32
+ /** 받은 `down` 구간의 수 — `failures` 와 다르면 이어진 것이 있었다. */
33
+ downPeriods: number;
34
+ /** 옮길 수 없던 상태 낱말들 — 무엇이 왔는지 말한다. */
35
+ unknownStatuses: string[];
36
+ missing: MtbfMissing[];
37
+ }
38
+ /**
39
+ * 이 설비의 MTBF.
40
+ *
41
+ * **모르는 상태를 만나면 답하지 않는다.** 빼고 계산하면 그 구간이 고장이었을 때 고장이 조용히
42
+ * 사라지고, 그러면 이 수가 실제보다 좋아진다 — 자료가 나쁠수록 수가 좋아지는 쪽은 두지 않는다.
43
+ */
44
+ export declare function computeMtbf(input: MtbfInput): Mtbf;
@@ -0,0 +1,105 @@
1
+ /*
2
+ * **MTBF** — ISO 22400-2. 고장 사이에 얼마나 돌았나.
3
+ *
4
+ * ── 왜 구간을 받나 (집계 값이 아니라) ────────────────────────────────────────
5
+ * `computeMtbf(가동시간, 고장횟수)` 로 두면 **「고장 횟수」를 부르는 쪽이 센다.** 그러면 세는 방법이
6
+ * 갈리고, 갈린 것을 아무도 모른다 — 오늘 이 저장소에서 여러 번 본 부류다.
7
+ *
8
+ * 특히 한 가지를 부르는 쪽이 거의 틀린다(아래 §고장 하나). 그래서 구간을 그대로 받아 세는 것까지
9
+ * 계약이 한다.
10
+ *
11
+ * ── 분자는 **실 가동 시간**이다 ──────────────────────────────────────────────
12
+ * `busy` 만 더한다. 달력 시간이 아니고, `setup` 도 아니다.
13
+ *
14
+ * 표준의 시간 모델에서 준비(AUST)는 실 생산 시간(APT)이 아니다. 준비를 더하면 **준비가 긴 설비의
15
+ * MTBF 가 좋아진다** — 고장 사이에 더 오래 「돌았다」고 세어지기 때문이다. 그것은 이 수가 답하려는
16
+ * 물음이 아니다.
17
+ *
18
+ * 대기(`idle`)와 계획정지(`planned-stop`)도 안 더한다. 서 있는 동안에는 고장날 일이 없다.
19
+ *
20
+ * ── 고장 하나 — **이어진 `down` 은 한 번이다** ───────────────────────────────
21
+ * 원본이 정지를 토막으로 적는 일이 흔하다. 교대가 갈릴 때, 사유를 나중에 고칠 때, 날짜가 바뀔 때.
22
+ *
23
+ * ```
24
+ * down 08:00–14:00 · down 14:00–20:00 한 번 고장난 것이다
25
+ * 그것을 두 번으로 세면 MTBF 가 절반이 된다
26
+ * ```
27
+ *
28
+ * 그래서 구간의 수를 세지 않고 **고장으로 들어간 횟수**를 센다. 이어진 `down` 은 들어간 것이 한 번
29
+ * 이므로 한 번이다. 이 셈을 부르는 쪽에 맡기면 거의 틀리고, 틀린 쪽이 늘 **더 나쁜 수**를 낸다.
30
+ *
31
+ * ── 고장이 없으면 답하지 않는다 ──────────────────────────────────────────────
32
+ * 나누는 수가 0이다. 「무한히 안 고장났다」로 답하면 화면이 그것을 가장 좋은 수로 그리는데, 실제로는
33
+ * **아직 한 번도 안 고장난 것**이고 그 기간이 한 시간일 수도 있다.
34
+ *
35
+ * 그때는 돌았던 시간과 고장 0을 그대로 낸다 — 사람이 그 둘을 보고 판단한다.
36
+ */
37
+ import { normalizeEquipmentStatus } from "./contract.js";
38
+ const at = (t) => (typeof t === 'string' ? Date.parse(t) : NaN);
39
+ /**
40
+ * 이 설비의 MTBF.
41
+ *
42
+ * **모르는 상태를 만나면 답하지 않는다.** 빼고 계산하면 그 구간이 고장이었을 때 고장이 조용히
43
+ * 사라지고, 그러면 이 수가 실제보다 좋아진다 — 자료가 나쁠수록 수가 좋아지는 쪽은 두지 않는다.
44
+ */
45
+ export function computeMtbf(input) {
46
+ const { equipmentId } = input;
47
+ const missing = [];
48
+ const unknown = new Set();
49
+ /* 시각으로 세운다 — 받은 순서를 믿지 않는다. 이어짐 판정이 순서에 걸린다. */
50
+ const sorted = [...(input.periods ?? [])]
51
+ .map(p => ({ status: normalizeEquipmentStatus(p?.status), said: String(p?.status ?? ''), from: at(p?.from), to: at(p?.to) }))
52
+ .sort((a, b) => a.from - b.from);
53
+ let operatingMs = 0;
54
+ let failures = 0;
55
+ let downPeriods = 0;
56
+ let invalid = false;
57
+ /* 앞 구간이 고장이었나 — 이어진 `down` 을 한 번으로 세는 열쇠다. */
58
+ let wasDown = false;
59
+ for (const p of sorted) {
60
+ if (!Number.isFinite(p.from) || !Number.isFinite(p.to) || p.to <= p.from) {
61
+ invalid = true;
62
+ /* 길이 없는 구간은 이어짐 판정도 흐트린다 — 앞 상태를 그대로 두고 넘긴다. */
63
+ continue;
64
+ }
65
+ if (p.status === undefined) {
66
+ unknown.add(p.said);
67
+ /*
68
+ * 모르는 낱말은 **이어짐을 끊는다.** 그것이 고장이었는지 아닌지 모르므로, 앞뒤의 `down` 을
69
+ * 한 번으로 묶어도 두 번으로 세도 근거가 없다. 어차피 답하지 않을 것이라 여기서는 끊어 둔다.
70
+ */
71
+ wasDown = false;
72
+ continue;
73
+ }
74
+ if (p.status === 'busy')
75
+ operatingMs += p.to - p.from;
76
+ if (p.status === 'down') {
77
+ downPeriods++;
78
+ if (!wasDown)
79
+ failures++;
80
+ wasDown = true;
81
+ }
82
+ else {
83
+ wasDown = false;
84
+ }
85
+ }
86
+ if (invalid)
87
+ missing.push('invalid-period');
88
+ if (unknown.size)
89
+ missing.push('unknown-status');
90
+ if (operatingMs <= 0)
91
+ missing.push('no-operating-time');
92
+ if (failures === 0)
93
+ missing.push('no-failure');
94
+ const out = {
95
+ equipmentId,
96
+ operatingMs,
97
+ failures,
98
+ downPeriods,
99
+ unknownStatuses: [...unknown],
100
+ missing
101
+ };
102
+ if (missing.length)
103
+ return out;
104
+ return { ...out, mtbfMs: operatingMs / failures };
105
+ }
@@ -5,7 +5,7 @@
5
5
  * 서명은 양쪽이 **한 글자까지 같은 규칙**을 써야 성립한다. 한쪽이 이어붙이는 순서를 바꾸면 다른 쪽은
6
6
  * 401 만 보고 이유를 알 수 없다. 그래서 만드는 함수와 확인하는 함수가 한 파일에 있어야 한다.
7
7
  *
8
- * 실제로 operato-mes 안에 `sign` 과 `verifySignature` 가 함께 있었는데, `verifySignature` 는 저장소
8
+ * 실제로 operato-plant 안에 `sign` 과 `verifySignature` 가 함께 있었는데, `verifySignature` 는 저장소
9
9
  * 어디에서도 불리지 않았다 — 확인할 수신부가 없었기 때문이다. 확인하는 쪽이 생기면서 그 함수의 자리가
10
10
  * 여기가 됐다.
11
11
  *
package/dist/webhook.d.ts CHANGED
@@ -55,7 +55,7 @@ export declare const WEBHOOK_TOO_MANY = 429;
55
55
  * 봉투 하나 — 번호와 사실을 함께 든다.
56
56
  *
57
57
  * `scope` 가 **번호를 매기는 단위**다. 이것이 없으면 받는 쪽은 그 번호가 어느 줄의 번호인지 모르고,
58
- * 커서를 무엇으로 잡을지 짐작해야 한다. 실제로 operato-mes 가 `[domain, channel]` 마다 번호를 매기면서
58
+ * 커서를 무엇으로 잡을지 짐작해야 한다. 실제로 operato-plant 가 `[domain, channel]` 마다 번호를 매기면서
59
59
  * 봉투에는 `channel` 을 싣지 않고 있었다 — 두 채널이 흐르기 시작하면 두 줄의 번호가 한 커서에 섞여
60
60
  * 연속성 검사가 끊임없이 「비었다」로 답한다.
61
61
  *
package/dist/webhook.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * ── 왜 계약에 있나 (2026-08-31) ──────────────────────────────────────────
5
5
  * 보내는 쪽은 응답 코드 하나로 「다시 보낼 것인가 · 기다릴 것인가 · 사람을 부를 것인가」를 정한다.
6
6
  * 그 판단표가 양쪽에 따로 있으면 한쪽만 바뀌는 날이 온다. 실제로 그렇게 되어 있었다 — 트윈은 409 를
7
- * 「그 트윈이 실시간으로 돌지 않는다」로 쓰고 operato-mes 는 409 를 「번호가 비었다」로 읽었다.
7
+ * 「그 트윈이 실시간으로 돌지 않는다」로 쓰고 operato-plant 는 409 를 「번호가 비었다」로 읽었다.
8
8
  * 붙였다면 트윈이 「안 돈다」고 답하는 것을 MES 가 「번호가 비었다」로 읽어 같은 구간을 되풀이 보냈다.
9
9
  *
10
10
  * 계약 층의 기준이 「만드는 쪽과 읽는 쪽이 합의해야 하는가」이고, 응답 코드와 번호 규칙이 그것이다.
@@ -0,0 +1,97 @@
1
+ import type { ISOTime } from './contract.ts';
2
+ /** 직행수율 하나의 단위 — **공정 하나**다. 경로 전체는 이름이 다르다(§아래). */
3
+ export interface FirstPassInput {
4
+ /** 어느 작업지시의. */
5
+ jobOrderName: string;
6
+ /** 어느 공정의 — 실적이 이 값을 들고 있다. */
7
+ operationKey: string;
8
+ /**
9
+ * 그 공정에 **들어간 수**. 모르면 적지 않는다 — 아래 둘의 합이 대신 선다.
10
+ *
11
+ * **분모가 아니다.** 분모는 마친 수(양품+부적합)이고, 이 값은 **창이 닫혔는지**를 말한다 — 들어간
12
+ * 것보다 마친 것이 적으면 아직 공정에 남아 있다.
13
+ */
14
+ entered?: number;
15
+ /** 처음에 통과한 수 — **재작업해서 통과한 것을 여기 더하지 않는다**(§`FIRST_PASS_RULE`). */
16
+ good: number;
17
+ /**
18
+ * **부적합 처분이 달린 수** — 종류를 가리지 않는다.
19
+ *
20
+ * 이름을 `scrap` 으로 두지 않는다. 실적의 그 칸은 「부적합 전체」를 세는데 처분 어휘의 `scrap` 은
21
+ * 「폐기」 하나다(§`DISPOSITION_DECISION`). 같은 낱말이 두 뜻이라, 부르는 쪽이 「폐기 수」로 읽으면
22
+ * 재작업된 것이 분모에서 빠져 **직행수율이 100%로 나온다.** 오류는 안 난다.
23
+ */
24
+ nonconforming: number;
25
+ }
26
+ /**
27
+ * 분모는 **그 공정을 마친 수**다 — 양품과 부적합의 합.
28
+ *
29
+ * ── 왜 「들어간 수」가 분모가 아닌가 (첫 판을 고침) ─────────────────────────
30
+ * 처음에는 `들어간 수 − 부적합` 을 분자로 뒀다. **틀렸다.** 들어갔는데 아직 안 나온 것이 그 계산에서
31
+ * 「처음에 통과」로 세어진다 — 158이 들어가고 154가 나왔는데 160으로 잰다면 2개를 통과로 센다.
32
+ *
33
+ * 마친 수로 세면 그 수가 참이다. `entered` 는 분모가 아니라 **창이 닫혔는지**를 말하는 데 쓴다.
34
+ */
35
+ export type FirstPassBasis = 'completed';
36
+ /** 낼 수 없는 이유 — 「0%」로 답하지 않는다. */
37
+ export type FirstPassMissing =
38
+ /** 마친 것이 없다 — 아직 아무 실적이 없다. */
39
+ 'no-input'
40
+ /** 마친 수가 들어간 수보다 많다 — 재작업 산출을 되더한 자료가 여기서 걸린다. */
41
+ | 'inconsistent-counts'
42
+ /** 수가 아니거나 음수다. */
43
+ | 'invalid-counts';
44
+ export interface FirstPassYield {
45
+ jobOrderName: string;
46
+ operationKey: string;
47
+ /** 0~1. **낼 수 없으면 없다** — 0 으로 채우지 않는다. */
48
+ fpy?: number;
49
+ /** 분자로 쓴 수 — 처음에 통과한 수. */
50
+ firstPassGood: number;
51
+ /** 분모로 쓴 수 — 그 공정을 마친 수. */
52
+ completed: number;
53
+ basis: FirstPassBasis;
54
+ /**
55
+ * 아직 그 공정에 있는 수 — **`entered` 를 알 때만.**
56
+ *
57
+ * 이 값이 0보다 크면 창이 열려 있고, 그때의 수율은 「지금까지 마친 것」의 수율이다. 모르면 비어
58
+ * 있고, 그것은 「0개가 남았다」와 다르다.
59
+ */
60
+ inProcess?: number;
61
+ /** 낼 수 없던 이유들. 비어 있으면 낸 것이다. */
62
+ missing: FirstPassMissing[];
63
+ }
64
+ /**
65
+ * **이 수가 직행수율이 되기 위한 규율** — 계약이 글로 들고, 두 쪽이 같은 것을 지킨다.
66
+ *
67
+ * 트윈과 MES 가 각자 이 문장을 지키지 않으면 같은 이름으로 다른 수가 나오고, 그때 어느 쪽이 맞는지
68
+ * 말할 수 없다.
69
+ */
70
+ export declare const FIRST_PASS_RULE = "\uC7AC\uC791\uC5C5\u00B7\uC218\uB9AC\uB85C \uD1B5\uACFC\uD55C \uC0B0\uCD9C\uC740 \uC6D0\uB798 \uACF5\uC815 \uC2E4\uC801\uC758 \uC591\uD488\uC5D0 \uB418\uB354\uD558\uC9C0 \uC54A\uB294\uB2E4 \u2014 \uADF8\uAC83\uC744 \uB354\uD558\uBA74 \uC774 \uC218\uB294 \uD488\uC9C8\uB960\uC774 \uB41C\uB2E4";
71
+ /**
72
+ * 공정 하나의 직행수율.
73
+ *
74
+ * **경로 전체는 이 함수가 아니다.** 갈래가 합류하는 공장에서 공정별과 경로 전체는 다른 수이고, 경로
75
+ * 전체는 `rolledThroughputYield` 로 이름이 갈린다 — ISO 22400 의 KPI 가 아니라 다른 관행의 이름이라
76
+ * 섞으면 사람이 공정 하나의 수로 읽는다.
77
+ */
78
+ export declare function computeFirstPassYield(input: FirstPassInput): FirstPassYield;
79
+ /**
80
+ * **경로 전체의 수율** — 공정별 직행수율의 곱.
81
+ *
82
+ * ISO 22400 의 KPI 가 아니다. 다른 관행(식스시그마)의 이름이고, 그래서 **이름을 갈라 둔다** —
83
+ * 직행수율이라고 부르면 사람이 공정 하나의 수로 읽고, 「어느 공정을 고칠까」에 답할 수 없다.
84
+ *
85
+ * 하나라도 낼 수 없으면 **전체를 내지 않는다.** 빠진 공정을 1로 두고 곱하면 그 공정이 완벽했다는
86
+ * 뜻이 되고, 자료가 없을수록 수가 좋아진다.
87
+ */
88
+ export declare function rolledThroughputYield(steps: readonly FirstPassYield[]): {
89
+ rty?: number;
90
+ countedSteps: number;
91
+ skipped: number;
92
+ };
93
+ /** 언제 잰 것인가 — 창이 열린 채로 센 수는 분모가 작다(§머리말). */
94
+ export interface FirstPassWindow {
95
+ from?: ISOTime;
96
+ to?: ISOTime;
97
+ }
package/dist/yield.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * **이 수가 직행수율이 되기 위한 규율** — 계약이 글로 들고, 두 쪽이 같은 것을 지킨다.
3
+ *
4
+ * 트윈과 MES 가 각자 이 문장을 지키지 않으면 같은 이름으로 다른 수가 나오고, 그때 어느 쪽이 맞는지
5
+ * 말할 수 없다.
6
+ */
7
+ export const FIRST_PASS_RULE = '재작업·수리로 통과한 산출은 원래 공정 실적의 양품에 되더하지 않는다 — 그것을 더하면 이 수는 품질률이 된다';
8
+ const finite = (n) => typeof n === 'number' && Number.isFinite(n);
9
+ /**
10
+ * 공정 하나의 직행수율.
11
+ *
12
+ * **경로 전체는 이 함수가 아니다.** 갈래가 합류하는 공장에서 공정별과 경로 전체는 다른 수이고, 경로
13
+ * 전체는 `rolledThroughputYield` 로 이름이 갈린다 — ISO 22400 의 KPI 가 아니라 다른 관행의 이름이라
14
+ * 섞으면 사람이 공정 하나의 수로 읽는다.
15
+ */
16
+ export function computeFirstPassYield(input) {
17
+ const missing = [];
18
+ const { jobOrderName, operationKey } = input;
19
+ const good = input.good;
20
+ const bad = input.nonconforming;
21
+ const known = input.entered !== undefined;
22
+ if (!finite(good) || !finite(bad) || good < 0 || bad < 0 || (known && (!finite(input.entered) || input.entered < 0))) {
23
+ return { jobOrderName, operationKey, firstPassGood: 0, completed: 0, basis: 'completed', missing: ['invalid-counts'] };
24
+ }
25
+ const completed = good + bad;
26
+ if (completed <= 0)
27
+ missing.push('no-input');
28
+ /*
29
+ * 마친 수가 들어간 수보다 많으면 **답하지 않는다.** 어느 쪽이 틀렸는지 우리가 정할 수 없고, 그때
30
+ * 비율을 내면 그 수가 참인 척한다. 화면이 그것을 그리면 사람이 자료를 의심하지 않고 지표를 의심한다.
31
+ *
32
+ * 이 검사가 잡는 실제 경우가 하나 있다 — **재작업 산출을 원래 실적에 되더한 자료**다. 그러면 마친
33
+ * 수가 들어간 수를 넘고, 여기서 거절된다(§`FIRST_PASS_RULE`).
34
+ */
35
+ if (known && completed > input.entered)
36
+ missing.push('inconsistent-counts');
37
+ const inProcess = known ? Math.max(0, input.entered - completed) : undefined;
38
+ if (missing.length) {
39
+ return {
40
+ jobOrderName,
41
+ operationKey,
42
+ firstPassGood: good,
43
+ completed,
44
+ basis: 'completed',
45
+ ...(inProcess === undefined ? {} : { inProcess }),
46
+ missing
47
+ };
48
+ }
49
+ /*
50
+ * 분자는 **양품 수**다 — 규율이 지켜지면 그것이 곧 「처음에 통과한 수」다(§`FIRST_PASS_RULE`).
51
+ *
52
+ * **함수는 그 규율이 지켜졌는지 알 수 없다.** 재작업 산출이 그 칸에 되더해져 있으면 같은 식이 품질률을
53
+ * 낸다. 위의 일관성 검사가 `entered` 를 알 때만 그것을 잡는다 — 모를 때는 자료를 만드는 쪽이 규율을
54
+ * 지켜야 하고, 그래서 그 규율이 계약에 글로 있다.
55
+ */
56
+ return {
57
+ jobOrderName,
58
+ operationKey,
59
+ fpy: good / completed,
60
+ firstPassGood: good,
61
+ completed,
62
+ basis: 'completed',
63
+ ...(inProcess === undefined ? {} : { inProcess }),
64
+ missing
65
+ };
66
+ }
67
+ /**
68
+ * **경로 전체의 수율** — 공정별 직행수율의 곱.
69
+ *
70
+ * ISO 22400 의 KPI 가 아니다. 다른 관행(식스시그마)의 이름이고, 그래서 **이름을 갈라 둔다** —
71
+ * 직행수율이라고 부르면 사람이 공정 하나의 수로 읽고, 「어느 공정을 고칠까」에 답할 수 없다.
72
+ *
73
+ * 하나라도 낼 수 없으면 **전체를 내지 않는다.** 빠진 공정을 1로 두고 곱하면 그 공정이 완벽했다는
74
+ * 뜻이 되고, 자료가 없을수록 수가 좋아진다.
75
+ */
76
+ export function rolledThroughputYield(steps) {
77
+ const usable = steps.filter(s => s.fpy !== undefined);
78
+ const skipped = steps.length - usable.length;
79
+ if (!usable.length || skipped > 0)
80
+ return { countedSteps: usable.length, skipped };
81
+ return { rty: usable.reduce((acc, s) => acc * s.fpy, 1), countedSteps: usable.length, skipped };
82
+ }
@@ -43,8 +43,10 @@ __export(index_exports, {
43
43
  EPCIS_CONTEXT: () => EPCIS_CONTEXT,
44
44
  EPCIS_EVENT: () => EPCIS_EVENT,
45
45
  EQUIPMENT_LEVEL: () => EQUIPMENT_LEVEL,
46
+ EQUIPMENT_STATUS: () => EQUIPMENT_STATUS,
46
47
  ERP_CAPABILITY: () => ERP_CAPABILITY,
47
48
  ERP_MASTER_AXIS: () => ERP_MASTER_AXIS,
49
+ FIRST_PASS_RULE: () => FIRST_PASS_RULE,
48
50
  GUARD_PRAGMA: () => GUARD_PRAGMA,
49
51
  ILMD_ATTR: () => ILMD_ATTR,
50
52
  LOCATION_SATURATION_NEAR: () => LOCATION_SATURATION_NEAR,
@@ -96,6 +98,8 @@ __export(index_exports, {
96
98
  commandSpecGaps: () => commandSpecGaps,
97
99
  commandTypeOf: () => commandTypeOf,
98
100
  commandsOf: () => commandsOf,
101
+ computeFirstPassYield: () => computeFirstPassYield,
102
+ computeMtbf: () => computeMtbf,
99
103
  computeOee: () => computeOee,
100
104
  conversionFactorOf: () => conversionFactorOf,
101
105
  criterionSaysNothing: () => criterionSaysNothing,
@@ -137,9 +141,12 @@ __export(index_exports, {
137
141
  isEnergyUsagePeriodRecord: () => isEnergyUsagePeriodRecord,
138
142
  isEpcisEventType: () => isEpcisEventType,
139
143
  isEquipmentLevel: () => isEquipmentLevel,
144
+ isEquipmentStatus: () => isEquipmentStatus,
145
+ isFailureStatus: () => isFailureStatus,
140
146
  isMasterDataRecord: () => isMasterDataRecord,
141
147
  isOperationalRecord: () => isOperationalRecord,
142
148
  isOrderTerminal: () => isOrderTerminal,
149
+ isPlannedStopStatus: () => isPlannedStopStatus,
143
150
  isTransformationRecord: () => isTransformationRecord,
144
151
  isoDurationHours: () => isoDurationHours,
145
152
  itemKeyOf: () => itemKeyOf,
@@ -157,6 +164,7 @@ __export(index_exports, {
157
164
  minuteOfDayAt: () => minuteOfDayAt,
158
165
  missingCommandPayload: () => missingCommandPayload,
159
166
  nextCommandState: () => nextCommandState,
167
+ normalizeEquipmentStatus: () => normalizeEquipmentStatus,
160
168
  objectEvent: () => objectEvent,
161
169
  objectUri: () => objectUri,
162
170
  observationAt: () => observationAt,
@@ -184,6 +192,7 @@ __export(index_exports, {
184
192
  resolveSubject: () => resolveSubject,
185
193
  retiredVocabularyIn: () => retiredVocabularyIn,
186
194
  reversalKey: () => reversalKey,
195
+ rolledThroughputYield: () => rolledThroughputYield,
187
196
  sgtinClass: () => sgtinClass,
188
197
  sgtinUri: () => sgtinUri,
189
198
  ssccUri: () => ssccUri,
@@ -396,19 +405,19 @@ function hierarchyOf(s, levelOfType) {
396
405
  }
397
406
  for (const start of known) {
398
407
  const seen = [start];
399
- for (let at = parent.get(start); at !== void 0; at = parent.get(at)) {
400
- if (seen.includes(at)) throw new Error(`location hierarchy has a cycle: ${[...seen, at].join(" \u2192 ")}`);
401
- seen.push(at);
402
- if (!known.has(at)) break;
408
+ for (let at2 = parent.get(start); at2 !== void 0; at2 = parent.get(at2)) {
409
+ if (seen.includes(at2)) throw new Error(`location hierarchy has a cycle: ${[...seen, at2].join(" \u2192 ")}`);
410
+ seen.push(at2);
411
+ if (!known.has(at2)) break;
403
412
  }
404
413
  }
405
414
  const homes = /* @__PURE__ */ new Map();
406
415
  for (const m of s.equipment ?? []) if (m.homeLocation) push(homes, m.homeLocation, m.id);
407
416
  const ancestorsOf = (id) => {
408
417
  const out = [];
409
- for (let at = parent.get(id); at !== void 0; at = parent.get(at)) {
410
- out.push(at);
411
- if (!known.has(at)) break;
418
+ for (let at2 = parent.get(id); at2 !== void 0; at2 = parent.get(at2)) {
419
+ out.push(at2);
420
+ if (!known.has(at2)) break;
412
421
  }
413
422
  return out;
414
423
  };
@@ -463,18 +472,18 @@ var DISPOSITION_DECISION = [
463
472
  /** 아직 정하지 않았다 — 묶어 두고 쓰지 않는다. */
464
473
  "hold"
465
474
  ];
466
- function testPassedAt(r, at) {
475
+ function testPassedAt(r, at2) {
467
476
  if (r.result !== "pass") return false;
468
- if (!r.expiresAt || !at) return r.result === "pass";
469
- return parsedMs(at) <= parsedMs(r.expiresAt);
477
+ if (!r.expiresAt || !at2) return r.result === "pass";
478
+ return parsedMs(at2) <= parsedMs(r.expiresAt);
470
479
  }
471
- function meetsTests(required, results, at) {
480
+ function meetsTests(required, results, at2) {
472
481
  if (!required.length) return true;
473
482
  const bySpec = new Map((results ?? []).map((r) => [r.specId, r]));
474
483
  for (const specId of required) {
475
484
  const r = bySpec.get(specId);
476
485
  if (!r) continue;
477
- if (!testPassedAt(r, at)) return false;
486
+ if (!testPassedAt(r, at2)) return false;
478
487
  }
479
488
  return true;
480
489
  }
@@ -523,9 +532,9 @@ function judgeAgainstSpec(spec, result) {
523
532
  }
524
533
  return allJudged ? "pass" : void 0;
525
534
  }
526
- function effectivityAt(p, at, opts) {
527
- if (!p || !at) return void 0;
528
- const atMs = parsedMs(at);
535
+ function effectivityAt(p, at2, opts) {
536
+ if (!p || !at2) return void 0;
537
+ const atMs = parsedMs(at2);
529
538
  if (!Number.isFinite(atMs)) return void 0;
530
539
  const from = p.effectiveStart ? parsedMs(p.effectiveStart) : NaN;
531
540
  if (Number.isFinite(from) && atMs < from) return "not-yet";
@@ -535,12 +544,12 @@ function effectivityAt(p, at, opts) {
535
544
  }
536
545
  var PARSED_MAX = 64;
537
546
  var parsedCache = /* @__PURE__ */ new Map();
538
- function parsedMs(at) {
539
- const hit = parsedCache.get(at);
547
+ function parsedMs(at2) {
548
+ const hit = parsedCache.get(at2);
540
549
  if (hit !== void 0) return hit;
541
- const ms = Date.parse(at);
550
+ const ms = Date.parse(at2);
542
551
  if (parsedCache.size >= PARSED_MAX) parsedCache.clear();
543
- parsedCache.set(at, ms);
552
+ parsedCache.set(at2, ms);
544
553
  return ms;
545
554
  }
546
555
  var classIndexCache = /* @__PURE__ */ new WeakMap();
@@ -553,9 +562,9 @@ function classIndex(defs) {
553
562
  return built;
554
563
  }
555
564
  var EMPTY_CLASS_INDEX = /* @__PURE__ */ new Map();
556
- function classClosure(directIds, defs, at) {
565
+ function classClosure(directIds, defs, at2) {
557
566
  const byId = classIndex(defs);
558
- const inWindow = (d) => !d || effectivityAt(d, at) === void 0;
567
+ const inWindow = (d) => !d || effectivityAt(d, at2) === void 0;
559
568
  const out = /* @__PURE__ */ new Set();
560
569
  const stack = [...directIds ?? []];
561
570
  while (stack.length) {
@@ -725,14 +734,14 @@ function offCalendarAt(r, ms, utcOffsetMinutes) {
725
734
  const h = Math.floor(minute / 60);
726
735
  return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
727
736
  }
728
- function requiredTestsFor(directIds, defs, at) {
737
+ function requiredTestsFor(directIds, defs, at2) {
729
738
  if (!defs?.length) return [];
730
739
  const cache = requiredTestsCache.get(defs) ?? /* @__PURE__ */ new Map();
731
740
  if (!requiredTestsCache.has(defs)) requiredTestsCache.set(defs, cache);
732
- const key = `${at ?? ""}\0${(directIds ?? []).join("")}`;
741
+ const key = `${at2 ?? ""}\0${(directIds ?? []).join("")}`;
733
742
  const hit = cache.get(key);
734
743
  if (hit) return hit;
735
- const closure = classClosure(directIds, defs, at);
744
+ const closure = classClosure(directIds, defs, at2);
736
745
  const required = [];
737
746
  for (const d of defs) if (closure.has(d.id)) required.push(...d.testSpecificationIds ?? []);
738
747
  if (cache.size >= REQUIRED_TESTS_MAX) cache.clear();
@@ -742,27 +751,27 @@ function requiredTestsFor(directIds, defs, at) {
742
751
  var REQUIRED_TESTS_MAX = 512;
743
752
  var requiredTestsCache = /* @__PURE__ */ new WeakMap();
744
753
  function capabilityOf(r, ctx) {
745
- const at = ctx?.at;
746
- const eff = effectivityAt(r, at);
754
+ const at2 = ctx?.at;
755
+ const eff = effectivityAt(r, at2);
747
756
  if (eff === "not-yet") return { available: false, reason: "not-yet" };
748
757
  if (eff === "expired") return { available: false, reason: "retired" };
749
758
  if (r.held) return { available: false, reason: "held" };
750
759
  if (r.status === "down") return { available: false, reason: "down" };
751
- if (at) {
752
- const ms = parsedMs(at);
760
+ if (at2) {
761
+ const ms = parsedMs(at2);
753
762
  if (Number.isFinite(ms)) {
754
763
  const why = offCalendarReasonAt(r, ms, ctx?.utcOffsetMinutes);
755
764
  if (why === "non-working") return { available: false, reason: "resting" };
756
765
  if (why === "off-hours") return { available: false, reason: "off-shift" };
757
766
  }
758
767
  }
759
- if (ctx?.requiredTests?.length && !meetsTests(ctx.requiredTests, r.testResults, at))
768
+ if (ctx?.requiredTests?.length && !meetsTests(ctx.requiredTests, r.testResults, at2))
760
769
  return { available: false, reason: "test-expired" };
761
770
  if (r.status && r.status !== "idle" && r.status !== "available") return { available: false, reason: "working" };
762
771
  return { available: true, reason: "available" };
763
772
  }
764
- function observationAt(observations, propertyId, at) {
765
- const t = parsedMs(at);
773
+ function observationAt(observations, propertyId, at2) {
774
+ const t = parsedMs(at2);
766
775
  if (!(t >= 0)) return void 0;
767
776
  let best;
768
777
  let bestStart = -1;
@@ -976,6 +985,20 @@ var ENERGY_EVENT = {
976
985
  drSuggested: "energy.dr.suggested"
977
986
  };
978
987
  var OBSERVATION_BASIS = ["metered", "provider", "billed", "estimated"];
988
+ var EQUIPMENT_STATUS = ["busy", "setup", "down", "idle", "planned-stop"];
989
+ function isEquipmentStatus(value) {
990
+ return typeof value === "string" && EQUIPMENT_STATUS.includes(value);
991
+ }
992
+ function normalizeEquipmentStatus(value) {
993
+ const said = typeof value === "string" ? value.trim().toLowerCase() : "";
994
+ return isEquipmentStatus(said) ? said : void 0;
995
+ }
996
+ function isFailureStatus(value) {
997
+ return normalizeEquipmentStatus(value) === "down";
998
+ }
999
+ function isPlannedStopStatus(value) {
1000
+ return normalizeEquipmentStatus(value) === "planned-stop";
1001
+ }
979
1002
  var CMD = {
980
1003
  orderHold: "order.hold",
981
1004
  orderResume: "order.resume",
@@ -2044,8 +2067,8 @@ function ingestEnergyRecords(records, opts) {
2044
2067
  const errors = [];
2045
2068
  const meterId = String(record?.meterId ?? "").trim();
2046
2069
  if (!meterId) errors.push("meterId \uC5C6\uC74C \u2014 \uC5B4\uB514\uC758 \uC18C\uBE44\uC778\uC9C0 \uBAA8\uB974\uB294 \uAC12\uC740 \uB204\uC801\uD560 \uC218 \uC5C6\uB2E4");
2047
- const at = String(record?.at ?? "").trim() || opts.defaultEventTime;
2048
- const atMs = at ? Date.parse(at) : Number.NaN;
2070
+ const at2 = String(record?.at ?? "").trim() || opts.defaultEventTime;
2071
+ const atMs = at2 ? Date.parse(at2) : Number.NaN;
2049
2072
  if (!Number.isFinite(atMs)) errors.push("at \uC5C6\uC74C/\uD615\uC2DD \uC624\uB958 \u2014 \uC9C0\uAE08 \uC2DC\uAC01\uC73C\uB85C \uBA54\uC6B0\uBA74 \uB0A8\uC758 \uC218\uC694 \uAD6C\uAC04\uC5D0 \uC2E4\uB9B0\uB2E4");
2050
2073
  const num = (v, name) => {
2051
2074
  if (v === void 0 || v === null || v === "") return void 0;
@@ -2100,8 +2123,8 @@ function ingestEnergyEquipmentRecords(records, opts) {
2100
2123
  const r = record;
2101
2124
  const equipmentId = String(r?.equipmentId ?? "").trim();
2102
2125
  if (!equipmentId) errors.push("equipmentId \uC5C6\uC74C \u2014 \uC5B4\uB290 \uC124\uBE44\uC758 \uC0C1\uD0DC\uC778\uC9C0 \uBAA8\uB974\uB294 \uAC12\uC740 \uC2E4\uC744 \uC218 \uC5C6\uB2E4");
2103
- const at = String(r?.at ?? "").trim() || opts.defaultEventTime;
2104
- const atMs = at ? Date.parse(at) : Number.NaN;
2126
+ const at2 = String(r?.at ?? "").trim() || opts.defaultEventTime;
2127
+ const atMs = at2 ? Date.parse(at2) : Number.NaN;
2105
2128
  if (!Number.isFinite(atMs)) errors.push("at \uC5C6\uC74C/\uD615\uC2DD \uC624\uB958 \u2014 \uC9C0\uAE08 \uC2DC\uAC01\uC73C\uB85C \uBA54\uC6B0\uBA74 \uC5B8\uC81C\uC758 \uC0C1\uD0DC\uC778\uC9C0 \uC54C \uC218 \uC5C6\uB2E4");
2106
2129
  const num = (v, name, min, max) => {
2107
2130
  if (v === void 0 || v === null || v === "") return void 0;
@@ -2239,9 +2262,9 @@ function ingestEnergyGenerationRecords(records, opts) {
2239
2262
  const rawSince = r?.kWhSince;
2240
2263
  if (rawSince !== void 0 && rawSince !== null && String(rawSince).trim()) {
2241
2264
  const text = String(rawSince).trim();
2242
- const at = Date.parse(text);
2243
- if (!Number.isFinite(at)) errors.push(`kWhSince \uB97C \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(rawSince)}`);
2244
- else if (Number.isFinite(Date.parse(eventTime)) && at > Date.parse(eventTime)) {
2265
+ const at2 = Date.parse(text);
2266
+ if (!Number.isFinite(at2)) errors.push(`kWhSince \uB97C \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(rawSince)}`);
2267
+ else if (Number.isFinite(Date.parse(eventTime)) && at2 > Date.parse(eventTime)) {
2245
2268
  errors.push(`kWhSince(${text}) \uAC00 \uC7B0 \uC2DC\uAC01(${eventTime})\uBCF4\uB2E4 \uB4A4\uB2E4 \u2014 \uB458 \uC911 \uD558\uB098\uAC00 \uD2C0\uB838\uB2E4`);
2246
2269
  } else since = text;
2247
2270
  }
@@ -3196,13 +3219,13 @@ function lotFromAttributes(attrs) {
3196
3219
  function readEpochMs(raw) {
3197
3220
  if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
3198
3221
  if (typeof raw !== "string" || !raw.trim()) return void 0;
3199
- const at = Date.parse(raw);
3200
- return Number.isNaN(at) ? void 0 : at;
3222
+ const at2 = Date.parse(raw);
3223
+ return Number.isNaN(at2) ? void 0 : at2;
3201
3224
  }
3202
3225
 
3203
3226
  // src/operational-ingest.ts
3204
3227
  var TASK_STATUS = ["created", "assigned", "in-progress", "completed"];
3205
- var EQUIPMENT_STATUS = ["idle", "busy", "down", "setup", "planned-stop"];
3228
+ var EQUIPMENT_STATUS2 = ["idle", "busy", "down", "setup", "planned-stop"];
3206
3229
  var PERSON_STATUS = ["idle", "busy"];
3207
3230
  var ASSET_STATUS = ["idle", "in-use"];
3208
3231
  var SPECS = {
@@ -3270,7 +3293,7 @@ var SPECS = {
3270
3293
  재검사하지는 않는다: 그 모양은 `EquipmentMotion` 계약이고, 여기서 두 번 지키면 두 벌이 된다. */
3271
3294
  motion: "object"
3272
3295
  },
3273
- enums: { status: EQUIPMENT_STATUS }
3296
+ enums: { status: EQUIPMENT_STATUS2 }
3274
3297
  },
3275
3298
  person: {
3276
3299
  eventType: OP_EVENT.person,
@@ -3415,7 +3438,7 @@ var SPECS = {
3415
3438
  decidedBy: "string",
3416
3439
  recordTime: "string"
3417
3440
  },
3418
- enums: { status: EQUIPMENT_STATUS }
3441
+ enums: { status: EQUIPMENT_STATUS2 }
3419
3442
  },
3420
3443
  /*
3421
3444
  * **부적합 처분** — 재고 판정을 받은 것을 어떻게 하기로 정했나.
@@ -3633,8 +3656,8 @@ function ingestOperationalRecords(records, opts) {
3633
3656
  }
3634
3657
  }
3635
3658
  const closedPeriodEnd = kind === "equipment-period" ? String(r.to ?? "").trim() : "";
3636
- const at = closedPeriodEnd || String(r.at ?? "").trim() || opts.defaultEventTime;
3637
- const atMs = at ? Date.parse(at) : Number.NaN;
3659
+ const at2 = closedPeriodEnd || String(r.at ?? "").trim() || opts.defaultEventTime;
3660
+ const atMs = at2 ? Date.parse(at2) : Number.NaN;
3638
3661
  if (!Number.isFinite(atMs)) {
3639
3662
  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`);
3640
3663
  }
@@ -3726,28 +3749,28 @@ function operationsCapabilityOf(input) {
3726
3749
  var DISTRIBUTIONS = /* @__PURE__ */ new Set(["poisson", "uniform", "constant", "profile"]);
3727
3750
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
3728
3751
  var bad = (errorCode, errorParams) => ({ ok: false, errorCode, errorParams });
3729
- function validateGenerator(g, at) {
3730
- if (!g || typeof g !== "object") return bad("scenario-generator-invalid", { at });
3731
- if (!g.kind || typeof g.kind !== "string") return bad("scenario-generator-kind-required", { at });
3752
+ function validateGenerator(g, at2) {
3753
+ if (!g || typeof g !== "object") return bad("scenario-generator-invalid", { at: at2 });
3754
+ if (!g.kind || typeof g.kind !== "string") return bad("scenario-generator-kind-required", { at: at2 });
3732
3755
  const rate = g.rate;
3733
- if (!rate || typeof rate !== "object") return bad("scenario-rate-required", { at, kind: g.kind });
3734
- if (!isNum(rate.meanPerHour) || rate.meanPerHour < 0) return bad("scenario-rate-mean-invalid", { at, kind: g.kind });
3735
- if (!DISTRIBUTIONS.has(rate.distribution)) return bad("scenario-rate-distribution-invalid", { at, kind: g.kind, distribution: String(rate.distribution ?? "") });
3736
- if (rate.distribution === "profile" && !Array.isArray(rate.profile)) return bad("scenario-rate-profile-required", { at, kind: g.kind });
3756
+ if (!rate || typeof rate !== "object") return bad("scenario-rate-required", { at: at2, kind: g.kind });
3757
+ if (!isNum(rate.meanPerHour) || rate.meanPerHour < 0) return bad("scenario-rate-mean-invalid", { at: at2, kind: g.kind });
3758
+ if (!DISTRIBUTIONS.has(rate.distribution)) return bad("scenario-rate-distribution-invalid", { at: at2, kind: g.kind, distribution: String(rate.distribution ?? "") });
3759
+ if (rate.distribution === "profile" && !Array.isArray(rate.profile)) return bad("scenario-rate-profile-required", { at: at2, kind: g.kind });
3737
3760
  const content = g.content;
3738
- if (!content || typeof content !== "object") return bad("scenario-content-required", { at, kind: g.kind });
3739
- if (!Array.isArray(content.skuMix) || content.skuMix.length === 0) return bad("scenario-sku-mix-required", { at, kind: g.kind });
3761
+ if (!content || typeof content !== "object") return bad("scenario-content-required", { at: at2, kind: g.kind });
3762
+ if (!Array.isArray(content.skuMix) || content.skuMix.length === 0) return bad("scenario-sku-mix-required", { at: at2, kind: g.kind });
3740
3763
  for (const s of content.skuMix) {
3741
- if (!s?.gtin || typeof s.gtin !== "string") return bad("scenario-sku-gtin-required", { at, kind: g.kind });
3742
- if (!isNum(s.weight) || s.weight <= 0) return bad("scenario-sku-weight-invalid", { at, kind: g.kind, gtin: String(s.gtin) });
3764
+ if (!s?.gtin || typeof s.gtin !== "string") return bad("scenario-sku-gtin-required", { at: at2, kind: g.kind });
3765
+ if (!isNum(s.weight) || s.weight <= 0) return bad("scenario-sku-weight-invalid", { at: at2, kind: g.kind, gtin: String(s.gtin) });
3743
3766
  }
3744
3767
  const q = content.qtyPerLine;
3745
- if (!q || !isNum(q.min) || !isNum(q.max)) return bad("scenario-qty-required", { at, kind: g.kind });
3746
- if (q.min < 0 || q.max < q.min) return bad("scenario-qty-range-invalid", { at, kind: g.kind, min: q.min, max: q.max });
3768
+ if (!q || !isNum(q.min) || !isNum(q.max)) return bad("scenario-qty-required", { at: at2, kind: g.kind });
3769
+ if (q.min < 0 || q.max < q.min) return bad("scenario-qty-range-invalid", { at: at2, kind: g.kind, min: q.min, max: q.max });
3747
3770
  const l = content.linesPerOrder;
3748
- if (l && (!isNum(l.min) || !isNum(l.max) || l.min < 1 || l.max < l.min)) return bad("scenario-lines-range-invalid", { at, kind: g.kind });
3771
+ if (l && (!isNum(l.min) || !isNum(l.max) || l.min < 1 || l.max < l.min)) return bad("scenario-lines-range-invalid", { at: at2, kind: g.kind });
3749
3772
  if (g.stimulus !== void 0 && g.stimulus !== "arrival" && g.stimulus !== "order") {
3750
- return bad("scenario-stimulus-invalid", { at, kind: g.kind, stimulus: String(g.stimulus) });
3773
+ return bad("scenario-stimulus-invalid", { at: at2, kind: g.kind, stimulus: String(g.stimulus) });
3751
3774
  }
3752
3775
  return { ok: true };
3753
3776
  }
@@ -3893,6 +3916,98 @@ function computeOee(c, nowMs) {
3893
3916
  };
3894
3917
  }
3895
3918
 
3919
+ // src/yield.ts
3920
+ var FIRST_PASS_RULE = "\uC7AC\uC791\uC5C5\xB7\uC218\uB9AC\uB85C \uD1B5\uACFC\uD55C \uC0B0\uCD9C\uC740 \uC6D0\uB798 \uACF5\uC815 \uC2E4\uC801\uC758 \uC591\uD488\uC5D0 \uB418\uB354\uD558\uC9C0 \uC54A\uB294\uB2E4 \u2014 \uADF8\uAC83\uC744 \uB354\uD558\uBA74 \uC774 \uC218\uB294 \uD488\uC9C8\uB960\uC774 \uB41C\uB2E4";
3921
+ var finite = (n) => typeof n === "number" && Number.isFinite(n);
3922
+ function computeFirstPassYield(input) {
3923
+ const missing = [];
3924
+ const { jobOrderName, operationKey } = input;
3925
+ const good = input.good;
3926
+ const bad2 = input.nonconforming;
3927
+ const known = input.entered !== void 0;
3928
+ if (!finite(good) || !finite(bad2) || good < 0 || bad2 < 0 || known && (!finite(input.entered) || input.entered < 0)) {
3929
+ return { jobOrderName, operationKey, firstPassGood: 0, completed: 0, basis: "completed", missing: ["invalid-counts"] };
3930
+ }
3931
+ const completed = good + bad2;
3932
+ if (completed <= 0) missing.push("no-input");
3933
+ if (known && completed > input.entered) missing.push("inconsistent-counts");
3934
+ const inProcess = known ? Math.max(0, input.entered - completed) : void 0;
3935
+ if (missing.length) {
3936
+ return {
3937
+ jobOrderName,
3938
+ operationKey,
3939
+ firstPassGood: good,
3940
+ completed,
3941
+ basis: "completed",
3942
+ ...inProcess === void 0 ? {} : { inProcess },
3943
+ missing
3944
+ };
3945
+ }
3946
+ return {
3947
+ jobOrderName,
3948
+ operationKey,
3949
+ fpy: good / completed,
3950
+ firstPassGood: good,
3951
+ completed,
3952
+ basis: "completed",
3953
+ ...inProcess === void 0 ? {} : { inProcess },
3954
+ missing
3955
+ };
3956
+ }
3957
+ function rolledThroughputYield(steps) {
3958
+ const usable = steps.filter((s) => s.fpy !== void 0);
3959
+ const skipped = steps.length - usable.length;
3960
+ if (!usable.length || skipped > 0) return { countedSteps: usable.length, skipped };
3961
+ return { rty: usable.reduce((acc, s) => acc * s.fpy, 1), countedSteps: usable.length, skipped };
3962
+ }
3963
+
3964
+ // src/reliability.ts
3965
+ var at = (t) => typeof t === "string" ? Date.parse(t) : NaN;
3966
+ function computeMtbf(input) {
3967
+ const { equipmentId } = input;
3968
+ const missing = [];
3969
+ const unknown = /* @__PURE__ */ new Set();
3970
+ const sorted = [...input.periods ?? []].map((p) => ({ status: normalizeEquipmentStatus(p?.status), said: String(p?.status ?? ""), from: at(p?.from), to: at(p?.to) })).sort((a, b) => a.from - b.from);
3971
+ let operatingMs = 0;
3972
+ let failures = 0;
3973
+ let downPeriods = 0;
3974
+ let invalid = false;
3975
+ let wasDown = false;
3976
+ for (const p of sorted) {
3977
+ if (!Number.isFinite(p.from) || !Number.isFinite(p.to) || p.to <= p.from) {
3978
+ invalid = true;
3979
+ continue;
3980
+ }
3981
+ if (p.status === void 0) {
3982
+ unknown.add(p.said);
3983
+ wasDown = false;
3984
+ continue;
3985
+ }
3986
+ if (p.status === "busy") operatingMs += p.to - p.from;
3987
+ if (p.status === "down") {
3988
+ downPeriods++;
3989
+ if (!wasDown) failures++;
3990
+ wasDown = true;
3991
+ } else {
3992
+ wasDown = false;
3993
+ }
3994
+ }
3995
+ if (invalid) missing.push("invalid-period");
3996
+ if (unknown.size) missing.push("unknown-status");
3997
+ if (operatingMs <= 0) missing.push("no-operating-time");
3998
+ if (failures === 0) missing.push("no-failure");
3999
+ const out = {
4000
+ equipmentId,
4001
+ operatingMs,
4002
+ failures,
4003
+ downPeriods,
4004
+ unknownStatuses: [...unknown],
4005
+ missing
4006
+ };
4007
+ if (missing.length) return out;
4008
+ return { ...out, mtbfMs: operatingMs / failures };
4009
+ }
4010
+
3896
4011
  // src/erp.ts
3897
4012
  var SCHEDULE_STATUS = ["draft", "released", "started", "completed", "closed", "cancelled"];
3898
4013
  function acceptsPerformance(status) {
@@ -4078,8 +4193,10 @@ function commandSpecGaps(specs, command) {
4078
4193
  EPCIS_CONTEXT,
4079
4194
  EPCIS_EVENT,
4080
4195
  EQUIPMENT_LEVEL,
4196
+ EQUIPMENT_STATUS,
4081
4197
  ERP_CAPABILITY,
4082
4198
  ERP_MASTER_AXIS,
4199
+ FIRST_PASS_RULE,
4083
4200
  GUARD_PRAGMA,
4084
4201
  ILMD_ATTR,
4085
4202
  LOCATION_SATURATION_NEAR,
@@ -4131,6 +4248,8 @@ function commandSpecGaps(specs, command) {
4131
4248
  commandSpecGaps,
4132
4249
  commandTypeOf,
4133
4250
  commandsOf,
4251
+ computeFirstPassYield,
4252
+ computeMtbf,
4134
4253
  computeOee,
4135
4254
  conversionFactorOf,
4136
4255
  criterionSaysNothing,
@@ -4172,9 +4291,12 @@ function commandSpecGaps(specs, command) {
4172
4291
  isEnergyUsagePeriodRecord,
4173
4292
  isEpcisEventType,
4174
4293
  isEquipmentLevel,
4294
+ isEquipmentStatus,
4295
+ isFailureStatus,
4175
4296
  isMasterDataRecord,
4176
4297
  isOperationalRecord,
4177
4298
  isOrderTerminal,
4299
+ isPlannedStopStatus,
4178
4300
  isTransformationRecord,
4179
4301
  isoDurationHours,
4180
4302
  itemKeyOf,
@@ -4192,6 +4314,7 @@ function commandSpecGaps(specs, command) {
4192
4314
  minuteOfDayAt,
4193
4315
  missingCommandPayload,
4194
4316
  nextCommandState,
4317
+ normalizeEquipmentStatus,
4195
4318
  objectEvent,
4196
4319
  objectUri,
4197
4320
  observationAt,
@@ -4219,6 +4342,7 @@ function commandSpecGaps(specs, command) {
4219
4342
  resolveSubject,
4220
4343
  retiredVocabularyIn,
4221
4344
  reversalKey,
4345
+ rolledThroughputYield,
4222
4346
  sgtinClass,
4223
4347
  sgtinUri,
4224
4348
  ssccUri,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/ops-contract",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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",