@operato/twin-kernel 0.7.4 → 0.7.6

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.
@@ -842,6 +842,18 @@ export interface EquipmentState extends EffectivePeriod {
842
842
  * 셋을 합치면 "왜 안 움직이나" 에 답할 수 없다(고쳐야 하나·풀어야 하나·기다려야 하나).
843
843
  */
844
844
  offShift?: boolean;
845
+ /** generating — 만든 전력·역송(kW). */
846
+ generatedKW?: number;
847
+ exportKW?: number;
848
+ /** storing — 충전율(%)·충전·방전(kW). 충전과 방전을 나눈다(손실·수명 판단이 그 둘을 구별한다). */
849
+ soc?: number;
850
+ chargeKW?: number;
851
+ dischargeKW?: number;
852
+ /** curtailable — 줄일 수 있나, 그리고 최소 유지(kW). 그 아래로 내리면 공정이 죽는다. */
853
+ curtailable?: boolean;
854
+ minKW?: number;
855
+ /** switching — 개폐 위치(IEC 61850 `Pos`). 과도·불량을 열림/닫힘으로 반올림하지 않는다. */
856
+ position?: 'open' | 'closed' | 'intermediate' | 'bad';
845
857
  /**
846
858
  * 유효 기간 밖이라 이 시각의 모델에 없다 — **네 번째 이유**(§Effectivity).
847
859
  * 도입 예정(`not-yet`)과 폐기(`expired`)를 구별한다. 유효하면 값이 없다.
@@ -1218,7 +1230,13 @@ export interface EnergyState {
1218
1230
  /** 관측 시작 이후 최대 수요 — 월 경계는 여기서 정하지 않는다(위 주석). */
1219
1231
  peakSince?: {
1220
1232
  kW: number;
1221
- windowStartMs: number;
1233
+ windowStartMs: number; /**
1234
+ * 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — **버린 것을 세어 둔다.**
1235
+ *
1236
+ * 원천에 우리가 모르는 설비가 있다는 것은 그 자체로 알아야 할 사실이다(모델이 낡았거나 매핑이
1237
+ * 틀렸다). 조용히 버리면 「값이 왜 안 보이지」로만 남는다.
1238
+ */
1239
+ unknownEquipment?: number;
1222
1240
  };
1223
1241
  /** 현장이 선언한 계약전력(자리 속성) — 없으면 계약 대비 판정을 하지 않는다. */
1224
1242
  contractKW?: number;
@@ -1428,6 +1446,12 @@ export declare const OP_EVENT: {
1428
1446
  export declare const ENERGY_EVENT: {
1429
1447
  /** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
1430
1448
  readonly measured: "energy.measured";
1449
+ /**
1450
+ * 설비의 에너지 상태 — 발전·저장·감축 여지·개폐 위치.
1451
+ *
1452
+ * 계량과 다른 사건으로 둔다: 계량은 구간에 **누적**되고 이것은 그 설비의 **지금**을 바꾼다.
1453
+ */
1454
+ readonly equipment: "energy.equipment";
1431
1455
  /** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
1432
1456
  readonly demandWindow: "energy.demand.window";
1433
1457
  /**
@@ -1455,6 +1479,27 @@ export declare const ENERGY_EVENT: {
1455
1479
  readonly drSuggested: "energy.dr.suggested";
1456
1480
  };
1457
1481
  export type EnergyEventType = (typeof ENERGY_EVENT)[keyof typeof ENERGY_EVENT];
1482
+ /**
1483
+ * 설비가 낸 **자기 에너지 상태** — 계량이 아닌 능력들의 값.
1484
+ *
1485
+ * 계량(`energy.measured`)과 갈라 두는 이유: 계량은 **구간에 누적되는 표본**이고, 이것들은 **그 설비의
1486
+ * 지금 상태**다. 같은 문으로 넣으면 발전량이 부하 구간에 더해지거나 충전율이 요금 판정에 섞인다.
1487
+ *
1488
+ * 한 봉투에 여러 능력의 값이 함께 올 수 있다(원 시스템이 한 번에 준다) — 각 값은 그것을 선언한 능력의
1489
+ * 필드로만 들어간다. 없는 값은 **보내지 않는다**(0 을 보내면 「그렇게 측정됐다」가 된다).
1490
+ */
1491
+ export interface EnergyEquipmentData {
1492
+ equipmentId: string;
1493
+ at: ISOTime;
1494
+ generatedKW?: number;
1495
+ exportKW?: number;
1496
+ soc?: number;
1497
+ chargeKW?: number;
1498
+ dischargeKW?: number;
1499
+ curtailable?: boolean;
1500
+ minKW?: number;
1501
+ position?: 'open' | 'closed' | 'intermediate' | 'bad';
1502
+ }
1458
1503
  /** 계량 도착의 실린 값 — 계량 지점 하나의 한 시점. */
1459
1504
  export interface EnergyMeasuredData {
1460
1505
  /** 계량 지점(설비 id). */
@@ -1850,6 +1895,14 @@ export interface TwinKernel {
1850
1895
  adoptStructure(def: TwinModelDef): StructureShift;
1851
1896
  /** 이 커널이 관측으로 구동됨을 선언한다 — 첫 이벤트가 오기 전에도 그렇다. */
1852
1897
  observe(): void;
1898
+ /**
1899
+ * 시각 기준점을 세운다 — 이 트윈의 「지금」은 `기준점 + 경과`다.
1900
+ *
1901
+ * 세우는 쪽이 기동 순간의 실제 시각을 준다. 주지 않으면 커널의 기본 기준점을 쓰는데, 그러면 시뮬
1902
+ * 트윈의 저널 시각이 재기동마다 되감겨 「언제 있었던 일인가」를 되짚을 수 없다.
1903
+ * **기동 직후에만** 부른다 — 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋난다.
1904
+ */
1905
+ setClockOrigin?(originMs: number): void;
1853
1906
  getSnapshot(): StateSnapshot;
1854
1907
  onEvent(handler: EventHandler): Unsubscribe;
1855
1908
  dispatch(cmd: Command): CommandAck;
package/dist/contract.js CHANGED
@@ -631,6 +631,12 @@ export const OP_EVENT = {
631
631
  export const ENERGY_EVENT = {
632
632
  /** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
633
633
  measured: 'energy.measured',
634
+ /**
635
+ * 설비의 에너지 상태 — 발전·저장·감축 여지·개폐 위치.
636
+ *
637
+ * 계량과 다른 사건으로 둔다: 계량은 구간에 **누적**되고 이것은 그 설비의 **지금**을 바꾼다.
638
+ */
639
+ equipment: 'energy.equipment',
634
640
  /** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
635
641
  demandWindow: 'energy.demand.window',
636
642
  /**
@@ -70,6 +70,21 @@ export declare class EmsKernel extends FlowEngine {
70
70
  * 적는다(상위 `apply` 의 재방출 규약과 같은 이유).
71
71
  */
72
72
  apply(envelope: CanonicalEnvelope): void;
73
+ /**
74
+ * 설비가 낸 자기 에너지 상태를 그 설비에 적는다 — 발전·저장·감축 여지·개폐 위치.
75
+ *
76
+ * ── 계량과 갈라 두는 이유 ──────────────────────────────────────────────────
77
+ * 계량은 **수요 구간에 누적**되는 표본이고, 이것들은 그 설비의 **지금**이다. 같은 문으로 넣으면
78
+ * 태양광 발전량이 부하 구간에 더해지거나 충전율이 요금 판정에 섞인다.
79
+ *
80
+ * 모르는 설비의 상태는 **버리지 않고 세어 둔다** — 원천에 우리 모델이 모르는 설비가 있다는 것은
81
+ * 그 자체로 알아야 할 사실이다(상위 커널의 `unhandled` 와 같은 규율).
82
+ *
83
+ * 온 값만 덮는다: 보내지 않은 필드는 그대로 둔다(없는 값을 0 으로 만들지 않는다).
84
+ */
85
+ private applyEquipmentEnergy;
86
+ /** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
87
+ private unknownEquipmentReports;
73
88
  private ingestMeasured;
74
89
  private openWindow;
75
90
  /**
@@ -173,8 +173,49 @@ export class EmsKernel extends FlowEngine {
173
173
  this.ingestMeasured(envelope);
174
174
  return;
175
175
  }
176
+ if (envelope.eventType === ENERGY_EVENT.equipment) {
177
+ this.applyEquipmentEnergy(envelope);
178
+ return;
179
+ }
176
180
  super.apply(envelope);
177
181
  }
182
+ /**
183
+ * 설비가 낸 자기 에너지 상태를 그 설비에 적는다 — 발전·저장·감축 여지·개폐 위치.
184
+ *
185
+ * ── 계량과 갈라 두는 이유 ──────────────────────────────────────────────────
186
+ * 계량은 **수요 구간에 누적**되는 표본이고, 이것들은 그 설비의 **지금**이다. 같은 문으로 넣으면
187
+ * 태양광 발전량이 부하 구간에 더해지거나 충전율이 요금 판정에 섞인다.
188
+ *
189
+ * 모르는 설비의 상태는 **버리지 않고 세어 둔다** — 원천에 우리 모델이 모르는 설비가 있다는 것은
190
+ * 그 자체로 알아야 할 사실이다(상위 커널의 `unhandled` 와 같은 규율).
191
+ *
192
+ * 온 값만 덮는다: 보내지 않은 필드는 그대로 둔다(없는 값을 0 으로 만들지 않는다).
193
+ */
194
+ applyEquipmentEnergy(envelope) {
195
+ const d = envelope.data;
196
+ const id = String(d?.equipmentId ?? '').trim();
197
+ const eq = id ? this.equipment.get(id) : undefined;
198
+ if (!eq) {
199
+ this.unknownEquipmentReports++;
200
+ return;
201
+ }
202
+ const put = (k) => {
203
+ const v = d[k];
204
+ if (v !== undefined)
205
+ eq[k] = v;
206
+ };
207
+ put('generatedKW');
208
+ put('exportKW');
209
+ put('soc');
210
+ put('chargeKW');
211
+ put('dischargeKW');
212
+ put('curtailable');
213
+ put('minKW');
214
+ put('position');
215
+ this.revision++;
216
+ }
217
+ /** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
218
+ unknownEquipmentReports = 0;
178
219
  ingestMeasured(envelope) {
179
220
  const d = envelope.data;
180
221
  /* 시각은 **계측이 말한 것**이 먼저다(`data.at`) — 봉투 시각은 전달 시각일 수 있다.
@@ -276,12 +317,23 @@ export class EmsKernel extends FlowEngine {
276
317
  * kW 를 못 읽은 경우(계기 오류·필드 누락)가 실제로 있고, 그것은 「아무것도 오지 않았다」와
277
318
  * 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
278
319
  */
279
- ...(w.samples === 0 ? { observedAbsence: 'no-load-samples' } : {})
320
+ ...(w.samples === 0 ? { observedAbsence: 'no-load-samples' } : {}),
321
+ /*
322
+ * **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
323
+ * (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
324
+ */
325
+ ...(w.derived ? { derived: true } : {})
280
326
  });
281
327
  /* 피크는 **마감된 구간**으로만 갱신한다 — 열린 구간의 최대는 아직 확정이 아니다. */
282
328
  if (w.maxKW !== undefined && (this.peak === undefined || w.maxKW > this.peak.kW)) {
283
329
  this.peak = { kW: w.maxKW, windowStartMs: w.startMs };
284
- this.emitOp(ENERGY_EVENT.peak, { kW: w.maxKW, windowStartMs: w.startMs, ...(w.contractKW !== undefined ? { contractKW: w.contractKW } : {}) });
330
+ this.emitOp(ENERGY_EVENT.peak, {
331
+ kW: w.maxKW,
332
+ windowStartMs: w.startMs,
333
+ ...(w.contractKW !== undefined ? { contractKW: w.contractKW } : {}),
334
+ /* 피크도 그 구간에서 왔다 — 구간이 만든 값이면 피크도 만든 값이다. */
335
+ ...(w.derived ? { derived: true } : {})
336
+ });
285
337
  }
286
338
  /* 다음 구간은 표본이 올 때 연다 — 미리 열면 오지 않은 구간을 존재하는 것처럼 만든다. */
287
339
  this.open = undefined;
@@ -419,9 +471,18 @@ export class EmsKernel extends FlowEngine {
419
471
  }
420
472
  tick(dtMs) {
421
473
  super.tick(dtMs);
422
- /* 부하를 먼저 만들고 마감한다 — 마감이 먼저면 마지막 값이 다음 구간으로 밀린다. */
423
- this.deriveLoad(this.clockMs);
424
- this.closeDue(this.clockMs);
474
+ /*
475
+ * 구간은 **이 트윈의 「지금」**으로 잡는다(`nowMs`) — 경과 시간(`clockMs`)이 아니다.
476
+ *
477
+ * 예전에는 경과 시간을 그대로 넘겨서 시뮬 트윈의 수요 구간이 **1970년**에 섰다. 계측 경로는
478
+ * 계측이 말한 절대 시각을 쓰므로, 두 경로가 서로 다른 시간축에 값을 넣고 있었던 셈이다.
479
+ * 요금은 벽시계의 15분으로 매겨지니 그 경계에 서야 견줄 수 있다.
480
+ *
481
+ * 부하를 먼저 만들고 마감한다 — 마감이 먼저면 마지막 값이 다음 구간으로 밀린다.
482
+ */
483
+ const at = this.nowMs();
484
+ this.deriveLoad(at);
485
+ this.closeDue(at);
425
486
  }
426
487
  getSnapshot() {
427
488
  const snap = super.getSnapshot();
@@ -439,6 +500,7 @@ export class EmsKernel extends FlowEngine {
439
500
  closed: this.closed.map(w => ({ ...w })),
440
501
  closedTotal: this.closedTotal,
441
502
  ...(this.peak ? { peakSince: { ...this.peak } } : {}),
503
+ ...(this.unknownEquipmentReports ? { unknownEquipment: this.unknownEquipmentReports } : {}),
442
504
  ...(contractKW !== undefined ? { contractKW } : {}),
443
505
  /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
444
506
  ...(this.flowRequests.size
@@ -37,3 +37,26 @@ export declare function isEnergyRecord(record: unknown): boolean;
37
37
  * 페이로드를 먼저 보고, 저널·시간여행은 봉투를 본다 — 둘이 다르면 같은 사실이 두 시각을 갖는다.
38
38
  */
39
39
  export declare function ingestEnergyRecords(records: EnergyRecord | EnergyRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
40
+ /** 정규 설비 에너지 레코드 — 커넥터가 이 모양으로 맞춰 준다(필드 이름이 계약이다). */
41
+ export interface EnergyEquipmentRecord {
42
+ equipmentId: string;
43
+ at?: string;
44
+ generatedKW?: number;
45
+ exportKW?: number;
46
+ soc?: number;
47
+ chargeKW?: number;
48
+ dischargeKW?: number;
49
+ curtailable?: boolean;
50
+ minKW?: number;
51
+ position?: string;
52
+ }
53
+ /** 이 레코드가 설비 에너지 상태인가 — 라우팅 판정을 한 곳에 둔다. */
54
+ export declare function isEnergyEquipmentRecord(record: unknown): boolean;
55
+ /**
56
+ * 설비 에너지 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 거부하고, 거부한 것은
57
+ * 이유와 함께 돌려준다.
58
+ *
59
+ * **값이 하나도 없는 레코드는 거부한다** — 계량과 다른 점이다. 계량은 「응답했다」는 사실 자체가
60
+ * 관측이지만(그래서 kW 없는 표본을 받는다), 여기서는 바꿀 상태가 없는 것을 사실로 적을 이유가 없다.
61
+ */
62
+ export declare function ingestEnergyEquipmentRecords(records: EnergyEquipmentRecord | EnergyEquipmentRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
@@ -89,3 +89,106 @@ export function ingestEnergyRecords(records, opts) {
89
89
  }
90
90
  return { accepted, rejected };
91
91
  }
92
+ /** 개폐 위치의 값 — 표준이 정한 넷. 그 밖의 낱말은 받지 않는다(뭉개면 없는 확신이 생긴다). */
93
+ const POSITIONS = new Set(['open', 'closed', 'intermediate', 'bad']);
94
+ /** 이 레코드가 설비 에너지 상태인가 — 라우팅 판정을 한 곳에 둔다. */
95
+ export function isEnergyEquipmentRecord(record) {
96
+ if (!record || typeof record !== 'object')
97
+ return false;
98
+ const r = record;
99
+ return typeof r.equipmentId === 'string' && r.equipmentId.trim().length > 0 && r.epc === undefined && r.meterId === undefined;
100
+ }
101
+ /**
102
+ * 설비 에너지 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 거부하고, 거부한 것은
103
+ * 이유와 함께 돌려준다.
104
+ *
105
+ * **값이 하나도 없는 레코드는 거부한다** — 계량과 다른 점이다. 계량은 「응답했다」는 사실 자체가
106
+ * 관측이지만(그래서 kW 없는 표본을 받는다), 여기서는 바꿀 상태가 없는 것을 사실로 적을 이유가 없다.
107
+ */
108
+ export function ingestEnergyEquipmentRecords(records, opts) {
109
+ const arr = Array.isArray(records) ? records : records ? [records] : [];
110
+ const accepted = [];
111
+ const rejected = [];
112
+ let seq = 0;
113
+ for (const record of arr) {
114
+ const errors = [];
115
+ const r = record;
116
+ const equipmentId = String(r?.equipmentId ?? '').trim();
117
+ if (!equipmentId)
118
+ errors.push('equipmentId 없음 — 어느 설비의 상태인지 모르는 값은 실을 수 없다');
119
+ const at = String(r?.at ?? '').trim() || opts.defaultEventTime;
120
+ const atMs = at ? Date.parse(at) : Number.NaN;
121
+ if (!Number.isFinite(atMs))
122
+ errors.push('at 없음/형식 오류 — 지금 시각으로 메우면 언제의 상태인지 알 수 없다');
123
+ const num = (v, name, min, max) => {
124
+ if (v === undefined || v === null || v === '')
125
+ return undefined;
126
+ const n = Number(v);
127
+ if (!Number.isFinite(n)) {
128
+ errors.push(`${name} 가 수가 아니다: ${JSON.stringify(v)}`);
129
+ return undefined;
130
+ }
131
+ if (min !== undefined && n < min) {
132
+ errors.push(`${name} 가 ${min} 보다 작다: ${n}`);
133
+ return undefined;
134
+ }
135
+ if (max !== undefined && n > max) {
136
+ errors.push(`${name} 가 ${max} 보다 크다: ${n}`);
137
+ return undefined;
138
+ }
139
+ return n;
140
+ };
141
+ const generatedKW = num(r?.generatedKW, 'generatedKW', 0);
142
+ const exportKW = num(r?.exportKW, 'exportKW');
143
+ /* 충전율은 백분율이다 — 범위를 벗어난 값은 받지 않는다(단위를 잘못 매핑한 커넥터를 조용히 통과시키면
144
+ 「배터리가 380% 찼다」가 화면에 뜬다). */
145
+ const soc = num(r?.soc, 'soc', 0, 100);
146
+ const chargeKW = num(r?.chargeKW, 'chargeKW', 0);
147
+ const dischargeKW = num(r?.dischargeKW, 'dischargeKW', 0);
148
+ const minKW = num(r?.minKW, 'minKW', 0);
149
+ let curtailable;
150
+ if (r?.curtailable !== undefined && r?.curtailable !== null) {
151
+ if (typeof r.curtailable !== 'boolean')
152
+ errors.push(`curtailable 가 참/거짓이 아니다: ${JSON.stringify(r.curtailable)}`);
153
+ else
154
+ curtailable = r.curtailable;
155
+ }
156
+ let position;
157
+ if (r?.position !== undefined && r?.position !== null && r?.position !== '') {
158
+ const p = String(r.position);
159
+ if (!POSITIONS.has(p))
160
+ errors.push(`position 이 표준 값이 아니다(open·closed·intermediate·bad): ${JSON.stringify(r.position)}`);
161
+ else
162
+ position = p;
163
+ }
164
+ const values = { generatedKW, exportKW, soc, chargeKW, dischargeKW, curtailable, minKW, position };
165
+ if (!errors.length && Object.values(values).every(v => v === undefined)) {
166
+ errors.push('바꿀 상태가 하나도 없다 — 값 없는 보고는 사실로 적을 것이 없다');
167
+ }
168
+ if (errors.length) {
169
+ rejected.push({ record, errors });
170
+ continue;
171
+ }
172
+ const eventTime = new Date(atMs).toISOString();
173
+ const data = {
174
+ equipmentId,
175
+ at: eventTime,
176
+ ...(generatedKW !== undefined ? { generatedKW } : {}),
177
+ ...(exportKW !== undefined ? { exportKW } : {}),
178
+ ...(soc !== undefined ? { soc } : {}),
179
+ ...(chargeKW !== undefined ? { chargeKW } : {}),
180
+ ...(dischargeKW !== undefined ? { dischargeKW } : {}),
181
+ ...(curtailable !== undefined ? { curtailable } : {}),
182
+ ...(minKW !== undefined ? { minKW } : {}),
183
+ ...(position !== undefined ? { position } : {})
184
+ };
185
+ accepted.push({
186
+ eventId: `${opts.tenantId}-energy-eq-${++seq}`,
187
+ eventType: ENERGY_EVENT.equipment,
188
+ eventTime,
189
+ tenantId: opts.tenantId,
190
+ data
191
+ });
192
+ }
193
+ return { accepted, rejected };
194
+ }
@@ -285,6 +285,20 @@ export declare abstract class FlowEngine implements TwinKernel {
285
285
  orders: Map<string, FlowOrder>;
286
286
  revision: number;
287
287
  clockMs: number;
288
+ /**
289
+ * 시각의 **기준점** — 이 트윈의 「지금」은 `originMs + clockMs` 다.
290
+ *
291
+ * ── 왜 고정 상수가 아닌가 (2026-08-15) ─────────────────────────────────────
292
+ * 기준점이 `BASE_EPOCH` 하나로 고정돼 있어서, 시뮬 트윈의 저널 시각이 **재기동마다 되감겼다.**
293
+ * 며칠을 돈 트윈의 사건들이 전부 `2026-01-01T00:00:xx` 에 몰려 있었고, 그래서 「언제 있었던 일인가」를
294
+ * 되짚을 수 없었다. 실 시각으로 창을 자르는 성과·이력 질의에는 그 트윈이 아예 보이지 않는다.
295
+ *
296
+ * 에너지에서는 더 아프다: 수요 구간은 벽시계의 15분에 맞춰 끊어야 요금과 견줄 수 있는데, 기준점이
297
+ * 가짜면 그 구간도 가짜 시각에 선다.
298
+ *
299
+ * 기본값은 그대로 둔다(시험·결정성). 살아 있는 시뮬 트윈을 세우는 호스트가 실제 시각으로 옮긴다.
300
+ */
301
+ protected originMs: number;
288
302
  protected rng: Rng;
289
303
  protected policy: AllocationPolicy;
290
304
  /** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
@@ -452,6 +466,15 @@ export declare abstract class FlowEngine implements TwinKernel {
452
466
  * 아직 아무것도 못 들었으면 시뮬 기준으로 떨어진다(그때는 판정할 사실도 없다).
453
467
  */
454
468
  protected nowMs(): number;
469
+ /**
470
+ * 시각 기준점을 세운다 — **살아 있는 시뮬 트윈은 실제 시각 위에서 돈다.**
471
+ *
472
+ * 세우는 쪽(호스트)이 기동 순간의 실제 시각을 준다. 재기동하면 그만큼 앞으로 뛰는데, 그것이 사실이다
473
+ * (그 사이 이 트윈은 돌지 않았고, 저널의 빈 구간이 그 사실을 말한다).
474
+ *
475
+ * 이미 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋나므로 **기동 직후에만** 부른다.
476
+ */
477
+ setClockOrigin(originMs: number): void;
455
478
  protected now(): string;
456
479
  /**
457
480
  * 자극이 선언한 **약속**을 오더 필드로 — 표준 `OperationsRequest.Priority`·`StartTime`·`EndTime`.
@@ -207,6 +207,23 @@ export function computeOee(c, nowMs) {
207
207
  runMs: c.runMs, setupMs: c.setupMs, downMs: c.downMs, idleMs: Math.max(0, uptime - c.runMs), goodCount: c.goodCount, scrapCount: c.scrapCount
208
208
  };
209
209
  }
210
+ /**
211
+ * 에너지 능력이 선언한 상태 필드를 스냅샷에 싣는다 — **온 것만.**
212
+ *
213
+ * 능력 계약이 이 필드들을 관측으로 선언해 두었는데 투영이 싣지 않으면, 값이 커널 안에서만 살고
214
+ * 화면·성과·저널 어디에도 나타나지 않는다(실제로 그랬다: 값을 적었는데 스냅샷에 없었다).
215
+ *
216
+ * 값이 없으면 **키를 만들지 않는다** — 에너지에서 「모른다」와 「0 이다」는 요금이 갈리는 차이다.
217
+ */
218
+ function energyFieldsOf(m) {
219
+ const out = {};
220
+ const src = m;
221
+ for (const k of ['generatedKW', 'exportKW', 'soc', 'chargeKW', 'dischargeKW', 'curtailable', 'minKW', 'position']) {
222
+ if (src[k] !== undefined)
223
+ out[k] = src[k];
224
+ }
225
+ return out;
226
+ }
210
227
  export class FlowEngine {
211
228
  tenantId;
212
229
  locations = new Map();
@@ -227,6 +244,20 @@ export class FlowEngine {
227
244
  orders = new Map();
228
245
  revision = 0;
229
246
  clockMs = 0;
247
+ /**
248
+ * 시각의 **기준점** — 이 트윈의 「지금」은 `originMs + clockMs` 다.
249
+ *
250
+ * ── 왜 고정 상수가 아닌가 (2026-08-15) ─────────────────────────────────────
251
+ * 기준점이 `BASE_EPOCH` 하나로 고정돼 있어서, 시뮬 트윈의 저널 시각이 **재기동마다 되감겼다.**
252
+ * 며칠을 돈 트윈의 사건들이 전부 `2026-01-01T00:00:xx` 에 몰려 있었고, 그래서 「언제 있었던 일인가」를
253
+ * 되짚을 수 없었다. 실 시각으로 창을 자르는 성과·이력 질의에는 그 트윈이 아예 보이지 않는다.
254
+ *
255
+ * 에너지에서는 더 아프다: 수요 구간은 벽시계의 15분에 맞춰 끊어야 요금과 견줄 수 있는데, 기준점이
256
+ * 가짜면 그 구간도 가짜 시각에 선다.
257
+ *
258
+ * 기본값은 그대로 둔다(시험·결정성). 살아 있는 시뮬 트윈을 세우는 호스트가 실제 시각으로 옮긴다.
259
+ */
260
+ originMs = BASE_EPOCH;
230
261
  rng = mulberry32(1);
231
262
  policy;
232
263
  /** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
@@ -843,7 +874,7 @@ export class FlowEngine {
843
874
  }),
844
875
  items: [...this.items.values()].map(i => this.itemState(i)),
845
876
  equipment: [...this.equipment.values()].map(m => {
846
- const s = { id: m.id, kind: m.kind, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}) };
877
+ const s = { id: m.id, kind: m.kind, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}), ...energyFieldsOf(m) };
847
878
  const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
848
879
  if (t && t.status === 'in-progress' && t.intent !== 'process')
849
880
  s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
@@ -1016,7 +1047,20 @@ export class FlowEngine {
1016
1047
  */
1017
1048
  nowMs() {
1018
1049
  const observed = this.observeMode ? this.observer?.lastObservedMs : undefined;
1019
- return observed ?? BASE_EPOCH + this.clockMs;
1050
+ return observed ?? this.originMs + this.clockMs;
1051
+ }
1052
+ /**
1053
+ * 시각 기준점을 세운다 — **살아 있는 시뮬 트윈은 실제 시각 위에서 돈다.**
1054
+ *
1055
+ * 세우는 쪽(호스트)이 기동 순간의 실제 시각을 준다. 재기동하면 그만큼 앞으로 뛰는데, 그것이 사실이다
1056
+ * (그 사이 이 트윈은 돌지 않았고, 저널의 빈 구간이 그 사실을 말한다).
1057
+ *
1058
+ * 이미 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋나므로 **기동 직후에만** 부른다.
1059
+ */
1060
+ setClockOrigin(originMs) {
1061
+ if (!Number.isFinite(originMs))
1062
+ throw new Error('clock origin must be a finite epoch millisecond value');
1063
+ this.originMs = originMs;
1020
1064
  }
1021
1065
  now() { return new Date(this.nowMs()).toISOString(); }
1022
1066
  /**
package/dist/index.d.ts CHANGED
@@ -29,8 +29,8 @@ export { WmsKernel } from './kernel.ts';
29
29
  export { YmsKernel } from './yms-kernel.ts';
30
30
  export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from './mes-kernel.ts';
31
31
  export { EmsKernel, DEMAND_WINDOW_MS, demandWindowStart } from './ems-kernel.ts';
32
- export { ingestEnergyRecords, isEnergyRecord } from './energy-ingest.ts';
32
+ export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord } from './energy-ingest.ts';
33
33
  export { attributeEnergy, energyIntensity, energyOfWindows } from './energy-attribution.ts';
34
34
  export type { AttributionBasis, AttributionResult, EnergyConsumer, EnergyPool, EnergyShare, IntensityInput, IntensityResult, IntensityDenominator, WeightKind, WindowedEnergy } from './energy-attribution.ts';
35
- export type { EnergyRecord, EnergyIngestOptions, EnergyIngestResult } from './energy-ingest.ts';
35
+ export type { EnergyRecord, EnergyEquipmentRecord, EnergyIngestOptions, EnergyIngestResult } from './energy-ingest.ts';
36
36
  export * from './vocabulary.ts';
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ export { WmsKernel } from "./kernel.js";
29
29
  export { YmsKernel } from "./yms-kernel.js";
30
30
  export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from "./mes-kernel.js";
31
31
  export { EmsKernel, DEMAND_WINDOW_MS, demandWindowStart } from "./ems-kernel.js";
32
- export { ingestEnergyRecords, isEnergyRecord } from "./energy-ingest.js";
32
+ export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord } from "./energy-ingest.js";
33
33
  export { attributeEnergy, energyIntensity, energyOfWindows } from "./energy-attribution.js";
34
34
  /* 에너지 상태 타입은 **계약**에 있다(상태의 모양은 계약이다) — contract 의 `export *` 가 이미 낸다. */
35
35
  export * from "./vocabulary.js";
@@ -107,7 +107,9 @@ __export(index_exports, {
107
107
  inWorkCalendar: () => inWorkCalendar,
108
108
  inWorkCalendarAt: () => inWorkCalendarAt,
109
109
  ingest: () => ingest,
110
+ ingestEnergyEquipmentRecords: () => ingestEnergyEquipmentRecords,
110
111
  ingestEnergyRecords: () => ingestEnergyRecords,
112
+ isEnergyEquipmentRecord: () => isEnergyEquipmentRecord,
111
113
  isEnergyRecord: () => isEnergyRecord,
112
114
  isEquipmentLevel: () => isEquipmentLevel,
113
115
  isoDurationHours: () => isoDurationHours,
@@ -483,6 +485,12 @@ var OP_EVENT = {
483
485
  var ENERGY_EVENT = {
484
486
  /** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
485
487
  measured: "energy.measured",
488
+ /**
489
+ * 설비의 에너지 상태 — 발전·저장·감축 여지·개폐 위치.
490
+ *
491
+ * 계량과 다른 사건으로 둔다: 계량은 구간에 **누적**되고 이것은 그 설비의 **지금**을 바꾼다.
492
+ */
493
+ equipment: "energy.equipment",
486
494
  /** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
487
495
  demandWindow: "energy.demand.window",
488
496
  /**
@@ -2789,6 +2797,14 @@ function computeOee(c, nowMs) {
2789
2797
  scrapCount: c.scrapCount
2790
2798
  };
2791
2799
  }
2800
+ function energyFieldsOf(m) {
2801
+ const out = {};
2802
+ const src = m;
2803
+ for (const k of ["generatedKW", "exportKW", "soc", "chargeKW", "dischargeKW", "curtailable", "minKW", "position"]) {
2804
+ if (src[k] !== void 0) out[k] = src[k];
2805
+ }
2806
+ return out;
2807
+ }
2792
2808
  var FlowEngine = class {
2793
2809
  tenantId;
2794
2810
  locations = /* @__PURE__ */ new Map();
@@ -2809,6 +2825,20 @@ var FlowEngine = class {
2809
2825
  orders = /* @__PURE__ */ new Map();
2810
2826
  revision = 0;
2811
2827
  clockMs = 0;
2828
+ /**
2829
+ * 시각의 **기준점** — 이 트윈의 「지금」은 `originMs + clockMs` 다.
2830
+ *
2831
+ * ── 왜 고정 상수가 아닌가 (2026-08-15) ─────────────────────────────────────
2832
+ * 기준점이 `BASE_EPOCH` 하나로 고정돼 있어서, 시뮬 트윈의 저널 시각이 **재기동마다 되감겼다.**
2833
+ * 며칠을 돈 트윈의 사건들이 전부 `2026-01-01T00:00:xx` 에 몰려 있었고, 그래서 「언제 있었던 일인가」를
2834
+ * 되짚을 수 없었다. 실 시각으로 창을 자르는 성과·이력 질의에는 그 트윈이 아예 보이지 않는다.
2835
+ *
2836
+ * 에너지에서는 더 아프다: 수요 구간은 벽시계의 15분에 맞춰 끊어야 요금과 견줄 수 있는데, 기준점이
2837
+ * 가짜면 그 구간도 가짜 시각에 선다.
2838
+ *
2839
+ * 기본값은 그대로 둔다(시험·결정성). 살아 있는 시뮬 트윈을 세우는 호스트가 실제 시각으로 옮긴다.
2840
+ */
2841
+ originMs = BASE_EPOCH;
2812
2842
  rng = mulberry32(1);
2813
2843
  policy;
2814
2844
  /** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
@@ -3375,7 +3405,7 @@ var FlowEngine = class {
3375
3405
  }),
3376
3406
  items: [...this.items.values()].map((i) => this.itemState(i)),
3377
3407
  equipment: [...this.equipment.values()].map((m) => {
3378
- const s = { id: m.id, kind: m.kind, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: "master", ...this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}, ...this.effectivePart(m), ...this.shiftOf(m) ? { shift: this.shiftOf(m) } : {} };
3408
+ const s = { id: m.id, kind: m.kind, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: "master", ...this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}, ...this.effectivePart(m), ...this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}, ...energyFieldsOf(m) };
3379
3409
  const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
3380
3410
  if (t && t.status === "in-progress" && t.intent !== "process") s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
3381
3411
  return s;
@@ -3529,7 +3559,19 @@ var FlowEngine = class {
3529
3559
  */
3530
3560
  nowMs() {
3531
3561
  const observed = this.observeMode ? this.observer?.lastObservedMs : void 0;
3532
- return observed ?? BASE_EPOCH + this.clockMs;
3562
+ return observed ?? this.originMs + this.clockMs;
3563
+ }
3564
+ /**
3565
+ * 시각 기준점을 세운다 — **살아 있는 시뮬 트윈은 실제 시각 위에서 돈다.**
3566
+ *
3567
+ * 세우는 쪽(호스트)이 기동 순간의 실제 시각을 준다. 재기동하면 그만큼 앞으로 뛰는데, 그것이 사실이다
3568
+ * (그 사이 이 트윈은 돌지 않았고, 저널의 빈 구간이 그 사실을 말한다).
3569
+ *
3570
+ * 이미 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋나므로 **기동 직후에만** 부른다.
3571
+ */
3572
+ setClockOrigin(originMs) {
3573
+ if (!Number.isFinite(originMs)) throw new Error("clock origin must be a finite epoch millisecond value");
3574
+ this.originMs = originMs;
3533
3575
  }
3534
3576
  now() {
3535
3577
  return new Date(this.nowMs()).toISOString();
@@ -5730,8 +5772,48 @@ var EmsKernel = class extends FlowEngine {
5730
5772
  this.ingestMeasured(envelope);
5731
5773
  return;
5732
5774
  }
5775
+ if (envelope.eventType === ENERGY_EVENT.equipment) {
5776
+ this.applyEquipmentEnergy(envelope);
5777
+ return;
5778
+ }
5733
5779
  super.apply(envelope);
5734
5780
  }
5781
+ /**
5782
+ * 설비가 낸 자기 에너지 상태를 그 설비에 적는다 — 발전·저장·감축 여지·개폐 위치.
5783
+ *
5784
+ * ── 계량과 갈라 두는 이유 ──────────────────────────────────────────────────
5785
+ * 계량은 **수요 구간에 누적**되는 표본이고, 이것들은 그 설비의 **지금**이다. 같은 문으로 넣으면
5786
+ * 태양광 발전량이 부하 구간에 더해지거나 충전율이 요금 판정에 섞인다.
5787
+ *
5788
+ * 모르는 설비의 상태는 **버리지 않고 세어 둔다** — 원천에 우리 모델이 모르는 설비가 있다는 것은
5789
+ * 그 자체로 알아야 할 사실이다(상위 커널의 `unhandled` 와 같은 규율).
5790
+ *
5791
+ * 온 값만 덮는다: 보내지 않은 필드는 그대로 둔다(없는 값을 0 으로 만들지 않는다).
5792
+ */
5793
+ applyEquipmentEnergy(envelope) {
5794
+ const d = envelope.data;
5795
+ const id = String(d?.equipmentId ?? "").trim();
5796
+ const eq = id ? this.equipment.get(id) : void 0;
5797
+ if (!eq) {
5798
+ this.unknownEquipmentReports++;
5799
+ return;
5800
+ }
5801
+ const put = (k) => {
5802
+ const v = d[k];
5803
+ if (v !== void 0) eq[k] = v;
5804
+ };
5805
+ put("generatedKW");
5806
+ put("exportKW");
5807
+ put("soc");
5808
+ put("chargeKW");
5809
+ put("dischargeKW");
5810
+ put("curtailable");
5811
+ put("minKW");
5812
+ put("position");
5813
+ this.revision++;
5814
+ }
5815
+ /** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
5816
+ unknownEquipmentReports = 0;
5735
5817
  ingestMeasured(envelope) {
5736
5818
  const d = envelope.data;
5737
5819
  const atMs = [String(d?.at ?? ""), String(envelope.eventTime ?? "")].map((v) => Date.parse(v)).find((v) => Number.isFinite(v));
@@ -5802,11 +5884,22 @@ var EmsKernel = class extends FlowEngine {
5802
5884
  * kW 를 못 읽은 경우(계기 오류·필드 누락)가 실제로 있고, 그것은 「아무것도 오지 않았다」와
5803
5885
  * 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
5804
5886
  */
5805
- ...w.samples === 0 ? { observedAbsence: "no-load-samples" } : {}
5887
+ ...w.samples === 0 ? { observedAbsence: "no-load-samples" } : {},
5888
+ /*
5889
+ * **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
5890
+ * (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
5891
+ */
5892
+ ...w.derived ? { derived: true } : {}
5806
5893
  });
5807
5894
  if (w.maxKW !== void 0 && (this.peak === void 0 || w.maxKW > this.peak.kW)) {
5808
5895
  this.peak = { kW: w.maxKW, windowStartMs: w.startMs };
5809
- this.emitOp(ENERGY_EVENT.peak, { kW: w.maxKW, windowStartMs: w.startMs, ...w.contractKW !== void 0 ? { contractKW: w.contractKW } : {} });
5896
+ this.emitOp(ENERGY_EVENT.peak, {
5897
+ kW: w.maxKW,
5898
+ windowStartMs: w.startMs,
5899
+ ...w.contractKW !== void 0 ? { contractKW: w.contractKW } : {},
5900
+ /* 피크도 그 구간에서 왔다 — 구간이 만든 값이면 피크도 만든 값이다. */
5901
+ ...w.derived ? { derived: true } : {}
5902
+ });
5810
5903
  }
5811
5904
  this.open = void 0;
5812
5905
  }
@@ -5926,8 +6019,9 @@ var EmsKernel = class extends FlowEngine {
5926
6019
  }
5927
6020
  tick(dtMs) {
5928
6021
  super.tick(dtMs);
5929
- this.deriveLoad(this.clockMs);
5930
- this.closeDue(this.clockMs);
6022
+ const at = this.nowMs();
6023
+ this.deriveLoad(at);
6024
+ this.closeDue(at);
5931
6025
  }
5932
6026
  getSnapshot() {
5933
6027
  const snap = super.getSnapshot();
@@ -5943,6 +6037,7 @@ var EmsKernel = class extends FlowEngine {
5943
6037
  closed: this.closed.map((w) => ({ ...w })),
5944
6038
  closedTotal: this.closedTotal,
5945
6039
  ...this.peak ? { peakSince: { ...this.peak } } : {},
6040
+ ...this.unknownEquipmentReports ? { unknownEquipment: this.unknownEquipmentReports } : {},
5946
6041
  ...contractKW !== void 0 ? { contractKW } : {},
5947
6042
  /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
5948
6043
  ...this.flowRequests.size ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) } : {}
@@ -6005,6 +6100,90 @@ function ingestEnergyRecords(records, opts) {
6005
6100
  }
6006
6101
  return { accepted, rejected };
6007
6102
  }
6103
+ var POSITIONS = /* @__PURE__ */ new Set(["open", "closed", "intermediate", "bad"]);
6104
+ function isEnergyEquipmentRecord(record) {
6105
+ if (!record || typeof record !== "object") return false;
6106
+ const r = record;
6107
+ return typeof r.equipmentId === "string" && r.equipmentId.trim().length > 0 && r.epc === void 0 && r.meterId === void 0;
6108
+ }
6109
+ function ingestEnergyEquipmentRecords(records, opts) {
6110
+ const arr = Array.isArray(records) ? records : records ? [records] : [];
6111
+ const accepted = [];
6112
+ const rejected = [];
6113
+ let seq = 0;
6114
+ for (const record of arr) {
6115
+ const errors = [];
6116
+ const r = record;
6117
+ const equipmentId = String(r?.equipmentId ?? "").trim();
6118
+ if (!equipmentId) errors.push("equipmentId \uC5C6\uC74C \u2014 \uC5B4\uB290 \uC124\uBE44\uC758 \uC0C1\uD0DC\uC778\uC9C0 \uBAA8\uB974\uB294 \uAC12\uC740 \uC2E4\uC744 \uC218 \uC5C6\uB2E4");
6119
+ const at = String(r?.at ?? "").trim() || opts.defaultEventTime;
6120
+ const atMs = at ? Date.parse(at) : Number.NaN;
6121
+ if (!Number.isFinite(atMs)) errors.push("at \uC5C6\uC74C/\uD615\uC2DD \uC624\uB958 \u2014 \uC9C0\uAE08 \uC2DC\uAC01\uC73C\uB85C \uBA54\uC6B0\uBA74 \uC5B8\uC81C\uC758 \uC0C1\uD0DC\uC778\uC9C0 \uC54C \uC218 \uC5C6\uB2E4");
6122
+ const num = (v, name, min, max) => {
6123
+ if (v === void 0 || v === null || v === "") return void 0;
6124
+ const n = Number(v);
6125
+ if (!Number.isFinite(n)) {
6126
+ errors.push(`${name} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
6127
+ return void 0;
6128
+ }
6129
+ if (min !== void 0 && n < min) {
6130
+ errors.push(`${name} \uAC00 ${min} \uBCF4\uB2E4 \uC791\uB2E4: ${n}`);
6131
+ return void 0;
6132
+ }
6133
+ if (max !== void 0 && n > max) {
6134
+ errors.push(`${name} \uAC00 ${max} \uBCF4\uB2E4 \uD06C\uB2E4: ${n}`);
6135
+ return void 0;
6136
+ }
6137
+ return n;
6138
+ };
6139
+ const generatedKW = num(r?.generatedKW, "generatedKW", 0);
6140
+ const exportKW = num(r?.exportKW, "exportKW");
6141
+ const soc = num(r?.soc, "soc", 0, 100);
6142
+ const chargeKW = num(r?.chargeKW, "chargeKW", 0);
6143
+ const dischargeKW = num(r?.dischargeKW, "dischargeKW", 0);
6144
+ const minKW = num(r?.minKW, "minKW", 0);
6145
+ let curtailable;
6146
+ if (r?.curtailable !== void 0 && r?.curtailable !== null) {
6147
+ if (typeof r.curtailable !== "boolean") errors.push(`curtailable \uAC00 \uCC38/\uAC70\uC9D3\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(r.curtailable)}`);
6148
+ else curtailable = r.curtailable;
6149
+ }
6150
+ let position;
6151
+ if (r?.position !== void 0 && r?.position !== null && r?.position !== "") {
6152
+ const p = String(r.position);
6153
+ if (!POSITIONS.has(p)) errors.push(`position \uC774 \uD45C\uC900 \uAC12\uC774 \uC544\uB2C8\uB2E4(open\xB7closed\xB7intermediate\xB7bad): ${JSON.stringify(r.position)}`);
6154
+ else position = p;
6155
+ }
6156
+ const values = { generatedKW, exportKW, soc, chargeKW, dischargeKW, curtailable, minKW, position };
6157
+ if (!errors.length && Object.values(values).every((v) => v === void 0)) {
6158
+ errors.push("\uBC14\uAFC0 \uC0C1\uD0DC\uAC00 \uD558\uB098\uB3C4 \uC5C6\uB2E4 \u2014 \uAC12 \uC5C6\uB294 \uBCF4\uACE0\uB294 \uC0AC\uC2E4\uB85C \uC801\uC744 \uAC83\uC774 \uC5C6\uB2E4");
6159
+ }
6160
+ if (errors.length) {
6161
+ rejected.push({ record, errors });
6162
+ continue;
6163
+ }
6164
+ const eventTime = new Date(atMs).toISOString();
6165
+ const data = {
6166
+ equipmentId,
6167
+ at: eventTime,
6168
+ ...generatedKW !== void 0 ? { generatedKW } : {},
6169
+ ...exportKW !== void 0 ? { exportKW } : {},
6170
+ ...soc !== void 0 ? { soc } : {},
6171
+ ...chargeKW !== void 0 ? { chargeKW } : {},
6172
+ ...dischargeKW !== void 0 ? { dischargeKW } : {},
6173
+ ...curtailable !== void 0 ? { curtailable } : {},
6174
+ ...minKW !== void 0 ? { minKW } : {},
6175
+ ...position !== void 0 ? { position } : {}
6176
+ };
6177
+ accepted.push({
6178
+ eventId: `${opts.tenantId}-energy-eq-${++seq}`,
6179
+ eventType: ENERGY_EVENT.equipment,
6180
+ eventTime,
6181
+ tenantId: opts.tenantId,
6182
+ data
6183
+ });
6184
+ }
6185
+ return { accepted, rejected };
6186
+ }
6008
6187
 
6009
6188
  // src/energy-attribution.ts
6010
6189
  var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
@@ -6288,7 +6467,9 @@ function retiredVocabularyIn(line) {
6288
6467
  inWorkCalendar,
6289
6468
  inWorkCalendarAt,
6290
6469
  ingest,
6470
+ ingestEnergyEquipmentRecords,
6291
6471
  ingestEnergyRecords,
6472
+ isEnergyEquipmentRecord,
6292
6473
  isEnergyRecord,
6293
6474
  isEquipmentLevel,
6294
6475
  isoDurationHours,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
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": {