@operato/twin-kernel 0.7.16 → 0.7.18

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.
@@ -1450,6 +1450,26 @@ export interface ScenarioDef {
1450
1450
  * 아무도 답할 수 없다).
1451
1451
  */
1452
1452
  interventions?: ScenarioIntervention[];
1453
+ /**
1454
+ * **가정한 선언** — 「이렇게 선언돼 있었다면?」(예: 축전지에 피크 억제 임계를 넣으면).
1455
+ *
1456
+ * 개입(`interventions`)이 시각을 가진 **행동**이라면 이쪽은 시각이 없는 **모델의 변주**다. 둘을 한
1457
+ * 자리에 두지 않는 이유: 행동은 저널에 남을 사실이 되고, 변주는 「그런 현장이었다면」이라는 가정이다.
1458
+ *
1459
+ * 시나리오와 함께 다니므로 fork 가 자기완결이다 — 같은 선언 하나로 예측·백테스트·AI 가 같은 미래를
1460
+ * 재생한다. 다만 **실행 중 트윈에 이 시나리오를 걸면 그 트윈의 선언이 실제로 바뀐다**: 그것은
1461
+ * 「우리 현장에 그 자동화가 있다」는 주장이므로, 무엇을 덮어썼는지 소비처가 읽을 수 있어야 한다
1462
+ * (`declarationOverrides()`).
1463
+ */
1464
+ overrides?: ScenarioOverride[];
1465
+ }
1466
+ /** 가정한 선언 하나 — 어느 자원의 어느 속성을 무엇으로. */
1467
+ export interface ScenarioOverride {
1468
+ resourceId: string;
1469
+ propertyId: string;
1470
+ value: string | number;
1471
+ /** 그 자원이 없었으면 false — 조용히 성공한 척하지 않는다. */
1472
+ applied?: boolean;
1453
1473
  }
1454
1474
  /** 가정한 개입 하나 — 시각 + 행동. */
1455
1475
  export interface ScenarioIntervention {
@@ -554,6 +554,15 @@ export class EmsKernel extends FlowEngine {
554
554
  const state = this.equipment.get(e.id);
555
555
  if (!state)
556
556
  continue;
557
+ /*
558
+ * 시뮬의 출발 SOC — 선언에서 한 번 심는다(계측이 들어오면 그것이 이긴다).
559
+ * 이것이 없던 동안, 정책을 선언해도 「SOC 를 모른다」에 걸려 배터리가 아무 일도 하지 못했다.
560
+ */
561
+ if (!Number.isFinite(Number(state.soc))) {
562
+ const seed = this.numberProperty(e, EMS_PROPERTY.initialSoc);
563
+ if (seed !== undefined)
564
+ state.soc = seed;
565
+ }
557
566
  const over = demandBeforeStorageKW - dispatched - above;
558
567
  if (over <= 0) {
559
568
  /* 임계 아래 — 쓸 이유가 없다. 낡은 방전값을 남기지 않는다(상태가 지난 틱을 말하게 된다). */
@@ -30,5 +30,40 @@ export declare const EMS_PROPERTY: {
30
30
  readonly maxDischargeKW: "dispatch.maxDischargeKW";
31
31
  /** 예비 SOC(%) — 이 아래로는 쓰지 않는다(비상 대비). 없으면 0 으로 본다. */
32
32
  readonly reserveSoc: "dispatch.reserveSoc";
33
+ /**
34
+ * 시뮬레이션이 **출발할 때의 SOC(%)** — 씨앗값이다.
35
+ *
36
+ * 계측이 SOC 를 알려 주는 트윈에는 필요 없다(잰 값이 진실이다). 시뮬 트윈에는 알려 줄 것이 없어서
37
+ * 「SOC 를 모르면 방전하지 않는다」는 규율에 걸려 **배터리가 아무 일도 하지 못했다** — 정책을 선언해도
38
+ * 피크가 한 톨도 깎이지 않았다(실화면에서 그렇게 났다).
39
+ *
40
+ * 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이긴다.
41
+ */
42
+ readonly initialSoc: "storage.initialSoc";
33
43
  };
44
+ /**
45
+ * 이 선언들의 **단위와 범위** — 값을 읽는 쪽이 아니라 **주는 쪽**을 위한 표다.
46
+ *
47
+ * ── 왜 커널이 단위를 말해야 하나 (2026-08-17) ────────────────────────────────
48
+ * 사용자가 「현재 SOC 80%」라고 말했고, 저작 AI 는 `storage.initialSoc = 0.8` 을 썼다. 커널은 이 값을
49
+ * **퍼센트**로 읽으므로(`(soc - reserve) / 100`) 그 배터리는 0.8% 로 선언됐다 — 쓸 수 있는 에너지가
50
+ * 사실상 없는 상태이고, **경고 하나 없이** 그렇게 됐다. 실화면에서 그렇게 났다.
51
+ *
52
+ * 잘못은 값을 준 쪽이 아니라 **단위를 말하지 않은 쪽**에 있다. 타입을 지어내지 못하게 카탈로그를 주면서
53
+ * 속성은 id 만 줬으니, 값을 만드는 쪽은 단위를 짐작할 수밖에 없었다.
54
+ *
55
+ * 표를 커널에 두는 이유: 이 단위는 **커널의 셈법이 정하는 것**이다(퍼센트로 나누는 코드가 여기 있다).
56
+ * 소비처마다 적으면 두 벌이 되고, 두 벌은 갈라진다 — 갈라지는 순간 이 결함이 그대로 돌아온다.
57
+ */
58
+ export interface EmsPropertySpec {
59
+ /** 단위 — 사람이 읽는 표기(kW·kWh·% 등). 무차원이면 없다. */
60
+ uom?: string;
61
+ /** 값의 종류 — GS1 dataType 표기(`xs:double`·`xs:string`). */
62
+ dataType: 'xs:double' | 'xs:string';
63
+ /** 허용 범위 [min, max] — 밖의 값은 받는 쪽이 거절할 수 있다(있는 것만 적는다). */
64
+ range?: [number, number];
65
+ /** 무엇을 뜻하나 — 영어 canonical(표현 계층이 사용자 언어로 옮긴다). */
66
+ note: string;
67
+ }
68
+ export declare const EMS_PROPERTY_SPEC: Record<string, EmsPropertySpec>;
34
69
  export declare const EMS_TYPES: TwinTypeInfo[];
@@ -61,7 +61,31 @@ export const EMS_PROPERTY = {
61
61
  /** 최대 방전율(kW) — 인버터가 낼 수 있는 한계. */
62
62
  maxDischargeKW: 'dispatch.maxDischargeKW',
63
63
  /** 예비 SOC(%) — 이 아래로는 쓰지 않는다(비상 대비). 없으면 0 으로 본다. */
64
- reserveSoc: 'dispatch.reserveSoc'
64
+ reserveSoc: 'dispatch.reserveSoc',
65
+ /**
66
+ * 시뮬레이션이 **출발할 때의 SOC(%)** — 씨앗값이다.
67
+ *
68
+ * 계측이 SOC 를 알려 주는 트윈에는 필요 없다(잰 값이 진실이다). 시뮬 트윈에는 알려 줄 것이 없어서
69
+ * 「SOC 를 모르면 방전하지 않는다」는 규율에 걸려 **배터리가 아무 일도 하지 못했다** — 정책을 선언해도
70
+ * 피크가 한 톨도 깎이지 않았다(실화면에서 그렇게 났다).
71
+ *
72
+ * 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이긴다.
73
+ */
74
+ initialSoc: 'storage.initialSoc'
75
+ };
76
+ export const EMS_PROPERTY_SPEC = {
77
+ [EMS_PROPERTY.contractKW]: { uom: 'kW', dataType: 'xs:double', note: 'contracted power at the metering point, in kW.' },
78
+ [EMS_PROPERTY.ratedKW]: { uom: 'kW', dataType: 'xs:double', note: 'power drawn while running, in kW.' },
79
+ [EMS_PROPERTY.standbyKW]: { uom: 'kW', dataType: 'xs:double', note: 'power drawn while idle, in kW.' },
80
+ [EMS_PROPERTY.demandChargePerKW]: { dataType: 'xs:double', note: 'demand charge per kW of billing-period peak, in the declared currency.' },
81
+ [EMS_PROPERTY.energyChargePerKWh]: { dataType: 'xs:double', note: 'energy charge per kWh, in the declared currency.' },
82
+ [EMS_PROPERTY.currency]: { dataType: 'xs:string', note: 'ISO 4217 currency code, e.g. USD or KRW.' },
83
+ [EMS_PROPERTY.capacityKWh]: { uom: 'kWh', dataType: 'xs:double', note: 'usable energy of the storage, in kWh.' },
84
+ [EMS_PROPERTY.dischargeAboveKW]: { uom: 'kW', dataType: 'xs:double', note: 'net demand above which the storage discharges, in kW.' },
85
+ [EMS_PROPERTY.maxDischargeKW]: { uom: 'kW', dataType: 'xs:double', note: 'inverter limit on discharge rate, in kW.' },
86
+ /* 퍼센트다 — 0.8 은 0.8% 이고 80% 가 아니다. 이 한 줄이 없어서 배터리가 조용히 비어 있었다. */
87
+ [EMS_PROPERTY.reserveSoc]: { uom: '%', dataType: 'xs:double', range: [0, 100], note: 'reserve state of charge as a percentage 0-100 (20 means 20%), never discharged below.' },
88
+ [EMS_PROPERTY.initialSoc]: { uom: '%', dataType: 'xs:double', range: [0, 100], note: 'starting state of charge as a percentage 0-100 (80 means 80%, not 0.8).' }
65
89
  };
66
90
  export const EMS_TYPES = [
67
91
  /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
@@ -1,4 +1,4 @@
1
- import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, StructureShift } from './contract.ts';
1
+ import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, StructureShift } from './contract.ts';
2
2
  import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
3
3
  import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
4
4
  import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
@@ -329,6 +329,8 @@ export declare abstract class FlowEngine implements TwinKernel {
329
329
  private interventions;
330
330
  /** 개입이 어떻게 됐나 — 걸린 것·거절된 것. 조용히 사라지지 않게 소비처가 읽는다. */
331
331
  private interventionLog;
332
+ /** 덮어쓴 선언 — 「이 값은 가정이다」를 소비처가 말할 수 있게. */
333
+ private overrideLog;
332
334
  private generating;
333
335
  private speed;
334
336
  private eventSeq;
@@ -480,6 +482,13 @@ export declare abstract class FlowEngine implements TwinKernel {
480
482
  * 아직 시각이 오지 않은 것은 여기 없다(일어나지 않은 일을 결과로 적지 않는다).
481
483
  */
482
484
  interventionOutcomes(): InterventionOutcome[];
485
+ /**
486
+ * 이 커널이 **가정으로 덮어쓴 선언들** — 없으면 빈 배열.
487
+ *
488
+ * 비어 있지 않다는 것은 「이 트윈의 수는 선언된 현장이 아니라 가정한 현장의 것」이라는 뜻이다.
489
+ * 화면·AI 가 그 사실을 함께 말해야 한다.
490
+ */
491
+ declarationOverrides(): ScenarioOverride[];
483
492
  /**
484
493
  * 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
485
494
  *
@@ -290,6 +290,8 @@ export class FlowEngine {
290
290
  interventions = [];
291
291
  /** 개입이 어떻게 됐나 — 걸린 것·거절된 것. 조용히 사라지지 않게 소비처가 읽는다. */
292
292
  interventionLog = [];
293
+ /** 덮어쓴 선언 — 「이 값은 가정이다」를 소비처가 말할 수 있게. */
294
+ overrideLog = [];
293
295
  generating = false;
294
296
  speed = 1;
295
297
  eventSeq = 0;
@@ -875,6 +877,14 @@ export class FlowEngine {
875
877
  /* 개입은 **시작할 때** 시각이 정해진다(경과 기준) — 여기서는 받아만 둔다. */
876
878
  this.interventions = (def.interventions ?? []).map(spec => ({ spec, dueMs: 0, done: false }));
877
879
  this.interventionLog = [];
880
+ /*
881
+ * 가정한 선언은 **지금 곧 걸린다** — 시각이 없는 변주이므로 미룰 것이 없다.
882
+ * 무엇을 덮어썼는지 남긴다: 말하지 않으면 소비처가 what-if 결과를 트윈의 현재 상태로 읽는다.
883
+ */
884
+ this.overrideLog = (def.overrides ?? []).map(o => ({
885
+ ...o,
886
+ applied: this.setResourceProperty(o.resourceId, o.propertyId, o.value)
887
+ }));
878
888
  },
879
889
  start: () => {
880
890
  if (this.generating)
@@ -889,7 +899,7 @@ export class FlowEngine {
889
899
  }
890
900
  },
891
901
  pause: () => { this.generating = false; },
892
- reset: () => { this.generating = false; this.gens = []; this.interventions = []; this.interventionLog = []; },
902
+ reset: () => { this.generating = false; this.gens = []; this.interventions = []; this.interventionLog = []; this.overrideLog = []; },
893
903
  setSpeed: (f) => { this.speed = f; }
894
904
  };
895
905
  tick(dtMs) {
@@ -1117,6 +1127,15 @@ export class FlowEngine {
1117
1127
  interventionOutcomes() {
1118
1128
  return this.interventionLog.map(o => ({ ...o }));
1119
1129
  }
1130
+ /**
1131
+ * 이 커널이 **가정으로 덮어쓴 선언들** — 없으면 빈 배열.
1132
+ *
1133
+ * 비어 있지 않다는 것은 「이 트윈의 수는 선언된 현장이 아니라 가정한 현장의 것」이라는 뜻이다.
1134
+ * 화면·AI 가 그 사실을 함께 말해야 한다.
1135
+ */
1136
+ declarationOverrides() {
1137
+ return this.overrideLog.map(o => ({ ...o }));
1138
+ }
1120
1139
  // ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
1121
1140
  /**
1122
1141
  * 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
@@ -34,6 +34,7 @@ __export(index_exports, {
34
34
  EMS_EQUIPMENT_TYPES: () => EMS_EQUIPMENT_TYPES,
35
35
  EMS_LOCATION_TYPES: () => EMS_LOCATION_TYPES,
36
36
  EMS_PROPERTY: () => EMS_PROPERTY,
37
+ EMS_PROPERTY_SPEC: () => EMS_PROPERTY_SPEC,
37
38
  EMS_TYPES: () => EMS_TYPES,
38
39
  ENERGY_EVENT: () => ENERGY_EVENT,
39
40
  EPCIS_CONTEXT: () => EPCIS_CONTEXT,
@@ -1725,7 +1726,31 @@ var EMS_PROPERTY = {
1725
1726
  /** 최대 방전율(kW) — 인버터가 낼 수 있는 한계. */
1726
1727
  maxDischargeKW: "dispatch.maxDischargeKW",
1727
1728
  /** 예비 SOC(%) — 이 아래로는 쓰지 않는다(비상 대비). 없으면 0 으로 본다. */
1728
- reserveSoc: "dispatch.reserveSoc"
1729
+ reserveSoc: "dispatch.reserveSoc",
1730
+ /**
1731
+ * 시뮬레이션이 **출발할 때의 SOC(%)** — 씨앗값이다.
1732
+ *
1733
+ * 계측이 SOC 를 알려 주는 트윈에는 필요 없다(잰 값이 진실이다). 시뮬 트윈에는 알려 줄 것이 없어서
1734
+ * 「SOC 를 모르면 방전하지 않는다」는 규율에 걸려 **배터리가 아무 일도 하지 못했다** — 정책을 선언해도
1735
+ * 피크가 한 톨도 깎이지 않았다(실화면에서 그렇게 났다).
1736
+ *
1737
+ * 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이긴다.
1738
+ */
1739
+ initialSoc: "storage.initialSoc"
1740
+ };
1741
+ var EMS_PROPERTY_SPEC = {
1742
+ [EMS_PROPERTY.contractKW]: { uom: "kW", dataType: "xs:double", note: "contracted power at the metering point, in kW." },
1743
+ [EMS_PROPERTY.ratedKW]: { uom: "kW", dataType: "xs:double", note: "power drawn while running, in kW." },
1744
+ [EMS_PROPERTY.standbyKW]: { uom: "kW", dataType: "xs:double", note: "power drawn while idle, in kW." },
1745
+ [EMS_PROPERTY.demandChargePerKW]: { dataType: "xs:double", note: "demand charge per kW of billing-period peak, in the declared currency." },
1746
+ [EMS_PROPERTY.energyChargePerKWh]: { dataType: "xs:double", note: "energy charge per kWh, in the declared currency." },
1747
+ [EMS_PROPERTY.currency]: { dataType: "xs:string", note: "ISO 4217 currency code, e.g. USD or KRW." },
1748
+ [EMS_PROPERTY.capacityKWh]: { uom: "kWh", dataType: "xs:double", note: "usable energy of the storage, in kWh." },
1749
+ [EMS_PROPERTY.dischargeAboveKW]: { uom: "kW", dataType: "xs:double", note: "net demand above which the storage discharges, in kW." },
1750
+ [EMS_PROPERTY.maxDischargeKW]: { uom: "kW", dataType: "xs:double", note: "inverter limit on discharge rate, in kW." },
1751
+ /* 퍼센트다 — 0.8 은 0.8% 이고 80% 가 아니다. 이 한 줄이 없어서 배터리가 조용히 비어 있었다. */
1752
+ [EMS_PROPERTY.reserveSoc]: { uom: "%", dataType: "xs:double", range: [0, 100], note: "reserve state of charge as a percentage 0-100 (20 means 20%), never discharged below." },
1753
+ [EMS_PROPERTY.initialSoc]: { uom: "%", dataType: "xs:double", range: [0, 100], note: "starting state of charge as a percentage 0-100 (80 means 80%, not 0.8)." }
1729
1754
  };
1730
1755
  var EMS_TYPES = [
1731
1756
  /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
@@ -2996,6 +3021,8 @@ var FlowEngine = class {
2996
3021
  interventions = [];
2997
3022
  /** 개입이 어떻게 됐나 — 걸린 것·거절된 것. 조용히 사라지지 않게 소비처가 읽는다. */
2998
3023
  interventionLog = [];
3024
+ /** 덮어쓴 선언 — 「이 값은 가정이다」를 소비처가 말할 수 있게. */
3025
+ overrideLog = [];
2999
3026
  generating = false;
3000
3027
  speed = 1;
3001
3028
  eventSeq = 0;
@@ -3523,6 +3550,10 @@ var FlowEngine = class {
3523
3550
  this.gens = def.generators.map((spec) => ({ spec, nextMs: 0 }));
3524
3551
  this.interventions = (def.interventions ?? []).map((spec) => ({ spec, dueMs: 0, done: false }));
3525
3552
  this.interventionLog = [];
3553
+ this.overrideLog = (def.overrides ?? []).map((o) => ({
3554
+ ...o,
3555
+ applied: this.setResourceProperty(o.resourceId, o.propertyId, o.value)
3556
+ }));
3526
3557
  },
3527
3558
  start: () => {
3528
3559
  if (this.generating) return;
@@ -3541,6 +3572,7 @@ var FlowEngine = class {
3541
3572
  this.gens = [];
3542
3573
  this.interventions = [];
3543
3574
  this.interventionLog = [];
3575
+ this.overrideLog = [];
3544
3576
  },
3545
3577
  setSpeed: (f) => {
3546
3578
  this.speed = f;
@@ -3744,6 +3776,15 @@ var FlowEngine = class {
3744
3776
  interventionOutcomes() {
3745
3777
  return this.interventionLog.map((o) => ({ ...o }));
3746
3778
  }
3779
+ /**
3780
+ * 이 커널이 **가정으로 덮어쓴 선언들** — 없으면 빈 배열.
3781
+ *
3782
+ * 비어 있지 않다는 것은 「이 트윈의 수는 선언된 현장이 아니라 가정한 현장의 것」이라는 뜻이다.
3783
+ * 화면·AI 가 그 사실을 함께 말해야 한다.
3784
+ */
3785
+ declarationOverrides() {
3786
+ return this.overrideLog.map((o) => ({ ...o }));
3787
+ }
3747
3788
  // ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
3748
3789
  /**
3749
3790
  * 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
@@ -6292,6 +6333,10 @@ var EmsKernel = class extends FlowEngine {
6292
6333
  if (!(capacity > 0) || !(maxRate > 0)) continue;
6293
6334
  const state = this.equipment.get(e.id);
6294
6335
  if (!state) continue;
6336
+ if (!Number.isFinite(Number(state.soc))) {
6337
+ const seed = this.numberProperty(e, EMS_PROPERTY.initialSoc);
6338
+ if (seed !== void 0) state.soc = seed;
6339
+ }
6295
6340
  const over = demandBeforeStorageKW - dispatched - above;
6296
6341
  if (over <= 0) {
6297
6342
  if (Number(state.dischargeKW) > 0) {
@@ -6752,6 +6797,7 @@ function retiredVocabularyIn(line) {
6752
6797
  EMS_EQUIPMENT_TYPES,
6753
6798
  EMS_LOCATION_TYPES,
6754
6799
  EMS_PROPERTY,
6800
+ EMS_PROPERTY_SPEC,
6755
6801
  EMS_TYPES,
6756
6802
  ENERGY_EVENT,
6757
6803
  EPCIS_CONTEXT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.16",
3
+ "version": "0.7.18",
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": {