@operato/twin-kernel 0.7.27 → 0.7.28

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.
@@ -210,6 +210,39 @@ interface Rng {
210
210
  * FlowEngine.computeAttentions(sim) 와 live projector 미러가 **공유**하는 계산 층(face2-inbound-live §1.1):
211
211
  * sim/live 가 같은 임계값·규칙을 쓴다. good/scrap 미제공(관측 상태에 없음)이면 scrap-high 는 자연 스킵(metric 층 갭).
212
212
  */
213
+ /**
214
+ * 주의 판정의 **기준값 어휘** — 현장이 정하는 수다.
215
+ *
216
+ * ── 왜 선언으로 받나 (2026-08-18) ───────────────────────────────────────────
217
+ * 혼잡 90%·불량 15%는 **우리가 정한 수**였다(코드 상수). 그런데 통로를 95%로 채워 쓰는 창고는 그 기준에서
218
+ * 영구히 「병목」이고, 초기 라인(불량 20% 예상)은 계속 「불량 과다」로 운다. 경보가 소음이 되면 사람이
219
+ * 경보 자체를 읽지 않는다 — 그때 진짜 경보도 함께 묻힌다.
220
+ *
221
+ * 그래서 기준을 현장이 넣을 수 있게 하고, **넣지 않으면 기본값을 쓰되 그 사실을 밝힌다**(`basis`).
222
+ * 조용히 기본값을 진실처럼 두지 않는다.
223
+ *
224
+ * 퍼센트다: 90 이 90%이고 0.9 는 0.9%다(SOC 에서 한 번 겪은 함정 — 명세가 그 말을 한다).
225
+ */
226
+ export declare const ATTENTION_PROPERTY: {
227
+ /** 자리의 혼잡 판정 기준(%) — 점유/용량이 이 값을 넘으면 병목으로 본다. */
228
+ readonly congestionRatio: "attention.congestionRatio";
229
+ /** 설비의 불량률 판정 기준(%) — 이 값을 넘으면 불량 과다로 본다. */
230
+ readonly scrapRate: "attention.scrapRate";
231
+ /** 불량률을 재기 시작할 최소 표본 수 — 적은 표본의 비율은 판정 근거가 못 된다. */
232
+ readonly scrapMinSamples: "attention.scrapMinSamples";
233
+ };
234
+ /** 선언이 없을 때 쓰는 기본값 — **드러내 둔다**(코드 안에 숨은 상수가 아니라 계약의 일부다). */
235
+ export declare const ATTENTION_DEFAULTS: {
236
+ readonly congestionPct: 90;
237
+ readonly scrapPct: 15;
238
+ readonly scrapMinSamples: 10;
239
+ };
240
+ /** 판정에 쓸 기준 — 대상별 선언이 있으면 그것, 없으면 기본값(그 사실을 함께 든다). */
241
+ export interface AttentionThresholds {
242
+ congestionPctOf?: (locationId: string) => number | undefined;
243
+ scrapPctOf?: (equipmentId: string) => number | undefined;
244
+ scrapMinSamplesOf?: (equipmentId: string) => number | undefined;
245
+ }
213
246
  export declare function deriveAttentions(view: {
214
247
  equipment: {
215
248
  id: string;
@@ -243,7 +276,13 @@ export declare function deriveAttentions(view: {
243
276
  }[];
244
277
  }, acked?: ReadonlySet<string>,
245
278
  /** 지금(ISO) — 납기 판정에 필요하다. **주지 않으면 지연을 판정하지 않는다**(모르면 판단하지 않는다). */
246
- nowIso?: ISOTime): Attention[];
279
+ nowIso?: ISOTime,
280
+ /**
281
+ * 현장이 선언한 판정 기준 — 주지 않으면 기본값으로 판정하고 그 사실을 `params.basis` 로 밝힌다.
282
+ *
283
+ * 부르는 쪽이 모델에서 뽑아 넘긴다(이 함수는 순수하게 남는다 — 모델을 읽는 규칙이 두 곳이 되지 않게).
284
+ */
285
+ thresholds?: AttentionThresholds): Attention[];
247
286
  /** OEE 계측 카운터 — sim 은 tick 으로 누적, live 는 실 텔레메트리 또는 이벤트 누적기가 채운다(face2-inbound-live §1.1). */
248
287
  export interface OeeCounters {
249
288
  runMs: number;
@@ -491,6 +530,14 @@ export declare abstract class FlowEngine implements TwinKernel {
491
530
  getSnapshot(): StateSnapshot;
492
531
  protected computeAttentions(): Attention[];
493
532
  protected collectAttentions(): Attention[];
533
+ /**
534
+ * 현장이 선언한 판정 기준을 모델에서 뽑는다 — **규칙은 한 곳**(순수 함수는 모델을 읽지 않는다).
535
+ *
536
+ * 자리·설비의 속성에서 읽고, 없으면 `undefined` 를 돌려 기본값이 쓰이게 한다(그 사실은 판정 층이
537
+ * `basis` 로 밝힌다). 값이 수가 아니거나 범위를 벗어나면 **없는 것으로 본다** — 사람이 손으로 넣는
538
+ * 값이라 잘못 들어올 수 있고, 잘못된 기준으로 판정하는 것은 기본값보다 나쁘다.
539
+ */
540
+ protected attentionThresholds(): AttentionThresholds;
494
541
  /**
495
542
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
496
543
  * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
@@ -44,9 +44,38 @@ function mulberry32(seed) {
44
44
  * FlowEngine.computeAttentions(sim) 와 live projector 미러가 **공유**하는 계산 층(face2-inbound-live §1.1):
45
45
  * sim/live 가 같은 임계값·규칙을 쓴다. good/scrap 미제공(관측 상태에 없음)이면 scrap-high 는 자연 스킵(metric 층 갭).
46
46
  */
47
+ /**
48
+ * 주의 판정의 **기준값 어휘** — 현장이 정하는 수다.
49
+ *
50
+ * ── 왜 선언으로 받나 (2026-08-18) ───────────────────────────────────────────
51
+ * 혼잡 90%·불량 15%는 **우리가 정한 수**였다(코드 상수). 그런데 통로를 95%로 채워 쓰는 창고는 그 기준에서
52
+ * 영구히 「병목」이고, 초기 라인(불량 20% 예상)은 계속 「불량 과다」로 운다. 경보가 소음이 되면 사람이
53
+ * 경보 자체를 읽지 않는다 — 그때 진짜 경보도 함께 묻힌다.
54
+ *
55
+ * 그래서 기준을 현장이 넣을 수 있게 하고, **넣지 않으면 기본값을 쓰되 그 사실을 밝힌다**(`basis`).
56
+ * 조용히 기본값을 진실처럼 두지 않는다.
57
+ *
58
+ * 퍼센트다: 90 이 90%이고 0.9 는 0.9%다(SOC 에서 한 번 겪은 함정 — 명세가 그 말을 한다).
59
+ */
60
+ export const ATTENTION_PROPERTY = {
61
+ /** 자리의 혼잡 판정 기준(%) — 점유/용량이 이 값을 넘으면 병목으로 본다. */
62
+ congestionRatio: 'attention.congestionRatio',
63
+ /** 설비의 불량률 판정 기준(%) — 이 값을 넘으면 불량 과다로 본다. */
64
+ scrapRate: 'attention.scrapRate',
65
+ /** 불량률을 재기 시작할 최소 표본 수 — 적은 표본의 비율은 판정 근거가 못 된다. */
66
+ scrapMinSamples: 'attention.scrapMinSamples'
67
+ };
68
+ /** 선언이 없을 때 쓰는 기본값 — **드러내 둔다**(코드 안에 숨은 상수가 아니라 계약의 일부다). */
69
+ export const ATTENTION_DEFAULTS = { congestionPct: 90, scrapPct: 15, scrapMinSamples: 10 };
47
70
  export function deriveAttentions(view, acked,
48
71
  /** 지금(ISO) — 납기 판정에 필요하다. **주지 않으면 지연을 판정하지 않는다**(모르면 판단하지 않는다). */
49
- nowIso) {
72
+ nowIso,
73
+ /**
74
+ * 현장이 선언한 판정 기준 — 주지 않으면 기본값으로 판정하고 그 사실을 `params.basis` 로 밝힌다.
75
+ *
76
+ * 부르는 쪽이 모델에서 뽑아 넘긴다(이 함수는 순수하게 남는다 — 모델을 읽는 규칙이 두 곳이 되지 않게).
77
+ */
78
+ thresholds) {
50
79
  // 언어 중립: kind + params(원시값) + 조치 code 만 방출. 사람이 읽는 title/detail/rationale/라벨은
51
80
  // 표현계층(클라 i18next)이 kind/code 로 렌더한다(무방언·다국어 — design/plans/i18n.md L3).
52
81
  const out = [];
@@ -67,12 +96,19 @@ nowIso) {
67
96
  for (const n of view.locations) {
68
97
  if ((n.capacity ?? 0) > 0) {
69
98
  const r = (n.occupancy ?? 0) / n.capacity;
70
- if (r >= 0.9) {
99
+ /* 기준은 현장이 정한다 — 없으면 기본값이고, 어느 쪽인지 함께 낸다(조용히 기본값을 진실로 두지 않는다). */
100
+ const declaredPct = thresholds?.congestionPctOf?.(n.id);
101
+ const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.congestionPct;
102
+ if (r * 100 >= thresholdPct) {
71
103
  const saturated = r >= 1;
72
104
  out.push({
73
105
  id: `bottleneck:${n.id}`, kind: 'bottleneck', severity: saturated ? 'high' : 'medium',
74
106
  anchor: { locationId: n.id },
75
- params: { locationId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
107
+ params: {
108
+ locationId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100),
109
+ saturated: saturated ? 1 : 0,
110
+ thresholdPct, basis: declaredPct === undefined ? 'default' : 'declared'
111
+ },
76
112
  recommendedActions: [{ code: 'advice.add-resource' }, { code: 'advice.downstream-priority' }]
77
113
  // 권고만(command 없음) — 표현계층이 advice.* 코드로 렌더
78
114
  });
@@ -130,13 +166,21 @@ nowIso) {
130
166
  }
131
167
  for (const m of view.equipment) {
132
168
  const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
133
- if (total >= 10) {
169
+ const declaredMin = thresholds?.scrapMinSamplesOf?.(m.id);
170
+ const minSamples = declaredMin ?? ATTENTION_DEFAULTS.scrapMinSamples;
171
+ if (total >= minSamples) {
134
172
  const rate = (m.scrapCount ?? 0) / total;
135
- if (rate >= 0.15)
173
+ const declaredPct = thresholds?.scrapPctOf?.(m.id);
174
+ const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.scrapPct;
175
+ /* 「높음」은 기준의 두 배에서 — 기준이 현장의 것이면 그 두 배도 현장의 것이다(상수 두 벌을 두지 않는다). */
176
+ if (rate * 100 >= thresholdPct)
136
177
  out.push({
137
- id: `scrap:${m.id}`, kind: 'scrap-high', severity: rate >= 0.3 ? 'high' : 'medium',
178
+ id: `scrap:${m.id}`, kind: 'scrap-high', severity: rate * 100 >= thresholdPct * 2 ? 'high' : 'medium',
138
179
  anchor: { moverId: m.id, locationId: m.location },
139
- params: { moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100) },
180
+ params: {
181
+ moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100),
182
+ thresholdPct, basis: declaredPct === undefined ? 'default' : 'declared'
183
+ },
140
184
  recommendedActions: [
141
185
  { code: 'act.hold-for-inspection', command: CMD.resourceHold, args: { resourceId: m.id } },
142
186
  { code: 'act.reset-metrics', command: CMD.resourceResetMetrics, args: { resourceId: m.id } }
@@ -1105,10 +1149,40 @@ export class FlowEngine {
1105
1149
  locations: [...this.locations.values()],
1106
1150
  orders: [...this.orders.values()],
1107
1151
  tasks: [...this.tasks.values()]
1108
- }, this._acked, now // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
1109
- );
1152
+ }, this._acked, now, // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
1153
+ this.attentionThresholds());
1110
1154
  return out;
1111
1155
  }
1156
+ /**
1157
+ * 현장이 선언한 판정 기준을 모델에서 뽑는다 — **규칙은 한 곳**(순수 함수는 모델을 읽지 않는다).
1158
+ *
1159
+ * 자리·설비의 속성에서 읽고, 없으면 `undefined` 를 돌려 기본값이 쓰이게 한다(그 사실은 판정 층이
1160
+ * `basis` 로 밝힌다). 값이 수가 아니거나 범위를 벗어나면 **없는 것으로 본다** — 사람이 손으로 넣는
1161
+ * 값이라 잘못 들어올 수 있고, 잘못된 기준으로 판정하는 것은 기본값보다 나쁘다.
1162
+ */
1163
+ attentionThresholds() {
1164
+ const pct = (props, id) => {
1165
+ const raw = props?.find(p => p.id === id)?.value;
1166
+ if (raw === undefined)
1167
+ return undefined;
1168
+ const n = Number(raw);
1169
+ return Number.isFinite(n) && n > 0 && n <= 100 ? n : undefined;
1170
+ };
1171
+ const count = (props, id) => {
1172
+ const raw = props?.find(p => p.id === id)?.value;
1173
+ if (raw === undefined)
1174
+ return undefined;
1175
+ const n = Number(raw);
1176
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : undefined;
1177
+ };
1178
+ const locProps = new Map((this.boardDef?.locations ?? []).map(l => [l.id, l.properties]));
1179
+ const eqProps = new Map(readBoardEquipment(this.boardDef ?? {}).map(e => [e.id, e.properties]));
1180
+ return {
1181
+ congestionPctOf: id => pct(locProps.get(id), ATTENTION_PROPERTY.congestionRatio),
1182
+ scrapPctOf: id => pct(eqProps.get(id), ATTENTION_PROPERTY.scrapRate),
1183
+ scrapMinSamplesOf: id => count(eqProps.get(id), ATTENTION_PROPERTY.scrapMinSamples)
1184
+ };
1185
+ }
1112
1186
  /**
1113
1187
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
1114
1188
  * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
@@ -19,6 +19,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // src/index.ts
20
20
  var index_exports = {};
21
21
  __export(index_exports, {
22
+ ATTENTION_DEFAULTS: () => ATTENTION_DEFAULTS,
23
+ ATTENTION_PROPERTY: () => ATTENTION_PROPERTY,
22
24
  BIZSTEP: () => BIZSTEP,
23
25
  BTT: () => BTT,
24
26
  BTT_DELIVERY: () => BTT_DELIVERY,
@@ -2952,7 +2954,16 @@ function mulberry32(seed) {
2952
2954
  } });
2953
2955
  return fn;
2954
2956
  }
2955
- function deriveAttentions(view, acked, nowIso) {
2957
+ var ATTENTION_PROPERTY = {
2958
+ /** 자리의 혼잡 판정 기준(%) — 점유/용량이 이 값을 넘으면 병목으로 본다. */
2959
+ congestionRatio: "attention.congestionRatio",
2960
+ /** 설비의 불량률 판정 기준(%) — 이 값을 넘으면 불량 과다로 본다. */
2961
+ scrapRate: "attention.scrapRate",
2962
+ /** 불량률을 재기 시작할 최소 표본 수 — 적은 표본의 비율은 판정 근거가 못 된다. */
2963
+ scrapMinSamples: "attention.scrapMinSamples"
2964
+ };
2965
+ var ATTENTION_DEFAULTS = { congestionPct: 90, scrapPct: 15, scrapMinSamples: 10 };
2966
+ function deriveAttentions(view, acked, nowIso, thresholds) {
2956
2967
  const out = [];
2957
2968
  for (const m of view.equipment) {
2958
2969
  if (m.status === "down") {
@@ -2973,14 +2984,24 @@ function deriveAttentions(view, acked, nowIso) {
2973
2984
  for (const n of view.locations) {
2974
2985
  if ((n.capacity ?? 0) > 0) {
2975
2986
  const r = (n.occupancy ?? 0) / n.capacity;
2976
- if (r >= 0.9) {
2987
+ const declaredPct = thresholds?.congestionPctOf?.(n.id);
2988
+ const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.congestionPct;
2989
+ if (r * 100 >= thresholdPct) {
2977
2990
  const saturated = r >= 1;
2978
2991
  out.push({
2979
2992
  id: `bottleneck:${n.id}`,
2980
2993
  kind: "bottleneck",
2981
2994
  severity: saturated ? "high" : "medium",
2982
2995
  anchor: { locationId: n.id },
2983
- params: { locationId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
2996
+ params: {
2997
+ locationId: n.id,
2998
+ occupancy: n.occupancy ?? 0,
2999
+ capacity: n.capacity ?? 0,
3000
+ ratioPct: Math.round(r * 100),
3001
+ saturated: saturated ? 1 : 0,
3002
+ thresholdPct,
3003
+ basis: declaredPct === void 0 ? "default" : "declared"
3004
+ },
2984
3005
  recommendedActions: [{ code: "advice.add-resource" }, { code: "advice.downstream-priority" }]
2985
3006
  // 권고만(command 없음) — 표현계층이 advice.* 코드로 렌더
2986
3007
  });
@@ -3013,14 +3034,25 @@ function deriveAttentions(view, acked, nowIso) {
3013
3034
  }
3014
3035
  for (const m of view.equipment) {
3015
3036
  const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
3016
- if (total >= 10) {
3037
+ const declaredMin = thresholds?.scrapMinSamplesOf?.(m.id);
3038
+ const minSamples = declaredMin ?? ATTENTION_DEFAULTS.scrapMinSamples;
3039
+ if (total >= minSamples) {
3017
3040
  const rate = (m.scrapCount ?? 0) / total;
3018
- if (rate >= 0.15) out.push({
3041
+ const declaredPct = thresholds?.scrapPctOf?.(m.id);
3042
+ const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.scrapPct;
3043
+ if (rate * 100 >= thresholdPct) out.push({
3019
3044
  id: `scrap:${m.id}`,
3020
3045
  kind: "scrap-high",
3021
- severity: rate >= 0.3 ? "high" : "medium",
3046
+ severity: rate * 100 >= thresholdPct * 2 ? "high" : "medium",
3022
3047
  anchor: { moverId: m.id, locationId: m.location },
3023
- params: { moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100) },
3048
+ params: {
3049
+ moverId: m.id,
3050
+ goodCount: m.goodCount ?? 0,
3051
+ scrapCount: m.scrapCount ?? 0,
3052
+ ratePct: Math.round(rate * 100),
3053
+ thresholdPct,
3054
+ basis: declaredPct === void 0 ? "default" : "declared"
3055
+ },
3024
3056
  recommendedActions: [
3025
3057
  { code: "act.hold-for-inspection", command: CMD.resourceHold, args: { resourceId: m.id } },
3026
3058
  { code: "act.reset-metrics", command: CMD.resourceResetMetrics, args: { resourceId: m.id } }
@@ -3885,11 +3917,44 @@ var FlowEngine = class {
3885
3917
  tasks: [...this.tasks.values()]
3886
3918
  },
3887
3919
  this._acked,
3888
- now
3920
+ now,
3889
3921
  // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
3922
+ this.attentionThresholds()
3890
3923
  );
3891
3924
  return out;
3892
3925
  }
3926
+ /**
3927
+ * 현장이 선언한 판정 기준을 모델에서 뽑는다 — **규칙은 한 곳**(순수 함수는 모델을 읽지 않는다).
3928
+ *
3929
+ * 자리·설비의 속성에서 읽고, 없으면 `undefined` 를 돌려 기본값이 쓰이게 한다(그 사실은 판정 층이
3930
+ * `basis` 로 밝힌다). 값이 수가 아니거나 범위를 벗어나면 **없는 것으로 본다** — 사람이 손으로 넣는
3931
+ * 값이라 잘못 들어올 수 있고, 잘못된 기준으로 판정하는 것은 기본값보다 나쁘다.
3932
+ */
3933
+ attentionThresholds() {
3934
+ const pct = (props, id) => {
3935
+ const raw = props?.find((p) => p.id === id)?.value;
3936
+ if (raw === void 0) return void 0;
3937
+ const n = Number(raw);
3938
+ return Number.isFinite(n) && n > 0 && n <= 100 ? n : void 0;
3939
+ };
3940
+ const count = (props, id) => {
3941
+ const raw = props?.find((p) => p.id === id)?.value;
3942
+ if (raw === void 0) return void 0;
3943
+ const n = Number(raw);
3944
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : void 0;
3945
+ };
3946
+ const locProps = new Map(
3947
+ (this.boardDef?.locations ?? []).map((l) => [l.id, l.properties])
3948
+ );
3949
+ const eqProps = new Map(
3950
+ readBoardEquipment(this.boardDef ?? {}).map((e) => [e.id, e.properties])
3951
+ );
3952
+ return {
3953
+ congestionPctOf: (id) => pct(locProps.get(id), ATTENTION_PROPERTY.congestionRatio),
3954
+ scrapPctOf: (id) => pct(eqProps.get(id), ATTENTION_PROPERTY.scrapRate),
3955
+ scrapMinSamplesOf: (id) => count(eqProps.get(id), ATTENTION_PROPERTY.scrapMinSamples)
3956
+ };
3957
+ }
3893
3958
  /**
3894
3959
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
3895
3960
  * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
@@ -7175,6 +7240,8 @@ function retiredVocabularyIn(line) {
7175
7240
  }
7176
7241
  // Annotate the CommonJS export names for ESM import in node:
7177
7242
  0 && (module.exports = {
7243
+ ATTENTION_DEFAULTS,
7244
+ ATTENTION_PROPERTY,
7178
7245
  BIZSTEP,
7179
7246
  BTT,
7180
7247
  BTT_DELIVERY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.27",
3
+ "version": "0.7.28",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {