@operato/twin-kernel 0.7.5 → 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). */
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`) — 봉투 시각은 전달 시각일 수 있다.
@@ -459,6 +500,7 @@ export class EmsKernel extends FlowEngine {
459
500
  closed: this.closed.map(w => ({ ...w })),
460
501
  closedTotal: this.closedTotal,
461
502
  ...(this.peak ? { peakSince: { ...this.peak } } : {}),
503
+ ...(this.unknownEquipmentReports ? { unknownEquipment: this.unknownEquipmentReports } : {}),
462
504
  ...(contractKW !== undefined ? { contractKW } : {}),
463
505
  /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
464
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
+ }
@@ -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();
@@ -857,7 +874,7 @@ export class FlowEngine {
857
874
  }),
858
875
  items: [...this.items.values()].map(i => this.itemState(i)),
859
876
  equipment: [...this.equipment.values()].map(m => {
860
- 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) };
861
878
  const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
862
879
  if (t && t.status === 'in-progress' && t.intent !== 'process')
863
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 };
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();
@@ -3389,7 +3405,7 @@ var FlowEngine = class {
3389
3405
  }),
3390
3406
  items: [...this.items.values()].map((i) => this.itemState(i)),
3391
3407
  equipment: [...this.equipment.values()].map((m) => {
3392
- 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) };
3393
3409
  const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
3394
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 };
3395
3411
  return s;
@@ -5756,8 +5772,48 @@ var EmsKernel = class extends FlowEngine {
5756
5772
  this.ingestMeasured(envelope);
5757
5773
  return;
5758
5774
  }
5775
+ if (envelope.eventType === ENERGY_EVENT.equipment) {
5776
+ this.applyEquipmentEnergy(envelope);
5777
+ return;
5778
+ }
5759
5779
  super.apply(envelope);
5760
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;
5761
5817
  ingestMeasured(envelope) {
5762
5818
  const d = envelope.data;
5763
5819
  const atMs = [String(d?.at ?? ""), String(envelope.eventTime ?? "")].map((v) => Date.parse(v)).find((v) => Number.isFinite(v));
@@ -5981,6 +6037,7 @@ var EmsKernel = class extends FlowEngine {
5981
6037
  closed: this.closed.map((w) => ({ ...w })),
5982
6038
  closedTotal: this.closedTotal,
5983
6039
  ...this.peak ? { peakSince: { ...this.peak } } : {},
6040
+ ...this.unknownEquipmentReports ? { unknownEquipment: this.unknownEquipmentReports } : {},
5984
6041
  ...contractKW !== void 0 ? { contractKW } : {},
5985
6042
  /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
5986
6043
  ...this.flowRequests.size ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) } : {}
@@ -6043,6 +6100,90 @@ function ingestEnergyRecords(records, opts) {
6043
6100
  }
6044
6101
  return { accepted, rejected };
6045
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
+ }
6046
6187
 
6047
6188
  // src/energy-attribution.ts
6048
6189
  var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
@@ -6326,7 +6467,9 @@ function retiredVocabularyIn(line) {
6326
6467
  inWorkCalendar,
6327
6468
  inWorkCalendarAt,
6328
6469
  ingest,
6470
+ ingestEnergyEquipmentRecords,
6329
6471
  ingestEnergyRecords,
6472
+ isEnergyEquipmentRecord,
6330
6473
  isEnergyRecord,
6331
6474
  isEquipmentLevel,
6332
6475
  isoDurationHours,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.5",
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": {