@operato/twin-kernel 0.7.0 → 0.7.2

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,229 @@
1
+ /*
2
+ * 에너지 귀속 — **이 kWh 는 누구의 것인가.** 설계: `design/profiles/ems.md` §8·§10(7단계)
3
+ *
4
+ * ── 왜 규칙이 따로 필요한가 ─────────────────────────────────────────────────
5
+ * 에너지의 값은 대개 「이 라인이 얼마 썼나」·「이 오더 한 대에 몇 kWh 들었나」로 쓰인다. 그런데 현장의
6
+ * **계량 알갱이는 대개 소비처보다 굵다** — 구역 계량기 하나가 공조·조명·컨베이어를 함께 잰다
7
+ * (ISO 50001 의 SEU 가 그 알갱이다). 그래서 개체별 kWh 는 **측정이 아니라 배분**인 경우가 많다.
8
+ *
9
+ * 그 사실을 값에 실어 보내지 않으면, 배분한 숫자가 측정한 숫자와 같은 무게로 읽힌다 — 그것이 CBAM·
10
+ * ESG 보고에서 첫 질문에 깨지는 지점이다. 그래서 이 모듈이 내는 모든 몫은 **근거(basis)를 함께** 갖는다.
11
+ *
12
+ * ── 세 가지 근거 ────────────────────────────────────────────────────────────
13
+ * · `measured` — 그 소비처에 **전용 계량기**가 있다(1:1). 가장 강하다.
14
+ * · `apportioned` — 알갱이가 굵어 **나눈 것**이다. 무엇으로 나눴는지(`weightKind`)를 함께 낸다.
15
+ * · `unattributed` — 나눌 근거가 없다. **그대로 남긴다.**
16
+ *
17
+ * ── 균등 분배를 기본값으로 두지 않는다 ──────────────────────────────────────
18
+ * 몫이 없을 때 인원수로 나누듯 균등하게 나누면 숫자는 나오지만 그것은 **짐작**이다. 기본값이 되면
19
+ * 아무도 그것이 짐작인 줄 모른다. 그래서 균등(`equal`)은 **호출자가 명시적으로 고를 때만** 쓴다.
20
+ *
21
+ * ── 합은 보존된다 ───────────────────────────────────────────────────────────
22
+ * 배분한 합 + 미귀속 합 = 측정한 합. 이 불변식이 깨지면 어딘가에서 전기가 사라지거나 생겨난 것이고,
23
+ * 그 위의 모든 원단위가 거짓이 된다. 시험이 그것을 지킨다.
24
+ */
25
+ /** 부동소수 비교 — kWh 는 소수점이 있고, 합 보존을 정수로 요구하면 거짓 실패가 난다. */
26
+ const near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
27
+ /**
28
+ * 구간 에너지를 소비처에 귀속시킨다 — **나눌 수 없으면 나누지 않는다.**
29
+ *
30
+ * @param weightKind 배분에 쓸 몫의 뜻. **`equal` 은 호출자가 명시적으로 고를 때만** 쓰인다(위 주석).
31
+ */
32
+ export function attributeEnergy(opts) {
33
+ const byId = new Map(opts.consumers.map(c => [c.id, c]));
34
+ /* 전용 계량기 색인 — 소비처가 「이 계량기는 내 것」이라고 선언한 경우. */
35
+ const dedicated = new Map();
36
+ for (const c of opts.consumers)
37
+ if (c.meterId)
38
+ dedicated.set(c.meterId, c);
39
+ const shares = [];
40
+ const unattributed = [];
41
+ const excluded = [];
42
+ const overhead = [];
43
+ for (const pool of opts.pools) {
44
+ const kWh = Number(pool.kWh);
45
+ if (!Number.isFinite(kWh))
46
+ continue; // 값이 아닌 것은 귀속의 대상이 아니다(0 으로 만들지 않는다)
47
+ /* ① 전용 계량기 — 통째로 그 소비처의 것이다. 가장 강한 근거이므로 먼저 본다. */
48
+ const own = dedicated.get(pool.meterId);
49
+ if (own) {
50
+ shares.push({ consumerId: own.id, kWh, basis: 'measured', poolMeterId: pool.meterId });
51
+ continue;
52
+ }
53
+ /*
54
+ * ①-b **공통(간접) 풀** — 직접 귀속되지 않는 것이 정상이다.
55
+ *
56
+ * 배부를 요청하지 않았으면 공통 바구니에 그대로 둔다(결손 칸에 담지 않는다 — 정상을 결함으로
57
+ * 보이게 만들지 않는다). 요청했으면 지목된 공정 소비처들에 나누고 `overhead: true` 를 붙인다.
58
+ */
59
+ if (pool.overhead) {
60
+ const alloc = opts.overheadAllocation;
61
+ const targets = (alloc?.processConsumerIds ?? []).map(id => byId.get(id)).filter((c) => !!c);
62
+ const wOf = (c) => alloc?.weightKind === 'equal' ? 1 : Number.isFinite(Number(c.weight)) && Number(c.weight) > 0 ? Number(c.weight) : 0;
63
+ const sharing = alloc?.weightKind === 'equal' ? targets : targets.filter(c => wOf(c) > 0);
64
+ const ws = sharing.map(wOf);
65
+ const total = ws.reduce((a, b) => a + b, 0);
66
+ if (!alloc || total <= 0) {
67
+ overhead.push({ poolMeterId: pool.meterId, kWh });
68
+ continue;
69
+ }
70
+ let done = 0;
71
+ sharing.forEach((c, i) => {
72
+ const share = ws[i] / total;
73
+ const amount = i === sharing.length - 1 ? kWh - done : kWh * share;
74
+ done += amount;
75
+ shares.push({
76
+ consumerId: c.id,
77
+ kWh: amount,
78
+ basis: 'apportioned',
79
+ poolMeterId: pool.meterId,
80
+ weightKind: alloc.weightKind,
81
+ weightShare: share,
82
+ overhead: true
83
+ });
84
+ });
85
+ continue;
86
+ }
87
+ /* ② 덮는 소비처가 선언되지 않았다 — 나눌 대상이 없다. */
88
+ const covered = (pool.consumerIds ?? []).map(id => byId.get(id)).filter((c) => !!c);
89
+ if (!covered.length) {
90
+ unattributed.push({ poolMeterId: pool.meterId, kWh, reason: 'no-consumers' });
91
+ continue;
92
+ }
93
+ /* ③ 하나뿐이면 그것도 측정이다 — 그 계량기가 그 소비처만 덮는다는 선언이므로. */
94
+ if (covered.length === 1) {
95
+ shares.push({ consumerId: covered[0].id, kWh, basis: 'measured', poolMeterId: pool.meterId });
96
+ continue;
97
+ }
98
+ /* ④ 여럿이면 몫이 필요하다. `equal` 은 호출자가 고른 경우에만 몫을 만든다. */
99
+ const kind = opts.weightKind;
100
+ const weightOf = (c) => kind === 'equal' ? 1 : Number.isFinite(Number(c.weight)) && Number(c.weight) > 0 ? Number(c.weight) : 0;
101
+ /*
102
+ * 몫이 0 인 소비처는 **배분에서 뺀다**(그리고 뺐다는 사실을 낸다) — 0 을 주면 「재어 보니 0」이라는
103
+ * 주장이 되고, 대기전력이 있는 설비에서 그것은 거짓이다.
104
+ */
105
+ const sharing = kind === 'equal' ? covered : covered.filter(c => weightOf(c) > 0);
106
+ if (kind && sharing.length < covered.length) {
107
+ for (const c of covered)
108
+ if (weightOf(c) <= 0)
109
+ excluded.push({ consumerId: c.id, poolMeterId: pool.meterId, reason: 'zero-weight' });
110
+ }
111
+ const weights = sharing.map(weightOf);
112
+ const sum = weights.reduce((a, b) => a + b, 0);
113
+ if (!kind || sum <= 0) {
114
+ unattributed.push({
115
+ poolMeterId: pool.meterId,
116
+ kWh,
117
+ reason: 'no-weights',
118
+ consumerIds: covered.map(c => c.id)
119
+ });
120
+ continue;
121
+ }
122
+ /*
123
+ * 비례 배분 — 마지막 소비처가 **나머지를 받는다**(합 보존).
124
+ *
125
+ * 각자 반올림하면 합이 원값과 어긋나고, 그 차이는 원단위·보고로 전파된다. 반올림은 표현의 일이고
126
+ * 여기서는 하지 않는다.
127
+ */
128
+ let given = 0;
129
+ sharing.forEach((c, i) => {
130
+ const share = weights[i] / sum;
131
+ const amount = i === sharing.length - 1 ? kWh - given : kWh * share;
132
+ given += amount;
133
+ shares.push({
134
+ consumerId: c.id,
135
+ kWh: amount,
136
+ basis: 'apportioned',
137
+ poolMeterId: pool.meterId,
138
+ weightKind: kind,
139
+ weightShare: share
140
+ });
141
+ });
142
+ }
143
+ const measuredKWh = opts.pools.reduce((a, p) => a + (Number.isFinite(Number(p.kWh)) ? Number(p.kWh) : 0), 0);
144
+ const attributedKWh = shares.reduce((a, s) => a + s.kWh, 0);
145
+ const unattributedKWh = unattributed.reduce((a, u) => a + u.kWh, 0);
146
+ const overheadKWh = overhead.reduce((a, o) => a + o.kWh, 0);
147
+ /* 합 보존은 이 모듈의 존재 이유다 — 깨지면 그 위의 모든 원단위가 거짓이 된다. */
148
+ if (!near(attributedKWh + unattributedKWh + overheadKWh, measuredKWh, 1e-6)) {
149
+ throw new Error(`energy attribution lost or created energy: measured=${measuredKWh} attributed=${attributedKWh} unattributed=${unattributedKWh} overhead=${overheadKWh}`);
150
+ }
151
+ return { shares, unattributed, excluded, overhead, totals: { measuredKWh, attributedKWh, unattributedKWh, overheadKWh } };
152
+ }
153
+ const UNIT = {
154
+ output: 'kWh/unit',
155
+ runtimeHours: 'kW',
156
+ area: 'kWh/m2'
157
+ };
158
+ /**
159
+ * 원단위 — **답할 수 없으면 답하지 않는다.**
160
+ *
161
+ * ── 왜 구간을 맞대어 보나 ───────────────────────────────────────────────────
162
+ * 분자는 에너지 트윈이, 분모는 생산 트윈이 낸다. 두 트윈은 각자의 시계로 돌고, 라이브와 히스토리가
163
+ * 섞이기도 한다. 다른 구간의 두 사실을 나누면 숫자는 나오지만 **아무것도 뜻하지 않는다** — 야간의
164
+ * 전력을 주간의 산출로 나눈 값이 그렇다. 그래서 구간이 어긋나면 거절한다(호출자가 맞춰서 다시 묻는다).
165
+ *
166
+ * ── 왜 0 을 무한으로 만들지 않나 ────────────────────────────────────────────
167
+ * 그 구간에 아무것도 만들지 않았다면 「대당 에너지」는 **정의되지 않는다.** `Infinity` 를 내면 화면이
168
+ * 그것을 큰 수로 그리고, 사용자는 최악의 원단위를 본 것으로 읽는다.
169
+ */
170
+ export function energyIntensity(input) {
171
+ const kWh = Number(input.kWh);
172
+ if (!Number.isFinite(kWh))
173
+ return { value: null, reason: 'no-energy' };
174
+ const den = Number(input.denominator?.value);
175
+ if (!Number.isFinite(den))
176
+ return { value: null, reason: 'no-denominator' };
177
+ const ew = input.energyWindow;
178
+ const dw = input.denominator?.window;
179
+ /* 두 구간을 다 알 때만 맞대어 본다 — 하나를 모르면 어긋남을 주장할 수 없다(모름은 거절의 근거가 아니다). */
180
+ if (ew && dw && (ew.startMs !== dw.startMs || ew.endMs !== dw.endMs))
181
+ return { value: null, reason: 'window-mismatch' };
182
+ if (den === 0)
183
+ return { value: null, reason: 'zero-denominator' };
184
+ const window = ew ?? dw;
185
+ return {
186
+ value: kWh / den,
187
+ unit: UNIT[input.denominator.kind] ?? 'kWh',
188
+ kWh,
189
+ denominator: den,
190
+ ...(window ? { window } : { window: { startMs: 0, endMs: 0 } })
191
+ };
192
+ }
193
+ /**
194
+ * 마감된 수요 구간들에서 그 범위의 전력량을 만든다 — **파생의 근거를 함께.**
195
+ *
196
+ * 범위에 **걸친** 구간은 세지 않는다(부분을 비례로 자르면 그 비례가 또 하나의 추정이 된다).
197
+ * 온전히 들어오는 구간만 센다 — 그래서 실제로 센 범위를 함께 낸다.
198
+ */
199
+ export function energyOfWindows(windows, range) {
200
+ const inRange = (w) => !range || (w.startMs >= range.startMs && w.endMs <= range.endMs);
201
+ let kWh = 0;
202
+ let counted = 0;
203
+ let skipped = 0;
204
+ let first;
205
+ let last;
206
+ for (const w of windows ?? []) {
207
+ if (!inRange(w))
208
+ continue;
209
+ const mean = Number(w.meanKW);
210
+ const hours = (Number(w.endMs) - Number(w.startMs)) / 3_600_000;
211
+ if (!Number.isFinite(mean) || !Number.isFinite(hours) || hours <= 0) {
212
+ skipped++;
213
+ continue;
214
+ }
215
+ kWh += mean * hours;
216
+ counted++;
217
+ if (first === undefined || w.startMs < first)
218
+ first = w.startMs;
219
+ if (last === undefined || w.endMs > last)
220
+ last = w.endMs;
221
+ }
222
+ return {
223
+ ...(counted > 0 ? { kWh } : {}),
224
+ basis: 'mean-kw',
225
+ counted,
226
+ skipped,
227
+ ...(first !== undefined && last !== undefined ? { window: { startMs: first, endMs: last } } : {})
228
+ };
229
+ }
@@ -0,0 +1,39 @@
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
+ export interface EnergyIngestResult {
25
+ accepted: CanonicalEnvelope[];
26
+ rejected: {
27
+ record: unknown;
28
+ errors: string[];
29
+ }[];
30
+ }
31
+ /** 이 레코드가 에너지 표본인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게). */
32
+ export declare function isEnergyRecord(record: unknown): boolean;
33
+ /**
34
+ * 표본 레코드들을 봉투로 — 유효한 것만 통과하고 나머지는 이유와 함께 남는다.
35
+ *
36
+ * `eventTime` 은 **계측이 말한 시각**(`at`)이 먼저다. 봉투와 페이로드에 같은 값을 싣는다: 커널은
37
+ * 페이로드를 먼저 보고, 저널·시간여행은 봉투를 본다 — 둘이 다르면 같은 사실이 두 시각을 갖는다.
38
+ */
39
+ export declare function ingestEnergyRecords(records: EnergyRecord | EnergyRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
@@ -0,0 +1,91 @@
1
+ /*
2
+ * 에너지 계측 인제스트 — **표본 레코드 → 봉투.** 설계: `design/profiles/ems.md` §4.1
3
+ *
4
+ * ── 왜 따로 있나 (2026-08-14) ───────────────────────────────────────────────
5
+ * 라이브 인제스트 경로는 **EPCIS 하나만 알았다.** 호스트의 정규 레코드 룰이 모든 레코드를
6
+ * `type: 'ObjectEvent'` 로 만들었기 때문이다(`canonical-ingest.ts`). 그 길로 계측을 넣으면 둘 중
7
+ * 하나가 된다: `epc` 가 없어 검증에서 거부되거나, 엉뚱하게 **물품 관측**으로 읽힌다.
8
+ *
9
+ * 에너지는 물(物)의 계보가 아니므로 EPCIS 어휘를 쓰지 않는다(§4). 그래서 정규 레코드의 **종류를
10
+ * 하나 더 인정**한다 — 봉투는 같은 것을 쓰고(저널·리플레이·시간여행을 그대로 얻는다) 어휘만 자기 것이다.
11
+ *
12
+ * ── 검증은 커널의 일이다 ────────────────────────────────────────────────────
13
+ * 매핑(원 시스템 스키마 → 정규 레코드)은 커넥터의 몫이고, **무엇이 유효한 사실인가**는 커널이 정한다
14
+ * (`face2-adapters.md` §7 의 규율: 매핑=밖, 검증=커널). 그래서 이 판정이 여기 있다.
15
+ *
16
+ * ── 무엇을 거부하나 ─────────────────────────────────────────────────────────
17
+ * 지어낼 수 없는 것이 빠지면 거부한다 — 계량 지점(`meterId`)과 시각(`at`)이다. 지금 시각으로 메우면
18
+ * 남의 구간에 실리고, 지점을 지어내면 어디의 소비인지 모르는 값이 누적된다. **거부한 것은 이유와 함께
19
+ * 돌려준다**(조용히 버리지 않는다 — 소비처가 그 수를 세어 사람에게 말할 수 있어야 한다).
20
+ *
21
+ * 값(`kW`)이 없는 표본은 **거부하지 않는다**: 계량기가 살아 있다는 사실 자체가 관측이고, 커널이
22
+ * 「받았지만 부하를 못 읽었다」를 구별해 낸다(`observedAbsence: 'no-load-samples'`).
23
+ */
24
+ import { ENERGY_EVENT } from "./contract.js";
25
+ /** 이 레코드가 에너지 표본인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게). */
26
+ export function isEnergyRecord(record) {
27
+ if (!record || typeof record !== 'object')
28
+ return false;
29
+ const r = record;
30
+ /* 계량 지점이 있고 EPCIS 어휘가 없으면 에너지다. `epc` 가 함께 있으면 둘 중 무엇인지 알 수 없으므로
31
+ 에너지로 받지 않는다 — 그 판단은 커넥터가 명확히 해야 한다. */
32
+ return typeof r.meterId === 'string' && r.meterId.trim().length > 0 && r.epc === undefined;
33
+ }
34
+ /**
35
+ * 표본 레코드들을 봉투로 — 유효한 것만 통과하고 나머지는 이유와 함께 남는다.
36
+ *
37
+ * `eventTime` 은 **계측이 말한 시각**(`at`)이 먼저다. 봉투와 페이로드에 같은 값을 싣는다: 커널은
38
+ * 페이로드를 먼저 보고, 저널·시간여행은 봉투를 본다 — 둘이 다르면 같은 사실이 두 시각을 갖는다.
39
+ */
40
+ export function ingestEnergyRecords(records, opts) {
41
+ const arr = Array.isArray(records) ? records : records ? [records] : [];
42
+ const accepted = [];
43
+ const rejected = [];
44
+ let seq = 0;
45
+ for (const record of arr) {
46
+ const errors = [];
47
+ const meterId = String(record?.meterId ?? '').trim();
48
+ if (!meterId)
49
+ errors.push('meterId 없음 — 어디의 소비인지 모르는 값은 누적할 수 없다');
50
+ const at = String(record?.at ?? '').trim() || opts.defaultEventTime;
51
+ const atMs = at ? Date.parse(at) : Number.NaN;
52
+ if (!Number.isFinite(atMs))
53
+ errors.push('at 없음/형식 오류 — 지금 시각으로 메우면 남의 수요 구간에 실린다');
54
+ /* 값이 있으면 수여야 한다 — 문자열·NaN 을 그대로 흘리면 커널이 합에서 조용히 빠뜨린다. */
55
+ const num = (v, name) => {
56
+ if (v === undefined || v === null || v === '')
57
+ return undefined;
58
+ const n = Number(v);
59
+ if (!Number.isFinite(n)) {
60
+ errors.push(`${name} 가 수가 아니다: ${JSON.stringify(v)}`);
61
+ return undefined;
62
+ }
63
+ return n;
64
+ };
65
+ const kW = num(record?.kW, 'kW');
66
+ const kWh = num(record?.kWh, 'kWh');
67
+ const powerFactor = num(record?.powerFactor, 'powerFactor');
68
+ if (errors.length) {
69
+ rejected.push({ record, errors });
70
+ continue;
71
+ }
72
+ const eventTime = new Date(atMs).toISOString();
73
+ const data = {
74
+ meterId,
75
+ /* 계약은 `kW` 를 필수로 두지만 **못 읽은 표본도 사실**이다 — 그 경우 값을 비우고 보낸다.
76
+ 커널이 「받았지만 부하를 못 읽었다」로 세고, 구간 마감에 그 이유를 싣는다. */
77
+ ...(kW !== undefined ? { kW } : {}),
78
+ ...(kWh !== undefined ? { kWh } : {}),
79
+ ...(powerFactor !== undefined ? { powerFactor } : {}),
80
+ at: eventTime
81
+ };
82
+ accepted.push({
83
+ eventId: `${opts.tenantId}-energy-${++seq}`,
84
+ eventType: ENERGY_EVENT.measured,
85
+ eventTime,
86
+ tenantId: opts.tenantId,
87
+ data
88
+ });
89
+ }
90
+ return { accepted, rejected };
91
+ }
package/dist/index.d.ts CHANGED
@@ -29,5 +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 type { EnergyState, DemandWindowState, MeterPointState } from './ems-kernel.ts';
32
+ export { ingestEnergyRecords, isEnergyRecord } from './energy-ingest.ts';
33
+ export { attributeEnergy, energyIntensity, energyOfWindows } from './energy-attribution.ts';
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';
33
36
  export * from './vocabulary.ts';
package/dist/index.js CHANGED
@@ -29,4 +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";
33
+ export { attributeEnergy, energyIntensity, energyOfWindows } from "./energy-attribution.js";
34
+ /* 에너지 상태 타입은 **계약**에 있다(상태의 모양은 계약이다) — contract 의 `export *` 가 이미 낸다. */
32
35
  export * from "./vocabulary.js";