@operato/twin-kernel 0.6.14 → 0.7.0

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.
@@ -0,0 +1,284 @@
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
+ /** 상태에 남기는 마감 구간 수 — 하루치(15분 × 96). 그 앞은 저널이 답한다. */
37
+ const KEEP_CLOSED = 96;
38
+ export class EmsKernel extends FlowEngine {
39
+ points = new Map();
40
+ open;
41
+ closed = [];
42
+ closedTotal = 0;
43
+ peak;
44
+ /** 이 구간에서 이미 제안을 냈나 — 같은 사실을 되풀어 방송하지 않는다(라이브 브리지의 교훈). */
45
+ suggestedFor;
46
+ windowMs;
47
+ constructor(tenantId, policy = firstFitPolicy, windowMs = DEMAND_WINDOW_MS) {
48
+ super(tenantId, policy);
49
+ this.windowMs = windowMs;
50
+ }
51
+ /**
52
+ * 계약전력 — **현장이 선언한 자리 속성**에서 읽는다(`EMS_PROPERTY.contractKW`).
53
+ *
54
+ * 여러 자리가 선언하면 **가장 큰 값**을 쓴다: 수전 지점의 계약이 분기의 것보다 크고, 트윈 전체의
55
+ * 한계는 수전이 정한다. 선언이 없으면 `undefined` — 계약을 모르면 계약 대비 판정을 하지 않는다
56
+ * (기본값을 지어내면 그 뒤 모든 판정이 거짓 위에 선다).
57
+ */
58
+ declaredContractKW() {
59
+ let max;
60
+ for (const loc of this.boardDef?.locations ?? []) {
61
+ for (const p of loc.properties ?? []) {
62
+ if (p.id !== EMS_PROPERTY.contractKW)
63
+ continue;
64
+ const v = Number(p.value);
65
+ if (!Number.isFinite(v) || v <= 0)
66
+ continue;
67
+ if (max === undefined || v > max)
68
+ max = v;
69
+ }
70
+ }
71
+ return max;
72
+ }
73
+ /**
74
+ * 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
75
+ *
76
+ * 가로챈 표본은 **다시 방출하지 않는다**: 원천이 이미 그 사실을 갖고 있고, 저널은 인입에서 한 번만
77
+ * 적는다(상위 `apply` 의 재방출 규약과 같은 이유).
78
+ */
79
+ apply(envelope) {
80
+ if (envelope.eventType === ENERGY_EVENT.measured) {
81
+ this.ingestMeasured(envelope);
82
+ return;
83
+ }
84
+ super.apply(envelope);
85
+ }
86
+ ingestMeasured(envelope) {
87
+ const d = envelope.data;
88
+ /* 시각은 **계측이 말한 것**이 먼저다(`data.at`) — 봉투 시각은 전달 시각일 수 있다.
89
+ 둘 다 없으면 어느 구간의 것인지 알 수 없다: 지금 시각으로 메우면 남의 구간에 실린다. */
90
+ const atMs = [String(d?.at ?? ''), String(envelope.eventTime ?? '')]
91
+ .map(v => Date.parse(v))
92
+ .find(v => Number.isFinite(v));
93
+ if (atMs === undefined)
94
+ throw new Error('energy.measured has no usable time (data.at / eventTime) — cannot place it in a demand window');
95
+ const id = String(d?.meterId ?? '').trim();
96
+ if (!id)
97
+ throw new Error('energy.measured has no meterId — a measurement with no meter cannot be accumulated');
98
+ /* 이 표본이 새 구간의 것이면 앞 구간을 먼저 닫는다 — 마감이 표본보다 늦으면 최대가 섞인다. */
99
+ this.closeDue(atMs);
100
+ this.openWindow(atMs);
101
+ const kW = Number.isFinite(Number(d?.kW)) ? Number(d.kW) : undefined;
102
+ const point = this.points.get(id) ?? { id, samplesInWindow: 0 };
103
+ /* 늦게 온 옛 표본이 최신 관측을 덮지 않게 — 상위 커널의 `stale` 판정과 같은 규율. */
104
+ if (point.atMs === undefined || atMs >= point.atMs) {
105
+ point.atMs = atMs;
106
+ if (kW !== undefined)
107
+ point.kW = kW;
108
+ if (Number.isFinite(Number(d?.kWh)))
109
+ point.kWh = Number(d.kWh);
110
+ if (Number.isFinite(Number(d?.powerFactor)))
111
+ point.powerFactor = Number(d.powerFactor);
112
+ }
113
+ point.samplesInWindow++;
114
+ this.points.set(id, point);
115
+ const w = this.open;
116
+ if (kW !== undefined) {
117
+ /*
118
+ * 구간의 부하는 **지점의 합**이다(수전 하나만 계량하는 현장도, 피더를 여럿 계량하는 현장도 있다).
119
+ * 마지막으로 관측된 값들을 더한다 — 표본 주기가 지점마다 달라도 이것이 그 순간의 최선이다.
120
+ * 지점 하나가 침묵하면 그 값은 낡은 채로 더해진다: 그래서 `atMs` 를 지점마다 남겨 소비처가
121
+ * 「이 지점은 3시간째 조용하다」를 볼 수 있게 한다.
122
+ */
123
+ const total = [...this.points.values()].reduce((sum, p) => sum + (p.kW ?? 0), 0);
124
+ if (w.maxKW === undefined || total > w.maxKW)
125
+ w.maxKW = total;
126
+ w.samples++;
127
+ w.meanKW = (w.meanKW === undefined ? total : (w.meanKW * (w.samples - 1) + total) / w.samples);
128
+ }
129
+ this.revision++;
130
+ this.judgeOpenWindow(atMs);
131
+ }
132
+ openWindow(atMs) {
133
+ const start = demandWindowStart(atMs, this.windowMs);
134
+ if (this.open?.startMs === start)
135
+ return;
136
+ const contractKW = this.declaredContractKW();
137
+ this.open = { startMs: start, endMs: start + this.windowMs, samples: 0, ...(contractKW !== undefined ? { contractKW } : {}) };
138
+ /* 새 구간이면 지점별 표본 수도 새로 센다 — 구간마다 「못 쟀다」를 답할 수 있어야 한다. */
139
+ for (const p of this.points.values())
140
+ p.samplesInWindow = 0;
141
+ this.suggestedFor = undefined;
142
+ }
143
+ /**
144
+ * 지난 구간을 닫는다 — **표본이 없으면 값을 만들지 않는다.**
145
+ *
146
+ * 침묵한 구간을 「0 kW」로 닫으면 그 트윈은 「그 15분 동안 전기를 쓰지 않았다」고 말하는 것이 된다.
147
+ * 구간이 지난 것은 사실이고 우리가 못 쟀다는 것도 사실이므로, 구간은 남기고 값은 비운다.
148
+ *
149
+ * 표본이 오지 않으면 마감도 오지 않는다(라이브에서 계측이 끊기면 열린 구간이 그대로 남는다).
150
+ * 그래서 이것은 **공개**다 — 호스트가 시각을 주며 부를 수 있다(`tick` 도 이것을 부른다).
151
+ */
152
+ closeDue(nowMs) {
153
+ const done = [];
154
+ while (this.open && nowMs >= this.open.endMs) {
155
+ const w = this.open;
156
+ if (w.contractKW !== undefined && w.maxKW !== undefined)
157
+ w.overContract = w.maxKW > w.contractKW;
158
+ this.closed.push(w);
159
+ this.closedTotal++;
160
+ if (this.closed.length > KEEP_CLOSED)
161
+ this.closed.splice(0, this.closed.length - KEEP_CLOSED);
162
+ done.push(w);
163
+ this.emitOp(ENERGY_EVENT.demandWindow, {
164
+ startMs: w.startMs,
165
+ endMs: w.endMs,
166
+ ...(w.maxKW !== undefined ? { maxKW: w.maxKW } : {}),
167
+ ...(w.meanKW !== undefined ? { meanKW: w.meanKW } : {}),
168
+ samples: w.samples,
169
+ ...(w.contractKW !== undefined ? { contractKW: w.contractKW } : {}),
170
+ ...(w.overContract !== undefined ? { overContract: w.overContract } : {}),
171
+ /*
172
+ * 부하를 셀 근거가 없었다는 사실을 코드로 낸다 — 화면이 「0 kW」와 「못 쟀다」를 구별할 수 있게.
173
+ *
174
+ * 이름이 조건과 정확히 같아야 한다: `samples` 는 **kW 를 실은 표본**의 수다. 표본은 왔는데
175
+ * kW 를 못 읽은 경우(계기 오류·필드 누락)가 실제로 있고, 그것은 「아무것도 오지 않았다」와
176
+ * 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
177
+ */
178
+ ...(w.samples === 0 ? { observedAbsence: 'no-load-samples' } : {})
179
+ });
180
+ /* 피크는 **마감된 구간**으로만 갱신한다 — 열린 구간의 최대는 아직 확정이 아니다. */
181
+ if (w.maxKW !== undefined && (this.peak === undefined || w.maxKW > this.peak.kW)) {
182
+ this.peak = { kW: w.maxKW, windowStartMs: w.startMs };
183
+ this.emitOp(ENERGY_EVENT.peak, { kW: w.maxKW, windowStartMs: w.startMs, ...(w.contractKW !== undefined ? { contractKW: w.contractKW } : {}) });
184
+ }
185
+ /* 다음 구간은 표본이 올 때 연다 — 미리 열면 오지 않은 구간을 존재하는 것처럼 만든다. */
186
+ this.open = undefined;
187
+ }
188
+ return done;
189
+ }
190
+ /**
191
+ * 열린 구간의 판정 — **이대로 가면 계약을 넘는가.**
192
+ *
193
+ * 예측은 「지금까지의 평균 부하가 구간 끝까지 이어진다」다. 단순하지만 그 가정을 **값과 함께 낸다**
194
+ * (`projectionBasis`) — 근거를 감춘 예측은 사용자가 검증할 수 없다. 남은 시간이 짧을수록 이 예측은
195
+ * 실제에 가까워진다(구간 초반의 경보는 성급할 수 있다는 뜻이고, 그것도 사용자가 알아야 한다).
196
+ *
197
+ * 넘을 것 같으면 **제안**을 낸다 — 무엇을 줄일 수 있는지는 모델이 선언한 감축 가능 설비가 답한다.
198
+ * 우리는 끄지 않는다.
199
+ */
200
+ judgeOpenWindow(atMs) {
201
+ const w = this.open;
202
+ if (!w || w.contractKW === undefined || w.meanKW === undefined)
203
+ return;
204
+ if (this.suggestedFor === w.startMs)
205
+ return;
206
+ const elapsed = Math.max(1, atMs - w.startMs);
207
+ const projected = (w.meanKW * elapsed + w.meanKW * (w.endMs - atMs)) / this.windowMs;
208
+ if (projected <= w.contractKW)
209
+ return;
210
+ this.suggestedFor = w.startMs;
211
+ this.emitOp(ENERGY_EVENT.drSuggested, {
212
+ windowStartMs: w.startMs,
213
+ projectedKW: projected,
214
+ contractKW: w.contractKW,
215
+ gapKW: projected - w.contractKW,
216
+ projectionBasis: 'mean-so-far',
217
+ /* 줄일 수 있는 것 — 선언된 감축 가능 설비다. 우선순위·최소 유지는 현장이 정하므로 여기서
218
+ 고르지 않고 **후보를 있는 대로** 낸다(고르는 것은 사람의 일이다). */
219
+ curtailableCandidates: this.curtailableIds()
220
+ });
221
+ }
222
+ /** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
223
+ curtailableIds() {
224
+ return (this.boardDef?.equipment ?? [])
225
+ .filter(e => String(e.kind ?? '') === 'curtailable-load')
226
+ .map(e => e.id);
227
+ }
228
+ /*
229
+ * ── 흐름 훅 넷 — **상속의 대가다** (§6 안 A) ─────────────────────────────
230
+ *
231
+ * `FlowEngine` 은 이 넷을 abstract 로 요구한다: 도착·오더·배정·작업 완료. 에너지에는 그 넷이 없다
232
+ * (물건이 도착하지 않고, 주문이 없고, 배정할 자원이 없고, 완료될 작업이 없다). 그래서 여기서
233
+ * **아무 일도 하지 않는다** — 그런데 조용히 넘기지 않는다: 이 커널이 그런 요청을 받았다는 것은
234
+ * **배선이 잘못됐다는 사실**이고(EMS 트윈에 물류 명령을 보낸 것), 조용히 넘기면 그 사실이 사라진다.
235
+ *
236
+ * 던지지도 않는다: 저널 재생 중이라면 트윈 전체가 멈춘다. 그래서 커널이 담을 줄 모르는 사건을
237
+ * 세는 자리(`ObservedReducer.unhandled`)와 같은 규율으로, **개수를 세어 상태로 낸다.**
238
+ */
239
+ flowRequests = new Map();
240
+ noteFlowRequest(hook) {
241
+ this.flowRequests.set(hook, (this.flowRequests.get(hook) ?? 0) + 1);
242
+ }
243
+ onArrival() {
244
+ this.noteFlowRequest('onArrival');
245
+ }
246
+ onOrder() {
247
+ this.noteFlowRequest('onOrder');
248
+ }
249
+ allocate() {
250
+ this.noteFlowRequest('allocate');
251
+ }
252
+ onTaskComplete() {
253
+ this.noteFlowRequest('onTaskComplete');
254
+ }
255
+ /** 시뮬 시간으로도 구간이 닫힌다 — 관측이 없어도 시간은 간다. */
256
+ tick(dtMs) {
257
+ super.tick(dtMs);
258
+ this.closeDue(this.clockMs);
259
+ }
260
+ getSnapshot() {
261
+ const snap = super.getSnapshot();
262
+ const contractKW = this.declaredContractKW();
263
+ const energy = {
264
+ points: [...this.points.values()].map(p => ({ ...p })),
265
+ ...(this.open
266
+ ? {
267
+ open: {
268
+ ...this.open,
269
+ ...(this.open.meanKW !== undefined ? { projectedKW: this.open.meanKW, projectionBasis: 'mean-so-far' } : {})
270
+ }
271
+ }
272
+ : {}),
273
+ closed: this.closed.map(w => ({ ...w })),
274
+ closedTotal: this.closedTotal,
275
+ ...(this.peak ? { peakSince: { ...this.peak } } : {}),
276
+ ...(contractKW !== undefined ? { contractKW } : {}),
277
+ /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
278
+ ...(this.flowRequests.size
279
+ ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) }
280
+ : {})
281
+ };
282
+ return { ...snap, energy };
283
+ }
284
+ }
@@ -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", "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,111 @@
1
+ /** 로케이션(수동) 타입 키 — 배전 계통의 구간. */
2
+ export const EMS_LOCATION_TYPES = ['incoming', 'feeder', 'submeter-zone'];
3
+ /** 설비(능동) 타입 키 — 계량 지점과 에너지 자원. */
4
+ export const EMS_EQUIPMENT_TYPES = ['meter', 'breaker', 'pv-array', 'battery', '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: 'curtailable-load',
98
+ role: 'equipment',
99
+ label: 'twin.type.curtailable-load',
100
+ /*
101
+ * 감축 가능 부하 — 줄일 수 있는 소비처(공조·충전기·비상시 미가동 라인).
102
+ *
103
+ * 표준에 이 이름은 없다: IEC 61850 은 설비를 종류로 부르고 「감축 가능」은 **운영 정책**이다.
104
+ * 그래서 `iec61850` 칸을 비우고 ISO 50001 의 SEU 로만 대응한다 — 억지로 논리 노드를 적으면
105
+ * 적합성 표가 거짓을 말한다.
106
+ */
107
+ standardClass: { iso50001: 'SEU' },
108
+ identity: { scheme: 'kernel:id' },
109
+ capabilities: ['curtailable', 'metered', 'operable']
110
+ }
111
+ ];
package/dist/epcis.d.ts CHANGED
@@ -127,7 +127,7 @@ interface EpcisHeader {
127
127
  /**
128
128
  * **기록** 시각(ISO) — 발생 시각과 별개다.
129
129
  *
130
- * 이 둘이 갈라지는 것이 실 연동의 정상이다(현장에서 일어난 뒤 늦게 도착). 소비처가 순서를 판정할 때
130
+ * 이 둘이 어긋나는 것이 실 연동의 정상이다(현장에서 일어난 뒤 늦게 도착). 소비처가 순서를 판정할 때
131
131
  * `eventTime` 만 보면 늦게 도착한 옛 이벤트가 최신 상태를 덮는다 — 그 판정의 재료가 이 값이다.
132
132
  */
133
133
  recordTime?: string;
@@ -228,7 +228,7 @@ export declare const ILMD_ATTR: {
228
228
  * 두 숫자 마디의 자릿수 합은 13(점 제외), Lot 은 GS3A3Component(URI 이스케이프 허용).
229
229
  */
230
230
  export declare function lgtinClass(companyPrefix: string, itemRefAndIndicator: string, lot: string): string;
231
- /** 식별자를 뜯어 본 결과 — 소비처가 문자열을 자르지 않게 한다(자르면 규칙이 갈라진다). */
231
+ /** 식별자를 뜯어 본 결과 — 소비처가 문자열을 자르지 않게 한다(자르면 규칙이 어긋난다). */
232
232
  export interface ParsedEpc {
233
233
  /** 표준 스킴. 모르면 'unknown'(원문을 그대로 남긴다 — 추측하지 않는다). */
234
234
  scheme: 'sgtin' | 'lgtin' | 'idpat' | 'sscc' | 'gdti' | 'grai' | 'giai' | 'sgln' | 'unknown';
@@ -246,7 +246,7 @@ export interface ParsedEpc {
246
246
  /**
247
247
  * EPC/클래스 식별자 파서 — **표준 지식이라 커널이 소유한다.**
248
248
  *
249
- * 소비처가 `uri.split(':').pop()` 으로 자르면 규칙이 갈라진다(실제로 화면이 LGTIN 의 마지막 마디인
249
+ * 소비처가 `uri.split(':').pop()` 으로 자르면 규칙이 어긋난다(실제로 화면이 LGTIN 의 마지막 마디인
250
250
  * 로트를 SKU 이름으로 표시할 위험이 있었다). 뜯는 일은 여기 한 곳에서 한다.
251
251
  */
252
252
  export declare function parseEpc(uri: string): ParsedEpc;
package/dist/epcis.js CHANGED
@@ -72,7 +72,7 @@ export function lgtinClass(companyPrefix, itemRefAndIndicator, lot) {
72
72
  /**
73
73
  * EPC/클래스 식별자 파서 — **표준 지식이라 커널이 소유한다.**
74
74
  *
75
- * 소비처가 `uri.split(':').pop()` 으로 자르면 규칙이 갈라진다(실제로 화면이 LGTIN 의 마지막 마디인
75
+ * 소비처가 `uri.split(':').pop()` 으로 자르면 규칙이 어긋난다(실제로 화면이 LGTIN 의 마지막 마디인
76
76
  * 로트를 SKU 이름으로 표시할 위험이 있었다). 뜯는 일은 여기 한 곳에서 한다.
77
77
  */
78
78
  export function parseEpc(uri) {
@@ -240,7 +240,7 @@ export function validateEpcisEvent(e) {
240
240
  v.push('recordTime ISO8601 아님');
241
241
  }
242
242
  /* 개체·로트 마스터데이터는 **생겨나는 순간에만** 실린다(§7.3.8) — ObjectEvent(ADD) · Transformation.
243
- * 아무 이벤트에나 허용하면 "생애 동안 정적" 이라는 성질이 깨지고 이벤트마다 값이 갈라진다. */
243
+ * 아무 이벤트에나 허용하면 "생애 동안 정적" 이라는 성질이 깨지고 이벤트마다 값이 어긋난다. */
244
244
  if (e.ilmd !== undefined) {
245
245
  const allowed = (e.type === 'ObjectEvent' && e.action === 'ADD') || e.type === 'TransformationEvent';
246
246
  if (!allowed)
@@ -32,7 +32,7 @@ export interface StructureSegment {
32
32
  /**
33
33
  * 구조가 바뀐 지점마다 무엇이 사라졌는지. 조용히 넘어가지 않는다.
34
34
  *
35
- * 갈아타기의 결과 자체는 `StructureShift`(계약) 한 벌이고, 재생은 거기에 **몇 번째 마디였나**만
35
+ * 구조 전환의 결과 자체는 `StructureShift`(계약) 한 벌이고, 재생은 거기에 **몇 번째 마디였나**만
36
36
  * 더한다 — 두 벌로 두면 한쪽에 필드가 늘어날 때 다른 쪽이 조용히 뒤처진다.
37
37
  */
38
38
  export interface SegmentShift extends StructureShift {
@@ -45,7 +45,7 @@ export interface SegmentShift extends StructureShift {
45
45
  * 새 공장에 대고 접게 되어 이력이 거짓말을 하기 때문이다(도장 부스가 둘이던 시절의 사실을 여섯 개짜리
46
46
  * 공장에 접는다). 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
47
47
  *
48
- * 셋째 길이 이것이다: 마디마다 **그때의 구조**로 접고, 경계에서 구조만 갈아탄다(관측된 사실은 이어
48
+ * 셋째 길이 이것이다: 마디마다 **그때의 구조**로 접고, 경계에서 구조만 전환한다(관측된 사실은 이어
49
49
  * 간다). 그러면 이력이 "그때 그 공장의 사실" 로 계속 읽힌다.
50
50
  *
51
51
  * 경계에서 사라진 자원은 결과에 실어 보낸다 — 수가 줄어든 것을 사용자가 눈치채지 못하면 안 된다.
@@ -46,7 +46,7 @@ export function replay(model, events) {
46
46
  * 새 공장에 대고 접게 되어 이력이 거짓말을 하기 때문이다(도장 부스가 둘이던 시절의 사실을 여섯 개짜리
47
47
  * 공장에 접는다). 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
48
48
  *
49
- * 셋째 길이 이것이다: 마디마다 **그때의 구조**로 접고, 경계에서 구조만 갈아탄다(관측된 사실은 이어
49
+ * 셋째 길이 이것이다: 마디마다 **그때의 구조**로 접고, 경계에서 구조만 전환한다(관측된 사실은 이어
50
50
  * 간다). 그러면 이력이 "그때 그 공장의 사실" 로 계속 읽힌다.
51
51
  *
52
52
  * 경계에서 사라진 자원은 결과에 실어 보낸다 — 수가 줄어든 것을 사용자가 눈치채지 못하면 안 된다.