@operato/twin-kernel 0.6.14 → 0.7.1

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.
Files changed (45) hide show
  1. package/dist/capability.d.ts +29 -1
  2. package/dist/capability.js +23 -1
  3. package/dist/capacity.d.ts +5 -5
  4. package/dist/capacity.js +7 -7
  5. package/dist/contract.d.ts +176 -32
  6. package/dist/contract.js +66 -7
  7. package/dist/counterfactual.d.ts +1 -1
  8. package/dist/counterfactual.js +2 -2
  9. package/dist/domain-catalog.d.ts +83 -17
  10. package/dist/domain-catalog.js +99 -14
  11. package/dist/domain-definition.d.ts +17 -1
  12. package/dist/ems-kernel.d.ts +83 -0
  13. package/dist/ems-kernel.js +307 -0
  14. package/dist/ems-profile.d.ts +16 -0
  15. package/dist/ems-profile.js +133 -0
  16. package/dist/energy-attribution.d.ts +208 -0
  17. package/dist/energy-attribution.js +229 -0
  18. package/dist/energy-ingest.d.ts +39 -0
  19. package/dist/energy-ingest.js +91 -0
  20. package/dist/epcis.d.ts +3 -3
  21. package/dist/epcis.js +2 -2
  22. package/dist/event-journal.d.ts +2 -2
  23. package/dist/event-journal.js +1 -1
  24. package/dist/flow-engine.d.ts +20 -20
  25. package/dist/flow-engine.js +40 -40
  26. package/dist/forecast.js +1 -1
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.js +5 -0
  29. package/dist/kernel.d.ts +3 -3
  30. package/dist/kernel.js +5 -5
  31. package/dist/mes-kernel.d.ts +2 -2
  32. package/dist/mes-kernel.js +2 -2
  33. package/dist/mes-profile.js +25 -1
  34. package/dist/observed-reducer.d.ts +25 -4
  35. package/dist/observed-reducer.js +30 -9
  36. package/dist/operations-capability.d.ts +8 -8
  37. package/dist/operations-capability.js +2 -2
  38. package/dist/task-fold.d.ts +1 -1
  39. package/dist/task-fold.js +1 -1
  40. package/dist/twin-observer.js +1 -1
  41. package/dist/wms-profile.d.ts +0 -11
  42. package/dist/wms-profile.js +17 -2
  43. package/dist/yms-profile.js +16 -1
  44. package/dist-cjs/index.cjs +814 -56
  45. package/package.json +1 -1
@@ -0,0 +1,307 @@
1
+ /*
2
+ * EMS 커널 — **계측 누적 → 구간 마감 → 피크 판정.** 설계: `design/profiles/ems.md` §3~§5·§10(3단계)
3
+ *
4
+ * ── 무엇이 다른가 ───────────────────────────────────────────────────────────
5
+ * 물류·생산 커널은 **물건이 자리 사이를 옮겨 다니는** 흐름을 굴린다. 에너지에는 옮겨 다니는 물건이
6
+ * 없다 — 스칼라가 시간 위에서 변한다. 그래도 `FlowEngine` 을 상속한다(§6 안 A): 저널·웜스타트·구조
7
+ * 전환·정합성 하네스를 그대로 얻고, 에너지에 없는 축은 **카탈로그가 「해당 없음」으로 선언**한다
8
+ * (`TWIN_AXES[].systems`). 상속의 대가를 축 선언으로 갚는 구조다.
9
+ *
10
+ * ── 표본을 쌓지 않는다 (§4.1 인제스트 계약) ─────────────────────────────────
11
+ * 계량은 멈추지 않는다(계량기 하나가 1분 주기면 하루 1,440건, 피더 100개면 14만건). 그래서 이 커널은
12
+ * **표본을 보관하지 않는다** — 열린 구간의 누적값만 들고 있다(최대·적산·표본 수). 상태 크기가 계측
13
+ * 주기와 무관해야 웜스타트·스냅샷·fork 가 그대로 성립한다.
14
+ *
15
+ * ── 우리가 내는 사실과 받은 사실을 구별한다 ─────────────────────────────────
16
+ * 표본(`energy.measured`)은 **원천이 준 것**이라 우리가 다시 방출하지 않는다. 구간 마감·피크 경신·
17
+ * 감축 제안은 **우리가 판정한 것**이라 저널에 남긴다(`emitOp`). 저널만 보고 "이건 누가 말한 것인가" 를
18
+ * 답할 수 있어야 한다.
19
+ *
20
+ * ── 하지 않는 것 ────────────────────────────────────────────────────────────
21
+ * · **제어하지 않는다** — 차단·투입·감축 실행은 범위 밖이다(§1). `dr.suggested` 는 제안이고, 집행은
22
+ * 사람이 자기 시스템에서 한다. 이름이 그 사실을 말한다.
23
+ * · **요금 구간 전환**(`tariff.shift`)은 아직 없다 — 요금표는 현장·계절·계약의 것이고, 그것을 지어내면
24
+ * 금액이 거짓이 된다. 최소 커널의 몫이 아니다(§10 의 다음 단계).
25
+ * · **월 경계를 정하지 않는다** — 「월 최대 수요」는 현장 시간대와 요금제가 정하는 것이라, 여기서는
26
+ * **관측 시작 이후 최대**만 답하고 그 사실을 이름으로 말한다(`peakSince`). 지어내지 않는다.
27
+ */
28
+ import { FlowEngine } from "./flow-engine.js";
29
+ import { firstFitPolicy } from "./allocation-policy.js";
30
+ import { ENERGY_EVENT } from "./contract.js";
31
+ import { EMS_PROPERTY } from "./ems-profile.js";
32
+ /** 수요 구간 — 요금의 알갱이다. 15분은 한국·다수 요금제의 최대수요 산정 단위다. */
33
+ export const DEMAND_WINDOW_MS = 15 * 60 * 1000;
34
+ /** 그 시각이 속한 구간의 시작 — 벽시계 경계(00·15·30·45분)에 맞춘다. */
35
+ export const demandWindowStart = (atMs, windowMs = DEMAND_WINDOW_MS) => Math.floor(atMs / windowMs) * windowMs;
36
+ /**
37
+ * 세 번째 생성 인자에서 **쓸 수 있는 창 길이만** 뽑는다 — 나머지는 무시하고 기본값을 쓴다.
38
+ * 0·음수·객체·문자열을 창 길이로 삼으면 구간 경계가 `NaN` 이 되고 그 트윈의 모든 마감이 거짓이 된다.
39
+ */
40
+ function windowMsOf(opts) {
41
+ const raw = typeof opts === 'number' ? opts : opts?.windowMs;
42
+ const n = Number(raw);
43
+ return Number.isFinite(n) && n > 0 ? n : DEMAND_WINDOW_MS;
44
+ }
45
+ /** 상태에 남기는 마감 구간 수 — 하루치(15분 × 96). 그 앞은 저널이 답한다. */
46
+ const KEEP_CLOSED = 96;
47
+ export class EmsKernel extends FlowEngine {
48
+ points = new Map();
49
+ open;
50
+ closed = [];
51
+ closedTotal = 0;
52
+ peak;
53
+ /** 이 구간에서 이미 제안을 냈나 — 같은 사실을 되풀어 방송하지 않는다(라이브 브리지의 교훈). */
54
+ suggestedFor;
55
+ windowMs;
56
+ /**
57
+ * ── 세 번째 인자는 **호스트가 정한 자리**다 (2026-08-14 실측으로 고침) ──────
58
+ *
59
+ * 호스트는 모든 커널을 한 모양으로 세운다: `new Kernel(tenantId, undefined, productionSpecOf(model))`.
60
+ * 즉 세 번째 인자는 **도메인 옵션 슬롯**이고 커널마다 뜻이 다르다(야드는 모드, 생산은 명세).
61
+ *
62
+ * 처음에 이 자리를 `windowMs: number` 로 받았다가, 라이브에서 **모든 수요 구간이 깨졌다** —
63
+ * 생산 명세 객체가 창 길이로 들어와 `startMs: null`·`endMs: NaN` 이 됐다. 내 시험은 커널을
64
+ * `new EmsKernel('t')` 로 직접 세웠기 때문에 그것을 잡지 못했다(호스트와 다른 방식으로 세운 것이
65
+ * 그 자체로 결함이었다).
66
+ *
67
+ * 그래서 **쓸 수 있는 것만 읽는다**: 수면 창 길이로 쓰고, 객체면 `windowMs` 를 찾고, 없으면 기본값
68
+ * (15분)이다. 모르는 것을 창 길이로 삼지 않는다.
69
+ */
70
+ constructor(tenantId, policy = firstFitPolicy, opts) {
71
+ super(tenantId, policy);
72
+ this.windowMs = windowMsOf(opts);
73
+ }
74
+ /**
75
+ * 계약전력 — **현장이 선언한 자리 속성**에서 읽는다(`EMS_PROPERTY.contractKW`).
76
+ *
77
+ * 여러 자리가 선언하면 **가장 큰 값**을 쓴다: 수전 지점의 계약이 분기의 것보다 크고, 트윈 전체의
78
+ * 한계는 수전이 정한다. 선언이 없으면 `undefined` — 계약을 모르면 계약 대비 판정을 하지 않는다
79
+ * (기본값을 지어내면 그 뒤 모든 판정이 거짓 위에 선다).
80
+ */
81
+ declaredContractKW() {
82
+ let max;
83
+ for (const loc of this.boardDef?.locations ?? []) {
84
+ for (const p of loc.properties ?? []) {
85
+ if (p.id !== EMS_PROPERTY.contractKW)
86
+ continue;
87
+ const v = Number(p.value);
88
+ if (!Number.isFinite(v) || v <= 0)
89
+ continue;
90
+ if (max === undefined || v > max)
91
+ max = v;
92
+ }
93
+ }
94
+ return max;
95
+ }
96
+ /**
97
+ * 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
98
+ *
99
+ * 가로챈 표본은 **다시 방출하지 않는다**: 원천이 이미 그 사실을 갖고 있고, 저널은 인입에서 한 번만
100
+ * 적는다(상위 `apply` 의 재방출 규약과 같은 이유).
101
+ */
102
+ apply(envelope) {
103
+ if (envelope.eventType === ENERGY_EVENT.measured) {
104
+ this.ingestMeasured(envelope);
105
+ return;
106
+ }
107
+ super.apply(envelope);
108
+ }
109
+ ingestMeasured(envelope) {
110
+ const d = envelope.data;
111
+ /* 시각은 **계측이 말한 것**이 먼저다(`data.at`) — 봉투 시각은 전달 시각일 수 있다.
112
+ 둘 다 없으면 어느 구간의 것인지 알 수 없다: 지금 시각으로 메우면 남의 구간에 실린다. */
113
+ const atMs = [String(d?.at ?? ''), String(envelope.eventTime ?? '')]
114
+ .map(v => Date.parse(v))
115
+ .find(v => Number.isFinite(v));
116
+ if (atMs === undefined)
117
+ throw new Error('energy.measured has no usable time (data.at / eventTime) — cannot place it in a demand window');
118
+ const id = String(d?.meterId ?? '').trim();
119
+ if (!id)
120
+ throw new Error('energy.measured has no meterId — a measurement with no meter cannot be accumulated');
121
+ /* 이 표본이 새 구간의 것이면 앞 구간을 먼저 닫는다 — 마감이 표본보다 늦으면 최대가 섞인다. */
122
+ this.closeDue(atMs);
123
+ this.openWindow(atMs);
124
+ const kW = Number.isFinite(Number(d?.kW)) ? Number(d.kW) : undefined;
125
+ const point = this.points.get(id) ?? { id, samplesInWindow: 0 };
126
+ /* 늦게 온 옛 표본이 최신 관측을 덮지 않게 — 상위 커널의 `stale` 판정과 같은 규율. */
127
+ if (point.atMs === undefined || atMs >= point.atMs) {
128
+ point.atMs = atMs;
129
+ if (kW !== undefined)
130
+ point.kW = kW;
131
+ if (Number.isFinite(Number(d?.kWh)))
132
+ point.kWh = Number(d.kWh);
133
+ if (Number.isFinite(Number(d?.powerFactor)))
134
+ point.powerFactor = Number(d.powerFactor);
135
+ }
136
+ point.samplesInWindow++;
137
+ this.points.set(id, point);
138
+ const w = this.open;
139
+ if (kW !== undefined) {
140
+ /*
141
+ * 구간의 부하는 **지점의 합**이다(수전 하나만 계량하는 현장도, 피더를 여럿 계량하는 현장도 있다).
142
+ * 마지막으로 관측된 값들을 더한다 — 표본 주기가 지점마다 달라도 이것이 그 순간의 최선이다.
143
+ * 지점 하나가 침묵하면 그 값은 낡은 채로 더해진다: 그래서 `atMs` 를 지점마다 남겨 소비처가
144
+ * 「이 지점은 3시간째 조용하다」를 볼 수 있게 한다.
145
+ */
146
+ const total = [...this.points.values()].reduce((sum, p) => sum + (p.kW ?? 0), 0);
147
+ if (w.maxKW === undefined || total > w.maxKW)
148
+ w.maxKW = total;
149
+ w.samples++;
150
+ w.meanKW = (w.meanKW === undefined ? total : (w.meanKW * (w.samples - 1) + total) / w.samples);
151
+ }
152
+ this.revision++;
153
+ this.judgeOpenWindow(atMs);
154
+ }
155
+ openWindow(atMs) {
156
+ const start = demandWindowStart(atMs, this.windowMs);
157
+ if (this.open?.startMs === start)
158
+ return;
159
+ const contractKW = this.declaredContractKW();
160
+ this.open = { startMs: start, endMs: start + this.windowMs, samples: 0, ...(contractKW !== undefined ? { contractKW } : {}) };
161
+ /* 새 구간이면 지점별 표본 수도 새로 센다 — 구간마다 「못 쟀다」를 답할 수 있어야 한다. */
162
+ for (const p of this.points.values())
163
+ p.samplesInWindow = 0;
164
+ this.suggestedFor = undefined;
165
+ }
166
+ /**
167
+ * 지난 구간을 닫는다 — **표본이 없으면 값을 만들지 않는다.**
168
+ *
169
+ * 침묵한 구간을 「0 kW」로 닫으면 그 트윈은 「그 15분 동안 전기를 쓰지 않았다」고 말하는 것이 된다.
170
+ * 구간이 지난 것은 사실이고 우리가 못 쟀다는 것도 사실이므로, 구간은 남기고 값은 비운다.
171
+ *
172
+ * 표본이 오지 않으면 마감도 오지 않는다(라이브에서 계측이 끊기면 열린 구간이 그대로 남는다).
173
+ * 그래서 이것은 **공개**다 — 호스트가 시각을 주며 부를 수 있다(`tick` 도 이것을 부른다).
174
+ */
175
+ closeDue(nowMs) {
176
+ const done = [];
177
+ while (this.open && nowMs >= this.open.endMs) {
178
+ const w = this.open;
179
+ if (w.contractKW !== undefined && w.maxKW !== undefined)
180
+ w.overContract = w.maxKW > w.contractKW;
181
+ this.closed.push(w);
182
+ this.closedTotal++;
183
+ if (this.closed.length > KEEP_CLOSED)
184
+ this.closed.splice(0, this.closed.length - KEEP_CLOSED);
185
+ done.push(w);
186
+ this.emitOp(ENERGY_EVENT.demandWindow, {
187
+ startMs: w.startMs,
188
+ endMs: w.endMs,
189
+ ...(w.maxKW !== undefined ? { maxKW: w.maxKW } : {}),
190
+ ...(w.meanKW !== undefined ? { meanKW: w.meanKW } : {}),
191
+ samples: w.samples,
192
+ ...(w.contractKW !== undefined ? { contractKW: w.contractKW } : {}),
193
+ ...(w.overContract !== undefined ? { overContract: w.overContract } : {}),
194
+ /*
195
+ * 부하를 셀 근거가 없었다는 사실을 코드로 낸다 — 화면이 「0 kW」와 「못 쟀다」를 구별할 수 있게.
196
+ *
197
+ * 이름이 조건과 정확히 같아야 한다: `samples` 는 **kW 를 실은 표본**의 수다. 표본은 왔는데
198
+ * kW 를 못 읽은 경우(계기 오류·필드 누락)가 실제로 있고, 그것은 「아무것도 오지 않았다」와
199
+ * 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
200
+ */
201
+ ...(w.samples === 0 ? { observedAbsence: 'no-load-samples' } : {})
202
+ });
203
+ /* 피크는 **마감된 구간**으로만 갱신한다 — 열린 구간의 최대는 아직 확정이 아니다. */
204
+ if (w.maxKW !== undefined && (this.peak === undefined || w.maxKW > this.peak.kW)) {
205
+ this.peak = { kW: w.maxKW, windowStartMs: w.startMs };
206
+ this.emitOp(ENERGY_EVENT.peak, { kW: w.maxKW, windowStartMs: w.startMs, ...(w.contractKW !== undefined ? { contractKW: w.contractKW } : {}) });
207
+ }
208
+ /* 다음 구간은 표본이 올 때 연다 — 미리 열면 오지 않은 구간을 존재하는 것처럼 만든다. */
209
+ this.open = undefined;
210
+ }
211
+ return done;
212
+ }
213
+ /**
214
+ * 열린 구간의 판정 — **이대로 가면 계약을 넘는가.**
215
+ *
216
+ * 예측은 「지금까지의 평균 부하가 구간 끝까지 이어진다」다. 단순하지만 그 가정을 **값과 함께 낸다**
217
+ * (`projectionBasis`) — 근거를 감춘 예측은 사용자가 검증할 수 없다. 남은 시간이 짧을수록 이 예측은
218
+ * 실제에 가까워진다(구간 초반의 경보는 성급할 수 있다는 뜻이고, 그것도 사용자가 알아야 한다).
219
+ *
220
+ * 넘을 것 같으면 **제안**을 낸다 — 무엇을 줄일 수 있는지는 모델이 선언한 감축 가능 설비가 답한다.
221
+ * 우리는 끄지 않는다.
222
+ */
223
+ judgeOpenWindow(atMs) {
224
+ const w = this.open;
225
+ if (!w || w.contractKW === undefined || w.meanKW === undefined)
226
+ return;
227
+ if (this.suggestedFor === w.startMs)
228
+ return;
229
+ const elapsed = Math.max(1, atMs - w.startMs);
230
+ const projected = (w.meanKW * elapsed + w.meanKW * (w.endMs - atMs)) / this.windowMs;
231
+ if (projected <= w.contractKW)
232
+ return;
233
+ this.suggestedFor = w.startMs;
234
+ this.emitOp(ENERGY_EVENT.drSuggested, {
235
+ windowStartMs: w.startMs,
236
+ projectedKW: projected,
237
+ contractKW: w.contractKW,
238
+ gapKW: projected - w.contractKW,
239
+ projectionBasis: 'mean-so-far',
240
+ /* 줄일 수 있는 것 — 선언된 감축 가능 설비다. 우선순위·최소 유지는 현장이 정하므로 여기서
241
+ 고르지 않고 **후보를 있는 대로** 낸다(고르는 것은 사람의 일이다). */
242
+ curtailableCandidates: this.curtailableIds()
243
+ });
244
+ }
245
+ /** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
246
+ curtailableIds() {
247
+ return (this.boardDef?.equipment ?? [])
248
+ .filter(e => String(e.kind ?? '') === 'curtailable-load')
249
+ .map(e => e.id);
250
+ }
251
+ /*
252
+ * ── 흐름 훅 넷 — **상속의 대가다** (§6 안 A) ─────────────────────────────
253
+ *
254
+ * `FlowEngine` 은 이 넷을 abstract 로 요구한다: 도착·오더·배정·작업 완료. 에너지에는 그 넷이 없다
255
+ * (물건이 도착하지 않고, 주문이 없고, 배정할 자원이 없고, 완료될 작업이 없다). 그래서 여기서
256
+ * **아무 일도 하지 않는다** — 그런데 조용히 넘기지 않는다: 이 커널이 그런 요청을 받았다는 것은
257
+ * **배선이 잘못됐다는 사실**이고(EMS 트윈에 물류 명령을 보낸 것), 조용히 넘기면 그 사실이 사라진다.
258
+ *
259
+ * 던지지도 않는다: 저널 재생 중이라면 트윈 전체가 멈춘다. 그래서 커널이 담을 줄 모르는 사건을
260
+ * 세는 자리(`ObservedReducer.unhandled`)와 같은 규율으로, **개수를 세어 상태로 낸다.**
261
+ */
262
+ flowRequests = new Map();
263
+ noteFlowRequest(hook) {
264
+ this.flowRequests.set(hook, (this.flowRequests.get(hook) ?? 0) + 1);
265
+ }
266
+ onArrival() {
267
+ this.noteFlowRequest('onArrival');
268
+ }
269
+ onOrder() {
270
+ this.noteFlowRequest('onOrder');
271
+ }
272
+ allocate() {
273
+ this.noteFlowRequest('allocate');
274
+ }
275
+ onTaskComplete() {
276
+ this.noteFlowRequest('onTaskComplete');
277
+ }
278
+ /** 시뮬 시간으로도 구간이 닫힌다 — 관측이 없어도 시간은 간다. */
279
+ tick(dtMs) {
280
+ super.tick(dtMs);
281
+ this.closeDue(this.clockMs);
282
+ }
283
+ getSnapshot() {
284
+ const snap = super.getSnapshot();
285
+ const contractKW = this.declaredContractKW();
286
+ const energy = {
287
+ points: [...this.points.values()].map(p => ({ ...p })),
288
+ ...(this.open
289
+ ? {
290
+ open: {
291
+ ...this.open,
292
+ ...(this.open.meanKW !== undefined ? { projectedKW: this.open.meanKW, projectionBasis: 'mean-so-far' } : {})
293
+ }
294
+ }
295
+ : {}),
296
+ closed: this.closed.map(w => ({ ...w })),
297
+ closedTotal: this.closedTotal,
298
+ ...(this.peak ? { peakSince: { ...this.peak } } : {}),
299
+ ...(contractKW !== undefined ? { contractKW } : {}),
300
+ /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
301
+ ...(this.flowRequests.size
302
+ ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) }
303
+ : {})
304
+ };
305
+ return { ...snap, energy };
306
+ }
307
+ }
@@ -0,0 +1,16 @@
1
+ import type { TwinTypeInfo } from './domain-catalog.ts';
2
+ /** 로케이션(수동) 타입 키 — 배전 계통의 구간. */
3
+ export declare const EMS_LOCATION_TYPES: readonly ["incoming", "feeder", "submeter-zone"];
4
+ /** 설비(능동) 타입 키 — 계량 지점과 에너지 자원. */
5
+ export declare const EMS_EQUIPMENT_TYPES: readonly ["meter", "breaker", "pv-array", "battery", "utility", "curtailable-load"];
6
+ /**
7
+ * 커널이 **읽는** 자리 속성 — 뜻을 코드 한가운데 숨기지 않는다.
8
+ *
9
+ * 속성 자체는 열려 있고(`ResourceProperty`) 어휘는 표준이 정하지 않는다. 그래서 커널이 판정에 쓰는
10
+ * 것만 여기 이름으로 못 박는다 — 이 목록에 없는 속성은 커널이 나르기만 하고 해석하지 않는다.
11
+ */
12
+ export declare const EMS_PROPERTY: {
13
+ /** 계약전력(kW) — 수전·분기 자리에 선언한다. 없으면 계약 대비 판정을 하지 않는다. */
14
+ readonly contractKW: "contract.kW";
15
+ };
16
+ export declare const EMS_TYPES: TwinTypeInfo[];
@@ -0,0 +1,133 @@
1
+ /** 로케이션(수동) 타입 키 — 배전 계통의 구간. */
2
+ export const EMS_LOCATION_TYPES = ['incoming', 'feeder', 'submeter-zone'];
3
+ /** 설비(능동) 타입 키 — 계량 지점과 에너지 자원. */
4
+ export const EMS_EQUIPMENT_TYPES = ['meter', 'breaker', 'pv-array', 'battery', 'utility', 'curtailable-load'];
5
+ /**
6
+ * 커널이 **읽는** 자리 속성 — 뜻을 코드 한가운데 숨기지 않는다.
7
+ *
8
+ * 속성 자체는 열려 있고(`ResourceProperty`) 어휘는 표준이 정하지 않는다. 그래서 커널이 판정에 쓰는
9
+ * 것만 여기 이름으로 못 박는다 — 이 목록에 없는 속성은 커널이 나르기만 하고 해석하지 않는다.
10
+ */
11
+ export const EMS_PROPERTY = {
12
+ /** 계약전력(kW) — 수전·분기 자리에 선언한다. 없으면 계약 대비 판정을 하지 않는다. */
13
+ contractKW: 'contract.kW'
14
+ };
15
+ export const EMS_TYPES = [
16
+ /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
17
+ {
18
+ key: 'incoming',
19
+ role: 'location',
20
+ /* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
21
+ level: 'Other',
22
+ label: 'twin.type.incoming',
23
+ /* 수전 지점 — 계약전력이 걸리는 자리이고, 요금의 근거가 되는 수요는 여기서 잰다.
24
+ ISO 50001 의 「에너지 유입」 경계이기도 하다(조직의 에너지 검토가 여기서 시작한다). */
25
+ standardClass: { iec61850: 'MMTR', iso50001: 'EnergyInput' },
26
+ identity: { scheme: 'kernel:id' },
27
+ capabilities: ['metered']
28
+ },
29
+ {
30
+ key: 'feeder',
31
+ role: 'location',
32
+ /* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
33
+ level: 'Other',
34
+ label: 'twin.type.feeder',
35
+ /* 분기 회로 — 부하 분해의 단위. 계약전력의 하위 배분이 여기서 정해진다. */
36
+ standardClass: { iec61850: 'Feeder', iso50001: 'EnergyUse' },
37
+ identity: { scheme: 'kernel:id' },
38
+ capabilities: ['metered']
39
+ },
40
+ {
41
+ key: 'submeter-zone',
42
+ role: 'location',
43
+ /* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
44
+ level: 'Other',
45
+ label: 'twin.type.submeter-zone',
46
+ /*
47
+ * 구역 계량 — 여러 부하를 한 계량기로 묶어 재는 자리(공조·조명처럼 개별 계량이 없는 것들).
48
+ *
49
+ * ISO 50001 의 **SEU**(유의 에너지 사용처)가 대개 이 알갱이다. 개별 설비까지 재지 못하는 현장이
50
+ * 많고, 그것을 「모른다」로 두는 대신 **묶음으로 아는 것**이 정직하다.
51
+ */
52
+ standardClass: { iec61850: 'MMXU', iso50001: 'SEU' },
53
+ identity: { scheme: 'kernel:id' },
54
+ capabilities: ['metered']
55
+ },
56
+ /* ── 설비: 계량 지점과 에너지 자원 ──────────────────────────────────────── */
57
+ {
58
+ key: 'meter',
59
+ role: 'equipment',
60
+ label: 'twin.type.meter',
61
+ /* 계량기 — 측정 논리 노드(MMXU=측정단위, MMTR=적산). 자산으로도 하나다(ISO 55000). */
62
+ standardClass: { iec61850: 'MMXU', iso55000: 'Asset', iso50001: 'MeasurementPoint' },
63
+ identity: { scheme: 'kernel:id' },
64
+ capabilities: ['metered', 'operable']
65
+ },
66
+ {
67
+ key: 'breaker',
68
+ role: 'equipment',
69
+ label: 'twin.type.breaker',
70
+ /*
71
+ * 차단기 — IEC 61850 `XCBR`. **우리는 이것을 조작하지 않는다**(안전 계통은 범위 밖: ems.md §1).
72
+ * 상태를 읽어 계통 구성을 알 뿐이다 — 그래서 능력은 `operable` 만이고 `curtailable` 이 아니다.
73
+ */
74
+ standardClass: { iec61850: 'XCBR', iso55000: 'Asset' },
75
+ identity: { scheme: 'kernel:id' },
76
+ capabilities: ['operable']
77
+ },
78
+ {
79
+ key: 'pv-array',
80
+ role: 'equipment',
81
+ label: 'twin.type.pv-array',
82
+ /* 태양광 어레이 — IEC 61850-7-420(분산자원)의 `DPVA`. 발전과 계량은 다른 능력이다. */
83
+ standardClass: { iec61850: 'DPVA', iso55000: 'Asset', iso50001: 'RenewableSupply' },
84
+ identity: { scheme: 'kernel:id' },
85
+ capabilities: ['generating', 'metered', 'operable']
86
+ },
87
+ {
88
+ key: 'battery',
89
+ role: 'equipment',
90
+ label: 'twin.type.battery',
91
+ /* 축전지(ESS) — `ZBAT`. 충전·방전을 나눠 재고, 저장은 보관(`storable`)이 아니다(물건이 아니다). */
92
+ standardClass: { iec61850: 'ZBAT', iso55000: 'Asset' },
93
+ identity: { scheme: 'kernel:id' },
94
+ capabilities: ['storing', 'metered', 'operable']
95
+ },
96
+ {
97
+ key: 'utility',
98
+ role: 'equipment',
99
+ label: 'twin.type.utility',
100
+ /*
101
+ * 공통 설비 — 공조·컴프레서·칠러·조명·폐수처리처럼 **어느 공정에도 귀속되지 않는** 소비처.
102
+ *
103
+ * ── 왜 따로 있나 (2026-08-14) ────────────────────────────────────────────
104
+ * 물류·생산 트윈에서 이런 것들은 **설비가 아니다**(공정에 매핑되지 않으므로 자원 축에 없다).
105
+ * 그런데 에너지에서는 소비의 절반을 차지하고 감축 후보 1순위다 — 담을 자리가 반드시 있어야 한다.
106
+ *
107
+ * 처음에는 `curtailable-load` 하나로 받으려 했다. 그런데 그 이름은 **「줄일 수 있다」고 주장**한다:
108
+ * 폐수처리·방폭 환기·서버실 냉방은 공통이지만 줄일 수 없고, 그것을 감축 가능으로 두면 트윈이
109
+ * 「이걸 줄이면 됩니다」라는 거짓 제안을 한다. 공통성과 감축 가능성은 **다른 축**이다.
110
+ *
111
+ * 표준: IEC 61850 에 「공통 설비」라는 논리 노드는 없다(설비 종류마다 다른 노드다) — 비운다.
112
+ * ISO 50001 의 SEU 는 이 부류를 가장 많이 가리킨다(유의 에너지 사용처).
113
+ */
114
+ standardClass: { iso50001: 'SEU', iso55000: 'Asset' },
115
+ identity: { scheme: 'kernel:id' },
116
+ capabilities: ['metered', 'operable']
117
+ },
118
+ {
119
+ key: 'curtailable-load',
120
+ role: 'equipment',
121
+ label: 'twin.type.curtailable-load',
122
+ /*
123
+ * 감축 가능 부하 — 줄일 수 있는 소비처(공조·충전기·비상시 미가동 라인).
124
+ *
125
+ * 표준에 이 이름은 없다: IEC 61850 은 설비를 종류로 부르고 「감축 가능」은 **운영 정책**이다.
126
+ * 그래서 `iec61850` 칸을 비우고 ISO 50001 의 SEU 로만 대응한다 — 억지로 논리 노드를 적으면
127
+ * 적합성 표가 거짓을 말한다.
128
+ */
129
+ standardClass: { iso50001: 'SEU' },
130
+ identity: { scheme: 'kernel:id' },
131
+ capabilities: ['curtailable', 'metered', 'operable']
132
+ }
133
+ ];
@@ -0,0 +1,208 @@
1
+ /** 몫의 근거 — 값과 **반드시 함께** 다닌다. */
2
+ export type AttributionBasis = 'measured' | 'apportioned' | 'unattributed';
3
+ /** 무엇으로 나눴나 — 배분일 때만 뜻이 있다. */
4
+ export type WeightKind = 'runtimeMs' | 'output' | 'ratedKW' | 'equal';
5
+ export interface EnergyConsumer {
6
+ /** 소비처 — 설비·오더·구역 무엇이든 id 하나로 가리킨다. */
7
+ id: string;
8
+ /** 이 소비처를 **전용으로** 재는 계량기. 있으면 그 풀은 통째로 이 소비처의 것이다. */
9
+ meterId?: string;
10
+ /**
11
+ * 나눌 때의 몫 — 뜻은 `weightKind` 가 정한다(가동시간 ms · 산출 수량 · 정격 kW).
12
+ * 없거나 0 이면 **그 소비처는 배분에서 빠진다**(0 으로 세지 않는다 — 몫을 모르는 것이다).
13
+ */
14
+ weight?: number;
15
+ }
16
+ /** 한 계량기가 잰 구간 에너지와, 그것이 덮는 소비처들. */
17
+ export interface EnergyPool {
18
+ meterId: string;
19
+ /** 그 구간에 잰 전력량. */
20
+ kWh: number;
21
+ /** 이 계량기가 덮는 소비처 id 들 — 현장이 선언한다(우리가 추론하지 않는다). */
22
+ consumerIds: string[];
23
+ /**
24
+ * **공통(간접) 소비인가** — 공조·컴프레서·칠러·조명·폐수처리처럼 어느 공정·오더에도 직접
25
+ * 귀속되지 않는 부하(카탈로그의 `utility` 타입).
26
+ *
27
+ * ── 왜 표시가 필요한가 (2026-08-14) ──────────────────────────────────────
28
+ * 이것을 「미귀속」과 같은 칸에 담으면 **정상 상태가 결함처럼** 보인다. 공통 설비의 전기는 직접
29
+ * 귀속되지 않는 것이 옳고, 사람이 채워야 할 결손(몫이 없다·덮는 대상이 없다)과는 다른 사실이다.
30
+ *
31
+ * 공정에 배부하고 싶으면 **명시적으로 요청한다**(`overheadAllocation`) — 원가회계의 간접비 배부와
32
+ * 같은 규율이고, 그 결과는 배분(`apportioned`)이며 `overhead: true` 로 표시된다.
33
+ */
34
+ overhead?: boolean;
35
+ }
36
+ export interface EnergyShare {
37
+ consumerId: string;
38
+ kWh: number;
39
+ basis: Exclude<AttributionBasis, 'unattributed'>;
40
+ /** 어느 계량기에서 왔나 — 되짚을 수 있어야 한다. */
41
+ poolMeterId: string;
42
+ /** 배분일 때만: 무엇으로 나눴나. */
43
+ weightKind?: WeightKind;
44
+ /** 배분일 때만: 그 소비처의 몫이 전체의 얼마였나(근거의 투명성). */
45
+ weightShare?: number;
46
+ /**
47
+ * 이 몫이 **공통 설비에서 배부된 것**인가 — 직접 소비와 같은 무게로 읽히지 않게.
48
+ * 「제품 1대당 kWh」를 직접분·공통분으로 갈라 말할 수 있는 근거다.
49
+ */
50
+ overhead?: boolean;
51
+ }
52
+ export interface Unattributed {
53
+ poolMeterId: string;
54
+ kWh: number;
55
+ /**
56
+ * 왜 나누지 못했나 — **언어중립 코드**(화면이 옮긴다).
57
+ * · `no-consumers` — 이 계량기가 무엇을 덮는지 선언되지 않았다
58
+ * · `no-weights` — 소비처는 있는데 나눌 몫이 없다(가동시간·산출량 어느 것도)
59
+ */
60
+ reason: 'no-consumers' | 'no-weights';
61
+ /** 몫을 못 정한 소비처들 — 사람이 무엇을 채워야 할지 알 수 있게. */
62
+ consumerIds?: string[];
63
+ }
64
+ export interface AttributionResult {
65
+ shares: EnergyShare[];
66
+ unattributed: Unattributed[];
67
+ /**
68
+ * 배분에서 **빠진** 소비처들 — 조용히 빼지 않는다.
69
+ *
70
+ * 몫이 0 이면(그 구간에 돌지 않았다) 그 소비처에 `0 kWh` 를 주지 않는다: 그것은 「우리가 재어 보니
71
+ * 0 이었다」는 **지어낸 사실**이고, 대기전력이 있는 설비에서는 곧 거짓이다. 대신 빠졌다는 사실을 낸다 —
72
+ * 그러면 사람이 「이 설비는 정말 안 돌았나」를 확인할 수 있다.
73
+ */
74
+ excluded: {
75
+ consumerId: string;
76
+ poolMeterId: string;
77
+ reason: 'zero-weight';
78
+ }[];
79
+ /**
80
+ * **공통(간접) 에너지** — 배부하지 않았다. 결손이 아니라 **정상**이다(위 `EnergyPool.overhead`).
81
+ * 배부를 요청하면 이 바구니가 비고 그만큼 `shares` 로 간다(그때는 `overhead: true` 가 붙는다).
82
+ */
83
+ overhead: {
84
+ poolMeterId: string;
85
+ kWh: number;
86
+ }[];
87
+ totals: {
88
+ measuredKWh: number;
89
+ attributedKWh: number;
90
+ unattributedKWh: number;
91
+ /** 공통으로 남은 양 — 원단위를 직접분·공통분으로 갈라 말할 수 있게. */
92
+ overheadKWh: number;
93
+ };
94
+ }
95
+ /**
96
+ * 구간 에너지를 소비처에 귀속시킨다 — **나눌 수 없으면 나누지 않는다.**
97
+ *
98
+ * @param weightKind 배분에 쓸 몫의 뜻. **`equal` 은 호출자가 명시적으로 고를 때만** 쓰인다(위 주석).
99
+ */
100
+ export declare function attributeEnergy(opts: {
101
+ pools: readonly EnergyPool[];
102
+ consumers: readonly EnergyConsumer[];
103
+ weightKind?: WeightKind;
104
+ /**
105
+ * 공통(간접) 에너지를 **배부할 것인가** — 기본은 배부하지 않는다.
106
+ *
107
+ * 배부는 원가회계의 간접비 배부와 같다: 숫자는 나오지만 그것은 **측정이 아니다.** 기본값으로 두면
108
+ * 아무도 그것이 배부인 줄 모르므로, 호출자가 몫의 종류를 말할 때만 한다.
109
+ * 대상은 공통 풀이 덮는 소비처가 아니라 **`processConsumerIds` 로 지목한 공정 소비처들**이다
110
+ * (공통 계량기는 공정을 덮지 않는다 — 그것이 공통인 이유다).
111
+ */
112
+ overheadAllocation?: {
113
+ weightKind: WeightKind;
114
+ processConsumerIds: readonly string[];
115
+ };
116
+ }): AttributionResult;
117
+ /** 분모의 종류 — 무엇당 에너지인가. 단위가 뜻을 정한다. */
118
+ export type IntensityDenominator = 'output' | 'runtimeHours' | 'area';
119
+ export interface IntensityInput {
120
+ /** 분자 — 그 구간의 전력량. 모르면 `undefined`(0 이 아니다). */
121
+ kWh?: number;
122
+ /** 분자가 덮는 구간. */
123
+ energyWindow?: {
124
+ startMs: number;
125
+ endMs: number;
126
+ };
127
+ denominator: {
128
+ kind: IntensityDenominator;
129
+ /** 값 — 모르면 `undefined`. */
130
+ value?: number;
131
+ /** 분모가 덮는 구간 — 분자와 같아야 한다(아래 판정). */
132
+ window?: {
133
+ startMs: number;
134
+ endMs: number;
135
+ };
136
+ };
137
+ }
138
+ export type IntensityResult = {
139
+ value: number;
140
+ unit: string;
141
+ kWh: number;
142
+ denominator: number;
143
+ window: {
144
+ startMs: number;
145
+ endMs: number;
146
+ };
147
+ } | {
148
+ value: null;
149
+ /**
150
+ * 왜 답하지 못했나 — **언어중립 코드**.
151
+ * · `no-energy` — 분자를 모른다
152
+ * · `no-denominator` — 분모를 모른다(생산량을 못 읽었다)
153
+ * · `zero-denominator` — 분모가 0 이다(그 구간에 아무것도 만들지 않았다 → 원단위가 정의되지 않는다)
154
+ * · `window-mismatch` — 분자와 분모가 **다른 구간**의 사실이다
155
+ */
156
+ reason: 'no-energy' | 'no-denominator' | 'zero-denominator' | 'window-mismatch';
157
+ };
158
+ /**
159
+ * 원단위 — **답할 수 없으면 답하지 않는다.**
160
+ *
161
+ * ── 왜 구간을 맞대어 보나 ───────────────────────────────────────────────────
162
+ * 분자는 에너지 트윈이, 분모는 생산 트윈이 낸다. 두 트윈은 각자의 시계로 돌고, 라이브와 히스토리가
163
+ * 섞이기도 한다. 다른 구간의 두 사실을 나누면 숫자는 나오지만 **아무것도 뜻하지 않는다** — 야간의
164
+ * 전력을 주간의 산출로 나눈 값이 그렇다. 그래서 구간이 어긋나면 거절한다(호출자가 맞춰서 다시 묻는다).
165
+ *
166
+ * ── 왜 0 을 무한으로 만들지 않나 ────────────────────────────────────────────
167
+ * 그 구간에 아무것도 만들지 않았다면 「대당 에너지」는 **정의되지 않는다.** `Infinity` 를 내면 화면이
168
+ * 그것을 큰 수로 그리고, 사용자는 최악의 원단위를 본 것으로 읽는다.
169
+ */
170
+ export declare function energyIntensity(input: IntensityInput): IntensityResult;
171
+ export interface WindowedEnergy {
172
+ /** 그 범위의 전력량 — 셀 수 있는 구간이 하나도 없으면 `undefined`(0 이 아니다). */
173
+ kWh?: number;
174
+ /**
175
+ * 어떻게 얻었나 — **파생임을 숨기지 않는다.**
176
+ * · `mean-kw` — 구간 평균부하 × 구간 길이. 표본의 평균이므로 **추정**이다.
177
+ *
178
+ * 계기 적산값(`kWh`)의 차분이 더 정확하지만, 그러려면 구간마다 적산 스냅샷을 남겨야 한다
179
+ * (아직 하지 않는다 — 남기게 되면 근거가 `meter-delta` 로 바뀐다).
180
+ */
181
+ basis: 'mean-kw';
182
+ /** 센 구간 수. */
183
+ counted: number;
184
+ /**
185
+ * 못 센 구간 수 — 표본이 없어 평균부하를 모르는 구간이다. **조용히 빼지 않는다**:
186
+ * 이 수가 크면 위 `kWh` 는 그 범위의 전부가 아니라 **일부의 합**이다.
187
+ */
188
+ skipped: number;
189
+ /** 실제로 센 범위 — 요청 범위와 다를 수 있다(구간 경계에 맞춘다). */
190
+ window?: {
191
+ startMs: number;
192
+ endMs: number;
193
+ };
194
+ }
195
+ /**
196
+ * 마감된 수요 구간들에서 그 범위의 전력량을 만든다 — **파생의 근거를 함께.**
197
+ *
198
+ * 범위에 **걸친** 구간은 세지 않는다(부분을 비례로 자르면 그 비례가 또 하나의 추정이 된다).
199
+ * 온전히 들어오는 구간만 센다 — 그래서 실제로 센 범위를 함께 낸다.
200
+ */
201
+ export declare function energyOfWindows(windows: readonly {
202
+ startMs: number;
203
+ endMs: number;
204
+ meanKW?: number;
205
+ }[], range?: {
206
+ startMs: number;
207
+ endMs: number;
208
+ }): WindowedEnergy;