@operato/ops-contract 0.1.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.
Files changed (41) hide show
  1. package/README.md +23 -0
  2. package/dist/capability.d.ts +142 -0
  3. package/dist/capability.js +127 -0
  4. package/dist/capacity.d.ts +99 -0
  5. package/dist/capacity.js +172 -0
  6. package/dist/contract.d.ts +3557 -0
  7. package/dist/contract.js +1248 -0
  8. package/dist/domain-catalog.d.ts +280 -0
  9. package/dist/domain-catalog.js +322 -0
  10. package/dist/domain-definition.d.ts +356 -0
  11. package/dist/domain-definition.js +137 -0
  12. package/dist/ems-profile.d.ts +147 -0
  13. package/dist/ems-profile.js +367 -0
  14. package/dist/energy-ingest.d.ts +214 -0
  15. package/dist/energy-ingest.js +801 -0
  16. package/dist/epcis.d.ts +458 -0
  17. package/dist/epcis.js +640 -0
  18. package/dist/face2-adapter.d.ts +191 -0
  19. package/dist/face2-adapter.js +284 -0
  20. package/dist/index.d.ts +18 -0
  21. package/dist/index.js +39 -0
  22. package/dist/iso-duration.d.ts +5 -0
  23. package/dist/iso-duration.js +43 -0
  24. package/dist/master-data.d.ts +46 -0
  25. package/dist/master-data.js +100 -0
  26. package/dist/mes-profile.d.ts +14 -0
  27. package/dist/mes-profile.js +59 -0
  28. package/dist/operational-ingest.d.ts +44 -0
  29. package/dist/operational-ingest.js +379 -0
  30. package/dist/operations-capability.d.ts +117 -0
  31. package/dist/operations-capability.js +120 -0
  32. package/dist/scenario-validate.d.ts +15 -0
  33. package/dist/scenario-validate.js +72 -0
  34. package/dist/vocabulary.d.ts +28 -0
  35. package/dist/vocabulary.js +81 -0
  36. package/dist/wms-profile.d.ts +20 -0
  37. package/dist/wms-profile.js +58 -0
  38. package/dist/yms-profile.d.ts +15 -0
  39. package/dist/yms-profile.js +39 -0
  40. package/dist-cjs/index.cjs +3767 -0
  41. package/package.json +30 -0
@@ -0,0 +1,367 @@
1
+ /** 로케이션(수동) 타입 키 — 배전 계통의 구간. */
2
+ export const EMS_LOCATION_TYPES = ['incoming', 'feeder', 'submeter-zone'];
3
+ /**
4
+ * 계량 지점의 방향 — **무엇을 재는 계량기인가.**
5
+ *
6
+ * import 쓰는 쪽. 계통에서 받는다(대부분의 계량기).
7
+ * export 내는 쪽. 발전이 계통으로 나가는 것을 잰다.
8
+ * bidirectional 양쪽을 한 계량기로 잰다(상계 거래·축전지). 부호가 방향을 담는다.
9
+ *
10
+ * 선언하지 않으면 **모르는 것**이고, 모르는 것은 부하로 센다. 빼면 부하가 오류 없이 작아지고, 작아진
11
+ * 부하는 「계약 안쪽」이라는 더 위험한 거짓을 만든다(같은 이유로 정체 모를 계량기도 합에 넣는다).
12
+ *
13
+ * `bidirectional` 은 **그 계량기가 순 유입을 재는 것**이다. 발전을 부하에서 빼는 것이 아니다 — 그
14
+ * 계량기가 읽은 값이 그 접속점의 사실이고, 요금이 그 값에 매겨진다.
15
+ *
16
+ * 표준 근거: 계량기의 적산 레지스터가 유입·유출로 갈려 있고(IEC 62053 계열), ISO 50001 은 유입 경계를
17
+ * 따로 둔다(`EnergyInput`). 우리가 지은 낱말이 아니다.
18
+ */
19
+ export const METER_DIRECTION = ['import', 'export', 'bidirectional'];
20
+ /** 설비(능동) 타입 키 — 계량 지점과 에너지 자원. */
21
+ export const EMS_EQUIPMENT_TYPES = ['meter', 'breaker', 'pv-array', 'battery', 'utility', 'curtailable-load'];
22
+ /**
23
+ * 커널이 **읽는** 자리 속성 — 뜻을 코드 한가운데 숨기지 않는다.
24
+ *
25
+ * 속성 자체는 열려 있고(`ResourceProperty`) 어휘는 표준이 정하지 않는다. 그래서 커널이 판정에 쓰는
26
+ * 것만 여기 이름으로 못 박는다 — 이 목록에 없는 속성은 커널이 나르기만 하고 해석하지 않는다.
27
+ */
28
+ export const EMS_PROPERTY = {
29
+ /** 계약전력(kW) — 수전·분기 자리에 선언한다. 없으면 계약 대비 판정을 하지 않는다. */
30
+ contractKW: 'contract.kW',
31
+ /*
32
+ * ── 계량 지점의 방향 (2026-08-25) ──────────────────────────────────────────
33
+ * **요금은 계통 접속점의 순 유입에 매겨진다.** 그러니 그 지점의 계량기가 무엇을 재는지 모르면
34
+ * 요금을 사실대로 말할 수 없다 — 내보낸 양을 쓴 양으로 세면 계약 대비 판단이 반대로 뒤집힌다.
35
+ *
36
+ * 이 축이 없는 동안 계량 지점의 kW 를 전부 부하로 셌다. 상계 거래를 하는 현장, 축전지를 붙인 현장,
37
+ * 열병합이 있는 현장 — 접속점 계량기가 양쪽을 재는 곳은 늘 있다.
38
+ *
39
+ * **발전기를 계량기로 만드는 것과 다른 일이다.** 발전은 설비의 사실이고(`energyGenerating`), 그것을
40
+ * 계량 지점으로 보내는 것은 여전히 하지 않는다. 이 축은 **접속점 계량기**가 무엇을 재는지 말하는
41
+ * 자리다.
42
+ *
43
+ * 방향은 **관측이 아니라 선언**이다. 표본마다 바뀌는 값이 아니고, 그 계량기가 무엇을 재도록
44
+ * 설치되었는지는 현장이 안다. 그래서 표본(`EnergyRecord`)에 넣지 않고 자원 속성으로 둔다.
45
+ *
46
+ * 값은 계량기 자신의 낱말을 쓴다(적산 레지스터가 그렇게 갈려 있다). §`METER_DIRECTION`.
47
+ */
48
+ meterDirection: 'meter.direction',
49
+ /*
50
+ * ── 부하 계수 — 시뮬레이션이 전기를 만들 수 있게 (2026-08-14) ───────────────
51
+ * 「이 설비가 돌면 몇 kW 인가」는 **현장이 아는 값**이다. 그래서 타입 기본값을 두지 않는다:
52
+ * 선언하지 않은 설비는 부하를 만들지 않는다. 기본값을 두면 아무도 그 수가 짐작인 줄 모른 채
53
+ * 요금 판정이 그 위에 선다.
54
+ */
55
+ /** 가동 중 소비(kW). */
56
+ ratedKW: 'power.ratedKW',
57
+ /** 멈춰 있을 때의 소비(kW). 없으면 멈춘 동안을 **비운다** — 0 이라고 주장하지 않는다. */
58
+ standbyKW: 'power.standbyKW',
59
+ /*
60
+ * ── 요금 단가 — 수를 금액으로 바꾸는 선언 (2026-08-17) ─────────────────────
61
+ *
62
+ * 그동안 트윈은 「최대수요 328kW」·「전력량 1,547kWh」까지 말하고 멈췄다. 그런데 피크를 깎는 일이
63
+ * 돈이 되는 이유는 **요금이 둘로 나뉘기** 때문이다: 사용량(kWh)에 붙는 요금과 **최대수요(kW)에 붙는
64
+ * 기본요금**. 단가가 없으면 그 절반을 말할 수 없고, 그래서 「피크를 깎아 얼마를 아끼나」에 답하지 못했다.
65
+ *
66
+ * 단가는 **현장의 계약**이다(같은 나라 안에서도 사업자·요금제마다 다르다). 그래서 기본값을 두지
67
+ * 않는다 — 선언하지 않으면 금액을 계산하지 않는다. 짐작한 단가로 낸 금액은 숫자가 있다는 것만으로
68
+ * 사람을 결정으로 밀어붙인다.
69
+ *
70
+ * 계약전력(`contract.kW`)과 같은 자리(수전)에 선언한다.
71
+ */
72
+ /** 기본요금 단가 — 최대수요 1kW 당(청구 주기 기준). */
73
+ demandChargePerKW: 'tariff.demandChargePerKW',
74
+ /**
75
+ * **요금적용전력**(kW) — 기본요금이 실제로 매겨지는 기준. 계약전력과 다른 값이다.
76
+ *
77
+ * 기본요금이 걸리는 수는 이 주기에 잰 최대가 아닌 경우가 많다 — 지난 몇 달의 최고를 끌고 가거나,
78
+ * 약정 용량으로 매기거나, 사업자가 따로 정한다. 그 규칙은 나라와 계약마다 다르므로 커널이 계산하지
79
+ * 않고 **계산된 결과를 받는다.** 이 값이 있으면 기본요금이 조건부 파생이 아니라 사실이 된다.
80
+ *
81
+ * 계약전력(`contract.kW`)을 이 자리에 넣지 말 것. 넘었을 때의 뜻이 다르다 — 계약 초과는 약정
82
+ * 위반이고, 이쪽은 다음 주기의 기본요금이 오른다는 뜻이다.
83
+ */
84
+ billingDemandKW: 'tariff.billingDemandKW',
85
+ /** 사용량 단가 — 1kWh 당. */
86
+ energyChargePerKWh: 'tariff.energyChargePerKWh',
87
+ /** 통화 — ISO 4217 코드(USD·KRW…). 없으면 금액에 단위를 붙이지 않는다. */
88
+ currency: 'tariff.currency',
89
+ /*
90
+ * ── 축전지의 용량과 방전 정책 (2026-08-17) ─────────────────────────────────
91
+ *
92
+ * 「배터리로 피크를 깎는다」를 시뮬이 보이려면 방전을 만들어야 하고, 그것은 **선언에서** 나와야 한다.
93
+ * 임계·최대율·예비를 우리가 정하면 그 수가 어디서 왔는지 아무도 설명할 수 없다.
94
+ *
95
+ * ── 선언한다는 것은 「그 자동화가 있다」는 주장이다 ─────────────────────────
96
+ * 이 정책을 모델에 적으면 **라이브가 아닌 모든 구동이 그 규칙으로 돈다.** 현장에 BESS 제어기가 없는데
97
+ * 적으면 파생 부하가 실제보다 낙관적으로 나온다(피크가 깎인 것으로 보인다). 그러니 「도입하면?」을
98
+ * 묻는 것이라면 선언이 아니라 **what-if 가 덮어쓸 일**이다.
99
+ *
100
+ * 용량이 없으면 방전하지 않는다 — 용량을 모르면 「얼마나 버티나」를 답할 수 없고, 버티는 시간을
101
+ * 모른 채 깎으면 무한히 깎을 수 있다고 주장하는 셈이다.
102
+ */
103
+ /** 축전지 용량(kWh) — SOC 를 에너지로 바꾸는 값. 없으면 방전을 만들지 않는다. */
104
+ capacityKWh: 'storage.capacityKWh',
105
+ /** 이 순수요를 넘으면 방전한다(kW) — 피크 억제 임계. */
106
+ dischargeAboveKW: 'dispatch.dischargeAboveKW',
107
+ /** 최대 방전율(kW) — 인버터가 낼 수 있는 한계. */
108
+ maxDischargeKW: 'dispatch.maxDischargeKW',
109
+ /** 예비 SOC(%) — 이 아래로는 쓰지 않는다(비상 대비). 없으면 0 으로 본다. */
110
+ reserveSoc: 'dispatch.reserveSoc',
111
+ /**
112
+ * 시뮬레이션이 **출발할 때의 SOC(%)** — 씨앗값이다.
113
+ *
114
+ * 계측이 SOC 를 알려 주는 트윈에는 필요 없다(잰 값이 진실이다). 시뮬 트윈에는 알려 줄 것이 없어서
115
+ * 「SOC 를 모르면 방전하지 않는다」는 규율에 걸려 **배터리가 아무 일도 하지 못했다** — 정책을 선언해도
116
+ * 피크가 한 톨도 깎이지 않았다(실화면에서 그렇게 났다).
117
+ *
118
+ * 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이긴다.
119
+ */
120
+ initialSoc: 'storage.initialSoc',
121
+ /*
122
+ * ── 발전 — 태양광이 서 있기만 하던 자리 (2026-08-18) ────────────────────────
123
+ *
124
+ * 계통도에 태양광을 그려 놓고 시뮬에서는 한 톨도 만들지 못했다. 미러는 원천이 발전량을 보내 주지만,
125
+ * 시뮬 트윈에는 그것을 만들 근거가 없었다 — 그래서 「태양광을 늘리면 피크가 얼마나 내려가나」를 물을 수
126
+ * 없었다(what-if 의 값이 절반만 성립했다).
127
+ *
128
+ * **형상을 우리가 지어내지 않는다.** 위도·계절·날씨로 곡선을 만들면 그 수가 어디서 왔는지 아무도
129
+ * 설명할 수 없고, 흐린 날 현장의 실적과 어긋난다. 그래서 현장이 **하루 형상을 적는다**: 24개 비율
130
+ * (0~1)이면 그것이 그 현장의 곡선이다(측정한 형상을 그대로 붙일 수 있다).
131
+ *
132
+ * 정격만 있고 형상이 없으면 발전하지 않는다 — 하루 종일 정격으로 발전하는 태양광은 없다.
133
+ */
134
+ /**
135
+ * 발전 정격(kW) — **교류 쪽 최대 출력**. 계통으로 나가는 값이다.
136
+ *
137
+ * 이용률의 분모가 이 값이다(만든 양 ÷ 정격 × 시간). **직류 쪽 정격(§`genRatedKWdc`)과 다르고 더 작다** —
138
+ * 두 값을 한 이름에 담으면 이용률이 그 차이만큼 틀리고, 틀린 이유가 아무 데도 표시되지 않는다.
139
+ *
140
+ * 설비마다 선언하거나, 설비별 값을 모르는 현장은 **자리(수전)에 합계로** 선언한다.
141
+ */
142
+ genRatedKW: 'generation.ratedKW',
143
+ /**
144
+ * 발전 정격(kW) — **직류 쪽**. 변환기(인버터)의 입력 쪽 정격이고, 태양광이면 패널 정격의 합이다.
145
+ *
146
+ * 이용률의 분모로 쓰지 않는다. 이 값이 교류 정격보다 큰 것은 설계이고(변환기를 그렇게 고른다),
147
+ * 그 차이가 「맑은 정오에도 교류 출력이 더 오르지 않는 이유」를 설명한다.
148
+ */
149
+ genRatedKWdc: 'generation.ratedKWdc',
150
+ /**
151
+ * 하루 형상 — 쉼표로 나눈 **24개 비율**(0~1). 시각(현장 시간대)의 정격 대비 출력이다.
152
+ *
153
+ * 예: `0,0,0,0,0,0,0.05,0.2,0.45,0.7,0.9,1,1,0.95,0.8,0.6,0.35,0.12,0.02,0,0,0,0,0`
154
+ */
155
+ genDailyProfile: 'generation.dailyProfile'
156
+ };
157
+ export const EMS_PROPERTY_SPEC = {
158
+ [EMS_PROPERTY.contractKW]: { uom: 'kW', dataType: 'xs:double', note: 'contracted power at the metering point, in kW.' },
159
+ /* 값이 셋 중 하나여야 한다 — 그 밖의 낱말은 받지 않는다(뜻이 통할 것 같은 말도 받지 않는다). */
160
+ [EMS_PROPERTY.meterDirection]: {
161
+ dataType: 'xs:string',
162
+ note: 'what this metering point measures: "import" (drawn from the grid), "export" (generation sent out), ' +
163
+ 'or "bidirectional" (one meter for both, sign carries the direction). ' +
164
+ 'Left undeclared, the point counts as load — leaving it out never makes demand look smaller than it is.'
165
+ },
166
+ [EMS_PROPERTY.ratedKW]: { uom: 'kW', dataType: 'xs:double', note: 'power drawn while running, in kW.' },
167
+ [EMS_PROPERTY.standbyKW]: { uom: 'kW', dataType: 'xs:double', note: 'power drawn while idle, in kW.' },
168
+ [EMS_PROPERTY.demandChargePerKW]: { dataType: 'xs:double', note: 'demand charge per kW of billing-period peak, in the declared currency.' },
169
+ [EMS_PROPERTY.billingDemandKW]: {
170
+ uom: 'kW',
171
+ dataType: 'xs:double',
172
+ note: 'billing demand in kW — the basis the demand charge is actually billed on (often a rolling 12-month peak). Not the contracted power.'
173
+ },
174
+ [EMS_PROPERTY.energyChargePerKWh]: { dataType: 'xs:double', note: 'energy charge per kWh, in the declared currency.' },
175
+ [EMS_PROPERTY.currency]: { dataType: 'xs:string', note: 'ISO 4217 currency code, e.g. USD or KRW.' },
176
+ [EMS_PROPERTY.capacityKWh]: { uom: 'kWh', dataType: 'xs:double', note: 'usable energy of the storage, in kWh.' },
177
+ [EMS_PROPERTY.dischargeAboveKW]: { uom: 'kW', dataType: 'xs:double', note: 'net demand above which the storage discharges, in kW.' },
178
+ [EMS_PROPERTY.maxDischargeKW]: { uom: 'kW', dataType: 'xs:double', note: 'inverter limit on discharge rate, in kW.' },
179
+ /* 퍼센트다 — 0.8 은 0.8% 이고 80% 가 아니다. 이 한 줄이 없어서 배터리가 조용히 비어 있었다. */
180
+ [EMS_PROPERTY.reserveSoc]: { uom: '%', dataType: 'xs:double', range: [0, 100], note: 'reserve state of charge as a percentage 0-100 (20 means 20%), never discharged below.' },
181
+ [EMS_PROPERTY.initialSoc]: { uom: '%', dataType: 'xs:double', range: [0, 100], note: 'starting state of charge as a percentage 0-100 (80 means 80%, not 0.8).' },
182
+ [EMS_PROPERTY.genRatedKW]: {
183
+ uom: 'kW',
184
+ dataType: 'xs:double',
185
+ note: 'rated AC generation output, in kW — the denominator of capacity factor. Declare per equipment, or on the incoming location as a site total.'
186
+ },
187
+ [EMS_PROPERTY.genRatedKWdc]: {
188
+ uom: 'kW',
189
+ dataType: 'xs:double',
190
+ note: 'rated DC generation capacity, in kW (panel rating for PV). Larger than the AC rating by design; not the capacity-factor denominator.'
191
+ },
192
+ [EMS_PROPERTY.genDailyProfile]: {
193
+ dataType: 'xs:string',
194
+ note: 'daily shape as 24 comma-separated fractions of rated output (0-1), one per hour of local time — the site declares its own curve; we do not invent one.'
195
+ }
196
+ };
197
+ /**
198
+ * 하루 형상에서 **이 시각의 비율**을 읽는다 — 없거나 형태가 아니면 `undefined`(발전하지 않는다).
199
+ *
200
+ * 24개가 아니면 받지 않는다: 값이 몇 개인지 짐작해 늘리거나 자르면, 사람이 적은 곡선과 우리가 쓰는 곡선이
201
+ * 달라진다. 0~1 밖의 값도 받지 않는다(정격의 배수로 발전하는 태양광은 없다).
202
+ */
203
+ export function generationFractionAt(profile, hourOfDay) {
204
+ const parts = String(profile ?? '')
205
+ .split(',')
206
+ .map(v => v.trim())
207
+ .filter(v => v !== '');
208
+ if (parts.length !== 24)
209
+ return undefined;
210
+ const h = Math.floor(hourOfDay);
211
+ if (!Number.isFinite(h) || h < 0 || h > 23)
212
+ return undefined;
213
+ const v = Number(parts[h]);
214
+ if (!Number.isFinite(v) || v < 0 || v > 1)
215
+ return undefined;
216
+ return v;
217
+ }
218
+ export const EMS_TYPES = [
219
+ /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
220
+ {
221
+ key: 'incoming',
222
+ role: 'location',
223
+ /* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
224
+ level: 'Other',
225
+ label: 'twin.type.incoming',
226
+ /* 수전 지점 — 계약전력이 걸리는 자리이고, 요금의 근거가 되는 수요는 여기서 잰다.
227
+ ISO 50001 의 「에너지 유입」 경계이기도 하다(조직의 에너지 검토가 여기서 시작한다). */
228
+ standardClass: { iec61850: 'MMTR', iso50001: 'EnergyInput' },
229
+ identity: { scheme: 'kernel:id' },
230
+ capabilities: ['metered']
231
+ },
232
+ {
233
+ key: 'feeder',
234
+ role: 'location',
235
+ /* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
236
+ level: 'Other',
237
+ label: 'twin.type.feeder',
238
+ /* 분기 회로 — 부하 분해의 단위. 계약전력의 하위 배분이 여기서 정해진다. */
239
+ standardClass: { iec61850: 'Feeder', iso50001: 'EnergyUse' },
240
+ identity: { scheme: 'kernel:id' },
241
+ capabilities: ['metered']
242
+ },
243
+ {
244
+ key: 'submeter-zone',
245
+ role: 'location',
246
+ /* 전기 계통의 구간은 **설비 계층의 단이 아니다** — 표준의 탈출구(`Other`)를 쓴다(아래 주석). */
247
+ level: 'Other',
248
+ label: 'twin.type.submeter-zone',
249
+ /*
250
+ * 구역 계량 — 여러 부하를 한 계량기로 묶어 재는 자리(공조·조명처럼 개별 계량이 없는 것들).
251
+ *
252
+ * ISO 50001 의 **SEU**(유의 에너지 사용처)가 대개 이 알갱이다. 개별 설비까지 재지 못하는 현장이
253
+ * 많고, 그것을 「모른다」로 두는 대신 **묶음으로 아는 것**이 정직하다.
254
+ */
255
+ standardClass: { iec61850: 'MMXU', iso50001: 'SEU' },
256
+ identity: { scheme: 'kernel:id' },
257
+ capabilities: ['metered']
258
+ },
259
+ /* ── 설비: 계량 지점과 에너지 자원 ──────────────────────────────────────── */
260
+ {
261
+ key: 'meter',
262
+ role: 'equipment',
263
+ label: 'twin.type.meter',
264
+ /* 계량기 — 측정 논리 노드(MMXU=측정단위, MMTR=적산). 자산으로도 하나다(ISO 55000). */
265
+ standardClass: { iec61850: 'MMXU', iso55000: 'Asset', iso50001: 'MeasurementPoint' },
266
+ identity: { scheme: 'kernel:id' },
267
+ capabilities: ['metered', 'operable']
268
+ },
269
+ {
270
+ key: 'breaker',
271
+ role: 'equipment',
272
+ label: 'twin.type.breaker',
273
+ /*
274
+ * 차단기 — IEC 61850 `XCBR`. **우리는 이것을 조작하지 않는다**(안전 계통은 범위 밖: ems.md §1).
275
+ * 상태를 읽어 계통 구성을 알 뿐이다.
276
+ *
277
+ * 능력은 `switching` 이다 — `operable` 이 아니다(2026-08-14). `operable` 이던 동안 저작면이 차단기에
278
+ * **제어 컴포넌트**를 권했다: 우리가 하지 않기로 한 조작을 화면이 부추긴 것이다. 「위치를 읽는다」와
279
+ * 「명령을 받는다」는 다른 능력이고, 이제 그 둘이 갈려 있다(capability.ts 주석).
280
+ */
281
+ standardClass: { iec61850: 'XCBR', iso55000: 'Asset' },
282
+ identity: { scheme: 'kernel:id' },
283
+ capabilities: ['switching']
284
+ },
285
+ {
286
+ key: 'pv-array',
287
+ role: 'equipment',
288
+ label: 'twin.type.pv-array',
289
+ /* 태양광 어레이 — IEC 61850-7-420(분산자원)의 `DPVA`. 발전과 계량은 다른 능력이다. */
290
+ standardClass: { iec61850: 'DPVA', iso55000: 'Asset', iso50001: 'RenewableSupply' },
291
+ identity: { scheme: 'kernel:id' },
292
+ capabilities: ['energyGenerating', 'metered', 'operable']
293
+ },
294
+ {
295
+ key: 'battery',
296
+ role: 'equipment',
297
+ label: 'twin.type.battery',
298
+ /* 축전지(ESS) — `ZBAT`. 충전·방전을 나눠 재고, 저장은 보관(`storable`)이 아니다(물건이 아니다). */
299
+ standardClass: { iec61850: 'ZBAT', iso55000: 'Asset' },
300
+ identity: { scheme: 'kernel:id' },
301
+ capabilities: ['energyStoring', 'metered', 'operable']
302
+ },
303
+ {
304
+ key: 'utility',
305
+ role: 'equipment',
306
+ label: 'twin.type.utility',
307
+ /*
308
+ * 공통 설비 — 공조·컴프레서·칠러·조명·폐수처리처럼 **어느 공정에도 귀속되지 않는** 소비처.
309
+ *
310
+ * ── 왜 따로 있나 (2026-08-14) ────────────────────────────────────────────
311
+ * 물류·생산 트윈에서 이런 것들은 **설비가 아니다**(공정에 매핑되지 않으므로 자원 축에 없다).
312
+ * 그런데 에너지에서는 소비의 절반을 차지하고 감축 후보 1순위다 — 담을 자리가 반드시 있어야 한다.
313
+ *
314
+ * 처음에는 `curtailable-load` 하나로 받으려 했다. 그런데 그 이름은 **「줄일 수 있다」고 주장**한다:
315
+ * 폐수처리·방폭 환기·서버실 냉방은 공통이지만 줄일 수 없고, 그것을 감축 가능으로 두면 트윈이
316
+ * 「이걸 줄이면 됩니다」라는 거짓 제안을 한다. 공통성과 감축 가능성은 **다른 축**이다.
317
+ *
318
+ * 표준: IEC 61850 에 「공통 설비」라는 논리 노드는 없다(설비 종류마다 다른 노드다) — 비운다.
319
+ * ISO 50001 의 SEU 는 이 부류를 가장 많이 가리킨다(유의 에너지 사용처).
320
+ */
321
+ standardClass: { iso50001: 'SEU', iso55000: 'Asset' },
322
+ identity: { scheme: 'kernel:id' },
323
+ capabilities: ['metered', 'operable']
324
+ },
325
+ {
326
+ key: 'curtailable-load',
327
+ role: 'equipment',
328
+ label: 'twin.type.curtailable-load',
329
+ /*
330
+ * 감축 가능 부하 — 줄일 수 있는 소비처(공조·충전기·비상시 미가동 라인).
331
+ *
332
+ * 표준에 이 이름은 없다: IEC 61850 은 설비를 종류로 부르고 「감축 가능」은 **운영 정책**이다.
333
+ * 그래서 `iec61850` 칸을 비우고 ISO 50001 의 SEU 로만 대응한다 — 억지로 논리 노드를 적으면
334
+ * 적합성 표가 거짓을 말한다.
335
+ */
336
+ standardClass: { iso50001: 'SEU' },
337
+ identity: { scheme: 'kernel:id' },
338
+ capabilities: ['curtailable', 'metered', 'operable']
339
+ }
340
+ ];
341
+ /**
342
+ * 이 자리의 **전기 상류** — 어디서 전기를 받는가.
343
+ *
344
+ * ── 왜 함수로 두나 (2026-08-18) ─────────────────────────────────────────────
345
+ * 두 세대가 섞여 있다. 새 모델은 `upstream` 을 적고, 그 전 모델은 분기의 `parentId` 에 수전을 적었다.
346
+ * 판정을 소비처마다 쓰면(호스트·계통도·커널) 한쪽만 고쳐지고 그때부터 화면과 계산이 다른 계통을 말한다.
347
+ * 그래서 **읽는 규칙을 한 곳**에 둔다.
348
+ *
349
+ * 옛 세대를 흡수하는 조건이 좁다: `parentId` 가 **전기 자리**(수전·분기·구역 계량)를 가리킬 때만
350
+ * 상류로 읽는다. 공장·구역 같은 공간 부모를 상류로 읽으면 없는 결선을 만들어 낸다.
351
+ */
352
+ export function electricalUpstreamOf(locations, id) {
353
+ const byId = new Map((locations ?? []).filter(l => l?.id).map(l => [String(l.id), l]));
354
+ const self = byId.get(String(id));
355
+ if (!self)
356
+ return undefined;
357
+ if (self.upstreamId)
358
+ return String(self.upstreamId);
359
+ const parent = self.parentId ? byId.get(String(self.parentId)) : undefined;
360
+ if (!parent)
361
+ return undefined;
362
+ return isElectricalLocationType(String(parent.type ?? '')) ? String(parent.id) : undefined;
363
+ }
364
+ /** 전기 계통의 자리인가 — 수전·분기·구역 계량. */
365
+ export function isElectricalLocationType(type) {
366
+ return EMS_LOCATION_TYPES.includes(type);
367
+ }
@@ -0,0 +1,214 @@
1
+ import { type CanonicalEnvelope } from './contract.ts';
2
+ /**
3
+ * 정규 에너지 표본 — **커넥터가 이 모양으로 맞춰 준다.**
4
+ *
5
+ * 필드 이름은 계약이다(`EnergyMeasuredData` 와 같은 이름). 원 시스템의 낱말(`ActivePower`·`P_kW`·
6
+ * `MMXU.TotW`)을 여기서 받지 않는다 — 그 번역이 커넥터의 일이고, 커널까지 방언이 들어오면
7
+ * 소비처마다 다른 이름을 알아야 한다.
8
+ */
9
+ export interface EnergyRecord {
10
+ meterId: string;
11
+ /** 유효전력(kW) — 그 계량 주기의 평균. 없으면 「못 읽었다」이고 0 이 아니다. */
12
+ kW?: number;
13
+ /** 계기 적산값(kWh) — 차분은 소비처가 한다(계기 교체·리셋을 지어내지 않는다). */
14
+ kWh?: number;
15
+ powerFactor?: number;
16
+ /** 계측 시각(ISO) — **지어낼 수 없는 값**이다. */
17
+ at?: string;
18
+ }
19
+ export interface EnergyIngestOptions {
20
+ tenantId: string;
21
+ /** 레코드에 시각이 없을 때 쓸 값 — **주지 않으면 그 레코드를 거부한다.** */
22
+ defaultEventTime?: string;
23
+ /**
24
+ * 자원의 **선언된 정체성**을 답한다 — 없으면 `undefined`.
25
+ *
26
+ * 마감된 구간 사실의 이름에 들어갈 주체를 이것으로 정한다(§`SubjectBasis`). 커널이 이름을
27
+ * 지어내지 않는 것과 같은 자세다: 모델이 선언하지 않았으면 답하지 않고, 호출부가 무엇을 할지 정한다.
28
+ */
29
+ identityOf?: (kind: 'equipment' | 'meter', localId: string) => string | undefined;
30
+ /**
31
+ * 선언된 정체성이 없을 때 이름표가 **어디까지 통하는지** — 보통 이 트윈의 id.
32
+ *
33
+ * 이름에 넣는 것이 아니라 **범위를 밝히는 데** 쓴다. 이것이 있으면 사실은 `twin-local` 로 표시되고,
34
+ * 읽는 쪽은 그 이름을 트윈 밖에서 같다고 판정하지 않는다. 주지 않으면 범위를 모르는 것이므로
35
+ * 그때도 `twin-local` 이되 범위는 비어 있다.
36
+ */
37
+ scopeId?: string;
38
+ /**
39
+ * **지금** — 아직 오지 않은 시각의 사실을 받지 않기 위해 쓴다. 주지 않으면 `defaultEventTime` 을 쓰고,
40
+ * 그것도 없으면 판단하지 않는다(없는 기준으로 거절하지 않는다).
41
+ */
42
+ nowISO?: string;
43
+ }
44
+ export interface EnergyIngestResult {
45
+ accepted: CanonicalEnvelope[];
46
+ rejected: {
47
+ record: unknown;
48
+ errors: string[];
49
+ }[];
50
+ }
51
+ /** 이 레코드가 에너지 표본인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게). */
52
+ export declare function isEnergyRecord(record: unknown): boolean;
53
+ /**
54
+ * 표본 레코드들을 봉투로 — 유효한 것만 통과하고 나머지는 이유와 함께 남는다.
55
+ *
56
+ * `eventTime` 은 **계측이 말한 시각**(`at`)이 먼저다. 봉투와 페이로드에 같은 값을 싣는다: 커널은
57
+ * 페이로드를 먼저 보고, 저널·시간여행은 봉투를 본다 — 둘이 다르면 같은 사실이 두 시각을 갖는다.
58
+ */
59
+ export declare function ingestEnergyRecords(records: EnergyRecord | EnergyRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
60
+ /** 정규 설비 에너지 레코드 — 커넥터가 이 모양으로 맞춰 준다(필드 이름이 계약이다). */
61
+ export interface EnergyEquipmentRecord {
62
+ equipmentId: string;
63
+ at?: string;
64
+ generatedKW?: number;
65
+ exportKW?: number;
66
+ soc?: number;
67
+ chargeKW?: number;
68
+ dischargeKW?: number;
69
+ curtailable?: boolean;
70
+ minKW?: number;
71
+ position?: string;
72
+ /** 전기 계측 — 전압(V)·전류(A). 상별 값은 배열로(단상이면 원소 하나). */
73
+ dcVoltage?: number;
74
+ dcCurrent?: number;
75
+ acVoltage?: number[];
76
+ acCurrent?: number[];
77
+ }
78
+ /** 이 레코드가 설비 에너지 상태인가 — 라우팅 판정을 한 곳에 둔다. */
79
+ export declare function isEnergyEquipmentRecord(record: unknown): boolean;
80
+ /**
81
+ * 설비 에너지 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 받지 않고, 받지 않은 것은
82
+ * 이유와 함께 알린다.
83
+ *
84
+ * **값이 하나도 없는 레코드는 거부한다** — 계량과 다른 점이다. 계량은 「응답했다」는 사실 자체가
85
+ * 관측이지만(그래서 kW 없는 표본을 받는다), 여기서는 바꿀 상태가 없는 것을 사실로 적을 이유가 없다.
86
+ */
87
+ export declare function ingestEnergyEquipmentRecords(records: EnergyEquipmentRecord | EnergyEquipmentRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
88
+ /** 정규 발전 적산 레코드 — 커넥터가 이 모양으로 맞춰 준다(필드 이름이 계약이다). */
89
+ export interface EnergyGenerationRecord {
90
+ equipmentId: string;
91
+ /** 계기 적산값(kWh) — 연결된 시스템이 준 값 그대로. 차분은 소비처가 한다. */
92
+ kWh?: number;
93
+ /**
94
+ * **이 적산이 쌓이기 시작한 시각**(ISO) — 원본이 그 뜻을 말할 때만 싣는다 (2026-08-28).
95
+ *
96
+ * 하루 누적을 주는 원본이 있다. 그 값을 총적산으로 받으면 **매일 자정이 계기 교체로 기록된다**
97
+ * (§`EnergyGeneratedData.since` 의 실측).
98
+ *
99
+ * **없으면 비운다** — 설치 이후라고 가정하지 않는다. 원본이 말하지 않은 것을 커넥터가 지어내지
100
+ * 않는 것이 이 문의 규율이다. 다만 원본의 **문서나 코드가 그 뜻을 말한다면** 그것을 커널 어휘로
101
+ * 옮기는 것은 지어내기가 아니다 — 그때 현장의 선언된 시간대로 그 구간의 시작을 적는다.
102
+ */
103
+ kWhSince?: string;
104
+ /**
105
+ * **어떤 누적인가** — 원본이 말하지 않으면 `'unknown'` 을 싣는다 (2026-08-28).
106
+ *
107
+ * 비우지 말고 `'unknown'` 을 실으라는 뜻이 아니다: 비워도 커널이 `'unknown'` 으로 둔다. 다만
108
+ * **모른다는 것을 아는 커넥터는 그것을 말하는 편이 낫다** — 그러면 「아직 안 붙였다」와 「원본이 말해
109
+ * 주지 않는다」가 갈린다.
110
+ *
111
+ * 값의 목록과 뜻은 `EnergyGeneratedData.accumulation` 에 있다.
112
+ */
113
+ kWhAccumulation?: 'lifetime' | 'daily' | 'monthly' | 'billing' | 'unknown';
114
+ at?: string;
115
+ }
116
+ /** 이 레코드가 발전 적산인가 — 어느 갈래로 보낼지를 한 곳에서 정한다. */
117
+ export declare function isEnergyGenerationRecord(record: unknown): boolean;
118
+ /**
119
+ * 발전 적산 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 받지 않고, 받지 않은 것은
120
+ * 이유와 함께 알린다.
121
+ *
122
+ * **적산이 없는 레코드는 받지 않는다** — 이 문의 값은 그것 하나다. 순시 출력은 설비 상태 문이 받는다
123
+ * (`EnergyEquipmentRecord.generatedKW`).
124
+ */
125
+ export declare function ingestEnergyGenerationRecords(records: EnergyGenerationRecord | EnergyGenerationRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
126
+ /** 마감된 사용 구간 — 커넥터가 맞추는 모양. */
127
+ export interface EnergyUsagePeriodRecord {
128
+ meterId: string;
129
+ from: string;
130
+ to: string;
131
+ kWh: number;
132
+ maxKW?: number;
133
+ unitPrice?: number;
134
+ currency?: string;
135
+ basis?: string;
136
+ }
137
+ export declare function isEnergyUsagePeriodRecord(record: unknown): boolean;
138
+ /**
139
+ * 마감된 **발전 기간** — 「이 기간에 이 설비가 이만큼 냈다」.
140
+ *
141
+ * 라이브에서는 커널이 스스로 마감해 낸다(§`closeGenerationPeriod`). 이 문은 **지난 기록을 채울 때**
142
+ * 쓴다 — 원본이 날짜별 발전량을 주는 현장에서, 트윈이 없던 동안의 날들을 뒤늦게 넣는다.
143
+ *
144
+ * 상태를 바꾸지 않는다. 저널에만 적히고, 저널을 읽는 쪽(성과 계산)이 그 사실을 본다.
145
+ */
146
+ export interface EnergyGenerationPeriodRecord {
147
+ equipmentId: string;
148
+ from: string;
149
+ to: string;
150
+ kWh: number;
151
+ /** 원본이 준 적산의 종류 — 총량을 어떻게 얻었는지가 이것으로 갈린다. */
152
+ accumulation?: string;
153
+ /** 그 기간에서 처음·마지막으로 관측한 시각. 기간 경계와 다르면 그만큼 재지 않았다. */
154
+ observedFrom?: string;
155
+ observedTo?: string;
156
+ }
157
+ export declare function isEnergyGenerationPeriodRecord(record: unknown): boolean;
158
+ /**
159
+ * 마감된 발전 기간을 봉투로 — **사건 시각은 기간의 끝**이다(그때 성립한다).
160
+ *
161
+ * 적산의 문(`ingestEnergyGenerationRecords`)과 겹치지 않는다: 그쪽은 구간이 없는 시점의 값이고
162
+ * 이쪽은 구간이 있는 마감된 사실이다. 겹치면 지난 기록이 「지금 적산」으로 읽혀 트윈의 시계가
163
+ * 과거로 끌린다.
164
+ */
165
+ export declare function ingestEnergyGenerationPeriodRecords(records: EnergyGenerationPeriodRecord | EnergyGenerationPeriodRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
166
+ /** 발전 단가 — 이 기간에 낸 전기 1kWh 의 값. 날마다 바뀐다. */
167
+ export interface EnergyGenerationPriceRecord {
168
+ from: string;
169
+ to: string;
170
+ unitPrice: number;
171
+ currency: string;
172
+ }
173
+ export declare function isEnergyGenerationPriceRecord(record: unknown): boolean;
174
+ /**
175
+ * 발전 단가를 봉투로 — **사건 시각은 `from`** 이다(그때부터 적용된다).
176
+ *
177
+ * 제도의 가산·인증서 셈은 커넥터가 하고 여기에는 1kWh 당 얼마만 온다. 커널이 한 제도의 모양을
178
+ * 안으면 다른 제도에서 그 자리가 거짓이 된다.
179
+ */
180
+ export declare function ingestEnergyGenerationPriceRecords(records: EnergyGenerationPriceRecord | EnergyGenerationPriceRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
181
+ /** 이 주기에 적용되는 요금 기준 — 공급자가 정하고 매 주기 다시 정해진다. */
182
+ export interface EnergyTariffBasisRecord {
183
+ from: string;
184
+ to: string;
185
+ billingDemandKW?: number;
186
+ demandChargePerKW?: number;
187
+ currency?: string;
188
+ }
189
+ export declare function isEnergyTariffBasisRecord(record: unknown): boolean;
190
+ /**
191
+ * 요금 기준을 봉투로 — **사건 시각은 `from`** 이다.
192
+ *
193
+ * 「이 시각부터 이 기준이 적용된다」이므로 주기가 끝나기 전에 성립한다. `to` 로 잡으면 아직 오지 않은
194
+ * 시각이 저널에 적힌다(실제로 그렇게 해서 9월 1일 사건이 8월 29일에 적힌 적이 있다).
195
+ */
196
+ export declare function ingestEnergyTariffBasisRecords(records: EnergyTariffBasisRecord | EnergyTariffBasisRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
197
+ /** 청구서 — 커넥터가 커널의 요금 어휘로 옮겨 보낸다. */
198
+ export interface EnergyBillRecord {
199
+ from: string;
200
+ to: string;
201
+ energyCharge?: number;
202
+ demandCharge?: number;
203
+ total?: number;
204
+ currency?: string;
205
+ billingDemandKW?: number;
206
+ }
207
+ export declare function isEnergyBillRecord(record: unknown): boolean;
208
+ export declare function resolveSubject(kind: 'equipment' | 'meter', localId: string, opts: EnergyIngestOptions): {
209
+ subject: string;
210
+ basis: 'declared' | 'twin-local';
211
+ };
212
+ export declare function periodFactId(tenantId: string, kind: string, subject: string, from: string, to: string): string;
213
+ export declare function ingestEnergyUsagePeriodRecords(records: EnergyUsagePeriodRecord | EnergyUsagePeriodRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
214
+ export declare function ingestEnergyBillRecords(records: EnergyBillRecord | EnergyBillRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;