@operato/twin-kernel 0.7.5 → 0.7.7

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,26 @@ 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';
857
+ /**
858
+ * 위 값들을 **언제 들었나** — 값과 함께 다녀야 하는 사실.
859
+ *
860
+ * 에너지에서 가장 위험한 화면은 멈춘 값을 지금 값처럼 보여 주는 것이다. 통신이 끊긴 설비는 마지막
861
+ * 상태를 그대로 들고 있고, 시각이 없으면 화면은 그것을 「지금」으로 그린다. 값만 적고 시각을
862
+ * 버리면 그 판단을 아무도 할 수 없다.
863
+ */
864
+ measuredAt?: ISOTime;
845
865
  /**
846
866
  * 유효 기간 밖이라 이 시각의 모델에 없다 — **네 번째 이유**(§Effectivity).
847
867
  * 도입 예정(`not-yet`)과 폐기(`expired`)를 구별한다. 유효하면 값이 없다.
@@ -1218,7 +1238,13 @@ export interface EnergyState {
1218
1238
  /** 관측 시작 이후 최대 수요 — 월 경계는 여기서 정하지 않는다(위 주석). */
1219
1239
  peakSince?: {
1220
1240
  kW: number;
1221
- windowStartMs: number;
1241
+ windowStartMs: number; /**
1242
+ * 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — **버린 것을 세어 둔다.**
1243
+ *
1244
+ * 원천에 우리가 모르는 설비가 있다는 것은 그 자체로 알아야 할 사실이다(모델이 낡았거나 매핑이
1245
+ * 틀렸다). 조용히 버리면 「값이 왜 안 보이지」로만 남는다.
1246
+ */
1247
+ unknownEquipment?: number;
1222
1248
  };
1223
1249
  /** 현장이 선언한 계약전력(자리 속성) — 없으면 계약 대비 판정을 하지 않는다. */
1224
1250
  contractKW?: number;
@@ -1428,6 +1454,12 @@ export declare const OP_EVENT: {
1428
1454
  export declare const ENERGY_EVENT: {
1429
1455
  /** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
1430
1456
  readonly measured: "energy.measured";
1457
+ /**
1458
+ * 설비의 에너지 상태 — 발전·저장·감축 여지·개폐 위치.
1459
+ *
1460
+ * 계량과 다른 사건으로 둔다: 계량은 구간에 **누적**되고 이것은 그 설비의 **지금**을 바꾼다.
1461
+ */
1462
+ readonly equipment: "energy.equipment";
1431
1463
  /** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
1432
1464
  readonly demandWindow: "energy.demand.window";
1433
1465
  /**
@@ -1455,6 +1487,27 @@ export declare const ENERGY_EVENT: {
1455
1487
  readonly drSuggested: "energy.dr.suggested";
1456
1488
  };
1457
1489
  export type EnergyEventType = (typeof ENERGY_EVENT)[keyof typeof ENERGY_EVENT];
1490
+ /**
1491
+ * 설비가 낸 **자기 에너지 상태** — 계량이 아닌 능력들의 값.
1492
+ *
1493
+ * 계량(`energy.measured`)과 갈라 두는 이유: 계량은 **구간에 누적되는 표본**이고, 이것들은 **그 설비의
1494
+ * 지금 상태**다. 같은 문으로 넣으면 발전량이 부하 구간에 더해지거나 충전율이 요금 판정에 섞인다.
1495
+ *
1496
+ * 한 봉투에 여러 능력의 값이 함께 올 수 있다(원 시스템이 한 번에 준다) — 각 값은 그것을 선언한 능력의
1497
+ * 필드로만 들어간다. 없는 값은 **보내지 않는다**(0 을 보내면 「그렇게 측정됐다」가 된다).
1498
+ */
1499
+ export interface EnergyEquipmentData {
1500
+ equipmentId: string;
1501
+ at: ISOTime;
1502
+ generatedKW?: number;
1503
+ exportKW?: number;
1504
+ soc?: number;
1505
+ chargeKW?: number;
1506
+ dischargeKW?: number;
1507
+ curtailable?: boolean;
1508
+ minKW?: number;
1509
+ position?: 'open' | 'closed' | 'intermediate' | 'bad';
1510
+ }
1458
1511
  /** 계량 도착의 실린 값 — 계량 지점 하나의 한 시점. */
1459
1512
  export interface EnergyMeasuredData {
1460
1513
  /** 계량 지점(설비 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,52 @@ 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
+ /* **값과 함께 시각을 남긴다** — 없으면 화면이 멈춘 값을 지금 값으로 그린다(계량 지점과 같은 규율). */
216
+ if (d?.at)
217
+ eq.measuredAt = d.at;
218
+ this.revision++;
219
+ }
220
+ /** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
221
+ unknownEquipmentReports = 0;
178
222
  ingestMeasured(envelope) {
179
223
  const d = envelope.data;
180
224
  /* 시각은 **계측이 말한 것**이 먼저다(`data.at`) — 봉투 시각은 전달 시각일 수 있다.
@@ -459,6 +503,7 @@ export class EmsKernel extends FlowEngine {
459
503
  closed: this.closed.map(w => ({ ...w })),
460
504
  closedTotal: this.closedTotal,
461
505
  ...(this.peak ? { peakSince: { ...this.peak } } : {}),
506
+ ...(this.unknownEquipmentReports ? { unknownEquipment: this.unknownEquipmentReports } : {}),
462
507
  ...(contractKW !== undefined ? { contractKW } : {}),
463
508
  /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
464
509
  ...(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', 'measuredAt']) {
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", "measuredAt"]) {
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,49 @@ 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
+ if (d?.at) eq.measuredAt = d.at;
5814
+ this.revision++;
5815
+ }
5816
+ /** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
5817
+ unknownEquipmentReports = 0;
5761
5818
  ingestMeasured(envelope) {
5762
5819
  const d = envelope.data;
5763
5820
  const atMs = [String(d?.at ?? ""), String(envelope.eventTime ?? "")].map((v) => Date.parse(v)).find((v) => Number.isFinite(v));
@@ -5981,6 +6038,7 @@ var EmsKernel = class extends FlowEngine {
5981
6038
  closed: this.closed.map((w) => ({ ...w })),
5982
6039
  closedTotal: this.closedTotal,
5983
6040
  ...this.peak ? { peakSince: { ...this.peak } } : {},
6041
+ ...this.unknownEquipmentReports ? { unknownEquipment: this.unknownEquipmentReports } : {},
5984
6042
  ...contractKW !== void 0 ? { contractKW } : {},
5985
6043
  /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
5986
6044
  ...this.flowRequests.size ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) } : {}
@@ -6043,6 +6101,90 @@ function ingestEnergyRecords(records, opts) {
6043
6101
  }
6044
6102
  return { accepted, rejected };
6045
6103
  }
6104
+ var POSITIONS = /* @__PURE__ */ new Set(["open", "closed", "intermediate", "bad"]);
6105
+ function isEnergyEquipmentRecord(record) {
6106
+ if (!record || typeof record !== "object") return false;
6107
+ const r = record;
6108
+ return typeof r.equipmentId === "string" && r.equipmentId.trim().length > 0 && r.epc === void 0 && r.meterId === void 0;
6109
+ }
6110
+ function ingestEnergyEquipmentRecords(records, opts) {
6111
+ const arr = Array.isArray(records) ? records : records ? [records] : [];
6112
+ const accepted = [];
6113
+ const rejected = [];
6114
+ let seq = 0;
6115
+ for (const record of arr) {
6116
+ const errors = [];
6117
+ const r = record;
6118
+ const equipmentId = String(r?.equipmentId ?? "").trim();
6119
+ 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");
6120
+ const at = String(r?.at ?? "").trim() || opts.defaultEventTime;
6121
+ const atMs = at ? Date.parse(at) : Number.NaN;
6122
+ 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");
6123
+ const num = (v, name, min, max) => {
6124
+ if (v === void 0 || v === null || v === "") return void 0;
6125
+ const n = Number(v);
6126
+ if (!Number.isFinite(n)) {
6127
+ errors.push(`${name} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
6128
+ return void 0;
6129
+ }
6130
+ if (min !== void 0 && n < min) {
6131
+ errors.push(`${name} \uAC00 ${min} \uBCF4\uB2E4 \uC791\uB2E4: ${n}`);
6132
+ return void 0;
6133
+ }
6134
+ if (max !== void 0 && n > max) {
6135
+ errors.push(`${name} \uAC00 ${max} \uBCF4\uB2E4 \uD06C\uB2E4: ${n}`);
6136
+ return void 0;
6137
+ }
6138
+ return n;
6139
+ };
6140
+ const generatedKW = num(r?.generatedKW, "generatedKW", 0);
6141
+ const exportKW = num(r?.exportKW, "exportKW");
6142
+ const soc = num(r?.soc, "soc", 0, 100);
6143
+ const chargeKW = num(r?.chargeKW, "chargeKW", 0);
6144
+ const dischargeKW = num(r?.dischargeKW, "dischargeKW", 0);
6145
+ const minKW = num(r?.minKW, "minKW", 0);
6146
+ let curtailable;
6147
+ if (r?.curtailable !== void 0 && r?.curtailable !== null) {
6148
+ if (typeof r.curtailable !== "boolean") errors.push(`curtailable \uAC00 \uCC38/\uAC70\uC9D3\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(r.curtailable)}`);
6149
+ else curtailable = r.curtailable;
6150
+ }
6151
+ let position;
6152
+ if (r?.position !== void 0 && r?.position !== null && r?.position !== "") {
6153
+ const p = String(r.position);
6154
+ if (!POSITIONS.has(p)) errors.push(`position \uC774 \uD45C\uC900 \uAC12\uC774 \uC544\uB2C8\uB2E4(open\xB7closed\xB7intermediate\xB7bad): ${JSON.stringify(r.position)}`);
6155
+ else position = p;
6156
+ }
6157
+ const values = { generatedKW, exportKW, soc, chargeKW, dischargeKW, curtailable, minKW, position };
6158
+ if (!errors.length && Object.values(values).every((v) => v === void 0)) {
6159
+ 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");
6160
+ }
6161
+ if (errors.length) {
6162
+ rejected.push({ record, errors });
6163
+ continue;
6164
+ }
6165
+ const eventTime = new Date(atMs).toISOString();
6166
+ const data = {
6167
+ equipmentId,
6168
+ at: eventTime,
6169
+ ...generatedKW !== void 0 ? { generatedKW } : {},
6170
+ ...exportKW !== void 0 ? { exportKW } : {},
6171
+ ...soc !== void 0 ? { soc } : {},
6172
+ ...chargeKW !== void 0 ? { chargeKW } : {},
6173
+ ...dischargeKW !== void 0 ? { dischargeKW } : {},
6174
+ ...curtailable !== void 0 ? { curtailable } : {},
6175
+ ...minKW !== void 0 ? { minKW } : {},
6176
+ ...position !== void 0 ? { position } : {}
6177
+ };
6178
+ accepted.push({
6179
+ eventId: `${opts.tenantId}-energy-eq-${++seq}`,
6180
+ eventType: ENERGY_EVENT.equipment,
6181
+ eventTime,
6182
+ tenantId: opts.tenantId,
6183
+ data
6184
+ });
6185
+ }
6186
+ return { accepted, rejected };
6187
+ }
6046
6188
 
6047
6189
  // src/energy-attribution.ts
6048
6190
  var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
@@ -6326,7 +6468,9 @@ function retiredVocabularyIn(line) {
6326
6468
  inWorkCalendar,
6327
6469
  inWorkCalendarAt,
6328
6470
  ingest,
6471
+ ingestEnergyEquipmentRecords,
6329
6472
  ingestEnergyRecords,
6473
+ isEnergyEquipmentRecord,
6330
6474
  isEnergyRecord,
6331
6475
  isEquipmentLevel,
6332
6476
  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.7",
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": {