@operato/twin-kernel 0.7.53 → 0.7.55
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/dist/contract.d.ts +320 -9
- package/dist/contract.js +120 -0
- package/dist/flow-engine.d.ts +13 -0
- package/dist/flow-engine.js +17 -0
- package/dist/forecast.d.ts +17 -0
- package/dist/forecast.js +22 -6
- package/dist/mes-kernel.d.ts +7 -0
- package/dist/mes-kernel.js +51 -2
- package/dist-cjs/index.cjs +114 -8
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -204,17 +204,34 @@ levelOfType?: (type: string) => EquipmentLevel | undefined): Hierarchy;
|
|
|
204
204
|
* 템플릿이다. 즉 「모른다」가 아니라 「원천 쪽」이다. 표식은 그 기본값과 다를 때 달린다.
|
|
205
205
|
*/
|
|
206
206
|
export type PropertySource = 'reference' | 'local';
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
/**
|
|
208
|
+
* **ISA-95 `ValueType`** — 값·데이터형·단위 한 묶음. 1차 출처(B2MML v0700+): `ValueType`
|
|
209
|
+
* (`B2MML-Common.xsd`)의 `ValueString`·`DataType`·`UnitOfMeasure`.
|
|
210
|
+
*
|
|
211
|
+
* ── 왜 따로 뽑나 (2026-08-24) ────────────────────────────────────────────────
|
|
212
|
+
* 표준은 이 묶음을 **여러 곳에서 재사용한다** — 자원 속성(`<X>PropertyType.Value`) · 시험 측정값
|
|
213
|
+
* (`PropertyMeasurementType.Value`) · 공정 모수 실측(`OpSegmentDataType.Value`) · 자리 속성
|
|
214
|
+
* (`OperationalLocationPropertyType.Value`). 처음에는 자원 속성에만 있었고 세 필드가 그 안에 인라인
|
|
215
|
+
* 이었다. 두 번째 자리가 생기는 순간 **같은 매핑을 두 번 적게 되고**, 그때부터 한쪽만 고쳐지는 길이
|
|
216
|
+
* 열린다(단위를 한쪽에서만 필수로 만들거나, `dataType` 주석이 갈리는 식으로).
|
|
217
|
+
*
|
|
218
|
+
* 표준이 한 타입으로 둔 것을 우리도 한 타입으로 둔다. **평평하게 펴는 것**은 우리 선택이다 —
|
|
219
|
+
* 표준은 `Value` 를 자식 요소로 두지만, 소비처가 `p.value` 로 읽는 편이 `p.value.valueString` 보다
|
|
220
|
+
* 낫고 이 저장소가 이미 그렇게 하고 있었다. 그 선택을 여기 적어 둔다(되돌리려면 소비처 전부가 움직인다).
|
|
221
|
+
*/
|
|
222
|
+
export interface StandardValue {
|
|
212
223
|
/** 표준 `Value.ValueString`. 값의 표기는 문자열이고, 뜻은 `dataType`·`uom` 이 정한다. */
|
|
213
224
|
value?: string;
|
|
214
225
|
/** 표준 `Value.DataType` — 예: `xs:double`. 미지정이면 소비처가 형을 짐작하지 않는다. */
|
|
215
226
|
dataType?: string;
|
|
216
|
-
/** 표준 `Value.UnitOfMeasure` — UN/CEFACT 공통코드(예: `MTS`·`KMH`). **단위 없는 물리량은 쓰지 않는다.** */
|
|
227
|
+
/** 표준 `Value.UnitOfMeasure` — UN/CEFACT 공통코드(예: `MTS`·`KMH`·`CEL`). **단위 없는 물리량은 쓰지 않는다.** */
|
|
217
228
|
uom?: string;
|
|
229
|
+
}
|
|
230
|
+
export interface ResourceProperty extends StandardValue {
|
|
231
|
+
/** 속성 식별자 — 표준 `ID`. 어휘는 표준이 정하지 않으므로 우리가 한 곳에서 정한다 — 그 자리는 읽는 쪽에 있다
|
|
232
|
+
* (headless-twin `EQUIPMENT_PROPERTY`·`PROPERTY_EFFECTS`). 커널은 속성을 **나르기만** 한다. */
|
|
233
|
+
id: string;
|
|
234
|
+
description?: string;
|
|
218
235
|
/** 하위 속성 — 표준 `<X>PropertyChild`(재귀). 구조화된 속성(예: 정격/실측 묶음)을 잃지 않기 위해. */
|
|
219
236
|
children?: ResourceProperty[];
|
|
220
237
|
/** 이 개체 속성이 구체화하는 **등급 속성** — 표준 `<X>ClassPropertyID`. */
|
|
@@ -278,14 +295,29 @@ export type TestSpecificationRefs = string[];
|
|
|
278
295
|
* **결과가 선언돼 있으면 그때부터 제약이 된다**: 불합격이거나 유효기간이 지났으면 그 등급으로 자격이
|
|
279
296
|
* 성립하지 않는다. 그것이 이 값을 싣는 이유다(싣지 않으면 아무 제약도 없다).
|
|
280
297
|
*
|
|
281
|
-
*
|
|
298
|
+
* ── 1차 출처와 대조했다 (2026-08-24) ─────────────────────────────────────────
|
|
299
|
+
* 예전에는 「필드 이름을 아직 대조하지 않았다」고 유보해 두었다. `B2MML-OperationsTest.xsd` 의
|
|
300
|
+
* `TestResultType` 을 읽었고, 그 결과 **둘이 갈렸다.**
|
|
301
|
+
*
|
|
302
|
+
* 표준이 준 것 `EvaluationDate` · `Expiration` · `TestableObjectID`
|
|
303
|
+
* · `EvaluatedCriterionResult` · `PropertyMeasurement`(unbounded)
|
|
304
|
+
* 우리가 좁힌 것 `result: 'pass' | 'fail'` — **표준은 판정을 열거하지 않는다**(`TextType`)
|
|
305
|
+
*
|
|
306
|
+
* 좁힘을 적어 두는 이유: HACCP 같은 규제 판정에는 「적합·부적합」 밖의 값이 온다(재검·조건부). 그때
|
|
307
|
+
* 이 유니온을 넓혀야 하는데, **좁힘이라고 적혀 있지 않으면 다음 사람이 「표준이 둘만 정했다」고 읽는다.**
|
|
308
|
+
*
|
|
309
|
+
* `at`·`expiresAt` 은 표준 `EvaluationDate`·`Expiration` 이다 — 이름을 바꾸지 않는다(이미 나간 계약이고,
|
|
310
|
+
* 개명이 사는 값은 이 주석이 대신 낸다).
|
|
282
311
|
*/
|
|
283
312
|
export interface TestResult {
|
|
284
313
|
/** 어느 시험인가 — `TestSpecification.id` 를 가리킨다. */
|
|
285
314
|
specId: string;
|
|
286
|
-
/**
|
|
315
|
+
/**
|
|
316
|
+
* 합격 여부 — **우리가 좁힌 것이다.** 표준 `EvaluatedCriterionResult` 는 `TextType` 이고
|
|
317
|
+
* 판정 어휘를 열거하지 않는다(원문 대조 2026-08-24). 넓힐 때는 이 주석과 함께 움직인다.
|
|
318
|
+
*/
|
|
287
319
|
result: 'pass' | 'fail';
|
|
288
|
-
/** 언제 통과·불합격했나(ISO)
|
|
320
|
+
/** 언제 통과·불합격했나(ISO) — 표준 `EvaluationDate`. */
|
|
289
321
|
at?: ISOTime;
|
|
290
322
|
/**
|
|
291
323
|
* 언제까지 유효한가(ISO) — 자격에는 대개 유효기간이 있다.
|
|
@@ -294,6 +326,18 @@ export interface TestResult {
|
|
|
294
326
|
* (유효기간을 모르는 것과 지난 것은 다르다).
|
|
295
327
|
*/
|
|
296
328
|
expiresAt?: ISOTime;
|
|
329
|
+
/**
|
|
330
|
+
* 이 판정의 **근거가 된 측정값들** — 표준 `TestResult.PropertyMeasurement`(`maxOccurs="unbounded"`).
|
|
331
|
+
*
|
|
332
|
+
* ── 왜 판정에 값을 붙이나 (당위) ──────────────────────────────────────────
|
|
333
|
+
* 「부적합」이라고만 적힌 기록과 「4.2°C 를 재어 한계를 넘었다」는 기록은 **다른 물건**이다. 앞의
|
|
334
|
+
* 것으로는 되짚을 수 없다 — 얼마나 벗어났나, 언제였나, 다시 재면 같은 답이 나오나에 답할 수 없다.
|
|
335
|
+
* 규제 기록은 성질상 **사고 뒤에** 읽히므로, 근거 없는 판정은 그때 아무 일도 하지 못한다.
|
|
336
|
+
*
|
|
337
|
+
* **없어도 판정은 성립한다** — 원본이 결론만 주는 현장이 있고, 그때 값을 지어내지 않는다. 빈 것은
|
|
338
|
+
* `testEvidenceGaps` 가 세어 낸다(조용히 넘기지 않는다).
|
|
339
|
+
*/
|
|
340
|
+
propertyMeasurements?: PropertyMeasurement[];
|
|
297
341
|
}
|
|
298
342
|
/**
|
|
299
343
|
* 이 시험 결과가 **이 시각에 유효한 합격인가.**
|
|
@@ -348,6 +392,76 @@ export interface Capability {
|
|
|
348
392
|
available: boolean;
|
|
349
393
|
reason: CapabilityReason;
|
|
350
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* **판정 기준 하나** — 표준 `TestSpecificationCriteriaType`(`B2MML-OperationsTest.xsd`).
|
|
397
|
+
*
|
|
398
|
+
* ── 왜 이 축이 필요한가 (당위) ────────────────────────────────────────────────
|
|
399
|
+
* 측정값만 있으면 트윈은 「4.2」를 들고 있을 뿐이고 **그것이 괜찮은지 모른다.** 판정의 재료는
|
|
400
|
+
* 「값」과 「기준」 둘이다. 기준이 없으면 트윈은 조건을 알면서도 아무 말도 할 수 없다 — 계기판이 된다.
|
|
401
|
+
*
|
|
402
|
+
* ── 커널은 기준을 **정하지 않고, 재해석하지도 않는다** ──────────────────────
|
|
403
|
+
* 기준은 그 현장의 규정이다(식품안전·약전·사내 규격). 트윈이 정할 것이 아니고, **표현식을 커널이
|
|
404
|
+
* 평가하지도 않는다** — 표준의 `Expression` 이 `TextType` 이라 문법이 정의돼 있지 않다. 문법을 우리가
|
|
405
|
+
* 만들면 그 순간 방언이 되고, 현장의 규정과 우리 해석이 갈리는 날 어느 쪽이 옳은지 아무도 모른다.
|
|
406
|
+
*
|
|
407
|
+
* 그러면 이 값이 무슨 일을 하나: **판정에 근거가 있는지를 물을 수 있게 한다**(§`testEvidenceGaps`).
|
|
408
|
+
* 「부적합」이라고만 적힌 기록과 「4.2°C 를 재어 한계 4.0 을 넘었다」는 기록은 다른 물건이다.
|
|
409
|
+
*/
|
|
410
|
+
export interface TestSpecificationCriterion {
|
|
411
|
+
/** 표준 `ID`. */
|
|
412
|
+
id: string;
|
|
413
|
+
/** 표준 `Description`. */
|
|
414
|
+
description?: string;
|
|
415
|
+
/** 표준 `Sequence` — 기준이 여럿일 때의 순서. */
|
|
416
|
+
sequence?: number;
|
|
417
|
+
/**
|
|
418
|
+
* 표준 `Expression`(`TextType`) — **그 현장의 표기 그대로** 나른다. 커널은 이것을 평가하지 않는다.
|
|
419
|
+
*
|
|
420
|
+
* 문법이 정의돼 있지 않으므로(자유 문자열) 파싱하면 그 순간 방언이고, 현장 규정과 우리 해석이
|
|
421
|
+
* 갈리는 날 어느 쪽이 옳은지 아무도 모른다. 사람이 읽는 값이다.
|
|
422
|
+
*
|
|
423
|
+
* **`expression` 과 `limit` 중 적어도 하나는 있어야 한다**(§`criterionSaysNothing`) — 둘 다 없는
|
|
424
|
+
* 기준은 아무 한계도 말하지 않으면서 「기준이 선언됐다」는 착각만 만든다.
|
|
425
|
+
*/
|
|
426
|
+
expression?: string;
|
|
427
|
+
/**
|
|
428
|
+
* **평가 가능한 한계** — 이것이 있으면 커널이 판정할 수 있다.
|
|
429
|
+
*
|
|
430
|
+
* ── 이 축은 **표준에 없다. 우리 것이다** (2026-08-24) ──────────────────────
|
|
431
|
+
* B2MML 일곱 파일(Common · OperationsTest · OperationsPerformance(+Types) · OperationalLocation ·
|
|
432
|
+
* OperationsEvent · OperationsSchedule · WorkAlert)을 원문으로 훑었고 `Minimum`·`Maximum`·
|
|
433
|
+
* `Tolerance`·`Range`·`LowerLimit`·`UpperLimit` 이 **하나도 없다.** ISA-95 의 기준은
|
|
434
|
+
* `Expression`(TextType)과 `Result`(TextType)뿐이고, `ValueType` 도 `ValueString`·`DataType`·
|
|
435
|
+
* `UnitOfMeasure`·`Key` 넷이다. 즉 표준은 한계를 **사람이 읽는 문장**으로만 담는다.
|
|
436
|
+
*
|
|
437
|
+
* 그런데 트윈은 **아무도 판정하지 않을 때 판정해야 한다** — 그것이 이 축의 근거다(당위). 자유 문장
|
|
438
|
+
* 으로는 그것을 할 수 없다. 그래서 숫자 한계를 우리가 더한다. **더한 것이라고 적어 두는 것**이
|
|
439
|
+
* 이 저장소의 규율이고(§`TestResult.result` 의 좁힘과 같은 자리), 그러면 다음 사람이 이것을
|
|
440
|
+
* 「표준이 준 이름」으로 읽지 않는다.
|
|
441
|
+
*
|
|
442
|
+
* 실 원본이 이 모양으로 준다는 것이 확인됐다(첫 실 연동: `criticalLimits.{minimum,maximum}`).
|
|
443
|
+
* **그것이 이 축을 정한 것은 아니다** — 판정이 필요하다는 당위가 정했고, 원본은 그것을 채울 수
|
|
444
|
+
* 있다는 사실을 확인해 준 것이다.
|
|
445
|
+
*/
|
|
446
|
+
limit?: {
|
|
447
|
+
/** 이 값 미만이면 벗어난다. 없으면 아래쪽 한계가 없다(모르는 것이 아니라 없는 것이다). */
|
|
448
|
+
minimum?: number;
|
|
449
|
+
/** 이 값 초과면 벗어난다. */
|
|
450
|
+
maximum?: number;
|
|
451
|
+
/**
|
|
452
|
+
* 한계의 단위 — 표준 `UnitOfMeasure` 와 같은 어휘(UN/CEFACT).
|
|
453
|
+
*
|
|
454
|
+
* **없으면 짐작하지 않는다.** 관측도 단위를 말하지 않으면 같은 척도로 보고 비교하고(같은 선언에서
|
|
455
|
+
* 온 값들이다), **둘이 서로 다른 단위를 말하면 판정하지 않는다** — 섭씨 한계에 화씨 관측을
|
|
456
|
+
* 견주면 조용히 틀린다.
|
|
457
|
+
*/
|
|
458
|
+
uom?: string;
|
|
459
|
+
};
|
|
460
|
+
/** 표준 `Result` — 이 기준이 만족될 때 기대하는 값(현장 표기). */
|
|
461
|
+
result?: string;
|
|
462
|
+
/** 표준 `EvaluatedPropertyID` — 이 기준이 **무엇을 재어** 판정하나. 측정값과 짝을 맞추는 키다. */
|
|
463
|
+
evaluatedPropertyId?: string;
|
|
464
|
+
}
|
|
351
465
|
export interface TestSpecification {
|
|
352
466
|
/** 표준 `ID` — 자원의 `testSpecificationIds` 가 이 값을 가리킨다. */
|
|
353
467
|
id: string;
|
|
@@ -355,7 +469,84 @@ export interface TestSpecification {
|
|
|
355
469
|
description?: string;
|
|
356
470
|
/** 표준 `Version` — 같은 시험의 판이 바뀌면 자격의 뜻도 바뀐다(어느 판으로 검증했나). */
|
|
357
471
|
version?: string;
|
|
472
|
+
/**
|
|
473
|
+
* 판정 기준들 — 표준 `TestSpecificationCriteria`(`maxOccurs="unbounded"`).
|
|
474
|
+
*
|
|
475
|
+
* 선언하지 않아도 명세는 성립한다(「무엇으로 검증했다고 하는지」를 이름으로 답하는 것이 그 최소
|
|
476
|
+
* 역할이었다). 선언하면 **판정에 근거가 있는지**를 물을 수 있게 된다.
|
|
477
|
+
*/
|
|
478
|
+
criteria?: TestSpecificationCriterion[];
|
|
358
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* **측정값 하나** — 표준 `PropertyMeasurementType`(`B2MML-OperationsTest.xsd`).
|
|
482
|
+
*
|
|
483
|
+
* 표준에서 이것은 `TestResult` 의 자식이다: 판정 하나가 **자기 근거가 된 측정들을 든다.** 값의 모양은
|
|
484
|
+
* `ValueType` 이라 `StandardValue` 를 그대로 쓴다 — 단위 없는 물리량은 쓰지 않는다는 규율이 여기에도 산다.
|
|
485
|
+
*/
|
|
486
|
+
export interface PropertyMeasurement extends StandardValue {
|
|
487
|
+
/** 표준 `ID`. */
|
|
488
|
+
id?: string;
|
|
489
|
+
/** 표준 `Description`. */
|
|
490
|
+
description?: string;
|
|
491
|
+
/** 표준 `TestableObjectPropertyID` — **무엇을 쟀나**(그 개체의 어느 속성인가). 기준과 짝을 맞추는 키다. */
|
|
492
|
+
testableObjectPropertyId?: string;
|
|
493
|
+
/** 표준 `MeasurementDate` — 언제 쟀나. */
|
|
494
|
+
measurementDate?: ISOTime;
|
|
495
|
+
/**
|
|
496
|
+
* 이 값이 **잰 것이 아니라 접은 것**임을 밝힌다 — §`DemandWindowState.derived` 와 같은 규율.
|
|
497
|
+
*
|
|
498
|
+
* 트윈은 원본이 주지 않는 값을 계산할 수 있다(그것이 트윈의 존재 이유다). 그런데 그 수를 실측과
|
|
499
|
+
* **같은 자리에 같은 모양으로** 두면 화면·보고서가 그것을 잰 값으로 읽는다. 규제 기록에서 그
|
|
500
|
+
* 구별이 사라지는 것은 결함이 아니라 사고다. 그래서 값 옆에 종류를 함께 싣는다.
|
|
501
|
+
*/
|
|
502
|
+
derived?: boolean;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* **이 기준이 아무 한계도 말하지 않나** — 선언만 있고 내용이 없는 것을 가려낸다.
|
|
506
|
+
*
|
|
507
|
+
* 표현식(사람이 읽는 것)도 없고 숫자 한계(커널이 판정하는 것)도 없으면, 그 기준은 「기준이 선언됐다」는
|
|
508
|
+
* 착각만 만든다. 화면이 「관리점 셋이 걸려 있다」고 말하는데 그중 하나가 아무것도 재지 않는 것은
|
|
509
|
+
* 이 저장소가 거절하는 모양이다.
|
|
510
|
+
*/
|
|
511
|
+
export declare function criterionSaysNothing(c: Pick<TestSpecificationCriterion, 'expression' | 'limit'>): boolean;
|
|
512
|
+
/**
|
|
513
|
+
* **이 값이 한계를 벗어났나** — 판정할 수 있을 때만 답한다.
|
|
514
|
+
*
|
|
515
|
+
* ── 세 갈래를 가린다 ────────────────────────────────────────────────────────
|
|
516
|
+
* `true` 벗어났다
|
|
517
|
+
* `false` 한계 안이다
|
|
518
|
+
* `undefined` **판정할 수 없다** — 숫자 한계가 없거나, 값이 수가 아니거나, 단위가 어긋난다
|
|
519
|
+
*
|
|
520
|
+
* `undefined` 를 `false` 로 뭉개지 않는 것이 이 함수의 요점이다. 「한계 안이다」와 「판정 못 했다」를
|
|
521
|
+
* 같은 값으로 답하면 화면이 판정하지 못한 것을 **적합으로** 보여 준다 — 규제 기록에서 그것은 사고다.
|
|
522
|
+
*
|
|
523
|
+
* ── 표현식은 읽지 않는다 ────────────────────────────────────────────────────
|
|
524
|
+
* `expression` 이 있어도 파싱하지 않는다(§`TestSpecificationCriterion.expression`). 숫자 한계가
|
|
525
|
+
* 없으면 사람이 읽을 문장은 있어도 커널은 「모른다」고 답한다.
|
|
526
|
+
*
|
|
527
|
+
* ── 단위가 어긋나면 판정하지 않는다 ─────────────────────────────────────────
|
|
528
|
+
* 섭씨 한계에 화씨 관측을 견주면 **오류 없이 틀린다.** 둘 다 단위를 말하지 않으면 같은 척도로 본다 —
|
|
529
|
+
* 같은 선언에서 온 값들이고, 그때 판정을 거부하면 단위를 적지 않는 원본에서 이 축이 영원히 침묵한다.
|
|
530
|
+
*/
|
|
531
|
+
export declare function outsideLimit(criterion: Pick<TestSpecificationCriterion, 'limit'> | undefined, observed: Pick<StandardValue, 'value' | 'uom'> | undefined): boolean | undefined;
|
|
532
|
+
/**
|
|
533
|
+
* **이 판정에 근거가 있나** — 기준과 측정값의 짝을 맞춰 빈 곳을 돌려준다.
|
|
534
|
+
*
|
|
535
|
+
* ── 왜 커널이 이것을 답하나 ─────────────────────────────────────────────────
|
|
536
|
+
* 커널은 기준을 **평가하지 않는다**(`Expression` 은 문법이 정의되지 않은 자유 문자열). 그러나
|
|
537
|
+
* **짝이 맞는지는 문법을 몰라도 알 수 있다** — 그리고 그것이 이 축을 실은 이유다: 「부적합」이라고만
|
|
538
|
+
* 적힌 기록과 근거를 든 기록을 화면이 구별할 수 있어야 한다.
|
|
539
|
+
*
|
|
540
|
+
* unmeasured 기준은 있는데 그것을 잰 값이 없다 — **판정의 근거가 비었다**
|
|
541
|
+
* unmatched 측정값은 있는데 그것을 요구한 기준이 없다 — 어느 규정으로 잰 것인지 모른다
|
|
542
|
+
*
|
|
543
|
+
* 판정하지 않고 **세어서 낸다**: 이 저장소는 빈 것을 조용히 넘기지 않는다(§`ObservedReducer.unhandled`).
|
|
544
|
+
* 그리고 짝의 키는 표준이 준 것 그대로다 — `EvaluatedPropertyID` ↔ `TestableObjectPropertyID`.
|
|
545
|
+
*/
|
|
546
|
+
export declare function testEvidenceGaps(spec: Pick<TestSpecification, 'criteria'> | undefined, result: Pick<TestResult, 'propertyMeasurements'> | undefined): {
|
|
547
|
+
unmeasured: string[];
|
|
548
|
+
unmatched: string[];
|
|
549
|
+
};
|
|
359
550
|
/**
|
|
360
551
|
* 유효 기간 — **ISA-95 `EffectiveStartDate` / `EffectiveEndDate`.**
|
|
361
552
|
*
|
|
@@ -817,7 +1008,105 @@ export interface LocationState {
|
|
|
817
1008
|
* 보드에 좌표가 없고 용량이 비어 있어 **계획에 참여하지 못한다**(그 사실을 감추지 않기 위한 표시).
|
|
818
1009
|
*/
|
|
819
1010
|
origin?: 'master' | 'observed';
|
|
1011
|
+
/**
|
|
1012
|
+
* 이 자리에서 관측된 **물리량들의 마지막 값** — 속성마다 하나(§`LocationObservation`).
|
|
1013
|
+
*
|
|
1014
|
+
* 이력이 아니라 **지금 값**이다: 이력은 저널이 든다(그것이 「그때 그 방이 몇 도였나」에 답하는
|
|
1015
|
+
* 자리다). 여기 두는 이유는 판정이 지금 값을 보기 때문이고, 상태가 시간에 비례해 자라지 않게
|
|
1016
|
+
* **속성당 하나**만 든다.
|
|
1017
|
+
*
|
|
1018
|
+
* 없으면 **두지 않는다** — 빈 배열을 두면 「센서가 없는 자리」와 「아직 못 들은 자리」가 같아진다.
|
|
1019
|
+
*/
|
|
1020
|
+
observations?: LocationObservation[];
|
|
820
1021
|
}
|
|
1022
|
+
/**
|
|
1023
|
+
* **자리에서 관측된 물리량 하나** — 냉장실 온도·습도, 세척수 유량 같은 것.
|
|
1024
|
+
*
|
|
1025
|
+
* ── 왜 트윈이 이것을 들어야 하나 (당위) ─────────────────────────────────────
|
|
1026
|
+
* 트윈의 판정 대상은 「물건의 상태」이고, 물건의 상태는 **조건 없이 정해지지 않는다.** 「이 로트가
|
|
1027
|
+
* 냉장실에 있었다」까지만 아는 트윈은 그 로트가 괜찮았는지 말할 수 없다 — 기능 하나가 없는 것이
|
|
1028
|
+
* 아니라 **판정의 재료가 없는 것**이다.
|
|
1029
|
+
*
|
|
1030
|
+
* 그리고 **이 조인은 트윈만 할 수 있다.** 계측 시스템은 물건의 자리 이력을 모르고, 물류 시스템은
|
|
1031
|
+
* 조건 이력을 모른다. 물건이 여러 시스템을 지나면 어느 원본도 전 경로를 모른다.
|
|
1032
|
+
*
|
|
1033
|
+
* ── 주인은 **자리**다. 물건이 아니다 ────────────────────────────────────────
|
|
1034
|
+
* 방 하나의 온도가 그 안의 물건 수백 개와 관계되고, **그 수백 개는 시간에 따라 바뀐다.** 값을 물건마다
|
|
1035
|
+
* 복사하면 한 사실의 사본이 수백 개 생기고 드나드는 물건마다 어긋난다. 그래서 관측은 **자리에 한 번**
|
|
1036
|
+
* 두고, 「그때 그 방에 있던 물건」은 시각∩자리로 파생한다(파생은 저장하지 않는다).
|
|
1037
|
+
*
|
|
1038
|
+
* 진행 중인 공정이 있어도 마찬가지다: 숙성창고에 로트가 여럿이면 숙성 작업도 여럿이고 구간이 각기
|
|
1039
|
+
* 다른데 **센서는 하나**다. 그 값은 어느 한 작업의 것이 아니다.
|
|
1040
|
+
*
|
|
1041
|
+
* ── 표준 근거 — 붙는 자리가 「자리」가 아니라 **설비 계층 노드**다 ────────────
|
|
1042
|
+
* 1차 출처: `OperationsEventType`(`B2MML-OperationsEvent.xsd`) + `OperationsRecordTemplateType`
|
|
1043
|
+
* (`B2MML-Common.xsd`). 붙는 자리는 `HierarchyScope.EquipmentID` 인데, ISA-95 에서는 **장소 계층이
|
|
1044
|
+
* 곧 설비 계층**이다(Enterprise → Site → Area → StorageZone → StorageUnit) — 냉장실은 `StorageZone`
|
|
1045
|
+
* 수준의 설비다. 커널의 자리가 그 노드이므로(§`LocationState.level` 이 `EquipmentLevel1Type` 근거)
|
|
1046
|
+
* `locationId` 가 그대로 대응된다.
|
|
1047
|
+
*
|
|
1048
|
+
* `OperationalLocationType` 에는 이 축이 **없다** — 그쪽은 나중에 들어온 공간(spatial) 개념이고
|
|
1049
|
+
* 마스터 속성만 든다(시계열이 아니다).
|
|
1050
|
+
*
|
|
1051
|
+
* ── 우리가 좁힌 것 ──────────────────────────────────────────────────────────
|
|
1052
|
+
* 표준의 기록 항목은 `InformationObject`(무엇이든)다. **그 임의성을 쓰지 않는다** — 값을 `ValueType`
|
|
1053
|
+
* 으로 좁힌다(§`StandardValue`). 단위 없는 물리량을 받으면 소비처가 3이 섭씨인지 화씨인지 알 수 없고,
|
|
1054
|
+
* 그 상태로는 어떤 판정도 못 한다.
|
|
1055
|
+
*/
|
|
1056
|
+
export interface LocationObservation extends StandardValue {
|
|
1057
|
+
/** 표준 `HierarchyScope.EquipmentID` — 어느 자리인가. 커널의 자리 id 다. */
|
|
1058
|
+
locationId: string;
|
|
1059
|
+
/**
|
|
1060
|
+
* **무엇을 쟀나** — 속성 식별자. 기준과 짝을 맞추는 키다
|
|
1061
|
+
* (§`TestSpecificationCriterion.evaluatedPropertyId`).
|
|
1062
|
+
*
|
|
1063
|
+
* 어휘는 커널이 정하지 않는다 — 재는 것은 현장이 정한다(온도·습도·유량·중량). 커널은 나르고
|
|
1064
|
+
* 짝만 맞춘다. `OP_PARAM` 과 같은 규율이고, 표준도 파라미터 ID 어휘를 정하지 않는다.
|
|
1065
|
+
*/
|
|
1066
|
+
propertyId: string;
|
|
1067
|
+
/**
|
|
1068
|
+
* **언제의 사실인가** — 표준 `EffectiveTimestamp`.
|
|
1069
|
+
*
|
|
1070
|
+
* 관측 시각이지 기록 시각이 아니다. 둘이 어긋나는 것이 실 연동의 정상이고, 그래서 아래를 따로 든다.
|
|
1071
|
+
*/
|
|
1072
|
+
effectiveTime: ISOTime;
|
|
1073
|
+
/**
|
|
1074
|
+
* **언제까지의 사실인가** — 표준 `EffectiveEndDate`. 없으면 그 시점 하나의 값이다.
|
|
1075
|
+
*
|
|
1076
|
+
* ── 왜 구간이 필요한가 ────────────────────────────────────────────────────
|
|
1077
|
+
* ① **원본이 시각을 주지 않는 기록이 있다.** 수동 점검 장부는 「그날 아침」처럼 적고 시계를 적지
|
|
1078
|
+
* 않는다(표준도 주기 점검을 전제한다 — `RecurrenceTimeInterval`). 그때 09:00 을 찍으면 **자정을
|
|
1079
|
+
* 지어내는 것과 같은 발명**이다(§ADR-0039 「선언이 있으면 변환이지만 없으면 발명이다」).
|
|
1080
|
+
* 구간으로 적으면 참인 것만 말한다: 그날 아침의 어느 때.
|
|
1081
|
+
* ② **영향 로트를 찾는 데 구간이 필요하다.** 점 하나로는 며칠 머문 로트를 잡지 못한다 — 온도가 튄
|
|
1082
|
+
* 한 시간의 이벤트 목록에는 정작 그 방의 로트가 하나도 없을 수 있다.
|
|
1083
|
+
*
|
|
1084
|
+
* 구간을 **모르는 것**과 **점인 것**은 다르다: 점이면 이 값이 없고, 모르면 넓은 구간으로 적는다.
|
|
1085
|
+
*/
|
|
1086
|
+
effectiveEndTime?: ISOTime;
|
|
1087
|
+
/** 언제 적혔나 — 표준 `RecordTimestamp`. 늦게 도착한 옛 관측이 최신을 덮지 않게 하는 재료다. */
|
|
1088
|
+
recordTime?: ISOTime;
|
|
1089
|
+
/** 누가 말했나 — 표준 `Source`. 계측기인지 사람이 적은 장부인지는 이 값이 답한다. */
|
|
1090
|
+
source?: string;
|
|
1091
|
+
/**
|
|
1092
|
+
* 이 값이 **잰 것이 아니라 접은 것**임을 밝힌다 — §`PropertyMeasurement.derived` 와 같은 규율.
|
|
1093
|
+
*
|
|
1094
|
+
* 트윈은 원본이 주지 않는 값을 계산할 수 있다(그것이 존재 이유다). 그러나 실측과 같은 자리에 같은
|
|
1095
|
+
* 모양으로 두면 보고서가 그것을 잰 값으로 읽는다. 규제 기록에서 그 구별이 사라지는 것은 결함이
|
|
1096
|
+
* 아니라 사고다.
|
|
1097
|
+
*/
|
|
1098
|
+
derived?: boolean;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* **이 시각에 이 자리에서 참인 관측** — 구간을 든 관측을 시각으로 찾는다.
|
|
1102
|
+
*
|
|
1103
|
+
* 「그때 그 방이 몇 도였나」에 답하는 첫 칸이다. 점 관측은 그 시각에만, 구간 관측은 구간 안에서 참이다.
|
|
1104
|
+
* 여러 개가 겹치면 **가장 늦게 시작한 것**이 답이다(정정이 나중에 온다).
|
|
1105
|
+
*
|
|
1106
|
+
* 커널은 값이 옳은지 **판정하지 않는다** — 기준의 표현식은 문법이 정의되지 않은 자유 문자열이다
|
|
1107
|
+
* (§`TestSpecificationCriterion`). 여기서 하는 일은 짝을 찾아 주는 것까지다.
|
|
1108
|
+
*/
|
|
1109
|
+
export declare function observationAt(observations: readonly LocationObservation[] | undefined, propertyId: string, at: ISOTime): LocationObservation | undefined;
|
|
821
1110
|
/**
|
|
822
1111
|
* 물품 상태 — 표준이 담는 것을 담는다(EPCIS 2.0 / TDS).
|
|
823
1112
|
*
|
|
@@ -1557,6 +1846,28 @@ export interface StateSnapshot {
|
|
|
1557
1846
|
firstAtMs?: number;
|
|
1558
1847
|
lastAtMs?: number;
|
|
1559
1848
|
}[];
|
|
1849
|
+
/**
|
|
1850
|
+
* **개체를 잇지 못한 채 다음 공정으로 넘어간 횟수** — 계보에 구멍이 남았다.
|
|
1851
|
+
*
|
|
1852
|
+
* ── 왜 이 값이 필요했나 (2026-08-24 실측) ────────────────────────────────────
|
|
1853
|
+
* 미러의 오더는 **확보분을 가진 적이 없다.** 원본이 「이 오더에 어느 개체가 잡혀 있나」를 말하지
|
|
1854
|
+
* 않는 것이 정상이다(첫 실 연동: 오더 사실이 `status`·`requested`·`fulfilled` 만 든다). 그런데
|
|
1855
|
+
* 커널이 중간 공정을 넘을 때 **들고 갈 개체를 요구하고 던졌다** — 그래서 미러에서 예측이
|
|
1856
|
+
* 구조적으로 실패했다(`twinForecast` 가 오더 하나 때문에 전부 실패).
|
|
1857
|
+
*
|
|
1858
|
+
* 진행을 막은 것이 과했다. **단계를 넘는 것은 공정의 진행이고 개체를 잇는 것은 계보**다. 계보를
|
|
1859
|
+
* 못 이으면 계보를 비우는 것이 맞고, 그렇다고 진행까지 멈추면 있는 답(언제 끝나나·처리량)까지 잃는다.
|
|
1860
|
+
*
|
|
1861
|
+
* 그래서 넘기되 **세어 낸다.** 세지 않으면 계보에 구멍이 있는데 화면이 정상으로 보인다 —
|
|
1862
|
+
* `unhandled` 를 스냅샷이 떨어뜨려 정합성 검사가 영원히 조용했던 것과 같은 부류의 실수다.
|
|
1863
|
+
*
|
|
1864
|
+
* **목록이 아니라 수다**: 오더 수가 규모에 비례해 자라므로 목록은 잘라야 하고, 자르면 「조용히
|
|
1865
|
+
* 자르지 않는다」를 지킬 수 없다. 어느 오더인지는 그 오더의 계보가 빈 것으로 답한다.
|
|
1866
|
+
*
|
|
1867
|
+
* 자체 구동(시뮬)에서는 **이 값이 오르지 않는다** — 시뮬은 개체를 스스로 만들므로 확보분이 비는
|
|
1868
|
+
* 것이 곧 결함이고, 거기서는 여전히 던진다(진짜 결함을 조용하게 만들지 않는다).
|
|
1869
|
+
*/
|
|
1870
|
+
stepsWithoutMaterial?: number;
|
|
1560
1871
|
/**
|
|
1561
1872
|
* 확인(ack)해 둔 주목 신호 id — **상태에서 파생되지 않는 유일한 축.**
|
|
1562
1873
|
*
|
package/dist/contract.js
CHANGED
|
@@ -245,6 +245,92 @@ export function meetsTests(required, results, at) {
|
|
|
245
245
|
}
|
|
246
246
|
return true;
|
|
247
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* **이 기준이 아무 한계도 말하지 않나** — 선언만 있고 내용이 없는 것을 가려낸다.
|
|
250
|
+
*
|
|
251
|
+
* 표현식(사람이 읽는 것)도 없고 숫자 한계(커널이 판정하는 것)도 없으면, 그 기준은 「기준이 선언됐다」는
|
|
252
|
+
* 착각만 만든다. 화면이 「관리점 셋이 걸려 있다」고 말하는데 그중 하나가 아무것도 재지 않는 것은
|
|
253
|
+
* 이 저장소가 거절하는 모양이다.
|
|
254
|
+
*/
|
|
255
|
+
export function criterionSaysNothing(c) {
|
|
256
|
+
if (c?.expression?.trim())
|
|
257
|
+
return false;
|
|
258
|
+
const l = c?.limit;
|
|
259
|
+
return !(typeof l?.minimum === 'number' || typeof l?.maximum === 'number');
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* **이 값이 한계를 벗어났나** — 판정할 수 있을 때만 답한다.
|
|
263
|
+
*
|
|
264
|
+
* ── 세 갈래를 가린다 ────────────────────────────────────────────────────────
|
|
265
|
+
* `true` 벗어났다
|
|
266
|
+
* `false` 한계 안이다
|
|
267
|
+
* `undefined` **판정할 수 없다** — 숫자 한계가 없거나, 값이 수가 아니거나, 단위가 어긋난다
|
|
268
|
+
*
|
|
269
|
+
* `undefined` 를 `false` 로 뭉개지 않는 것이 이 함수의 요점이다. 「한계 안이다」와 「판정 못 했다」를
|
|
270
|
+
* 같은 값으로 답하면 화면이 판정하지 못한 것을 **적합으로** 보여 준다 — 규제 기록에서 그것은 사고다.
|
|
271
|
+
*
|
|
272
|
+
* ── 표현식은 읽지 않는다 ────────────────────────────────────────────────────
|
|
273
|
+
* `expression` 이 있어도 파싱하지 않는다(§`TestSpecificationCriterion.expression`). 숫자 한계가
|
|
274
|
+
* 없으면 사람이 읽을 문장은 있어도 커널은 「모른다」고 답한다.
|
|
275
|
+
*
|
|
276
|
+
* ── 단위가 어긋나면 판정하지 않는다 ─────────────────────────────────────────
|
|
277
|
+
* 섭씨 한계에 화씨 관측을 견주면 **오류 없이 틀린다.** 둘 다 단위를 말하지 않으면 같은 척도로 본다 —
|
|
278
|
+
* 같은 선언에서 온 값들이고, 그때 판정을 거부하면 단위를 적지 않는 원본에서 이 축이 영원히 침묵한다.
|
|
279
|
+
*/
|
|
280
|
+
export function outsideLimit(criterion, observed) {
|
|
281
|
+
const l = criterion?.limit;
|
|
282
|
+
if (!l)
|
|
283
|
+
return undefined;
|
|
284
|
+
const hasMin = typeof l.minimum === 'number';
|
|
285
|
+
const hasMax = typeof l.maximum === 'number';
|
|
286
|
+
if (!hasMin && !hasMax)
|
|
287
|
+
return undefined;
|
|
288
|
+
const raw = observed?.value;
|
|
289
|
+
if (raw === undefined || raw === null || String(raw).trim() === '')
|
|
290
|
+
return undefined;
|
|
291
|
+
const n = Number(raw);
|
|
292
|
+
if (!Number.isFinite(n))
|
|
293
|
+
return undefined;
|
|
294
|
+
/* 단위가 **둘 다 있고 다르면** 판정하지 않는다. 하나만 있거나 둘 다 없으면 같은 척도로 본다. */
|
|
295
|
+
const lu = l.uom?.trim();
|
|
296
|
+
const ou = observed?.uom?.trim();
|
|
297
|
+
if (lu && ou && lu !== ou)
|
|
298
|
+
return undefined;
|
|
299
|
+
if (hasMin && n < l.minimum)
|
|
300
|
+
return true;
|
|
301
|
+
if (hasMax && n > l.maximum)
|
|
302
|
+
return true;
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* **이 판정에 근거가 있나** — 기준과 측정값의 짝을 맞춰 빈 곳을 돌려준다.
|
|
307
|
+
*
|
|
308
|
+
* ── 왜 커널이 이것을 답하나 ─────────────────────────────────────────────────
|
|
309
|
+
* 커널은 기준을 **평가하지 않는다**(`Expression` 은 문법이 정의되지 않은 자유 문자열). 그러나
|
|
310
|
+
* **짝이 맞는지는 문법을 몰라도 알 수 있다** — 그리고 그것이 이 축을 실은 이유다: 「부적합」이라고만
|
|
311
|
+
* 적힌 기록과 근거를 든 기록을 화면이 구별할 수 있어야 한다.
|
|
312
|
+
*
|
|
313
|
+
* unmeasured 기준은 있는데 그것을 잰 값이 없다 — **판정의 근거가 비었다**
|
|
314
|
+
* unmatched 측정값은 있는데 그것을 요구한 기준이 없다 — 어느 규정으로 잰 것인지 모른다
|
|
315
|
+
*
|
|
316
|
+
* 판정하지 않고 **세어서 낸다**: 이 저장소는 빈 것을 조용히 넘기지 않는다(§`ObservedReducer.unhandled`).
|
|
317
|
+
* 그리고 짝의 키는 표준이 준 것 그대로다 — `EvaluatedPropertyID` ↔ `TestableObjectPropertyID`.
|
|
318
|
+
*/
|
|
319
|
+
export function testEvidenceGaps(spec, result) {
|
|
320
|
+
const criteria = spec?.criteria ?? [];
|
|
321
|
+
const measurements = result?.propertyMeasurements ?? [];
|
|
322
|
+
/* 기준이 재는 속성들 / 실제로 잰 속성들 — 속성 id 를 말하지 않은 것은 짝을 맞출 수 없으므로 뺀다. */
|
|
323
|
+
const wanted = new Set(criteria.map(c => c.evaluatedPropertyId).filter((k) => !!k));
|
|
324
|
+
const got = new Set(measurements.map(m => m.testableObjectPropertyId).filter((k) => !!k));
|
|
325
|
+
return {
|
|
326
|
+
unmeasured: criteria
|
|
327
|
+
.filter(c => c.evaluatedPropertyId && !got.has(c.evaluatedPropertyId))
|
|
328
|
+
.map(c => c.id),
|
|
329
|
+
unmatched: measurements
|
|
330
|
+
.map(m => m.testableObjectPropertyId)
|
|
331
|
+
.filter((k) => !!k && !wanted.has(k))
|
|
332
|
+
};
|
|
333
|
+
}
|
|
248
334
|
/**
|
|
249
335
|
* 이 시각에 유효 기간 밖인가 — **한 규칙**으로 개체·등급·설비↔자산 매핑을 모두 판정한다.
|
|
250
336
|
*
|
|
@@ -753,6 +839,40 @@ export function capabilityOf(r, ctx) {
|
|
|
753
839
|
return { available: false, reason: 'working' };
|
|
754
840
|
return { available: true, reason: 'available' };
|
|
755
841
|
}
|
|
842
|
+
/**
|
|
843
|
+
* **이 시각에 이 자리에서 참인 관측** — 구간을 든 관측을 시각으로 찾는다.
|
|
844
|
+
*
|
|
845
|
+
* 「그때 그 방이 몇 도였나」에 답하는 첫 칸이다. 점 관측은 그 시각에만, 구간 관측은 구간 안에서 참이다.
|
|
846
|
+
* 여러 개가 겹치면 **가장 늦게 시작한 것**이 답이다(정정이 나중에 온다).
|
|
847
|
+
*
|
|
848
|
+
* 커널은 값이 옳은지 **판정하지 않는다** — 기준의 표현식은 문법이 정의되지 않은 자유 문자열이다
|
|
849
|
+
* (§`TestSpecificationCriterion`). 여기서 하는 일은 짝을 찾아 주는 것까지다.
|
|
850
|
+
*/
|
|
851
|
+
export function observationAt(observations, propertyId, at) {
|
|
852
|
+
const t = parsedMs(at);
|
|
853
|
+
if (!(t >= 0))
|
|
854
|
+
return undefined;
|
|
855
|
+
let best;
|
|
856
|
+
let bestStart = -1;
|
|
857
|
+
for (const o of observations ?? []) {
|
|
858
|
+
if (o.propertyId !== propertyId)
|
|
859
|
+
continue;
|
|
860
|
+
const from = parsedMs(o.effectiveTime);
|
|
861
|
+
if (!(from >= 0) || from > t)
|
|
862
|
+
continue;
|
|
863
|
+
/* 구간이 있으면 그 안이어야 한다. 없으면 그 시점의 값이고, 그 뒤로도 정정될 때까지 유효하다. */
|
|
864
|
+
if (o.effectiveEndTime) {
|
|
865
|
+
const to = parsedMs(o.effectiveEndTime);
|
|
866
|
+
if (to >= 0 && to < t)
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
if (from > bestStart) {
|
|
870
|
+
best = o;
|
|
871
|
+
bestStart = from;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return best;
|
|
875
|
+
}
|
|
756
876
|
// ── 운영 델타(비-EPCIS) — State 채널의 나머지 절반 ──────────────────────────
|
|
757
877
|
// EPCIS 이벤트는 재고/위치만 재구성 가능. tasks·equipment·orders 의 운영 상태는
|
|
758
878
|
// 이 델타로 미러한다. envelope.eventType = 'task.status' | 'equipment.status' | 'order.status'.
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -560,6 +560,19 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
560
560
|
protected localParams: Map<string, Map<string, string>>;
|
|
561
561
|
/** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
|
|
562
562
|
private observer?;
|
|
563
|
+
/**
|
|
564
|
+
* 이 커널의 상태가 **관측에서 왔나** — 미러인가.
|
|
565
|
+
*
|
|
566
|
+
* 자체 구동(시뮬)과 가르는 축이다. 두 구동은 **같은 빈 값에 다른 뜻**을 갖는다: 시뮬에서 확보분이
|
|
567
|
+
* 비는 것은 결함이고, 미러에서는 원본이 말하지 않는 정상이다. 그 구분 없이 한쪽 규칙을 쓰면
|
|
568
|
+
* 시뮬의 진짜 결함이 조용해지거나 미러가 정상 상태에서 죽는다.
|
|
569
|
+
*
|
|
570
|
+
* fork 도 이 표식을 물려받는다(§`fork` — skip 목록에 없다). 예측은 미러에서 갈라져 나오므로
|
|
571
|
+
* 그것이 맞다: 예측이 든 상태는 여전히 **관측에서 온 상태**다.
|
|
572
|
+
*/
|
|
573
|
+
protected get observationDriven(): boolean;
|
|
574
|
+
/** 계보를 잇지 못한 채 넘어간 횟수 — §`StateSnapshot.stepsWithoutMaterial`. */
|
|
575
|
+
protected stepsWithoutMaterial: number;
|
|
563
576
|
/** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
|
|
564
577
|
/**
|
|
565
578
|
* **관측 모드에서 원본과 어긋난 횟수** — 받아들였지만 사실이 맞지 않았다.
|
package/dist/flow-engine.js
CHANGED
|
@@ -573,6 +573,21 @@ export class FlowEngine {
|
|
|
573
573
|
localParams = new Map();
|
|
574
574
|
/** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
|
|
575
575
|
observer;
|
|
576
|
+
/**
|
|
577
|
+
* 이 커널의 상태가 **관측에서 왔나** — 미러인가.
|
|
578
|
+
*
|
|
579
|
+
* 자체 구동(시뮬)과 가르는 축이다. 두 구동은 **같은 빈 값에 다른 뜻**을 갖는다: 시뮬에서 확보분이
|
|
580
|
+
* 비는 것은 결함이고, 미러에서는 원본이 말하지 않는 정상이다. 그 구분 없이 한쪽 규칙을 쓰면
|
|
581
|
+
* 시뮬의 진짜 결함이 조용해지거나 미러가 정상 상태에서 죽는다.
|
|
582
|
+
*
|
|
583
|
+
* fork 도 이 표식을 물려받는다(§`fork` — skip 목록에 없다). 예측은 미러에서 갈라져 나오므로
|
|
584
|
+
* 그것이 맞다: 예측이 든 상태는 여전히 **관측에서 온 상태**다.
|
|
585
|
+
*/
|
|
586
|
+
get observationDriven() {
|
|
587
|
+
return !!this.observer;
|
|
588
|
+
}
|
|
589
|
+
/** 계보를 잇지 못한 채 넘어간 횟수 — §`StateSnapshot.stepsWithoutMaterial`. */
|
|
590
|
+
stepsWithoutMaterial = 0;
|
|
576
591
|
/** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
|
|
577
592
|
/**
|
|
578
593
|
* **관측 모드에서 원본과 어긋난 횟수** — 받아들였지만 사실이 맞지 않았다.
|
|
@@ -1528,6 +1543,8 @@ export class FlowEngine {
|
|
|
1528
1543
|
const u = this.observer?.snapshot?.().unhandled;
|
|
1529
1544
|
return u?.length ? { unhandled: u } : {};
|
|
1530
1545
|
})(),
|
|
1546
|
+
/* 계보를 잇지 못한 채 넘어간 횟수 — 같은 규율(세기만 하고 내보내지 않으면 없는 것과 같다). */
|
|
1547
|
+
...(this.stepsWithoutMaterial > 0 ? { stepsWithoutMaterial: this.stepsWithoutMaterial } : {}),
|
|
1531
1548
|
tasks: [...this.tasks.values()].map(t => ({
|
|
1532
1549
|
id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc],
|
|
1533
1550
|
fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
|
package/dist/forecast.d.ts
CHANGED
|
@@ -22,6 +22,23 @@ export interface MonteCarloResult {
|
|
|
22
22
|
mean: number;
|
|
23
23
|
p50: number;
|
|
24
24
|
p90: number;
|
|
25
|
+
/**
|
|
26
|
+
* **계보를 잇지 못한 채 넘어간 횟수** — 회차들 중 **최대값**(§`StateSnapshot.stepsWithoutMaterial`).
|
|
27
|
+
*
|
|
28
|
+
* ── 왜 예측 결과에 실어야 하나 (2026-08-24) ──────────────────────────────────
|
|
29
|
+
* 미러의 오더는 확보분을 갖지 않는다(원본이 「어느 개체가 잡혀 있나」를 말하지 않는 것이 그 시스템의
|
|
30
|
+
* 정상이다). 그래서 예측이 공정을 넘을 때 개체를 이을 수 없고, 커널은 **넘기되 세어 둔다.**
|
|
31
|
+
*
|
|
32
|
+
* 그런데 예측은 **회차마다 fork 를 만들고 버린다.** 그 카운터가 사본 안에만 있으면 아무에게도 닿지
|
|
33
|
+
* 않는다 — 이 저장소가 이미 그 실수를 겪었다(리듀서가 오래 세어 온 값을 스냅샷이 떨어뜨려, 그것을
|
|
34
|
+
* 읽도록 쓰인 검사가 영원히 조용했다). **세어 놓고 내보내지 않으면 안 센 것과 같다.**
|
|
35
|
+
*
|
|
36
|
+
* 왜 합이 아니라 최대인가: 회차는 **같은 미래의 표본들**이다. 서른 번 굴려 각 회차가 열 칸을
|
|
37
|
+
* 비웠으면 사실은 「열 칸」이고 300이 아니다. 합을 내면 회차 수를 늘리는 것만으로 숫자가 커진다.
|
|
38
|
+
*
|
|
39
|
+
* 0 이면 **싣지 않는다** — 0 을 정보처럼 보이게 하지 않는다.
|
|
40
|
+
*/
|
|
41
|
+
stepsWithoutMaterial?: number;
|
|
25
42
|
}
|
|
26
43
|
export interface MonteCarloOptions {
|
|
27
44
|
runs: number;
|
package/dist/forecast.js
CHANGED
|
@@ -19,14 +19,17 @@ function clockOf(twin) {
|
|
|
19
19
|
export function monteCarloForecast(twin, opts) {
|
|
20
20
|
const now = clockOf(twin);
|
|
21
21
|
const samples = [];
|
|
22
|
+
let gaps = 0;
|
|
22
23
|
for (let i = 0; i < opts.runs; i++) {
|
|
23
24
|
const { fc, step, target } = startRun(twin, opts, now, i);
|
|
24
25
|
let guard = 0;
|
|
25
26
|
while (clockOf(fc) < target && guard++ < 1_000_000)
|
|
26
27
|
fc.tick(step);
|
|
27
|
-
|
|
28
|
+
const snap = fc.getSnapshot();
|
|
29
|
+
gaps = Math.max(gaps, snap.stepsWithoutMaterial ?? 0);
|
|
30
|
+
samples.push(opts.metric(snap));
|
|
28
31
|
}
|
|
29
|
-
return summarize(opts.runs, samples);
|
|
32
|
+
return summarize(opts.runs, samples, gaps);
|
|
30
33
|
}
|
|
31
34
|
/**
|
|
32
35
|
* 한 회차의 **출발점** — fork(현재 보존) + 그 회차의 미래(seed 변주).
|
|
@@ -66,6 +69,7 @@ export async function monteCarloForecastAsync(twin, opts) {
|
|
|
66
69
|
*/
|
|
67
70
|
const everyTicks = Math.max(1, opts.yieldEveryTicks ?? 50);
|
|
68
71
|
const samples = [];
|
|
72
|
+
let gaps = 0;
|
|
69
73
|
for (let i = 0; i < opts.runs; i++) {
|
|
70
74
|
const { fc, step, target } = startRun(twin, opts, now, i);
|
|
71
75
|
let guard = 0;
|
|
@@ -77,15 +81,27 @@ export async function monteCarloForecastAsync(twin, opts) {
|
|
|
77
81
|
await yieldFn();
|
|
78
82
|
}
|
|
79
83
|
}
|
|
80
|
-
|
|
84
|
+
const snap = fc.getSnapshot();
|
|
85
|
+
gaps = Math.max(gaps, snap.stepsWithoutMaterial ?? 0);
|
|
86
|
+
samples.push(opts.metric(snap));
|
|
81
87
|
if (i < opts.runs - 1)
|
|
82
88
|
await yieldFn();
|
|
83
89
|
}
|
|
84
|
-
return summarize(opts.runs, samples);
|
|
90
|
+
return summarize(opts.runs, samples, gaps);
|
|
85
91
|
}
|
|
86
|
-
function summarize(runs, samples) {
|
|
92
|
+
function summarize(runs, samples, stepsWithoutMaterial = 0) {
|
|
87
93
|
const sorted = [...samples].sort((a, b) => a - b);
|
|
88
94
|
const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
|
|
89
95
|
const mean = samples.reduce((a, b) => a + b, 0) / (samples.length || 1);
|
|
90
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
runs,
|
|
98
|
+
samples,
|
|
99
|
+
min: sorted[0],
|
|
100
|
+
max: sorted[sorted.length - 1],
|
|
101
|
+
mean,
|
|
102
|
+
p50: pct(0.5),
|
|
103
|
+
p90: pct(0.9),
|
|
104
|
+
/* 0 은 싣지 않는다 — 「구멍이 없다」와 「이 축을 모른다」를 화면이 구별할 수 있게. */
|
|
105
|
+
...(stepsWithoutMaterial > 0 ? { stepsWithoutMaterial } : {})
|
|
106
|
+
};
|
|
91
107
|
}
|
package/dist/mes-kernel.d.ts
CHANGED
|
@@ -275,6 +275,13 @@ export declare class MesKernel extends FlowEngine {
|
|
|
275
275
|
private onOrderDef;
|
|
276
276
|
/** 정의 모드 할당 — 레시피 입력 BOM 전량 확보 후 첫 라우트 스텝 태스크. */
|
|
277
277
|
private allocateDef;
|
|
278
|
+
/**
|
|
279
|
+
* 공정 하나의 작업을 낸다.
|
|
280
|
+
*
|
|
281
|
+
* `itemEpc` 가 **비어 있을 수 있다**(빈 문자열) — 원본이 개체를 말하지 않는 미러에서 그렇다. 그때
|
|
282
|
+
* 이 작업은 「무엇을」 없이 「어디서 무엇을 한다」만 안다. 빈 값으로 두는 것이 규약이다(§1453 의
|
|
283
|
+
* `t.itemRefs?.[0] ?? ''` 와 같은 자리) — 없는 개체를 지어내지 않는다.
|
|
284
|
+
*/
|
|
278
285
|
private emitStationDef;
|
|
279
286
|
/** 정의 모드 완료 — 라우트 인덱스: 중간=WIP 변환+다음 스텝, 마지막=완제품(수율). */
|
|
280
287
|
private onTaskCompleteDef;
|
package/dist/mes-kernel.js
CHANGED
|
@@ -811,9 +811,16 @@ export class MesKernel extends FlowEngine {
|
|
|
811
811
|
o.status = 'op-' + ops[0].key;
|
|
812
812
|
this.emitOrder(o);
|
|
813
813
|
}
|
|
814
|
+
/**
|
|
815
|
+
* 공정 하나의 작업을 낸다.
|
|
816
|
+
*
|
|
817
|
+
* `itemEpc` 가 **비어 있을 수 있다**(빈 문자열) — 원본이 개체를 말하지 않는 미러에서 그렇다. 그때
|
|
818
|
+
* 이 작업은 「무엇을」 없이 「어디서 무엇을 한다」만 안다. 빈 값으로 두는 것이 규약이다(§1453 의
|
|
819
|
+
* `t.itemRefs?.[0] ?? ''` 와 같은 자리) — 없는 개체를 지어내지 않는다.
|
|
820
|
+
*/
|
|
814
821
|
emitStationDef(o, op, itemEpc) {
|
|
815
822
|
const loc = this.locationByType(op.locationType);
|
|
816
|
-
const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: 'created', itemEpc, fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
|
|
823
|
+
const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: 'created', itemEpc: itemEpc ?? '', fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
|
|
817
824
|
this.tasks.set(task.id, task);
|
|
818
825
|
this.emitTask(task);
|
|
819
826
|
}
|
|
@@ -849,10 +856,34 @@ export class MesKernel extends FlowEngine {
|
|
|
849
856
|
}
|
|
850
857
|
/* 다음 자리에 무엇을 들고 가는가 — 만든 것이 있으면 그것, 없으면 들고 있던 것. */
|
|
851
858
|
const carried = order.allocated[0];
|
|
852
|
-
|
|
859
|
+
/*
|
|
860
|
+
* ── 개체가 없어도 **진행은 막지 않는다** (2026-08-24) ───────────────────────
|
|
861
|
+
* 예전에는 여기서 무조건 던졌다. 그래서 **미러에서 예측이 구조적으로 실패했다**: 미러의 오더는
|
|
862
|
+
* 확보분을 가진 적이 없고(원본이 「어느 개체가 잡혀 있나」를 말하지 않는 것이 정상이다 — 첫 실
|
|
863
|
+
* 연동의 오더 사실은 `status`·`requested`·`fulfilled` 뿐이다), 그 오더가 중간 공정을 넘는
|
|
864
|
+
* 순간마다 터졌다. 오더 하나 때문에 `twinForecast` 가 통째로 실패했다.
|
|
865
|
+
*
|
|
866
|
+
* 두 가지를 섞은 것이 잘못이었다.
|
|
867
|
+
*
|
|
868
|
+
* 단계를 넘는 것 공정의 **진행**이다 — 라우트·소요·용량이 답한다. 개체 번호가 필요 없다
|
|
869
|
+
* 개체를 잇는 것 **계보**다 — 개체가 없으면 이을 수 없다
|
|
870
|
+
*
|
|
871
|
+
* 계보를 못 이으면 **계보를 비우는 것**이 맞다(지어내지 않는다 — 예전에 `'WIP'` 를 지어내던
|
|
872
|
+
* 그 자리다). 그런데 진행까지 멈추면 있는 답(언제 끝나나·처리량)까지 잃는다. **없어도 되는
|
|
873
|
+
* 것을 요구해서 있는 답을 잃고 있었다.**
|
|
874
|
+
*
|
|
875
|
+
* ── 그러나 시뮬에서는 여전히 결함이다 ────────────────────────────────────
|
|
876
|
+
* 자체 구동은 개체를 스스로 만든다. 거기서 확보분이 비면 `allocateDef` 가 확보하지 못했거나
|
|
877
|
+
* 선언이 어긋난 것이고, 조용히 넘기면 **계보가 통째로 빈 시뮬**이 정상처럼 보인다. 그래서
|
|
878
|
+
* 관측 구동일 때만 넘긴다(§`observationDriven`).
|
|
879
|
+
*/
|
|
880
|
+
if (!carried && !this.observationDriven) {
|
|
853
881
|
throw new Error(`order ${order.id} at step '${t.kind}': nothing to carry to '${next.key}' — the order holds no allocated material ` +
|
|
854
882
|
`and this step declares no produced material. Declare the step's input or its output in the model.`);
|
|
855
883
|
}
|
|
884
|
+
/* 넘기되 **세어 낸다** — 세지 않으면 계보에 구멍이 있는데 화면이 정상으로 보인다. */
|
|
885
|
+
if (!carried)
|
|
886
|
+
this.stepsWithoutMaterial++;
|
|
856
887
|
this.emitStationDef(order, next, carried);
|
|
857
888
|
order.status = 'op-' + next.key;
|
|
858
889
|
this.emitOrder(order);
|
|
@@ -898,6 +929,24 @@ export class MesKernel extends FlowEngine {
|
|
|
898
929
|
this.emitOrder(order);
|
|
899
930
|
return;
|
|
900
931
|
}
|
|
932
|
+
/*
|
|
933
|
+
* ── 원본이 개체를 말하지 않는 현장 (2026-08-24) ────────────────────────────
|
|
934
|
+
* 위 `seedIncomplete` 는 **우리가 못 심은 것**이다(원본이 말한 물품이 스냅샷에 없었다). 이것은
|
|
935
|
+
* 다른 사실이다: **원본이 애초에 말하지 않는다.** 첫 실 연동의 오더 사실은 `status`·`requested`
|
|
936
|
+
* ·`fulfilled` 뿐이고, 「어느 개체가 잡혀 있나」를 주지 않는 것이 그 시스템의 정상이다.
|
|
937
|
+
*
|
|
938
|
+
* 두 사실을 한 값으로 뭉개지 않는다 — 앞의 것은 고칠 수 있는 결함이고 뒤의 것은 원본의 성질이다.
|
|
939
|
+
*
|
|
940
|
+
* **여기서는 진행도 넘기지 않는다.** 중간 단계는 넘긴다(§`onTaskCompleteDef` — 단계를 넘는 것은
|
|
941
|
+
* 공정의 진행이고 개체가 필요 없다). 그러나 마지막 단계는 **산출을 만든다**: 소비할 것 없이
|
|
942
|
+
* 제품을 내면 「일어나지 않은 생산」을 기록하는 것이고, 그것은 계보가 비는 것보다 나쁘다.
|
|
943
|
+
* 그래서 막혔다고 말하고 멈춘다 — 이유가 상태에 남으므로 화면이 원인을 지어내지 않는다.
|
|
944
|
+
*/
|
|
945
|
+
if (this.observationDriven) {
|
|
946
|
+
order.status = 'blocked-source-omits-material';
|
|
947
|
+
this.emitOrder(order);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
901
950
|
throw new Error(`order ${order.id} at final step '${t.kind}': nothing allocated to consume — the kernel does not make a ` +
|
|
902
951
|
`product out of nothing. Declare the recipe inputs, or the step's produced material.`);
|
|
903
952
|
}
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -96,6 +96,7 @@ __export(index_exports, {
|
|
|
96
96
|
constantDuration: () => constantDuration,
|
|
97
97
|
conversionFactorOf: () => conversionFactorOf,
|
|
98
98
|
counterfactualAt: () => counterfactualAt,
|
|
99
|
+
criterionSaysNothing: () => criterionSaysNothing,
|
|
99
100
|
demandWindowStart: () => demandWindowStart,
|
|
100
101
|
deriveAttentions: () => deriveAttentions,
|
|
101
102
|
documentPath: () => documentPath,
|
|
@@ -142,10 +143,12 @@ __export(index_exports, {
|
|
|
142
143
|
monteCarloForecastAsync: () => monteCarloForecastAsync,
|
|
143
144
|
objectEvent: () => objectEvent,
|
|
144
145
|
objectUri: () => objectUri,
|
|
146
|
+
observationAt: () => observationAt,
|
|
145
147
|
offCalendarAt: () => offCalendarAt,
|
|
146
148
|
offCalendarReasonAt: () => offCalendarReasonAt,
|
|
147
149
|
operationalKindOf: () => operationalKindOf,
|
|
148
150
|
operationsCapabilityOf: () => operationsCapabilityOf,
|
|
151
|
+
outsideLimit: () => outsideLimit,
|
|
149
152
|
parseEpc: () => parseEpc,
|
|
150
153
|
parseIsoDuration: () => parseIsoDuration,
|
|
151
154
|
partialFitPolicy: () => partialFitPolicy,
|
|
@@ -168,6 +171,7 @@ __export(index_exports, {
|
|
|
168
171
|
ssccUri: () => ssccUri,
|
|
169
172
|
stateFieldsOf: () => stateFieldsOf,
|
|
170
173
|
subLotIdOf: () => subLotIdOf,
|
|
174
|
+
testEvidenceGaps: () => testEvidenceGaps,
|
|
171
175
|
testPassedAt: () => testPassedAt,
|
|
172
176
|
transactionEvent: () => transactionEvent,
|
|
173
177
|
transformationEvent: () => transformationEvent,
|
|
@@ -289,6 +293,38 @@ function meetsTests(required, results, at) {
|
|
|
289
293
|
}
|
|
290
294
|
return true;
|
|
291
295
|
}
|
|
296
|
+
function criterionSaysNothing(c) {
|
|
297
|
+
if (c?.expression?.trim()) return false;
|
|
298
|
+
const l = c?.limit;
|
|
299
|
+
return !(typeof l?.minimum === "number" || typeof l?.maximum === "number");
|
|
300
|
+
}
|
|
301
|
+
function outsideLimit(criterion, observed) {
|
|
302
|
+
const l = criterion?.limit;
|
|
303
|
+
if (!l) return void 0;
|
|
304
|
+
const hasMin = typeof l.minimum === "number";
|
|
305
|
+
const hasMax = typeof l.maximum === "number";
|
|
306
|
+
if (!hasMin && !hasMax) return void 0;
|
|
307
|
+
const raw = observed?.value;
|
|
308
|
+
if (raw === void 0 || raw === null || String(raw).trim() === "") return void 0;
|
|
309
|
+
const n = Number(raw);
|
|
310
|
+
if (!Number.isFinite(n)) return void 0;
|
|
311
|
+
const lu = l.uom?.trim();
|
|
312
|
+
const ou = observed?.uom?.trim();
|
|
313
|
+
if (lu && ou && lu !== ou) return void 0;
|
|
314
|
+
if (hasMin && n < l.minimum) return true;
|
|
315
|
+
if (hasMax && n > l.maximum) return true;
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
function testEvidenceGaps(spec, result) {
|
|
319
|
+
const criteria = spec?.criteria ?? [];
|
|
320
|
+
const measurements = result?.propertyMeasurements ?? [];
|
|
321
|
+
const wanted = new Set(criteria.map((c) => c.evaluatedPropertyId).filter((k) => !!k));
|
|
322
|
+
const got = new Set(measurements.map((m) => m.testableObjectPropertyId).filter((k) => !!k));
|
|
323
|
+
return {
|
|
324
|
+
unmeasured: criteria.filter((c) => c.evaluatedPropertyId && !got.has(c.evaluatedPropertyId)).map((c) => c.id),
|
|
325
|
+
unmatched: measurements.map((m) => m.testableObjectPropertyId).filter((k) => !!k && !wanted.has(k))
|
|
326
|
+
};
|
|
327
|
+
}
|
|
292
328
|
function effectivityAt(p, at) {
|
|
293
329
|
if (!p || !at) return void 0;
|
|
294
330
|
const atMs = parsedMs(at);
|
|
@@ -521,6 +557,26 @@ function capabilityOf(r, ctx) {
|
|
|
521
557
|
if (r.status && r.status !== "idle" && r.status !== "available") return { available: false, reason: "working" };
|
|
522
558
|
return { available: true, reason: "available" };
|
|
523
559
|
}
|
|
560
|
+
function observationAt(observations, propertyId, at) {
|
|
561
|
+
const t = parsedMs(at);
|
|
562
|
+
if (!(t >= 0)) return void 0;
|
|
563
|
+
let best;
|
|
564
|
+
let bestStart = -1;
|
|
565
|
+
for (const o of observations ?? []) {
|
|
566
|
+
if (o.propertyId !== propertyId) continue;
|
|
567
|
+
const from = parsedMs(o.effectiveTime);
|
|
568
|
+
if (!(from >= 0) || from > t) continue;
|
|
569
|
+
if (o.effectiveEndTime) {
|
|
570
|
+
const to = parsedMs(o.effectiveEndTime);
|
|
571
|
+
if (to >= 0 && to < t) continue;
|
|
572
|
+
}
|
|
573
|
+
if (from > bestStart) {
|
|
574
|
+
best = o;
|
|
575
|
+
bestStart = from;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return best;
|
|
579
|
+
}
|
|
524
580
|
var OP_EVENT = {
|
|
525
581
|
task: "task.status",
|
|
526
582
|
equipment: "equipment.status",
|
|
@@ -814,13 +870,16 @@ function clockOf2(twin) {
|
|
|
814
870
|
function monteCarloForecast(twin, opts) {
|
|
815
871
|
const now = clockOf2(twin);
|
|
816
872
|
const samples = [];
|
|
873
|
+
let gaps = 0;
|
|
817
874
|
for (let i = 0; i < opts.runs; i++) {
|
|
818
875
|
const { fc, step, target } = startRun(twin, opts, now, i);
|
|
819
876
|
let guard = 0;
|
|
820
877
|
while (clockOf2(fc) < target && guard++ < 1e6) fc.tick(step);
|
|
821
|
-
|
|
878
|
+
const snap = fc.getSnapshot();
|
|
879
|
+
gaps = Math.max(gaps, snap.stepsWithoutMaterial ?? 0);
|
|
880
|
+
samples.push(opts.metric(snap));
|
|
822
881
|
}
|
|
823
|
-
return summarize(opts.runs, samples);
|
|
882
|
+
return summarize(opts.runs, samples, gaps);
|
|
824
883
|
}
|
|
825
884
|
function startRun(twin, opts, nowMs, i) {
|
|
826
885
|
const fc = twin.fork();
|
|
@@ -833,6 +892,7 @@ async function monteCarloForecastAsync(twin, opts) {
|
|
|
833
892
|
const yieldFn = opts.yieldFn ?? (() => Promise.resolve());
|
|
834
893
|
const everyTicks = Math.max(1, opts.yieldEveryTicks ?? 50);
|
|
835
894
|
const samples = [];
|
|
895
|
+
let gaps = 0;
|
|
836
896
|
for (let i = 0; i < opts.runs; i++) {
|
|
837
897
|
const { fc, step, target } = startRun(twin, opts, now, i);
|
|
838
898
|
let guard = 0;
|
|
@@ -844,16 +904,28 @@ async function monteCarloForecastAsync(twin, opts) {
|
|
|
844
904
|
await yieldFn();
|
|
845
905
|
}
|
|
846
906
|
}
|
|
847
|
-
|
|
907
|
+
const snap = fc.getSnapshot();
|
|
908
|
+
gaps = Math.max(gaps, snap.stepsWithoutMaterial ?? 0);
|
|
909
|
+
samples.push(opts.metric(snap));
|
|
848
910
|
if (i < opts.runs - 1) await yieldFn();
|
|
849
911
|
}
|
|
850
|
-
return summarize(opts.runs, samples);
|
|
912
|
+
return summarize(opts.runs, samples, gaps);
|
|
851
913
|
}
|
|
852
|
-
function summarize(runs, samples) {
|
|
914
|
+
function summarize(runs, samples, stepsWithoutMaterial = 0) {
|
|
853
915
|
const sorted = [...samples].sort((a, b) => a - b);
|
|
854
916
|
const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
|
|
855
917
|
const mean = samples.reduce((a, b) => a + b, 0) / (samples.length || 1);
|
|
856
|
-
return {
|
|
918
|
+
return {
|
|
919
|
+
runs,
|
|
920
|
+
samples,
|
|
921
|
+
min: sorted[0],
|
|
922
|
+
max: sorted[sorted.length - 1],
|
|
923
|
+
mean,
|
|
924
|
+
p50: pct(0.5),
|
|
925
|
+
p90: pct(0.9),
|
|
926
|
+
/* 0 은 싣지 않는다 — 「구멍이 없다」와 「이 축을 모른다」를 화면이 구별할 수 있게. */
|
|
927
|
+
...stepsWithoutMaterial > 0 ? { stepsWithoutMaterial } : {}
|
|
928
|
+
};
|
|
857
929
|
}
|
|
858
930
|
|
|
859
931
|
// src/twin-observer.ts
|
|
@@ -3729,6 +3801,21 @@ var FlowEngine = class {
|
|
|
3729
3801
|
localParams = /* @__PURE__ */ new Map();
|
|
3730
3802
|
/** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
|
|
3731
3803
|
observer;
|
|
3804
|
+
/**
|
|
3805
|
+
* 이 커널의 상태가 **관측에서 왔나** — 미러인가.
|
|
3806
|
+
*
|
|
3807
|
+
* 자체 구동(시뮬)과 가르는 축이다. 두 구동은 **같은 빈 값에 다른 뜻**을 갖는다: 시뮬에서 확보분이
|
|
3808
|
+
* 비는 것은 결함이고, 미러에서는 원본이 말하지 않는 정상이다. 그 구분 없이 한쪽 규칙을 쓰면
|
|
3809
|
+
* 시뮬의 진짜 결함이 조용해지거나 미러가 정상 상태에서 죽는다.
|
|
3810
|
+
*
|
|
3811
|
+
* fork 도 이 표식을 물려받는다(§`fork` — skip 목록에 없다). 예측은 미러에서 갈라져 나오므로
|
|
3812
|
+
* 그것이 맞다: 예측이 든 상태는 여전히 **관측에서 온 상태**다.
|
|
3813
|
+
*/
|
|
3814
|
+
get observationDriven() {
|
|
3815
|
+
return !!this.observer;
|
|
3816
|
+
}
|
|
3817
|
+
/** 계보를 잇지 못한 채 넘어간 횟수 — §`StateSnapshot.stepsWithoutMaterial`. */
|
|
3818
|
+
stepsWithoutMaterial = 0;
|
|
3732
3819
|
/** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
|
|
3733
3820
|
/**
|
|
3734
3821
|
* **관측 모드에서 원본과 어긋난 횟수** — 받아들였지만 사실이 맞지 않았다.
|
|
@@ -4501,6 +4588,8 @@ var FlowEngine = class {
|
|
|
4501
4588
|
const u = this.observer?.snapshot?.().unhandled;
|
|
4502
4589
|
return u?.length ? { unhandled: u } : {};
|
|
4503
4590
|
})(),
|
|
4591
|
+
/* 계보를 잇지 못한 채 넘어간 횟수 — 같은 규율(세기만 하고 내보내지 않으면 없는 것과 같다). */
|
|
4592
|
+
...this.stepsWithoutMaterial > 0 ? { stepsWithoutMaterial: this.stepsWithoutMaterial } : {},
|
|
4504
4593
|
tasks: [...this.tasks.values()].map((t) => ({
|
|
4505
4594
|
id: t.id,
|
|
4506
4595
|
kind: t.kind,
|
|
@@ -7598,9 +7687,16 @@ var MesKernel = class extends FlowEngine {
|
|
|
7598
7687
|
o.status = "op-" + ops[0].key;
|
|
7599
7688
|
this.emitOrder(o);
|
|
7600
7689
|
}
|
|
7690
|
+
/**
|
|
7691
|
+
* 공정 하나의 작업을 낸다.
|
|
7692
|
+
*
|
|
7693
|
+
* `itemEpc` 가 **비어 있을 수 있다**(빈 문자열) — 원본이 개체를 말하지 않는 미러에서 그렇다. 그때
|
|
7694
|
+
* 이 작업은 「무엇을」 없이 「어디서 무엇을 한다」만 안다. 빈 값으로 두는 것이 규약이다(§1453 의
|
|
7695
|
+
* `t.itemRefs?.[0] ?? ''` 와 같은 자리) — 없는 개체를 지어내지 않는다.
|
|
7696
|
+
*/
|
|
7601
7697
|
emitStationDef(o, op, itemEpc) {
|
|
7602
7698
|
const loc = this.locationByType(op.locationType);
|
|
7603
|
-
const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: "created", itemEpc, fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
|
|
7699
|
+
const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: "created", itemEpc: itemEpc ?? "", fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
|
|
7604
7700
|
this.tasks.set(task.id, task);
|
|
7605
7701
|
this.emitTask(task);
|
|
7606
7702
|
}
|
|
@@ -7624,11 +7720,12 @@ var MesKernel = class extends FlowEngine {
|
|
|
7624
7720
|
order.allocated = [made];
|
|
7625
7721
|
}
|
|
7626
7722
|
const carried = order.allocated[0];
|
|
7627
|
-
if (!carried) {
|
|
7723
|
+
if (!carried && !this.observationDriven) {
|
|
7628
7724
|
throw new Error(
|
|
7629
7725
|
`order ${order.id} at step '${t.kind}': nothing to carry to '${next.key}' \u2014 the order holds no allocated material and this step declares no produced material. Declare the step's input or its output in the model.`
|
|
7630
7726
|
);
|
|
7631
7727
|
}
|
|
7728
|
+
if (!carried) this.stepsWithoutMaterial++;
|
|
7632
7729
|
this.emitStationDef(order, next, carried);
|
|
7633
7730
|
order.status = "op-" + next.key;
|
|
7634
7731
|
this.emitOrder(order);
|
|
@@ -7643,6 +7740,11 @@ var MesKernel = class extends FlowEngine {
|
|
|
7643
7740
|
this.emitOrder(order);
|
|
7644
7741
|
return;
|
|
7645
7742
|
}
|
|
7743
|
+
if (this.observationDriven) {
|
|
7744
|
+
order.status = "blocked-source-omits-material";
|
|
7745
|
+
this.emitOrder(order);
|
|
7746
|
+
return;
|
|
7747
|
+
}
|
|
7646
7748
|
throw new Error(
|
|
7647
7749
|
`order ${order.id} at final step '${t.kind}': nothing allocated to consume \u2014 the kernel does not make a product out of nothing. Declare the recipe inputs, or the step's produced material.`
|
|
7648
7750
|
);
|
|
@@ -9085,6 +9187,7 @@ function retiredVocabularyIn(line) {
|
|
|
9085
9187
|
constantDuration,
|
|
9086
9188
|
conversionFactorOf,
|
|
9087
9189
|
counterfactualAt,
|
|
9190
|
+
criterionSaysNothing,
|
|
9088
9191
|
demandWindowStart,
|
|
9089
9192
|
deriveAttentions,
|
|
9090
9193
|
documentPath,
|
|
@@ -9131,10 +9234,12 @@ function retiredVocabularyIn(line) {
|
|
|
9131
9234
|
monteCarloForecastAsync,
|
|
9132
9235
|
objectEvent,
|
|
9133
9236
|
objectUri,
|
|
9237
|
+
observationAt,
|
|
9134
9238
|
offCalendarAt,
|
|
9135
9239
|
offCalendarReasonAt,
|
|
9136
9240
|
operationalKindOf,
|
|
9137
9241
|
operationsCapabilityOf,
|
|
9242
|
+
outsideLimit,
|
|
9138
9243
|
parseEpc,
|
|
9139
9244
|
parseIsoDuration,
|
|
9140
9245
|
partialFitPolicy,
|
|
@@ -9157,6 +9262,7 @@ function retiredVocabularyIn(line) {
|
|
|
9157
9262
|
ssccUri,
|
|
9158
9263
|
stateFieldsOf,
|
|
9159
9264
|
subLotIdOf,
|
|
9265
|
+
testEvidenceGaps,
|
|
9160
9266
|
testPassedAt,
|
|
9161
9267
|
transactionEvent,
|
|
9162
9268
|
transformationEvent,
|
package/package.json
CHANGED