@operato/twin-kernel 0.7.23 → 0.7.24

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.
@@ -1376,6 +1376,19 @@ export interface StateSnapshot {
1376
1376
  * 왕복시켜야 재기동·재계산에서 확인 상태가 유지된다.
1377
1377
  */
1378
1378
  acked?: string[];
1379
+ /**
1380
+ * 주목 신호가 **처음 성립한 시각**(id → ISO) — 확인과 마찬가지로 **계산으로 되살릴 수 없다.**
1381
+ *
1382
+ * 신호는 매 스냅샷 다시 계산되지만 「언제부터인가」는 그 계산 안에 없다. 왕복시키지 않으면 재기동·
1383
+ * 웜스타트 뒤 **세 시간째 지속된 조건이 「0초째」로 되살아난다** — 방금 찬 자리와 세 시간째 막힌
1384
+ * 자리는 할 일이 다르므로, 그 값이 거짓이면 화면은 사람을 잘못된 순서로 움직인다.
1385
+ *
1386
+ * 사라진 조건은 함께 지운다(재발은 새 시작이다) — 그 규율은 `computeAttentions` 가 지킨다.
1387
+ */
1388
+ attentionSince?: {
1389
+ id: string;
1390
+ since: ISOTime;
1391
+ }[];
1379
1392
  /**
1380
1393
  * 에너지 — **에너지 트윈만 채운다**(계량 지점·수요 구간·피크). 다른 종류에서는 없다.
1381
1394
  *
@@ -1,6 +1,6 @@
1
1
  import { FlowEngine } from './flow-engine.ts';
2
2
  import { type AllocationPolicy } from './allocation-policy.ts';
3
- import { type CanonicalEnvelope, type DemandWindowState, type StateSnapshot, type Attention, type StructureShift, type TwinModelDef } from './contract.ts';
3
+ import { type CanonicalEnvelope, type DemandWindowState, type EnergyState, type StateSnapshot, type Attention, type StructureShift, type TwinModelDef } from './contract.ts';
4
4
  /** 수요 구간 — 요금의 알갱이다. 15분은 한국·다수 요금제의 최대수요 산정 단위다. */
5
5
  export declare const DEMAND_WINDOW_MS: number;
6
6
  /** 그 시각이 속한 구간의 시작 — 벽시계 경계(00·15·30·45분)에 맞춘다. */
@@ -208,5 +208,27 @@ export declare class EmsKernel extends FlowEngine {
208
208
  /** 글자로 선언된 값(발전 형상·통화 같은 것) — 비어 있으면 없는 것으로 본다. */
209
209
  private stringProperty;
210
210
  tick(dtMs: number): void;
211
+ /**
212
+ * 에너지의 연속성 — **SCADA 는 「이번 15분에 지금까지 얼마」를 모른다.**
213
+ *
214
+ * 계량기는 순간값과 적산값을 준다. 「이 구간의 최대」·「구간이 열릴 때의 적산」·「관측 이후 최대」는
215
+ * 우리가 접어 만든 값이라, 재기동하면 원천이 되풀어 주지 않는다. 잃으면 오류 없이 값이 작아진다 —
216
+ * 구간 전력량이 기준점을 새로 잡아 짧아지고, 그 구간의 피크가 부팅 이후로만 잡힌다(요금이 걸린 수다).
217
+ *
218
+ * 지난 마감 구간도 이어받는다: 이미 저널에 있는 사실이지만, 화면이 그것을 보려고 매번 저널을 접지
219
+ * 않게 한다(자른 사실은 `closedTotal` 이 그대로 나른다).
220
+ *
221
+ * 지금 열린 구간이 **이미 지나간 것**이어도 그대로 받는다 — 다음 표본이 오면 커널이 그 구간을 정상
222
+ * 경로로 마감한다(그 사실은 자기 시각을 달고 있어 늦게 나가도 참이다). 여기서 버리면 그 15분은
223
+ * 통째로 사라진다.
224
+ */
225
+ hydrateContinuity(snap: {
226
+ acked?: string[];
227
+ attentionSince?: {
228
+ id: string;
229
+ since: string;
230
+ }[];
231
+ energy?: EnergyState;
232
+ }): void;
211
233
  getSnapshot(): StateSnapshot;
212
234
  }
@@ -875,6 +875,38 @@ export class EmsKernel extends FlowEngine {
875
875
  this.deriveLoad(at, dtMs);
876
876
  this.closeDue(at);
877
877
  }
878
+ /**
879
+ * 에너지의 연속성 — **SCADA 는 「이번 15분에 지금까지 얼마」를 모른다.**
880
+ *
881
+ * 계량기는 순간값과 적산값을 준다. 「이 구간의 최대」·「구간이 열릴 때의 적산」·「관측 이후 최대」는
882
+ * 우리가 접어 만든 값이라, 재기동하면 원천이 되풀어 주지 않는다. 잃으면 오류 없이 값이 작아진다 —
883
+ * 구간 전력량이 기준점을 새로 잡아 짧아지고, 그 구간의 피크가 부팅 이후로만 잡힌다(요금이 걸린 수다).
884
+ *
885
+ * 지난 마감 구간도 이어받는다: 이미 저널에 있는 사실이지만, 화면이 그것을 보려고 매번 저널을 접지
886
+ * 않게 한다(자른 사실은 `closedTotal` 이 그대로 나른다).
887
+ *
888
+ * 지금 열린 구간이 **이미 지나간 것**이어도 그대로 받는다 — 다음 표본이 오면 커널이 그 구간을 정상
889
+ * 경로로 마감한다(그 사실은 자기 시각을 달고 있어 늦게 나가도 참이다). 여기서 버리면 그 15분은
890
+ * 통째로 사라진다.
891
+ */
892
+ hydrateContinuity(snap) {
893
+ super.hydrateContinuity(snap);
894
+ const e = snap?.energy;
895
+ if (!e)
896
+ return;
897
+ for (const p of e.points ?? [])
898
+ if (p?.id)
899
+ this.points.set(p.id, { ...p });
900
+ if (e.open)
901
+ this.open = { ...e.open };
902
+ if (e.closed?.length)
903
+ this.closed = e.closed.map(w => ({ ...w })).slice(-KEEP_CLOSED);
904
+ /* 자른 수는 이어받은 목록으로 다시 세지 않는다 — 그러면 잘려 나간 과거가 조용히 0 이 된다. */
905
+ if (Number.isFinite(e.closedTotal))
906
+ this.closedTotal = Number(e.closedTotal);
907
+ if (e.peakSince && Number.isFinite(e.peakSince.kW))
908
+ this.peak = { kW: e.peakSince.kW, windowStartMs: e.peakSince.windowStartMs };
909
+ }
878
910
  getSnapshot() {
879
911
  const snap = super.getSnapshot();
880
912
  const contractKW = this.declaredContractKW();
@@ -407,6 +407,29 @@ export declare abstract class FlowEngine implements TwinKernel {
407
407
  * 것이고, 그것을 허용하면 이 함수가 막으려던 일이 이 함수를 통해 일어난다.
408
408
  */
409
409
  resumeRevision(from: number): void;
410
+ /**
411
+ * **원천이 되풀어 주지 않는 것만 잇는다** — 미러(관측 구동) 재기동의 연속성.
412
+ *
413
+ * ── 왜 따로 두나 (2026-08-18) ──────────────────────────────────────────────
414
+ * 미러의 진실은 원천이다. 그래서 재고·위치·설비 상태는 스냅샷에서 심지 않는다 — 다음 계측·다음
415
+ * 마스터 재읽기가 곧 정정하므로, 심으면 **떠난 물건이 되살아나는** 위험만 남는다.
416
+ *
417
+ * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 접어 만든 것들이다:
418
+ * · 확인(ack)·조건이 처음 성립한 시각 — 사람이 한 일과 우리가 기억한 시각
419
+ * · 열린 구간의 누적·적산 기준점·관측 이후 최대 — SCADA 는 「이번 15분에 지금까지 얼마」를 모른다
420
+ *
421
+ * 이것을 잃으면 오류 없이 값이 작아진다(실측: 재기동 뒤 그 구간의 전력량·피크가 작게 나오고, 세
422
+ * 시간째 지속된 경보가 「0초째」가 된다). 그래서 축을 갈라, 되풀어 주지 않는 것만 이어받는다.
423
+ *
424
+ * 도메인 축(에너지 등)은 서브클래스가 override 로 덧붙인다(`super.hydrateContinuity(snap)` 합성).
425
+ */
426
+ hydrateContinuity(snap: {
427
+ acked?: string[];
428
+ attentionSince?: {
429
+ id: string;
430
+ since: string;
431
+ }[];
432
+ }): void;
410
433
  hydrateObserved(snap: {
411
434
  locations: LocationState[];
412
435
  items: ItemState[];
@@ -415,8 +438,13 @@ export declare abstract class FlowEngine implements TwinKernel {
415
438
  assets?: AssetState[];
416
439
  tasks?: TaskState[];
417
440
  orders?: OrderState[];
418
- /** 확인해 둔 주목 신호 id — 계산으로 되살릴 수 없는 유일한 축이라 스냅샷에서 이어받는다. */
441
+ /** 확인해 둔 주목 신호 id — 계산으로 되살릴 수 없어 스냅샷에서 이어받는다. */
419
442
  acked?: string[];
443
+ /** 주목 신호가 처음 성립한 시각 — 이어받지 않으면 지속된 조건이 「방금」으로 되살아난다. */
444
+ attentionSince?: {
445
+ id: string;
446
+ since: string;
447
+ }[];
420
448
  }, orders?: OrderStatusDelta[]): void;
421
449
  /**
422
450
  * what-if 구성 변주 — **선언을 덮어쓴다**(fork 대상). 바꿨으면 true.
@@ -465,11 +465,39 @@ export class FlowEngine {
465
465
  throw new Error(`리비전을 뒤로 되돌릴 수 없다: ${this.revision} → ${from} (겹치는 번호가 생긴다)`);
466
466
  this.revision = from;
467
467
  }
468
+ /**
469
+ * **원천이 되풀어 주지 않는 것만 잇는다** — 미러(관측 구동) 재기동의 연속성.
470
+ *
471
+ * ── 왜 따로 두나 (2026-08-18) ──────────────────────────────────────────────
472
+ * 미러의 진실은 원천이다. 그래서 재고·위치·설비 상태는 스냅샷에서 심지 않는다 — 다음 계측·다음
473
+ * 마스터 재읽기가 곧 정정하므로, 심으면 **떠난 물건이 되살아나는** 위험만 남는다.
474
+ *
475
+ * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 접어 만든 것들이다:
476
+ * · 확인(ack)·조건이 처음 성립한 시각 — 사람이 한 일과 우리가 기억한 시각
477
+ * · 열린 구간의 누적·적산 기준점·관측 이후 최대 — SCADA 는 「이번 15분에 지금까지 얼마」를 모른다
478
+ *
479
+ * 이것을 잃으면 오류 없이 값이 작아진다(실측: 재기동 뒤 그 구간의 전력량·피크가 작게 나오고, 세
480
+ * 시간째 지속된 경보가 「0초째」가 된다). 그래서 축을 갈라, 되풀어 주지 않는 것만 이어받는다.
481
+ *
482
+ * 도메인 축(에너지 등)은 서브클래스가 override 로 덧붙인다(`super.hydrateContinuity(snap)` 합성).
483
+ */
484
+ hydrateContinuity(snap) {
485
+ for (const id of snap?.acked ?? [])
486
+ this._acked.add(id);
487
+ for (const e of snap?.attentionSince ?? [])
488
+ if (e?.id && e.since)
489
+ this._attentionSince.set(e.id, e.since);
490
+ }
468
491
  hydrateObserved(snap, orders = []) {
469
492
  /* 확인 처리를 먼저 이어받는다 — 아래에서 상태를 주입하면 곧바로 주목 신호가 계산되므로, 늦게
470
493
  * 이어받으면 그 한 번은 확인 안 된 것으로 계산된다(화면이 잠깐 빨개진다). */
471
494
  for (const id of snap.acked ?? [])
472
495
  this._acked.add(id);
496
+ /* 「언제부터인가」도 함께 이어받는다 — 상태를 심으면 곧바로 신호가 계산되므로, 늦게 받으면 그 한 번은
497
+ 모두 방금 생긴 것으로 찍히고 그 값이 그대로 굳는다(다음 계산은 이미 기억된 시각을 쓴다). */
498
+ for (const e of snap.attentionSince ?? [])
499
+ if (e?.id && e.since)
500
+ this._attentionSince.set(e.id, e.since);
473
501
  /* **관측된 것을 버리지 않는다.** 예전에는 자리·설비 상태를 'idle' 로, OEE 누적을 0 으로 덮고
474
502
  * 물품의 로트·단위·소속·마스터데이터를 떨어뜨렸다. 씨앗이 잃은 것은 **예측도 모른다** —
475
503
  * 고장 난 설비를 정상으로, 진행 중인 일을 없는 것으로 놓고 미래를 실행하면 답이 낙관 쪽으로 치우친다. */
@@ -748,7 +776,7 @@ export class FlowEngine {
748
776
  */
749
777
  abandonedTasks = 0;
750
778
  /** 주목 신호가 **처음 성립한 시각**(id → ISO). 조건이 사라지면 지운다 — 재발은 새 시작이다. */
751
- _attentionSince = new Map(); // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
779
+ _attentionSince = new Map();
752
780
  dispatch(cmd) {
753
781
  /*
754
782
  * 이 커맨드가 낳는 이벤트에 상관값을 단다. 명시값이 없으면 **commandId 를 쓴다** — 호출자가
@@ -1022,7 +1050,9 @@ export class FlowEngine {
1022
1050
  })),
1023
1051
  attentions: this.computeAttentions(),
1024
1052
  /* 확인해 둔 신호 — 스냅샷으로 왕복해야 재기동 후에도 확인 상태가 유지된다. */
1025
- acked: [...this._acked]
1053
+ acked: [...this._acked],
1054
+ /* 언제부터인가 — 이것도 계산으로 되살릴 수 없다. 위 계산이 사라진 조건을 이미 지웠으므로 지금 남은 것이 곧 진실이다. */
1055
+ attentionSince: [...this._attentionSince].map(([id, since]) => ({ id, since }))
1026
1056
  };
1027
1057
  }
1028
1058
  /*
@@ -3254,8 +3254,29 @@ var FlowEngine = class {
3254
3254
  if (from < this.revision) throw new Error(`\uB9AC\uBE44\uC804\uC744 \uB4A4\uB85C \uB418\uB3CC\uB9B4 \uC218 \uC5C6\uB2E4: ${this.revision} \u2192 ${from} (\uACB9\uCE58\uB294 \uBC88\uD638\uAC00 \uC0DD\uAE34\uB2E4)`);
3255
3255
  this.revision = from;
3256
3256
  }
3257
+ /**
3258
+ * **원천이 되풀어 주지 않는 것만 잇는다** — 미러(관측 구동) 재기동의 연속성.
3259
+ *
3260
+ * ── 왜 따로 두나 (2026-08-18) ──────────────────────────────────────────────
3261
+ * 미러의 진실은 원천이다. 그래서 재고·위치·설비 상태는 스냅샷에서 심지 않는다 — 다음 계측·다음
3262
+ * 마스터 재읽기가 곧 정정하므로, 심으면 **떠난 물건이 되살아나는** 위험만 남는다.
3263
+ *
3264
+ * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 접어 만든 것들이다:
3265
+ * · 확인(ack)·조건이 처음 성립한 시각 — 사람이 한 일과 우리가 기억한 시각
3266
+ * · 열린 구간의 누적·적산 기준점·관측 이후 최대 — SCADA 는 「이번 15분에 지금까지 얼마」를 모른다
3267
+ *
3268
+ * 이것을 잃으면 오류 없이 값이 작아진다(실측: 재기동 뒤 그 구간의 전력량·피크가 작게 나오고, 세
3269
+ * 시간째 지속된 경보가 「0초째」가 된다). 그래서 축을 갈라, 되풀어 주지 않는 것만 이어받는다.
3270
+ *
3271
+ * 도메인 축(에너지 등)은 서브클래스가 override 로 덧붙인다(`super.hydrateContinuity(snap)` 합성).
3272
+ */
3273
+ hydrateContinuity(snap) {
3274
+ for (const id of snap?.acked ?? []) this._acked.add(id);
3275
+ for (const e of snap?.attentionSince ?? []) if (e?.id && e.since) this._attentionSince.set(e.id, e.since);
3276
+ }
3257
3277
  hydrateObserved(snap, orders = []) {
3258
3278
  for (const id of snap.acked ?? []) this._acked.add(id);
3279
+ for (const e of snap.attentionSince ?? []) if (e?.id && e.since) this._attentionSince.set(e.id, e.since);
3259
3280
  for (const n of snap.locations) {
3260
3281
  this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity ?? 0, parallelism: n.parallelism, occupancy: n.occupancy ?? 0, status: n.status ?? "idle", parentId: n.parentId });
3261
3282
  }
@@ -3515,7 +3536,6 @@ var FlowEngine = class {
3515
3536
  abandonedTasks = 0;
3516
3537
  /** 주목 신호가 **처음 성립한 시각**(id → ISO). 조건이 사라지면 지운다 — 재발은 새 시작이다. */
3517
3538
  _attentionSince = /* @__PURE__ */ new Map();
3518
- // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
3519
3539
  dispatch(cmd) {
3520
3540
  this._correlationId = cmd.correlationId ?? cmd.commandId;
3521
3541
  try {
@@ -3758,7 +3778,9 @@ var FlowEngine = class {
3758
3778
  })),
3759
3779
  attentions: this.computeAttentions(),
3760
3780
  /* 확인해 둔 신호 — 스냅샷으로 왕복해야 재기동 후에도 확인 상태가 유지된다. */
3761
- acked: [...this._acked]
3781
+ acked: [...this._acked],
3782
+ /* 언제부터인가 — 이것도 계산으로 되살릴 수 없다. 위 계산이 사라진 조건을 이미 지웠으므로 지금 남은 것이 곧 진실이다. */
3783
+ attentionSince: [...this._attentionSince].map(([id, since]) => ({ id, since }))
3762
3784
  };
3763
3785
  }
3764
3786
  /*
@@ -6676,6 +6698,30 @@ var EmsKernel = class extends FlowEngine {
6676
6698
  this.deriveLoad(at, dtMs);
6677
6699
  this.closeDue(at);
6678
6700
  }
6701
+ /**
6702
+ * 에너지의 연속성 — **SCADA 는 「이번 15분에 지금까지 얼마」를 모른다.**
6703
+ *
6704
+ * 계량기는 순간값과 적산값을 준다. 「이 구간의 최대」·「구간이 열릴 때의 적산」·「관측 이후 최대」는
6705
+ * 우리가 접어 만든 값이라, 재기동하면 원천이 되풀어 주지 않는다. 잃으면 오류 없이 값이 작아진다 —
6706
+ * 구간 전력량이 기준점을 새로 잡아 짧아지고, 그 구간의 피크가 부팅 이후로만 잡힌다(요금이 걸린 수다).
6707
+ *
6708
+ * 지난 마감 구간도 이어받는다: 이미 저널에 있는 사실이지만, 화면이 그것을 보려고 매번 저널을 접지
6709
+ * 않게 한다(자른 사실은 `closedTotal` 이 그대로 나른다).
6710
+ *
6711
+ * 지금 열린 구간이 **이미 지나간 것**이어도 그대로 받는다 — 다음 표본이 오면 커널이 그 구간을 정상
6712
+ * 경로로 마감한다(그 사실은 자기 시각을 달고 있어 늦게 나가도 참이다). 여기서 버리면 그 15분은
6713
+ * 통째로 사라진다.
6714
+ */
6715
+ hydrateContinuity(snap) {
6716
+ super.hydrateContinuity(snap);
6717
+ const e = snap?.energy;
6718
+ if (!e) return;
6719
+ for (const p of e.points ?? []) if (p?.id) this.points.set(p.id, { ...p });
6720
+ if (e.open) this.open = { ...e.open };
6721
+ if (e.closed?.length) this.closed = e.closed.map((w) => ({ ...w })).slice(-KEEP_CLOSED);
6722
+ if (Number.isFinite(e.closedTotal)) this.closedTotal = Number(e.closedTotal);
6723
+ if (e.peakSince && Number.isFinite(e.peakSince.kW)) this.peak = { kW: e.peakSince.kW, windowStartMs: e.peakSince.windowStartMs };
6724
+ }
6679
6725
  getSnapshot() {
6680
6726
  const snap = super.getSnapshot();
6681
6727
  const contractKW = this.declaredContractKW();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.23",
3
+ "version": "0.7.24",
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": {